Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,20 @@ import {
} from '@astryxdesign/a11y-spec/playwright';
import {
drag,
FRAME_STARVATION_MS,
freezePageWhen,
holdInputUntil,
installPageFreeze,
installTimerGate,
openStory,
requireFrames,
tap,
thawPage,
touchDriver,
webInputDriver,
WHEEL_SCROLL_END_MS,
withStorybook,
type InputHold,
} from '@astryxdesign/interaction-harness';

import {paging} from '../../motion/uikit/paging';
Expand Down Expand Up @@ -549,7 +553,12 @@ test('the scroll offset holds through a swipe, and the settle hands it on', asyn
});
});
const driver = await touchDriver(page);
await driver.play(drag(from, to, {durationMs: 80, restMs: 0}));
// The row pages from the release the page reads. A release it reads on the
// other side of the advance speed from the script fails by name.
await driver.play(drag(from, to, {durationMs: 80, restMs: 0}), {
release: 'scroll',
boundaries: {releasePxPerS: paging.release.advanceSpeed},
});
const seen = await page.evaluate(
() => (window as unknown as {__seen: number[]}).__seen,
);
Expand Down Expand Up @@ -800,6 +809,7 @@ async function pressInSettle(
scroller: Locator,
until: number,
back = false,
beforeThaw?: () => Promise<void>,
) {
const next = await page
.getByRole('button', {name: /next|right|forward/i})
Expand All @@ -810,6 +820,7 @@ async function pressInSettle(
// The settle's frames go through the gate, so the freeze below holds them.
await installTimerGate(page);
await scroller.evaluate(el => el.setAttribute('data-press-in-settle', ''));
await logShown(scroller);
// A drag past half a view that rests before the lift releases at 0, so the
// row settles on the nearest page, the next one. A flick's release reads
// its speed from when the touches arrive, and a starved page receives
Expand All @@ -825,27 +836,102 @@ async function pressInSettle(
// `until`. A read from this process comes a round trip late, and a loaded
// runner can miss the whole pass in that time. A settle that hands the row
// back first fails here, by name.
await freezePageWhen(
await freezeWhenHeldIn(
page,
({at, isBack}) => {
const el = document.querySelector('[data-press-in-settle]');
const items = Array.from(el?.children ?? []) as HTMLElement[];
if (el == null || items.length === 0) {
return false;
}
const shown =
el.getBoundingClientRect().left - items[0].getBoundingClientRect().left;
const isHeld = items.some(item => item.style.translate !== '');
return isHeld && (isBack ? shown < at : shown >= at);
},
{at: until, isBack: back},
back ? -Infinity : until,
back ? until : Infinity,
);
const held = await isTakenOver(scroller);
await page.mouse.click(next.x + next.width / 2, next.y + next.height / 2);
await beforeThaw?.();
await thawPage(page);
return held;
}

type Shown = {t: number; held: boolean; shown: number};

/**
* Logs, on every real frame from now on, where the row shows and whether the
* paging holds it. The freeze checks the same frames.
*/
async function logShown(scroller: Locator) {
await scroller.evaluate(el => {
const log: Shown[] = [];
(window as unknown as {__shownLog: Shown[]}).__shownLog = log;
const freeze = (
window as unknown as {
__harnessFreeze?: {frame: (callback: () => void) => number};
}
).__harnessFreeze;
const frame = (callback: () => void) =>
freeze == null ? requestAnimationFrame(callback) : freeze.frame(callback);
const items = Array.from(el.children) as HTMLElement[];
const read = () => {
log.push({
t: performance.now(),
held: items.some(item => item.style.translate !== ''),
shown:
el.getBoundingClientRect().left -
items[0].getBoundingClientRect().left,
});
if (log.length < 2000) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: this loop re-arms on every frame until it holds 2,000 entries, but only freezeWhenHeldIn's catch path reads the log. After freezePageWhen resolves, the loop keeps doing two getBoundingClientRect reads per frame through the edge press, the thaw, the move and the touch: main-thread work in the frames this change protects on a starved runner. You could stop it once the freeze lands, for example with if (log.length < 2000 && !freeze?.frozen) (the install exposes frozen), or with a flag set after freezePageWhen resolves.

frame(read);
}
};
frame(read);
});
}

/**
* Freezes the scene in the first real frame where the paging holds the row
* shown in `[from, to)`. A row that passed that window between two frames
* more than the starvation gap apart was starved of frames, which fails by
* name. Needs `logShown` first.
*/
async function freezeWhenHeldIn(page: Page, from: number, to: number) {
try {
await freezePageWhen(
page,
({lo, hi}) => {
const el = document.querySelector('[data-press-in-settle]');
const items = Array.from(el?.children ?? []) as HTMLElement[];
if (el == null || items.length === 0) {
return false;
}
const shown =
el.getBoundingClientRect().left -
items[0].getBoundingClientRect().left;
const isHeld = items.some(item => item.style.translate !== '');
return isHeld && shown >= lo && shown < hi;
},
{lo: from, hi: to},
);
} catch (error) {
const log = await page.evaluate(
() => (window as unknown as {__shownLog: Shown[]}).__shownLog,
);
const side = ({shown}: Shown) => (shown < from ? -1 : shown >= to ? 1 : 0);
// The row crossed the window between a frame that held it short of the
// window and the next, which shows it past or handed back.
for (let i = 1; i < log.length; i++) {
const [before, after] = [log[i - 1], log[i]];
const isCrossed =
before.held &&
side(before) !== 0 &&
(side(after) === -side(before) ||
(!after.held && side(after) !== side(before)));
if (isCrossed && after.t - before.t > FRAME_STARVATION_MS) {
requireFrames([before, after], {
min: 1,
during: 'the row held in its freeze window',
where: sample => sample.held && side(sample) === 0,
});
}
}
throw error;
}
}

/** The row's offset once two reads a while apart agree. */
async function restingOffset(scroller: Locator) {
await settled(scroller);
Expand Down Expand Up @@ -874,6 +960,12 @@ test('an edge press late in a settle steps on to a stop', async ({page}) => {
expect(Math.min(...stops.map(stop => Math.abs(stop - at)))).toBeLessThan(1);
});

/**
* How far into an edge button's move the touch lands, ms on the scene's
* clock. The case needs the catch short of halfway to the move's target.
*/
const CATCH_INTO_MOVE_MS = 100;

for (const holdMs of [150, 40]) {
test(`during an edge button's move, a ${holdMs} ms touch on the row stops it where it shows, and the row rests on a stop`, async ({
page,
Expand All @@ -886,11 +978,28 @@ for (const holdMs of [150, 40]) {
if (box == null) {
throw new Error('no scroller');
}
expect(await pressInSettle(page, scroller, stops[3] * 0.9)).toBe(true);
const driver = await touchDriver(page);
// The page handles the touch on the scene's clock, a set time into the
// edge button's move, however late this process sends it. A touch that
// reaches the page after that time fails by name.
let hold: InputHold | null = null;
expect(
await pressInSettle(page, scroller, stops[3] * 0.9, false, async () => {
hold = await holdInputUntil(page, {
afterResumeMs: CATCH_INTO_MOVE_MS,
on: ['touchstart', 'pointerdown'],
});
}),
).toBe(true);
if (hold == null) {
throw new Error('no hold');
}
const caught: InputHold = hold;
await caught.resumed();
await driver.play(
tap({x: box.x + box.width / 2, y: box.y + box.height / 2}, {holdMs}),
);
await caught.landed();
const at = await restingOffset(scroller);
expect(Math.min(...stops.map(stop => Math.abs(stop - at)))).toBeLessThan(1);
// A finger stops the pager's own move at the touch, short of its target.
Expand Down
Loading