Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/binding-slot-execution.md
Original file line number Diff line number Diff line change
@@ -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<J>` / `SlotError<M>`, 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.
15 changes: 10 additions & 5 deletions documentation/plans/binding-slot-execution.md
Original file line number Diff line number Diff line change
@@ -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<Args>`) is placed and filled with markup. A **binding slot**
Expand Down Expand Up @@ -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`);
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion documentation/plans/text-positions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
113 changes: 103 additions & 10 deletions documentation/server-components/server-components-principles.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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<Args>`) is placed and the client fills it with
markup; a **binding slot** (`BindingSlot<Args, Bindings>`, 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<reason>` at the type level:
`BindingSlot`'s return is `SlotOutput<J>`, 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
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading