diff --git a/common/src/tests/fan-out.js b/common/src/tests/fan-out.js index 202a6ad..5c9cabe 100644 --- a/common/src/tests/fan-out.js +++ b/common/src/tests/fan-out.js @@ -1,5 +1,5 @@ import { BaseTest, RUN } from './base-test.js'; -import { nextMacrotask, qpNum, tryVerify } from './utils.js'; +import { nextFrameTask, nextMacrotask, qp, qpNum, tryVerify } from './utils.js'; /** * One value, rendered in many places. @@ -54,6 +54,15 @@ export class FanOut extends BaseTest { */ #range; + /** + * Messages arrive as real tasks (that is what sockets do). `?yield=frame` + * spaces them a frame apart instead, so the conformance suite can trace + * frameworks whose scheduler renders at most once per frame. + * + * @type {() => Promise} + */ + #nextMessage = qp('yield') === 'frame' ? nextFrameTask : nextMacrotask; + constructor({ consumers = qpNum('consumers', 1_000), updates = qpNum('updates', 10_000), @@ -137,7 +146,7 @@ export class FanOut extends BaseTest { // the first value never reached the DOM (found by // tests/specs/conformance.spec.ts). Before `:start`, so the hop is // not part of the measurement. - await nextMacrotask(); + await this.#nextMessage(); performance.mark(`:start`); @@ -159,7 +168,7 @@ export class FanOut extends BaseTest { // The next message arrives as a new (macro)task, // like a real `websocket.on('message', ...)` would. - await nextMacrotask(); + await this.#nextMessage(); } tryVerify(name, this.verify); diff --git a/common/src/tests/many-items.js b/common/src/tests/many-items.js index a0aa8dc..c54f8ea 100644 --- a/common/src/tests/many-items.js +++ b/common/src/tests/many-items.js @@ -44,7 +44,7 @@ export class ManyItems extends BaseTest { #percentRandomAwait = 0; /** - * @type {'micro' | 'macro'} + * @type {'micro' | 'macro' | 'frame'} */ #yieldKind = yieldKind(); diff --git a/common/src/tests/one-item.js b/common/src/tests/one-item.js index 2005b83..807d7eb 100644 --- a/common/src/tests/one-item.js +++ b/common/src/tests/one-item.js @@ -26,7 +26,7 @@ export class OneItem extends BaseTest { #percentRandomAwait = 0; /** - * @type {'micro' | 'macro'} + * @type {'micro' | 'macro' | 'frame'} */ #yieldKind = yieldKind(); diff --git a/common/src/tests/utils.js b/common/src/tests/utils.js index b60dd23..42f09e1 100644 --- a/common/src/tests/utils.js +++ b/common/src/tests/utils.js @@ -134,6 +134,21 @@ export function nextMacrotask() { }); } +/** + * A task that begins after the next animation frame has fired. + * + * setTimeout (not the MessageChannel hop): a frame-throttled scheduler + * (marko) parks pending renders on a rAF-posted MessageChannel message, + * and posted messages run before timers -- so by the time this resolves, + * such a framework has flushed and re-armed, and the next write renders + * on its own instead of coalescing into the previous frame. + */ +export function nextFrameTask() { + return new Promise((resolve) => { + requestAnimationFrame(() => setTimeout(resolve)); + }); +} + /** * How the update loops hand control back between updates. * @@ -147,22 +162,31 @@ export function nextMacrotask() { * `macro` is a real task, the same MessageChannel hop fan-out uses, which * is how a `websocket.on('message')` handler is actually reached. * + * `frame` is an animation frame and then a task ({@link nextFrameTask}): + * one write per *frame*, so even a scheduler with a frame-rate floor has + * nothing left to coalesce. The conformance suite uses it to trace + * frame-throttled frameworks; it is far too slow to measure with. + * * `micro` stays the default: it is what every recorded run so far used, and * a real task per update would put the 100k-update variants into the * minutes. * - * @returns {'micro' | 'macro'} + * @returns {'micro' | 'macro' | 'frame'} */ export function yieldKind() { - return qp('yield') === 'macro' ? 'macro' : 'micro'; + const kind = qp('yield'); + + return kind === 'macro' || kind === 'frame' ? kind : 'micro'; } /** * One turn of whichever queue {@link yieldKind} selects. * - * @param {'micro' | 'macro'} kind + * @param {'micro' | 'macro' | 'frame'} kind */ export function yieldTo(kind) { + if (kind === 'frame') return nextFrameTask(); + return kind === 'macro' ? nextMacrotask() : Promise.resolve(); } diff --git a/tests/README.md b/tests/README.md index c1f7887..f105a71 100644 --- a/tests/README.md +++ b/tests/README.md @@ -47,6 +47,13 @@ between `:start` and `:done`, and the tests assert: dbmon-with-chat is excluded: it runs forever off worker-driven timing, so there is no deterministic finite trace to compare. +Frameworks whose scheduler has a frame-rate floor (marko: after the +first write in a frame, further renders wait for the next animation +frame) run the externally-paced specs with `yield=frame` instead of +`yield=macro`: the same workload delivered one write per *frame* +(a rAF and then a task), which leaves nothing to coalesce even at a +frame-rate floor. Every trace assertion still applies to them. + ## Running ```bash diff --git a/tests/specs/conformance.spec.ts b/tests/specs/conformance.spec.ts index d1eb7ab..64e13ae 100644 --- a/tests/specs/conformance.spec.ts +++ b/tests/specs/conformance.spec.ts @@ -65,6 +65,13 @@ interface Trace { interface ConformanceSpec { app: string; query: string; + /** + * The query FRAME_THROTTLED frameworks run with: the same workload, + * delivered one write per frame instead of one per task. Absent on the + * self-advancing spec (incrementing-render-effect waits for each + * render, so a frame-floored framework already renders every state). + */ + pacedQuery?: string; /** The exact sequence of page-text states the run must pass through */ expectedStates: string[]; /** @@ -80,10 +87,26 @@ function range(start: number, end: number): number[] { return Array.from({ length: end - start }, (_, i) => start + i); } +/* + * Frameworks whose scheduler has a frame-rate floor: the first write in a + * frame renders in a microtask, every later write waits for the next + * animation frame (marko's schedule() stays "scheduled" until a + * rAF-driven MessageChannel message resets it -- marko/src/dom/schedule.ts). + * One update per *task* still coalesces into one render per *frame*, so + * the task-paced queries cannot observe their per-write states. These + * frameworks run the externally-paced specs with `pacedQuery` instead: + * `yield=frame` delivers one write per frame (rAF + a task, see + * common/src/tests/utils.js nextFrameTask), which leaves nothing to + * coalesce even at a frame-rate floor -- and every trace assertion + * (exact states, zero element churn, the text-node budget) still applies. + */ +const FRAME_THROTTLED = new Set(['marko']); + const SPECS: ConformanceSpec[] = [ { app: 'one-item-many-updates', query: `?updates=${UPDATES}&percentRandomAwait=100&yield=macro`, + pacedQuery: `?updates=${UPDATES}&percentRandomAwait=100&yield=frame`, // the initial render already shows [0] before :start, and set(0) // re-renders the same text -- the first observable change is [1] expectedStates: range(1, UPDATES).map((i) => `[${i}]`), @@ -92,6 +115,7 @@ const SPECS: ConformanceSpec[] = [ { app: 'ten-k-items-one-time', query: `?items=${ITEMS}&updates=${ITEMS}&percentRandomAwait=100&yield=macro`, + pacedQuery: `?items=${ITEMS}&updates=${ITEMS}&percentRandomAwait=100&yield=frame`, // sequential updates: state k has items 0..k set, the rest untouched expectedStates: range(0, ITEMS).map((k) => range(0, ITEMS) @@ -103,6 +127,7 @@ const SPECS: ConformanceSpec[] = [ { app: 'fan-out', query: `?consumers=${CONSUMERS}&updates=${UPDATES}&burstSize=1`, + pacedQuery: `?consumers=${CONSUMERS}&updates=${UPDATES}&burstSize=1&yield=frame`, // bursts of 1: nothing to coalesce, every value must reach every // consumer, and consumers must never tear (a state where they // disagree would not match) @@ -214,8 +239,13 @@ async function runConformance( page.on('pageerror', (error) => errors.push(error.message)); + const query = + FRAME_THROTTLED.has(framework) && spec.pacedQuery + ? spec.pacedQuery + : spec.query; + await page.addInitScript(installTraceObserver); - await page.goto(`${server.url}/${spec.query}`); + await page.goto(`${server.url}/${query}`); await page.waitForFunction( () => performance.getEntriesByName(':done', 'mark').length > 0,