diff --git a/.changeset/nested-fill-context.md b/.changeset/nested-fill-context.md new file mode 100644 index 0000000000..69e175dc85 --- /dev/null +++ b/.changeset/nested-fill-context.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +Server components: a client fill nested in server content that another fill places (a region) now sees the context that enclosing fill provides, on the server render, at hydration (eager or deferred, including an inner occurrence mounting first) and for occurrences a stream or live hole adds later. On the server, a fill called while an enclosing occurrence's args evaluate renders when the region is placed, under the placing owner; the region's usage flip runs after the fill's output has resolved, so content placed inside a provider (whose children resolve in the walk) ships as markup instead of an occluded record. On the client, a nested fill mounts under the owner that read the region, waits while the region is unplaced, and mounts afresh at the next placement after one is disposed. A fill's two claim windows now share one claim owner, so a fill rooted at a context provider claims its server markup instead of rendering a detached copy. diff --git a/packages/web/frames/src/client.ts b/packages/web/frames/src/client.ts index bb1e324b4a..94d5b26b64 100644 --- a/packages/web/frames/src/client.ts +++ b/packages/web/frames/src/client.ts @@ -510,9 +510,13 @@ function claimScope(el: Element): ClaimScope & { index(root: ParentNode): void } * evaluated. It claims under the same prefix but gathers nothing: the * first window's gather is still in the registry, and gathering again * would put the keys the evaluation already claimed back as unclaimed. + * Both windows run under the fill's ONE claim `owner` (id `prefix`): the + * window claims that subtree only, and what the first evaluated lazily — a + * context provider's children — creates under it when the second reads it. */ function claimRender( prefix: string, + owner: any, existing: Node[], render: () => any, scope?: ClaimScope, @@ -521,14 +525,11 @@ function claimRender( const sc: any = sharedConfig; // No window, or no registry gathered yet (no `hydrate()` pass has run): // nothing to claim against — render fresh over the markup. - if (!sc.hydrateWindow || !sc.registry) return render(); + if (!sc.hydrateWindow || !sc.registry) return runWithOwner(owner, render); const prevRoots = sc.claimRoots; sc.claimRoots = existing; try { - // The claim owner too: the window claims this fill's subtree only. - return runWithOwner(createOwner({ id: prefix }), () => - sc.hydrateWindow(bound ? undefined : prefix, render, scope) - ); + return runWithOwner(owner, () => sc.hydrateWindow(bound ? undefined : prefix, render, scope)); } finally { sc.claimRoots = prevRoots; } @@ -670,6 +671,8 @@ function slotArgsProxy(args: () => Record) { reads.set(key, read); } const v = read(); + // A region read is its placement (the regions tier's `place`). + if (v instanceof Element) tierLoads.regions.r.place(v); return v instanceof Boxed ? v.c : v; }, has: (_, key) => key in args(), @@ -701,7 +704,7 @@ function slotsFor(props: Record, scope?: ClaimScope) { { get(_, prop) { if (typeof prop !== "string" || !(prop in props)) return undefined; - return (slotProps: any, ctx: any) => { + const mount = (slotProps: any, ctx: any) => { const key = ctx && ctx.key; // A re-call replaces the invocation wholesale (the frame only // runs slot cleanups at unmount, not between re-calls): the @@ -733,15 +736,20 @@ function slotsFor(props: Record, scope?: ClaimScope) { // pending fill's nodes are legitimately detached while its // covering boundary shows the fallback; the boundary, not a frame // cleanup, decides when that render is done with). Their range - // binding gets an owner of its own below. + // binding gets an owner of its own. + // + // The binding's owner: the fill's own (a stream-mounted fill), else + // one minted here — TRANSPARENT, so a fill mounting inside the + // hydrate pass consumes no id from the adopting component's counter + // (a keyed sibling after the frame keys the same whether a fill + // mounted at t=0 or after a hold; the claim window has its own id). const fillOwner = streamInvoke ? createOwner() : null; - if (fillOwner && key !== undefined && ctx) { - fillScopes.set(key, fillOwner); - ctx.onCleanup(() => { - if (fillScopes.get(key) === fillOwner) fillScopes.delete(key); - fillOwner.dispose(); - }); - } + const owner = fillOwner || createOwner({ transparent: true }); + fillScopes.set(key, owner); + ctx.onCleanup(() => { + if (fillScopes.get(key) === owner) fillScopes.delete(key); + owner.dispose(); + }); // The prop is read INSIDE the claim scope: compiled component props // are getters, so JSX evaluates lazily at access — deferring the // access into the scoped owner is what makes plain JSX (no thunks) @@ -794,12 +802,11 @@ function slotsFor(props: Record, scope?: ClaimScope) { // re-call with existing nodes must render for real — claiming would // no-op its inserts and silently drop whatever the re-call // displaced, e.g. moved-out {$frame} region ranges (#547). - const value = fillOwner - ? runWithOwner(fillOwner, () => - adopted ? claimRender(prefix, ctx.existing, evaluate, scope) : evaluate() - ) - : adopted - ? claimRender(prefix, ctx.existing, evaluate, scope) + const claim = adopted && runWithOwner(owner, () => createOwner({ id: prefix })); + const value = adopted + ? claimRender(prefix, claim, ctx.existing, evaluate, scope) + : fillOwner + ? runWithOwner(fillOwner, evaluate) : evaluate(); // Every fill is one `insert` before the range's end marker — the // primitive compiled JSX uses for `{expr}` positions — so the @@ -831,29 +838,19 @@ function slotsFor(props: Record, scope?: ClaimScope) { // inert: the boundary's own resume still claimed the swapped-in // server markup, so the region looked right, but nothing // downstream (a route change out of it) ever re-rendered it again. - // - // The binding's owner: the fill's own (a stream-mounted fill, - // already in `fillScopes` with its cleanup), else one minted here - // and registered the same way — TRANSPARENT, so a fill mounting - // inside the hydrate pass consumes no id from the adopting - // component's counter (a keyed sibling after the frame keys the - // same whether a fill mounted at t=0 or after a hold; the claim - // window below has its own id). - const owner = fillOwner || createOwner({ transparent: true }); - if (!fillOwner) { - fillScopes.set(key, owner); - ctx.onCleanup(() => { - if (fillScopes.get(key) === owner) fillScopes.delete(key); - owner.dispose(); - }); - } const end = range.end; const bind = () => insert(end.parentNode as any, value, end, [...ctx.existing]); - runWithOwner(owner, () => - adopted ? claimRender(prefix, ctx.existing, bind, scope, true) : bind() - ); + adopted + ? claimRender(prefix, claim, ctx.existing, bind, scope, true) + : runWithOwner(owner, bind); return undefined; }; + // A fill nested in a region mounts where the region is placed (the + // regions tier's `nest`; no region exists without the tier). + return (slotProps: any, ctx: any) => { + const R = tierLoads.regions?.r; + R ? R.nest(mount, slotProps, ctx) : mount(slotProps, ctx); + }; } } ); diff --git a/packages/web/frames/src/frame-sink.ts b/packages/web/frames/src/frame-sink.ts index 8b4ceffe10..5066ee6012 100644 --- a/packages/web/frames/src/frame-sink.ts +++ b/packages/web/frames/src/frame-sink.ts @@ -1358,6 +1358,53 @@ function suppressedFill(render) { } } // === Document SSR (t = 0) === +// Nested fills. A fill called while an enclosing occurrence's args evaluate +// sits in server content that enclosing fill places (a region), so it is +// that fill's child, as in any client tree: it renders where the region is +// placed, under the placing owner — the context the enclosing fill provides +// there — as the adopting client mounts it (`placements`, frames client). +// Until then it is PARKED: a token in the region's markup, swapped for its +// range at placement, or at the record of a region left occluded. +let parked: Map | null = null; +let parkedIds = 0; +const PARKED = //; +function park(render) { + if (!parked) return render(); + const id = ++parkedIds; + parked.set(id, { render, out: undefined }); + return { t: `` }; +} +const runParked = (entry, owner?) => entry.out || (entry.out = entry.render(owner)); +// Swap a region's tokens for their ranges (rendered under `owner`), then +// render any token the content dropped — its record is declared. +function placeParked(node, nested, owner?) { + const out = expandParked(node, nested, owner); + for (const entry of nested.values()) runParked(entry, owner); + return out; +} +function expandParked(node, nested, owner) { + if (Array.isArray(node)) { + const out: any = node.map(n => expandParked(n, nested, owner)); + out.$slot = node.$slot; + return out; + } + if (!node || node.t === undefined) return node; + if (!node.h) return splitParked(node.t, nested, owner) || node; + // Async holes stay between their segments, one hole per node. + const out: any[] = []; + node.t.forEach((t, i) => { + out.push(splitParked(t, nested, owner) || { t }); + if (i < node.h.length) out.push({ t: ["", ""], h: [node.h[i]], p: [node.p[i]] }); + }); + return out; +} +function splitParked(t, nested, owner) { + const parts = typeof t === "string" ? t.split(PARKED) : [t]; + return parts.length > 1 + ? parts.map((part, i) => (i & 1 ? runParked(nested.get(+part), owner) : { t: part })) + : undefined; +} + /** * Document-mode slot props — the t = 0 counterpart of * `createSlotProps`: the server component renders INLINE in the @@ -1385,10 +1432,11 @@ export function createDocumentSlotProps(clientProps, frameId) { // story: arg re-emissions update the adopted occurrence's props. One // known coarsening: a region (server JSX arg) placed by the fill resolves // inside this suppressed span, so its interior holes keep the t=0 latch. - const range = (occurrence, content) => { + const range = (occurrence, content, tail?) => { const r: any = [ { t: `` }, content, + tail, { t: `` } ]; r.$slot = true; @@ -1411,8 +1459,10 @@ export function createDocumentSlotProps(clientProps, frameId) { // see "no NoHydration zone" and pass through, leaking ambient-chain keys // onto the wrapper instead of its sc- occurrence namespace. const zoneOwner = getOwner ? getOwner() : null; - const scoped = (occurrence, render) => { + const scoped = (occurrence, render, owner?) => { const id = `sc-${frameId}-${occurrence}-`; + // A placed nested fill: inside the enclosing fill, outside any zone. + if (owner) return runWithOwner(owner, () => runWithHydrationScope(id, render)); const run = () => Hydration ? Hydration({ @@ -1443,11 +1493,17 @@ export function createDocumentSlotProps(clientProps, frameId) { // INSIDE scoped(): compiled component props are getters, so the // client's JSX evaluates lazily at access under the same keys — // plain JSX, no thunk convention. - return suppressedFill(() => - scoped(prop, () => { - const value = clientProps[prop]; - return range(prop, typeof value === "function" ? value() : value); - }) + return park(owner => + suppressedFill(() => + scoped( + prop, + () => { + const value = clientProps[prop]; + return range(prop, typeof value === "function" ? value() : value); + }, + owner + ) + ) ); } const raw = callArgs[0]; @@ -1470,12 +1526,16 @@ export function createDocumentSlotProps(clientProps, frameId) { // SYNC render (`renderToString`) has no later script to settle a // declaration in and no serializer for one: its record is the // value, written after the fill. + // A parked fill declares at its placement — the marker's time. const context = sharedConfig.context; const recordKey = `sc:slot:${frameId}:${occurrence}`; + const deferred = !!parked; let settleRecord; - if (context && context.async) { + const declare = () => + context && + context.async && context.serialize(recordKey, new Promise(resolve => (settleRecord = resolve))); - } + deferred || declare(); try { const resolved = {}; // Usage tracking (dispatch case 3, document face): regions ride as @@ -1517,6 +1577,8 @@ export function createDocumentSlotProps(clientProps, frameId) { // Keys whose evaluation minted reactive scopes (scopeStamp moved): // not re-runnable, so no watched binding opens for them below. const minted = {}; + // Per arg, the fills its evaluation parked (see `park`). + const nestedOf = {}; for (const key of Object.keys(raw)) { if (key === "$key") continue; const desc = Object.getOwnPropertyDescriptor(raw, key); @@ -1535,10 +1597,17 @@ export function createDocumentSlotProps(clientProps, frameId) { if (evaluate) { evals[key] = evaluate; const stampBefore = scopeStamp(); + const outer = parked; + const nested = (nestedOf[key] = parked = new Map()); try { value = evaluate(); states[key] = { settled: true, last: value }; } catch (err) { + // The attempt's markup is gone; its parked fills' records + // are declared all the same. + parked = outer; + for (const entry of nested.values()) runParked(entry); + nested.clear(); const blocked = ssrHandleError && ssrHandleError(err); if (!blocked) throw err; const state = (states[key] = { settled: false, last: undefined }); @@ -1551,6 +1620,7 @@ export function createDocumentSlotProps(clientProps, frameId) { if (liveArgs) liveArgs.commit(); }); } + parked = outer; if (scopeStamp() !== stampBefore) minted[key] = true; } vals[key] = value; @@ -1573,7 +1643,14 @@ export function createDocumentSlotProps(clientProps, frameId) { resolved[key] = value; } else if (isServerContent(value)) { const childId = `${frameId}.${occurrence}.${key}`; - const region = { key, childId, value, used: false, locked: false }; + const region = { + key, + childId, + value, + nested: nestedOf[key], + used: false, + locked: false + }; regions.push(region); // Used or occluded, the record names it `{$frame}`: the // `regions` tier, announced. @@ -1593,7 +1670,14 @@ export function createDocumentSlotProps(clientProps, frameId) { region.used = true; // A region is a frame ELEMENT the client wrapper adopts — // the same DOM contract as the boundary, one level down. - return [{ t: frameElementOpen(childId) }, value, { t: FRAME_ELEMENT_CLOSE }]; + // Its parked fills render here, under the placing owner. + return [ + { t: frameElementOpen(childId) }, + region.nested && region.nested.size + ? placeParked(value, region.nested, getOwner()) + : value, + { t: FRAME_ELEMENT_CLOSE } + ]; }; } else if (ssrAsyncValue && isAsyncValue(value)) { // DR-2 value tier, document face: the inline fill's read of an @@ -1630,45 +1714,20 @@ export function createDocumentSlotProps(clientProps, frameId) { resolved[key] = vals[key] = noteTraces(value, documentNeeds); } } - const out = suppressedFill(() => - scoped(occurrence, () => range(occurrence, slot(resolved))) - ); - const unused = regions.filter(r => !r.used); - if (context) { - // One record shape (A5, server-components-principles.md): the - // t=0 document emits the record a stream would — every invoked - // occurrence gets one, and EVERY region arg rides as its - // `{$frame}` address ref, used or not. The ref is addressing, - // not content: a used region's content ships once as page - // markup (the adopting client resolves the ref to the element - // already in the interior), an occluded one ships once as its - // `sc:region:` record. Primitive args always ship — a scalar - // the client needs AS DATA to re-invoke the wrapper is not a - // single-copy concern (value-from-page recovery is a - // template-mode question, never a substring guess: the old - // heuristic dropped correct args on any markup coincidence). - const args = {}; - for (const key of Object.keys(vals)) { - const value = vals[key]; - const region = regions.find(r => r.key === key); - if (region) { - args[key] = { $frame: region.childId }; - continue; - } - // Container check first for exactness: a store whose STATE has - // a `t` key would satisfy isServerContent's shape probe. - if (!isContainerTraced(value) && isServerContent(value)) continue; - // Containers (at any depth) ride the record as trace envelopes; - // everything else passes through by reference. - args[key] = argBorderForm(value, documentNeeds); - } - // A CLONE settles the declaration (or IS the record, on a sync - // render); `args` stays canonical for the ledger below — - // re-emissions mutate it and clone again, so the initial - // record can never change under a consumer. - settleRecord ? settleRecord({ ...args }) : context.serialize(recordKey, { ...args }); - for (const region of unused) { - // Lock BEFORE returning: the content is now committed to the data + // Parked fills of args that are not regions have no placement. + for (const key in nestedOf) + if (!regions.some(r => r.key === key)) + for (const entry of nestedOf[key].values()) runParked(entry); + // The usage flip: a region the fill did not place while its + // output resolved — a provider's children included, which + // resolve in the walk, not at the fill's return — is occluded. + // The range's tail runs it, once, after the content resolved. + const flip: any = () => { + if (flip.done || !context) return; + flip.done = true; + for (const region of regions) { + if (region.used) continue; + // Lock at once: the content is now committed to the data // channel, so any later async placement of this region must // suppress its markup (see the thunk above) — that is what makes // "serialize once at flush" a guarantee rather than a race. @@ -1681,35 +1740,90 @@ export function createDocumentSlotProps(clientProps, frameId) { // land, the resolveRootHoles shape). context.serialize( `sc:region:${region.childId}`, - resolveRegionHtml(context, region.value) + resolveRegionHtml( + context, + region.nested && region.nested.size + ? placeParked(region.value, region.nested) + : region.value + ) ); } - // The document arg ledger (DR-2 case 1 at t=0): every - // re-runnable arg that classified as DATA opens a watched - // binding AFTER its record emitted, mirroring the stream face — - // the same authored getter shape stays live on both faces. Only - // on an armed document (live.args): the hostless fallback - // latches, like everything else at t=0. - if (liveArgs) { - for (const key of Object.keys(evals)) { - if (regions.some(r => r.key === key)) continue; - if (minted[key]) continue; // scope-minting eval: latched - const ledgerKey = `${frameId}:${occurrence}:${key}`; - openArgBinding( - liveArgs, - ledgerKey, - occurrence, - key, - evals[key], - states[key], - value => { - args[key] = argBorderForm(value, documentNeeds); - liveArgs.slot(frameId, occurrence, { ...args }); + }; + flip.$lhSkip = true; + const finish = owner => { + if (deferred) declare(); + try { + const out = suppressedFill(() => + scoped(occurrence, () => range(occurrence, slot(resolved), flip), owner) + ); + if (context) { + // One record shape (A5, server-components-principles.md): the + // t=0 document emits the record a stream would — every invoked + // occurrence gets one, and EVERY region arg rides as its + // `{$frame}` address ref, used or not. The ref is addressing, + // not content: a used region's content ships once as page + // markup (the adopting client resolves the ref to the element + // already in the interior), an occluded one ships once as its + // `sc:region:` record. Primitive args always ship — a scalar + // the client needs AS DATA to re-invoke the wrapper is not a + // single-copy concern (value-from-page recovery is a + // template-mode question, never a substring guess: the old + // heuristic dropped correct args on any markup coincidence). + const args = {}; + for (const key of Object.keys(vals)) { + const value = vals[key]; + const region = regions.find(r => r.key === key); + if (region) { + args[key] = { $frame: region.childId }; + continue; } - ); + // Container check first for exactness: a store whose STATE has + // a `t` key would satisfy isServerContent's shape probe. + if (!isContainerTraced(value) && isServerContent(value)) continue; + // Containers (at any depth) ride the record as trace envelopes; + // everything else passes through by reference. + args[key] = argBorderForm(value, documentNeeds); + } + // A CLONE settles the declaration (or IS the record, on a sync + // render); `args` stays canonical for the ledger below — + // re-emissions mutate it and clone again, so the initial + // record can never change under a consumer. + settleRecord + ? settleRecord({ ...args }) + : context.serialize(recordKey, { ...args }); + // The document arg ledger (DR-2 case 1 at t=0): every + // re-runnable arg that classified as DATA opens a watched + // binding AFTER its record emitted, mirroring the stream face — + // the same authored getter shape stays live on both faces. Only + // on an armed document (live.args): the hostless fallback + // latches, like everything else at t=0. + if (liveArgs) { + for (const key of Object.keys(evals)) { + if (regions.some(r => r.key === key)) continue; + if (minted[key]) continue; // scope-minting eval: latched + const ledgerKey = `${frameId}:${occurrence}:${key}`; + openArgBinding( + liveArgs, + ledgerKey, + occurrence, + key, + evals[key], + states[key], + value => { + args[key] = argBorderForm(value, documentNeeds); + liveArgs.slot(frameId, occurrence, { ...args }); + } + ); + } + } } + return out; + } catch (error) { + settleRecord && settleRecord(undefined); + throw error; } - } + }; + const out = park(finish); // A keyed call registers for repeats (an un-keyed one is // positional: two placements are two ranges). if (rk !== undefined && rk === occurrence) repeats.has(rk) || repeats.set(rk, out); diff --git a/packages/web/frames/src/regions-tier.ts b/packages/web/frames/src/regions-tier.ts index 6d40f4fc62..4357d5d38b 100644 --- a/packages/web/frames/src/regions-tier.ts +++ b/packages/web/frames/src/regions-tier.ts @@ -61,10 +61,17 @@ // `createFrame` is how the tier binds a frame over a region element: the // runtime's own class, registered with the parent's host. import { createFrame } from "./client.js"; +import { getOwner, onCleanup, runWithOwner } from "solid-js"; // Pure DOM helpers, bundled into this chunk as their own copy (no module // state, no instance to keep in agreement — frame-client is importless by // design). -import { eachInRange, isFrameElement, makeFrameElement, FRAME_ID_ATTR } from "./frame-client.js"; +import { + eachInRange, + isFrameElement, + makeFrameElement, + FRAME_ID_ATTR, + FRAME_TAG +} from "./frame-client.js"; /** One region of an occurrence: its wire id, its element, the frame bound * over it once `bind` ran, and whether the element was adopted from the @@ -174,6 +181,7 @@ export function resolve( if (!entry) cache.set(key, (entry = { childId, element: makeFrameElement(childId), frame: undefined })); else if (entry.childId !== childId) rename(entry, childId); + placements.has(entry.element) || placements.set(entry.element, { o: null, m: new Map() }); props[key] = entry.element; } } @@ -217,6 +225,63 @@ export function bind(frame: any, occurrence: string): void { } } +// Placement — what makes a fill nested in a region the child of the fill +// that places it, as client children are of the component that places them +// (the Solid binding's half; the frames above never see an owner). Per +// region element: the owner that placed it (null until placed, and again +// once that placement is disposed), and its nested fills by occurrence, +// each with the owner it mounted under (null while it waits). +const placements = new WeakMap }>(); + +/** + * A fill read a region element: the reader's owner places it, as reading + * `props.children` places client children. The region's nested fills mount + * under it — those waiting for a placement, and those the last placement's + * disposal took down with it. + */ +export function place(el: Element): void { + const o = getOwner(); + const p = placements.get(el); + if (!o || !p || p.o === o) return; + p.o = o; + onCleanup(() => { + if (p.o === o) p.o = null; + for (const entry of p.m.values()) if (entry.o === o) entry.o = null; + }); + for (const entry of p.m.values()) entry.o || entry.run(); +} + +/** + * Invoke a fill (`mount`, the binding's) for an occurrence: one in a region + * mounts under the owner that placed the region — so it reads the context + * the enclosing fill provides there — and lives as long as that placement. + * While the region is unplaced (occluded, or its placement disposed) it + * waits; the next placement mounts it afresh over what the range holds. + */ +export function nest(mount: (props: any, ctx: any) => void, props: any, ctx: any): void { + const start = ctx && ctx.range && ctx.range.start; + const p = start && placements.get(start.parentNode.closest?.(FRAME_TAG)); + if (!p) return mount(props, ctx); + const key = ctx.key; + let at = ctx; + const entry = { + o: null as any, + run() { + // A remount renders fresh over what the last mount left. + if (!at) { + at = { ...ctx, adopted: false, existing: [] }; + for (let n = start.nextSibling; n && n !== ctx.range.end; n = n.nextSibling) + at.existing.push(n); + } + runWithOwner((entry.o = p.o), () => mount(props, at)); + at = null; + } + }; + p.m.set(key, entry); + ctx.onCleanup(() => p.m.get(key) === entry && p.m.delete(key)); + if (p.o) entry.run(); +} + /** * Dispose the frames bound over an occurrence's regions and forget them; * without an occurrence, every region of the frame (the frame is being diff --git a/packages/web/test/consistency/nested-fill-context.spec.tsx b/packages/web/test/consistency/nested-fill-context.spec.tsx new file mode 100644 index 0000000000..4b114e569b --- /dev/null +++ b/packages/web/test/consistency/nested-fill-context.spec.tsx @@ -0,0 +1,84 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + * + * Context through nested slot fills, stream face. A fill in a region — server + * content an enclosing fill places — is that fill's child, as in any client + * tree: it reads the context the enclosing fill provides where it placed the + * region. That holds for an occurrence a live hole's patch adds to the region + * later, as for those the region arrived with. (The document face, adopted + * and deferred: test/hydration/frame-nested-fill-context*.spec.tsx.) + */ +import { afterEach, beforeAll, describe, expect, test, vi } from "vitest"; +import { createContext, createRoot, flush, Loading, useContext } from "solid-js"; +import { dynamic } from "@solidjs/web"; +import { installServerComponents } from "../../frames/src/client.js"; +import { prepareTier } from "../../frames/src/frame-client.js"; +import { createServerReference } from "../../server-functions/src/client.js"; +import { freshFid, holeHtml, makeHost, pump, slotRange, stubHeldFetch } from "./support.js"; + +beforeAll(() => prepareTier("regions")); +const disposers: (() => void)[] = []; +afterEach(() => { + for (const d of disposers.splice(0)) d(); + vi.unstubAllGlobals(); + document.body.innerHTML = ""; +}); + +const Theme = createContext("default"); + +describe("context through nested slot fills — stream face", () => { + test("a nested fill reads the enclosing fill's provider, a live hole's new one too", async () => { + const id = freshFid("nested-ctx"); + const { host } = makeHost(); + installServerComponents(host); + const { held } = stubHeldFetch([id]); + const getX = createServerReference(id); + const Site = dynamic(() => getX() as any); + let div!: HTMLDivElement; + disposers.push( + createRoot(d => { +
+ fallback}> + ( + +
{p.children}
+
+ )} + badge={(p: any) => ( + + {p.text}={useContext(Theme)} + + )} + /> +
+
; + document.body.appendChild(div); + return d; + }) + ); + await pump(); + const region = `${id}.panel#0.children`; + const send = (c: any) => held[0].send({ id, version: 1, ...c }); + send({ type: "start" }); + send({ type: "slot", key: "panel#0", args: { children: { $frame: region } } }); + send({ type: "slot", key: "badge#0", args: { text: "a" } }); + send({ type: "slot", key: "badge#1", args: { text: "out" } }); + send({ type: "html", html: `
${slotRange("panel#0")}${slotRange("badge#1")}
` }); + send({ type: "html", id: region, html: `

${holeHtml(0, slotRange("badge#0"))}

` }); + await pump(); + const texts = () => [...div.querySelectorAll("b")].map(b => b.textContent); + expect(texts()).toEqual(["a=dark", "out=default"]); + + send({ type: "slot", key: "badge#2", args: { text: "c" } }); + send({ + type: "hole", + id: region, + key: "lh:0", + html: slotRange("badge#0") + slotRange("badge#2") + }); + await pump(); + expect(texts()).toEqual(["a=dark", "c=dark", "out=default"]); + }); +}); diff --git a/packages/web/test/harness/__artifacts__/frame-nested-fill-context.json b/packages/web/test/harness/__artifacts__/frame-nested-fill-context.json new file mode 100644 index 0000000000..6087b6587f --- /dev/null +++ b/packages/web/test/harness/__artifacts__/frame-nested-fill-context.json @@ -0,0 +1,5 @@ +{ + "name": "frame-nested-fill-context", + "shell": "

", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/frame-nested-fill-context.tsx b/packages/web/test/harness/frame-nested-fill-context.tsx new file mode 100644 index 0000000000..6b62d159f1 --- /dev/null +++ b/packages/web/test/harness/frame-nested-fill-context.tsx @@ -0,0 +1,102 @@ +/** + * @jsxImportSource @solidjs/web + * + * Context through nested slot fills. A client fill is a client component: + * the server content it places (a region) is part of its subtree, so a + * fill nested in that content sees the context the enclosing fill provides + * — on the server render, at hydration, and when an occurrence mounts out + * of order under deferred hydration. + * + * `Panel` (the `panel` fill) provides `Theme` around the server content it + * receives as `children`; `Badge` (the `badge` fill, placed by the server + * inside that content) reads it — noting what it read in `seen` — and + * counts clicks. A second `badge` sits outside any panel and reads the + * default. `Drawer` provides its own theme and places its content only + * while open: the first starts open (its content is markup), the second + * closed (its content is an occluded region record). + * + * Shared by test/server/frame-nested-fill-context.spec.tsx (writes the + * artifact) and test/hydration/frame-nested-fill-context*.spec.tsx. + */ +import { createContext, createSignal, Loading, untrack, useContext } from "solid-js"; +import { dynamic, type JSX } from "@solidjs/web"; + +export const FID = "frame-nested-ctx/page"; +export const ARGS = ["page"]; + +export const Theme = createContext("default"); + +/** The enclosing fill: provides the theme around the server content. */ +export function Panel(props: { children: JSX.Element }) { + return ( + +
{props.children}
+
+ ); +} + +/** An enclosing fill that places its content only while open. */ +export function Drawer(props: { id: string; open: boolean; children: JSX.Element }) { + const [open, setOpen] = createSignal(untrack(() => props.open)); + return ( + + + {open() && props.children} + + ); +} + +/** Each badge mount's id and the theme it read. */ +export const seen: string[] = []; + +/** The nested fill: reads the theme of whatever encloses it. */ +export function Badge(props: { id: string }) { + const [clicks, setClicks] = createSignal(0); + const theme = useContext(Theme); + seen.push(`${untrack(() => props.id)}=${theme}`); + return ( + + ); +} + +/** The page: the server component under a `Loading`, mounted through `dynamic`. */ +export function makeApp(source: () => any) { + return () => { + const Page = dynamic(source); + return ( +
+ shell-fallback

}> + } + drawer={(p: any) => } + badge={(p: any) => } + /> +
+
+ ); + }; +} + +/** The server component: badges inside a panel's and drawers' content, and one outside. */ +export function makePageComponent() { + return (props: { panel: any; drawer: any; badge: any }) => ( +
+ +

+ +

+
+ + + + + + + +
+ ); +} diff --git a/packages/web/test/hydration/frame-nested-fill-context-deferred.spec.tsx b/packages/web/test/hydration/frame-nested-fill-context-deferred.spec.tsx new file mode 100644 index 0000000000..dc28f28360 --- /dev/null +++ b/packages/web/test/hydration/frame-nested-fill-context-deferred.spec.tsx @@ -0,0 +1,45 @@ +/** + * @jsxImportSource @solidjs/web + */ +// Context through nested slot fills — client half, deferred hydration. An +// event inside the nested occurrence mounts the occurrences enclosing it, +// outermost first: the panel, then the badge in its content — which reads +// the panel's theme and sees the event that woke it. +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { flush } from "solid-js"; +import { setDeferredHydration } from "../../frames/src/client.js"; +import { seen } from "../harness/frame-nested-fill-context.jsx"; +import { bootNestedContext } from "./frame-nested-fill-context-helpers.jsx"; + +beforeAll(() => setDeferredHydration(true)); +afterAll(() => setDeferredHydration(false)); + +describe("document face — context through nested slot fills — deferred hydrate", () => { + test("an event in the nested occurrence mounts it under the enclosing fill's provider", async () => { + const page = await bootNestedContext(); + expect(seen).toEqual([]); + // Nothing mounted yet: the click mounts panel, then badge, then lands. + await page.click("in"); + expect(page.badge("in"), "badge in adopted").toBe(page.server.in); + expect(page.badge("in").textContent).toBe("dark:1"); + // It mounted first (the handler's write then mounts every other + // occurrence before it applies). + expect(seen[0]).toBe("in=dark"); + // The idle pass mounts the rest. + for (let i = 0; i < 4; i++) { + flush(); + await new Promise(r => setTimeout(r)); + } + await page.click("out"); + expect(page.badge("out"), "badge out adopted").toBe(page.server.out); + expect(page.badge("out").textContent).toBe("default:1"); + expect(page.badge("in").textContent).toBe("dark:1"); + expect([...seen].sort()).toEqual(["d1=drawer", "in=dark", "out=default"]); + await page.toggle("d2"); + expect(page.badge("d2").textContent).toBe("drawer:0"); + expect(page.warnings).toEqual([]); + expect(page.errors).toEqual([]); + page.dispose(); + page.container.remove(); + }); +}); diff --git a/packages/web/test/hydration/frame-nested-fill-context-helpers.tsx b/packages/web/test/hydration/frame-nested-fill-context-helpers.tsx new file mode 100644 index 0000000000..bddaf2a4b8 --- /dev/null +++ b/packages/web/test/hydration/frame-nested-fill-context-helpers.tsx @@ -0,0 +1,62 @@ +/** + * @jsxImportSource @solidjs/web + * + * Shared by the frame-nested-fill-context*.spec.tsx hydrate specs: boots the + * document test/server/frame-nested-fill-context.spec.tsx rendered and + * hydrates it. Each spec lives in its own file because the frames client's + * boundary index and claim set are module state — one page per module. + */ +import { expect, vi } from "vitest"; +import { flush } from "solid-js"; +import { hydrate } from "@solidjs/web"; +import { installServerComponents } from "../../frames/src/client.js"; +import { prepareTier } from "../../frames/src/frame-client.js"; +import { createServerReference } from "../../server-functions/src/client.js"; +import { makeHost } from "../lifecycle-matrix/harness.js"; +import { ARGS, FID, makeApp } from "../harness/frame-nested-fill-context.jsx"; +import { applyChunk, drain, loadArtifact } from "./frame-live-document-helpers.js"; + +export async function bootNestedContext() { + const { shell, rest } = loadArtifact("frame-nested-fill-context"); + (globalThis as any)._$HY = { events: [], completed: new WeakSet(), r: {}, fe() {} }; + const container = document.createElement("div"); + document.body.appendChild(container); + installServerComponents(makeHost().host); + vi.stubGlobal("fetch", async () => { + throw new Error("unexpected fetch"); + }); + const warnings: string[] = []; + vi.spyOn(console, "warn").mockImplementation((...args: unknown[]) => { + warnings.push(args.map(String).join(" ")); + }); + const errors: string[] = []; + vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => { + errors.push(args.map(String).join(" ")); + }); + const list = createServerReference(FID); + const App = makeApp(() => list(...ARGS)); + applyChunk(container, shell + rest, true); + // The panel's content is a `{$frame}` region: the tier is resident, as on + // a page whose modulepreload landed. + await prepareTier("regions"); + const badge = (id: string) => container.querySelector(`#badge-${id}`)!; + const server = { + in: badge("in"), + out: badge("out"), + panel: container.querySelector("section.panel")! + }; + expect(server.in.textContent).toBe("dark:0"); + expect(server.out.textContent).toBe("default:0"); + const dispose = hydrate(() => , container); + const click = async (id: string) => { + badge(id).dispatchEvent(new MouseEvent("click", { bubbles: true })); + flush(); + await drain(); + }; + const toggle = async (id: string) => { + container.querySelector(`#toggle-${id}`)!.click(); + flush(); + await drain(); + }; + return { container, badge, server, click, toggle, warnings, errors, dispose, drain }; +} diff --git a/packages/web/test/hydration/frame-nested-fill-context.spec.tsx b/packages/web/test/hydration/frame-nested-fill-context.spec.tsx new file mode 100644 index 0000000000..fe3822ad8a --- /dev/null +++ b/packages/web/test/hydration/frame-nested-fill-context.spec.tsx @@ -0,0 +1,56 @@ +/** + * @jsxImportSource @solidjs/web + */ +// Context through nested slot fills — client half (adoption-time mounting). +// Hydrates the document test/server/frame-nested-fill-context.spec.tsx +// rendered: the badge inside the panel's content reads the theme the panel +// provides, as it did on the server — no mismatch, the server's nodes +// adopted — and stays live. A drawer's badge lives as long as the drawer +// places its content: closing disposes it, opening mounts it afresh under +// the drawer's theme — the closed drawer's (occluded) badge included. +import { describe, expect, test } from "vitest"; +import { flush } from "solid-js"; +import { seen } from "../harness/frame-nested-fill-context.jsx"; +import { bootNestedContext } from "./frame-nested-fill-context-helpers.jsx"; + +describe("document face — context through nested slot fills — hydrate", () => { + test("the nested fill adopts under the enclosing fill's provider", async () => { + const page = await bootNestedContext(); + flush(); + await page.drain(); + expect(page.warnings).toEqual([]); + expect(page.errors).toEqual([]); + expect(page.badge("in"), "badge in adopted").toBe(page.server.in); + expect(page.badge("out"), "badge out adopted").toBe(page.server.out); + expect(page.server.panel.isConnected, "panel adopted").toBe(true); + await page.click("in"); + await page.click("out"); + expect(page.badge("in").textContent).toBe("dark:1"); + expect(page.badge("out").textContent).toBe("default:1"); + // Every adopted badge read its enclosing fill's theme; the closed + // drawer's waits for its placement. + expect([...seen].sort()).toEqual(["d1=drawer", "in=dark", "out=default"]); + + // Closing a drawer disposes its badge; opening mounts it afresh. + const d1 = page.badge("d1"); + await page.click("d1"); + expect(d1.textContent).toBe("drawer:1"); + await page.toggle("d1"); + expect(page.badge("d1")).toBeNull(); + await page.toggle("d1"); + expect(page.badge("d1").textContent).toBe("drawer:0"); + await page.click("d1"); + expect(page.badge("d1").textContent).toBe("drawer:1"); + + // The occluded drawer's badge mounts at its first placement. + await page.toggle("d2"); + expect(page.badge("d2").textContent).toBe("drawer:0"); + await page.click("d2"); + expect(page.badge("d2").textContent).toBe("drawer:1"); + expect(seen.slice(3)).toEqual(["d1=drawer", "d2=drawer"]); + expect(page.warnings).toEqual([]); + expect(page.errors).toEqual([]); + page.dispose(); + page.container.remove(); + }); +}); diff --git a/packages/web/test/server/frame-nested-fill-context.spec.tsx b/packages/web/test/server/frame-nested-fill-context.spec.tsx new file mode 100644 index 0000000000..77f4f7d164 --- /dev/null +++ b/packages/web/test/server/frame-nested-fill-context.spec.tsx @@ -0,0 +1,37 @@ +/** + * @jsxImportSource @solidjs/web + */ +// Context through nested slot fills — server half. A fill placed inside the +// server content an enclosing fill renders sees that fill's context, as in +// any client tree. Writes the artifact +// test/hydration/frame-nested-fill-context*.spec.tsx hydrate. +import { describe, expect, test } from "vitest"; +import { frameTransformDirectResult, ServerComponentPlugin } from "../../frames/src/frame-sink.js"; +import { ARGS, FID, makeApp, makePageComponent } from "../harness/frame-nested-fill-context.jsx"; +import { recordStream, writeArtifact } from "./artifact-recorder.js"; + +const markup = (html: string) => html.replace(//g, ""); +const badge = (html: string, id: string) => + html.match(new RegExp(`id="badge-${id}"[^>]*>(.*?)`))?.[1].replace(//g, ""); + +describe("document face — context through nested slot fills — server render", () => { + test("the nested fill reads the enclosing fill's provider; one outside reads the default", async () => { + const Inline = frameTransformDirectResult(makePageComponent(), { id: FID, args: ARGS }) as any; + const App = makeApp(() => Promise.resolve(Inline)); + const { shell, rest } = await recordStream(() => , { + plugins: [ServerComponentPlugin] + } as any); + const html = markup(shell + rest); + expect(badge(html, "in")).toBe("dark:0"); + expect(badge(html, "out")).toBe("default:0"); + // The panel placed its content: markup, not an occluded region record. + expect(html).toContain(`data-fid="${FID}.panel#0.children"`); + expect(shell + rest).not.toContain(`sc:region:${FID}.panel#0.children`); + // An open drawer's badge reads the drawer's theme; a closed drawer's + // content is an occluded region record, never markup. + expect(badge(html, "d1")).toBe("drawer:0"); + expect(html).not.toContain("badge-d2"); + expect(shell + rest).toContain(`sc:region:${FID}.drawer#1.children`); + writeArtifact("frame-nested-fill-context", { name: "frame-nested-fill-context", shell, rest }); + }); +}); diff --git a/scripts/size/floor-caps.json b/scripts/size/floor-caps.json index 5382748378..ec9ba5ed50 100644 --- a/scripts/size/floor-caps.json +++ b/scripts/size/floor-caps.json @@ -12,12 +12,12 @@ "minified": 53236 }, "page: base server components (hydrating + dynamic + frames + sf reference)": { - "cap": "33.44 KB", - "minified": 102617 + "cap": "33.46 KB", + "minified": 102629 }, "page: live server components (base + live/GET + action + isPending/latest)": { - "cap": "37.52 KB", - "minified": 115976 + "cap": "37.58 KB", + "minified": 115982 }, "server: floor (getRequestEvent + isServer)": { "cap": "1.34 KB", diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index 5532652518..ba04cf2e81 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -4542,8 +4542,14 @@ module.exports = [ // slot occurrences that survive stay mounted, new ones mount and dropped // ones dispose; deferred, an occurrence not mounted yet keeps the record its // markup was rendered from. Cap at measured + 10 B rounded up to 0.01 KB. - limit: "12.23 KB", - capMinified: 36842, + // Size-Exception (nested slot fills see the enclosing fill's context, + // 2026-10-17, over next @ 7010268d): 12.23 -> 12.25 KB, measured at + // 12,235 B brotli / 36,845 B minified (+19 B brotli, +3 B minified). A fill + // in a region mounts under the owner that placed it (the regions tier's + // placement), and a fill's two claim windows share one claim owner. Cap at + // measured + 10 B rounded up to 0.01 KB. + limit: "12.25 KB", + capMinified: 36845, alias: framesAlias, external: framesExternal }, @@ -4899,6 +4905,12 @@ module.exports = [ // measured locally (linux) at 33,421 B (102,617 B minified). Lower only: // cap at measured + 10 B rounded up to 0.01 KB; recorded minified never // raised. + // Size-Exception (nested slot fills see the enclosing fill's context, + // 2026-10-17, over next @ 7010268d): 33.44 -> 33.46 KB, measured at + // 33,441 B brotli / 102,629 B minified (+20 B brotli, +12 B minified). A fill + // in a region mounts under the owner that placed it (the regions tier's + // placement), and a fill's two claim windows share one claim owner. Cap + // (floor-caps.json) at measured + 10 B rounded up to 0.01 KB. limit: floorCaps["page: base server components (hydrating + dynamic + frames + sf reference)"], capMinified: floorMinified["page: base server components (hydrating + dynamic + frames + sf reference)"], @@ -5195,6 +5207,12 @@ module.exports = [ // measured locally (linux) at 37,510 B (115,976 B minified). Lower only: // cap at measured + 10 B rounded up to 0.01 KB; recorded minified never // raised. + // Size-Exception (nested slot fills see the enclosing fill's context, + // 2026-10-17, over next @ 7010268d): 37.52 -> 37.58 KB, measured at + // 37,565 B brotli / 115,982 B minified (+55 B brotli, +6 B minified). A fill + // in a region mounts under the owner that placed it (the regions tier's + // placement), and a fill's two claim windows share one claim owner. Cap + // (floor-caps.json) at measured + 10 B rounded up to 0.01 KB. limit: floorCaps["page: live server components (base + live/GET + action + isPending/latest)"], capMinified: floorMinified["page: live server components (base + live/GET + action + isPending/latest)"], @@ -5319,8 +5337,14 @@ module.exports = [ // measured locally (linux) at 35,290 B (108,961 B minified). Lower only: // cap at measured + 10 B rounded up to 0.01 KB; recorded minified never // raised. - limit: "35.30 KB", - capMinified: 108961, + // Size-Exception (nested slot fills see the enclosing fill's context, + // 2026-10-17, over next @ 7010268d): 35.30 -> 35.42 KB, measured at + // 35,408 B brotli / 108,973 B minified (+118 B brotli, +12 B minified). A fill + // in a region mounts under the owner that placed it (the regions tier's + // placement), and a fill's two claim windows share one claim owner. Cap at + // measured + 10 B rounded up to 0.01 KB. + limit: "35.42 KB", + capMinified: 108973, alias: pageAlias }, {