@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.
@@ -4,6 +4,7 @@ import { type DispatchContext, recordTouch, withDispatchContext } from './dispat
4
4
  import { createInstanceProxy, type InstanceHandle } from './instance-proxy.ts'
5
5
  import { buildDispatchEvent, MAX_CASCADE_DEPTH, type MachineStore } from './machine-store.ts'
6
6
  import { serverReadsResolver } from './reads-helpers.ts'
7
+ import { armAfterTimers, cancelAfterTimers } from './timers.ts'
7
8
 
8
9
  /**
9
10
  * Per-request scope for session-lifecycle machine actors. Created at the
@@ -67,6 +68,11 @@ export class SessionRuntime {
67
68
  // Server plane: never run effects inline — queue them for the entry
68
69
  // point to schedule after persist + lock release.
69
70
  onEffect: (invocation) => this.pendingEffects.push(invocation),
71
+ // `after` state timeouts: arm on entry, cancel on exit. Timers live in the
72
+ // process-wide registry (server/timers.ts), not this transient runtime.
73
+ onStateEnter: (stateKey, ctx) =>
74
+ armAfterTimers(this.store, this.sessionId, def, stateKey, ctx),
75
+ onStateExit: (stateKey) => cancelAfterTimers(this.sessionId, def.name, stateKey),
70
76
  }).start()
71
77
  this.actors.set(
72
78
  def.name,
@@ -104,6 +110,14 @@ export class SessionRuntime {
104
110
  return this.store.getDef(name)?.lifecycle
105
111
  }
106
112
 
113
+ /** Machines whose entry effect fired on a fresh `start()` — queued in
114
+ * `pendingEffects` but distinct from `touched` (an entry commits no
115
+ * transition). Entry points persist this set so a freshly-entered state
116
+ * isn't re-created and re-fired next request. Read before draining. */
117
+ entryFiredMachines(): Set<string> {
118
+ return new Set(this.pendingEffects.map((e) => e.machineName))
119
+ }
120
+
107
121
  /** Hand queued effect invocations to the scheduler, clearing the queue. */
108
122
  drainPendingEffects(): EffectInvocation[] {
109
123
  const drained = this.pendingEffects
@@ -0,0 +1,77 @@
1
+ import type { AnyMachineDef } from '../engine/index.ts'
2
+ import { dispatchToApp } from './app-dispatch.ts'
3
+ import { reenterSessionEvent } from './effects.ts'
4
+ import { scopedLogger } from './logger.ts'
5
+ import type { MachineStore } from './machine-store.ts'
6
+
7
+ const timerLog = scopedLogger('timers')
8
+
9
+ /** Timer scope for app machines. They're process singletons (one instance per
10
+ * name), so they share one scope and the machine name disambiguates the key —
11
+ * unlike session machines, which scope by session id. */
12
+ export const APP_SCOPE = '@app'
13
+
14
+ /**
15
+ * Process-wide, in-memory, NON-DURABLE registry for `after` state timeouts.
16
+ * Session runtimes are transient (per request) and app instances live for the
17
+ * process, so a timer can't live on either — it lives here, keyed by the state
18
+ * it's counting down for, and fires across (or between) requests. A process
19
+ * restart drops armed timers (the 1.0 non-durable contract, same as effects).
20
+ */
21
+ const timers = new Map<string, ReturnType<typeof setTimeout>[]>()
22
+
23
+ const key = (scope: string, machineName: string, stateKey: string): string =>
24
+ `${scope} ${machineName} ${stateKey}`
25
+
26
+ /** Cancel every `after` timer armed for a (scope, machine, state). `scope` is
27
+ * the session id for session machines, `APP_SCOPE` for app machines. */
28
+ export function cancelAfterTimers(scope: string, machineName: string, stateKey: string): void {
29
+ const k = key(scope, machineName, stateKey)
30
+ const handles = timers.get(k)
31
+ if (!handles) return
32
+ for (const h of handles) clearTimeout(h)
33
+ timers.delete(k)
34
+ }
35
+
36
+ /**
37
+ * Arm the `after` timers declared on `stateKey` (a no-op if it declares none).
38
+ * The host calls this when a machine ENTERS the state. Each timer, on elapse,
39
+ * dispatches its `send` event back through the machine's normal event path —
40
+ * a session machine through the session re-entry (fresh lock + hydrate), an app
41
+ * machine through `dispatchToApp` (no lock; the long-lived instance is sent
42
+ * directly). Either way it's guard-dropped if the state has since moved on (the
43
+ * cancel-on-exit is the fast path; the guard-drop is the correctness backstop).
44
+ * Re-arming clears any stale timers for the key first.
45
+ */
46
+ export function armAfterTimers(
47
+ store: MachineStore,
48
+ scope: string,
49
+ def: AnyMachineDef,
50
+ stateKey: string,
51
+ ctx: object,
52
+ ): void {
53
+ const after = def.states[stateKey]?.after
54
+ if (!after || after.length === 0) return
55
+ cancelAfterTimers(scope, def.name, stateKey)
56
+ const k = key(scope, def.name, stateKey)
57
+ const handles = after.map((entry) => {
58
+ const delay = typeof entry.delay === 'function' ? entry.delay(ctx as never) : entry.delay
59
+ const h = setTimeout(() => {
60
+ timers.delete(k) // a state has one arm-set; drop the whole key on fire
61
+ const fire =
62
+ def.lifecycle === 'app'
63
+ ? dispatchToApp(store, def, entry.send as never).then(() => {})
64
+ : reenterSessionEvent(store, scope, def.name, entry.send)
65
+ void fire.catch((err) => {
66
+ timerLog.error(
67
+ { scope, machine: def.name, state: stateKey, err: String(err) },
68
+ 'after-timer dispatch failed',
69
+ )
70
+ })
71
+ }, delay)
72
+ // Don't keep the process alive for a pending timer.
73
+ ;(h as unknown as { unref?: () => void }).unref?.()
74
+ return h
75
+ })
76
+ timers.set(k, handles)
77
+ }
@@ -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
+ }
@@ -4,6 +4,7 @@ import {
4
4
  registerBinding,
5
5
  requireCurrentRenderState,
6
6
  } from '../../server/render-context.ts'
7
+ import { isItemReadResult } from '../each.ts'
7
8
  import { isReadResult, type ReadResult } from '../read.ts'
8
9
  import { type DirectiveInvocation, defineDirective, invoke } from './core.ts'
9
10
 
@@ -44,7 +45,21 @@ function evalRead<T>(value: ConditionalEntry<T>): T {
44
45
  return isReadResult(value) ? (value.selector(value.instance) as T) : value
45
46
  }
46
47
 
48
+ /** Backstop for hand-written templates (the compiler rejects this at build
49
+ * time): an item read inside a `:list` spec would stringify as garbage AND
50
+ * never re-diff — the compound directive recomposes per machine, not per row. */
51
+ function rejectItemRead(v: unknown): void {
52
+ if (isItemReadResult(v)) {
53
+ throw new Error(
54
+ 'stator: read(item, …) is not supported inside a class:list/style:list spec — the ' +
55
+ 'compound directive recomposes per machine, not per row. Give the whole attribute ' +
56
+ 'a single item read, or use a machine read inside the spec.',
57
+ )
58
+ }
59
+ }
60
+
47
61
  function collectMachines(spec: unknown, out: Set<string>): void {
62
+ rejectItemRead(spec)
48
63
  if (spec == null || spec === false) return
49
64
  if (typeof spec === 'string') return
50
65
  if (isReadResult(spec)) {
@@ -57,6 +72,7 @@ function collectMachines(spec: unknown, out: Set<string>): void {
57
72
  }
58
73
  if (typeof spec === 'object') {
59
74
  for (const v of Object.values(spec)) {
75
+ rejectItemRead(v)
60
76
  if (isReadResult(v)) out.add(v.machineName)
61
77
  }
62
78
  }
@@ -3,6 +3,7 @@ import {
3
3
  type ErasedItemRenderer,
4
4
  type ErasedKeyFn,
5
5
  type ErasedSelector,
6
+ type ItemBinding,
6
7
  keyedScopePrefix,
7
8
  keyToken,
8
9
  popListScope,
@@ -37,6 +38,42 @@ export function isEachResult(v: unknown): v is EachResult {
37
38
  )
38
39
  }
39
40
 
41
+ /**
42
+ * SPIKE (finding #5 / option C): the value of an item-dependent interpolation
43
+ * inside an `each` row — what the compiler will lower `{item.field}` to. It
44
+ * evaluates the selector against the *current* row's item now, and registers a
45
+ * per-row binding so a later content change patches this one slot in place
46
+ * (rather than re-rendering the row, so islands/focus survive).
47
+ */
48
+ export interface ItemReadResult {
49
+ readonly __isItemRead: true
50
+ readonly selector: (item: unknown, index: number) => unknown
51
+ readonly value: unknown
52
+ }
53
+
54
+ export function isItemReadResult(v: unknown): v is ItemReadResult {
55
+ return typeof v === 'object' && v !== null && (v as Record<string, unknown>).__isItemRead === true
56
+ }
57
+
58
+ // biome-ignore lint/suspicious/noExplicitAny: erased item selector — the item type is the each callback's, recovered at the call site
59
+ export function itemBind(selector: (item: any, index: number) => unknown): ItemReadResult {
60
+ const state = requireCurrentRenderState()
61
+ if (!state.currentRowBindings) {
62
+ throw new Error(
63
+ 'stator: read(item, …) (itemBind) called outside an each() row render — an item ' +
64
+ 'binding is owned by its row, which supplies the item and re-diffs the binding. ' +
65
+ 'This happens when an item read sits inside a when()/match()/defer() arm (an arm ' +
66
+ 're-renders on its own schedule, without the row) or outside each() entirely. ' +
67
+ 'Use a machine read there instead.',
68
+ )
69
+ }
70
+ // Compute the value now (for initial render); the per-row binding is registered
71
+ // by html.ts, which knows the position (text span vs attribute) — same split as
72
+ // read() → handleRead.
73
+ const value = selector(state.currentItem, state.currentItemIndex ?? 0)
74
+ return { __isItemRead: true, selector, value }
75
+ }
76
+
40
77
  export function each<T>(
41
78
  items: readonly T[] | ReadResult<readonly T[]>,
42
79
  fn: (item: T, index: number) => HtmlFragment,
@@ -63,8 +100,10 @@ export function each<T>(
63
100
  let innerHtml: string
64
101
  if (keyFn) {
65
102
  const keys = coerceKeys(array, keyFn as ErasedKeyFn, slotId)
66
- innerHtml = renderKeyedListBody(state, slotId, array, keys, fn)
103
+ const body = renderKeyedListBody(state, slotId, array, keys, fn)
104
+ innerHtml = body.html
67
105
  if (machineName && selector) {
106
+ const hasItemBindings = [...body.rowsByKey.values()].some((b) => b.length > 0)
68
107
  registerBinding(state, {
69
108
  slotId,
70
109
  machineName,
@@ -74,10 +113,13 @@ export function each<T>(
74
113
  itemRenderer: fn as ErasedItemRenderer,
75
114
  keyFn: keyFn as ErasedKeyFn,
76
115
  lastKeys: keys,
116
+ // Same opt-in as non-keyed: only track rows when there ARE item bindings.
117
+ rowsByKey: hasItemBindings ? body.rowsByKey : undefined,
77
118
  })
78
119
  }
79
120
  } else {
80
- innerHtml = renderListBody(state, slotId, array, fn)
121
+ const body = renderListBody(state, slotId, array, fn)
122
+ innerHtml = body.html
81
123
  if (machineName && selector) {
82
124
  registerBinding(state, {
83
125
  slotId,
@@ -86,6 +128,10 @@ export function each<T>(
86
128
  lastValue: array,
87
129
  kind: 'list',
88
130
  itemRenderer: fn as ErasedItemRenderer,
131
+ // Only opt into the granular path when there ARE item bindings —
132
+ // otherwise (static-capture rows) leave it undefined so recompute
133
+ // wholesale-re-renders as before, keeping content fresh.
134
+ rows: body.rows.some((r) => r.length > 0) ? body.rows : undefined,
89
135
  })
90
136
  }
91
137
  }
@@ -111,23 +157,39 @@ export function renderListBody<T>(
111
157
  listSlotId: SlotId,
112
158
  items: readonly T[],
113
159
  fn: (item: T, index: number) => HtmlFragment,
114
- ): string {
160
+ ): { html: string; rows: ItemBinding[][] } {
115
161
  unregisterBindingsForScope(state, listSlotId)
116
162
 
163
+ // Save/restore the ambient row context around the whole body so a nested
164
+ // each() (a row rendering its own list) doesn't clobber this list's row.
165
+ const prevItem = state.currentItem
166
+ const prevIndex = state.currentItemIndex
167
+ const prevRow = state.currentRowBindings
168
+
117
169
  const chunks: string[] = []
170
+ const rows: ItemBinding[][] = []
118
171
  for (let i = 0; i < items.length; i++) {
119
172
  pushListScope(state, listSlotId, i)
173
+ const rowBindings: ItemBinding[] = []
174
+ state.currentItem = items[i]
175
+ state.currentItemIndex = i
176
+ state.currentRowBindings = rowBindings
120
177
  try {
121
178
  const fragment = fn(items[i]!, i)
122
179
  if (!isHtmlFragment(fragment)) {
123
180
  throw new Error('stator: each() callback must return an html`...` result')
124
181
  }
125
182
  chunks.push(fragment.html)
183
+ rows.push(rowBindings)
126
184
  } finally {
127
185
  popListScope(state)
128
186
  }
129
187
  }
130
- return chunks.join('')
188
+
189
+ state.currentItem = prevItem
190
+ state.currentItemIndex = prevIndex
191
+ state.currentRowBindings = prevRow
192
+ return { html: chunks.join(''), rows }
131
193
  }
132
194
 
133
195
  /**
@@ -173,13 +235,17 @@ export function renderKeyedListBody<T>(
173
235
  items: readonly T[],
174
236
  keys: readonly string[],
175
237
  fn: (item: T, index: number) => HtmlFragment,
176
- ): string {
238
+ ): { html: string; rowsByKey: Map<string, ItemBinding[]> } {
177
239
  unregisterBindingsForScope(state, listSlotId)
178
240
  const chunks: string[] = []
241
+ const rowsByKey = new Map<string, ItemBinding[]>()
179
242
  for (let i = 0; i < items.length; i++) {
180
- chunks.push(renderKeyedItem(state, listSlotId, items[i] as T, i, keys[i]!, fn))
243
+ const key = keys[i]!
244
+ const row = renderKeyedItem(state, listSlotId, items[i] as T, i, key, fn)
245
+ chunks.push(row.html)
246
+ rowsByKey.set(key, row.bindings)
181
247
  }
182
- return chunks.join('')
248
+ return { html: chunks.join(''), rowsByKey }
183
249
  }
184
250
 
185
251
  /**
@@ -195,10 +261,21 @@ export function renderKeyedItem<T>(
195
261
  index: number,
196
262
  key: string,
197
263
  fn: (item: T, index: number) => HtmlFragment,
198
- ): string {
264
+ ): { html: string; bindings: ItemBinding[] } {
199
265
  const token = keyToken(key)
200
266
  unregisterBindingsForScope(state, keyedScopePrefix(listSlotId, token))
201
267
  pushKeyedScope(state, listSlotId, token)
268
+
269
+ // Item-value binding context for this row (option C, keyed), save/restore
270
+ // around the body so a nested each() doesn't clobber it.
271
+ const prevItem = state.currentItem
272
+ const prevIndex = state.currentItemIndex
273
+ const prevRow = state.currentRowBindings
274
+ const bindings: ItemBinding[] = []
275
+ state.currentItem = item
276
+ state.currentItemIndex = index
277
+ state.currentRowBindings = bindings
278
+
202
279
  let html: string
203
280
  try {
204
281
  const fragment = fn(item, index)
@@ -207,6 +284,9 @@ export function renderKeyedItem<T>(
207
284
  }
208
285
  html = fragment.html
209
286
  } finally {
287
+ state.currentItem = prevItem
288
+ state.currentItemIndex = prevIndex
289
+ state.currentRowBindings = prevRow
210
290
  popListScope(state)
211
291
  }
212
292
  if (!isSingleRootElement(html)) {
@@ -216,7 +296,7 @@ export function renderKeyedItem<T>(
216
296
  `items would corrupt sibling indices`,
217
297
  )
218
298
  }
219
- return html
299
+ return { html, bindings }
220
300
  }
221
301
 
222
302
  const VOID_TAGS = new Set([
@@ -1,4 +1,5 @@
1
1
  import {
2
+ allocSlotId,
2
3
  type RenderState,
3
4
  registerBinding,
4
5
  requireCurrentRenderState,
@@ -22,12 +23,13 @@ export function raw(html: string): HtmlFragment {
22
23
  }
23
24
 
24
25
  import { isBranchResult } from './conditional.ts'
26
+ import { isDeferResult } from './defer.ts'
25
27
  import {
26
28
  type DirectiveContext,
27
29
  type DirectiveInvocation,
28
30
  isDirectiveInvocation,
29
31
  } from './directives/core.ts'
30
- import { isEachResult } from './each.ts'
32
+ import { type ItemReadResult, isEachResult, isItemReadResult } from './each.ts'
31
33
  import { isReadResult, type ReadResult } from './read.ts'
32
34
 
33
35
  export function html(strings: TemplateStringsArray, ...values: unknown[]): HtmlFragment {
@@ -100,11 +102,26 @@ function processValue(builder: HtmlBuilder, state: RenderState, value: unknown):
100
102
  return
101
103
  }
102
104
 
105
+ if (isDeferResult(value)) {
106
+ if (pos.kind !== 'text') {
107
+ throw new Error('stator: cannot inline a defer() result outside text position')
108
+ }
109
+ builder.pushRaw(value.html)
110
+ return
111
+ }
112
+
103
113
  if (isReadResult(value)) {
104
114
  handleRead(builder, state, value, pos)
105
115
  return
106
116
  }
107
117
 
118
+ // read(item, …) → itemBind: register the per-row binding by position (text span
119
+ // or attribute), the item analog of handleRead.
120
+ if (isItemReadResult(value)) {
121
+ handleItemRead(builder, state, value, pos)
122
+ return
123
+ }
124
+
108
125
  if (pos.kind === 'text') {
109
126
  builder.pushRaw(escapeText(stringifyValue(value)))
110
127
  return
@@ -180,6 +197,53 @@ function handleRead(
180
197
  throw new Error(`stator: read() result cannot be interpolated at ${pos.kind} position`)
181
198
  }
182
199
 
200
+ /** Register a `read(item, …)` per-row binding by position — the item analog of
201
+ * handleRead. Pushes onto the row's binding list (owned by the ListBinding), not
202
+ * state.bindings; recompute diffs it per row and emits text- or attr-op patches. */
203
+ function handleItemRead(
204
+ builder: HtmlBuilder,
205
+ state: RenderState,
206
+ r: ItemReadResult,
207
+ pos: ValuePosition,
208
+ ): void {
209
+ const row = state.currentRowBindings
210
+ if (!row) {
211
+ throw new Error(
212
+ 'stator: read(item, …) interpolated outside an each() row render — an item binding ' +
213
+ 'is owned by its row (see the itemBind ownership rule). Use a machine read here.',
214
+ )
215
+ }
216
+ if (pos.kind === 'text') {
217
+ const slotId = allocSlotId(state)
218
+ row.push({ kind: 'text', slotId, selector: r.selector, lastValue: r.value })
219
+ builder.pushRaw(`<span data-slot="${slotId}">${escapeText(stringifyValue(r.value))}</span>`)
220
+ return
221
+ }
222
+ if (pos.kind === 'attr-value') {
223
+ if (pos.hasLiteralText) {
224
+ throw new Error(
225
+ `stator: attribute "${pos.attrName}" mixes literal text with a read(item, …). ` +
226
+ `An attribute value must come from a single source.`,
227
+ )
228
+ }
229
+ row.push({
230
+ kind: 'attr',
231
+ attrName: pos.attrName,
232
+ parentId: pos.elementId,
233
+ selector: r.selector,
234
+ lastValue: r.value,
235
+ })
236
+ // Same boolean semantics as a machine attr read (see handleRead).
237
+ if (r.value === false || r.value === null || r.value === undefined) {
238
+ builder.omitCurrentAttribute()
239
+ } else if (r.value !== true) {
240
+ builder.pushRaw(escapeAttribute(sanitizeAttrValue(pos.attrName, stringifyValue(r.value))))
241
+ }
242
+ return
243
+ }
244
+ throw new Error(`stator: read(item, …) cannot be interpolated at ${pos.kind} position`)
245
+ }
246
+
183
247
  function stringifyValue(v: unknown): string {
184
248
  if (v == null) return ''
185
249
  if (typeof v === 'string') return v
@@ -6,6 +6,8 @@ export {
6
6
  renderBranchBody,
7
7
  when,
8
8
  } from './conditional.ts'
9
+ export type { DeferArms, DeferResult } from './defer.ts'
10
+ export { defer, isDeferResult } from './defer.ts'
9
11
  export type {
10
12
  Directive,
11
13
  DirectiveContext,
@@ -21,7 +23,7 @@ export type { ClassListSpec, StyleListSpec } from './directives/list-attr.ts'
21
23
  export { classList, styleList } from './directives/list-attr.ts'
22
24
  export { on } from './directives/on.ts'
23
25
  export type { EachResult } from './each.ts'
24
- export { each, isEachResult, renderListBody } from './each.ts'
26
+ export { each, isEachResult, itemBind, renderListBody } from './each.ts'
25
27
  export { html, raw } from './html.ts'
26
28
  export type { ReadResult } from './read.ts'
27
29
  export { isReadResult, read } from './read.ts'
@@ -29,22 +29,46 @@ export function isReadResult(v: unknown): v is ReadResult {
29
29
  export function read<TDef extends AnyMachineDef, TResult>(
30
30
  instance: InstanceOf<TDef>,
31
31
  selector: (instance: InstanceOf<TDef>) => TResult,
32
- ): ReadResult<TResult> {
32
+ ): ReadResult<TResult>
33
+ /**
34
+ * Live field of an `each` row — `read(item, (i) => i.field)`. `read()` is the
35
+ * one marker for live data, so an item field that changes over time is read the
36
+ * same way a machine value is. The compiler lowers this form to a per-row
37
+ * `itemBind`; it is valid only where `item` is the each callback's item param.
38
+ */
39
+ export function read<TItem, TResult>(
40
+ item: TItem,
41
+ selector: (item: TItem) => TResult,
42
+ ): ReadResult<TResult>
43
+ export function read(
44
+ // biome-ignore lint/suspicious/noExplicitAny: overload implementation — the typed surface is the two signatures above
45
+ instance: any,
46
+ // biome-ignore lint/suspicious/noExplicitAny: overload implementation — see above
47
+ selector: (instance: any) => unknown,
48
+ ): ReadResult {
33
49
  const state = requireCurrentRenderState()
34
50
  const def = defForProxy(instance as unknown as object)
35
51
  if (!def) {
52
+ // The item-read overload exists only for typing — the compiler rewrites
53
+ // read(item, …) to itemBind before runtime, so a non-machine here is a bug.
36
54
  throw new Error(
37
55
  'stator: read() must be called with a machine instance produced by the framework',
38
56
  )
39
57
  }
40
58
  const slotId = allocSlotId(state)
41
- const value = selector(instance)
59
+ // During a recompute-driven re-render (fan-out), resolve the CURRENT proxy for
60
+ // this machine so arm/list interiors see fresh state — the closure `instance`
61
+ // was frozen at connect-time by `rehydrate()` (FINDINGS #3). On the initial
62
+ // render `currentRuntime` is null and `instance` already IS the current proxy.
63
+ const live = state.currentRuntime?.proxyFor(def.name)
64
+ const source = live ?? instance
65
+ const value = selector(source)
42
66
  return {
43
67
  __isReadResult: true,
44
68
  slotId,
45
69
  machineName: def.name,
46
70
  selector: selector as ErasedSelector,
47
- instance,
48
- value: value as TResult,
71
+ instance: source,
72
+ value,
49
73
  }
50
74
  }