@abide/abide 0.34.1 → 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 +50 -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/server/runtime/snapshotEntryFromCache.ts +9 -0
- package/src/lib/server/sockets/defineSocket.ts +6 -1
- package/src/lib/shared/cacheEntryFromSnapshot.ts +14 -12
- package/src/lib/shared/contentBodyKind.ts +27 -0
- package/src/lib/shared/createSocketSubRegistry.ts +112 -0
- package/src/lib/shared/decodeResponse.ts +5 -4
- package/src/lib/shared/isStreamingResponse.ts +5 -5
- package/src/lib/test/createTestSocketChannel.ts +9 -70
- 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/generateSSR.ts +28 -47
- 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/skeletonContext.ts +83 -0
- package/src/lib/ui/compile/types/SkeletonContext.ts +15 -0
- 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/socketChannel.ts +18 -98
- 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
|
}
|
|
@@ -2,10 +2,9 @@ import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
|
|
|
2
2
|
import { componentWrapperTag } from './componentWrapperTag.ts'
|
|
3
3
|
import { groupBindParts } from './groupBindParts.ts'
|
|
4
4
|
import { isControlFlow } from './isControlFlow.ts'
|
|
5
|
-
import { isTextLeaf } from './isTextLeaf.ts'
|
|
6
5
|
import { lowerContext } from './lowerContext.ts'
|
|
7
6
|
import { scopeAttr } from './scopeAttr.ts'
|
|
8
|
-
import {
|
|
7
|
+
import { skeletonContext } from './skeletonContext.ts'
|
|
9
8
|
import { staticAttr } from './staticAttr.ts'
|
|
10
9
|
import { staticTextPart } from './staticTextPart.ts'
|
|
11
10
|
import { stripEffects } from './stripEffects.ts'
|
|
@@ -65,41 +64,33 @@ export function generateSSR(
|
|
|
65
64
|
return children.map((child) => generate(child, target)).join('')
|
|
66
65
|
}
|
|
67
66
|
|
|
67
|
+
/* Per-node skeleton position, computed once. Both back-ends read this single source of
|
|
68
|
+
truth so their `<!--a-->` anchor placement cannot drift — the fresh-context boundaries
|
|
69
|
+
(control-flow branches, component/slot/snippet content) are enumerated there, not
|
|
70
|
+
re-tracked here as mutable state that a forgotten reset could leak past. */
|
|
71
|
+
const { inSkeleton, markText } = skeletonContext(nodes)
|
|
72
|
+
|
|
68
73
|
/* A control-flow branch's content, generated exactly like a normal child list so
|
|
69
74
|
a branch holds ANY content (components, text, nested blocks). `generate` emits
|
|
70
75
|
nested `<script>`s in document order; `withNestedScripts` puts their bindings in
|
|
71
76
|
scope — matching the client build, so hydration stays aligned. The caller wraps
|
|
72
77
|
it in the `[ … ]` range markers the runtime tracks (unconditionally per block,
|
|
73
|
-
so an empty/false branch still emits the boundary the client claims).
|
|
78
|
+
so an empty/false branch still emits the boundary the client claims). The branch's
|
|
79
|
+
fresh build context is already recorded by `skeletonContext`, so its children read
|
|
80
|
+
their own (reset) position — no flag juggling here. */
|
|
74
81
|
function branchContent(children: TemplateNode[], target: string): string {
|
|
75
|
-
|
|
76
|
-
the parent skeleton — so reset the skeleton/anchor tracking; the branch's own
|
|
77
|
-
skeletonable elements re-enter it. */
|
|
78
|
-
const previousSkeleton = inSkeleton
|
|
79
|
-
const previousMark = markText
|
|
80
|
-
inSkeleton = false
|
|
81
|
-
markText = false
|
|
82
|
-
const out = withNestedScripts(children, () => generateInto(children, target))
|
|
83
|
-
inSkeleton = previousSkeleton
|
|
84
|
-
markText = previousMark
|
|
85
|
-
return out
|
|
82
|
+
return withNestedScripts(children, () => generateInto(children, target))
|
|
86
83
|
}
|
|
87
84
|
const openRange = (target: string): string => push(target, RANGE_OPEN)
|
|
88
85
|
const closeRange = (target: string): string => push(target, RANGE_CLOSE)
|
|
89
86
|
|
|
90
|
-
/* True inside a skeletonable subtree; `markText` true when, additionally, the current
|
|
91
|
-
element is NOT a text-leaf — so its reactive text is interleaved and the client uses
|
|
92
|
-
an `<!--a-->` anchor. The marker is kept both sides (like control-flow ranges), so
|
|
93
|
-
SSR markup stays identical to the client DOM. */
|
|
94
|
-
let inSkeleton = false
|
|
95
|
-
let markText = false
|
|
96
|
-
|
|
97
87
|
/* In a skeleton, a control-flow block or slot is positioned by an `<!--a-->` anchor
|
|
98
88
|
(cloned into the located parent), so it can sit anywhere among static siblings.
|
|
99
89
|
Emitted both sides in document order — the client's anchor scan lines up with it.
|
|
100
90
|
Outside a skeleton (top-level / inside a branch) blocks mount on the host directly,
|
|
101
91
|
so no anchor. */
|
|
102
|
-
const anchorMark = (target: string): string =>
|
|
92
|
+
const anchorMark = (node: TemplateNode, target: string): string =>
|
|
93
|
+
inSkeleton.get(node) ? push(target, '<!--a-->') : ''
|
|
103
94
|
|
|
104
95
|
function generate(node: TemplateNode, target: string): string {
|
|
105
96
|
/* A control-flow block is positioned by an `<!--a-->` anchor when in a skeleton
|
|
@@ -107,7 +98,7 @@ export function generateSSR(
|
|
|
107
98
|
mirroring the client's single `skeletonMarkup` control-flow branch — rather than
|
|
108
99
|
prefixing each block kind's own `anchorMark` call (six sites that had to stay in
|
|
109
100
|
lock-step). `anchorMark` no-ops outside a skeleton, so non-block nodes ignore it. */
|
|
110
|
-
const anchor = isControlFlow(node) ? anchorMark(target) : ''
|
|
101
|
+
const anchor = isControlFlow(node) ? anchorMark(node, target) : ''
|
|
111
102
|
if (node.kind === 'text') {
|
|
112
103
|
return node.parts
|
|
113
104
|
.map((part) => {
|
|
@@ -116,7 +107,7 @@ export function generateSSR(
|
|
|
116
107
|
return markup === '' ? '' : push(target, markup)
|
|
117
108
|
}
|
|
118
109
|
const value = `$text(${lowerExpression(part.code)})`
|
|
119
|
-
return markText
|
|
110
|
+
return markText.get(node)
|
|
120
111
|
? `${target}.push('<!--a-->' + ${value});\n`
|
|
121
112
|
: `${target}.push(${value});\n`
|
|
122
113
|
})
|
|
@@ -197,6 +188,11 @@ export function generateSSR(
|
|
|
197
188
|
const parts = node.props.map(
|
|
198
189
|
(prop) => `${JSON.stringify(prop.name)}: () => (${lowerExpression(prop.code)})`,
|
|
199
190
|
)
|
|
191
|
+
/* Slot content is a fresh build context — the child's `<slot>` mounts it via
|
|
192
|
+
`mountSlot`, not the parent skeleton clone, and the client builds it through
|
|
193
|
+
`componentParts`/`generateChildren` (never the skeleton path). `skeletonContext`
|
|
194
|
+
records it reset, so its children emit no enclosing-skeleton anchors the client
|
|
195
|
+
slot builder would lack. */
|
|
200
196
|
const slotCode = generateInto(node.children, '$slot')
|
|
201
197
|
if (slotCode.trim() !== '') {
|
|
202
198
|
parts.push(
|
|
@@ -256,21 +252,11 @@ export function generateSSR(
|
|
|
256
252
|
}
|
|
257
253
|
code += push(target, '>')
|
|
258
254
|
if (!VOID_TAGS.has(node.tag)) {
|
|
259
|
-
/*
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
if (entering) {
|
|
264
|
-
inSkeleton = true
|
|
265
|
-
}
|
|
266
|
-
const previousMark = markText
|
|
267
|
-
markText = inSkeleton && !isTextLeaf(node)
|
|
268
|
-
/* A `<script>` child scopes its bindings to this element's subtree. */
|
|
255
|
+
/* Each child's skeleton position (whether its reactive text interleaves into an
|
|
256
|
+
anchor, whether a nested block anchors) is already recorded by `skeletonContext`
|
|
257
|
+
— read per node, not tracked here. A `<script>` child scopes its bindings to
|
|
258
|
+
this element's subtree. */
|
|
269
259
|
code += withNestedScripts(node.children, () => generateInto(node.children, target))
|
|
270
|
-
markText = previousMark
|
|
271
|
-
if (entering) {
|
|
272
|
-
inSkeleton = false
|
|
273
|
-
}
|
|
274
260
|
code += push(target, `</${node.tag}>`)
|
|
275
261
|
}
|
|
276
262
|
return code
|
|
@@ -281,19 +267,14 @@ export function generateSSR(
|
|
|
281
267
|
by an `<!--a-->` anchor and its content bounded by a `[ … ]` range (matching the
|
|
282
268
|
client's `mountSlot`), so it can sit among static siblings. The fallback is a fresh,
|
|
283
269
|
non-skeleton build context — the client builds it via `mountSlot`/`fillBefore`, not the
|
|
284
|
-
skeleton clone — so its reactive text takes no anchor (
|
|
270
|
+
skeleton clone — so its reactive text takes no anchor (`skeletonContext` records the
|
|
271
|
+
fallback children reset). */
|
|
285
272
|
function generateSlot(
|
|
286
273
|
node: Extract<TemplateNode, { kind: 'element' }>,
|
|
287
274
|
target: string,
|
|
288
275
|
): string {
|
|
289
|
-
const wrap = inSkeleton
|
|
290
|
-
const previousSkeleton = inSkeleton
|
|
291
|
-
const previousMark = markText
|
|
292
|
-
inSkeleton = false
|
|
293
|
-
markText = false
|
|
276
|
+
const wrap = inSkeleton.get(node)
|
|
294
277
|
const fallback = generateInto(node.children, target)
|
|
295
|
-
inSkeleton = previousSkeleton
|
|
296
|
-
markText = previousMark
|
|
297
278
|
const body =
|
|
298
279
|
fallback.trim() === ''
|
|
299
280
|
? `if ($props && $props.$children) { ${target}.push($props.$children()); }\n`
|
|
@@ -301,7 +282,7 @@ export function generateSSR(
|
|
|
301
282
|
if (!wrap) {
|
|
302
283
|
return body
|
|
303
284
|
}
|
|
304
|
-
return `${anchorMark(target)}${openRange(target)}${body}${closeRange(target)}`
|
|
285
|
+
return `${anchorMark(node, target)}${openRange(target)}${body}${closeRange(target)}`
|
|
305
286
|
}
|
|
306
287
|
|
|
307
288
|
/* Boundary markers + a `$awaits` registration carrying the promise and
|
|
@@ -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]
|