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
7 changes: 7 additions & 0 deletions .changeset/binding-slot-text-positions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@solidjs/web": patch
---

Binding slots: a slot property placed as a child (`<strong>{list.remaining}</strong> items left`) is a text position. The server emits a comment pair around it, `<!--_s:t=<occurrence>:<key>-->…<!--/_s:t-->`, with the escaped t=0 value inside on the document face and nothing on the stream face; the client writes the text between the markers from the occurrence's render effect, and a refetch's morph keeps the client's text. Strings and numbers render; nullish and booleans render empty, as a client insert renders them. Any other value is a new client dev finding (`BINDING_SLOT_POSITION`, reason `text-shape`) and clears. The content of `<textarea>`, `<title>`, `<style>` and `<script>` is not a text position (a comment is literal text there); bind `value=` or a style property instead.

Breaking: a slot property as a child used to render nothing and raise the `text` finding; it now renders and binds, and the `text` reason is retired.
21 changes: 16 additions & 5 deletions documentation/plans/text-positions.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
# Text positions

Status: design for review, 2026-09-29. Gap G1 in
Status: implemented 2026-09-29 (principles §9.2.5). Gap G1 in
[`examples-grid-plan.md`](./examples-grid-plan.md); builds on
[`binding-slot-execution.md`](./binding-slot-execution.md) (G2), and lands
after it. Decisions recorded; ready for implementation review.
after it. Where the build departed from this text it is corrected in place,
marked _(as built)_.

A binding slot's value placed as a child — `<a>{t.label}</a>`,
`<strong>{list.remaining}</strong> items left` — becomes a position the
Expand Down Expand Up @@ -54,7 +55,11 @@ for the toggle's label; `todos-server`'s count wants it.
marker. It never enters slot-range interiors or nested frames, so only
server-owned markup can hold one. `consumersEqual` also compares
`start`. No parent-element marker is needed: the walk that finds slot
ranges finds these at no extra traversal.
ranges finds these at no extra traversal. _(As built: the position
joins the parent's EXISTING consumer entry for the occurrence when its
attribute markers opened one. Two entries for one element each treat
the other's handler as released — a counter button,
`<button onClick={row.bump}>{row.count}</button>`, lost its handler.)_
4. **Client write.** The occurrence's one value effect writes a text
position as `node.data = String(v)` into the range's one text node,
creating it between the markers when absent (the stream face, or an
Expand Down Expand Up @@ -82,7 +87,11 @@ for the toggle's label; `todos-server`'s count wants it.
## Checks made before writing

- The compiled server template needs no change for ordinary parents: the
hole already reaches the resolver with the stand-in.
hole already reaches the resolver with the stand-in. _(As built: two
resolvers, not one. `resolveSSRNode` sees it under live holes and in
element children; `tryResolveString`, a `ssr()` hole's sync path, sees
it under `renderToString` and through a component's `children`, where
it fell to `unrecognizedInsert`. Both emit the pair.)_
- Occurrence, key and encoding reuse `slotEntry`/`encodeSlotKey`
(`server.ts:4794–4805`); the client decodes as `slotPositions` does.
- Existing server spec to rewrite: `frame-binding-slots.spec.tsx:986`
Expand Down Expand Up @@ -131,7 +140,9 @@ for the toggle's label; `todos-server`'s count wants it.
another occurrence taking the same position; server release; a
non-primitive finding.
2. Server: the resolver branch; `slotTextPosition` removed. No compiler
change.
change. The writer rides on the stand-in (`slotValue` sets it), so the
resolvers only call it: a server render with no binding slots does not
retain it (the `renderToString` floor, #3722).
3. Client: discovery in `collectSlots`, `consumersEqual`, the write in the
occurrence's computation, the morph skip.
4. Docs in the same PR: §9.2.3 (text joins the position kinds; the finding
Expand Down
71 changes: 71 additions & 0 deletions documentation/server-components/server-components-principles.md
Original file line number Diff line number Diff line change
Expand Up @@ -3371,6 +3371,77 @@ object); it is an error today, so adding it later is not breaking.
`STRICT_READ_UNTRACKED` ("an effect callback") for any fill with
a ref; refs are read once, untracked, at bind now.

#### 9.2.5 Amendment — binding slots: text positions (2026-09-29)

Design: `documentation/plans/text-positions.md`. A binding-slot
value placed as a child — `<strong>{list.remaining}</strong> items
left` — is a position, like an attribute: the server writes the
t = 0 value on the document face and nothing on the stream face,
and the client owns the text from there.

**The model.**

- *A marker pair around the value.*
`<!--_s:t=<occurrence>:<key>-->` + text + `<!--/_s:t-->`. One
marker cannot work on either face: on the stream face the value is
empty, so there is no text node to find; on the document face it
merges with static text beside it (`3 items left`). The end marker
bounds the range on both. The entry is `slotEntry`'s encoding, and
occurrence ids are already encoded for comment contexts, so the
entry cannot close the comment. A child among siblings sits inside
the compiler's own `<!--$-->…<!--/-->` insert range, as any dynamic
child does.
- *Primitives only, as a client insert renders them.* A string or
number renders (escaped on the server, `node.data` on the client);
`null`, `undefined` and booleans render empty. Anything else — an
object, an array, a node, a function — is the client's
`text-shape` finding and clears: markup belongs in a template
slot. A read off a fill that returned markup keeps the `markup`
finding and emits an empty pair.
- *Both server walkers emit it.* `resolveSSRNode` (live holes,
element children) and `tryResolveString` (a `ssr()` hole's sync
path — `renderToString`, a component's `children`). The compiled
template is unchanged.
- *Discovery rides the slot walk.* `collectSlots` already visits
every child of server-owned markup; a start marker registers
`{ pos: "text", key, start }` on its parent element's consumer
entry for the occurrence — the entry its attribute markers
opened, if any, so an element is one consumer however its
positions are spelled. Two entries for one element would each
treat the other's handler as released. `consumersEqual` compares
`start`: a pair the morph re-creates is a consumer change.
- *The occurrence's one effect writes it.* Text values are read in
the compute phase beside the other value positions and written
into the range's one text node, created between the markers when
absent. A primitive-only write is attribute-shaped, so it needs
no effect of its own.
- *The morph keeps a pair it meets again.* An incoming start marker
meeting an old one with the same data skips both ranges, keeping
the old interior — the text analogue of the same-slot-range
skip. Anything else reconciles as ordinary nodes; the consumer
change that follows rebinds the owner. No relocation index: the
interior is one text node the owner rewrites on rebind. A pair
the server stops emitting is the server's again and gets no final
write, as a released attribute.

**The raw-text rule.** A binding value is never the content of a
raw-text element — `<textarea>`, `<title>`, `<style>`, `<script>`
— where a comment is literal text and the markers would land in the
content. `<textarea>` binds `value=`; a style binds a style
property. Stated, not checked: the resolver does not know the
parent, and a compiler check would add a code path to every SSR
output (the SSR compile is one compile; `serverComponents` is a
setting on it) for a misuse only server components can make. In
`<textarea>` and `<title>` a violation shows on first render; in
`<style>` and `<script>` it can fail silently.

This supersedes, in 9.2.3: text positions "deferred, not rejected"
("Rules of the shape"), "a text child" among the coercions that
render nothing, and the open item; in its build record, "placed as
text" among the misuses. The `text` finding reason is retired. The
rule's sentence widens with it: **a slot property is a JSX
attribute value or a text child, whole, and nothing else.**

### 9.3 Stage 8 seed — connection-shaped transport (2026-08-17)

Recorded from the design conversation; nothing here is built (the
Expand Down
Loading
Loading