Skip to content

web: binding-slot text positions - #3714

Merged
ryansolid merged 6 commits into
nextfrom
feat/text-positions
Sep 30, 2026
Merged

ryansolid merged 6 commits into
nextfrom
feat/text-positions

Conversation

@ryansolid

@ryansolid ryansolid commented Sep 29, 2026 •

Copy link
Copy Markdown
Member

Summary

Gap G1 in documentation/plans/examples-grid-plan.md; design in documentation/plans/text-positions.md, recorded as principles §9.2.5. Was stacked on #3713, which merged as ecb68a1; retargeted to next.

A binding-slot stand-in placed as a child (<strong>{list.remaining}</strong>) was a markup read: it rendered nothing and raised a text finding, so a server-rendered count or label next to a handler needed a client component around the whole element. Now a child is a text position the client owns, like an attribute value:

  • Server. The stand-in emits <!--_s:t=<occurrence>:<key>-->text<!--/_s:t-->. The document face holds the escaped t=0 value (strings and numbers only; nullish and booleans empty, 0 renders), and the stream face is empty. Both walkers emit it: resolveSSRNode (live holes, element children) and tryResolveString (renderToString, a component's children). Without the second, those paths dropped the value to unrecognizedInsert. A markup-face read still raises markup and emits an empty pair. The writer rides on the stand-in (slotValue sets it) and the walkers only call it, so a server render with no binding slots does not retain it.
  • Discovery. collectSlots registers each pair on its parent element's existing consumer entry for the occurrence. When they were separate entries for one element, each treated the other's handler as released, which broke a counter button (handler, attribute and text on one <button>). consumersEqual compares the start marker too, so a pair the morph re-creates rebinds.
  • Write. Text values are read in the occurrence's one render effect's compute, beside the attribute values. The effect sets the text node's data between the markers, creating it when absent. A non-primitive clears the position and raises the dev text-shape finding.
  • Morph. reconcileChildren skips a matching old/new pair and keeps the old interior, so the client-owned text node survives a refetch.
  • Raw-text rule (documented): <textarea>, <title>, <style> and <script> content is never a text position.

Two corrections to the design are marked (as built) in the plan: the two walkers, and the consumer merge.

Public API changes

  1. A binding-slot value placed as a child renders and binds. Before, it rendered nothing. The slot rule reads "a JSX attribute value or a text child, whole, and nothing else" in the principles, the skill and the dev messages ("handler, ref or text child").
  2. Diagnostic reason text retired, text-shape added (BINDING_SLOT_POSITION), for a non-primitive written to a text position on the client.
  3. New wire markers <!--_s:t=<occurrence>:<key>--> / <!--/_s:t--> in frames output.

How did you test this change?

Specs first, including the ones pinning each load-bearing piece. Disabling the consumer merge, the morph skip, the start compare or the tryResolveString branch each fails a spec.

  • Server (frame-binding-slots.spec.tsx): stream-face empty pairs, document-face escaping, nullish/boolean/0, the array, function-child and component-children shapes under both renderToString and renderToStream, the markup finding with an empty pair.
  • Client (frames-binding-slots.spec.tsx): bind, a getter update and a refetch keeping the same node; another occurrence taking the position, and server release; the counter button; a morph-re-created range rebinding; text-shape.
  • Hydration adoption: text pairs on the adopted rows. A MutationObserver asserts the t=0 write leaves the server's text nodes untouched, then they update.

Results:

Rebased onto next @ cce43eb (2026-09-30).

  • @solidjs/web: client 1115 passed (1 expected fail), server 1373 passed (2 skipped), hydrate 270 passed
  • pnpm types, web test-types
  • Size: every scenario within its cap after the exception below

Browser, examples/todos-server with a temporary edit (<strong>{list.remaining}</strong> {list.itemsWord} left, getters in listFor; reverted, not in this PR), against the dev server with a fresh Vite dep cache:

  • The document face showed 2 inside the pair.
  • Hydration left it in place, and a toggle showed "1 item left" immediately.
  • The same text node survived the refetch, and a reload showed the server's value.
  • Zero client warnings.

Size-Exception: binding-slot execution / text positions; size vetted by maintainer; base moved under the PR. Against next @ ecb68a1 (#3713 squashed; #3727 had raised the page caps to 46.04 / 50.17 KB): page: base server components 46.04 -> 46.20 KB, measured at 46,193 B against 45,934 B (+259 B brotli); page: live server components 50.17 -> 50.45 KB, measured at 50,442 B against 50,125 B (+317 B brotli); +760 B minified each. The frames eager client (not a frozen cap) is 12.71 -> 12.98 KB, measured at 12,963 B against 12,694 B (+269 B). First measured against #3713 @ 73640f5 at 12,963 / 46,204 / 50,346 B (caps 12.98 / 46.21 / 50.35 KB, accepted by the maintainer). The bytes are discovery, the text write and the morph skip. Trimmed before accepting; discovering pairs over an element's direct children was weighed and rejected (~100 B for a new rule).

Size-Exception: server: renderToString 20.36 -> 20.38 KB, measured at 20,372 B against next @ ecb68a1's 20,356 B (+16 B brotli, -35 B minified; the same numbers as against #3713 @ 73640f5). None of the text-position code is retained there (the writer rides on the stand-in); the code shrinks and brotli layout grows. Accepted by the maintainer.

@changeset-bot

changeset-bot Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0949bc8

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 12 packages
Name Type
@solidjs/web Patch
@solidjs/babel-plugin Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
test-integration Patch
todos-server-example Patch
@solidjs/compiler Patch
@solidjs/signals Patch
solid-js Patch
@solidjs/universal Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

ryansolid added a commit that referenced this pull request Sep 29, 2026
Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid and others added 4 commits September 30, 2026 03:01
A binding slot's fill ran inside `createMemo(() => fill(args))`: a
top-level read tracked, so an eager fill re-ran on every change and
disposed whatever its body created with it. The template fill ran
once, untracked, under its occurrence's owner. Same slot border, two
execution models.

Now both run as a component body does. The binding fill runs once per
occurrence, `untrack(fn, label)` under the occurrence's owner with live
args: state it creates lives with the occurrence, a top-level read is
a one-time read that dev names (`STRICT_READ_UNTRACKED`, "the `row`
binding-slot fill"), and getters are the reactive form. One render
effect per occurrence still writes the value positions through
`assign`'s diff. Handlers and refs are read once when an element binds
and handed to `assign`, so events delegate, tuples bind and
interactions wrap as in client JSX; the frames-own listener goes, and
only merged refs keep a fan-out dispatcher. Template fills run under the
same labelled untrack on every path — a live render (reveal content, a
non-adopted mount) used to call them inside the ambient computation.

On the server, `claimEntries` flattens arrays at `ref` only; an array
at a handler position binds nothing and is a dev finding (reason
`tuple`) — the tuple belongs in the fill.

Renames, no aliases: `AttributeSlot` -> `BindingSlot`, whose return is
now `SlotOutput<J>` (a branded `SlotError` for an array, DOM node,
function, async value or `$` key, failing on both sides), and the
diagnostic code `ATTRIBUTE_SLOT_POSITION` -> `BINDING_SLOT_POSITION`.
The specs, principles (§9.2.4), both skills and 08-dev-diagnostics move
to the vocabulary. todos-server's `filters` fill was eager and is
getters now.

Size-Exception: the live server components page re-based 50.09 ->
50.15 KB (+56 B brotli, -5 B minified; brotli layout).

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
A binding-slot stand-in placed as a child is now a text position the
client owns, not a markup read that renders nothing. The server emits
`<!--_s:t=<occurrence>:<key>-->text<!--/_s:t-->`: the escaped t=0
value on the document face (strings and numbers only), an empty pair on
the stream face. Both server walkers emit it: `resolveSSRNode` (live
holes, element children) and `tryResolveString` (`renderToString`, a
component's `children`). A markup-face read still raises the `markup`
finding and emits an empty pair.

On the client, `collectSlots` registers each pair on its parent
element's existing consumer entry for the occurrence (separate entries
made each treat the other's handler as released), `consumersEqual`
compares the start marker, and the occurrence's one render effect
writes the text node between the markers, creating it when absent. A
non-primitive clears the position and raises the dev `text-shape`
finding. `reconcileChildren` keeps a pair it meets again, interior and
all.

The `text` diagnostic reason is retired. Principles §9.2.5 records the
amendment, including the raw-text rule: `<textarea>`, `<title>`,
`<style>` and `<script>` content is never a text position.

Size-Exception: frames eager client 12.71 -> 12.98 KB, base page
45.91 -> 46.16 KB, live page 50.15 -> 50.35 KB (+268 / +254 / +208 B
brotli against #3713; +760 B minified), the text-position discovery,
write and morph skip. Accepted by the maintainer.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid
ryansolid force-pushed the feat/binding-slot-execution branch from 06935ab to 73640f5 Compare September 30, 2026 14:29
@ryansolid
ryansolid changed the base branch from feat/binding-slot-execution to next September 30, 2026 18:46
ryansolid and others added 2 commits September 30, 2026 11:47
next now carries #3713 as squash ecb68a1, whose tree is the
feat/binding-slot-execution tip this branch was built on, merged with
next @ 3c1f809. The merge base is the pre-#3713 cce43eb, so the
squashed changes conflicted with the same changes here: each conflicted
file resolves to this branch's version, plus next's createSSRResponse
change in packages/web/src/server.ts. The result is the tree of merging
feat/binding-slot-execution @ 1f40c23 into this branch (merge base
73640f5), where only scripts/size conflicted. The page caps are
next's here; the following commit raises them.

Co-authored-by: Cursor <cursoragent@cursor.com>
The base moved under the PR: #3727 raised the page caps to 46.04 /
50.17 KB. Against next @ ecb68a1 (#3713 squashed) the pages measure
46,193 / 50,442 B (+259 / +317 B brotli, +760 B minified): caps 46.20 /
50.45 KB, rounded up to the next 0.01 KB. Size vetted by the maintainer.

Co-authored-by: Cursor <cursoragent@cursor.com>
@github-actions

Copy link
Copy Markdown

Size (brotli, eager entry chunk)

scenario head vs base cap lazy chunks (not counted)
signals: core floor (createSignal/Memo/Effect/Root/flush) 9.51 KB 0 B 9.51 KB ✅
signals: + createStore 16.85 KB 0 B 16.85 KB ✅
signals: + isPending/latest 12.16 KB 0 B 12.16 KB ✅
app: render + one signal (the simple-app floor) 11.97 KB 0 B 12.05 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 19.66 KB 0 B 19.69 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 30.79 KB 0 B 30.79 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 14.90 KB 0 B 14.94 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 16.42 KB 0 B 16.48 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 30.65 KB 0 B 30.71 KB ✅ lazy-page.js 0.04 KB
frames: eager client consumer (frames client + transport, lazy codec) 12.96 KB +269 B (+2.1%) 12.98 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 46.19 KB +259 B (+0.6%) 46.20 KB ✅ decode.js 6.07 KB, lazy-page.js 0.04 KB
page: live server components (base + live/GET + action + isPending/latest) 50.44 KB +317 B (+0.6%) 50.45 KB ✅ decode.js 6.07 KB, lazy-page.js 0.04 KB
server: floor (getRequestEvent + isServer) 1.33 KB 0 B 1.34 KB ✅
server: renderToString (the server-render floor) 20.37 KB +16 B (+0.1%) 20.38 KB ✅

Bundled with Rolldown (what Vite ships), brotli q11, decimal KB. Caps in scripts/size/scenarios.js; the floor and page caps in floor-caps.json are frozen (lower only, or Size-Exception: in the PR body).

@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 36761159138

Coverage remained the same at 75.991%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1195
Covered Lines: 962
Line Coverage: 80.5%
Relevant Branches: 925
Covered Branches: 649
Branch Coverage: 70.16%
Branches in Coverage %: Yes
Coverage Strength: 27.81 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Sep 30, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 185 untouched benchmarks
⏩ 3 skipped benchmarks1


Comparing feat/text-positions (0949bc8) with next (3c1f809)2

Open in CodSpeed

Footnotes

  1. 3 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

  2. No successful run was found on next (ecb68a1) during the generation of this report, so 3c1f809 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report. ↩

@ryansolid
ryansolid merged commit d4b10b5 into next Sep 30, 2026
7 checks passed
ryansolid added a commit that referenced this pull request Sep 30, 2026
next now carries #3713 and #3714 as squashes ecb68a1 and d4b10b5.
This branch was stacked on their pre-squash commits; the only conflicts
were scripts/size, which this PR does not touch, and resolve to next's.
The result is the tree of merging feat/text-positions @ 0949bc8 into
this branch, where nothing conflicted.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid
ryansolid deleted the feat/text-positions branch September 30, 2026 19:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants