@abide/abide 0.34.2 → 0.36.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 +196 -215
- package/CHANGELOG.md +46 -0
- package/README.md +75 -69
- 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/shared/cache.ts +10 -3
- package/src/lib/shared/cacheManagedSlot.ts +10 -0
- package/src/lib/shared/withCacheManaged.ts +18 -0
- 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/dom/awaitBlock.ts +7 -3
- package/src/lib/ui/dom/each.ts +8 -15
- package/src/lib/ui/dom/eachAsync.ts +8 -9
- package/src/lib/ui/dom/switchBlock.ts +7 -3
- package/src/lib/ui/dom/tryBlock.ts +16 -5
- package/src/lib/ui/dom/when.ts +7 -3
- package/src/lib/ui/installHotBridge.ts +2 -0
- package/src/lib/ui/remoteProxy.ts +32 -6
- package/src/lib/ui/router.ts +26 -10
- package/src/lib/ui/runtime/REQUEST_SUPERSEDED.ts +8 -0
- package/src/lib/ui/runtime/abortNode.ts +22 -0
- package/src/lib/ui/runtime/currentAbortSignal.ts +26 -0
- package/src/lib/ui/runtime/escapeKey.ts +10 -4
- package/src/lib/ui/runtime/reactiveAbortState.ts +15 -0
- package/src/lib/ui/runtime/runNode.ts +9 -0
- package/src/lib/ui/runtime/scopeGroup.ts +40 -0
- package/src/lib/ui/runtime/unlinkDeps.ts +8 -0
- 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
|
@@ -12,9 +12,10 @@ calls type-check — `scope()` is the authored reactive surface (`scope().state(
|
|
|
12
12
|
`.computed(...)` / `.undo()` …), so it must resolve like any import. `state`/`linked`/
|
|
13
13
|
`computed` are ALSO declared ambiently as a fallback for the rare bare/nested use the
|
|
14
14
|
top-level rewrite doesn't project (their top-level declarations become value types, so
|
|
15
|
-
these are normally unused — fine, the shadow disables noUnusedLocals). `
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
these are normally unused — fine, the shadow disables noUnusedLocals). `props` is the prop
|
|
16
|
+
reader — destructured (`const { a = 1 } = props<Shape>()`); declared returning its type
|
|
17
|
+
argument (default `Record<string, any>`) so each binding inherits its prop type and its
|
|
18
|
+
`= default` narrows.
|
|
18
19
|
*/
|
|
19
20
|
const SHADOW_PREAMBLE = `import { effect } from '${ABIDE_PACKAGE_NAME}/ui/effect'
|
|
20
21
|
import { html } from '${ABIDE_PACKAGE_NAME}/shared/html'
|
|
@@ -23,7 +24,7 @@ import { scope } from '${ABIDE_PACKAGE_NAME}/ui/scope'
|
|
|
23
24
|
declare function state<T>(initial?: T, transform?: (next: T, previous: T) => T): { value: T }
|
|
24
25
|
declare function linked<T>(seed: () => T, transform?: (next: T, previous: T) => T): { value: T }
|
|
25
26
|
declare function computed<T>(compute: () => T): { readonly value: T }
|
|
26
|
-
declare
|
|
27
|
+
declare function props<T = Record<string, any>>(): T
|
|
27
28
|
void [effect, html, snippet, scope]
|
|
28
29
|
`
|
|
29
30
|
|
|
@@ -36,9 +37,9 @@ expressions and child-component props, with diagnostics mapped back through the
|
|
|
36
37
|
returned segments.
|
|
37
38
|
|
|
38
39
|
The script's signal surface is rewritten to value types:
|
|
39
|
-
let count = state(0)
|
|
40
|
-
const total = computed(() => …)
|
|
41
|
-
|
|
40
|
+
let count = state(0) → let count = (0)
|
|
41
|
+
const total = computed(() => …) → const total = (() => …)()
|
|
42
|
+
const { a } = props<{ a: T }>() → `__Props = { a: T }` + the verbatim destructure
|
|
42
43
|
Everything else (functions, plain consts, imports) is emitted verbatim, so
|
|
43
44
|
expressions inside it (e.g. a computed's compute body) are checked and mapped too.
|
|
44
45
|
*/
|
|
@@ -50,7 +51,7 @@ export function compileShadow(source: string): CompiledShadow {
|
|
|
50
51
|
const scriptStart = leadingScript ? source.indexOf('>', leadingScript.index) + 1 : 0
|
|
51
52
|
const templateStart = leadingScript ? (leadingScript.index ?? 0) + leadingScript[0].length : 0
|
|
52
53
|
|
|
53
|
-
const { imports, types, scope,
|
|
54
|
+
const { imports, types, scope, propsShapes } = analyzeScript(scriptBody, scriptStart)
|
|
54
55
|
builder.raw(SHADOW_PREAMBLE)
|
|
55
56
|
for (const line of imports) {
|
|
56
57
|
builder.flush(line)
|
|
@@ -62,11 +63,20 @@ export function compileShadow(source: string): CompiledShadow {
|
|
|
62
63
|
for (const line of types) {
|
|
63
64
|
builder.flush(line)
|
|
64
65
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
/* `__Props` is the parent-facing prop shape: each `props<Shape>()` destructure
|
|
67
|
+
contributes its whole `Shape` (intersected if there's more than one), or an empty
|
|
68
|
+
object for a component that reads no props. */
|
|
69
|
+
builder.raw(
|
|
70
|
+
propsShapes.length > 0
|
|
71
|
+
? `type __Props = ${propsShapes.join(' & ')}\n`
|
|
72
|
+
: `interface __Props {}\n`,
|
|
73
|
+
)
|
|
74
|
+
/* `__props` (not `props`) so the destructuring `props()` sugar resolves to the
|
|
75
|
+
declared function, not this parameter. async so `await` blocks are legal; never
|
|
76
|
+
executed, so the return is void. */
|
|
77
|
+
builder.raw('export default async function (__props: __Props): Promise<void> {\n')
|
|
68
78
|
/* Reference props so an all-optional bag with no reads doesn't read as unused. */
|
|
69
|
-
builder.raw('void
|
|
79
|
+
builder.raw('void __props;\n')
|
|
70
80
|
for (const line of scope) {
|
|
71
81
|
builder.flush(line)
|
|
72
82
|
}
|
|
@@ -137,20 +147,20 @@ type ScriptAnalysis = {
|
|
|
137
147
|
imports: ScopeLine[]
|
|
138
148
|
types: ScopeLine[]
|
|
139
149
|
scope: ScopeLine[]
|
|
140
|
-
|
|
150
|
+
propsShapes: string[]
|
|
141
151
|
}
|
|
142
152
|
|
|
143
153
|
/* Walks the leading `<script>` and produces the shadow's module imports, the
|
|
144
|
-
module-scope type declarations, the value-typed scope lines, and the
|
|
145
|
-
|
|
146
|
-
|
|
154
|
+
module-scope type declarations, the value-typed scope lines, and the `props<Shape>()`
|
|
155
|
+
prop shapes. `scriptStart` is the body's absolute offset in the source, so verbatim
|
|
156
|
+
spans map back exactly. */
|
|
147
157
|
function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis {
|
|
148
158
|
const imports: ScopeLine[] = []
|
|
149
159
|
const types: ScopeLine[] = []
|
|
150
160
|
const scope: ScopeLine[] = []
|
|
151
|
-
const
|
|
161
|
+
const propsShapes: string[] = []
|
|
152
162
|
if (scriptBody.trim() === '') {
|
|
153
|
-
return { imports, types, scope,
|
|
163
|
+
return { imports, types, scope, propsShapes }
|
|
154
164
|
}
|
|
155
165
|
const file = ts.createSourceFile('script.ts', scriptBody, ts.ScriptTarget.Latest, true)
|
|
156
166
|
/* A verbatim span: original text + the segment mapping it back, relative to the
|
|
@@ -180,10 +190,10 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
|
|
|
180
190
|
continue
|
|
181
191
|
}
|
|
182
192
|
for (const declaration of reactive) {
|
|
183
|
-
scope.push(scopeLineFor(declaration,
|
|
193
|
+
scope.push(scopeLineFor(declaration, propsShapes, verbatim, span))
|
|
184
194
|
}
|
|
185
195
|
}
|
|
186
|
-
return { imports, types, scope,
|
|
196
|
+
return { imports, types, scope, propsShapes }
|
|
187
197
|
}
|
|
188
198
|
|
|
189
199
|
/* Value-projects a nested control-flow `<script>` body the way `analyzeScript`
|
|
@@ -207,8 +217,8 @@ function projectNestedScript(code: string): string {
|
|
|
207
217
|
.join('\n')
|
|
208
218
|
}
|
|
209
219
|
|
|
210
|
-
/* The `state`/`computed`/`
|
|
211
|
-
if it isn't one declaring them (so the caller emits it verbatim). A statement
|
|
220
|
+
/* The `state`/`computed`/`linked`/`props()` declarations in a variable statement, or
|
|
221
|
+
undefined if it isn't one declaring them (so the caller emits it verbatim). A statement
|
|
212
222
|
mixing reactive and plain declarations is rare; treated as all-verbatim. */
|
|
213
223
|
function reactiveDeclarations(statement: ts.Statement): ts.VariableDeclaration[] | undefined {
|
|
214
224
|
if (!ts.isVariableStatement(statement)) {
|
|
@@ -220,7 +230,7 @@ function reactiveDeclarations(statement: ts.Statement): ts.VariableDeclaration[]
|
|
|
220
230
|
}
|
|
221
231
|
|
|
222
232
|
/* The callee name of a `NAME = state(...)` / `linked(...)` / `computed(...)` /
|
|
223
|
-
`
|
|
233
|
+
`props()` decl — bare or the explicit scope form (`scope().state(...)` / `c.state(...)`),
|
|
224
234
|
receiver-agnostic (the method name marks it reactive). */
|
|
225
235
|
function signalCallee(declaration: ts.VariableDeclaration): string | undefined {
|
|
226
236
|
const initializer = declaration.initializer
|
|
@@ -237,10 +247,10 @@ function signalCallee(declaration: ts.VariableDeclaration): string | undefined {
|
|
|
237
247
|
}
|
|
238
248
|
|
|
239
249
|
/* Builds one scope line for a reactive declaration, projecting it to its value
|
|
240
|
-
type. `
|
|
250
|
+
type. A `props()` destructure contributes its whole shape (pushed into `propsShapes`). */
|
|
241
251
|
function scopeLineFor(
|
|
242
252
|
declaration: ts.VariableDeclaration,
|
|
243
|
-
|
|
253
|
+
propsShapes: string[],
|
|
244
254
|
verbatim: (node: ts.Node) => string,
|
|
245
255
|
span: (node: ts.Node, prefixLength: number) => ShadowMapping,
|
|
246
256
|
): ScopeLine {
|
|
@@ -250,6 +260,14 @@ function scopeLineFor(
|
|
|
250
260
|
const callee = ts.isPropertyAccessExpression(call.expression)
|
|
251
261
|
? call.expression.name.text
|
|
252
262
|
: (call.expression as ts.Identifier).text
|
|
263
|
+
if (callee === 'props') {
|
|
264
|
+
/* `const {…} = props<Shape>()`: the type arg (default `Record<string, any>`)
|
|
265
|
+
IS the parent-facing prop shape, and the destructure projects verbatim against
|
|
266
|
+
the declared typed `props()` so each binding inherits its value type. */
|
|
267
|
+
const shape = call.typeArguments?.[0]
|
|
268
|
+
propsShapes.push(shape === undefined ? 'Record<string, any>' : verbatim(shape))
|
|
269
|
+
return { text: `const ${verbatim(declaration)};`, segments: [span(declaration, 6)] }
|
|
270
|
+
}
|
|
253
271
|
if (callee === 'state') {
|
|
254
272
|
/* state<T>(initial): T is the value type — carry it onto the `let` so an
|
|
255
273
|
explicit annotation isn't lost to `any`/`any[]` inference of the initial. */
|
|
@@ -273,40 +291,27 @@ function scopeLineFor(
|
|
|
273
291
|
segments: [span(declaration.name, 4), span(init, prefix.length)],
|
|
274
292
|
}
|
|
275
293
|
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
if (fn === undefined) {
|
|
289
|
-
return {
|
|
290
|
-
text: `${keyword} ${name} = undefined;`,
|
|
291
|
-
segments: [span(declaration.name, keywordOffset)],
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
const prefix = `${keyword} ${name}${annotation} = (`
|
|
294
|
+
/* computed<T>(compute) / linked<T>(seed) — the only callees left: T is the value
|
|
295
|
+
type — the call's first arg is a thunk, so invoking it yields the value. Annotate
|
|
296
|
+
so an explicit type argument isn't lost to inference of the thunk's return. */
|
|
297
|
+
const typeNode = call.typeArguments?.[0]
|
|
298
|
+
const annotation = typeNode === undefined ? '' : `: ${verbatim(typeNode)}`
|
|
299
|
+
const fn = call.arguments[0]
|
|
300
|
+
/* `linked` is a writable `State<T>` at runtime (it reseeds AND accepts `.value =`
|
|
301
|
+
writes), so project it as `let`; `computed` is genuinely read-only, so `const`. */
|
|
302
|
+
const keyword = callee === 'linked' ? 'let' : 'const'
|
|
303
|
+
/* binding-name map offset = past the keyword + space (`let ` = 4, `const ` = 6) */
|
|
304
|
+
const keywordOffset = keyword.length + 1
|
|
305
|
+
if (fn === undefined) {
|
|
295
306
|
return {
|
|
296
|
-
text: `${
|
|
297
|
-
segments: [span(declaration.name, keywordOffset)
|
|
307
|
+
text: `${keyword} ${name} = undefined;`,
|
|
308
|
+
segments: [span(declaration.name, keywordOffset)],
|
|
298
309
|
}
|
|
299
310
|
}
|
|
300
|
-
|
|
301
|
-
const key = call.arguments[0]
|
|
302
|
-
const keyText = key !== undefined && ts.isStringLiteralLike(key) ? key.text : name
|
|
303
|
-
const typeNode = call.typeArguments?.[0]
|
|
304
|
-
const typeText = typeNode === undefined ? 'unknown' : verbatim(typeNode)
|
|
305
|
-
const optional = typeNode === undefined || /\bundefined\b/.test(typeText)
|
|
306
|
-
props.push(` ${JSON.stringify(keyText)}${optional ? '?' : ''}: ${typeText}`)
|
|
311
|
+
const prefix = `${keyword} ${name}${annotation} = (`
|
|
307
312
|
return {
|
|
308
|
-
text:
|
|
309
|
-
segments: [span(declaration.name,
|
|
313
|
+
text: `${prefix}${verbatim(fn)})();`,
|
|
314
|
+
segments: [span(declaration.name, keywordOffset), span(fn, prefix.length)],
|
|
310
315
|
}
|
|
311
316
|
}
|
|
312
317
|
|
|
@@ -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
|