@statorjs/stator 1.3.0 → 1.4.1
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 +50 -19
- 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 {
|
|
|
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
|
|
|
@@ -20,6 +21,15 @@ import { type DirectiveInvocation, defineDirective, invoke } from './core.ts'
|
|
|
20
21
|
|
|
21
22
|
type ConditionalEntry<TValue> = TValue | ReadResult<TValue>
|
|
22
23
|
|
|
24
|
+
/** Resolve the machine instance a spec read evaluates against. During a
|
|
25
|
+
* fan-out recompute this returns the CURRENT proxy — the ReadResult's captured
|
|
26
|
+
* `.instance` was frozen at first render, and `rehydrate()` has since replaced
|
|
27
|
+
* the connection's actor (FINDINGS #3, one composition boundary further out:
|
|
28
|
+
* the spec object outlives the render that built it). Null runtime (initial
|
|
29
|
+
* render, request-scoped recompute) falls back to the captured instance,
|
|
30
|
+
* which is current there by construction. */
|
|
31
|
+
type InstanceResolver = (r: ReadResult<unknown>) => unknown
|
|
32
|
+
|
|
23
33
|
/** Recursive shape accepted by class:list. */
|
|
24
34
|
export type ClassListSpec =
|
|
25
35
|
| string
|
|
@@ -40,11 +50,25 @@ export type StyleListSpec =
|
|
|
40
50
|
| Record<string, ConditionalEntry<string | number | null | undefined>>
|
|
41
51
|
| StyleListSpec[]
|
|
42
52
|
|
|
43
|
-
function evalRead<T>(value: ConditionalEntry<T
|
|
44
|
-
return isReadResult(value) ? (value.selector(value
|
|
53
|
+
function evalRead<T>(value: ConditionalEntry<T>, resolve: InstanceResolver): T {
|
|
54
|
+
return isReadResult(value) ? (value.selector(resolve(value)) as T) : value
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Backstop for hand-written templates (the compiler rejects this at build
|
|
58
|
+
* time): an item read inside a `:list` spec would stringify as garbage AND
|
|
59
|
+
* never re-diff — the compound directive recomposes per machine, not per row. */
|
|
60
|
+
function rejectItemRead(v: unknown): void {
|
|
61
|
+
if (isItemReadResult(v)) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
'stator: read(item, …) is not supported inside a class:list/style:list spec — the ' +
|
|
64
|
+
'compound directive recomposes per machine, not per row. Give the whole attribute ' +
|
|
65
|
+
'a single item read, or use a machine read inside the spec.',
|
|
66
|
+
)
|
|
67
|
+
}
|
|
45
68
|
}
|
|
46
69
|
|
|
47
70
|
function collectMachines(spec: unknown, out: Set<string>): void {
|
|
71
|
+
rejectItemRead(spec)
|
|
48
72
|
if (spec == null || spec === false) return
|
|
49
73
|
if (typeof spec === 'string') return
|
|
50
74
|
if (isReadResult(spec)) {
|
|
@@ -57,47 +81,48 @@ function collectMachines(spec: unknown, out: Set<string>): void {
|
|
|
57
81
|
}
|
|
58
82
|
if (typeof spec === 'object') {
|
|
59
83
|
for (const v of Object.values(spec)) {
|
|
84
|
+
rejectItemRead(v)
|
|
60
85
|
if (isReadResult(v)) out.add(v.machineName)
|
|
61
86
|
}
|
|
62
87
|
}
|
|
63
88
|
}
|
|
64
89
|
|
|
65
|
-
function composeClass(spec: ClassListSpec): string {
|
|
90
|
+
function composeClass(spec: ClassListSpec, resolve: InstanceResolver): string {
|
|
66
91
|
const parts: string[] = []
|
|
67
|
-
walkClass(spec, parts)
|
|
92
|
+
walkClass(spec, parts, resolve)
|
|
68
93
|
return parts.join(' ')
|
|
69
94
|
}
|
|
70
95
|
|
|
71
|
-
function walkClass(spec: ClassListSpec, out: string[]): void {
|
|
96
|
+
function walkClass(spec: ClassListSpec, out: string[], resolve: InstanceResolver): void {
|
|
72
97
|
if (spec == null || spec === false) return
|
|
73
98
|
if (typeof spec === 'string') {
|
|
74
99
|
if (spec.length > 0) out.push(spec)
|
|
75
100
|
return
|
|
76
101
|
}
|
|
77
102
|
if (isReadResult(spec)) {
|
|
78
|
-
const v = spec.selector(spec
|
|
103
|
+
const v = spec.selector(resolve(spec))
|
|
79
104
|
if (typeof v === 'string' && v.length > 0) out.push(v)
|
|
80
105
|
return
|
|
81
106
|
}
|
|
82
107
|
if (Array.isArray(spec)) {
|
|
83
|
-
for (const item of spec) walkClass(item, out)
|
|
108
|
+
for (const item of spec) walkClass(item, out, resolve)
|
|
84
109
|
return
|
|
85
110
|
}
|
|
86
111
|
if (typeof spec === 'object') {
|
|
87
112
|
for (const [name, cond] of Object.entries(spec)) {
|
|
88
|
-
const truthy = evalRead(cond)
|
|
113
|
+
const truthy = evalRead(cond, resolve)
|
|
89
114
|
if (truthy) out.push(name)
|
|
90
115
|
}
|
|
91
116
|
}
|
|
92
117
|
}
|
|
93
118
|
|
|
94
|
-
function composeStyle(spec: StyleListSpec): string {
|
|
119
|
+
function composeStyle(spec: StyleListSpec, resolve: InstanceResolver): string {
|
|
95
120
|
const decls: string[] = []
|
|
96
|
-
walkStyle(spec, decls)
|
|
121
|
+
walkStyle(spec, decls, resolve)
|
|
97
122
|
return decls.join('; ')
|
|
98
123
|
}
|
|
99
124
|
|
|
100
|
-
function walkStyle(spec: StyleListSpec, out: string[]): void {
|
|
125
|
+
function walkStyle(spec: StyleListSpec, out: string[], resolve: InstanceResolver): void {
|
|
101
126
|
if (spec == null || spec === false) return
|
|
102
127
|
if (typeof spec === 'string') {
|
|
103
128
|
const trimmed = spec.trim()
|
|
@@ -105,19 +130,19 @@ function walkStyle(spec: StyleListSpec, out: string[]): void {
|
|
|
105
130
|
return
|
|
106
131
|
}
|
|
107
132
|
if (isReadResult(spec)) {
|
|
108
|
-
const v = spec.selector(spec
|
|
133
|
+
const v = spec.selector(resolve(spec))
|
|
109
134
|
if (typeof v === 'string' && v.trim().length > 0) {
|
|
110
135
|
out.push(v.trim().replace(/;\s*$/, ''))
|
|
111
136
|
}
|
|
112
137
|
return
|
|
113
138
|
}
|
|
114
139
|
if (Array.isArray(spec)) {
|
|
115
|
-
for (const item of spec) walkStyle(item, out)
|
|
140
|
+
for (const item of spec) walkStyle(item, out, resolve)
|
|
116
141
|
return
|
|
117
142
|
}
|
|
118
143
|
if (typeof spec === 'object') {
|
|
119
144
|
for (const [prop, raw] of Object.entries(spec)) {
|
|
120
|
-
const value = evalRead(raw)
|
|
145
|
+
const value = evalRead(raw, resolve)
|
|
121
146
|
if (value == null || value === '') continue
|
|
122
147
|
// A single property's value must not carry `;` — a `read()`-sourced value
|
|
123
148
|
// like `red; position: fixed; …` would otherwise inject extra
|
|
@@ -140,26 +165,32 @@ function cssValue(v: string): string {
|
|
|
140
165
|
/**
|
|
141
166
|
* Register one attr-binding per unique machine the spec depends on. Each
|
|
142
167
|
* binding's selector ignores its arg and recomputes the entire attribute
|
|
143
|
-
* by re-walking the spec — every ReadResult inside re-evaluates its
|
|
144
|
-
*
|
|
168
|
+
* by re-walking the spec — every ReadResult inside re-evaluates its selector
|
|
169
|
+
* against the RESOLVED instance (current proxy under a fan-out recompute,
|
|
170
|
+
* the captured one otherwise), returning fresh state.
|
|
145
171
|
*/
|
|
146
172
|
function bindListAttr<TSpec>(
|
|
147
173
|
state: RenderState,
|
|
148
174
|
spec: TSpec,
|
|
149
175
|
attrName: string,
|
|
150
176
|
elementId: string,
|
|
151
|
-
compose: (spec: TSpec) => string,
|
|
177
|
+
compose: (spec: TSpec, resolve: InstanceResolver) => string,
|
|
152
178
|
): string {
|
|
153
179
|
const machines = new Set<string>()
|
|
154
180
|
collectMachines(spec, machines)
|
|
155
181
|
|
|
156
|
-
|
|
182
|
+
// Resolution is per-evaluation, through the render state the binding was
|
|
183
|
+
// registered under — recompute sets `currentRuntime` for the fan-out pass.
|
|
184
|
+
const resolve: InstanceResolver = (r) =>
|
|
185
|
+
state.currentRuntime?.proxyFor(r.machineName) ?? r.instance
|
|
186
|
+
|
|
187
|
+
const initial = compose(spec, resolve)
|
|
157
188
|
for (const machineName of machines) {
|
|
158
189
|
const slotId = allocSlotId(state)
|
|
159
190
|
registerBinding(state, {
|
|
160
191
|
slotId,
|
|
161
192
|
machineName,
|
|
162
|
-
selector: () => compose(spec),
|
|
193
|
+
selector: () => compose(spec, resolve),
|
|
163
194
|
lastValue: initial,
|
|
164
195
|
kind: 'attr',
|
|
165
196
|
attrName,
|
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
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export type ResourceStatus = 'pending' | 'fulfilled' | 'rejected'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A peekable async value. You cannot synchronously read a native promise's
|
|
5
|
+
* settled state (`.then` is always a microtask), so `defer` wraps a thunk's
|
|
6
|
+
* result in a resource that records its settlement as it happens — letting the
|
|
7
|
+
* fill pass read `status` synchronously ("peek") at the moment it splices HTML.
|
|
8
|
+
*
|
|
9
|
+
* A non-promise thunk result fulfills immediately, so `defer` degrades to plain
|
|
10
|
+
* inline render for synchronous data.
|
|
11
|
+
*/
|
|
12
|
+
export interface Resource<T = unknown> {
|
|
13
|
+
readonly status: ResourceStatus
|
|
14
|
+
readonly value?: T
|
|
15
|
+
readonly reason?: unknown
|
|
16
|
+
/**
|
|
17
|
+
* Resolves when the resource settles (fulfilled OR rejected). Never rejects —
|
|
18
|
+
* a rejection is recorded as `status: 'rejected'` + `reason`, and `settled`
|
|
19
|
+
* still resolves, so the resolve phase can `await` a batch of resources with a
|
|
20
|
+
* plain `Promise.all` and never has to catch.
|
|
21
|
+
*/
|
|
22
|
+
readonly settled: Promise<void>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isPromiseLike(v: unknown): v is PromiseLike<unknown> {
|
|
26
|
+
return (
|
|
27
|
+
(typeof v === 'object' || typeof v === 'function') &&
|
|
28
|
+
v !== null &&
|
|
29
|
+
typeof (v as { then?: unknown }).then === 'function'
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Wrap a thunk's result in a peekable {@link Resource}. Synchronous values (and
|
|
35
|
+
* synchronous throws) settle immediately; a promise records its settlement on a
|
|
36
|
+
* single attached continuation.
|
|
37
|
+
*/
|
|
38
|
+
export function createResource<T>(produce: () => T | Promise<T>): Resource<T> {
|
|
39
|
+
let result: T | Promise<T>
|
|
40
|
+
try {
|
|
41
|
+
result = produce()
|
|
42
|
+
} catch (reason) {
|
|
43
|
+
// A thunk that throws synchronously is a rejected resource, not a crash.
|
|
44
|
+
return { status: 'rejected', reason, settled: Promise.resolve() }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!isPromiseLike(result)) {
|
|
48
|
+
return { status: 'fulfilled', value: result, settled: Promise.resolve() }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const resource: {
|
|
52
|
+
status: ResourceStatus
|
|
53
|
+
value?: T
|
|
54
|
+
reason?: unknown
|
|
55
|
+
settled: Promise<void>
|
|
56
|
+
} = { status: 'pending', settled: Promise.resolve() }
|
|
57
|
+
|
|
58
|
+
resource.settled = Promise.resolve(result).then(
|
|
59
|
+
(value) => {
|
|
60
|
+
resource.status = 'fulfilled'
|
|
61
|
+
resource.value = value
|
|
62
|
+
},
|
|
63
|
+
(reason) => {
|
|
64
|
+
resource.status = 'rejected'
|
|
65
|
+
resource.reason = reason
|
|
66
|
+
},
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return resource
|
|
70
|
+
}
|