diff --git a/documentation/server-components/frames-consistency-contract.md b/documentation/server-components/frames-consistency-contract.md index 49de0dfdf..fd9895cc2 100644 --- a/documentation/server-components/frames-consistency-contract.md +++ b/documentation/server-components/frames-consistency-contract.md @@ -832,3 +832,283 @@ Frames-client (`packages/web/frames/src/`): gate. 7. **R7/C13** — needs a wire sweep delimiter (producer + transport), per the rulings draft; client-only work cannot carry it. + +## Generic hydration — classification and pins (2026-10-06) + +Branch `test/hydration-consistency-generic` off `wip/frames-pass-integration` +@ `00dbc8663` (#3837). The question: are the reds above frames-only, or are +some of them plain Solid 2 hydration holes that a page with no server +component would hit? Method: classify each invariant and red, then drive +the generic twin of every candidate through a **frames-free** page — +`hydrate()` over a `renderToStream` document with two sibling streamed +`` boundaries (`packages/web/test/harness/generic-hydration.tsx`, +artifacts rendered by `test/server/generic-hydration.gen.spec.tsx`), by +hand (`test/consistency/generic/*.spec.tsx`) and under a property harness +(`test/consistency/generic/{scenario,run,campaign}`, opt-in behind the same +`CONSISTENCY_FUZZ` knobs as §Harness). **Nothing here changes an engine.** + +**Answer: yes — six generic reds, four of them one class.** GH1–GH3 are the +plain-Solid form of R10/C19 (a claim pass that reads a value the markup was +not rendered from and does not reconcile the text); GH4 is the plain form +of the frames pass's "unrevealed boundary with `STATUS_PENDING` shows +fallback"; GH5/GH6 are the plain form of R1/C3 and C14 for a hold the +hydration runtime does not count — the root module preload. Everything else +in C1–C19 is either frames-only or holds on plain pages (1000 harness cases, +two seeds, no finding outside the six). + +### Classification + +Key: **SC-only** — needs frames/slots/records to express; **generic-restated** +— the SC case is an instance of a plain hydration rule (§3 _n_ cites +`documentation/plans/solid-web-size-audit.md` §3) that could break without +frames; **generic-suspect** — shared mechanism, nothing in the plain suite +pinned it before this pass. "Plain verdict" is what the generic pins and +harness found. + +| # | class | plain rule (§3) / mechanism | plain verdict | +| --- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| C1 | generic-restated | §3 1–2, 8 — `getNextElement` claim-by-key, the claim pass never mutates (`client.ts:insertExpression`) | holds; key misses only as consequences of GH3/GH4 | +| C2 | generic-restated | §3 70, 76 — `resumeBoundaryHydration` is driven by the `_fr` settle + `whenRevealed`, not by the DOM reveal | holds (every range live and reactive, 1000 cases) | +| C3 | generic-restated | §3 35–37, 69 — `_pendingBoundaries` / `checkHydrationComplete`; the root preload wait (`client.ts:hydrate` `rootMapping`) | holds for ``; **red GH5** (preload hold not counted) | +| C4 | SC-only | document records (`drainRecords`) have no plain twin; the plain "applies once" is C1's no-duplicate | — | +| C5 | SC-only | per-response data tables | — | +| C6 | SC-only | held `slot:*` records across rebind | — | +| C7 | SC-only | frame store / `#flush` | — | +| C8 | SC-only | frame fan-out | — | +| C9 | generic-restated | §3 8, 68, 70 — settled fragment hydrates straight through (`hydratedCreateLoadingBoundary`) into an UNREVEALED core boundary | **red GH4** (fallback committed over settled content) | +| C10 | generic-restated | §3 18, 22, 70 — ids from the owner's counter; a resume's `gather(id)` | holds (both orders claim the server nodes, no miss) | +| C11 | SC-only | container traces | — | +| C12 | generic-restated | §3 68 — `_fr` states pending / settled / parked / superseded / rejected (the rejected arm has a client twin on plain pages) | holds at settle points; the transient violation is GH4 | +| C13 | SC-only | live holes / sweeps | — | +| C14 | generic-restated | §3 35, 69 — `initBoundaryResume` disposal release + `cleanupFragment`; the preload path's deferred disposer | holds for boundaries; **red GH6** (dispose during preload) | +| C15 | SC-only | staging | — | +| C16 | SC-only | component identity | — | +| C17 | SC-only | shell gate / address | — | +| C18 | SC-only | occurrence classification | — | +| C19 | generic-suspect | §3 6, 38, 71 — `normalize` adopts the text node without a write; the snapshot scope (#3504) is what makes the read match | **red GH1, GH2, GH3** (three sources the snapshot does not cover) | +| R1 | generic-restated | a hold registered with nothing hydration counts | **GH5** is its plain twin | +| R2 | SC-only | `#appliedRootValue` | — | +| R3 | SC-only | a plain reveal IS a trigger (C2 row) | holds | +| R4 | SC-only | data path version | — | +| R5 | SC-only | rebind | — | +| R6 | SC-only | a server `` with no client twin; the plain `s === 2` arm resumes fresh (`hydration/diagnostics`) | holds | +| R7 | SC-only | sweep delimiter | — | +| R8 | SC-only | gate / address | — | +| R9 | SC-only | classification vs drain | — | +| R10 | generic-suspect | the claim pass is not a mutation pass; the trace is one source without a snapshot — GH1–GH3 are the others | **GH1–GH3** | + +The frames pass's six "smelled generic" findings, placed: (1) `initBoundaryResume` +ids vs the fragment ledger — SC-only (a plain boundary's id IS its fragment +key by design; the `sc:` prefix is the frames fix); (2) a hold on a page that +never ran `hydrate()` — SC-only in that shape (`initBoundaryResume` is +reached only under `hydrating`), but its generic twin — a hold the runtime +does not count — is GH5; (3) unrevealed boundary + `STATUS_PENDING` → +fallback — **generic, GH4** (the `flatten` memo-of-a-promise arm was not +reproduced on a plain page); (4) `$df` not a sync trigger — plain `` +resumes on the `_fr` settle, holds; (5) R10's class for the plain adapters — +**generic, GH1–GH3** (the hybrid async-iterable signal path is protected by +its creation-time snapshot of the first yield; the store path parks its +backlog past hydration end — both by reasoning, not pinned); (6) events vs a +hold — **generic**: GH5's second arm (the bootstrap stops capturing once the +wrong done drained the queue) and GH4's lost click. + +### Generic reds + +Pins: `packages/web/test/consistency/generic/replay.spec.tsx` (GH1–GH4, over +the harness's laws) and `preload-hold.spec.tsx` (GH5, GH6). Schedules read +as `describeScenario` prints them: `H` hydrate, `Cn` the stream's n-th chunk, +`W` a client write to the module-level signal, `P` a push to the module-level +store list, `Ea`/`Eb` a click on a boundary's button, `t` a 20ms settle, `m` a +microtask, `X` dispose. + +#### GH1 — C19: a memo created before capture is read live by a resume's claim pass + +**Shape.** `const label = createRoot(() => createMemo(() => "label:" + path()))` +at module level (a global store module), read in the shell and in both +boundaries; `setPath("/b")` after `hydrate()` and before the fragments land +(`[ab] :: H W t C0 C1 C2 t`). **Observed:** the shell shows `label:/b`; each +boundary's resume claims the server text `label:/a` while the memo it read +says `label:/b` — the `.raw` hole beside it (the plain signal) reads the +snapshot `/a`, claims, and catches up to `/b`; `.label` never does until the +memo changes again. **Expected** (the write-before-resume contract, #3504): +the boundary resumes against the server snapshot, then catches up. **Where +it goes wrong.** `captureWriteSnapshot` records the pre-write value of a +plain SIGNAL written during capture (`core.ts:setSignal`), and a computed +created during capture gets its creation value as snapshot (`core.ts:computed`); +a computed created BEFORE capture has neither, recomputes live when its +dependency is written, and the in-scope reader finds no `_snapshotValue` to +serve. `normalize` then adopts the text node without a write (§3 6) and +`insertExpression`'s claim arm returns the value (§3 8). **Severity:** stale +DOM, no diagnostic, until the next distinct change (medium). **Fix direction +(not applied):** the computed analog of `captureWriteSnapshot` — when a +computed without a snapshot recomputes while capture is active and it is not +itself in a snapshot scope, record its pre-recompute value; or make the claim +pass reconcile a text hole whose read differs from the node (which also +covers R10). + +#### GH2 — C19: a shell async memo adopted pending re-runs before a later boundary resumes + +**Shape.** `shared = createMemo(async () => "shared:" + path())` in the shell, +read only inside the boundaries (pending when the shell flushes, so the +client adopts it pending: no creation snapshot — `computed()` skips +`STATUS_PENDING`, and an async landing "reveals" by design). It lands +`shared:/a` with the first fragment; a write re-runs it (`H C0 C1 t W t C2 t`); +the second boundary resumes reading `shared:/b` and claims `shared:/a`. +**Observed:** `b.shared shared:/a ≠ shared:/b` beside `b.raw /b` — one +boundary internally inconsistent. **Expected:** `shared:/b` once settled. +**Severity:** stale DOM, no diagnostic (medium). **Fix direction:** same +as GH1 (the first landed value of a pending-adopted computed is the server's +value and could seed its snapshot), or reconcile at claim. + +#### GH3 — C19 / C1: a store write to a leaf no reader has materialized is not snapshotted + +**Shape.** `const [store, setStore] = createStore({ items: ["i0", "i1"] })` at +module level; `` inside each boundary; a push +(`setStore(s => s.items.push("i2"))`) after `hydrate()` and before the +fragments land (`H P C0 C1 C2 t`). **Observed:** at each resume `` reads +three items against two server rows — `Hydration key miss for "…620"` (a +detached `
  • ` the warning blames on id namespaces) and a list one row +short until the next structural change. **Expected:** two rows claimed, the +third inserted at release. **Where it goes wrong.** No shell reader had read +`items.length`, so the write mutates the raw target with no leaf signal to +capture; the leaf is created at the resume's first read with the post-write +value and a snapshot OF that value. Materializing the leaf before the write +(a shell reader of `items.length`) makes the same schedule green — the hole +is exactly "unmaterialized leaf". **Severity:** stale DOM + misleading dev +diagnostic (medium). **Fix direction:** under capture, a store write to an +unmaterialized leaf materializes it (so `captureWriteSnapshot` sees the +pre-write value) or records a per-target pre-write snapshot. + +#### GH4 — C9 / C12 / events: a boundary resuming while a shell async source is in flight commits its fallback over the settled content + +**Shape.** The GH2 page; the write lands BEFORE the first fragment +(`H W C0 C1 m t`): `shared` is superseded by a client flight (15ms); the +boundary's fragment reveals and it resumes while the flight is open. +**Observed:** the resume's content reads `shared` pending; the core boundary +has never revealed on the client, so it falls back — the fallback is +rendered in the claim window (`Hydration key miss for "410"`, `

    `, +a phantom the claim arm keeps out of the DOM), then `releaseSnapshotScope` +re-runs the insert OUTSIDE the window and commits it: the server `

    ` +is detached and a fresh client `

    a-loading

    ` stands in +its place until the flight lands, when the same server nodes are +re-attached (node identity holds, parity holds at the settle point). A +click queued on the server section at its reveal (`H W m C0 C1 C2 Eb`) +replays while the section is detached — the walk from the detached button +never reaches the delegated container — and is consumed: `b: 1 clicks, 0 handled`. +**Expected:** the settled server content is the boundary's revealed value +(async-holds-latest), no fallback, no detach, the click replays. **Where it +goes wrong.** `hydratedCreateLoadingBoundary`'s settled paths hand the +server content to `coreLoadingBoundary` as a fresh, UNREVEALED boundary; +"revealed" is a client-render fact the hydration path never asserts. The +frames pass's finding (3) is this, with frames. **Severity:** visible +fallback flash over settled content, focus/selection loss, lost pre-hydration +input (medium-high). **Fix direction:** a boundary hydrating straight +through / resuming from a settled fragment starts revealed (the claimed +content is its value), so a pending read holds. + +#### GH5 — C3: hydration-done does not count a root's module preload + +**Shape.** Two roots; A's `hydrate()` finds `a_assets` and defers its render +behind `loadModuleAssets`; B's `hydrate()` runs synchronously meanwhile +(islands entry-clients start several roots in one tick — the code comment +in `hydrate` names the shape). **Observed:** B's pass ends → `checkHydrationComplete` +→ `drainHydrationCallbacks`: `onHydrationEnd` fires, `isHydrationInProgress()` +reads false, `_$HY.done = true` a macrotask later — while A has claimed +nothing and cannot until its module lands. Second arm: with a queued click +to drain, the replay nulls `_$HY.events` at done and the bootstrap stops +capturing; a click on A's server markup during A's wait is neither queued +nor handled (`a: 0` where 1 was sent). A third root starting after the +timeout would degrade to `render()` (§3 33; reasoned from the `_$HY.done` +guard, not pinned). **Expected:** done waits for the preload. **Where it +goes wrong.** The wait registers with nothing the completion check counts — +`_hydratingValue` is a per-root flag the next root's `finally` clears, and +`_pendingBoundaries` knows only `` registrations. R1 with the +record defer swapped for the preload. **Severity:** wrong done + lost input +(medium-high in islands setups). **Fix direction:** count the preload wait +as a pending registration — the same `_pendingBoundaries++` / release pair +`initBoundaryResume` keeps (what `sharedConfig.holdBoundary` wraps, minus +its owner requirement: `hydrate`'s preload branch has no owner yet) around +the `p.then`. + +#### GH6 — C14: disposing a root during its module preload does not cancel the deferred render + +**Shape.** `const dispose = hydrate(App, el)` with a pending `_assets` +preload; `dispose()` before the module lands. **Observed:** `hydrate` returns +`() => disposer && disposer()` with `disposer` unset until the preload's +`.then`; the call is a no-op, the render runs when the module lands, and +the root stays live (a write re-renders it) with no handle left — a second +call to the same function reaches the late disposer. **Expected:** nothing +renders after dispose. **Severity:** leaked live root (low-medium; HMR and +test teardown are the realistic callers). **Fix direction:** a `disposed` +flag in `hydrate`'s preload branch, checked before the deferred `render` +(and clearing `hydrating` / checking completion when set). + +### Generic holds confirmed + +On the plain page (hand pins in `replay.spec.tsx` "generic holds", and the +harness's 1000 cases with `CONSISTENCY_IGNORE=C1,C9,C19,E` → 0 findings): + +- **C2 / C10** — both fragment orders, hydrate before / between / after the + chunks: every boundary claims its server nodes (node identity, no + duplicate, no key miss absent a client write), is invoked once, and + reacts after a post-done write. +- **C3** — hydration-done waits for both streamed `` boundaries in + either order; `isHydrationInProgress()` stays true until then. +- **C12** — pending → the server fallback shows; revealed → content, no + fallback (at every settle point). +- **C14** — dispose while both are pending, or between the reveals: the late + chunks touch nothing, nothing runs, no error (the placeholder range is + removed at disposal; a late `$df` queues a retry that never lands). +- **Events** — a click queued before `hydrate()` on a settled fragment, or + after a reveal before the resume, replays exactly once at the claim + (absent GH4's detach). +- **#3504 snapshot** — the plain signal written during hydration resumes on + the snapshot and catches up in every schedule (the control beside GH1). +- Reasoned, not pinned: the hybrid async-iterable SIGNAL adapter is covered + by its creation snapshot (the first yield is delivered synchronously, so + the memo is not pending at creation); the STORE adapter parks its backlog + past hydration end (§3 60); `lazy()` without `moduleUrl` under a settled + boundary takes the async path and cannot claim — the documented + degradation of §3 81, not a hole. + +### Harness arm + +`packages/web/test/consistency/generic/` — same knobs as §Harness +(`CONSISTENCY_FUZZ=1 CONSISTENCY_SEED=… CONSISTENCY_CASES=… CONSISTENCY_IGNORE=… +CONSISTENCY_MODE=survey|shrink`, run against `test/consistency/generic`). +Scenario: a fragment order (`ab` / `ba`, two server renders) and a shuffled +schedule of `hydrate`, the stream's chunks (wire order kept), an optional +client write and store push (after `hydrate` — before it they are an app +mismatch, outside the contract), clicks on either boundary, a dispose, +0–3 settles, 0–2 microtasks. Laws: G no-runtime-error; C1 no-key-miss / +no-unclaimed / node-identity / no-duplicate; C9 no-fallback-over-settled; +C3 in-progress-until-done / done-counts-holds; C12 fragment-parity; C19 +claim-shows-signal / -memo / -async-memo / -store-list; C14 dispose-no-invoke +/ dispose-no-dom; C2 every-range-live / -reactive; E queued-click-replays-once. + +| seed | cases | ignore | cases with findings | findings by law | +| ----- | ----- | -------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| 3289 | 500 | — | 101 | C1 no-key-miss 95, C19 store-list 60 (GH3), C19 memo 55 (GH1), C9 fallback-over-settled 26 (GH4), C19 async-memo 16 (GH2), E 8 (GH4) | +| 91501 | 500 | — | 99 | C1 84, C19 memo 60, C19 store-list 48, C9 26, C19 async-memo 15, E 4 | +| 91501 | 500 | C1, C9, C19, E | **0** | C2, C3, C12, C14, G: nothing surfaces | + +Limitations: one page shape (two sibling boundaries; no nested boundaries, +no `lazy()`, no ``); the server's chunking is fixed per order +(three chunks: the first boundary's data, `shared` + its fragment, the +second fragment); `_hydrationDone` is a worker latch so every case after +the first runs post-done (a reveal before `hydrate()` is held and replayed +at registration — both regimes are legal pages); `readyState` is not +mocked (plain hydration consults it only for truncation). + +### Caveats + +- The verdicts are "a pin could not break it", as above. The harness covers + one page; nested boundaries resolving out of order, `lazy()` inside a + boundary and two `hydrate()` roots are covered only by the existing suite + (`parity-harness`, `loading-lazy-resume-3749`, `multi-root-registry`). +- GH4 self-heals for the DOM (the server nodes return); its lasting damage + is the lost input and the focus/selection loss, which the pin observes + through the click only. +- Severity of GH5 depends on the islands setup: a single deferred root is + fine (control pinned); the red needs a second root finishing while the + first waits. diff --git a/packages/web/test/consistency/generic/README.md b/packages/web/test/consistency/generic/README.md new file mode 100644 index 000000000..83380979a --- /dev/null +++ b/packages/web/test/consistency/generic/README.md @@ -0,0 +1,67 @@ +# Generic hydration harness + +The frames-free arm of the consistency contract +(`documentation/server-components/frames-consistency-contract.md`, §"Generic +hydration — classification and pins"). Same idea as `../harness` — a +fast-check schedule generator plus an oracle of the contract's laws — over a +PLAIN Solid 2 page: `hydrate()` of a `renderToStream` document with two +sibling streamed `` boundaries reading module-level state +(`test/harness/generic-hydration.tsx`). No frames, slots or records. + +## Run + +```sh +# the pins (always on): reds as test.fails, controls and holds as test +npx vitest run --config vite.config.hydrate.mjs test/consistency/generic + +# the campaign (opt-in), same knobs as ../harness +CONSISTENCY_FUZZ=1 CONSISTENCY_SEED=3289 CONSISTENCY_CASES=500 \ + npx vitest run --config vite.config.hydrate.mjs test/consistency/generic +CONSISTENCY_FUZZ=1 CONSISTENCY_SEED=91501 CONSISTENCY_CASES=500 CONSISTENCY_IGNORE=C1,C9,C19,E ... +CONSISTENCY_FUZZ=1 CONSISTENCY_MODE=shrink CONSISTENCY_IGNORE=C19 ... + +# replaying: GENERIC_DEBUG=1 prints the page and the sources after every step +GENERIC_DEBUG=1 npx vitest run --config vite.config.hydrate.mjs test/consistency/generic/replay.spec.tsx -t "GH4" --silent=false +``` + +The page's server render is an artifact: `test/harness/__artifacts__/ +generic-hydration-{ab,ba}.json` (`{ shell, chunks }`, one per fragment +order), written by `test/server/generic-hydration.gen.spec.tsx` under the +server config. Regenerate it after changing the shared page: + +```sh +npx vitest run --config vite.config.server.mjs test/server/generic-hydration.gen.spec.tsx +``` + +## Scenario (`scenario.ts`) + +`[order] :: H C0 W C1 t P C2 Ea X` — `H` hydrate, `Cn` the stream's n-th +chunk parses (markup appended, scripts run; wire order kept), `W` a client +write to the module-level signal (`setPath("/b")`), `P` a push to the +module-level store list, `Ea`/`Eb` a click on a boundary's button (queued by +the bootstrap-shaped capture if its range is not claimed yet), `t` a settle +(20ms — an in-flight client async re-run lands), `m` a microtask, `X` dispose. +`W`, `P` and `X` are normalized after `H` (a write before `hydrate()` is an +app mismatch, outside the contract). + +## Laws (`run.tsx`) + +| when | id | law | +| --------- | --- | --------------------------------------------------------------- | +| immediate | G | `no-runtime-error` | +| immediate | C1 | `no-key-miss`, `no-unclaimed`, `node-identity`, `no-duplicate` | +| immediate | C9 | `no-fallback-over-settled` (a client fallback after the reveal) | +| immediate | C3 | `in-progress-until-done` | +| immediate | C14 | `dispose-no-invoke`, `dispose-no-dom` | +| settled | C12 | `fragment-parity` | +| settled | C19 | `claim-shows-signal` / `-memo` / `-async-memo` / `-store-list` | +| end | C3 | `done-counts-holds` | +| end | C2 | `every-range-live`, `every-range-reactive` | +| end | E | `queued-click-replays-once` | + +## Limitations + +One page shape; the server's chunking is fixed per order; `_hydrationDone` +is a worker latch (every case after the first runs post-done — a reveal +before `hydrate()` is held and replayed at registration); `readyState` is +not mocked. diff --git a/packages/web/test/consistency/generic/campaign.spec.tsx b/packages/web/test/consistency/generic/campaign.spec.tsx new file mode 100644 index 000000000..35e7d1c8a --- /dev/null +++ b/packages/web/test/consistency/generic/campaign.spec.tsx @@ -0,0 +1,99 @@ +/** + * @vitest-environment jsdom + * + * The opt-in property campaign of the GENERIC (frames-free) hydration + * harness. Off by default; run with + * + * CONSISTENCY_FUZZ=1 [CONSISTENCY_SEED=3289] [CONSISTENCY_CASES=100] + * [CONSISTENCY_IGNORE=C19] [CONSISTENCY_MODE=survey|shrink] + * npx vitest run --config vite.config.hydrate.mjs test/consistency/generic + * + * Same knobs and modes as test/consistency/harness (survey tallies findings + * by invariant and law; shrink stops on the first finding outside + * CONSISTENCY_IGNORE and prints the reduced scenario as JSON for + * replay.spec.tsx). + */ +import { describe, expect, test } from "vitest"; +import fc from "fast-check"; +import { runScenario } from "./run.js"; +import { describeScenario, scenarioArb, type Scenario } from "./scenario.js"; +import { loadArtifact } from "./support.js"; + +const FUZZ = !!process.env.CONSISTENCY_FUZZ; +const SEED = Number(process.env.CONSISTENCY_SEED ?? 3289); +const CASES = Number(process.env.CONSISTENCY_CASES ?? 100); +const IGNORE = new Set((process.env.CONSISTENCY_IGNORE ?? "").split(",").filter(Boolean)); +const MODE = process.env.CONSISTENCY_MODE ?? "survey"; + +const out = (s: string) => process.stdout.write(s + "\n"); +const arb = scenarioArb(order => loadArtifact(order).chunks.length); + +describe("generic hydration harness", () => { + test("smoke: the canonical schedule (hydrate, then the chunks) has no finding", async () => { + const art = loadArtifact("ab"); + const result = await runScenario({ + order: "ab", + chunks: art.chunks.length, + events: [ + { t: "hydrate" }, + ...art.chunks.map((_, i) => ({ t: "chunk" as const, i })), + { t: "tick" } + ] + }); + expect(result.findings).toEqual([]); + }); +}); + +describe.skipIf(!FUZZ)("generic hydration harness campaign", () => { + test( + `seed ${SEED}, ${CASES} cases, mode ${MODE}, ignoring [${[...IGNORE].join(",")}]`, + { timeout: 0 }, + async () => { + if (MODE === "shrink") { + try { + await fc.assert( + fc.asyncProperty(arb, async scenario => { + const result = await runScenario(scenario); + const real = result.findings.filter(f => !IGNORE.has(f.id)); + if (real.length) { + const f = real[0]; + throw new Error( + `${f.id} ${f.law} @${f.step}: ${f.detail}\n${result.description}\n${JSON.stringify(scenario)}` + ); + } + }), + { seed: SEED, numRuns: CASES, endOnFailure: true } + ); + out(`campaign: seed ${SEED}, ${CASES} cases, no finding outside [${[...IGNORE]}]`); + } catch (e: any) { + out(String(e && e.message)); + throw e; + } + return; + } + const scenarios = fc.sample(arb, { seed: SEED, numRuns: CASES }); + const tally = new Map(); + let failing = 0; + for (const scenario of scenarios) { + const result = await runScenario(scenario); + const real = result.findings.filter(f => !IGNORE.has(f.id)); + if (real.length) failing++; + const seen = new Set(); + for (const f of real) { + const key = `${f.id} ${f.law}`; + if (seen.has(key)) continue; + seen.add(key); + const t = tally.get(key); + if (t) t.count++; + else tally.set(key, { count: 1, first: scenario, firstDetail: f.detail }); + } + } + out(`campaign survey: seed ${SEED}, ${CASES} cases, ${failing} with findings`); + for (const [key, t] of [...tally].sort((a, b) => b[1].count - a[1].count)) { + out(` ${key}: ${t.count} cases — e.g. ${t.firstDetail}`); + out(` ${describeScenario(t.first)}`); + out(` ${JSON.stringify(t.first)}`); + } + } + ); +}); diff --git a/packages/web/test/consistency/generic/preload-hold.spec.tsx b/packages/web/test/consistency/generic/preload-hold.spec.tsx new file mode 100644 index 000000000..50aac040d --- /dev/null +++ b/packages/web/test/consistency/generic/preload-hold.spec.tsx @@ -0,0 +1,239 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + * + * Generic hydration pins — the root module preload as a HOLD + * (`documentation/server-components/frames-consistency-contract.md` + * §"Generic hydration", GH5 / GH6). `hydrate()` with a root module map + * (`_assets`) defers its render behind `loadModuleAssets` + * (web/src/client.ts `hydrate`, the `rootMapping` branch). That wait is a + * hold on the page's hydration — the root has claimed nothing yet — and + * it registers with nothing `checkHydrationComplete` counts: only + * `sharedConfig.hydrating` (the root's own flag) and `_pendingBoundaries` + * (`` registrations). Plain Solid 2, hand-built `_$HY` fixtures + * (as hydration/loading-lazy-resume-3749.spec.tsx builds its preload). + */ +import { afterEach, describe, expect, test } from "vitest"; +import { createSignal, flush } from "solid-js"; +import { hydrate } from "@solidjs/web"; +import { + bootHy, + hydrationInProgress, + installBootstrapCapture, + macrotask, + onHydrationEnd, + spyConsole +} from "./support.js"; + +function pendingPreload(hy: any, key: string) { + let land!: () => void; + hy.loading[key] = new Promise(r => (land = r)).then(() => { + hy.modules[key] = {}; + }); + return land; +} + +const disposers: (() => void)[] = []; +afterEach(async () => { + for (const d of disposers.splice(0)) d(); + await macrotask(); + await macrotask(); + delete (globalThis as any)._$HY; + document.body.innerHTML = ""; +}); + +describe("GH5 — hydration-done does not count a root's module preload (C3)", () => { + function twoRoots() { + const hy = bootHy({ r: { a_assets: { a0: "/assets/A.js" } }, modules: {}, loading: {} }); + const land = pendingPreload(hy, "a0"); + const A = document.createElement("div"); + const B = document.createElement("div"); + document.body.append(A, B); + A.innerHTML = `
    `; + B.innerHTML = `
    `; + const serverA = A.firstElementChild!; + let aClicks = 0; + let bClicks = 0; + const startA = () => + disposers.push( + hydrate( + () => ( +
    + +
    + ), + A, + { renderId: "a" } + ) + ); + const startB = () => + disposers.push( + hydrate( + () => ( +
    + +
    + ), + B, + { renderId: "b" } + ) + ); + return { hy, land, A, B, serverA, startA, startB, clicks: () => ({ a: aClicks, b: bClicks }) }; + } + + // Observed: B's synchronous pass ends → `checkHydrationComplete` sees no + // pending boundary → done drains (onHydrationEnd fires, isHydrationInProgress + // false, `_$HY.done = true` a macrotask later) while A — a hydration root + // this page started — has not claimed a node and cannot until its module + // lands. Expected: done waits for (or counts) the preload. + test.fails( + "root A deferred behind its preload, root B hydrates synchronously: done fires before A has claimed", + async () => { + const t = twoRoots(); + const spies = spyConsole(); + const ends: string[] = []; + t.startA(); + onHydrationEnd(() => ends.push("end")); + expect(hydrationInProgress()).toBe(true); + t.startB(); + flush(); + await macrotask(); + try { + // A is still waiting; its server markup is unclaimed. + expect(t.hy.completed.has(t.serverA)).toBe(false); + expect(hydrationInProgress()).toBe(true); + expect(ends).toEqual([]); + expect(t.hy.done).not.toBe(true); + } finally { + t.land(); + await macrotask(); + await macrotask(); + spies.restore(); + } + } + ); + + // The consequence a user sees: once done has drained an empty replay + // queue, `_$HY.events` is nulled and the bootstrap stops capturing. A + // click on A's server markup during A's preload wait is neither queued + // nor handled — the page had declared itself hydrated. + test.fails( + "a click on the deferred root during its wait is lost once the wrong done drained the queue", + async () => { + const t = twoRoots(); + const spies = spyConsole(); + const uncapture = installBootstrapCapture(document.body); + // one pre-hydration click on B, so the replay has a queue to drain + t.B.querySelector("button")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + t.startA(); + t.startB(); + flush(); + await macrotask(); + // the user clicks A while A still waits on its module + t.A.querySelector("button")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + t.land(); + await macrotask(); + await macrotask(); + flush(); + await macrotask(); + try { + expect(t.clicks()).toEqual({ a: 1, b: 1 }); + } finally { + uncapture(); + spies.restore(); + } + } + ); + + test("control: a single deferred root — done waits for the preload, the queued click replays", async () => { + const hy = bootHy({ r: { _assets: { "0": "/assets/A.js" } }, modules: {}, loading: {} }); + const land = pendingPreload(hy, "0"); + const A = document.createElement("div"); + document.body.append(A); + A.innerHTML = `
    `; + const uncapture = installBootstrapCapture(document.body); + A.querySelector("button")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + let clicks = 0; + const ends: string[] = []; + disposers.push( + hydrate( + () => ( +
    + +
    + ), + A + ) + ); + onHydrationEnd(() => ends.push("end")); + flush(); + await macrotask(); + expect(hydrationInProgress()).toBe(true); + expect(ends).toEqual([]); + land(); + await macrotask(); + await macrotask(); + flush(); + await macrotask(); + expect(ends).toEqual(["end"]); + expect(hydrationInProgress()).toBe(false); + expect(clicks).toBe(1); + uncapture(); + }); +}); + +describe("GH6 — disposing a root during its module preload does not cancel the deferred render (C14)", () => { + // Observed: `hydrate()` returns `() => disposer && disposer()` with + // `disposer` unset until the preload lands; a dispose before that is a + // no-op, the render still runs when the module lands, and the root stays + // live (reacting to writes) with no handle left to dispose it. Expected: + // nothing renders after dispose; `hydrating` and completion settle. + test.fails("dispose before the module lands: the root must not render afterwards", async () => { + const hy = bootHy({ r: { _assets: { "0": "/assets/A.js" } }, modules: {}, loading: {} }); + const land = pendingPreload(hy, "0"); + const A = document.createElement("div"); + document.body.append(A); + A.innerHTML = `
    x
    `; + const [t, setT] = createSignal("x"); + let renders = 0; + const dispose = hydrate(() => { + renders++; + return
    {t()}
    ; + }, A); + dispose(); + land(); + await macrotask(); + await macrotask(); + flush(); + setT("y"); + flush(); + try { + expect(renders).toBe(0); + expect(A.innerHTML).toBe(`
    x
    `); + } finally { + // the leaked root: a second call reaches the late disposer + dispose(); + } + }); + + test("control: dispose after the module landed tears the root down", async () => { + const hy = bootHy({ r: { _assets: { "0": "/assets/A.js" } }, modules: {}, loading: {} }); + const land = pendingPreload(hy, "0"); + const A = document.createElement("div"); + document.body.append(A); + A.innerHTML = `
    x
    `; + const [t, setT] = createSignal("x"); + const dispose = hydrate(() =>
    {t()}
    , A); + land(); + await macrotask(); + await macrotask(); + flush(); + expect(A.querySelector("div")!.textContent).toBe("x"); + dispose(); + // a render root's disposer clears what it rendered; a later write lands nowhere + expect(A.querySelector("div")).toBeNull(); + setT("y"); + flush(); + expect(A.querySelector("div")).toBeNull(); + }); +}); diff --git a/packages/web/test/consistency/generic/replay.spec.tsx b/packages/web/test/consistency/generic/replay.spec.tsx new file mode 100644 index 000000000..f8a9a57ad --- /dev/null +++ b/packages/web/test/consistency/generic/replay.spec.tsx @@ -0,0 +1,228 @@ +/** + * @vitest-environment jsdom + * + * Replay pins of the GENERIC (frames-free) hydration harness — + * `documentation/server-components/frames-consistency-contract.md` + * §"Generic hydration — classification and pins (2026-10-06)". Each red is + * a `test.fails` over the harness's own laws (so the pin is red for the + * right reason), with the observed / expected frame in a comment, the + * other fragment order where the invariant claims independence, and a + * passing control. The page is plain Solid 2 — `hydrate()` over a + * `renderToStream` document with two streamed `` boundaries; no + * frames, slots or records anywhere (test/harness/generic-hydration.tsx). + * + * Schedules read as in `describeScenario`: H hydrate, Cn the stream's n-th + * chunk, W a client write to the module-level signal, P a push to the + * module-level store list, Ea/Eb a click on a boundary's button, t a settle + * point (20ms), m a microtask, X dispose. + */ +import { describe, expect, test } from "vitest"; +import { runScenario, type Finding } from "./run.js"; +import type { Event, Scenario } from "./scenario.js"; +import { loadArtifact } from "./support.js"; + +function schedule(order: "ab" | "ba", spec: string): Scenario { + const chunks = loadArtifact(order).chunks.length; + const events: Event[] = spec.split(/\s+/).map(tok => { + if (tok === "H") return { t: "hydrate" }; + if (tok === "W") return { t: "write" }; + if (tok === "P") return { t: "push" }; + if (tok === "t") return { t: "tick" }; + if (tok === "m") return { t: "micro" }; + if (tok === "X") return { t: "dispose" }; + if (tok === "Ea") return { t: "click", side: "a" }; + if (tok === "Eb") return { t: "click", side: "b" }; + if (/^C\d$/.test(tok)) return { t: "chunk", i: Number(tok.slice(1)) }; + throw new Error(`unknown token ${tok}`); + }); + return { order, chunks, events }; +} + +const only = (findings: Finding[], id: string, law?: string) => + findings + .filter(f => f.id === id && (!law || f.law === law)) + .map(f => `${f.id} ${f.law} @${f.step}: ${f.detail}`); + +describe("GH1 — a derived source with no snapshot is read live by the resume's claim pass (C19 / #3504)", () => { + // Observed: after the write, the shell shows `label:/b`; each boundary's + // resume claims the server text `label:/a` while the memo it read says + // `label:/b`, and nothing re-runs the hole until the memo changes again. + // Expected (write-before-resume's contract): the boundary resumes against + // the server snapshot, then catches up — `label:/b` once settled. + test.fails( + "C19 claim-shows-memo: module-level memo, write before both reveals — [ab] :: H W t C0 C1 C2 t", + async () => { + const r = await runScenario(schedule("ab", "H W t C0 C1 C2 t")); + expect(only(r.findings, "C19", "claim-shows-memo")).toEqual([]); + } + ); + test.fails( + "C19 claim-shows-memo: the other fragment order — [ba] :: H W t C0 C1 C2 t", + async () => { + const r = await runScenario(schedule("ba", "H W t C0 C1 C2 t")); + expect(only(r.findings, "C19", "claim-shows-memo")).toEqual([]); + } + ); + test.fails( + "C19 claim-shows-memo: write between the two reveals — the later boundary is stale — [ab] :: H C0 C1 t W t C2 t", + async () => { + const r = await runScenario(schedule("ab", "H C0 C1 t W t C2 t")); + expect( + only(r.findings, "C19", "claim-shows-memo").filter(m => m.includes("b.label")) + ).toEqual([]); + } + ); + test("control: the boundary that resumed BEFORE the write is live and catches up — [ab] :: H C0 C1 t W t C2 t", async () => { + const r = await runScenario(schedule("ab", "H C0 C1 t W t C2 t")); + expect(only(r.findings, "C19", "claim-shows-memo").filter(m => m.includes("a.label"))).toEqual( + [] + ); + }); + // The plain signal beside it (`.raw`) is snapshot-captured on its first + // write and catches up — the control that shows the rule exists. + test("control: the plain signal read beside the memo resumes on the snapshot and catches up", async () => { + const r = await runScenario(schedule("ab", "H W t C0 C1 C2 t")); + expect(only(r.findings, "C19", "claim-shows-signal")).toEqual([]); + }); + test("control: no write — every hole shows its source", async () => { + const r = await runScenario(schedule("ab", "H C0 C1 C2 t")); + expect(only(r.findings, "C19")).toEqual([]); + }); +}); + +describe("GH2 — a shell async memo adopted pending, re-run by a write before a later boundary resumes (C19)", () => { + // `shared` is pending when the shell flushes (read only under the + // boundaries): the client adopts it with no creation-time snapshot. It + // lands `shared:/a` with the first fragment; a write re-runs it to + // `shared:/b` (the shell and the resumed boundary update); the second + // boundary then resumes reading `shared:/b` and claims the server text + // `shared:/a` — stale until the memo changes again. + test.fails("C19 claim-shows-async-memo: [ab] :: H C0 C1 t W t C2 t", async () => { + const r = await runScenario(schedule("ab", "H C0 C1 t W t C2 t")); + expect(only(r.findings, "C19", "claim-shows-async-memo")).toEqual([]); + }); + test.fails( + "C19 claim-shows-async-memo: the other fragment order — [ba] :: H C0 C1 t W t C2 t", + async () => { + const r = await runScenario(schedule("ba", "H C0 C1 t W t C2 t")); + expect(only(r.findings, "C19", "claim-shows-async-memo")).toEqual([]); + } + ); + test("control: write after both resumed — both boundaries catch up", async () => { + const r = await runScenario(schedule("ab", "H C0 C1 C2 t W t")); + expect(only(r.findings, "C19", "claim-shows-async-memo")).toEqual([]); + }); +}); + +describe("GH3 — a store write to a leaf no reader has materialized is not snapshotted (C19 / C1)", () => { + // The push mutates the raw array (no `length` leaf exists yet; nothing in + // the shell read it). At the resume `` creates the leaf with the + // post-write length, renders three rows against two server rows: a + // hydration key miss for the third (a detached `
  • `, the warning blames + // id namespaces) and a list one row short until the next structural + // change. With the leaf materialized before the write (a shell reader of + // `items.length`) the resume claims two rows and catches up to three. + test.fails( + "C19 claim-shows-store-list: push before the reveals — [ab] :: H P C0 C1 C2 t", + async () => { + const r = await runScenario(schedule("ab", "H P C0 C1 C2 t")); + expect(only(r.findings, "C19", "claim-shows-store-list")).toEqual([]); + } + ); + test.fails("C1 no-key-miss: the third row misses its key — [ab] :: H P C0 C1 C2 t", async () => { + const r = await runScenario(schedule("ab", "H P C0 C1 C2 t")); + expect(only(r.findings, "C1", "no-key-miss").filter(m => m.includes("
  • "))).toEqual([]); + }); + test.fails( + "C19 claim-shows-store-list: the other fragment order — [ba] :: H P C0 C1 C2 t", + async () => { + const r = await runScenario(schedule("ba", "H P C0 C1 C2 t")); + expect(only(r.findings, "C19", "claim-shows-store-list")).toEqual([]); + } + ); + test("control: push after both resumed — both lists update, no key miss", async () => { + const r = await runScenario(schedule("ab", "H C0 C1 C2 t P t")); + expect(only(r.findings, "C19", "claim-shows-store-list")).toEqual([]); + expect(only(r.findings, "C1")).toEqual([]); + }); +}); + +describe("GH4 — a boundary resuming while a shell async source is in flight shows its fallback over the settled content (C9 / C12 / events)", () => { + // The write re-runs `shared` (the server's pending answer is superseded; + // the client flight takes 15ms). The boundary's resume renders its + // content, reads `shared` pending, and — never having revealed on the + // client — falls back: the fallback is claimed in the window (a key miss + // for `

    `, a phantom), then the snapshot release re-runs + // the insert outside the window and COMMITS it: the server `

    ` is + // detached and a fresh client fallback stands in its place until `shared` + // lands, when the same server nodes are re-attached. Expected: the + // settled server content is the boundary's revealed value; a pending + // read holds it (async-holds-latest), no fallback, no detach. + test.fails("C9 no-fallback-over-settled: [ab] :: H W C0 C1 m t", async () => { + const r = await runScenario(schedule("ab", "H W C0 C1 m t")); + expect(only(r.findings, "C9")).toEqual([]); + }); + test.fails( + "C9 no-fallback-over-settled: the other fragment order — [ba] :: H W C0 C1 m t", + async () => { + const r = await runScenario(schedule("ba", "H W C0 C1 m t")); + expect(only(r.findings, "C9")).toEqual([]); + } + ); + test.fails( + "C1 no-key-miss: the fallback is claimed in the resume window — [ab] :: H W C0 C1 m t", + async () => { + const r = await runScenario(schedule("ab", "H W C0 C1 m t")); + expect(only(r.findings, "C1", "no-key-miss").filter(m => m.includes("fb a"))).toEqual([]); + } + ); + // A click queued on the server section while it is detached replays into + // nothing (the walk from the detached button never reaches the delegated + // container) and is consumed. + test.fails( + "E queued-click-replays-once: a click queued at the reveal is lost — [ab] :: H W m C0 C1 C2 Eb", + async () => { + const r = await runScenario(schedule("ab", "H W m C0 C1 C2 Eb")); + expect(only(r.findings, "E")).toEqual([]); + } + ); + test("control: the server nodes come back once the flight lands (node identity, parity at the settle point)", async () => { + const r = await runScenario(schedule("ab", "H W C0 C1 m t")); + expect(only(r.findings, "C1", "node-identity")).toEqual([]); + expect(only(r.findings, "C12")).toEqual([]); + }); + test("control: no write — the resume holds the content, the click replays", async () => { + const r = await runScenario(schedule("ab", "H m C0 C1 C2 Eb t")); + expect(only(r.findings, "C9")).toEqual([]); + expect(only(r.findings, "E")).toEqual([]); + expect(only(r.findings, "C1")).toEqual([]); + }); +}); + +describe("generic holds — what the harness could not break (1000 cases, two seeds)", () => { + test("C14: dispose while both boundaries are pending — the late chunks touch nothing, nothing runs", async () => { + const r = await runScenario(schedule("ab", "H X C0 C1 C2 t")); + expect(only(r.findings, "C14")).toEqual([]); + expect(only(r.findings, "G")).toEqual([]); + }); + test("C14: dispose between the reveals", async () => { + const r = await runScenario(schedule("ba", "H C0 C1 X C2 t")); + expect(only(r.findings, "C14")).toEqual([]); + expect(only(r.findings, "G")).toEqual([]); + }); + test("C3: hydration-done waits for both streamed boundaries, in either order", async () => { + for (const order of ["ab", "ba"] as const) { + const r = await runScenario(schedule(order, "H C0 t C1 t C2 t")); + expect(only(r.findings, "C3")).toEqual([]); + } + }); + test("C12 / C10: a fully loaded page (every chunk before hydrate) claims both fragments in place", async () => { + const r = await runScenario(schedule("ab", "C0 C1 C2 H t")); + expect(only(r.findings, "C12")).toEqual([]); + expect(only(r.findings, "C1")).toEqual([]); + }); + test("E: a click queued before hydrate on a settled fragment replays once at the claim", async () => { + const r = await runScenario(schedule("ab", "C0 C1 C2 Ea Eb H t")); + expect(only(r.findings, "E")).toEqual([]); + }); +}); diff --git a/packages/web/test/consistency/generic/run.tsx b/packages/web/test/consistency/generic/run.tsx new file mode 100644 index 000000000..9c00b9e04 --- /dev/null +++ b/packages/web/test/consistency/generic/run.tsx @@ -0,0 +1,375 @@ +/** + * @jsxImportSource @solidjs/web + * + * The runner and oracle of the generic (frames-free) hydration harness. + * One scenario = one page (test/harness/generic-hydration.tsx, as the + * server rendered it) driven through `hydrate()` and the real stream chunks + * under the scenario's schedule, with the contract's plain-hydration laws + * checked after every event: + * + * immediate — G no-runtime-error; C1 no-key-miss / no-unclaimed / + * node-identity / no-duplicate; C14 dispose-no-invoke / + * dispose-no-dom; C3 in-progress-until-done + * settled — C12 fragment-parity; C19 claim-shows-value (shell and each + * resumed boundary show the value their sources read) + * end — C3 done-counts-holds; C2 every-range-live / + * every-range-reactive; E queued-click-replays-once + * + * `_hydrationDone` is a worker-level latch: every case after the first in + * a worker runs in the post-done regime (a reveal before `hydrate()` is + * held by the ledger and replayed at the boundary's registration), the + * first case pre-done (the reveal swaps at once). Both are legal pages. + */ +import { flush, untrack } from "solid-js"; +import { hydrate } from "@solidjs/web"; +import { createGenericApp, type GenericApp } from "../../harness/generic-hydration.jsx"; +import { describeScenario, type Event, type Scenario, type Side } from "./scenario.js"; +import { + applyChunk, + bootHy, + hydrationInProgress, + installBootstrapCapture, + loadArtifact, + macrotask, + microtask, + onHydrationEnd, + quiesce, + revealedKey, + spyConsole, + textOf +} from "./support.js"; + +export interface Finding { + id: string; + law: string; + step: number; + detail: string; +} + +export interface RunResult { + scenario: Scenario; + description: string; + findings: Finding[]; +} + +const SIDES: Side[] = ["a", "b"]; + +interface World { + scenario: Scenario; + container: HTMLDivElement; + app: GenericApp; + step: number; + hydratedAt: number; + disposedAt: number; + /** Side → step of the chunk that revealed its fragment. */ + revealedAt: Map; + /** Side → the server-rendered `
    ` (captured at its reveal). */ + serverNode: Map; + /** Side → clicks the schedule dispatched on its button. */ + dispatched: Map; + clicksAtDispose: number; + invocationsAtDispose: number; + textAtDispose: string | null; + warningsSeen: number; + errorsSeen: number; + hydrationEnd?: { step: number; invocations: string[]; disposed: boolean }; + findings: Finding[]; +} + +function sel(w: World, s: string) { + return w.container.querySelector(s); +} +function count(w: World, s: string) { + return w.container.querySelectorAll(s).length; +} +function find(w: World, id: string, law: string, detail: string) { + w.findings.push({ id, law, step: w.step, detail }); +} + +/** Read an accessor untracked; `undefined` when it throws (a pending async read). */ +function peek(fn: (() => T) | undefined): T | undefined { + if (!fn) return undefined; + try { + return untrack(fn); + } catch { + return undefined; + } +} + +function immediateLaws(w: World, warnings: string[], errors: string[]) { + for (; w.errorsSeen < errors.length; w.errorsSeen++) + find(w, "G", "no-runtime-error", errors[w.errorsSeen].slice(0, 160)); + for (; w.warningsSeen < warnings.length; w.warningsSeen++) { + const msg = warnings[w.warningsSeen]; + if (msg.includes("Hydration key miss")) find(w, "C1", "no-key-miss", msg.slice(0, 160)); + else if (msg.includes("unclaimed server-rendered")) + find(w, "C1", "no-unclaimed", msg.slice(0, 160)); + else find(w, "G", "no-runtime-error", "warn: " + msg.slice(0, 160)); + } + for (const side of SIDES) { + const live = sel(w, `section.${side}`); + const server = w.serverNode.get(side); + if (live && server && live !== server) + find(w, "C1", "node-identity", `section.${side} is a fresh node, not the server's`); + if (count(w, `section.${side}`) > 1) find(w, "C1", "no-duplicate", `two section.${side}`); + if (count(w, `p.fb.${side}`) > 1) find(w, "C1", "no-duplicate", `two fallbacks for ${side}`); + // The reveal removed the server fallback with the placeholder range; a + // fallback showing after it is fresh client DOM over settled markup. + const fb = sel(w, `p.fb.${side}`); + if (w.revealedAt.has(side) && w.disposedAt < 0 && fb && !fb.hasAttribute("_hk")) + find( + w, + "C9", + "no-fallback-over-settled", + `${side}: client fallback replaced the settled content` + ); + } + if (w.disposedAt >= 0 && w.step > w.disposedAt) { + if (w.app.invocations.length !== w.invocationsAtDispose) + find(w, "C14", "dispose-no-invoke", `Side ran after dispose`); + const text = w.container.textContent; + if (text !== w.textAtDispose) + find(w, "C14", "dispose-no-dom", `page text changed after dispose: ${text}`); + } + if (w.hydratedAt >= 0 && w.disposedAt < 0 && !w.hydrationEnd && !hydrationInProgress()) + find(w, "C3", "in-progress-until-done", "isHydrationInProgress() false before hydration end"); +} + +function settledLaws(w: World) { + if (w.hydratedAt < 0 || w.disposedAt >= 0) return; + const { app } = w; + const path = peek(app.path)!; + const label = peek(app.label)!; + const shared = peek(app.shared()); + const items = [...app.store.items]; + if (textOf(sel(w, "h1")) !== label) + find(w, "C19", "claim-shows-memo", `shell h1 ${textOf(sel(w, "h1"))} ≠ ${label}`); + if (textOf(sel(w, "h2")) !== path) + find(w, "C19", "claim-shows-signal", `shell h2 ${textOf(sel(w, "h2"))} ≠ ${path}`); + for (const side of SIDES) { + const revealed = w.revealedAt.has(side); + const section = sel(w, `section.${side}`); + const fallback = sel(w, `p.fb.${side}`); + if (revealed) { + if (!section) find(w, "C12", "fragment-parity", `${side} revealed but no content`); + if (fallback) find(w, "C12", "fragment-parity", `${side} revealed but fallback shows`); + } else { + if (section) find(w, "C12", "fragment-parity", `${side} not revealed but content shows`); + if (!fallback) find(w, "C12", "fragment-parity", `${side} not revealed but no fallback`); + } + if (!section) continue; + const raw = textOf(section.querySelector(".raw")); + const lab = textOf(section.querySelector(".label")); + const sh = textOf(section.querySelector(".shared")); + const lis = [...section.querySelectorAll("li")].map(l => l.textContent); + if (raw !== path) find(w, "C19", "claim-shows-signal", `${side}.raw ${raw} ≠ signal ${path}`); + if (lab !== label) find(w, "C19", "claim-shows-memo", `${side}.label ${lab} ≠ memo ${label}`); + if (typeof shared === "string" && sh !== shared) + find(w, "C19", "claim-shows-async-memo", `${side}.shared ${sh} ≠ async memo ${shared}`); + if (lis.join(",") !== items.join(",")) + find(w, "C19", "claim-shows-store-list", `${side} list [${lis}] ≠ store [${items}]`); + } +} + +async function endLaws(w: World) { + const { app } = w; + const end = w.hydrationEnd; + if (w.hydratedAt >= 0 && !end && w.disposedAt < 0) + find(w, "C3", "done-counts-holds", "hydration never reported done"); + if (end && !end.disposed) { + for (const side of SIDES) + if (!end.invocations.includes(side)) + find(w, "C3", "done-counts-holds", `done @${end.step} before ${side} claimed`); + } + for (const side of SIDES) { + const got = app.clicks.filter(c => c === side).length; + const sent = w.dispatched.get(side) ?? 0; + if (w.disposedAt < 0) { + if (got !== sent) + find(w, "E", "queued-click-replays-once", `${side}: ${sent} clicks, ${got} handled`); + } else if (got > sent) + find(w, "E", "queued-click-replays-once", `${side}: handled more than sent`); + } + if (w.hydratedAt < 0 || w.disposedAt >= 0) return; + for (const side of SIDES) { + const n = app.invocations.filter(i => i === side).length; + if (w.revealedAt.has(side) && n !== 1) + find(w, "C2", "every-range-live", `${side} invoked ${n} times`); + } + // reactivity probe — the write re-runs the shell's async `shared` memo, + // which holds the write (and every reader of `path`) until it lands + // (15ms): wait it out before reading. + app.setPath("/z"); + flush(); + await new Promise(r => setTimeout(r, 30)); + flush(); + if (textOf(sel(w, "h2")) !== "/z") + find(w, "C2", "every-range-reactive", "shell h2 did not react"); + for (const side of SIDES) { + const section = sel(w, `section.${side}`); + if (!section) continue; + if (textOf(section.querySelector(".raw")) !== "/z") + find(w, "C2", "every-range-reactive", `${side}.raw did not react`); + if (textOf(section.querySelector(".label")) !== "label:/z") + find(w, "C2", "every-range-reactive", `${side}.label did not react`); + } +} + +export async function runScenario(scenario: Scenario): Promise { + const art = loadArtifact(scenario.order); + const hy = bootHy(); + const spies = spyConsole(); + const container = document.createElement("div"); + document.body.appendChild(container); + const uncapture = installBootstrapCapture(container); + const app = createGenericApp(scenario.order); + const w: World = { + scenario, + container, + app, + step: -1, + hydratedAt: -1, + disposedAt: -1, + revealedAt: new Map(), + serverNode: new Map(), + dispatched: new Map(), + clicksAtDispose: 0, + invocationsAtDispose: 0, + textAtDispose: null, + warningsSeen: 0, + errorsSeen: 0, + findings: [] + }; + let dispose: (() => void) | undefined; + // GENERIC_DEBUG=1: the page (templates elided) and the sources after every step. + const dump = (label: string) => { + if (!process.env.GENERIC_DEBUG) return; + const html = container.innerHTML.replace(//g, ""); + console.log( + `${label}: ${html.slice(0, 700)}\n path=${peek(app.path)} label=${peek(app.label)} shared=${peek(app.shared())} items=${app.store.items} clicks=${app.clicks} inv=${app.invocations} queued=${hy.events?.length} inProgress=${hydrationInProgress()} end=${JSON.stringify(w.hydrationEnd)}` + ); + }; + applyChunk(container, art.shell, true); + // Which fragment key belongs to which boundary: the `pl-K` template that + // precedes each fallback in the shell. + const keyToSide = new Map(); + for (const side of SIDES) { + const fb = container.querySelector(`p.fb.${side}`); + const pl = fb?.previousElementSibling as HTMLTemplateElement | null; + if (pl && pl.id.startsWith("pl-")) keyToSide.set(pl.id.slice(3), side); + } + + const run = async (e: Event) => { + switch (e.t) { + case "hydrate": + w.hydratedAt = w.step; + dispose = hydrate(() => , container); + onHydrationEnd(() => { + w.hydrationEnd = { + step: w.step, + invocations: [...app.invocations], + disposed: w.disposedAt >= 0 + }; + }); + break; + case "chunk": { + const chunk = art.chunks[e.i]; + applyChunk(container, chunk); + const key = revealedKey(chunk); + const side = key && keyToSide.get(key); + if (side) { + // the section is in the live tree (swapped) or still in its + // template (held by the ledger until a claimant registers) + const tpl = container.querySelector( + `template[id="${key}"]` + ) as HTMLTemplateElement | null; + const section = + container.querySelector(`section.${side}`) ?? + tpl?.content.querySelector("section") ?? + null; + w.revealedAt.set(side, w.step); + if (section) w.serverNode.set(side, section); + else + find( + w, + "G", + "no-runtime-error", + `chunk ${e.i} revealed ${key} but no section for ${side}` + ); + } + break; + } + case "write": + app.setPath("/b"); + break; + case "push": + app.pushItem("i2"); + break; + case "click": { + const btn = container.querySelector(`section.${e.side} button`); + if (btn) { + btn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + w.dispatched.set(e.side, (w.dispatched.get(e.side) ?? 0) + 1); + } + break; + } + case "tick": + // a settle point: long enough for a client async re-run (`shared`, + // 15ms) to land, so the laws read a quiescent page + flush(); + await new Promise(r => setTimeout(r, 20)); + flush(); + break; + case "micro": + await microtask(); + break; + case "dispose": + // before the call: a disposal that completes hydration drains the + // end callbacks synchronously, and the snapshot must read "disposed" + w.disposedAt = w.step; + w.clicksAtDispose = app.clicks.length; + w.invocationsAtDispose = app.invocations.length; + dispose?.(); + flush(); + w.textAtDispose = container.textContent; + break; + } + }; + + try { + for (const e of scenario.events) { + w.step++; + try { + await run(e); + } catch (err: any) { + find(w, "G", "no-runtime-error", `threw at ${e.t}: ${err && err.message}`); + } + flush(); + dump(`step ${w.step} ${e.t}${"i" in e ? e.i : ""}${"side" in e ? e.side : ""}`); + immediateLaws(w, spies.warnings, spies.errors); + if (e.t === "tick") settledLaws(w); + } + // quiesce: let every landed answer, resume and the drain's own timeout run + w.step++; + await quiesce(); + // the boundaries' own async memos (data:a / data:b) are adopted settled; + // `shared`'s re-run after a write takes 15ms — wait it out + await new Promise(r => setTimeout(r, 25)); + flush(); + dump("end"); + immediateLaws(w, spies.warnings, spies.errors); + settledLaws(w); + await endLaws(w); + flush(); + immediateLaws(w, spies.warnings, spies.errors); + } finally { + dispose?.(); + uncapture(); + await macrotask(); + await macrotask(); + spies.restore(); + container.remove(); + delete (globalThis as any)._$HY; + } + return { scenario, description: describeScenario(scenario), findings: w.findings }; +} diff --git a/packages/web/test/consistency/generic/scenario.ts b/packages/web/test/consistency/generic/scenario.ts new file mode 100644 index 000000000..ca857e7ff --- /dev/null +++ b/packages/web/test/consistency/generic/scenario.ts @@ -0,0 +1,136 @@ +/** + * The scenario language of the GENERIC (frames-free) hydration harness and + * its fast-check arbitrary — `documentation/server-components/ + * frames-consistency-contract.md` §"Generic hydration". + * + * The page is fixed (test/harness/generic-hydration.tsx: a shell and two + * sibling streamed `` boundaries reading module-level state); the + * server rendered it in both fragment orders (`__artifacts__/ + * generic-hydration-.json`). A scenario picks the order and an EVENT + * SCHEDULE: when the client hydrates relative to the stream's chunks, and + * where client writes (a signal write, a store push), pre-hydration clicks, + * a disposal, and settle points fall between them. The chunks keep their + * wire order (each references resolvers the previous one defined); every + * other event is free. Shrinking moves the schedule toward the canonical + * order, so a reduced counterexample reads as "these events, in this + * order". + */ +import fc from "fast-check"; +import type { Order } from "../../harness/generic-hydration.jsx"; + +export type Side = "a" | "b"; + +export type Event = + | { t: "hydrate" } + /** The stream's `i`-th chunk parses (markup appended, its scripts run). */ + | { t: "chunk"; i: number } + /** A client write to the module-level signal (`setPath("/b")`). */ + | { t: "write" } + /** A client push to the module-level store list. */ + | { t: "push" } + /** A user click on a boundary's button — queued by the bootstrap if its range is not claimed yet. */ + | { t: "click"; side: Side } + /** A settle point: flush, 20ms (an in-flight client async re-run lands), flush. */ + | { t: "tick" } + | { t: "micro" } + | { t: "dispose" }; + +export interface Scenario { + order: Order; + /** How many chunks the artifact for `order` has (fixed per artifact). */ + chunks: number; + events: Event[]; +} + +/** Required events in canonical order: hydrate, the chunks in wire order, then the optional ones. */ +export function requiredEvents( + chunks: number, + opts: { + write: boolean; + push: boolean; + clicks: Side[]; + dispose: boolean; + ticks: number; + micros: number; + } +): Event[] { + const events: Event[] = [{ t: "hydrate" }]; + for (let i = 0; i < chunks; i++) events.push({ t: "chunk", i }); + if (opts.write) events.push({ t: "write" }); + if (opts.push) events.push({ t: "push" }); + for (const side of opts.clicks) events.push({ t: "click", side }); + for (let i = 0; i < opts.ticks; i++) events.push({ t: "tick" }); + for (let i = 0; i < opts.micros; i++) events.push({ t: "micro" }); + if (opts.dispose) events.push({ t: "dispose" }); + return events; +} + +/** + * Restore the constraints a permutation may have broken: chunks stay in + * wire order (positions kept, indices re-assigned ascending); `dispose`, + * `write` and `push` follow `hydrate` (a client write BEFORE `hydrate()` + * runs is an app-level mismatch — the server markup was never rendered + * from that state — and outside the contract; a write DURING hydration is + * what the snapshot rules, #3504, are about). + */ +export function normalize(events: Event[]): Event[] { + const out = events.map(e => ({ ...e })); + let next = 0; + for (const e of out) if (e.t === "chunk") e.i = next++; + for (const t of ["dispose", "write", "push"] as const) { + const h = out.findIndex(e => e.t === "hydrate"); + const d = out.findIndex(e => e.t === t); + if (d >= 0 && d < h) { + const [moved] = out.splice(d, 1); + out.splice(h, 0, moved); + } + } + return out; +} + +export function scenarioArb(chunksFor: (order: Order) => number): fc.Arbitrary { + return fc + .record({ + order: fc.constantFrom("ab" as const, "ba" as const), + write: fc.boolean(), + push: fc.boolean(), + clicks: fc.subarray(["a", "b"] as Side[]), + dispose: fc.boolean(), + ticks: fc.nat({ max: 3 }), + micros: fc.nat({ max: 2 }) + }) + .chain(({ order, ...opts }) => { + const chunks = chunksFor(order); + const required = requiredEvents(chunks, opts); + return fc + .shuffledSubarray(required, { minLength: required.length, maxLength: required.length }) + .map(events => ({ order, chunks, events: normalize(events) })); + }); +} + +/** One line per scenario: `[ab] :: H C0 W C1 t P C2 Ea X`. */ +export function describeScenario(s: Scenario): string { + const ev = s.events + .map(e => { + switch (e.t) { + case "hydrate": + return "H"; + case "chunk": + return `C${e.i}`; + case "write": + return "W"; + case "push": + return "P"; + case "click": + return `E${e.side}`; + case "tick": + return "t"; + case "micro": + return "m"; + case "dispose": + return "X"; + } + }) + .join(" "); + return `[${s.order}] :: ${ev}`; +} diff --git a/packages/web/test/consistency/generic/support.ts b/packages/web/test/consistency/generic/support.ts new file mode 100644 index 000000000..aa7aeb47c --- /dev/null +++ b/packages/web/test/consistency/generic/support.ts @@ -0,0 +1,117 @@ +/** + * Shared support for the generic (frames-free) hydration consistency pins + * and harness: the server artifacts of test/harness/generic-hydration.tsx, + * the streaming-parse stand-in, and the page handle the pins and the + * runner drive. + */ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { vi } from "vitest"; +import { flush } from "solid-js"; +import { sharedConfig } from "solid-js/internal"; +import type { Order } from "../../harness/generic-hydration.jsx"; + +const here = dirname(fileURLToPath(import.meta.url)); + +export interface Artifact { + shell: string; + chunks: string[]; +} + +const artifacts = new Map(); +/** `__artifacts__/generic-hydration-.json`, rendered by test/server/generic-hydration.gen.spec.tsx. */ +export function loadArtifact(order: Order): Artifact { + let a = artifacts.get(order); + if (!a) { + a = JSON.parse( + readFileSync( + resolve(here, `../../harness/__artifacts__/generic-hydration-${order}.json`), + "utf-8" + ) + ) as Artifact; + artifacts.set(order, a); + } + return a; +} + +/** + * A streamed chunk parsing: markup appended, then its inline scripts run — + * what a streaming browser parse does, one chunk at a time. + */ +export function applyChunk(container: HTMLElement, chunk: string, first = false) { + const scriptRe = /]*)>([\s\S]*?)<\/script>/g; + const scripts = [...chunk.matchAll(scriptRe)].map(m => m[1]); + const stripped = chunk.replace(scriptRe, ""); + if (first) container.innerHTML = stripped; + else container.insertAdjacentHTML("beforeend", stripped); + for (const s of scripts) (0, eval)(s); +} + +/** The fragment key a chunk reveals (`$df("K")`), if any. */ +export function revealedKey(chunk: string): string | undefined { + return /\$df\("([^"]+)"\)/.exec(chunk)?.[1]; +} + +export const macrotask = () => new Promise(r => setTimeout(r)); +export const microtask = () => Promise.resolve(); +/** Flush, a macrotask, flush — repeated: the drain's own setTimeout needs a round. */ +export async function quiesce(rounds = 3) { + for (let i = 0; i < rounds; i++) { + flush(); + await macrotask(); + } + flush(); +} + +/** Fresh `_$HY` the way the server bootstrap leaves it, plus console spies. */ +export function bootHy(extra: Record = {}) { + const hy: any = { events: [], completed: new WeakSet(), r: {}, fe() {}, ...extra }; + (globalThis as any)._$HY = hy; + return hy; +} + +export function spyConsole() { + const warnings: string[] = []; + const errors: string[] = []; + const warn = vi.spyOn(console, "warn").mockImplementation((...args: unknown[]) => { + warnings.push(args.map(String).join(" ")); + }); + const error = vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => { + errors.push(args.map(String).join(" ")); + }); + return { + warnings, + errors, + restore() { + warn.mockRestore(); + error.mockRestore(); + } + }; +} + +/** + * The bootstrap's event capture, in shape (web/src/server.ts + * `generateHydrationScript`): a configured event on a server-rendered + * element is queued at its nearest `_hk` ancestor unless that ancestor is + * already claimed — and nothing is captured once `_$HY.events` is gone. + */ +export function installBootstrapCapture(root: Element, types = ["click"]) { + const handler = (e: Event) => { + const hy = (globalThis as any)._$HY; + if (!hy || !hy.events) return; + let n: Node | null = e.target as Node; + while (n && !(n.nodeType === 1 && (n as Element).hasAttribute("_hk"))) n = n.parentNode; + if (n && !hy.completed.has(n)) hy.events.push([n, e]); + }; + for (const t of types) root.addEventListener(t, handler, true); + return () => { + for (const t of types) root.removeEventListener(t, handler, true); + }; +} + +export const hydrationInProgress = () => + !!(sharedConfig as any).isHydrationInProgress && (sharedConfig as any).isHydrationInProgress(); +export const onHydrationEnd = (cb: () => void) => (sharedConfig as any).onHydrationEnd(cb); + +export const textOf = (n: Element | null | undefined) => (n ? (n.textContent ?? "") : null); diff --git a/packages/web/test/harness/__artifacts__/generic-hydration-ab.json b/packages/web/test/harness/__artifacts__/generic-hydration-ab.json new file mode 100644 index 000000000..fb54b5a50 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/generic-hydration-ab.json @@ -0,0 +1,8 @@ +{ + "shell": "

    label:/a

    /a

    a-loading

    b-loading

    ", + "chunks": [ + "", + "", + "" + ] +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/generic-hydration-ba.json b/packages/web/test/harness/__artifacts__/generic-hydration-ba.json new file mode 100644 index 000000000..cefb16176 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/generic-hydration-ba.json @@ -0,0 +1,8 @@ +{ + "shell": "

    label:/a

    /a

    a-loading

    b-loading

    ", + "chunks": [ + "", + "", + "" + ] +} \ No newline at end of file diff --git a/packages/web/test/harness/generic-hydration.tsx b/packages/web/test/harness/generic-hydration.tsx new file mode 100644 index 000000000..e2d75ad63 --- /dev/null +++ b/packages/web/test/harness/generic-hydration.tsx @@ -0,0 +1,118 @@ +/** + * @jsxImportSource @solidjs/web + * + * The page the generic (frames-free) hydration consistency pins and harness + * drive — `documentation/server-components/frames-consistency-contract.md` + * §"Generic hydration". Plain Solid 2: a shell and two sibling streamed + * `` boundaries, each reading + * + * - a plain signal (`path`) created OUTSIDE any hydration root — module-level + * state, the shape of a global store module (snapshot-capturable on its + * first write during hydration, #3504); + * - a memo derived from it (`label`) under its own root, created BEFORE + * `hydrate()` — so it is in no hydration root's snapshot scope and has no + * creation-time snapshot; + * - a module-level store list rendered by `` (structural writes); + * - a shell async memo (`shared`) that depends on `path` and is read ONLY + * inside the boundaries — pending when the shell flushes, so the client + * adopts it as a pending answer (no creation-time snapshot) and it lands + * with the first fragment; + * - an async memo of its own (what keeps each boundary pending on the + * server; `order` decides which fragment the server flushes first). + * + * The server half (test/server/generic-hydration.gen.spec.tsx) renders both + * orders and writes `__artifacts__/generic-hydration-.json` + * (`{ shell, chunks }`); the client half replays the chunks under a schedule + * it controls (write / dispose / tick between them). + */ +import { createMemo, createRoot, createSignal, createStore, Loading } from "solid-js"; +import { For } from "@solidjs/web"; + +const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); + +export type Order = "ab" | "ba"; + +export interface GenericApp { + App: () => any; + /** Client write to the module-level signal. */ + setPath: (p: string) => void; + path: () => string; + /** The module-level memo derived from `path`. */ + label: () => string; + store: { items: string[] }; + /** Append one item to the module-level store list. */ + pushItem: (item: string) => void; + /** Click handler log: the boundary name of each button clicked. */ + clicks: string[]; + /** `Side` component bodies run, by boundary name (one per mount). */ + invocations: string[]; + /** The shell's `shared` memo, once `App` has rendered (undefined before). */ + shared: () => (() => string) | undefined; +} + +export function createGenericApp(order: Order): GenericApp { + const [path, setPath] = createSignal("/a", { ownedWrite: true }); + const label = createRoot(() => createMemo(() => "label:" + path())); + const [store, setStore] = createStore<{ items: string[] }>({ items: ["i0", "i1"] }); + const clicks: string[] = []; + const invocations: string[] = []; + let shared: (() => string) | undefined; + + function Side(props: { name: string; delay: number; shared: () => string }) { + invocations.push(props.name); + const data = createMemo(async () => { + await sleep(props.delay); + return "data:" + props.name; + }); + return ( +
    + {data()} + {path()} + {label()} + {props.shared()} +
      + {item =>
    • {item}
    • }
      +
    + +
    + ); + } + + function App() { + // Pending when the shell flushes (read only under the boundaries), lands + // on the client with the first fragment's chunk. + const sharedMemo = createMemo(async () => { + const p = path(); + await sleep(15); + return "shared:" + p; + }); + shared = sharedMemo; + return ( +
    +

    {label()}

    +

    {path()}

    + a-loading

    }> + +
    + b-loading

    }> + +
    +
    + ); + } + + return { + App, + setPath, + path, + label, + store, + pushItem: item => + setStore(s => { + s.items.push(item); + }), + clicks, + invocations, + shared: () => shared + }; +} diff --git a/packages/web/test/server/generic-hydration.gen.spec.tsx b/packages/web/test/server/generic-hydration.gen.spec.tsx new file mode 100644 index 000000000..f94a8db8c --- /dev/null +++ b/packages/web/test/server/generic-hydration.gen.spec.tsx @@ -0,0 +1,62 @@ +/** + * @jsxImportSource @solidjs/web + * + * Server half of test/consistency/generic/** (the frames-free hydration + * consistency pins and harness): renders test/harness/generic-hydration.tsx + * in both fragment orders and writes the shell plus each later chunk. + */ +import { expect, test } from "vitest"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { renderToStream } from "@solidjs/web"; +import { createGenericApp, type Order } from "../harness/generic-hydration.jsx"; + +const artifactsDir = resolve(dirname(fileURLToPath(import.meta.url)), "../harness/__artifacts__"); +mkdirSync(artifactsDir, { recursive: true }); + +async function renderOrder(order: Order) { + const { App } = createGenericApp(order); + return new Promise<{ shell: string; chunks: string[] }>(done => { + const chunks: string[] = []; + let shell: string | undefined; + let shellDone = false; + renderToStream(() => , { + onCompleteShell() { + shellDone = true; + } + }).pipe({ + write(c: string) { + chunks.push(c); + if (shellDone && shell === undefined) shell = chunks.splice(0).join(""); + }, + end() { + if (shell === undefined) shell = chunks.splice(0).join(""); + done({ shell, chunks }); + } + }); + }); +} + +for (const order of ["ab", "ba"] as Order[]) { + test(`render generic-hydration chunks (${order})`, async () => { + const out = await renderOrder(order); + expect(out.shell).toContain("a-loading"); + expect(out.shell).toContain("b-loading"); + expect(out.shell).toContain("label:/a"); + // The fragments land in the order the server settled them (`shared` + // lands between — in its own chunk or batched with a neighbour). + const all = out.chunks.join(""); + const first = all.indexOf(order === "ab" ? "data:a" : "data:b"); + const second = all.indexOf(order === "ab" ? "data:b" : "data:a"); + expect(first).toBeGreaterThan(-1); + expect(second).toBeGreaterThan(first); + expect(all).toContain("shared:/a"); + expect(out.chunks.length).toBeGreaterThanOrEqual(2); + expect(out.chunks.length).toBeLessThanOrEqual(3); + writeFileSync( + resolve(artifactsDir, `generic-hydration-${order}.json`), + JSON.stringify(out, null, 2) + ); + }); +}