From 1805273a2a0456483cf48305e1d19d4c18b9475b Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 29 Sep 2026 14:32:24 -0700 Subject: [PATCH 1/2] web: binding-slot fills run once, bound as client JSX binds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A binding slot's fill ran inside `createMemo(() => fill(args))`: a top-level read tracked, so an eager fill re-ran on every change and disposed whatever its body created with it. The template fill ran once, untracked, under its occurrence's owner. Same slot border, two execution models. Now both run as a component body does. The binding fill runs once per occurrence, `untrack(fn, label)` under the occurrence's owner with live args: state it creates lives with the occurrence, a top-level read is a one-time read that dev names (`STRICT_READ_UNTRACKED`, "the `row` binding-slot fill"), and getters are the reactive form. One render effect per occurrence still writes the value positions through `assign`'s diff. Handlers and refs are read once when an element binds and handed to `assign`, so events delegate, tuples bind and interactions wrap as in client JSX; the frames-own listener goes, and only merged refs keep a fan-out dispatcher. Template fills run under the same labelled untrack on every path — a live render (reveal content, a non-adopted mount) used to call them inside the ambient computation. On the server, `claimEntries` flattens arrays at `ref` only; an array at a handler position binds nothing and is a dev finding (reason `tuple`) — the tuple belongs in the fill. Renames, no aliases: `AttributeSlot` -> `BindingSlot`, whose return is now `SlotOutput` (a branded `SlotError` for an array, DOM node, function, async value or `$` key, failing on both sides), and the diagnostic code `ATTRIBUTE_SLOT_POSITION` -> `BINDING_SLOT_POSITION`. The specs, principles (§9.2.4), both skills and 08-dev-diagnostics move to the vocabulary. todos-server's `filters` fill was eager and is getters now. Size-Exception: the live server components page re-based 50.09 -> 50.15 KB (+56 B brotli, -5 B minified; brotli layout). Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- .changeset/binding-slot-execution.md | 9 + documentation/plans/binding-slot-execution.md | 15 +- documentation/plans/text-positions.md | 2 +- .../server-components-principles.md | 113 ++++++- documentation/solid-2.0/08-dev-diagnostics.md | 32 +- examples/chat/src/app.css | 2 +- examples/chat/src/app.tsx | 2 +- examples/chat/src/lib/ai.tsx | 12 +- examples/chat/src/lib/model.ts | 2 +- examples/notes/src/app.tsx | 2 +- examples/notes/src/components/searchField.ts | 10 +- examples/notes/src/server/App.tsx | 6 +- examples/todos-server/README.md | 22 +- examples/todos-server/package.json | 2 +- examples/todos-server/src/app.tsx | 25 +- examples/todos-server/src/server/todos.tsx | 21 +- examples/todos-server/src/todo-row.tsx | 2 +- packages/h/jsx-runtime/src/jsx.d.ts | 2 +- packages/signals/src/core/dev.ts | 2 +- .../skills/reactivity-diagnostics/SKILL.md | 7 +- packages/web/frames/src/client.ts | 293 +++++++++--------- packages/web/frames/src/frame-client.ts | 22 +- packages/web/frames/src/frame-sink.ts | 12 +- packages/web/frames/src/server.ts | 48 ++- packages/web/jsx/jsx-h.d.ts | 2 +- packages/web/jsx/jsx.d.ts | 2 +- .../web/skills/server-components/SKILL.md | 88 +++--- packages/web/src/server.ts | 77 +++-- packages/web/test/binding-slot.type-tests.ts | 77 +++++ ...spec.tsx => frames-binding-slots.spec.tsx} | 219 +++++++++++-- packages/web/test/frames-client.spec.tsx | 37 +++ ...pec.tsx => binding-slot-adoption.spec.tsx} | 23 +- ....spec.tsx => frame-binding-slots.spec.tsx} | 56 +++- .../test/server/spread-slot-walk.bench.tsx | 2 +- packages/web/vite.config.server.mjs | 4 +- scripts/size/floor-caps.json | 2 +- scripts/size/scenarios.js | 8 +- 37 files changed, 886 insertions(+), 376 deletions(-) create mode 100644 .changeset/binding-slot-execution.md create mode 100644 packages/web/test/binding-slot.type-tests.ts rename packages/web/test/{frames-attribute-slots.spec.tsx => frames-binding-slots.spec.tsx} (82%) rename packages/web/test/hydration/{attribute-slot-adoption.spec.tsx => binding-slot-adoption.spec.tsx} (86%) rename packages/web/test/server/{frame-attribute-slots.spec.tsx => frame-binding-slots.spec.tsx} (95%) diff --git a/.changeset/binding-slot-execution.md b/.changeset/binding-slot-execution.md new file mode 100644 index 000000000..ef574ca89 --- /dev/null +++ b/.changeset/binding-slot-execution.md @@ -0,0 +1,9 @@ +--- +"@solidjs/web": patch +"@solidjs/signals": patch +"@solidjs/h": patch +--- + +Binding slots: the fill runs once per occurrence, untracked, under the occurrence's owner — as a component body and a template-slot fill do. State created in the fill lives as long as the occurrence; a top-level read is a one-time read (dev: `STRICT_READ_UNTRACKED`, naming the fill); getters are the reactive form. Handlers and refs are read once when an element binds and go through `assign`, so events delegate, tuples bind and interactions wrap as in client JSX. On the server an array at a handler position is a dev finding (reason `tuple`) instead of being flattened; only `ref` merges arrays. Template-slot fills are untracked on every render path and carry the same labelled warning. + +Breaking: `AttributeSlot` is renamed `BindingSlot`, with no alias, and its return is constrained (`SlotOutput` / `SlotError`, both exported) so an array, DOM node, function, async value or `$`-prefixed key is a type error on both sides. The diagnostic code `ATTRIBUTE_SLOT_POSITION` is renamed `BINDING_SLOT_POSITION`. The fill-shape finding also names async values. diff --git a/documentation/plans/binding-slot-execution.md b/documentation/plans/binding-slot-execution.md index f2873d9fd..0fe843a2d 100644 --- a/documentation/plans/binding-slot-execution.md +++ b/documentation/plans/binding-slot-execution.md @@ -1,8 +1,8 @@ # Binding-slot execution -Status: design for review, 2026-09-29. Gap G2 in -[`examples-grid-plan.md`](./examples-grid-plan.md). Decisions recorded; -ready for implementation review. +Status: implemented 2026-09-29 (principles §9.2.4). Gap G2 in +[`examples-grid-plan.md`](./examples-grid-plan.md). Two checks below +were corrected by the implementation; each says so in place. A server component hands the client one of two slot kinds. A **template slot** (`Slot`) is placed and filled with markup. A **binding slot** @@ -129,7 +129,10 @@ one source reading as noise. - **The template fill runs untracked.** Yes: `runWithOwner(fillOwner, …)` at line 726; `runWithOwner` clears `tracking` with the owner (the - comment at line 754 relies on it). + comment at line 754 relies on it). *Corrected in implementation:* only + the streamed invocation; a live render (reveal-boundary content, a + non-adopted mount) called the fill inside the ambient tracked + computation. The `untrack(fn, label)` wrap covers both. - **The dev signal exists.** 2.0's `STRICT_READ_UNTRACKED` (`08-dev-diagnostics.md:303`) fires for untracked reads in a scope entered with `untrack(fn, label)` (`packages/signals/src/core/core.ts:1601`); @@ -147,7 +150,9 @@ one source reading as noise. - **What relies on the memo re-running.** The examples do not: `todos-server`'s `rowFor` and `notes`' `searchField` return getters over client state and handlers that read lazily; `chat`'s `codeBlock` returns one static - handler. One spec does: the first case in + handler. *Corrected in implementation:* `todos-server`'s `filters` + fill was eager (`{ all: props.filter === "all", … }`) and relied on + the re-run; it is getters now. One spec does: the first case in `packages/web/test/frames-attribute-slots.spec.tsx` (line 103) computes `done` and `removed` eagerly from a signal and live args and asserts re-runs (`runs`). It is rewritten to getters, and its assertions become diff --git a/documentation/plans/text-positions.md b/documentation/plans/text-positions.md index ae7947484..056418243 100644 --- a/documentation/plans/text-positions.md +++ b/documentation/plans/text-positions.md @@ -85,7 +85,7 @@ for the toggle's label; `todos-server`'s count wants it. hole already reaches the resolver with the stand-in. - Occurrence, key and encoding reuse `slotEntry`/`encodeSlotKey` (`server.ts:4794–4805`); the client decodes as `slotPositions` does. -- Existing server spec to rewrite: `frame-attribute-slots.spec.tsx:963` +- Existing server spec to rewrite: `frame-binding-slots.spec.tsx:986` ("a stand-in placed as text … renders NOTHING at t=0 too"). Its stringified and coerced cases stay findings. diff --git a/documentation/server-components/server-components-principles.md b/documentation/server-components/server-components-principles.md index 536bca8cc..ed3e93aa0 100644 --- a/documentation/server-components/server-components-principles.md +++ b/documentation/server-components/server-components-principles.md @@ -2446,9 +2446,9 @@ 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 +(`test/server/frame-binding-slots.spec.tsx`, +`test/frames-binding-slots.spec.tsx`, +`test/hydration/binding-slot-adoption.spec.tsx`). Where the build departed from the text above, the build is right and the text is amended here: @@ -3038,9 +3038,9 @@ the finding. **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 +(`test/server/frame-binding-slots.spec.tsx`, both faces; +`test/frames-binding-slots.spec.tsx`, the client binding; +`test/hydration/binding-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 @@ -3247,10 +3247,10 @@ Findings from the port, none of them slot mechanics: 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 + reveals, all bind (`test/frames-binding-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 + (`test/server/frame-binding-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 @@ -3277,6 +3277,100 @@ client-created entities without client markup; actions against pending ids as a general concern (the port's sequencing is an example's answer). +#### 9.2.4 Amendment — binding slots: the fill runs once (2026-09-29) + +Design: `documentation/plans/binding-slot-execution.md`. Two slot +kinds, named for what the server does with them: a **template +slot** (`Slot`) is placed and the client fills it with +markup; a **binding slot** (`BindingSlot`, until +now `AttributeSlot`) is called, and the server binds the returned +object's properties at positions — attributes, class names, style +properties, handlers, refs — and never branches or computes on +them (a stand-in is always truthy). No alias for the old name. + +**Why.** 9.2.3 ran the binding fill in `createMemo(() => +fill(args))`: a top-level read in the body tracked, so an eager +fill re-ran on every change and disposed whatever its body had +created — a signal, a memo, an `onCleanup` — with it. The template +fill ran once, untracked, under its occurrence's owner. Same slot +border, two execution models; the binding fill that `hackernews` +wants (a signal in the body, getters over it, a handler) worked +only because its memo happened to track nothing. + +**The model.** + +- *One call, one scope.* The fill runs once per occurrence, + `untrack(() => fill(args), label)`, under the occurrence's owner, + with live args — as a template fill and a component body run. + State created in the body lives as long as the occurrence. A + top-level read is a one-time read, and dev names it + (`STRICT_READ_UNTRACKED`, "the \`row\` binding-slot fill"); getters + are the reactive form, as on a component's props object. +- *An object only.* Arrays, DOM nodes, functions and async values + are the `fill-shape` finding at runtime (async values newly + named) and a `SlotError` at the type level: + `BindingSlot`'s return is `SlotOutput`, which is `J` for a + plain object without a `$`-prefixed key and a branded error + otherwise, so the mistake fails both where the client passes its + fill and where the server reads the slot. +- *One render effect per occurrence* for the value positions, + unchanged: a getter's change re-reads the occurrence's positions + and `assign` writes only the one that moved. Per element was + considered and dropped — more effects to save getter re-reads. +- *Handlers and refs bind once, through `assign`.* Read once, + untracked, when an element binds, and handed to + `assign`/`assignProp` as client JSX hands them: delegated for the + events it delegates (so a handler's `stopPropagation()` no longer + stops a native ancestor listener, as in client JSX), tuples, + `dispatchAsInteraction`. The frames-own listener and its + dispatch-time read of the current output are gone. A handler + position names one key (last wins, as the server already + emits); several keys at one `ref` position still fan out through + a stable dispatcher. Delegated slots are released only by the + occurrence that set them, so an element moving between + occurrences keeps the incoming handler. +- *A server-side handler tuple is a finding.* `claimEntries` + flattened an array at every position, so + `onKeyDown={[row.key, 1]}` dropped `1` silently and + `[row.key, row.data]` emitted `data` as a second handler. Arrays + now flatten at `ref` only (merged refs); at a handler position + the array binds nothing and raises `BINDING_SLOT_POSITION`, + reason `tuple` — the tuple belongs in the fill, which + `assignProp` binds. +- *Template fills get the same label.* They run under + `untrack(fn, "the \`comment\` template-slot fill")`. Found on the + way: only a streamed invocation was untracked before (inside + `runWithOwner(fillOwner, …)`); a live render — reveal-boundary + content, a non-adopted mount — called the fill inside the + ambient tracked computation. Both paths are untracked now. +- *Names.* The diagnostic code is `BINDING_SLOT_POSITION` + (was `ATTRIBUTE_SLOT_POSITION`); the specs are + `test/server/frame-binding-slots.spec.tsx`, + `test/frames-binding-slots.spec.tsx` and + `test/hydration/binding-slot-adoption.spec.tsx`. The wire is + untouched. + +This supersedes, in 9.2.3: the `createMemo` mount and the +dispatch-time handler read ("Binding: values in the compute +phase"), the per-(element, event) listener that fans out to every +key ("What folds in"), and "a live-delivered arg change re-runs +the fill" — an arg change now moves the getters that read it. +Deferred: a fill returning an accessor (one effect over a whole +object); it is an error today, so adding it later is not breaking. + +**Findings.** + +- *One example fill was eager.* `todos-server`'s `filters` built + `{ all: props.filter === "all", … }` in the body and relied on the + memo re-running; under run-once it would have frozen at the + first filter. It is getters now, like its siblings `rowFor` and + `listFor`. The design's pre-check had cleared the examples; it + missed this one. +- *A spurious dev warning is gone.* The ref dispatcher read the + memo's output inside `assign`'s effect callback, which raised + `STRICT_READ_UNTRACKED` ("an effect callback") for any fill with + a ref; refs are read once, untracked, at bind now. + ### 9.3 Stage 8 seed — connection-shaped transport (2026-08-17) Recorded from the design conversation; nothing here is built (the @@ -4146,8 +4240,7 @@ that principle decides where one ELEMENT lives; this section decides whether a COMPONENT should be a server component at all. Slot names here predate the 2026-09-29 rename: a *markup slot* is a **template slot** (`Slot`) and an *attribute slot* a **binding -slot** (`BindingSlot`; the code still says `AttributeSlot` until -`documentation/plans/binding-slot-execution.md` lands). +slot** (`BindingSlot`, renamed in the code by §9.2.4). ### 10.1 Three axes, not one diff --git a/documentation/solid-2.0/08-dev-diagnostics.md b/documentation/solid-2.0/08-dev-diagnostics.md index c12661eea..b9b4a0808 100644 --- a/documentation/solid-2.0/08-dev-diagnostics.md +++ b/documentation/solid-2.0/08-dev-diagnostics.md @@ -327,6 +327,8 @@ function AlsoGood(props) { This also fires for store property access in the same contexts. +A server component's slot fill runs as a component body does — once, untracked — so a top-level read in it fires this too, with the context naming the fill ("the \`row\` binding-slot fill", "the \`comment\` template-slot fill"). In a binding-slot fill the reactive form is a getter on the returned object (server-components-principles.md §9.2.4). + #### `UNTRACKED_READ_AFTER_AWAIT` **Message:** "[name] was first read after an `await` in an async computation, so it is not a dependency: the computation will not re-run when it changes. Read it before the first `await`, or wrap the read in untrack() if a one-time value is intended." @@ -697,22 +699,24 @@ 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. -#### `ATTRIBUTE_SLOT_POSITION` +#### `BINDING_SLOT_POSITION` **Messages:** -- "[ATTRIBUTE_SLOT_POSITION] A slot (`row`) is spread onto a server-rendered
  • . An attribute slot binds by position — name each one (`class={row.rowClass} onClick={row.remove}`) so the template shows what the client owns." -- "[ATTRIBUTE_SLOT_POSITION] `row`'s `done` 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.done}`, not `class={`x ${row.done}`}`). If it is, the element was compiled without the `serverComponents` compiler option. Nothing renders here on either face." -- "[ATTRIBUTE_SLOT_POSITION] `row`'s `count` 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] An attribute-slot value (`done` of `row`) reached `class` 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." -- "[ATTRIBUTE_SLOT_POSITION] `title` of slot `row` 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." -- "[ATTRIBUTE_SLOT_POSITION] `class` reads `done` off slot `row`, but the client fill returned markup, not an object. A slot renders one or the other: return an object (`{ done: … }`) for positions, or place the slot as content." -- "[ATTRIBUTE_SLOT_POSITION] A `click` 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); onX={row.onX}`) so the client supplies it, or bind a mutation to `action=`." -- "[ATTRIBUTE_SLOT_POSITION] The fill for `row#1` returned a key named `t`, which is reserved (keys beginning with `$`, the node keys `t`/`h`/`p`/`then`, and Object/Array prototype member names): the server reads it as the slot's range, not as a value. Rename it." -- "[ATTRIBUTE_SLOT_POSITION] Server markup binds `row#9` at 2 elements (positions: hidden, checked), but no client fill resolves for slot `row` — those positions never bind and the elements are inert. Pass `row` 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 `row#9` at 2 elements (positions: hidden, checked), 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." - -Check (dev only; server components — server-components-principles.md §9.2.3). An attribute slot's property (`const row = props.row(args); row.done`) is a stand-in the server template binds at a **position** — the whole value of an attribute, a class name's condition or style property's value inside `class`/`style` object form, a handler, a ref — and the position's marker (`_s:hidden`, `_s:class="row#1:done=completed"`, `_s:on:click`, `_s:ref`) is what tells the client which positions it owns. The rule in one sentence: **a slot property is a JSX attribute value, whole, and nothing else.** The finding fires where a stand-in landed somewhere it cannot bind. `data.reason` says which: `"spread"` (`error`, throws) — the slot's whole return spread onto an element, the retired shape (the client would decide what it owns and the template could not show it); `"stringified"` (`warn`) — the stand-in was coerced to a string (a template literal, a concatenation, `String()`), so it is not the whole value; `"coerced"` (`warn`) — the stand-in was used in an expression (a comparison, arithmetic, `==`; `Symbol.toPrimitive` with a `number`/`default` hint), which the server cannot evaluate because it does not have the value — the decision belongs in the client fill, which returns the decided value; `"inline"` (`warn`) — it reached `ssrClassName`/`ssrStyle` inside template quotes, which happens only when the element was compiled without the `serverComponents` compiler option (the option makes a dynamic `class`/`style` a whole-attribute hole); `"text"` (`warn`) — placed as text content, a position not bindable yet; `"markup"` (`warn`) — read off a slot whose client fill returned content, not an object (a slot renders markup **or** data); `"server-local"` (`warn`) — a `ref`/`on*` position on a server intrinsic received a plain server function, which can never run; `"reserved-key"` (`warn`, document face) — the fill's object used a key the slot's range occupies (beginning with `$` or a digit, `length`, `slice`, `t`/`h`/`p`, `then`, `constructor`/`toString`/`valueOf`/`toJSON` — the explicit set; every other key, `filter` or `map` included, is a property read), so a read of it is the range, not a value; `"arg"` (`warn`) — a stand-in was passed in another slot call's argument (`props.child({ parentId: parent.id })`, or nested in plain objects and arrays: `{ nested: { x: row.done } }`, `[row.done]` — a `Map`, `Set` or class instance is not walked), data the server does not have; the arg carries `undefined` at that path on both faces — the record and the document face's t=0 fill (`data.from`/`data.fromKey` name the stand-in, `data.path` the position inside the arg when nested; a stand-in reachable at two paths is reported once, at the first); `"prop"` (`warn`) — a stand-in at a `prop:*` key of a runtime spread, a property position the server cannot bind (the compiled form drops `prop:*` as SSR always has, silently); `"fill-shape"` (`warn`, kind `render`, client) — the client side has the wrong shape: the fill returned something other than a plain object (`null`, an array, a DOM node, a primitive — `data.shape`), or the prop the markup reads as data is not a function; nothing binds until it is; `"orphan"` (`warn`, kind `render` — from the frame client's slot sync) — an element carries `_s:*` markers for an occurrence whose positions can never bind: `data.why` is `"fill"` when no client fill resolves for the prop (the prop was not passed, or its name differs between the sides), or `"record"` when a **called** occurrence (`prop#n`) has no args record once records can no longer arrive (the producer emits the record ahead of the markup that reads it, so a missing one is the protocol out of step — a client and server from different builds, or a runtime id mismatch — never a fill mistake; a bare occurrence named by the prop alone has no record by design and never reports); `data.elements` are the marked elements, reported once per occurrence per frame. The orphan is the one failure in this model that is otherwise silent — a handler that never fires, a class that never updates, indistinguishable from nothing happening. For `stringified`, `coerced`, `inline` and `text` **nothing renders at the position on either face**: the document face never shows a t=0 value the stream face could not reproduce, so the misuse is visible on the first render rather than on the first refetch. Truthiness (`if (row.done)`, `row.x && …`) has no runtime hook — a stand-in is an object and always truthy — and is the one misuse only the rule catches. Once per (occurrence, key, reason, position) per render; a server-local function on a spread element's handler position is judged where the client would bind it — a later spread that owns the position (source order, as `mergeProps` reads it) leaves an earlier named handler unread and unreported. `data.occurrence` names the occurrence, `data.key` the property, `data.position` the position where it applies. +- "[BINDING_SLOT_POSITION] A slot (`row`) is spread onto a server-rendered
  • . A binding slot binds by position — name each one (`class={row.rowClass} onClick={row.remove}`) so the template shows what the client owns." +- "[BINDING_SLOT_POSITION] `row`'s `done` is a binding-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.done}`, not `class={`x ${row.done}`}`). If it is, the element was compiled without the `serverComponents` compiler option. Nothing renders here on either face." +- "[BINDING_SLOT_POSITION] `row`'s `count` is a binding-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." +- "[BINDING_SLOT_POSITION] A binding-slot value (`done` of `row`) reached `class` 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." +- "[BINDING_SLOT_POSITION] `title` of slot `row` 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." +- "[BINDING_SLOT_POSITION] `class` reads `done` off slot `row`, but the client fill returned markup, not an object. A slot renders one or the other: return an object (`{ done: … }`) for positions, or place the slot as content." +- "[BINDING_SLOT_POSITION] A `click` position on a server-rendered element received a server-local function — it can never run. Bind a slot's property there (`const row = props.row(args); onX={row.onX}`) so the client supplies it, or bind a mutation to `action=`." +- "[BINDING_SLOT_POSITION] A `keydown` handler position on a server-rendered element received an array. A position marker names the slot's keys, never data, so a tuple's data cannot reach the client — nothing binds here. Return the tuple from the fill (`[handler, data]`) and bind that one property." +- "[BINDING_SLOT_POSITION] The fill for `row#1` returned an array; server markup reads its properties at bound positions, so it must return an object (`{ done, onToggle, … }`). Nothing binds." +- "[BINDING_SLOT_POSITION] The fill for `row#1` returned a key named `t`, which is reserved (keys beginning with `$`, the node keys `t`/`h`/`p`/`then`, and Object/Array prototype member names): the server reads it as the slot's range, not as a value. Rename it." +- "[BINDING_SLOT_POSITION] Server markup binds `row#9` at 2 elements (positions: hidden, checked), but no client fill resolves for slot `row` — those positions never bind and the elements are inert. Pass `row` 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." +- "[BINDING_SLOT_POSITION] Server markup binds `row#9` at 2 elements (positions: hidden, checked), 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." + +Check (dev only; server components — server-components-principles.md §9.2.3). A binding slot's property (`const row = props.row(args); row.done`) is a stand-in the server template binds at a **position** — the whole value of an attribute, a class name's condition or style property's value inside `class`/`style` object form, a handler, a ref — and the position's marker (`_s:hidden`, `_s:class="row#1:done=completed"`, `_s:on:click`, `_s:ref`) is what tells the client which positions it owns. The rule in one sentence: **a slot property is a JSX attribute value, whole, and nothing else.** The finding fires where a stand-in landed somewhere it cannot bind. `data.reason` says which: `"spread"` (`error`, throws) — the slot's whole return spread onto an element, the retired shape (the client would decide what it owns and the template could not show it); `"stringified"` (`warn`) — the stand-in was coerced to a string (a template literal, a concatenation, `String()`), so it is not the whole value; `"coerced"` (`warn`) — the stand-in was used in an expression (a comparison, arithmetic, `==`; `Symbol.toPrimitive` with a `number`/`default` hint), which the server cannot evaluate because it does not have the value — the decision belongs in the client fill, which returns the decided value; `"inline"` (`warn`) — it reached `ssrClassName`/`ssrStyle` inside template quotes, which happens only when the element was compiled without the `serverComponents` compiler option (the option makes a dynamic `class`/`style` a whole-attribute hole); `"text"` (`warn`) — placed as text content, a position not bindable yet; `"markup"` (`warn`) — read off a slot whose client fill returned content, not an object (a slot renders markup **or** data); `"server-local"` (`warn`) — a `ref`/`on*` position on a server intrinsic received a plain server function, which can never run; `"tuple"` (`warn`) — an `on*` position received an array (`onKeyDown={[row.key, 1]}`): a marker names keys, never data, so nothing binds — return the tuple from the fill instead, where the client binds it as client JSX does (only a `ref` position takes an array, whose refs merge); `"reserved-key"` (`warn`, document face) — the fill's object used a key the slot's range occupies (beginning with `$` or a digit, `length`, `slice`, `t`/`h`/`p`, `then`, `constructor`/`toString`/`valueOf`/`toJSON` — the explicit set; every other key, `filter` or `map` included, is a property read), so a read of it is the range, not a value; `"arg"` (`warn`) — a stand-in was passed in another slot call's argument (`props.child({ parentId: parent.id })`, or nested in plain objects and arrays: `{ nested: { x: row.done } }`, `[row.done]` — a `Map`, `Set` or class instance is not walked), data the server does not have; the arg carries `undefined` at that path on both faces — the record and the document face's t=0 fill (`data.from`/`data.fromKey` name the stand-in, `data.path` the position inside the arg when nested; a stand-in reachable at two paths is reported once, at the first); `"prop"` (`warn`) — a stand-in at a `prop:*` key of a runtime spread, a property position the server cannot bind (the compiled form drops `prop:*` as SSR always has, silently); `"fill-shape"` (`warn`, kind `render`, client) — the client side has the wrong shape: the fill returned something other than a plain object (`null`, an array, a DOM node, an async value, a primitive — `data.shape`), or the prop the markup reads as data is not a function; nothing binds until it is; `"orphan"` (`warn`, kind `render` — from the frame client's slot sync) — an element carries `_s:*` markers for an occurrence whose positions can never bind: `data.why` is `"fill"` when no client fill resolves for the prop (the prop was not passed, or its name differs between the sides), or `"record"` when a **called** occurrence (`prop#n`) has no args record once records can no longer arrive (the producer emits the record ahead of the markup that reads it, so a missing one is the protocol out of step — a client and server from different builds, or a runtime id mismatch — never a fill mistake; a bare occurrence named by the prop alone has no record by design and never reports); `data.elements` are the marked elements, reported once per occurrence per frame. The orphan is the one failure in this model that is otherwise silent — a handler that never fires, a class that never updates, indistinguishable from nothing happening. For `stringified`, `coerced`, `inline` and `text` **nothing renders at the position on either face**: the document face never shows a t=0 value the stream face could not reproduce, so the misuse is visible on the first render rather than on the first refetch. Truthiness (`if (row.done)`, `row.x && …`) has no runtime hook — a stand-in is an object and always truthy — and is the one misuse only the rule catches. Once per (occurrence, key, reason, position) per render; a server-local function on a spread element's handler position is judged where the client would bind it — a later spread that owns the position (source order, as `mergeProps` reads it) leaves an earlier named handler unread and unreported. `data.occurrence` names the occurrence, `data.key` the property, `data.position` the position where it applies. ## Programmatic diagnostics API @@ -976,7 +980,7 @@ The runtime derives a request's trace itself in every tier — the W3C `tracepar | `HEAD_TAG_INVALID` | warn | head | `useHead` registration the render could not honor; `data.reason` names the rule (dev) | | `UNRECOGNIZED_INSERT_VALUE` | warn | render | Value at an insert position the renderer cannot render; skipped (dev; server and client) | | `UNSCOPED_HOLE_ALLOCATED_IDS` | warn | render | Unscoped hole was handed a function whose content took ids at a position the other side does not share; keys permute (dev) | -| `ATTRIBUTE_SLOT_POSITION` | warn/err | ssr/render | Attribute-slot value where it cannot bind; `data.reason` names the position (spread is an error and throws), client reasons `orphan`/`fill-shape` | +| `BINDING_SLOT_POSITION` | warn/err | ssr/render | Binding-slot value where it cannot bind; `data.reason` names the position (spread is an error and throws), client reasons `orphan`/`fill-shape` | ## Run attribution — "why did this run" diff --git a/examples/chat/src/app.css b/examples/chat/src/app.css index f4911b457..e2a898b2e 100644 --- a/examples/chat/src/app.css +++ b/examples/chat/src/app.css @@ -103,7 +103,7 @@ body { } /* Code blocks are server-rendered with a client-wired copy button (the - `codeBlock` attribute slot) — the wrapper anchors the button over the pre. */ + `codeBlock` binding slot) — the wrapper anchors the button over the pre. */ .md .code-block { position: relative; margin: 0 0 12px; diff --git a/examples/chat/src/app.tsx b/examples/chat/src/app.tsx index 04d969afe..337f298bf 100644 --- a/examples/chat/src/app.tsx +++ b/examples/chat/src/app.tsx @@ -18,7 +18,7 @@ let nextId = 0; // Behavior for SERVER-rendered elements: every code block in a reply // carries a copy button the server renders with `onClick={block.onCopy}`, -// where `block` is the `codeBlock` ATTRIBUTE slot's value — the object the fill +// where `block` is the `codeBlock` BINDING slot's value — the object the fill // below returns. The marker in the markup names the occurrence and key; the // client binds this handler on every button, including blocks that streamed // in mid-sentence. It reads the code from the element it was clicked in, so diff --git a/examples/chat/src/lib/ai.tsx b/examples/chat/src/lib/ai.tsx index 143541485..18e9bdddb 100644 --- a/examples/chat/src/lib/ai.tsx +++ b/examples/chat/src/lib/ai.tsx @@ -26,7 +26,7 @@ // 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: `codeBlock` is an ATTRIBUTE slot (principles §9.2.3): the client +// - BEHAVIOR: `codeBlock` is a BINDING 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 @@ -42,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 AttributeSlot, type Slot } from "@solidjs/web/frames"; +import { type BindingSlot, 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"; @@ -68,7 +68,7 @@ const escapeHtml = (text: string) => text.replace(/[&<>]/g, c => HTML_ESCAPES[c] * 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: `onClick={block.onCopy}` reads a - * attribute slot's property at an event position, which marks the button + * binding 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. @@ -134,9 +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 +/** The client's behavior for a code block: one binding slot, called once per * reply (no args), read at every copy button's `onClick`. */ -export type CodeBlockSlot = AttributeSlot<{}, { onCopy: CopyHandler }>; +export type CodeBlockSlot = BindingSlot<{}, { onCopy: CopyHandler }>; /** * The generation's structured face as a live STORE (DR-2 case 3): a @@ -233,7 +233,7 @@ function Message(props: { text: AsyncIterable; block: { onCopy: CopyHand {segmentsOf(closePartial(text())).map(segment => segment.code ? ( // Behavior from the client on a server element: `block.onCopy` - // is an attribute-slot read at an event position, so this button + // is a binding-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/chat/src/lib/model.ts b/examples/chat/src/lib/model.ts index 639066eab..a1b5312a0 100644 --- a/examples/chat/src/lib/model.ts +++ b/examples/chat/src/lib/model.ts @@ -105,7 +105,7 @@ const GREETING = `Welcome to **Solid Chat** — and yes, I am typing this *into \`\`\`jsx // even this code block streamed in as highlighted HTML — -// and its Copy button reads a client attribute slot on a server element +// and its Copy button reads a client binding slot on a server element \`\`\` diff --git a/examples/notes/src/app.tsx b/examples/notes/src/app.tsx index 9de24cc92..1512ea1e3 100644 --- a/examples/notes/src/app.tsx +++ b/examples/notes/src/app.tsx @@ -3,7 +3,7 @@ // this file only fills its client positions — the notes list (a server // component of its own, keyed by the search param) and the route outlet. // The search field isn't a client position either: its markup is server -// chrome, and searchField() is an ATTRIBUTE slot fill — the values and handlers +// chrome, and searchField() is a BINDING slot fill — the values and handlers // the server template binds at positions on its own elements (§9.2.3). // Nothing here fetches data; every read // goes through a `dynamic()` over a server-component query. Links (the diff --git a/examples/notes/src/components/searchField.ts b/examples/notes/src/components/searchField.ts index 6ab9855c8..f81cb92de 100644 --- a/examples/notes/src/components/searchField.ts +++ b/examples/notes/src/components/searchField.ts @@ -7,16 +7,16 @@ */ // 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): +// behavior the client contributes, as ONE binding 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. // -// 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 +// The fill runs once, as a component body does, so the values are getters +// over router state: the input restores `?searchText` on deep links and +// back/forward, the spinner tracks the pending navigation. Before +// binding 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. diff --git a/examples/notes/src/server/App.tsx b/examples/notes/src/server/App.tsx index dc759a649..e0ee74175 100644 --- a/examples/notes/src/server/App.tsx +++ b/examples/notes/src/server/App.tsx @@ -10,7 +10,7 @@ // 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 -// an ATTRIBUTE slot (principles §9.2.3) — one call, `props.search()`, returning +// a BINDING 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 @@ -24,13 +24,13 @@ // (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 type { BindingSlot } from "@solidjs/web/frames"; import EditButton from "~/components/EditButton"; import type { SearchBehavior } from "~/components/searchField"; export async function appView() { return (props: { - search: AttributeSlot<{}, SearchBehavior>; + search: BindingSlot<{}, SearchBehavior>; noteList: JSX.Element; children: JSX.Element; }) => { diff --git a/examples/todos-server/README.md b/examples/todos-server/README.md index c458e060b..7f15c0ace 100644 --- a/examples/todos-server/README.md +++ b/examples/todos-server/README.md @@ -5,7 +5,7 @@ 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 +through **binding slots**. The row is one component, `TodoRow`, that both sides render. Same deliberately unreliable API as the SPA (400 ms saves, ~33% of them @@ -17,10 +17,10 @@ pnpm dev # http://localhost:3010 pnpm build && pnpm start # http://localhost:3010 ``` -## Attribute slots +## Binding slots A slot renders one of two things: markup (placed as ``) or -**attribute values** — a plain object the server template consumes by +**bindings** — 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 @@ -46,7 +46,7 @@ const filters = props.filters(); 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). +binding 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`, @@ -75,16 +75,18 @@ const rowFor = (p: Entity): RowBehavior => ({ onRetry: () => actions.retryTodo(p.id) }); - ({ all: filter === "all", … })} pending={…} count={…} /> + ({ get all() { return 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 +The values are getters because a fill runs once per occurrence, as a +component body does: a top-level read in it is read once, and a getter is +the reactive form — the same shape a client component's props object has, +which is why the same `rowFor` result is also `TodoRow`'s prop for the +pending rows. Handlers and refs are read once, when an element binds. 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 +binds handlers as client JSX does (delegated where it delegates), 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. diff --git a/examples/todos-server/package.json b/examples/todos-server/package.json index 97d20e5b5..7c362608f 100644 --- a/examples/todos-server/package.json +++ b/examples/todos-server/package.json @@ -1,6 +1,6 @@ { "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", + "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: binding slots the server template reads at positions, a shared TodoRow on both sides, one markup slot for pending adds", "version": "0.0.1-rc.1", "private": true, "author": "Ryan Carniato", diff --git a/examples/todos-server/src/app.tsx b/examples/todos-server/src/app.tsx index 296737efd..806af43bd 100644 --- a/examples/todos-server/src/app.tsx +++ b/examples/todos-server/src/app.tsx @@ -5,7 +5,7 @@ // 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 +// template binds. The server rows read those through a binding 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"; @@ -50,12 +50,11 @@ function TodoList(props: { filter: Filter }) { // 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. + // Same function for a server row (through the `row` binding slot) and a + // pending one (passed to directly). The fill runs once per + // occurrence, as a component body does, so values are getters — a + // top-level read would be read once — and handlers are plain closures + // that read at event time. const rowFor = (p: Entity): RowBehavior => ({ get rowClass() { return { @@ -123,9 +122,15 @@ function TodoList(props: { filter: Filter }) { ); }} filters={() => ({ - all: props.filter === "all", - active: props.filter === "active", - completed: props.filter === "completed" + get all() { + return props.filter === "all"; + }, + get active() { + return props.filter === "active"; + }, + get completed() { + return props.filter === "completed"; + } })} /> ); diff --git a/examples/todos-server/src/server/todos.tsx b/examples/todos-server/src/server/todos.tsx index 19d9b64d0..69c60ef69 100644 --- a/examples/todos-server/src/server/todos.tsx +++ b/examples/todos-server/src/server/todos.tsx @@ -3,13 +3,14 @@ // 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 +// them on the server's own elements through BINDING 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. +// at positions of the template. The fill on the other side runs once per +// occurrence with the args as reactive props and returns the values, its +// getters tracking the args (a refetch) and the client's state (an +// optimistic write); the runtime writes each bound position that moves, 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 @@ -20,7 +21,7 @@ // 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 type { BindingSlot, Slot } from "@solidjs/web/frames"; import * as db from "~/lib/db"; import { TodoRow, type RowBehavior } from "~/todo-row"; @@ -43,11 +44,11 @@ export interface FilterBehavior { } export interface TodoListProps { - list: AttributeSlot<{ total: number; active: string[]; completed: string[] }, ListBehavior>; - row: AttributeSlot; + list: BindingSlot<{ total: number; active: string[]; completed: string[] }, ListBehavior>; + row: BindingSlot; pending: Slot; count: Slot<{ remaining: number; total: number }>; - filters: AttributeSlot<{}, FilterBehavior>; + filters: BindingSlot<{}, FilterBehavior>; } export async function todoListView() { diff --git a/examples/todos-server/src/todo-row.tsx b/examples/todos-server/src/todo-row.tsx index 6e25bf77a..1d797b4d9 100644 --- a/examples/todos-server/src/todo-row.tsx +++ b/examples/todos-server/src/todo-row.tsx @@ -1,7 +1,7 @@ // 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: +// `row` IS at the call site — on the server a BINDING 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 diff --git a/packages/h/jsx-runtime/src/jsx.d.ts b/packages/h/jsx-runtime/src/jsx.d.ts index 5d78b23dc..1fea5cd73 100644 --- a/packages/h/jsx-runtime/src/jsx.d.ts +++ b/packages/h/jsx-runtime/src/jsx.d.ts @@ -253,7 +253,7 @@ export namespace JSX { /** * 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 + * element (a binding 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 diff --git a/packages/signals/src/core/dev.ts b/packages/signals/src/core/dev.ts index d8b376d27..c3de22c72 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" - | "ATTRIBUTE_SLOT_POSITION" + | "BINDING_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 e38d1f497..d9b2d0caa 100644 --- a/packages/solid/skills/reactivity-diagnostics/SKILL.md +++ b/packages/solid/skills/reactivity-diagnostics/SKILL.md @@ -843,9 +843,9 @@ 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. -### ATTRIBUTE_SLOT_POSITION +### BINDING_SLOT_POSITION -An attribute slot's property (`const row = props.row(args); row.done`) +A binding 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 @@ -858,7 +858,8 @@ 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 +an `action=`), `"tuple"` (an `on*` position got an array — return the +`[handler, data]` tuple from the fill instead), `"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 diff --git a/packages/web/frames/src/client.ts b/packages/web/frames/src/client.ts index d1110adaf..a58fac5c4 100644 --- a/packages/web/frames/src/client.ts +++ b/packages/web/frames/src/client.ts @@ -25,7 +25,8 @@ import { getOwner, OBSERVE, onCleanup, - runWithOwner + runWithOwner, + untrack } from "solid-js"; import type { Element as SolidElement } from "solid-js"; // `insert` MUST resolve to the shared @solidjs/web instance the compiled app @@ -96,7 +97,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, AttributeSlot } from "./server.js"; +export type { Slot, BindingSlot, SlotOutput, SlotError } from "./server.js"; /** * Client-condition twin of the server face's `asyncArg` (DR-2 value tier): @@ -362,184 +363,187 @@ function slotArgsProxy(args: () => Record) { } interface ElementState { - prev: any; - ref: any; + /** `assign`'s diff state: the props last written to the element. */ + prev: Record; + /** Bound handler props (`onclick`): the key and the value read at bind. */ + handlers: Record; + /** The ref dispatcher for the element's current ref keys, and those keys. */ + ref: ((el: Element) => void) | undefined; 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. + * The occurrence holding each handler prop of an element. A rebind can hand + * an element from one occurrence to another (a positional id now names + * another row's data), and a delegated handler is one slot on the element: + * the outgoing occurrence's release must not clear what the incoming one set. */ -function bindDataOccurrence(fill: (args: any) => any, args: any, ctx: any) { +const handlerOwners = new WeakMap>(); + +/** + * Bind a binding-slot occurrence (principles §9.2.3). The fill runs ONCE, + * untracked, under the occurrence's owner — a component body: a top-level + * read is a one-time read (dev names it through `untrack`'s label), and state + * created in the body lives as long as the occurrence. Its object's value + * positions are written by one render effect over every consuming element, + * diffed per position by `assign`, so a getter's change re-reads the + * occurrence and touches only what moved. Handlers and refs are read once + * when an element binds and handed to `assign`, which binds them as client + * JSX does (delegation, tuples). 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, + label: string | undefined +) { 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 raw = untrack(() => fill(args), label); + // 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); an + // async value has no properties to bind until it settles. + const node = typeof Node === "function" && raw instanceof Node; + const pending = isAsyncValue(raw); + if (IS_DEV && (raw == null || typeof raw !== "object" || Array.isArray(raw) || node || pending)) { + const shape = + raw === null + ? "null" + : node + ? "a DOM node" + : Array.isArray(raw) + ? "an array" + : pending + ? "an async value" + : typeof raw; + slotShapeFinding( + ctx.key, + shape, + `[BINDING_SLOT_POSITION] The fill for \`${ctx.key}\` returned ${shape}; server markup reads ` + + `its properties at bound positions, so it must return an object (\`{ done, onToggle, … }\`). ` + + `Nothing binds.` + ); + } + const out = raw == null || typeof raw !== "object" || node || pending ? {} : raw; + const token = {}; 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. + // list on a rebind (its markers gone, the element kept by the morph) is + // released so its handlers unbind. let bound = new Set(); createRenderEffect( - () => { - const out = output(); - return consumers().map(({ element, positions }) => ({ + () => + consumers().map(({ element, positions }) => ({ element, - ...propsFor(element, positions, out) - })); - }, + positions, + values: valuesFor(positions) + })), writes => { const next = new Set(); - for (const { element, props, handlers } of writes) { + for (const { element, positions, values } of writes) { next.add(element); - write(element, props, handlers); + write(element, positions, values); } - for (const element of bound) if (!next.has(element)) write(element, {}, {}); + for (const element of bound) if (!next.has(element)) release(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. + // names another row's data) unbinds what it bound: the element may outlive + // the occurrence (a morph keeps un-keyed elements) and another occurrence + // may bind it next, so a handler left behind fires a disposed fill's. onCleanup(() => { - for (const element of bound) write(element, {}, {}); + for (const element of bound) release(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)); - } + // Value positions are READ in the compute phase: a getter read here + // tracks, so the occurrence re-writes when its sources move. + function valuesFor(positions: any[]) { 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 === "ref" || pos.startsWith("on:")) continue; 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) { + return props; + } + function write(element: Element, positions: any[], props: Record) { + let st = state.get(element); + if (!st) state.set(element, (st = { prev: {}, handlers: {}, ref: undefined, refId: "" })); + // Handler positions: the marker's event name (`onClick` compiled to + // `click`) as the prop `assign` binds. The server merges duplicate + // handlers last-wins, so a position names one key; given more, the last. + // Several keys at a ref position all fire, in marker order. + const handlers: Record = {}; + const refKeys: string[] = []; + for (const { pos, key } of positions) { + if (pos === "ref") refKeys.push(key); + else if (pos.startsWith("on:")) handlers["on" + pos.slice(3)] = key; + } + let owners = handlerOwners.get(element); + if (!owners) handlerOwners.set(element, (owners = {})); + for (const prop in handlers) { + const key = handlers[prop]; + let h = st.handlers[prop]; + if (h === undefined || h.key !== key) + st.handlers[prop] = h = { key, value: untrack(() => out[key]) }; + props[prop] = h.value; + owners[prop] = token; + } + // A handler the server released (or this occurrence let go of) is + // unbound through `assign`'s diff — unless another occurrence has taken + // the element's handler since, which is then not ours to clear. + const clearing: Record = {}; + for (const prop in st.handlers) { + if (prop in handlers) continue; + delete st.handlers[prop]; + if (owners[prop] === token) { + delete owners[prop]; + clearing[prop] = true; + } + } + if (refKeys.length) { // 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. + // changes; a rebind that changes the bound keys fires it once. const id = refKeys.join(","); if (st.refId !== id) { - const keys = refKeys; + const refs = refKeys.map(k => untrack(() => out[k])); st.refId = id; st.ref = (el: Element) => { - const o = output(); - for (const k of keys) { - const r = o[k]; - typeof r === "function" && r(el); - } + for (const r of refs) typeof r === "function" && r(el); }; } props.ref = st.ref; } - return { props, handlers }; + // 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" || k in clearing) continue; + delete st.prev[k]; + } + assign(element, props, true, st.prev); + } + function release(element: Element) { + if (!state.has(element)) return; + write(element, [], {}); + state.delete(element); } } /** - * 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 + * Dev finding (`BINDING_SLOT_POSITION`, reason `fill-shape`): the client + * side of a binding 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. */ @@ -547,7 +551,7 @@ function slotShapeFinding(occurrence: string, shape: string, message: string) { DEV!.report( OBSERVE!.diagnostics.emit( { - code: "ATTRIBUTE_SLOT_POSITION", + code: "BINDING_SLOT_POSITION", kind: "render", severity: "warn", message, @@ -601,7 +605,7 @@ function slotsFor(props: Record) { fillScopes.delete(key); prevFill.dispose(); } - // Attribute slot (§9.2.3): the occurrence's node is the set of server + // Binding 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 @@ -614,7 +618,7 @@ function slotsFor(props: Record) { slotShapeFinding( key, typeof fill, - `[ATTRIBUTE_SLOT_POSITION] Server markup reads slot \`${prop}\` as data (\`${key}\`), ` + + `[BINDING_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.` @@ -632,7 +636,12 @@ function slotsFor(props: Record) { const args = ctx.onUpdate ? liveSlotProps(slotProps, ctx) : slotArgsProxy(() => slotProps); - bindDataOccurrence(fill, args, ctx); + bindDataOccurrence( + fill, + args, + ctx, + IS_DEV ? `the \`${prop}\` binding-slot fill` : undefined + ); }); return undefined; } @@ -694,8 +703,14 @@ function slotsFor(props: Record) { // Without live updates the same async-read wrap still applies // over the static args (DR-2: async values suspend at the // consumption read on every path). - return v( - ctx.onUpdate ? liveSlotProps(slotProps, ctx) : slotArgsProxy(() => slotProps) + // Called untracked, as a component body is: a top-level read + // is a one-time read, and dev names it. + const fillProps = ctx.onUpdate + ? liveSlotProps(slotProps, ctx) + : slotArgsProxy(() => slotProps); + return untrack( + () => v(fillProps), + IS_DEV ? `the \`${prop}\` template-slot fill` : undefined ); } return v; diff --git a/packages/web/frames/src/frame-client.ts b/packages/web/frames/src/frame-client.ts index 25d5e315d..791063cce 100644 --- a/packages/web/frames/src/frame-client.ts +++ b/packages/web/frames/src/frame-client.ts @@ -413,9 +413,9 @@ const SLOT_START = /^slot:(.+):start$/; const SLOT_END = /^slot:(.+):end$/; const slotEnd = id => `slot:${id}:end`; -// === Attribute slots (principles §9.2.3: a slot read at positions of server markup) === +// === Binding 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 +// A server element that reads a binding 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:` @@ -1384,7 +1384,7 @@ class FrameImpl { // interior reactively insert before `end` and return undefined — the // frame then never touches the interior (morphs protect slot ranges). range: end ? { start, end } : undefined, - // Attribute slot (§9.2.3): the positions of server markup that read this + // Binding 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 @@ -1665,7 +1665,7 @@ class FrameImpl { } /** 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. */ + * and — for the slot sync — its binding-slot elements into the same map. */ #collectSlots(found, elements) { collectSlots(this.#firstContent(), this.#end, found, elements); } @@ -1880,7 +1880,7 @@ 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 + // Binding-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; @@ -2540,7 +2540,7 @@ function preservesOpen(el) { return t === "DETAILS" || t === "DIALOG"; } -// Attribute-slot ownership (principles §9.2.3). The INCOMING element's `_s:*` +// Binding-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` @@ -2609,7 +2609,7 @@ function morphOwnedStyle(oldEl, value, names) { /** * Apply the server's value for `name` (null: absent) to an element with - * attribute-slot positions: a client-owned attribute is left alone; owned + * binding-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. @@ -2699,7 +2699,7 @@ function afterRange(start, id) { } /** - * Dev: an attribute-slot occurrence's marked positions cannot bind — no + * Dev: a binding-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 @@ -2723,16 +2723,16 @@ function devSlotOrphan(frame, occurrence, consumers, why) { DEV.report( OBSERVE.diagnostics.emit( { - code: "ATTRIBUTE_SLOT_POSITION", + code: "BINDING_SLOT_POSITION", kind: "render", severity: "warn", message: why === "fill" - ? `[ATTRIBUTE_SLOT_POSITION] Server markup binds \`${occurrence}\` at ${where}, but no client fill ` + + ? `[BINDING_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 ` + + : `[BINDING_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 ` + diff --git a/packages/web/frames/src/frame-sink.ts b/packages/web/frames/src/frame-sink.ts index eea34beae..5ba77b14a 100644 --- a/packages/web/frames/src/frame-sink.ts +++ b/packages/web/frames/src/frame-sink.ts @@ -910,7 +910,7 @@ function slotRange(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); +// an object it is the fill's data (binding 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 @@ -1012,11 +1012,11 @@ function standInArgFinding(sv, key, occurrence, path) { } const where = path === "" ? `Arg \`${key}\`` : `Arg \`${key}${path}\``; devCheck({ - code: "ATTRIBUTE_SLOT_POSITION", + code: "BINDING_SLOT_POSITION", kind: "ssr", severity: "warn", message: - `[ATTRIBUTE_SLOT_POSITION] ${where} of \`${occurrence}\` is another slot's value ` + + `[BINDING_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.`, @@ -1058,11 +1058,11 @@ function slotFace(range, content) { for (const key of Object.keys(content)) { if (isRangeKey(key)) { devCheck({ - code: "ATTRIBUTE_SLOT_POSITION", + code: "BINDING_SLOT_POSITION", kind: "ssr", severity: "warn", message: - `[ATTRIBUTE_SLOT_POSITION] The fill for \`${range.$occurrence}\` returned a key named \`${key}\`, ` + + `[BINDING_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.`, @@ -1173,7 +1173,7 @@ function structuralArgsKey(raw) { /** * One call is one occurrence however many times the render evaluates it. - * The natural attribute-slot shape puts the call in a component prop — + * The natural binding-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 diff --git a/packages/web/frames/src/server.ts b/packages/web/frames/src/server.ts index 1a9036f39..77b7caa23 100644 --- a/packages/web/frames/src/server.ts +++ b/packages/web/frames/src/server.ts @@ -51,15 +51,49 @@ setAsyncIterableSharer(shareAsyncIterable); */ export type Slot

    = (props: P & { $key?: string | number }) => SolidElement; +declare const slotError: unique symbol; + +/** + * A binding slot's rejected return shape: assigning to it fails, and the + * error names the reason `M`. + * @experimental + */ +export type SlotError = { [slotError]: M }; + /** - * 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 + * What a binding slot's fill may return: `J` when it is a plain object with no + * `$`-prefixed key (reserved for occurrence identity), otherwise a + * `SlotError` naming why not. + * @experimental + */ +export type SlotOutput = J extends readonly unknown[] + ? SlotError<"binding slot output must be an object, not an array"> + : J extends Node + ? SlotError<"binding slot output must be an object, not a DOM node"> + : J extends (...args: any[]) => any + ? SlotError<"binding slot output must be an object, not a function"> + : J extends PromiseLike | AsyncIterable + ? SlotError<"binding slot output must be settled, not async"> + : Extract extends never + ? J + : SlotError<`reserved key: ${Extract & string}`>; + +/** + * A binding slot (principles §9.2.3): the client renders an object instead of + * markup, and the server template binds its 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). + * a property IS (attribute, class name, style property, handler, ref). On + * the server a property is a stand-in, never the value: bind it, never + * branch on it or compute with it. + * + * The fill runs once per occurrence, untracked, as a component body does: + * state it creates lives with the occurrence, a top-level read is a + * one-time read, and a getter is the reactive form. Handlers and refs are + * read once, when an element binds. * * `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 @@ -74,9 +108,9 @@ export type Slot

    = (props: P & { $key?: string | number }) => SolidEleme * prop. * @experimental */ -export type AttributeSlot

    > = {} extends P - ? (props?: P & { $key?: string | number }) => J - : (props: P & { $key?: string | number }) => J; +export type BindingSlot

    > = {} extends P + ? (props?: P & { $key?: string | number }) => SlotOutput + : (props: P & { $key?: string | number }) => SlotOutput; /** * Types an async value crossing the slot border (DR-2, value tier). What you diff --git a/packages/web/jsx/jsx-h.d.ts b/packages/web/jsx/jsx-h.d.ts index 5e45dafa8..5d59a1ace 100644 --- a/packages/web/jsx/jsx-h.d.ts +++ b/packages/web/jsx/jsx-h.d.ts @@ -254,7 +254,7 @@ export namespace JSX { /** * 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 + * element (a binding 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 diff --git a/packages/web/jsx/jsx.d.ts b/packages/web/jsx/jsx.d.ts index 9cdceed73..25a0eb6bd 100644 --- a/packages/web/jsx/jsx.d.ts +++ b/packages/web/jsx/jsx.d.ts @@ -253,7 +253,7 @@ export namespace JSX { /** * 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 + * element (a binding 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 diff --git a/packages/web/skills/server-components/SKILL.md b/packages/web/skills/server-components/SKILL.md index 81f469550..0058cf460 100644 --- a/packages/web/skills/server-components/SKILL.md +++ b/packages/web/skills/server-components/SKILL.md @@ -14,12 +14,12 @@ this file is the operative subset. - 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. + client-driven value on it is a **binding 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**. + placed through a **template 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. @@ -27,19 +27,19 @@ 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"; +import type { BindingSlot, 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 + pending: Slot; // template: the client returns JSX + row: BindingSlot<{ id: string; completed: boolean }, RowBehavior>; // bindings: the client returns an object + filters: BindingSlot<{}, FilterBehavior>; // no args: called bare } ``` -A **markup slot** is placed as an element: ``. The client +A **template slot** is placed as an element: ``. The client owns those nodes. -An **attribute slot** is _called_ once per data context and its properties +A **binding slot** is _called_ once per data context and its properties are _bound_ at positions of the server's own template: ```tsx @@ -72,10 +72,15 @@ export async function todoList() { The client passes the fill — a function of the args that returns the object: ```tsx - ({ all: filter() === "all", … })} pending={() => {…}} /> + ({ get all() { return filter() === "all"; }, … })} pending={() => {…}} /> ``` -## The one rule for attribute slots +The fill must return a plain object. An array, a DOM node, a function or +an async value is a type error on both sides (`BindingSlot`'s return is +`SlotOutput`, a branded `SlotError` naming the reason) and a +`fill-shape` finding at runtime. + +## The one rule for binding slots > **A slot property is a JSX attribute value, whole, and nothing else.** @@ -97,25 +102,32 @@ 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 &&