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..16124f5c5
--- /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). A spread element compiles its named `ref`/`on*` (``, wherever they sit relative to the spreads) to the same claim map — `{ click: expr, ref: [a, b] }`, duplicate refs merged, a duplicate handler last-wins as on the template path — keyed by the index of the source each attribute sits before (`` → `{ 1: { click: go } }`) and handed to `_$ssrElement` as a seventh argument, a thunk the runtime reads only inside a server component's render, so plain SSR never evaluates the expressions (they used to drop with no marker and no finding); the runtime settles each handler position in source order, as the client's `spread`/`merge()` do (the last source that has the key owns it, `undefined` included). A `ref` beside a lone spread goes through the sources loop under the option (a dynamic spread call is thunked there, as it is beside any other attribute). The event name is always the lowercased `on*` remainder (`onClick` → `click`); `on:`/`oncapture:` are not 2.0 syntaxes and are no longer special-cased. Both compilers share the `attributeSlots` server-components fixture; plain SSR output is unchanged.
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/.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/.changeset/frames-attribute-slots.md b/.changeset/frames-attribute-slots.md
new file mode 100644
index 000000000..44759f995
--- /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. A stand-in passed in another slot's argument — at the top or nested in plain objects and arrays (`{ nested: { x: row.done } }`, `[row.done]`; a `Map`, `Set` or class instance is not walked) — is not data the server has: the arg carries `undefined` at that path on both faces (the record, and the document face's t=0 fill reads the same), with a finding (reason `arg`, `data.path` when nested). A cyclic arg crosses the border as a cycle (three walks used to overflow the stack on it; the border walk `toBorderForm` — every document-face serialization — is copy-on-write with no per-node bookkeeping on ordinary data, measured at or below its previous cost, and falls back to a memoized clone once a cycle is met past depth 16, with replacements reused so a shared generator is seated once). A value reachable at two arg paths crosses as one (one seat for one generator, the identity the input had); a stand-in shared between two paths is reported once, at its first. On the runtime spread path, a stand-in at a `prop:*` key is a finding (reason `prop`; the compiled form drops `prop:*` as SSR always has). Fill output keys are the fill's, save an explicit reserved set (`$`-prefixed, digit-leading, `length`, `slice`, `t`/`h`/`p`, `then`, `constructor`/`toString`/`valueOf`/`toJSON`): a key that shadows an `Array.prototype` method (`filter`, `at`, `map`) binds as data on both faces. The frame client reports the otherwise-silent failures — 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), and a fill that is not a function or returns a DOM node or a non-object where data was read (reason `fill-shape`). Handler positions get one listener per element and event, dispatching to the current fill's handlers; a rebind that releases a key clears the attribute it wrote; an occurrence's end detaches the listeners it attached, so a kept un-keyed element carries one listener (not one per occurrence that bound it, firing twice) and a dropped occurrence's handler never runs through its disposed fill. On the document face `claims` is armed on a render context derived from the page's for the component's subtree, so the page's elements after a server component keep the pre-slot spread walk. Named `ref`/`on*` on a spread element (``) bind like every other handler position: `ssrElement` takes the compiled claim map as an optional seventh argument (a thunk keyed by source index, read only under an armed render context) and reads it — and the spread's own handler keys — through `ssrClaim`'s logic (arrays of refs, merged duplicates, handler tuples, the `server-local` finding). A handler position settles in source order, as the client's `spread`/`merge()` do: the last source that _has_ the key — a spread or a named attribute at its position, whatever the value, `undefined` included — owns it, and a nullish owner binds nothing (`` binds nothing on both sides when `cond` is false); refs merge whatever their order; one marker per position (see the compiler changeset). A merged array ref (`ref={[a, b]} ref={c}`) binds every entry on the template path too. The lazy-asset resolution cache is created on the root render context, so a `lazy()` under a derived context (a Loading boundary, a server-owned frame) and one outside it share one cache — one resolver call per module per request. Under the `serverComponents` compiler option a dynamic `class`/`style` on an intrinsic element compiles to `ssrElementAttribute`, so `class={undefined}` writes nothing where template quotes wrote `class=""` (nullish is "not set", as for every other attribute). `@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_*` from `@solidjs/web`'s server entry as the runtime surface; the `ATTRIBUTE_SLOT_POSITION` diagnostic code (reasons `spread`, `stringified`, `coerced`, `inline`, `text`, `markup`, `server-local`, `reserved-key`, `arg`, `prop`; client: `orphan`, `fill-shape`). 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/.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/.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/.changeset/ssr-spread-walk-hot-path.md b/.changeset/ssr-spread-walk-hot-path.md
new file mode 100644
index 000000000..e6382ac0e
--- /dev/null
+++ b/.changeset/ssr-spread-walk-hot-path.md
@@ -0,0 +1,5 @@
+---
+"@solidjs/web": patch
+---
+
+SSR: `ssrElement`'s spread walk keeps its pre-slot shape on the hot path. Attribute-slot handling — a `class`/`style` object, a stand-in at an attribute or behavior key, a slot's return among the sources — moves into helpers reached only for object values and non-literal sources; a string `class`/`style`, a plain attribute and a plain source cost what they did before slots (`spread-static-tail` bench: the two-source forms had regressed 7–28%).
diff --git a/documentation/plans/stage8-connection-transport.md b/documentation/plans/stage8-connection-transport.md
index 09dfebcf3..0bd0e6421 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.3 — `predict` retired).
diff --git a/documentation/server-components/server-components-principles.md b/documentation/server-components/server-components-principles.md
index dfedae7b0..a61609ad3 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,21 @@ 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, 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
sink-lifetime separation means SSE/socket transports turn the same
authored component non-terminating (generator-only-model.md §9,
@@ -1537,6 +1556,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 +1979,1302 @@ 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 => (
+
+
` and `` update through ordinary bindings on server nodes.
+ 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)`). 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
+ §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
+ 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.
+
+**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 marker (`_slot`, per the build
+record below) 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.**
+
+- *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, 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. **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 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
+shipped. Added: a fourth use site for server-component props
+(attribute fill: a called slot in spread position; `ServerComponent
`
+widens accordingly) and one marker attribute on server elements
+(`_slot`, the `_hk` family — the build record below says why it is
+not a `_bnd` position kind), plus two dev diagnostic codes
+(`ATTRIBUTE_SLOT_FILL`, `ATTRIBUTE_SLOT_CONFLICT`). 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
+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 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, 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, 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 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.
+
+**Build record (2026-09-27, same night; runtime as shipped).** The
+shape above is in `packages/web` behind three test files
+(`test/server/frame-attribute-slots.spec.tsx`,
+`test/frames-attribute-slots.spec.tsx`,
+`test/hydration/attribute-slot-adoption.spec.tsx`). Where the build
+departed from the text above, the build is right and the text is
+amended here:
+
+- *The marker is `_slot`, not a `_bnd` position kind.*
+ `_slot="[ ]*"` — the `_hk` family, one
+ attribute per element, space-separated when several fills spread
+ onto one element. `_bnd` is parsed per DISPATCH (its grammar is
+ `pos=prop`, resolved by prop name against live props with no
+ record); an attribute slot is an OCCURRENCE — it has an args record,
+ identity across responses, and the slot sync's mount/update/unmount
+ lifecycle — so it rides the slot system's discovery, not the claim
+ system's. Folding it into `_bnd` would have taxed every event
+ dispatch on a marked element with a non-event kind to skip.
+- *The branded source is the slot proxy's existing return, widened.*
+ `slotRange()` (stream face) and the document face's `range()` carry
+ `$occurrence`, and the document face's carries `$content` — the
+ fill's t=0 return. `ssrElement` recognizes `$slot` among its
+ sources in all three shapes the compilers produce: a plain object
+ in the array (`[{ class }, result]`), the result of a THUNK in the
+ mixed path (the native compiler wraps a spread CALL as
+ `() => props.row(args)` — the shape every real call takes), and a
+ lone spread (`ssrElement("b", props.x(), …)`, where the document
+ face's value is the marker-pair ARRAY). The slot source is
+ replaced, in place, by the fill's output (document face) or an
+ empty source (stream face), so the walk's precedence rule is
+ untouched: the fill's attributes land at the spread's position.
+- *`class`/`style` merge is by trailer, not by source.* The fill's
+ `class`/`style` are pulled out of its source, the walk skips those
+ two keys, and one merged attribute is appended after the walk:
+ the component's value (read as the walk would have — the last
+ source carrying the key) then each fill's contribution in spread
+ order. No double escaping, no parsing of style strings. A static
+ `class`/`style` written AFTER the spread compiles to trailing
+ markup the merge cannot reach → `ATTRIBUTE_SLOT_CONFLICT` (write
+ static attributes before the spread).
+- *One owner, enforced at t=0 only.* `ATTRIBUTE_SLOT_CONFLICT`
+ (dev, throws) for a fill key any component source also sets, a
+ class name both set, or two fills sharing a key;
+ `ATTRIBUTE_SLOT_FILL` (dev, throws) for a fill that returns content
+ in spread position. The stream face never runs fills, so a
+ conflict on a call-driven mount is not seen by the server; the
+ document render of the same component is where it surfaces.
+- *The client mount is `spread(el, () => fill(args), true)` under a
+ per-occurrence owner, always.* The fill runs inside the spread's
+ compute — the whole derivation reruns when anything it read
+ changes (args, an optimistic store), `assign` diffs per key. Args
+ are the same `liveSlotProps` proxy content occurrences get, so a
+ re-emitted record updates the instance in place. The
+ per-occurrence owner is unconditional here (content fills scope
+ only stream-mounted invocations, for the zombie-heuristic reason
+ recorded in `slotsFor`): an element occurrence places no nodes, so
+ nothing can be misread, and the spread's effect must die with the
+ occurrence. Fill-returned handlers go through client `spread`'s
+ own delegation (the "open" item above closes this way — the
+ one-owner rule keeps `_bnd` and a fill off the same position).
+- *The morph's exception is ownership, reported by the fill.* The
+ fill's output keys are reported each run through `ctx.own` (a
+ frame contract: attribute names as the DOM spells them,
+ `class:` / `style:` in object form, `class` /
+ `style` whole for strings) onto the element (`_$slotOwned`,
+ occurrence → names). `morphAttributes` neither removes nor sets an
+ owned attribute; for `class`/`style` with owned NAMES it applies
+ the server's value and re-imposes the owned names' live state on
+ top (a class the fill toggled on stays on through a server class
+ change; an owned style property survives the attribute rewrite).
+ A replaced element is a zombie mount (its node left the tree) and
+ the fill remounts on the fresh node; an unmounted occurrence
+ releases its ownership so the element is wholly the server's from
+ the next morph on.
+- *No regions in attribute slots.* An element occurrence has no
+ interior: region discovery and range replacement skip it; a
+ server-JSX arg to an attribute fill has nowhere to render and is
+ not supported.
+
+Confirmed empirically: a t=0 document adopts the fill onto the
+server-rendered element with no re-render (the hydrate spec); the
+first client-state change writes through; a keyed morph keeps the
+element and every owned value across a server class change and an
+args re-emission; a dropped row disposes its fills; both event
+delivery and `checked`/`hidden` property-reflected attributes behave.
+The test harness surfaced one thing worth knowing: the frames client
+binds through the packaged `@solidjs/web` instance (`spread`,
+`insert`, `delegateEvents`), so a jsdom spec that renders through
+`../src` has two delegation registries and fill-returned handlers
+never fire — render through the packaged entry (the spec does). Not
+a product issue; one instance in an app.
+
+Still open from the list above: args duplication across several
+fills on one element tree (two calls is the current honest cost);
+off-response adds under live.
+
+**Acceptance gate, first run (2026-09-28; `examples/todos-server`).**
+The SPA's TodoMVC ported as one server component with eleven slots
+— nine attribute (`main`, `toggleAll`, `row`, `check`, `retry`,
+`destroy`, `footer`, `filterLink`, `clearCompleted`) and two content
+(`pending` for adds, `count`) — over the SPA's own API (400 ms,
+~33% failure) behind the server boundary, multi-flight shape
+(`yield refresh(todos)` after each call). Exercised in a browser
+against the dev server, document SSR and hydration included:
+
+- Toggle: optimistic `completed pending` and the count move in the
+ same frame; the settle changes only `class` on the SAME `
` (a
+ mutation observer saw three class writes and nothing else — no
+ morph churn, no flash). Failure reverts `checked` and the count,
+ leaves `errored` + a titled retry; retry shows
+ `errored completed pending` and clears on success.
+- Add: client row in the `pending` slot → server row with `_key`
+ (the count already adjusted); a failed add stays as an `errored`
+ client row and retries from there.
+- Remove: `hidden` + `pending` optimistically; the morph drops the
+ row when the refetch lands. A failed bulk clear fans out to
+ per-row `errored` with `Retry removeTodo`.
+- Toggle-all / clear-completed over server-passed id lists; filters
+ via the hash as pure client state (`hidden` composed with intent);
+ a full reload under `#/active` hydrates clean and applies the
+ filter after settle.
+- Overlapping toggles (250 ms apart) settled together at the later
+ refetch — both optimistic, both correct, but the first's settlement
+ was held by the second: the two actions' writes to one
+ `createOptimisticStore` share a transition. Core semantics, same
+ as the SPA would show; noted, not a slots matter.
+
+Two runtime bugs fell out, both fixed on the branch:
+`@solidjs/compiler`'s spread path passed `$key` through unrenamed
+(server markup carried a literal `$key`, so keyed morphs lost row
+identity; the template path and Babel were right), and `dynamic`'s
+kept-resolution delivery — a signal write — ran inside its own
+compute when the source is a memo that already settled the call
+(this shape, and every hydrated document's first refetch), tripping
+the dev owned-scope guard into the error boundary; the address
+signal is `ownedWrite` now. Neither was visible from the specs
+because they never ran the exact shape end-to-end.
+
+The port is the multi-flight shape only. That is a statement about
+the example, not the mechanism: the fills never see which transport
+delivered the refetch (the hold is the transaction's, and the
+`frames-optimistic-hold` spec pins the single-flight hold with a
+content fill), and the single-flight wiring is the router's
+(`createFlightDataCollector`, its action runner — the notes
+example), not something this stage supplies.
+
+Two typing gaps the port surfaced, both public-surface decisions
+taken the same day: `$key` was not declared on intrinsic elements
+(the compilers accepted it) — it is now, in `CustomAttributes`; and
+`Slot
` returns `Element`, which TypeScript will not spread — the
+attribute-object return type this called for is superseded by the
+next amendment, which is also where the gate's *finding* is
+recorded: the fill table came out heavier than the SPA's store
+projection, which is the one failure the simplicity-parity
+criterion was pointed at.
+
+#### 9.2.3 Amendment — attribute slots, second form: one per data context, bound per position (2026-09-28)
+
+Recorded from the design conversation the morning after the
+acceptance gate ran. The gate passed its behaviors and failed its
+criterion: `examples/todos-server` needed eleven slot props and a
+ten-entry fill table on the client for a row the SPA writes once.
+Asking why led to the row's real question — *wrap it in a client
+component, or attribute-slot it?* — and both answers worked, which
+meant the design had left the choice to taste. Re-deriving from the
+question instead of the mechanism found one answer, and a shape in
+which the attribute-slot *spread* of 9.2.2 is a special case done
+wrong. **The 09-27 spread shape — one slot per element, its return
+spread onto that element, the client deciding what it owns — is
+retired.** The idea it carried is not: the client still contributes
+*attribute values* to server markup, and that is what this form is
+named for. The 09-27 text and its build record stay as the record
+of how the shape was found; nothing in them ships. Where this
+amendment and 9.2.2 disagree, this amendment is right.
+
+**The reframe: a slot is a client render, and it renders one of two
+things.** A markup slot's client function returns JSX; the server
+places it as a region, and the client owns those nodes. An
+*attribute* slot's client function returns a plain object; the
+server *consumes* it — binds its properties into named positions
+of the server's own template, one call serving every element of
+one data context — and the client owns exactly those values. What
+the object holds is anything that is not markup: an attribute
+value, a class name's condition, a style property, a handler, a
+ref — every derived thing a client would compute for an element
+the server rendered. (The build called this form "data slots" for
+a day, after what the fill returns; it was renamed the same day
+because the name misled — the values are attributes of server
+elements, the object is only how they travel.) The client renders
+JSON, the server renders markup. Both run at t = 0 on the document
+face (the server invokes the client function and serializes what
+came back into place), both are live afterward through the same
+occurrence machinery, and both obey one ownership rule: whoever
+produced the output owns it — the markup owner owns the nodes, the
+attribute owner owns the properties the template read.
+
+That reframe carries the placement principle §9.2 should have led
+with, because it is what the gate was missing:
+
+> **An element lives where the data that creates it lives.** An
+> element that exists because of server data is server markup;
+> client behavior or client-driven values on it are an attribute slot —
+> you never wrap a server-rendered thing to add behavior, you bind.
+> An element that exists because of client state alone (an
+> optimistic add, a modal, a drag ghost, an editor open over a
+> field the server rendered as text) is a client component in a
+> markup slot. The boundary moves with the data's ownership, not
+> with where the author wanted to write code.
+
+Under it the TodoMVC row is not a choice: the row exists because
+the server has a todo, so it is server markup with an attribute slot; the
+pending row exists because the client has an intent the server has
+not seen, so it is a client component; when the server confirms it,
+it *becomes* server markup, and `$key` reconciles the transition.
+Every case that was a judgment call resolves the same way — a
+`selected` class on a server nav link binds; a search input in
+server markup binds; a live-typing filter the client owns entirely
+is a client component; a server table's client-side sort indicators
+bind.
+
+**The shape.** One attribute slot per *data context* — one call, one
+client scope, consumed by any element in the template:
+
+```tsx
+// ── todo-row.tsx — no directive, no side ────────────────────
+export interface RowBehavior {
+ rowClass: Record;
+ removed: boolean;
+ done: boolean;
+ onToggle: (e: Event) => void;
+ onRemove: () => void;
+ onRetry: () => void;
+ error?: string;
+}
+export function TodoRow(props: { title: string; row: RowBehavior }) {
+ return (
+
+ );
+}
+
+// ── client ──────────────────────────────────────────────────
+const rowFor = (p: { id: string; completed: boolean }): RowBehavior => ({
+ rowClass: { todo: true, completed: done(p), pending: !!intent.byId[p.id], errored: !!errors[p.id] },
+ removed: removed(p.id),
+ done: done(p),
+ onToggle: e => toggleTodo(p.id, e.currentTarget.checked),
+ onRemove: () => removeTodo(p.id, p.completed),
+ onRetry: () => retryTodo(p.id),
+ error: errors[p.id]?.message
+});
+
+{a => }}
+/>
+```
+
+`TodoRow` is one component, compiled twice like every isomorphic
+Solid component always has been. The server passes it an attribute slot;
+the client passes it the fill's result directly. It cannot tell the
+difference and does not need to: on the server its attribute
+positions bind branded stand-ins that serialize at t = 0 and go
+live on the client; on the client they are ordinary bindings.
+React needs three component categories (server, client, shared)
+because its boundary is the component; here the boundary is data
+ownership, so the component is neutral by construction and the
+decision lives at the call site. Nor is there a "can only exist on
+one side" rule for the shared file to obey: the only directive is
+`"use server"`, and it marks a call boundary reachable from *both*
+sides (the server calls the function, the client calls the stub),
+so the import graph is symmetric. The way to break a shared
+component is the ordinary isomorphic one — reading `document`
+during render — which predates all of this.
+
+**Rules of the shape.**
+
+- *Keys are semantic, positions are structural.* The object's
+ property names are the client's vocabulary (`done`, `onToggle`,
+ `onRemove`); the template decides what each one *is* by where it
+ binds it. Nothing in the object says attribute, handler, or ref
+ — the position does. `ref={row.input}` makes `row.input` a ref,
+ called with that element; the same property bound at two
+ positions is two reads. Position kinds: an attribute (`hidden`,
+ `checked`, `title`, `value`), a class name (`class={{ completed:
+ row.done }}`), the whole `class`/`style`, a style property, an
+ event (`onClick`, `onInput` — the position is the lowercased
+ name, as the client runtime derives it), a ref. Text positions
+ (`{row.count}`) are the obvious next kind and
+ are deferred, not rejected (open, below). The vocabulary follows
+ the one convention it already lives under: the object is a
+ *props interface* (a shared component takes it as a prop), so
+ handlers are `on` + intent (`onToggle`, `onCopy` — the position
+ names the DOM event, the key names the meaning, as a component's
+ `onSelect` does), values are nouns, a ref is `ref`. A convention
+ for the reader, not a rule for the runtime: the prefix is never
+ read, because the moment it were, the client would again be
+ deciding what it owns.
+- **A slot property is a JSX attribute value, whole, and nothing
+ else.** `class={row.rowClass}`, `hidden={row.removed}`,
+ `onInput={row.onToggle}` — never `` class={`todo ${row.done}`} ``,
+ never `row.count > 3`, never `if (row.error)`, `row.error && …`
+ or ``, never passed to a server helper.
+ The reason is not style: *the server does not have the value.*
+ On the stream face a property read is a stand-in with no value;
+ on the document face it holds the t = 0 value and nothing later.
+ Anything computed from it on the server is computed from nothing,
+ and the client — which owns the value — cannot see or update a
+ decision the server made. A decision that depends on a slot value
+ belongs in the fill (return `rowClass`, not `done`, when the class
+ is the decision) or, when it decides whether a node *exists*, in a
+ markup slot (the placement principle). Every coercion the runtime
+ can see — a template literal, `+`, a comparison, a text child — is
+ a dev finding and renders **nothing on either face**, so the
+ misuse shows on the first render, not the first refetch. Truthiness
+ has no hook: a stand-in is an object and always truthy, so
+ `row.error && ` puts a retry button on every
+ row. That is the one case only the sentence above catches, and why
+ it is the sentence to teach — to people and to agents.
+- *Spreading an attribute slot's object is an error.* `{...row}` is the
+ 09-27 shape: the client decides what it owns and the template
+ cannot show it. Name the positions. Dev throws (the finding is an
+ `error`); a prod build renders the element with nothing from that
+ source — the misuse is caught in development, never in production.
+- *Reserved keys.* The call's return doubles as a placeable range so
+ the same call serves both output types, and the range's own reads
+ pass through the proxy: keys beginning with `$` or a digit (the
+ walker's index reads), `length` and `slice` (the resolver's copy of
+ a placed range), the node keys `t`/`h`/`p`, `then`
+ (thenable probes), and the four `Object.prototype` names an engine
+ coerces through (`constructor`, `toString`, `valueOf`, `toJSON`).
+ Every other string key — `filter`, `map`, `at`, `sort`, `join`
+ included — is a property read of the fill's output, on both faces
+ (the set is explicit, not "whatever the range has": the document
+ face's range is an array and the stream face's is not, and
+ `key in range` had let `Array.prototype` answer on one face only).
+ A fill output that uses a reserved key is a document-face dev
+ finding (`reserved-key`).
+- *A stand-in is not an argument.* `props.child({ parentId:
+ parent.id })` passes another slot's value as data the server does
+ not have — at the top or nested in plain objects and arrays
+ (`{ nested: { x: row.done } }`, `[row.done]`; a `Map`, `Set` or
+ class instance is the app's and is not walked); the arg carries
+ `undefined` at that path on both faces (the record, and the
+ document face's t = 0 fill, which must read what hydration will)
+ and dev says so (`arg`, with the path — once per stand-in, at its
+ first path). A cyclic arg crosses as a cycle. Pass the server's own
+ value, or read it in the client fill from client state.
+- *A handler position is `on`.* The marker carries the
+ runtime's derivation (`onClick` → `click`), on a template element
+ and a spread element alike: under `serverComponents` both compile
+ their named `ref`/`on*` to one claim map (`{ click: expr, ref:
+ [a, b] }` — duplicate refs merged, a handler tuple kept whole, a
+ duplicate handler last-wins) that is read only inside a server
+ component's render — the template element's as the guarded
+ `ssrClaim` hole, the spread element's as a thunk `ssrElement`
+ takes, keyed by the index of the source each attribute sits before
+ (`` → `{ 1: { click: go } }`) — so
+ plain SSR never evaluates a handler expression. A spread's own
+ handler keys bind through the same reading, and a handler position
+ settles in *source order*, because the marker promises what the
+ client binds and the client compiles the same element to
+ `spread(el, [a, { onClick: go }, b])`: the last source that *has*
+ the key wins (`collectProps` shadows an earlier source's key by
+ presence; `merge()` looks a key up with `in`), a named attribute
+ being a source at its position — so a key holding `undefined`
+ owns the position too, and binds nothing (`` binds nothing when `cond` is
+ false, on both sides). Refs merge whatever their order (the client
+ fires every ref); a nullish ref contributes nothing. A server-local
+ function at any of these is a finding (`server-local`) — where it
+ is the one the client would bind. `prop:*` positions are not bindable (the server renders
+ no properties): a stand-in there is a finding (`prop`) on the
+ runtime spread path; the compiled form drops `prop:*` as SSR
+ always has.
+- *The occurrence is the call, not the element.* `$key` on the call
+ is occurrence identity (client state follows the entity across
+ responses); `$key` on the `
` is morph identity for the node.
+ Two keys, two jobs. An occurrence lives while any consuming
+ element does; its consumers may change per response (a row gains
+ a bound button) without the fill re-running. **`$key` is
+ optional.** A call is one occurrence however often the render
+ evaluates it: the natural shape puts the call in a shared
+ component's prop — ``
+ — and compiled props are getters, so every position the component
+ binds re-evaluates the expression; the first call's proxy answers
+ the rest and the record emits once (without this, one record per
+ position — the double-data disease). A `$key`ed call repeats by
+ name; an un-keyed call repeats by *structural args* once its face
+ is known to be data (identical args are an identical fill output,
+ so one occurrence for both sites changes nothing on screen) —
+ never for a placed range, since two ``
+ are two ranges, and never for args identity cannot read by value
+ (a function, a promise, an iterable). What `$key` adds is identity
+ *across* responses: state inside the fill's scope follows the
+ entity through reorders and arg changes; without it that state is
+ positional per prop, which is right for a stateless fill and wrong
+ for one holding an edit draft. Correctness never depends on
+ `$key`; values re-deliver with every response either way.
+
+**Granularity — the compiler's, and per position where the shared
+component forces it.** A client element with
+several dynamic attributes compiles to one effect per element that
+reads every value, compares each against the last, and writes the
+ones that changed. An attribute slot does the same per occurrence: the
+fill runs under one computation, the runtime diffs the bound
+positions against the last output, and writes the ones that moved
+— `class` flipping to `completed` touches `class` and nothing else,
+though `hidden` and `onClick` were recomputed. Plain values are
+the floor; getters on the returned object are the idiom for a fill
+a *shared component* also consumes on the client (build finding,
+09-28). The runtime reads each bound value position inside its
+tracking computation, so a getter tracks its own sources and the
+object is built once — that is finer than the compiler's
+per-element effect, but the reason is not granularity. It is the
+client face of the same component: ``
+compiles to one eager read of `props.row.onToggle` in the component
+body — a handler position is bound once, not tracked — and `row` is
+a prop getter. A fill that computes its values on construction
+(`done: done(p.id, …)`) does that reactive read *there*, in the
+untracked body, and the strict-read diagnostic names it: the row
+would not update. Getters move every value read to the position
+that binds it — a tracking scope for a value, event time for a
+handler — and the construction reads nothing. So: plain values when
+only the server template reads the output; getters when a client
+`` reads it too. Handlers are bound once at mount as a
+dispatcher that reads the *current* output's handler, so identity
+churn across runs re-attaches nothing. A ref is called once per
+(element, property) at mount and excluded from the diff. A
+live-delivered arg change re-runs the fill for that occurrence like
+any other dependency.
+
+**Wire.** Per occurrence: the args, once (the 09-27 duplication
+across per-element fills is gone by construction). Per *consuming*
+element: a marker per bound attribute, `_s:=":"`,
+with class names / style properties appended (`_s:class="row#0001:done=completed"`),
+events as `_s:on:click`, refs as `_s:ref` — the `_hk` family; the
+occurrence alphabet excludes `:` and the key is percent-encoded onto
+an alphabet that excludes it too, so the split is exact. Handler and
+ref positions cost the name only. On the document face the values
+are the attributes you would emit anyway (`class="todo completed"`,
+`checked`): zero overhead over static markup. On the stream face
+values are omitted — no fill ran, the client is about to write them
+— so refetched markup is slightly smaller than static. The morph
+needs no ownership table: an incoming element's own `_s:*`
+attributes say which positions the client owns, so the morph skips
+them (whole attributes) or re-imposes the owned names (class/style)
+and everything else is the server's. The names are the only cost
+per-position adds over per-element and the part that compresses
+best — every row carries the identical pattern. Tighter encodings
+(indices, out-of-band) are available and deliberately not taken:
+readable on-element markers are worth more than bytes compression
+already removes, and Qwik 2's move to a compact `qwik/vnode` blob is
+also why nothing external can read its output.
+
+**What folds in.** Stage 6's behavior claims (§9.1: `onClick={props.
+onCopy}`, `ref={props.copyBtn}` — the `_bnd` marker, dispatch-time
+resolution by prop name) are the attribute slot with one property and no
+data context. They had looked thin for a reason: `ref` never found a
+use case on its own, and handlers alone are unstable once you look at
+what they attach to — a handler on a checkbox without ownership of
+`checked` is the uncontrolled/controlled mismatch (the native flip,
+then the refetch morphs `checked` back under a failed or in-flight
+mutation), and the row that goes `pending` after its own button was
+clicked forces "wrap the row" for the *feedback* of a binding you
+were allowed to put on the button unwrapped. Handlers-only yields
+"buttons don't need wrapping, checkboxes do." Either a server
+element takes no client binding, or the binding carries values;
+given handlers are in, values are in, and it is one mechanism. `ref`
+returns not because it found a use case but because, under a
+position-typed model, *excluding* it is the rule you would have to
+teach. So: `_bnd` and `CLAIM_PROP` go; §9.1's three-row table
+becomes two rows — *called, placed* (markup) and *called, read at a
+position* (data) — and the notes example's search field becomes
+`const search = props.search(); `.
+The per-element scope §9.1 reserved for refs is now the
+per-occurrence scope every attribute slot has; a handler position is
+a listener the client attaches on the consuming element itself —
+one dispatcher per (element, event), stable across fill runs, that
+reads the occurrence's current output at event time and fans out to
+every key bound at that position — and the `_bnd` up-walk goes with
+the prop-name lookup it served.
+
+**The compiler round, and why the 09-27 settlement is superseded.**
+09-27 chose spread as the only spelling because it is the one
+attribute position the SSR compiler defers wholesale to the runtime,
+and "no gated transform" was taken as the constraint. That
+constraint produced the shape that failed the gate. Per-position
+binding needs the compiler at exactly the two places where a value
+lands *inside* template quotes: dynamic `class`/`style` compile to
+`class="${ssrClassName(x)}"`, and a helper called inside the quotes
+cannot emit the sibling marker attribute. So, gated on the
+`serverComponents` option both compilers already carry for `_bnd`:
+a dynamic `class`/`style` on an intrinsic element compiles to a
+whole-attribute hole (`ssrElementAttribute("class", x)`, the helper
+the spread path already uses for trailing attributes), and
+`class`/`style` object literals are not folded inline there (the
+fold would evaluate a stand-in's truthiness). Every other position
+already routes through a self-contained helper — `ssrAttribute` for
+attributes, the `ssrClaim` hole for events and refs, `ssrElement`'s
+walk for spread elements — and those learn the brand at runtime. The
+guard is the one `_bnd` introduced; the round is smaller than Stage
+6's; plain SSR compiles exactly as before.
+
+**Prior art.** Kent C. Dodds' prop getters (downshift's
+`getItemProps({ item, index })`): called once per item, the result
+spread across whichever elements make up the item. This is that
+shape with the roles inverted across the wire and the spread made
+explicit per position — which is what keeps it analyzable and gives
+the server the narrow contract. Marko 6's split of one component into
+server markup and the client's reactive residue is what the placement
+principle produces without analysis: the server template is the
+template, the client ships a function per data context that returns
+values, and outside client-created entities no markup crosses. Qwik 2
+kept handlers on the element (`q-e:click`) and moved structure
+out-of-band; the same split, and where we would go if bytes ever
+argued for it. React Server Components is the pole this refines: its
+answer to a server row with client behavior is a client component
+around it, which is where the row template goes, and its three
+component categories are the cost of drawing the boundary at the
+component.
+
+**Public surface (flagged; nothing here has users yet).** Removed:
+`AttributeSlot
` (09-27, never released), the `_slot` marker,
+`ATTRIBUTE_SLOT_FILL`/`ATTRIBUTE_SLOT_CONFLICT`; Stage 6's `_bnd`
+marker, `CLAIM_PROP`, `BEHAVIOR_CLAIM_DROPPED`, the frame `props`
+option and host `delegate` plumbing that served `_bnd` resolution,
+and the direct `onX={props.onX}` / `ref={props.x}` spelling on
+server intrinsics (a function-valued prop read at a position is now
+a dev error naming the attribute-slot spelling). Added: `AttributeSlot
`
+(`@solidjs/web/frames`, both faces); the `_s:*` marker family; one
+diagnostic code, `ATTRIBUTE_SLOT_POSITION` (dev: a server-local function
+or a spread where a slot value belongs, a slot value stringified
+outside a bindable position, a reserved key in a fill's output);
+`$key` on intrinsic elements in the JSX typings. Compiler: no new
+option; the `serverComponents` transform widens as above.
+
+**Acceptance gate (restated; the gate does not move, the criterion
+now has teeth).** `examples/todos-server` re-ported on this shape
+with a shared `TodoRow`, ONE `row` attribute slot for the row's whole
+behavior, and pending rows that are not inert — parity with the SPA
+means an added todo is toggleable and deletable while pending, which
+the client component does with the same `rowFor` the server rows
+bind. Pass condition, in addition to 9.2.2's behaviors: the client
+carries no markup except what the placement principle requires (the
+pending row), the server template shows every position the client
+owns, and the fills read as small components rather than a lookup
+table — if `rowFor` is heavier than the SPA's `TodoItem`, that is
+the finding.
+
+**Open.**
+
+- *Text positions.* `{row.remaining}` as a child is the natural
+ fourth kind (TodoMVC's count is one); needs a marker pair in
+ content, deferred to keep this round to attributes.
+- *Client-created entities without client markup.* The pending row
+ is a `
` with data holes; the only reason it is a client
+ component is that the client must *produce* the node. The
+ generalization is a server template stamped once per client item
+ (`{p =>
{p.title}
}`
+ with the client returning data, not markup) — the model closing
+ in both directions. A separate stage: list identity for client
+ items, ordering against server items, supersession by a server
+ row with the same key. §9.2.1's off-response adds live here.
+- *Actions against pending ids.* A toggle on a pending row targets an
+ id the server has not seen; the port sequences it behind the add's
+ settlement (an example concern, surfaced by parity).
+
+**Build record (2026-09-28, same day; runtime as built).** The shape
+above is in `packages/web` behind three test files
+(`test/server/frame-attribute-slots.spec.tsx`, both faces;
+`test/frames-attribute-slots.spec.tsx`, the client binding;
+`test/hydration/attribute-slot-adoption.spec.tsx`, t = 0 adoption), the
+compiler round behind one shared server-components fixture
+(`attributeSlots`, Babel and native), and the 09-27 attribute-slot build
+— never committed — is gone with its three specs, `_bnd`'s spec and
+the `behaviorClaims` fixtures. Web suites 986 / 1262 / 257, Babel
+268, native compiler fixtures green. Where the build departed from
+the text above, the build is right and the text is amended here:
+
+- *The stand-in is the slot proxy's property read.* One proxy over
+ the call's range (both faces): a key the range has, a `$` key, or
+ a node key passes through; any other string key answers with a
+ `SLOT_VALUE`-branded `{ occurrence, key, value, face }`. On the
+ document face the fill's return is classified once — `null`/
+ `undefined` or a plain object is DATA (its properties are the
+ t = 0 values); a string, an array, a function, or an SSR node is
+ MARKUP, and a read off it is a dev finding at the position. The
+ classification edge is the `t` key: an SSR node is `{ t }` plus
+ `h`/`p` and nothing else, so an object carrying `t` *and* other
+ keys is data that used a reserved name (`t` unreadable, the rest
+ binds) and dev names it. Reserved, therefore, and checked on the
+ document face: `$`-prefixed keys, `t`/`h`/`p`/`then`, and
+ Object/Array prototype member names (`length`, `map` — the engine
+ calls array methods on the document face's range through the
+ proxy).
+- *Every attribute helper learns the brand; the compilers touch two
+ positions.* `ssrAttribute` (an attribute, a boolean), the
+ class-name and style-property helpers, `ssrElementAttribute`
+ (whole `class`/`style` — the hole the `serverComponents` round
+ adds), `ssrClaim` (events, refs — the Stage 6 hole kept, its
+ marker replaced), and `ssrElement`'s walk for runtime spreads all
+ recognize a stand-in and emit the marker beside whatever the
+ position would have written. A stand-in that reaches
+ stringification — a template literal, a text child — is an
+ `ATTRIBUTE_SLOT_POSITION` finding; so is spreading the slot's
+ return itself, a server-local function at a claim position, or a
+ markup-faced read. Findings dedupe per render on (occurrence,
+ key, reason, position), because a component's prop getters
+ re-evaluate positions and the same misuse would otherwise report
+ once per read.
+- *A misused stand-in renders nothing, on both faces.* As first
+ built, the document face wrote the t = 0 value where a stand-in
+ was stringified, placed as text or reached an inline `class`/
+ `style`, and the stream face wrote nothing — so a misuse looked
+ right on the first render and broke on the first refetch, the
+ worst place to find it. Struck the same day: every such position
+ renders nothing on either face, and the finding is the only
+ signal. The stand-in also defines `Symbol.toPrimitive`, so a
+ comparison, arithmetic or `==` (`number`/`default` hint) is its
+ own reason, `coerced`, distinct from `stringified` (`string`
+ hint): the message says the server has no value to decide with
+ and the decision belongs in the fill. Truthiness has no hook — a
+ stand-in is an object — which is why the rule above is stated as
+ one sentence, and why `@solidjs/web` ships it as a skill
+ (`skills/server-components/SKILL.md`, in the package's `files`)
+ where an agent writing a server component will read it.
+- *Marker grammar as built.* `_s:=":"`;
+ a class name or style property appends `=`; several names
+ bound off one occurrence on one element join with `,`
+ (`_s:class="row#1:done=completed,row#1:editing=editing"`); a whole
+ `class`/`style` read carries no `=`. Events are `_s:on:`
+ with `onInput` lowercased to `input` (the client runtime's own
+ derivation; the client attaches a listener under that name);
+ refs `_s:ref`. Keys and
+ names percent-encode onto `[A-Za-z0-9_.-]`, the occurrence
+ alphabet, so `:`/`=`/`,` split exactly. A zero-arg call is the
+ occurrence named by the prop alone (`codeBlock:onCopy`, no `#n`) —
+ one data context per prop, the notes search field's shape. The
+ document face writes the value where the position would have put
+ it and the marker after. A class-name or style-property position
+ whose names all resolve empty writes no `class=""` — the marker
+ alone says the client owns it; a *whole-value* `class`/`style`
+ read writes what plain SSR writes for the value (`class=""` for
+ an empty object), so the two faces of the same template agree.
+- *Handler positions are one guarded hole per element, as Stage 6
+ left them.* The compilers still collect `ref`/`on*` expressions
+ into `ssrClaim({ click: expr, ref: expr })` behind
+ `sharedConfig.context.claims`; what changed is inside the helper —
+ a stand-in becomes an `_s:on:*`/`_s:ref` marker, a server-local
+ function is `ATTRIBUTE_SLOT_POSITION`, and nothing writes `_bnd`. The
+ arming enum (`CLAIMS_STREAM` / `CLAIMS_DOCUMENT`) is unchanged and
+ still what keeps client fill content, which re-enters the zone
+ owner, from marking or warning. On the document face the enum is
+ armed on a render context *derived* from the page's (prototype
+ inheritance, as a Loading boundary's buffered context) for the
+ component's subtree alone: the page's own elements after the
+ component keep the pre-slot walk, and a late hole minted inside
+ re-emits under its mint-time context, still armed. On the client,
+ the listeners an occurrence attaches are its own: its end (a later
+ response drops it; a positional id now names another row) detaches
+ them, so a kept un-keyed element carries one listener, not one per
+ occurrence that ever bound it, and a dropped occurrence's handler
+ never fires through its disposed fill.
+- *A repeated call is one occurrence per render, on both faces —
+ keyed or not.* Found by the first todos port, which emitted eleven
+ `sc:slot:…row#` records per row (one per position read through
+ `props.row`'s getter); first closed for `$key`ed calls only, with
+ a rule that a getter-re-evaluated call must carry `$key`. That
+ rule was then struck (same day, the AI-usability review: an
+ unenforced rule whose failure is silent duplication is a trap, not
+ a rule) and `$key` made optional, as the rules above now say. Both
+ proxies keep two per-render maps: occurrence id → proxy for keyed
+ calls, registered at the call; `prop + structural args` → proxy
+ for un-keyed calls, registered when the face is known to be data —
+ at the first property read on the stream face (`slotProxy`'s
+ `onData`), at the fill's classification on the document face.
+ Args with a getter, a function, a promise or an iterable are never
+ compared. A placed range never registers: two identical positional
+ markup calls stay two ranges (pinned). No wire change: ids stay
+ `prop#`.
+- *A data occurrence's nodes are its consumers, and it is never a
+ zombie.* The client's slot discovery collects `_s:*` elements into
+ per-occurrence consumer lists `[{ element, positions }]` alongside
+ the range walk. The occurrence's "nodes" are those elements (so the
+ existing bookkeeping sees them), but the zombie rule — output whose
+ node left the tree remounts fresh — does not apply: a replaced
+ consumer is a *consumer change*, and an occurrence no element
+ reads is simply not found and unmounts at the sync's end. Consumer
+ sets compare structurally per sync; a change without an args
+ change rebinds in place through a per-occurrence rebinder (the
+ fill's computation stays; new elements and positions take their
+ current values), independent of the args path that follows.
+ `#syncSlots` runs at the end of every flush and scoped to the
+ materialized fragment at each segment reveal, so positions inside
+ late-revealed content bind when they appear.
+- *Binding: values in the compute phase, writes in the effect.* The
+ client mount is `createMemo(() => fill(args))` under the
+ occurrence's owner, and one render effect per occurrence whose
+ compute reads the output's value positions per consuming element
+ (attribute, class name, style property, whole class/style) into a
+ props object and whose effect phase only `assign`s it against the
+ element's previous props. Reading in the compute is what makes the
+ getter idiom work — each getter's sources are tracked by the
+ binding, not by the fill's memo. Handlers are stable dispatchers
+ created once per (element, position) that read the *current*
+ output's handler at event time; a ref fires once per (element,
+ property). Built the other way first (reads in the effect phase):
+ values did not update under getters, which is how the
+ compute-phase rule and the Granularity amendment were found.
+- *The morph's exception is read off the incoming element.* The
+ morph parses the new element's `_s:*` attributes into owned
+ positions and, for each attribute it would set or remove, either
+ skips it (a whole attribute the client owns) or applies the
+ server's value and re-imposes the owned names' live state (class
+ names, style properties). No ownership table, no `ctx.own`
+ contract: what 09-27 reported per run, the markup states.
+- *`AttributeSlot
` is conditional on `P`.* `(props?: P & { $key?
+ }) => J` when `{}` extends `P` — the zero-arg call type-checks —
+ and required otherwise. Exported as `DataSlot` for a day; renamed
+ with the diagnostic code (`DATA_SLOT_POSITION` →
+ `ATTRIBUTE_SLOT_POSITION`) when the reframe above was, so that
+ the type, the code, the specs and this section say one thing.
+ The wire is untouched: `_s:*` markers, `SLOT_*` exports,
+ `sc:slot:` record ids are as they were.
+- *No compiler change to the DOM output.* `$key` on an intrinsic
+ strips at a DOM compile (already so); the `serverComponents` SSR
+ transform is the only codegen touched, and plain SSR output is
+ byte-identical to before.
+
+Confirmed empirically, `examples/todos-server` re-ported to the
+shape and driven in a browser against the dev server, document SSR
+and hydration included: one `TodoRow` on both sides; `row` (per
+todo, keyed), `list` and `filters` (zero-arg) attribute slots; `pending`
+and `count` markup (the count is the text position the open list
+defers); one record per occurrence; hydrated `checked`/
+`class`/`hidden` in the HTML before JavaScript and bound with no
+re-render; toggle / retry / toggle-all / clear-completed / filters;
+row nodes stable across settles; the count right through pending
+adds and their toggles; zero console warnings in dev (the strict-read
+diagnostic was the tell that found the compute-phase rule). The
+notes example's search field (`_s:value`, `_s:on:input`,
+`_s:on:submit`, `_s:class="search:active=spinner--active"`,
+`_s:aria-busy`) and the chat example's `codeBlock` copy button
+(`_s:on:click="codeBlock:onCopy"`, a zero-arg occurrence bound inside
+streamed segment content) both moved off `_bnd` and work.
+
+The gate's criterion, this time: `rowFor` is seven properties — four
+getters and three closures — against the SPA's `TodoItem`, which
+holds the same seven things and the markup; the client ships no row
+markup except the pending row, which is `TodoRow` again. The
+server template shows every position the client owns. Passed.
+
+Findings from the port, none of them slot mechanics:
+
+- *Pending rows that are not inert* need two things the SPA never
+ did. `` must be handed the store's own intent objects (stable
+ identity; the default keyed mode) — a spread copy per array change
+ remounted every pending row. And a toggle or remove on a pending
+ id waits for the add's promise (`inflight` map) and then, if the
+ add failed, edits the failed-add error record locally (the todo
+ lives nowhere else); the count skips `intent.byId` entries for
+ ids that are still extra rows and counts the extras themselves.
+- *The shared component's `$key` is on the `
` inside `TodoRow`*
+ (`
`), not at the call site — the call carries
+ its own `$key` for the occurrence. Two keys, two jobs, as written;
+ the port shows where each one physically goes.
+- *Chat's greeting at t = 0 replays only its first paragraph.* Not
+ this work: reproduced on the branch's HEAD with the tree stashed.
+ Recorded here so the next reader does not chase it into slots.
+- *One flake, run to ground.* Two early browser runs of the chat
+ example never invoked the `codeBlock` fill (no marker was bound);
+ after a web rebuild and a cleared Vite dep cache, three
+ consecutive runs bound it. The alternative that would have been a
+ bug — a race between the segment reveal's scoped sync and the
+ copy button's arrival — was tested rather than argued: jsdom
+ specs for an occurrence whose only consumer arrives in a segment
+ revealed after the record and the first flush, in a live hole's
+ re-emission, and in a hole that re-emits before its segment
+ reveals, all bind (`test/frames-attribute-slots.spec.tsx`); and
+ the server emits the marker on every sweep of a live hole with an
+ unrelated document render interleaved
+ (`test/server/frame-attribute-slots.spec.tsx`). The runtime is
+ clean in every ordering the model has; what the two runs saw was
+ a Vite dep cache holding the prebundled client from the
+ stash-and-rebuild experiment (`.vite` was cleared only on the
+ final restart). Closed as an environment artifact — and it left
+ a finding: the failure was *silent*. Every misuse in this model
+ reports on the server; the one failure that reaches a user — a
+ marked element whose positions never bind, so a button does
+ nothing when clicked — reported nothing. The frame client now
+ names it (`ATTRIBUTE_SLOT_POSITION`, reason `orphan`, once per
+ occurrence per frame) at the point `#syncSlots` classifies the
+ occurrence: no fill resolves for the prop (`why: "fill"`), or a
+ *called* occurrence has no args record once records can no
+ longer arrive (`why: "record"` — the producer emits the record
+ ahead of the markup that reads it, so a missing one is the
+ protocol out of step, never the fill; a bare occurrence has no
+ record by design). Behavior is unchanged in both cases. Honest
+ limit: the flake's own shape — a *stale client* — is the one
+ skew no client check can see, because the stale client lacks
+ the check; the finding covers the newer-client, dropped-record
+ and id-mismatch shapes, and the missing-prop misconfiguration.
+
+Still open from the list above, unchanged: text positions;
+client-created entities without client markup; actions against
+pending ids as a general concern (the port's sequencing is an
+example's answer).
+
### 9.3 Stage 8 seed — connection-shaped transport (2026-08-17)
Recorded from the design conversation; nothing here is built (the
diff --git a/documentation/solid-2.0/08-dev-diagnostics.md b/documentation/solid-2.0/08-dev-diagnostics.md
index 62e2727ca..1247908e7 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
}>
}
- copy={copyCode}
+ codeBlock={() => ({ onCopy: copyCode })}
/>
diff --git a/examples/chat/src/lib/ai.tsx b/examples/chat/src/lib/ai.tsx
index 5e366f992..143541485 100644
--- a/examples/chat/src/lib/ai.tsx
+++ b/examples/chat/src/lib/ai.tsx
@@ -26,12 +26,14 @@
// writes) and materializes on the client as a live read-only store:
// `` reads `props.usage.tokens` like local state and each
// field updates granularly, no re-shipping, no domain keys.
-// - BEHAVIOR: `copy={…}` is a client FUNCTION passed as a prop. The
-// server puts it in an event position on an intrinsic element
-// (`onClick={props.copy}` on each code block's copy button, inside the
-// streaming hole) — the markup carries a claim marker naming the prop
-// and the browser's delegation resolves it through this frame's live
-// props at dispatch (Stage 6). No client component wraps the button.
+// - BEHAVIOR: `codeBlock` is an ATTRIBUTE slot (principles §9.2.3): the client
+// fill returns an object — `{ onCopy }` — and the server reads its
+// properties at positions (`onClick={block.onCopy}` on each code block's
+// copy button, inside the streaming hole). The markup carries a marker
+// per bound position naming the occurrence and key; the client binds
+// the handler on every button the markup has, and rebinds as holes
+// re-emit. One occurrence serves every block. No client component
+// wraps the button.
//
// Slots render as JSX (``), never as calls: the compiler
// wraps each prop in a getter, so reads defer to the slot border where the
@@ -40,7 +42,7 @@
// cases. (To hand the client the async value ITSELF — the raw promise or
// iterable, consumer-controlled — wrap it in `asyncArg` instead.)
import { createMemo, createProjection, Loading } from "solid-js";
-import { type Slot } from "@solidjs/web/frames";
+import { type AttributeSlot, type Slot } from "@solidjs/web/frames";
import { Marked } from "marked";
import hljs from "highlight.js/lib/core";
import javascript from "highlight.js/lib/languages/javascript";
@@ -65,11 +67,11 @@ const escapeHtml = (text: string) => text.replace(/[&<>]/g, c => HTML_ESCAPES[c]
* Split the markdown into PROSE and CODE segments. Prose renders as opaque
* HTML (`innerHTML` — the browser never parses markdown), but code blocks
* come back as JSX so each can carry a copy BUTTON — an element the server
- * renders with behavior from the client (Stage 6): `onClick={props.copy}`
- * on a server intrinsic mints a `_bnd` marker naming the client prop, and
- * the browser's event delegation resolves it through the mounted frame's
- * live props at dispatch. No client component wraps the block; the handler
- * reads the code off the DOM it was clicked in.
+ * renders with behavior from the client: `onClick={block.onCopy}` reads a
+ * attribute slot's property at an event position, which marks the button
+ * (`_s:on:click="codeBlock:onCopy"`) for the client to bind. No client
+ * component wraps the block; the handler reads the code off the DOM it was
+ * clicked in.
*/
function segmentsOf(md: string) {
const tokens = marked.lexer(md);
@@ -132,6 +134,9 @@ function closePartial(md: string): string {
export type StatusSlot = Slot<{ progress: string; stats: Stats; usage: Usage }>;
export type CopyHandler = (e: MouseEvent & { currentTarget: HTMLButtonElement }) => void;
+/** The client's behavior for a code block: one attribute slot, called once per
+ * reply (no args), read at every copy button's `onClick`. */
+export type CodeBlockSlot = AttributeSlot<{}, { onCopy: CopyHandler }>;
/**
* The generation's structured face as a live STORE (DR-2 case 3): a
@@ -156,7 +161,7 @@ function usageStore(gen: Generation) {
export async function reply(prompt: string) {
const gen = generate(prompt);
- return (props: { status: StatusSlot; copy: CopyHandler }) => {
+ return (props: { status: StatusSlot; codeBlock: CodeBlockSlot }) => {
// Async values read through memos: `progress()` is the iterable's
// latest yield, `stats()` the promise's resolution (not-ready until it
// lands). The same reads would feed markup holes — here they feed the
@@ -164,9 +169,12 @@ export async function reply(prompt: string) {
const progress = createMemo(() => gen.progress);
const stats = createMemo(() => gen.stats);
const usage = usageStore(gen);
+ // The data context for every code block in this reply: one call, one
+ // occurrence; its properties bind wherever the markup reads them.
+ const block = props.codeBlock();
return (
-
+
);
@@ -189,13 +197,14 @@ export async function reply(prompt: string) {
*/
export async function welcome() {
const gen = greet();
- return (props: { status: StatusSlot; copy: CopyHandler }) => {
+ return (props: { status: StatusSlot; codeBlock: CodeBlockSlot }) => {
const progress = createMemo(() => gen.progress);
const stats = createMemo(() => gen.stats);
const usage = usageStore(gen);
+ const block = props.codeBlock();
return (
-
+
);
@@ -216,21 +225,20 @@ export async function welcome() {
* motivates an eventual patch format for hole re-emissions: streamed text is
* append-mostly, so a prefix-check could ship just the tail.)
*/
-function Message(props: { text: AsyncIterable; copy: CopyHandler }) {
+function Message(props: { text: AsyncIterable; block: { onCopy: CopyHandler } }) {
const text = createMemo(() => props.text);
return (
▍
}>
{segmentsOf(closePartial(text())).map(segment =>
segment.code ? (
- // Behavior from the client on a server element (Stage 6):
- // `props.copy` is the client-passed handler; this position
- // compiles to a `_bnd` claim marker that rides every hole
- // re-emission, so the button works mid-stream and keeps
- // working after each morph. The handler reads its code from
- // the DOM at dispatch — delegation, not per-block wiring.
+ // Behavior from the client on a server element: `block.onCopy`
+ // is an attribute-slot read at an event position, so this button
+ // carries a marker that rides every hole re-emission — the
+ // client binds it mid-stream and rebinds after each morph.
+ // The handler reads its code from the DOM at dispatch.
-
}>
} />
diff --git a/examples/notes/src/components/searchField.ts b/examples/notes/src/components/searchField.ts
index 71bdf6c0f..6ab9855c8 100644
--- a/examples/notes/src/components/searchField.ts
+++ b/examples/notes/src/components/searchField.ts
@@ -5,57 +5,54 @@
* LICENSE file in the root directory of this source tree.
*
*/
-// The demo's SearchField.client.js, dissolved (Stage 6). The search field's
-// MARKUP lives in the server shell (server/App.tsx); what remains here is
-// pure behavior — a bag of functions the client hands the server component:
+// The demo's SearchField.client.js, dissolved. The search field's MARKUP
+// lives in the server shell (server/App.tsx); what remains here is the
+// behavior the client contributes, as ONE attribute slot (principles §9.2.3):
+// the server calls `props.search()` once and reads the returned object's
+// properties at positions — `value`/`onInput` on the input, `onSubmit` on
+// the form, the spinner's active class and `aria-busy`. The client binds
+// exactly those positions on the server's elements.
//
-// - `onSearch`/`onSubmit` are event props: the server marks the elements,
-// and the document-level delegation walk resolves them through the
-// frame's live props at dispatch time.
-// - `searchInput`/`spinner` are ref props: they fire with the adopted
-// elements under this component's owner, so the effects inside sync
-// server-rendered DOM against client router state (the input restores
-// `?searchText` on deep links and back/forward; the spinner tracks the
-// pending navigation) and dispose with the app.
-//
-// A word on fit, because this file shows the PATTERN'S BOUNDARY as much as
-// the pattern. Event props and one-way refs (the spinner) are the sweet
-// spot: behavior on chrome you'd never ship a component for — and in chat's
-// copy buttons, on markup the client couldn't author at all. The input's
-// value-sync effect below is the edge: once an element's STATE must track
-// client reactivity, a ref means hand-writing the binding that JSX's
-// `value={...}` gives a client component for free. We keep the input server
-// chrome here because one three-line effect is a fair trade for dissolving
-// the shell's last hydration island — but when an element is mostly client
-// state, make it a client position and let JSX do the syncing.
+// The values are getters over router state, so each position tracks its
+// own reads and updates alone: the input restores `?searchText` on deep
+// links and back/forward, the spinner tracks the pending navigation. Before
+// attribute slots this file was refs hand-syncing that DOM (the pattern's
+// boundary then — an element whose STATE tracks client reactivity wanted a
+// client component). Now the binding is what the template says: a value
+// position over client state is the same one line on both sides.
//
// Search state itself is unchanged: the `?searchText` query param, so typing
// navigates — the router reruns the root preload and the notes-list server
// component refetches, morphing the list boundary in place.
import { useSearchParams } from "@solidjs/router";
-import { createEffect, isPending } from "solid-js";
+import { isPending } from "solid-js";
+
+/** What the client decides about the search field. */
+export interface SearchBehavior {
+ value: string;
+ active: boolean;
+ /** `aria-busy` wants the string, not the boolean's bare attribute. */
+ busy: "true" | "false";
+ onInput: (e: InputEvent) => void;
+ onSubmit: (e: SubmitEvent) => void;
+}
export default function searchField() {
const [search, setParams] = useSearchParams();
- const isSearching = () => isPending(() => search.searchText);
- return {
- onSearch: (e: InputEvent) => {
- setParams({ searchText: (e.target as HTMLInputElement).value });
+ const isSearching = () => !!isPending(() => search.searchText);
+ return (): SearchBehavior => ({
+ get value() {
+ return (search.searchText as string) || "";
},
- onSubmit: (e: SubmitEvent) => e.preventDefault(),
- searchInput: (el: HTMLInputElement) => {
- createEffect(
- () => (search.searchText as string) || "",
- text => {
- el.value = text;
- }
- );
+ get active() {
+ return isSearching();
+ },
+ get busy() {
+ return isSearching() ? "true" : "false";
+ },
+ onInput: (e: InputEvent) => {
+ setParams({ searchText: (e.target as HTMLInputElement).value });
},
- spinner: (el: HTMLElement) => {
- createEffect(isSearching, active => {
- el.classList.toggle("spinner--active", !!active);
- el.setAttribute("aria-busy", String(!!active));
- });
- }
- };
+ onSubmit: (e: SubmitEvent) => e.preventDefault()
+ });
}
diff --git a/examples/notes/src/server/App.tsx b/examples/notes/src/server/App.tsx
index f63d41c54..dc759a649 100644
--- a/examples/notes/src/server/App.tsx
+++ b/examples/notes/src/server/App.tsx
@@ -7,17 +7,16 @@
// components (their own boundaries) that refresh fine-grained while the
// shell stands still.
//
-// The search field is NOT a client position anymore (the React demo's
+// The search field is NOT a client position either (the React demo's
// SearchField.client.js, and this file's `search` slot until Stage 6): its
// markup is server chrome like everything else, and the CLIENT contributes
-// only behavior — `onInput` is an event prop resolved through the frame's
-// live props at dispatch, and the two refs hand the client the input and
-// spinner elements at adoption, where effects sync them against router
-// state. One input needed a whole shipped component before; now it needs
-// three functions. (This is also the pattern's boundary: the input's value
-// tracks client router state, so one of those functions hand-syncs what a
-// client component would write as `value={...}` — see searchField.ts for
-// when to choose which.)
+// an ATTRIBUTE slot (principles §9.2.3) — one call, `props.search()`, returning
+// the values and handlers this template reads at positions: the input's
+// `value` and `onInput`, the form's `onSubmit`, the spinner's class and
+// `aria-busy`. The client owns exactly those positions; the router state
+// they track is the client's, so the reads are getters over it and each
+// position updates on its own. One input needed a whole shipped component
+// before; now it needs one small object — see searchField.ts.
//
// The New button is NOT a client position either: EditButton is a plain
// anchor, and the router intercepts every same-origin at the document
@@ -25,50 +24,56 @@
// (The React demo needed a client component here because its navigation was
// a context call — ours is just an href.)
import type { JSX } from "@solidjs/web";
+import type { AttributeSlot } from "@solidjs/web/frames";
import EditButton from "~/components/EditButton";
+import type { SearchBehavior } from "~/components/searchField";
export async function appView() {
return (props: {
- onSearch: (e: InputEvent) => void;
- onSubmit: (e: SubmitEvent) => void;
- searchInput: (el: HTMLInputElement) => void;
- spinner: (el: HTMLElement) => void;
+ search: AttributeSlot<{}, SearchBehavior>;
noteList: JSX.Element;
children: JSX.Element;
- }) => (
-
+ );
+ };
}
diff --git a/examples/todos-server/README.md b/examples/todos-server/README.md
new file mode 100644
index 000000000..c458e060b
--- /dev/null
+++ b/examples/todos-server/README.md
@@ -0,0 +1,119 @@
+# Todos — TodoMVC as a Solid Server Component
+
+The [../todos](../todos) example, with the list moved to the server. The
+markup that the SPA's `MainSection`, `TodoItem` and `Footer` produced is now
+returned by one `"use server"` component and arrives as HTML; the browser
+keeps the header, the optimistic state, and — the point of this example —
+every behavior those components had, bound to the server's own elements
+through **attribute slots**. The row is one component, `TodoRow`, that both
+sides render.
+
+Same deliberately unreliable API as the SPA (400 ms saves, ~33% of them
+fail), so the optimistic UI, the per-item errors and the retry affordances
+get exercised.
+
+```sh
+pnpm dev # http://localhost:3010
+pnpm build && pnpm start # http://localhost:3010
+```
+
+## Attribute slots
+
+A slot renders one of two things: markup (placed as ``) or
+**attribute values** — a plain object the server template consumes by
+reading its properties at positions: an attribute, a class name, a style
+property, a handler, a ref. The server component calls the slot once per
+data context and reads from the result wherever it likes
+([src/server/todos.tsx](./src/server/todos.tsx)):
+
+```tsx
+const list = props.list({ total, active, completed });
+const filters = props.filters();
+
+
+
+
+ {todos.map(t => (
+
+ ))}
+
+
+
+All
+```
+
+`TodoRow` ([src/todo-row.tsx](./src/todo-row.tsx)) is the shared component:
+it binds `row.rowClass`, `row.removed`, `row.done`, `row.onToggle`,
+`row.onRemove`, `row.onRetry`, `row.error` at attribute, class, event and
+handler positions and cannot tell — does not need to — whether `row` is an
+attribute slot's value (server) or the fill's result passed directly (client).
+
+The object is a props interface — `TodoRow` takes it as a prop on the client
+path — so it is named like one: handlers are `on` + intent (`onToggle`,
+`onRemove`; the position names the DOM event, the key names the meaning),
+values are nouns (`done`, `rowClass`, `error`), a ref is `ref`. The
+runtime reads nothing into the prefix — the position decides what a
+property is — but a reader can tell a handler from a value without the type.
+
+One rule: a slot property is a JSX attribute value, whole, and nothing else.
+`class={row.rowClass}` binds; ``class={`todo ${row.rowClass}`}``,
+`{row.title}` as text, or `if (row.done)` in the server component do not —
+the server has no value to compute with, so the decision belongs in the
+fill, which returns the decided value.
+
+The client's fill receives the args as reactive props and returns the
+**object** the template reads ([src/app.tsx](./src/app.tsx)):
+
+```tsx
+const rowFor = (p: Entity): RowBehavior => ({
+ get rowClass() { return { todo: true, completed: done(p.id, p.completed), pending: …, errored: … }; },
+ get done() { return done(p.id, p.completed); },
+ get removed() { return removed(p.id) || !visible(done(p.id, p.completed)); },
+ get error() { return errors[p.id] ? `Retry ${errors[p.id].type}` : undefined; },
+ onToggle: e => actions.toggleTodo(p.id, e.currentTarget.checked),
+ onRemove: () => actions.removeTodo(p.id, p.completed),
+ onRetry: () => actions.retryTodo(p.id)
+});
+
+ ({ all: filter === "all", … })} pending={…} count={…} />
+```
+
+The values are getters because the same `rowFor` result is a client
+component's prop for the pending rows: a handler position (`onInput={props.row.onToggle}`)
+is read once in the component body, and a getter-shaped object reads no
+reactive state there. On the server each read at a position marks the
+element (`_s:class="row#0001:rowClass"`, `_s:on:input="row#0001:onToggle"`);
+the client binds exactly those positions, writes the values that change,
+dispatches events to the current handler, and a response morphing the list
+skips the positions a fill owns. At document SSR the fill runs inline, so
+`checked` and `class="todo completed"` are in the HTML before JavaScript; on
+hydration the fill binds to the same nodes.
+
+## What the client holds
+
+The SPA kept the todo array on the client. This app never has it — the rows
+are markup. What it holds is **intent** (what it asked the server to do and
+has not heard back about) in a `createOptimisticStore`, and the errors it
+heard back in a plain store ([src/todos.ts](./src/todos.ts)). Fills combine
+those with each call's args:
+
+- toggle: `intent.byId[id]?.completed ?? p.completed`
+- remove: `hidden` on the server's `
`; the morph drops the row when the
+ refetch lands (or `hidden` reverts when the save fails)
+- add: the one thing the client cannot decorate is a row the server has not
+ rendered, so `` is a markup slot where the client renders
+ in-flight and failed adds — as `TodoRow` again, with `rowFor(todo)`, so a
+ pending row toggles and deletes like any other. Those actions wait for
+ the add to settle before calling the server (it has no such id yet); a
+ todo whose add failed lives only in its error record, so they edit that.
+- counts, toggle-all, clear-completed, filters: the server passes its
+ numbers and id lists as args; the fills adjust them by intent
+
+## Mutation shape
+
+Each action writes its intent, calls the server function, then
+`yield refresh(todos)` — the "typical multi-flight" shape: the write and the
+re-read are two requests, and the action's transaction spans both, so the
+optimistic value holds until the refetched markup and args have applied.
+(Under a router's single-flight mutations the fills are identical; only the
+hold differs.)
diff --git a/examples/todos-server/package.json b/examples/todos-server/package.json
new file mode 100644
index 000000000..8d5fd7272
--- /dev/null
+++ b/examples/todos-server/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "todos-server-example",
+ "description": "TodoMVC as a Solid Server Component — the list is server markup, and every optimistic behavior (toggle, remove, pending/error/retry, counts, filters) is a client fill over slot args and createOptimisticStore state: attribute slots the server template reads at positions, a shared TodoRow on both sides, one markup slot for pending adds",
+ "version": "0.0.0",
+ "private": true,
+ "author": "Ryan Carniato",
+ "license": "MIT",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "start": "node server.js",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@solidjs/web": "workspace:*",
+ "solid-js": "workspace:*",
+ "unstorage": "^1.17.5"
+ },
+ "devDependencies": {
+ "@solidjs/vite-plugin": "3.0.0-next.35",
+ "vite": "^8.0.0"
+ }
+}
diff --git a/examples/todos-server/server.js b/examples/todos-server/server.js
new file mode 100644
index 000000000..7ca58f846
--- /dev/null
+++ b/examples/todos-server/server.js
@@ -0,0 +1,113 @@
+// The whole production server: static client assets plus one import — the
+// built server bundle's `handleRequest`, an adapter-agnostic web
+// `Request -> Response` that streams the SSR render, resolves hashed assets
+// through the build manifest, and serves the `/_server` endpoint. The node
+// <-> web plumbing below is the only glue.
+//
+// Responses are compressed because every deployed host compresses text, and
+// a benchmark against an uncompressed origin measures the harness rather than
+// the app. Brotli quality 4 keeps per-chunk flushes cheap while still ~6x on
+// HTML; each SSR chunk is flushed so streaming boundaries survive.
+import { createServer } from "node:http";
+import { readFileSync } from "node:fs";
+import { Readable } from "node:stream";
+import { fileURLToPath } from "node:url";
+import path from "node:path";
+import { brotliCompressSync, createBrotliCompress, createGzip, constants } from "node:zlib";
+import { handleRequest } from "./dist/server/server.js";
+
+const root = path.dirname(fileURLToPath(import.meta.url));
+const PORT = process.env.PORT || 3010;
+
+const MIME = {
+ ".js": "application/javascript",
+ ".css": "text/css",
+ ".html": "text/html",
+ ".json": "application/json",
+ ".ico": "image/x-icon",
+ ".svg": "image/svg+xml"
+};
+
+/** Negotiated streaming compressor piped into `res`, or null for identity. */
+function encoder(req, res) {
+ const accepts = req.headers["accept-encoding"] || "";
+ let stream;
+ if (/\bbr\b/.test(accepts)) {
+ res.setHeader("Content-Encoding", "br");
+ stream = createBrotliCompress({ params: { [constants.BROTLI_PARAM_QUALITY]: 4 } });
+ } else if (/\bgzip\b/.test(accepts)) {
+ res.setHeader("Content-Encoding", "gzip");
+ stream = createGzip();
+ } else return null;
+ stream.pipe(res);
+ return stream;
+}
+
+// Static assets compress once at a higher quality — they're cached, not streamed.
+const staticBrotli = new Map();
+
+function webRequest(req) {
+ const url = new URL(req.url || "/", `http://${req.headers.host || `localhost:${PORT}`}`);
+ const method = req.method || "GET";
+ const body = method === "GET" || method === "HEAD" ? undefined : Readable.toWeb(req);
+ return new Request(url, {
+ method,
+ headers: req.headers,
+ body,
+ ...(body ? { duplex: "half" } : {})
+ });
+}
+
+createServer(async (req, res) => {
+ const url = req.url || "/";
+
+ if (url !== "/" && !url.includes("..")) {
+ const file = url.split("?")[0];
+ try {
+ const content = readFileSync(path.resolve(root, "dist/client" + file));
+ const headers = {
+ "Content-Type": MIME[path.extname(file)] ?? "application/octet-stream",
+ "Cache-Control": "public, max-age=3600"
+ };
+ if (/\bbr\b/.test(req.headers["accept-encoding"] || "")) {
+ let compressed = staticBrotli.get(file);
+ if (!compressed) {
+ compressed = brotliCompressSync(content, {
+ params: { [constants.BROTLI_PARAM_QUALITY]: 9 }
+ });
+ staticBrotli.set(file, compressed);
+ }
+ res.writeHead(200, { ...headers, "Content-Encoding": "br" });
+ return res.end(compressed);
+ }
+ res.writeHead(200, headers);
+ return res.end(content);
+ } catch {
+ // Fall through to the handler (SSR routes, /_server, ...).
+ }
+ }
+
+ try {
+ const response = await handleRequest(webRequest(req));
+ const cookies = response.headers.getSetCookie?.();
+ response.headers.forEach((value, key) => {
+ if (key !== "set-cookie") res.setHeader(key, value);
+ });
+ if (cookies?.length) res.setHeader("set-cookie", cookies);
+ res.statusCode = response.status;
+ const out = encoder(req, res) ?? res;
+ if (response.body) {
+ for await (const chunk of response.body) {
+ out.write(chunk);
+ if (out !== res) out.flush();
+ }
+ }
+ out.end();
+ } catch (e) {
+ console.error(e);
+ res.statusCode = 500;
+ res.end(e.message);
+ }
+}).listen(PORT, () => {
+ console.log(`Todos (server components) on http://localhost:${PORT}`);
+});
diff --git a/examples/todos-server/src/Document.tsx b/examples/todos-server/src/Document.tsx
new file mode 100644
index 000000000..5c25ecd4b
--- /dev/null
+++ b/examples/todos-server/src/Document.tsx
@@ -0,0 +1,16 @@
+import { HydrationScript, type JSX } from "@solidjs/web";
+
+export default function Document(props: { children?: JSX.Element }) {
+ return (
+
+
+
+
+
+ Solid 2.0 Todos (server components)
+
+
+ {props.children}
+
+ );
+}
diff --git a/examples/todos-server/src/app.css b/examples/todos-server/src/app.css
new file mode 100644
index 000000000..249c846af
--- /dev/null
+++ b/examples/todos-server/src/app.css
@@ -0,0 +1,434 @@
+html,
+body {
+ margin: 0;
+ padding: 0;
+}
+
+button {
+ margin: 0;
+ padding: 0;
+ border: 0;
+ background: none;
+ font-size: 100%;
+ vertical-align: baseline;
+ font-family: inherit;
+ font-weight: inherit;
+ color: inherit;
+ -webkit-appearance: none;
+ appearance: none;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+body {
+ font:
+ 14px "Helvetica Neue",
+ Helvetica,
+ Arial,
+ sans-serif;
+ line-height: 1.4em;
+ background: #f5f5f5;
+ color: #111111;
+ min-width: 230px;
+ max-width: 550px;
+ margin: 0 auto;
+ font-weight: 300;
+}
+
+:focus {
+ outline: 0;
+}
+
+.hidden {
+ display: none;
+}
+
+.todoapp {
+ background: #fff;
+ margin: 130px 0 40px 0;
+ position: relative;
+ box-shadow:
+ 0 2px 4px 0 rgba(0, 0, 0, 0.2),
+ 0 25px 50px 0 rgba(0, 0, 0, 0.1);
+}
+
+.todoapp input::-webkit-input-placeholder {
+ font-style: italic;
+ font-weight: 300;
+ color: rgba(0, 0, 0, 0.4);
+}
+
+.todoapp input::-moz-placeholder {
+ font-style: italic;
+ font-weight: 300;
+ color: rgba(0, 0, 0, 0.4);
+}
+
+.todoapp input::input-placeholder {
+ font-style: italic;
+ font-weight: 300;
+ color: rgba(0, 0, 0, 0.4);
+}
+
+.todoapp h1 {
+ position: absolute;
+ top: -140px;
+ width: 100%;
+ font-size: 80px;
+ font-weight: 200;
+ text-align: center;
+ color: #b83f45;
+ -webkit-text-rendering: optimizeLegibility;
+ -moz-text-rendering: optimizeLegibility;
+ text-rendering: optimizeLegibility;
+}
+
+.new-todo,
+.edit {
+ position: relative;
+ margin: 0;
+ width: 100%;
+ font-size: 24px;
+ font-family: inherit;
+ font-weight: inherit;
+ line-height: 1.4em;
+ color: inherit;
+ padding: 6px;
+ border: 1px solid #999;
+ box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2);
+ box-sizing: border-box;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+.new-todo {
+ padding: 16px 16px 16px 60px;
+ height: 65px;
+ border: none;
+ background: rgba(0, 0, 0, 0.003);
+ box-shadow: inset 0 -2px 1px rgba(0, 0, 0, 0.03);
+}
+
+.main {
+ position: relative;
+ z-index: 2;
+ border-top: 1px solid #e6e6e6;
+}
+
+.toggle-all {
+ width: 1px;
+ height: 1px;
+ border: none;
+ opacity: 0;
+ position: absolute;
+ right: 100%;
+ bottom: 100%;
+}
+
+.toggle-all + label {
+ width: 45px;
+ height: 65px;
+ font-size: 0;
+ position: absolute;
+ top: -65px;
+ left: -0;
+}
+
+.toggle-all + label:before {
+ content: "❯";
+ display: inline-block;
+ font-size: 22px;
+ color: #949494;
+ padding: 10px 27px 10px 27px;
+ -webkit-transform: rotate(90deg);
+ transform: rotate(90deg);
+}
+
+.toggle-all:checked + label:before {
+ color: #484848;
+}
+
+.todo-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.todo-list li {
+ position: relative;
+ font-size: 24px;
+ border-bottom: 1px solid #ededed;
+}
+
+.todo-list li:last-child {
+ border-bottom: none;
+}
+
+.todo-list li.editing {
+ border-bottom: none;
+ padding: 0;
+}
+
+.todo-list li.editing .edit {
+ display: block;
+ width: calc(100% - 43px);
+ padding: 12px 16px;
+ margin: 0 0 0 43px;
+}
+
+.todo-list li.editing .view {
+ display: none;
+}
+
+.todo-list li .toggle {
+ text-align: center;
+ width: 40px;
+ /* auto, since non-WebKit browsers doesn't support input styling */
+ height: auto;
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ margin: auto 0;
+ border: none; /* Mobile Safari */
+ -webkit-appearance: none;
+ appearance: none;
+}
+
+.todo-list li .toggle {
+ opacity: 0;
+}
+
+.todo-list li .toggle + label {
+ background-image: url("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23949494%22%20stroke-width%3D%223%22%2F%3E%3C%2Fsvg%3E");
+ background-repeat: no-repeat;
+ background-position: center left;
+}
+
+.todo-list li .toggle:checked + label {
+ background-image: url("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%2359A193%22%20stroke-width%3D%223%22%2F%3E%3Cpath%20fill%3D%22%233EA390%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22%2F%3E%3C%2Fsvg%3E");
+}
+
+.todo-list li label {
+ word-break: break-all;
+ padding: 15px 15px 15px 60px;
+ display: block;
+ line-height: 1.2;
+ transition: color 0.4s;
+ font-weight: 400;
+ color: #484848;
+}
+
+.todo-list li.completed label {
+ color: #949494;
+ text-decoration: line-through;
+}
+
+.todo-list li .destroy {
+ display: none;
+ position: absolute;
+ top: 0;
+ right: 10px;
+ bottom: 0;
+ width: 40px;
+ height: 40px;
+ margin: auto 0;
+ font-size: 30px;
+ color: #949494;
+ transition: color 0.2s ease-out;
+}
+
+.todo-list li .destroy:hover,
+.todo-list li .destroy:focus {
+ color: #c18585;
+}
+
+.todo-list li .destroy:after {
+ content: "×";
+ display: block;
+ height: 100%;
+ line-height: 1.1;
+}
+
+.todo-list li:hover .destroy {
+ display: block;
+}
+
+.todo-list li .edit {
+ display: none;
+}
+
+.todo-list li.editing:last-child {
+ margin-bottom: -1px;
+}
+
+.footer {
+ padding: 10px 15px;
+ height: 20px;
+ text-align: center;
+ font-size: 15px;
+ border-top: 1px solid #e6e6e6;
+}
+
+.footer:before {
+ content: "";
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ height: 50px;
+ overflow: hidden;
+ box-shadow:
+ 0 1px 1px rgba(0, 0, 0, 0.2),
+ 0 8px 0 -3px #f6f6f6,
+ 0 9px 1px -3px rgba(0, 0, 0, 0.2),
+ 0 16px 0 -6px #f6f6f6,
+ 0 17px 2px -6px rgba(0, 0, 0, 0.2);
+}
+
+.todo-count {
+ float: left;
+ text-align: left;
+}
+
+.todo-count strong {
+ font-weight: 300;
+}
+
+.filters {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ position: absolute;
+ right: 0;
+ left: 0;
+}
+
+.filters li {
+ display: inline;
+}
+
+.filters li a {
+ color: inherit;
+ margin: 3px;
+ padding: 3px 7px;
+ text-decoration: none;
+ border: 1px solid transparent;
+ border-radius: 3px;
+}
+
+.filters li a:hover {
+ border-color: rgba(175, 47, 47, 0.1);
+}
+
+.filters li a.selected {
+ border-color: rgba(175, 47, 47, 0.2);
+}
+
+.clear-completed,
+html .clear-completed:active {
+ float: right;
+ position: relative;
+ line-height: 19px;
+ text-decoration: none;
+ cursor: pointer;
+}
+
+.clear-completed:hover {
+ text-decoration: underline;
+}
+
+.info {
+ margin: 65px auto 0;
+ color: #4d4d4d;
+ font-size: 11px;
+ text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
+ text-align: center;
+}
+
+.info p {
+ line-height: 1;
+}
+
+.info a {
+ color: inherit;
+ text-decoration: none;
+ font-weight: 400;
+}
+
+.info a:hover {
+ text-decoration: underline;
+}
+
+/* Optimistic / saving / errored affordances */
+
+.loading {
+ padding: 20px;
+ text-align: center;
+ font-style: italic;
+ color: #949494;
+}
+
+.todo.pending label {
+ opacity: 0.5;
+ font-style: italic;
+}
+
+.todo.errored label {
+ color: #c91524;
+}
+
+.todo-list li .retry {
+ display: none;
+ position: absolute;
+ top: 0;
+ right: 10px;
+ bottom: 0;
+ width: 40px;
+ height: 40px;
+ margin: auto 0;
+ font-size: 24px;
+ color: #c91524;
+ cursor: pointer;
+ transition: color 0.2s ease-out;
+}
+
+.todo-list li .retry:after {
+ content: "\21bb";
+ display: block;
+ height: 100%;
+ line-height: 1.4;
+}
+
+.todo-list li .retry:hover,
+.todo-list li .retry:focus {
+ color: #8e0e1a;
+}
+
+.todo-list li.errored .retry {
+ display: block;
+}
+
+.todo-list li.errored .destroy,
+.todo-list li.errored:hover .destroy {
+ display: none;
+}
+
+.app-error {
+ margin: 130px auto;
+ padding: 24px;
+ background: #fff;
+ border: 1px solid #c91524;
+ color: #c91524;
+ text-align: center;
+ font-size: 16px;
+}
+
+.app-error button {
+ display: inline-block;
+ margin-top: 12px;
+ padding: 6px 12px;
+ border: 1px solid #c91524;
+ border-radius: 3px;
+ cursor: pointer;
+ color: #c91524;
+}
diff --git a/examples/todos-server/src/app.tsx b/examples/todos-server/src/app.tsx
new file mode 100644
index 000000000..296737efd
--- /dev/null
+++ b/examples/todos-server/src/app.tsx
@@ -0,0 +1,155 @@
+// The client side. Compare with ../../todos/src/app.tsx: `Header` and
+// `TodoRow` are the same client components — `TodoRow` is shared with the
+// server (./todo-row.tsx). `MainSection` and `Footer` are gone: that markup
+// comes from the server component (server/todos.tsx) as HTML, and the
+// client's part of it is one FILL per data context — `rowFor` for a row,
+// `listFor` for the list — a function of the server's args (and the
+// client's intent, errors and filter) returning the values the server
+// template binds. The server rows read those through an attribute slot; the
+// pending rows (todos the server has not seen) are the only client markup,
+// and they are `TodoRow` again, handed the same fill's result directly.
+import { createContext, Errored, For, Loading, useContext } from "solid-js";
+import { dynamic } from "@solidjs/web";
+import { createTodos, type Todos as TodosState } from "./todos";
+import { createHashFilter, type Filter } from "./filter";
+import { TodoRow, type RowBehavior } from "./todo-row";
+import type { Entity } from "./server/todos";
+import "./app.css";
+
+const TodosContext = createContext();
+
+function Header() {
+ const { actions } = useContext(TodosContext);
+ return (
+
+
todos
+ {
+ if (e.key !== "Enter") return;
+ const title = e.currentTarget.value.trim();
+ if (!title) return;
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
+ actions.addTodo({ id, title, completed: false });
+ e.currentTarget.value = "";
+ }}
+ />
+
+ );
+}
+
+function TodoList(props: { filter: Filter }) {
+ const state = useContext(TodosContext);
+ const { intent, errors, done, removed, extraRows, counts, actions } = state;
+ const Todos = dynamic(() => state.todos());
+
+ const visible = (completed: boolean) =>
+ props.filter === "all" || (props.filter === "active") !== completed;
+
+ // A row's behavior, from the server's view of it (`completed` as of the
+ // last response) and the client's (intent over it, errors beside it).
+ // Same function for a server row (through the `row` attribute slot) and a
+ // pending one (passed to directly). Values are getters and
+ // handlers are plain closures: building the object reads nothing, so the
+ // reads happen where the template binds each property — a tracking scope
+ // for a value, event time for a handler — and each position updates on
+ // its own.
+ const rowFor = (p: Entity): RowBehavior => ({
+ get rowClass() {
+ return {
+ todo: true,
+ completed: done(p.id, p.completed),
+ pending: !!intent.byId[p.id] || intent.adds.some(t => t.id === p.id),
+ errored: !!errors[p.id]
+ };
+ },
+ get done() {
+ return done(p.id, p.completed);
+ },
+ get removed() {
+ return removed(p.id) || !visible(done(p.id, p.completed));
+ },
+ get error() {
+ return errors[p.id] ? `Retry ${errors[p.id]!.type}` : undefined;
+ },
+ onToggle: e => actions.toggleTodo(p.id, e.currentTarget.checked),
+ onRemove: () => actions.removeTodo(p.id, p.completed),
+ onRetry: () => actions.retryTodo(p.id)
+ });
+
+ // The ids whose client-side `completed` is the given value, from the
+ // server's two lists (an entity's intent may have moved it across).
+ const idsWhere = (p: { active: string[]; completed: string[] }, completed: boolean) => [
+ ...p.active.filter(id => !removed(id) && done(id, false) === completed),
+ ...p.completed.filter(id => !removed(id) && done(id, true) === completed)
+ ];
+ const listFor = (p: { total: number; active: string[]; completed: string[] }) => {
+ const active = () => idsWhere(p, false);
+ const completed = () => idsWhere(p, true);
+ const allDone = () => active().length === 0 && completed().length > 0;
+ return {
+ get empty() {
+ return counts({ remaining: 0, total: p.total }).total === 0;
+ },
+ get allDone() {
+ return allDone();
+ },
+ get noneDone() {
+ return completed().length === 0;
+ },
+ onToggleAll: () =>
+ allDone() ? actions.toggleAll(completed(), false) : actions.toggleAll(active(), true),
+ onClearCompleted: () => actions.clearCompleted(completed())
+ };
+ };
+
+ return (
+ (
+
+ {todo => }
+
+ )}
+ count={p => {
+ const remaining = () => counts(p).remaining;
+ return (
+ <>
+ {remaining()} {remaining() === 1 ? "item" : "items"} left
+ >
+ );
+ }}
+ filters={() => ({
+ all: props.filter === "all",
+ active: props.filter === "active",
+ completed: props.filter === "completed"
+ })}
+ />
+ );
+}
+
+export default function App() {
+ const filter = createHashFilter();
+ return (
+ (
+
+
Something went wrong: {String(err())}
+ Reset
+
+ )}
+ >
+
+
+
+ Loading…}>
+
+
+
+
+
+ );
+}
diff --git a/examples/todos-server/src/filter.ts b/examples/todos-server/src/filter.ts
new file mode 100644
index 000000000..e80aca0c7
--- /dev/null
+++ b/examples/todos-server/src/filter.ts
@@ -0,0 +1,29 @@
+import { createSignal, onSettled } from "solid-js";
+
+export type Filter = "all" | "active" | "completed";
+
+function parseHash(hash: string): Filter {
+ if (hash === "#/active") return "active";
+ if (hash === "#/completed") return "completed";
+ return "all";
+}
+
+/**
+ * View-state primitive that mirrors the URL hash into a reactive filter.
+ *
+ * The SPA twin reads `location.hash` at creation. Here the app is
+ * server-rendered and the server cannot see the hash, so the signal starts
+ * at "all" on both faces (the hydrated HTML matches what the client's first
+ * pass computes) and takes the real hash once the initial activity settles —
+ * at the same moment the `hashchange` listener attaches.
+ */
+export function createHashFilter(): () => Filter {
+ const [filter, setFilter] = createSignal("all");
+ onSettled(() => {
+ const onChange = () => setFilter(parseHash(location.hash));
+ onChange();
+ window.addEventListener("hashchange", onChange);
+ return () => window.removeEventListener("hashchange", onChange);
+ });
+ return filter;
+}
diff --git a/examples/todos-server/src/lib/db.ts b/examples/todos-server/src/lib/db.ts
new file mode 100644
index 000000000..66b11d66b
--- /dev/null
+++ b/examples/todos-server/src/lib/db.ts
@@ -0,0 +1,81 @@
+// The todo store, server-only: this module is only imported by "use server"
+// modules, so unstorage never reaches the client build. It is the SPA twin's
+// mock API (../../../todos/src/api.ts) moved behind the server boundary with
+// the same shape and the same deliberate unreliability: every save waits
+// 400 ms and ~33% of them fail, so the optimistic UI, the per-item errors
+// and the retry affordances get exercised. Todos reset on server restart
+// (memory driver); swap the driver for a durable store in a deployment.
+import { createStorage } from "unstorage";
+import memoryDriver from "unstorage/drivers/memory";
+
+export interface Todo {
+ id: string;
+ title: string;
+ completed: boolean;
+}
+
+const storage = createStorage({ driver: memoryDriver() });
+
+const SEED: Todo[] = [
+ { id: "0001", title: "Read the server-components principles", completed: true },
+ { id: "0002", title: "Port TodoMVC to a server component", completed: false },
+ { id: "0003", title: "Break the network and watch it recover", completed: false }
+];
+
+export async function getTodos(): Promise {
+ const todos = (await storage.getItem("todos")) as Todo[] | null;
+ if (todos) return todos;
+ await storage.setItem("todos", SEED);
+ return SEED;
+}
+
+async function saveTodos(todos: Todo[]) {
+ if (Math.random() < 0.33) return reject(400);
+ await storage.setItem("todos", todos);
+ return delay(undefined, 400);
+}
+
+export async function addTodo(todo: Todo) {
+ const newTodo = { ...todo };
+ const todos = await getTodos();
+ if (todos.some(t => t.id === newTodo.id)) return newTodo;
+ const index = todos.findIndex(t => t.id > newTodo.id);
+ if (index > -1) todos.splice(index, 0, newTodo);
+ else todos.push(newTodo);
+ await saveTodos(todos);
+ return newTodo;
+}
+
+export async function removeTodo(todoId: string) {
+ return saveTodos((await getTodos()).filter(t => t.id !== todoId));
+}
+
+export async function toggleTodo(todoId: string, completed: boolean) {
+ let found: Todo | undefined;
+ const todos = (await getTodos()).map(t => {
+ if (t.id !== todoId) return t;
+ return (found = { ...t, completed });
+ });
+ if (!found) return reject(400);
+ await saveTodos(todos);
+ return found;
+}
+
+export async function toggleAll(ids: string[], completed: boolean) {
+ const set = new Set(ids);
+ const todos = (await getTodos()).map(t => (set.has(t.id) ? { ...t, completed } : t));
+ return saveTodos(todos);
+}
+
+export async function clearCompleted(ids: string[]) {
+ const set = new Set(ids);
+ return saveTodos((await getTodos()).filter(t => !set.has(t.id)));
+}
+
+function delay(payload: T, time: number) {
+ return new Promise(res => setTimeout(res, time, payload));
+}
+
+function reject(time: number) {
+ return new Promise((_, rej) => setTimeout(rej, time, new Error("Failed to save")));
+}
diff --git a/examples/todos-server/src/server/todos.tsx b/examples/todos-server/src/server/todos.tsx
new file mode 100644
index 000000000..19d9b64d0
--- /dev/null
+++ b/examples/todos-server/src/server/todos.tsx
@@ -0,0 +1,130 @@
+"use server";
+// TodoMVC's list as a server component. Compare with the SPA twin's
+// app.tsx: the markup is the same, but this side renders DATA only — titles,
+// counts, ids, the server's `completed` — and never a pending row, an error
+// class or a retry button. Those belong to the client, and the client puts
+// them on the server's own elements through ATTRIBUTE SLOTS: a slot CALLED with
+// the element's data context and READ as an object, its properties bound
+// at positions of the template. The fill on the other side receives the
+// args as reactive props and returns the values; the runtime writes each
+// bound position, re-runs the fill when the args change (a refetch) or the
+// client's state does (an optimistic write), and morphs around the
+// positions so a new response never clobbers a client-owned value.
+//
+// One call per data context: `props.row(entity)` is the row's whole client
+// behavior, consumed by the row's
, its checkbox and its buttons
+// (../todo-row.tsx); `props.list(...)` is the list-level behavior consumed
+// by the section, the toggle-all box, the footer and the clear button.
+//
+// The one thing the client cannot bind is a row the server has not
+// rendered — an optimistic add — so `` is a pre-placed
+// MARKUP slot where the client renders its in-flight rows: the same
+// `TodoRow`, with the same fill's result passed directly.
+import type { AttributeSlot, Slot } from "@solidjs/web/frames";
+import * as db from "~/lib/db";
+import { TodoRow, type RowBehavior } from "~/todo-row";
+
+export type Entity = { id: string; completed: boolean };
+
+/** The list-level values and behavior the client owns. */
+export interface ListBehavior {
+ empty: boolean;
+ allDone: boolean;
+ onToggleAll: () => void;
+ noneDone: boolean;
+ onClearCompleted: () => void;
+}
+
+/** Which filter link is selected — client state (the URL hash). */
+export interface FilterBehavior {
+ all: boolean;
+ active: boolean;
+ completed: boolean;
+}
+
+export interface TodoListProps {
+ list: AttributeSlot<{ total: number; active: string[]; completed: string[] }, ListBehavior>;
+ row: AttributeSlot;
+ pending: Slot;
+ count: Slot<{ remaining: number; total: number }>;
+ filters: AttributeSlot<{}, FilterBehavior>;
+}
+
+export async function todoListView() {
+ const todos = await db.getTodos();
+ const active = todos.filter(t => !t.completed).map(t => t.id);
+ const completed = todos.filter(t => t.completed).map(t => t.id);
+ return (props: TodoListProps) => {
+ const list = props.list({ total: todos.length, active, completed });
+ const filters = props.filters();
+ return (
+ <>
+
+
+
+
+ {todos.map(t => (
+
+ ))}
+
+
+
+
+ >
+ );
+ };
+}
+
+// The mutations. Plain server functions: the client calls them from inside
+// its actions and follows each with a refetch of `todoListView` (see
+// ../todos.ts) — the "typical multi-flight" shape, where the write and the
+// re-read are separate requests and the action's transaction spans both.
+export async function addTodo(todo: db.Todo) {
+ return db.addTodo(todo);
+}
+export async function removeTodo(id: string) {
+ return db.removeTodo(id);
+}
+export async function toggleTodo(id: string, completed: boolean) {
+ return db.toggleTodo(id, completed);
+}
+export async function toggleAll(ids: string[], completed: boolean) {
+ return db.toggleAll(ids, completed);
+}
+export async function clearCompleted(ids: string[]) {
+ return db.clearCompleted(ids);
+}
diff --git a/examples/todos-server/src/todo-row.tsx b/examples/todos-server/src/todo-row.tsx
new file mode 100644
index 000000000..6e25bf77a
--- /dev/null
+++ b/examples/todos-server/src/todo-row.tsx
@@ -0,0 +1,43 @@
+// The row. One component, no directive, no side: the server renders it for
+// every todo it has, the client renders it for every todo the server does
+// not have yet (an add in flight, or one that failed). What differs is what
+// `row` IS at the call site — on the server an ATTRIBUTE SLOT (server/todos.tsx:
+// `props.row({ id, completed })`), whose properties are stand-ins the
+// positions below bind and the client owns; on the client the fill's result
+// itself (app.tsx: `rowFor(todo)`), so the same positions are ordinary
+// bindings. The component cannot tell and does not need to.
+//
+// Keys are semantic, positions are structural: nothing in `RowBehavior` says
+// attribute, class or handler — where each property is bound below does.
+
+/** What the client decides about a row: the values and behavior it owns. */
+export interface RowBehavior {
+ rowClass: Record;
+ removed: boolean;
+ done: boolean;
+ onToggle: (e: InputEvent & { currentTarget: HTMLInputElement }) => void;
+ onRemove: () => void;
+ onRetry: () => void;
+ error: string | undefined;
+}
+
+export function TodoRow(props: { id: string; title: string; row: RowBehavior }) {
+ // `$key` is the
's MORPH identity (server markup: a response keeps the
+ // node for the same todo); a DOM compile strips it. The `row` call's own
+ // `$key` is the occurrence's identity — two keys, two jobs.
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/examples/todos-server/src/todos.ts b/examples/todos-server/src/todos.ts
new file mode 100644
index 000000000..eb73a8f35
--- /dev/null
+++ b/examples/todos-server/src/todos.ts
@@ -0,0 +1,220 @@
+// The client's half of the list. Compare with ../../todos/src/todos.ts: the
+// SPA keeps the whole todo array on the client and layers optimistic writes
+// and errors over it. Here the array is server markup that the client never
+// holds — what it holds is INTENT (what it has asked the server to do and
+// not yet heard back about) and the errors it heard back. Both are keyed by
+// entity id, and the fills in app.tsx combine them with the args each server
+// element carries.
+//
+// Same three lifetime layers as the SPA, same order, without the array:
+//
+// 3. Optimistic (transition-scoped) ── `intent`, a `createOptimisticStore`
+// written inside `action` generators;
+// auto-reverts when the action settles,
+// which is when the server's answer
+// has APPLIED (see the actions).
+// 2. Ephemeral (UI-scoped) ── `errors`, a plain store written after
+// the call fails. Survives the revert
+// because it is not optimistic.
+// 1. Persistent (durable) ── the server's rows: `p.completed` in
+// a fill's props is truth as of the
+// last response.
+//
+// Every fill reads (3) over (1) and shows (2) beside it.
+//
+// Pending rows are not inert (parity with the SPA): a toggle or remove on a
+// todo whose add is still in flight writes its intent immediately and then
+// waits for the add to settle before talking to the server — the server
+// has no such id until then. A todo whose add FAILED exists only here, so
+// those actions edit the failed record instead (the retry carries the
+// change).
+
+import { action, createMemo, createOptimisticStore, createStore, refresh } from "solid-js";
+import type { Todo } from "~/lib/db";
+import * as server from "~/server/todos";
+
+export type { Todo };
+
+export type TodoError = {
+ type: "addTodo" | "removeTodo" | "toggleTodo";
+ args: any[];
+};
+
+export interface Intent {
+ /** The server's `completed` when the intent was written (for the counts). */
+ from: boolean;
+ completed?: boolean;
+ removed?: boolean;
+}
+
+export function createTodos() {
+ // The source the boundary shows and the actions refetch. `refresh(todos)`
+ // re-runs the memo, which re-calls the server component; the transaction
+ // holds until the refetched markup and args have applied.
+ const todos = createMemo(() => server.todoListView());
+
+ const [intent, setIntent] = createOptimisticStore<{
+ byId: Record;
+ adds: Todo[];
+ }>({ byId: {}, adds: [] });
+
+ const [errors, setErrors] = createStore>({});
+
+ /** Adds in flight, by id: what a toggle/remove on a pending row waits on. */
+ const inflight = new Map>();
+ /** The failed add a todo exists in, if that is the only place it exists. */
+ const failedAdd = (id: string) => (errors[id]?.type === "addTodo" ? errors[id] : undefined);
+
+ /** The client's view of one entity's `completed`: intent over the server. */
+ const done = (id: string, completed: boolean) => intent.byId[id]?.completed ?? completed;
+ const removed = (id: string) => !!intent.byId[id]?.removed;
+
+ /** Todos the server does not have: in-flight adds, then adds that failed.
+ * The store's own objects, not copies: their identity is stable across
+ * reads, so a `` over them keeps each row's node while the list
+ * around it changes. */
+ const extraRows = (): Todo[] => {
+ const rows: Todo[] = [...intent.adds];
+ for (const id in errors) {
+ const error = errors[id];
+ if (error?.type === "addTodo" && !rows.some(r => r.id === id)) rows.push(error.args[0]);
+ }
+ return rows.sort((a, b) => (a.id > b.id ? 1 : -1));
+ };
+
+ /** Counts as the client sees them: the server's, adjusted by intent. */
+ const counts = (p: { remaining: number; total: number }) => {
+ let remaining = p.remaining;
+ let total = p.total;
+ const extra = extraRows();
+ for (const id in intent.byId) {
+ // Intent over a row the server has; a pending row's intent is read
+ // with the row below.
+ if (extra.some(t => t.id === id)) continue;
+ const i = intent.byId[id];
+ if (i.removed) {
+ total--;
+ if (!i.from) remaining--;
+ } else if (i.completed !== undefined && i.completed !== i.from) {
+ remaining += i.completed ? -1 : 1;
+ }
+ }
+ for (const t of extra) {
+ if (removed(t.id)) continue;
+ total++;
+ if (!done(t.id, t.completed)) remaining++;
+ }
+ return { remaining, total };
+ };
+
+ function fail(id: string, error: TodoError) {
+ setErrors(e => {
+ e[id] ||= error;
+ });
+ }
+ function ok(id: string) {
+ setErrors(e => {
+ delete e[id];
+ });
+ }
+
+ const add = action(function* (todo: Todo) {
+ setIntent(s => {
+ if (!s.adds.some(t => t.id === todo.id)) s.adds.push(todo);
+ });
+ try {
+ yield server.addTodo(todo);
+ ok(todo.id);
+ } catch {
+ fail(todo.id, { type: "addTodo", args: [todo] });
+ }
+ yield refresh(todos);
+ });
+
+ const actions = {
+ addTodo(todo: Todo): Promise {
+ const p = add(todo).finally(() => {
+ if (inflight.get(todo.id) === p) inflight.delete(todo.id);
+ });
+ inflight.set(todo.id, p);
+ return p;
+ },
+ removeTodo: action(function* (id: string, completed: boolean) {
+ setIntent(s => {
+ s.byId[id] = { from: completed, removed: true };
+ });
+ // Sequenced behind the add the server has not answered yet.
+ const pending = inflight.get(id);
+ if (pending) yield pending;
+ if (failedAdd(id)) {
+ // The todo never reached the server: removing it is forgetting it.
+ ok(id);
+ return;
+ }
+ try {
+ yield server.removeTodo(id);
+ ok(id);
+ } catch {
+ fail(id, { type: "removeTodo", args: [id, completed] });
+ }
+ yield refresh(todos);
+ }),
+ toggleTodo: action(function* (id: string, completed: boolean) {
+ setIntent(s => {
+ s.byId[id] = { from: !completed, completed };
+ });
+ const pending = inflight.get(id);
+ if (pending) yield pending;
+ const failed = failedAdd(id);
+ if (failed) {
+ // The todo exists only in its failed add: the retry adds it toggled.
+ setErrors(e => {
+ (e[id]!.args[0] as Todo).completed = completed;
+ });
+ return;
+ }
+ try {
+ yield server.toggleTodo(id, completed);
+ ok(id);
+ } catch {
+ fail(id, { type: "toggleTodo", args: [id, completed] });
+ }
+ yield refresh(todos);
+ }),
+ toggleAll: action(function* (ids: string[], completed: boolean) {
+ setIntent(s => {
+ for (const id of ids) s.byId[id] = { from: !completed, completed };
+ });
+ try {
+ yield server.toggleAll(ids, completed);
+ ids.forEach(ok);
+ } catch {
+ // Bulk failed — fan the error out to per-item entries so each
+ // failed item gets its own retry affordance via `retryTodo`.
+ ids.forEach(id => fail(id, { type: "toggleTodo", args: [id, completed] }));
+ }
+ yield refresh(todos);
+ }),
+ clearCompleted: action(function* (ids: string[]) {
+ setIntent(s => {
+ for (const id of ids) s.byId[id] = { from: true, removed: true };
+ });
+ try {
+ yield server.clearCompleted(ids);
+ ids.forEach(ok);
+ } catch {
+ ids.forEach(id => fail(id, { type: "removeTodo", args: [id, true] }));
+ }
+ yield refresh(todos);
+ }),
+ retryTodo(id: string): Promise {
+ const error = errors[id];
+ if (!error) return Promise.resolve();
+ return (actions[error.type] as (...args: any[]) => Promise)(...error.args);
+ }
+ };
+
+ return { todos, intent, errors, done, removed, extraRows, counts, actions };
+}
+
+export type Todos = ReturnType;
diff --git a/examples/todos-server/src/vite-env.d.ts b/examples/todos-server/src/vite-env.d.ts
new file mode 100644
index 000000000..11f02fe2a
--- /dev/null
+++ b/examples/todos-server/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/examples/todos-server/tsconfig.json b/examples/todos-server/tsconfig.json
new file mode 100644
index 000000000..23bd1e3bc
--- /dev/null
+++ b/examples/todos-server/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true,
+ "esModuleInterop": true,
+ "jsx": "preserve",
+ "jsxImportSource": "@solidjs/web",
+ "allowJs": true,
+ "strict": true,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "isolatedModules": true,
+ "resolveJsonModule": true,
+ "paths": {
+ "~/*": ["./src/*"]
+ }
+ },
+ "include": ["src", "vite.config.ts"]
+}
diff --git a/examples/todos-server/vite.config.ts b/examples/todos-server/vite.config.ts
new file mode 100644
index 000000000..2be0a74d9
--- /dev/null
+++ b/examples/todos-server/vite.config.ts
@@ -0,0 +1,15 @@
+import { fileURLToPath } from "node:url";
+import { defineConfig } from "vite";
+import solid from "@solidjs/vite-plugin";
+
+// The same turnkey setup as ../hackernews: `serverFunctions.components` makes
+// a `"use server"` function that returns a component stream its markup over
+// the server-function endpoint (and inline it at document SSR). Nothing in
+// src/ imports the frames runtime — the generated entries wire it.
+export default defineConfig({
+ resolve: {
+ alias: { "~": fileURLToPath(new URL("./src", import.meta.url)) }
+ },
+ server: { port: 3010 },
+ plugins: [solid({ start: {}, ssr: true, serverFunctions: { components: true } })]
+});
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 => {
@@ -588,11 +590,10 @@ function transformAttributes(
}
if (key.startsWith("prop:")) return;
if (key.startsWith("on")) {
- // Capture-phase variants can't ride delegation; v1 drops them as
- // before. `on:x` keeps the raw name, `onXxx` lowercases — the same
- // event-name derivation as the client runtime.
- if (info.serverComponents && !key.startsWith("oncapture:")) {
- const pos = key.startsWith("on:") ? key.slice(3) : key.slice(2).toLowerCase();
+ // `onXxx` lowercases to the event name — the client runtime's own
+ // derivation (`onClick` -> `click`); the position is bound under it.
+ if (info.serverComponents) {
+ const pos = key.slice(2).toLowerCase();
if (pos) claims.push([pos, value.expression as babelTypes.Expression]);
}
return;
@@ -628,6 +629,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) ||
@@ -771,23 +796,7 @@ function transformAttributes(
}
});
if (claims.length) {
- // Duplicate event keys were already last-wins-stripped above; `ref` is
- // exempt from that pass (client semantics fire every ref), so multiple
- // refs merge into an array value.
- const byPos = new Map();
- for (const [pos, expr] of claims) {
- let list = byPos.get(pos);
- if (!list) byPos.set(pos, (list = []));
- list.push(expr);
- }
- const map = t.objectExpression(
- [...byPos].map(([pos, exprs]) =>
- t.objectProperty(
- t.stringLiteral(pos),
- exprs.length === 1 ? exprs[0] : t.arrayExpression(exprs)
- )
- )
- );
+ const map = claimMap(claims);
// `_$sharedConfig.context && _$sharedConfig.context.claims
// ? _$ssrClaim({...}) : ""`
// — the claims flag is only set inside a server component's render
@@ -933,7 +942,7 @@ function transformChildren(
function createElement(
path: BabelPath & { doNotEscape?: boolean },
- { topLevel, hydratable }: SSRTransformInfo
+ { topLevel, hydratable, serverComponents }: SSRTransformInfo
): SSRSpreadTransformResult {
const tagName = getTagName(path.node),
config = getConfig(path),
@@ -986,6 +995,21 @@ function createElement(
const node = attribute.node;
return !(t.isJSXAttribute(node) && t.isJSXIdentifier(node.name) && node.name.name === "ref");
});
+ // Server components (principles §9.2.3): the named `ref`/`on*` attributes
+ // of a spread element are handler positions like a template element's,
+ // and compile to the same claim map — `{ click: expr, ref: [a, b] }`,
+ // duplicate refs merged, a duplicate handler last-wins as the template
+ // path strips it — keyed by the index of the source each attribute sits
+ // before (`` → `{ 1: { click: go } }`, the
+ // spreads being sources 0 and 2), and handed to `ssrElement` as a thunk
+ // it reads only inside a server component's render (the gate the template
+ // path's `ssrClaim` guard reads), so plain SSR never evaluates the
+ // expressions. The runtime settles each handler position in source order,
+ // as the client's `spread(el, [a, { onClick: go }, b])` does — a spread at
+ // that index or later that HAS the key owns it — and merges every ref.
+ // Plain SSR output is unchanged (dropped, as a server element has no
+ // handlers to run).
+ const claims: [number, string, babelTypes.Expression][] = [];
let props: babelTypes.Expression[];
// Attributes written AFTER the last spread are markup, not a source: no
@@ -1006,7 +1030,13 @@ function createElement(
// keys.
const tail: Array = [];
const skipKeys: string[] = [];
- if (propAttributes.length === 1 && t.isJSXSpreadAttribute(propAttributes[0].node)) {
+ if (
+ propAttributes.length === 1 &&
+ t.isJSXSpreadAttribute(propAttributes[0].node) &&
+ // A `ref` beside the lone spread is a claim under `serverComponents`;
+ // the loop below places it.
+ !(serverComponents && attributes.length > 1)
+ ) {
props = [propAttributes[0].node.argument];
} else {
props = [];
@@ -1052,8 +1082,30 @@ function createElement(
: node.name.name;
if (hasChildren && key === "children") return;
- if (key === "ref") return;
- if (key.startsWith("prop:") || key.startsWith("on")) return;
+ if (key === "ref" || key.startsWith("on")) {
+ if (serverComponents && t.isJSXExpressionContainer(value)) {
+ const expression = value.expression;
+ const pos = key === "ref" ? "ref" : key.slice(2).toLowerCase();
+ if (
+ pos &&
+ !(
+ t.isJSXEmptyExpression(expression) ||
+ t.isStringLiteral(expression) ||
+ t.isNumericLiteral(expression) ||
+ t.isBooleanLiteral(expression)
+ )
+ ) {
+ // The index of the source this attribute sits before: the
+ // sources pushed so far, plus the running literal if it will
+ // be pushed ahead of the next spread. The literal never
+ // carries a handler key, so a spread's index is either below
+ // this or at/after it — never ambiguous.
+ claims.push([props.length + (runningObject.length ? 1 : 0), pos, expression]);
+ }
+ }
+ return;
+ }
+ if (key.startsWith("prop:")) return;
if (i > lastSpread) {
const part = tailAttribute(path, tagName, key, node);
if (part !== undefined) {
@@ -1130,10 +1182,65 @@ function createElement(
}
args.push(registerSkip(path, skipKeys), markup);
}
+ if (claims.length) {
+ if (!skipKeys.length) args.push(t.identifier("undefined"), t.identifier("undefined"));
+ args.push(t.arrowFunctionExpression([], claimSegments(claims)));
+ }
const exprs = [t.callExpression(registerImportMethod(path, "ssrElement"), args)];
return { exprs, template: "", declarations: [], dynamics: [], spreadElement: true };
}
+/**
+ * A spread element's claim map by source index (`createElement`): `{ 1: {
+ * click: go }, 3: { ref: el } }`. A handler position keeps its LAST named
+ * attribute only — the template path's duplicate strip, applied here to the
+ * claims (a later attribute wins whatever sits between, so the earlier one
+ * is never read on either side); refs merge within a segment as `claimMap`
+ * merges them, and across segments at the runtime.
+ */
+function claimSegments(
+ claims: [number, string, babelTypes.Expression][]
+): babelTypes.ObjectExpression {
+ const lastHandler = new Map();
+ claims.forEach(([, pos], i) => {
+ if (pos !== "ref") lastHandler.set(pos, i);
+ });
+ const segments = new Map();
+ claims.forEach(([index, pos, expr], i) => {
+ if (pos !== "ref" && lastHandler.get(pos) !== i) return;
+ let list = segments.get(index);
+ if (!list) segments.set(index, (list = []));
+ list.push([pos, expr]);
+ });
+ return t.objectExpression(
+ [...segments].map(([index, list]) => t.objectProperty(t.numericLiteral(index), claimMap(list)))
+ );
+}
+
+/**
+ * The compiled claim map of an element's handler positions, `{ click: expr,
+ * ref: [a, b] }`: duplicate event keys were last-wins-stripped by the
+ * template path's duplicate strip (or `claimSegments`); `ref` is exempt from
+ * that pass (client semantics fire every ref), so multiple refs merge into
+ * an array value.
+ */
+function claimMap(claims: [string, babelTypes.Expression][]): babelTypes.ObjectExpression {
+ const byPos = new Map();
+ for (const [pos, expr] of claims) {
+ let list = byPos.get(pos);
+ if (!list) byPos.set(pos, (list = []));
+ list.push(expr);
+ }
+ return t.objectExpression(
+ [...byPos].map(([pos, exprs]) =>
+ t.objectProperty(
+ t.stringLiteral(pos),
+ exprs.length === 1 ? exprs[0] : t.arrayExpression(exprs)
+ )
+ )
+ );
+}
+
/**
* What an attribute after an element's last spread contributes to
* `ssrElement`'s attribute string. A static is written as the template path
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/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..708de2678
--- /dev/null
+++ b/packages/babel-plugin/test/__ssr_server_components_fixtures__/attributeSlots/code.js
@@ -0,0 +1,58 @@
+// 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 = (
+
+);
+
+// A spread element's named `ref`/`on*` compile to the same claim map a
+// template element's do — duplicate refs merged, a tuple kept whole, a
+// duplicate handler last-wins — keyed by the index of the source each
+// attribute sits before, handed to `ssrElement` as a thunk it reads only
+// inside a server component's render, wherever the attributes sit relative
+// to the spreads (before, between, after): the runtime settles a handler
+// position in source order, as the client's spread does. Plain SSR
+// drops them; the tail after the last spread still bakes its statics; the
+// spread's own handler keys are the runtime's to bind.
+const spread = (
+
+
+
+
+
+
+);
+
+// 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..120267788
--- /dev/null
+++ b/packages/babel-plugin/test/__ssr_server_components_fixtures__/attributeSlots/output.js
@@ -0,0 +1,161 @@
+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 { ssrElement as _$ssrElement } 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$ = [
+ '
"];
-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/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-server-components/attributeSlots/output.js b/packages/compiler/__tests__/fixtures/ssr-server-components/attributeSlots/output.js
new file mode 100644
index 000000000..9e24b682a
--- /dev/null
+++ b/packages/compiler/__tests__/fixtures/ssr-server-components/attributeSlots/output.js
@@ -0,0 +1,95 @@
+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 { ssrElement as _$ssrElement } 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$ = [
+ "
"
-];
-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__/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/__tests__/ssr-server-components-fixtures.test.js b/packages/compiler/__tests__/ssr-server-components-fixtures.test.js
index 303aceeff..cf7ad9bb8 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,23 @@ 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");
+ // Spread elements: `ref`/`on*` drop as before — no claim thunk, no
+ // source property, and the handler expressions do not appear at all.
+ expect(output).not.toContain("row.go");
+ expect(output).not.toContain("row.el");
+ expect(output).not.toContain("row.key");
+ expect(output).not.toContain("localHandler");
+ expect(output).not.toContain("row.first");
+ expect(output).not.toContain("row.third");
+ expect(output).toMatch(/_\$ssrElement\("button", rest, \[/);
+ // A lone spread beside a `ref` stays the bare props argument.
+ expect(output).toMatch(/_\$ssrElement\("u", rest, undefined, false\)/);
});
});
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/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)?);
}
}
diff --git a/packages/compiler/src/ssr/transform.rs b/packages/compiler/src/ssr/transform.rs
index 185ac033c..b7786f029 100644
--- a/packages/compiler/src/ssr/transform.rs
+++ b/packages/compiler/src/ssr/transform.rs
@@ -1439,7 +1439,7 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> {
self.uses_ssr_select_values = true;
}
let do_not_escape = tag_name == "script" || tag_name == "style";
- let (props, tail) = self.spread_props(
+ let (props, tail, claims) = self.spread_props(
&tag_name,
&element.opening_element.attributes,
!element.children.is_empty(),
@@ -1472,6 +1472,23 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> {
let markup = self.tail_markup(element.span, tail);
args.push(expression_to_argument(markup));
}
+ // The claim map rides as a seventh argument, a thunk `ssrElement`
+ // reads only under an armed render context; `undefined` pads the
+ // skip/markup slots when the element has no tail.
+ if !claims.is_empty() {
+ if args.len() == 4 {
+ for _ in 0..2 {
+ args.push(expression_to_argument(self.ast().expression_identifier(
+ element.span,
+ self.ast().ident("undefined"),
+ )));
+ }
+ }
+ let map = self.claim_segments(element.span, claims);
+ args.push(expression_to_argument(
+ crate::shared::ast::concise_arrow_thunk(self.allocator, element.span, map),
+ ));
+ }
Ok(self.ast().expression_call(
element.span,
self.ast()
@@ -1507,17 +1524,39 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> {
) -> Result<(
Expression<'a>,
Option<(std::vec::Vec, std::vec::Vec>)>,
+ std::vec::Vec<(usize, String, Expression<'a>)>,
)> {
+ // Server components (principles §9.2.3): the named `ref`/`on*`
+ // attributes of a spread element are handler positions like a
+ // template element's, and compile to the same claim map — `{ click:
+ // expr, ref: [a, b] }`, duplicate refs merged, a duplicate handler
+ // last-wins as the template path strips it — keyed by the index of
+ // the source each attribute sits before (`` → `{ 1: { click: go } }`, the spreads being sources 0 and
+ // 2), and handed to `ssrElement` as a thunk it reads only inside a
+ // server component's render (the gate the template path's `ssrClaim`
+ // guard reads), so plain SSR never evaluates the expressions. The
+ // runtime settles each handler position in source order, as the
+ // client's `spread(el, [a, { onClick: go }, b])` does — a spread at
+ // that index or later that HAS the key owns it — and merges every
+ // ref. Plain SSR
+ // output is unchanged (dropped, as a server element has no handlers
+ // to run). Collected in the loop below (`spread_claim`), where the
+ // source index is known.
+ let mut claims: std::vec::Vec<(usize, String, Expression<'a>)> = std::vec::Vec::new();
// The DOM transform handles `ref` outside its spread prop sources.
let mut prop_attributes = attributes.iter().filter(|attr| {
!matches!(attr, JSXAttributeItem::Attribute(attr)
if matches!(&attr.name, oxc_ast::ast::JSXAttributeName::Identifier(name)
if name.name == "ref"))
});
+ // A `ref` beside the lone spread is a claim under `serverComponents`;
+ // the loop below places it.
if let (Some(JSXAttributeItem::SpreadAttribute(spread)), None) =
(prop_attributes.next(), prop_attributes.next())
+ && !(self.server_components && attributes.len() > 1)
{
- return Ok((spread.argument.clone_in(self.allocator), None));
+ return Ok((spread.argument.clone_in(self.allocator), None, claims));
}
let last_spread = attributes
.iter()
@@ -1551,6 +1590,19 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> {
prop_objects.push(argument);
}
JSXAttributeItem::Attribute(attr) => {
+ if self.server_components
+ && let Some((pos, expression)) = self.spread_claim(attr)
+ {
+ // The index of the source this attribute sits
+ // before: the sources pushed so far, plus the running
+ // literal if it will be pushed ahead of the next
+ // spread. The literal never carries a handler key, so
+ // a spread's index is either below this or at/after
+ // it — never ambiguous.
+ let index = prop_objects.len() + usize::from(!running_props.is_empty());
+ claims.push((index, pos, expression));
+ continue;
+ }
if let Some(property) =
self.spread_prop_property(tag_name, attr, has_children, i > last_spread)?
{
@@ -1595,7 +1647,47 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> {
.expect("single SSR spread prop object exists")
};
let tail = (!skip_keys.is_empty()).then_some((skip_keys, tail));
- Ok((props, tail))
+ Ok((props, tail, claims))
+ }
+
+ /// The handler position a named attribute of a spread element claims
+ /// (Babel's `claims` in `createElement`): a `ref`/`on*` whose value is
+ /// an expression that is not a literal; `onXxx` lowercases to the event
+ /// name as the template path derives it. `None` for every other
+ /// attribute — including a `ref`/`on*` with a literal or no value,
+ /// which `spread_prop_property` drops as before.
+ fn spread_claim(
+ &self,
+ attr: &oxc_ast::ast::JSXAttribute<'a>,
+ ) -> Option<(String, Expression<'a>)> {
+ let name = match &attr.name {
+ oxc_ast::ast::JSXAttributeName::Identifier(name) => name.name.to_string(),
+ oxc_ast::ast::JSXAttributeName::NamespacedName(name) => {
+ format!("{}:{}", name.namespace.name, name.name.name)
+ }
+ };
+ let pos = if name == "ref" {
+ "ref".to_string()
+ } else {
+ let pos = name.strip_prefix("on")?.to_lowercase();
+ if pos.is_empty() {
+ return None;
+ }
+ pos
+ };
+ let Some(JSXAttributeValue::ExpressionContainer(container)) = &attr.value else {
+ return None;
+ };
+ let expression = container.expression.as_expression()?;
+ if matches!(
+ expression,
+ Expression::StringLiteral(_)
+ | Expression::NumericLiteral(_)
+ | Expression::BooleanLiteral(_)
+ ) {
+ return None;
+ }
+ Some((pos, expression.clone_in(self.allocator)))
}
/// One attribute of a spread element: a property of the running source
@@ -1617,9 +1709,21 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> {
if has_children && name == "children" {
return Ok(None);
}
+ // `ref`/`on*` render nothing on the server; under `serverComponents`
+ // they are the element's claim map instead (`spread_claim`).
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 +1995,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(
@@ -2025,15 +2130,11 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> {
return Ok(());
}
if let Some(rest) = key.strip_prefix("on") {
- // Capture-phase variants can't ride delegation; they drop as
- // before. `on:x` keeps the raw name, `onXxx` lowercases — the
- // same event-name derivation as the client runtime.
- if self.server_components && !key.starts_with("oncapture:") {
- let pos = if let Some(raw) = rest.strip_prefix(':') {
- raw.to_string()
- } else {
- rest.to_lowercase()
- };
+ // `onXxx` lowercases to the event name — the client runtime's
+ // own derivation (`onClick` -> `click`); the position is bound
+ // under it.
+ if self.server_components {
+ let pos = rest.to_lowercase();
if !pos.is_empty() {
claims.push((pos, expression));
}
@@ -2057,6 +2158,31 @@ 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,19 +2823,77 @@ 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
- /// refs), and the expressions only evaluate when the render context's
- /// claims flag is set —
- /// `_$sharedConfig.context && _$sharedConfig.context.claims
- /// ? _$ssrClaim({...}) : ""`.
- fn ssr_claim_hole(
- &mut self,
+ /// A spread element's claim map by source index (Babel's
+ /// `claimSegments`): `{ 1: { click: go }, 3: { ref: el } }`. A handler
+ /// position keeps its LAST named attribute only — the template path's
+ /// duplicate strip, applied here to the claims (a later attribute wins
+ /// whatever sits between, so the earlier one is never read on either
+ /// side); refs merge within a segment as `claim_map` merges them, and
+ /// across segments at the runtime.
+ fn claim_segments(
+ &self,
+ span: Span,
+ claims: std::vec::Vec<(usize, String, Expression<'a>)>,
+ ) -> Expression<'a> {
+ let mut last_handler: std::vec::Vec<(String, usize)> = std::vec::Vec::new();
+ for (i, (_, pos, _)) in claims.iter().enumerate() {
+ if pos == "ref" {
+ continue;
+ }
+ if let Some(entry) = last_handler.iter_mut().find(|(name, _)| name == pos) {
+ entry.1 = i;
+ } else {
+ last_handler.push((pos.clone(), i));
+ }
+ }
+ let mut segments: std::vec::Vec<(usize, std::vec::Vec<(String, Expression<'a>)>)> =
+ std::vec::Vec::new();
+ for (i, (index, pos, expr)) in claims.into_iter().enumerate() {
+ if pos != "ref"
+ && last_handler
+ .iter()
+ .any(|(name, last)| *name == pos && *last != i)
+ {
+ continue;
+ }
+ if let Some(entry) = segments.iter_mut().find(|(at, _)| *at == index) {
+ entry.1.push((pos, expr));
+ } else {
+ segments.push((index, vec![(pos, expr)]));
+ }
+ }
+ let mut properties = self.ast().vec();
+ for (index, list) in segments {
+ let Expression::NumericLiteral(key) = self.ast().expression_numeric_literal(
+ span,
+ index as f64,
+ None,
+ oxc_ast::ast::NumberBase::Decimal,
+ ) else {
+ unreachable!("a numeric literal expression");
+ };
+ let value = self.claim_map(span, list);
+ properties.push(self.ast().object_property_kind_object_property(
+ span,
+ oxc_ast::ast::PropertyKind::Init,
+ oxc_ast::ast::PropertyKey::NumericLiteral(key),
+ value,
+ false,
+ false,
+ false,
+ ));
+ }
+ self.ast().expression_object(span, properties)
+ }
+
+ /// The compiled claim map of an element's handler positions, `{ click:
+ /// expr, ref: [a, b] }` (Babel's `claimMap`): duplicate positions merge
+ /// into arrays (multiple refs).
+ fn claim_map(
+ &self,
span: Span,
claims: std::vec::Vec<(String, Expression<'a>)>,
- template: &mut SsrTemplate<'a>,
) -> Expression<'a> {
- self.uses_ssr_claim = true;
let mut by_pos: std::vec::Vec<(String, std::vec::Vec>)> =
std::vec::Vec::new();
for (pos, expr) in claims {
@@ -2733,7 +2917,24 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> {
};
properties.push(self.object_property(span, &pos, value));
}
- let map = self.ast().expression_object(span, properties);
+ self.ast().expression_object(span, properties)
+ }
+
+ /// 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
+ /// ? _$ssrClaim({...}) : ""`.
+ fn ssr_claim_hole(
+ &mut self,
+ span: Span,
+ claims: std::vec::Vec<(String, Expression<'a>)>,
+ template: &mut SsrTemplate<'a>,
+ ) -> Expression<'a> {
+ self.uses_ssr_claim = true;
+ let map = self.claim_map(span, claims);
let context_read = |transform: &Self| -> Expression<'a> {
Expression::StaticMemberExpression(
transform.ast().alloc_static_member_expression(
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/signals/src/core/dev.ts b/packages/signals/src/core/dev.ts
index a38a89ce0..d8b376d27 100644
--- a/packages/signals/src/core/dev.ts
+++ b/packages/signals/src/core/dev.ts
@@ -103,7 +103,7 @@ export type DiagnosticCode =
| "HEAD_TAG_INVALID"
| "UNRECOGNIZED_INSERT_VALUE"
| "UNSCOPED_HOLE_ALLOCATED_IDS"
- | "BEHAVIOR_CLAIM_DROPPED"
+ | "ATTRIBUTE_SLOT_POSITION"
| "FRAME_MARKER_CORRUPTED"
| "DYNAMIC_ASYNC_COMPONENT";
diff --git a/packages/solid/skills/reactivity-diagnostics/SKILL.md b/packages/solid/skills/reactivity-diagnostics/SKILL.md
index cfc369a70..8c0593b06 100644
--- a/packages/solid/skills/reactivity-diagnostics/SKILL.md
+++ b/packages/solid/skills/reactivity-diagnostics/SKILL.md
@@ -843,14 +843,32 @@ JavaScript or through a cast. Fix: call the function at the hole
(`{renderHead()}` — a call hole is scoped on both sides) or assign the built
value first and insert that.
-### BEHAVIOR_CLAIM_DROPPED
-
-A behavior position (an event handler) on a server-rendered element got
-something the wire cannot carry: a client prop through a spread
-(`data.reason: "spread"` — write the position out, `onClick={props.x}`) or a
-function that exists only on the server (`"server-local"` — pass it from the
-client through the server component's props, or bind a mutation to
-`action=`).
+### ATTRIBUTE_SLOT_POSITION
+
+An attribute slot's property (`const row = props.row(args); row.done`)
+landed where the server template cannot bind it. The rule: a slot property
+is a JSX attribute value, whole, and nothing else. `data.reason`:
+`"spread"` (throws — the slot's whole return spread onto an element; name
+each position instead), `"stringified"` (coerced into a string — a template
+literal, a concatenation), `"coerced"` (used in an expression — a
+comparison, arithmetic, a branch on its result; the server has no value to
+compute with, so decide in the client fill and return the decided value),
+`"inline"` (reached `class`/`style` inside template quotes — the element
+was compiled without the `serverComponents` compiler option), `"text"`
+(placed as text, not a bindable position yet), `"markup"` (read off a slot
+whose client fill returned content, not an object), `"server-local"` (a
+`ref`/`on*` position got a plain server function — bind a slot property or
+an `action=`), `"reserved-key"` (the fill's object used a key the slot's
+range occupies), `"orphan"` (client, kind `render`: an element carries
+markers for an occurrence that can never bind — `data.why` `"fill"`, no
+client fill for the prop; `"record"`, a called occurrence with no args
+record once none can arrive, which is the protocol out of step — client
+and server from different builds — not a fill mistake). For
+`stringified`/`coerced`/`inline`/`text` NOTHING
+renders at the position on either face, so the misuse shows on the first
+render, not the first refetch. Truthiness (`if (row.done)`) has no hook and
+is the one misuse only the rule catches — a stand-in is always truthy.
+The fuller guide is `@solidjs/web`'s `skills/server-components/SKILL.md`.
## Verifying a fix
diff --git a/packages/web/frames/src/client.ts b/packages/web/frames/src/client.ts
index 064c118ae..3253e4a9d 100644
--- a/packages/web/frames/src/client.ts
+++ b/packages/web/frames/src/client.ts
@@ -22,7 +22,9 @@ import {
createOwner,
createRenderEffect,
createSignal,
+ DEV,
getOwner,
+ OBSERVE,
onCleanup,
runWithOwner,
sharedConfig
@@ -33,7 +35,7 @@ import type { Element as SolidElement } from "solid-js";
// copy of `insert` and the reconcile/render machinery it drags in (~4kb the app
// already has). Kept external in rollup.config.js for the same reason the
// server-functions/client import below is.
-import { insert, delegateEvents } from "@solidjs/web";
+import { insert, assign } from "@solidjs/web";
import { createFrame, createFrameElement, createFrameHost, FRAME_ID_ATTR } from "./frame-client.js";
import { COMPONENT_BINDING, createServerComponentHandler } from "./frame-transport.js";
// The container tier (DR-2 case 3): server projections cross the border as
@@ -52,6 +54,9 @@ import {
import { materializeContainerTrace } from "solid-js/internal";
setContainerTraceMaterializer(materializeContainerTrace);
+
+// Build-time literal (see diagnostics.ts): dev-only guidance folds out of prod.
+const IS_DEV = "_SOLID_DEV_" as unknown as boolean;
// This import must resolve to the SHARED built instance, not a bundled
// copy: configuring the server-function client only counts if it's the same
// module the compiled reference proxies call through
@@ -93,7 +98,7 @@ export {
// Server components are authored in universal code, so the slot type has to
// resolve under the browser condition too. Type-only, so nothing crosses into
// the client bundle.
-export type { Slot } from "./server.js";
+export type { Slot, AttributeSlot } from "./server.js";
/**
* Client-condition twin of the server face's `asyncArg` (DR-2 value tier):
@@ -160,11 +165,7 @@ export function getFrameHost() {
revive: reviveContainerTraces,
// Lets the record-dedupe compare identity-test containers instead of
// probing them (a pending container's property reads throw not-ready).
- isContainer: isMaterializedContainer,
- // Behavior claims: arms document listeners for event types named by
- // `_bnd` markers. Threaded as an option because the core client entry
- // must not export the event system into tree-shaken subsets.
- delegate: delegateEvents
+ isContainer: isMaterializedContainer
});
}
return sharedHost;
@@ -362,6 +363,203 @@ function slotArgsProxy(args: () => Record) {
);
}
+interface ElementState {
+ prev: any;
+ ref: any;
+ refId: string;
+ on: Set;
+ keys: Record;
+ listener: EventListener;
+}
+
+/**
+ * Bind a data occurrence (principles §9.2.3): one computation runs the
+ * fill, and every consuming element's bound positions are written from its
+ * output — diffed per position by `assign`, so a change in one key touches
+ * one attribute. Handlers and refs are bound ONCE per (element, position) as
+ * stable dispatchers that read the latest output, so the fill may return
+ * fresh closures every run without re-adding listeners or re-firing refs.
+ * A consumer change (`ctx.onRebind`: the morph replaced an element, a
+ * response bound a new position) rebinds without re-running the fill.
+ */
+function bindDataOccurrence(fill: (args: any) => any, args: any, ctx: any) {
+ const [consumers, setConsumers] = createSignal(ctx.positions);
+ ctx.onRebind(setConsumers);
+ // The fill's output, one computation for the occurrence: what a
+ // position's dispatcher reads at event time.
+ const output = createMemo(() => {
+ const out = fill(args);
+ // Content where data was expected: a DOM node is an object, so it is
+ // named here rather than read as one (its properties are the DOM's).
+ const node = typeof Node === "function" && out instanceof Node;
+ if (IS_DEV && (out == null || typeof out !== "object" || Array.isArray(out) || node)) {
+ const shape =
+ out === null ? "null" : node ? "a DOM node" : Array.isArray(out) ? "an array" : typeof out;
+ slotShapeFinding(
+ ctx.key,
+ shape,
+ `[ATTRIBUTE_SLOT_POSITION] The fill for \`${ctx.key}\` returned ${shape}; server markup reads ` +
+ `its properties at bound positions, so it must return an object (\`{ done, toggle, … }\`). ` +
+ `Nothing binds until it does.`
+ );
+ }
+ return out == null || typeof out !== "object" || node ? {} : out;
+ });
+ // Per element: the props last assigned (assign's diff state), the stable
+ // ref dispatcher minted for its ref position, and its ONE listener — the
+ // events it is attached under (`on`) and the keys each event fans out to.
+ const state = new WeakMap();
+ // Value positions are READ in the compute phase: a fill may return
+ // getters (the shared-component idiom — see rowFor in
+ // examples/todos-server), and a getter read here tracks, so the position
+ // re-writes when its own sources move. Handler and ref positions read
+ // nothing here; their dispatchers read the output at event time.
+ // The elements written last time: one that drops out of the consumer
+ // list on a rebind (its markers gone, the element kept by the morph) gets
+ // a final empty write so its handlers unbind.
+ let bound = new Set();
+ createRenderEffect(
+ () => {
+ const out = output();
+ return consumers().map(({ element, positions }) => ({
+ element,
+ ...propsFor(element, positions, out)
+ }));
+ },
+ writes => {
+ const next = new Set();
+ for (const { element, props, handlers } of writes) {
+ next.add(element);
+ write(element, props, handlers);
+ }
+ for (const element of bound) if (!next.has(element)) write(element, {}, {});
+ bound = next;
+ }
+ );
+ // The occurrence's end (a later response dropped it, a positional id now
+ // names another row's data) unbinds what it bound: the listeners it
+ // attached are its own — the element may outlive the occurrence (a morph
+ // keeps un-keyed elements) and another occurrence may bind it next, so a
+ // listener left behind fires a disposed fill's handler, and twice.
+ onCleanup(() => {
+ for (const element of bound) write(element, {}, {});
+ });
+ function write(element: Element, props: Record, handlers: Record) {
+ const st = state.get(element)!;
+ // A value position the server RELEASED (a rebind whose incoming markup
+ // no longer marks it) is the server's again, and the morph already
+ // wrote the server's value there. Drop it from the diff state so
+ // `assign` does not null the attribute the morph just applied. The
+ // ref is the client's alone: it stays in `prev` and clears through the
+ // diff.
+ for (const k in st.prev) if (!(k in props) && k !== "ref") delete st.prev[k];
+ assign(element, props, true, st.prev);
+ // Handler positions are listeners the client attaches itself (the
+ // marker's event name — `onClick` compiled to `click`): one listener
+ // per element, attached under each bound event, that reads the output
+ // at event time and fans out to the event's keys in marker order. A
+ // released position detaches its event.
+ st.keys = handlers;
+ for (const name of st.on) {
+ if (!(name in handlers)) {
+ element.removeEventListener(name, st.listener);
+ st.on.delete(name);
+ }
+ }
+ for (const name in handlers) {
+ if (!st.on.has(name)) {
+ element.addEventListener(name, st.listener);
+ st.on.add(name);
+ }
+ }
+ }
+ function propsFor(element: Element, positions: any[], out: any) {
+ let st = state.get(element);
+ if (!st) {
+ const s: ElementState = {
+ prev: {},
+ ref: undefined,
+ refId: "",
+ on: new Set(),
+ keys: {},
+ listener(this: Element, e: Event) {
+ const o = output();
+ const keys = s.keys[e.type];
+ if (keys === undefined) return;
+ for (const k of keys) {
+ const h = o[k];
+ if (Array.isArray(h)) h[0].call(this, h[1], e);
+ else if (typeof h === "function") h.call(this, e);
+ }
+ }
+ };
+ state.set(element, (st = s));
+ }
+ const props: Record = {};
+ const handlers: Record = {};
+ let classNames: Record | null = null;
+ let styleProps: Record | null = null;
+ // Several keys can bind at ONE ref or handler position (the server
+ // merges duplicates into the marker — `_s:ref="occ:a,occ:b"`); every
+ // key fires, in marker order.
+ let refKeys: string[] | null = null;
+ for (const { pos, key, name } of positions) {
+ if (pos === "class" || pos === "style") {
+ if (name === undefined) props[pos] = out[key];
+ else if (pos === "class") (classNames || (classNames = {}))[name] = !!out[key];
+ else (styleProps || (styleProps = {}))[name] = out[key];
+ } else if (pos === "ref") {
+ (refKeys || (refKeys = [])).push(key);
+ } else if (pos.startsWith("on:")) {
+ const event = pos.slice(3);
+ (handlers[event] || (handlers[event] = [])).push(key);
+ } else props[pos] = out[key];
+ }
+ if (classNames !== null && !("class" in props)) props.class = classNames;
+ if (styleProps !== null && !("style" in props)) props.style = styleProps;
+ if (refKeys !== null) {
+ // One stable ref per key set: `assign` fires a ref when its value
+ // changes, so the fill may return fresh closures every run without
+ // re-firing it; a rebind that changes the bound keys fires it once.
+ const id = refKeys.join(",");
+ if (st.refId !== id) {
+ const keys = refKeys;
+ st.refId = id;
+ st.ref = (el: Element) => {
+ const o = output();
+ for (const k of keys) {
+ const r = o[k];
+ typeof r === "function" && r(el);
+ }
+ };
+ }
+ props.ref = st.ref;
+ }
+ return { props, handlers };
+ }
+}
+
+/**
+ * Dev finding (`ATTRIBUTE_SLOT_POSITION`, reason `fill-shape`): the client
+ * side of an attribute slot has the wrong shape — the fill's return is not
+ * an object, or the prop is not a function. Through the diagnostics
+ * channel, so an observer captures it beside the server's findings.
+ */
+function slotShapeFinding(occurrence: string, shape: string, message: string) {
+ DEV!.report(
+ OBSERVE!.diagnostics.emit(
+ {
+ code: "ATTRIBUTE_SLOT_POSITION",
+ kind: "render",
+ severity: "warn",
+ message,
+ data: { reason: "fill-shape", occurrence, shape }
+ },
+ null
+ )
+ );
+}
+
/** Whether a resolved slot value is reactive at the top level. */
function isReactiveContent(value: any): boolean {
if (typeof value === "function") return true;
@@ -405,6 +603,41 @@ function slotsFor(props: Record) {
fillScopes.delete(key);
prevFill.dispose();
}
+ // Attribute slot (§9.2.3): the occurrence's node is the set of server
+ // elements reading its properties at bound positions. Always under
+ // a per-occurrence owner: the binding must die with the occurrence
+ // (a later response dropping it, or every consumer replaced by
+ // the morph), and there are no placed nodes for the frame's zombie
+ // heuristic to misread.
+ if (ctx && ctx.positions) {
+ const fill = props[prop];
+ if (typeof fill !== "function") {
+ if (IS_DEV) {
+ slotShapeFinding(
+ key,
+ typeof fill,
+ `[ATTRIBUTE_SLOT_POSITION] Server markup reads slot \`${prop}\` as data (\`${key}\`), ` +
+ `but the client prop is ${typeof fill === "object" ? "an object" : `a ${typeof fill}`}, ` +
+ `not a function. The fill is a function of the occurrence's args returning the object ` +
+ `the markup reads: \`${prop}={args => ({ … })}\`. Nothing binds until it is.`
+ );
+ }
+ return undefined;
+ }
+ const owner = createOwner();
+ fillScopes.set(key, owner);
+ ctx.onCleanup(() => {
+ if (fillScopes.get(key) === owner) fillScopes.delete(key);
+ owner.dispose();
+ });
+ runWithOwner(owner, () => {
+ const args = ctx.onUpdate
+ ? liveSlotProps(slotProps, ctx)
+ : slotArgsProxy(() => slotProps);
+ bindDataOccurrence(fill, args, ctx);
+ });
+ return undefined;
+ }
// Stream-mounted fills (no ambient owner at invocation — the frame
// called from a chunk microtask) render under a PER-OCCURRENCE
// owner whose disposal rides the frame's occurrence-level cleanup:
@@ -659,10 +892,6 @@ function boundaryComponent(host: any, fnId: string) {
// placeholder mount) binds the function id — the argless address.
id,
slots: slotsFor(props),
- // Raw client props (compiled getters — live at every read): behavior
- // claims (`_bnd` markers on server elements) resolve ref/event props
- // by name through these at dispatch/materialize time.
- props,
ownerScope: boundaryScope(owner),
reveal: revealSeam(owner),
// Any apply releases the gate — content ("materialize") is the normal
@@ -1110,9 +1339,6 @@ function adoptBoundary(
host,
id: address,
slots: slotsFor(props),
- // Raw client props for behavior-claim resolution (see the stream-mount
- // counterpart above).
- props,
ownerScope: boundaryScope(owner),
reveal: revealSeam(owner),
// Any apply for the currently bound address — a morph, a reveal, an
diff --git a/packages/web/frames/src/frame-client.ts b/packages/web/frames/src/frame-client.ts
index b28512f04..25d5e315d 100644
--- a/packages/web/frames/src/frame-client.ts
+++ b/packages/web/frames/src/frame-client.ts
@@ -280,15 +280,6 @@ export interface FrameHostOptions {
* identity only.
*/
isContainer?(value: unknown): boolean;
- /**
- * Arms event types for behavior claims: the `_bnd` sweep collects the
- * event names it finds and hands them here so delegated dispatch can
- * reach them. Platform glue passes its `delegateEvents` — the option
- * exists (rather than client.js importing the event system) so
- * tree-shaken subsets without events pay nothing. Frames registered
- * with this host inherit it unless they pass their own `delegate`.
- */
- delegate?(eventNames: Iterable): void;
}
/**
@@ -301,13 +292,6 @@ export interface FrameOptions {
id?: string;
/** Client content keyed by prop name (occurrences resolve by prop). */
slots?: Record;
- /**
- * Raw client props for behavior-claim resolution: server elements carrying
- * `_bnd="pos=prop"` markers (compiled under the `serverComponents` option)
- * resolve ref/event positions by name through this object — read live at
- * dispatch/materialize time, so compiled prop getters stay latest-value.
- */
- props?: Record;
/**
* Adopt existing server-rendered DOM: the first apply morphs against it,
* and slots sync immediately (hydration attach) — a document-SSR boot
@@ -325,8 +309,6 @@ export interface FrameOptions {
* streamed chunks).
*/
ownerScope?(fn: () => T): T;
- /** Per-frame override of the host's `delegate` (see FrameHostOptions). */
- delegate?(eventNames: Iterable): void;
/**
* Boundary-driven segment reveal. When present, `#revealSegment` hands the
* placeholder seam to this hook instead of swapping imperatively: the binding
@@ -415,119 +397,6 @@ function claimNode(handlers, el) {
const claimedAttr = name => name === "href" || name === "action";
-// === Behavior claims (Stage 6: ref/event props on server elements) ===
-//
-// Server markup carries `_bnd="pos=prop[,pos=prop]*"` markers (compiled
-// under the `serverComponents` option) naming which CLIENT props hold the
-// behavior for each position. Dispatch resolves by name through the frame's
-// live props at event time — latest-props by construction, no table. The
-// seam with client.js is a registered symbol read from inside its delegation
-// walk (importless in both directions, zero top-level bytes there); THIS
-// module is the only writer. Document-listener arming flows the other way as
-// a host/frame option (`delegate`, wired by the platform glue to
-// delegateEvents) — publishing it from client.js would drag the whole event
-// system into every tree-shaken subset of the core entry.
-const BOUND_SEAM = Symbol.for("solid.bnd");
-const boundSeam = globalThis[BOUND_SEAM] || (globalThis[BOUND_SEAM] = {});
-const BND_ATTR = "_bnd";
-const BND_SELECTOR = "[_bnd]";
-
-// Parsed on demand — the string is a handful of entries and reads happen
-// per dispatch / per sweep, so a cache would cost more bytes than it saves.
-function bndMap(el) {
- const s = el.getAttribute(BND_ATTR);
- if (!s) return undefined;
- const map = {};
- for (const entry of s.split(",")) {
- const eq = entry.indexOf("=");
- if (eq < 1) continue;
- const pos = entry.slice(0, eq);
- const prop = decodeURIComponent(entry.slice(eq + 1));
- // Repeated positions (multiple refs) accumulate.
- const prev = map[pos];
- if (prev === undefined) map[pos] = prop;
- else if (Array.isArray(prev)) prev.push(prop);
- else map[pos] = [prev, prop];
- }
- return map;
-}
-
-// Dispatch-time resolution for the delegation walk. The owning frame rides
-// a sweep-stamped expando (not an ancestor climb: range-bounded frames have
-// no wrapping element, and morphs re-stamp replaced elements on re-sweep).
-boundSeam.resolve = (el, type) => {
- const frame = el._$bndFrame;
- if (!frame) return undefined;
- const map = bndMap(el);
- const prop = map && map[type];
- if (typeof prop !== "string") return undefined;
- return claimFn(frame, type, prop);
-};
-
-/** Read one claimed prop off the frame, warning (dev) on non-functions. */
-function claimFn(frame, pos, prop) {
- const fn = frame.clientProp(prop);
- if (typeof fn === "function") return fn;
- if ("_SOLID_DEV_" && fn !== undefined) {
- console.warn(
- `A server element claims \`${pos}\` from client prop \`${prop}\`, but the mounted ` +
- `frame's prop is not a function.`
- );
- }
- return undefined;
-}
-
-/**
- * Sweep one materialized/morph-touched subtree for `_bnd` markers: stamp
- * each marked element with its owning frame (dispatch resolution), arm
- * document listeners for claimed event types, and fire ref positions.
- * Dormant cost without markers: one selector query per apply.
- */
-function sweepBound(root, frame, delegate, scope) {
- const isElement = root.nodeType === ELEMENT_NODE;
- if (!isElement && root.nodeType !== 11 /* DOCUMENT_FRAGMENT_NODE */) return;
- let els;
- if (isElement && root.hasAttribute(BND_ATTR)) (els = []).push(root);
- const found = root.querySelectorAll(BND_SELECTOR);
- if (found.length) {
- els || (els = []);
- for (let i = 0; i < found.length; i++) els.push(found[i]);
- }
- if (!els) return;
- // The whole marker pass runs under the creator's ownerScope (the client
- // component that passed the props): refs get effects, context, and
- // onCleanup inside the callback, bounded by the frame's owner — the
- // contract §9.1 promises. Arming is scope-indifferent, so one wrap covers
- // everything.
- const run = () => {
- let types;
- for (const el of els) {
- el._$bndFrame = frame;
- const map = bndMap(el);
- if (!map) continue;
- for (const pos in map) {
- if (pos === "ref") fireRefs(frame, el, map.ref);
- else (types || (types = [])).push(pos);
- }
- }
- if (types && delegate) delegate(types);
- };
- scope ? scope(run) : run();
-}
-
-// Ref-position dedupe rides an expando: refs fire once per (element, prop) —
-// a morph that replaces the element re-fires on the fresh node (fresh
-// expando); a re-sweep over a kept node does not.
-function fireRefs(frame, el, prop) {
- const fired = el._$bndFired || (el._$bndFired = new Set());
- for (const p of Array.isArray(prop) ? prop : [prop]) {
- if (fired.has(p)) continue;
- fired.add(p);
- const fn = claimFn(frame, "ref", p);
- if (fn) fn(el);
- }
-}
-
/** Sweep `root` (element or fragment) and its claimable interior. */
function claimTree(handlers, root) {
const isElement = root.nodeType === ELEMENT_NODE;
@@ -542,7 +411,72 @@ const placeholderId = name => `pl-${name}`;
const SLOT_START = /^slot:(.+):start$/;
const SLOT_END = /^slot:(.+):end$/;
-const slotEnd = id => `slot:${id}:end`; /**
+const slotEnd = id => `slot:${id}:end`;
+
+// === Attribute slots (principles §9.2.3: a slot read at positions of server markup) ===
+//
+// A server element that reads an attribute slot's properties carries one marker
+// per bound position — `_s:=":"`, with the
+// class name / style property appended for a name inside `class`/`style`
+// (`_s:class="row#1:done=completed,row#1:busy=pending"`), `_s:on:`
+// for a handler, `_s:ref` for a ref. The OCCURRENCE is the slot call (one
+// data context — `props.row({ id, completed })`), not the element: any
+// number of elements consume it, and the sync mounts it once, handing the
+// consumer every (element, position, key) it found. The fill runs once per
+// occurrence with the occurrence's args (the same `slot:` record
+// a markup slot's call emits) and writes each position from its returned
+// object; a re-emitted record updates the args in place, as for markup
+// occurrences. The elements stay server-owned: the morph keeps them (keyed
+// or positional), and reads the markers off INCOMING markup to know which
+// positions are the client's (see `morphAttributes`) — no ownership table.
+const SLOT_MARKER = "_s:";
+
+/**
+ * Parse one element's `_s:*` markers into positions grouped by occurrence:
+ * `{ [occurrence]: [{ pos, key, name }] }`, or null. `pos` is the marker's
+ * position as written (`class`, `hidden`, `on:click`, `ref`), `name` the
+ * class name / style property for a member position. Keys and names are
+ * percent-encoded on the wire (they are client-controlled strings landing
+ * in a `,`/`:`/`=`-delimited grammar) and decoded here.
+ */
+function slotPositions(el) {
+ const attrs = el.attributes;
+ let out = null;
+ for (let i = 0; i < attrs.length; i++) {
+ const attr = attrs[i];
+ if (!attr.name.startsWith(SLOT_MARKER)) continue;
+ const pos = attr.name.slice(SLOT_MARKER.length);
+ for (const entry of attr.value.split(",")) {
+ const colon = entry.indexOf(":");
+ if (colon < 1) continue;
+ const occurrence = entry.slice(0, colon);
+ const eq = entry.indexOf("=", colon);
+ const key = decodeURIComponent(
+ eq === -1 ? entry.slice(colon + 1) : entry.slice(colon + 1, eq)
+ );
+ const name = eq === -1 ? undefined : decodeURIComponent(entry.slice(eq + 1));
+ out || (out = Object.create(null));
+ (out[occurrence] || (out[occurrence] = [])).push({ pos, key, name });
+ }
+ }
+ return out;
+}
+
+/** Whether a data occurrence's consumer set changed (elements or positions). */
+function consumersEqual(a, b) {
+ if (a.length !== b.length) return false;
+ for (let i = 0; i < a.length; i++) {
+ const x = a[i];
+ const y = b[i];
+ if (x.element !== y.element || x.positions.length !== y.positions.length) return false;
+ for (let j = 0; j < x.positions.length; j++) {
+ const p = x.positions[j];
+ const q = y.positions[j];
+ if (p.pos !== q.pos || p.key !== q.key || p.name !== q.name) return false;
+ }
+ }
+ return true;
+} /**
* Maps a wire chunk onto resident-store record writes. `data` chunks map to
* no records — they are response-scoped and the host applies them through
* its data hook.
@@ -708,10 +642,6 @@ export function createFrameHost(options = {}) {
return true;
};
return {
- // Document-listener arming for behavior-claim event positions: the
- // platform glue passes delegateEvents here so frames can arm types no
- // compiled client handler ever registered (see the seam note above).
- delegate: options.delegate,
register(id, frame) {
let set = frames.get(id);
if (!set) frames.set(id, (set = new Set()));
@@ -842,6 +772,10 @@ class FrameImpl {
#slotRegions = new Map();
#slotResolvedRefs = new Map();
#slotNodes = new Map();
+ // Data occurrences (§9.2.3): the consumer set last handed to the mount,
+ // and the mount's rebind callback (`ctx.onRebind`) for when it changes.
+ #slotConsumers = new Map();
+ #slotRebinders = new Map();
#processedAssets = new WeakSet();
// The pending re-check for adopt-time occurrences deferred on a
// still-arriving args record (#2968 — see #syncSlots).
@@ -862,26 +796,11 @@ class FrameImpl {
// longer matches the sweep selector), mirroring compiled setAttribute.
// Stable identity so it threads into the morph without allocation.
#claimTree = (node, direct) => {
- // Behavior claims sweep first, and unconditionally — `_bnd` markers are
- // this frame's own contract, not a registered-consumer one. `direct`
- // re-checks (in-place attribute rewrites) are nav-claim specific; a
- // morph that rewrites `_bnd` in place re-parses at next dispatch, and
- // kept elements keep their stamp.
- if (!direct && node.nodeType !== TEXT_NODE && node.nodeType !== COMMENT_NODE) {
- const o = this.#options;
- sweepBound(node, this, o.delegate || (o.host && o.host.delegate), o.ownerScope);
- }
const handlers = claimHandlers();
if (!handlers) return;
this.#scoped(() => (direct ? claimNode(handlers, node) : claimTree(handlers, node)));
};
- /** A raw client prop, read live — behavior-claim resolution (`_bnd`). */
- clientProp(name) {
- const props = this.#options.props;
- return props ? props[name] : undefined;
- }
-
/** Run `fn` under the creator's `ownerScope` (when provided). */
#scoped(fn) {
const scope = this.#options.ownerScope;
@@ -1189,13 +1108,27 @@ class FrameImpl {
// "comment#0"); the callback is looked up by its prop — the part before
// "#" — so one callback services N occurrences from an iterated render
// prop.
+ // Data occurrences (`_s:*` markers, principles §9.2.3) land in the same
+ // map, keyed the same way, with their CONSUMERS as the occurrence's
+ // node: an array of `{ element, positions }` in document order. The
+ // loop below treats them as occurrences whose mount binds those
+ // positions rather than filling a range (no interior, no regions, never
+ // replaced), and whose consumer set may change without a re-call.
const found = new Map();
- if (root) collectSlots(root.firstChild, null, found);
- else this.#collectSlots(found);
+ if (root) collectSlots(root.firstChild, null, found, found);
+ else this.#collectSlots(found, found);
for (const [occurrence, start] of found) {
const callback = this.#resolveSlot(propOf(occurrence));
- if (!callback) continue; // no client impl for this prop up the tree
+ const consumers = Array.isArray(start) ? start : null;
+ if (!callback) {
+ // No client impl for this prop up the tree. A range stays empty,
+ // which content can mean; bound positions never bind, which
+ // nothing can mean — the elements sit inert with no error. Dev
+ // names them (once per occurrence).
+ if ("_SOLID_DEV_" && consumers) devSlotOrphan(this, occurrence, consumers, "fill");
+ continue;
+ }
const record = this.#resolveSlotRecord(occurrence);
// A record whose data refs have not ARRIVED yet is not applicable: the
// producer emits the slot chunk before the `data` chunks carrying its
@@ -1218,7 +1151,12 @@ class FrameImpl {
// though state can't survive a destroyed node.
const prev = this.#slotNodes.get(occurrence);
const prevFirst = Array.isArray(prev) ? prev[0] : prev;
- const zombie = this.#mountedSlots.has(occurrence) && prevFirst && !prevFirst.parentNode;
+ // A data occurrence is never a zombie: its nodes are the server's
+ // consumers, not the fill's output — a replaced element is a consumer
+ // change (rebind, below), and an occurrence no element reads any more
+ // is simply not found (unmounted at the end).
+ const zombie =
+ !consumers && this.#mountedSlots.has(occurrence) && prevFirst && !prevFirst.parentNode;
if (zombie) {
this.#mountedSlots.delete(occurrence);
this.#runSlotCleanups(occurrence);
@@ -1261,6 +1199,16 @@ class FrameImpl {
});
continue;
}
+ // A CALLED occurrence (`prop#n`) always has a record — the producer
+ // emits it at the call, ahead of the markup that reads it — so
+ // marked positions with none here, once records can no longer
+ // arrive, are the protocol's invariant broken (a record dropped, or
+ // marker and record minted under different ids), never something
+ // the fill can fix. The mount below still runs, as it always has;
+ // dev says why its args are empty. A bare occurrence (the prop
+ // itself) has no record by design.
+ if ("_SOLID_DEV_" && consumers && record === undefined && occurrence.indexOf("#") !== -1)
+ devSlotOrphan(this, occurrence, consumers, "record");
// Direct-insert occurrences have no `slot:` record and mount with
// empty props; render-function occurrences mount with resolved props.
// Mounting replaces the range interior: on a fresh stream it is
@@ -1287,8 +1235,19 @@ class FrameImpl {
// its entries during the invoke instead.
if (this.#options.adopt) this.#discoverRegions(occurrence, start);
const nodes = this.#invokeSlot(occurrence, callback, record, start, this.#options.adopt);
- if (nodes) this.#replaceRange(occurrence, start, nodes);
- this.#slotNodes.set(occurrence, nodes);
+ // A data occurrence's nodes are its consuming elements (so the
+ // zombie check above sees a morph that replaced them all); its mount
+ // never returns nodes to place.
+ if (consumers) {
+ this.#slotNodes.set(
+ occurrence,
+ consumers.map(c => c.element)
+ );
+ this.#slotConsumers.set(occurrence, consumers);
+ } else {
+ if (nodes) this.#replaceRange(occurrence, start, nodes);
+ this.#slotNodes.set(occurrence, nodes);
+ }
this.#mountedSlots.add(occurrence);
// Re-scan after invoke: a fresh mount's regions come from
// #resolveArgs during the invoke, and the callback's output may have
@@ -1298,7 +1257,22 @@ class FrameImpl {
// large adopted tree).
if (!this.#options.adopt || nodes) this.#discoverRegions(occurrence, start);
this.#bindRegions(occurrence);
- } else if (record !== this.#slotArgs.get(occurrence)) {
+ continue;
+ }
+ // A mounted data occurrence whose CONSUMERS changed — a morph replaced
+ // one of its elements, a response added or dropped a bound position
+ // — rebinds in place: the fill's computation stays, the binding gets
+ // the new set. Independent of an args change, which follows below.
+ if (consumers && !consumersEqual(this.#slotConsumers.get(occurrence), consumers)) {
+ this.#slotConsumers.set(occurrence, consumers);
+ this.#slotNodes.set(
+ occurrence,
+ consumers.map(c => c.element)
+ );
+ const rebind = this.#slotRebinders.get(occurrence);
+ if (rebind) rebind(consumers);
+ }
+ if (record !== this.#slotArgs.get(occurrence)) {
// A re-sent record differing only in {$ref} identity may carry the
// SAME values (tables rotate per response, so the store-write
// dedupe stays conservative). Value-compare the new refs against
@@ -1332,8 +1306,15 @@ class FrameImpl {
// reusing its cached server-content regions. Same contract: an
// undefined return keeps the current interior.
const nodes = this.#invokeSlot(occurrence, callback, record, start);
- if (nodes) this.#replaceRange(occurrence, start, nodes);
- this.#slotNodes.set(occurrence, nodes);
+ if (consumers)
+ this.#slotNodes.set(
+ occurrence,
+ consumers.map(c => c.element)
+ );
+ else {
+ if (nodes) this.#replaceRange(occurrence, start, nodes);
+ this.#slotNodes.set(occurrence, nodes);
+ }
this.#bindRegions(occurrence);
}
}
@@ -1360,6 +1341,7 @@ class FrameImpl {
// binding's updater so a stream args-change can't push props into a
// disposed instance. The new invocation re-registers if it wants updates.
this.#slotUpdaters.delete(occurrence);
+ this.#slotRebinders.delete(occurrence);
const cleanups = this.#slotCleanups.get(occurrence) ?? [];
// One walk yields both the interior and the end marker. The end marker is
// part of the consumer contract (ctx.range): a framework binding that owns
@@ -1367,7 +1349,10 @@ class FrameImpl {
// insert before — the markers are the only stable nodes in the range.
let existing = [];
let end = null;
- if (start) end = eachInRange(start, occurrence, n => existing.push(n));
+ // A data occurrence's node is its consumer list: no interior to collect,
+ // no end marker. The consumer gets the positions instead.
+ const positions = Array.isArray(start) ? start : undefined;
+ if (start && !positions) end = eachInRange(start, occurrence, n => existing.push(n));
const ctx = {
// Identity for hydration-claim scoping: consumers derive the same
// key prefix the document producer used for this occurrence. The
@@ -1398,7 +1383,16 @@ class FrameImpl {
// The range's own markers, when it has them: consumers that bind the
// interior reactively insert before `end` and return undefined — the
// frame then never touches the interior (morphs protect slot ranges).
- range: end ? { start, end } : undefined
+ range: end ? { start, end } : undefined,
+ // Attribute slot (§9.2.3): the positions of server markup that read this
+ // occurrence — `[{ element, positions: [{ pos, key, name }] }]` in
+ // document order. The consumer runs the fill, writes each position
+ // from its returned object, and returns undefined (there is nothing
+ // to place). `onRebind` receives the new set when consumers change
+ // (a morph replaced an element; a response bound a new position)
+ // without the args changing — the fill's computation survives.
+ positions,
+ onRebind: positions ? fn => this.#slotRebinders.set(occurrence, fn) : undefined
};
// One record shape (A5): the t=0 record carries used regions as
// `{$frame}` refs like any stream record would, and #resolveArgs
@@ -1431,10 +1425,12 @@ class FrameImpl {
#unmountSlot(key) {
this.#mountedSlots.delete(key);
this.#slotNodes.delete(key);
+ this.#slotConsumers.delete(key);
// Long-session hygiene: an occurrence gone from the stream releases its
// record and caches — keyed churn must not accumulate forever.
this.#slotArgs.delete(key);
this.#slotUpdaters.delete(key);
+ this.#slotRebinders.delete(key);
this.#slotResolvedRefs.delete(key);
this.#removeSlotRecord(key);
this.#runSlotCleanups(key);
@@ -1547,7 +1543,9 @@ class FrameImpl {
* their own slot sync — this is what wires nested occurrences at boot.
*/
#discoverRegions(slotKey, start) {
- if (!start) return;
+ // A data occurrence has no interior (its args are data; a region arg
+ // has nowhere to render at an attribute position).
+ if (!start || Array.isArray(start)) return;
const regions = this.#regionsFor(slotKey);
eachInRange(start, slotKey, n => collectRegionElements(n, regions));
}
@@ -1666,9 +1664,10 @@ class FrameImpl {
}
}
- /** Collect this frame's own top-level slot ranges (bounded to its content). */
- #collectSlots(found) {
- collectSlots(this.#firstContent(), this.#end, found);
+ /** Collect this frame's own top-level slot ranges (bounded to its content),
+ * and — for the slot sync — its attribute-slot elements into the same map. */
+ #collectSlots(found, elements) {
+ collectSlots(this.#firstContent(), this.#end, found, elements);
}
/** Find a fragment placeholder `` bounded to this
@@ -1801,10 +1800,8 @@ class FrameImpl {
parent.insertBefore(fragment, this.#end);
this.#hasContent = true;
} else {
- // #claimTree self-gates each half (nav claims on registered handlers,
- // the behavior-claim sweep on `_bnd` presence), so it threads in
- // unconditionally — reconcile-inserted subtrees must sweep markers
- // even when no nav-claim consumer registered.
+ // #claimTree self-gates on registered nav-claim handlers, so it
+ // threads in unconditionally.
const claim = this.#claimTree;
// Frame-wide displaced-range index. Slot ranges are keyed by occurrence
// id, unique within this frame's content, and a keyed re-render can move
@@ -1855,9 +1852,6 @@ class FrameImpl {
}
return false;
}
- // Unconditional for the same reason as the root morph: hole re-emissions
- // carry `_bnd` markers (the chat copy-button shape — an event prop inside
- // a streaming hole), and those must sweep regardless of nav consumers.
reconcileChildren(open.parentNode, parseFragment(html), open, close, this.#claimTree);
return true;
}
@@ -1886,21 +1880,32 @@ class FrameImpl {
if (!el) return false;
const parsed = parseFragment(``).firstChild;
const keepOpen = preservesOpen(el);
+ // Attribute-slot positions the rebuilt text marks stay the client's, as in
+ // the root morph (`morphAttributes`).
+ const owned = parsed ? ownedPositions(parsed) : null;
const current = el.attributes;
for (let i = current.length - 1; i >= 0; i--) {
const name = current[i].name;
if (name === "data-lha" || (keepOpen && name === "open")) continue;
- if (!parsed || !parsed.hasAttribute(name)) el.removeAttribute(name);
+ if (!parsed || !parsed.hasAttribute(name)) {
+ if (applyOwned(el, name, "", owned) !== undefined) continue;
+ el.removeAttribute(name);
+ }
}
if (parsed) {
for (let i = 0; i < parsed.attributes.length; i++) {
const { name, value } = parsed.attributes[i];
if (keepOpen && name === "open") continue;
+ if (applyOwned(el, name, value, owned) !== undefined) continue;
if (el.getAttribute(name) !== value) el.setAttribute(name, value);
}
}
if (removed)
- for (const name of removed) if (!(keepOpen && name === "open")) el.removeAttribute(name);
+ for (const name of removed) {
+ if (keepOpen && name === "open") continue;
+ if (applyOwned(el, name, "", owned) !== undefined) continue;
+ el.removeAttribute(name);
+ }
return true;
}
@@ -1911,9 +1916,6 @@ class FrameImpl {
/** Sweep-claim the frame's existing content (the adoption path). */
#claimContent() {
- // No consumer gate here: #claimTree self-gates each half (nav claims on
- // registered handlers, the behavior-claim sweep on `_bnd` presence), and
- // adopted content must sweep markers even when no router registered.
let n = this.#firstContent();
while (n && n !== this.#end) {
this.#claimTree(n);
@@ -2344,7 +2346,7 @@ function findPlaceholder(n, end, id) {
* child-owned (the child discovers, with callbacks and records threaded
* down), so slots belonging to nested frames / client content are ignored.
*/
-function collectSlots(n, end, out) {
+function collectSlots(n, end, out, elements) {
while (n && n !== end) {
const id = slotStartId(n);
if (id !== null) {
@@ -2353,7 +2355,26 @@ function collectSlots(n, end, out) {
n = afterRange(n, id);
continue;
}
- if (n.nodeType === ELEMENT_NODE && !isFrameElement(n)) collectSlots(n.firstChild, null, out);
+ if (n.nodeType === ELEMENT_NODE && !isFrameElement(n)) {
+ // Data occurrences (`_s:*` markers), when the caller wants them — the
+ // slot sync does; the morph's range index does not (an element is
+ // reconciled as an element, not relocated as a protected range). The
+ // element's positions join its occurrence's consumer list, in document
+ // order. The element's interior is still walked: it is server content
+ // and may hold further occurrences of either kind.
+ if (elements !== undefined && n.hasAttributes()) {
+ const byOccurrence = slotPositions(n);
+ if (byOccurrence !== null) {
+ for (const occurrence in byOccurrence) {
+ let consumers = elements.get(occurrence);
+ if (consumers === undefined) elements.set(occurrence, (consumers = []));
+ else if (!Array.isArray(consumers)) continue; // a range claimed the id (dev range check)
+ consumers.push({ element: n, positions: byOccurrence[occurrence] });
+ }
+ }
+ }
+ collectSlots(n.firstChild, null, out, elements);
+ }
n = n.nextSibling;
}
}
@@ -2519,15 +2540,106 @@ function preservesOpen(el) {
return t === "DETAILS" || t === "DIALOG";
}
+// Attribute-slot ownership (principles §9.2.3). The INCOMING element's `_s:*`
+// markers say which of its positions a client fill writes: `_s:hidden` owns
+// the `hidden` attribute; `_s:class="occ:k=done"` owns the class name
+// `done` and `_s:class="occ:k"` the whole `class` string (likewise `style`
+// and its properties); `_s:on:*` / `_s:ref` are not attributes and need no
+// guard. A position the client owns is left alone by the morph: not
+// removed, not set. `class`/`style` are shared attributes — the server's
+// classes and the fill's toggled names live in one string — so when a fill
+// owns NAMES within them the morph applies the server's value and re-imposes
+// the owned names' live state on top. The markers themselves are ordinary
+// attributes and morph like any other, which is what lets the slot sync
+// see a consumer change.
+function ownedPositions(el) {
+ const attrs = el.attributes;
+ let out = null;
+ for (let i = 0; i < attrs.length; i++) {
+ const name = attrs[i].name;
+ if (!name.startsWith(SLOT_MARKER)) continue;
+ const pos = name.slice(SLOT_MARKER.length);
+ if (pos === "ref" || pos.startsWith("on:")) continue;
+ out || (out = { attrs: new Set(), class: null, style: null });
+ if (pos !== "class" && pos !== "style") {
+ out.attrs.add(pos);
+ continue;
+ }
+ for (const entry of attrs[i].value.split(",")) {
+ const eq = entry.indexOf("=");
+ if (eq === -1) {
+ out.attrs.add(pos); // a whole-value read owns the attribute
+ continue;
+ }
+ (out[pos] || (out[pos] = [])).push(decodeURIComponent(entry.slice(eq + 1)));
+ }
+ }
+ return out;
+}
+
+/** Set `class` to the server's value with the fill-owned class names' live
+ * state preserved. Returns whether the attribute changed. */
+function morphOwnedClass(oldEl, value, names) {
+ const list = oldEl.classList;
+ const set = new Set(value ? value.split(/\s+/) : []);
+ set.delete("");
+ for (const name of names) list.contains(name) ? set.add(name) : set.delete(name);
+ const next = [...set].join(" ");
+ if (next === (oldEl.getAttribute("class") || "")) return false;
+ next ? oldEl.setAttribute("class", next) : oldEl.removeAttribute("class");
+ return true;
+}
+
+/** Set `style` to the server's value with the fill-owned properties' live
+ * values preserved. Returns whether the attribute changed. */
+function morphOwnedStyle(oldEl, value, names) {
+ const style = oldEl.style;
+ const saved = names.map(name => [
+ name,
+ style.getPropertyValue(name),
+ style.getPropertyPriority(name)
+ ]);
+ const before = oldEl.getAttribute("style");
+ value ? oldEl.setAttribute("style", value) : oldEl.removeAttribute("style");
+ for (const [name, v, priority] of saved) {
+ v ? style.setProperty(name, v, priority) : style.removeProperty(name);
+ }
+ return oldEl.getAttribute("style") !== before;
+}
+
+/**
+ * Apply the server's value for `name` (null: absent) to an element with
+ * attribute-slot positions: a client-owned attribute is left alone; owned
+ * `class`/`style` NAMES are re-imposed over the server's string. Returns
+ * whether the attribute changed, or undefined when the position is not
+ * owned and the caller writes it.
+ */
+function applyOwned(oldEl, name, value, owned) {
+ if (owned === null) return undefined;
+ if (owned.attrs.has(name)) return false;
+ if ((name === "class" || name === "style") && owned[name] !== null) {
+ return name === "class"
+ ? morphOwnedClass(oldEl, value, owned.class)
+ : morphOwnedStyle(oldEl, value, owned.style);
+ }
+ return undefined;
+}
+
function morphAttributes(oldEl, newEl, claim) {
let reclaim = false;
let changed = false;
const keepOpen = preservesOpen(oldEl);
+ const owned = ownedPositions(newEl);
const oldAttrs = oldEl.attributes;
for (let i = oldAttrs.length - 1; i >= 0; i--) {
const name = oldAttrs[i].name;
if (keepOpen && name === "open") continue;
if (!newEl.hasAttribute(name)) {
+ const handled = applyOwned(oldEl, name, "", owned);
+ if (handled !== undefined) {
+ changed = handled || changed;
+ continue;
+ }
oldEl.removeAttribute(name);
changed = true;
reclaim ||= claimedAttr(name);
@@ -2536,11 +2648,17 @@ function morphAttributes(oldEl, newEl, claim) {
const newAttrs = newEl.attributes;
for (let i = 0; i < newAttrs.length; i++) {
const attr = newAttrs[i];
- if (keepOpen && attr.name === "open") continue;
- if (oldEl.getAttribute(attr.name) !== attr.value) {
- oldEl.setAttribute(attr.name, attr.value);
+ const name = attr.name;
+ if (keepOpen && name === "open") continue;
+ const handled = applyOwned(oldEl, name, attr.value, owned);
+ if (handled !== undefined) {
+ changed = handled || changed;
+ continue;
+ }
+ if (oldEl.getAttribute(name) !== attr.value) {
+ oldEl.setAttribute(name, attr.value);
changed = true;
- reclaim ||= claimedAttr(attr.name);
+ reclaim ||= claimedAttr(name);
}
}
// The morph is the only write path for server-owned elements, and it makes
@@ -2580,6 +2698,52 @@ function afterRange(start, id) {
return null;
}
+/**
+ * Dev: an attribute-slot occurrence's marked positions cannot bind — no
+ * fill resolves for its prop (`why` = "fill"), or a called occurrence has
+ * no args record once records can no longer arrive ("record"). The
+ * failure this names is otherwise silent: a handler that never fires, a
+ * class that never updates, indistinguishable from nothing happening.
+ * One report per occurrence per frame (`slotOrphans`, keyed by frame so
+ * the class carries no dev-only field); the elements ride along in `data`
+ * so a console can jump to them. A module function, not a method, so the
+ * production build sheds it whole with its gated call sites.
+ */
+let slotOrphans;
+function devSlotOrphan(frame, occurrence, consumers, why) {
+ if (!"_SOLID_DEV_") return;
+ let seen = (slotOrphans ??= new WeakMap()).get(frame);
+ if (!seen) slotOrphans.set(frame, (seen = new Set()));
+ if (seen.has(occurrence)) return;
+ seen.add(occurrence);
+ const prop = propOf(occurrence);
+ const positions = new Set();
+ for (const c of consumers) for (const p of c.positions) positions.add(p.pos);
+ const where = `${consumers.length} element${consumers.length === 1 ? "" : "s"} (positions: ${[...positions].join(", ")})`;
+ DEV.report(
+ OBSERVE.diagnostics.emit(
+ {
+ code: "ATTRIBUTE_SLOT_POSITION",
+ kind: "render",
+ severity: "warn",
+ message:
+ why === "fill"
+ ? `[ATTRIBUTE_SLOT_POSITION] Server markup binds \`${occurrence}\` at ${where}, but no client fill ` +
+ `resolves for slot \`${prop}\` — those positions never bind and the elements are inert. ` +
+ `Pass \`${prop}\` to the server component on the client (a function returning the object the ` +
+ `markup reads), or check that the prop name matches on both sides.`
+ : `[ATTRIBUTE_SLOT_POSITION] Server markup binds \`${occurrence}\` at ${where}, but no args record ` +
+ `for it arrived and none can — the fill mounts with empty args. A called slot always emits its ` +
+ `record ahead of the markup that reads it, so this is the frame protocol out of step, not the fill: ` +
+ `a client and server from different builds (a stale dev prebundle, a cached asset), or a runtime ` +
+ `bug minting the marker and the record under different ids.`,
+ data: { reason: "orphan", why, occurrence, elements: consumers.map(c => c.element) }
+ },
+ null
+ )
+ );
+}
+
/**
* Dev-only range integrity check: a slot start marker whose end marker is not
* a later sibling means the range was corrupted between the producer and
diff --git a/packages/web/frames/src/frame-container-plugin.ts b/packages/web/frames/src/frame-container-plugin.ts
index fadd7d0e0..6d284c614 100644
--- a/packages/web/frames/src/frame-container-plugin.ts
+++ b/packages/web/frames/src/frame-container-plugin.ts
@@ -7,6 +7,7 @@
* the reactive core injects both halves. See frame-container-plugin.js.
* @experimental
*/
+import { DESCEND, rewriteTree } from "./tree-rewrite.js";
/** A container's border serialization: one subscribe() per consumer. */
export interface ContainerTrace {
@@ -174,42 +175,27 @@ export function toBorderForm(value: unknown, envelopeContainers: boolean): unkno
* exotic is a container (probed FIRST, by WeakMap — property-read safe), an
* iterable (probed under a guard: an unknown proxy's reads may throw), or
* an app value the serializer owns. No-op until the hooks are installed.
+ * A cyclic value is rewritten as a cycle (see rewriteTree).
*/
export function toBorderForm(value, envelopeContainers) {
if (value == null || typeof value !== "object") return value;
const resolve = envelopeContainers ? state.resolveTrace : undefined;
const share = state.shareIterable;
if (!resolve && !share) return value;
- if (resolve) {
- const trace = resolve(value);
- if (trace) return { [TRACE]: trace };
- }
- // Before the plain-object walk: an iterable spelled as a literal
- // (`{ [Symbol.asyncIterator]() {} }`) is a source, not a record.
- if (share && isShareableIterable(value)) return share(value);
- if (Array.isArray(value)) {
- let out = value;
- for (let i = 0; i < value.length; i++) {
- const next = toBorderForm(value[i], envelopeContainers);
- if (next !== value[i]) {
- if (out === value) out = value.slice();
- out[i] = next;
- }
- }
- return out;
- }
- if (Object.getPrototypeOf(value) === Object.prototype) {
- let out = value;
- for (const key of Object.keys(value)) {
- const next = toBorderForm(value[key], envelopeContainers);
- if (next !== value[key]) {
- if (out === value) out = { ...value };
- out[key] = next;
+ return rewriteTree(
+ value,
+ v => {
+ if (resolve) {
+ const trace = resolve(v);
+ if (trace) return { [TRACE]: trace };
}
- }
- return out;
- }
- return value;
+ // Before the plain-object walk: an iterable spelled as a literal
+ // (`{ [Symbol.asyncIterator]() {} }`) is a source, not a record.
+ if (share && isShareableIterable(v)) return share(v);
+ return DESCEND;
+ },
+ false
+ );
}
// An async iterable the sharer may take over: not one of seroval's own
diff --git a/packages/web/frames/src/frame-sink.ts b/packages/web/frames/src/frame-sink.ts
index e72a0e755..057d9f007 100644
--- a/packages/web/frames/src/frame-sink.ts
+++ b/packages/web/frames/src/frame-sink.ts
@@ -173,12 +173,19 @@ const LIVE_SOURCE = Symbol.for("solid.LiveSource");
import {
renderToStream,
createLiveHoles,
- CLAIM_PROP,
CLAIMS_STREAM,
- CLAIMS_DOCUMENT
+ CLAIMS_DOCUMENT,
+ slotValue,
+ isSlotValue,
+ SLOT_VALUE,
+ SLOT_FACE_STREAM,
+ SLOT_FACE_DATA,
+ SLOT_FACE_MARKUP
} from "../../src/server.js";
+import { devCheck } from "../../src/diagnostics.js";
import { createJSONSerializer } from "../../serialization/src/serializer.js";
import { isContainerTraced, toBorderForm } from "./frame-container-plugin.js";
+import { DESCEND, rewriteTree } from "./tree-rewrite.js";
import {
ChunkReader,
createChunk,
@@ -811,7 +818,7 @@ export function renderServerComponent(component, options = {}) {
// is live for the response window. The document face never sets
// this (t=0 latches to the V1 snapshot).
ctx.liveHoles = createLiveHoles(sink);
- // Behavior claims (Stage 6): arm the compiled guard for the whole
+ // Handler positions: arm the compiled `ssrClaim` guard for the whole
// response — everything here is the component's own render.
ctx.claims = CLAIMS_STREAM;
}
@@ -895,9 +902,183 @@ function frameStream(makeCode, options) {
/** The slot marker range for an occurrence, as a pre-rendered SSR value.
* `$slot` opts the range out of live-hole marking: a slot is a client-owned
* position — the server can never re-render it, so a live binding over one
- * would be permanently inert and its markers pure tax. */
+ * would be permanently inert and its markers pure tax.
+ *
+ * `$occurrence` names the occurrence for `ssrElement`, which meets the range
+ * when a slot's return is SPREAD onto an element — the retired shape it
+ * rejects by name (principles §9.2.3). */
function slotRange(occurrence) {
- return { t: ``, $slot: true };
+ return {
+ t: ``,
+ $slot: true,
+ $occurrence: occurrence
+ };
+}
+
+// A slot call's return serves BOTH things a slot can render (principles
+// §9.2.3): placed as a child it is the marker range (markup slot); read as
+// an object it is the fill's data (attribute slot) — `const row = props.row(a);
+//
`. One proxy over the range: the keys the engine
+// reads off a range pass through — an EXPLICIT set, the same on both faces
+// (the document face's range is an array, the stream face's a plain
+// object, and `key in target` would let `filter`/`at`/`sort`/`map` fall
+// through to Array.prototype on one face only, writing function source
+// into the markup); any other string key is a property READ, answered
+// with a `SLOT_VALUE` stand-in naming the occurrence and the key, carrying
+// the t=0 value when the fill ran. The attribute helpers (`@solidjs/web`
+// server) bind the position where the stand-in lands. Reserved for the
+// fill's output, therefore, and nothing else: `$`-prefixed keys, symbols,
+// `length`, numeric indices and `slice` (the resolver's array reads — the
+// copy `escape` takes of a placed range), the node keys `t`/`h`/`p`, `then`
+// (thenable probes), and the four Object.prototype names an engine coerces
+// through (`constructor`, `toString`, `valueOf`, `toJSON`) — the document
+// face checks the output and says so.
+const RANGE_KEYS = new Set([
+ "t",
+ "h",
+ "p",
+ "then",
+ "length",
+ "slice",
+ "constructor",
+ "toString",
+ "valueOf",
+ "toJSON"
+]);
+const NODE_KEYS = new Set(["t", "h", "p"]);
+/** Whether a string key read off a slot proxy is the range's own (passes
+ * through) rather than a property read of the fill's output. */
+function isRangeKey(key) {
+ const c = key.charCodeAt(0);
+ return c === 36 /* $ */ || (c >= 48 && c <= 57) /* index */ || RANGE_KEYS.has(key);
+}
+function slotProxy(range, occurrence, face, content, onData) {
+ return new Proxy(range, {
+ get(target, key, receiver) {
+ if (typeof key !== "string" || isRangeKey(key)) return Reflect.get(target, key, receiver);
+ // The first property read fixes the proxy's face as DATA (see
+ // repeatKey): a placed range never gets here.
+ if (onData) onData = void onData();
+ return slotValue(occurrence, key, content ? content[key] : undefined, face);
+ }
+ });
+}
+
+/**
+ * A slot arg's form at the serialization border: `toBorderForm`, after one
+ * check the border alone can make — the arg is, or carries, another slot's
+ * stand-in (`props.child({ parentId: parent.id })`, `parent` a slot;
+ * `{ nested: { x: row.done } }`, `[row.done]`). The server has no value
+ * there (it is the client's), so the record ships `undefined` at that
+ * position and dev says why; serializing the stand-in would hand the
+ * client an object where it expects the value (`{ k, v, f }` — and on the
+ * document face `v` is the t=0 value, which hydration then contradicts).
+ */
+function argBorderForm(value, key, occurrence) {
+ return toBorderForm(withoutStandIns(value, key, occurrence), true);
+}
+
+/**
+ * A slot arg with every stand-in in it replaced by `undefined` — the same
+ * rewrite as `toBorderForm` (copy-on-write, plain arrays and objects by
+ * their leaves, a cycle rewritten as a cycle; see rewriteTree): a
+ * container first (a WeakMap probe — a pending projection proxy's
+ * property reads throw not-ready), then the stand-in test on plain
+ * objects alone; anything exotic is the app's and is not read. Both faces
+ * take the same arg: the document face's t=0 fill reads what hydration
+ * will (see createDocumentSlotProps), the records carry it.
+ */
+function withoutStandIns(value, key, occurrence) {
+ return rewriteTree(
+ value,
+ (v, path) => {
+ if (isContainerTraced(v)) return v;
+ if (Object.getPrototypeOf(v) === Object.prototype && isSlotValue(v)) {
+ if ("_SOLID_DEV_") standInArgFinding(v, key, occurrence, path);
+ return undefined;
+ }
+ return DESCEND;
+ },
+ true
+ );
+}
+
+function standInArgFinding(sv, key, occurrence, path) {
+ if ("_SOLID_DEV_") {
+ // Once per (occurrence, arg, path) per render — and a stand-in shared
+ // between two paths is met once, at its first (`rewriteTree` answers
+ // the second from its record): the document face scrubs the fill's arg
+ // and the record's separately, and a live re-evaluation of the same
+ // getter is the same misuse.
+ const ctx = sharedConfig.context;
+ if (ctx) {
+ const id = `${occurrence}\u0000${key}${path}\u0000arg`;
+ const seen = ctx.slotFindings || (ctx.slotFindings = new Set());
+ if (seen.has(id)) return;
+ seen.add(id);
+ }
+ const where = path === "" ? `Arg \`${key}\`` : `Arg \`${key}${path}\``;
+ devCheck({
+ code: "ATTRIBUTE_SLOT_POSITION",
+ kind: "ssr",
+ severity: "warn",
+ message:
+ `[ATTRIBUTE_SLOT_POSITION] ${where} of \`${occurrence}\` is another slot's value ` +
+ `(\`${sv.k}\` of \`${sv[SLOT_VALUE]}\`). The server does not have it — the client owns ` +
+ `it — so it cannot be passed as data; the arg carries \`undefined\` there on both faces. ` +
+ `Pass the server's own value, or have the client fill read it from its own state.`,
+ data: {
+ reason: "arg",
+ occurrence,
+ key,
+ path: path === "" ? undefined : path,
+ from: sv[SLOT_VALUE],
+ fromKey: sv.k
+ }
+ });
+ }
+}
+
+/** Classify a document-face fill's return for property reads: a plain
+ * object is data (its properties are the t=0 values); content — an SSR
+ * node, a node list, a string, a function — is markup, and a read off it
+ * is a dev finding at the position that binds it. Nothing (`null`/
+ * `undefined`) reads as data with no values. */
+function slotFace(range, content) {
+ if (content == null) return SLOT_FACE_DATA;
+ if (typeof content !== "object" || Array.isArray(content)) return SLOT_FACE_MARKUP;
+ if (isServerContent(content)) {
+ // An SSR node is `{ t }` (plus `h`/`p`) and nothing else. An object
+ // with further keys is the fill's DATA that happened to use a node key
+ // — classified as data in every build (a `t` value is unreadable, the
+ // rest binds), and named below in dev.
+ let data = false;
+ for (const key in content) {
+ if (!NODE_KEYS.has(key)) {
+ data = true;
+ break;
+ }
+ }
+ if (!data) return SLOT_FACE_MARKUP;
+ }
+ if ("_SOLID_DEV_") {
+ for (const key of Object.keys(content)) {
+ if (isRangeKey(key)) {
+ devCheck({
+ code: "ATTRIBUTE_SLOT_POSITION",
+ kind: "ssr",
+ severity: "warn",
+ message:
+ `[ATTRIBUTE_SLOT_POSITION] The fill for \`${range.$occurrence}\` returned a key named \`${key}\`, ` +
+ `which is reserved (keys beginning with \`$\` or a digit, \`length\`, \`slice\`, the node keys \`t\`/\`h\`/\`p\`, ` +
+ `\`then\`, \`constructor\`, \`toString\`, \`valueOf\`, \`toJSON\`): the server reads it as the ` +
+ `slot's range, not as a value. Rename it.`,
+ data: { reason: "reserved-key", occurrence: range.$occurrence, key }
+ });
+ }
+ }
+ }
+ return SLOT_FACE_DATA;
}
// Occurrence ids embed user data (`$key`), and they land in contexts with
@@ -925,16 +1106,118 @@ function encodeOccurrenceKey(key) {
* adoption and every later stream.
*/
function occurrenceId(prop, raw, counts) {
- const k = raw.$key;
- // Numbers encode too: exponent forms ("1e+21") carry `+`.
- if (typeof k === "string" || typeof k === "number") {
- return `${prop}#${encodeOccurrenceKey(k)}`;
- }
+ const keyed = keyedId(prop, raw);
+ if (keyed !== undefined) return keyed;
const n = counts[prop] || 0;
counts[prop] = n + 1;
return `${prop}#${n}`;
}
+/** `prop#<$key>` for a call that names its occurrence; `undefined` otherwise. */
+function keyedId(prop, raw) {
+ const k = raw.$key;
+ // Numbers encode too: exponent forms ("1e+21") carry `+`.
+ return typeof k === "string" || typeof k === "number"
+ ? `${prop}#${encodeOccurrenceKey(k)}`
+ : undefined;
+}
+
+/**
+ * Structural identity of a call's args, for collapsing repeated un-keyed
+ * DATA calls (see repeatKey): a canonical string over plain values
+ * — primitives, and arrays / plain objects of them, keys sorted — or
+ * `undefined` when the args hold anything identity can't be read off by
+ * value: a function (a thunk, a handler), a promise or async iterable (the
+ * value tier), a class instance, a getter (compiled props — evaluating it
+ * here would double the read the record path owns), a cycle (no finite
+ * by-value form). `$key` is excluded: a keyed call is named, not compared.
+ */
+function structuralArgsKey(raw) {
+ let out = "";
+ let ancestors;
+ // Length-prefixed strings and keys keep the encoding injective.
+ const walk = (v, top) => {
+ if (v === null) out += "N";
+ else if (typeof v === "string") out += "s" + v.length + ":" + v;
+ else if (typeof v === "boolean") out += v ? "T" : "F";
+ else if (typeof v === "undefined") out += "U";
+ else if (typeof v === "number") out += "n" + v + ";";
+ else if (typeof v === "bigint") out += "b" + v + ";";
+ else if (typeof v !== "object") return false;
+ else {
+ const isArray = Array.isArray(v);
+ if (!isArray) {
+ const proto = Object.getPrototypeOf(v);
+ if (proto !== Object.prototype && proto !== null) return false;
+ }
+ if (ancestors === undefined) ancestors = new Set();
+ else if (ancestors.has(v)) return false;
+ ancestors.add(v);
+ try {
+ if (isArray) {
+ out += "[";
+ for (const item of v) if (!walk(item)) return false;
+ out += "]";
+ } else {
+ out += "{";
+ for (const k of Object.keys(v).sort()) {
+ if (top && k === "$key") continue;
+ const desc = Object.getOwnPropertyDescriptor(v, k);
+ if (desc.get || desc.set) return false;
+ out += "k" + k.length + ":" + k;
+ if (!walk(desc.value)) return false;
+ }
+ out += "}";
+ }
+ } finally {
+ ancestors.delete(v);
+ }
+ }
+ return true;
+ };
+ return walk(raw, true) ? out : undefined;
+}
+
+/**
+ * One call is one occurrence however many times the render evaluates it.
+ * The natural attribute-slot shape puts the call in a component prop —
+ * `` — and
+ * compiled props are getters: every position the shared component binds
+ * re-evaluates the expression. Without this, each read would mint an
+ * occurrence, run the fill and emit a record (the double-data disease, once
+ * per position). Two names collapse a repeat onto the first call's proxy:
+ *
+ * - `$key`: the occurrence is named, so any repeat is the same occurrence
+ * whatever its face — the author said so.
+ * - structural args, for a call already known to be DATA: identical args
+ * give an identical fill output, so binding both sites' positions to one
+ * occurrence changes nothing on screen. Known-data only, because a placed
+ * range is not collapsible — two `` are two
+ * ranges — and on the stream face a proxy's face is fixed by its first
+ * use (property read → data; `t` read → placed). The getter shape reads
+ * at the call, so the second evaluation finds the first registered; a
+ * call that is neither read nor placed before an identical call stays a
+ * separate occurrence (duplicate record, correct output). The document
+ * face classifies the fill's return at the call and registers there.
+ *
+ * `$key` therefore names an ENTITY — client state inside the fill's scope
+ * follows it across responses — and is optional; without it identity is
+ * positional per prop across responses and structural within one.
+ *
+ * Both names share one per-render map (`repeats`): a keyed call's key is
+ * its occurrence id (`prop#<$key>`), an un-keyed call's is `prop\0`,
+ * a zero-arg call's (document face, where the fill runs) is `prop\0`
+ * — disjoint alphabets. The caller registers a keyed proxy at the call and
+ * an un-keyed one when its face is known (first wins), and looks up before
+ * minting. `undefined`: this call can never be recognized as a repeat.
+ */
+function repeatKey(prop, raw) {
+ const keyed = keyedId(prop, raw);
+ if (keyed !== undefined) return keyed;
+ const structural = structuralArgsKey(raw);
+ return structural === undefined ? undefined : prop + "\0" + structural;
+}
+
/** An async value in the DR-2 value-tier sense: passed whole, rides the data
* channel, and the consumer's READ settles. */
function isAsyncValue(v) {
@@ -1018,6 +1301,8 @@ export function createDocumentSlotProps(
export function createDocumentSlotProps(clientProps, frameId) {
const counts = Object.create(null);
const getters = new Map();
+ // Repeated calls are one occurrence within a render (see repeatKey).
+ const repeats = new Map();
// `$slot`-tagged like the stream face's slotRange: the engine resolves a
// slot-tagged value MINT-SUPPRESSED, so fill content — client-owned DOM
// the adopting frame claims — never grows live-hole markers or bindings.
@@ -1026,6 +1311,11 @@ export function createDocumentSlotProps(clientProps, frameId) {
// story: arg re-emissions update the adopted occurrence's props. One
// known coarsening: a region (server JSX arg) placed by the fill resolves
// inside this suppressed span, so its interior holes keep the t=0 latch.
+ //
+ // The return is the slot PROXY over this range (see slotProxy): placed,
+ // it is the range; read, its properties are the fill's t=0 values — this
+ // being the document face, where the fill ran. A fill that returned
+ // content classifies as markup and reads off it are dev findings.
const range = (occurrence, content) => {
const r = [
{ t: `` },
@@ -1033,7 +1323,10 @@ export function createDocumentSlotProps(clientProps, frameId) {
{ t: `` }
];
r.$slot = true;
- return r;
+ r.$occurrence = occurrence;
+ const face = slotFace(r, content);
+ r.$face = face;
+ return slotProxy(r, occurrence, face, face === SLOT_FACE_DATA ? content : undefined);
};
// Client content renders under a per-occurrence hydration-key OWNER
// scope, so the adopting client re-renders each slot under the SAME
@@ -1078,20 +1371,34 @@ export function createDocumentSlotProps(clientProps, frameId) {
// Direct-insert position: the client's content renders inline,
// wrapped in the range the adopting frame will claim.
if (callArgs.length === 0 || callArgs[0] === undefined) {
+ // A zero-arg call is the occurrence named by the prop, so a
+ // repeat of one known to be DATA collapses onto the first proxy
+ // like the args path below (see repeatKey — `` re-evaluates the getter at every
+ // position the component binds, and each evaluation ran the
+ // fill). A placed range is one range per placement.
+ const rk = prop + "\0";
+ const repeat = repeats.get(rk);
+ if (repeat) return repeat;
// Direct-insert positions are key-scoped like render props —
// there is no natural id parity across the boundary, so BOTH
// sides evaluate inside the occurrence scope. The prop is read
// INSIDE scoped(): compiled component props are getters, so the
// client's JSX evaluates lazily at access under the same keys —
// plain JSX, no thunk convention.
- return suppressedFill(() =>
+ const out = suppressedFill(() =>
scoped(prop, () => {
const value = clientProps[prop];
return range(prop, typeof value === "function" ? value() : value);
})
);
+ if (out.$face === SLOT_FACE_DATA) repeats.set(rk, out);
+ return out;
}
const raw = callArgs[0];
+ const rk = repeatKey(prop, raw);
+ const repeat = rk !== undefined && repeats.get(rk);
+ if (repeat) return repeat;
const occurrence = occurrenceId(prop, raw, counts);
const slot = clientProps[prop];
if (typeof slot !== "function") return range(occurrence, undefined);
@@ -1240,7 +1547,11 @@ export function createDocumentSlotProps(clientProps, frameId) {
configurable: true
});
} else {
- resolved[key] = value;
+ // What hydration will read: a stand-in anywhere in the arg is
+ // `undefined` in the record (argBorderForm), so the t=0 fill
+ // takes the same value — the one-record shape holds for the
+ // args the fill saw, not only the ones it shipped.
+ resolved[key] = vals[key] = withoutStandIns(value, key, occurrence);
}
}
const out = suppressedFill(() =>
@@ -1273,7 +1584,7 @@ export function createDocumentSlotProps(clientProps, frameId) {
if (!isContainerTraced(value) && isServerContent(value)) continue;
// Containers (at any depth) ride the record as trace envelopes;
// everything else passes through by reference.
- args[key] = toBorderForm(value, true);
+ args[key] = argBorderForm(value, key, occurrence);
}
// A CLONE serializes; `args` stays canonical for the ledger
// below — re-emissions mutate it and clone again, so the
@@ -1315,13 +1626,18 @@ export function createDocumentSlotProps(clientProps, frameId) {
evals[key],
states[key],
value => {
- args[key] = toBorderForm(value, true);
+ args[key] = argBorderForm(value, key, occurrence);
liveArgs.slot(frameId, occurrence, { ...args });
}
);
}
}
}
+ // The fill ran and its return is classified: a keyed call
+ // registers for repeats whatever its face, an un-keyed one only
+ // as data (two identical placements are two ranges).
+ if (rk !== undefined && (rk === occurrence || out.$face === SLOT_FACE_DATA))
+ repeats.has(rk) || repeats.set(rk, out);
return out;
};
// A slot getter placed directly as a child (`{props.children}`) is a
@@ -1329,10 +1645,6 @@ export function createDocumentSlotProps(clientProps, frameId) {
// same way it does on the stream face (the impurity gates would
// latch it anyway — this makes it exact rather than incidental).
fn.$lhSkip = true;
- // The claim brand (Stage 6): a stub placed in a ref/on* position
- // instead of being called claims by prop name — `ssrClaim` reads
- // this to mint `_bnd`.
- fn[CLAIM_PROP] = prop;
getters.set(prop, fn);
}
return fn;
@@ -1379,15 +1691,17 @@ const FRAME_ELEMENT_CLOSE = `${FRAME_TAG}>`;
* ships last values, then the channel closes (an open stream would hold the
* response forever).
*
- * CONTEXT GEOMETRY: components render under per-component context CLONES,
- * so the ctx a server component arms under is usually not the root object
- * the renderer's flush loop reads. Everything read DOWNWARD (`liveHoles`,
- * `commit` — consumed by the subtree under the arm point) rides the clone:
- * descendants spread-copy it. Everything read at the ROOT (the end latch,
- * and the once-per-document arming dedupe — a second component elsewhere
- * arms under a sibling clone that never saw the first) rides `ctx.live`,
- * the shared slot the root context creates and every clone carries by
- * reference.
+ * CONTEXT GEOMETRY: the ctx a server component arms under is not always
+ * the root object the renderer's flush loop reads — it may be a DERIVED
+ * context (`Object.create(root)`: a Loading boundary's buffered context,
+ * or the claims-arming context `frameTransformDirectResult` renders a
+ * server-owned frame under). Everything read DOWNWARD (`liveHoles`,
+ * `commit` — consumed by the subtree under the arm point) is written on
+ * that ctx and inherited by whatever derives from it below. Everything
+ * read at the ROOT (the end latch, and the once-per-document arming dedupe
+ * — a second component elsewhere arms under a sibling derived ctx that
+ * never saw the first) rides `ctx.live`, the shared slot the root context
+ * creates and every derived context inherits by reference.
*/
function armDocumentLiveHoles(ctx) {
if (!ctx || ctx.liveHoles !== undefined) return;
@@ -1543,21 +1857,33 @@ export function frameTransformDirectResult(value, { id, args }) {
// the client's content keeps full app context while the component's
// own render is context-isolated.
serverOwned(() => {
- armDocumentLiveHoles(sharedConfig.context);
- // Behavior claims (Stage 6): arm the compiled guard for this subtree
- // (descendant context clones spread-copy it). Minting is additionally
- // scope-gated inside ssrClaim, so client fill content — which
- // re-enters the zone owner outside the component barrier — neither
- // claims nor warns.
- sharedConfig.context.claims = CLAIMS_DOCUMENT;
- const slotProps = createDocumentSlotProps(props, id);
- // A `live` answer (the declaration's in-process brand lands on this
- // wrapper after it is made, before it renders) marks the scope live:
- // every async source inside takes its first value and closes — the
- // document completes, and the standing render is the client's
- // connection after hydration (RFC 11 §9.5, Server face 3). Read at
- // render, not at wrap: the brand arrives from `live`, outside.
- return serverComponentScope(() => component(slotProps), !!wrapped[LIVE_SOURCE]);
+ const page = sharedConfig.context;
+ armDocumentLiveHoles(page);
+ // Handler positions: arm the compiled `ssrClaim` guard — and the
+ // spread walk's slot probes — for this subtree, on a render context
+ // DERIVED from the page's (prototype: every shared field and method
+ // reads through, as a Loading boundary's buffered context does). The
+ // page's own context never carries `claims`, so the document's
+ // elements after the component keep the pre-slot walk; a late hole
+ // minted inside re-emits under its mint-time context — this one —
+ // and stays armed. Marking is additionally scope-gated inside
+ // ssrClaim, so client fill content — which re-enters the zone owner
+ // outside the component barrier — neither marks nor warns.
+ const ctx = Object.create(page);
+ ctx.claims = CLAIMS_DOCUMENT;
+ sharedConfig.context = ctx;
+ try {
+ const slotProps = createDocumentSlotProps(props, id);
+ // A `live` answer (the declaration's in-process brand lands on this
+ // wrapper after it is made, before it renders) marks the scope live:
+ // every async source inside takes its first value and closes — the
+ // document completes, and the standing render is the client's
+ // connection after hydration (RFC 11 §9.5, Server face 3). Read at
+ // render, not at wrap: the brand arrives from `live`, outside.
+ return serverComponentScope(() => component(slotProps), !!wrapped[LIVE_SOURCE]);
+ } finally {
+ sharedConfig.context = page;
+ }
}),
{ t: FRAME_ELEMENT_CLOSE }
];
@@ -1791,6 +2117,8 @@ export function createSlotProps(
export function createSlotProps(sink, frame) {
const counts = Object.create(null);
const getters = new Map();
+ // Repeated calls are one occurrence within a render (see repeatKey).
+ const repeats = new Map();
return new Proxy(Object.create(null), {
// Every key virtually exists — a prop is a *position* the client may
// fill, and the server cannot know which ones the client supplied. This
@@ -1812,14 +2140,19 @@ export function createSlotProps(sink, frame) {
if (!fn) {
fn = (...callArgs) => {
if (callArgs.length === 0 || callArgs[0] === undefined) {
- return slotRange(prop);
+ return slotProxy(slotRange(prop), prop, SLOT_FACE_STREAM, undefined);
}
+ const rk = repeatKey(prop, callArgs[0]);
+ const repeat = rk !== undefined && repeats.get(rk);
+ if (repeat) return repeat;
// Slot records are emit-once: occurrence identity is positional
// (`counts`), so a live-hole re-evaluation reaching a called slot
// would mint new occurrences and re-serialize args — the double-
// data disease. First render stamps the engine so the enclosing
// hole latches instead of binding; a sweep that gets here anyway
- // (the escalated-first-render case) aborts and closes the binding.
+ // (the escalated-first-render case) flags the engine (`gateHit`)
+ // and hands back an inert placeholder — the engine discards that
+ // sweep's value and closes the binding.
const live = sharedConfig.context && sharedConfig.context.liveHoles;
if (live) {
if (live.sweeping) {
@@ -1956,7 +2289,7 @@ export function createSlotProps(sink, frame) {
const ref = `arg:${occurrence}:${key}`;
// Containers (at any depth) swap for their trace envelopes
// before the value meets seroval — see toBorderForm.
- ctx.serialize(ref, toBorderForm(value, true));
+ ctx.serialize(ref, argBorderForm(value, key, occurrence));
args[key] = { $ref: ref };
if (evaluate && !state) state = { settled: true, last: value };
}
@@ -1986,21 +2319,30 @@ export function createSlotProps(sink, frame) {
} else {
const ref = `arg:${occurrence}:${key}@${sink.nextArgRef(ledgerKey)}`;
sink.mintRef(ref);
- ctx.serialize(ref, toBorderForm(value, true));
+ ctx.serialize(ref, argBorderForm(value, key, occurrence));
args[key] = { $ref: ref };
}
sink.slot(occurrence, { ...args });
});
}
- return slotRange(occurrence);
+ // A keyed call registers for repeats at the call; an un-keyed one
+ // at its first property read — the moment its face is known to be
+ // data (the proxy calls `onData` once, then never again).
+ const keyed = rk === occurrence;
+ const out = slotProxy(
+ slotRange(occurrence),
+ occurrence,
+ SLOT_FACE_STREAM,
+ undefined,
+ rk === undefined || keyed ? undefined : () => repeats.has(rk) || repeats.set(rk, out)
+ );
+ if (keyed) repeats.set(rk, out);
+ return out;
};
// A slot getter placed directly as a child (`{props.children}`) is a
// function-shaped hole; the tag opts it out of live-hole marking the
// same way `$slot` opts out its returned range.
fn.$lhSkip = true;
- // The claim brand (Stage 6): see createDocumentSlotProps — same
- // contract on the stream face.
- fn[CLAIM_PROP] = prop;
getters.set(prop, fn);
}
return fn;
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/frames/src/server.ts b/packages/web/frames/src/server.ts
index f8dae0700..1a9036f39 100644
--- a/packages/web/frames/src/server.ts
+++ b/packages/web/frames/src/server.ts
@@ -51,6 +51,33 @@ setAsyncIterableSharer(shareAsyncIterable);
*/
export type Slot
= (props: P & { $key?: string | number }) => SolidElement;
+/**
+ * An attribute slot (principles §9.2.3): the client renders an object instead of
+ * markup, and the server template consumes it by reading properties at
+ * positions — `const row = props.row({ id, completed });` then
+ * `
`. One call is one data context: any element
+ * in the template may read from it, and the client owns exactly the values
+ * the template read. Keys are the client's names; the position decides what
+ * a property IS (attribute, class name, style property, handler, ref).
+ *
+ * `P` is the args the server passes (reactive props to the fill, as for
+ * `Slot`); `J` is the object the fill returns — the same type a shared
+ * component takes as a prop when the client renders it directly, so one
+ * `TodoRow` serves both sides. `$key` is occurrence identity, as for `Slot`,
+ * and optional: within a render, repeated calls with structurally equal
+ * args are one occurrence (a call in a component prop is re-evaluated per
+ * position the component binds), so the natural spelling needs no key;
+ * `$key` is for state inside the fill's scope that must follow the entity
+ * across responses. A slot with no args (`P` empty) is called bare —
+ * `const filters = props.filters();` — and is one occurrence named by the
+ * prop.
+ * @experimental
+ */
+export type AttributeSlot
> = {} extends P
+ ? (props?: P & { $key?: string | number }) => J
+ : (props: P & { $key?: string | number }) => J;
+
/**
* Types an async value crossing the slot border (DR-2, value tier). What you
* pass is what ships — the promise / async iterable itself rides the data
diff --git a/packages/web/frames/src/tree-rewrite.ts b/packages/web/frames/src/tree-rewrite.ts
new file mode 100644
index 000000000..92cc45100
--- /dev/null
+++ b/packages/web/frames/src/tree-rewrite.ts
@@ -0,0 +1,141 @@
+/**
+ * Copy-on-write rewrite of a value tree, for the serialization-border walks
+ * (`toBorderForm` — every document-face serialization and every slot-arg
+ * record — and the slot-arg stand-in scrub). `visit(value, path)` is asked
+ * at every OBJECT node first (primitives pass through unasked: no visitor
+ * replaces one, and leaves are most of a payload) and answers with the
+ * node's replacement, or `DESCEND` to walk the node's leaves — which
+ * happens for a plain array or plain object only; anything else (a class
+ * instance, a `Map`, a proxy) is the app's and passes through untouched.
+ *
+ * Author values are never mutated. The walk is plain recursion, copy-on-
+ * write: an untouched subtree passes by reference, a changed one is copied
+ * along the path to the root. That is the whole cost for acyclic data of
+ * ordinary depth (measured below what `next`'s direct recursion paid, the
+ * primitive short-circuit buying more than the visitor call costs).
+ *
+ * A back-reference to an ancestor has no copy-on-write answer (the copy's
+ * back-reference would point at the original, unrewritten), and tracking
+ * ancestors at every node would tax every walk. So ancestors are tracked
+ * only from a depth ordinary payloads never reach (`TRACK`): a cycle is
+ * caught within one cycle-length past it, the pass aborts and the tree is
+ * rewritten again as a memoized clone — one copy per container, a back-
+ * reference resolving to the ancestor's copy. Only cyclic (or unusually
+ * deep) values pay. Replacements are recorded as they are produced (a trace
+ * envelope, a multicast seat, a scrubbed stand-in): a node seen again on
+ * the first pass — a cycle revisits its nodes on every level down to the
+ * catch — and every node of the clone pass answer from the record, so a
+ * visitor's side effects run once per node whichever way the walk went.
+ * Walks that replace nothing never pay the lookup. A value reachable at two
+ * paths is one node: its replacement is shared (one seat for one iterable —
+ * the identity the input had, which the serializer writes once and the
+ * client materializes once), and a shared subtree with nothing to replace
+ * is visited at each occurrence on the first pass and once on the clone
+ * pass. `path` is built (`.key`, `[i]`) only when `withPath`.
+ */
+export const DESCEND: unique symbol = Symbol("descend");
+
+const CYCLE = {};
+
+/** The depth from which the first pass tracks its ancestors. */
+const TRACK = 16;
+
+type Visit = (value: object, path: string | undefined) => any;
+
+type State = { memo: Map | undefined; ancestors: Set | undefined };
+
+export function rewriteTree(value: any, visit: Visit, withPath: boolean): any {
+ if (value === null || typeof value !== "object") return value;
+ const path = withPath ? "" : undefined;
+ const state: State = { memo: undefined, ancestors: undefined };
+ try {
+ return cow(value, visit, path, 0, state);
+ } catch (err) {
+ if (err !== CYCLE) throw err;
+ }
+ return clone(value, visit, path, state.memo || (state.memo = new Map()));
+}
+
+function shape(value: any): 1 | 2 | 0 {
+ return Array.isArray(value) ? 1 : Object.getPrototypeOf(value) === Object.prototype ? 2 : 0;
+}
+
+// `value` is an object (the callers test).
+function cow(value: any, visit: Visit, path: string | undefined, depth: number, state: State) {
+ const memo = state.memo;
+ if (memo !== undefined && memo.has(value)) return memo.get(value);
+ const r = visit(value, path);
+ if (r !== DESCEND) {
+ if (r !== value) (memo || (state.memo = new Map())).set(value, r);
+ return r;
+ }
+ const s = shape(value);
+ if (s === 0) return value;
+ // Deep enough to suspect a cycle: this container joins the ancestors for
+ // the walk of its leaves. No try/finally — an abort discards the set, a
+ // visitor's error propagates past it.
+ let ancestors;
+ if (depth >= TRACK) {
+ ancestors = state.ancestors || (state.ancestors = new Set());
+ if (ancestors.has(value)) throw CYCLE;
+ ancestors.add(value);
+ }
+ let out = value;
+ if (s === 1) {
+ for (let i = 0; i < value.length; i++) {
+ const c = value[i];
+ if (c === null || typeof c !== "object") continue;
+ const next = cow(
+ c,
+ visit,
+ path === undefined ? undefined : `${path}[${i}]`,
+ depth + 1,
+ state
+ );
+ if (next !== c) {
+ if (out === value) out = value.slice();
+ out[i] = next;
+ }
+ }
+ } else {
+ for (const k of Object.keys(value)) {
+ const c = value[k];
+ if (c === null || typeof c !== "object") continue;
+ const next = cow(c, visit, path === undefined ? undefined : `${path}.${k}`, depth + 1, state);
+ if (next !== c) {
+ if (out === value) out = { ...value };
+ out[k] = next;
+ }
+ }
+ }
+ if (ancestors !== undefined) ancestors.delete(value);
+ return out;
+}
+
+// `value` is an object (the callers test).
+function clone(value: any, visit: Visit, path: string | undefined, memo: Map) {
+ if (memo.has(value)) return memo.get(value);
+ const r = visit(value, path);
+ if (r !== DESCEND) {
+ memo.set(value, r);
+ return r;
+ }
+ const s = shape(value);
+ if (s === 0) return value;
+ const out = s === 1 ? value.slice() : { ...value };
+ memo.set(value, out);
+ if (s === 1) {
+ for (let i = 0; i < value.length; i++) {
+ const c = value[i];
+ if (c !== null && typeof c === "object")
+ out[i] = clone(c, visit, path === undefined ? undefined : `${path}[${i}]`, memo);
+ }
+ } else {
+ for (const k of Object.keys(value)) {
+ const c = value[k];
+ if (c !== null && typeof c === "object")
+ out[k] = clone(c, visit, path === undefined ? undefined : `${path}.${k}`, memo);
+ }
+ }
+ return out;
+}
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 = {
diff --git a/packages/web/package.json b/packages/web/package.json
index e94caac26..21425d165 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -41,7 +41,8 @@
"frames/package.json",
"performance-tracks/dist",
"performance-tracks/types",
- "performance-tracks/package.json"
+ "performance-tracks/package.json",
+ "skills"
],
"exports": {
".": {
diff --git a/packages/web/skills/server-components/SKILL.md b/packages/web/skills/server-components/SKILL.md
new file mode 100644
index 000000000..81f469550
--- /dev/null
+++ b/packages/web/skills/server-components/SKILL.md
@@ -0,0 +1,206 @@
+# Writing Solid server components with slots
+
+A Solid server component is a `"use server"` function that returns a
+template; the browser receives its markup and never its code. Anything the
+client contributes goes through a **slot** on the component's props. This
+guide is the set of rules an author — a person or an agent — needs to write
+one correctly the first time. The design record is
+`documentation/server-components/server-components-principles.md` §9.2.3;
+this file is the operative subset.
+
+## Where things go
+
+> **An element lives where the data that creates it lives.**
+
+- Exists because of **server data** (a todo row, a nav link, a search
+ form the server renders)? It is **server markup**. Client behavior or a
+ client-driven value on it is an **attribute slot** — bind, never wrap.
+ Do not wrap a server-rendered element in a client component to give it a
+ handler or a class.
+- Exists because of **client state alone** (an optimistic add, a modal, a
+ drag ghost, an editor open over a field)? It is a **client component**
+ placed through a **markup slot**.
+
+When the client confirms an optimistic entity and the server renders it, the
+row _becomes_ server markup; `$key` on the element reconciles the two.
+
+## The two kinds of slot
+
+```tsx
+import type { AttributeSlot, Slot } from "@solidjs/web/frames";
+
+interface TodosProps {
+ pending: Slot; // markup: the client returns JSX
+ row: AttributeSlot<{ id: string; completed: boolean }, RowBehavior>; // attributes: the client returns an object
+ filters: AttributeSlot<{}, FilterBehavior>; // no args: called bare
+}
+```
+
+A **markup slot** is placed as an element: ``. The client
+owns those nodes.
+
+An **attribute slot** is _called_ once per data context and its properties
+are _bound_ at positions of the server's own template:
+
+```tsx
+export async function todoList() {
+ "use server";
+ const todos = await db.list();
+ return (props: TodosProps) => {
+ const filters = props.filters();
+ return (
+ <>
+
+ {todos.map(t => (
+
+ ))}
+
+
+
+ All
+
+ >
+ );
+ };
+}
+```
+
+The client passes the fill — a function of the args that returns the object:
+
+```tsx
+ ({ all: filter() === "all", … })} pending={() => {…}} />
+```
+
+## The one rule for attribute slots
+
+> **A slot property is a JSX attribute value, whole, and nothing else.**
+
+Legal positions: an attribute (`hidden={row.removed}`, `checked={row.done}`,
+`title={row.error}`), a class name inside object form
+(`class={{ completed: row.done }}`), the whole `class`/`style`
+(`class={row.rowClass}`), a style property (`style={{ opacity: row.fade }}`),
+an event (`onInput={row.onToggle}`), a ref (`ref={row.ref}`) — on an element
+with a spread too (`` — the last
+source that has the key wins, `undefined` included, as the client's
+spread reads it). Not a `prop:*`
+property, not a text child, not inside another slot call's args (nested in
+plain objects and arrays included). Reserved keys the fill must not use:
+keys beginning with `$` or a digit, `length`, `slice`, `t`/`h`/`p`, `then`,
+`constructor`/`toString`/`valueOf`/`toJSON`; anything else (`filter`, `map`
+included) is a plain property.
+
+The server **does not have the value** — on the stream face a property read
+is a stand-in with no value, on the document face only the initial one — so
+nothing can be computed from it on the server. Every one of these is wrong:
+
+| Wrong | Why | Write instead |
+| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
+| ``class={`todo ${row.done}`}`` | stringified: not the whole value | `class={{ todo: true, completed: row.done }}` or return `rowClass` from the fill |
+| `row.count > 3 ? "a" : "b"` | comparison on a stand-in | decide in the fill; return the decided value |
+| `if (row.error) …`, `row.error && `, `` | truthiness: a stand-in is an object and **always truthy** — the branch always renders | presence → `hidden={…}` or a class; a node that exists because of client state → a markup slot |
+| `{row.count}` | text is not a bindable position yet | a markup slot for the text |
+| `format(row.title)` (a server helper) | the helper receives a stand-in | do the formatting in the fill |
+| `
` | spread: the template must show what the client owns | name each position |
+| `onClick={() => …}` on a server element | a server function can never run in the browser | bind a slot property or a form `action` |
+
+Every case except truthiness is a dev finding (`ATTRIBUTE_SLOT_POSITION`)
+and renders **nothing on either face**, so it shows on the first render.
+Truthiness has no runtime hook: if a retry button appears on every row, this
+is why.
+
+## Writing the fill
+
+- **Values as getters, handlers as plain closures** when a shared client
+ component also consumes the object (the usual case):
+
+ ```tsx
+ const rowFor = (p: { id: string; completed: boolean }): RowBehavior => ({
+ get rowClass() {
+ return { todo: true, completed: done(p.id, p.completed), pending: !!intent.byId[p.id] };
+ },
+ get done() {
+ return done(p.id, p.completed);
+ },
+ get removed() {
+ return removed(p.id);
+ },
+ onToggle: e => actions.toggle(p.id, e.currentTarget.checked),
+ onRemove: () => actions.remove(p.id)
+ });
+ ```
+
+ A client `` reads `props.row.onToggle` once,
+ untracked, in its body; a plain-value fill would do its reactive reads
+ there and never update (`STRICT_READ_UNTRACKED` names it). Getters move
+ each read to the position that binds it. Plain values are fine when only
+ the server template reads the object.
+
+- **Name it like a props interface**, because it is one (a shared component
+ takes it as a prop). Handlers are `on` + intent — `onToggle`, `onRemove`,
+ `onCopy`, `onClearCompleted`; the position names the DOM event, the key
+ names the meaning, as a component's `onSelect` does. Values are nouns or
+ adjectives (`done`, `rowClass`, `error`, `busy`); a ref is `ref`. The
+ runtime reads nothing into the prefix — the position decides what a
+ property is — but a reader can tell a handler from a value without
+ opening the type.
+- The fill runs once per occurrence (one call); each bound position tracks
+ its own reads; handlers dispatch to the fill's _current_ handler; a ref
+ fires once per element.
+- **`$key` is optional.** A call repeated within a render (a component
+ prop re-evaluates it per position) is one occurrence either way. Add
+ `$key: t.id` when the fill holds its own state (an edit draft, a signal
+ created inside) that must follow the entity across refetches and
+ reorders. Values never depend on it.
+- Reserved keys in the returned object: anything beginning with `$`, the
+ node keys `t` `h` `p` `then`, and `Object`/`Array` prototype member names
+ (`length`, `map`, …).
+
+## Shared components
+
+One component can render on both sides: on the server its `row` prop is
+the attribute slot's return, on the client it is the fill's result. It
+cannot tell and need not. Put `$key={props.id}` on the element it renders
+(morph identity) — the slot call's own `$key` is a different key for a
+different job. Do not read `document`/`window` during render; that is the
+ordinary isomorphic rule.
+
+## Compiling
+
+Server components need the SSR compile with the `serverComponents` compiler
+option. With `@solidjs/vite-plugin`, `solid({ ssr: true, serverFunctions:
+{ components: true } })` is the turnkey setup (the examples under
+`examples/todos-server`, `examples/notes`, `examples/chat`); a hand-rolled
+build passes `serverComponents: true` to `@solidjs/compiler` or
+`@solidjs/babel-plugin` for the SSR side. Without it a dynamic
+`class`/`style` compiles inside the template's quotes, where a slot value
+cannot be marked — the `inline` finding below.
+
+## Diagnostics
+
+`ATTRIBUTE_SLOT_POSITION` (`data.reason`):
+
+| reason | severity | what happened | fix |
+| -------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `spread` | error (throws) | a slot's return was spread onto an element | name each position |
+| `stringified` | warn | template literal / concatenation | whole value at one position |
+| `coerced` | warn | comparison, arithmetic, `==` | decide in the fill |
+| `text` | warn | placed as a text child | markup slot |
+| `inline` | warn | reached `class`/`style` inside template quotes | compile with `serverComponents: true` |
+| `markup` | warn | the fill returned JSX but the template read a property | return an object, or place the slot |
+| `server-local` | warn | a server function at `on*`/`ref` | bind a slot property |
+| `reserved-key` | warn | the fill used a reserved key | rename it |
+| `arg` | warn | a slot property passed in another slot call's argument (nested in plain objects/arrays; `data.path`) | pass the server's own value, or read it in the fill from client state |
+| `prop` | warn | a slot property at a `prop:*` key of a spread | bind the attribute form, or set the property in the fill's ref |
+| `fill-shape` | warn (client) | the fill returned a non-object (`null`, array, DOM node, primitive), or the prop read as data is not a function | return a plain object from a function prop |
+| `orphan` | warn (client) | markers for an occurrence that can never bind: `data.why` `"fill"` — no fill for the prop; `"record"` — a called occurrence with no args record | `fill`: pass the prop / match the name on both sides. `record`: not the fill — rebuild client and server together (stale prebundle, cached asset), else report it |
+
+Every reason but `fill-shape` and `orphan` comes from the server render.
+`orphan` is the one failure this model cannot otherwise show you: the
+element is inert — a handler that never fires, a class that never updates —
+with no error.
+
+`STRICT_READ_UNTRACKED` inside a fill: use getters (above).
diff --git a/packages/web/src/client.ts b/packages/web/src/client.ts
index bcbbf6340..a4717d21c 100644
--- a/packages/web/src/client.ts
+++ b/packages/web/src/client.ts
@@ -2619,18 +2619,7 @@ function eventHandler(e, container, state) {
value
});
const handleNode = () => {
- let handler = node[key];
- // Server-claimed handler (`_bnd` marker, Stage 6 behavior claims):
- // resolved at dispatch through the frame runtime's registered-symbol
- // seam — latest-props by construction, importless in both directions.
- // The read lives entirely inside this walk (no module-level state):
- // client.js contributes ZERO top-level bytes to tree-shaken subsets,
- // and the seam stays live for markers adopted before the frame runtime
- // loads (the document face). Only pays when no compiled handler exists.
- if (handler === undefined && node.hasAttribute && node.hasAttribute("_bnd")) {
- const seam = globalThis[Symbol.for("solid.bnd")];
- if (seam) handler = seam.resolve(node, e.type);
- }
+ const handler = node[key];
if (handler && !node.disabled) {
const data = node[`${key}Data`];
data !== undefined
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));
diff --git a/packages/web/src/server.ts b/packages/web/src/server.ts
index 1aa3a40c7..334609e74 100644
--- a/packages/web/src/server.ts
+++ b/packages/web/src/server.ts
@@ -339,6 +339,14 @@ export { createComponent, effect, memo, untrack, mergeProps, scope, getOwner };
// the claims-gate guard (`sharedConfig.context.claims ? ssrClaim(...) : ""`)
// needs the shared render context at template-evaluation time.
export { sharedConfig };
+// Module-local alias for the per-element hot paths (`ssrElement`'s walk):
+// an ESM module runner that keeps imports live exposes each imported
+// binding through a getter, so every `sharedConfig.x` at a call site is a
+// getter call — the SSR bench lane runs the source that way and showed the
+// walk's one context read per element as a ~5–10% regression that the
+// bundled output (a plain binding) never had. Reading through a module
+// constant makes it a property load in both.
+const renderConfig = sharedConfig;
export {
DOMWithState,
@@ -616,6 +624,14 @@ function applyAssetTracking(context, tracking, manifest, noScripts) {
});
context.registerModule = tracking.registerModule;
context.getBoundaryModules = tracking.getBoundaryModules;
+ // The per-request resolution cache lazy() reads (`resolveLazyAssets`),
+ // created on the ROOT context: render contexts derive from it by
+ // prototype (a Loading boundary's buffered context, a server-owned
+ // frame's claims context), and a cache the first lazy() on the page
+ // created lazily on a derived context would be that subtree's alone —
+ // the next lazy() outside it would start a second Map and re-ask the
+ // resolver for every module the first already resolved.
+ context._lazyAssets = new Map();
// A manifest can be the static object produced by a build (sync lookups,
// entry enumeration) or a resolver — the primitive a dev server implements
// against its live module graph: `{ resolve, resolveSync? }`, where
@@ -3562,9 +3578,9 @@ export function createLiveHoles(sink, scoped) {
// face never noticed — its whole response is one render, so the global
// still points at the armed context when async sweeps fire. The document
// face replaces it as the document renders past the component, so swept
- // re-emissions there lost every context-derived byte: `_bnd` claim
- // markers vanished from late holes (a copy button that compiled, streamed
- // its markup, and never armed).
+ // re-emissions there lost every context-derived byte: handler-position
+ // slot markers (`_s:on:click`) vanished from late holes (a copy button
+ // that compiled, streamed its markup, and never bound).
const swept = (owner, ctx, fn) => {
const prev = sharedConfig.context;
sharedConfig.context = ctx;
@@ -4190,12 +4206,18 @@ export function ssrClassName(value) {
if (typeof value === "number") return "" + value;
if (!value) return "";
if (typeof value === "string") return escape(value, true);
+ // An attribute-slot value here (whole, or as a class-name's condition) landed
+ // inside `class="…"` quotes, where its position marker cannot be emitted
+ // (see ssrElementAttribute): the element was compiled without the
+ // `serverComponents` option. Say so; nothing renders (either face).
+ if (isSlotValue(value)) return slotValueInline("class", value) ?? "";
value = classListToObject(value);
let classKeys = Object.keys(value),
result = "";
for (let i = 0, len = classKeys.length; i < len; i++) {
- const key = classKeys[i],
- classValue = !!value[key];
+ const key = classKeys[i];
+ let classValue = value[key];
+ if (isSlotValue(classValue)) classValue = slotValueInline("class", classValue);
if (!key || key === "undefined" || !classValue) continue;
result && (result += " ");
// Object keys land inside class="..." so they must be attribute-escaped.
@@ -4208,6 +4230,7 @@ export function ssrStyle(value: string | { [k: string]: string }): string;
export function ssrStyle(value) {
if (!value) return "";
if (typeof value === "string") return escape(value, true);
+ if (isSlotValue(value)) return slotValueInline("style", value) ?? "";
let result = "";
const k = Object.keys(value);
@@ -4215,7 +4238,8 @@ export function ssrStyle(value) {
// Object keys land inside style="..." so they must be attribute-escaped
// to prevent breaking out via `"`.
const s = escape(k[i], true);
- const v = value[k[i]];
+ let v = value[k[i]];
+ if (isSlotValue(v)) v = slotValueInline("style", v);
if (v != undefined) {
const r = escape(v, true);
if (r != undefined && r !== "undefined") {
@@ -4234,6 +4258,7 @@ export function ssrStyleProperty(name, value) {
// `style={{ [k]: v }}` the compiler wraps the key with `_$escape(k, true)`
// before concatenating the `:` suffix. Either way `name` is safe to splice
// into style="..." without further escaping.
+ if (isSlotValue(value)) value = slotValueInline("style", value);
return value != null ? name + value : "";
}
export function ssrElement(
@@ -4242,10 +4267,11 @@ export function ssrElement(
children: any,
needsId: boolean,
skip?: (key: string) => boolean,
- attrs?: string | (() => string)
+ attrs?: string | (() => string),
+ claims?: () => Record>
): { t: string };
-export function ssrElement(tag, props, children, needsId, skip, attrs) {
+export function ssrElement(tag, props, children, needsId, skip, attrs, claims) {
// The hydration key must be allocated before the props thunk runs: dynamic
// props (`mergeProps(() => ...)`) create a memo, which consumes a child id.
// The client claims the element (getNextElement) before applying the spread,
@@ -4284,22 +4310,43 @@ export function ssrElement(tag, props, children, needsId, skip, attrs) {
let proxy = false;
let viewKeys = null;
let owners = null;
+ // Under `slots`, the collecting pass also records which source each key
+ // came from (`ownerIndex`, parallel to `viewKeys`): a handler position's
+ // precedence against the element's named claims is source order
+ // (spreadBehaviorMarkers).
+ let ownerIndex = null;
+ // An attribute slot's object spread whole (`
`) is the retired
+ // 09-27 shape (principles §9.2.3): the client would decide what it owns
+ // and the template could not show it. Its range tag (`$slot`) is how it
+ // surfaces among the sources; name the positions instead. The tag is
+ // probed where the sources are already being classified, and a tagged
+ // source leaves the plain path with the other non-literal kinds — the
+ // collecting pass (`collectSpreadSources`) replaces it. This function is
+ // the SSR spread's hot path: what is not the plain walk lives in helpers
+ // so the walk itself stays small enough to optimize as one unit
+ // (spread-static-tail bench), and every slot probe on it — the `$slot`
+ // tag per source, the object test per attribute — sits behind `slots`:
+ // a stand-in is only ever met inside a server component's render, where
+ // the frame renderers arm `context.claims` (the compiled `ssrClaim`
+ // guard's value), so a render with no server components walks exactly
+ // as it did before attribute slots existed.
+ const ctx = renderConfig.context;
+ const slots = ctx !== undefined && ctx.claims !== undefined;
if (Array.isArray(props)) {
- let i = 0;
- for (; i < props.length; i++) {
- const s = props[i];
- if (s == null || typeof s === "function" || $PROXY in s) break;
- }
- if (i === props.length) sources = props;
+ if (slots && props.$slot === true) props = slotSpreadSource(tag, props);
else {
- viewKeys = [];
- owners = [];
- for (let i = 0; i < props.length; i++) {
- let s = props[i];
- // A function source is a plain thunk, called once (see above); a
- // nullish source contributes nothing (#3297).
- if (typeof s === "function") s = s();
- if (s != null) sourceOwners(s, viewKeys, owners);
+ let i = 0;
+ for (; i < props.length; i++) {
+ const s = props[i];
+ if (s == null || typeof s === "function" || $PROXY in s || (slots && s.$slot === true))
+ break;
+ }
+ if (i === props.length) sources = props;
+ else {
+ viewKeys = [];
+ owners = [];
+ if (slots) ownerIndex = [];
+ collectSpreadSources(tag, props, viewKeys, owners, slots, ownerIndex);
}
}
} else if (props == null) {
@@ -4311,6 +4358,8 @@ export function ssrElement(tag, props, children, needsId, skip, attrs) {
owners = [];
sourceOwners(props, viewKeys, owners);
} else proxy = true;
+ } else if (slots && props.$slot === true) {
+ props = slotSpreadSource(tag, props);
}
const info = tagInfo(tag);
const skipChildren = info.isVoid;
@@ -4318,6 +4367,12 @@ export function ssrElement(tag, props, children, needsId, skip, attrs) {
// already does), so skipped props leave no stray whitespace behind:
// `
` rather than `
` (#3382).
let result = info.open + hk;
+ // Handler/ref positions met under `slots` (principles §9.2.3), by position
+ // with the index of the source that owns each; emitted as one marker each
+ // after the walk, settled against the compiled `claims` in source order
+ // (see spreadBehaviorMarkers). Never touched outside a server component's
+ // render.
+ let behaviors = null;
// One walk over one prop body: the outer loop runs once for a single props
// object and once per source otherwise. With several sources every
// source's key list is taken once up front, and "a later source owns this
@@ -4384,34 +4439,49 @@ export function ssrElement(tag, props, children, needsId, skip, attrs) {
const value = props[prop];
// Nullish is "not set" for every attribute, `style`/`class` included —
// the client removes the attribute for `undefined`, and emitting
- // `style=""` here made the server disagree with it (#3382).
- if (
- value == undefined ||
- prop === "ref" ||
- prop.startsWith("on") ||
- prop.startsWith("prop:")
- ) {
- // Behavior claims ride NAMED ref/on* positions only — the compiler
- // can't see through a spread, so a claim-carrying stub landing here
- // silently drops. Say so where the author can act on it.
- if (
- "_SOLID_DEV_" &&
- typeof value === "function" &&
- value[CLAIM_PROP] !== undefined &&
- (prop === "ref" || prop.slice(0, 2) === "on")
- ) {
- devCheck({
- code: "BEHAVIOR_CLAIM_DROPPED",
- kind: "ssr",
- severity: "warn",
- message:
- `[BEHAVIOR_CLAIM_DROPPED] A spread on a server-rendered <${tag}> carries \`${prop}\` from client props — ` +
- `spreads don't participate in behavior claims, so this drops. ` +
- `Write the position out: \`${prop}={props.${String(value[CLAIM_PROP])}}\`.`,
- data: { reason: "spread", tag, position: prop, prop: String(value[CLAIM_PROP]) }
- });
- }
+ // `style=""` here made the server disagree with it (#3382). A nullish
+ // handler/ref key still OWNS its position under `slots`: the client's
+ // spread shadows an earlier source's key by presence (`collectProps`,
+ // `sourceHas`), then attaches nothing for `undefined` — so a named
+ // handler before this source binds nothing either.
+ if (value == undefined) {
+ if (slots && claims !== undefined && (prop === "ref" || prop.startsWith("on")))
+ behaviors = spreadBehaviorPosition(
+ behaviors,
+ prop,
+ value,
+ ctx.claims,
+ ownerIndex !== null ? ownerIndex[i] : s,
+ true
+ );
+ continue;
+ }
+ if (prop.startsWith("prop:")) {
+ // A property write has no server side; a stand-in there is named.
+ if (slots && typeof value === "object") spreadPropPosition(prop, value);
continue;
+ }
+ // An attribute-slot value at this key (principles §9.2.3) binds the position
+ // the key names: an attribute, a handler, a ref, or — for `class` /
+ // `style` objects — a name inside the attribute. A stand-in is an
+ // object, so under `slots` every object value takes the one helper
+ // that knows them (`spreadObjectAttribute`; the compiled positions
+ // share it); strings and booleans — the walk's common case — never
+ // do, and outside a server component the ladder is the pre-slot one.
+ if (prop === "ref" || prop.startsWith("on")) {
+ if (slots)
+ behaviors = spreadBehaviorPosition(
+ behaviors,
+ prop,
+ value,
+ ctx.claims,
+ ownerIndex !== null ? ownerIndex[i] : s,
+ claims !== undefined
+ );
+ continue;
+ }
+ if (slots && typeof value === "object") {
+ result += spreadObjectAttribute(prop, value);
} else if (prop === "style") {
result += ` style="${ssrStyle(value)}"`;
} else if (prop === "class") {
@@ -4444,6 +4514,15 @@ export function ssrElement(tag, props, children, needsId, skip, attrs) {
// source it replaces had its getters read: the expressions run at the same
// point in the hydration-id sequence. Evaluating it in argument position
// would move them ahead of the element's own key.
+ // `claims` is the compiled claim map of the element's named `ref`/`on*`
+ // attributes (the spread element's counterpart of the template path's
+ // guarded `ssrClaim` hole), keyed by the source index each attribute sits
+ // before, a thunk read only inside a server component's render — the same
+ // gate the template path's guard reads — so plain SSR never evaluates a
+ // handler expression. Called here, after the walk and before the tail
+ // thunk, for the same hydration-id reason.
+ if (slots && (behaviors !== null || claims !== undefined))
+ result += spreadBehaviorMarkers(behaviors, claims, ctx.claims);
if (attrs !== undefined) result += typeof attrs === "function" ? attrs() : attrs;
// The hydration key is unquoted, so a void element needs the space before
// `/>` or the slash becomes part of the key's value.
@@ -4478,9 +4557,19 @@ export function ssrElementAttribute(key, value) {
// absent, `""` is a bare attribute, anything else is attribute-escaped.
// `key` is a compile-time attribute name (never `ref`, `on*` or `prop:*`,
// which the compiler drops) and is trusted like `ssrAttribute`'s.
+ //
+ // Under the `serverComponents` compiler option a dynamic `class`/`style`
+ // on an intrinsic element compiles to THIS helper rather than into
+ // template quotes, so an attribute-slot value (principles §9.2.3) — the whole
+ // value, or a name's condition inside the object — can emit its position
+ // marker beside the attribute.
if (value == undefined) return "";
- if (key === "style") return ` style="${ssrStyle(value)}"`;
- if (key === "class") return ` class="${ssrClassName(value)}"`;
+ if (key === "style" || key === "class") {
+ return typeof value === "object"
+ ? slotClassOrStyle(key, value)
+ : ` ${key}="${key === "class" ? ssrClassName(value) : ssrStyle(value)}"`;
+ }
+ if (isSlotValue(value)) return slotAttribute(key, value);
if (typeof value === "boolean") return value ? ` ${key}` : "";
return value === "" ? ` ${key}` : ` ${key}="${escape(value, true)}"`;
}
@@ -4490,9 +4579,17 @@ export function ssrAttribute(key, value) {
// Compiler contract: `key` is always a compile-time string literal emitted
// from a JSX attribute name (see setAttr in babel-plugin/src/ssr/element.js)
// which can never contain `"`, `<`, `&`, or `>`. `value` is already
- // attribute-escaped by the compiler via `_$escape(..., true)`. Both are
- // trusted here so this hot path stays a pure string concatenation.
- return value == null || value === false ? "" : value === true ? ` ${key}` : ` ${key}="${value}"`;
+ // attribute-escaped by the compiler via `_$escape(..., true)` — which
+ // passes an attribute-slot value through untouched, so the position it names
+ // is bound here (principles §9.2.3). Both are trusted here so this hot
+ // path stays a pure string concatenation.
+ if (value == null || value === false) return "";
+ if (typeof value === "object") {
+ return isSlotValue(value)
+ ? slotAttribute(key, value)
+ : ` ${key}="${escape(String(value), true)}"`;
+ }
+ return value === true ? ` ${key}` : ` ${key}="${value}"`;
}
export function ssrHydrationKey(): string;
@@ -4501,81 +4598,529 @@ export function ssrHydrationKey() {
return hk ? ` _hk=${hk}` : "";
}
-// ---- server-component behavior claims (Stage 6: ref/event props) ----
+// ---- Attribute slots: positions in server markup that a client fill's values own ----
+//
+// (server-components-principles.md §9.2.3.) A slot is a client render; a
+// ATTRIBUTE slot's fill returns a plain object instead of JSX, and the server
+// template consumes it by reading properties at positions:
+//
+// const row = props.row({ id, completed });
+//
+//
+//
+// The call mints the occurrence and its args record exactly as a placed
+// (markup) slot call does; what comes back is the slot PROXY (frame-sink.ts),
+// whose property reads hand out `SLOT_VALUE`-branded stand-ins: the
+// occurrence, the property name and — on the document face, where the fill
+// ran at t=0 — the value. Every place an attribute value is written on the
+// server recognizes the brand and does two things: writes the t=0 value as
+// the attribute (document face; the stream face never runs fills and writes
+// nothing) and emits the position's MARKER beside it, on the consuming
+// element:
//
-// Compiled SSR output (behind the `serverComponents` compiler option) emits
-// `ctx.claims ? ssrClaim({ click: expr, ref: expr2 }) : ""` as a
-// whole-attribute hole on intrinsic elements carrying ref/on* positions. The
-// brand is the slot-props stub: a function-valued prop read off a server
-// component's props proxy carries its prop name (CLAIM_PROP), and the marker
-// simply names it — `_bnd="click=onCopy"`. Resolution happens client-side at
-// dispatch/adoption time through the frame's LIVE props (nearest `data-fid`
-// ancestor), which is what makes re-renders latest-props by construction: no
-// binding table, no versioning, no supersession window.
+// _s:=":" whole attribute
+// _s:class=":=,…" a name inside class/style
+// _s:on:=":" handler
+// _s:ref=":[,…]" ref
//
-// The gate has two layers, split between evaluation and mint:
-// - `ctx.claims` (the compiled guard) is ARMING — a plain enum the frame
-// renderers set at server-component entry, so renders with no server
-// components never evaluate the expressions (a property miss), and
-// context clones carry it by spread.
-// - the mint check here is SCOPE — on the document face (CLAIMS_DOCUMENT)
-// only owner chains inside the component barrier mint. Client fill
-// content re-enters the zone owner captured OUTSIDE the barrier, so
-// fills neither claim nor warn (their handlers are hydration's, and
-// legitimate). The stream face (CLAIMS_STREAM) mints unconditionally:
-// the whole response is the component and fills never render there.
-export const CLAIM_PROP = /*#__PURE__*/ Symbol.for("solid.claim-prop");
+// The `_hk` family: framework-owned marks. The client frame discovers
+// consumers by these attributes, groups them by occurrence, runs the fill
+// once per occurrence and writes each position from the returned object;
+// the morph reads the same markers off incoming markup to know which
+// positions are the client's. Keys are semantic (the client's names),
+// positions structural (the template decides what a property IS by where it
+// binds it) — nothing in the object says attribute, handler or ref.
+//
+// Where the brand is met: `ssrAttribute` (a compiled attribute; `escape`
+// passes the stand-in through), `ssrElementAttribute` (a compiled
+// `class`/`style` under the `serverComponents` option, or a trailing
+// attribute of a spread element), `ssrElement`'s walk (a runtime spread),
+// `ssrClaim` (the compiled per-element hole for ref/on* positions). The
+// grammar: the occurrence alphabet (frame-sink.ts) excludes `:`, `,` and
+// `=`; keys and names percent-encode onto an alphabet that excludes them
+// too, so every split is exact and the client decodes names back.
+
+// The compiled guard's arming values (`sharedConfig.context.claims`): the
+// frame renderers set one at server-component entry so renders with no
+// server components never evaluate the ref/on* hole's expressions. On the
+// document face only owner chains inside the component barrier warn about
+// server-local handlers (client fill content re-enters the zone owner
+// captured OUTSIDE the barrier — its handlers are hydration's).
export const CLAIMS_STREAM = 1;
export const CLAIMS_DOCUMENT = 2;
-// `_bnd` value grammar: `pos=prop[,pos=prop]*`. Prop names are client-
-// controlled strings landing in a quoted attribute that splits on `,`/`=`,
-// so unsafe characters percent-encode — URI-style (UTF-8 %XX sequences),
-// because unlike occurrence ids the CLIENT decodes these back to prop names
-// (`decodeURIComponent` at dispatch). The passthrough alphabet is attribute-
-// and grammar-safe; `%` itself encodes, so the mapping is injective.
-// Position names come from static JSX attribute names and are grammar-safe
-// by construction.
-const CLAIM_UNSAFE = /[^A-Za-z0-9_.!~*'()-]/g;
-function encodeClaimKey(key) {
- return String(key).replace(CLAIM_UNSAFE, c => encodeURIComponent(c));
+/** The brand on an attribute slot's stand-in for one property read. */
+export const SLOT_VALUE = /*#__PURE__*/ Symbol.for("solid.slot-value");
+/** Marker attribute prefix on a consuming element (`_s:class`, `_s:on:click`, `_s:ref`). */
+export const SLOT_MARKER = "_s:";
+/** Faces a stand-in can come from (`slotValue`'s `face`). */
+export const SLOT_FACE_STREAM = 0;
+export const SLOT_FACE_DATA = 1;
+export const SLOT_FACE_MARKUP = 2;
+
+const ATTRIBUTE_SLOT_POSITION = "ATTRIBUTE_SLOT_POSITION";
+
+/**
+ * Report a position finding once per (occurrence, key, reason, position) for
+ * the current render: a live hole evaluates its expression more than once
+ * (registration, then the baseline the re-emission ledger keeps), and the
+ * same misuse must not print twice. The set lives on the render context and
+ * dies with it.
+ */
+function slotFinding(sv, reason, position, message, severity = "warn") {
+ if ("_SOLID_DEV_") {
+ const ctx = sharedConfig.context;
+ if (ctx) {
+ const id = `${sv[SLOT_VALUE]}\u0000${sv.k}\u0000${reason}\u0000${position || ""}`;
+ const seen = ctx.slotFindings || (ctx.slotFindings = new Set());
+ if (seen.has(id)) return;
+ seen.add(id);
+ }
+ devCheck({
+ code: ATTRIBUTE_SLOT_POSITION,
+ kind: "ssr",
+ severity,
+ message,
+ data: { reason, occurrence: sv[SLOT_VALUE], key: sv.k, position }
+ });
+ }
}
-export function ssrClaim(map) {
- const mode = sharedConfig.context && sharedConfig.context.claims;
- if (
- !mode ||
- (mode === CLAIMS_DOCUMENT &&
- !(typeof inServerComponentScope === "function" && inServerComponentScope()))
- ) {
- return "";
+export function isSlotValue(value: unknown): boolean;
+
+export function isSlotValue(value) {
+ return value !== null && typeof value === "object" && value[SLOT_VALUE] !== undefined;
+}
+
+/**
+ * An attribute slot's stand-in for one property read. `face` says what the
+ * occurrence's fill produced where this read happens: nothing (the stream
+ * face never runs fills), a data object (the document face — `value` is
+ * the t=0 value of `key`), or markup (the document face ran the fill and
+ * it returned content — a read off it is a dev finding at the position).
+ * The server has no value to compute with — on the stream face none at
+ * all, on the document face only the t=0 one — so the ONLY thing a stand-in
+ * can be is the whole value at one position. Every coercion the runtime can
+ * see (a template literal, `+`, a comparison, an explicit `String()`) is a
+ * dev finding, and it renders NOTHING on either face: the document face
+ * never shows a t=0 value the stream face cannot reproduce, so a misuse is
+ * visible on the first render rather than on the first refetch. Truthiness
+ * (`if (row.done)`, `row.x && …`) has no hook and is the one misuse only
+ * the rule can catch — a stand-in is an object and always truthy.
+ * @internal Created by the slot proxies (frames server).
+ */
+export function slotValue(occurrence: string, key: string, value: unknown, face: number): object;
+
+export function slotValue(occurrence, key, value, face) {
+ const sv = { [SLOT_VALUE]: occurrence, k: key, v: value, f: face };
+ // Every implicit coercion goes through `Symbol.toPrimitive` first:
+ // `>`/`<`/arithmetic/`==` with hint "number" or "default", template
+ // literals, `String(x)` and string concatenation with "string". An
+ // explicit `.toString()` call is the one path around it.
+ Object.defineProperty(sv, Symbol.toPrimitive, {
+ value: hint => slotValueString(sv, hint === "string" ? "stringified" : "coerced")
+ });
+ Object.defineProperty(sv, "toString", { value: () => slotValueString(sv, "stringified") });
+ return sv;
+}
+
+function slotValueString(sv, reason) {
+ if ("_SOLID_DEV_") {
+ const prop = propOfOccurrence(sv[SLOT_VALUE]);
+ slotFinding(
+ sv,
+ reason,
+ undefined,
+ reason === "coerced"
+ ? `[${ATTRIBUTE_SLOT_POSITION}] \`${prop}\`'s \`${sv.k}\` is an attribute-slot value used in an expression ` +
+ `(a comparison, arithmetic, or a branch on its result). The server does not have the value — ` +
+ `the client owns it — so nothing can be computed from it here. It must be the WHOLE value of ` +
+ `an attribute, class name, style property, handler or ref; a decision that depends on it ` +
+ `belongs in the client fill (return the decided value) or in a markup slot.`
+ : `[${ATTRIBUTE_SLOT_POSITION}] \`${prop}\`'s \`${sv.k}\` is an attribute-slot value ` +
+ `and was stringified outside a bindable position — it must be the WHOLE value of an attribute, ` +
+ `class name, style property, handler or ref (\`class={row.${sv.k}}\`, not \`class={\`x \${row.${sv.k}}\`}\`). ` +
+ `If it is, the element was compiled without the \`serverComponents\` compiler option. ` +
+ `Nothing renders here on either face.`
+ );
+ }
+ return "";
+}
+
+function slotTextPosition(sv) {
+ if ("_SOLID_DEV_") {
+ slotFinding(
+ sv,
+ "text",
+ undefined,
+ `[${ATTRIBUTE_SLOT_POSITION}] \`${sv.k}\` of slot \`${propOfOccurrence(sv[SLOT_VALUE])}\` is placed as TEXT. ` +
+ `Text is not a bindable position yet: nothing renders here on either face. ` +
+ `Bind it to an attribute, or render the text in a markup slot.`
+ );
+ }
+ return "";
+}
+
+function slotMarkupRead(sv, position) {
+ if ("_SOLID_DEV_" && sv.f === SLOT_FACE_MARKUP) {
+ slotFinding(
+ sv,
+ "markup",
+ position,
+ `[${ATTRIBUTE_SLOT_POSITION}] \`${position}\` reads \`${sv.k}\` off slot \`${propOfOccurrence(sv[SLOT_VALUE])}\`, ` +
+ `but the client fill returned markup, not an object. A slot renders one or the other: ` +
+ `return an object (\`{ ${sv.k}: … }\`) for positions, or place the slot as content.`
+ );
}
+}
+
+/** A stand-in rendered where its marker cannot go (inside template quotes):
+ * a dev finding; the position gets no value (`undefined`) on either face. */
+function slotValueInline(kind, sv) {
+ if ("_SOLID_DEV_") {
+ slotFinding(
+ sv,
+ "inline",
+ kind,
+ `[${ATTRIBUTE_SLOT_POSITION}] An attribute-slot value (\`${sv.k}\` of \`${propOfOccurrence(sv[SLOT_VALUE])}\`) ` +
+ `reached \`${kind}\` inside template quotes, where its position marker cannot be emitted — the ` +
+ `element was compiled without the \`serverComponents\` compiler option. Nothing renders here ` +
+ `on either face.`
+ );
+ }
+ return undefined;
+}
+
+// `:[=]` — the marker value's one entry. Keys and
+// names are client-controlled strings landing in a quoted attribute that
+// splits on `,`, `:` and `=`; they percent-encode URI-style onto an
+// alphabet that excludes all three (and `%`, so the mapping is injective),
+// and the CLIENT decodes them back (`decodeURIComponent`).
+const SLOT_KEY_UNSAFE = /[^A-Za-z0-9_.!~*'()-]/g;
+function encodeSlotKey(key) {
+ return String(key).replace(SLOT_KEY_UNSAFE, c => encodeURIComponent(c));
+}
+function slotEntry(sv, name) {
+ return `${sv[SLOT_VALUE]}:${encodeSlotKey(sv.k)}${name !== undefined ? "=" + encodeSlotKey(name) : ""}`;
+}
+function slotMarker(position, entries) {
+ return ` ${SLOT_MARKER}${position}="${entries}"`;
+}
+function propOfOccurrence(occurrence) {
+ const i = occurrence.indexOf("#");
+ return i === -1 ? occurrence : occurrence.slice(0, i);
+}
+
+/** One whole attribute bound to a stand-in: the t=0 value, then the marker. */
+function slotAttribute(key, sv) {
+ slotMarkupRead(sv, key);
let out = "";
- for (const pos in map) {
- const value = map[pos];
- const list = Array.isArray(value) ? value : [value];
- for (const fn of list) {
- const prop = (typeof fn === "function" && fn[CLAIM_PROP]) || undefined;
- if (prop === undefined) {
- if ("_SOLID_DEV_") {
- devCheck({
- code: "BEHAVIOR_CLAIM_DROPPED",
- kind: "ssr",
- severity: "warn",
- message:
- `[BEHAVIOR_CLAIM_DROPPED] A \`${pos}\` position on a server-rendered element received a server-local ` +
- `${typeof fn} — this handler can never run. Pass the function through the ` +
- `server component's props from the client (compose on the client before ` +
- `passing), or bind a mutation to \`action=\`.`,
- data: { reason: "server-local", position: pos, received: typeof fn }
- });
+ if (sv.f === SLOT_FACE_DATA) {
+ const v = sv.v;
+ if (v != null && v !== false) {
+ out = v === true || v === "" ? ` ${key}` : ` ${key}="${escape(String(v), true)}"`;
+ }
+ }
+ return out + slotMarker(key, slotEntry(sv));
+}
+
+/**
+ * `class`/`style` as `ssrElementAttribute` writes them: the whole value may
+ * be a stand-in, or the object's members may be (a class name's condition,
+ * a style property's value) — each member binds that NAME. Without a
+ * stand-in anywhere the output is the plain helpers', byte for byte.
+ */
+function slotClassOrStyle(key, value) {
+ const isClass = key === "class";
+ if (isSlotValue(value)) {
+ slotMarkupRead(value, key);
+ let out = "";
+ if (value.f === SLOT_FACE_DATA && value.v != null) {
+ out = ` ${key}="${isClass ? ssrClassName(value.v) : ssrStyle(value.v)}"`;
+ }
+ return out + slotMarker(key, slotEntry(value));
+ }
+ if (typeof value === "object" && value !== null) {
+ const obj = isClass ? classListToObject(value) : value;
+ let entries = "";
+ let inner = "";
+ for (const name of Object.keys(obj)) {
+ let v = obj[name];
+ if (isSlotValue(v)) {
+ slotMarkupRead(v, `${key}:${name}`);
+ entries += (entries ? "," : "") + slotEntry(v, name);
+ v = v.f === SLOT_FACE_DATA ? v.v : undefined;
+ }
+ if (isClass) {
+ if (!name || name === "undefined" || !v) continue;
+ inner += (inner ? " " : "") + escape(name, true);
+ } else if (v != undefined) {
+ const r = escape(v, true);
+ if (r != undefined && r !== "undefined") {
+ inner += (inner ? ";" : "") + `${escape(name, true)}:${r}`;
+ }
+ }
+ }
+ // No stand-in: `inner` IS the plain helper's string (same walk, same
+ // escaping, same skips), written as the plain path writes it.
+ if (entries === "") return ` ${key}="${inner}"`;
+ return (inner ? ` ${key}="${inner}"` : "") + slotMarker(key, entries);
+ }
+ return ` ${key}="${isClass ? ssrClassName(value) : ssrStyle(value)}"`;
+}
+
+/** `onClick` → `click` (the client runtime's derivation). */
+function eventPosition(prop) {
+ return prop.slice(2).toLowerCase();
+}
+
+/**
+ * A `ref`/`on*` key met by `ssrElement`'s walk under `slots` — in a
+ * source, whatever its shape: a stand-in, a list of them (refs), a handler
+ * tuple, a server-local function, nothing — collected by position into
+ * `behaviors` exactly as `ssrClaim` reads the compiled claim map, so the
+ * spread path and the template path bind the same shapes and raise the
+ * same finding. A later source owns a key outright (the walk reads only
+ * the owner), so within the sources a position is one value. With no
+ * compiled claims on the element (`settle` false — the common spread, and
+ * the gated walk's hot path) the map is `pos` → marker entries, empty
+ * positions left out. With claims to settle, `pos` → `{ e: entries, at:
+ * owning source index }`, every position recorded whatever its value: a
+ * server-local function or `undefined` owns the position as the client
+ * sees it (`collectProps` shadows by key presence) and binds nothing
+ * (spreadBehaviorMarkers).
+ */
+function spreadBehaviorPosition(behaviors, prop, value, mode, index, settle) {
+ const pos = prop === "ref" ? "ref" : eventPosition(prop);
+ const entries = value == null ? "" : claimEntries(pos, value, mode);
+ if (!settle) {
+ if (entries) (behaviors ||= new Map()).set(pos, entries);
+ return behaviors;
+ }
+ behaviors ||= new Map();
+ const b = behaviors.get(pos);
+ if (b === undefined) behaviors.set(pos, { e: entries, at: index });
+ else {
+ b.e = entries;
+ b.at = index;
+ }
+ return behaviors;
+}
+
+/**
+ * The behavior markers of a spread element: the sources' (`behaviors`)
+ * settled against its compiled claim map (`claims` — the named `ref`/`on*`
+ * attributes, keyed by the index of the source each sits before; a thunk
+ * `ssrElement` calls only under `slots`, so plain SSR never evaluates
+ * them). The marker promises what the client binds, and the client's
+ * `spread(el, [a, { onClick }, b])` keeps the LAST source that HAS the key
+ * (`collectProps`: a later source's key shadows by presence; `merge()`'s
+ * lookup is `property in s`), a named attribute being a source at its
+ * position: a named handler at index `k` binds unless a spread at index
+ * `k` or later owns the position — with any value, `undefined` included —
+ * and a nullish named handler that is not so owned clears the position
+ * (the client attaches nothing for `undefined`); a later named attribute
+ * overrides an earlier one (the compilers keep only the last, so a segment
+ * never repeats a handler). Refs merge whatever their order (the client
+ * fires every ref); a nullish ref contributes nothing. One marker per
+ * position.
+ */
+function spreadBehaviorMarkers(behaviors, claims, mode) {
+ if (claims !== undefined) {
+ const segments = claims();
+ // Integer keys enumerate in ascending order: source order.
+ for (const k in segments) {
+ const map = segments[k];
+ for (const pos in map) {
+ const value = map[pos];
+ behaviors ||= new Map();
+ const b = behaviors.get(pos);
+ if (pos === "ref") {
+ if (value == null) continue;
+ const entries = claimEntries("ref", value, mode);
+ if (!entries) continue;
+ if (b === undefined) behaviors.set("ref", { e: entries, at: -1 });
+ else b.e = b.e ? b.e + "," + entries : entries;
+ continue;
+ }
+ if (b !== undefined && b.at >= +k) continue;
+ const entries = value == null ? "" : claimEntries(pos, value, mode);
+ if (b === undefined) behaviors.set(pos, { e: entries, at: -1 });
+ else b.e = entries;
+ }
+ }
+ }
+ let out = "";
+ if (behaviors !== null) {
+ for (const [pos, b] of behaviors) {
+ const e = claims === undefined ? b : b.e;
+ if (e) out += slotMarker(pos === "ref" ? "ref" : "on:" + pos, e);
+ }
+ }
+ return out;
+}
+
+/**
+ * `ssrElement`'s collecting pass for sources that are not all plain
+ * literals: a function source is a plain thunk, called once (see the walk;
+ * the compilers thunk a spread CALL, `{...props.row(args)}`); a nullish
+ * source contributes nothing (#3297); a slot's return spread whole is the
+ * retired shape (`slotSpreadSource`, probed only under `slots` — see the
+ * walk); everything else is collected through its leaves (`sourceOwners`).
+ * Under `slots` (`ownerIndex` given) each key also records the index of the
+ * source it came from, kept in step with `sourceOwners`' merge (a key seen
+ * again moves to the end, owned by the later source): what settles a
+ * handler position against the element's named claims.
+ */
+function collectSpreadSources(tag, props, viewKeys, owners, slots, ownerIndex) {
+ for (let i = 0; i < props.length; i++) {
+ let s = props[i];
+ if (typeof s === "function") s = s();
+ if (s != null) {
+ if (slots && !($PROXY in s) && s.$slot === true) s = slotSpreadSource(tag, s);
+ if (ownerIndex === null) sourceOwners(s, viewKeys, owners);
+ else {
+ const ks = [];
+ const os = [];
+ sourceOwners(s, ks, os);
+ for (let j = 0; j < ks.length; j++) {
+ const at = viewKeys.indexOf(ks[j]);
+ if (at !== -1) {
+ viewKeys.splice(at, 1);
+ owners.splice(at, 1);
+ ownerIndex.splice(at, 1);
+ }
+ viewKeys.push(ks[j]);
+ owners.push(os[j]);
+ ownerIndex.push(i);
}
- continue;
}
- out += `${out ? "," : ""}${pos}=${encodeClaimKey(prop)}`;
}
}
- return out ? ` _bnd="${out}"` : "";
+}
+
+/**
+ * A `prop:*` key of a runtime spread whose value is a stand-in: the
+ * compiler drops `prop:` on the server (a property is the client DOM's), so
+ * a slot cannot bind there yet — nothing renders, and dev says so.
+ */
+function spreadPropPosition(prop, value) {
+ if ("_SOLID_DEV_" && isSlotValue(value)) {
+ slotFinding(
+ value,
+ "prop",
+ prop,
+ `[${ATTRIBUTE_SLOT_POSITION}] \`${value.k}\` of slot \`${propOfOccurrence(value[SLOT_VALUE])}\` is bound ` +
+ `at \`${prop}\`. Property positions are not bindable (the server renders no properties): nothing ` +
+ `renders here. Bind the attribute form (\`${prop.slice(5)}\`), or set the property in the client fill's ref.`
+ );
+ }
+}
+
+/**
+ * An object value at an attribute key of a runtime spread: a `class`/`style`
+ * map (which may carry stand-ins as its conditions), a stand-in for the
+ * whole attribute, or any other object, attribute-escaped as its string.
+ */
+function spreadObjectAttribute(prop, value) {
+ if (prop === "style" || prop === "class") return slotClassOrStyle(prop, value);
+ if (value[SLOT_VALUE] !== undefined) return slotAttribute(attrName(prop), value);
+ return ` ${attrName(prop)}="${escape(value, true)}"`;
+}
+
+/**
+ * A slot's return spread whole onto an element (`
`): the
+ * retired shape — the client would decide what it owns and the template
+ * could not show it. Dev throws; prod contributes nothing.
+ */
+function slotSpreadSource(tag, source) {
+ if ("_SOLID_DEV_") {
+ const occurrence = source.$occurrence;
+ const text =
+ `[${ATTRIBUTE_SLOT_POSITION}] A slot${occurrence ? ` (\`${propOfOccurrence(occurrence)}\`)` : ""} is spread ` +
+ `onto a server-rendered <${tag}>. An attribute slot binds by position — name each one ` +
+ `(\`class={row.rowClass} onClick={row.remove}\`) so the template shows what the client owns.`;
+ recordFinding({
+ code: ATTRIBUTE_SLOT_POSITION,
+ kind: "ssr",
+ severity: "error",
+ message: text,
+ data: { reason: "spread", tag, occurrence }
+ });
+ throw new Error(text);
+ }
+ return {};
+}
+
+/**
+ * The compiled per-element hole for ref/on* positions on a server
+ * intrinsic (behind the `serverComponents` compiler option):
+ * `ctx.claims ? ssrClaim({ click: expr, ref: expr2 }) : ""`. The compiler
+ * drops handler and ref expressions from plain SSR output, so this is
+ * where a stand-in at one of those positions is seen. A server-local
+ * function there can never run (the server has no client to run it on);
+ * dev says so, inside the component barrier only.
+ */
+export function ssrClaim(map: Record): string;
+
+export function ssrClaim(map) {
+ const mode = sharedConfig.context && sharedConfig.context.claims;
+ if (!mode) return "";
+ let out = "";
+ for (const pos in map) {
+ const entries = claimEntries(pos, map[pos], mode);
+ if (entries) out += slotMarker(pos === "ref" ? "ref" : "on:" + pos, entries);
+ }
+ return out;
+}
+
+/**
+ * One handler/ref position's marker entries (`occ:key[,occ:key…]`, "" for
+ * none): a stand-in, or a list of values (several refs; a handler tuple)
+ * each read for its stand-in. A server-local function — the one shape that
+ * can never run — is a dev finding inside the component barrier (`mode` is
+ * the render context's claims enum: the stream face is always in scope, the
+ * document face asks `inServerComponentScope`).
+ */
+function claimEntries(pos, value, mode) {
+ if (Array.isArray(value)) {
+ // Nested lists flatten: a merged duplicate of an array ref is
+ // `[[a, b], c]`.
+ let entries = "";
+ for (const v of value) {
+ const inner = claimEntries(pos, v, mode);
+ if (inner) entries += (entries ? "," : "") + inner;
+ }
+ return entries;
+ }
+ if (isSlotValue(value)) {
+ slotMarkupRead(value, pos);
+ return slotEntry(value);
+ }
+ if ("_SOLID_DEV_" && typeof value === "function" && !value.$slotWarned && claimInScope(mode)) {
+ // Once per function: a live hole evaluates its expression more than
+ // once, and a row template hands the same handler to every row.
+ value.$slotWarned = true;
+ devCheck({
+ code: ATTRIBUTE_SLOT_POSITION,
+ kind: "ssr",
+ severity: "warn",
+ message:
+ `[${ATTRIBUTE_SLOT_POSITION}] A \`${pos}\` position on a server-rendered element received a server-local ` +
+ `function — it can never run. Bind an attribute slot's property there ` +
+ `(\`const row = props.row(args); ${pos === "ref" ? "ref" : "onX"}={row.${pos === "ref" ? "ref" : "onX"}}\`) ` +
+ `so the client supplies it, or bind a mutation to \`action=\`.`,
+ data: { reason: "server-local", position: pos }
+ });
+ }
+ return "";
+}
+
+function claimInScope(mode) {
+ return (
+ mode === CLAIMS_STREAM ||
+ (typeof inServerComponentScope === "function" && inServerComponentScope())
+ );
}
// ---
<\/div>/);
+ expect(findings()).toEqual([]);
+ });
+
+ it("a fill that returned markup is read as data at a position: a dev finding, nothing written", async () => {
+ const ServerComp = (props: any) => {
+ const row = props.row({ id: 1 });
+ return
');
+ expect(findings("markup").length).toBe(1);
+ });
+
+ it("fill keys that shadow prototype methods (`filter`, `at`, `sort`, `map`, `join`) bind as data on both faces", async () => {
+ // The document-face proxy's target is the range ARRAY and the stream
+ // face's a plain object: a key present on either prototype must still
+ // read as a slot value, never fall through to the prototype (which on
+ // the document face wrote `data-f="function filter() { [native code] }"`
+ // with no marker, and `hidden={row.at}` hid the element).
+ const ServerComp = (props: any) => {
+ const row = props.row({ id: 1 });
+ return (
+
'
+ );
+ expect(findings()).toEqual([]);
+ });
+
+ it("a placed document-face range with a dynamic node survives the resolver's copy", async () => {
+ // `escape` copies a node array with `.slice()` when it cannot join it
+ // (a function node forces the copy). The document-face range is a
+ // proxy over that array: `slice` must reach Array.prototype, not be
+ // answered as a fill key — with the explicit passthrough set it was,
+ // and every dynamic placement threw `s.slice is not a function`.
+ const ServerComp = (props: any) => {props.note()};
+ const Inline = frameTransformDirectResult(ServerComp, { id: "dsd0d" }) as any;
+ const html = plain(
+ await document(() =>
+ Inline({
+ note: () => {
+ const t = createMemo(() => "late");
+ return {t()};
+ }
+ })
+ )
+ );
+ expect(html).toMatch(/]*>late<\/b>/);
+ expect(findings()).toEqual([]);
+ });
+
+ it("a fill output key the range's own shape occupies is a dev finding", async () => {
+ const ServerComp = (props: any) => {
+ const row = props.row({ id: 1 });
+ return
x
;
+ };
+ const Inline = frameTransformDirectResult(ServerComp, { id: "dsd2" }) as any;
+ await document(() => Inline({ row: () => ({ removed: false, t: 1, $key: 2 }) }));
+ expect(findings("reserved-key").map(e => (e.data as any).key)).toEqual(["t", "$key"]);
+ });
+
+ it("a stand-in placed as text, stringified, or coerced renders NOTHING at t=0 too — the faces agree — with a dev finding each", async () => {
+ // The document face has the t=0 value and the stream face never does;
+ // rendering it at t=0 would make the first refetch change the page.
+ const ServerComp = (props: any) => {
+ const row = props.row({ id: 1 });
+ return (
+
+
{row.title}
+
+
3 ? "yes" : "no"} data-sum={row.count + 1} />
+
+ );
+ };
+ const Inline = frameTransformDirectResult(ServerComp, { id: "dsd3" }) as any;
+ const html = plain(
+ await document(() => Inline({ row: () => ({ title: "Hello ", count: 5 }) }))
+ );
+ expect(html).toContain("");
+ expect(html).toContain('');
+ // A comparison on a stand-in is `undefined`-shaped: `"" > 3` is false.
+ expect(html).toContain('');
+ expect(findings("text").length).toBe(1);
+ expect(findings("stringified").length).toBe(2);
+ expect(findings("coerced").map(e => (e.data as any).key)).toEqual(["count"]);
+ });
+});
diff --git a/packages/web/test/server/retry-robustness.spec.tsx b/packages/web/test/server/retry-robustness.spec.tsx
index a451823c6..e77f42ed0 100644
--- a/packages/web/test/server/retry-robustness.spec.tsx
+++ b/packages/web/test/server/retry-robustness.spec.tsx
@@ -65,6 +65,37 @@ describe("lazy() asset resolution across suspended passes", () => {
expect(passes).toBe(2);
expect(resolved).toEqual(["./Mod.tsx"]);
});
+
+ test("one cache per request across derived render contexts", async () => {
+ // Render contexts derive from the root by prototype (a Loading
+ // boundary's buffered context). When the FIRST lazy() on the page sat
+ // inside such a boundary, a cache created on demand landed as that
+ // derived context's own property, and a lazy() outside it afterwards
+ // started a second cache — and a second resolver call for the same
+ // module.
+ const resolved: string[] = [];
+ const manifest = (moduleUrl: string) => {
+ resolved.push(moduleUrl);
+ return Promise.resolve({ js: ["/assets/mod.js"], css: [] });
+ };
+ const Mod = () => mod-content;
+ const LazyMod = lazy(() => Promise.resolve({ default: Mod }), undefined, "./Mod.tsx");
+
+ const html = await renderComplete(
+ () => (
+
+ waiting}>
+
+
+
+
+ ),
+ { manifest }
+ );
+
+ expect(html.match(/mod-content/g)).toHaveLength(2);
+ expect(resolved).toEqual(["./Mod.tsx"]);
+ });
});
describe("retry wrapper flattening", () => {
diff --git a/packages/web/test/server/spread-slot-walk.bench.tsx b/packages/web/test/server/spread-slot-walk.bench.tsx
new file mode 100644
index 000000000..4039ea4ae
--- /dev/null
+++ b/packages/web/test/server/spread-slot-walk.bench.tsx
@@ -0,0 +1,69 @@
+// Tier-1 SSR-lane bench: the slot-aware spread walk (principles §9.2.3),
+// under `renderServerComponent` — the stream face, where `context.claims`
+// is armed for the whole render and `ssrElement`'s walk probes every
+// object value and every non-literal source for a stand-in. Two forms:
+//
+// bound — `
`: one occurrence per row,
+// read at a class-name, an attribute and a handler position. The
+// walk mints three markers per element and the sink one record
+// per occurrence — the per-row cost of an attribute slot.
+// armed — the same spread with plain string values: the gate is up (the
+// `context.claims` read per element) but no value is an object,
+// so every slot probe short-circuits. The control — what a
+// server component's ordinary elements pay for being inside one.
+//
+// `spread-static-tail` is the plain-SSR side of the same walk (the gate
+// down); the two lanes together cover both branches of `slots`.
+//
+// Vitest's reported mean is the full stream — render, records, html chunk.
+
+/**
+ * @jsxImportSource @solidjs/web
+ */
+import { bench } from "vitest";
+import { renderServerComponent } from "../../frames/src/frame-sink.js";
+
+const ROWS = 500;
+const rows = Array.from({ length: ROWS }, (_, i) => ({
+ id: i,
+ title: `Row ${i}`,
+ kind: i % 2 ? "odd" : "even"
+}));
+
+const Bound = (props: any) => (
+
+);
+
+bench(`spread-slot-walk: ${ROWS} rows (renderServerComponent): bound`, async () => {
+ await renderServerComponent(Bound, { frame: { id: "slot-walk-bound" } });
+});
+
+bench(`spread-slot-walk: ${ROWS} rows (renderServerComponent): armed`, async () => {
+ await renderServerComponent(Armed, { frame: { id: "slot-walk-armed" } });
+});
diff --git a/packages/web/test/server/tree-rewrite.spec.tsx b/packages/web/test/server/tree-rewrite.spec.tsx
new file mode 100644
index 000000000..57667032d
--- /dev/null
+++ b/packages/web/test/server/tree-rewrite.spec.tsx
@@ -0,0 +1,165 @@
+// The serialization-border walk (`toBorderForm`, the slot-arg stand-in
+// scrub): copy-on-write on acyclic data with no per-node bookkeeping, a
+// memoized clone once a back-reference is met — with the first pass's
+// replacements reused, so a visitor's side effects (a multicast seat, a
+// trace envelope) happen once per node either way.
+import { describe, expect, it } from "vitest";
+import { DESCEND, rewriteTree } from "../../frames/src/tree-rewrite.js";
+
+const SWAP = Symbol("swap");
+type Swappable = { [SWAP]: true; id: string };
+const swappable = (id: string): Swappable => ({ [SWAP]: true, id });
+
+/** A visitor that replaces `swappable` leaves with a fresh seat and counts. */
+function seats() {
+ const taken: string[] = [];
+ const visit = (v: any) => {
+ if (v[SWAP] === true) {
+ taken.push(v.id);
+ return { seat: v.id };
+ }
+ return DESCEND;
+ };
+ return { taken, visit };
+}
+
+describe("rewriteTree", () => {
+ it("acyclic: untouched subtrees pass by reference, changed paths are copied, the author value is not mutated", () => {
+ const { taken, visit } = seats();
+ const keep = { k: 1, deep: [1, { z: 2 }] };
+ const src = { keep, list: [swappable("a"), "b"], own: "x" };
+ const out = rewriteTree(src, visit, false);
+ expect(out).not.toBe(src);
+ expect(out.keep).toBe(keep);
+ expect(out.list).toEqual([{ seat: "a" }, "b"]);
+ expect(out.own).toBe("x");
+ expect(src.list[0]).toEqual(swappable("a"));
+ expect(taken).toEqual(["a"]);
+ });
+
+ it("one value at two paths is one replacement: the output keeps the input's identity", () => {
+ // `{ a: gen, b: gen }` is a graph with ONE async iterable; its border form
+ // has one seat, referenced twice, and the serializer writes it once — so
+ // the client materializes one iterable too, exactly as one generator at
+ // two props behaves in the author's own code (readers of one generator
+ // share its yields). Two seats would be a faithful copy of nothing.
+ const { taken, visit } = seats();
+ const gen = swappable("g");
+ const out = rewriteTree({ a: gen, b: [gen], c: { d: gen } }, visit, false);
+ expect(out.a).toEqual({ seat: "g" });
+ expect(out.b[0]).toBe(out.a);
+ expect(out.c.d).toBe(out.a);
+ expect(taken).toEqual(["g"]);
+ });
+
+ it("acyclic with nothing to swap returns the value itself", () => {
+ const { visit } = seats();
+ const src = { a: [1, 2, { b: "c" }], d: null };
+ expect(rewriteTree(src, visit, false)).toBe(src);
+ });
+
+ it("paths are built only when asked, `.key` and `[i]`; primitives are never visited", () => {
+ const paths: (string | undefined)[] = [];
+ let visited = 0;
+ const visit = (v: any, path: string | undefined) => {
+ visited++;
+ paths.push(path);
+ return DESCEND;
+ };
+ rewriteTree({ a: [1, { b: 2 }], c: "s" }, visit, true);
+ expect(paths).toEqual(["", ".a", ".a[1]"]);
+ expect(visited).toBe(3);
+ paths.length = 0;
+ rewriteTree({ a: [1] }, visit, false);
+ expect(paths).toEqual([undefined, undefined]);
+ expect(rewriteTree(1, visit, false)).toBe(1);
+ expect(rewriteTree(null, visit, false)).toBe(null);
+ });
+
+ it("a cycle: the copy's back-reference points at the copy, and every replacement happens once", () => {
+ const { taken, visit } = seats();
+ // The seat sits BEFORE the back-reference in key order: the first pass
+ // takes it, then meets the cycle and aborts; the clone pass must reuse
+ // it rather than take a second seat (a generator shared twice never
+ // closes).
+ const src: any = { first: swappable("g"), tag: "m" };
+ src.self = src;
+ src.after = swappable("h");
+ const out = rewriteTree(src, visit, false);
+ expect(out).not.toBe(src);
+ expect(out.self).toBe(out);
+ expect(out.first).toEqual({ seat: "g" });
+ expect(out.after).toEqual({ seat: "h" });
+ expect(out.tag).toBe("m");
+ expect(taken).toEqual(["g", "h"]);
+ // The original is intact.
+ expect(src.self).toBe(src);
+ expect(src.first).toEqual(swappable("g"));
+ });
+
+ it("a cycle through an array, and a shared acyclic subtree beside it", () => {
+ const { taken, visit } = seats();
+ const shared = { s: swappable("s"), keep: 1 };
+ const src: any = { list: [shared, shared], pair: [] };
+ src.pair.push(src, shared);
+ const out = rewriteTree(src, visit, false);
+ expect(out.pair[0]).toBe(out);
+ // One copy of the shared subtree, referenced from every occurrence.
+ expect(out.list[0]).toBe(out.list[1]);
+ expect(out.pair[1]).toBe(out.list[0]);
+ expect(out.list[0]).toEqual({ s: { seat: "s" }, keep: 1 });
+ expect(taken).toEqual(["s"]);
+ });
+
+ it("a cycle with nothing to swap still comes back as a cycle of copies, not a stack overflow", () => {
+ const { taken, visit } = seats();
+ const src: any = { a: 1 };
+ src.me = src;
+ const out = rewriteTree(src, visit, false);
+ expect(out.a).toBe(1);
+ expect(out.me).toBe(out);
+ expect(taken).toEqual([]);
+ });
+
+ it("exotic values are not walked, on either pass", () => {
+ const { taken, visit } = seats();
+ class Box {
+ constructor(public inner = swappable("boxed")) {}
+ }
+ const map = new Map([["k", swappable("mapped")]]);
+ const src: any = { box: new Box(), map, set: new Set([swappable("set")]) };
+ expect(rewriteTree(src, visit, false)).toBe(src);
+ src.self = src;
+ const out = rewriteTree(src, visit, false);
+ expect(out.box).toBe(src.box);
+ expect(out.map).toBe(map);
+ expect(out.self).toBe(out);
+ expect(taken).toEqual([]);
+ });
+
+ it("very deep acyclic data is rewritten correctly (ancestors tracked past the threshold, no false cycle)", () => {
+ const { taken, visit } = seats();
+ let leaf: any = { end: swappable("deep") };
+ const bottom = leaf;
+ for (let i = 0; i < 300; i++) leaf = { next: leaf };
+ const out = rewriteTree(leaf, visit, false);
+ let cursor = out;
+ for (let i = 0; i < 300; i++) cursor = cursor.next;
+ expect(cursor).toEqual({ end: { seat: "deep" } });
+ expect(bottom.end).toEqual(swappable("deep"));
+ expect(taken).toEqual(["deep"]);
+ });
+
+ it("a visitor's own error propagates", () => {
+ const boom = new Error("boom");
+ expect(() =>
+ rewriteTree(
+ { a: 1 },
+ () => {
+ throw boom;
+ },
+ false
+ )
+ ).toThrow(boom);
+ });
+});
diff --git a/packages/web/vite.config.server.mjs b/packages/web/vite.config.server.mjs
index 173cffb00..a66829153 100644
--- a/packages/web/vite.config.server.mjs
+++ b/packages/web/vite.config.server.mjs
@@ -14,10 +14,13 @@ export default defineConfig({
// `sourceNames`: what the vite plugin's dev/observe postures pass, so
// compiled `` reaches the server `createComponent` with its label
// and diagnostics carry `ownerPath` (server-diagnostics.spec.tsx pins it).
+ // `serverComponents`: what the plugin passes for an SSR build with
+ // `serverFunctions.components` — attribute-slot positions (`ref`/`on*`, dynamic
+ // `class`/`style`) compile to runtime holes (test/server/frame-attribute-slots).
plugins: [
solidPlugin({
compiler,
- solid: { generate: "ssr", hydratable: true, sourceNames: true }
+ solid: { generate: "ssr", hydratable: true, sourceNames: true, serverComponents: true }
})
],
test: {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4d9375091..f2bd3cd75 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -331,6 +331,25 @@ importers:
specifier: ^7.0.0
version: 7.3.3(@types/node@25.6.0)(lightningcss@1.33.0)(terser@5.49.0)
+ examples/todos-server:
+ dependencies:
+ '@solidjs/web':
+ specifier: workspace:*
+ version: link:../../packages/web
+ solid-js:
+ specifier: workspace:*
+ version: link:../../packages/solid
+ unstorage:
+ specifier: ^1.17.5
+ version: 1.17.5
+ devDependencies:
+ '@solidjs/vite-plugin':
+ specifier: 3.0.0-next.35
+ version: 3.0.0-next.35(@solidjs/web@packages+web)(@testing-library/jest-dom@6.9.1)(solid-js@packages+solid)(vite@8.1.5(@types/node@25.6.0)(esbuild@0.27.7)(terser@5.49.0))
+ vite:
+ specifier: ^8.0.0
+ version: 8.1.5(@types/node@25.6.0)(esbuild@0.27.7)(terser@5.49.0)
+
packages/babel-plugin:
dependencies:
'@babel/helper-module-imports':
@@ -3512,10 +3531,6 @@ packages:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
engines: {node: '>=8.6'}
- picomatch@4.0.3:
- resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
- engines: {node: '>=12'}
-
picomatch@4.0.4:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
@@ -5915,7 +5930,7 @@ snapshots:
dependencies:
'@types/estree': 1.0.8
estree-walker: 2.0.2
- picomatch: 4.0.3
+ picomatch: 4.0.5
optionalDependencies:
rollup: 4.59.0
@@ -7142,8 +7157,6 @@ snapshots:
picomatch@2.3.1: {}
- picomatch@4.0.3: {}
-
picomatch@4.0.4: {}
picomatch@4.0.5: {}
@@ -7456,8 +7469,8 @@ snapshots:
tinyglobby@0.2.16:
dependencies:
- fdir: 6.5.0(picomatch@4.0.4)
- picomatch: 4.0.4
+ fdir: 6.5.0(picomatch@4.0.5)
+ picomatch: 4.0.5
tinyglobby@0.2.17:
dependencies:
diff --git a/scripts/size/floor-caps.json b/scripts/size/floor-caps.json
index 8a68bc927..bd2c40a4f 100644
--- a/scripts/size/floor-caps.json
+++ b/scripts/size/floor-caps.json
@@ -2,6 +2,6 @@
"signals: core floor (createSignal/Memo/Effect/Root/flush)": "9.51 KB",
"app: render + one signal (the simple-app floor)": "12.05 KB",
"app: hydrating (no stores) with Show/For/Loading/Errored/lazy": "19.69 KB",
- "page: base server components (hydrating + dynamic + frames + sf reference)": "44.93 KB",
- "page: live server components (base + live/GET + action + isPending/latest)": "49.10 KB"
+ "page: base server components (hydrating + dynamic + frames + sf reference)": "45.77 KB",
+ "page: live server components (base + live/GET + action + isPending/latest)": "49.96 KB"
}
diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js
index da6e9d685..b03d6ce52 100644
--- a/scripts/size/scenarios.js
+++ b/scripts/size/scenarios.js
@@ -2839,7 +2839,35 @@ module.exports = [
// rounded up to the next 0.01 kB. Deltas across this line are not comparable.
// Rebased onto `next` @ ee49b3eee (2026-09-28): 11768 -> 11768 B, unchanged
// by the three signals fixes that landed since (#3678, #3684, #3682); cap stays.
- limit: "11.77 KB",
+ //
+ // Attribute slots, second form (#3704, 2026-09-28): 11.77 ->
+ // 12.70 KB, measured at 12,693 B against `next` @ 695836771's 11,768
+ // (+925 B; 923 B over the cap). +3,259 B minified: frames client +3,225
+ // (32,610 -> 35,835), the retained transport slice +34. The frames bytes
+ // are the feature replacing behavior claims: `_s:` markers
+ // parsed per element (`slotPositions`, `ownedPositions`), a data
+ // occurrence's consumer set bound per position with a render effect per
+ // element (`bindDataOccurrence`, `consumersEqual`) — value positions
+ // diffed through `assign` with released positions pruned from the diff
+ // state, one listener per element fanning out to every key bound at an
+ // event, one stable ref per key set, a final empty write for an element
+ // the rebind dropped — the owned positions held through every morph
+ // path (`applyOwned` + the class/style splitters, in `morphAttributes`
+ // and `#applyAttrs`), and `#syncSlots` resolving fills by occurrence and
+ // args records by id. The `_bnd` claim path it retires (`bndMap`,
+ // `sweepBound`, `fireRefs`, `claimFn`, `clientProp`, ~1.8 KB
+ // unminified) is the offset already in the number. Dev-only (the
+ // `ATTRIBUTE_SLOT_POSITION` orphan and fill-shape reporters) is 0 B
+ // here: module functions behind the flag, not methods. Conscious bump: a
+ // consumer that never binds a slot still carries the position parser
+ // and the owned-attribute morph; a split behind the marker is the
+ // candidate follow-up, like the live reader's split behind the wire slot.
+ // Review fixes (#3704, same day): 12,693 -> 12,695 B (+2 B; +35 B
+ // minified, frames client 35,835 -> 35,870): an occurrence's `onCleanup`
+ // detaching the listeners it attached (a kept un-keyed element carried
+ // one per occurrence that ever bound it and fired twice; a dropped
+ // occurrence's handler ran through its disposed fill). Cap unchanged.
+ limit: "12.70 KB",
alias: framesAlias,
external: framesExternal
},
@@ -2908,6 +2936,16 @@ module.exports = [
// -3 B minified). The same parking-gate change as the hydrating (no
// stores) note; brotli layout turns the -3 B minified into a saving on
// this page. Not ratcheted.
+ // Size-Exception (#3704, 2026-09-28): 44.93 -> 45.76 KB,
+ // measured at 45,756 B against `next` @ 695836771's 44,865 (+891 B;
+ // +3,165 B minified, all of it the frames client) — attribute slots,
+ // second form: the per-position binding of a slot's props on server
+ // elements, held through the morph paths, replacing the `_bnd` claim
+ // sweep (the frames scenario note itemizes it). Accepted by the
+ // maintainer. Review fixes, same PR: 45,756 -> 45,761 B (+5 B; +35 B
+ // minified — the occurrence's listener detach, frames note), over the
+ // rounded cap by 1 B; 45.76 -> 45.77 KB under the same exception. The
+ // cap is frozen again at 45.77 KB.
limit: floorCaps["page: base server components (hydrating + dynamic + frames + sf reference)"],
alias: pageAlias
},
@@ -2959,6 +2997,12 @@ module.exports = [
// and the CSR app +5 B on the identical -3 B minified core; brotli layout
// amplifies it here as #3684's and #3675's did. Accepted by the
// maintainer. The cap is frozen again at 49.10 KB.
+ // Size-Exception (#3704, 2026-09-28): 49.10 -> 49.96 KB,
+ // measured at 49,951 B against `next` @ 695836771's 49,094 (+857 B;
+ // +3,165 B minified) — the same attribute-slot bytes as the base page;
+ // the live path adds nothing of its own. Accepted by the maintainer. The
+ // cap is frozen again at 49.96 KB. Review fixes, same PR: 49,951 ->
+ // 49,901 B (-50 B; +35 B minified — brotli layout), under the cap.
limit: floorCaps["page: live server components (base + live/GET + action + isPending/latest)"],
alias: pageAlias
}