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
6 changes: 6 additions & 0 deletions .changeset/frames-traces-tier.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"solid-js": patch
"@solidjs/web": patch
---

frames: the container-trace materializer is the frames client's traces tier — `@solidjs/web/frames/trace`, loaded through the server-announced tier mechanism (`prepareTier("trace")`), so the store engine leaves every server-component page that never meets a trace (page base −6.6 KB brotli, page live −6.7 KB; frames eager −83 B). `solid-js/internal/container-trace` is a new `solid-js` entry carrying `materializeContainerTrace(marker, claiming?)` (the store engine reached through `@solidjs/signals`, the hydration dispatch `withStoreHydration` and the patch protocol read back from `solid-js`); the materializer leaves `solid-js`'s main and `solid-js/internal` entries. The eager frames client keeps the trigger: the loader entry, the held-set predicate (an adopt-time record whose args carry a `{ $tr }` marker while the tier is absent is held under frames-rulings 3.1 — its server interior on screen, hydration-done waits — and mounts with the record it was held on, a replacement applying as an args change), and the `claiming` hint (`FrameHostOptions.revive(value, claiming?)`), which keys the materializer's parked backlog on the claim again: a fresh mount reads the fold of its whole backlog at once.
67 changes: 44 additions & 23 deletions documentation/plans/frames-savings-pass.md

Large diffs are not rendered by default.

29 changes: 28 additions & 1 deletion documentation/server-components/frames-rulings.md
Original file line number Diff line number Diff line change
Expand Up @@ -1055,7 +1055,11 @@ server's node.**
child, two after a keyed sibling — per S1's note); the client's `adoptBoundary`
consumes none. S1's `.fails` pin: `container-trace-hold-id-determinism` "a
keyed sibling after the frame claims the server's node". Independent of any
hold; pre-existing on `next`.
hold; pre-existing on `next`. **The pin is on the tree since C3**
(2026-10-06, `test/hydration/container-trace-hold-id-determinism.spec.tsx`,
ported with S1's hold specs): still red on a resident run, still `.fails`,
with this ruling named as the reading it waits on (3c) — C3 changed
nothing in what the server or the adopter consumes.
- **Decides.** That pin. A parity bug under C10's rule read one level up (the
frame's own ids, not the fill's) — fix without a new ruling, but it needs the
server's consumption pinned first (it varies by position), and the fix may be
Expand Down Expand Up @@ -1185,6 +1189,29 @@ update it is. The claim pass never rewrites a hole.**
`claiming` hint (S1's `revive(value, claiming?)`, threaded from the
adopt-time mount) arrives at plan step C3 with S1 and converts the park
back to keyed-on-claim then.
- **Landed (2026-10-06, C3 — `feat/frames-traces-tier`): the park is
keyed on the claim again.** The hint is threaded as S1 had it —
`#invokeSlot`'s `adopted` → `#resolveArgs(occurrence, record, claiming)` →
`FrameHostOptions.revive(value, claiming)` → `reviveContainerTraces(value,
claiming)` → `materialize(marker, claiming)` → `materializeContainerTrace
(marker, claiming)` (now `solid-js/internal/container-trace`, the traces
tier's entry) — and the materializer parks only when `claiming` is true:
every adopt-time mount of an adopt frame (t = 0, under the tier's hold,
at a fragment's reveal, on a frame adopted after done — `ctx.adopted` is
the one invocation a consumer may answer with a claim, so it is exactly
the set of claims; the "no hydration state says claim" problem the
unconditional port worked around does not arise, since the hint is the
frame's, not the pass's). A fresh mount — a stream re-call, a codec-face
decode — reads the fold of its whole backlog at once and pays no beat.
The release order 3.2 pins holds through the tier's hold: the late claim
under `needsTrace` materializes with `claiming`, the frame's hold releases
after that sync, done after the hold, the backlog after done
(`container-trace-hold-{snapshot,hydration-end}`, `tier-trace-hold`;
solid's `container-trace.spec` pins the keyed park itself: a fresh mount
during hydration parks nothing either). The +4 B this costs on the
`@solidjs/web` server floor is `materialize`'s second parameter in the
codec plugin the SSR runtime bundles (within the minified allowance;
brotli −9).

- **Mechanism today.** `web/src/client.ts:insertExpression` under hydration is
a claim pass, not a mutation pass (C1/C9: nothing moves);
Expand Down
4 changes: 4 additions & 0 deletions packages/solid/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@
"types": "./types/internal.d.ts",
"default": "./dist/internal.js"
},
"./internal/container-trace": {
"types": "./types/client/container-trace.d.ts",
"default": "./dist/container-trace.js"
},
"./package.json": "./package.json"
},
"scripts": {
Expand Down
17 changes: 16 additions & 1 deletion packages/solid/rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,5 +85,20 @@ export default [
// protocol is `@solidjs/signals` (external, the app's one instance) and the
// server-scope seams are read back from "solid-js" (external, so the
// platform/tier conditions pick the same main build the app runs).
build("src/internal.ts", "internal", ["solid-js", "@solidjs/signals"], false, false)
build("src/internal.ts", "internal", ["solid-js", "@solidjs/signals"], false, false),
// `solid-js/internal/container-trace`: the container-trace materializer,
// its own entry because the main build is one flat module — any binding in
// it that reaches the store engine welds the engine to whoever imports the
// binding. This entry reaches `createProjection` through `@solidjs/signals`
// (external, per-module files) and reads the hydration dispatch and the
// patch protocol back from "solid-js" (external), so an app bundler can
// give the engine to the lazy chunk `@solidjs/web/frames`' traces tier
// loads it in. No tier-specific code of its own, so one build.
build(
"src/client/container-trace.ts",
"container-trace",
["solid-js", "@solidjs/signals"],
false,
false
)
];
258 changes: 258 additions & 0 deletions packages/solid/src/client/container-trace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
/**
* `solid-js/internal/container-trace` — the client half of the container tier
* at the slot border (DR-2 case 3): a server projection crosses a
* serialization boundary as its TRACE and materializes back into a live local
* projection. NOT public API (the `solid-js/internal` namespace): consumed by
* `@solidjs/web/frames`' traces tier (`@solidjs/web/frames/trace`), which the
* frames client loads lazily — the server announces the tier when it
* serializes a trace, and an un-announced marker in a record's args starts
* the load itself (frames savings pass §2) — so the store engine
* (`createProjection` and everything `@solidjs/signals/store` drags in, ~8 KB
* brotli) stays out of a server-component page that never meets one.
*
* Its own dist entry, not a member of `solid-js`: that build is one flat
* module, so any binding in it that references the engine welds the engine to
* the eager chunk the moment anything imports the binding — a lazy
* `import("solid-js/internal")` would split off a facade and leave the engine
* where it was. This module reaches `createProjection` through
* `@solidjs/signals` (per-module files the app bundler can assign to this
* chunk) and takes the hydration dispatch and the patch protocol from
* `solid-js` by name, so it behaves exactly as the wrapper did at every call
* site while retaining nothing of the engine on the eager side.
*/
import {
createProjection as coreProjection,
createRoot as coreRoot,
createSignal as coreSignal,
getOwner,
NotReadyError,
runWithOwner,
type Store
} from "@solidjs/signals";
// Read back from the main entry (external: the app's one instance, whose
// `enableHydration()` filled the adapter slot `withStoreHydration` reads).
// Property reads, not named imports, so a server-tier resolution of this
// entry (which has none of these) stays inert until something calls it.
import * as core from "solid-js";

// The seams, typed here because the main entry marks them `@internal`
// and strips them from its declarations — `sharedConfig` is public, listed
// for the same member-read discipline (the same arrangement as
// src/internal.ts). Each is a member read ON THE NAMESPACE BINDING at the
// call — never `const x = core` — so the bundler rewrites them to named
// imports of the one `solid-js` instance; a namespace that escapes into a
// variable retains every export of the flat main module (measured: +32 KB
// minified on the page, the store wrappers' engine edge included).
interface Seams {
withStoreHydration<T>(
coreFn: (fn: any, seed: any, options?: any) => T,
fn: any,
seed: any,
options?: any
): T;
applyPatches(target: any, patches: any[]): void;
forwardIteratorReturn(it: any, value?: any): any;
sharedConfig: { onHydrationEnd?: (callback: () => void) => void };
}
const applyPatches = (target: any, patches: any[]) =>
(core as unknown as Seams).applyPatches(target, patches);
const forwardIteratorReturn = (it: any, value?: any) =>
(core as unknown as Seams).forwardIteratorReturn(it, value);

/** The projection constructor with solid's hydration dispatch — what `createProjection` from `solid-js` does, minus the wrapper's own engine edge. */
const createProjection = (fn: (draft: any) => any, seed: any): Store<any> =>
(core as unknown as Seams).withStoreHydration(coreProjection as any, fn, seed);

/**
* A root with NO parent. Materialization runs at arg-read, under whatever
* owner is reading — during hydration an id-carrying one — and a root
* created there inherits the next child id, shifting every key the reader
* mints after it: a trace revived at t=0 consumed one root id while one
* revived by a late claim (no ambient owner) consumed none, and a keyed
* sibling after the frame hydrated under different keys in the two runs.
* The store is shared and memoized per trace; it belongs to no reader's id
* space.
*/
const detachedRoot = <T>(init: () => T): T => runWithOwner(null, () => coreRoot(init))!;

/** Run `callback` once hydration has completed — now (a microtask) when none is in progress. */
function afterHydration(callback: () => void) {
const onHydrationEnd = (core as unknown as Seams).sharedConfig.onHydrationEnd;
onHydrationEnd ? onHydrationEnd(callback) : queueMicrotask(callback);
}

/**
* Materialize a container TRACE — snapshot then patch batches, the
* continuation protocol a server projection serializes as when it crosses a
* boundary (hydration resume in solid-js; the slot border via the serializer's
* container plugin) — into a live local projection. The result reads like
* the server value did: not-ready until the snapshot lands, then a
* read-only store the batches keep updating, done when the trace ends.
*
* Created under a detached root (see `detachedRoot`): revival can run inside
* a render effect's owner, and the store is memoized per trace (see the
* plugin's WeakMap) — a store owned by its first reader would be disposed by
* that reader's re-render while other readers still hold it, and one rooted
* under it would take a hydration id from it. Consumption is pull-driven and
* the trace is response-bounded, so the projection settles on its own; GC
* collects the pair with the trace.
*
* Materialized for a CLAIM (`claiming` — the reader is about to hydrate
* server markup rendered from this value: a frame's adopt-time mount, at
* t=0 or deferred under its hold, frames-rulings 3.1 / 3.2), a replayed
* backlog beyond the snapshot is PARKED until hydration ends
* (`onHydrationEnd`; the next microtask when no pass is in progress — what
* a claim made after hydration-done gets). The snapshot is the state the
* server's markup shows; the claim renders the fill against that markup and
* trusts it — a text hole is never rewritten during a claim — so a store
* already past the markup left the DOM diverged from it for good (the trace
* had nothing further to emit). Applied after the claim, the backlog re-runs
* the fill's reads outside hydration and the DOM catches up: the same
* parking solid's store-shaped async-iterable hydration applies to a
* buffered backlog (frames-rulings 3.6 (iii), "the consumer parks"). The
* release order is the one 3.2 pins: claim, the frame's hold release, done,
* then the backlog. Materialized for a FRESH mount (no server markup to
* agree with — a stream re-call, a codec-face decode), nothing is parked:
* the first read is the fold of the whole backlog. Live emissions land
* after the claim by construction either way. A failure applies in order,
* after everything queued before it, so it, too, waits on a parked backlog.
*
* Consumed by the serialization layer (`@solidjs/web/frames`). Declared, not
* stripped: this entry's whole surface is the seam, and the subpath's
* namespace is what makes it non-public.
*/
export function materializeContainerTrace(
marker: {
$tr: AsyncIterable<any> | { __SEROVAL_STREAM__: true };
$ta?: number;
},
claiming?: boolean
): Store<any> {
const src = marker.$tr as any;
// Raw seroval stream (the wire shape since the stream-mint protocol):
// `.on()` replays buffered emissions SYNCHRONOUSLY, so a snapshot the
// document already delivered is applied before the first read — the store
// reads as READY during hydration's synchronous claim walk, matching the
// page's settled markup. The async-iterable branch below (pre-stream
// payloads) can only surface its buffer through microtasks, which made a
// settled-inline boundary suspend at the walk and hydrate a phantom
// fallback over settled markup (the chat welcome/status meter miss).
if (src != null && src.__SEROVAL_STREAM__ === true) {
const queue: any[] = [];
let failed: { error: any } | undefined;
let cursor = 0;
let first = true;
// How far into the queue a compute may apply: everything, except a
// claim's replayed backlog beyond the snapshot, parked until hydration
// ends (see above).
let limit = Infinity;
// Everything lives under the root (see the block comment below):
// materialization runs at arg-read inside a reader's render scope, and
// a version signal owned by that reader would be disposed by its
// re-render while the memoized store lives on.
return detachedRoot(() => {
const [version, setVersion] = coreSignal(0);
// Subscribe before creating the projection: the buffered replay runs
// synchronously inside on(), filling the queue the first compute
// drains. Replayed values must NOT bump the version — the replay can
// run inside an owned render scope where reactive writes are illegal,
// and the projection doesn't exist yet to need waking. Only live
// emissions (stream callbacks on later tasks) bump.
let live = false;
const bump = () => live && setVersion(n => n + 1);
src.on({
next(value: any) {
queue.push(value);
bump();
},
// The trace ended: the last applied state latches (same contract as
// the iterable path's `done`).
return() {},
throw(error: any) {
failed = { error };
bump();
}
});
live = true;
// The park (see above): decided here, because the projection's first
// compute runs at creation. Released at hydration end with a version
// bump, so the compute drains the backlog as one ordinary update.
if (claiming && queue.length > 1) {
limit = 1;
afterHydration(() => {
limit = Infinity;
bump();
});
}
return createProjection(
(draft: any) => {
version();
while (cursor < queue.length && cursor < limit) {
const value = queue[cursor++];
if (first) {
first = false;
// Full authoritative snapshot into a fresh {}/[] seed — pure
// writes, no draft reads (see the iterable branch below).
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) draft[i] = value[i];
draft.length = value.length;
} else {
Object.assign(draft, value);
}
} else {
applyPatches(draft, value);
}
}
// In order: after everything queued before it has applied.
if (failed && cursor === queue.length) throw failed.error;
// Nothing buffered yet (revival raced ahead of the record's data
// script): pending until the snapshot lands, marked on the
// projection's own node — the version bump reruns this compute.
if (first) throw new NotReadyError(getOwner());
},
(marker.$ta ? [] : {}) as any
);
})!;
}
// A root, not a bare null owner: the projection's async machinery routes
// its pending/error states through the owner's queue, and with no owner
// at all the internal NotReadyError (the "pending until snapshot" mark)
// surfaces as an unhandled error in dev. The root is never disposed —
// the projection settles itself when the trace ends and is collected
// with the store.
return detachedRoot(() =>
createProjection(
(draft: any) => ({
[Symbol.asyncIterator]() {
const srcIt = src[Symbol.asyncIterator]();
let first = true;
return {
next: () =>
Promise.resolve(srcIt.next()).then((res: any) => {
if (res.done) return { done: true as const, value: undefined };
if (first) {
first = false;
// The first yield is the full authoritative snapshot. The
// seed is a fresh empty {}/[] minted here, so this is pure
// writes — no reads of the draft, which is still PENDING
// (reading a pending proxy throws NotReadyError, which
// would reject this step and error the projection).
if (Array.isArray(res.value)) {
for (let i = 0; i < res.value.length; i++) draft[i] = res.value[i];
draft.length = res.value.length;
} else {
Object.assign(draft, res.value);
}
} else {
applyPatches(draft, res.value);
}
return { done: false as const, value: undefined };
}),
return: (value?: any) => forwardIteratorReturn(srcIt, value)
};
}
}),
(marker.$ta ? [] : {}) as any
)
)!;
}
Loading