diff --git a/CLAUDE.md b/CLAUDE.md index 4c81e950..04dd6d61 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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=` measures a tarball packed elsewhere, such as another commit's, so two diff --git a/e2e/perf/scenarios.perf.js b/e2e/perf/scenarios.perf.js index 8b9e4ec6..8c1b3d87 100644 --- a/e2e/perf/scenarios.perf.js +++ b/e2e/perf/scenarios.perf.js @@ -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. @@ -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)] @@ -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. @@ -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] } } diff --git a/scripts/fixtures/packed-perf-entry.js b/scripts/fixtures/packed-perf-entry.js index 9a99d047..79de09bf 100644 --- a/scripts/fixtures/packed-perf-entry.js +++ b/scripts/fixtures/packed-perf-entry.js @@ -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, @@ -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: () => ({