@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.
Files changed (40) hide show
  1. package/AGENTS.md +3 -3
  2. package/CHANGELOG.md +50 -0
  3. package/README.md +2 -2
  4. package/package.json +2 -1
  5. package/src/abideResolverPlugin.ts +53 -9
  6. package/src/lib/server/runtime/STREAMED_HTML_HEADER.ts +13 -0
  7. package/src/lib/server/runtime/buildPreloadManifest.ts +151 -0
  8. package/src/lib/server/runtime/createServer.ts +4 -1
  9. package/src/lib/server/runtime/createUiPageRenderer.ts +104 -9
  10. package/src/lib/server/runtime/flushingGzipStream.ts +62 -0
  11. package/src/lib/server/runtime/gzipResponse.ts +23 -11
  12. package/src/lib/server/runtime/serializeCacheSnapshot.ts +22 -33
  13. package/src/lib/server/runtime/snapshotEntryFromCache.ts +9 -0
  14. package/src/lib/server/sockets/defineSocket.ts +6 -1
  15. package/src/lib/shared/cacheEntryFromSnapshot.ts +14 -12
  16. package/src/lib/shared/contentBodyKind.ts +27 -0
  17. package/src/lib/shared/createSocketSubRegistry.ts +112 -0
  18. package/src/lib/shared/decodeResponse.ts +5 -4
  19. package/src/lib/shared/isStreamingResponse.ts +5 -5
  20. package/src/lib/test/createTestSocketChannel.ts +9 -70
  21. package/src/lib/ui/README.md +1 -1
  22. package/src/lib/ui/compile/REACTIVE_CALLEES.ts +6 -6
  23. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
  24. package/src/lib/ui/compile/compileShadow.ts +60 -55
  25. package/src/lib/ui/compile/desugarSignals.ts +93 -38
  26. package/src/lib/ui/compile/generateSSR.ts +28 -47
  27. package/src/lib/ui/compile/lowerDocAccess.ts +26 -11
  28. package/src/lib/ui/compile/parseTemplate.ts +8 -5
  29. package/src/lib/ui/compile/prepareNestedScript.ts +2 -2
  30. package/src/lib/ui/compile/renameSignalRefs.ts +153 -23
  31. package/src/lib/ui/compile/skeletonContext.ts +83 -0
  32. package/src/lib/ui/compile/types/SkeletonContext.ts +15 -0
  33. package/src/lib/ui/compile/types/TemplateAttr.ts +4 -2
  34. package/src/lib/ui/dom/applyResolved.ts +27 -7
  35. package/src/lib/ui/installHotBridge.ts +2 -0
  36. package/src/lib/ui/runtime/escapeKey.ts +10 -4
  37. package/src/lib/ui/seedStreamedResolution.ts +22 -0
  38. package/src/lib/ui/socketChannel.ts +18 -98
  39. package/src/lib/ui/startClient.ts +17 -3
  40. 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). `prop` is sugar
16
- too, but every `prop()` declaration is rewritten away, so it never appears here.
17
- `$props` is the legacy untyped prop bag (pre-`prop()` sugar) made available raw.
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 const $props: Record<string, (() => unknown) | undefined>
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) → let count = (0)
40
- const total = computed(() => …) → const total = (() => …)()
41
- let title = prop<string>('t') Props field + `let title = props['t']`
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, props } = analyzeScript(scriptBody, scriptStart)
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
- builder.raw(`interface __Props {\n${props.join('\n')}\n}\n`)
66
- /* async so `await` blocks are legal; never executed, so the return is void. */
67
- builder.raw('export default async function (props: __Props): Promise<void> {\n')
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 props;\n')
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
- props: string[]
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 Props
145
- interface fields. `scriptStart` is the body's absolute offset in the source, so
146
- verbatim spans map back exactly. */
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 props: string[] = []
161
+ const propsShapes: string[] = []
152
162
  if (scriptBody.trim() === '') {
153
- return { imports, types, scope, props }
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, props, verbatim, span))
193
+ scope.push(scopeLineFor(declaration, propsShapes, verbatim, span))
184
194
  }
185
195
  }
186
- return { imports, types, scope, props }
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`/`prop` declarations in a variable statement, or undefined
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
- `prop(...)` decl — bare or the explicit scope form (`scope().state(...)` / `c.state(...)`),
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. `prop` also contributes a Props field (pushed into `props`). */
250
+ type. A `props()` destructure contributes its whole shape (pushed into `propsShapes`). */
241
251
  function scopeLineFor(
242
252
  declaration: ts.VariableDeclaration,
243
- props: string[],
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
- if (callee === 'computed' || callee === 'linked') {
277
- /* computed<T>(compute) / linked<T>(seed): T is the value type the call's
278
- first arg is a thunk, so invoking it yields the value. Annotate so an
279
- explicit type argument isn't lost to inference of the thunk's return. */
280
- const typeNode = call.typeArguments?.[0]
281
- const annotation = typeNode === undefined ? '' : `: ${verbatim(typeNode)}`
282
- const fn = call.arguments[0]
283
- /* `linked` is a writable `State<T>` at runtime (it reseeds AND accepts `.value =`
284
- writes), so project it as `let`; `computed` is genuinely read-only, so `const`. */
285
- const keyword = callee === 'linked' ? 'let' : 'const'
286
- /* binding-name map offset = past the keyword + space (`let ` = 4, `const ` = 6) */
287
- const keywordOffset = keyword.length + 1
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: `${prefix}${verbatim(fn)})();`,
297
- segments: [span(declaration.name, keywordOffset), span(fn, prefix.length)],
307
+ text: `${keyword} ${name} = undefined;`,
308
+ segments: [span(declaration.name, keywordOffset)],
298
309
  }
299
310
  }
300
- /* prop<T>('key'): Props field `key[?]: T`, scope binding read from props. */
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: `let ${name} = props[${JSON.stringify(keyText)}];`,
309
- segments: [span(declaration.name, 4)],
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 (`prop` is excluded it
6
- reads parent-passed props, not scope data, so it stays a bare call). A bare call to one
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 if the script calls a scope primitive bare (`state(0)`) instead of through a scope
12
- (`scope().state(0)` / `c.state(0)`). Walks all calls, so a stray one nested in a function
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
- throw new Error(
23
- `abide: bare \`${name}(...)\` is not allowed — reactive state lives on a scope. Use \`scope().${name}(...)\` (or a captured handle: \`const s = scope(); s.${name}(...)\`).`,
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)` / `prop` → a computed `scope().derive`
75
- doc slot, referenced as `name()` (its string-free reader): a function
76
- of other paths, recomputed via the graph, never stored/serialized. */
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 propDeclarations = propDeclarationLines(statement, printer, source)
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 (propDeclarations !== undefined) {
105
- lines.push(...propDeclarations)
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`, or a `prop` (a read-only computed over the parent thunk). The writable
122
- `computed(compute, set)` lens keeps a `.value` cell (handled by the caller). */
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
- callee === 'computed' &&
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
- /* If `statement` declares `prop(...)` bindings, returns a reactive computed over
238
- the parent-supplied `$props` thunk for each; otherwise undefined. The optional
239
- call (`?.()`) tolerates an omitted prop. */
240
- function propDeclarationLines(
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) !== 'prop' || !ts.isIdentifier(declaration.name)) {
302
+ if (signalCallee(declaration) !== 'props' || !ts.isObjectBindingPattern(declaration.name)) {
251
303
  return undefined
252
304
  }
253
- const key = (declaration.initializer as ts.CallExpression).arguments[0]
254
- const keyText =
255
- key === undefined ? "''" : printer.printNode(ts.EmitHint.Unspecified, key, source)
256
- lines.push(
257
- `const ${declaration.name.text} = scope().derive(${JSON.stringify(declaration.name.text)}, () => $props[${keyText}]?.())`,
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 { skeletonable } from './skeletonable.ts'
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
- /* A control-flow branch is a fresh build context — the block runtime mounts it, not
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 => (inSkeleton ? push(target, '<!--a-->') : '')
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
- /* Track skeleton context: reactive text gets an `<!--a-->` anchor only when
260
- interleaved (this element has non-text children) inside a skeletonable
261
- subtree matching the client's anchor vs marker-free text-leaf choice. */
262
- const entering = !inSkeleton && skeletonable(node)
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 (reset like `branchContent`). */
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(`byId/${key}`)
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(`lines/${i}/sku`)
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). Reads are lowered to
19
- `read(path)`; a later pass hoists static-path reads to a `cell` bound once at
20
- component init (the string-free hot path the bench measured). Index expressions
21
- are themselves visited, so a read used as an index lowers too.
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
- segments.unshift({ kind: 'literal', value: current.name.text })
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 string-typed by
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
- fragments.push(segment.node)
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]