Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/frames-a1b-stage-deletion-s-ref.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/web": patch
---

frames: A1b + A4 (S-ref) — a slot record's `{$ref}`s settle at the host's write, through the response's own data table (`FrameHostOptions.resolve(ref, frameId, version, current)`); an undelivered key is a pending read the response's `data` chunk settles and its `complete`/`error` rejects (L1 — a value that never comes is an error, not a silence); a fresh mount waits for the record to settle, a mounted occurrence's prop pends and holds its value. Codec data tables are per response (keyed by frame id and version). Deleted: `ServerComponentHandlerOptions.onStream`, `STAGED_DATA` and the staged tables, `FrameHost.resolve`, `FrameHostOptions.isContainer`/`FrameHost.isContainer`, `Frame.rebase`, the frame's record dedupe (`#refArgsUnchanged`, `#slotResolvedRefs`) — a re-sent record's equality is the fill's per-prop memo's — and the frame's error/root value latches (applied state keyed by record identity). `FrameHost.preview`/`Frame.preview` lose their `resolve` parameter and stay: the compute-half preview is what stages a refetch's args with the transaction that read it.
5 changes: 5 additions & 0 deletions .changeset/frames-a4-declared-slot-records.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/web": patch
---

frames: A4 (S-record) — the document face declares a slot record at its marker: `_$HY.r["sc:slot:<fid>:<occurrence>"]` is a pending promise written with the occurrence's markup and settled with the args by the record's data script (the shape a fragment's `<key>_fr` takes), so the adopting client awaits a record that trails its range's reveal through the value's own `.then` instead of polling the registry (C2 (a2) flips). Output shape: ≈ +32–38 B per document slot record (the resolver helpers are shared with the page's fragment declarations); a sync render (`renderToString`) writes the settled value as before. Deleted: the #2968 `setTimeout` re-drain poll and `FrameOptions.recordsPending` / `FrameOptions.drainRecords`.
321 changes: 184 additions & 137 deletions documentation/server-components/frames-rulings.md

Large diffs are not rendered by default.

294 changes: 159 additions & 135 deletions packages/web/frames/src/client.ts

Large diffs are not rendered by default.

652 changes: 295 additions & 357 deletions packages/web/frames/src/frame-client.ts

Large diffs are not rendered by default.

481 changes: 255 additions & 226 deletions packages/web/frames/src/frame-sink.ts

Large diffs are not rendered by default.

125 changes: 45 additions & 80 deletions packages/web/frames/src/frame-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,6 @@ export interface ServerComponentHandlerOptions<C = unknown> {
* to that address's store. Multi-mount fans out per site.
*/
component(fnId: string): C;
/**
* A new response is about to stream into an address: rotate
* response-scoped state (codec data tables) here. `version` is the
* client-owned stream counter the chunks will be stamped with.
*/
onStream?(address: string, version: number, response: Response): void;
/**
* Answer a call before any request is made (t = 0 local answers — a
* boundary the document already carries). Returning `undefined` is a
Expand Down Expand Up @@ -387,7 +381,11 @@ export const COMPONENT_BINDING = /*#__PURE__*/ Symbol.for("solid.component-bindi
// separator, the response's version. Mounts receive tokens through their
// address accessor (dynamic treats addresses as opaque, so a new token is
// delivered like an address switch — inside the transition that read it).
// NUL never occurs in a function id.
// The token is how a refetch of the address a mount SHOWS enters the
// reactive graph at all: `dynamic` delivers a kept resolution only when its
// address differs, so a refetch resolving to the bare address would be a
// write nothing observes, and its content could not be held by the
// transaction that asked for it (C15). NUL never occurs in a function id.
const CONTENT_TOKEN = "\u0000";

/**
Expand All @@ -405,9 +403,11 @@ export function contentAddress(token) {
* by token; a plain address, or a token already committed or superseded,
* is a no-op. Mounts call both halves from the render effect that follows
* their address accessor: `preview` from its compute half — under the
* transition that delivered the token, so the slot args it pushes into the
* live fills (see `FrameHost.preview`) are held with it — and `commit`
* from its effect half, replaying the rest of the response in the commit.
* transaction that delivered the token, so the slot args it pushes into the
* live fills (see `FrameHost.preview`) are staged with it and a fill's
* derivation over an arg re-derives in that pass, never one flush behind
* the intent it held (principles §9.2.2) — and `commit` from its effect
* half, landing the buffered response as the store's writes in the commit.
* Installed by the handler (one active handler at a time, as for
* `resolveServerComponent` below).
* @internal
Expand All @@ -417,16 +417,6 @@ export const stagedContent: {
commit(token: string): void;
} = { preview() {}, commit() {} };

/**
* The handler option (internal) through which an integration that routes
* `data` chunks to per-stream tables stages a response's data: a factory
* for `{ begin(id), apply(chunk), resolve(ref, id), commit() }` — `begin`
* where the integration's `onStream` would rotate, `commit` installing the
* staged tables in its place.
* @internal
*/
export const STAGED_DATA = Symbol("solid.StagedData");

// The live transport registry's resolver, installed by
// createServerComponentHandler. Module state on the config pattern (one
// active handler at a time, a later creation replaces the current one):
Expand Down Expand Up @@ -646,13 +636,7 @@ export function createServerComponentHandler<C>(options: ServerComponentHandlerO
* updates on delivery; calling the binding directly (a non-gated mount)
* passes the binding's own constant address.
*/
export function createServerComponentHandler({
host,
component,
onStream,
intercept,
[STAGED_DATA]: openData
}) {
export function createServerComponentHandler({ host, component, intercept }) {
// Mount components, one per FUNCTION (the equals-gate identity).
const byFn = new Map();
const componentFor = fnId => {
Expand Down Expand Up @@ -681,22 +665,27 @@ export function createServerComponentHandler({
return binding;
};
// Content for a call a mount is SHOWING is staged, not written: the
// response's chunks buffer under the address, and the call resolves a
// binding to a content token naming that version. The mount's address
// accessor delivers the token inside the transition that read the call;
// the follow effect's compute half previews the slot args into the live
// fills (held with the transition) and its effect half commits the rest —
// so new content lands in that transition's commit, alongside everything
// else it holds, and not when the body happens to finish arriving. One
// entry per address:
// the newest response is the only one worth committing (versions are
// bumped as responses arrive, so a later stage always supersedes).
// response's chunks buffer under the address until the body ends, and
// the call resolves a binding to a content token naming that version. The
// mount's address accessor delivers the token inside the transition that
// read the call; the follow effect's compute half previews the slot args
// into the live fills (staged with the transition) and its effect half
// commits the rest as one run of writes — so new content lands in that
// transition's commit, alongside everything else it holds, and not when
// the body happens to finish arriving. The response's DATA is the one
// part that writes through as it arrives: tables are per response
// (frames-rulings 1.2 — the host's data path keys them by the chunk's
// version), so the staged response decodes into its own table while the
// shown response's stays in place, and the preview resolves the staged
// args through it. One entry per address: the newest response is the
// only one worth committing (versions are bumped as responses arrive, so
// a later stage always supersedes).
const staged = new Map();
// The binding a reference to an address resolves: its newest token once
// content was staged for it, so a flight reference in a mutation's
// envelope names the version the same response carried.
const latest = new Map();
const stage = (address, base, version, response) => {
const stage = (address, base, version) => {
// Content is staged under a token of the address's binding; an address
// no binding was minted for has no reader a token could reach.
if (!base) return undefined;
Expand All @@ -705,37 +694,20 @@ export function createServerComponentHandler({
// through: it is now what the mount shows.
let committed = false;
const chunks = [];
const streams = [];
// The response's data decodes as it arrives, into tables of its own when
// the integration routes data per stream (STAGED_DATA): the preview
// resolves the staged args through them while the shown response's
// tables stay in place, and the commit installs them. A host without
// per-stream tables takes data at once, as it would unstaged.
const data = openData && openData();
const token = address + CONTENT_TOKEN + version;
const entry = {
token,
prepareData: host.prepareData,
stream(id, v) {
if (committed) onStream && onStream(id, v, response);
else if (data) data.begin(id);
else streams.push([id, v]);
},
apply(chunk) {
if (committed) host.apply(chunk);
else if (chunk.type !== "data") chunks.push(chunk);
else if (data) data.apply(chunk);
else host.apply(chunk);
if (committed || chunk.type === "data") host.apply(chunk);
else chunks.push(chunk);
},
preview() {
if (host.preview)
for (const chunk of chunks) host.preview(chunk, data ? data.resolve : undefined);
if (host.preview) for (const chunk of chunks) host.preview(chunk);
},
commit() {
committed = true;
staged.delete(address);
if (data) data.commit();
else if (onStream) for (const [id, v] of streams) onStream(id, v, response);
for (const chunk of chunks) host.apply(chunk);
}
};
Expand All @@ -748,6 +720,8 @@ export function createServerComponentHandler({
};
/** The binding a settled call resolves to: its staged version's token. */
const settled = (address, binding) => latest.get(address) || binding;
/** Run a half of the staged entry a token names, while it is still the
* address's (committing removes it). */
/** Run a half of the staged entry a token names, while it is still the
* address's (committing removes it). */
const named = (token, half) => {
Expand Down Expand Up @@ -798,17 +772,16 @@ export function createServerComponentHandler({
};
/**
* An unstaged response has begun for an address — at its header, before
* its body is read. The integration rotates its response-scoped state,
* and the address's store moves to the response's version NOW: the
* address is a source (`host.landing`), and from here until the body's
* first flush it reads "in flight" — a mount opened in between pends on
* that landing instead of materializing the superseded one. The body's
* own `start` chunk then writes the same version and nothing.
* its body is read. The address's store moves to the response's version
* NOW: the address is a source (`host.landing`), and from here until the
* body's first flush it reads "in flight" — a mount opened in between
* pends on that landing instead of materializing the superseded one. The
* body's own `start` chunk then writes the same version and nothing. The
* version is the response's identity for its data too (the integration's
* table rotates on it — frames-rulings 1.2), so nothing else announces
* the response.
*/
const begin = (address, version, response) => {
if (onStream) onStream(address, version, response);
host.apply({ type: "start", id: address, version });
};
const begin = (address, version) => host.apply({ type: "start", id: address, version });
return {
intercept:
intercept &&
Expand Down Expand Up @@ -880,7 +853,7 @@ export function createServerComponentHandler({
return binding;
}
const version = bump(address);
begin(address, version, response);
begin(address, version);
// The end is judged by the loop from `connection.ended` (set
// synchronously by applyFrames); a rejected read is a death it
// already sees, not an error record — the loop decides what the
Expand All @@ -906,9 +879,8 @@ export function createServerComponentHandler({
// needs the binding to place the boundary and the shell gate is its
// hold — settling those late would block progressive streaming
// behind a completed body.
const entry = host.get(address) ? stage(address, binding, version, response) : undefined;
if (entry) entry.stream(address, version);
else begin(address, version, response);
const entry = host.get(address) ? stage(address, binding, version) : undefined;
if (!entry) begin(address, version);
const target = entry || host;
const applied = applyFrameResponse(response, target, { as: address, version }).catch(err =>
target.apply({
Expand Down Expand Up @@ -1011,16 +983,9 @@ export function createServerComponentHandler({
const version = bump(frameId);
let entry = regionOf(frameId);
if (!entry && host.get(frameId)) {
entry = stage(
frameId,
frameId === as ? binding : byAddress.get(frameId),
version,
response
);
entry = stage(frameId, frameId === as ? binding : byAddress.get(frameId), version);
if (entry) regions.set(frameId, entry);
}
if (entry) entry.stream(frameId, version);
else if (onStream) onStream(frameId, version, response);
return version;
},
onOutcome: text => {
Expand Down
9 changes: 6 additions & 3 deletions packages/web/test/consistency/c01-claim-window-roots.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ describe("C1 — an adopted occurrence's claim window gathers against its own ro
page = bootPage(
frameHtml(fid, `<ul>${slotRange("item#0", fillHtml(fid, "item#0", "one"))}</ul>`)
);
// Declared at the marker (S-record); its settle is the script the
// parser is still owed.
const record = page.declareSlotRecord(fid, "item#0");
const Comp = (globalThis as any)._$SC.r(fid);
const li = page.container.querySelector("li")!;
const invocations: number[] = [];
Expand All @@ -61,9 +64,9 @@ describe("C1 — an adopted occurrence's claim window gathers against its own ro
document.body.appendChild(other);
const disposeOther = hydrate(() => <p>other</p>, other);
await quiesce();
// The record the parser was still owed: the deferred claim gathers
// against the frame's root, claims the server's node in place.
page.slotRecord(fid, "item#0", { text: "one" });
// The record's settle the parser was still owed: the deferred claim
// gathers against the frame's root, claims the server's node in place.
record.settle({ text: "one" });
await quiesce();
await quiesce();
expect(invocations).toEqual([1]);
Expand Down
113 changes: 57 additions & 56 deletions packages/web/test/consistency/c02-revealed-occurrence-mounts.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,64 +171,65 @@ describe("C2 — no inert server content", () => {
dispose();
});

// Arm (a2): the record lands AFTER the reveal.
test.fails(
"(a2) render-prop occurrence revealed after adoption, record after the reveal: mounted and live",
async () => {
const fid = freshFid("c2a2");
const frag = "c2a2";
page = bootPage(pendingShell(fid, frag));
page.declareFragment(frag);
const Comp = (globalThis as any)._$SC.r(fid);
const [tick, setTick] = createSignal(0);
const invocations: number[] = [];
const dispose = hydrate(
() => (
<Comp
item={(p: { text: string }) => {
invocations.push(1);
return (
<li>
{p.text}
{tick()}
</li>
);
}}
/>
),
page.container
);
await quiesce();
expect(invocations.length).toBe(0);
// Arm (a2): the record lands AFTER the reveal. Under the declared-record
// protocol (frames A4, S-record) the producer writes the record at the
// occurrence's marker as a PENDING value — with the fragment, ahead of
// its swap — and settles it with the args when they are known; here the
// settle trails the reveal by two quiescences, with the parser done and
// no fragment pending (the shape no poll could cover).
//
// Was red on `next`: the record was a plain property write to `_$HY.r`
// observed by nothing — the reveal's drain ran before it, and the
// `#recordRefresh` poll armed only while `recordsPending()`. Green: the
// reveal's drain finds the declaration and awaits it (`.then`); the
// settle is a write the frame sees, re-syncs on, and the deferred mount
// claims the revealed markup.
test("(a2) render-prop occurrence revealed after adoption, record settled after the reveal: mounted and live", async () => {
const fid = freshFid("c2a2");
const frag = "c2a2";
page = bootPage(pendingShell(fid, frag));
page.declareFragment(frag);
const Comp = (globalThis as any)._$SC.r(fid);
const [tick, setTick] = createSignal(0);
const invocations: number[] = [];
const dispose = hydrate(
() => (
<Comp
item={(p: { text: string }) => {
invocations.push(1);
return (
<li>
{p.text}
{tick()}
</li>
);
}}
/>
),
page.container
);
await quiesce();
expect(invocations.length).toBe(0);

page.revealFragment(frag, slotRange("item#0", liveFillHtml(fid, "item#0", "one")));
await quiesce();
page.slotRecord(fid, "item#0", { text: "one" });
await quiesce();
await quiesce();
// Observed on next: the server <li> is in the page (text "one0") but
// the fill was never invoked (invocations 0) and the bump below leaves
// the DOM at "one0"; nothing is logged. Expected: one invocation, the
// hole follows the signal. Where it goes wrong: client.ts
// adoptBoundary — the only post-adopt drains are the `fr.subscribe`
// callback (runs AT the reveal, finds no `sc:slot:` key yet) and
// frame-client.ts #syncSlots' `#recordRefresh` timer, which arms only
// when a sync discovers a recordless occurrence while
// `recordsPending()`; no sync ever runs over the revealed range (the
// reveal applied nothing, so no `#flush`), so the record's later
// arrival — a plain property write to `_$HY.r` — is observed by
// nothing and the range stays inert.
expect(page.container.textContent).toBe("one0");
expect(invocations.length).toBe(1);
// The fragment carries the declaration (pending), then the swap.
const record = page.declareSlotRecord(fid, "item#0");
page.revealFragment(frag, slotRange("item#0", liveFillHtml(fid, "item#0", "one")));
await quiesce();
expect(invocations.length).toBe(0);
expect(page.container.textContent).toBe("one0");
record.settle({ text: "one" });
await quiesce();
await quiesce();
expect(page.container.textContent).toBe("one0");
expect(invocations.length).toBe(1);

setTick(1);
flush();
expect(page.container.textContent).toBe("one1");
expect(page.warnings).toEqual([]);
expect(page.errors).toEqual([]);
dispose();
}
);
setTick(1);
flush();
expect(page.container.textContent).toBe("one1");
expect(page.warnings).toEqual([]);
expect(page.errors).toEqual([]);
dispose();
});

// Arm (b): a direct-insert occurrence (`children`) is recordless by design.
// Revealed into the adopted region, it must mount all the same. Was red
Expand Down
Loading