Skip to content

Commit ad1ecc5

Browse files
ryansolidClaude via Cursor
andauthored
Stage 7: attribute slots, second form — one slot per data context, bound per position (#3704)
Retires the first form (one slot per element, its return spread onto that element) and Stage 6's `_bnd` behavior claims in favor of §9.2.3's second form: a slot call returns a proxy whose reads yield `SLOT_VALUE` stand-ins, every server attribute helper recognizes the brand and emits an `_s:<position>="<occurrence>:<key>"` marker beside the t=0 value (document face) or beside nothing (stream face), and the client binds one memo and one render effect per occurrence, writing positions by marker. - Both compilers, under `serverComponents`, route dynamic `class`/`style` through a whole-attribute hole and pass spread-element `ref`/`on*` claims to `ssrElement` (new optional seventh `claims` argument); plain SSR output is unchanged. - Spread-element handler positions settle in source order and by key presence, matching the client's `spread`/`merge()`: a later source's key, `undefined` included, owns the position; duplicate named handlers keep the last; refs merge. - An occurrence detaches its listeners on dispose; released positions keep server-owned values; stand-ins passed as slot args are scrubbed (plain objects and arrays) with an `arg` finding. - The document-face border walk is cycle-safe with side effects run once, and keeps an acyclic fast path. - `$key` on spread intrinsics (native compiler); `dynamic`'s address signal is `ownedWrite`; a showing call's refetch resolves after apply. Size-Exception: frames eager client +927 B, page base +896 B, page live +807 B (frozen caps raised per the ledger); client apps −10..−77 B. Co-authored-by: Claude via Cursor <noreply@cursor.com>
1 parent 644eaf3 commit ad1ecc5

84 files changed

Lines changed: 8721 additions & 940 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@solidjs/babel-plugin": patch
3+
"@solidjs/compiler": patch
4+
---
5+
6+
`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*` (`<button {...rest} onClick={row.go} ref={row.el} />`, 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 (`<b {...a} onClick={go} {...b}>` → `{ 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.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solidjs/compiler": patch
3+
---
4+
5+
Native compiler: `$key` on an intrinsic element with a spread (`<li $key={id} class="todo" {...attrs}>`) 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.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solidjs/web": patch
3+
---
4+
5+
`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.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@solidjs/web": patch
3+
"@solidjs/signals": patch
4+
---
5+
6+
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 `<props.x />`, 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.
7+
8+
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 (`<button {...rest} onClick={row.go} />`) 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 (`<b {...rest} onClick={cond ? row.go : undefined}>` 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.
9+
10+
Surface added: `AttributeSlot<P, J>` 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.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solidjs/web": patch
3+
---
4+
5+
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.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@solidjs/web": patch
3+
"@solidjs/h": patch
4+
---
5+
6+
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.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solidjs/web": patch
3+
---
4+
5+
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%).

‎documentation/plans/stage8-connection-transport.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -810,4 +810,4 @@ prev)`; corrected to `(prev, value)`. Pinned:
810810
Cursors as a protocol (only the `Last-Event-ID` seam), WebSocket, any
811811
subscription registry or connection-local subscription state, any new
812812
authoring API for liveness, any server configuration for liveness, Stage 7's
813-
predictions (independent; §9.2.1).
813+
optimism (independent; attribute slots, §9.2.3 — `predict` retired).

0 commit comments

Comments
 (0)