@abide/abide 0.34.2 → 0.35.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/AGENTS.md +3 -3
- package/CHANGELOG.md +36 -0
- package/README.md +2 -2
- package/package.json +2 -1
- package/src/abideResolverPlugin.ts +53 -9
- package/src/lib/server/runtime/STREAMED_HTML_HEADER.ts +13 -0
- package/src/lib/server/runtime/buildPreloadManifest.ts +151 -0
- package/src/lib/server/runtime/createServer.ts +4 -1
- package/src/lib/server/runtime/createUiPageRenderer.ts +104 -9
- package/src/lib/server/runtime/flushingGzipStream.ts +62 -0
- package/src/lib/server/runtime/gzipResponse.ts +23 -11
- package/src/lib/server/runtime/serializeCacheSnapshot.ts +22 -33
- package/src/lib/ui/README.md +1 -1
- package/src/lib/ui/compile/REACTIVE_CALLEES.ts +6 -6
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
- package/src/lib/ui/compile/compileShadow.ts +60 -55
- package/src/lib/ui/compile/desugarSignals.ts +93 -38
- package/src/lib/ui/compile/lowerDocAccess.ts +26 -11
- package/src/lib/ui/compile/parseTemplate.ts +8 -5
- package/src/lib/ui/compile/prepareNestedScript.ts +2 -2
- package/src/lib/ui/compile/renameSignalRefs.ts +153 -23
- package/src/lib/ui/compile/types/TemplateAttr.ts +4 -2
- package/src/lib/ui/dom/applyResolved.ts +27 -7
- package/src/lib/ui/installHotBridge.ts +2 -0
- package/src/lib/ui/runtime/escapeKey.ts +10 -4
- package/src/lib/ui/seedStreamedResolution.ts +22 -0
- package/src/lib/ui/startClient.ts +17 -3
- package/src/lib/shared/types/CacheSnapshot.ts +0 -16
|
@@ -2,26 +2,28 @@ import ts from 'typescript'
|
|
|
2
2
|
import { REACTIVE_CALLEES } from './REACTIVE_CALLEES.ts'
|
|
3
3
|
import { renameSignalRefs } from './renameSignalRefs.ts'
|
|
4
4
|
|
|
5
|
-
/* The reactive primitives that must be reached through a scope
|
|
6
|
-
|
|
7
|
-
of these is a compile error: reactive state is owned by a scope and the surface must show
|
|
5
|
+
/* The reactive primitives that must be reached through a scope. A bare call to one of
|
|
6
|
+
these is a compile error: reactive state is owned by a scope and the surface must show
|
|
8
7
|
it (`scope().state(...)`), so a reader always sees the scope interaction. */
|
|
9
8
|
const SCOPE_PRIMITIVES: ReadonlySet<string> = new Set(['state', 'linked', 'computed'])
|
|
10
9
|
|
|
11
|
-
/* Throws
|
|
12
|
-
|
|
13
|
-
is caught too, not just top-level declarations. */
|
|
10
|
+
/* Throws on a bare scope primitive (`state(0)` instead of `scope().state(0)`) or on the
|
|
11
|
+
removed `prop(...)` reader — props are now read by destructuring `props()`. Walks all
|
|
12
|
+
calls, so a stray one nested in a function is caught too, not just top-level declarations. */
|
|
14
13
|
function assertScopedPrimitives(source: ts.SourceFile): void {
|
|
15
14
|
const visit = (node: ts.Node): void => {
|
|
16
|
-
if (
|
|
17
|
-
ts.isCallExpression(node) &&
|
|
18
|
-
ts.isIdentifier(node.expression) &&
|
|
19
|
-
SCOPE_PRIMITIVES.has(node.expression.text)
|
|
20
|
-
) {
|
|
15
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
|
|
21
16
|
const name = node.expression.text
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
17
|
+
if (SCOPE_PRIMITIVES.has(name)) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
`abide: bare \`${name}(...)\` is not allowed — reactive state lives on a scope. Use \`scope().${name}(...)\` (or a captured handle: \`const s = scope(); s.${name}(...)\`).`,
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
if (name === 'prop') {
|
|
23
|
+
throw new Error(
|
|
24
|
+
'abide: `prop(...)` has been removed — read props by destructuring `props()`, e.g. `const { name } = props()` (with a default: `const { name = fallback } = props()`).',
|
|
25
|
+
)
|
|
26
|
+
}
|
|
25
27
|
}
|
|
26
28
|
ts.forEachChild(node, visit)
|
|
27
29
|
}
|
|
@@ -64,6 +66,19 @@ export function desugarSignals(scriptBody: string): {
|
|
|
64
66
|
}
|
|
65
67
|
for (const declaration of statement.declarationList.declarations) {
|
|
66
68
|
const callee = signalCallee(declaration)
|
|
69
|
+
if (callee === 'props') {
|
|
70
|
+
/* `const {…} = props()` — each destructured binding is a read-only
|
|
71
|
+
computed over the parent thunk, read as `name()`. */
|
|
72
|
+
if (!ts.isObjectBindingPattern(declaration.name)) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
'abide: `props()` must be destructured — `const { a, b } = props()`',
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
for (const binding of propsBindings(declaration)) {
|
|
78
|
+
computedNames.add(binding.local)
|
|
79
|
+
}
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
67
82
|
if (!ts.isIdentifier(declaration.name)) {
|
|
68
83
|
continue
|
|
69
84
|
}
|
|
@@ -71,9 +86,9 @@ export function desugarSignals(scriptBody: string): {
|
|
|
71
86
|
/* Plain `state(initial)` → a serializable `model` doc slot. */
|
|
72
87
|
stateNames.add(declaration.name.text)
|
|
73
88
|
} else if (isComputedSlot(declaration)) {
|
|
74
|
-
/* Read-only `computed(compute)`
|
|
75
|
-
|
|
76
|
-
|
|
89
|
+
/* Read-only `computed(compute)` → a computed `scope().derive` doc slot,
|
|
90
|
+
referenced as `name()` (its string-free reader): a function of other
|
|
91
|
+
paths, recomputed via the graph, never stored/serialized. */
|
|
77
92
|
computedNames.add(declaration.name.text)
|
|
78
93
|
} else if (callee !== undefined && REACTIVE_CALLEES.has(callee)) {
|
|
79
94
|
/* `.value` cells: `linked` and `state(initial, transform)` — they own
|
|
@@ -95,14 +110,14 @@ export function desugarSignals(scriptBody: string): {
|
|
|
95
110
|
for (const statement of source.statements) {
|
|
96
111
|
const stateAssignments = stateDeclarationAssignments(statement, printer, source)
|
|
97
112
|
const computedDeclarations = computedDeclarationLines(statement, printer, source)
|
|
98
|
-
const
|
|
113
|
+
const propsDestructure = propsDestructureLines(statement, printer, source)
|
|
99
114
|
const cellDeclarations = cellDeclarationLines(statement, printer, source)
|
|
100
115
|
if (stateAssignments !== undefined) {
|
|
101
116
|
lines.push(...stateAssignments)
|
|
102
117
|
} else if (computedDeclarations !== undefined) {
|
|
103
118
|
lines.push(...computedDeclarations)
|
|
104
|
-
} else if (
|
|
105
|
-
lines.push(...
|
|
119
|
+
} else if (propsDestructure !== undefined) {
|
|
120
|
+
lines.push(...propsDestructure)
|
|
106
121
|
} else if (cellDeclarations !== undefined) {
|
|
107
122
|
lines.push(...cellDeclarations)
|
|
108
123
|
} else {
|
|
@@ -118,16 +133,12 @@ export function desugarSignals(scriptBody: string): {
|
|
|
118
133
|
}
|
|
119
134
|
|
|
120
135
|
/* True for a read-only computed slot — `computed(compute)` with no write-through
|
|
121
|
-
`set
|
|
122
|
-
|
|
136
|
+
`set`. The writable `computed(compute, set)` lens keeps a `.value` cell (handled by
|
|
137
|
+
the caller). */
|
|
123
138
|
function isComputedSlot(declaration: ts.VariableDeclaration): boolean {
|
|
124
|
-
const callee = signalCallee(declaration)
|
|
125
|
-
if (callee === 'prop') {
|
|
126
|
-
return true
|
|
127
|
-
}
|
|
128
139
|
const initializer = declaration.initializer
|
|
129
140
|
return (
|
|
130
|
-
|
|
141
|
+
signalCallee(declaration) === 'computed' &&
|
|
131
142
|
initializer !== undefined &&
|
|
132
143
|
ts.isCallExpression(initializer) &&
|
|
133
144
|
initializer.arguments.length === 1
|
|
@@ -234,10 +245,51 @@ function signalCallee(declaration: ts.VariableDeclaration): string | undefined {
|
|
|
234
245
|
return undefined
|
|
235
246
|
}
|
|
236
247
|
|
|
237
|
-
/*
|
|
238
|
-
the
|
|
239
|
-
|
|
240
|
-
|
|
248
|
+
/* One destructured prop: the local binding name, the parent prop key it reads, and
|
|
249
|
+
the optional `= default` expression (the fallback when the prop is absent). */
|
|
250
|
+
type PropsBinding = { local: string; key: string; initializer: ts.Expression | undefined }
|
|
251
|
+
|
|
252
|
+
/* The bindings of a `const {…} = props()` pattern. Rest (`...rest`) and nested
|
|
253
|
+
destructuring have no single prop key, so they throw a legible compile error. */
|
|
254
|
+
function propsBindings(declaration: ts.VariableDeclaration): PropsBinding[] {
|
|
255
|
+
const pattern = declaration.name as ts.ObjectBindingPattern
|
|
256
|
+
return pattern.elements.map((element) => {
|
|
257
|
+
if (element.dotDotDotToken !== undefined) {
|
|
258
|
+
throw new Error('abide: `...rest` in `props()` destructuring is not supported')
|
|
259
|
+
}
|
|
260
|
+
if (!ts.isIdentifier(element.name)) {
|
|
261
|
+
throw new Error('abide: nested destructuring in `props()` is not supported')
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
local: element.name.text,
|
|
265
|
+
key: propsBindingKey(element),
|
|
266
|
+
initializer: element.initializer,
|
|
267
|
+
}
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/* The parent prop key a binding element reads — its rename source (`name: alias` →
|
|
272
|
+
`name`) or, absent a rename, the local name itself. */
|
|
273
|
+
function propsBindingKey(element: ts.BindingElement): string {
|
|
274
|
+
const propertyName = element.propertyName
|
|
275
|
+
if (propertyName === undefined) {
|
|
276
|
+
return (element.name as ts.Identifier).text
|
|
277
|
+
}
|
|
278
|
+
if (
|
|
279
|
+
ts.isIdentifier(propertyName) ||
|
|
280
|
+
ts.isStringLiteralLike(propertyName) ||
|
|
281
|
+
ts.isNumericLiteral(propertyName)
|
|
282
|
+
) {
|
|
283
|
+
return propertyName.text
|
|
284
|
+
}
|
|
285
|
+
throw new Error('abide: computed prop keys in `props()` destructuring are not supported')
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/* If `statement` is a `const {…} = props()` destructure, returns one reactive
|
|
289
|
+
computed per binding — `scope().derive("name", () => $props["key"]?.() ?? default)`
|
|
290
|
+
— read as `name()`; otherwise undefined. The `?? default` applies the binding's
|
|
291
|
+
`= default` fallback when the prop is absent. */
|
|
292
|
+
function propsDestructureLines(
|
|
241
293
|
statement: ts.Statement,
|
|
242
294
|
printer: ts.Printer,
|
|
243
295
|
source: ts.SourceFile,
|
|
@@ -247,15 +299,18 @@ function propDeclarationLines(
|
|
|
247
299
|
}
|
|
248
300
|
const lines: string[] = []
|
|
249
301
|
for (const declaration of statement.declarationList.declarations) {
|
|
250
|
-
if (signalCallee(declaration) !== '
|
|
302
|
+
if (signalCallee(declaration) !== 'props' || !ts.isObjectBindingPattern(declaration.name)) {
|
|
251
303
|
return undefined
|
|
252
304
|
}
|
|
253
|
-
const key
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
305
|
+
for (const { local, key, initializer } of propsBindings(declaration)) {
|
|
306
|
+
const fallback =
|
|
307
|
+
initializer === undefined
|
|
308
|
+
? ''
|
|
309
|
+
: ` ?? (${printer.printNode(ts.EmitHint.Unspecified, initializer, source)})`
|
|
310
|
+
lines.push(
|
|
311
|
+
`const ${local} = scope().derive(${JSON.stringify(local)}, () => $props[${JSON.stringify(key)}]?.()${fallback})`,
|
|
312
|
+
)
|
|
313
|
+
}
|
|
259
314
|
}
|
|
260
315
|
return lines
|
|
261
316
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import ts from 'typescript'
|
|
2
|
+
import { escapeKey } from '../runtime/escapeKey.ts'
|
|
2
3
|
|
|
3
4
|
/*
|
|
4
5
|
The linchpin compiler pass. Rewrites idiomatic data access on a reactive document
|
|
@@ -9,16 +10,19 @@ component hit the fast path instead of building path strings by hand:
|
|
|
9
10
|
model.note = 'x' → model.replace("note", 'x')
|
|
10
11
|
model.count += 1 → model.replace("count", model.read("count") + 1)
|
|
11
12
|
model.lines.push(v) → model.add("lines/-", v)
|
|
12
|
-
delete model.byId[key] → model.remove(
|
|
13
|
+
delete model.byId[key] → model.remove("byId/" + escapeKey(key))
|
|
13
14
|
model.lines[0].sku → model.read("lines/0/sku")
|
|
14
|
-
model.lines[i].sku → model.read(
|
|
15
|
+
model.lines[i].sku → model.read("lines/" + escapeKey(i) + "/sku")
|
|
15
16
|
|
|
16
17
|
A member/element-access chain rooted at `docName` becomes a `/`-joined path:
|
|
17
18
|
literal keys and numeric indices fold into one string literal; a non-literal
|
|
18
|
-
index makes the path a template (a dynamic segment).
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
index makes the path a template (a dynamic segment). Path segments are
|
|
20
|
+
JSON-Pointer-escaped so a key holding `/` or `~` addresses one segment, not
|
|
21
|
+
many — literal keys at compile time, dynamic ones wrapped in a runtime
|
|
22
|
+
`escapeKey(...)`. Reads are lowered to `read(path)`; a later pass hoists
|
|
23
|
+
static-path reads to a `cell` bound once at component init (the string-free hot
|
|
24
|
+
path the bench measured). Index expressions are themselves visited, so a read
|
|
25
|
+
used as an index lowers too.
|
|
22
26
|
*/
|
|
23
27
|
export function lowerDocAccess(code: string, docName: string): string {
|
|
24
28
|
const source = ts.createSourceFile('component.ts', code, ts.ScriptTarget.Latest, true)
|
|
@@ -54,12 +58,15 @@ function docAccessTransformer(docName: string): ts.TransformerFactory<ts.SourceF
|
|
|
54
58
|
let current: ts.Expression = node
|
|
55
59
|
while (true) {
|
|
56
60
|
if (ts.isPropertyAccessExpression(current)) {
|
|
57
|
-
|
|
61
|
+
/* A property name is an identifier — escapeKey is a no-op on it, but
|
|
62
|
+
a string-literal element key (`model["a/b"]`) can carry `/`|`~` and
|
|
63
|
+
must escape at compile time so the `/`-joined path doesn't mis-split. */
|
|
64
|
+
segments.unshift({ kind: 'literal', value: escapeKey(current.name.text) })
|
|
58
65
|
current = current.expression
|
|
59
66
|
} else if (ts.isElementAccessExpression(current)) {
|
|
60
67
|
const argument = current.argumentExpression
|
|
61
68
|
if (ts.isStringLiteral(argument) || ts.isNumericLiteral(argument)) {
|
|
62
|
-
segments.unshift({ kind: 'literal', value: argument.text })
|
|
69
|
+
segments.unshift({ kind: 'literal', value: escapeKey(argument.text) })
|
|
63
70
|
} else {
|
|
64
71
|
segments.unshift({
|
|
65
72
|
kind: 'expression',
|
|
@@ -165,8 +172,8 @@ function docCall(docName: string, method: string, args: ts.Expression[]): ts.Cal
|
|
|
165
172
|
/*
|
|
166
173
|
Turns segments into a path expression: an all-literal path is one string literal
|
|
167
174
|
(`"lines/0/sku"`); a path with a dynamic segment is a string-concatenation that
|
|
168
|
-
evaluates to the path at runtime (`"lines/" + i + "/sku"`), kept
|
|
169
|
-
leading with a literal.
|
|
175
|
+
evaluates to the path at runtime (`"lines/" + escapeKey(i) + "/sku"`), kept
|
|
176
|
+
string-typed by leading with a literal.
|
|
170
177
|
*/
|
|
171
178
|
function buildPath(segments: Segment[]): ts.Expression {
|
|
172
179
|
if (segments.every((segment) => segment.kind === 'literal')) {
|
|
@@ -194,7 +201,15 @@ function buildPath(segments: Segment[]): ts.Expression {
|
|
|
194
201
|
if (separator !== '') {
|
|
195
202
|
appendText(separator)
|
|
196
203
|
}
|
|
197
|
-
|
|
204
|
+
/* A dynamic key's value is unknown at compile time — escape it at runtime so a
|
|
205
|
+
key holding `/`|`~` (a date, a composite id) addresses one segment, not many. */
|
|
206
|
+
fragments.push(
|
|
207
|
+
ts.factory.createCallExpression(
|
|
208
|
+
ts.factory.createIdentifier('escapeKey'),
|
|
209
|
+
undefined,
|
|
210
|
+
[segment.node],
|
|
211
|
+
),
|
|
212
|
+
)
|
|
198
213
|
}
|
|
199
214
|
})
|
|
200
215
|
const head = fragments[0]
|
|
@@ -130,7 +130,7 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
|
|
|
130
130
|
cursor += 1
|
|
131
131
|
}
|
|
132
132
|
if (source.charAt(cursor) !== '=') {
|
|
133
|
-
attrs.push({ kind: 'static', name, value: '' }) // boolean attribute
|
|
133
|
+
attrs.push({ kind: 'static', name, value: '', bare: true }) // boolean attribute
|
|
134
134
|
continue
|
|
135
135
|
}
|
|
136
136
|
cursor += 1 // past '='
|
|
@@ -284,13 +284,16 @@ function rejectStrayBranches(
|
|
|
284
284
|
/* Turns a component's attributes into props. A component has no directives —
|
|
285
285
|
every attribute is a prop under its written name, so `on*`/`bind:`/`attach`
|
|
286
286
|
round-trip to their original names (the kinds the tag-blind attribute parser
|
|
287
|
-
assigned) instead of being dropped. A static value becomes a string literal
|
|
288
|
-
every other kind keeps its `code`,
|
|
289
|
-
included (e.g. an `onclick` callback). */
|
|
287
|
+
assigned) instead of being dropped. A static value becomes a string literal —
|
|
288
|
+
a bare attribute coerces to `true` instead; every other kind keeps its `code`,
|
|
289
|
+
letting a prop hold any value, functions included (e.g. an `onclick` callback). */
|
|
290
290
|
function toProps(attrs: TemplateAttr[]): { name: string; code: string; loc?: number }[] {
|
|
291
291
|
return attrs.map((attr) => {
|
|
292
292
|
if (attr.kind === 'static') {
|
|
293
|
-
|
|
293
|
+
/* A bare attribute (`<Toggle on />`) is a boolean flag: coerce it to
|
|
294
|
+
`true` so the prop reads as a boolean, not the empty string a native
|
|
295
|
+
element would serialise. An explicit `on=""` stays the empty string. */
|
|
296
|
+
return { name: attr.name, code: attr.bare ? 'true' : JSON.stringify(attr.value) }
|
|
294
297
|
}
|
|
295
298
|
/* Every non-static kind keeps its `code`/`loc`; only the prop name differs —
|
|
296
299
|
a directive (`event`/`bind`/`attach`) round-trips to its written name. */
|
|
@@ -3,7 +3,7 @@ import { REACTIVE_CALLEES } from './REACTIVE_CALLEES.ts'
|
|
|
3
3
|
|
|
4
4
|
/*
|
|
5
5
|
The signal binding names a `<script>` nested in a control-flow branch declares
|
|
6
|
-
(`state`/`linked`/`computed
|
|
6
|
+
(`state`/`linked`/`computed`). The back-end adds them to the deref scope so both the
|
|
7
7
|
script body and the branch's markup rewrite `{a}` → `a.value` — these stay PLAIN
|
|
8
8
|
signals (local to the branch's render, owned by its scope, re-seeded from the
|
|
9
9
|
in-scope data each mount), unlike the top-level component script which desugars to
|
|
@@ -30,7 +30,7 @@ export function nestedBindingNames(code: string): Set<string> {
|
|
|
30
30
|
return names
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
/* The callee name of a `NAME = state(...)` / `computed(...)` / `
|
|
33
|
+
/* The callee name of a `NAME = state(...)` / `computed(...)` / `linked(...)` declaration —
|
|
34
34
|
bare or the explicit scope form (`scope().state(...)` / `c.state(...)`), receiver-agnostic. */
|
|
35
35
|
function signalCallee(declaration: ts.VariableDeclaration): string | undefined {
|
|
36
36
|
const initializer = declaration.initializer
|
|
@@ -9,6 +9,13 @@ touched — declaration names, parameter names, and property names are collected
|
|
|
9
9
|
into a skip set first, and object shorthand (`{ count }`) is expanded to
|
|
10
10
|
`{ count: model.count }`. This is the bridge from the signal surface the author
|
|
11
11
|
writes to the patch substrate underneath.
|
|
12
|
+
|
|
13
|
+
The rewrite is lexically scope-aware: a reference whose name is re-bound by an
|
|
14
|
+
enclosing scope (a function/arrow parameter or a nested local declaration) refers
|
|
15
|
+
to that inner binding, not the component signal, so it is left untouched. Without
|
|
16
|
+
this, a callback like `list.map(option => option.toUpperCase())` in a component
|
|
17
|
+
that also has an `option` prop (`const { option } = props()`) would have its loop
|
|
18
|
+
variable rewritten to `option()` and blow up at runtime (`option is not a function`).
|
|
12
19
|
*/
|
|
13
20
|
export function renameSignalRefs(
|
|
14
21
|
code: string,
|
|
@@ -18,6 +25,10 @@ export function renameSignalRefs(
|
|
|
18
25
|
): string {
|
|
19
26
|
const source = ts.createSourceFile('component.ts', code, ts.ScriptTarget.Latest, true)
|
|
20
27
|
|
|
28
|
+
/* The signal names that a nested binding can shadow — only these matter for
|
|
29
|
+
scope tracking, so we ignore every other local binding. */
|
|
30
|
+
const signalNames = new Set<string>([...stateNames, ...derivedNames, ...computedNames])
|
|
31
|
+
|
|
21
32
|
/* Identifier nodes that are names, not value reads — never rewritten. */
|
|
22
33
|
const skip = new Set<ts.Node>()
|
|
23
34
|
const collect = (node: ts.Node): void => {
|
|
@@ -45,33 +56,47 @@ export function renameSignalRefs(
|
|
|
45
56
|
|
|
46
57
|
const result = ts.transform(source, [
|
|
47
58
|
(context) => (root) => {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
+
/* Each visitor carries the set of signal names shadowed by the scopes it sits
|
|
60
|
+
inside; entering a scope that re-binds a signal name produces a fresh visitor
|
|
61
|
+
with that name added, so shadowing is per-branch (a sibling scope is unaffected). */
|
|
62
|
+
const makeVisitor = (shadowed: ReadonlySet<string>): ts.Visitor => {
|
|
63
|
+
const visit = (node: ts.Node): ts.Node => {
|
|
64
|
+
/* Shorthand `{ count }` → `{ count: model.count }` / `{ total: total.value }`,
|
|
65
|
+
unless a nearer scope shadows the name. */
|
|
66
|
+
if (ts.isShorthandPropertyAssignment(node) && !shadowed.has(node.name.text)) {
|
|
67
|
+
const replacement = referenceFor(
|
|
68
|
+
node.name.text,
|
|
69
|
+
stateNames,
|
|
70
|
+
derivedNames,
|
|
71
|
+
computedNames,
|
|
72
|
+
)
|
|
73
|
+
if (replacement !== undefined) {
|
|
74
|
+
return ts.factory.createPropertyAssignment(node.name.text, replacement)
|
|
75
|
+
}
|
|
59
76
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
77
|
+
if (ts.isIdentifier(node) && !skip.has(node) && !shadowed.has(node.text)) {
|
|
78
|
+
const replacement = referenceFor(
|
|
79
|
+
node.text,
|
|
80
|
+
stateNames,
|
|
81
|
+
derivedNames,
|
|
82
|
+
computedNames,
|
|
83
|
+
)
|
|
84
|
+
if (replacement !== undefined) {
|
|
85
|
+
return replacement
|
|
86
|
+
}
|
|
70
87
|
}
|
|
88
|
+
/* Recurse with the scope this node introduces folded in (same visitor when
|
|
89
|
+
it adds no shadowing name, so unscoped subtrees allocate nothing extra). */
|
|
90
|
+
const inner = extendShadowed(node, shadowed, signalNames)
|
|
91
|
+
return ts.visitEachChild(
|
|
92
|
+
node,
|
|
93
|
+
inner === shadowed ? visit : makeVisitor(inner),
|
|
94
|
+
context,
|
|
95
|
+
)
|
|
71
96
|
}
|
|
72
|
-
return
|
|
97
|
+
return visit
|
|
73
98
|
}
|
|
74
|
-
return ts.visitNode(root,
|
|
99
|
+
return ts.visitNode(root, makeVisitor(new Set())) as ts.SourceFile
|
|
75
100
|
},
|
|
76
101
|
])
|
|
77
102
|
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed })
|
|
@@ -80,6 +105,111 @@ export function renameSignalRefs(
|
|
|
80
105
|
return output
|
|
81
106
|
}
|
|
82
107
|
|
|
108
|
+
/* The shadowed-name set for `node`'s children: the parent set plus any signal name
|
|
109
|
+
that `node` re-binds as a new scope. Returns the parent set unchanged (same
|
|
110
|
+
reference) when `node` introduces no colliding binding, so the caller can skip
|
|
111
|
+
allocating a new visitor. */
|
|
112
|
+
function extendShadowed(
|
|
113
|
+
node: ts.Node,
|
|
114
|
+
shadowed: ReadonlySet<string>,
|
|
115
|
+
signalNames: ReadonlySet<string>,
|
|
116
|
+
): ReadonlySet<string> {
|
|
117
|
+
const introduced = new Set<string>()
|
|
118
|
+
collectScopeBindings(node, introduced)
|
|
119
|
+
const added = [...introduced].filter((name) => signalNames.has(name) && !shadowed.has(name))
|
|
120
|
+
if (added.length === 0) {
|
|
121
|
+
return shadowed
|
|
122
|
+
}
|
|
123
|
+
return new Set<string>([...shadowed, ...added])
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/* The names `node` binds when it opens a lexical scope — function/arrow parameters
|
|
127
|
+
(and the function's own name), block-level `let`/`const`/`function`/`class`,
|
|
128
|
+
`for`-header declarations, and the `catch` binding. Only names bound directly at
|
|
129
|
+
this scope; deeper scopes are folded in as the walk descends into them. */
|
|
130
|
+
function collectScopeBindings(node: ts.Node, into: Set<string>): void {
|
|
131
|
+
const parameters = functionParameters(node)
|
|
132
|
+
if (parameters !== undefined) {
|
|
133
|
+
for (const parameter of parameters) {
|
|
134
|
+
collectBindingNames(parameter.name, into)
|
|
135
|
+
}
|
|
136
|
+
if (
|
|
137
|
+
(ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node)) &&
|
|
138
|
+
node.name !== undefined
|
|
139
|
+
) {
|
|
140
|
+
into.add(node.name.text)
|
|
141
|
+
}
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
if (ts.isBlock(node) || ts.isModuleBlock(node) || ts.isCaseBlock(node)) {
|
|
145
|
+
const statements = ts.isCaseBlock(node)
|
|
146
|
+
? node.clauses.flatMap((clause) => [...clause.statements])
|
|
147
|
+
: node.statements
|
|
148
|
+
for (const statement of statements) {
|
|
149
|
+
collectStatementBindings(statement, into)
|
|
150
|
+
}
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
if (ts.isCatchClause(node) && node.variableDeclaration !== undefined) {
|
|
154
|
+
collectBindingNames(node.variableDeclaration.name, into)
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
if (ts.isForStatement(node) || ts.isForInStatement(node) || ts.isForOfStatement(node)) {
|
|
158
|
+
const initializer = node.initializer
|
|
159
|
+
if (initializer !== undefined && ts.isVariableDeclarationList(initializer)) {
|
|
160
|
+
for (const declaration of initializer.declarations) {
|
|
161
|
+
collectBindingNames(declaration.name, into)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* The names a single block-level statement binds: `var`/`let`/`const`, and named
|
|
168
|
+
`function`/`class` declarations. */
|
|
169
|
+
function collectStatementBindings(statement: ts.Statement, into: Set<string>): void {
|
|
170
|
+
if (ts.isVariableStatement(statement)) {
|
|
171
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
172
|
+
collectBindingNames(declaration.name, into)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (
|
|
176
|
+
(ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) &&
|
|
177
|
+
statement.name !== undefined
|
|
178
|
+
) {
|
|
179
|
+
into.add(statement.name.text)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/* Every identifier bound by a binding name — a plain identifier or the leaves of a
|
|
184
|
+
destructuring pattern (object/array, including rest elements). */
|
|
185
|
+
function collectBindingNames(name: ts.BindingName, into: Set<string>): void {
|
|
186
|
+
if (ts.isIdentifier(name)) {
|
|
187
|
+
into.add(name.text)
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
for (const element of name.elements) {
|
|
191
|
+
if (ts.isBindingElement(element)) {
|
|
192
|
+
collectBindingNames(element.name, into)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/* The parameter list of a function-like node, or undefined if `node` is not one. */
|
|
198
|
+
function functionParameters(node: ts.Node): ts.NodeArray<ts.ParameterDeclaration> | undefined {
|
|
199
|
+
if (
|
|
200
|
+
ts.isArrowFunction(node) ||
|
|
201
|
+
ts.isFunctionDeclaration(node) ||
|
|
202
|
+
ts.isFunctionExpression(node) ||
|
|
203
|
+
ts.isMethodDeclaration(node) ||
|
|
204
|
+
ts.isConstructorDeclaration(node) ||
|
|
205
|
+
ts.isGetAccessorDeclaration(node) ||
|
|
206
|
+
ts.isSetAccessorDeclaration(node)
|
|
207
|
+
) {
|
|
208
|
+
return node.parameters
|
|
209
|
+
}
|
|
210
|
+
return undefined
|
|
211
|
+
}
|
|
212
|
+
|
|
83
213
|
/* `model.<name>` for a state binding, `<name>()` for a computed doc-slot (the
|
|
84
214
|
string-free reader `scope().derive` returns), `<name>.value` for a runtime cell
|
|
85
215
|
(linked / lens / transform-state), else undefined. */
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/*
|
|
2
|
-
An element attribute. `static` is a literal
|
|
2
|
+
An element attribute. `static` is a literal — `bare` marks a valueless attribute
|
|
3
|
+
(`disabled`, not `disabled=""`), letting a component coerce it to `true` while a
|
|
4
|
+
native element still serialises it as `name=""`; `expression` is `name={code}` bound
|
|
3
5
|
reactively; `event` is `on<event>={code}` where `code` evaluates to the handler;
|
|
4
6
|
`bind` is `bind:<property>={lvalue}`, a two-way binding whose `code` is the
|
|
5
7
|
writable doc path read into the property and written back on input; `attach` is
|
|
@@ -9,7 +11,7 @@ absolute offset of `code` in the original `.abide` source (see TextPart) —
|
|
|
9
11
|
optional, set only when the parser tracks positions for the type-checking shadow.
|
|
10
12
|
*/
|
|
11
13
|
export type TemplateAttr =
|
|
12
|
-
| { kind: 'static'; name: string; value: string }
|
|
14
|
+
| { kind: 'static'; name: string; value: string; bare?: true }
|
|
13
15
|
| { kind: 'expression'; name: string; code: string; loc?: number }
|
|
14
16
|
| { kind: 'event'; event: string; code: string; loc?: number }
|
|
15
17
|
| { kind: 'bind'; property: string; code: string; loc?: number }
|
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
import { RESUME } from '../runtime/RESUME.ts'
|
|
2
|
+
import { seedStreamedResolution } from '../seedStreamedResolution.ts'
|
|
2
3
|
|
|
3
4
|
/*
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
Bundle-side consumer of an SSR stream chunk, the counterpart of the doc stream's inline
|
|
6
|
+
vanilla scripts (SSR_SWAP_SCRIPT's `__abideSwap` + `__abideResolve`) for a stream the
|
|
7
|
+
running bundle consumes itself — streaming SPA navigation, socket-delivered SSR. It routes
|
|
8
|
+
the two chunk kinds the stream interleaves:
|
|
9
|
+
|
|
10
|
+
- `<abide-cache>{StreamedResolution}</abide-cache>` → seed the streamed cache partition.
|
|
11
|
+
A script set via innerHTML never runs, so the cache channel rides a data frame here
|
|
12
|
+
(not the doc stream's `<script>__abideResolve(…)</script>`); seeding it keeps the
|
|
13
|
+
pending {#await} read warm instead of cold-missing to the network.
|
|
14
|
+
- `<abide-resolve data-id="ID"><script type="application/json">…</script>…</abide-resolve>`
|
|
15
|
+
→ register the value in the resume manifest (for hydration), find the matching
|
|
16
|
+
`<!--abide:await:ID-->…<!--/abide:await:ID-->` boundary in `root`, remove the pending
|
|
17
|
+
nodes between the markers, and insert the resolved content in their place.
|
|
18
|
+
|
|
19
|
+
The pending shell painted instantly; this swaps in each value as it arrives — completing
|
|
20
|
+
the out-of-order streaming loop on the client.
|
|
11
21
|
*/
|
|
12
22
|
// @documentation plumbing
|
|
13
23
|
export function applyResolved(root: Element, frame: string): void {
|
|
@@ -17,6 +27,16 @@ export function applyResolved(root: Element, frame: string): void {
|
|
|
17
27
|
if (resolved === null || resolved.getAttribute === undefined) {
|
|
18
28
|
return
|
|
19
29
|
}
|
|
30
|
+
/* A cache-seed frame warms the streamed cache partition — paired with the DOM swap so a
|
|
31
|
+
bundle-consumed stream can't adopt a resolved branch while dropping its cache key. */
|
|
32
|
+
if (resolved.nodeName === 'ABIDE-CACHE') {
|
|
33
|
+
try {
|
|
34
|
+
seedStreamedResolution(JSON.parse(resolved.textContent ?? 'null'))
|
|
35
|
+
} catch {
|
|
36
|
+
/* malformed payload — leave unseeded; the read falls back to a live fetch */
|
|
37
|
+
}
|
|
38
|
+
return
|
|
39
|
+
}
|
|
20
40
|
const id = resolved.getAttribute('data-id')
|
|
21
41
|
if (id === null) {
|
|
22
42
|
return
|
|
@@ -24,6 +24,7 @@ import { effect } from './effect.ts'
|
|
|
24
24
|
import { enterScope } from './enterScope.ts'
|
|
25
25
|
import { exitScope } from './exitScope.ts'
|
|
26
26
|
import { enterRenderPass } from './runtime/enterRenderPass.ts'
|
|
27
|
+
import { escapeKey } from './runtime/escapeKey.ts'
|
|
27
28
|
import { exitRenderPass } from './runtime/exitRenderPass.ts'
|
|
28
29
|
import { hotReloadEnabled } from './runtime/hotReloadEnabled.ts'
|
|
29
30
|
import { hotReplace } from './runtime/hotReplace.ts'
|
|
@@ -68,6 +69,7 @@ export function installHotBridge(): void {
|
|
|
68
69
|
mountSlot,
|
|
69
70
|
mountChild,
|
|
70
71
|
hydrate,
|
|
72
|
+
escapeKey,
|
|
71
73
|
nextBlockId,
|
|
72
74
|
enterRenderPass,
|
|
73
75
|
exitRenderPass,
|
|
@@ -4,10 +4,16 @@ Escapes one object key into a JSON Pointer reference token (RFC 6901): `~`→`~0
|
|
|
4
4
|
survives a `/`-joined path instead of being mis-split into segments. `~` is
|
|
5
5
|
escaped first so a `/`→`~1` substitution isn't re-escaped. The common key (a
|
|
6
6
|
plain identifier) contains neither char, so the scan returns it untouched.
|
|
7
|
+
|
|
8
|
+
Coerces first: lowerDocAccess wraps every dynamic path segment in this call, and a
|
|
9
|
+
segment is often a number (an array index `lines[i]`) — String() mirrors the `+`
|
|
10
|
+
join that built the path before, leaving numerics as their decimal text.
|
|
7
11
|
*/
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
12
|
+
// @documentation plumbing
|
|
13
|
+
export function escapeKey(key: string | number): string {
|
|
14
|
+
const segment = String(key)
|
|
15
|
+
if (!segment.includes('~') && !segment.includes('/')) {
|
|
16
|
+
return segment
|
|
11
17
|
}
|
|
12
|
-
return
|
|
18
|
+
return segment.replace(/~/g, '~0').replace(/\//g, '~1')
|
|
13
19
|
}
|