Skip to content
Open
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
15 changes: 12 additions & 3 deletions common/src/tests/fan-out.js
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<unknown>}
*/
#nextMessage = qp('yield') === 'frame' ? nextFrameTask : nextMacrotask;

constructor({
consumers = qpNum('consumers', 1_000),
updates = qpNum('updates', 10_000),
Expand Down Expand Up @@ -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`);

Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion common/src/tests/many-items.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export class ManyItems extends BaseTest {
#percentRandomAwait = 0;

/**
* @type {'micro' | 'macro'}
* @type {'micro' | 'macro' | 'frame'}
*/
#yieldKind = yieldKind();

Expand Down
2 changes: 1 addition & 1 deletion common/src/tests/one-item.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export class OneItem extends BaseTest {
#percentRandomAwait = 0;

/**
* @type {'micro' | 'macro'}
* @type {'micro' | 'macro' | 'frame'}
*/
#yieldKind = yieldKind();

Expand Down
30 changes: 27 additions & 3 deletions common/src/tests/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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();
}

Expand Down
7 changes: 7 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 31 additions & 1 deletion tests/specs/conformance.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
/**
Expand All @@ -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}]`),
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
Loading