diff --git a/documentation/plans/binding-slot-execution.md b/documentation/plans/binding-slot-execution.md new file mode 100644 index 000000000..f2873d9fd --- /dev/null +++ b/documentation/plans/binding-slot-execution.md @@ -0,0 +1,238 @@ +# 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. + +A server component hands the client one of two slot kinds. A **template +slot** (`Slot`) is placed and filled with markup. A **binding slot** +(`BindingSlot`, today `AttributeSlot`) is called by the +server; the client's fill returns an object whose properties the server +binds at positions — attributes, class names, style properties, handlers, +refs, and text once G1 lands — and never computes with. This doc fixes how +the binding slot's fill runs on the client. + +## What runs today + +`bindDataOccurrence` (`packages/web/frames/src/client.ts:384–530`): + +- The fill runs inside `createMemo` (line 389). A top-level read in its body + tracks, so an eager fill (`const done = toggled() === p.id`) re-runs on + every change, and anything the body created — a signal, a memo, an + `onCleanup` — is disposed and recreated with it. +- One `createRenderEffect` per occurrence (lines 420–437) computes every + consuming element's positions from the memo's output; `assign` diffs the + writes. A getter read there tracks, but its change re-reads every + position of the occurrence. +- Handlers go through a frames-own listener per element (lines 462–491) that + reads `output()[key]` at event time and fans out to every key bound at the + event. No delegation, no `dispatchAsInteraction` wrap. Refs go through a + stable dispatcher (lines 519–533). This replaced direct binding through + `assign` in `04a12a069`, for handler fan-out. + +The template slot's fill, by contrast, is called once, untracked, under a +per-occurrence owner, with live props (lines 689–730; `runWithOwner` clears +`tracking`). Same slot border, two execution models. + +So the fill `hackernews` wants — a signal in the body, getters over it, a +handler — works today only because its memo tracks nothing and never +re-runs. One eager read added to that body and the signal resets on every +change. The model below makes the working case the contract. + +## The model (settled) + +1. **One call, one scope.** A binding slot is always a function. Each call + is an occurrence; its fill runs once, untracked, under the occurrence's + owner (the one `slotsFor` already creates, lines 605–640), with live + args — exactly as a template slot's fill and a component body run. State + created in the body lives as long as the occurrence. Args are optional; + the scope is why a no-args slot is still a function. +2. **An object only.** The fill returns a plain object: plain values + (static), getters (reactive), handlers and refs as values. Arrays, DOM + nodes, functions and async values are excluded — at runtime the existing + `fill-shape` finding (lines 391–403), at the type level a constraint on + `Bindings` (below). Top-level reads are one-time reads, the same as in a + component body; getters are the reactive form, the same as a component's + props object. +3. **One render effect per occurrence** for its value positions, as + today (lines 420–437). A getter's change re-reads the occurrence's + positions and `assign` writes only what changed. This is client JSX's + grouping: a template's dynamic attributes share one effect + (`attributeExpressions` fixture), and the cost of the coarser group is + re-reading a few getters, not DOM writes. An occurrence can span more + than one server template, so it can group more than client JSX would; + that costs reads, not effects. Text positions (G1) join the same + effect: a binding value at a text position is a primitive, so its + write is attribute-shaped — client JSX gives an insert its own effect + for content generality a binding value never has. Rebinds keep today's path (`ctx.onRebind` feeds the effect's + consumer list; an element that leaves gets a final empty write). +4. **Handlers and refs bind once, through `assign`.** Read once when the + element binds, untracked, and handed to `assign`/`assignProp` + (`packages/web/src/client.ts:2534–2561`), which delegates the events + client JSX delegates, binds tuples, wraps with `dispatchAsInteraction`, + and removes the previous listener. A handler behind a getter is read + once, as `onClick={cond() ? a : b}` is in client JSX. The own listener + goes. The internal marker `on:` (2.0 removed the authored `on:` + namespace — `MIGRATION.md:580`) maps to `"on" + event`, which + `assignProp` lowercases. Fan-out survives for refs only: several keys at + one ref position (`_s:ref="occ:a,occ:b"`) keep the stable dispatcher. + Handler duplicates are last-wins on the server since #3704, so a handler + position names one key. +5. **Names.** `AttributeSlot` → `BindingSlot`. The name + speaks to the server author, where misuse happens: a stand-in is always + truthy, so a binding slot's value must never be branched on, computed + with or passed along. `DataSlot` and `PropsSlot` both read as a value to + use. + +### The type constraint + +`Bindings` stays `extends object` (an F-bounded `J extends SlotOutput` is +a circular constraint, TS2313), and the slot's return type becomes +`SlotOutput`: `J` when valid, else a branded `SlotError<"reason">` that +fails assignment with the reason in the message. + +```ts +declare const slotError: unique symbol; +type SlotError = { [slotError]: M }; +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}`>; + +export type BindingSlot

> = {} extends P + ? (props?: P & { $key?: string | number }) => SlotOutput + : (props: P & { $key?: string | number }) => SlotOutput; +``` + +Prototyped (temp files, removed): `BindingSlot<{}, string[]>` errors with +the array reason both where the server reads the slot and where the client +passes its fill; `{ $x; ok }` errors with the reserved key; a valid +`Toggle` slot is untouched on both sides. + +### Deferred: the accessor return + +A fill returning an accessor (`() => ({ … })`) would be one effect over a +whole object. It is not added: getters are Solid's idiom for a props-like +object, `createMemo` in the body covers "compute once, share across keys", +and a function return is an error today (runtime finding and type), so +adding it later is not breaking. Revisit when real fills show getters over +one source reading as noise. + +## Checks made before writing + +- **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). +- **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`); + dev components use it with their name (`packages/solid/src/client/core.ts:284`), + ``/`` with theirs (`flow.ts:226`, `317`). The fill runs as + `untrack(() => fill(args), IS_DEV && "the \`row\` binding-slot fill")`. + No new diagnostic. +- **Delegation keeps what fills rely on.** The delegated dispatcher calls + `handler.call(node, e)` or `handler.call(node, data, e)` and simulates + `currentTarget` (`client.ts:2621–2651`), so `this`, `currentTarget` and + tuples behave as the own listener's do (spec "a handler position receives + tuples…"). A non-delegated event (`myevent` in that spec) takes + `addEventListener`. Release is `assignProp(…, undefined, prev)`, which + clears the delegated slot or removes the listener. +- **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 + `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 + "ran once, positions updated". The case at line 878 ("a fill of getters + … runs once") already states the model. +- **Benches.** `spread-slot-walk`, `spread-static-tail` and `border-walk` + are server benches (`packages/web/test/server/`); nothing measures the + client binding. Effect count is unchanged (decision 5), so none is added. + +## Public API changes (flagged) + +Each is a change to documented or observable behavior: + +1. **An eager plain-object fill stops updating.** A top-level read in the + fill body is a one-time read; dev warns `STRICT_READ_UNTRACKED`. Getters + are the reactive form. +2. **State in a fill body lives as long as the occurrence** (today: until + the memo's next run). +3. **Handlers become delegated** for the events client JSX delegates. A + binding handler's `e.stopPropagation()` no longer stops a native listener + on an ancestor, exactly as in client JSX. +4. **Handlers and refs are read once.** A getter for a handler no longer + re-reads per event. +5. **`AttributeSlot` → `BindingSlot`**, with the return type + constrained (`SlotError`). No alias. +6. **Diagnostic code `ATTRIBUTE_SLOT_POSITION` → `BINDING_SLOT_POSITION`.** +7. **Server-side handler tuples become a finding** (decision 1). +8. **Template fills warn `STRICT_READ_UNTRACKED`** on top-level reads + (decision 2). + +## Decisions (2026-09-29) + +1. **Server-side handler tuples are a finding.** Read, not yet reproduced: + `claimEntries` (`packages/web/src/server.ts:5085–5094`) flattens an array + at every position. At a handler position `onKeyDown={[row.key, 1]}` emits + `_s:on:keydown="occ:key"` and drops `1` silently; `[row.key, row.data]` + emits two keys, and the client reads `data` as a second handler and skips + it (`typeof h === "function"`). The flatten exists for merged refs + (`[[a, b], c]`). Fix: flatten only at `ref`; an array at a handler + position is a dev finding (`reason: "tuple"`) pointing at the fill — + return `onKeyDown: [handler, data]` from the fill, which `assignProp` + binds. The data a server tuple would carry is server data the args + already pass. Spec first (server marker, client dispatch). +2. **Template fills get the same dev signal.** They run untracked with no + label today, so a top-level read there silently doesn't track either. + Both fills run under `untrack(fn, label)`; a new warning in an existing + context (flagged). +3. **No `Bound`.** It would not close the gap it targets: TypeScript + accepts any object in a condition, so `t.open ? a : b` type-checks with + an opaque `Bound` exactly as with `boolean`, while every + attribute type in `jsx.d.ts` would have to accept it and the + shared-component idiom (`TodoRow` takes `RowBehavior` on both sides) + would need `Bound | RowBehavior`. The server's runtime + findings catch every coercion that goes through `Symbol.toPrimitive` + (comparison, arithmetic, template literal, `String()` — + `server.ts:4703–4724`); truthiness has no hook in the language and is + caught by nothing but the rule. That remaining hole is recorded, not + closed; a type-aware lint rule could reach it. +4. **Naming.** `ATTRIBUTE_SLOT_POSITION` → `BINDING_SLOT_POSITION`; no + `AttributeSlot` alias; the spec file, §9.2.3, the frames skill and + `08-dev-diagnostics.md` move to the new vocabulary in the same change. + +5. **One effect per occurrence** (model point 3). Per element was + considered and dropped: it multiplied effects (four per `todos-server` + row) to save getter re-reads, and none of the problems this doc fixes + comes from the grouping. + +## Implementation + +1. Specs first, failing: a fill with a top-level signal read and a signal + created in the body (value frozen, state survives, dev warning); a getter + change writing only its position; a delegated handler; a release + through `assign`; the tuple case (decision 1). +2. `bindDataOccurrence`: the fill called once under + `untrack(…, label)` in the occurrence's owner; the shape finding on its + result; the occurrence's one render effect over `consumers()` for value + positions through `assign`'s diff; handlers and refs read once when an + element binds, outside the effect's tracking. +3. Server: `claimEntries` per decision 1; the diagnostic rename. +4. Types: `BindingSlot`, `SlotOutput`, `SlotError` in + `packages/web/frames/src/server.ts`; type tests for each rejected shape + on both sides. +5. Docs and skill in the same PR; changeset for `@solidjs/web`; the PR + body lists every change above under its own heading. + +G1 (text positions) follows +([`text-positions.md`](./text-positions.md)): a text position is one more +value position in the occurrence's effect. diff --git a/documentation/plans/examples-grid-plan.md b/documentation/plans/examples-grid-plan.md new file mode 100644 index 000000000..dfd15ff97 --- /dev/null +++ b/documentation/plans/examples-grid-plan.md @@ -0,0 +1,395 @@ +# Examples Plan — one idiomatic app per grid coordinate + +_Drafted 2026-09-28, from the fit conversation that produced principles §10 +and §9.6, with Stages 1–8 built and #3704 (attribute slots, second form) +the last heavy feature. Status: DIRECTION AGREED in conversation; nothing +here is built. This page is the checklist for the example set as a whole; +the two flagships get their own plans (linked below) and this page does not +design them. Design record: +`documentation/server-components/server-components-principles.md` §10 (fit) +and §9.6 (live mutations seed). The grid is the one in +["The Grand Unifying Architecture of Frontend"](https://dev.to/playfulprogramming/the-grand-unifying-architecture-of-frontend-bhk) +(dev.to, 2026-09-22): response window on the horizontal axis +(request/response → persistent), affordance weight on the vertical +(server-owned markup → client-owned state), and three responsibilities every +app separates — navigation (client), content (server), affordances +(client). Owner: Ryan._ + +## Objective + +The examples were built as test beds, one per feature, and that was the +right way to build the features. Now that the feature set is complete they +should become **good examples**: one idiomatic app per coordinate on the +grid, each the app it would be if the framework didn't need proving, with +`rendering` remaining the one deliberate kitchen sink. Two of them are +flagships — realistic products, not demos — because the right half of the +grid is where no one else has a story and an AI chat and a board are the +two apps people are actually building there. + +## The bar (every example) + +Priority order; a lower item never overrides a higher one. + +1. **One coordinate.** The README's first paragraph names it and names the + example's twin (the same app at another coordinate) where one exists. +2. **The app's own affordances and nothing else.** Every feature present is + one the app would have if the competitors didn't exist. Features with no + idiomatic home live in specs, not examples — that absence is evidence + for principles §10.5, not a gap to fill. +3. **The three responsibilities visible.** Which elements the compiler + claims (navigation), which functions are content (`"use server"`), and + what the overlay is (affordances) should be readable from the source + without a tour. +4. **Lead with the affordance that is hardest elsewhere.** Among the app's + natural affordances, the README and the polish go to the _layering + moment_ — the place where client behaviour on server markup is one line + here and a DSL, a controller, or a round trip in Datastar, Turbo + + Stimulus, or LiveView. By demonstration only: READMEs stay neutral and + name no competitor; the head-to-head is an article. + +The trivial case must stay trivial: one client position with no per-item +data is one line each side (`codeBlock={() => ({ onCopy })}` on the client, +`onClick={block.onCopy}` on the server). A stateless fill is the object +literal; a fill that owns state is its signals plus the literal. No example +may need more; where one does, that is a §9.2.3 ergonomics finding to +raise, not something example code hides. + +**Authoring layout** (decided 2026-09-29, every example with a server +side, twins included). One screen is one file; the directive marks the +server part in place: + +- A server component is a function-level `"use server"` inside its client + wrapper, in the file that uses it: + `const getStory = query(async (id: string) => { "use server"; … }, "story")`. + The binding is the wrapper, so the name is the server function's name + (`getStory`, as any server function is named) and there is no second + name to invent. Actions likewise (`action(async (…) => { "use server"; … })`). +- The route file holds the screen: its query with the server component + inline, server-only helpers the component renders (a recursive + `Comment`), the slot's type, and the route component with its fills + inline. Everything referenced only from `"use server"` bodies is pruned + from the client build. +- A route file exports only what the router reads — its default component + and `preload`. The query, helpers and types stay module-private; a query + is exported only when another screen shares it. +- `src/server/` holds only server-only modules (`hn.ts` and its capture, + `db.ts`), each beginning `import "server-only";` — the vite plugin's + boundary marker, which fails the build if the module reaches a client + bundle — and imported by namespace (`hn.getStory`, `db.getTodos`), so + the data layer echoing the server function's name reads as intended. + Mixed files (the route files) never import the marker. Inline in a mixed + file goes only markup and helpers whose leak would be harmless; secrets, + server APIs and heavy data live behind the marker. +- `src/` is otherwise client and flat: `app.tsx`, `routes/`, `types.ts`. + No `lib/`, no `api.ts`; `components/` only when there are client + components. + +Verified against the toolchain (not yet by an example build): the +directive pass extracts an inline `"use server"` inside `query(…)` and +names it from the enclosing binding (`getStory-`, not an anonymous +ordinal — stable across reordering); a module-level helper and a +namespace import used only by server bodies are absent from the client +output (compiler fixtures `nested-functions`, `dead-code-scoped`, and a +direct run of the route-file shape); `serverFunctions.components` installs +its result transforms globally and turns on `serverComponents` for every +SSR compile, so neither depends on the directive's level; a non-exported +wrapper registers exactly as an exported one. The first example PR +confirms with its build: passing with the `server-only` markers in place +(pruning precedes resolution) and failing on a deliberate client import. + +## The map + +```text + request/response persistent + ┌──────────────────────────────┬──────────────────────────────┐ + client-heavy │ hackernews-spa SPA + JSON │ board FLAGSHIP │ + (affordances)│ todos SPA + optim │ live data tier, optimistic │ + │ │ store, until, drag, │ + │ │ presence │ + ├──────────────────────────────┼──────────────────────────────┤ + (islands) │ notes RSC coord. │ │ + │ islands + single-flight │ │ + ├──────────────────────────────┼──────────────────────────────┤ + server-heavy │ hackernews reads │ chat FLAGSHIP │ + (markup) │ todos-server writes │ live server components, │ + │ │ durable generation, threads│ + └──────────────────────────────┴──────────────────────────────┘ + off-grid: rendering (SSR-mode kitchen sink) · effect (data tier × Effect) + · sierpinski (renderer perf) · migrating-element (conditional, §V5) + retired: room (both pages absorbed by the flagships) +``` + +Twins: `hackernews` ↔ `hackernews-spa`; `todos-server` ↔ `todos`. The +realistic pair on the right (`board`, `chat`) mirrors the pedagogical pair +on the left (`hackernews`, `notes`), and is its own comparison: the same +primitives with the client owning the markup (`board`) and with the server +owning it (`chat`). + +## Per-example disposition + +### `hackernews` — bottom-left, reads. KEEP; collapse → binding slot, layout, README. Blocked on G1, G2 + +The front door: the simplest server component, navigation over server +markup, a single stateful client concern. Its layering moment is comment +collapse — client state on server-rendered elements deep in a tree, surviving +navigation. Today that is a `Toggle` client component wrapping a template +slot (`toggle={p => {p.children}}`); under §9.2.3's +placement principle a thread exists because the server has comments, so it +is server markup and the collapse is a binding slot. + +Target shape (reviewed 2026-09-29): the recursive `Comment` is a server +component and the client never sees the tree — per comment with replies, one +`props.toggle({ $key: c.id })` call whose properties bind the toggle's +`open` class, its `onClick`, its label (text position, G1) and the replies' +`display`. The fill is the SPA twin's `Toggle` almost line for line, under +G2's execution model: a signal in the fill body (it runs once per +occurrence), getters over it, `onToggle` as a plain handler; `$key` makes +the state follow the comment across refetches and die with it, as in the +SPA. No store, no client components. Markup stays byte-identical to the +twin's. In the authoring layout the screen is `routes/story.tsx` — +`getStory` with the server component inline, `Comment`, the `Toggle` +bindings interface and `ToggleSlot`, the route component with the fill +inline — over `server/hn.ts`; `lib/`, `views.tsx`, `api.ts` and +`components/` go. Example-local fixes riding along: `CommentDefinition` +gains the `id` the data already carries; the README's "`$key` keeps it +attached" claim becomes true (today no key is passed); the bundle check +can grep `comment-children` too. + +### `hackernews-spa` — top-left. KEEP; layout only + +The twin; exists only as the comparison. README names the coordinate. Takes +the authoring layout: each route file's `query` carries its server function +inline over `server/hn.ts` (a plain server-only module, no longer a +module-level `"use server"` file). Diffing the twins then shows the thesis +at the route file: the same `getStory`, returning JSON in one and markup in +the other, and client components in one only. + +### `notes` — middle-left, the RSC coordinate. KEEP; authoring layout + README + +React's own server-components demo ported: client islands whose state +survives server updates around them, single-flight mutations by redirect, +the search field as the idiomatic binding slot. Its layering moment is the +editor keeping its draft while the sidebar list refreshes around it — the +"shared client state preserved" line the HTML-partial tools cannot cross. +Behavior unchanged; the code moves to the authoring layout (queries and +actions inline in the files that use them, `server/` for `db.ts`) and the +README is repositioned. The overlap with `chat` (both are +sidebar + viewer + mutations) is intentional: opposite sides of the grid, +different audience. + +### `todos` — top-left. KEEP as is + +The SPA control: optimistic store over `refresh`, client-held API mock. +Twin of `todos-server`; also the pedagogical control for `board`. + +### `todos-server` — bottom-left, writes. RESHAPE + +Today: the §9.2.3 acceptance gate — seven client-owned positions per row, an +intent record, a client error map, multi-flight `refresh`. Principles §10 +places it as a widget app wearing collaborative-list clothes; it is not the +example anyone should learn from and the §9.2.3 record stays as its +history. + +Target: the write side of the HTMX corner, made enviable rather than +mimicked. Server markup throughout; every mutation a compiler-claimed +`

` that works without JS; **one** binding slot +with **one** position (`done`) fed by an optimistic store the action writes +before it yields; single-flight responses that morph the row back. About +ten lines of client code, no component beyond the root, instant toggles. +Failures server-rendered: a rejected action's response carries the row with +its error and a retry form. Layering moment: the optimistic toggle. + +Scope line: toggle, remove, and add are optimistic. If any of them needs +more than a store write inside its action, it is not slick and it is out +(the pending-row-for-add template slot is the first candidate to fall; the +README may describe it as the increment). Bulk actions stay plain forms. +Pending feedback, if the router marks a submitting claimed form +(`aria-busy` / `data-pending`), is CSS only — see V4. + +### `chat` — bottom-right FLAGSHIP. REBUILD (own plan) + +Plan: `documentation/plans/chat-flagship.md` (to write first). Realistic +AI chat: threads durable and addressable; generation as a job that outlives +the request, with the thread a `live` server component projecting durable +state (close the tab, come back, caught up in one morph; two tabs agree); +real model with the fake as no-key fallback; structured message parts; +stop / regenerate / rename / delete as forms; the optimistic user bubble as +a client element in a template slot cleared by `until` on the echo; copy +button as the binding slot; native `
` for collapse. Failed +generations are facts about the thread — durable, server-rendered with a +retry. Absorbs `room`'s `/` page. The `usage` projection goes (token usage +is a number on the finished message), which removes the container tier's +only example — flagged, consistent with principles §10.5. + +### `board` — top-right FLAGSHIP. NEW (own plan) + +Plan: `documentation/plans/board-flagship.md` (to write first). Pure data +tier, no frames: one board of lists and cards as a `live` source, reconciled +by id into an optimistic nested store; moves and reorders as optimistic writes with +`until`; two tabs converging; rejected moves reverting; presence. Drag +within and between lists with pointer events, fractional ordering. Scope: +create / rename / archive, titles only, no card detail. Absorbs `room`'s +`/live` page. Acceptance bar must name the two identity moments explicitly +or they will not get built: a card sliding into its new column (same +element on both sides), and a card being edited inline while another tab +moves it — focus, caret, and draft arriving in the new column intact +(one element per card at board level, referenced from whichever list holds +it). `board` is also the top-right form of `chat`: a live thread read by +client components into an optimistic store is this architecture with a +transcript instead of lists, which is why no separate `chat-spa` exists. + +### `room` — RETIRE + +Stage 8's test bed. `/` becomes `chat`'s thread; `/live` becomes `board`. +The chaos switch and status pills were apparatus; connection state stays +visible in both flagships through `onstatus`, as an affordance the apps +would have anyway. Delete once both flagships exist. + +### `migrating-element` — CONDITIONAL on V5 + +Today a canvas, because the README records that `