diff --git a/.changeset/compiler-server-components-attribute-slot-positions.md b/.changeset/compiler-server-components-attribute-slot-positions.md new file mode 100644 index 000000000..16124f5c5 --- /dev/null +++ b/.changeset/compiler-server-components-attribute-slot-positions.md @@ -0,0 +1,6 @@ +--- +"@solidjs/babel-plugin": patch +"@solidjs/compiler": patch +--- + +`serverComponents` (SSR): keep attribute-slot positions bindable on intrinsic elements. A dynamic `class` or `style` now compiles to a whole-attribute `_$ssrElementAttribute` hole instead of a value inside the template's quotes, so a server component's `class={{ selected: filters.all }}` can mark the class name a client attribute slot owns; `ref`/`on*` positions compile, as before, to one guarded `_$ssrClaim` hole per element, which now emits `_s:on:*`/`_s:ref` markers for slot reads (the `_bnd` behavior-claim marker is gone). A spread element compiles its named `ref`/`on*` (`` puts a retry button on every + row. That is the one case only the sentence above catches, and why + it is the sentence to teach — to people and to agents. +- *Spreading an attribute slot's object is an error.* `{...row}` is the + 09-27 shape: the client decides what it owns and the template + cannot show it. Name the positions. Dev throws (the finding is an + `error`); a prod build renders the element with nothing from that + source — the misuse is caught in development, never in production. +- *Reserved keys.* The call's return doubles as a placeable range so + the same call serves both output types, and the range's own reads + pass through the proxy: keys beginning with `$` or a digit (the + walker's index reads), `length` and `slice` (the resolver's copy of + a placed range), the node keys `t`/`h`/`p`, `then` + (thenable probes), and the four `Object.prototype` names an engine + coerces through (`constructor`, `toString`, `valueOf`, `toJSON`). + Every other string key — `filter`, `map`, `at`, `sort`, `join` + included — is a property read of the fill's output, on both faces + (the set is explicit, not "whatever the range has": the document + face's range is an array and the stream face's is not, and + `key in range` had let `Array.prototype` answer on one face only). + A fill output that uses a reserved key is a document-face dev + finding (`reserved-key`). +- *A stand-in is not an argument.* `props.child({ parentId: + parent.id })` passes another slot's value as data the server does + not have — at the top or nested in plain objects and arrays + (`{ nested: { x: row.done } }`, `[row.done]`; a `Map`, `Set` or + class instance is the app's and is not walked); the arg carries + `undefined` at that path on both faces (the record, and the + document face's t = 0 fill, which must read what hydration will) + and dev says so (`arg`, with the path — once per stand-in, at its + first path). A cyclic arg crosses as a cycle. Pass the server's own + value, or read it in the client fill from client state. +- *A handler position is `on`.* The marker carries the + runtime's derivation (`onClick` → `click`), on a template element + and a spread element alike: under `serverComponents` both compile + their named `ref`/`on*` to one claim map (`{ click: expr, ref: + [a, b] }` — duplicate refs merged, a handler tuple kept whole, a + duplicate handler last-wins) that is read only inside a server + component's render — the template element's as the guarded + `ssrClaim` hole, the spread element's as a thunk `ssrElement` + takes, keyed by the index of the source each attribute sits before + (`` → `{ 1: { click: go } }`) — so + plain SSR never evaluates a handler expression. A spread's own + handler keys bind through the same reading, and a handler position + settles in *source order*, because the marker promises what the + client binds and the client compiles the same element to + `spread(el, [a, { onClick: go }, b])`: the last source that *has* + the key wins (`collectProps` shadows an earlier source's key by + presence; `merge()` looks a key up with `in`), a named attribute + being a source at its position — so a key holding `undefined` + owns the position too, and binds nothing (`` binds nothing when `cond` is + false, on both sides). Refs merge whatever their order (the client + fires every ref); a nullish ref contributes nothing. A server-local + function at any of these is a finding (`server-local`) — where it + is the one the client would bind. `prop:*` positions are not bindable (the server renders + no properties): a stand-in there is a finding (`prop`) on the + runtime spread path; the compiled form drops `prop:*` as SSR + always has. +- *The occurrence is the call, not the element.* `$key` on the call + is occurrence identity (client state follows the entity across + responses); `$key` on the `
  • ` is morph identity for the node. + Two keys, two jobs. An occurrence lives while any consuming + element does; its consumers may change per response (a row gains + a bound button) without the fill re-running. **`$key` is + optional.** A call is one occurrence however often the render + evaluates it: the natural shape puts the call in a shared + component's prop — `` + — and compiled props are getters, so every position the component + binds re-evaluates the expression; the first call's proxy answers + the rest and the record emits once (without this, one record per + position — the double-data disease). A `$key`ed call repeats by + name; an un-keyed call repeats by *structural args* once its face + is known to be data (identical args are an identical fill output, + so one occurrence for both sites changes nothing on screen) — + never for a placed range, since two `` + are two ranges, and never for args identity cannot read by value + (a function, a promise, an iterable). What `$key` adds is identity + *across* responses: state inside the fill's scope follows the + entity through reorders and arg changes; without it that state is + positional per prop, which is right for a stateless fill and wrong + for one holding an edit draft. Correctness never depends on + `$key`; values re-deliver with every response either way. + +**Granularity — the compiler's, and per position where the shared +component forces it.** A client element with +several dynamic attributes compiles to one effect per element that +reads every value, compares each against the last, and writes the +ones that changed. An attribute slot does the same per occurrence: the +fill runs under one computation, the runtime diffs the bound +positions against the last output, and writes the ones that moved +— `class` flipping to `completed` touches `class` and nothing else, +though `hidden` and `onClick` were recomputed. Plain values are +the floor; getters on the returned object are the idiom for a fill +a *shared component* also consumes on the client (build finding, +09-28). The runtime reads each bound value position inside its +tracking computation, so a getter tracks its own sources and the +object is built once — that is finer than the compiler's +per-element effect, but the reason is not granularity. It is the +client face of the same component: `` +compiles to one eager read of `props.row.onToggle` in the component +body — a handler position is bound once, not tracked — and `row` is +a prop getter. A fill that computes its values on construction +(`done: done(p.id, …)`) does that reactive read *there*, in the +untracked body, and the strict-read diagnostic names it: the row +would not update. Getters move every value read to the position +that binds it — a tracking scope for a value, event time for a +handler — and the construction reads nothing. So: plain values when +only the server template reads the output; getters when a client +`` reads it too. Handlers are bound once at mount as a +dispatcher that reads the *current* output's handler, so identity +churn across runs re-attaches nothing. A ref is called once per +(element, property) at mount and excluded from the diff. A +live-delivered arg change re-runs the fill for that occurrence like +any other dependency. + +**Wire.** Per occurrence: the args, once (the 09-27 duplication +across per-element fills is gone by construction). Per *consuming* +element: a marker per bound attribute, `_s:=":"`, +with class names / style properties appended (`_s:class="row#0001:done=completed"`), +events as `_s:on:click`, refs as `_s:ref` — the `_hk` family; the +occurrence alphabet excludes `:` and the key is percent-encoded onto +an alphabet that excludes it too, so the split is exact. Handler and +ref positions cost the name only. On the document face the values +are the attributes you would emit anyway (`class="todo completed"`, +`checked`): zero overhead over static markup. On the stream face +values are omitted — no fill ran, the client is about to write them +— so refetched markup is slightly smaller than static. The morph +needs no ownership table: an incoming element's own `_s:*` +attributes say which positions the client owns, so the morph skips +them (whole attributes) or re-imposes the owned names (class/style) +and everything else is the server's. The names are the only cost +per-position adds over per-element and the part that compresses +best — every row carries the identical pattern. Tighter encodings +(indices, out-of-band) are available and deliberately not taken: +readable on-element markers are worth more than bytes compression +already removes, and Qwik 2's move to a compact `qwik/vnode` blob is +also why nothing external can read its output. + +**What folds in.** Stage 6's behavior claims (§9.1: `onClick={props. +onCopy}`, `ref={props.copyBtn}` — the `_bnd` marker, dispatch-time +resolution by prop name) are the attribute slot with one property and no +data context. They had looked thin for a reason: `ref` never found a +use case on its own, and handlers alone are unstable once you look at +what they attach to — a handler on a checkbox without ownership of +`checked` is the uncontrolled/controlled mismatch (the native flip, +then the refetch morphs `checked` back under a failed or in-flight +mutation), and the row that goes `pending` after its own button was +clicked forces "wrap the row" for the *feedback* of a binding you +were allowed to put on the button unwrapped. Handlers-only yields +"buttons don't need wrapping, checkboxes do." Either a server +element takes no client binding, or the binding carries values; +given handlers are in, values are in, and it is one mechanism. `ref` +returns not because it found a use case but because, under a +position-typed model, *excluding* it is the rule you would have to +teach. So: `_bnd` and `CLAIM_PROP` go; §9.1's three-row table +becomes two rows — *called, placed* (markup) and *called, read at a +position* (data) — and the notes example's search field becomes +`const search = props.search(); `. +The per-element scope §9.1 reserved for refs is now the +per-occurrence scope every attribute slot has; a handler position is +a listener the client attaches on the consuming element itself — +one dispatcher per (element, event), stable across fill runs, that +reads the occurrence's current output at event time and fans out to +every key bound at that position — and the `_bnd` up-walk goes with +the prop-name lookup it served. + +**The compiler round, and why the 09-27 settlement is superseded.** +09-27 chose spread as the only spelling because it is the one +attribute position the SSR compiler defers wholesale to the runtime, +and "no gated transform" was taken as the constraint. That +constraint produced the shape that failed the gate. Per-position +binding needs the compiler at exactly the two places where a value +lands *inside* template quotes: dynamic `class`/`style` compile to +`class="${ssrClassName(x)}"`, and a helper called inside the quotes +cannot emit the sibling marker attribute. So, gated on the +`serverComponents` option both compilers already carry for `_bnd`: +a dynamic `class`/`style` on an intrinsic element compiles to a +whole-attribute hole (`ssrElementAttribute("class", x)`, the helper +the spread path already uses for trailing attributes), and +`class`/`style` object literals are not folded inline there (the +fold would evaluate a stand-in's truthiness). Every other position +already routes through a self-contained helper — `ssrAttribute` for +attributes, the `ssrClaim` hole for events and refs, `ssrElement`'s +walk for spread elements — and those learn the brand at runtime. The +guard is the one `_bnd` introduced; the round is smaller than Stage +6's; plain SSR compiles exactly as before. + +**Prior art.** Kent C. Dodds' prop getters (downshift's +`getItemProps({ item, index })`): called once per item, the result +spread across whichever elements make up the item. This is that +shape with the roles inverted across the wire and the spread made +explicit per position — which is what keeps it analyzable and gives +the server the narrow contract. Marko 6's split of one component into +server markup and the client's reactive residue is what the placement +principle produces without analysis: the server template is the +template, the client ships a function per data context that returns +values, and outside client-created entities no markup crosses. Qwik 2 +kept handlers on the element (`q-e:click`) and moved structure +out-of-band; the same split, and where we would go if bytes ever +argued for it. React Server Components is the pole this refines: its +answer to a server row with client behavior is a client component +around it, which is where the row template goes, and its three +component categories are the cost of drawing the boundary at the +component. + +**Public surface (flagged; nothing here has users yet).** Removed: +`AttributeSlot` (09-27, never released), the `_slot` marker, +`ATTRIBUTE_SLOT_FILL`/`ATTRIBUTE_SLOT_CONFLICT`; Stage 6's `_bnd` +marker, `CLAIM_PROP`, `BEHAVIOR_CLAIM_DROPPED`, the frame `props` +option and host `delegate` plumbing that served `_bnd` resolution, +and the direct `onX={props.onX}` / `ref={props.x}` spelling on +server intrinsics (a function-valued prop read at a position is now +a dev error naming the attribute-slot spelling). Added: `AttributeSlot` +(`@solidjs/web/frames`, both faces); the `_s:*` marker family; one +diagnostic code, `ATTRIBUTE_SLOT_POSITION` (dev: a server-local function +or a spread where a slot value belongs, a slot value stringified +outside a bindable position, a reserved key in a fill's output); +`$key` on intrinsic elements in the JSX typings. Compiler: no new +option; the `serverComponents` transform widens as above. + +**Acceptance gate (restated; the gate does not move, the criterion +now has teeth).** `examples/todos-server` re-ported on this shape +with a shared `TodoRow`, ONE `row` attribute slot for the row's whole +behavior, and pending rows that are not inert — parity with the SPA +means an added todo is toggleable and deletable while pending, which +the client component does with the same `rowFor` the server rows +bind. Pass condition, in addition to 9.2.2's behaviors: the client +carries no markup except what the placement principle requires (the +pending row), the server template shows every position the client +owns, and the fills read as small components rather than a lookup +table — if `rowFor` is heavier than the SPA's `TodoItem`, that is +the finding. + +**Open.** + +- *Text positions.* `{row.remaining}` as a child is the natural + fourth kind (TodoMVC's count is one); needs a marker pair in + content, deferred to keep this round to attributes. +- *Client-created entities without client markup.* The pending row + is a `
  • ` with data holes; the only reason it is a client + component is that the client must *produce* the node. The + generalization is a server template stamped once per client item + (`{p =>
  • {p.title}
  • }` + with the client returning data, not markup) — the model closing + in both directions. A separate stage: list identity for client + items, ordering against server items, supersession by a server + row with the same key. §9.2.1's off-response adds live here. +- *Actions against pending ids.* A toggle on a pending row targets an + id the server has not seen; the port sequences it behind the add's + settlement (an example concern, surfaced by parity). + +**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 +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 +the `behaviorClaims` fixtures. Web suites 986 / 1262 / 257, Babel +268, native compiler fixtures green. Where the build departed from +the text above, the build is right and the text is amended here: + +- *The stand-in is the slot proxy's property read.* One proxy over + the call's range (both faces): a key the range has, a `$` key, or + a node key passes through; any other string key answers with a + `SLOT_VALUE`-branded `{ occurrence, key, value, face }`. On the + document face the fill's return is classified once — `null`/ + `undefined` or a plain object is DATA (its properties are the + t = 0 values); a string, an array, a function, or an SSR node is + MARKUP, and a read off it is a dev finding at the position. The + classification edge is the `t` key: an SSR node is `{ t }` plus + `h`/`p` and nothing else, so an object carrying `t` *and* other + keys is data that used a reserved name (`t` unreadable, the rest + binds) and dev names it. Reserved, therefore, and checked on the + document face: `$`-prefixed keys, `t`/`h`/`p`/`then`, and + Object/Array prototype member names (`length`, `map` — the engine + calls array methods on the document face's range through the + proxy). +- *Every attribute helper learns the brand; the compilers touch two + positions.* `ssrAttribute` (an attribute, a boolean), the + class-name and style-property helpers, `ssrElementAttribute` + (whole `class`/`style` — the hole the `serverComponents` round + adds), `ssrClaim` (events, refs — the Stage 6 hole kept, its + marker replaced), and `ssrElement`'s walk for runtime spreads all + recognize a stand-in and emit the marker beside whatever the + position would have written. A stand-in that reaches + stringification — a template literal, a text child — is an + `ATTRIBUTE_SLOT_POSITION` finding; so is spreading the slot's + return itself, a server-local function at a claim position, or a + markup-faced read. Findings dedupe per render on (occurrence, + key, reason, position), because a component's prop getters + re-evaluate positions and the same misuse would otherwise report + once per read. +- *A misused stand-in renders nothing, on both faces.* As first + built, the document face wrote the t = 0 value where a stand-in + was stringified, placed as text or reached an inline `class`/ + `style`, and the stream face wrote nothing — so a misuse looked + right on the first render and broke on the first refetch, the + worst place to find it. Struck the same day: every such position + renders nothing on either face, and the finding is the only + signal. The stand-in also defines `Symbol.toPrimitive`, so a + comparison, arithmetic or `==` (`number`/`default` hint) is its + own reason, `coerced`, distinct from `stringified` (`string` + hint): the message says the server has no value to decide with + and the decision belongs in the fill. Truthiness has no hook — a + stand-in is an object — which is why the rule above is stated as + one sentence, and why `@solidjs/web` ships it as a skill + (`skills/server-components/SKILL.md`, in the package's `files`) + where an agent writing a server component will read it. +- *Marker grammar as built.* `_s:=":"`; + a class name or style property appends `=`; several names + bound off one occurrence on one element join with `,` + (`_s:class="row#1:done=completed,row#1:editing=editing"`); a whole + `class`/`style` read carries no `=`. Events are `_s:on:` + with `onInput` lowercased to `input` (the client runtime's own + derivation; the client attaches a listener under that name); + refs `_s:ref`. Keys and + names percent-encode onto `[A-Za-z0-9_.-]`, the occurrence + alphabet, so `:`/`=`/`,` split exactly. A zero-arg call is the + occurrence named by the prop alone (`codeBlock:onCopy`, no `#n`) — + one data context per prop, the notes search field's shape. The + document face writes the value where the position would have put + it and the marker after. A class-name or style-property position + whose names all resolve empty writes no `class=""` — the marker + alone says the client owns it; a *whole-value* `class`/`style` + read writes what plain SSR writes for the value (`class=""` for + an empty object), so the two faces of the same template agree. +- *Handler positions are one guarded hole per element, as Stage 6 + left them.* The compilers still collect `ref`/`on*` expressions + into `ssrClaim({ click: expr, ref: expr })` behind + `sharedConfig.context.claims`; what changed is inside the helper — + a stand-in becomes an `_s:on:*`/`_s:ref` marker, a server-local + function is `ATTRIBUTE_SLOT_POSITION`, and nothing writes `_bnd`. The + arming enum (`CLAIMS_STREAM` / `CLAIMS_DOCUMENT`) is unchanged and + still what keeps client fill content, which re-enters the zone + owner, from marking or warning. On the document face the enum is + armed on a render context *derived* from the page's (prototype + inheritance, as a Loading boundary's buffered context) for the + component's subtree alone: the page's own elements after the + component keep the pre-slot walk, and a late hole minted inside + re-emits under its mint-time context, still armed. On the client, + the listeners an occurrence attaches are its own: its end (a later + response drops it; a positional id now names another row) detaches + them, so a kept un-keyed element carries one listener, not one per + occurrence that ever bound it, and a dropped occurrence's handler + never fires through its disposed fill. +- *A repeated call is one occurrence per render, on both faces — + keyed or not.* Found by the first todos port, which emitted eleven + `sc:slot:…row#` records per row (one per position read through + `props.row`'s getter); first closed for `$key`ed calls only, with + a rule that a getter-re-evaluated call must carry `$key`. That + rule was then struck (same day, the AI-usability review: an + unenforced rule whose failure is silent duplication is a trap, not + a rule) and `$key` made optional, as the rules above now say. Both + proxies keep two per-render maps: occurrence id → proxy for keyed + calls, registered at the call; `prop + structural args` → proxy + for un-keyed calls, registered when the face is known to be data — + at the first property read on the stream face (`slotProxy`'s + `onData`), at the fill's classification on the document face. + Args with a getter, a function, a promise or an iterable are never + compared. A placed range never registers: two identical positional + markup calls stay two ranges (pinned). No wire change: ids stay + `prop#`. +- *A data occurrence's nodes are its consumers, and it is never a + zombie.* The client's slot discovery collects `_s:*` elements into + per-occurrence consumer lists `[{ element, positions }]` alongside + the range walk. The occurrence's "nodes" are those elements (so the + existing bookkeeping sees them), but the zombie rule — output whose + node left the tree remounts fresh — does not apply: a replaced + consumer is a *consumer change*, and an occurrence no element + reads is simply not found and unmounts at the sync's end. Consumer + sets compare structurally per sync; a change without an args + change rebinds in place through a per-occurrence rebinder (the + fill's computation stays; new elements and positions take their + current values), independent of the args path that follows. + `#syncSlots` runs at the end of every flush and scoped to the + materialized fragment at each segment reveal, so positions inside + late-revealed content bind when they appear. +- *Binding: values in the compute phase, writes in the effect.* The + client mount is `createMemo(() => fill(args))` under the + occurrence's owner, and one render effect per occurrence whose + compute reads the output's value positions per consuming element + (attribute, class name, style property, whole class/style) into a + props object and whose effect phase only `assign`s it against the + element's previous props. Reading in the compute is what makes the + getter idiom work — each getter's sources are tracked by the + binding, not by the fill's memo. Handlers are stable dispatchers + created once per (element, position) that read the *current* + output's handler at event time; a ref fires once per (element, + property). Built the other way first (reads in the effect phase): + values did not update under getters, which is how the + compute-phase rule and the Granularity amendment were found. +- *The morph's exception is read off the incoming element.* The + morph parses the new element's `_s:*` attributes into owned + positions and, for each attribute it would set or remove, either + skips it (a whole attribute the client owns) or applies the + server's value and re-imposes the owned names' live state (class + names, style properties). No ownership table, no `ctx.own` + contract: what 09-27 reported per run, the markup states. +- *`AttributeSlot` is conditional on `P`.* `(props?: P & { $key? + }) => J` when `{}` extends `P` — the zero-arg call type-checks — + and required otherwise. Exported as `DataSlot` for a day; renamed + with the diagnostic code (`DATA_SLOT_POSITION` → + `ATTRIBUTE_SLOT_POSITION`) when the reframe above was, so that + the type, the code, the specs and this section say one thing. + The wire is untouched: `_s:*` markers, `SLOT_*` exports, + `sc:slot:` record ids are as they were. +- *No compiler change to the DOM output.* `$key` on an intrinsic + strips at a DOM compile (already so); the `serverComponents` SSR + transform is the only codegen touched, and plain SSR output is + byte-identical to before. + +Confirmed empirically, `examples/todos-server` re-ported to the +shape and driven in a browser against the dev server, document SSR +and hydration included: one `TodoRow` on both sides; `row` (per +todo, keyed), `list` and `filters` (zero-arg) attribute slots; `pending` +and `count` markup (the count is the text position the open list +defers); one record per occurrence; hydrated `checked`/ +`class`/`hidden` in the HTML before JavaScript and bound with no +re-render; toggle / retry / toggle-all / clear-completed / filters; +row nodes stable across settles; the count right through pending +adds and their toggles; zero console warnings in dev (the strict-read +diagnostic was the tell that found the compute-phase rule). The +notes example's search field (`_s:value`, `_s:on:input`, +`_s:on:submit`, `_s:class="search:active=spinner--active"`, +`_s:aria-busy`) and the chat example's `codeBlock` copy button +(`_s:on:click="codeBlock:onCopy"`, a zero-arg occurrence bound inside +streamed segment content) both moved off `_bnd` and work. + +The gate's criterion, this time: `rowFor` is seven properties — four +getters and three closures — against the SPA's `TodoItem`, which +holds the same seven things and the markup; the client ships no row +markup except the pending row, which is `TodoRow` again. The +server template shows every position the client owns. Passed. + +Findings from the port, none of them slot mechanics: + +- *Pending rows that are not inert* need two things the SPA never + did. `` must be handed the store's own intent objects (stable + identity; the default keyed mode) — a spread copy per array change + remounted every pending row. And a toggle or remove on a pending + id waits for the add's promise (`inflight` map) and then, if the + add failed, edits the failed-add error record locally (the todo + lives nowhere else); the count skips `intent.byId` entries for + ids that are still extra rows and counts the extras themselves. +- *The shared component's `$key` is on the `
  • ` inside `TodoRow`* + (`
  • `), not at the call site — the call carries + its own `$key` for the occurrence. Two keys, two jobs, as written; + the port shows where each one physically goes. +- *Chat's greeting at t = 0 replays only its first paragraph.* Not + this work: reproduced on the branch's HEAD with the tree stashed. + Recorded here so the next reader does not chase it into slots. +- *One flake, run to ground.* Two early browser runs of the chat + example never invoked the `codeBlock` fill (no marker was bound); + after a web rebuild and a cleared Vite dep cache, three + consecutive runs bound it. The alternative that would have been a + bug — a race between the segment reveal's scoped sync and the + copy button's arrival — was tested rather than argued: jsdom + 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 + 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 + 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 + final restart). Closed as an environment artifact — and it left + a finding: the failure was *silent*. Every misuse in this model + reports on the server; the one failure that reaches a user — a + marked element whose positions never bind, so a button does + nothing when clicked — reported nothing. The frame client now + names it (`ATTRIBUTE_SLOT_POSITION`, reason `orphan`, once per + occurrence per frame) at the point `#syncSlots` classifies the + occurrence: no fill resolves for the prop (`why: "fill"`), or a + *called* occurrence has no args record once records can no + longer arrive (`why: "record"` — the producer emits the record + ahead of the markup that reads it, so a missing one is the + protocol out of step, never the fill; a bare occurrence has no + record by design). Behavior is unchanged in both cases. Honest + limit: the flake's own shape — a *stale client* — is the one + skew no client check can see, because the stale client lacks + the check; the finding covers the newer-client, dropped-record + and id-mismatch shapes, and the missing-prop misconfiguration. + +Still open from the list above, unchanged: text positions; +client-created entities without client markup; actions against +pending ids as a general concern (the port's sequencing is an +example's answer). + ### 9.3 Stage 8 seed — connection-shaped transport (2026-08-17) Recorded from the design conversation; nothing here is built (the diff --git a/documentation/solid-2.0/08-dev-diagnostics.md b/documentation/solid-2.0/08-dev-diagnostics.md index 62e2727ca..1247908e7 100644 --- a/documentation/solid-2.0/08-dev-diagnostics.md +++ b/documentation/solid-2.0/08-dev-diagnostics.md @@ -697,14 +697,22 @@ 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. -#### `BEHAVIOR_CLAIM_DROPPED` +#### `ATTRIBUTE_SLOT_POSITION` **Messages:** -- "[BEHAVIOR_CLAIM_DROPPED] A spread on a server-rendered
    diff --git a/examples/chat/src/lib/model.ts b/examples/chat/src/lib/model.ts
    index 68e3531fa..639066eab 100644
    --- a/examples/chat/src/lib/model.ts
    +++ b/examples/chat/src/lib/model.ts
    @@ -105,8 +105,8 @@ 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 is a client handler on a server element
    -
    +// and its Copy button reads a client attribute slot on a server element
    +
     \`\`\`
     
     When the app hydrates it adopts this reply mid-sentence and replays whatever it missed — no fetch, no JSON twin. Ask about **server components**, **signals**, or **markdown** and the next reply arrives the other way: a server-function call streaming over its own connection.`;
    diff --git a/examples/notes/src/app.tsx b/examples/notes/src/app.tsx
    index 85c78465f..9de24cc92 100644
    --- a/examples/notes/src/app.tsx
    +++ b/examples/notes/src/app.tsx
    @@ -2,10 +2,10 @@
     // the same composition, but the shell's markup lives in server/App.tsx and
     // 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 anymore: its markup is server
    -// chrome, and searchField() contributes only behavior props (Stage 6 —
    -// event props resolve through the frame at dispatch, ref props hand the
    -// client the elements at adoption). Nothing here fetches data; every read
    +// 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
    +// 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
     // New/Edit buttons) aren't client positions at all: the router intercepts
     // plain anchors, so they render entirely on the server.
    @@ -34,7 +34,7 @@ export default function App() {
             return (
               Loading...}>
                 
                        } />
    diff --git a/examples/notes/src/components/searchField.ts b/examples/notes/src/components/searchField.ts
    index 71bdf6c0f..6ab9855c8 100644
    --- a/examples/notes/src/components/searchField.ts
    +++ b/examples/notes/src/components/searchField.ts
    @@ -5,57 +5,54 @@
      * LICENSE file in the root directory of this source tree.
      *
      */
    -// The demo's SearchField.client.js, dissolved (Stage 6). The search field's
    -// MARKUP lives in the server shell (server/App.tsx); what remains here is
    -// pure behavior — a bag of functions the client hands the server component:
    +// 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):
    +// 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.
     //
    -// - `onSearch`/`onSubmit` are event props: the server marks the elements,
    -//   and the document-level delegation walk resolves them through the
    -//   frame's live props at dispatch time.
    -// - `searchInput`/`spinner` are ref props: they fire with the adopted
    -//   elements under this component's owner, so the effects inside sync
    -//   server-rendered DOM against client router state (the input restores
    -//   `?searchText` on deep links and back/forward; the spinner tracks the
    -//   pending navigation) and dispose with the app.
    -//
    -// A word on fit, because this file shows the PATTERN'S BOUNDARY as much as
    -// the pattern. Event props and one-way refs (the spinner) are the sweet
    -// spot: behavior on chrome you'd never ship a component for — and in chat's
    -// copy buttons, on markup the client couldn't author at all. The input's
    -// value-sync effect below is the edge: once an element's STATE must track
    -// client reactivity, a ref means hand-writing the binding that JSX's
    -// `value={...}` gives a client component for free. We keep the input server
    -// chrome here because one three-line effect is a fair trade for dissolving
    -// the shell's last hydration island — but when an element is mostly client
    -// state, make it a client position and let JSX do the syncing.
    +// 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
    +// 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.
     //
     // Search state itself is unchanged: the `?searchText` query param, so typing
     // navigates — the router reruns the root preload and the notes-list server
     // component refetches, morphing the list boundary in place.
     import { useSearchParams } from "@solidjs/router";
    -import { createEffect, isPending } from "solid-js";
    +import { isPending } from "solid-js";
    +
    +/** What the client decides about the search field. */
    +export interface SearchBehavior {
    +  value: string;
    +  active: boolean;
    +  /** `aria-busy` wants the string, not the boolean's bare attribute. */
    +  busy: "true" | "false";
    +  onInput: (e: InputEvent) => void;
    +  onSubmit: (e: SubmitEvent) => void;
    +}
     
     export default function searchField() {
       const [search, setParams] = useSearchParams();
    -  const isSearching = () => isPending(() => search.searchText);
    -  return {
    -    onSearch: (e: InputEvent) => {
    -      setParams({ searchText: (e.target as HTMLInputElement).value });
    +  const isSearching = () => !!isPending(() => search.searchText);
    +  return (): SearchBehavior => ({
    +    get value() {
    +      return (search.searchText as string) || "";
         },
    -    onSubmit: (e: SubmitEvent) => e.preventDefault(),
    -    searchInput: (el: HTMLInputElement) => {
    -      createEffect(
    -        () => (search.searchText as string) || "",
    -        text => {
    -          el.value = text;
    -        }
    -      );
    +    get active() {
    +      return isSearching();
    +    },
    +    get busy() {
    +      return isSearching() ? "true" : "false";
    +    },
    +    onInput: (e: InputEvent) => {
    +      setParams({ searchText: (e.target as HTMLInputElement).value });
         },
    -    spinner: (el: HTMLElement) => {
    -      createEffect(isSearching, active => {
    -        el.classList.toggle("spinner--active", !!active);
    -        el.setAttribute("aria-busy", String(!!active));
    -      });
    -    }
    -  };
    +    onSubmit: (e: SubmitEvent) => e.preventDefault()
    +  });
     }
    diff --git a/examples/notes/src/server/App.tsx b/examples/notes/src/server/App.tsx
    index f63d41c54..dc759a649 100644
    --- a/examples/notes/src/server/App.tsx
    +++ b/examples/notes/src/server/App.tsx
    @@ -7,17 +7,16 @@
     // components (their own boundaries) that refresh fine-grained while the
     // shell stands still.
     //
    -// The search field is NOT a client position anymore (the React demo's
    +// 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
    -// only behavior — `onInput` is an event prop resolved through the frame's
    -// live props at dispatch, and the two refs hand the client the input and
    -// spinner elements at adoption, where effects sync them against router
    -// state. One input needed a whole shipped component before; now it needs
    -// three functions. (This is also the pattern's boundary: the input's value
    -// tracks client router state, so one of those functions hand-syncs what a
    -// client component would write as `value={...}` — see searchField.ts for
    -// when to choose which.)
    +// an ATTRIBUTE 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
    +// they track is the client's, so the reads are getters over it and each
    +// position updates on its own. One input needed a whole shipped component
    +// before; now it needs one small object — see searchField.ts.
     //
     // The New button is NOT a client position either: EditButton is a plain
     // anchor, and the router intercepts every same-origin  at the document
    @@ -25,50 +24,56 @@
     // (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 EditButton from "~/components/EditButton";
    +import type { SearchBehavior } from "~/components/searchField";
     
     export async function appView() {
       return (props: {
    -    onSearch: (e: InputEvent) => void;
    -    onSubmit: (e: SubmitEvent) => void;
    -    searchInput: (el: HTMLInputElement) => void;
    -    spinner: (el: HTMLElement) => void;
    +    search: AttributeSlot<{}, SearchBehavior>;
         noteList: JSX.Element;
         children: JSX.Element;
    -  }) => (
    -    
    -
    - ); +
    {props.children}
    + + ); + }; } diff --git a/examples/todos-server/README.md b/examples/todos-server/README.md new file mode 100644 index 000000000..c458e060b --- /dev/null +++ b/examples/todos-server/README.md @@ -0,0 +1,119 @@ +# Todos — TodoMVC as a Solid Server Component + +The [../todos](../todos) example, with the list moved to the server. The +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 +sides render. + +Same deliberately unreliable API as the SPA (400 ms saves, ~33% of them +fail), so the optimistic UI, the per-item errors and the retry affordances +get exercised. + +```sh +pnpm dev # http://localhost:3010 +pnpm build && pnpm start # http://localhost:3010 +``` + +## Attribute slots + +A slot renders one of two things: markup (placed as ``) or +**attribute values** — 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 +([src/server/todos.tsx](./src/server/todos.tsx)): + +```tsx +const list = props.list({ total, active, completed }); +const filters = props.filters(); + + +All +``` + +`TodoRow` ([src/todo-row.tsx](./src/todo-row.tsx)) is the shared component: +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). + +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`, +`onRemove`; the position names the DOM event, the key names the meaning), +values are nouns (`done`, `rowClass`, `error`), a ref is `ref`. The +runtime reads nothing into the prefix — the position decides what a +property is — but a reader can tell a handler from a value without the type. + +One rule: a slot property is a JSX attribute value, whole, and nothing else. +`class={row.rowClass}` binds; ``class={`todo ${row.rowClass}`}``, +`{row.title}` as text, or `if (row.done)` in the server component do not — +the server has no value to compute with, so the decision belongs in the +fill, which returns the decided value. + +The client's fill receives the args as reactive props and returns the +**object** the template reads ([src/app.tsx](./src/app.tsx)): + +```tsx +const rowFor = (p: Entity): RowBehavior => ({ + get rowClass() { return { todo: true, completed: done(p.id, p.completed), pending: …, errored: … }; }, + get done() { return done(p.id, p.completed); }, + get removed() { return removed(p.id) || !visible(done(p.id, p.completed)); }, + get error() { return errors[p.id] ? `Retry ${errors[p.id].type}` : undefined; }, + onToggle: e => actions.toggleTodo(p.id, e.currentTarget.checked), + onRemove: () => actions.removeTodo(p.id, p.completed), + onRetry: () => actions.retryTodo(p.id) +}); + + ({ all: 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 +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 +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. + +## What the client holds + +The SPA kept the todo array on the client. This app never has it — the rows +are markup. What it holds is **intent** (what it asked the server to do and +has not heard back about) in a `createOptimisticStore`, and the errors it +heard back in a plain store ([src/todos.ts](./src/todos.ts)). Fills combine +those with each call's args: + +- toggle: `intent.byId[id]?.completed ?? p.completed` +- remove: `hidden` on the server's `
  • `; the morph drops the row when the + refetch lands (or `hidden` reverts when the save fails) +- add: the one thing the client cannot decorate is a row the server has not + rendered, so `` is a markup slot where the client renders + in-flight and failed adds — as `TodoRow` again, with `rowFor(todo)`, so a + pending row toggles and deletes like any other. Those actions wait for + the add to settle before calling the server (it has no such id yet); a + todo whose add failed lives only in its error record, so they edit that. +- counts, toggle-all, clear-completed, filters: the server passes its + numbers and id lists as args; the fills adjust them by intent + +## Mutation shape + +Each action writes its intent, calls the server function, then +`yield refresh(todos)` — the "typical multi-flight" shape: the write and the +re-read are two requests, and the action's transaction spans both, so the +optimistic value holds until the refetched markup and args have applied. +(Under a router's single-flight mutations the fills are identical; only the +hold differs.) diff --git a/examples/todos-server/package.json b/examples/todos-server/package.json new file mode 100644 index 000000000..8d5fd7272 --- /dev/null +++ b/examples/todos-server/package.json @@ -0,0 +1,24 @@ +{ + "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", + "version": "0.0.0", + "private": true, + "author": "Ryan Carniato", + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "start": "node server.js", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@solidjs/web": "workspace:*", + "solid-js": "workspace:*", + "unstorage": "^1.17.5" + }, + "devDependencies": { + "@solidjs/vite-plugin": "3.0.0-next.35", + "vite": "^8.0.0" + } +} diff --git a/examples/todos-server/server.js b/examples/todos-server/server.js new file mode 100644 index 000000000..7ca58f846 --- /dev/null +++ b/examples/todos-server/server.js @@ -0,0 +1,113 @@ +// The whole production server: static client assets plus one import — the +// built server bundle's `handleRequest`, an adapter-agnostic web +// `Request -> Response` that streams the SSR render, resolves hashed assets +// through the build manifest, and serves the `/_server` endpoint. The node +// <-> web plumbing below is the only glue. +// +// Responses are compressed because every deployed host compresses text, and +// a benchmark against an uncompressed origin measures the harness rather than +// the app. Brotli quality 4 keeps per-chunk flushes cheap while still ~6x on +// HTML; each SSR chunk is flushed so streaming boundaries survive. +import { createServer } from "node:http"; +import { readFileSync } from "node:fs"; +import { Readable } from "node:stream"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import { brotliCompressSync, createBrotliCompress, createGzip, constants } from "node:zlib"; +import { handleRequest } from "./dist/server/server.js"; + +const root = path.dirname(fileURLToPath(import.meta.url)); +const PORT = process.env.PORT || 3010; + +const MIME = { + ".js": "application/javascript", + ".css": "text/css", + ".html": "text/html", + ".json": "application/json", + ".ico": "image/x-icon", + ".svg": "image/svg+xml" +}; + +/** Negotiated streaming compressor piped into `res`, or null for identity. */ +function encoder(req, res) { + const accepts = req.headers["accept-encoding"] || ""; + let stream; + if (/\bbr\b/.test(accepts)) { + res.setHeader("Content-Encoding", "br"); + stream = createBrotliCompress({ params: { [constants.BROTLI_PARAM_QUALITY]: 4 } }); + } else if (/\bgzip\b/.test(accepts)) { + res.setHeader("Content-Encoding", "gzip"); + stream = createGzip(); + } else return null; + stream.pipe(res); + return stream; +} + +// Static assets compress once at a higher quality — they're cached, not streamed. +const staticBrotli = new Map(); + +function webRequest(req) { + const url = new URL(req.url || "/", `http://${req.headers.host || `localhost:${PORT}`}`); + const method = req.method || "GET"; + const body = method === "GET" || method === "HEAD" ? undefined : Readable.toWeb(req); + return new Request(url, { + method, + headers: req.headers, + body, + ...(body ? { duplex: "half" } : {}) + }); +} + +createServer(async (req, res) => { + const url = req.url || "/"; + + if (url !== "/" && !url.includes("..")) { + const file = url.split("?")[0]; + try { + const content = readFileSync(path.resolve(root, "dist/client" + file)); + const headers = { + "Content-Type": MIME[path.extname(file)] ?? "application/octet-stream", + "Cache-Control": "public, max-age=3600" + }; + if (/\bbr\b/.test(req.headers["accept-encoding"] || "")) { + let compressed = staticBrotli.get(file); + if (!compressed) { + compressed = brotliCompressSync(content, { + params: { [constants.BROTLI_PARAM_QUALITY]: 9 } + }); + staticBrotli.set(file, compressed); + } + res.writeHead(200, { ...headers, "Content-Encoding": "br" }); + return res.end(compressed); + } + res.writeHead(200, headers); + return res.end(content); + } catch { + // Fall through to the handler (SSR routes, /_server, ...). + } + } + + try { + const response = await handleRequest(webRequest(req)); + const cookies = response.headers.getSetCookie?.(); + response.headers.forEach((value, key) => { + if (key !== "set-cookie") res.setHeader(key, value); + }); + if (cookies?.length) res.setHeader("set-cookie", cookies); + res.statusCode = response.status; + const out = encoder(req, res) ?? res; + if (response.body) { + for await (const chunk of response.body) { + out.write(chunk); + if (out !== res) out.flush(); + } + } + out.end(); + } catch (e) { + console.error(e); + res.statusCode = 500; + res.end(e.message); + } +}).listen(PORT, () => { + console.log(`Todos (server components) on http://localhost:${PORT}`); +}); diff --git a/examples/todos-server/src/Document.tsx b/examples/todos-server/src/Document.tsx new file mode 100644 index 000000000..5c25ecd4b --- /dev/null +++ b/examples/todos-server/src/Document.tsx @@ -0,0 +1,16 @@ +import { HydrationScript, type JSX } from "@solidjs/web"; + +export default function Document(props: { children?: JSX.Element }) { + return ( + + + + + + Solid 2.0 Todos (server components) + + + {props.children} + + ); +} diff --git a/examples/todos-server/src/app.css b/examples/todos-server/src/app.css new file mode 100644 index 000000000..249c846af --- /dev/null +++ b/examples/todos-server/src/app.css @@ -0,0 +1,434 @@ +html, +body { + margin: 0; + padding: 0; +} + +button { + margin: 0; + padding: 0; + border: 0; + background: none; + font-size: 100%; + vertical-align: baseline; + font-family: inherit; + font-weight: inherit; + color: inherit; + -webkit-appearance: none; + appearance: none; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + font: + 14px "Helvetica Neue", + Helvetica, + Arial, + sans-serif; + line-height: 1.4em; + background: #f5f5f5; + color: #111111; + min-width: 230px; + max-width: 550px; + margin: 0 auto; + font-weight: 300; +} + +:focus { + outline: 0; +} + +.hidden { + display: none; +} + +.todoapp { + background: #fff; + margin: 130px 0 40px 0; + position: relative; + box-shadow: + 0 2px 4px 0 rgba(0, 0, 0, 0.2), + 0 25px 50px 0 rgba(0, 0, 0, 0.1); +} + +.todoapp input::-webkit-input-placeholder { + font-style: italic; + font-weight: 300; + color: rgba(0, 0, 0, 0.4); +} + +.todoapp input::-moz-placeholder { + font-style: italic; + font-weight: 300; + color: rgba(0, 0, 0, 0.4); +} + +.todoapp input::input-placeholder { + font-style: italic; + font-weight: 300; + color: rgba(0, 0, 0, 0.4); +} + +.todoapp h1 { + position: absolute; + top: -140px; + width: 100%; + font-size: 80px; + font-weight: 200; + text-align: center; + color: #b83f45; + -webkit-text-rendering: optimizeLegibility; + -moz-text-rendering: optimizeLegibility; + text-rendering: optimizeLegibility; +} + +.new-todo, +.edit { + position: relative; + margin: 0; + width: 100%; + font-size: 24px; + font-family: inherit; + font-weight: inherit; + line-height: 1.4em; + color: inherit; + padding: 6px; + border: 1px solid #999; + box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); + box-sizing: border-box; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.new-todo { + padding: 16px 16px 16px 60px; + height: 65px; + border: none; + background: rgba(0, 0, 0, 0.003); + box-shadow: inset 0 -2px 1px rgba(0, 0, 0, 0.03); +} + +.main { + position: relative; + z-index: 2; + border-top: 1px solid #e6e6e6; +} + +.toggle-all { + width: 1px; + height: 1px; + border: none; + opacity: 0; + position: absolute; + right: 100%; + bottom: 100%; +} + +.toggle-all + label { + width: 45px; + height: 65px; + font-size: 0; + position: absolute; + top: -65px; + left: -0; +} + +.toggle-all + label:before { + content: "❯"; + display: inline-block; + font-size: 22px; + color: #949494; + padding: 10px 27px 10px 27px; + -webkit-transform: rotate(90deg); + transform: rotate(90deg); +} + +.toggle-all:checked + label:before { + color: #484848; +} + +.todo-list { + margin: 0; + padding: 0; + list-style: none; +} + +.todo-list li { + position: relative; + font-size: 24px; + border-bottom: 1px solid #ededed; +} + +.todo-list li:last-child { + border-bottom: none; +} + +.todo-list li.editing { + border-bottom: none; + padding: 0; +} + +.todo-list li.editing .edit { + display: block; + width: calc(100% - 43px); + padding: 12px 16px; + margin: 0 0 0 43px; +} + +.todo-list li.editing .view { + display: none; +} + +.todo-list li .toggle { + text-align: center; + width: 40px; + /* auto, since non-WebKit browsers doesn't support input styling */ + height: auto; + position: absolute; + top: 0; + bottom: 0; + margin: auto 0; + border: none; /* Mobile Safari */ + -webkit-appearance: none; + appearance: none; +} + +.todo-list li .toggle { + opacity: 0; +} + +.todo-list li .toggle + label { + background-image: url("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23949494%22%20stroke-width%3D%223%22%2F%3E%3C%2Fsvg%3E"); + background-repeat: no-repeat; + background-position: center left; +} + +.todo-list li .toggle:checked + label { + background-image: url("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%2359A193%22%20stroke-width%3D%223%22%2F%3E%3Cpath%20fill%3D%22%233EA390%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22%2F%3E%3C%2Fsvg%3E"); +} + +.todo-list li label { + word-break: break-all; + padding: 15px 15px 15px 60px; + display: block; + line-height: 1.2; + transition: color 0.4s; + font-weight: 400; + color: #484848; +} + +.todo-list li.completed label { + color: #949494; + text-decoration: line-through; +} + +.todo-list li .destroy { + display: none; + position: absolute; + top: 0; + right: 10px; + bottom: 0; + width: 40px; + height: 40px; + margin: auto 0; + font-size: 30px; + color: #949494; + transition: color 0.2s ease-out; +} + +.todo-list li .destroy:hover, +.todo-list li .destroy:focus { + color: #c18585; +} + +.todo-list li .destroy:after { + content: "×"; + display: block; + height: 100%; + line-height: 1.1; +} + +.todo-list li:hover .destroy { + display: block; +} + +.todo-list li .edit { + display: none; +} + +.todo-list li.editing:last-child { + margin-bottom: -1px; +} + +.footer { + padding: 10px 15px; + height: 20px; + text-align: center; + font-size: 15px; + border-top: 1px solid #e6e6e6; +} + +.footer:before { + content: ""; + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 50px; + overflow: hidden; + box-shadow: + 0 1px 1px rgba(0, 0, 0, 0.2), + 0 8px 0 -3px #f6f6f6, + 0 9px 1px -3px rgba(0, 0, 0, 0.2), + 0 16px 0 -6px #f6f6f6, + 0 17px 2px -6px rgba(0, 0, 0, 0.2); +} + +.todo-count { + float: left; + text-align: left; +} + +.todo-count strong { + font-weight: 300; +} + +.filters { + margin: 0; + padding: 0; + list-style: none; + position: absolute; + right: 0; + left: 0; +} + +.filters li { + display: inline; +} + +.filters li a { + color: inherit; + margin: 3px; + padding: 3px 7px; + text-decoration: none; + border: 1px solid transparent; + border-radius: 3px; +} + +.filters li a:hover { + border-color: rgba(175, 47, 47, 0.1); +} + +.filters li a.selected { + border-color: rgba(175, 47, 47, 0.2); +} + +.clear-completed, +html .clear-completed:active { + float: right; + position: relative; + line-height: 19px; + text-decoration: none; + cursor: pointer; +} + +.clear-completed:hover { + text-decoration: underline; +} + +.info { + margin: 65px auto 0; + color: #4d4d4d; + font-size: 11px; + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); + text-align: center; +} + +.info p { + line-height: 1; +} + +.info a { + color: inherit; + text-decoration: none; + font-weight: 400; +} + +.info a:hover { + text-decoration: underline; +} + +/* Optimistic / saving / errored affordances */ + +.loading { + padding: 20px; + text-align: center; + font-style: italic; + color: #949494; +} + +.todo.pending label { + opacity: 0.5; + font-style: italic; +} + +.todo.errored label { + color: #c91524; +} + +.todo-list li .retry { + display: none; + position: absolute; + top: 0; + right: 10px; + bottom: 0; + width: 40px; + height: 40px; + margin: auto 0; + font-size: 24px; + color: #c91524; + cursor: pointer; + transition: color 0.2s ease-out; +} + +.todo-list li .retry:after { + content: "\21bb"; + display: block; + height: 100%; + line-height: 1.4; +} + +.todo-list li .retry:hover, +.todo-list li .retry:focus { + color: #8e0e1a; +} + +.todo-list li.errored .retry { + display: block; +} + +.todo-list li.errored .destroy, +.todo-list li.errored:hover .destroy { + display: none; +} + +.app-error { + margin: 130px auto; + padding: 24px; + background: #fff; + border: 1px solid #c91524; + color: #c91524; + text-align: center; + font-size: 16px; +} + +.app-error button { + display: inline-block; + margin-top: 12px; + padding: 6px 12px; + border: 1px solid #c91524; + border-radius: 3px; + cursor: pointer; + color: #c91524; +} diff --git a/examples/todos-server/src/app.tsx b/examples/todos-server/src/app.tsx new file mode 100644 index 000000000..296737efd --- /dev/null +++ b/examples/todos-server/src/app.tsx @@ -0,0 +1,155 @@ +// The client side. Compare with ../../todos/src/app.tsx: `Header` and +// `TodoRow` are the same client components — `TodoRow` is shared with the +// server (./todo-row.tsx). `MainSection` and `Footer` are gone: that markup +// comes from the server component (server/todos.tsx) as HTML, and the +// 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 +// 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"; +import { dynamic } from "@solidjs/web"; +import { createTodos, type Todos as TodosState } from "./todos"; +import { createHashFilter, type Filter } from "./filter"; +import { TodoRow, type RowBehavior } from "./todo-row"; +import type { Entity } from "./server/todos"; +import "./app.css"; + +const TodosContext = createContext(); + +function Header() { + const { actions } = useContext(TodosContext); + return ( +
    +

    todos

    + { + if (e.key !== "Enter") return; + const title = e.currentTarget.value.trim(); + if (!title) return; + const id = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + actions.addTodo({ id, title, completed: false }); + e.currentTarget.value = ""; + }} + /> +
    + ); +} + +function TodoList(props: { filter: Filter }) { + const state = useContext(TodosContext); + const { intent, errors, done, removed, extraRows, counts, actions } = state; + const Todos = dynamic(() => state.todos()); + + const visible = (completed: boolean) => + props.filter === "all" || (props.filter === "active") !== completed; + + // 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. + const rowFor = (p: Entity): RowBehavior => ({ + get rowClass() { + return { + todo: true, + completed: done(p.id, p.completed), + pending: !!intent.byId[p.id] || intent.adds.some(t => t.id === p.id), + errored: !!errors[p.id] + }; + }, + get done() { + return done(p.id, p.completed); + }, + get removed() { + return removed(p.id) || !visible(done(p.id, p.completed)); + }, + get error() { + return errors[p.id] ? `Retry ${errors[p.id]!.type}` : undefined; + }, + onToggle: e => actions.toggleTodo(p.id, e.currentTarget.checked), + onRemove: () => actions.removeTodo(p.id, p.completed), + onRetry: () => actions.retryTodo(p.id) + }); + + // The ids whose client-side `completed` is the given value, from the + // server's two lists (an entity's intent may have moved it across). + const idsWhere = (p: { active: string[]; completed: string[] }, completed: boolean) => [ + ...p.active.filter(id => !removed(id) && done(id, false) === completed), + ...p.completed.filter(id => !removed(id) && done(id, true) === completed) + ]; + const listFor = (p: { total: number; active: string[]; completed: string[] }) => { + const active = () => idsWhere(p, false); + const completed = () => idsWhere(p, true); + const allDone = () => active().length === 0 && completed().length > 0; + return { + get empty() { + return counts({ remaining: 0, total: p.total }).total === 0; + }, + get allDone() { + return allDone(); + }, + get noneDone() { + return completed().length === 0; + }, + onToggleAll: () => + allDone() ? actions.toggleAll(completed(), false) : actions.toggleAll(active(), true), + onClearCompleted: () => actions.clearCompleted(completed()) + }; + }; + + return ( + ( + + {todo => } + + )} + count={p => { + const remaining = () => counts(p).remaining; + return ( + <> + {remaining()} {remaining() === 1 ? "item" : "items"} left + + ); + }} + filters={() => ({ + all: props.filter === "all", + active: props.filter === "active", + completed: props.filter === "completed" + })} + /> + ); +} + +export default function App() { + const filter = createHashFilter(); + return ( + ( +
    +

    Something went wrong: {String(err())}

    + +
    + )} + > + +
    +
    + Loading…

    }> + +
    +
    +
    +
    + ); +} diff --git a/examples/todos-server/src/filter.ts b/examples/todos-server/src/filter.ts new file mode 100644 index 000000000..e80aca0c7 --- /dev/null +++ b/examples/todos-server/src/filter.ts @@ -0,0 +1,29 @@ +import { createSignal, onSettled } from "solid-js"; + +export type Filter = "all" | "active" | "completed"; + +function parseHash(hash: string): Filter { + if (hash === "#/active") return "active"; + if (hash === "#/completed") return "completed"; + return "all"; +} + +/** + * View-state primitive that mirrors the URL hash into a reactive filter. + * + * The SPA twin reads `location.hash` at creation. Here the app is + * server-rendered and the server cannot see the hash, so the signal starts + * at "all" on both faces (the hydrated HTML matches what the client's first + * pass computes) and takes the real hash once the initial activity settles — + * at the same moment the `hashchange` listener attaches. + */ +export function createHashFilter(): () => Filter { + const [filter, setFilter] = createSignal("all"); + onSettled(() => { + const onChange = () => setFilter(parseHash(location.hash)); + onChange(); + window.addEventListener("hashchange", onChange); + return () => window.removeEventListener("hashchange", onChange); + }); + return filter; +} diff --git a/examples/todos-server/src/lib/db.ts b/examples/todos-server/src/lib/db.ts new file mode 100644 index 000000000..66b11d66b --- /dev/null +++ b/examples/todos-server/src/lib/db.ts @@ -0,0 +1,81 @@ +// The todo store, server-only: this module is only imported by "use server" +// modules, so unstorage never reaches the client build. It is the SPA twin's +// mock API (../../../todos/src/api.ts) moved behind the server boundary with +// the same shape and the same deliberate unreliability: every save waits +// 400 ms and ~33% of them fail, so the optimistic UI, the per-item errors +// and the retry affordances get exercised. Todos reset on server restart +// (memory driver); swap the driver for a durable store in a deployment. +import { createStorage } from "unstorage"; +import memoryDriver from "unstorage/drivers/memory"; + +export interface Todo { + id: string; + title: string; + completed: boolean; +} + +const storage = createStorage({ driver: memoryDriver() }); + +const SEED: Todo[] = [ + { id: "0001", title: "Read the server-components principles", completed: true }, + { id: "0002", title: "Port TodoMVC to a server component", completed: false }, + { id: "0003", title: "Break the network and watch it recover", completed: false } +]; + +export async function getTodos(): Promise { + const todos = (await storage.getItem("todos")) as Todo[] | null; + if (todos) return todos; + await storage.setItem("todos", SEED); + return SEED; +} + +async function saveTodos(todos: Todo[]) { + if (Math.random() < 0.33) return reject(400); + await storage.setItem("todos", todos); + return delay(undefined, 400); +} + +export async function addTodo(todo: Todo) { + const newTodo = { ...todo }; + const todos = await getTodos(); + if (todos.some(t => t.id === newTodo.id)) return newTodo; + const index = todos.findIndex(t => t.id > newTodo.id); + if (index > -1) todos.splice(index, 0, newTodo); + else todos.push(newTodo); + await saveTodos(todos); + return newTodo; +} + +export async function removeTodo(todoId: string) { + return saveTodos((await getTodos()).filter(t => t.id !== todoId)); +} + +export async function toggleTodo(todoId: string, completed: boolean) { + let found: Todo | undefined; + const todos = (await getTodos()).map(t => { + if (t.id !== todoId) return t; + return (found = { ...t, completed }); + }); + if (!found) return reject(400); + await saveTodos(todos); + return found; +} + +export async function toggleAll(ids: string[], completed: boolean) { + const set = new Set(ids); + const todos = (await getTodos()).map(t => (set.has(t.id) ? { ...t, completed } : t)); + return saveTodos(todos); +} + +export async function clearCompleted(ids: string[]) { + const set = new Set(ids); + return saveTodos((await getTodos()).filter(t => !set.has(t.id))); +} + +function delay(payload: T, time: number) { + return new Promise(res => setTimeout(res, time, payload)); +} + +function reject(time: number) { + return new Promise((_, rej) => setTimeout(rej, time, new Error("Failed to save"))); +} diff --git a/examples/todos-server/src/server/todos.tsx b/examples/todos-server/src/server/todos.tsx new file mode 100644 index 000000000..19d9b64d0 --- /dev/null +++ b/examples/todos-server/src/server/todos.tsx @@ -0,0 +1,130 @@ +"use server"; +// TodoMVC's list as a server component. Compare with the SPA twin's +// 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 +// 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. +// +// 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 +// (../todo-row.tsx); `props.list(...)` is the list-level behavior consumed +// by the section, the toggle-all box, the footer and the clear button. +// +// The one thing the client cannot bind is a row the server has not +// 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 * as db from "~/lib/db"; +import { TodoRow, type RowBehavior } from "~/todo-row"; + +export type Entity = { id: string; completed: boolean }; + +/** The list-level values and behavior the client owns. */ +export interface ListBehavior { + empty: boolean; + allDone: boolean; + onToggleAll: () => void; + noneDone: boolean; + onClearCompleted: () => void; +} + +/** Which filter link is selected — client state (the URL hash). */ +export interface FilterBehavior { + all: boolean; + active: boolean; + completed: boolean; +} + +export interface TodoListProps { + list: AttributeSlot<{ total: number; active: string[]; completed: string[] }, ListBehavior>; + row: AttributeSlot; + pending: Slot; + count: Slot<{ remaining: number; total: number }>; + filters: AttributeSlot<{}, FilterBehavior>; +} + +export async function todoListView() { + const todos = await db.getTodos(); + const active = todos.filter(t => !t.completed).map(t => t.id); + const completed = todos.filter(t => t.completed).map(t => t.id); + return (props: TodoListProps) => { + const list = props.list({ total: todos.length, active, completed }); + const filters = props.filters(); + return ( + <> + + + + ); + }; +} + +// The mutations. Plain server functions: the client calls them from inside +// its actions and follows each with a refetch of `todoListView` (see +// ../todos.ts) — the "typical multi-flight" shape, where the write and the +// re-read are separate requests and the action's transaction spans both. +export async function addTodo(todo: db.Todo) { + return db.addTodo(todo); +} +export async function removeTodo(id: string) { + return db.removeTodo(id); +} +export async function toggleTodo(id: string, completed: boolean) { + return db.toggleTodo(id, completed); +} +export async function toggleAll(ids: string[], completed: boolean) { + return db.toggleAll(ids, completed); +} +export async function clearCompleted(ids: string[]) { + return db.clearCompleted(ids); +} diff --git a/examples/todos-server/src/todo-row.tsx b/examples/todos-server/src/todo-row.tsx new file mode 100644 index 000000000..6e25bf77a --- /dev/null +++ b/examples/todos-server/src/todo-row.tsx @@ -0,0 +1,43 @@ +// 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: +// `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 +// bindings. The component cannot tell and does not need to. +// +// Keys are semantic, positions are structural: nothing in `RowBehavior` says +// attribute, class or handler — where each property is bound below does. + +/** What the client decides about a row: the values and behavior it owns. */ +export interface RowBehavior { + rowClass: Record; + removed: boolean; + done: boolean; + onToggle: (e: InputEvent & { currentTarget: HTMLInputElement }) => void; + onRemove: () => void; + onRetry: () => void; + error: string | undefined; +} + +export function TodoRow(props: { id: string; title: string; row: RowBehavior }) { + // `$key` is the
  • 's MORPH identity (server markup: a response keeps the + // node for the same todo); a DOM compile strips it. The `row` call's own + // `$key` is the occurrence's identity — two keys, two jobs. + return ( +
  • + ); +} diff --git a/examples/todos-server/src/todos.ts b/examples/todos-server/src/todos.ts new file mode 100644 index 000000000..eb73a8f35 --- /dev/null +++ b/examples/todos-server/src/todos.ts @@ -0,0 +1,220 @@ +// The client's half of the list. Compare with ../../todos/src/todos.ts: the +// SPA keeps the whole todo array on the client and layers optimistic writes +// and errors over it. Here the array is server markup that the client never +// holds — what it holds is INTENT (what it has asked the server to do and +// not yet heard back about) and the errors it heard back. Both are keyed by +// entity id, and the fills in app.tsx combine them with the args each server +// element carries. +// +// Same three lifetime layers as the SPA, same order, without the array: +// +// 3. Optimistic (transition-scoped) ── `intent`, a `createOptimisticStore` +// written inside `action` generators; +// auto-reverts when the action settles, +// which is when the server's answer +// has APPLIED (see the actions). +// 2. Ephemeral (UI-scoped) ── `errors`, a plain store written after +// the call fails. Survives the revert +// because it is not optimistic. +// 1. Persistent (durable) ── the server's rows: `p.completed` in +// a fill's props is truth as of the +// last response. +// +// Every fill reads (3) over (1) and shows (2) beside it. +// +// Pending rows are not inert (parity with the SPA): a toggle or remove on a +// todo whose add is still in flight writes its intent immediately and then +// waits for the add to settle before talking to the server — the server +// has no such id until then. A todo whose add FAILED exists only here, so +// those actions edit the failed record instead (the retry carries the +// change). + +import { action, createMemo, createOptimisticStore, createStore, refresh } from "solid-js"; +import type { Todo } from "~/lib/db"; +import * as server from "~/server/todos"; + +export type { Todo }; + +export type TodoError = { + type: "addTodo" | "removeTodo" | "toggleTodo"; + args: any[]; +}; + +export interface Intent { + /** The server's `completed` when the intent was written (for the counts). */ + from: boolean; + completed?: boolean; + removed?: boolean; +} + +export function createTodos() { + // The source the boundary shows and the actions refetch. `refresh(todos)` + // re-runs the memo, which re-calls the server component; the transaction + // holds until the refetched markup and args have applied. + const todos = createMemo(() => server.todoListView()); + + const [intent, setIntent] = createOptimisticStore<{ + byId: Record; + adds: Todo[]; + }>({ byId: {}, adds: [] }); + + const [errors, setErrors] = createStore>({}); + + /** Adds in flight, by id: what a toggle/remove on a pending row waits on. */ + const inflight = new Map>(); + /** The failed add a todo exists in, if that is the only place it exists. */ + const failedAdd = (id: string) => (errors[id]?.type === "addTodo" ? errors[id] : undefined); + + /** The client's view of one entity's `completed`: intent over the server. */ + const done = (id: string, completed: boolean) => intent.byId[id]?.completed ?? completed; + const removed = (id: string) => !!intent.byId[id]?.removed; + + /** Todos the server does not have: in-flight adds, then adds that failed. + * The store's own objects, not copies: their identity is stable across + * reads, so a `` over them keeps each row's node while the list + * around it changes. */ + const extraRows = (): Todo[] => { + const rows: Todo[] = [...intent.adds]; + for (const id in errors) { + const error = errors[id]; + if (error?.type === "addTodo" && !rows.some(r => r.id === id)) rows.push(error.args[0]); + } + return rows.sort((a, b) => (a.id > b.id ? 1 : -1)); + }; + + /** Counts as the client sees them: the server's, adjusted by intent. */ + const counts = (p: { remaining: number; total: number }) => { + let remaining = p.remaining; + let total = p.total; + const extra = extraRows(); + for (const id in intent.byId) { + // Intent over a row the server has; a pending row's intent is read + // with the row below. + if (extra.some(t => t.id === id)) continue; + const i = intent.byId[id]; + if (i.removed) { + total--; + if (!i.from) remaining--; + } else if (i.completed !== undefined && i.completed !== i.from) { + remaining += i.completed ? -1 : 1; + } + } + for (const t of extra) { + if (removed(t.id)) continue; + total++; + if (!done(t.id, t.completed)) remaining++; + } + return { remaining, total }; + }; + + function fail(id: string, error: TodoError) { + setErrors(e => { + e[id] ||= error; + }); + } + function ok(id: string) { + setErrors(e => { + delete e[id]; + }); + } + + const add = action(function* (todo: Todo) { + setIntent(s => { + if (!s.adds.some(t => t.id === todo.id)) s.adds.push(todo); + }); + try { + yield server.addTodo(todo); + ok(todo.id); + } catch { + fail(todo.id, { type: "addTodo", args: [todo] }); + } + yield refresh(todos); + }); + + const actions = { + addTodo(todo: Todo): Promise { + const p = add(todo).finally(() => { + if (inflight.get(todo.id) === p) inflight.delete(todo.id); + }); + inflight.set(todo.id, p); + return p; + }, + removeTodo: action(function* (id: string, completed: boolean) { + setIntent(s => { + s.byId[id] = { from: completed, removed: true }; + }); + // Sequenced behind the add the server has not answered yet. + const pending = inflight.get(id); + if (pending) yield pending; + if (failedAdd(id)) { + // The todo never reached the server: removing it is forgetting it. + ok(id); + return; + } + try { + yield server.removeTodo(id); + ok(id); + } catch { + fail(id, { type: "removeTodo", args: [id, completed] }); + } + yield refresh(todos); + }), + toggleTodo: action(function* (id: string, completed: boolean) { + setIntent(s => { + s.byId[id] = { from: !completed, completed }; + }); + const pending = inflight.get(id); + if (pending) yield pending; + const failed = failedAdd(id); + if (failed) { + // The todo exists only in its failed add: the retry adds it toggled. + setErrors(e => { + (e[id]!.args[0] as Todo).completed = completed; + }); + return; + } + try { + yield server.toggleTodo(id, completed); + ok(id); + } catch { + fail(id, { type: "toggleTodo", args: [id, completed] }); + } + yield refresh(todos); + }), + toggleAll: action(function* (ids: string[], completed: boolean) { + setIntent(s => { + for (const id of ids) s.byId[id] = { from: !completed, completed }; + }); + try { + yield server.toggleAll(ids, completed); + ids.forEach(ok); + } catch { + // Bulk failed — fan the error out to per-item entries so each + // failed item gets its own retry affordance via `retryTodo`. + ids.forEach(id => fail(id, { type: "toggleTodo", args: [id, completed] })); + } + yield refresh(todos); + }), + clearCompleted: action(function* (ids: string[]) { + setIntent(s => { + for (const id of ids) s.byId[id] = { from: true, removed: true }; + }); + try { + yield server.clearCompleted(ids); + ids.forEach(ok); + } catch { + ids.forEach(id => fail(id, { type: "removeTodo", args: [id, true] })); + } + yield refresh(todos); + }), + retryTodo(id: string): Promise { + const error = errors[id]; + if (!error) return Promise.resolve(); + return (actions[error.type] as (...args: any[]) => Promise)(...error.args); + } + }; + + return { todos, intent, errors, done, removed, extraRows, counts, actions }; +} + +export type Todos = ReturnType; diff --git a/examples/todos-server/src/vite-env.d.ts b/examples/todos-server/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/examples/todos-server/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/todos-server/tsconfig.json b/examples/todos-server/tsconfig.json new file mode 100644 index 000000000..23bd1e3bc --- /dev/null +++ b/examples/todos-server/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "jsx": "preserve", + "jsxImportSource": "@solidjs/web", + "allowJs": true, + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true, + "resolveJsonModule": true, + "paths": { + "~/*": ["./src/*"] + } + }, + "include": ["src", "vite.config.ts"] +} diff --git a/examples/todos-server/vite.config.ts b/examples/todos-server/vite.config.ts new file mode 100644 index 000000000..2be0a74d9 --- /dev/null +++ b/examples/todos-server/vite.config.ts @@ -0,0 +1,15 @@ +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; +import solid from "@solidjs/vite-plugin"; + +// The same turnkey setup as ../hackernews: `serverFunctions.components` makes +// a `"use server"` function that returns a component stream its markup over +// the server-function endpoint (and inline it at document SSR). Nothing in +// src/ imports the frames runtime — the generated entries wire it. +export default defineConfig({ + resolve: { + alias: { "~": fileURLToPath(new URL("./src", import.meta.url)) } + }, + server: { port: 3010 }, + plugins: [solid({ start: {}, ssr: true, serverFunctions: { components: true } })] +}); diff --git a/packages/babel-plugin/README.md b/packages/babel-plugin/README.md index 4ed1ec2da..379a8f28c 100644 --- a/packages/babel-plugin/README.md +++ b/packages/babel-plugin/README.md @@ -253,7 +253,7 @@ Inline style attributes in templates when the value is a string or `Record 0, attributes = normalizeAttributes(path); let children: babelTypes.JSXExpressionContainer | undefined; - // Server-components claims: ref/on* positions on server-rendered - // intrinsics collect here and emit as one guarded whole-attribute hole - // (` _bnd="..."` or "") after the loop. Evaluation is gated on the render - // context's claims flag so plain SSR never runs the expressions. + // Server-components handler positions: ref/on* expressions on + // server-rendered intrinsics collect here and emit as one guarded + // whole-attribute hole after the loop, where `ssrClaim` turns attribute-slot + // reads into `_s:on:*` / `_s:ref` markers (and drops server-local + // functions). Evaluation is gated on the render context's claims flag so + // plain SSR never runs the expressions. const claims: [string, babelTypes.Expression][] = []; attributes.forEach(attribute => { @@ -588,11 +590,10 @@ function transformAttributes( } if (key.startsWith("prop:")) return; if (key.startsWith("on")) { - // Capture-phase variants can't ride delegation; v1 drops them as - // before. `on:x` keeps the raw name, `onXxx` lowercases — the same - // event-name derivation as the client runtime. - if (info.serverComponents && !key.startsWith("oncapture:")) { - const pos = key.startsWith("on:") ? key.slice(3) : key.slice(2).toLowerCase(); + // `onXxx` lowercases to the event name — the client runtime's own + // derivation (`onClick` -> `click`); the position is bound under it. + if (info.serverComponents) { + const pos = key.slice(2).toLowerCase(); if (pos) claims.push([pos, value.expression as babelTypes.Expression]); } return; @@ -628,6 +629,30 @@ function transformAttributes( checkMember: true, checkTags: true }); + // Server components (principles §9.2.3): a dynamic `class`/`style` + // is the one attribute shape the plain SSR output serializes INSIDE + // template quotes (`class="${ssrClassName(x)}"`), where an attribute-slot + // value read at that position — the whole value, or a name's + // condition in object form — would be stringified instead of + // bound. Under the option the whole attribute is a runtime hole, + // `ssrElementAttribute("class", x)`, whose helper emits the same + // bytes for a plain value and the position marker for a stand-in. + // Object literals stay objects (no inlining) for the same reason. + if (info.serverComponents && (key === "class" || key === "style")) { + const attr = t.callExpression(registerImportMethod(path, "ssrElementAttribute"), [ + t.stringLiteral(key), + value.expression as babelTypes.Expression + ]); + results.template.push(""); + results.templateValues.push( + isDynamicValue + ? hoistExpression(path, results, t.arrowFunctionExpression([], attr), { + group: true + }) + : attr + ); + return; + } let doEscape = true; let isBoolean = t.isBooleanLiteral(value) || @@ -771,23 +796,7 @@ function transformAttributes( } }); if (claims.length) { - // Duplicate event keys were already last-wins-stripped above; `ref` is - // exempt from that pass (client semantics fire every ref), so multiple - // refs merge into an array value. - const byPos = new Map(); - for (const [pos, expr] of claims) { - let list = byPos.get(pos); - if (!list) byPos.set(pos, (list = [])); - list.push(expr); - } - const map = t.objectExpression( - [...byPos].map(([pos, exprs]) => - t.objectProperty( - t.stringLiteral(pos), - exprs.length === 1 ? exprs[0] : t.arrayExpression(exprs) - ) - ) - ); + const map = claimMap(claims); // `_$sharedConfig.context && _$sharedConfig.context.claims // ? _$ssrClaim({...}) : ""` // — the claims flag is only set inside a server component's render @@ -933,7 +942,7 @@ function transformChildren( function createElement( path: BabelPath & { doNotEscape?: boolean }, - { topLevel, hydratable }: SSRTransformInfo + { topLevel, hydratable, serverComponents }: SSRTransformInfo ): SSRSpreadTransformResult { const tagName = getTagName(path.node), config = getConfig(path), @@ -986,6 +995,21 @@ function createElement( const node = attribute.node; return !(t.isJSXAttribute(node) && t.isJSXIdentifier(node.name) && node.name.name === "ref"); }); + // Server components (principles §9.2.3): the named `ref`/`on*` attributes + // of a spread element are handler positions like a template element's, + // and compile to the same claim map — `{ click: expr, ref: [a, b] }`, + // duplicate refs merged, a duplicate handler last-wins as the template + // path strips it — keyed by the index of the source each attribute sits + // before (`` → `{ 1: { click: go } }`, the + // spreads being sources 0 and 2), and handed to `ssrElement` as a thunk + // it reads only inside a server component's render (the gate the template + // path's `ssrClaim` guard reads), so plain SSR never evaluates the + // expressions. The runtime settles each handler position in source order, + // as the client's `spread(el, [a, { onClick: go }, b])` does — a spread at + // that index or later that HAS the key owns it — and merges every ref. + // Plain SSR output is unchanged (dropped, as a server element has no + // handlers to run). + const claims: [number, string, babelTypes.Expression][] = []; let props: babelTypes.Expression[]; // Attributes written AFTER the last spread are markup, not a source: no @@ -1006,7 +1030,13 @@ function createElement( // keys. const tail: Array = []; const skipKeys: string[] = []; - if (propAttributes.length === 1 && t.isJSXSpreadAttribute(propAttributes[0].node)) { + if ( + propAttributes.length === 1 && + t.isJSXSpreadAttribute(propAttributes[0].node) && + // A `ref` beside the lone spread is a claim under `serverComponents`; + // the loop below places it. + !(serverComponents && attributes.length > 1) + ) { props = [propAttributes[0].node.argument]; } else { props = []; @@ -1052,8 +1082,30 @@ function createElement( : node.name.name; if (hasChildren && key === "children") return; - if (key === "ref") return; - if (key.startsWith("prop:") || key.startsWith("on")) return; + if (key === "ref" || key.startsWith("on")) { + if (serverComponents && t.isJSXExpressionContainer(value)) { + const expression = value.expression; + const pos = key === "ref" ? "ref" : key.slice(2).toLowerCase(); + if ( + pos && + !( + t.isJSXEmptyExpression(expression) || + t.isStringLiteral(expression) || + t.isNumericLiteral(expression) || + t.isBooleanLiteral(expression) + ) + ) { + // The index of the source this attribute sits before: the + // sources pushed so far, plus the running literal if it will + // be pushed ahead of the next spread. The literal never + // carries a handler key, so a spread's index is either below + // this or at/after it — never ambiguous. + claims.push([props.length + (runningObject.length ? 1 : 0), pos, expression]); + } + } + return; + } + if (key.startsWith("prop:")) return; if (i > lastSpread) { const part = tailAttribute(path, tagName, key, node); if (part !== undefined) { @@ -1130,10 +1182,65 @@ function createElement( } args.push(registerSkip(path, skipKeys), markup); } + if (claims.length) { + if (!skipKeys.length) args.push(t.identifier("undefined"), t.identifier("undefined")); + args.push(t.arrowFunctionExpression([], claimSegments(claims))); + } const exprs = [t.callExpression(registerImportMethod(path, "ssrElement"), args)]; return { exprs, template: "", declarations: [], dynamics: [], spreadElement: true }; } +/** + * A spread element's claim map by source index (`createElement`): `{ 1: { + * click: go }, 3: { ref: el } }`. A handler position keeps its LAST named + * attribute only — the template path's duplicate strip, applied here to the + * claims (a later attribute wins whatever sits between, so the earlier one + * is never read on either side); refs merge within a segment as `claimMap` + * merges them, and across segments at the runtime. + */ +function claimSegments( + claims: [number, string, babelTypes.Expression][] +): babelTypes.ObjectExpression { + const lastHandler = new Map(); + claims.forEach(([, pos], i) => { + if (pos !== "ref") lastHandler.set(pos, i); + }); + const segments = new Map(); + claims.forEach(([index, pos, expr], i) => { + if (pos !== "ref" && lastHandler.get(pos) !== i) return; + let list = segments.get(index); + if (!list) segments.set(index, (list = [])); + list.push([pos, expr]); + }); + return t.objectExpression( + [...segments].map(([index, list]) => t.objectProperty(t.numericLiteral(index), claimMap(list))) + ); +} + +/** + * The compiled claim map of an element's handler positions, `{ click: expr, + * ref: [a, b] }`: duplicate event keys were last-wins-stripped by the + * template path's duplicate strip (or `claimSegments`); `ref` is exempt from + * that pass (client semantics fire every ref), so multiple refs merge into + * an array value. + */ +function claimMap(claims: [string, babelTypes.Expression][]): babelTypes.ObjectExpression { + const byPos = new Map(); + for (const [pos, expr] of claims) { + let list = byPos.get(pos); + if (!list) byPos.set(pos, (list = [])); + list.push(expr); + } + return t.objectExpression( + [...byPos].map(([pos, exprs]) => + t.objectProperty( + t.stringLiteral(pos), + exprs.length === 1 ? exprs[0] : t.arrayExpression(exprs) + ) + ) + ); +} + /** * What an attribute after an element's last spread contributes to * `ssrElement`'s attribute string. A static is written as the template path diff --git a/packages/babel-plugin/test/__dom_fixtures__/keyedElements/code.js b/packages/babel-plugin/test/__dom_fixtures__/keyedElements/code.js index 7c3d2df7b..bf9ded58b 100644 --- a/packages/babel-plugin/test/__dom_fixtures__/keyedElements/code.js +++ b/packages/babel-plugin/test/__dom_fixtures__/keyedElements/code.js @@ -18,3 +18,14 @@ const dynamicKey = ( ); const componentKey = ; + +// On a spread element the same rule applies to the spread path: the key +// joins the element's sources (renamed for SSR, dropped for DOM) rather than +// the template. +const spreadKey = ( +
      +
    • + {item.text} +
    • +
    +); diff --git a/packages/babel-plugin/test/__dom_fixtures__/keyedElements/output.js b/packages/babel-plugin/test/__dom_fixtures__/keyedElements/output.js index e534a38c5..bdb81cea4 100644 --- a/packages/babel-plugin/test/__dom_fixtures__/keyedElements/output.js +++ b/packages/babel-plugin/test/__dom_fixtures__/keyedElements/output.js @@ -1,4 +1,5 @@ import { template as _$template } from "r-dom"; +import { spread as _$spread } from "r-dom"; import { createComponent as _$createComponent } from "r-dom"; import { className as _$className } from "r-dom"; import { readShallow as _$readShallow } from "r-dom"; @@ -30,3 +31,21 @@ const componentKey = _$createComponent(Row, { return item.text; } }); + +// On a spread element the same rule applies to the spread path: the key +// joins the element's sources (renamed for SSR, dropped for DOM) rather than +// the template. +var _el$4 = _tmpl$2(), + _el$5 = _el$4.firstChild; +_$spread( + _el$5, + [ + { + class: "todo" + }, + () => item.attrs + ], + true +); +_$insert(_el$5, () => item.text); +const spreadKey = _el$4; diff --git a/packages/babel-plugin/test/__dom_hydratable_fixtures__/keyedElements/code.js b/packages/babel-plugin/test/__dom_hydratable_fixtures__/keyedElements/code.js index 7c3d2df7b..bf9ded58b 100644 --- a/packages/babel-plugin/test/__dom_hydratable_fixtures__/keyedElements/code.js +++ b/packages/babel-plugin/test/__dom_hydratable_fixtures__/keyedElements/code.js @@ -18,3 +18,14 @@ const dynamicKey = ( ); const componentKey = ; + +// On a spread element the same rule applies to the spread path: the key +// joins the element's sources (renamed for SSR, dropped for DOM) rather than +// the template. +const spreadKey = ( +
      +
    • + {item.text} +
    • +
    +); diff --git a/packages/babel-plugin/test/__dom_hydratable_fixtures__/keyedElements/output.js b/packages/babel-plugin/test/__dom_hydratable_fixtures__/keyedElements/output.js index 776c4eac7..4c8237c51 100644 --- a/packages/babel-plugin/test/__dom_hydratable_fixtures__/keyedElements/output.js +++ b/packages/babel-plugin/test/__dom_hydratable_fixtures__/keyedElements/output.js @@ -1,4 +1,6 @@ import { template as _$template } from "r-dom"; +import { runHydrationEvents as _$runHydrationEvents } from "r-dom"; +import { spread as _$spread } from "r-dom"; import { createComponent as _$createComponent } from "r-dom"; import { className as _$className } from "r-dom"; import { readShallow as _$readShallow } from "r-dom"; @@ -35,3 +37,25 @@ const componentKey = _$createComponent(Row, { return item.text; } }); + +// On a spread element the same rule applies to the spread path: the key +// joins the element's sources (renamed for SSR, dropped for DOM) rather than +// the template. +var _el$4 = _$getNextElement(_tmpl$2), + _el$5 = _el$4.firstChild; +_$spread( + _el$5, + [ + { + class: "todo" + }, + () => item.attrs + ], + true +); +_$insert( + _el$5, + _$scope(() => item.text) +); +_$runHydrationEvents(); +const spreadKey = _el$4; diff --git a/packages/babel-plugin/test/__ssr_fixtures__/keyedElements/code.js b/packages/babel-plugin/test/__ssr_fixtures__/keyedElements/code.js index 1554b204f..98e2e2375 100644 --- a/packages/babel-plugin/test/__ssr_fixtures__/keyedElements/code.js +++ b/packages/babel-plugin/test/__ssr_fixtures__/keyedElements/code.js @@ -18,3 +18,14 @@ const dynamicKey = ( ); const componentKey = ; + +// On a spread element the same rule applies to the spread path: the key +// joins the element's sources (renamed for SSR, dropped for DOM) rather than +// the template. +const spreadKey = ( +
      +
    • + {item.text} +
    • +
    +); diff --git a/packages/babel-plugin/test/__ssr_fixtures__/keyedElements/output.js b/packages/babel-plugin/test/__ssr_fixtures__/keyedElements/output.js index a9ef43ce9..523cc2748 100644 --- a/packages/babel-plugin/test/__ssr_fixtures__/keyedElements/output.js +++ b/packages/babel-plugin/test/__ssr_fixtures__/keyedElements/output.js @@ -1,10 +1,12 @@ +import { ssrElement as _$ssrElement } from "r-server"; import { ssrGroup as _$ssrGroup } from "r-server"; import { ssrClassName as _$ssrClassName } from "r-server"; import { ssrAttribute as _$ssrAttribute } from "r-server"; import { escape as _$escape } from "r-server"; import { ssr as _$ssr } from "r-server"; var _tmpl$ = '
    • Apple
    ', - _tmpl$2 = ["
      ', "
    "]; + _tmpl$2 = ["
      ', "
    "], + _tmpl$3 = ["
      ", "
    "]; // `$key` on an intrinsic element compiles to the `_key` attribute the // frame morph matches keyed elements by. Static keys inline into the // template; dynamic keys render as ordinary dynamic attributes. On a @@ -25,3 +27,22 @@ const componentKey = Row({ return item.text; } }); + +// On a spread element the same rule applies to the spread path: the key +// joins the element's sources (renamed for SSR, dropped for DOM) rather than +// the template. +var _v$4 = _$ssrElement( + "li", + [ + { + get _key() { + return item.id; + }, + class: "todo" + }, + () => item.attrs + ], + () => _$escape(item.text), + false +); +const spreadKey = _$ssr(_tmpl$3, _v$4); diff --git a/packages/babel-plugin/test/__ssr_server_components_fixtures__/attributeSlots/code.js b/packages/babel-plugin/test/__ssr_server_components_fixtures__/attributeSlots/code.js new file mode 100644 index 000000000..708de2678 --- /dev/null +++ b/packages/babel-plugin/test/__ssr_server_components_fixtures__/attributeSlots/code.js @@ -0,0 +1,58 @@ +// Attribute slots (principles §9.2.3). Ref/event positions on server intrinsics +// compile to one guarded whole-attribute claim hole per element, gated on +// the render context's claims flag so plain SSR never evaluates the +// expressions. +const template = ( +
    + + + warns at render when the gate is open + + multiple refs merge to an array + +
    +); + +// A spread element's named `ref`/`on*` compile to the same claim map a +// template element's do — duplicate refs merged, a tuple kept whole, a +// duplicate handler last-wins — keyed by the index of the source each +// attribute sits before, handed to `ssrElement` as a thunk it reads only +// inside a server component's render, wherever the attributes sit relative +// to the spreads (before, between, after): the runtime settles a handler +// position in source order, as the client's spread does. Plain SSR +// drops them; the tail after the last spread still bakes its statics; the +// spread's own handler keys are the runtime's to bind. +const spread = ( + +); + +// A dynamic `class`/`style` is a whole-attribute element-attribute hole +// rather than a value inside template quotes, so a slot value read at the +// position — whole, or as a name's condition in object form — binds instead +// of stringifying. Object literals stay objects. Static strings stay static. +const dynamicToo = ( +
  • + {label()} +
  • +); + +const objects = ( +
  • + + {label()} +
  • +); diff --git a/packages/babel-plugin/test/__ssr_server_components_fixtures__/attributeSlots/output.js b/packages/babel-plugin/test/__ssr_server_components_fixtures__/attributeSlots/output.js new file mode 100644 index 000000000..120267788 --- /dev/null +++ b/packages/babel-plugin/test/__ssr_server_components_fixtures__/attributeSlots/output.js @@ -0,0 +1,161 @@ +import { ssrAttribute as _$ssrAttribute } from "r-server"; +import { ssrGroup as _$ssrGroup } from "r-server"; +import { escape as _$escape } from "r-server"; +import { ssrElementAttribute as _$ssrElementAttribute } from "r-server"; +import { ssrElement as _$ssrElement } from "r-server"; +import { ssr as _$ssr } from "r-server"; +import { ssrClaim as _$ssrClaim } from "r-server"; +import { sharedConfig as _$sharedConfig } from "r-server"; +var _tmpl$ = [ + '
    warns at render when the gate is openmultiple refs merge to an array
    " + ], + _tmpl$2 = ["", "
  • "], + _tmpl$3 = [ + "', + "" + ]; +var _sk$ = k => k === "class"; +var _v$ = + _$sharedConfig.context && _$sharedConfig.context.claims + ? _$ssrClaim({ + click: props.onCopy, + ref: props.btn + }) + : "", + _v$2 = + _$sharedConfig.context && _$sharedConfig.context.claims + ? _$ssrClaim({ + input: props.onType, + keydown: props.onKey + }) + : "", + _v$3 = + _$sharedConfig.context && _$sharedConfig.context.claims + ? _$ssrClaim({ + click: localHandler + }) + : "", + _v$4 = + _$sharedConfig.context && _$sharedConfig.context.claims + ? _$ssrClaim({ + ref: [first, second] + }) + : ""; +// Attribute slots (principles §9.2.3). Ref/event positions on server intrinsics +// compile to one guarded whole-attribute claim hole per element, gated on +// the render context's claims flag so plain SSR never evaluates the +// expressions. +const template = _$ssr(_tmpl$, _v$, _v$2, _v$3, _v$4); + +// A spread element's named `ref`/`on*` compile to the same claim map a +// template element's do — duplicate refs merged, a tuple kept whole, a +// duplicate handler last-wins — keyed by the index of the source each +// attribute sits before, handed to `ssrElement` as a thunk it reads only +// inside a server component's render, wherever the attributes sit relative +// to the spreads (before, between, after): the runtime settles a handler +// position in source order, as the client's spread does. Plain SSR +// drops them; the tail after the last spread still bakes its statics; the +// spread's own handler keys are the runtime's to bind. +const spread = _$ssrElement( + "button", + rest, + [ + _$ssrElement("span", [more, last], undefined, false, undefined, undefined, () => ({ + 0: { + input: row.type + }, + 1: { + keydown: [row.key, 1] + } + })), + _$ssrElement("i", last, undefined, false, undefined, undefined, () => ({ + 0: { + ref: [[row.a, row.b], row.c] + }, + 1: { + click: localHandler + } + })), + _$ssrElement( + "em", + [ + rest, + { + title: "t" + }, + last + ], + undefined, + false, + undefined, + undefined, + () => ({ + 2: { + click: row.third + } + }) + ), + _$ssrElement("u", rest, undefined, false, undefined, undefined, () => ({ + 1: { + ref: row.el + } + })) + ], + false, + _sk$, + ' class="static"', + () => ({ + 1: { + click: row.go, + ref: row.el + } + }) +); + +// A dynamic `class`/`style` is a whole-attribute element-attribute hole +// rather than a value inside template quotes, so a slot value read at the +// position — whole, or as a name's condition in object form — binds instead +// of stringifying. Object literals stay objects. Static strings stay static. +var _g$ = _$ssrGroup( + () => [_$ssrElementAttribute("class", status()), _$ssrElementAttribute("style", row.style)], + 2 + ), + _v$7 = + _$sharedConfig.context && _$sharedConfig.context.claims + ? _$ssrClaim({ + click: props.onPick + }) + : "", + _v$8 = () => _$escape(label()); +const dynamicToo = _$ssr(_tmpl$2, _g$, _g$, _v$7, _v$8); +var _g$2 = _$ssrGroup( + () => [ + _$ssrElementAttribute("class", { + completed: row.done, + editing: row.editing + }), + _$ssrElementAttribute("style", { + color: row.color + }) + ], + 2 + ), + _v$10 = () => _$ssrAttribute("hidden", _$escape(row.removed, true)), + _v$11 = + _$sharedConfig.context && _$sharedConfig.context.claims + ? _$ssrClaim({ + input: row.toggle + }) + : "", + _v$12 = () => _$escape(label()), + _v$1 = () => _$ssrAttribute("checked", _$escape(row.done, true)); +const objects = _$ssr(_tmpl$3, _g$2, _g$2, _v$1, _v$10, _v$11, _v$12); diff --git a/packages/babel-plugin/test/__ssr_server_components_fixtures__/behaviorClaims/code.js b/packages/babel-plugin/test/__ssr_server_components_fixtures__/behaviorClaims/code.js deleted file mode 100644 index 87a5dc2db..000000000 --- a/packages/babel-plugin/test/__ssr_server_components_fixtures__/behaviorClaims/code.js +++ /dev/null @@ -1,22 +0,0 @@ -// Ref/event positions on server intrinsics compile to one guarded -// whole-attribute claim hole per element (`_bnd`), gated on the render -// context's claims flag so plain SSR never evaluates the expressions. -const template = ( -
    - - - warns at render when the gate is open - - multiple refs merge to an array - -
    capture variants stay dropped
    -
    -); - -const dynamicToo = ( -
  • - {label()} -
  • -); diff --git a/packages/babel-plugin/test/__ssr_server_components_fixtures__/behaviorClaims/output.js b/packages/babel-plugin/test/__ssr_server_components_fixtures__/behaviorClaims/output.js deleted file mode 100644 index 23d4d31de..000000000 --- a/packages/babel-plugin/test/__ssr_server_components_fixtures__/behaviorClaims/output.js +++ /dev/null @@ -1,52 +0,0 @@ -import { escape as _$escape } from "r-server"; -import { ssrClassName as _$ssrClassName } from "r-server"; -import { ssr as _$ssr } from "r-server"; -import { ssrClaim as _$ssrClaim } from "r-server"; -import { sharedConfig as _$sharedConfig } from "r-server"; -var _tmpl$ = [ - '
    warns at render when the gate is openmultiple refs merge to an array
    capture variants stay dropped
    " - ], - _tmpl$2 = ['
  • ", "
  • "]; -var _v$ = - _$sharedConfig.context && _$sharedConfig.context.claims - ? _$ssrClaim({ - click: props.onCopy, - ref: props.btn - }) - : "", - _v$2 = - _$sharedConfig.context && _$sharedConfig.context.claims - ? _$ssrClaim({ - input: props.onType, - "custom-thing": props.onCustom - }) - : "", - _v$3 = - _$sharedConfig.context && _$sharedConfig.context.claims - ? _$ssrClaim({ - click: localHandler - }) - : "", - _v$4 = - _$sharedConfig.context && _$sharedConfig.context.claims - ? _$ssrClaim({ - ref: [first, second] - }) - : ""; -// Ref/event positions on server intrinsics compile to one guarded -// whole-attribute claim hole per element (`_bnd`), gated on the render -// context's claims flag so plain SSR never evaluates the expressions. -const template = _$ssr(_tmpl$, _v$, _v$2, _v$3, _v$4); -var _v$5 = () => _$ssrClassName(status()), - _v$6 = - _$sharedConfig.context && _$sharedConfig.context.claims - ? _$ssrClaim({ - click: props.onPick - }) - : "", - _v$7 = () => _$escape(label()); -const dynamicToo = _$ssr(_tmpl$2, _v$5, _v$6, _v$7); diff --git a/packages/compiler/__tests__/fixtures/dom-hydratable/keyedElements/output.js b/packages/compiler/__tests__/fixtures/dom-hydratable/keyedElements/output.js index 89a1c85da..619f399ff 100644 --- a/packages/compiler/__tests__/fixtures/dom-hydratable/keyedElements/output.js +++ b/packages/compiler/__tests__/fixtures/dom-hydratable/keyedElements/output.js @@ -3,9 +3,11 @@ import { getNextElement as _$getNextElement } from "r-dom"; import { insert as _$insert } from "r-dom"; import { scope as _$scope } from "r-dom"; import { createComponent as _$createComponent } from "r-dom"; +import { spread as _$spread } from "r-dom"; import { readShallow as _$readShallow } from "r-dom"; import { className as _$className } from "r-dom"; import { effect as _$effect } from "r-dom"; +import { runHydrationEvents as _$runHydrationEvents } from "r-dom"; var _tmpl$ = /* @__PURE__ */ _$template(`
    • Apple`); var _tmpl$2 = /* @__PURE__ */ _$template(`
      • `); // `$key` is server markup identity (SSR-only): a DOM compile strips it from @@ -31,3 +33,16 @@ const componentKey = _$createComponent(Row, { return item.text; } }); +var _el$4 = _$getNextElement(_tmpl$2); +var _el$5 = _el$4.firstChild; +_$spread(_el$5, [{ class: "todo" }, () => { + return item.attrs; +}], true); +_$insert(_el$5, _$scope(() => { + return item.text; +})); +_$runHydrationEvents(); +// On a spread element the same rule applies to the spread path: the key +// joins the element's sources (renamed for SSR, dropped for DOM) rather than +// the template. +const spreadKey = _el$4; diff --git a/packages/compiler/__tests__/fixtures/dom/keyedElements/output.js b/packages/compiler/__tests__/fixtures/dom/keyedElements/output.js index 59b570c4b..dded0ad8c 100644 --- a/packages/compiler/__tests__/fixtures/dom/keyedElements/output.js +++ b/packages/compiler/__tests__/fixtures/dom/keyedElements/output.js @@ -1,6 +1,7 @@ import { template as _$template } from "r-dom"; import { insert as _$insert } from "r-dom"; import { createComponent as _$createComponent } from "r-dom"; +import { spread as _$spread } from "r-dom"; import { readShallow as _$readShallow } from "r-dom"; import { className as _$className } from "r-dom"; import { effect as _$effect } from "r-dom"; @@ -29,3 +30,15 @@ const componentKey = _$createComponent(Row, { return item.text; } }); +var _el$4 = _tmpl$2(); +var _el$5 = _el$4.firstChild; +_$spread(_el$5, [{ class: "todo" }, () => { + return item.attrs; +}], true); +_$insert(_el$5, () => { + return item.text; +}); +// On a spread element the same rule applies to the spread path: the key +// joins the element's sources (renamed for SSR, dropped for DOM) rather than +// the template. +const spreadKey = _el$4; diff --git a/packages/compiler/__tests__/fixtures/ssr-server-components/attributeSlots/output.js b/packages/compiler/__tests__/fixtures/ssr-server-components/attributeSlots/output.js new file mode 100644 index 000000000..9e24b682a --- /dev/null +++ b/packages/compiler/__tests__/fixtures/ssr-server-components/attributeSlots/output.js @@ -0,0 +1,95 @@ +import { escape as _$escape } from "r-server"; +import { ssr as _$ssr } from "r-server"; +import { ssrAttribute as _$ssrAttribute } from "r-server"; +import { ssrGroup as _$ssrGroup } from "r-server"; +import { ssrElement as _$ssrElement } from "r-server"; +import { ssrElementAttribute as _$ssrElementAttribute } from "r-server"; +import { ssrClaim as _$ssrClaim } from "r-server"; +import { sharedConfig as _$sharedConfig } from "r-server"; +var _tmpl$ = [ + "
        warns at render when the gate is openmultiple refs merge to an array
        " +]; +var _tmpl$2 = [ + "", + "
      • " +]; +var _tmpl$3 = [ + "", + "" +]; +var _sk$ = (k) => k === "class"; +var _v$ = _$sharedConfig.context && _$sharedConfig.context.claims ? _$ssrClaim({ + click: props.onCopy, + ref: props.btn +}) : "", _v$2 = _$sharedConfig.context && _$sharedConfig.context.claims ? _$ssrClaim({ + input: props.onType, + keydown: props.onKey +}) : "", _v$3 = _$sharedConfig.context && _$sharedConfig.context.claims ? _$ssrClaim({ click: localHandler }) : "", _v$4 = _$sharedConfig.context && _$sharedConfig.context.claims ? _$ssrClaim({ ref: [first, second] }) : ""; +// Attribute slots (principles §9.2.3). Ref/event positions on server intrinsics +// compile to one guarded whole-attribute claim hole per element, gated on +// the render context's claims flag so plain SSR never evaluates the +// expressions. +const template = _$ssr(_tmpl$, _v$, _v$2, _v$3, _v$4); +// A spread element's named `ref`/`on*` compile to the same claim map a +// template element's do — duplicate refs merged, a tuple kept whole, a +// duplicate handler last-wins — keyed by the index of the source each +// attribute sits before, handed to `ssrElement` as a thunk it reads only +// inside a server component's render, wherever the attributes sit relative +// to the spreads (before, between, after): the runtime settles a handler +// position in source order, as the client's spread does. Plain SSR +// drops them; the tail after the last spread still bakes its statics; the +// spread's own handler keys are the runtime's to bind. +const spread = _$ssrElement("button", rest, [ + _$ssrElement("span", [more, last], undefined, false, undefined, undefined, () => ({ + 0: { input: row.type }, + 1: { keydown: [row.key, 1] } + })), + _$ssrElement("i", last, undefined, false, undefined, undefined, () => ({ + 0: { ref: [[row.a, row.b], row.c] }, + 1: { click: localHandler } + })), + _$ssrElement("em", [ + rest, + { title: "t" }, + last + ], undefined, false, undefined, undefined, () => ({ 2: { click: row.third } })), + _$ssrElement("u", rest, undefined, false, undefined, undefined, () => ({ 1: { ref: row.el } })) +], false, _sk$, " class=\"static\"", () => ({ 1: { + click: row.go, + ref: row.el +} })); +var _g$ = _$ssrGroup(() => { + return [_$ssrElementAttribute("class", status()), _$ssrElementAttribute("style", row.style)]; +}, 2), _v$7 = _$sharedConfig.context && _$sharedConfig.context.claims ? _$ssrClaim({ click: props.onPick }) : "", _v$8 = () => { + return _$escape(label()); +}; +// A dynamic `class`/`style` is a whole-attribute element-attribute hole +// rather than a value inside template quotes, so a slot value read at the +// position — whole, or as a name's condition in object form — binds instead +// of stringifying. Object literals stay objects. Static strings stay static. +const dynamicToo = _$ssr(_tmpl$2, _g$, _g$, _v$7, _v$8); +var _g$2 = _$ssrGroup(() => { + return [_$ssrElementAttribute("class", { + completed: row.done, + editing: row.editing + }), _$ssrElementAttribute("style", { color: row.color })]; +}, 2), _v$12 = () => { + return _$ssrAttribute("hidden", _$escape(row.removed, true)); +}, _v$13 = _$sharedConfig.context && _$sharedConfig.context.claims ? _$ssrClaim({ input: row.toggle }) : "", _v$14 = () => { + return _$escape(label()); +}, _v$11 = () => { + return _$ssrAttribute("checked", _$escape(row.done, true)); +}; +const objects = _$ssr(_tmpl$3, _g$2, _g$2, _v$11, _v$12, _v$13, _v$14); diff --git a/packages/compiler/__tests__/fixtures/ssr-server-components/behaviorClaims/output.js b/packages/compiler/__tests__/fixtures/ssr-server-components/behaviorClaims/output.js deleted file mode 100644 index 3d214ebbf..000000000 --- a/packages/compiler/__tests__/fixtures/ssr-server-components/behaviorClaims/output.js +++ /dev/null @@ -1,35 +0,0 @@ -import { escape as _$escape } from "r-server"; -import { ssr as _$ssr } from "r-server"; -import { ssrClassName as _$ssrClassName } from "r-server"; -import { ssrClaim as _$ssrClaim } from "r-server"; -import { sharedConfig as _$sharedConfig } from "r-server"; -var _tmpl$ = [ - "
        warns at render when the gate is openmultiple refs merge to an array
        capture variants stay dropped
        " -]; -var _tmpl$2 = [ - "
      • ", - "
      • " -]; -var _v$ = _$sharedConfig.context && _$sharedConfig.context.claims ? _$ssrClaim({ - click: props.onCopy, - ref: props.btn -}) : "", _v$2 = _$sharedConfig.context && _$sharedConfig.context.claims ? _$ssrClaim({ - input: props.onType, - "custom-thing": props.onCustom -}) : "", _v$3 = _$sharedConfig.context && _$sharedConfig.context.claims ? _$ssrClaim({ click: localHandler }) : "", _v$4 = _$sharedConfig.context && _$sharedConfig.context.claims ? _$ssrClaim({ ref: [first, second] }) : ""; -// Ref/event positions on server intrinsics compile to one guarded -// whole-attribute claim hole per element (`_bnd`), gated on the render -// context's claims flag so plain SSR never evaluates the expressions. -const template = _$ssr(_tmpl$, _v$, _v$2, _v$3, _v$4); -var _v$5 = () => { - return _$ssrClassName(status()); -}, _v$6 = _$sharedConfig.context && _$sharedConfig.context.claims ? _$ssrClaim({ click: props.onPick }) : "", _v$7 = () => { - return _$escape(label()); -}; -const dynamicToo = _$ssr(_tmpl$2, _v$5, _v$6, _v$7); diff --git a/packages/compiler/__tests__/fixtures/ssr/keyedElements/output.js b/packages/compiler/__tests__/fixtures/ssr/keyedElements/output.js index 1707761c0..4bc54bf9d 100644 --- a/packages/compiler/__tests__/fixtures/ssr/keyedElements/output.js +++ b/packages/compiler/__tests__/fixtures/ssr/keyedElements/output.js @@ -3,6 +3,7 @@ import { ssr as _$ssr } from "r-server"; import { ssrAttribute as _$ssrAttribute } from "r-server"; import { ssrClassName as _$ssrClassName } from "r-server"; import { ssrGroup as _$ssrGroup } from "r-server"; +import { ssrElement as _$ssrElement } from "r-server"; var _tmpl$ = "
        • Apple
        "; var _tmpl$2 = [ "
          ", "
        " ]; +var _tmpl$3 = ["
          ", "
        "]; // `$key` on an intrinsic element compiles to the `_key` attribute the // frame morph matches keyed elements by. Static keys inline into the // template; dynamic keys render as ordinary dynamic attributes. On a @@ -30,3 +32,17 @@ const componentKey = Row({ return item.text; } }); +var _v$4 = _$ssrElement("li", [{ + get _key() { + return item.id; + }, + class: "todo" +}, () => { + return item.attrs; +}], () => { + return _$escape(item.text); +}, false); +// On a spread element the same rule applies to the spread path: the key +// joins the element's sources (renamed for SSR, dropped for DOM) rather than +// the template. +const spreadKey = _$ssr(_tmpl$3, _v$4); diff --git a/packages/compiler/__tests__/ssr-server-components-fixtures.test.js b/packages/compiler/__tests__/ssr-server-components-fixtures.test.js index 303aceeff..cf7ad9bb8 100644 --- a/packages/compiler/__tests__/ssr-server-components-fixtures.test.js +++ b/packages/compiler/__tests__/ssr-server-components-fixtures.test.js @@ -4,14 +4,16 @@ const { transform } = require("../index"); // Reuses the Babel plugin's server-components fixture source: under // `serverComponents: true`, ref/on* positions on intrinsic elements compile -// to one guarded `_$ssrClaim` hole per element instead of dropping. +// to one guarded `_$ssrClaim` hole per element instead of dropping, and a +// dynamic `class`/`style` becomes a whole-attribute `_$ssrElementAttribute` +// hole so an attribute-slot value read there binds instead of stringifying. const babelFixtures = path.resolve( __dirname, "../../babel-plugin/test/__ssr_server_components_fixtures__" ); const oxcFixtures = path.resolve(__dirname, "fixtures/ssr-server-components"); -const fixtures = ["behaviorClaims"]; +const fixtures = ["attributeSlots"]; function transformSsr(code, fixture, serverComponents) { return ( @@ -24,7 +26,7 @@ function transformSsr(code, fixture, serverComponents) { ); } -describe("SSR serverComponents behavior claims", () => { +describe("SSR serverComponents attribute slots", () => { it.each(fixtures)("matches generated Oxc output: %s", fixture => { const source = fs.readFileSync(path.join(babelFixtures, fixture, "code.js"), "utf8"); const output = transformSsr(source, fixture, true); @@ -36,10 +38,23 @@ describe("SSR serverComponents behavior claims", () => { expect(output).toBe(fs.readFileSync(outputPath, "utf8")); }); - it("stays inert when the option is off — refs hoist, on* drops, no claim import", () => { - const source = fs.readFileSync(path.join(babelFixtures, "behaviorClaims", "code.js"), "utf8"); - const output = transformSsr(source, "behaviorClaims", false); + it("stays inert when the option is off — refs hoist, on* drops, class/style inline", () => { + const source = fs.readFileSync(path.join(babelFixtures, "attributeSlots", "code.js"), "utf8"); + const output = transformSsr(source, "attributeSlots", false); expect(output).not.toContain("ssrClaim"); expect(output).not.toContain("sharedConfig"); + expect(output).not.toContain("ssrElementAttribute"); + expect(output).toContain("ssrClassName"); + // Spread elements: `ref`/`on*` drop as before — no claim thunk, no + // source property, and the handler expressions do not appear at all. + expect(output).not.toContain("row.go"); + expect(output).not.toContain("row.el"); + expect(output).not.toContain("row.key"); + expect(output).not.toContain("localHandler"); + expect(output).not.toContain("row.first"); + expect(output).not.toContain("row.third"); + expect(output).toMatch(/_\$ssrElement\("button", rest, \[/); + // A lone spread beside a `ref` stays the bare props argument. + expect(output).toMatch(/_\$ssrElement\("u", rest, undefined, false\)/); }); }); diff --git a/packages/compiler/src/compiler.rs b/packages/compiler/src/compiler.rs index 9e1869409..619f28ccb 100644 --- a/packages/compiler/src/compiler.rs +++ b/packages/compiler/src/compiler.rs @@ -115,7 +115,8 @@ pub struct CompileOptions { pub module_name: String, pub generate: Generate, pub hydratable: bool, - /// SSR-only: behavior-claim (`_bnd`) marker emission for server components. + /// SSR-only: attribute-slot position holes (`ref`/`on*` claims, whole-attribute + /// `class`/`style`) for server components. pub server_components: bool, /// SSR-only: emit each component's props literal with getters as a /// module-level constructor with shared getters (one hidden class per diff --git a/packages/compiler/src/config.rs b/packages/compiler/src/config.rs index 4c5d4245f..8a5428357 100644 --- a/packages/compiler/src/config.rs +++ b/packages/compiler/src/config.rs @@ -79,9 +79,11 @@ pub struct TransformOptions { pub validate: Option, pub omit_nested_closing_tags: Option, pub omit_last_closing_tag: Option, - /// Babel's `serverComponents`: SSR-only. `ref`/`on*` positions on - /// intrinsic elements compile to a guarded `_$ssrClaim` hole (the - /// `_bnd` behavior-claim marker) instead of dropping. + /// Babel's `serverComponents`: SSR-only. Attribute-slot positions on + /// intrinsic elements stay bindable: `ref`/`on*` compile to a guarded + /// `_$ssrClaim` hole instead of dropping, and a dynamic `class`/`style` + /// compiles to a whole-attribute `_$ssrElementAttribute` hole instead of + /// a value inside template quotes. pub server_components: Option, /// SSR-only (default `true`): a component's props literal with getters /// compiles to a module-level constructor with shared getters instead of diff --git a/packages/compiler/src/dom/spread.rs b/packages/compiler/src/dom/spread.rs index 135d72126..d9f12505d 100644 --- a/packages/compiler/src/dom/spread.rs +++ b/packages/compiler/src/dom/spread.rs @@ -69,6 +69,14 @@ impl<'a> AstDomTransform<'a, '_> { { continue; } + // `$key` is server markup identity: a DOM compile strips + // it (Babel's `renameElementKey` removes the attribute + // before the spread is processed; shared/attr_plan.rs is + // the template path's copy of the same rule). + if matches!(&attr.name, oxc_ast::ast::JSXAttributeName::Identifier(name) if name.name == "$key") + { + continue; + } running_props.push(self.spread_attribute_property(attr, tag_name)?); } } diff --git a/packages/compiler/src/ssr/transform.rs b/packages/compiler/src/ssr/transform.rs index 185ac033c..b7786f029 100644 --- a/packages/compiler/src/ssr/transform.rs +++ b/packages/compiler/src/ssr/transform.rs @@ -1439,7 +1439,7 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> { self.uses_ssr_select_values = true; } let do_not_escape = tag_name == "script" || tag_name == "style"; - let (props, tail) = self.spread_props( + let (props, tail, claims) = self.spread_props( &tag_name, &element.opening_element.attributes, !element.children.is_empty(), @@ -1472,6 +1472,23 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> { let markup = self.tail_markup(element.span, tail); args.push(expression_to_argument(markup)); } + // The claim map rides as a seventh argument, a thunk `ssrElement` + // reads only under an armed render context; `undefined` pads the + // skip/markup slots when the element has no tail. + if !claims.is_empty() { + if args.len() == 4 { + for _ in 0..2 { + args.push(expression_to_argument(self.ast().expression_identifier( + element.span, + self.ast().ident("undefined"), + ))); + } + } + let map = self.claim_segments(element.span, claims); + args.push(expression_to_argument( + crate::shared::ast::concise_arrow_thunk(self.allocator, element.span, map), + )); + } Ok(self.ast().expression_call( element.span, self.ast() @@ -1507,17 +1524,39 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> { ) -> Result<( Expression<'a>, Option<(std::vec::Vec, std::vec::Vec>)>, + std::vec::Vec<(usize, String, Expression<'a>)>, )> { + // Server components (principles §9.2.3): the named `ref`/`on*` + // attributes of a spread element are handler positions like a + // template element's, and compile to the same claim map — `{ click: + // expr, ref: [a, b] }`, duplicate refs merged, a duplicate handler + // last-wins as the template path strips it — keyed by the index of + // the source each attribute sits before (`` → `{ 1: { click: go } }`, the spreads being sources 0 and + // 2), and handed to `ssrElement` as a thunk it reads only inside a + // server component's render (the gate the template path's `ssrClaim` + // guard reads), so plain SSR never evaluates the expressions. The + // runtime settles each handler position in source order, as the + // client's `spread(el, [a, { onClick: go }, b])` does — a spread at + // that index or later that HAS the key owns it — and merges every + // ref. Plain SSR + // output is unchanged (dropped, as a server element has no handlers + // to run). Collected in the loop below (`spread_claim`), where the + // source index is known. + let mut claims: std::vec::Vec<(usize, String, Expression<'a>)> = std::vec::Vec::new(); // The DOM transform handles `ref` outside its spread prop sources. let mut prop_attributes = attributes.iter().filter(|attr| { !matches!(attr, JSXAttributeItem::Attribute(attr) if matches!(&attr.name, oxc_ast::ast::JSXAttributeName::Identifier(name) if name.name == "ref")) }); + // A `ref` beside the lone spread is a claim under `serverComponents`; + // the loop below places it. if let (Some(JSXAttributeItem::SpreadAttribute(spread)), None) = (prop_attributes.next(), prop_attributes.next()) + && !(self.server_components && attributes.len() > 1) { - return Ok((spread.argument.clone_in(self.allocator), None)); + return Ok((spread.argument.clone_in(self.allocator), None, claims)); } let last_spread = attributes .iter() @@ -1551,6 +1590,19 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> { prop_objects.push(argument); } JSXAttributeItem::Attribute(attr) => { + if self.server_components + && let Some((pos, expression)) = self.spread_claim(attr) + { + // The index of the source this attribute sits + // before: the sources pushed so far, plus the running + // literal if it will be pushed ahead of the next + // spread. The literal never carries a handler key, so + // a spread's index is either below this or at/after + // it — never ambiguous. + let index = prop_objects.len() + usize::from(!running_props.is_empty()); + claims.push((index, pos, expression)); + continue; + } if let Some(property) = self.spread_prop_property(tag_name, attr, has_children, i > last_spread)? { @@ -1595,7 +1647,47 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> { .expect("single SSR spread prop object exists") }; let tail = (!skip_keys.is_empty()).then_some((skip_keys, tail)); - Ok((props, tail)) + Ok((props, tail, claims)) + } + + /// The handler position a named attribute of a spread element claims + /// (Babel's `claims` in `createElement`): a `ref`/`on*` whose value is + /// an expression that is not a literal; `onXxx` lowercases to the event + /// name as the template path derives it. `None` for every other + /// attribute — including a `ref`/`on*` with a literal or no value, + /// which `spread_prop_property` drops as before. + fn spread_claim( + &self, + attr: &oxc_ast::ast::JSXAttribute<'a>, + ) -> Option<(String, Expression<'a>)> { + let name = match &attr.name { + oxc_ast::ast::JSXAttributeName::Identifier(name) => name.name.to_string(), + oxc_ast::ast::JSXAttributeName::NamespacedName(name) => { + format!("{}:{}", name.namespace.name, name.name.name) + } + }; + let pos = if name == "ref" { + "ref".to_string() + } else { + let pos = name.strip_prefix("on")?.to_lowercase(); + if pos.is_empty() { + return None; + } + pos + }; + let Some(JSXAttributeValue::ExpressionContainer(container)) = &attr.value else { + return None; + }; + let expression = container.expression.as_expression()?; + if matches!( + expression, + Expression::StringLiteral(_) + | Expression::NumericLiteral(_) + | Expression::BooleanLiteral(_) + ) { + return None; + } + Some((pos, expression.clone_in(self.allocator))) } /// One attribute of a spread element: a property of the running source @@ -1617,9 +1709,21 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> { if has_children && name == "children" { return Ok(None); } + // `ref`/`on*` render nothing on the server; under `serverComponents` + // they are the element's claim map instead (`spread_claim`). if name == "ref" || name.starts_with("prop:") || name.starts_with("on") { return Ok(None); } + // `$key` on an intrinsic element compiles to the `_key` attribute + // the frame morph matches keyed elements by, in a spread element's + // sources and tail exactly as in the template path + // (shared/attr_plan.rs; Babel renames the JSX attribute up front in + // `renameElementKey`, ahead of both paths). + let name = if name == "$key" { + "_key".to_string() + } else { + name + }; if in_tail && let Some(part) = self.tail_attribute(tag_name, &name, attr) { return Ok(Some(SpreadProp::Tail(name, part))); } @@ -1891,8 +1995,9 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> { .plan_attributes(&element.opening_element.attributes, &tag_name)?; let has_children = !element.children.is_empty() || outcome.children_replacement.is_some(); let mut attr_children: Option> = None; - // Server-components behavior claims: ref/on* positions collected - // across the element's attributes (Babel's `claims`). + // Server-components handler positions: ref/on* expressions + // collected across the element's attributes (Babel's `claims`), + // emitted as one `ssrClaim` hole that marks attribute-slot reads. let mut claims: std::vec::Vec<(String, Expression<'a>)> = std::vec::Vec::new(); for plan in outcome.plans { self.append_planned_attribute( @@ -2025,15 +2130,11 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> { return Ok(()); } if let Some(rest) = key.strip_prefix("on") { - // Capture-phase variants can't ride delegation; they drop as - // before. `on:x` keeps the raw name, `onXxx` lowercases — the - // same event-name derivation as the client runtime. - if self.server_components && !key.starts_with("oncapture:") { - let pos = if let Some(raw) = rest.strip_prefix(':') { - raw.to_string() - } else { - rest.to_lowercase() - }; + // `onXxx` lowercases to the event name — the client runtime's + // own derivation (`onClick` -> `click`); the position is bound + // under it. + if self.server_components { + let pos = rest.to_lowercase(); if !pos.is_empty() { claims.push((pos, expression)); } @@ -2057,6 +2158,31 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> { let is_dynamic_value = !plan.marker_static && self.classify().is_dynamic(None, &expression, true); + // Server components (principles §9.2.3): a dynamic `class`/`style` + // is the one attribute shape the plain SSR output serializes INSIDE + // template quotes (`class="${ssrClassName(x)}"`), where an attribute-slot + // value read at that position — the whole value, or a name's + // condition in object form — would be stringified instead of + // bound. Under the option the whole attribute is a runtime hole, + // `ssrElementAttribute("class", x)`, whose helper emits the same + // bytes for a plain value and the position marker for a stand-in. + // Object literals stay objects (no inlining) for the same reason. + if self.server_components && (key == "class" || key == "style") { + self.uses_ssr_element_attribute = true; + let key_literal = + self.ast() + .expression_string_literal(span, self.ast().str(&key), None); + let attr = + self.helper_call(span, "_$ssrElementAttribute", vec![key_literal, expression]); + let hole = if is_dynamic_value { + let arrow = self.arrow_return_expression(span, attr); + self.hoist_expression(template, span, arrow, true, false) + } else { + attr + }; + template.push_expr(hole); + return Ok(()); + } let is_boolean = matches!(expression, Expression::BooleanLiteral(_)); let mut do_escape = !is_boolean; let mut value = expression; @@ -2697,19 +2823,77 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> { ) } - /// One guarded whole-attribute behavior-claim hole per element (Babel's - /// `claims` emission): duplicate positions merge into arrays (multiple - /// refs), and the expressions only evaluate when the render context's - /// claims flag is set — - /// `_$sharedConfig.context && _$sharedConfig.context.claims - /// ? _$ssrClaim({...}) : ""`. - fn ssr_claim_hole( - &mut self, + /// A spread element's claim map by source index (Babel's + /// `claimSegments`): `{ 1: { click: go }, 3: { ref: el } }`. A handler + /// position keeps its LAST named attribute only — the template path's + /// duplicate strip, applied here to the claims (a later attribute wins + /// whatever sits between, so the earlier one is never read on either + /// side); refs merge within a segment as `claim_map` merges them, and + /// across segments at the runtime. + fn claim_segments( + &self, + span: Span, + claims: std::vec::Vec<(usize, String, Expression<'a>)>, + ) -> Expression<'a> { + let mut last_handler: std::vec::Vec<(String, usize)> = std::vec::Vec::new(); + for (i, (_, pos, _)) in claims.iter().enumerate() { + if pos == "ref" { + continue; + } + if let Some(entry) = last_handler.iter_mut().find(|(name, _)| name == pos) { + entry.1 = i; + } else { + last_handler.push((pos.clone(), i)); + } + } + let mut segments: std::vec::Vec<(usize, std::vec::Vec<(String, Expression<'a>)>)> = + std::vec::Vec::new(); + for (i, (index, pos, expr)) in claims.into_iter().enumerate() { + if pos != "ref" + && last_handler + .iter() + .any(|(name, last)| *name == pos && *last != i) + { + continue; + } + if let Some(entry) = segments.iter_mut().find(|(at, _)| *at == index) { + entry.1.push((pos, expr)); + } else { + segments.push((index, vec![(pos, expr)])); + } + } + let mut properties = self.ast().vec(); + for (index, list) in segments { + let Expression::NumericLiteral(key) = self.ast().expression_numeric_literal( + span, + index as f64, + None, + oxc_ast::ast::NumberBase::Decimal, + ) else { + unreachable!("a numeric literal expression"); + }; + let value = self.claim_map(span, list); + properties.push(self.ast().object_property_kind_object_property( + span, + oxc_ast::ast::PropertyKind::Init, + oxc_ast::ast::PropertyKey::NumericLiteral(key), + value, + false, + false, + false, + )); + } + self.ast().expression_object(span, properties) + } + + /// The compiled claim map of an element's handler positions, `{ click: + /// expr, ref: [a, b] }` (Babel's `claimMap`): duplicate positions merge + /// into arrays (multiple refs). + fn claim_map( + &self, span: Span, claims: std::vec::Vec<(String, Expression<'a>)>, - template: &mut SsrTemplate<'a>, ) -> Expression<'a> { - self.uses_ssr_claim = true; let mut by_pos: std::vec::Vec<(String, std::vec::Vec>)> = std::vec::Vec::new(); for (pos, expr) in claims { @@ -2733,7 +2917,24 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> { }; properties.push(self.object_property(span, &pos, value)); } - let map = self.ast().expression_object(span, properties); + self.ast().expression_object(span, properties) + } + + /// One guarded whole-attribute handler-position hole per element (Babel's + /// `claims` emission; `ssrClaim` marks attribute-slot reads as `_s:on:*` / + /// `_s:ref`): duplicate positions merge into arrays (multiple + /// refs), and the expressions only evaluate when the render context's + /// claims flag is set — + /// `_$sharedConfig.context && _$sharedConfig.context.claims + /// ? _$ssrClaim({...}) : ""`. + fn ssr_claim_hole( + &mut self, + span: Span, + claims: std::vec::Vec<(String, Expression<'a>)>, + template: &mut SsrTemplate<'a>, + ) -> Expression<'a> { + self.uses_ssr_claim = true; + let map = self.claim_map(span, claims); let context_read = |transform: &Self| -> Expression<'a> { Expression::StaticMemberExpression( transform.ast().alloc_static_member_expression( diff --git a/packages/h/jsx-runtime/src/jsx.d.ts b/packages/h/jsx-runtime/src/jsx.d.ts index bef160320..5d78b23dc 100644 --- a/packages/h/jsx-runtime/src/jsx.d.ts +++ b/packages/h/jsx-runtime/src/jsx.d.ts @@ -250,6 +250,16 @@ export namespace JSX { ref?: Ref; children?: FunctionMaybe; $ServerOnly?: boolean | undefined; + /** + * 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 + * 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 + * one occurrence per render with or without it). + */ + $key?: string | number | undefined; } interface ExplicitProperties {} type PropAttributes = { diff --git a/packages/signals/src/core/dev.ts b/packages/signals/src/core/dev.ts index a38a89ce0..d8b376d27 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" - | "BEHAVIOR_CLAIM_DROPPED" + | "ATTRIBUTE_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 cfc369a70..8c0593b06 100644 --- a/packages/solid/skills/reactivity-diagnostics/SKILL.md +++ b/packages/solid/skills/reactivity-diagnostics/SKILL.md @@ -843,14 +843,32 @@ 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. -### BEHAVIOR_CLAIM_DROPPED - -A behavior position (an event handler) on a server-rendered element got -something the wire cannot carry: a client prop through a spread -(`data.reason: "spread"` — write the position out, `onClick={props.x}`) or a -function that exists only on the server (`"server-local"` — pass it from the -client through the server component's props, or bind a mutation to -`action=`). +### ATTRIBUTE_SLOT_POSITION + +An attribute 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 +each position instead), `"stringified"` (coerced into a string — a template +literal, a concatenation), `"coerced"` (used in an expression — a +comparison, arithmetic, a branch on its result; the server has no value to +compute with, so decide in the client fill and return the decided value), +`"inline"` (reached `class`/`style` inside template quotes — the element +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 +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 +record once none can arrive, which is the protocol out of step — client +and server from different builds — not a fill mistake). For +`stringified`/`coerced`/`inline`/`text` NOTHING +renders at the position on either face, so the misuse shows on the first +render, not the first refetch. Truthiness (`if (row.done)`) has no hook and +is the one misuse only the rule catches — a stand-in is always truthy. +The fuller guide is `@solidjs/web`'s `skills/server-components/SKILL.md`. ## Verifying a fix diff --git a/packages/web/frames/src/client.ts b/packages/web/frames/src/client.ts index 064c118ae..3253e4a9d 100644 --- a/packages/web/frames/src/client.ts +++ b/packages/web/frames/src/client.ts @@ -22,7 +22,9 @@ import { createOwner, createRenderEffect, createSignal, + DEV, getOwner, + OBSERVE, onCleanup, runWithOwner, sharedConfig @@ -33,7 +35,7 @@ import type { Element as SolidElement } from "solid-js"; // copy of `insert` and the reconcile/render machinery it drags in (~4kb the app // already has). Kept external in rollup.config.js for the same reason the // server-functions/client import below is. -import { insert, delegateEvents } from "@solidjs/web"; +import { insert, assign } from "@solidjs/web"; import { createFrame, createFrameElement, createFrameHost, FRAME_ID_ATTR } from "./frame-client.js"; import { COMPONENT_BINDING, createServerComponentHandler } from "./frame-transport.js"; // The container tier (DR-2 case 3): server projections cross the border as @@ -52,6 +54,9 @@ import { import { materializeContainerTrace } from "solid-js/internal"; setContainerTraceMaterializer(materializeContainerTrace); + +// Build-time literal (see diagnostics.ts): dev-only guidance folds out of prod. +const IS_DEV = "_SOLID_DEV_" as unknown as boolean; // This import must resolve to the SHARED built instance, not a bundled // copy: configuring the server-function client only counts if it's the same // module the compiled reference proxies call through @@ -93,7 +98,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 } from "./server.js"; +export type { Slot, AttributeSlot } from "./server.js"; /** * Client-condition twin of the server face's `asyncArg` (DR-2 value tier): @@ -160,11 +165,7 @@ export function getFrameHost() { revive: reviveContainerTraces, // Lets the record-dedupe compare identity-test containers instead of // probing them (a pending container's property reads throw not-ready). - isContainer: isMaterializedContainer, - // Behavior claims: arms document listeners for event types named by - // `_bnd` markers. Threaded as an option because the core client entry - // must not export the event system into tree-shaken subsets. - delegate: delegateEvents + isContainer: isMaterializedContainer }); } return sharedHost; @@ -362,6 +363,203 @@ function slotArgsProxy(args: () => Record) { ); } +interface ElementState { + prev: any; + ref: any; + 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. + */ +function bindDataOccurrence(fill: (args: any) => any, args: any, ctx: any) { + 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 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. + let bound = new Set(); + createRenderEffect( + () => { + const out = output(); + return consumers().map(({ element, positions }) => ({ + element, + ...propsFor(element, positions, out) + })); + }, + writes => { + const next = new Set(); + for (const { element, props, handlers } of writes) { + next.add(element); + write(element, props, handlers); + } + for (const element of bound) if (!next.has(element)) write(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. + onCleanup(() => { + for (const element of bound) write(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)); + } + 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 === "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) { + // 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. + const id = refKeys.join(","); + if (st.refId !== id) { + const keys = refKeys; + st.refId = id; + st.ref = (el: Element) => { + const o = output(); + for (const k of keys) { + const r = o[k]; + typeof r === "function" && r(el); + } + }; + } + props.ref = st.ref; + } + return { props, handlers }; + } +} + +/** + * 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 + * an object, or the prop is not a function. Through the diagnostics + * channel, so an observer captures it beside the server's findings. + */ +function slotShapeFinding(occurrence: string, shape: string, message: string) { + DEV!.report( + OBSERVE!.diagnostics.emit( + { + code: "ATTRIBUTE_SLOT_POSITION", + kind: "render", + severity: "warn", + message, + data: { reason: "fill-shape", occurrence, shape } + }, + null + ) + ); +} + /** Whether a resolved slot value is reactive at the top level. */ function isReactiveContent(value: any): boolean { if (typeof value === "function") return true; @@ -405,6 +603,41 @@ function slotsFor(props: Record) { fillScopes.delete(key); prevFill.dispose(); } + // Attribute 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 + // the morph), and there are no placed nodes for the frame's zombie + // heuristic to misread. + if (ctx && ctx.positions) { + const fill = props[prop]; + if (typeof fill !== "function") { + if (IS_DEV) { + slotShapeFinding( + key, + typeof fill, + `[ATTRIBUTE_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.` + ); + } + return undefined; + } + const owner = createOwner(); + fillScopes.set(key, owner); + ctx.onCleanup(() => { + if (fillScopes.get(key) === owner) fillScopes.delete(key); + owner.dispose(); + }); + runWithOwner(owner, () => { + const args = ctx.onUpdate + ? liveSlotProps(slotProps, ctx) + : slotArgsProxy(() => slotProps); + bindDataOccurrence(fill, args, ctx); + }); + return undefined; + } // Stream-mounted fills (no ambient owner at invocation — the frame // called from a chunk microtask) render under a PER-OCCURRENCE // owner whose disposal rides the frame's occurrence-level cleanup: @@ -659,10 +892,6 @@ function boundaryComponent(host: any, fnId: string) { // placeholder mount) binds the function id — the argless address. id, slots: slotsFor(props), - // Raw client props (compiled getters — live at every read): behavior - // claims (`_bnd` markers on server elements) resolve ref/event props - // by name through these at dispatch/materialize time. - props, ownerScope: boundaryScope(owner), reveal: revealSeam(owner), // Any apply releases the gate — content ("materialize") is the normal @@ -1110,9 +1339,6 @@ function adoptBoundary( host, id: address, slots: slotsFor(props), - // Raw client props for behavior-claim resolution (see the stream-mount - // counterpart above). - props, ownerScope: boundaryScope(owner), reveal: revealSeam(owner), // Any apply for the currently bound address — a morph, a reveal, an diff --git a/packages/web/frames/src/frame-client.ts b/packages/web/frames/src/frame-client.ts index b28512f04..25d5e315d 100644 --- a/packages/web/frames/src/frame-client.ts +++ b/packages/web/frames/src/frame-client.ts @@ -280,15 +280,6 @@ export interface FrameHostOptions { * identity only. */ isContainer?(value: unknown): boolean; - /** - * Arms event types for behavior claims: the `_bnd` sweep collects the - * event names it finds and hands them here so delegated dispatch can - * reach them. Platform glue passes its `delegateEvents` — the option - * exists (rather than client.js importing the event system) so - * tree-shaken subsets without events pay nothing. Frames registered - * with this host inherit it unless they pass their own `delegate`. - */ - delegate?(eventNames: Iterable): void; } /** @@ -301,13 +292,6 @@ export interface FrameOptions { id?: string; /** Client content keyed by prop name (occurrences resolve by prop). */ slots?: Record; - /** - * Raw client props for behavior-claim resolution: server elements carrying - * `_bnd="pos=prop"` markers (compiled under the `serverComponents` option) - * resolve ref/event positions by name through this object — read live at - * dispatch/materialize time, so compiled prop getters stay latest-value. - */ - props?: Record; /** * Adopt existing server-rendered DOM: the first apply morphs against it, * and slots sync immediately (hydration attach) — a document-SSR boot @@ -325,8 +309,6 @@ export interface FrameOptions { * streamed chunks). */ ownerScope?(fn: () => T): T; - /** Per-frame override of the host's `delegate` (see FrameHostOptions). */ - delegate?(eventNames: Iterable): void; /** * Boundary-driven segment reveal. When present, `#revealSegment` hands the * placeholder seam to this hook instead of swapping imperatively: the binding @@ -415,119 +397,6 @@ function claimNode(handlers, el) { const claimedAttr = name => name === "href" || name === "action"; -// === Behavior claims (Stage 6: ref/event props on server elements) === -// -// Server markup carries `_bnd="pos=prop[,pos=prop]*"` markers (compiled -// under the `serverComponents` option) naming which CLIENT props hold the -// behavior for each position. Dispatch resolves by name through the frame's -// live props at event time — latest-props by construction, no table. The -// seam with client.js is a registered symbol read from inside its delegation -// walk (importless in both directions, zero top-level bytes there); THIS -// module is the only writer. Document-listener arming flows the other way as -// a host/frame option (`delegate`, wired by the platform glue to -// delegateEvents) — publishing it from client.js would drag the whole event -// system into every tree-shaken subset of the core entry. -const BOUND_SEAM = Symbol.for("solid.bnd"); -const boundSeam = globalThis[BOUND_SEAM] || (globalThis[BOUND_SEAM] = {}); -const BND_ATTR = "_bnd"; -const BND_SELECTOR = "[_bnd]"; - -// Parsed on demand — the string is a handful of entries and reads happen -// per dispatch / per sweep, so a cache would cost more bytes than it saves. -function bndMap(el) { - const s = el.getAttribute(BND_ATTR); - if (!s) return undefined; - const map = {}; - for (const entry of s.split(",")) { - const eq = entry.indexOf("="); - if (eq < 1) continue; - const pos = entry.slice(0, eq); - const prop = decodeURIComponent(entry.slice(eq + 1)); - // Repeated positions (multiple refs) accumulate. - const prev = map[pos]; - if (prev === undefined) map[pos] = prop; - else if (Array.isArray(prev)) prev.push(prop); - else map[pos] = [prev, prop]; - } - return map; -} - -// Dispatch-time resolution for the delegation walk. The owning frame rides -// a sweep-stamped expando (not an ancestor climb: range-bounded frames have -// no wrapping element, and morphs re-stamp replaced elements on re-sweep). -boundSeam.resolve = (el, type) => { - const frame = el._$bndFrame; - if (!frame) return undefined; - const map = bndMap(el); - const prop = map && map[type]; - if (typeof prop !== "string") return undefined; - return claimFn(frame, type, prop); -}; - -/** Read one claimed prop off the frame, warning (dev) on non-functions. */ -function claimFn(frame, pos, prop) { - const fn = frame.clientProp(prop); - if (typeof fn === "function") return fn; - if ("_SOLID_DEV_" && fn !== undefined) { - console.warn( - `A server element claims \`${pos}\` from client prop \`${prop}\`, but the mounted ` + - `frame's prop is not a function.` - ); - } - return undefined; -} - -/** - * Sweep one materialized/morph-touched subtree for `_bnd` markers: stamp - * each marked element with its owning frame (dispatch resolution), arm - * document listeners for claimed event types, and fire ref positions. - * Dormant cost without markers: one selector query per apply. - */ -function sweepBound(root, frame, delegate, scope) { - const isElement = root.nodeType === ELEMENT_NODE; - if (!isElement && root.nodeType !== 11 /* DOCUMENT_FRAGMENT_NODE */) return; - let els; - if (isElement && root.hasAttribute(BND_ATTR)) (els = []).push(root); - const found = root.querySelectorAll(BND_SELECTOR); - if (found.length) { - els || (els = []); - for (let i = 0; i < found.length; i++) els.push(found[i]); - } - if (!els) return; - // The whole marker pass runs under the creator's ownerScope (the client - // component that passed the props): refs get effects, context, and - // onCleanup inside the callback, bounded by the frame's owner — the - // contract §9.1 promises. Arming is scope-indifferent, so one wrap covers - // everything. - const run = () => { - let types; - for (const el of els) { - el._$bndFrame = frame; - const map = bndMap(el); - if (!map) continue; - for (const pos in map) { - if (pos === "ref") fireRefs(frame, el, map.ref); - else (types || (types = [])).push(pos); - } - } - if (types && delegate) delegate(types); - }; - scope ? scope(run) : run(); -} - -// Ref-position dedupe rides an expando: refs fire once per (element, prop) — -// a morph that replaces the element re-fires on the fresh node (fresh -// expando); a re-sweep over a kept node does not. -function fireRefs(frame, el, prop) { - const fired = el._$bndFired || (el._$bndFired = new Set()); - for (const p of Array.isArray(prop) ? prop : [prop]) { - if (fired.has(p)) continue; - fired.add(p); - const fn = claimFn(frame, "ref", p); - if (fn) fn(el); - } -} - /** Sweep `root` (element or fragment) and its claimable interior. */ function claimTree(handlers, root) { const isElement = root.nodeType === ELEMENT_NODE; @@ -542,7 +411,72 @@ const placeholderId = name => `pl-${name}`; const SLOT_START = /^slot:(.+):start$/; const SLOT_END = /^slot:(.+):end$/; -const slotEnd = id => `slot:${id}:end`; /** +const slotEnd = id => `slot:${id}:end`; + +// === Attribute 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 +// 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:` +// for a handler, `_s:ref` for a ref. The OCCURRENCE is the slot call (one +// data context — `props.row({ id, completed })`), not the element: any +// number of elements consume it, and the sync mounts it once, handing the +// consumer every (element, position, key) it found. The fill runs once per +// occurrence with the occurrence's args (the same `slot:` record +// a markup slot's call emits) and writes each position from its returned +// object; a re-emitted record updates the args in place, as for markup +// occurrences. The elements stay server-owned: the morph keeps them (keyed +// or positional), and reads the markers off INCOMING markup to know which +// positions are the client's (see `morphAttributes`) — no ownership table. +const SLOT_MARKER = "_s:"; + +/** + * Parse one element's `_s:*` markers into positions grouped by occurrence: + * `{ [occurrence]: [{ pos, key, name }] }`, or null. `pos` is the marker's + * position as written (`class`, `hidden`, `on:click`, `ref`), `name` the + * class name / style property for a member position. Keys and names are + * percent-encoded on the wire (they are client-controlled strings landing + * in a `,`/`:`/`=`-delimited grammar) and decoded here. + */ +function slotPositions(el) { + const attrs = el.attributes; + let out = null; + for (let i = 0; i < attrs.length; i++) { + const attr = attrs[i]; + if (!attr.name.startsWith(SLOT_MARKER)) continue; + const pos = attr.name.slice(SLOT_MARKER.length); + for (const entry of attr.value.split(",")) { + const colon = entry.indexOf(":"); + if (colon < 1) continue; + const occurrence = entry.slice(0, colon); + const eq = entry.indexOf("=", colon); + const key = decodeURIComponent( + eq === -1 ? entry.slice(colon + 1) : entry.slice(colon + 1, eq) + ); + const name = eq === -1 ? undefined : decodeURIComponent(entry.slice(eq + 1)); + out || (out = Object.create(null)); + (out[occurrence] || (out[occurrence] = [])).push({ pos, key, name }); + } + } + return out; +} + +/** Whether a data occurrence's consumer set changed (elements or positions). */ +function consumersEqual(a, b) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + const x = a[i]; + const y = b[i]; + if (x.element !== y.element || x.positions.length !== y.positions.length) return false; + for (let j = 0; j < x.positions.length; j++) { + const p = x.positions[j]; + const q = y.positions[j]; + if (p.pos !== q.pos || p.key !== q.key || p.name !== q.name) return false; + } + } + return true; +} /** * Maps a wire chunk onto resident-store record writes. `data` chunks map to * no records — they are response-scoped and the host applies them through * its data hook. @@ -708,10 +642,6 @@ export function createFrameHost(options = {}) { return true; }; return { - // Document-listener arming for behavior-claim event positions: the - // platform glue passes delegateEvents here so frames can arm types no - // compiled client handler ever registered (see the seam note above). - delegate: options.delegate, register(id, frame) { let set = frames.get(id); if (!set) frames.set(id, (set = new Set())); @@ -842,6 +772,10 @@ class FrameImpl { #slotRegions = new Map(); #slotResolvedRefs = new Map(); #slotNodes = new Map(); + // Data occurrences (§9.2.3): the consumer set last handed to the mount, + // and the mount's rebind callback (`ctx.onRebind`) for when it changes. + #slotConsumers = new Map(); + #slotRebinders = new Map(); #processedAssets = new WeakSet(); // The pending re-check for adopt-time occurrences deferred on a // still-arriving args record (#2968 — see #syncSlots). @@ -862,26 +796,11 @@ class FrameImpl { // longer matches the sweep selector), mirroring compiled setAttribute. // Stable identity so it threads into the morph without allocation. #claimTree = (node, direct) => { - // Behavior claims sweep first, and unconditionally — `_bnd` markers are - // this frame's own contract, not a registered-consumer one. `direct` - // re-checks (in-place attribute rewrites) are nav-claim specific; a - // morph that rewrites `_bnd` in place re-parses at next dispatch, and - // kept elements keep their stamp. - if (!direct && node.nodeType !== TEXT_NODE && node.nodeType !== COMMENT_NODE) { - const o = this.#options; - sweepBound(node, this, o.delegate || (o.host && o.host.delegate), o.ownerScope); - } const handlers = claimHandlers(); if (!handlers) return; this.#scoped(() => (direct ? claimNode(handlers, node) : claimTree(handlers, node))); }; - /** A raw client prop, read live — behavior-claim resolution (`_bnd`). */ - clientProp(name) { - const props = this.#options.props; - return props ? props[name] : undefined; - } - /** Run `fn` under the creator's `ownerScope` (when provided). */ #scoped(fn) { const scope = this.#options.ownerScope; @@ -1189,13 +1108,27 @@ class FrameImpl { // "comment#0"); the callback is looked up by its prop — the part before // "#" — so one callback services N occurrences from an iterated render // prop. + // Data occurrences (`_s:*` markers, principles §9.2.3) land in the same + // map, keyed the same way, with their CONSUMERS as the occurrence's + // node: an array of `{ element, positions }` in document order. The + // loop below treats them as occurrences whose mount binds those + // positions rather than filling a range (no interior, no regions, never + // replaced), and whose consumer set may change without a re-call. const found = new Map(); - if (root) collectSlots(root.firstChild, null, found); - else this.#collectSlots(found); + if (root) collectSlots(root.firstChild, null, found, found); + else this.#collectSlots(found, found); for (const [occurrence, start] of found) { const callback = this.#resolveSlot(propOf(occurrence)); - if (!callback) continue; // no client impl for this prop up the tree + const consumers = Array.isArray(start) ? start : null; + if (!callback) { + // No client impl for this prop up the tree. A range stays empty, + // which content can mean; bound positions never bind, which + // nothing can mean — the elements sit inert with no error. Dev + // names them (once per occurrence). + if ("_SOLID_DEV_" && consumers) devSlotOrphan(this, occurrence, consumers, "fill"); + continue; + } const record = this.#resolveSlotRecord(occurrence); // A record whose data refs have not ARRIVED yet is not applicable: the // producer emits the slot chunk before the `data` chunks carrying its @@ -1218,7 +1151,12 @@ class FrameImpl { // though state can't survive a destroyed node. const prev = this.#slotNodes.get(occurrence); const prevFirst = Array.isArray(prev) ? prev[0] : prev; - const zombie = this.#mountedSlots.has(occurrence) && prevFirst && !prevFirst.parentNode; + // A data occurrence is never a zombie: its nodes are the server's + // consumers, not the fill's output — a replaced element is a consumer + // change (rebind, below), and an occurrence no element reads any more + // is simply not found (unmounted at the end). + const zombie = + !consumers && this.#mountedSlots.has(occurrence) && prevFirst && !prevFirst.parentNode; if (zombie) { this.#mountedSlots.delete(occurrence); this.#runSlotCleanups(occurrence); @@ -1261,6 +1199,16 @@ class FrameImpl { }); continue; } + // A CALLED occurrence (`prop#n`) always has a record — the producer + // emits it at the call, ahead of the markup that reads it — so + // marked positions with none here, once records can no longer + // arrive, are the protocol's invariant broken (a record dropped, or + // marker and record minted under different ids), never something + // the fill can fix. The mount below still runs, as it always has; + // dev says why its args are empty. A bare occurrence (the prop + // itself) has no record by design. + if ("_SOLID_DEV_" && consumers && record === undefined && occurrence.indexOf("#") !== -1) + devSlotOrphan(this, occurrence, consumers, "record"); // Direct-insert occurrences have no `slot:` record and mount with // empty props; render-function occurrences mount with resolved props. // Mounting replaces the range interior: on a fresh stream it is @@ -1287,8 +1235,19 @@ class FrameImpl { // its entries during the invoke instead. if (this.#options.adopt) this.#discoverRegions(occurrence, start); const nodes = this.#invokeSlot(occurrence, callback, record, start, this.#options.adopt); - if (nodes) this.#replaceRange(occurrence, start, nodes); - this.#slotNodes.set(occurrence, nodes); + // A data occurrence's nodes are its consuming elements (so the + // zombie check above sees a morph that replaced them all); its mount + // never returns nodes to place. + if (consumers) { + this.#slotNodes.set( + occurrence, + consumers.map(c => c.element) + ); + this.#slotConsumers.set(occurrence, consumers); + } else { + if (nodes) this.#replaceRange(occurrence, start, nodes); + this.#slotNodes.set(occurrence, nodes); + } this.#mountedSlots.add(occurrence); // Re-scan after invoke: a fresh mount's regions come from // #resolveArgs during the invoke, and the callback's output may have @@ -1298,7 +1257,22 @@ class FrameImpl { // large adopted tree). if (!this.#options.adopt || nodes) this.#discoverRegions(occurrence, start); this.#bindRegions(occurrence); - } else if (record !== this.#slotArgs.get(occurrence)) { + continue; + } + // A mounted data occurrence whose CONSUMERS changed — a morph replaced + // one of its elements, a response added or dropped a bound position + // — rebinds in place: the fill's computation stays, the binding gets + // the new set. Independent of an args change, which follows below. + if (consumers && !consumersEqual(this.#slotConsumers.get(occurrence), consumers)) { + this.#slotConsumers.set(occurrence, consumers); + this.#slotNodes.set( + occurrence, + consumers.map(c => c.element) + ); + const rebind = this.#slotRebinders.get(occurrence); + if (rebind) rebind(consumers); + } + if (record !== this.#slotArgs.get(occurrence)) { // A re-sent record differing only in {$ref} identity may carry the // SAME values (tables rotate per response, so the store-write // dedupe stays conservative). Value-compare the new refs against @@ -1332,8 +1306,15 @@ class FrameImpl { // reusing its cached server-content regions. Same contract: an // undefined return keeps the current interior. const nodes = this.#invokeSlot(occurrence, callback, record, start); - if (nodes) this.#replaceRange(occurrence, start, nodes); - this.#slotNodes.set(occurrence, nodes); + if (consumers) + this.#slotNodes.set( + occurrence, + consumers.map(c => c.element) + ); + else { + if (nodes) this.#replaceRange(occurrence, start, nodes); + this.#slotNodes.set(occurrence, nodes); + } this.#bindRegions(occurrence); } } @@ -1360,6 +1341,7 @@ class FrameImpl { // binding's updater so a stream args-change can't push props into a // disposed instance. The new invocation re-registers if it wants updates. this.#slotUpdaters.delete(occurrence); + this.#slotRebinders.delete(occurrence); const cleanups = this.#slotCleanups.get(occurrence) ?? []; // One walk yields both the interior and the end marker. The end marker is // part of the consumer contract (ctx.range): a framework binding that owns @@ -1367,7 +1349,10 @@ class FrameImpl { // insert before — the markers are the only stable nodes in the range. let existing = []; let end = null; - if (start) end = eachInRange(start, occurrence, n => existing.push(n)); + // A data occurrence's node is its consumer list: no interior to collect, + // no end marker. The consumer gets the positions instead. + const positions = Array.isArray(start) ? start : undefined; + if (start && !positions) end = eachInRange(start, occurrence, n => existing.push(n)); const ctx = { // Identity for hydration-claim scoping: consumers derive the same // key prefix the document producer used for this occurrence. The @@ -1398,7 +1383,16 @@ class FrameImpl { // The range's own markers, when it has them: consumers that bind the // interior reactively insert before `end` and return undefined — the // frame then never touches the interior (morphs protect slot ranges). - range: end ? { start, end } : undefined + range: end ? { start, end } : undefined, + // Attribute 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 + // to place). `onRebind` receives the new set when consumers change + // (a morph replaced an element; a response bound a new position) + // without the args changing — the fill's computation survives. + positions, + onRebind: positions ? fn => this.#slotRebinders.set(occurrence, fn) : undefined }; // One record shape (A5): the t=0 record carries used regions as // `{$frame}` refs like any stream record would, and #resolveArgs @@ -1431,10 +1425,12 @@ class FrameImpl { #unmountSlot(key) { this.#mountedSlots.delete(key); this.#slotNodes.delete(key); + this.#slotConsumers.delete(key); // Long-session hygiene: an occurrence gone from the stream releases its // record and caches — keyed churn must not accumulate forever. this.#slotArgs.delete(key); this.#slotUpdaters.delete(key); + this.#slotRebinders.delete(key); this.#slotResolvedRefs.delete(key); this.#removeSlotRecord(key); this.#runSlotCleanups(key); @@ -1547,7 +1543,9 @@ class FrameImpl { * their own slot sync — this is what wires nested occurrences at boot. */ #discoverRegions(slotKey, start) { - if (!start) return; + // A data occurrence has no interior (its args are data; a region arg + // has nowhere to render at an attribute position). + if (!start || Array.isArray(start)) return; const regions = this.#regionsFor(slotKey); eachInRange(start, slotKey, n => collectRegionElements(n, regions)); } @@ -1666,9 +1664,10 @@ class FrameImpl { } } - /** Collect this frame's own top-level slot ranges (bounded to its content). */ - #collectSlots(found) { - collectSlots(this.#firstContent(), this.#end, found); + /** 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. */ + #collectSlots(found, elements) { + collectSlots(this.#firstContent(), this.#end, found, elements); } /** Find a fragment placeholder `