@statorjs/stator 1.3.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,7 @@ import { coerceKeys, renderKeyedItem, renderListBody } from '../template/each.ts
3
3
  import type { Patch } from '../wire/index.ts'
4
4
  import { isUrlAttribute, safeAttrUrl } from '../wire/safe-url.ts'
5
5
  import {
6
+ type ItemBinding,
6
7
  keyedScopePrefix,
7
8
  keyToken,
8
9
  type RenderState,
@@ -16,6 +17,40 @@ import type { SessionRuntime } from './session-runtime.ts'
16
17
  * shape drops this field. */
17
18
  type PendingPatch = { patch: Patch; sourceSlot: string }
18
19
 
20
+ /** Diff one row's item-value bindings against its current item, pushing a text-
21
+ * or attr-op patch for each changed field — the row itself is never re-rendered.
22
+ * Text bindings target their slot, attr bindings their element, mirroring the
23
+ * machine text/attr binding patches. Only ever called for live (retained/
24
+ * current) rows, so no subsumption concern. */
25
+ function diffItemBindings(
26
+ pending: PendingPatch[],
27
+ row: ItemBinding[],
28
+ item: unknown,
29
+ index: number,
30
+ ): void {
31
+ for (const ib of row) {
32
+ const nv = ib.selector(item, index)
33
+ if (valuesEqual(nv, ib.lastValue)) continue
34
+ ib.lastValue = nv
35
+ if (ib.kind === 'text') {
36
+ pending.push({
37
+ patch: { target: { kind: 'slot', id: ib.slotId }, op: 'text', value: stringify(nv) },
38
+ sourceSlot: ib.slotId,
39
+ })
40
+ } else {
41
+ pending.push({
42
+ patch: {
43
+ target: { kind: 'element', id: ib.parentId },
44
+ op: 'attr',
45
+ name: ib.attrName,
46
+ value: sanitizeAttrWire(ib.attrName, attrWireValue(nv)),
47
+ },
48
+ sourceSlot: ib.parentId,
49
+ })
50
+ }
51
+ }
52
+ }
53
+
19
54
  /**
20
55
  * Walk every binding tied to `machineName`, re-evaluate its selector against
21
56
  * the current machine snapshot, and produce patches for any binding whose
@@ -34,6 +69,19 @@ export function recompute(
34
69
  machineName: string,
35
70
  runtime: SessionRuntime,
36
71
  ): Patch[] {
72
+ // Expose the fan-out runtime to nested `read()`s so an arm/list body that
73
+ // re-renders this pass resolves the CURRENT proxy, not its frozen closure
74
+ // instance (FINDINGS #3). Restored after so nothing leaks across passes.
75
+ const prevRuntime = state.currentRuntime
76
+ state.currentRuntime = runtime
77
+ try {
78
+ return recomputeInner(state, machineName, runtime)
79
+ } finally {
80
+ state.currentRuntime = prevRuntime
81
+ }
82
+ }
83
+
84
+ function recomputeInner(state: RenderState, machineName: string, runtime: SessionRuntime): Patch[] {
37
85
  const proxy = runtime.proxyFor(machineName)
38
86
  if (!proxy) return []
39
87
 
@@ -78,28 +126,44 @@ export function recompute(
78
126
  } else if (binding.kind === 'list') {
79
127
  const newArray = newValue as readonly unknown[]
80
128
  const oldArray = binding.lastValue as readonly unknown[]
81
- if (!arrayShallowEqual(newArray, oldArray)) {
129
+ const rows = binding.rows
130
+ // SPIKE (finding #5 / option C): a same-length list with item bindings
131
+ // diffs each row's item-value bindings and patches only the changed fields
132
+ // — the row DOM (islands, focus) is preserved, and identity churn (the VM
133
+ // re-deriving item objects) no longer forces a re-render, because we
134
+ // compare *values*, not references. Nested read() bindings inside a row
135
+ // are separate machine bindings and update through the main pass as usual.
136
+ if (rows && newArray.length === rows.length) {
137
+ for (let i = 0; i < newArray.length; i++) {
138
+ diffItemBindings(pending, rows[i]!, newArray[i], i)
139
+ }
140
+ binding.lastValue = newArray
141
+ } else if (!arrayShallowEqual(newArray, oldArray)) {
142
+ // No item bindings, or the length changed → wholesale re-render (and
143
+ // refresh binding.rows so a later same-length pass is granular again).
82
144
  const fn = binding.itemRenderer
83
- const newInner = runInRender(state, () => renderListBody(state, slotId, newArray, fn))
145
+ const rendered = runInRender(state, () => renderListBody(state, slotId, newArray, fn))
84
146
  pending.push({
85
147
  patch: {
86
148
  target: { kind: 'slot', id: slotId },
87
149
  op: 'html',
88
- value: newInner,
150
+ value: rendered.html,
89
151
  },
90
152
  sourceSlot: slotId,
91
153
  })
92
154
  binding.lastValue = newArray
155
+ binding.rows = rendered.rows.some((r) => r.length > 0) ? rendered.rows : undefined
93
156
  }
94
157
  } else if (binding.kind === 'list-keyed') {
95
158
  // Keyed diff: shape changes become per-item insert/remove/move ops from
96
159
  // a replay simulation (`work` mirrors what the client's DOM will look
97
- // like after each emitted op — see the wire contract). Content inside
98
- // retained items updates through the items' own nested bindings; the
99
- // keyed path never re-renders a retained row.
160
+ // like after each emitted op — see the wire contract). Retained rows keep
161
+ // their DOM; their content updates through nested read()s and option C —
162
+ // per-row itemBind item-value bindings (diffed after the shape pass).
100
163
  const newArray = newValue as readonly unknown[]
101
164
  const newKeys = coerceKeys(newArray, binding.keyFn, slotId)
102
165
  const oldKeys = binding.lastKeys
166
+ const rowsByKey = binding.rowsByKey
103
167
  if (!stringArraysEqual(newKeys, oldKeys)) {
104
168
  const target = { kind: 'slot', id: slotId } as const
105
169
  const newKeySet = new Set(newKeys)
@@ -112,6 +176,7 @@ export function recompute(
112
176
  const scope = keyedScopePrefix(slotId, keyToken(key))
113
177
  removedScopes.push(`${scope}:`)
114
178
  unregisterBindingsForScope(state, scope)
179
+ rowsByKey?.delete(key)
115
180
  work.splice(i, 1)
116
181
  }
117
182
  // Settle each position left-to-right: an existing key moves up, a new
@@ -125,11 +190,12 @@ export function recompute(
125
190
  work.splice(from, 1)
126
191
  work.splice(i, 0, key)
127
192
  } else {
128
- const itemHtml = runInRender(state, () =>
193
+ const row = runInRender(state, () =>
129
194
  renderKeyedItem(state, slotId, newArray[i], i, key, binding.itemRenderer),
130
195
  )
196
+ rowsByKey?.set(key, row.bindings)
131
197
  pending.push({
132
- patch: { target, op: 'insert', index: i, value: itemHtml },
198
+ patch: { target, op: 'insert', index: i, value: row.html },
133
199
  sourceSlot: slotId,
134
200
  })
135
201
  work.splice(i, 0, key)
@@ -137,6 +203,20 @@ export function recompute(
137
203
  }
138
204
  binding.lastKeys = newKeys
139
205
  }
206
+ // Option C (keyed): retained rows re-evaluate their item-value bindings, so
207
+ // a content change patches the changed field in place — no re-render, no
208
+ // reliance on the row carrying a nested read(). Rows inserted this pass are
209
+ // already current (rendered above); removed rows are gone.
210
+ if (rowsByKey) {
211
+ const oldKeySet = new Set(oldKeys)
212
+ for (let i = 0; i < newKeys.length; i++) {
213
+ const key = newKeys[i]!
214
+ if (!oldKeySet.has(key)) continue
215
+ const row = rowsByKey.get(key)
216
+ if (!row) continue
217
+ diffItemBindings(pending, row, newArray[i], i)
218
+ }
219
+ }
140
220
  binding.lastValue = newArray
141
221
  } else if (binding.kind === 'branch') {
142
222
  const newKey = binding.branchKeyFn(newValue)
@@ -253,12 +333,19 @@ export function initialSyncPatches(state: RenderState, runtime: SessionRuntime):
253
333
  for (const key of keys) {
254
334
  unregisterBindingsForScope(state, keyedScopePrefix(slotId, keyToken(key)))
255
335
  }
336
+ // Rebuild rowsByKey alongside the HTML — the re-render replaces every row's
337
+ // slot registrations, so the old item-bindings are stale.
338
+ const rebuilt: typeof binding.rowsByKey = binding.rowsByKey ? new Map() : undefined
256
339
  let rowsHtml = ''
257
340
  for (let i = 0; i < items.length; i++) {
258
- rowsHtml += runInRender(state, () =>
259
- renderKeyedItem(state, slotId, items[i], i, keys[i]!, binding.itemRenderer),
341
+ const key = keys[i]!
342
+ const row = runInRender(state, () =>
343
+ renderKeyedItem(state, slotId, items[i], i, key, binding.itemRenderer),
260
344
  )
345
+ rowsHtml += row.html
346
+ rebuilt?.set(key, row.bindings)
261
347
  }
348
+ if (rebuilt) binding.rowsByKey = rebuilt
262
349
  patches.push({ target: { kind: 'slot', id: slotId }, op: 'html', value: rowsHtml })
263
350
  }
264
351
 
@@ -1,3 +1,4 @@
1
+ import type { Resource } from '../template/resource.ts'
1
2
  import type { HtmlFragment } from '../template/types.ts'
2
3
 
3
4
  export type SlotId = string
@@ -38,8 +39,34 @@ export interface ListBinding extends BindingBase {
38
39
  kind: 'list'
39
40
  /** Re-invoked per item when the list re-renders. */
40
41
  itemRenderer: ErasedItemRenderer
42
+ /** SPIKE (finding #5 / option C): per-row item-value bindings, collected at
43
+ * render. On recompute a *same-length* list re-evaluates these instead of
44
+ * re-rendering the whole body — a content change patches the changed field
45
+ * only, and the row DOM (islands, focus) is preserved. `undefined` on lists
46
+ * with no itemBind() interpolations (they fall back to wholesale re-render). */
47
+ rows?: ItemBinding[][]
41
48
  }
42
49
 
50
+ /** A per-row binding whose source is the each *item value*, not a machine
51
+ * (`read(item, …)` → itemBind). Never registered in `state.bindings`/`byMachine`
52
+ * — owned by its ListBinding and re-evaluated during the list's recompute.
53
+ * Discriminated by position, mirroring text vs attr machine bindings. */
54
+ interface ItemBindingBase {
55
+ /** `(item, index) => value` — evaluated against the row's current item. */
56
+ selector: (item: unknown, index: number) => unknown
57
+ lastValue: unknown
58
+ }
59
+ export interface TextItemBinding extends ItemBindingBase {
60
+ kind: 'text'
61
+ slotId: SlotId
62
+ }
63
+ export interface AttrItemBinding extends ItemBindingBase {
64
+ kind: 'attr'
65
+ attrName: string
66
+ parentId: ElementId
67
+ }
68
+ export type ItemBinding = TextItemBinding | AttrItemBinding
69
+
43
70
  /** A keyed list's key selector, item type erased (same variance argument as
44
71
  * ErasedSelector). */
45
72
  // biome-ignore lint/suspicious/noExplicitAny: contravariant parameter — see ErasedSelector
@@ -53,6 +80,11 @@ export interface KeyedListBinding extends BindingBase {
53
80
  keyFn: ErasedKeyFn
54
81
  /** The previous render's keys, in order — the diff baseline. */
55
82
  lastKeys: string[]
83
+ /** SPIKE (option C, keyed): per-row item-value bindings keyed by identity key
84
+ * — stable across moves (a row's slot ids live under its key scope). On
85
+ * recompute, retained keys re-evaluate these for field-level patches instead
86
+ * of relying on nested read()s. `undefined` when the rows have no itemBind(). */
87
+ rowsByKey?: Map<string, ItemBinding[]>
56
88
  }
57
89
 
58
90
  export interface BranchBinding extends BindingBase {
@@ -73,9 +105,37 @@ export interface BranchBinding extends BindingBase {
73
105
  * recompute narrows on the discriminant instead of asserting optionals. */
74
106
  export type Binding = TextBinding | AttrBinding | ListBinding | KeyedListBinding | BranchBinding
75
107
 
108
+ /**
109
+ * A `defer` slot recorded during the sync render pass, resolved and filled in
110
+ * the async resolve phase (see server/render.ts). Deliberately NOT a `Binding`
111
+ * — it never enters `byMachine`, so `recompute` provably never revisits it: the
112
+ * slot is static/one-shot, which is what keeps `defer` I/O off the session lock.
113
+ */
114
+ export interface DeferRecord {
115
+ slotId: SlotId
116
+ resource: Resource
117
+ /** Rendered with the resolved value when the resource fulfills. */
118
+ ready: (value: unknown) => HtmlFragment
119
+ /** Rendered with the reason when the resource rejects; absent ⇒ the rejection
120
+ * bubbles to route-level error handling. */
121
+ error?: (reason: unknown) => HtmlFragment
122
+ }
123
+
76
124
  interface Scope {
77
125
  prefix: string
78
126
  counter: number
127
+ /** Per-scope element-id counter. Element ids are scoped by the arm/list they
128
+ * render in — exactly like slot ids — so a node inside a `match`/`when`/`each`
129
+ * keeps a STABLE id across a re-render. (A flat global counter shifted the id
130
+ * whenever the element-id'd sibling set changed, so the live wire patched the
131
+ * wrong node — FINDINGS #2.) */
132
+ elementCounter: number
133
+ }
134
+
135
+ /** A fresh child scope: slot and element counters both start at 0, so ids inside
136
+ * are deterministic from the scope prefix regardless of surrounding renders. */
137
+ export function makeScope(prefix: string): Scope {
138
+ return { prefix, counter: 0, elementCounter: 0 }
79
139
  }
80
140
 
81
141
  export interface RenderState {
@@ -84,7 +144,30 @@ export interface RenderState {
84
144
  bindings: Map<SlotId, Binding>
85
145
  byMachine: Map<MachineName, Set<SlotId>>
86
146
  scopeStack: Scope[]
87
- elementIdCounter: number
147
+ /** `defer` slots recorded during the sync pass, drained by the resolve phase. */
148
+ deferred: DeferRecord[]
149
+ /** >0 while rendering inside a `defer` arm — machine bindings are illegal there
150
+ * (a defer slot is never re-diffed), so `registerBinding` throws. */
151
+ deferDepth: number
152
+ /** Whether this render should resolve `defer` slots. True on the read paths
153
+ * (GET, SSE-connect — off-lock); false for the `/__events` re-diff baseline,
154
+ * which runs UNDER the session lock and must never kick a defer thunk. */
155
+ resolveDeferred: boolean
156
+ /** Set only during a recompute-driven re-render (fan-out): nested `read()`s
157
+ * resolve their proxy from THIS runtime by machine name, instead of the
158
+ * closure-captured instance — which fan-out `rehydrate()` froze at
159
+ * connect-time, so an arm/list interior would otherwise render stale on the
160
+ * first data arrival (FINDINGS #3). Null on the initial render, where the
161
+ * closure instance already IS the current proxy. */
162
+ currentRuntime: { proxyFor(name: string): unknown } | null
163
+ /** SPIKE (option C): the each row currently rendering. `itemBind()` reads
164
+ * `currentItem`/`currentItemIndex` to evaluate its selector, and pushes its
165
+ * binding into `currentRowBindings` (the row collector the list attaches to
166
+ * its ListBinding.rows). All three are ambient like the render state itself,
167
+ * saved/restored around each row. */
168
+ currentItem?: unknown
169
+ currentItemIndex?: number
170
+ currentRowBindings?: ItemBinding[]
88
171
  }
89
172
 
90
173
  export function createRenderState(sessionId: SessionId, routeKey: string): RenderState {
@@ -93,8 +176,11 @@ export function createRenderState(sessionId: SessionId, routeKey: string): Rende
93
176
  routeKey,
94
177
  bindings: new Map(),
95
178
  byMachine: new Map(),
96
- scopeStack: [{ prefix: '', counter: 0 }],
97
- elementIdCounter: 0,
179
+ scopeStack: [makeScope('')],
180
+ deferred: [],
181
+ deferDepth: 0,
182
+ resolveDeferred: true,
183
+ currentRuntime: null,
98
184
  }
99
185
  }
100
186
 
@@ -134,11 +220,12 @@ export function allocSlotId(state: RenderState): SlotId {
134
220
  }
135
221
 
136
222
  export function allocElementId(state: RenderState): ElementId {
137
- return `e${state.elementIdCounter++}`
223
+ const scope = topScope(state)
224
+ return `${scope.prefix ? `${scope.prefix}:` : ''}e${scope.elementCounter++}`
138
225
  }
139
226
 
140
227
  export function pushListScope(state: RenderState, listSlotId: SlotId, iterIndex: number): void {
141
- state.scopeStack.push({ prefix: `${listSlotId}:i${iterIndex}`, counter: 0 })
228
+ state.scopeStack.push(makeScope(`${listSlotId}:i${iterIndex}`))
142
229
  }
143
230
 
144
231
  /**
@@ -148,7 +235,7 @@ export function pushListScope(state: RenderState, listSlotId: SlotId, iterIndex:
148
235
  * primitive keyed `each` is built on.
149
236
  */
150
237
  export function pushKeyedScope(state: RenderState, listSlotId: SlotId, token: string): void {
151
- state.scopeStack.push({ prefix: `${listSlotId}:k${token}`, counter: 0 })
238
+ state.scopeStack.push(makeScope(`${listSlotId}:k${token}`))
152
239
  }
153
240
 
154
241
  /** Keyed scope prefix for a key token (shared by render and recompute). */
@@ -173,7 +260,37 @@ export function popListScope(state: RenderState): void {
173
260
  state.scopeStack.pop()
174
261
  }
175
262
 
263
+ /**
264
+ * Enter a `defer` arm's render scope (used by the resolve phase when it renders
265
+ * the resolved arm). Scopes descendant slot ids under `${slotId}:d` — like a
266
+ * branch arm — and raises `deferDepth` so machine reads inside the arm throw.
267
+ */
268
+ export function pushDeferScope(state: RenderState, slotId: SlotId): void {
269
+ state.scopeStack.push(makeScope(`${slotId}:d`))
270
+ state.deferDepth += 1
271
+ }
272
+
273
+ export function popDeferScope(state: RenderState): void {
274
+ if (state.scopeStack.length <= 1) {
275
+ throw new Error('stator: cannot pop root render scope')
276
+ }
277
+ state.scopeStack.pop()
278
+ state.deferDepth -= 1
279
+ }
280
+
176
281
  export function registerBinding(state: RenderState, binding: Binding): void {
282
+ // Runtime backstop for the defer/machine boundary (the static compile check is
283
+ // the primary, build-time gate). A machine read — read(), or a machine-bound
284
+ // each/when/match — inside a defer arm would need live re-diffing, but a defer
285
+ // slot is one-shot and never revisited. Catch it (direct or reached through a
286
+ // helper) rather than silently freeze the value.
287
+ if (state.deferDepth > 0) {
288
+ throw new Error(
289
+ `stator: a machine read (read() / a machine-bound each/when/match) cannot appear inside a ` +
290
+ `defer() arm — defer is one-shot and static, so the value would never update. For a live ` +
291
+ `value, use a machine and place the read in a sibling slot outside the defer.`,
292
+ )
293
+ }
177
294
  state.bindings.set(binding.slotId, binding)
178
295
  let slotIds = state.byMachine.get(binding.machineName)
179
296
  if (!slotIds) {
@@ -1,4 +1,13 @@
1
- import { createRenderState, type RenderState, runInRender } from './render-context.ts'
1
+ import { deferSentinel } from '../template/defer.ts'
2
+ import { isHtmlFragment } from '../template/types.ts'
3
+ import {
4
+ createRenderState,
5
+ type DeferRecord,
6
+ popDeferScope,
7
+ pushDeferScope,
8
+ type RenderState,
9
+ runInRender,
10
+ } from './render-context.ts'
2
11
  import type {
3
12
  RouteDefinition,
4
13
  RouteRenderContext,
@@ -81,14 +90,23 @@ function createResponseContext(): {
81
90
  * returns the rendered HTML; the HTTP layer combines the HTML with the
82
91
  * response effects to build the final Response.
83
92
  */
84
- export function renderRoute(
93
+ export interface RenderRouteOptions {
94
+ /** Resolve `defer` slots (default true). The `/__events` re-diff baseline
95
+ * passes false: it runs under the session lock and must not kick defer I/O;
96
+ * its defer slots emit inert placeholders that the diff never touches. */
97
+ resolveDeferred?: boolean
98
+ }
99
+
100
+ export async function renderRoute(
85
101
  route: RouteDefinition,
86
102
  routeKey: string,
87
103
  sessionId: string,
88
104
  runtime: SessionRuntime,
89
105
  request: RouteRequest,
90
- ): RenderResult {
106
+ opts: RenderRouteOptions = {},
107
+ ): Promise<RenderResult> {
91
108
  const state = createRenderState(sessionId, routeKey)
109
+ state.resolveDeferred = opts.resolveDeferred ?? true
92
110
  const { ctx: responseCtx, effects } = createResponseContext()
93
111
  const renderCtx: RouteRenderContext = {
94
112
  response: responseCtx,
@@ -107,5 +125,60 @@ export function renderRoute(
107
125
  ;(renderCtx as Record<string, unknown>)[def.name] = proxy
108
126
  }
109
127
  const fragment = runInRender(state, () => route.render(renderCtx, request))
110
- return { html: fragment.html, renderState: state, response: effects }
128
+ let html = fragment.html
129
+ if (state.resolveDeferred && state.deferred.length > 0) {
130
+ html = await resolveDeferred(state, html)
131
+ }
132
+ return { html, renderState: state, response: effects }
133
+ }
134
+
135
+ /**
136
+ * The `defer` resolve phase (v1: blocking-inline, no streaming). Runs after the
137
+ * synchronous render, so it can `await` without losing `currentRenderState`.
138
+ *
139
+ * Drains `state.deferred` in batches: a resolve-window yield lets already-ready
140
+ * data settle with no added latency, then the batch's resources are awaited in
141
+ * parallel (bounded by the slowest, not the sum), then each slot's arm is
142
+ * rendered and spliced into its sentinel. A defer arm may itself contain a
143
+ * defer, which records during fill — so the loop drains until the page is quiet.
144
+ */
145
+ async function resolveDeferred(state: RenderState, html: string): Promise<string> {
146
+ while (state.deferred.length > 0) {
147
+ const batch = state.deferred
148
+ state.deferred = []
149
+ // Macrotask yield: sync values, warm caches, already-resolved promises, and
150
+ // multi-hop-but-instant chains settle here; real network/disk I/O does not.
151
+ await new Promise((resolve) => setTimeout(resolve, 0))
152
+ await Promise.all(batch.map((record) => record.resource.settled))
153
+ for (const record of batch) {
154
+ html = html.replace(deferSentinel(record.slotId), fillDeferSlot(state, record))
155
+ }
156
+ }
157
+ return html
158
+ }
159
+
160
+ /** Render a resolved defer slot's arm, scoped under the slot id (so nested
161
+ * static constructs get stable ids and machine reads are rejected). */
162
+ function fillDeferSlot(state: RenderState, record: DeferRecord): string {
163
+ return runInRender(state, () => {
164
+ pushDeferScope(state, record.slotId)
165
+ try {
166
+ const { resource } = record
167
+ let fragment: ReturnType<DeferRecord['ready']>
168
+ if (resource.status === 'fulfilled') {
169
+ fragment = record.ready(resource.value)
170
+ } else if (record.error) {
171
+ fragment = record.error(resource.reason)
172
+ } else {
173
+ // No error arm — let the rejection bubble to route-level error handling.
174
+ throw resource.reason
175
+ }
176
+ if (!isHtmlFragment(fragment)) {
177
+ throw new Error('stator: a defer() ready/error arm must return an html`...` result')
178
+ }
179
+ return fragment.html
180
+ } finally {
181
+ popDeferScope(state)
182
+ }
183
+ })
111
184
  }
@@ -19,9 +19,32 @@ import { isReadResult, type ReadResult } from './read.ts'
19
19
  export function clientShellAttrs(
20
20
  props: Record<string, unknown>,
21
21
  decl: Record<string, 'number' | 'string' | 'boolean'>,
22
+ base: Record<string, string | boolean> = {},
22
23
  ): string {
23
- let out = ''
24
+ // The component's own root static attributes are the BASE; usage-site props
25
+ // (below) win on scalar conflict and `class`/`style` concatenate (FINDINGS #4).
26
+ // Live read() props emit separately since their value changes over the wire.
27
+ const staticAttrs = new Map<string, string | true>()
28
+ for (const key in base) {
29
+ const v = base[key]
30
+ if (v === false) continue
31
+ staticAttrs.set(key, v === true ? true : String(v))
32
+ }
33
+ const setMerged = (name: string, value: string | true): void => {
34
+ const prev = staticAttrs.get(name)
35
+ if (
36
+ (name === 'class' || name === 'style') &&
37
+ typeof prev === 'string' &&
38
+ typeof value === 'string'
39
+ ) {
40
+ staticAttrs.set(name, `${prev} ${value}`.trim())
41
+ } else {
42
+ staticAttrs.set(name, value)
43
+ }
44
+ }
45
+
24
46
  let elementId: string | null = null
47
+ let liveOut = ''
25
48
  for (const key in decl) {
26
49
  const v = props[key]
27
50
  if (v == null) continue
@@ -35,10 +58,7 @@ export function clientShellAttrs(
35
58
  `live island attrs only make sense inside a route render.`,
36
59
  )
37
60
  }
38
- if (!elementId) {
39
- elementId = allocElementId(state)
40
- out += ` data-stator-id="${elementId}"`
41
- }
61
+ if (!elementId) elementId = allocElementId(state)
42
62
  const r = v as ReadResult
43
63
  registerBinding(state, {
44
64
  slotId: r.slotId,
@@ -51,15 +71,21 @@ export function clientShellAttrs(
51
71
  })
52
72
  // Same value semantics as template attr bindings (boolean-aware).
53
73
  if (r.value === false || r.value === null || r.value === undefined) continue
54
- out += r.value === true ? ` ${name}` : ` ${name}="${escapeAttribute(String(r.value))}"`
74
+ liveOut += r.value === true ? ` ${name}` : ` ${name}="${escapeAttribute(String(r.value))}"`
55
75
  continue
56
76
  }
57
77
 
58
78
  if (decl[key] === 'boolean') {
59
- if (v) out += ` ${name}`
79
+ if (v) setMerged(name, true)
60
80
  } else {
61
- out += ` ${name}="${escapeAttribute(String(v))}"`
81
+ setMerged(name, String(v))
62
82
  }
63
83
  }
64
- return out
84
+
85
+ let out = ''
86
+ if (elementId) out += ` data-stator-id="${elementId}"`
87
+ for (const [name, value] of staticAttrs) {
88
+ out += value === true ? ` ${name}` : ` ${name}="${escapeAttribute(value)}"`
89
+ }
90
+ return out + liveOut
65
91
  }
@@ -2,6 +2,7 @@ import {
2
2
  allocSlotId,
3
3
  type ErasedSelector,
4
4
  keyToken,
5
+ makeScope,
5
6
  popListScope,
6
7
  type RenderState,
7
8
  registerBinding,
@@ -49,7 +50,7 @@ export function renderBranchBody(
49
50
  ): string {
50
51
  unregisterBindingsForScope(state, slotId)
51
52
  if (!renderer) return ''
52
- state.scopeStack.push({ prefix: `${slotId}:b${keyToken(String(armKey))}`, counter: 0 })
53
+ state.scopeStack.push(makeScope(`${slotId}:b${keyToken(String(armKey))}`))
53
54
  try {
54
55
  const fragment = renderer()
55
56
  if (!isHtmlFragment(fragment)) {
@@ -0,0 +1,72 @@
1
+ import { allocSlotId, requireCurrentRenderState, type SlotId } from '../server/render-context.ts'
2
+ import { createResource } from './resource.ts'
3
+ import type { HtmlFragment } from './types.ts'
4
+
5
+ /**
6
+ * A deferred async region. Same position-marker shape as `EachResult` /
7
+ * `BranchResult` — a `data-slot` span the resolve phase fills. Unlike those, it
8
+ * registers no machine binding, so it is never re-diffed (static, one-shot).
9
+ */
10
+ export interface DeferResult {
11
+ readonly __isDeferResult: true
12
+ readonly html: string
13
+ readonly slotId: SlotId
14
+ }
15
+
16
+ export function isDeferResult(v: unknown): v is DeferResult {
17
+ return (
18
+ typeof v === 'object' && v !== null && (v as Record<string, unknown>).__isDeferResult === true
19
+ )
20
+ }
21
+
22
+ export interface DeferArms<T> {
23
+ ready: (value: Awaited<T>) => HtmlFragment
24
+ /** Optional. Absent ⇒ a rejection bubbles to route-level error handling. */
25
+ error?: (reason: unknown) => HtmlFragment
26
+ }
27
+
28
+ /** The fill phase replaces this exact token with the rendered arm. An HTML
29
+ * comment: invisible if a slot somehow never fills, and uniquely keyed. */
30
+ export function deferSentinel(slotId: SlotId): string {
31
+ return `<!--defer:${slotId}-->`
32
+ }
33
+
34
+ function deferPlaceholder(slotId: SlotId, inner: string): DeferResult {
35
+ // `display: contents` so the wrapper span doesn't disturb the surrounding
36
+ // element's layout — same rationale as each()/when().
37
+ const html = `<span data-slot="${slotId}" data-defer="true" style="display:contents">${inner}</span>`
38
+ return { __isDeferResult: true, html, slotId }
39
+ }
40
+
41
+ /**
42
+ * Render an async region without making frontmatter async. `thunk` is kicked
43
+ * during the synchronous render pass (closing over frontmatter locals); its
44
+ * result is wrapped in a peekable resource and recorded, and the resolve phase
45
+ * (server/render.ts) awaits it — in parallel with every other defer on the page
46
+ * — then renders `ready(value)` / `error(reason)` inline. A synchronous thunk
47
+ * result fills with no added latency and no placeholder.
48
+ *
49
+ * The thunk belongs *inside* `defer` (not a frontmatter const), so the framework
50
+ * owns the kick: fired once on a cold render, never re-run on the `/__events`
51
+ * re-diff.
52
+ */
53
+ export function defer<T>(thunk: () => T | Promise<T>, arms: DeferArms<T>): DeferResult {
54
+ const state = requireCurrentRenderState()
55
+ const slotId = allocSlotId(state)
56
+
57
+ // `/__events` re-diff baseline (under the session lock): never kick the thunk —
58
+ // that would run I/O under the lock. The slot is static and never diffed, so an
59
+ // inert placeholder is correct; the client keeps the content from its first render.
60
+ if (!state.resolveDeferred) {
61
+ return deferPlaceholder(slotId, '')
62
+ }
63
+
64
+ const resource = createResource(thunk)
65
+ state.deferred.push({
66
+ slotId,
67
+ resource,
68
+ ready: arms.ready as (value: unknown) => HtmlFragment,
69
+ error: arms.error,
70
+ })
71
+ return deferPlaceholder(slotId, deferSentinel(slotId))
72
+ }