@statorjs/stator 1.3.0 → 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/server/http.ts +10 -3
- package/src/server/recompute.ts +97 -10
- package/src/server/render-context.ts +123 -6
- package/src/server/render.ts +77 -4
- 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/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
|
}
|
|
@@ -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
|
}
|