@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.
- package/package.json +2 -2
- package/src/client/css.d.ts +6 -0
- package/src/client/inspector-flash.css +50 -0
- package/src/client/inspector.css +196 -0
- package/src/client/inspector.ts +31 -72
- package/src/client/runtime.ts +5 -1
- package/src/compiler/compile.ts +35 -8
- package/src/compiler/lower.ts +0 -0
- package/src/compiler/virtual-code.ts +1 -1
- package/src/engine/actor.ts +59 -0
- package/src/engine/types.ts +33 -0
- package/src/server/api-route.ts +7 -1
- package/src/server/create-app.ts +3 -1
- package/src/server/dev.ts +3 -1
- package/src/server/effects.ts +36 -17
- package/src/server/http.ts +25 -4
- package/src/server/machine-store.ts +7 -0
- package/src/server/recompute.ts +97 -10
- package/src/server/render-context.ts +123 -6
- package/src/server/render.ts +77 -4
- package/src/server/session-runtime.ts +14 -0
- package/src/server/timers.ts +77 -0
- package/src/template/client-shell.ts +35 -9
- package/src/template/conditional.ts +2 -1
- package/src/template/defer.ts +72 -0
- package/src/template/directives/list-attr.ts +16 -0
- package/src/template/each.ts +89 -9
- package/src/template/html.ts +65 -1
- package/src/template/index.ts +3 -1
- package/src/template/read.ts +28 -4
- package/src/template/resource.ts +70 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|
|
79
|
+
if (v) setMerged(name, true)
|
|
60
80
|
} else {
|
|
61
|
-
|
|
81
|
+
setMerged(name, String(v))
|
|
62
82
|
}
|
|
63
83
|
}
|
|
64
|
-
|
|
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(
|
|
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
|
}
|
package/src/template/each.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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([
|
package/src/template/html.ts
CHANGED
|
@@ -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
|
package/src/template/index.ts
CHANGED
|
@@ -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'
|
package/src/template/read.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
71
|
+
instance: source,
|
|
72
|
+
value,
|
|
49
73
|
}
|
|
50
74
|
}
|