From 19bed417bb7539becd41142e1ee3ef10030c42ab Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sun, 27 Sep 2026 01:14:27 -0700 Subject: [PATCH 01/21] =?UTF-8?q?docs(stage7):=20retire=20`predict`=20?= =?UTF-8?q?=E2=80=94=20Stage=207=20is=20attribute=20slots=20(=C2=A79.2.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decision record from the 2026-09-27 design pass. `predict(anchor, patch)` is retired before build. Re-deriving Stage 7 with Stage 6 and Stage 8 built found a no-verb shape: a server component spreads a *called* slot onto one of its own elements (`{...props.check({ id, completed })}`); the client fill returns attributes derived from slot args and `createOptimistic*` state; the engine `spread`s them. Server owns every node, client owns exactly the attribute values it declared. Why: it is the missing row in Stage 6's "a prop in a JSX position" taxonomy (ref lifecycle + slot-arg reactivity), so no new grammar; ownership is structural (one owner per attribute key, SSR-time error); rollback is `createOptimistic` revert, so the 08-18 machinery ledger — baselines, transaction-scoped range owners, re-assertion on the claim sweep, settlement hooks — goes to zero; live server components are the natural case rather than the engineered one; optimism is pay-for-use (pages without it carry no engine); fills render at t = 0 in document SSR. Retroactivity is the one thing given up. Rejected alongside: slot fills owning the row (the RSC pole — serializes the mutable row, two renderers per row, compounds under live). Prior art recorded: LiveView JS commands (`predict`'s lineage), Datastar bindings (same pole, no transaction, not server-rendered), Blazor (no client intent). Also: roadmap item 7 amended; §9.2 marked superseded (search record kept); §5.7 note — its "optimistic state lives in client slots" line is again literally the design; Stage 8 plan's out-of-scope pointer updated. Gate unchanged: the TodoMVC port, pass condition restated for attribute fills. Open for the build: spelling/compiler round, args duplication, fill-returned handler routing, the single-flight apply-inside-transaction flicker check. Public surface (flagged, not yet built): a fourth server-component prop use site (attribute fill), the compiler transform behind the server-components option, one `_bnd` position kind. Nothing removed. Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- .../plans/stage8-connection-transport.md | 2 +- .../server-components-principles.md | 397 +++++++++++++++++- 2 files changed, 397 insertions(+), 2 deletions(-) diff --git a/documentation/plans/stage8-connection-transport.md b/documentation/plans/stage8-connection-transport.md index 09dfebcf3..9d968b6e1 100644 --- a/documentation/plans/stage8-connection-transport.md +++ b/documentation/plans/stage8-connection-transport.md @@ -810,4 +810,4 @@ prev)`; corrected to `(prev, value)`. Pinned: Cursors as a protocol (only the `Last-Event-ID` seam), WebSocket, any subscription registry or connection-local subscription state, any new authoring API for liveness, any server configuration for liveness, Stage 7's -predictions (independent; §9.2.1). +optimism (independent; attribute slots, §9.2.2 — `predict` retired). diff --git a/documentation/server-components/server-components-principles.md b/documentation/server-components/server-components-principles.md index dfedae7b0..1b2271741 100644 --- a/documentation/server-components/server-components-principles.md +++ b/documentation/server-components/server-components-principles.md @@ -787,7 +787,11 @@ guarantees the two compose: a slot's optimistic state survives the settling morp (A7), so the overlay never flickers. (Stage 7 refines, not repeals, this line: transaction-scoped predictions may temporarily perturb server-rendered DOM, re-asserted over every authoritative apply and evaporating at settlement — the -invariant that only server records make output durable stands. See §9.2.) +invariant that only server records make output durable stands. See §9.2. +*Revised 2026-09-27, §9.2.2: predictions are retired and this paragraph is +again literally the design — the slot that holds optimistic state may be an +attribute of a server element, so "hide" and "strike-through" are `hidden` and +`class` fills, not perturbations of server-owned output.*) ### 5.8 Producer-side symmetry @@ -1037,6 +1041,19 @@ retired as a pole and survives only as potential authoring sugar. authoritative markup agrees with it (keyed content matched, attribute value asserted), on any arrival path; pending indicators settle with the transaction. No watermark, no dependency on Stage 8. + **Amended 2026-09-27 (§9.2.2): `predict` retired before build; + Stage 7 is attribute slots.** A server component spreads a + *called* slot onto one of its own elements + (`{...props.check({ id, completed })}`); the client fill returns + attributes over slot args and `createOptimistic*` state; the + engine `spread`s them. Server owns every node, client owns the + attribute values it declared — the missing row in Stage 6's + taxonomy (ref lifecycle + slot-arg reactivity). No baselines, no + re-assertion, no new engine; optimism is pay-for-use and + live-safe by construction. Retroactivity is the one thing given + up. Adds stay client JSX in a pre-placed content slot (§9.2.1's + `$key` convergence still covers off-response). Gate unchanged: + the TodoMVC port. 8. **Stage 8 — Connection-shaped transport.** Promoted from parked: the sink-lifetime separation means SSE/socket transports turn the same authored component non-terminating (generator-only-model.md §9, @@ -1537,6 +1554,12 @@ diverged simpler, and this paragraph is the record. ### 9.2 Stage 7 design — predictions: one declarative verb (settled 2026-08-18; supersedes the imperative-draft revival, overlays + entries, and the transactional draft) +*Superseded 2026-09-27 by §9.2.2: `predict` was retired before it +was built; Stage 7 is attribute slots. The text below stands as the +search record — the fifth shape died on the same kind of named cost +as the first four (the engine it needed), and §9.2.2 is the +survivor.* + Fourth and, by its structure, final form of this design. The supersession chain compressed into one night's search once the machinery was priced honestly, and the search record is the most @@ -1954,6 +1977,378 @@ watermark work item is deleted (§9.5). The only version that survives is the client-stamped ordinal, as stale-guard and resume cursor. +#### 9.2.2 Amendment — `predict` retired; optimism is attribute slots (2026-09-27) + +Recorded from the design conversation the night Stage 8 Part B +shipped. Stage 7 was left for last because it was the stage the +maintainer was least happy with: every other stage of this design +was reached with zero new API (`dynamic` + server functions + +slots + props in JSX positions), and Stage 7 alone invented a verb. +Re-deriving it from the same axioms with Stage 6 and Stage 8 built +found a shape that needs no verb. **`predict(anchor, patch)` is +retired before it was built.** The 08-18 text above and the 09-22 +amendment stay as the record of how the shape was found; nothing +in them ships. + +**The candidates, and why two lost.** + +1. *`predict` (the 08-18 design).* Client borrows server-owned + attributes for a transaction. Buys retroactivity — optimism + against any element in hand, no template change — and pays with + an engine: per-key baseline capture, transaction-scoped range + owners, re-assertion riding the claim sweep, re-targeting, + settlement hooks, and every open question listed above. The + engine lives in the frames client, eager for every server- + component page whether or not it predicts. Its lineage is + LiveView's `JS` commands (`JS.add_class |> JS.push`): declarative + client-side mutations of server markup preserved across server + patches, minus the transaction that would make rollback + automatic. +2. *Slot fills owning the row.* Each mutable row becomes a render- + prop fill (`{label}`) + rendering its own `
  • ` over `createOptimistic*` state. No new + surface at all, but it is the RSC pole: the client owns the + rendering of data the server already rendered, and as the + mutable fraction of a row grows the row degenerates to a client + component fed the full record — two renderers for one row. Under + live server components this compounds (the server re-renders + rows the client also re-renders, every tick). Rejected on the + single-copy axiom; it is the "client fork that rots." +3. *Attribute slots.* Adopted. Below. + +**The shape.** A server component spreads a *called* slot onto one +of its own elements. The client fill receives the occurrence's args +as reactive props and returns attributes; the engine `spread`s them +onto the element. The server owns every node; the client owns +exactly the attribute *values* it declared. + +```tsx +// ── server ("use server" component) ────────────────────────── +async function getTodos(filter: Filter) { + "use server"; + const todos = await db.todos.list(filter); + const remaining = todos.filter(t => !t.completed).length; + return props => ( +
    +
      + {t => ( +
    • + + {/* server content, never data */} +
    • + )}
      + {/* content slot: optimistic adds */} +
    +
    +
    + ); +} + +// ── client ─────────────────────────────────────────────────── +const [pending, setPending] = createOptimisticStore<{ + byId: Record; + adds: { tmp: string; title: string }[]; +}>({ byId: {}, adds: [] }); + +const done = (p: { id: string; completed: boolean }) => + pending.byId[p.id]?.completed ?? p.completed; + +const toggle = action(async (id: string, completed: boolean) => { + setPending(s => { s.byId[id] = { completed }; }); + await toggleTodo(id, completed); // single-flight response morphs the frame +}); +const remove = action(async (id: string) => { + setPending(s => { s.byId[id] = { removed: true }; }); + await deleteTodo(id); +}); +const add = action(async (title: string) => { + setPending(s => { s.adds.push({ tmp: crypto.randomUUID(), title }); }); + await createTodo(title); +}); +const toggleAll = action(async (ids: string[], completed: boolean) => { + setPending(s => { for (const id of ids) s.byId[id] = { completed }; }); + await toggleAllTodos(ids, completed); +}); + +const Todos = dynamic(() => getTodos(filter())); + + ({ class: { completed: done(p) }, hidden: !!pending.byId[p.id]?.removed })} + check={p => ({ checked: done(p), onChange: e => toggle(p.id, e.currentTarget.checked) })} + destroy={p => ({ onClick: () => remove(p.id) })} + pending={() => ( + {a =>
  • {a.title} Sending…
  • } + )} + count={p => {p.remaining - Object.values(pending.byId).filter(x => x.completed).length}} +/> +``` + +The three TodoMVC behaviors: + +- *Toggle.* `setPending` flips `done(p)`; `
  • ` and `` update through ordinary bindings on server nodes. + Success: the response morphs the row, `p.completed` becomes the + new value, the optimistic entry settles to the same thing. + Failure: the entry reverts, `done(p)` falls back to `p.completed`, + the checkbox corrects itself. No baseline is captured because the + client never borrowed the value — it owns it. +- *Remove.* `hidden` on the server-owned `
  • `. Success: the morph + drops the `$key` occurrence and the fill's scope disposes. + Failure: `hidden` reverts and the row is back untouched. (Same as + under `predict`, which forbade removal and used `hidden` too.) +- *Add.* The pending row is client JSX in a pre-placed content + slot — as it was under `predict`'s `append:` key and as it must + be: the server has not rendered the row, so nothing exists to + decorate. The real row arrives from the server; the pending one + evaporates with the transaction (§9.2.1's `$key` adoption covers + the off-response case). What is lost versus `predict` is position + freedom: the slot is where the author put it, not an arbitrary + anchor at call time. + +**Why this is the pole, not a compromise.** + +- *Ownership is structural.* An attribute has one owner. The server + writes it (static, in the template) or the client does (through a + fill), never both. `predict` needed a dev-mode discipline warning + for exactly this; here the conflict is detectable at SSR time when + the fill's output meets the element's static attributes. +- *Transaction-based, with no new machinery.* `createOptimistic*` + lifetimes do settlement and revert. The whole "machinery ledger" + of the 08-18 text — baseline capture, transaction-scoped range + owners, sweep consumers, settlement hooks — goes to zero because + the client owns the value instead of borrowing it. Overlapping + actions hold separate lanes as everywhere else in Solid. +- *Live-safe by construction.* A server patch delivers new args; the + derivation reruns with intent still on top. This is the case + `predict` had to engineer (re-assertion riding the claim sweep) + and the delicate part of that design. Here there is nothing to + hook. +- *Spans addresses.* Intent is client state, so switching + `getTodos("all")` → `getTodos("active")` mid-flight shows the same + optimism in the new frame. `predict` was address-scoped by design + ("predictions do not span addresses"). +- *Pay-for-use.* Pages without optimism carry nothing. Row-local + optimism (a vote button) is `createOptimistic` inside the fill: + `core/optimistic` + lanes, no store engine. TodoMVC pays for + `createOptimisticStore` because it has a counter and bulk actions + — intent shared across fills and written many-at-once wants + per-key subscriptions. That is the app's cost, not the platform's. +- *Server-rendered at t = 0.* Document SSR runs the fill inline like + any client component (the hydration-once rule), so `checked` is + in the HTML before JS with the optimistic store at base state. + After t = 0 the server never renders fills (post-load responses + carry content and args only); the client dresses the element in + the same apply pass, before paint, and the morph diffs around + client-owned keys so applied attributes survive patches. +- *It returns §5.7 to its literal text.* "Optimistic state lives in + client slots (which can overlay, badge, strike-through, or hide + server content)." Attribute slots are that sentence, made + precise: the slot is an attribute. + +**Prior art, for the record.** Datastar is the same ownership pole +reached from the hypermedia side: `data-class:completed="$_pending[id]?.completed ?? true"` +on server nodes over global client signals, re-evaluated across +morphs. Three differences, each of which is the thing we add: the +binding is an expression string in the server template rather than +a function beside the action that writes to it; there is no +transaction — the signal stays set until the server explicitly +patches it to `null` on success AND failure, overlapping requests +clear each other early, and a dead request leaks intent (which is +why their docs steer authors away from optimism entirely); and the +bindings are not server-rendered (they apply after the runtime +walks the DOM — the docs prescribe `style="display:none"` to hide +the flash), so correct pre-JS HTML needs the two-owner situation +this design forbids. LiveView's `JS` commands are `predict`'s +lineage (client ops on server nodes, preserved across patches, no +transaction). Blazor Interactive Server has no client intent at +all — every event round-trips the circuit; its answer to latency is +the persistent connection, not optimism. Theirs are optimistic +*bindings*; ours are optimistic *transactions* expressed through +bindings. + +**Against `predict`, dimension by dimension.** + +- *Declared* at the call site against any element (retroactive) vs + in the server template ahead of time. Retroactivity is the single + thing `predict` has over slots, and it is what forces its engine. +- *Owner of the attribute:* server (client borrows; baseline + captured, kept per transaction, restored) vs client (server passes + the value as an arg; no baseline exists). +- *Rollback:* restore baselines vs `createOptimistic` revert. +- *Server patch mid-flight:* re-assert after every apply vs args + update and the binding reruns. +- *Two elements, one intent:* `el.closest("li")` vs each element + named in the template (args passed twice — the wrinkle, below). +- *Wire:* `$key` + `data-id` (baseline read from the DOM) vs `$key` + + `{ id, completed }` — one boolean more per row. The title + crosses only if it is editable, which needs it on the client + anyway (the edit input's value); `predict` would have read it + back out of the label. +- *Engine:* net-new in the frames client vs a marker, SSR spread of + fill output, claim-time `spread`, morph skipping client-owned + keys — all existing code paths. +- *Failure modes:* undeclared property writes surviving rollback; + anchor replaced mid-transaction; anchor not yet materialized; + repeated predicts needing a merge rule — vs forgot to slot → + restructure; two owners → hard error. Both slot failures are + static. +- *Open questions:* every item in the 08-18 list (queue-or-warn + pre-materialization, position naming, floating geometry, + merge-or-stack, dev enforcement) is a consequence of borrowing. + None exists under slots. + +Net: `predict` buys retroactivity and one fewer boolean per row and +pays with the entire engine and every open question. Slots buy the +engine back and pay with a template declaration. + +**Fit with Stage 6 — the missing row.** §9.1's taxonomy is "one +grammar: a prop, used in a JSX position." Attribute slots are the +row it lacked: + +```text +use site server emits client resolves via +──────── ──────────── ─────────────────── +called slot record (id + args) a range it renders into +ref position claim marker on the element claim engine (per-element scope) +event position claim marker on the element delegation (dispatch-time lookup) +called, spread slot record + marker on element claim engine (per-element scope) → spread +``` + +Mechanically it is a ref prop with args and a return value: the +ref's per-element scope and lifecycle (fire on adoption, re-fire on +morph re-materialization, dispose on removal) plus the content +fill's arg reactivity (a patch on a surviving element delivers new +args into the same instance — exactly the behavior refs are +specified NOT to have, and content fills already do). The marker is +the same `_bnd=":="` attribute with one more position +kind beside `ref` and the event names. Nothing about the existing +tiers moves: + +- *Event props* stay the cheap tier, unchanged. `onChange={props.onToggle}` + is the degenerate attribute slot — constant handler, no args, no + reactivity — which is why it needs no per-element scope and rides + delegation. A row with only event props pays nothing; a row whose + attributes must react pays a scope. The events/refs tiering line + §9.1 drew now has attribute slots on the ref side. +- *Ref props* keep element-in-hand at materialization for what is + not an attribute: observers, measurement, third-party mounts, the + ref-fed `Portal` for persistent islands. +- *Content slots* unchanged; adds go through them. + +The rules that keep them apart, all static: + +- **One owner per attribute key.** Static attribute or event prop on + the element AND the same key in a fill's output is a conflict — + a hard error at SSR time, not a warning. +- **`class` and `style` merge in object form.** The server keeps + `class="toggle"`; the fill returns `class: { completed: done(p) }`; + `spread`'s classList semantics own only the named classes, so + ownership is per class name. A fill returning `class` as a string + clobbers the server's — the same footgun client `spread` has. +- **No attribute slots inside hole interiors.** Refs are excluded + there (the owner-creation latch forbids per-element scopes in + live holes); attribute slots need the scope and inherit the + exclusion. Event props keep working in holes. Optimism inside a + hole means restructuring it into JSX — already the "behavior means + JSX with a client prop" rule. + +**What survives from the earlier text.** The `$key` substrate +(keyed morph, 08-15) — it is what keeps a fill's scope on the entity +across reordering morphs. §9.2.1's convergence ruling survives for +the one place it still applies: keyed pending content in a content +slot, confirmed off-response by an authoritative morph bringing the +same `$key` (the narrow entity-keyed reopening stands). Attribute +values no longer "settle by convergence" — they are derivations; +the optimistic entry settles with its transaction, and if the server +has not yet converged the binding shows `p.completed` as any +optimistic store does under replication lag, with `until()` on the +data face as the hold. The outcomes-vs-indicators distinction +dissolves: an indicator (`class: "saving"`) is just an attribute +derived from `isPending`, and it drops when the transaction does. +The non-negotiable invariant is unchanged and now trivially true: +the frame is derived; only an authoritative record makes output +durable, because the client never writes server-owned output. + +**Costs, accepted.** Pre-declaration in the server template +(attribute and content) — the rule everything else already follows; +retroactive optimism on an unslotted element is "restructure the +server component." The three-layer composition `examples/todos` +gets from one `createOptimisticStore(async () => …)` is written by +hand in the fill (`intent ?? p.value`) because the persistent layer +is the frame's args, not client data; the error side-channel becomes +a second keyed record the fills read. Args passed twice when two +fills on one row need the same value (`row` and `check` above). +N per-element scopes for N optimistic rows — fine at TodoMVC scale, +to be measured at HN-comment scale, and paid only by rows that need +reactivity. + +**Open, for the build.** + +- *Spelling.* The lean is the spread of a called slot, + `{...props.check(args)}`, which reads as ordinary JSX and gives + the compiler a syntactic hook (a spread whose argument is a call + on a props member) in the same round as `$key` and Stage 6's + positions. Both compilers, parity tests. +- *Args duplication.* Whether an occurrence can scope args for + several fills on one element tree, or whether two calls is simply + the honest cost. +- *Fill-returned handlers.* Apply through client `spread` (Solid's + own delegated handlers) or route into the `_bnd` binding table so + server elements keep one event mechanism. Both ride the same + up-walk; the one-owner rule already prevents double-fire. +- *The flicker check.* The single-flight frame apply must land + inside the action's transaction, so `p.completed` flips before the + optimistic entry releases. If the response morph applies after + settlement, every success flashes back for a frame. Believed true + (`applyFrameResponse` runs before the call resolves); to be proved + against the optimistic lane timing before anything else. +- *Off-response adds under live* remain §9.2.1's convergence case. + +**Public surface (flagged).** No export is removed — `predict` never +shipped. Added: a fourth use site for server-component props +(attribute fill: a called slot in spread position; `ServerComponent

    ` +widens accordingly), the compiler transform for it behind the +server-components option, and one marker position kind in `_bnd`. +Everything the client writes is `createOptimistic*`, already public. + +**Acceptance gate — Server Component TodoMVC (restated for the third +time; the gate itself does not move).** Port `examples/todos` beside +itself, preserving its delays, ~33% write failure, per-item retry, +bulk actions, filters, and overlapping transitions. Pass condition: +**every optimistic behavior is a derivation over slot args and +`createOptimistic*` state inside an attribute fill, or client JSX in +a pre-placed content slot — zero imperative DOM writes, zero +selector coupling, zero new client vocabulary.** Toggle, remove, +pending/disabled/error markup are attribute fills; add is a content +slot; counters and filter state are data-shaped. Do not build the +compiler round until add/remove/toggle success and failure, checkbox +correction, concurrent and bulk mutations, retry/error markup, +state retention across reordering morphs (focus, typed values), and +clean hydration are all shown in the port. The simplicity-parity +criterion stands, and is now pointed at the one place it can fail: +if the hand-written layering in the fills is heavier than the +store's projection in the SPA, that is the finding. + +**Machinery ledger.** No net-new engine. Touched, all existing: +the SSR serializer (spread a fill's output onto the element and emit +the marker + slot record), the compiler round (recognize the spread +position; both compilers), the claim engine (a scope per marked +element receiving reactive args), client `spread` (unchanged), the +morph (skip client-owned keys on matched elements — the same class +of exception as foreign ranges). The optimistic engine is +`@solidjs/signals`' existing `createOptimistic`/`createOptimisticStore`, +imported by the app that uses them. + +**Consequences for the roadmap.** Stage 7 is "attribute slots," not +"predictions." It is dependency-shallow in the way Stage 6 was — a +compiler round plus the claim engine, no transaction machinery, no +solid-core changes — and independent of Stage 8 in both directions. +The size-harness "hydrating + stores" row stops being Stage 7's +floor: a frames page carries the optimistic engine only if the app +imports it. + ### 9.3 Stage 8 seed — connection-shaped transport (2026-08-17) Recorded from the design conversation; nothing here is built (the From fc45c90eda42d8b1af3125752407b8abcd187f1a Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sun, 27 Sep 2026 01:23:15 -0700 Subject: [PATCH 02/21] =?UTF-8?q?docs(stage7):=20=C2=A79.2.2=20=E2=80=94?= =?UTF-8?q?=20both=20mutation=20shapes=20are=20required?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clarification: attribute slots must hold under router single-flight AND typical multi-flight. The fill is identical in both; only the hold differs, and the hold is the transaction's existing job. Stated once for three arrival paths — single-flight (response regions applied before the call resolves), multi-flight (`refresh` of the `dynamic` source / router revalidation inside the action holds the transaction until the refetched binding applies, as `examples/todos` does today), live (no hold; `until()` or §9.2.1 convergence). The worked-case comment and the flicker-check item no longer name single-flight alone. Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- .../server-components-principles.md | 57 +++++++++++++++---- 1 file changed, 45 insertions(+), 12 deletions(-) diff --git a/documentation/server-components/server-components-principles.md b/documentation/server-components/server-components-principles.md index 1b2271741..d4ef5cacc 100644 --- a/documentation/server-components/server-components-principles.md +++ b/documentation/server-components/server-components-principles.md @@ -2057,7 +2057,9 @@ const done = (p: { id: string; completed: boolean }) => const toggle = action(async (id: string, completed: boolean) => { setPending(s => { s.byId[id] = { completed }; }); - await toggleTodo(id, completed); // single-flight response morphs the frame + await toggleTodo(id, completed); // authoritative apply lands in this transaction: + // single-flight: the response's regions; + // multi-flight: refresh(Todos) / router revalidation here }); const remove = action(async (id: string) => { setPending(s => { s.byId[id] = { removed: true }; }); @@ -2089,11 +2091,38 @@ The three TodoMVC behaviors: - *Toggle.* `setPending` flips `done(p)`; `

  • ` and `` update through ordinary bindings on server nodes. - Success: the response morphs the row, `p.completed` becomes the - new value, the optimistic entry settles to the same thing. - Failure: the entry reverts, `done(p)` falls back to `p.completed`, - the checkbox corrects itself. No baseline is captured because the - client never borrowed the value — it owns it. + Success: the authoritative apply morphs the row inside the + action's transaction, `p.completed` becomes the new value, the + optimistic entry settles to the same thing. Failure: the entry + reverts, `done(p)` falls back to `p.completed`, the checkbox + corrects itself. No baseline is captured because the client never + borrowed the value — it owns it. + +**Both mutation shapes are required (clarified 2026-09-27).** The +fill is identical under router single-flight and under typical +multi-flight; only the *hold* differs, and the hold is the +transaction's existing job. The requirement, stated once for three +arrival paths: *the authoritative apply lands inside the action's +transaction, however it arrives.* + +- *Single-flight (router).* The mutation response carries the + invalidated regions; `applyFrameResponse` morphs before the call + resolves. Apply and settlement are one event. +- *Multi-flight (typical).* The mutation POST returns; the refetch + of `getTodos(filter)` is a separate request. It must be an async + source the SAME transaction tracks — `refresh(Todos)` or the + router's revalidation inside the action — so the transaction stays + open until the new binding is delivered and applied. This is + exactly how `examples/todos` holds today (`yield api.toggleTodo`, + then `refresh(todos)`); frames need `dynamic`'s source to + participate the way any async memo does. Without the hold, + `done(p)` flashes back to the old `p.completed` between the POST + resolving and the refetch landing. +- *Live (off-response).* Nothing to hold on; the transaction settles + when the mutation returns and truth arrives on the stream. `until()` + on the data face is the author's hold if wanted; otherwise it is + §9.2.1's convergence case with a possible stale interval under + replication lag, as for any optimistic store. - *Remove.* `hidden` on the server-owned `
  • `. Success: the morph drops the `$key` occurrence and the fill's scope disposes. Failure: `hidden` reverts and the row is back untouched. (Same as @@ -2298,12 +2327,16 @@ reactivity. own delegated handlers) or route into the `_bnd` binding table so server elements keep one event mechanism. Both ride the same up-walk; the one-owner rule already prevents double-fire. -- *The flicker check.* The single-flight frame apply must land - inside the action's transaction, so `p.completed` flips before the - optimistic entry releases. If the response morph applies after - settlement, every success flashes back for a frame. Believed true - (`applyFrameResponse` runs before the call resolves); to be proved - against the optimistic lane timing before anything else. +- *The flicker check, on both mutation shapes.* The authoritative + apply must land inside the action's transaction, so `p.completed` + flips before the optimistic entry releases; otherwise every + success flashes back for a frame. Single-flight: `applyFrameResponse` + runs before the call resolves — believed true, to be proved + against the optimistic lane timing. Multi-flight: a `refresh` of + the `dynamic` source (or router revalidation) inside the action + must hold the transaction until the refetched binding is applied — + the same hold `examples/todos` relies on; to be proved for frames + specifically. Both before anything else. - *Off-response adds under live* remain §9.2.1's convergence case. **Public surface (flagged).** No export is removed — `predict` never From b94cd535ec03ce952965ab1aef3039695835bc41 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sun, 27 Sep 2026 21:27:21 -0700 Subject: [PATCH 03/21] =?UTF-8?q?docs(stage7):=20=C2=A79.2.2=20=E2=80=94?= =?UTF-8?q?=20spread=20only,=20no=20compiler=20change?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correction to the record: attribute slots need no compiler round on either face. Spread is the one attribute position the shared SSR compiler defers wholesale to the runtime — `` already compiles to `ssrElement("input", [{class:"toggle"}, props.check(a)], …)` with the spread passed through as a runtime source, and `ssrElement` already brand-checks its sources. A single-attribute spelling would need brands in the per-kind attribute helpers (a compiler change), which is why spread is the only spelling. Stage 6 needed its round because handler/ref expressions are dropped at SSR compile time; spreads never are. Client face is runtime (claim engine + `spread`). Removes the compiler transform from the flagged public surface, the machinery ledger, the gate wording, the roadmap consequence, and the roadmap item 7 pointer. Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- .../server-components-principles.md | 78 +++++++++++++------ 1 file changed, 54 insertions(+), 24 deletions(-) diff --git a/documentation/server-components/server-components-principles.md b/documentation/server-components/server-components-principles.md index d4ef5cacc..effb786bf 100644 --- a/documentation/server-components/server-components-principles.md +++ b/documentation/server-components/server-components-principles.md @@ -1049,9 +1049,11 @@ retired as a pole and survives only as potential authoring sugar. engine `spread`s them. Server owns every node, client owns the attribute values it declared — the missing row in Stage 6's taxonomy (ref lifecycle + slot-arg reactivity). No baselines, no - re-assertion, no new engine; optimism is pay-for-use and - live-safe by construction. Retroactivity is the one thing given - up. Adds stay client JSX in a pre-placed content slot (§9.2.1's + re-assertion, no new engine, and no compiler change — spread is + the one attribute position SSR already hands to the runtime + whole, so `ssrElement` brand-checks the source and both compilers + stay untouched. Optimism is pay-for-use and live-safe by + construction. Retroactivity is the one thing given up. Adds stay client JSX in a pre-placed content slot (§9.2.1's `$key` convergence still covers off-response). Gate unchanged: the TodoMVC port. 8. **Stage 8 — Connection-shaped transport.** Promoted from parked: the @@ -2313,13 +2315,39 @@ N per-element scopes for N optimistic rows — fine at TodoMVC scale, to be measured at HN-comment scale, and paid only by rows that need reactivity. +**Spread only, and no compiler change — settled 2026-09-27.** The +spelling is the spread of a called slot, `{...props.check(args)}`, +and it is the ONLY attribute position on offer, for a reason that +is the constraint itself: SSR shares the compiler, and a gated +transform is the one thing this design must not need. Spread is the +one attribute position the SSR compiler defers wholesale to the +runtime. `` compiles +today, unchanged, to + +```js +_$ssrElement("input", [{ class: "toggle", type: "checkbox" }, props.check(a)], …) +``` + +— the spread expression passed through verbatim as a runtime +source, and `ssrElement` already brand-checks its sources (`$PROXY +in s` for stores and views). A slot proxy's call result is one more +branded source: the runtime emits the `_bnd` marker and slot record +and, at t = 0, runs the fill and serializes its output as +attributes. A single attribute position (`checked={props.checked(a)}`) +would NOT work this way: attribute values compile into template +text through per-kind helpers (`ssrAttribute`, boolean handling, +`ssrClassList`, `ssrStyle`, static folding), many emission sites, +some compile-time — a brand there is a compiler change. Stage 6 +needed its compiler round for the opposite reason: handler and ref +expressions are DROPPED at SSR compile time, so the compiler had to +emit the guarded `_$claim`. Spreads are never dropped. The client +face is runtime too — no client compilation of a server component +exists; the claim engine reads the marker and calls client `spread` +in a per-element scope. Both faces runtime-only; both compilers +untouched; parity is free. + **Open, for the build.** -- *Spelling.* The lean is the spread of a called slot, - `{...props.check(args)}`, which reads as ordinary JSX and gives - the compiler a syntactic hook (a spread whose argument is a call - on a props member) in the same round as `$key` and Stage 6's - positions. Both compilers, parity tests. - *Args duplication.* Whether an occurrence can scope args for several fills on one element tree, or whether two calls is simply the honest cost. @@ -2342,9 +2370,9 @@ reactivity. **Public surface (flagged).** No export is removed — `predict` never shipped. Added: a fourth use site for server-component props (attribute fill: a called slot in spread position; `ServerComponent

    ` -widens accordingly), the compiler transform for it behind the -server-components option, and one marker position kind in `_bnd`. -Everything the client writes is `createOptimistic*`, already public. +widens accordingly) and one marker position kind in `_bnd`. No +compiler option, no transform. Everything the client writes is +`createOptimistic*`, already public. **Acceptance gate — Server Component TodoMVC (restated for the third time; the gate itself does not move).** Port `examples/todos` beside @@ -2355,29 +2383,31 @@ bulk actions, filters, and overlapping transitions. Pass condition: a pre-placed content slot — zero imperative DOM writes, zero selector coupling, zero new client vocabulary.** Toggle, remove, pending/disabled/error markup are attribute fills; add is a content -slot; counters and filter state are data-shaped. Do not build the -compiler round until add/remove/toggle success and failure, checkbox +slot; counters and filter state are data-shaped. Do not call the +shape settled until add/remove/toggle success and failure, checkbox correction, concurrent and bulk mutations, retry/error markup, state retention across reordering morphs (focus, typed values), and -clean hydration are all shown in the port. The simplicity-parity +clean hydration are all shown in the port, under both mutation +shapes. The simplicity-parity criterion stands, and is now pointed at the one place it can fail: if the hand-written layering in the fills is heavier than the store's projection in the SPA, that is the finding. -**Machinery ledger.** No net-new engine. Touched, all existing: -the SSR serializer (spread a fill's output onto the element and emit -the marker + slot record), the compiler round (recognize the spread -position; both compilers), the claim engine (a scope per marked -element receiving reactive args), client `spread` (unchanged), the -morph (skip client-owned keys on matched elements — the same class -of exception as foreign ranges). The optimistic engine is +**Machinery ledger.** No net-new engine, no compiler change. +Touched, all existing and all runtime: `ssrElement` (recognize the +branded source among a spread's sources; emit the marker + slot +record; at t = 0 run the fill and serialize its output), the claim +engine (a scope per marked element receiving reactive args), client +`spread` (unchanged), the morph (skip client-owned keys on matched +elements — the same class of exception as foreign ranges). The optimistic engine is `@solidjs/signals`' existing `createOptimistic`/`createOptimisticStore`, imported by the app that uses them. **Consequences for the roadmap.** Stage 7 is "attribute slots," not -"predictions." It is dependency-shallow in the way Stage 6 was — a -compiler round plus the claim engine, no transaction machinery, no -solid-core changes — and independent of Stage 8 in both directions. +"predictions." It is shallower than Stage 6 was — runtime only, no +compiler round, the claim engine plus `ssrElement`; no transaction +machinery, no solid-core changes — and independent of Stage 8 in +both directions. The size-harness "hydrating + stores" row stops being Stage 7's floor: a frames page carries the optimistic engine only if the app imports it. From 6df3dd26f61e20f77363a82c6f5ec37da8a35b47 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sun, 27 Sep 2026 21:40:33 -0700 Subject: [PATCH 04/21] =?UTF-8?q?docs(stage7):=20=C2=A79.2.2=20=E2=80=94?= =?UTF-8?q?=20flicker=20checks=20run;=20action=20syntax=20corrected?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Results of the two hold checks (spec left unstaged pending the fix decision): single-flight holds — the mutation's body applies before the call resolves, trace `false/false → false/true → true/true → true/true`. Multi-flight does NOT hold — the refetch's `handle()` resolves the binding at header time and applies the body detached, so `yield refresh(todos)` settles before content arrives and the intent releases over stale args: `false/false → false/true → false/false`. Same root cause #2977 named for address switches, for the same address. Fix direction recorded, not made: resolve a SHOWING call's refetch when its response has applied (parity with single-flight), header-time resolution kept for cold mounts and switches. Also corrects the worked case's actions to generator form (`function*` + `yield`): a bare `await` leaves the transaction (core/action.ts), which would have put the reconciling refresh outside it. Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- .../server-components-principles.md | 59 ++++++++++++------- 1 file changed, 39 insertions(+), 20 deletions(-) diff --git a/documentation/server-components/server-components-principles.md b/documentation/server-components/server-components-principles.md index effb786bf..1e687087e 100644 --- a/documentation/server-components/server-components-principles.md +++ b/documentation/server-components/server-components-principles.md @@ -2057,23 +2057,26 @@ const [pending, setPending] = createOptimisticStore<{ const done = (p: { id: string; completed: boolean }) => pending.byId[p.id]?.completed ?? p.completed; -const toggle = action(async (id: string, completed: boolean) => { +// `yield` is the transaction-safe suspension point (a bare `await` leaves +// the transaction — core/action.ts). The authoritative apply lands inside +// this transaction: single-flight, the response's regions apply before +// the yielded call resolves; multi-flight, `yield refresh(todos)` (or the +// router's revalidation) holds it until the refetched frame has applied. +const toggle = action(function* (id: string, completed: boolean) { setPending(s => { s.byId[id] = { completed }; }); - await toggleTodo(id, completed); // authoritative apply lands in this transaction: - // single-flight: the response's regions; - // multi-flight: refresh(Todos) / router revalidation here + yield toggleTodo(id, completed); }); -const remove = action(async (id: string) => { +const remove = action(function* (id: string) { setPending(s => { s.byId[id] = { removed: true }; }); - await deleteTodo(id); + yield deleteTodo(id); }); -const add = action(async (title: string) => { +const add = action(function* (title: string) { setPending(s => { s.adds.push({ tmp: crypto.randomUUID(), title }); }); - await createTodo(title); + yield createTodo(title); }); -const toggleAll = action(async (ids: string[], completed: boolean) => { +const toggleAll = action(function* (ids: string[], completed: boolean) { setPending(s => { for (const id of ids) s.byId[id] = { completed }; }); - await toggleAllTodos(ids, completed); + yield toggleAllTodos(ids, completed); }); const Todos = dynamic(() => getTodos(filter())); @@ -2355,16 +2358,32 @@ untouched; parity is free. own delegated handlers) or route into the `_bnd` binding table so server elements keep one event mechanism. Both ride the same up-walk; the one-owner rule already prevents double-fire. -- *The flicker check, on both mutation shapes.* The authoritative - apply must land inside the action's transaction, so `p.completed` - flips before the optimistic entry releases; otherwise every - success flashes back for a frame. Single-flight: `applyFrameResponse` - runs before the call resolves — believed true, to be proved - against the optimistic lane timing. Multi-flight: a `refresh` of - the `dynamic` source (or router revalidation) inside the action - must hold the transaction until the refetched binding is applied — - the same hold `examples/todos` relies on; to be proved for frames - specifically. Both before anything else. +- *The flicker check, on both mutation shapes — RUN 2026-09-27 + (`packages/web/test/frames-optimistic-hold.spec.tsx`).* The + authoritative apply must land inside the action's transaction, so + `p.completed` flips before the optimistic entry releases; otherwise + every success flashes back for a frame. **Single-flight holds**: + `applyFlightResponse` awaits the whole body before the mutation + resolves, and the fill's trace reads `false/false → false/true → + true/true → true/true` (server/derived) — the args land under the + live intent, then the intent releases over agreeing truth. + **Multi-flight does NOT hold**: the refetch's `handle()` returns + the binding at response-HEADER time and applies the body detached, + so `yield refresh(todos)` resolves before any content arrives, the + transaction commits, the intent releases, and the trace reads + `false/false → false/true → false/false` — the flash, with the new + args still in flight. Root cause is the same one #2977 named for + address switches ("the binding resolves at header time, but the + header is not an answer"), for the same address: a refetch of a + call a boundary is SHOWING has no answer until the new content + applies. Fix direction (not yet made; a behavior change to flag): + in the transport's plain path, when `host.get(address)` has a + bound frame, resolve the call when `applyFrameResponse` completes + rather than at headers — parity with single-flight, which already + awaits the body. Cold mounts and switches to unbound addresses + keep header-time resolution (the shell gate is their hold). + Consequence: `isPending(source)` stays true through a showing + call's refetch, and `refresh()` settles when content has applied. - *Off-response adds under live* remain §9.2.1's convergence case. **Public surface (flagged).** No export is removed — `predict` never From 1ced180dfe24000d3bc930b178be518cc32abda3 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sun, 27 Sep 2026 23:29:52 -0700 Subject: [PATCH 05/21] frames: a showing call's refetch settles when its response has applied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A refetch of a server-component call that a boundary is already showing resolved at the response header, with the body applying detached. The header is not an answer for a showing call — the boundary still shows the previous render until the new content lands — so a `yield refresh(source)` inside an action settled before the refetched slot args arrived, the transaction committed, and an optimistic write over those args released onto stale truth: the fill's derived value flashed `false/false → false/true → false/false`. Same tearing #2977 closed for address switches, for the same address. `handle()`'s plain path now returns `applied.then(() => binding)` when `host.get(address)` has a bound frame: the call settles when `applyFrameResponse` completes, as a single-flight mutation's already does. Cold mounts and switches to an address nothing shows keep header-time resolution — the mount needs the binding to place the boundary and the shell gate is their hold; settling them late would block progressive streaming behind a completed body. Tests (`frames-optimistic-hold.spec.tsx`): single-flight and multi-flight optimistic holds now trace identically; a revalidation-shaped refetch reads `isPending(source)` true until the content applies while a cold call still settles at the header. Both new assertions fail without the change. Public behavior: for a showing call, the call's promise (and so `refresh()`'s and a `dynamic` source's settle) resolves on apply, not at the header; `isPending(source)` stays true through the refetch. Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- ...frames-showing-refetch-settles-on-apply.md | 5 + .../server-components-principles.md | 28 +- packages/web/frames/src/frame-transport.ts | 16 +- .../web/test/frames-optimistic-hold.spec.tsx | 315 ++++++++++++++++++ 4 files changed, 352 insertions(+), 12 deletions(-) create mode 100644 .changeset/frames-showing-refetch-settles-on-apply.md create mode 100644 packages/web/test/frames-optimistic-hold.spec.tsx diff --git a/.changeset/frames-showing-refetch-settles-on-apply.md b/.changeset/frames-showing-refetch-settles-on-apply.md new file mode 100644 index 000000000..5c521dc82 --- /dev/null +++ b/.changeset/frames-showing-refetch-settles-on-apply.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +Frames: a refetch of a server-component call that a boundary is already showing now settles when its response has applied, not at the response header. The header is not an answer for a showing call — until the new content lands the boundary still shows the previous render — so `isPending(source)` stays true through the refetch and a `yield refresh(source)` inside an action holds its transaction (and any optimistic write in it) until the refetched slot args are on screen, matching what a single-flight mutation already does. Cold mounts and switches to an address nothing shows keep header-time resolution. diff --git a/documentation/server-components/server-components-principles.md b/documentation/server-components/server-components-principles.md index 1e687087e..132ffea77 100644 --- a/documentation/server-components/server-components-principles.md +++ b/documentation/server-components/server-components-principles.md @@ -2119,10 +2119,12 @@ transaction, however it arrives.* router's revalidation inside the action — so the transaction stays open until the new binding is delivered and applied. This is exactly how `examples/todos` holds today (`yield api.toggleTodo`, - then `refresh(todos)`); frames need `dynamic`'s source to - participate the way any async memo does. Without the hold, - `done(p)` flashes back to the old `p.completed` between the POST - resolving and the refetch landing. + then `refresh(todos)`). Without the hold, `done(p)` flashes back + to the old `p.completed` between the POST resolving and the + refetch landing — which is what the transport did until + 2026-09-27: a refetch resolved at the response header. It now + settles when the response has applied for a call a boundary is + showing (see the flicker check below). - *Live (off-response).* Nothing to hold on; the transaction settles when the mutation returns and truth arrives on the stream. `until()` on the data face is the author's hold if wanted; otherwise it is @@ -2376,14 +2378,20 @@ untouched; parity is free. address switches ("the binding resolves at header time, but the header is not an answer"), for the same address: a refetch of a call a boundary is SHOWING has no answer until the new content - applies. Fix direction (not yet made; a behavior change to flag): - in the transport's plain path, when `host.get(address)` has a - bound frame, resolve the call when `applyFrameResponse` completes + applies. **Fixed the same night** in the transport's plain path + (`frame-transport.ts`, `handle()`): when `host.get(address)` has a + bound frame, the call resolves when `applyFrameResponse` completes rather than at headers — parity with single-flight, which already awaits the body. Cold mounts and switches to unbound addresses - keep header-time resolution (the shell gate is their hold). - Consequence: `isPending(source)` stays true through a showing - call's refetch, and `refresh()` settles when content has applied. + keep header-time resolution (the mount needs the binding to place + the boundary; the shell gate is their hold). With it the + multi-flight trace matches single-flight's exactly, and a + revalidation-shaped refetch (an upstream write re-asking the same + call) reads `isPending(source)` true until the new content has + applied — the same tearing #2977 closed for switches, closed for + the same address. (`refresh()` itself stays verdict-quiet by + design; its promise is what now settles on apply.) Both mutation + shapes hold. - *Off-response adds under live* remain §9.2.1's convergence case. **Public surface (flagged).** No export is removed — `predict` never diff --git a/packages/web/frames/src/frame-transport.ts b/packages/web/frames/src/frame-transport.ts index e7e409550..4f6bcf717 100644 --- a/packages/web/frames/src/frame-transport.ts +++ b/packages/web/frames/src/frame-transport.ts @@ -752,7 +752,7 @@ export function createServerComponentHandler({ host, component, onStream, interc } const version = bump(address); if (onStream) onStream(address, version, response); - applyFrameResponse(response, host, { as: address, version }).catch(err => + const applied = applyFrameResponse(response, host, { as: address, version }).catch(err => host.apply({ type: "error", id: address, @@ -760,7 +760,19 @@ export function createServerComponentHandler({ host, component, onStream, interc error: { message: String(err && err.message) } }) ); - return binding; + // A refetch of a call a boundary is SHOWING settles when its response + // has applied, not at the header. The header is not an answer (#2977 + // said it for address switches; this is the same address): until the + // new content lands the boundary still shows the previous render, so + // a reader that drove the refetch — `isPending(source)`, a `refresh` + // inside an action's transaction holding an optimistic write over the + // old slot args (§9.2.2) — must keep reading pending or it tears. The + // hold is the whole body, as a single-flight mutation's already is. + // A cold mount or a switch to an address nothing shows keeps + // header-time resolution: the mount 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. + return host.get(address) ? applied.then(() => binding) : binding; }, /** diff --git a/packages/web/test/frames-optimistic-hold.spec.tsx b/packages/web/test/frames-optimistic-hold.spec.tsx new file mode 100644 index 000000000..4b1dbac5e --- /dev/null +++ b/packages/web/test/frames-optimistic-hold.spec.tsx @@ -0,0 +1,315 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ +// The hold optimism over server components depends on (principles §9.2.2): +// an optimistic write made in an action must still be live when the +// AUTHORITATIVE slot args land, on both mutation shapes — otherwise a fill +// deriving `intent ?? p.value` flashes the old server value between the +// optimistic release and the new args. The fill here is a content slot; the +// invariant is the same one attribute fills will rely on. +// +// single-flight — the mutation's response carries the invalidated region; +// the handler applies it before the call resolves. +// multi-flight — the mutation returns plain data; the action then +// `refresh`es the source the boundary reads, and the +// refetched region arrives on its own response. +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + action, + createMemo, + createOptimistic, + createRoot, + createSignal, + isPending, + refresh, + Loading +} from "solid-js"; +import { dynamic } from "../src/index.js"; +import { installServerComponents } from "../frames/src/client.js"; +import { flightCodec } from "../frames/src/frame-transport.js"; +import { createServerReference } from "../server-functions/src/client.js"; +import { + BODY_FORMAT_HEADER, + BodyFormat, + ChunkReader, + SINGLE_FLIGHT_HEADER, + createChunk, + serializeStream +} from "../server-functions/src/shared.js"; +import { makeHost, frameResponse, openFrameResponse, pump } from "./lifecycle-matrix/harness.js"; + +const TODOS = "hold/todos"; +const listHtml = "

    "; +const rowChunk = (id: string, version: number, completed: boolean) => ({ + type: "slot", + id, + version, + key: "row#0", + args: { id: "1", completed } +}); + +/** + * A HELD single-flight frame response: headers now, body fed by the test. + * The region's chunks are followed by the envelope as `outcome` chunks + * (the codec's own nodes, as `frameFlightResponse` writes them). + */ +function openFlightResponse() { + let controller!: ReadableStreamDefaultController; + const body = new ReadableStream({ + start(c) { + controller = c; + } + }); + return { + response: new Response(body, { + headers: { + "Content-Type": "application/x-frame-stream", + "X-Frame-Stream": "", + [SINGLE_FLIGHT_HEADER]: "true" + } + }), + send(chunk: any) { + controller.enqueue(createChunk(JSON.stringify(chunk))); + }, + async outcome(envelope: unknown) { + const reader = new ChunkReader(serializeStream(envelope, flightCodec(undefined))); + for (let node = await reader.next(); !node.done; node = await reader.next()) + controller.enqueue(createChunk(JSON.stringify({ type: "outcome", payload: node.value }))); + }, + close() { + controller.close(); + } + }; +} + +function plainResponse(value: unknown) { + return new Response(JSON.stringify(value), { + headers: { [BODY_FORMAT_HEADER]: BodyFormat.Json } + }); +} + +/** Mount ``; the fill traces every + * re-derivation as `server/derived` and renders the derived value. */ +function mount(Comp: any, done: (p: any) => boolean, trace: string[]) { + const container = document.createElement("div"); + document.body.appendChild(container); + let div!: HTMLDivElement; + const dispose = createRoot(d => { +
    + fallback}> + { + createMemo(() => trace.push(`${p.completed}/${done(p)}`)); + return
  • {String(done(p))}
  • ; + }} + /> + + ; + container.appendChild(div); + return d; + }); + return { + text: () => div.querySelector("li")?.textContent ?? div.textContent, + cleanup() { + dispose(); + container.remove(); + } + }; +} + +afterEach(() => vi.unstubAllGlobals()); + +describe("optimism over server components holds until the authoritative args land", () => { + test("single-flight: the region applies before the mutation resolves; the derived value never flashes", async () => { + const { host } = makeHost(); + installServerComponents(host); + const getTodos = createServerReference("hold/todos"); + const toggleTodo = createServerReference("hold/toggle-sf"); + + const flight = openFlightResponse(); + const urls: string[] = []; + vi.stubGlobal("fetch", async (input: any) => { + urls.push(typeof input === "string" ? input : input.url); + if (urls.length === 1) + return frameResponse(TODOS, [ + { type: "start", id: TODOS, version: 1 }, + rowChunk(TODOS, 1, false), + { type: "html", id: TODOS, version: 1, html: listHtml }, + { type: "complete", id: TODOS, version: 1 } + ]); + return flight.response; + }); + + const [pending, setPending] = createRoot(() => createOptimistic>({})); + const done = (p: any) => pending()[p.id] ?? p.completed; + const trace: string[] = []; + const Todos = dynamic(() => getTodos() as any); + const m = mount(Todos, done, trace); + await pump(); + expect(m.text()).toBe("false"); + expect(trace).toEqual(["false/false"]); + + const toggle = action(function* (id: string, completed: boolean) { + setPending(p => ({ ...p, [id]: completed })); + return yield toggleTodo(id, completed); + }); + const result = toggle("1", true); + await pump(); + // optimistic, server still says false + expect(m.text()).toBe("true"); + expect(trace.at(-1)).toBe("false/true"); + + // The server answers: the region for the invalidated call, then the + // envelope. Nothing resolves until the whole body is applied. + flight.send({ type: "start", id: TODOS, version: 1 }); + flight.send(rowChunk(TODOS, 1, true)); + flight.send({ type: "html", id: TODOS, version: 1, html: listHtml }); + flight.send({ type: "complete", id: TODOS, version: 1 }); + await flight.outcome({ value: "ok", data: {} }); + flight.close(); + await expect(result).resolves.toBe("ok"); + await pump(3); + + expect(m.text()).toBe("true"); + // the authoritative args landed while the optimistic value was live + // (true/true), then the intent released over agreeing truth (the second + // true/true) — the derived value never read false after the write + expect(trace).toEqual(["false/false", "false/true", "true/true", "true/true"]); + expect(pending()).toEqual({}); // settled: the intent has released + + m.cleanup(); + }); + + test("multi-flight: `refresh` of the source inside the action holds until the refetched args apply", async () => { + const { host } = makeHost(); + installServerComponents(host); + const getTodos = createServerReference("hold/todos"); + const toggleTodo = createServerReference("hold/toggle-mf"); + + const refetch = openFrameResponse(TODOS); + const urls: string[] = []; + vi.stubGlobal("fetch", async (input: any) => { + urls.push(typeof input === "string" ? input : input.url); + if (urls.length === 1) + return frameResponse(TODOS, [ + { type: "start", id: TODOS, version: 1 }, + rowChunk(TODOS, 1, false), + { type: "html", id: TODOS, version: 1, html: listHtml }, + { type: "complete", id: TODOS, version: 1 } + ]); + if (urls.length === 2) return plainResponse("ok"); + // the refetch: headers now (the binding resolves), body held + return refetch.response; + }); + + const [pending, setPending] = createRoot(() => createOptimistic>({})); + const done = (p: any) => pending()[p.id] ?? p.completed; + const trace: string[] = []; + const todos = createRoot(() => createMemo(() => getTodos() as any)); + const Todos = dynamic(() => todos()); + const m = mount(Todos, done, trace); + await pump(); + expect(m.text()).toBe("false"); + expect(trace).toEqual(["false/false"]); + + const toggle = action(function* (id: string, completed: boolean) { + setPending(p => ({ ...p, [id]: completed })); + const r = yield toggleTodo(id, completed); + yield refresh(todos); + return r; + }); + const result = toggle("1", true); + await pump(3); + expect(urls).toHaveLength(3); // initial, mutation, refetch + // The mutation returned and the refetch's headers arrived, but its body + // has not: the optimistic value must still be showing. + expect(m.text()).toBe("true"); + expect(trace.slice(1)).not.toContain("false/false"); + + refetch.send({ type: "start", id: TODOS, version: 2 }); + refetch.send(rowChunk(TODOS, 2, true)); + refetch.send({ type: "html", id: TODOS, version: 2, html: listHtml }); + refetch.send({ type: "complete", id: TODOS, version: 2 }); + refetch.close(); + await expect(result).resolves.toBe("ok"); + await pump(3); + + expect(m.text()).toBe("true"); + // same shape as single-flight: args under live intent, then release + expect(trace).toEqual(["false/false", "false/true", "true/true", "true/true"]); + expect(pending()).toEqual({}); + + m.cleanup(); + }); + + test("a refetch of a SHOWING call reads pending until its content applies; a cold call still settles at the header", async () => { + const { host } = makeHost(); + installServerComponents(host); + const getTodos = createServerReference("hold/todos"); + + const first = openFrameResponse(TODOS); + const second = openFrameResponse(TODOS); + const urls: string[] = []; + vi.stubGlobal("fetch", async (input: any) => { + urls.push(typeof input === "string" ? input : input.url); + return urls.length === 1 ? first.response : second.response; + }); + + // A revalidation-shaped refetch: an upstream write re-asks the same call + // (the router's query invalidation). `refresh()` itself is verdict-quiet + // by design; a write is what `isPending` reports. + const [tick, setTick] = createSignal(0); + const todos = createRoot(() => createMemo(() => (tick(), getTodos() as any))); + const Todos = dynamic(() => todos()); + const probe: string[] = []; + const container = document.createElement("div"); + document.body.appendChild(container); + const dispose = createRoot(d => { + container.appendChild( +
    + {isPending(todos) ? "pending" : "idle"} + fallback}> +
  • {String(p.completed)}
  • } /> +
    +
    + ); + return d; + }); + const read = () => { + probe.push(container.querySelector("span")!.textContent!); + return probe.at(-1); + }; + await pump(); + // Cold: the binding resolved at the header; the boundary is mounted and + // its shell gate is what holds the fallback. The source itself is idle. + expect(container.textContent).toContain("fallback"); + expect(read()).toBe("idle"); + first.send({ type: "start", id: TODOS, version: 1 }); + first.send(rowChunk(TODOS, 1, false)); + first.send({ type: "html", id: TODOS, version: 1, html: listHtml }); + first.send({ type: "complete", id: TODOS, version: 1 }); + first.close(); + await pump(); + expect(container.querySelector("li")!.textContent).toBe("false"); + + // Showing: the refetch's header is not an answer — the source reads + // pending until the new content has applied, old content stays. + setTick(1); + await pump(3); + expect(urls).toHaveLength(2); + expect(container.querySelector("li")!.textContent).toBe("false"); + expect(read()).toBe("pending"); + second.send({ type: "start", id: TODOS, version: 2 }); + second.send(rowChunk(TODOS, 2, true)); + second.send({ type: "html", id: TODOS, version: 2, html: listHtml }); + second.send({ type: "complete", id: TODOS, version: 2 }); + second.close(); + await pump(3); + expect(container.querySelector("li")!.textContent).toBe("true"); + expect(read()).toBe("idle"); + + dispose(); + container.remove(); + }); +}); From d93e71ee71104cf4f2f72ace48b956ac25672550 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Mon, 28 Sep 2026 11:37:55 -0700 Subject: [PATCH 06/21] compiler: `$key` on a spread intrinsic compiles like a template element MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `
  • ` took the spread path, which passed `$key` through unrenamed: server markup carried a literal `$key` attribute and the frame morph, which matches keyed elements by `_key`, lost identity for every row that also spread. It now follows the template-element rule — SSR emits `_key`, a DOM compile strips it. Babel already did; the shared `keyedElements` fixtures pin the spread case for both compilers. Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- .changeset/fix-compiler-spread-element-key.md | 5 ++++ .../__dom_fixtures__/keyedElements/code.js | 11 +++++++++ .../__dom_fixtures__/keyedElements/output.js | 19 +++++++++++++++ .../keyedElements/code.js | 11 +++++++++ .../keyedElements/output.js | 24 +++++++++++++++++++ .../__ssr_fixtures__/keyedElements/code.js | 11 +++++++++ .../__ssr_fixtures__/keyedElements/output.js | 23 +++++++++++++++++- .../dom-hydratable/keyedElements/output.js | 15 ++++++++++++ .../fixtures/dom/keyedElements/output.js | 13 ++++++++++ .../fixtures/ssr/keyedElements/output.js | 16 +++++++++++++ packages/compiler/src/dom/spread.rs | 8 +++++++ 11 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-compiler-spread-element-key.md diff --git a/.changeset/fix-compiler-spread-element-key.md b/.changeset/fix-compiler-spread-element-key.md new file mode 100644 index 000000000..c1d3c114f --- /dev/null +++ b/.changeset/fix-compiler-spread-element-key.md @@ -0,0 +1,5 @@ +--- +"@solidjs/compiler": patch +--- + +Native compiler: `$key` on an intrinsic element with a spread (`
  • `) now follows the same rule as on a template element — SSR compiles it to the `_key` attribute the frame morph matches keyed elements by, and a DOM compile strips it. It previously passed through the spread path unrenamed, so server markup carried a literal `$key` attribute and keyed morphs lost identity. Babel already behaved this way; the shared `keyedElements` fixtures pin the spread case for both. diff --git a/packages/babel-plugin/test/__dom_fixtures__/keyedElements/code.js b/packages/babel-plugin/test/__dom_fixtures__/keyedElements/code.js index 7c3d2df7b..bf9ded58b 100644 --- a/packages/babel-plugin/test/__dom_fixtures__/keyedElements/code.js +++ b/packages/babel-plugin/test/__dom_fixtures__/keyedElements/code.js @@ -18,3 +18,14 @@ const dynamicKey = ( ); const componentKey = ; + +// On a spread element the same rule applies to the spread path: the key +// joins the element's sources (renamed for SSR, dropped for DOM) rather than +// the template. +const spreadKey = ( +
      +
    • + {item.text} +
    • +
    +); diff --git a/packages/babel-plugin/test/__dom_fixtures__/keyedElements/output.js b/packages/babel-plugin/test/__dom_fixtures__/keyedElements/output.js index e534a38c5..bdb81cea4 100644 --- a/packages/babel-plugin/test/__dom_fixtures__/keyedElements/output.js +++ b/packages/babel-plugin/test/__dom_fixtures__/keyedElements/output.js @@ -1,4 +1,5 @@ import { template as _$template } from "r-dom"; +import { spread as _$spread } from "r-dom"; import { createComponent as _$createComponent } from "r-dom"; import { className as _$className } from "r-dom"; import { readShallow as _$readShallow } from "r-dom"; @@ -30,3 +31,21 @@ const componentKey = _$createComponent(Row, { return item.text; } }); + +// On a spread element the same rule applies to the spread path: the key +// joins the element's sources (renamed for SSR, dropped for DOM) rather than +// the template. +var _el$4 = _tmpl$2(), + _el$5 = _el$4.firstChild; +_$spread( + _el$5, + [ + { + class: "todo" + }, + () => item.attrs + ], + true +); +_$insert(_el$5, () => item.text); +const spreadKey = _el$4; diff --git a/packages/babel-plugin/test/__dom_hydratable_fixtures__/keyedElements/code.js b/packages/babel-plugin/test/__dom_hydratable_fixtures__/keyedElements/code.js index 7c3d2df7b..bf9ded58b 100644 --- a/packages/babel-plugin/test/__dom_hydratable_fixtures__/keyedElements/code.js +++ b/packages/babel-plugin/test/__dom_hydratable_fixtures__/keyedElements/code.js @@ -18,3 +18,14 @@ const dynamicKey = ( ); const componentKey = ; + +// On a spread element the same rule applies to the spread path: the key +// joins the element's sources (renamed for SSR, dropped for DOM) rather than +// the template. +const spreadKey = ( +
      +
    • + {item.text} +
    • +
    +); diff --git a/packages/babel-plugin/test/__dom_hydratable_fixtures__/keyedElements/output.js b/packages/babel-plugin/test/__dom_hydratable_fixtures__/keyedElements/output.js index 776c4eac7..4c8237c51 100644 --- a/packages/babel-plugin/test/__dom_hydratable_fixtures__/keyedElements/output.js +++ b/packages/babel-plugin/test/__dom_hydratable_fixtures__/keyedElements/output.js @@ -1,4 +1,6 @@ import { template as _$template } from "r-dom"; +import { runHydrationEvents as _$runHydrationEvents } from "r-dom"; +import { spread as _$spread } from "r-dom"; import { createComponent as _$createComponent } from "r-dom"; import { className as _$className } from "r-dom"; import { readShallow as _$readShallow } from "r-dom"; @@ -35,3 +37,25 @@ const componentKey = _$createComponent(Row, { return item.text; } }); + +// On a spread element the same rule applies to the spread path: the key +// joins the element's sources (renamed for SSR, dropped for DOM) rather than +// the template. +var _el$4 = _$getNextElement(_tmpl$2), + _el$5 = _el$4.firstChild; +_$spread( + _el$5, + [ + { + class: "todo" + }, + () => item.attrs + ], + true +); +_$insert( + _el$5, + _$scope(() => item.text) +); +_$runHydrationEvents(); +const spreadKey = _el$4; diff --git a/packages/babel-plugin/test/__ssr_fixtures__/keyedElements/code.js b/packages/babel-plugin/test/__ssr_fixtures__/keyedElements/code.js index 1554b204f..98e2e2375 100644 --- a/packages/babel-plugin/test/__ssr_fixtures__/keyedElements/code.js +++ b/packages/babel-plugin/test/__ssr_fixtures__/keyedElements/code.js @@ -18,3 +18,14 @@ const dynamicKey = ( ); const componentKey = ; + +// On a spread element the same rule applies to the spread path: the key +// joins the element's sources (renamed for SSR, dropped for DOM) rather than +// the template. +const spreadKey = ( +
      +
    • + {item.text} +
    • +
    +); diff --git a/packages/babel-plugin/test/__ssr_fixtures__/keyedElements/output.js b/packages/babel-plugin/test/__ssr_fixtures__/keyedElements/output.js index a9ef43ce9..523cc2748 100644 --- a/packages/babel-plugin/test/__ssr_fixtures__/keyedElements/output.js +++ b/packages/babel-plugin/test/__ssr_fixtures__/keyedElements/output.js @@ -1,10 +1,12 @@ +import { ssrElement as _$ssrElement } from "r-server"; import { ssrGroup as _$ssrGroup } from "r-server"; import { ssrClassName as _$ssrClassName } from "r-server"; import { ssrAttribute as _$ssrAttribute } from "r-server"; import { escape as _$escape } from "r-server"; import { ssr as _$ssr } from "r-server"; var _tmpl$ = '
    • Apple
    ', - _tmpl$2 = ["
      ', "
    "]; + _tmpl$2 = ["
      ', "
    "], + _tmpl$3 = ["
      ", "
    "]; // `$key` on an intrinsic element compiles to the `_key` attribute the // frame morph matches keyed elements by. Static keys inline into the // template; dynamic keys render as ordinary dynamic attributes. On a @@ -25,3 +27,22 @@ const componentKey = Row({ return item.text; } }); + +// On a spread element the same rule applies to the spread path: the key +// joins the element's sources (renamed for SSR, dropped for DOM) rather than +// the template. +var _v$4 = _$ssrElement( + "li", + [ + { + get _key() { + return item.id; + }, + class: "todo" + }, + () => item.attrs + ], + () => _$escape(item.text), + false +); +const spreadKey = _$ssr(_tmpl$3, _v$4); diff --git a/packages/compiler/__tests__/fixtures/dom-hydratable/keyedElements/output.js b/packages/compiler/__tests__/fixtures/dom-hydratable/keyedElements/output.js index 89a1c85da..619f399ff 100644 --- a/packages/compiler/__tests__/fixtures/dom-hydratable/keyedElements/output.js +++ b/packages/compiler/__tests__/fixtures/dom-hydratable/keyedElements/output.js @@ -3,9 +3,11 @@ import { getNextElement as _$getNextElement } from "r-dom"; import { insert as _$insert } from "r-dom"; import { scope as _$scope } from "r-dom"; import { createComponent as _$createComponent } from "r-dom"; +import { spread as _$spread } from "r-dom"; import { readShallow as _$readShallow } from "r-dom"; import { className as _$className } from "r-dom"; import { effect as _$effect } from "r-dom"; +import { runHydrationEvents as _$runHydrationEvents } from "r-dom"; var _tmpl$ = /* @__PURE__ */ _$template(`
    • Apple`); var _tmpl$2 = /* @__PURE__ */ _$template(`
      • `); // `$key` is server markup identity (SSR-only): a DOM compile strips it from @@ -31,3 +33,16 @@ const componentKey = _$createComponent(Row, { return item.text; } }); +var _el$4 = _$getNextElement(_tmpl$2); +var _el$5 = _el$4.firstChild; +_$spread(_el$5, [{ class: "todo" }, () => { + return item.attrs; +}], true); +_$insert(_el$5, _$scope(() => { + return item.text; +})); +_$runHydrationEvents(); +// On a spread element the same rule applies to the spread path: the key +// joins the element's sources (renamed for SSR, dropped for DOM) rather than +// the template. +const spreadKey = _el$4; diff --git a/packages/compiler/__tests__/fixtures/dom/keyedElements/output.js b/packages/compiler/__tests__/fixtures/dom/keyedElements/output.js index 59b570c4b..dded0ad8c 100644 --- a/packages/compiler/__tests__/fixtures/dom/keyedElements/output.js +++ b/packages/compiler/__tests__/fixtures/dom/keyedElements/output.js @@ -1,6 +1,7 @@ import { template as _$template } from "r-dom"; import { insert as _$insert } from "r-dom"; import { createComponent as _$createComponent } from "r-dom"; +import { spread as _$spread } from "r-dom"; import { readShallow as _$readShallow } from "r-dom"; import { className as _$className } from "r-dom"; import { effect as _$effect } from "r-dom"; @@ -29,3 +30,15 @@ const componentKey = _$createComponent(Row, { return item.text; } }); +var _el$4 = _tmpl$2(); +var _el$5 = _el$4.firstChild; +_$spread(_el$5, [{ class: "todo" }, () => { + return item.attrs; +}], true); +_$insert(_el$5, () => { + return item.text; +}); +// On a spread element the same rule applies to the spread path: the key +// joins the element's sources (renamed for SSR, dropped for DOM) rather than +// the template. +const spreadKey = _el$4; diff --git a/packages/compiler/__tests__/fixtures/ssr/keyedElements/output.js b/packages/compiler/__tests__/fixtures/ssr/keyedElements/output.js index 1707761c0..4bc54bf9d 100644 --- a/packages/compiler/__tests__/fixtures/ssr/keyedElements/output.js +++ b/packages/compiler/__tests__/fixtures/ssr/keyedElements/output.js @@ -3,6 +3,7 @@ import { ssr as _$ssr } from "r-server"; import { ssrAttribute as _$ssrAttribute } from "r-server"; import { ssrClassName as _$ssrClassName } from "r-server"; import { ssrGroup as _$ssrGroup } from "r-server"; +import { ssrElement as _$ssrElement } from "r-server"; var _tmpl$ = "
        • Apple
        "; var _tmpl$2 = [ "
          ", "
        " ]; +var _tmpl$3 = ["
          ", "
        "]; // `$key` on an intrinsic element compiles to the `_key` attribute the // frame morph matches keyed elements by. Static keys inline into the // template; dynamic keys render as ordinary dynamic attributes. On a @@ -30,3 +32,17 @@ const componentKey = Row({ return item.text; } }); +var _v$4 = _$ssrElement("li", [{ + get _key() { + return item.id; + }, + class: "todo" +}, () => { + return item.attrs; +}], () => { + return _$escape(item.text); +}, false); +// On a spread element the same rule applies to the spread path: the key +// joins the element's sources (renamed for SSR, dropped for DOM) rather than +// the template. +const spreadKey = _$ssr(_tmpl$3, _v$4); diff --git a/packages/compiler/src/dom/spread.rs b/packages/compiler/src/dom/spread.rs index 135d72126..d9f12505d 100644 --- a/packages/compiler/src/dom/spread.rs +++ b/packages/compiler/src/dom/spread.rs @@ -69,6 +69,14 @@ impl<'a> AstDomTransform<'a, '_> { { continue; } + // `$key` is server markup identity: a DOM compile strips + // it (Babel's `renameElementKey` removes the attribute + // before the spread is processed; shared/attr_plan.rs is + // the template path's copy of the same rule). + if matches!(&attr.name, oxc_ast::ast::JSXAttributeName::Identifier(name) if name.name == "$key") + { + continue; + } running_props.push(self.spread_attribute_property(attr, tag_name)?); } } From 574c9aa9c79befff29a10e252c94487b07f7734a Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Mon, 28 Sep 2026 11:38:14 -0700 Subject: [PATCH 07/21] web: dynamic's address delivery is an owned write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A kept resolution's address delivery tripped the dev owned-scope write guard when the source is a memo that already settled the server-component call — `todos = createMemo(() => getTodos())`, `dynamic(() => todos())`, `refresh(todos)` in an action (the multi-flight shape; the hydrated document's first refetch takes the same path). The delivery then runs inside `dynamic`'s own compute rather than a promise microtask, and dev threw `REACTIVE_WRITE_IN_OWNED_SCOPE` into the nearest error boundary on the first refetch. The per-site address signal is created with `ownedWrite`: nothing in that compute reads it back, so the guard has nothing to protect. Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- .changeset/fix-dynamic-memo-source-delivery.md | 5 +++++ packages/web/src/index.ts | 12 +++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-dynamic-memo-source-delivery.md diff --git a/.changeset/fix-dynamic-memo-source-delivery.md b/.changeset/fix-dynamic-memo-source-delivery.md new file mode 100644 index 000000000..7450b1cec --- /dev/null +++ b/.changeset/fix-dynamic-memo-source-delivery.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +`dynamic`: a kept resolution's address delivery no longer trips the dev owned-scope write guard when the source is a memo that already settled the server-component call (`todos = createMemo(() => getTodos())`, `dynamic(() => todos())`, `refresh(todos)` in an action — the multi-flight shape; the hydrated document's first refetch takes the same path). The delivery then runs inside `dynamic`'s own compute rather than a promise microtask; the per-site address signal is now created with `ownedWrite`, since nothing in that compute reads it back. Before, dev builds threw `REACTIVE_WRITE_IN_OWNED_SCOPE` into the nearest error boundary on the first refetch. diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts index a5eb5d587..6ce01896e 100644 --- a/packages/web/src/index.ts +++ b/packages/web/src/index.ts @@ -481,7 +481,17 @@ export function dynamic( // at this seam. Initialize from the LATEST resolved address: the // kept binding's own `.address` is the first resolution's and // goes stale the moment a later call is kept-delivered. - const [address, setAddress] = createSignal((deliveredAddress ??= binding.address)); + // `ownedWrite`: a delivery is a write from wherever the + // resolution lands — a promise microtask for an async source, + // but INSIDE the factory's compute when the source is a memo that + // already settled the call (the multi-flight `refresh(todos)` + // shape, and the hydrated document's first refetch), and inside + // the equals gate for a pump's yield. None of those read the + // address back, so the owned-scope write guard has nothing to + // protect here. + const [address, setAddress] = createSignal((deliveredAddress ??= binding.address), { + ownedWrite: true + }); sites.add(setAddress); onCleanup(() => sites.delete(setAddress)); return untrack(() => (binding.component as any)(props, address)); From 8dc1b2958d9d6b8f8e46e99a60b55a036127b79c Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Mon, 28 Sep 2026 11:38:28 -0700 Subject: [PATCH 08/21] compilers: `serverComponents` keeps attribute-slot positions bindable Under the SSR `serverComponents` option a server component's dynamic `class`/`style` compiled to a value inside the template's quotes (`class="${ssrClassName(x)}"`), where an attribute-slot stand-in has no place to leave its position marker. It now compiles to a whole-attribute `_$ssrElementAttribute` hole, so `class={{ selected: filters.all }}` marks the class name a client fill owns. `ref`/`on*` positions still collect into one guarded `_$ssrClaim` hole per element; the helper now emits `_s:on:*`/`_s:ref` markers for slot reads instead of the `_bnd` behavior-claim marker, which is gone. Both compilers share the `attributeSlots` fixture (Babel source, native output compared); `behaviorClaims` is deleted with the mechanism. Plain SSR output is byte-identical. Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- ...ver-components-attribute-slot-positions.md | 6 ++ packages/babel-plugin/README.md | 2 +- packages/babel-plugin/src/ssr/element.ts | 34 ++++++- .../attributeSlots/code.js | 34 +++++++ .../attributeSlots/output.js | 94 +++++++++++++++++++ .../behaviorClaims/code.js | 22 ----- .../behaviorClaims/output.js | 52 ---------- .../attributeSlots/output.js | 65 +++++++++++++ .../behaviorClaims/output.js | 35 ------- .../ssr-server-components-fixtures.test.js | 16 ++-- packages/compiler/src/compiler.rs | 3 +- packages/compiler/src/config.rs | 8 +- packages/compiler/src/ssr/transform.rs | 48 +++++++++- 13 files changed, 291 insertions(+), 128 deletions(-) create mode 100644 .changeset/compiler-server-components-attribute-slot-positions.md create mode 100644 packages/babel-plugin/test/__ssr_server_components_fixtures__/attributeSlots/code.js create mode 100644 packages/babel-plugin/test/__ssr_server_components_fixtures__/attributeSlots/output.js delete mode 100644 packages/babel-plugin/test/__ssr_server_components_fixtures__/behaviorClaims/code.js delete mode 100644 packages/babel-plugin/test/__ssr_server_components_fixtures__/behaviorClaims/output.js create mode 100644 packages/compiler/__tests__/fixtures/ssr-server-components/attributeSlots/output.js delete mode 100644 packages/compiler/__tests__/fixtures/ssr-server-components/behaviorClaims/output.js diff --git a/.changeset/compiler-server-components-attribute-slot-positions.md b/.changeset/compiler-server-components-attribute-slot-positions.md new file mode 100644 index 000000000..f1d1c533f --- /dev/null +++ b/.changeset/compiler-server-components-attribute-slot-positions.md @@ -0,0 +1,6 @@ +--- +"@solidjs/babel-plugin": patch +"@solidjs/compiler": patch +--- + +`serverComponents` (SSR): keep attribute-slot positions bindable on intrinsic elements. A dynamic `class` or `style` now compiles to a whole-attribute `_$ssrElementAttribute` hole instead of a value inside the template's quotes, so a server component's `class={{ selected: filters.all }}` can mark the class name a client attribute slot owns; `ref`/`on*` positions compile, as before, to one guarded `_$ssrClaim` hole per element, which now emits `_s:on:*`/`_s:ref` markers for slot reads (the `_bnd` behavior-claim marker is gone). Both compilers share the `attributeSlots` server-components fixture; plain SSR output is unchanged. diff --git a/packages/babel-plugin/README.md b/packages/babel-plugin/README.md index 4ed1ec2da..379a8f28c 100644 --- a/packages/babel-plugin/README.md +++ b/packages/babel-plugin/README.md @@ -253,7 +253,7 @@ Inline style attributes in templates when the value is a string or `Record 0, attributes = normalizeAttributes(path); let children: babelTypes.JSXExpressionContainer | undefined; - // Server-components claims: ref/on* positions on server-rendered - // intrinsics collect here and emit as one guarded whole-attribute hole - // (` _bnd="..."` or "") after the loop. Evaluation is gated on the render - // context's claims flag so plain SSR never runs the expressions. + // Server-components handler positions: ref/on* expressions on + // server-rendered intrinsics collect here and emit as one guarded + // whole-attribute hole after the loop, where `ssrClaim` turns attribute-slot + // reads into `_s:on:*` / `_s:ref` markers (and drops server-local + // functions). Evaluation is gated on the render context's claims flag so + // plain SSR never runs the expressions. const claims: [string, babelTypes.Expression][] = []; attributes.forEach(attribute => { @@ -628,6 +630,30 @@ function transformAttributes( checkMember: true, checkTags: true }); + // Server components (principles §9.2.3): a dynamic `class`/`style` + // is the one attribute shape the plain SSR output serializes INSIDE + // template quotes (`class="${ssrClassName(x)}"`), where an attribute-slot + // value read at that position — the whole value, or a name's + // condition in object form — would be stringified instead of + // bound. Under the option the whole attribute is a runtime hole, + // `ssrElementAttribute("class", x)`, whose helper emits the same + // bytes for a plain value and the position marker for a stand-in. + // Object literals stay objects (no inlining) for the same reason. + if (info.serverComponents && (key === "class" || key === "style")) { + const attr = t.callExpression(registerImportMethod(path, "ssrElementAttribute"), [ + t.stringLiteral(key), + value.expression as babelTypes.Expression + ]); + results.template.push(""); + results.templateValues.push( + isDynamicValue + ? hoistExpression(path, results, t.arrowFunctionExpression([], attr), { + group: true + }) + : attr + ); + return; + } let doEscape = true; let isBoolean = t.isBooleanLiteral(value) || diff --git a/packages/babel-plugin/test/__ssr_server_components_fixtures__/attributeSlots/code.js b/packages/babel-plugin/test/__ssr_server_components_fixtures__/attributeSlots/code.js new file mode 100644 index 000000000..c2f6867d1 --- /dev/null +++ b/packages/babel-plugin/test/__ssr_server_components_fixtures__/attributeSlots/code.js @@ -0,0 +1,34 @@ +// Attribute slots (principles §9.2.3). Ref/event positions on server intrinsics +// compile to one guarded whole-attribute claim hole per element, gated on +// the render context's claims flag so plain SSR never evaluates the +// expressions. +const template = ( +
        + + + warns at render when the gate is open + + multiple refs merge to an array + +
        capture variants stay dropped
        +
        +); + +// A dynamic `class`/`style` is a whole-attribute element-attribute hole +// rather than a value inside template quotes, so a slot value read at the +// position — whole, or as a name's condition in object form — binds instead +// of stringifying. Object literals stay objects. Static strings stay static. +const dynamicToo = ( +
      • + {label()} +
      • +); + +const objects = ( +
      • + + {label()} +
      • +); diff --git a/packages/babel-plugin/test/__ssr_server_components_fixtures__/attributeSlots/output.js b/packages/babel-plugin/test/__ssr_server_components_fixtures__/attributeSlots/output.js new file mode 100644 index 000000000..51fb4ce86 --- /dev/null +++ b/packages/babel-plugin/test/__ssr_server_components_fixtures__/attributeSlots/output.js @@ -0,0 +1,94 @@ +import { ssrAttribute as _$ssrAttribute } from "r-server"; +import { ssrGroup as _$ssrGroup } from "r-server"; +import { escape as _$escape } from "r-server"; +import { ssrElementAttribute as _$ssrElementAttribute } from "r-server"; +import { ssr as _$ssr } from "r-server"; +import { ssrClaim as _$ssrClaim } from "r-server"; +import { sharedConfig as _$sharedConfig } from "r-server"; +var _tmpl$ = [ + '
        warns at render when the gate is openmultiple refs merge to an array
        capture variants stay dropped
        " + ], + _tmpl$2 = ["", ""], + _tmpl$3 = [ + "', + "" + ]; +var _v$ = + _$sharedConfig.context && _$sharedConfig.context.claims + ? _$ssrClaim({ + click: props.onCopy, + ref: props.btn + }) + : "", + _v$2 = + _$sharedConfig.context && _$sharedConfig.context.claims + ? _$ssrClaim({ + input: props.onType, + "custom-thing": props.onCustom + }) + : "", + _v$3 = + _$sharedConfig.context && _$sharedConfig.context.claims + ? _$ssrClaim({ + click: localHandler + }) + : "", + _v$4 = + _$sharedConfig.context && _$sharedConfig.context.claims + ? _$ssrClaim({ + ref: [first, second] + }) + : ""; +// Attribute slots (principles §9.2.3). Ref/event positions on server intrinsics +// compile to one guarded whole-attribute claim hole per element, gated on +// the render context's claims flag so plain SSR never evaluates the +// expressions. +const template = _$ssr(_tmpl$, _v$, _v$2, _v$3, _v$4); + +// A dynamic `class`/`style` is a whole-attribute element-attribute hole +// rather than a value inside template quotes, so a slot value read at the +// position — whole, or as a name's condition in object form — binds instead +// of stringifying. Object literals stay objects. Static strings stay static. +var _g$ = _$ssrGroup( + () => [_$ssrElementAttribute("class", status()), _$ssrElementAttribute("style", row.style)], + 2 + ), + _v$7 = + _$sharedConfig.context && _$sharedConfig.context.claims + ? _$ssrClaim({ + click: props.onPick + }) + : "", + _v$8 = () => _$escape(label()); +const dynamicToo = _$ssr(_tmpl$2, _g$, _g$, _v$7, _v$8); +var _g$2 = _$ssrGroup( + () => [ + _$ssrElementAttribute("class", { + completed: row.done, + editing: row.editing + }), + _$ssrElementAttribute("style", { + color: row.color + }) + ], + 2 + ), + _v$10 = () => _$ssrAttribute("hidden", _$escape(row.removed, true)), + _v$11 = + _$sharedConfig.context && _$sharedConfig.context.claims + ? _$ssrClaim({ + input: row.toggle + }) + : "", + _v$12 = () => _$escape(label()), + _v$1 = () => _$ssrAttribute("checked", _$escape(row.done, true)); +const objects = _$ssr(_tmpl$3, _g$2, _g$2, _v$1, _v$10, _v$11, _v$12); diff --git a/packages/babel-plugin/test/__ssr_server_components_fixtures__/behaviorClaims/code.js b/packages/babel-plugin/test/__ssr_server_components_fixtures__/behaviorClaims/code.js deleted file mode 100644 index 87a5dc2db..000000000 --- a/packages/babel-plugin/test/__ssr_server_components_fixtures__/behaviorClaims/code.js +++ /dev/null @@ -1,22 +0,0 @@ -// Ref/event positions on server intrinsics compile to one guarded -// whole-attribute claim hole per element (`_bnd`), gated on the render -// context's claims flag so plain SSR never evaluates the expressions. -const template = ( -
        - - - warns at render when the gate is open - - multiple refs merge to an array - -
        capture variants stay dropped
        -
        -); - -const dynamicToo = ( -
      • - {label()} -
      • -); diff --git a/packages/babel-plugin/test/__ssr_server_components_fixtures__/behaviorClaims/output.js b/packages/babel-plugin/test/__ssr_server_components_fixtures__/behaviorClaims/output.js deleted file mode 100644 index 23d4d31de..000000000 --- a/packages/babel-plugin/test/__ssr_server_components_fixtures__/behaviorClaims/output.js +++ /dev/null @@ -1,52 +0,0 @@ -import { escape as _$escape } from "r-server"; -import { ssrClassName as _$ssrClassName } from "r-server"; -import { ssr as _$ssr } from "r-server"; -import { ssrClaim as _$ssrClaim } from "r-server"; -import { sharedConfig as _$sharedConfig } from "r-server"; -var _tmpl$ = [ - '
        warns at render when the gate is openmultiple refs merge to an array
        capture variants stay dropped
        " - ], - _tmpl$2 = ['
      • ", "
      • "]; -var _v$ = - _$sharedConfig.context && _$sharedConfig.context.claims - ? _$ssrClaim({ - click: props.onCopy, - ref: props.btn - }) - : "", - _v$2 = - _$sharedConfig.context && _$sharedConfig.context.claims - ? _$ssrClaim({ - input: props.onType, - "custom-thing": props.onCustom - }) - : "", - _v$3 = - _$sharedConfig.context && _$sharedConfig.context.claims - ? _$ssrClaim({ - click: localHandler - }) - : "", - _v$4 = - _$sharedConfig.context && _$sharedConfig.context.claims - ? _$ssrClaim({ - ref: [first, second] - }) - : ""; -// Ref/event positions on server intrinsics compile to one guarded -// whole-attribute claim hole per element (`_bnd`), gated on the render -// context's claims flag so plain SSR never evaluates the expressions. -const template = _$ssr(_tmpl$, _v$, _v$2, _v$3, _v$4); -var _v$5 = () => _$ssrClassName(status()), - _v$6 = - _$sharedConfig.context && _$sharedConfig.context.claims - ? _$ssrClaim({ - click: props.onPick - }) - : "", - _v$7 = () => _$escape(label()); -const dynamicToo = _$ssr(_tmpl$2, _v$5, _v$6, _v$7); diff --git a/packages/compiler/__tests__/fixtures/ssr-server-components/attributeSlots/output.js b/packages/compiler/__tests__/fixtures/ssr-server-components/attributeSlots/output.js new file mode 100644 index 000000000..d0b83c563 --- /dev/null +++ b/packages/compiler/__tests__/fixtures/ssr-server-components/attributeSlots/output.js @@ -0,0 +1,65 @@ +import { escape as _$escape } from "r-server"; +import { ssr as _$ssr } from "r-server"; +import { ssrAttribute as _$ssrAttribute } from "r-server"; +import { ssrGroup as _$ssrGroup } from "r-server"; +import { ssrElementAttribute as _$ssrElementAttribute } from "r-server"; +import { ssrClaim as _$ssrClaim } from "r-server"; +import { sharedConfig as _$sharedConfig } from "r-server"; +var _tmpl$ = [ + "
        warns at render when the gate is openmultiple refs merge to an array
        capture variants stay dropped
        " +]; +var _tmpl$2 = [ + "", + "" +]; +var _tmpl$3 = [ + "", + "" +]; +var _v$ = _$sharedConfig.context && _$sharedConfig.context.claims ? _$ssrClaim({ + click: props.onCopy, + ref: props.btn +}) : "", _v$2 = _$sharedConfig.context && _$sharedConfig.context.claims ? _$ssrClaim({ + input: props.onType, + "custom-thing": props.onCustom +}) : "", _v$3 = _$sharedConfig.context && _$sharedConfig.context.claims ? _$ssrClaim({ click: localHandler }) : "", _v$4 = _$sharedConfig.context && _$sharedConfig.context.claims ? _$ssrClaim({ ref: [first, second] }) : ""; +// Attribute slots (principles §9.2.3). Ref/event positions on server intrinsics +// compile to one guarded whole-attribute claim hole per element, gated on +// the render context's claims flag so plain SSR never evaluates the +// expressions. +const template = _$ssr(_tmpl$, _v$, _v$2, _v$3, _v$4); +var _g$ = _$ssrGroup(() => { + return [_$ssrElementAttribute("class", status()), _$ssrElementAttribute("style", row.style)]; +}, 2), _v$7 = _$sharedConfig.context && _$sharedConfig.context.claims ? _$ssrClaim({ click: props.onPick }) : "", _v$8 = () => { + return _$escape(label()); +}; +// A dynamic `class`/`style` is a whole-attribute element-attribute hole +// rather than a value inside template quotes, so a slot value read at the +// position — whole, or as a name's condition in object form — binds instead +// of stringifying. Object literals stay objects. Static strings stay static. +const dynamicToo = _$ssr(_tmpl$2, _g$, _g$, _v$7, _v$8); +var _g$2 = _$ssrGroup(() => { + return [_$ssrElementAttribute("class", { + completed: row.done, + editing: row.editing + }), _$ssrElementAttribute("style", { color: row.color })]; +}, 2), _v$12 = () => { + return _$ssrAttribute("hidden", _$escape(row.removed, true)); +}, _v$13 = _$sharedConfig.context && _$sharedConfig.context.claims ? _$ssrClaim({ input: row.toggle }) : "", _v$14 = () => { + return _$escape(label()); +}, _v$11 = () => { + return _$ssrAttribute("checked", _$escape(row.done, true)); +}; +const objects = _$ssr(_tmpl$3, _g$2, _g$2, _v$11, _v$12, _v$13, _v$14); diff --git a/packages/compiler/__tests__/fixtures/ssr-server-components/behaviorClaims/output.js b/packages/compiler/__tests__/fixtures/ssr-server-components/behaviorClaims/output.js deleted file mode 100644 index 3d214ebbf..000000000 --- a/packages/compiler/__tests__/fixtures/ssr-server-components/behaviorClaims/output.js +++ /dev/null @@ -1,35 +0,0 @@ -import { escape as _$escape } from "r-server"; -import { ssr as _$ssr } from "r-server"; -import { ssrClassName as _$ssrClassName } from "r-server"; -import { ssrClaim as _$ssrClaim } from "r-server"; -import { sharedConfig as _$sharedConfig } from "r-server"; -var _tmpl$ = [ - "
        warns at render when the gate is openmultiple refs merge to an array
        capture variants stay dropped
        " -]; -var _tmpl$2 = [ - "
      • ", - "
      • " -]; -var _v$ = _$sharedConfig.context && _$sharedConfig.context.claims ? _$ssrClaim({ - click: props.onCopy, - ref: props.btn -}) : "", _v$2 = _$sharedConfig.context && _$sharedConfig.context.claims ? _$ssrClaim({ - input: props.onType, - "custom-thing": props.onCustom -}) : "", _v$3 = _$sharedConfig.context && _$sharedConfig.context.claims ? _$ssrClaim({ click: localHandler }) : "", _v$4 = _$sharedConfig.context && _$sharedConfig.context.claims ? _$ssrClaim({ ref: [first, second] }) : ""; -// Ref/event positions on server intrinsics compile to one guarded -// whole-attribute claim hole per element (`_bnd`), gated on the render -// context's claims flag so plain SSR never evaluates the expressions. -const template = _$ssr(_tmpl$, _v$, _v$2, _v$3, _v$4); -var _v$5 = () => { - return _$ssrClassName(status()); -}, _v$6 = _$sharedConfig.context && _$sharedConfig.context.claims ? _$ssrClaim({ click: props.onPick }) : "", _v$7 = () => { - return _$escape(label()); -}; -const dynamicToo = _$ssr(_tmpl$2, _v$5, _v$6, _v$7); diff --git a/packages/compiler/__tests__/ssr-server-components-fixtures.test.js b/packages/compiler/__tests__/ssr-server-components-fixtures.test.js index 303aceeff..7b6dcd58a 100644 --- a/packages/compiler/__tests__/ssr-server-components-fixtures.test.js +++ b/packages/compiler/__tests__/ssr-server-components-fixtures.test.js @@ -4,14 +4,16 @@ const { transform } = require("../index"); // Reuses the Babel plugin's server-components fixture source: under // `serverComponents: true`, ref/on* positions on intrinsic elements compile -// to one guarded `_$ssrClaim` hole per element instead of dropping. +// to one guarded `_$ssrClaim` hole per element instead of dropping, and a +// dynamic `class`/`style` becomes a whole-attribute `_$ssrElementAttribute` +// hole so an attribute-slot value read there binds instead of stringifying. const babelFixtures = path.resolve( __dirname, "../../babel-plugin/test/__ssr_server_components_fixtures__" ); const oxcFixtures = path.resolve(__dirname, "fixtures/ssr-server-components"); -const fixtures = ["behaviorClaims"]; +const fixtures = ["attributeSlots"]; function transformSsr(code, fixture, serverComponents) { return ( @@ -24,7 +26,7 @@ function transformSsr(code, fixture, serverComponents) { ); } -describe("SSR serverComponents behavior claims", () => { +describe("SSR serverComponents attribute slots", () => { it.each(fixtures)("matches generated Oxc output: %s", fixture => { const source = fs.readFileSync(path.join(babelFixtures, fixture, "code.js"), "utf8"); const output = transformSsr(source, fixture, true); @@ -36,10 +38,12 @@ describe("SSR serverComponents behavior claims", () => { expect(output).toBe(fs.readFileSync(outputPath, "utf8")); }); - it("stays inert when the option is off — refs hoist, on* drops, no claim import", () => { - const source = fs.readFileSync(path.join(babelFixtures, "behaviorClaims", "code.js"), "utf8"); - const output = transformSsr(source, "behaviorClaims", false); + it("stays inert when the option is off — refs hoist, on* drops, class/style inline", () => { + const source = fs.readFileSync(path.join(babelFixtures, "attributeSlots", "code.js"), "utf8"); + const output = transformSsr(source, "attributeSlots", false); expect(output).not.toContain("ssrClaim"); expect(output).not.toContain("sharedConfig"); + expect(output).not.toContain("ssrElementAttribute"); + expect(output).toContain("ssrClassName"); }); }); diff --git a/packages/compiler/src/compiler.rs b/packages/compiler/src/compiler.rs index 9e1869409..619f28ccb 100644 --- a/packages/compiler/src/compiler.rs +++ b/packages/compiler/src/compiler.rs @@ -115,7 +115,8 @@ pub struct CompileOptions { pub module_name: String, pub generate: Generate, pub hydratable: bool, - /// SSR-only: behavior-claim (`_bnd`) marker emission for server components. + /// SSR-only: attribute-slot position holes (`ref`/`on*` claims, whole-attribute + /// `class`/`style`) for server components. pub server_components: bool, /// SSR-only: emit each component's props literal with getters as a /// module-level constructor with shared getters (one hidden class per diff --git a/packages/compiler/src/config.rs b/packages/compiler/src/config.rs index 4c5d4245f..8a5428357 100644 --- a/packages/compiler/src/config.rs +++ b/packages/compiler/src/config.rs @@ -79,9 +79,11 @@ pub struct TransformOptions { pub validate: Option, pub omit_nested_closing_tags: Option, pub omit_last_closing_tag: Option, - /// Babel's `serverComponents`: SSR-only. `ref`/`on*` positions on - /// intrinsic elements compile to a guarded `_$ssrClaim` hole (the - /// `_bnd` behavior-claim marker) instead of dropping. + /// Babel's `serverComponents`: SSR-only. Attribute-slot positions on + /// intrinsic elements stay bindable: `ref`/`on*` compile to a guarded + /// `_$ssrClaim` hole instead of dropping, and a dynamic `class`/`style` + /// compiles to a whole-attribute `_$ssrElementAttribute` hole instead of + /// a value inside template quotes. pub server_components: Option, /// SSR-only (default `true`): a component's props literal with getters /// compiles to a module-level constructor with shared getters instead of diff --git a/packages/compiler/src/ssr/transform.rs b/packages/compiler/src/ssr/transform.rs index 185ac033c..93e3829d6 100644 --- a/packages/compiler/src/ssr/transform.rs +++ b/packages/compiler/src/ssr/transform.rs @@ -1620,6 +1620,16 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> { if name == "ref" || name.starts_with("prop:") || name.starts_with("on") { return Ok(None); } + // `$key` on an intrinsic element compiles to the `_key` attribute + // the frame morph matches keyed elements by, in a spread element's + // sources and tail exactly as in the template path + // (shared/attr_plan.rs; Babel renames the JSX attribute up front in + // `renameElementKey`, ahead of both paths). + let name = if name == "$key" { + "_key".to_string() + } else { + name + }; if in_tail && let Some(part) = self.tail_attribute(tag_name, &name, attr) { return Ok(Some(SpreadProp::Tail(name, part))); } @@ -1891,8 +1901,9 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> { .plan_attributes(&element.opening_element.attributes, &tag_name)?; let has_children = !element.children.is_empty() || outcome.children_replacement.is_some(); let mut attr_children: Option> = None; - // Server-components behavior claims: ref/on* positions collected - // across the element's attributes (Babel's `claims`). + // Server-components handler positions: ref/on* expressions + // collected across the element's attributes (Babel's `claims`), + // emitted as one `ssrClaim` hole that marks attribute-slot reads. let mut claims: std::vec::Vec<(String, Expression<'a>)> = std::vec::Vec::new(); for plan in outcome.plans { self.append_planned_attribute( @@ -2057,6 +2068,34 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> { let is_dynamic_value = !plan.marker_static && self.classify().is_dynamic(None, &expression, true); + // Server components (principles §9.2.3): a dynamic `class`/`style` + // is the one attribute shape the plain SSR output serializes INSIDE + // template quotes (`class="${ssrClassName(x)}"`), where an attribute-slot + // value read at that position — the whole value, or a name's + // condition in object form — would be stringified instead of + // bound. Under the option the whole attribute is a runtime hole, + // `ssrElementAttribute("class", x)`, whose helper emits the same + // bytes for a plain value and the position marker for a stand-in. + // Object literals stay objects (no inlining) for the same reason. + if self.server_components && (key == "class" || key == "style") { + self.uses_ssr_element_attribute = true; + let key_literal = self + .ast() + .expression_string_literal(span, self.ast().str(&key), None); + let attr = self.helper_call( + span, + "_$ssrElementAttribute", + vec![key_literal, expression], + ); + let hole = if is_dynamic_value { + let arrow = self.arrow_return_expression(span, attr); + self.hoist_expression(template, span, arrow, true, false) + } else { + attr + }; + template.push_expr(hole); + return Ok(()); + } let is_boolean = matches!(expression, Expression::BooleanLiteral(_)); let mut do_escape = !is_boolean; let mut value = expression; @@ -2697,8 +2736,9 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> { ) } - /// One guarded whole-attribute behavior-claim hole per element (Babel's - /// `claims` emission): duplicate positions merge into arrays (multiple + /// One guarded whole-attribute handler-position hole per element (Babel's + /// `claims` emission; `ssrClaim` marks attribute-slot reads as `_s:on:*` / + /// `_s:ref`): duplicate positions merge into arrays (multiple /// refs), and the expressions only evaluate when the render context's /// claims flag is set — /// `_$sharedConfig.context && _$sharedConfig.context.claims From f1b95539e8c1d645817d774548cf9b8890fa4089 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Mon, 28 Sep 2026 11:38:40 -0700 Subject: [PATCH 09/21] jsx: `$key` is typed on intrinsic elements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `$key?: string | number` on `JSX.CustomAttributes`: the entity identity the frame morph matches keyed server elements by. Both compilers already handled it (SSR emits `_key`, a DOM compile strips it); TypeScript rejected the attribute. On a component, `$key` is slot occurrence identity across responses — optional, since a repeated call is one occurrence per render with or without it. Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- .changeset/jsx-key-intrinsic-typing.md | 6 ++++++ packages/h/jsx-runtime/src/jsx.d.ts | 10 ++++++++++ packages/web/jsx/jsx-h.d.ts | 10 ++++++++++ packages/web/jsx/jsx.d.ts | 10 ++++++++++ 4 files changed, 36 insertions(+) create mode 100644 .changeset/jsx-key-intrinsic-typing.md diff --git a/.changeset/jsx-key-intrinsic-typing.md b/.changeset/jsx-key-intrinsic-typing.md new file mode 100644 index 000000000..5dc53f2b2 --- /dev/null +++ b/.changeset/jsx-key-intrinsic-typing.md @@ -0,0 +1,6 @@ +--- +"@solidjs/web": patch +"@solidjs/h": patch +--- + +JSX typings: `$key?: string | number` is declared on intrinsic elements (`JSX.CustomAttributes`), the entity identity the frame morph matches keyed server elements by. Both compilers already handled it (SSR → `_key`, DOM strips it); TypeScript rejected it. diff --git a/packages/h/jsx-runtime/src/jsx.d.ts b/packages/h/jsx-runtime/src/jsx.d.ts index bef160320..5d78b23dc 100644 --- a/packages/h/jsx-runtime/src/jsx.d.ts +++ b/packages/h/jsx-runtime/src/jsx.d.ts @@ -250,6 +250,16 @@ export namespace JSX { ref?: Ref; children?: FunctionMaybe; $ServerOnly?: boolean | undefined; + /** + * Entity identity for server markup: the frame morph matches keyed + * elements across responses by it, so client state attached to the + * element (an attribute slot's bound positions, focus) follows the entity + * through reorders and refetches. SSR compiles it to the `_key` + * attribute; a DOM compile strips it. On a component, `$key` is slot + * occurrence identity across responses (optional; a repeated call is + * one occurrence per render with or without it). + */ + $key?: string | number | undefined; } interface ExplicitProperties {} type PropAttributes = { diff --git a/packages/web/jsx/jsx-h.d.ts b/packages/web/jsx/jsx-h.d.ts index 12a74aa4f..5e45dafa8 100644 --- a/packages/web/jsx/jsx-h.d.ts +++ b/packages/web/jsx/jsx-h.d.ts @@ -251,6 +251,16 @@ export namespace JSX { ref?: Ref; children?: FunctionMaybe; $ServerOnly?: boolean | undefined; + /** + * Entity identity for server markup: the frame morph matches keyed + * elements across responses by it, so client state attached to the + * element (an attribute slot's bound positions, focus) follows the entity + * through reorders and refetches. SSR compiles it to the `_key` + * attribute; a DOM compile strips it. On a component, `$key` is slot + * occurrence identity across responses (optional; a repeated call is + * one occurrence per render with or without it). + */ + $key?: string | number | undefined; } interface ExplicitProperties {} type PropAttributes = { diff --git a/packages/web/jsx/jsx.d.ts b/packages/web/jsx/jsx.d.ts index b176c3a24..9cdceed73 100644 --- a/packages/web/jsx/jsx.d.ts +++ b/packages/web/jsx/jsx.d.ts @@ -250,6 +250,16 @@ export namespace JSX { ref?: Ref; children?: Element | undefined; $ServerOnly?: boolean | undefined; + /** + * Entity identity for server markup: the frame morph matches keyed + * elements across responses by it, so client state attached to the + * element (an attribute slot's bound positions, focus) follows the entity + * through reorders and refetches. SSR compiles it to the `_key` + * attribute; a DOM compile strips it. On a component, `$key` is slot + * occurrence identity across responses (optional; a repeated call is + * one occurrence per render with or without it). + */ + $key?: string | number | undefined; } interface ExplicitProperties {} type PropAttributes = { From 9936a69faab057a458d26658f47209f1cd7b1e87 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Mon, 28 Sep 2026 11:39:15 -0700 Subject: [PATCH 10/21] =?UTF-8?q?frames:=20attribute=20slots=20=E2=80=94?= =?UTF-8?q?=20one=20per=20data=20context,=20bound=20per=20position?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A slot renders one of two things. Placed as `` it is markup and the client owns the nodes. Called and read — `const row = props.row({ id, completed })`, then `class={row.rowClass} hidden={row.removed} onInput={row.onToggle}` — it is attribute values: the fill returns a plain object, the server template reads its properties at positions of its own elements, and the client owns exactly those positions. Each read marks the element (`_s:class="row#1:rowClass"`, `_s:on:input="row#1:onToggle"`, `_s:class="list:allDone=completed"` for a class name); the client binds every marked position from one fill per occurrence, writes what changes, dispatches events to the current handler, fires refs once, and the morph skips what a fill owns. The document face runs the fill at t=0 so values are in the HTML before JavaScript; the stream face hands out stand-ins. One call is one occurrence, grouped by data context and consumable across elements. A call repeated within a render — a component prop getter re-evaluated per position — is one occurrence and one record: by `$key` when given, by structural args once the face is known to be data otherwise. `$key` is therefore optional and names an entity for state that must follow it across responses. Placed ranges never collapse. The rule, one sentence: a slot property is a JSX attribute value, whole, and nothing else. A stand-in that is stringified, used in an expression (`Symbol.toPrimitive`; reason `coerced`), placed as text, or reached inside template quotes renders nothing on either face and reports `ATTRIBUTE_SLOT_POSITION` in dev — the document face never shows a t=0 value the stream face could not reproduce. The frame client reports the one otherwise-silent failure, an element whose markers can never bind (reason `orphan`: no fill for the prop, or a called occurrence's record never arrived). `@solidjs/web` ships `skills/server-components/SKILL.md`. Surface: `AttributeSlot` from `@solidjs/web/frames`; the `_s:*` markers; `SLOT_VALUE`/`slotValue`/`isSlotValue`, `SLOT_MARKER`, `SLOT_FACE_*`, `CLAIMS_STREAM`/`CLAIMS_DOCUMENT`; the `ATTRIBUTE_SLOT_POSITION` diagnostic. Removed: the `_bnd` behavior-claim marker and its dispatch seam, `CLAIM_PROP`, `BEHAVIOR_CLAIM_DROPPED`, the frame `props` option, `FrameHostOptions.delegate`/`FrameOptions.delegate`. Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- .changeset/frames-attribute-slots.md | 10 + documentation/solid-2.0/08-dev-diagnostics.md | 18 +- packages/signals/src/core/dev.ts | 2 +- .../skills/reactivity-diagnostics/SKILL.md | 34 +- packages/web/frames/src/client.ts | 136 +++- packages/web/frames/src/frame-client.ts | 531 +++++++++----- packages/web/frames/src/frame-sink.ts | 252 ++++++- packages/web/frames/src/server.ts | 27 + packages/web/package.json | 3 +- .../web/skills/server-components/SKILL.md | 195 ++++++ packages/web/src/client.ts | 13 +- packages/web/src/server.ts | 521 +++++++++++--- .../web/test/frames-attribute-slots.spec.tsx | 655 ++++++++++++++++++ .../web/test/frames-behavior-claims.spec.tsx | 195 ------ packages/web/test/frames-client.spec.tsx | 49 ++ .../attribute-slot-adoption.spec.tsx | 126 ++++ .../server/frame-attribute-slots.spec.tsx | 534 ++++++++++++++ packages/web/vite.config.server.mjs | 5 +- 18 files changed, 2755 insertions(+), 551 deletions(-) create mode 100644 .changeset/frames-attribute-slots.md create mode 100644 packages/web/skills/server-components/SKILL.md create mode 100644 packages/web/test/frames-attribute-slots.spec.tsx delete mode 100644 packages/web/test/frames-behavior-claims.spec.tsx create mode 100644 packages/web/test/hydration/attribute-slot-adoption.spec.tsx create mode 100644 packages/web/test/server/frame-attribute-slots.spec.tsx diff --git a/.changeset/frames-attribute-slots.md b/.changeset/frames-attribute-slots.md new file mode 100644 index 000000000..26c74ce71 --- /dev/null +++ b/.changeset/frames-attribute-slots.md @@ -0,0 +1,10 @@ +--- +"@solidjs/web": patch +"@solidjs/signals": patch +--- + +Server components: attribute slots are rebuilt as one slot per data context, bound per position; the spread shape and behavior claims are gone. A slot renders one of two things — markup, placed as ``, or **attribute values**: a called slot's result is a plain object the server template reads by property at positions (`class={row.rowClass}`, `hidden={row.removed}`, `checked={row.done}`, `onInput={row.toggle}`, `ref={row.el}`). Each read marks the element (`_s:class="row#0001:rowClass"`, `_s:on:input="row#0001:toggle"`, `_s:class="list:allDone=completed"` for a class-name read) and the client binds exactly those positions: it runs the fill once per occurrence, writes the values that change per position, dispatches events to the current handler, fires `ref` once, and a morph skips the positions a fill owns. One call is one occurrence, grouped by data context, consumable across many elements; a call repeated within a render (a component prop getter re-evaluated per read) is one occurrence and one record — by `$key` when given, by structural args otherwise, so `$key` is optional and names an entity for state that must follow it across responses. On the document face the fill runs at t=0 so values are in the HTML before JavaScript and hydration binds the same nodes; on the stream face reads yield stand-ins and the record carries the args. + +The rule, in one sentence: a slot property is a JSX attribute value, whole, and nothing else. A stand-in that is stringified, used in an expression (`Symbol.toPrimitive`; reason `coerced`), placed as text, or reached inside template quotes renders **nothing on either face** and reports `ATTRIBUTE_SLOT_POSITION` (dev), so a misuse shows on the first render rather than the first refetch. The frame client reports the one otherwise-silent failure — an element whose markers can never bind because no fill resolves for the prop, or a called occurrence's args record never arrived (reason `orphan`, `data.why` `fill`/`record`, once per occurrence). `@solidjs/web` now ships `skills/server-components/SKILL.md` (the package's `files`) with the rule, the fill idiom and the diagnostic's reasons. + +Surface added: `AttributeSlot` from `@solidjs/web/frames` — `(props: P & { $key? }) => J`, `props` optional when `P` is empty; the `_s:*` markers, `SLOT_VALUE`/`slotValue`/`isSlotValue`, `SLOT_MARKER`, `SLOT_FACE_*`, `CLAIMS_STREAM`/`CLAIMS_DOCUMENT` as the runtime surface; the `ATTRIBUTE_SLOT_POSITION` diagnostic code (reasons `spread`, `stringified`, `coerced`, `inline`, `text`, `markup`, `server-local`, `reserved-key`; client: `orphan`). Removed: the `_bnd` behavior-claim marker, `CLAIM_PROP`, `BEHAVIOR_CLAIM_DROPPED` (a server-local function at a handler position is now `ATTRIBUTE_SLOT_POSITION`), the frame `props` option and `FrameHostOptions.delegate`/`FrameOptions.delegate`. The client frame's binding reads slot values in the render effect's compute phase, so a fill of getters tracks each position's own reads. diff --git a/documentation/solid-2.0/08-dev-diagnostics.md b/documentation/solid-2.0/08-dev-diagnostics.md index 62e2727ca..8e1ab0f8f 100644 --- a/documentation/solid-2.0/08-dev-diagnostics.md +++ b/documentation/solid-2.0/08-dev-diagnostics.md @@ -697,14 +697,22 @@ Check (`warn`, dev only; kind `render`; server render and client hydrate; once p Unscoped allocation alone is not the finding: a function hole with nothing scoped after it in its template lands on the same ids on both sides and stays silent. (An `` zero-arity `fallback={() => }` thunk used to be the common instance — handed back unresolved and built by the consuming hole; `` now calls a function-valued fallback inside its own scope whatever its arity, so that shape never reaches a hole.) Each side reports the permutation it can see. The server records the counter's next id when the hole was registered (`data.registered`, argument evaluation — where the client builds it) and around its evaluation in the walk (`data.before` → `data.after`), and reports when the hole allocated at a shifted position; `data.hole` is the hole's position in its template. The client always builds in place, so it reports when the content built inside an unscoped function hole moved the counter **and** missed a server-rendered key (`Hydration key miss …` is the symptom; this is the cause). `data.name` is the function (when it has one). Scoped holes, memo and component accessors, `children()`, `` rows and the runtime's own children inserts (`spread`, `Portal`) never raise it. Fix: call the function at the hole (`{renderHead()}` — a call hole is scoped on both sides) or pass the built value. -#### `BEHAVIOR_CLAIM_DROPPED` +#### `ATTRIBUTE_SLOT_POSITION` **Messages:** -- "[BEHAVIOR_CLAIM_DROPPED] A spread on a server-rendered