@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
package/src/server/create-app.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|
package/src/server/effects.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/server/http.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
package/src/server/recompute.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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:
|
|
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).
|
|
98
|
-
//
|
|
99
|
-
//
|
|
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
|
|
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:
|
|
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
|
-
|
|
259
|
-
|
|
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
|
-
|
|
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: [
|
|
97
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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) {
|
package/src/server/render.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|