@abide/abide 0.34.2 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -9,6 +9,13 @@ touched — declaration names, parameter names, and property names are collected
9
9
  into a skip set first, and object shorthand (`{ count }`) is expanded to
10
10
  `{ count: model.count }`. This is the bridge from the signal surface the author
11
11
  writes to the patch substrate underneath.
12
+
13
+ The rewrite is lexically scope-aware: a reference whose name is re-bound by an
14
+ enclosing scope (a function/arrow parameter or a nested local declaration) refers
15
+ to that inner binding, not the component signal, so it is left untouched. Without
16
+ this, a callback like `list.map(option => option.toUpperCase())` in a component
17
+ that also has an `option` prop (`const { option } = props()`) would have its loop
18
+ variable rewritten to `option()` and blow up at runtime (`option is not a function`).
12
19
  */
13
20
  export function renameSignalRefs(
14
21
  code: string,
@@ -18,6 +25,10 @@ export function renameSignalRefs(
18
25
  ): string {
19
26
  const source = ts.createSourceFile('component.ts', code, ts.ScriptTarget.Latest, true)
20
27
 
28
+ /* The signal names that a nested binding can shadow — only these matter for
29
+ scope tracking, so we ignore every other local binding. */
30
+ const signalNames = new Set<string>([...stateNames, ...derivedNames, ...computedNames])
31
+
21
32
  /* Identifier nodes that are names, not value reads — never rewritten. */
22
33
  const skip = new Set<ts.Node>()
23
34
  const collect = (node: ts.Node): void => {
@@ -45,33 +56,47 @@ export function renameSignalRefs(
45
56
 
46
57
  const result = ts.transform(source, [
47
58
  (context) => (root) => {
48
- const visit = (node: ts.Node): ts.Node => {
49
- /* Shorthand `{ count }` `{ count: model.count }` / `{ total: total.value }`. */
50
- if (ts.isShorthandPropertyAssignment(node)) {
51
- const replacement = referenceFor(
52
- node.name.text,
53
- stateNames,
54
- derivedNames,
55
- computedNames,
56
- )
57
- if (replacement !== undefined) {
58
- return ts.factory.createPropertyAssignment(node.name.text, replacement)
59
+ /* Each visitor carries the set of signal names shadowed by the scopes it sits
60
+ inside; entering a scope that re-binds a signal name produces a fresh visitor
61
+ with that name added, so shadowing is per-branch (a sibling scope is unaffected). */
62
+ const makeVisitor = (shadowed: ReadonlySet<string>): ts.Visitor => {
63
+ const visit = (node: ts.Node): ts.Node => {
64
+ /* Shorthand `{ count }` → `{ count: model.count }` / `{ total: total.value }`,
65
+ unless a nearer scope shadows the name. */
66
+ if (ts.isShorthandPropertyAssignment(node) && !shadowed.has(node.name.text)) {
67
+ const replacement = referenceFor(
68
+ node.name.text,
69
+ stateNames,
70
+ derivedNames,
71
+ computedNames,
72
+ )
73
+ if (replacement !== undefined) {
74
+ return ts.factory.createPropertyAssignment(node.name.text, replacement)
75
+ }
59
76
  }
60
- }
61
- if (ts.isIdentifier(node) && !skip.has(node)) {
62
- const replacement = referenceFor(
63
- node.text,
64
- stateNames,
65
- derivedNames,
66
- computedNames,
67
- )
68
- if (replacement !== undefined) {
69
- return replacement
77
+ if (ts.isIdentifier(node) && !skip.has(node) && !shadowed.has(node.text)) {
78
+ const replacement = referenceFor(
79
+ node.text,
80
+ stateNames,
81
+ derivedNames,
82
+ computedNames,
83
+ )
84
+ if (replacement !== undefined) {
85
+ return replacement
86
+ }
70
87
  }
88
+ /* Recurse with the scope this node introduces folded in (same visitor when
89
+ it adds no shadowing name, so unscoped subtrees allocate nothing extra). */
90
+ const inner = extendShadowed(node, shadowed, signalNames)
91
+ return ts.visitEachChild(
92
+ node,
93
+ inner === shadowed ? visit : makeVisitor(inner),
94
+ context,
95
+ )
71
96
  }
72
- return ts.visitEachChild(node, visit, context)
97
+ return visit
73
98
  }
74
- return ts.visitNode(root, visit) as ts.SourceFile
99
+ return ts.visitNode(root, makeVisitor(new Set())) as ts.SourceFile
75
100
  },
76
101
  ])
77
102
  const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed })
@@ -80,6 +105,111 @@ export function renameSignalRefs(
80
105
  return output
81
106
  }
82
107
 
108
+ /* The shadowed-name set for `node`'s children: the parent set plus any signal name
109
+ that `node` re-binds as a new scope. Returns the parent set unchanged (same
110
+ reference) when `node` introduces no colliding binding, so the caller can skip
111
+ allocating a new visitor. */
112
+ function extendShadowed(
113
+ node: ts.Node,
114
+ shadowed: ReadonlySet<string>,
115
+ signalNames: ReadonlySet<string>,
116
+ ): ReadonlySet<string> {
117
+ const introduced = new Set<string>()
118
+ collectScopeBindings(node, introduced)
119
+ const added = [...introduced].filter((name) => signalNames.has(name) && !shadowed.has(name))
120
+ if (added.length === 0) {
121
+ return shadowed
122
+ }
123
+ return new Set<string>([...shadowed, ...added])
124
+ }
125
+
126
+ /* The names `node` binds when it opens a lexical scope — function/arrow parameters
127
+ (and the function's own name), block-level `let`/`const`/`function`/`class`,
128
+ `for`-header declarations, and the `catch` binding. Only names bound directly at
129
+ this scope; deeper scopes are folded in as the walk descends into them. */
130
+ function collectScopeBindings(node: ts.Node, into: Set<string>): void {
131
+ const parameters = functionParameters(node)
132
+ if (parameters !== undefined) {
133
+ for (const parameter of parameters) {
134
+ collectBindingNames(parameter.name, into)
135
+ }
136
+ if (
137
+ (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node)) &&
138
+ node.name !== undefined
139
+ ) {
140
+ into.add(node.name.text)
141
+ }
142
+ return
143
+ }
144
+ if (ts.isBlock(node) || ts.isModuleBlock(node) || ts.isCaseBlock(node)) {
145
+ const statements = ts.isCaseBlock(node)
146
+ ? node.clauses.flatMap((clause) => [...clause.statements])
147
+ : node.statements
148
+ for (const statement of statements) {
149
+ collectStatementBindings(statement, into)
150
+ }
151
+ return
152
+ }
153
+ if (ts.isCatchClause(node) && node.variableDeclaration !== undefined) {
154
+ collectBindingNames(node.variableDeclaration.name, into)
155
+ return
156
+ }
157
+ if (ts.isForStatement(node) || ts.isForInStatement(node) || ts.isForOfStatement(node)) {
158
+ const initializer = node.initializer
159
+ if (initializer !== undefined && ts.isVariableDeclarationList(initializer)) {
160
+ for (const declaration of initializer.declarations) {
161
+ collectBindingNames(declaration.name, into)
162
+ }
163
+ }
164
+ }
165
+ }
166
+
167
+ /* The names a single block-level statement binds: `var`/`let`/`const`, and named
168
+ `function`/`class` declarations. */
169
+ function collectStatementBindings(statement: ts.Statement, into: Set<string>): void {
170
+ if (ts.isVariableStatement(statement)) {
171
+ for (const declaration of statement.declarationList.declarations) {
172
+ collectBindingNames(declaration.name, into)
173
+ }
174
+ }
175
+ if (
176
+ (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) &&
177
+ statement.name !== undefined
178
+ ) {
179
+ into.add(statement.name.text)
180
+ }
181
+ }
182
+
183
+ /* Every identifier bound by a binding name — a plain identifier or the leaves of a
184
+ destructuring pattern (object/array, including rest elements). */
185
+ function collectBindingNames(name: ts.BindingName, into: Set<string>): void {
186
+ if (ts.isIdentifier(name)) {
187
+ into.add(name.text)
188
+ return
189
+ }
190
+ for (const element of name.elements) {
191
+ if (ts.isBindingElement(element)) {
192
+ collectBindingNames(element.name, into)
193
+ }
194
+ }
195
+ }
196
+
197
+ /* The parameter list of a function-like node, or undefined if `node` is not one. */
198
+ function functionParameters(node: ts.Node): ts.NodeArray<ts.ParameterDeclaration> | undefined {
199
+ if (
200
+ ts.isArrowFunction(node) ||
201
+ ts.isFunctionDeclaration(node) ||
202
+ ts.isFunctionExpression(node) ||
203
+ ts.isMethodDeclaration(node) ||
204
+ ts.isConstructorDeclaration(node) ||
205
+ ts.isGetAccessorDeclaration(node) ||
206
+ ts.isSetAccessorDeclaration(node)
207
+ ) {
208
+ return node.parameters
209
+ }
210
+ return undefined
211
+ }
212
+
83
213
  /* `model.<name>` for a state binding, `<name>()` for a computed doc-slot (the
84
214
  string-free reader `scope().derive` returns), `<name>.value` for a runtime cell
85
215
  (linked / lens / transform-state), else undefined. */
@@ -1,5 +1,7 @@
1
1
  /*
2
- An element attribute. `static` is a literal; `expression` is `name={code}` bound
2
+ An element attribute. `static` is a literal `bare` marks a valueless attribute
3
+ (`disabled`, not `disabled=""`), letting a component coerce it to `true` while a
4
+ native element still serialises it as `name=""`; `expression` is `name={code}` bound
3
5
  reactively; `event` is `on<event>={code}` where `code` evaluates to the handler;
4
6
  `bind` is `bind:<property>={lvalue}`, a two-way binding whose `code` is the
5
7
  writable doc path read into the property and written back on input; `attach` is
@@ -9,7 +11,7 @@ absolute offset of `code` in the original `.abide` source (see TextPart) —
9
11
  optional, set only when the parser tracks positions for the type-checking shadow.
10
12
  */
11
13
  export type TemplateAttr =
12
- | { kind: 'static'; name: string; value: string }
14
+ | { kind: 'static'; name: string; value: string; bare?: true }
13
15
  | { kind: 'expression'; name: string; code: string; loc?: number }
14
16
  | { kind: 'event'; event: string; code: string; loc?: number }
15
17
  | { kind: 'bind'; property: string; code: string; loc?: number }
@@ -1,13 +1,23 @@
1
1
  import { RESUME } from '../runtime/RESUME.ts'
2
+ import { seedStreamedResolution } from '../seedStreamedResolution.ts'
2
3
 
3
4
  /*
4
- Client consumer of an SSR stream fragment. Parses a streamed
5
- `<abide-resolve data-id="ID"><script type="application/json">…</script>…</abide-resolve>`
6
- frame, registers its serialized value in the resume manifest (for later hydration), finds the
7
- matching `<!--abide:await:ID-->…<!--/abide:await:ID-->` boundary in `root`, removes
8
- the pending nodes between the markers, and inserts the resolved content in their
9
- place. The pending shell painted instantly; this swaps in each value as it
10
- arrives completing the out-of-order streaming loop on the client.
5
+ Bundle-side consumer of an SSR stream chunk, the counterpart of the doc stream's inline
6
+ vanilla scripts (SSR_SWAP_SCRIPT's `__abideSwap` + `__abideResolve`) for a stream the
7
+ running bundle consumes itself streaming SPA navigation, socket-delivered SSR. It routes
8
+ the two chunk kinds the stream interleaves:
9
+
10
+ - `<abide-cache>{StreamedResolution}</abide-cache>` seed the streamed cache partition.
11
+ A script set via innerHTML never runs, so the cache channel rides a data frame here
12
+ (not the doc stream's `<script>__abideResolve(…)</script>`); seeding it keeps the
13
+ pending {#await} read warm instead of cold-missing to the network.
14
+ - `<abide-resolve data-id="ID"><script type="application/json">…</script>…</abide-resolve>`
15
+ → register the value in the resume manifest (for hydration), find the matching
16
+ `<!--abide:await:ID-->…<!--/abide:await:ID-->` boundary in `root`, remove the pending
17
+ nodes between the markers, and insert the resolved content in their place.
18
+
19
+ The pending shell painted instantly; this swaps in each value as it arrives — completing
20
+ the out-of-order streaming loop on the client.
11
21
  */
12
22
  // @documentation plumbing
13
23
  export function applyResolved(root: Element, frame: string): void {
@@ -17,6 +27,16 @@ export function applyResolved(root: Element, frame: string): void {
17
27
  if (resolved === null || resolved.getAttribute === undefined) {
18
28
  return
19
29
  }
30
+ /* A cache-seed frame warms the streamed cache partition — paired with the DOM swap so a
31
+ bundle-consumed stream can't adopt a resolved branch while dropping its cache key. */
32
+ if (resolved.nodeName === 'ABIDE-CACHE') {
33
+ try {
34
+ seedStreamedResolution(JSON.parse(resolved.textContent ?? 'null'))
35
+ } catch {
36
+ /* malformed payload — leave unseeded; the read falls back to a live fetch */
37
+ }
38
+ return
39
+ }
20
40
  const id = resolved.getAttribute('data-id')
21
41
  if (id === null) {
22
42
  return
@@ -24,6 +24,7 @@ import { effect } from './effect.ts'
24
24
  import { enterScope } from './enterScope.ts'
25
25
  import { exitScope } from './exitScope.ts'
26
26
  import { enterRenderPass } from './runtime/enterRenderPass.ts'
27
+ import { escapeKey } from './runtime/escapeKey.ts'
27
28
  import { exitRenderPass } from './runtime/exitRenderPass.ts'
28
29
  import { hotReloadEnabled } from './runtime/hotReloadEnabled.ts'
29
30
  import { hotReplace } from './runtime/hotReplace.ts'
@@ -68,6 +69,7 @@ export function installHotBridge(): void {
68
69
  mountSlot,
69
70
  mountChild,
70
71
  hydrate,
72
+ escapeKey,
71
73
  nextBlockId,
72
74
  enterRenderPass,
73
75
  exitRenderPass,
@@ -4,10 +4,16 @@ Escapes one object key into a JSON Pointer reference token (RFC 6901): `~`→`~0
4
4
  survives a `/`-joined path instead of being mis-split into segments. `~` is
5
5
  escaped first so a `/`→`~1` substitution isn't re-escaped. The common key (a
6
6
  plain identifier) contains neither char, so the scan returns it untouched.
7
+
8
+ Coerces first: lowerDocAccess wraps every dynamic path segment in this call, and a
9
+ segment is often a number (an array index `lines[i]`) — String() mirrors the `+`
10
+ join that built the path before, leaving numerics as their decimal text.
7
11
  */
8
- export function escapeKey(key: string): string {
9
- if (!key.includes('~') && !key.includes('/')) {
10
- return key
12
+ // @documentation plumbing
13
+ export function escapeKey(key: string | number): string {
14
+ const segment = String(key)
15
+ if (!segment.includes('~') && !segment.includes('/')) {
16
+ return segment
11
17
  }
12
- return key.replace(/~/g, '~0').replace(/\//g, '~1')
18
+ return segment.replace(/~/g, '~0').replace(/\//g, '~1')
13
19
  }