@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.
Files changed (46) hide show
  1. package/AGENTS.md +196 -215
  2. package/CHANGELOG.md +46 -0
  3. package/README.md +75 -69
  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/shared/cache.ts +10 -3
  14. package/src/lib/shared/cacheManagedSlot.ts +10 -0
  15. package/src/lib/shared/withCacheManaged.ts +18 -0
  16. package/src/lib/ui/README.md +1 -1
  17. package/src/lib/ui/compile/REACTIVE_CALLEES.ts +6 -6
  18. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
  19. package/src/lib/ui/compile/compileShadow.ts +60 -55
  20. package/src/lib/ui/compile/desugarSignals.ts +93 -38
  21. package/src/lib/ui/compile/lowerDocAccess.ts +26 -11
  22. package/src/lib/ui/compile/parseTemplate.ts +8 -5
  23. package/src/lib/ui/compile/prepareNestedScript.ts +2 -2
  24. package/src/lib/ui/compile/renameSignalRefs.ts +153 -23
  25. package/src/lib/ui/compile/types/TemplateAttr.ts +4 -2
  26. package/src/lib/ui/dom/applyResolved.ts +27 -7
  27. package/src/lib/ui/dom/awaitBlock.ts +7 -3
  28. package/src/lib/ui/dom/each.ts +8 -15
  29. package/src/lib/ui/dom/eachAsync.ts +8 -9
  30. package/src/lib/ui/dom/switchBlock.ts +7 -3
  31. package/src/lib/ui/dom/tryBlock.ts +16 -5
  32. package/src/lib/ui/dom/when.ts +7 -3
  33. package/src/lib/ui/installHotBridge.ts +2 -0
  34. package/src/lib/ui/remoteProxy.ts +32 -6
  35. package/src/lib/ui/router.ts +26 -10
  36. package/src/lib/ui/runtime/REQUEST_SUPERSEDED.ts +8 -0
  37. package/src/lib/ui/runtime/abortNode.ts +22 -0
  38. package/src/lib/ui/runtime/currentAbortSignal.ts +26 -0
  39. package/src/lib/ui/runtime/escapeKey.ts +10 -4
  40. package/src/lib/ui/runtime/reactiveAbortState.ts +15 -0
  41. package/src/lib/ui/runtime/runNode.ts +9 -0
  42. package/src/lib/ui/runtime/scopeGroup.ts +40 -0
  43. package/src/lib/ui/runtime/unlinkDeps.ts +8 -0
  44. package/src/lib/ui/seedStreamedResolution.ts +22 -0
  45. package/src/lib/ui/startClient.ts +17 -3
  46. 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
  }
@@ -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]
@@ -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`, letting a prop hold any value, functions
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
- return { name: attr.name, code: JSON.stringify(attr.value) }
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`/`prop`). The back-end adds them to the deref scope so both the
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(...)` / `prop(...)` declaration —
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