From 37269e5b725e72df0347c26e94033bfdb291f3bb Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 9 Nov 2026 22:10:49 -0800 Subject: [PATCH 1/4] test(List): the update proofs read an exit while it runs Four List update proofs read a row's exit, or counted layouts, over the runner's round trips. On a loaded runner a round trip can outlast the exit, or take in a layout of other page work. - The removal proof arms a hold on the list that takes the exit from its first frame, judging its clip. Its inert, aria-hidden and accessibility reads then find the row, and the release lets it go. - The two swiped-exit proofs press the second change in the first frame that shows the exit, not 30 ms later. They read the rows once React commits it, in the same task. - The swiped-open layout proof counts layouts on the page's clock, from the click through two frames, not LayoutCount over round trips. --- .../ListUpdates.interaction.browser.spec.ts | 99 +++++++++++-------- 1 file changed, 58 insertions(+), 41 deletions(-) diff --git a/packages/core/src/List/__tests__/ListUpdates.interaction.browser.spec.ts b/packages/core/src/List/__tests__/ListUpdates.interaction.browser.spec.ts index f359543ff462d..11124285b6936 100644 --- a/packages/core/src/List/__tests__/ListUpdates.interaction.browser.spec.ts +++ b/packages/core/src/List/__tests__/ListUpdates.interaction.browser.spec.ts @@ -24,8 +24,10 @@ import { type Page, } from '@astryxdesign/a11y-spec/playwright'; import { + armHold, countBetween, openStory, + releaseAnimations, traceRendering, withStorybook, } from '@astryxdesign/interaction-harness'; @@ -191,8 +193,16 @@ test('a removal: the removed row stays in place, inert and out of the accessibil }) => { await open(page); const gap = await rowGap(page); + // The exit lasts as long as the glides. The page holds it from its first + // frame, so the row is still there for each read below, however late a + // loaded runner sends it. + const hold = await armHold(page, page.locator('#storybook-root ul'), { + judges: {target: leaving(page), property: 'clip-path'}, + newOnly: true, + }); await press(page, 'Remove the second row'); const {glides, clipped} = await snapshot(page); + await hold.held(); // The rows after it, and the content after the list, start where they were. // So do the buttons: the pressed one glides with its neighbors, never onto // them. @@ -222,6 +232,7 @@ test('a removal: the removed row stays in place, inert and out of the accessibil page.getByRole('listitem').filter({hasText: 'Sent'}), ).toHaveCount(0); // Then it goes. + await releaseAnimations(page); await expect(exit).toHaveCount(0, {timeout: 3000}); }); @@ -864,25 +875,26 @@ test('a change with a row swiped open lays the page out no more often than the s await expect .poll(async () => page.evaluate(() => document.getAnimations().length)) .toBe(0); - await cdp.send('Performance.enable'); - const layouts = async () => { - const {metrics} = await cdp.send('Performance.getMetrics'); - return metrics.find(metric => metric.name === 'LayoutCount')?.value ?? 0; - }; - const before = await layouts(); - // A click with no pointer, so no swipe closes and no hover restyles. - await page.evaluate(async () => { - const button = [...document.querySelectorAll('button')].find( - element => element.textContent?.trim() === 'Move Archive first', - ); - button?.click(); - await new Promise(resolve => { - requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + await cdp.detach(); + // Counted on the page's own clock, from the click through the next two + // frames. A count read over the runner's round trips also takes a layout + // of other page work in them. A story that just opened can do such work. + let change = {from: 0, to: 0}; + const trace = await traceRendering(page, async () => { + // A click with no pointer, so no swipe closes and no hover restyles. + change = await page.evaluate(async () => { + const button = [...document.querySelectorAll('button')].find( + element => element.textContent?.trim() === 'Move Archive first', + ); + const from = performance.timeOrigin + performance.now(); + button?.click(); + await new Promise(resolve => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + return {from, to: performance.timeOrigin + performance.now()}; }); }); - const after = await layouts(); - await cdp.detach(); - return after - before; + return countBetween(trace, change.from, change.to).Layout ?? 0; }; const none = await layoutsFor(false); const swiped = await layoutsFor(true); @@ -985,9 +997,10 @@ test('a row swiped away lays the page out no more often than the same swipe with }); /** - * Swipes `label` away, and 30 ms into its exit presses `button`. In the frame - * after the press, it reads the rows' order and the leaving row's glides and - * top. It also returns the row's top as its exit started. + * Swipes `label` away, and in the first frame that shows its exit presses + * `button`. Once the press commits, in the same task, it reads the rows' order + * and the leaving row's glides and top. It also returns the row's top as its + * exit started. */ async function secondChangeDuringExit( page: Page, @@ -1025,27 +1038,31 @@ async function secondChangeDuringExit( return; } const topBefore = exit.getBoundingClientRect().top; - setTimeout(() => { - [...document.querySelectorAll('button')] - .find(element => element.textContent?.trim() === button) - ?.click(); - requestAnimationFrame(() => { - (window as unknown as {__second: object}).__second = { - starts: exit.getAnimations().flatMap(animation => { - const first = ( - animation.effect as KeyframeEffect | null - )?.getKeyframes()[0]?.translate; - return typeof first === 'string' ? [first] : []; - }), - order: [...document.querySelectorAll('#storybook-root li')].map( - li => - `${li.hasAttribute('data-astryx-leaving') ? 'leaving:' : ''}${labelOf(li)}`, - ), - topBefore, - topAfter: exit.getBoundingClientRect().top, - }; - }); - }, 30); + // The exit lasts under half a second. A timer or a frame between this + // frame and the reads can come after it ends on a loaded runner. So + // the press lands now, and the reads follow React's commit of it, + // which runs in a microtask before this frame renders. + [...document.querySelectorAll('button')] + .find(element => element.textContent?.trim() === button) + ?.click(); + const read = () => { + (window as unknown as {__second: object}).__second = { + starts: exit.getAnimations().flatMap(animation => { + const first = ( + animation.effect as KeyframeEffect | null + )?.getKeyframes()[0]?.translate; + return typeof first === 'string' ? [first] : []; + }), + order: [...document.querySelectorAll('#storybook-root li')].map( + li => + `${li.hasAttribute('data-astryx-leaving') ? 'leaving:' : ''}${labelOf(li)}`, + ), + topBefore, + topAfter: exit.getBoundingClientRect().top, + }; + }; + // After the microtasks that the commit queues in turn. + queueMicrotask(() => queueMicrotask(() => queueMicrotask(read))); }; requestAnimationFrame(tick); }, From 1bb128d56abb3ffcbdd56d90c15dc8361f4d62e6 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 9 Nov 2026 22:53:47 -0800 Subject: [PATCH 2/4] test(List): the removal proof holds the whole animated container The removal proof held only the list, so the glides of the text and the buttons after it still ended on their own before a late read. The hold now takes the story's animated container, and checks it took the text's glide as well as the exit. --- .../ListUpdates.interaction.browser.spec.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/core/src/List/__tests__/ListUpdates.interaction.browser.spec.ts b/packages/core/src/List/__tests__/ListUpdates.interaction.browser.spec.ts index 11124285b6936..6f76319a5c721 100644 --- a/packages/core/src/List/__tests__/ListUpdates.interaction.browser.spec.ts +++ b/packages/core/src/List/__tests__/ListUpdates.interaction.browser.spec.ts @@ -193,11 +193,15 @@ test('a removal: the removed row stays in place, inert and out of the accessibil }) => { await open(page); const gap = await rowGap(page); - // The exit lasts as long as the glides. The page holds it from its first - // frame, so the row is still there for each read below, however late a - // loaded runner sends it. - const hold = await armHold(page, page.locator('#storybook-root ul'), { - judges: {target: leaving(page), property: 'clip-path'}, + // The exit and the glides last under half a second. The page holds them + // from their first frame, so every read below finds them, however late a + // loaded runner sends it. The hold takes the whole animated container: the + // text and the buttons after the list glide outside it. + const hold = await armHold(page, page.locator('#storybook-root > div'), { + judges: [ + {target: leaving(page), property: 'clip-path'}, + {target: page.getByTestId('after-list'), property: 'translate'}, + ], newOnly: true, }); await press(page, 'Remove the second row'); From f8fca8ba4d8f44c12fd9b208c26c3eddb6abd695 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 9 Nov 2026 23:28:44 -0800 Subject: [PATCH 3/4] test(List): the swiped-open layout count holds both frames The swiped-open layout proof stamped its end in the second frame's callback, before that frame laid out. A layout a swiped row adds in the second frame fell outside the count. It now ends on a third frame, once the second has laid out. --- .../List/__tests__/ListUpdates.interaction.browser.spec.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/core/src/List/__tests__/ListUpdates.interaction.browser.spec.ts b/packages/core/src/List/__tests__/ListUpdates.interaction.browser.spec.ts index 6f76319a5c721..6429aea455e71 100644 --- a/packages/core/src/List/__tests__/ListUpdates.interaction.browser.spec.ts +++ b/packages/core/src/List/__tests__/ListUpdates.interaction.browser.spec.ts @@ -892,8 +892,12 @@ test('a change with a row swiped open lays the page out no more often than the s ); const from = performance.timeOrigin + performance.now(); button?.click(); + // The third frame's callback runs once the second frame has laid + // out, so the window holds both frames' work. await new Promise(resolve => { - requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + requestAnimationFrame(() => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ); }); return {from, to: performance.timeOrigin + performance.now()}; }); From c5abb4191cc09e7635d971530a09926545901f48 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Tue, 10 Nov 2026 09:47:31 -0800 Subject: [PATCH 4/4] test(List): the removal proof's hold stays on the list The hold on the whole animated container also took the pressed button's own press animation. In WebKit that can start on the pointerdown, before the click removes the row, and it met the hold's count first. The hold takes the list only again. The glides after the list wait for a hold that waits for the judged motion. --- .../ListUpdates.interaction.browser.spec.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/core/src/List/__tests__/ListUpdates.interaction.browser.spec.ts b/packages/core/src/List/__tests__/ListUpdates.interaction.browser.spec.ts index 6429aea455e71..407a15680ffdd 100644 --- a/packages/core/src/List/__tests__/ListUpdates.interaction.browser.spec.ts +++ b/packages/core/src/List/__tests__/ListUpdates.interaction.browser.spec.ts @@ -193,15 +193,12 @@ test('a removal: the removed row stays in place, inert and out of the accessibil }) => { await open(page); const gap = await rowGap(page); - // The exit and the glides last under half a second. The page holds them - // from their first frame, so every read below finds them, however late a - // loaded runner sends it. The hold takes the whole animated container: the - // text and the buttons after the list glide outside it. - const hold = await armHold(page, page.locator('#storybook-root > div'), { - judges: [ - {target: leaving(page), property: 'clip-path'}, - {target: page.getByTestId('after-list'), property: 'translate'}, - ], + // The exit lasts as long as the glides. The page holds it from its first + // frame, so the row is still there for each read below, however late a + // loaded runner sends it. The hold takes the list only. The pressed + // button's own press can start first, and would meet the hold's count. + const hold = await armHold(page, page.locator('#storybook-root ul'), { + judges: {target: leaving(page), property: 'clip-path'}, newOnly: true, }); await press(page, 'Remove the second row');