@statorjs/stator 1.2.2 → 1.4.0

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.
@@ -52,8 +52,10 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
52
52
  sessionTtlSeconds: config.sessionTtlSeconds,
53
53
  appStore: config.appStore,
54
54
  })
55
- await store.bootAppMachines()
55
+ // Wire the effect scheduler BEFORE booting: a fresh app machine fires its
56
+ // initial-state entry effect during boot, and that must have somewhere to go.
56
57
  wireAppEffects(store)
58
+ await store.bootAppMachines()
57
59
 
58
60
  const routes = await discoverRoutes(routesDir)
59
61
  const app = await buildHonoApp({
package/src/server/dev.ts CHANGED
@@ -135,8 +135,10 @@ export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
135
135
  sessionTtlSeconds: config.sessionTtlSeconds,
136
136
  appStore: config.appStore,
137
137
  })
138
- await store.bootAppMachines()
138
+ // Wire the effect scheduler before booting — a fresh app machine's
139
+ // initial-state entry effect fires during boot and needs a live scheduler.
139
140
  runtime.wireAppEffects(store)
141
+ await store.bootAppMachines()
140
142
  }
141
143
  const rebuildRoutes = async (): Promise<void> => {
142
144
  routes = await runtime.discoverRoutes(routesDir, loader)
@@ -1,4 +1,4 @@
1
- import type { AnyMachineDef, EffectInvocation } from '../engine/index.ts'
1
+ import type { AnyMachineDef, EffectInvocation, EventObject } from '../engine/index.ts'
2
2
  import { dispatchToApp } from './app-dispatch.ts'
3
3
  import { scopedLogger } from './logger.ts'
4
4
  import type { MachineStore } from './machine-store.ts'
@@ -95,22 +95,7 @@ async function runSessionEffect(
95
95
  if (!completion) return
96
96
 
97
97
  try {
98
- await withSessionLock(sessionId, async () => {
99
- const runtime = new SessionRuntime(sessionId, store)
100
- try {
101
- const def = store.getDef(machineName)
102
- if (!def) return // machine graph changed under us (dev reload) — drop
103
- // loadGraph pulls reads + subscribers transitively, so completion
104
- // emits reach cross-machine listeners like any other event.
105
- await runtime.loadGraph([def])
106
- runtime.wireSubscriptions()
107
- const touched = runtime.processEvent(machineName, completion)
108
- await runtime.persistTouched(touched)
109
- await fanOut(touched, { sessionId })
110
- } finally {
111
- runtime.dispose()
112
- }
113
- })
98
+ await reenterSessionEvent(store, sessionId, machineName, completion)
114
99
  } catch (err) {
115
100
  effectLog.error(
116
101
  { machine: machineName, effectId, err: String(err) },
@@ -118,3 +103,37 @@ async function runSessionEffect(
118
103
  )
119
104
  }
120
105
  }
106
+
107
+ /**
108
+ * Re-enter an out-of-band event (an effect completion, or an `after` timeout)
109
+ * through the full session event path: fresh lock, hydrate the machine (the
110
+ * triggering runtime is long gone — the transient-actor model working for us),
111
+ * process, persist (including a machine that fired an entry effect on the
112
+ * resulting transition), fan out to live connections, and schedule any effect
113
+ * the event chained. Shared by effect completions and state timeouts.
114
+ */
115
+ export async function reenterSessionEvent(
116
+ store: MachineStore,
117
+ sessionId: string,
118
+ machineName: string,
119
+ event: EventObject,
120
+ ): Promise<void> {
121
+ await withSessionLock(sessionId, async () => {
122
+ const runtime = new SessionRuntime(sessionId, store)
123
+ try {
124
+ const def = store.getDef(machineName)
125
+ if (!def) return // machine graph changed under us (dev reload) — drop
126
+ // loadGraph pulls reads + subscribers transitively, so emits reach
127
+ // cross-machine listeners like any other event.
128
+ await runtime.loadGraph([def])
129
+ runtime.wireSubscriptions()
130
+ const touched = runtime.processEvent(machineName, event)
131
+ await runtime.persistTouched(new Set([...touched, ...runtime.entryFiredMachines()]))
132
+ await fanOut(touched, { sessionId })
133
+ // A transition or entry effect chained off this event surfaces here.
134
+ scheduleSessionEffects(runtime, store, sessionId)
135
+ } finally {
136
+ runtime.dispose()
137
+ }
138
+ })
139
+ }
@@ -222,7 +222,7 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
222
222
  return streamSSE(c, async (stream) => {
223
223
  const runtime = new SessionRuntime(sessionId, config.store)
224
224
  await runtime.loadGraph(route.reads)
225
- const { renderState } = renderRoute(route, routeKey, sessionId, runtime, request)
225
+ const { renderState } = await renderRoute(route, routeKey, sessionId, runtime, request)
226
226
  const conn = registerConnection({
227
227
  sessionId,
228
228
  clientId: c.req.query('client'),
@@ -320,7 +320,11 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
320
320
  await runtime.loadGraph([...route.reads, originDef])
321
321
  runtime.wireSubscriptions()
322
322
 
323
- const { renderState } = renderRoute(route, routeKey, sessionId, runtime, request)
323
+ // Baseline render for the diff runs UNDER the session lock — never
324
+ // resolve defer slots here (that would kick their I/O under the lock).
325
+ const { renderState } = await renderRoute(route, routeKey, sessionId, runtime, request, {
326
+ resolveDeferred: false,
327
+ })
324
328
 
325
329
  const touched = runtime.processEvent(body.machine, body.event)
326
330
 
@@ -333,7 +337,10 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
333
337
  patches.push(...recompute(renderState, name, runtime))
334
338
  }
335
339
 
336
- await runtime.persistTouched(touched)
340
+ // Persist committed machines plus any fresh machine that fired its
341
+ // initial entry effect (an entry commits no transition, so it's not in
342
+ // `touched`) — so it isn't re-created and re-fired next request.
343
+ await runtime.persistTouched(new Set([...touched, ...runtime.entryFiredMachines()]))
337
344
 
338
345
  await fanOut(touched, {
339
346
  sessionId,
@@ -414,6 +421,9 @@ async function bundleInspector(): Promise<string> {
414
421
  target: 'es2020',
415
422
  write: false,
416
423
  minify: false,
424
+ // The inspector imports its CSS as a text string (bundled inline, no
425
+ // separate stylesheet request).
426
+ loader: { '.css': 'text' },
417
427
  logLevel: 'silent',
418
428
  })
419
429
  cachedInspectorJs = result.outputFiles[0]!.text
@@ -454,7 +464,7 @@ async function handleGet(
454
464
  const runtime = new SessionRuntime(sessionId, store)
455
465
  try {
456
466
  await runtime.loadGraph(route.reads)
457
- const result = renderRoute(route, routeKey, sessionId, runtime, request)
467
+ const result = await renderRoute(route, routeKey, sessionId, runtime, request)
458
468
  let html = result.html
459
469
 
460
470
  const headHtml: string[] = []
@@ -479,6 +489,17 @@ async function handleGet(
479
489
  bodyEnd: bodyHtml.join(''),
480
490
  })
481
491
  applyRenderedEffects(c, result.response)
492
+
493
+ // A fresh machine that fired its initial entry effect on load must be
494
+ // persisted (so the next request hydrates instead of re-firing) and its
495
+ // effect scheduled off-lock, after the response. The common GET (no entry
496
+ // effect) skips both and stays a pure read.
497
+ const entryFired = runtime.entryFiredMachines()
498
+ if (entryFired.size > 0) {
499
+ await runtime.persistTouched(entryFired)
500
+ scheduleSessionEffects(runtime, store, sessionId)
501
+ }
502
+
482
503
  return c.html(html)
483
504
  } finally {
484
505
  runtime.dispose()
@@ -6,6 +6,7 @@ import { createInstanceProxy, type InstanceHandle } from './instance-proxy.ts'
6
6
  import { scopedLogger } from './logger.ts'
7
7
  import { serverReadsResolver } from './reads-helpers.ts'
8
8
  import type { Store } from './store.ts'
9
+ import { APP_SCOPE, armAfterTimers, cancelAfterTimers } from './timers.ts'
9
10
 
10
11
  const storeLog = scopedLogger('machine-store')
11
12
 
@@ -259,6 +260,12 @@ export class MachineStore {
259
260
  'app-machine effect dropped — no scheduler wired (call wireAppEffects(store))',
260
261
  )
261
262
  },
263
+ // `after` state timeouts, at app scope: arm on entry, cancel on exit.
264
+ // Timers live in the process-wide registry (server/timers.ts) and fire
265
+ // through dispatchToApp — no session, wall-clock only (revalidation,
266
+ // circuit breakers). See armAfterTimers.
267
+ onStateEnter: (stateKey, ctx) => armAfterTimers(this, APP_SCOPE, def, stateKey, ctx),
268
+ onStateExit: (stateKey) => cancelAfterTimers(APP_SCOPE, def.name, stateKey),
262
269
  }).start()
263
270
  this.appInstances.set(
264
271
  def.name,
@@ -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
  }