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-residue-1-d-list.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/web": patch
---

Frames client, residue pass 1 — the D list (dead / incidental code off the eager client; frames residue pass §2 rows 2b, 2c, 2e, 2f, 2g). `documentAddress` is gone: an adopted mount's address comes with its binding (every serialized reference resolves to the call's binding), and a mount with none — the per-function placeholder rendered directly — binds the function id. `slotsFor` keeps one dispose map: a live range binding's `insert` lives under the fill's own owner where there is one, else under an owner registered the same way, so a re-call disposes whichever path the previous invocation took. The zombie heuristic and the `#slotNodes` map it read are gone — the morph relocates ranges among siblings and recreates nothing (DR-5), so "mounted" is the frame's set. `#revealSegment` has one path: a frame created without `FrameOptions.reveal` reveals through a default seam that inserts the content at once (no boundary) — the content is applied as it is revealed, as on the boundary path — and a placeholder missing its closing comment is not revealed (dev error; `#showFallback` already treated it so). `handler.showing` no longer brands the bare per-function component: the reference a reader holds at t=0 is the bootstrap's binding, branded there. The capture arm (`contentHTML` + the last-unmount capture) stays: `lifecycle-matrix/remount` pins it.
5 changes: 5 additions & 0 deletions .changeset/frames-residue-1-have-constants.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/web": patch
---

`FRAME_HAVE_HEADER` and `FRAME_HAVE_BUDGET` leave the `@solidjs/web/frames` client entry's export list (frames residue pass §2 row 5a). They stay exported from `@solidjs/web/frames/server`, where the header is read; the client sends the have-list itself through the handler's `resume` and nothing consumed the names off the client entry. Removed public surface, `@experimental`.
5 changes: 5 additions & 0 deletions .changeset/frames-residue-1-lean-reask.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/web": patch
---

The lean re-ask (frames residue pass §3.3). The server-function client hands its response handler the call it dispatched as a thunk — `ctx.retry` on `responseHandler.handle`, `info.retry` on `responseHandler.intercept` — the same reference, arguments, declared shape and per-call options, so a `GET`-declared read stays a GET by construction. The frames client's re-ask for an errored boundary's `reset` (frames-rulings 3.3) is that thunk: `callFor(address).retry()`. Gone with it: the frames client's `reask` installer and its reads of the RPC slot and the metadata brand, and `createServerReference(id)` on the client half of the server-function RPC slot (`getServerFunctionRPC()`, `@internal`), which nothing reads any more. `callFor` (`@internal`) now returns the recorded call with its `retry`. The `responseHandler` type in `ServerFunctionsClientConfig` documents the existing `intercept` seam alongside `handle`, both carrying `retry`.
897 changes: 897 additions & 0 deletions documentation/plans/frames-residue-pass.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions documentation/plans/frames-savings-pass.md

Large diffs are not rendered by default.

27 changes: 14 additions & 13 deletions documentation/server-components/server-components-principles.md
Original file line number Diff line number Diff line change
Expand Up @@ -2573,9 +2573,10 @@ amended here:
are the same `liveSlotProps` proxy content occurrences get, so a
re-emitted record updates the instance in place. The
per-occurrence owner is unconditional here (content fills scope
only stream-mounted invocations, for the zombie-heuristic reason
recorded in `slotsFor`): an element occurrence places no nodes, so
nothing can be misread, and the spread's effect must die with the
only stream-mounted invocations — a live-render fill's ambient
owner, the reconstructed boundary's content computation, already
has the right lifetime; see `slotsFor`): an element occurrence
places no nodes, and the spread's effect must die with the
occurrence. Fill-returned handlers go through client `spread`'s
own delegation (the "open" item above closes this way — the
one-owner rule keeps `_bnd` and a fill off the same position).
Expand All @@ -2589,8 +2590,8 @@ amended here:
the server's value and re-imposes the owned names' live state on
top (a class the fill toggled on stays on through a server class
change; an owned style property survives the attribute rewrite).
A replaced element is a zombie mount (its node left the tree) and
the fill remounts on the fresh node; an unmounted occurrence
A replaced element is a consumer change (the fill rebinds on the
fresh node, see §9.2.3); an unmounted occurrence
releases its ownership so the element is wholly the server's from
the next morph on.
- *No regions in attribute slots.* An element occurrence has no
Expand Down Expand Up @@ -3238,14 +3239,14 @@ the text above, the build is right and the text is amended here:
compared. A placed range never registers: two identical positional
markup calls stay two ranges (pinned). No wire change: ids stay
`prop#<n>`.
- *A data occurrence's nodes are its consumers, and it is never a
zombie.* The client's slot discovery collects `_s:*` elements into
per-occurrence consumer lists `[{ element, positions }]` alongside
the range walk. The occurrence's "nodes" are those elements (so the
existing bookkeeping sees them), but the zombie rule — output whose
node left the tree remounts fresh — does not apply: a replaced
consumer is a *consumer change*, and an occurrence no element
reads is simply not found and unmounts at the sync's end. Consumer
- *A data occurrence's nodes are its consumers.* The client's slot
discovery collects `_s:*` elements into per-occurrence consumer
lists `[{ element, positions }]` alongside the range walk. The
occurrence's "nodes" are those elements: a replaced consumer is a
*consumer change*, and an occurrence no element reads is simply
not found and unmounts at the sync's end. (No mount's output is
ever checked for having left the tree — the morph recreates
nothing, DR-5; "mounted" is the frame's set.) Consumer
sets compare structurally per sync; a change without an args
change rebinds in place through a per-occurrence rebinder (the
fill's computation stays; new elements and positions take their
Expand Down
147 changes: 58 additions & 89 deletions packages/web/frames/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,16 +122,6 @@ const IS_DEV = "_SOLID_DEV_" as unknown as boolean;
// (externalizeSharedTransport), so the codec/flight config its defaults
// read is this instance by construction.
import { configureServerFunctionsClient } from "@solidjs/web/server-functions/client";
// The server-function registry's two seams, read through their registered
// symbols rather than imported (the pattern the frame runtime uses for
// every cross-bundle brand — `CLAIM_SEAM`, `COMPONENT_BINDING`): the
// late-bound RPC slot (registry.ts `provideServerFunctionRPC` — filled by
// the time any server function has been referenced, so by the time a
// response could have erred) and the declaration-metadata brand. Importing
// either from an entry would retain a registry copy in whichever bundle
// does not already carry one.
const SERVER_FUNCTION_RPC = Symbol.for("solid.ServerFunctionRPC");
const SERVER_FUNCTION_METADATA = Symbol.for("solid.ServerFunctionMetadata");
// The seroval codec is the frames client's heaviest dependency (~6 kB gz
// with the web plugin set) and the common frames traffic never needs it:
// HTML chunks, scalar slot args and document records (the hydration
Expand All @@ -154,10 +144,13 @@ export type { TierModule } from "./frame-client.js";
// entry's graph in an app's bundler.
/** @internal */
export { isAsyncValue } from "./frame-client.js";
// `FRAME_HAVE_HEADER` / `FRAME_HAVE_BUDGET` are the SERVER entry's
// (`@solidjs/web/frames/server`): the client sends the have-list itself
// (`resume`, through the handler) and no consumer of this entry reads the
// header's name — an exported constant costs a consumer that keeps the whole
// module its bytes, so the pair left this list (frames residue pass).
export {
FRAME_STREAM_HEADER,
FRAME_HAVE_HEADER,
FRAME_HAVE_BUDGET,
applyFrameResponse,
isFrameStreamResponse,
createServerComponentHandler
Expand Down Expand Up @@ -289,7 +282,7 @@ function followAddress(host: any, frame: { rebind(address: string): void }, bind
* threw — this one — and an errored landing is not a landing for a fresh
* consumer: the re-read is a promise for the NEXT flight (`host.landing`),
* and the flight is opened here (`reask`: the call behind the address,
* re-invoked — `dynamic`'s factory is hoisted and never re-runs for a
* made again — `dynamic`'s factory is hoisted and never re-runs for a
* `reset`, so the mount asks for itself). A re-ask whose call fails on the
* wire (no response to land) rejects the node with that failure.
*
Expand Down Expand Up @@ -322,16 +315,30 @@ function landing<T>(host: any, address: string, value: T, failed: () => unknown)
if (!wait) return value;
// An errored address with no flight open (a flight's `start` clears
// the mounts' error): this read is the re-ask.
const asked = error !== undefined && reask ? reask(address) : undefined;
const asked = error !== undefined ? reask(address) : undefined;
return (asked ? asked.then(() => wait) : wait).then(() => value);
});
}

// The re-ask (see `landing`): installed with the handler, it re-invokes the
// call behind an address and resolves when the call's response has been
// handled (the flight is open and will land through the host). Undefined
// until `installServerComponents` ran.
let reask: ((address: string) => Promise<unknown> | undefined) | undefined;
/**
* The re-ask (see `landing`): the call behind the address, as the transport
* recorded it (`callFor`), made again through the call's own `retry` — the
* server-function client hands its response handler the call it dispatched
* (or answered locally) as a thunk: the same reference, arguments, declared
* shape and per-call options, so a `GET`-declared read stays a GET by
* construction. Resolves when the call's response has been handled: the
* flight is open and lands through the host. `undefined` for an address no
* call is recorded for.
*/
function reask(address: string): Promise<unknown> | undefined {
const call = callFor(address);
if (IS_DEV && !call)
console.error(
`Server component boundary "${address}" errored, but no call is recorded for it; ` +
`reset() cannot re-ask the server. (The address was written by hand, not by a call.)`
);
return call && call.retry();
}

/**
* A mount's error tick for `landing`: the read, and the frame `onApply`
Expand Down Expand Up @@ -581,18 +588,16 @@ function isReactiveContent(value: any): boolean {
* claims — see `claimRender`.
*/
function slotsFor(props: Record<string, any>, scope?: ClaimScope) {
// Live range bindings, one per occurrence. A re-call replaces its
// occurrence's binding (the frame only runs slot cleanups at unmount, not
// between re-calls), so dispose the outgoing one before the incoming
// invocation takes either path — a static re-call after a reactive one must
// not leave a binding fighting the frame for the range.
const bindings = new Map<string, { dispose(): void }>();
// Each fill invocation's reactive scope, one per occurrence. The fill
// renders under a PER-OCCURRENCE owner (a child of the ambient scope, so
// context flows) whose disposal rides the frame's occurrence-level
// cleanup: a fill's `onCleanup` and effects live and die with the
// occurrence — a later response dropping it disposes right there — not
// with the covering boundary, which outlives every occurrence it covers.
// A live range binding (the reactive-content path below) is the same
// scope: the `insert` lives under the fill's owner where there is one,
// under an owner of its own otherwise, so one map disposes whichever path
// the previous invocation took.
const fillScopes = new Map<string, { dispose(): void }>();
return new Proxy(
{},
Expand All @@ -601,14 +606,11 @@ function slotsFor(props: Record<string, any>, scope?: ClaimScope) {
if (typeof prop !== "string" || !(prop in props)) return undefined;
return (slotProps: any, ctx: any) => {
const key = ctx && ctx.key;
const prev = key !== undefined && bindings.get(key);
if (prev) {
bindings.delete(key);
prev.dispose();
}
// A re-call replaces the invocation wholesale (same contract as
// the binding above): the outgoing fill's scope disposes before
// the incoming one renders.
// A re-call replaces the invocation wholesale (the frame only
// runs slot cleanups at unmount, not between re-calls): the
// outgoing fill's scope — its binding included — disposes before
// the incoming one renders, so a static re-call after a reactive
// one never leaves a binding fighting the frame for the range.
const prevFill = key !== undefined && fillScopes.get(key);
if (prevFill) {
fillScopes.delete(key);
Expand All @@ -618,8 +620,7 @@ function slotsFor(props: Record<string, any>, scope?: ClaimScope) {
// elements reading its properties at bound positions. Always under
// a per-occurrence owner: the binding must die with the occurrence
// (a later response dropping it, or every consumer replaced by
// the morph), and there are no placed nodes for the frame's zombie
// heuristic to misread. The binding itself is the BIND TIER's
// the morph). The binding itself is the BIND TIER's
// (`bind`, bind-tier.ts) — resident by construction: positions
// exist only once the tier parsed the markers.
if (ctx && ctx.positions) {
Expand Down Expand Up @@ -665,13 +666,10 @@ function slotsFor(props: Record<string, any>, scope?: ClaimScope) {
// Live-render invocations (a reveal boundary's content render, the
// t=0 adoption sync) are deliberately NOT scoped this way: the
// ambient owner — the reconstructed segment boundary's content
// computation — already owns the fill with the right lifetime, and
// handing it to frame cleanups instead is wrong there: the frame's
// zombie heuristic reads "mounted nodes without a parent" as a
// destroyed mount, but a pending fill's nodes are legitimately
// detached while its covering boundary shows the fallback — the
// cleanup would dispose the live pending effect and release the
// boundary over a hole.
// computation — already owns the fill with the right lifetime (a
// 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).
const fillOwner = streamInvoke ? createOwner() : null;
if (fillOwner && key !== undefined && ctx) {
fillScopes.set(key, fillOwner);
Expand Down Expand Up @@ -786,12 +784,17 @@ function slotsFor(props: Record<string, any>, scope?: ClaimScope) {
// it again.
if (ctx && ctx.range) {
const source = value;
const owner = createOwner();
bindings.set(key, owner);
ctx.onCleanup(() => {
if (bindings.get(key) === owner) bindings.delete(key);
owner.dispose();
});
// 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.
const owner = fillOwner || createOwner();
if (!fillOwner) {
fillScopes.set(key, owner);
ctx.onCleanup(() => {
if (fillScopes.get(key) === owner) fillScopes.delete(key);
owner.dispose();
});
}
const end = ctx.range.end;
const bind = () =>
insert(
Expand Down Expand Up @@ -1176,21 +1179,6 @@ function documentBoundary(
return boundaryComponent(host, id)(props, binding);
}

/**
* The address a document boundary's content is keyed under: the call's
* address as the hydration references recorded it (`_$SC.a`, address -> id).
* A mount without a live binding (a direct placeholder render at t=0) reads
* it from those records; an argless call's address IS the function id, so
* the common shell case needs no record at all.
*/
function documentAddress(id: string) {
const records = (globalThis as any)._$SC?.a;
if (records) {
for (const address in records) if (records[address] === id) return address;
}
return id;
}

function adoptBoundary(
host: any,
id: string,
Expand All @@ -1202,8 +1190,13 @@ function adoptBoundary(
// Content is keyed by the CALL's address (the identity split): the frame
// binds the address's resident store, while `id` — the function id, the
// document's wire name — stays the key records and region ids on the page
// are written under.
const address = binding ? contentAddress(binding()) : documentAddress(id);
// are written under. The address comes with the binding — every reference
// the document serializes resolves to the call's binding, and a `dynamic`
// mount is called with the live accessor. A mount with no binding (the
// per-function placeholder rendered directly, `_$SC.r(id)` — a page with
// no transport) binds the function id: an argless call's address IS its
// id, and no refetch reaches a page without a transport.
const address = binding ? contentAddress(binding()) : id;
// Occlusion records (case 3, document face): content a client wrapper
// never rendered during SSR shipped ONCE as hydration data instead of
// markup. Apply the records BEFORE binding the frame — the host buffers
Expand Down Expand Up @@ -1582,29 +1575,5 @@ export function installServerComponents(host: any = getFrameHost(), options?: In
const records = g._$SC.a || (g._$SC.a = {});
for (const address in records) showing(address, records[address]);
g._$SC.reg = showing;
// The re-ask (see `landing`): the call behind the address, as the
// handler recorded it, made again — through the same declaration shape
// it was made with (a `GET`-declared read stays a GET; declared metadata
// rides along), with no per-call options (those were the original
// caller's; this call is the boundary's own). The callable is minted
// through the late-bound RPC seam, never by importing the transport:
// the seam is filled by the time any response has been handled. Its
// response is handled like any other and lands through the host.
reask = address => {
const call = callFor(address);
const rpc = g[SERVER_FUNCTION_RPC];
if (!call || !rpc) {
if (IS_DEV)
console.error(
`Server component boundary "${address}" errored, but no call is recorded for it; ` +
`reset() cannot re-ask the server. (The address was written by hand, not by a call.)`
);
return undefined;
}
const meta: any = call.meta || {};
const ref = rpc.createServerReference(call.id);
Object.assign(ref[SERVER_FUNCTION_METADATA], meta);
return (meta.method === "GET" ? rpc.GET(ref) : ref)(...call.args);
};
configureServerFunctionsClient({ responseHandler: handler });
}
Loading