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 &&