Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,10 @@ The modernization roadmap (React 17/18 upgrade, `semantic-ui-react` exit, projec
- `npm run test:pack` — `build-lib`, then the packaging budgets (`test:pack:budget`, `scripts/check-package-budget.js`) and a server-render smoke of the packed tarball (`test:pack:consumer`). `npm run test:pack:peers` repeats the smoke on React 16.14, 17.0.2 and 19.3.0, from the same fixtures as the jest legs. `npm run test:pack:browser` (after `build-lib`) loads the packed tarball in Chromium: a throwaway host (`scripts/packed-workspace.js`, shared with the server smoke) bundles it with its own React beside the published `static/all.css`, and `e2e/packed/tarball.packed.js` (`playwright.packed.config.js`) checks that it renders, responds and styles only its own wrapper; CI runs it in the `browser` job. `npm run test:pack:browser:peers` runs the same page on React 16.14, 17 and 19 from the jest legs' fixtures, mounting through `ReactDOM.render` on 16 and 17 as 0.34.x hosts do; CI runs it there too
- `npm run test:perf` (after `build-lib`) — what the packed tarball's documents cost in Chromium.
- **What it measures:** the main thread's busy time (the DevTools protocol's `TaskDuration`) for a mount, and the
median of five runs of each action (an edit, a blur, a pick, a tab switch), in five documents of
`scripts/fixtures/packed-perf-entry.js`. The documents include a 200-row period table, a 200-row table with a
Select column, and two tabs whose second holds a 200-row table.
median of five runs of each action (an edit, a blur, a pick, a tab switch, a table shown and taken away), in eight
documents of `scripts/fixtures/packed-perf-entry.js`. The documents include a 200-row period table, a 200-row
table with a Select column, two tabs whose second holds a 200-row table, and that table beside 200 required
fields, shown by a `showIf`, in a popup, or in an Expand.
- **Where it runs:** in the same throwaway host as `test:pack:browser`, under `playwright.perf.config.js`, with
`e2e/perf/scenarios.perf.js`. It prints the medians and writes them to `test-results-perf/perf-results.json`.
- **Comparing versions:** `--tarball=<file>` measures a tarball packed elsewhere, such as another commit's, so two
Expand Down
90 changes: 74 additions & 16 deletions e2e/perf/scenarios.perf.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
* thread's busy time, the DevTools protocol's `TaskDuration`, over the mount and over each action, an action being
* what a user does and everything it sets off until the page is idle again (the 300 ms typing debounce included).
* Each action runs five times, and the median is what counts. After its runs, an edit or a pick checks that its field
* shows the last run's value, so that an action that stops changing anything fails instead of measuring nothing. A tab
* switch waits for the table's field to be there, and a switch back for it to be gone. Each test writes its
* shows the last run's value, so that an action that stops changing anything fails instead of measuring nothing. An
* action that brings a table, a tab switch or a `showIf`, a popup or an Expand, waits for the table's field to be there,
* and the one that takes it away for it to be gone: a collapse, when its animation ends. Each test writes its
* document's numbers to PERF_OUT before it checks them, so that a document over its ceilings still reports, and so do
* the ones after it. scripts/test-packed-perf.js runs it (`npm run test:perf`) and prints them; CI runs it in the
* advisory `browser-perf` job, which reports and never blocks.
Expand Down Expand Up @@ -37,6 +38,9 @@ const CEILINGS = {
selectForm: { mount: 340, pick: 100, edit: 100 },
numberTable: { mount: 780, edit: 100 },
tabs: { mount: 210, switch: 360, back: 100 },
reveal: { mount: 270, show: 270, hide: 100 },
popup: { mount: 260, open: 340, close: 110 },
expand: { mount: 280, expand: 600, collapse: 400 },
}

const median = values => [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)]
Expand Down Expand Up @@ -73,6 +77,21 @@ async function record (scenario, name, measure, action) {
results[scenario][name] = { median: median(runs), runs }
}

/**
* Runs `there` and then `back`, REPEAT times, and records the median of what each cost: an action that brings a
* table, and the one that takes it away again.
*/
async function alternate (scenario, [thereName, backName], measure, there, back) {
const theres = []
const backs = []
for (let i = 0; i < REPEAT; i++) {
theres.push(await measure(there))
backs.push(await measure(back))
}
results[scenario][thereName] = { median: median(theres), runs: theres }
results[scenario][backName] = { median: median(backs), runs: backs }
}

/**
* Adds a scenario's numbers to PERF_OUT, then checks them against its ceilings. A failed test's worker is replaced,
* and this module's state with it, so the file, not `results`, is what holds every scenario's.
Expand Down Expand Up @@ -147,25 +166,64 @@ test('two tabs, the second a 200-row table: a switch to it, and back', async ({
const summary = page.getByRole('tab', { name: 'Summary', exact: true })
const cell = page.locator('input[name="rows[1].note"]')
// A switch waits for the table's field to be there, and a switch back for it to be gone.
const switches = []
const backs = []
for (let i = 0; i < REPEAT; i++) {
switches.push(await measure(async () => {
await rows.click()
await cell.waitFor()
}))
backs.push(await measure(async () => {
await summary.click()
await cell.waitFor({ state: 'detached' })
}))
}
results.tabs.switch = { median: median(switches), runs: switches }
results.tabs.back = { median: median(backs), runs: backs }
await alternate('tabs', ['switch', 'back'], measure, async () => {
await rows.click()
await cell.waitFor()
}, async () => {
await summary.click()
await cell.waitFor({ state: 'detached' })
})
await rows.click()
await expect(cell).toHaveValue('note 1')
check('tabs')
})

test('a `showIf` that shows a 200-row table beside 200 required fields, and hides it', async ({ page }) => {
const { mount, measure } = await open(page, 'reveal')
results.reveal = { mount: { median: mount, runs: [mount] } }
const show = page.getByRole('textbox', { name: 'Show', exact: true })
const cell = page.locator('input[name="rows[1].note"]')
await alternate('reveal', ['show', 'hide'], measure, async () => {
await show.fill('yes')
await cell.waitFor()
}, async () => {
await show.fill('no')
await cell.waitFor({ state: 'detached' })
})
check('reveal')
})

test('a popup that holds a 200-row table beside 200 required fields: opening it, and closing it', async ({ page }) => {
const { mount, measure } = await open(page, 'popup')
results.popup = { mount: { median: mount, runs: [mount] } }
const opener = page.getByRole('button', { name: 'Open rows', exact: true })
const cell = page.locator('input[name="rows[1].note"]')
await alternate('popup', ['open', 'close'], measure, async () => {
await opener.click()
await cell.waitFor()
}, async () => {
await page.getByRole('dialog').press('Escape')
await cell.waitFor({ state: 'detached' })
})
check('popup')
})

test('an Expand that holds a 200-row table beside 200 required fields: expanding it, and collapsing it', async ({ page }) => {
const { mount, measure } = await open(page, 'expand')
results.expand = { mount: { median: mount, runs: [mount] } }
const title = page.getByRole('button', { name: 'Rows', exact: true })
const cell = page.locator('input[name="rows[1].note"]')
// A collapse waits for the content to go, when the animation ends.
await alternate('expand', ['expand', 'collapse'], measure, async () => {
await title.click()
await cell.waitFor()
}, async () => {
await title.click()
await cell.waitFor({ state: 'detached' })
})
check('expand')
})

test('a 200-row table of bounded number fields: an edit of a number', async ({ page }) => {
const { mount, measure } = await open(page, 'numberTable')
results.numberTable = { mount: { median: mount, runs: [mount] } }
Expand Down
55 changes: 41 additions & 14 deletions scripts/fixtures/packed-perf-entry.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,19 @@ const ROWS = 200
const range = count => Array.from({ length: count }, (_, i) => i)
const records = [{ id: 'a', name: 'A' }, { id: 'b', name: 'B' }, { id: 'c', name: 'C' }]
const choices = [{ text: 'A', value: 'a' }, { text: 'B', value: 'b' }, { text: 'C', value: 'c' }]
// A 200-row table of a required text column and a bounded number column, and its rows.
const rowsTable = {
view: 'Table',
name: 'rows',
headers: [
{ id: 'note', label: 'Note', renderCell: { view: 'Input', name: 'note', validate: 'required' } },
{ id: 'qty', label: 'Qty', renderCell: { view: 'Input', type: 'number', name: 'qty', min: 0, max: 100000 } },
],
}
const tableRows = () => ({ rows: range(ROWS).map(i => ({ note: `note ${i}`, qty: i })) })
// 200 required fields beside it, which every field that comes or goes used to validate, each.
const besideFields = range(ROWS).map(i => ({ view: 'Input', name: `field${i}`, label: `Field ${i}`, validate: 'required' }))
const besideValues = () => Object.fromEntries(range(ROWS).map(i => [`field${i}`, `value ${i}`]))
const periodField = name => ({
view: 'Input',
name,
Expand Down Expand Up @@ -57,27 +70,41 @@ const SCENARIOS = {
},
data: { other: 'text', ...Object.fromEntries(range(100).map(i => [`choice${i}`, 'a'])) },
}),
// Two tabs, the second a 200-row table of a required text column and a bounded number column, which mounts and
// unmounts with its tab.
// Two tabs, the second the 200-row table, which mounts and unmounts with its tab.
tabs: () => ({
meta: {
view: 'Tabs',
items: [
{ tab: 'Summary', content: { view: 'Text', children: 'Nothing to edit here' } },
{
tab: 'Rows',
content: {
view: 'Table',
name: 'rows',
headers: [
{ id: 'note', label: 'Note', renderCell: { view: 'Input', name: 'note', validate: 'required' } },
{ id: 'qty', label: 'Qty', renderCell: { view: 'Input', type: 'number', name: 'qty', min: 0, max: 100000 } },
],
},
},
{ tab: 'Rows', content: rowsTable },
],
},
data: tableRows(),
}),
// The 200-row table, which a `showIf` shows and hides, beside 200 required fields.
reveal: () => ({
meta: {
view: 'Col',
items: [{ view: 'Input', name: 'show', label: 'Show' }, ...besideFields, { ...rowsTable, showIf: { name: 'show', equal: 'yes' } }],
},
data: { show: 'no', ...besideValues(), ...tableRows() },
}),
// A popup that holds the 200-row table, beside 200 required fields.
popup: () => ({
meta: {
view: 'Col',
items: [
{ view: 'Button', children: 'Open rows', onClick: { name: 'popupOpen', args: ['rows'] } },
...besideFields,
{ view: 'Popup', id: 'rows', title: 'Rows', items: [rowsTable] },
],
},
data: { rows: range(ROWS).map(i => ({ note: `note ${i}`, qty: i })) },
data: { ...besideValues(), ...tableRows() },
}),
// An Expand that holds the 200-row table, beside 200 required fields. Its content goes when its animation ends.
expand: () => ({
meta: { view: 'Col', items: [...besideFields, { view: 'Expand', title: 'Rows', items: [rowsTable] }] },
data: { ...besideValues(), ...tableRows() },
}),
// A column of number fields with bounds, beside a text column.
numberTable: () => ({
Expand Down
Loading