@abide/abide 0.39.0 → 0.40.1

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 (71) hide show
  1. package/AGENTS.md +318 -146
  2. package/CHANGELOG.md +89 -0
  3. package/README.md +77 -79
  4. package/package.json +1 -1
  5. package/src/lib/server/rpc/parseArgs.ts +10 -1
  6. package/src/lib/server/rpc/runWithVerbTimeout.ts +7 -9
  7. package/src/lib/server/rpc/types/VerbHelper.ts +18 -21
  8. package/src/lib/server/runtime/SSR_SWAP_SCRIPT.ts +8 -7
  9. package/src/lib/server/sockets/createSocketDispatcher.ts +12 -3
  10. package/src/lib/server/sockets/defineSocket.ts +3 -1
  11. package/src/lib/shared/REF_JSON_HEADER.ts +9 -0
  12. package/src/lib/shared/REF_JSON_TAGS.ts +31 -0
  13. package/src/lib/shared/assertExhaustive.ts +14 -0
  14. package/src/lib/shared/buildRpcRequest.ts +8 -1
  15. package/src/lib/shared/decodeRefJson.ts +110 -0
  16. package/src/lib/shared/encodeRefJson.ts +106 -0
  17. package/src/lib/test/createTestSocketChannel.ts +6 -2
  18. package/src/lib/ui/compile/TS_PRINTER.ts +7 -0
  19. package/src/lib/ui/compile/analyzeComponent.ts +15 -16
  20. package/src/lib/ui/compile/assertRuntimeHelpersBound.ts +66 -0
  21. package/src/lib/ui/compile/assertTranspiles.ts +19 -0
  22. package/src/lib/ui/compile/compileModule.ts +56 -34
  23. package/src/lib/ui/compile/compileSSR.ts +1 -3
  24. package/src/lib/ui/compile/compileShadow.ts +14 -7
  25. package/src/lib/ui/compile/desugarSignals.ts +168 -107
  26. package/src/lib/ui/compile/generateBuild.ts +49 -59
  27. package/src/lib/ui/compile/generateSSR.ts +30 -21
  28. package/src/lib/ui/compile/hoistCells.ts +2 -2
  29. package/src/lib/ui/compile/isWhitespaceText.ts +11 -0
  30. package/src/lib/ui/compile/lowerContext.ts +23 -10
  31. package/src/lib/ui/compile/lowerDocAccess.ts +29 -3
  32. package/src/lib/ui/compile/lowerScript.ts +64 -0
  33. package/src/lib/ui/compile/parseTemplate.ts +74 -0
  34. package/src/lib/ui/compile/renameSignalRefs.ts +160 -90
  35. package/src/lib/ui/compile/resolveBranches.ts +21 -0
  36. package/src/lib/ui/compile/stripEffects.ts +22 -17
  37. package/src/lib/ui/compile/types/AnalyzedComponent.ts +5 -0
  38. package/src/lib/ui/compile/types/TemplateNode.ts +12 -2
  39. package/src/lib/ui/createScope.ts +14 -0
  40. package/src/lib/ui/dom/appendText.ts +2 -3
  41. package/src/lib/ui/dom/appendTextAt.ts +2 -3
  42. package/src/lib/ui/dom/applyResolved.ts +5 -8
  43. package/src/lib/ui/dom/awaitBlock.ts +22 -2
  44. package/src/lib/ui/dom/each.ts +4 -0
  45. package/src/lib/ui/dom/on.ts +7 -0
  46. package/src/lib/ui/dom/parseRawNodes.ts +17 -0
  47. package/src/lib/ui/dom/switchBlock.ts +4 -0
  48. package/src/lib/ui/dom/when.ts +4 -0
  49. package/src/lib/ui/installInspectorBridge.ts +3 -1
  50. package/src/lib/ui/navigate.ts +28 -3
  51. package/src/lib/ui/renderToStream.ts +17 -9
  52. package/src/lib/ui/resumeSeedScript.ts +16 -6
  53. package/src/lib/ui/router.ts +108 -15
  54. package/src/lib/ui/runtime/NODE_STATE.ts +22 -0
  55. package/src/lib/ui/runtime/RESUME.ts +13 -6
  56. package/src/lib/ui/runtime/clientPage.ts +114 -9
  57. package/src/lib/ui/runtime/createComputedNode.ts +5 -3
  58. package/src/lib/ui/runtime/createEffectNode.ts +3 -1
  59. package/src/lib/ui/runtime/createSignalNode.ts +4 -2
  60. package/src/lib/ui/runtime/flushEffects.ts +8 -5
  61. package/src/lib/ui/runtime/historyEntries.ts +39 -1
  62. package/src/lib/ui/runtime/localStoragePersistence.ts +14 -8
  63. package/src/lib/ui/runtime/readNode.ts +8 -7
  64. package/src/lib/ui/runtime/runNode.ts +18 -2
  65. package/src/lib/ui/runtime/scope.ts +12 -1
  66. package/src/lib/ui/runtime/trigger.ts +40 -24
  67. package/src/lib/ui/runtime/types/ReactiveNode.ts +4 -1
  68. package/src/lib/ui/runtime/updateIfNecessary.ts +40 -0
  69. package/src/lib/ui/socketChannel.ts +5 -2
  70. package/src/lib/ui/tryEncodeResume.ts +20 -0
  71. package/src/lib/ui/types/Scope.ts +9 -3
@@ -1,6 +1,7 @@
1
1
  import ts from 'typescript'
2
2
  import { REACTIVE_CALLEES } from './REACTIVE_CALLEES.ts'
3
- import { renameSignalRefs } from './renameSignalRefs.ts'
3
+
4
+ const factory = ts.factory
4
5
 
5
6
  /* The reactive primitives that must be reached through a scope. A bare call to one of
6
7
  these is a compile error: reactive state is owned by a scope and the surface must show
@@ -34,28 +35,28 @@ function assertScopedPrimitives(source: ts.SourceFile): void {
34
35
  Desugars the signal surface into the document form. A component's `<script>`
35
36
  declares reactive state as signals:
36
37
 
37
- let count = state(0)
38
- let items = state([])
39
- const total = computed(() => count + items.length)
38
+ let count = scope().state(0)
39
+ let items = scope().state([])
40
+ const total = scope().computed(() => count() + items.length)
40
41
 
41
- This collects the binding names, turns each plain `state(initial)` declaration
42
- into an initialising assignment on a shared `model` document (in source order, so
43
- a later state can read an earlier one), keeps the rest, then renames every
44
- reference through `renameSignalRefs`. Plain `state` becomes `model.x` access that
45
- `lowerDocAccess` lowers to patches/reads the document substrate's deep,
46
- fine-grained, serializable reactivity for free. `linked`, `computed`, and
47
- `state(initial, transform)` stay verbatim as runtime `.value` cells (referenced
48
- as `name.value`): they own no doc slot, so they reseed/recompute on resume rather
49
- than serialize. No reactive declarations the script is returned untouched (the
50
- explicit `const model = doc(...)` form still works).
42
+ This walks the already-parsed script once to collect the binding names, then returns
43
+ a `ts.TransformerFactory` that rewrites the declarations onto a shared `model`
44
+ document: each plain `state(initial)` becomes an initialising assignment (`model.x =
45
+ initial`, in source order so a later state can read an earlier one), each `computed`
46
+ and destructured prop becomes a `scope().derive` slot, and `linked` /
47
+ `state(initial, transform)` stay `.value` cells routed onto the scope. Returning a
48
+ TRANSFORMER (not rebuilt source text) lets the caller (`lowerScript`) chain it with
49
+ reference renaming and doc-access lowering over ONE parsed tree no print-then-reparse
50
+ between passes. Plain `state` becomes `model.x` access that `lowerDocAccess` lowers to
51
+ patches/reads. No reactive declarations an identity transformer (the explicit
52
+ `const model = doc(...)` form still works).
51
53
  */
52
- export function desugarSignals(scriptBody: string): {
53
- code: string
54
+ export function desugarSignals(source: ts.SourceFile): {
55
+ transformer: ts.TransformerFactory<ts.SourceFile>
54
56
  stateNames: Set<string>
55
57
  derivedNames: Set<string>
56
58
  computedNames: Set<string>
57
59
  } {
58
- const source = ts.createSourceFile('script.ts', scriptBody, ts.ScriptTarget.Latest, true)
59
60
  assertScopedPrimitives(source)
60
61
  const stateNames = new Set<string>()
61
62
  const derivedNames = new Set<string>()
@@ -103,43 +104,70 @@ export function desugarSignals(scriptBody: string): {
103
104
  }
104
105
  }
105
106
  }
106
- if (
107
- stateNames.size === 0 &&
108
- derivedNames.size === 0 &&
109
- computedNames.size === 0 &&
110
- !hasPropsDestructure
111
- ) {
112
- return { code: scriptBody, stateNames, derivedNames, computedNames }
113
- }
114
107
 
115
- const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed })
116
- const lines: string[] = []
117
- if (stateNames.size > 0) {
118
- lines.push('const model = scope()')
119
- }
120
- for (const statement of source.statements) {
121
- const stateAssignments = stateDeclarationAssignments(statement, printer, source)
122
- const computedDeclarations = computedDeclarationLines(statement, printer, source)
123
- const propsDestructure = propsDestructureLines(statement, printer, source)
124
- const cellDeclarations = cellDeclarationLines(statement, printer, source)
125
- if (stateAssignments !== undefined) {
126
- lines.push(...stateAssignments)
127
- } else if (computedDeclarations !== undefined) {
128
- lines.push(...computedDeclarations)
129
- } else if (propsDestructure !== undefined) {
130
- lines.push(...propsDestructure)
131
- } else if (cellDeclarations !== undefined) {
132
- lines.push(...cellDeclarations)
133
- } else {
134
- lines.push(printer.printNode(ts.EmitHint.Unspecified, statement, source))
108
+ const hasReactive =
109
+ stateNames.size > 0 ||
110
+ derivedNames.size > 0 ||
111
+ computedNames.size > 0 ||
112
+ hasPropsDestructure
113
+
114
+ const transformer: ts.TransformerFactory<ts.SourceFile> = () => (root) => {
115
+ if (!hasReactive) {
116
+ return root
135
117
  }
118
+ const statements: ts.Statement[] = []
119
+ /* A shared `model = scope()` host for the state slots, prepended once. */
120
+ if (stateNames.size > 0) {
121
+ statements.push(
122
+ constDeclaration(
123
+ 'model',
124
+ factory.createCallExpression(factory.createIdentifier('scope'), undefined, []),
125
+ ),
126
+ )
127
+ }
128
+ for (const statement of root.statements) {
129
+ statements.push(...loweredStatement(statement))
130
+ }
131
+ return factory.updateSourceFile(root, statements)
136
132
  }
137
- return {
138
- code: renameSignalRefs(lines.join('\n'), stateNames, derivedNames, computedNames),
139
- stateNames,
140
- derivedNames,
141
- computedNames,
142
- }
133
+
134
+ return { transformer, stateNames, derivedNames, computedNames }
135
+ }
136
+
137
+ /* The lowered form of a top-level statement: state slots → `model.x = init`
138
+ assignments, computed → `scope().derive` consts, props → derive consts (+ restProps),
139
+ cells → `scope().<callee>(...)` consts; anything else passes through verbatim. The
140
+ per-statement dispatch mirrors the name-collection pass. */
141
+ function loweredStatement(statement: ts.Statement): ts.Statement[] {
142
+ return (
143
+ stateAssignmentStatements(statement) ??
144
+ computedStatements(statement) ??
145
+ propsStatements(statement) ??
146
+ cellStatements(statement) ?? [statement]
147
+ )
148
+ }
149
+
150
+ /* `const NAME = <init>` — a fresh const declaration reusing the original initializer node. */
151
+ function constDeclaration(name: string, initializer: ts.Expression): ts.VariableStatement {
152
+ return factory.createVariableStatement(
153
+ undefined,
154
+ factory.createVariableDeclarationList(
155
+ [factory.createVariableDeclaration(name, undefined, undefined, initializer)],
156
+ ts.NodeFlags.Const,
157
+ ),
158
+ )
159
+ }
160
+
161
+ /* `scope().<method>(...args)` — the reactive method routed onto the ambient scope. */
162
+ function scopeMethodCall(method: string, args: readonly ts.Expression[]): ts.CallExpression {
163
+ return factory.createCallExpression(
164
+ factory.createPropertyAccessExpression(
165
+ factory.createCallExpression(factory.createIdentifier('scope'), undefined, []),
166
+ method,
167
+ ),
168
+ undefined,
169
+ args,
170
+ )
143
171
  }
144
172
 
145
173
  /* True for a read-only computed slot — `computed(compute)` with no write-through
@@ -159,15 +187,11 @@ function isComputedSlot(declaration: ts.VariableDeclaration): boolean {
159
187
  routed onto the scope: `linked(seed)` → `const x = scope().linked(seed)`. The cell
160
188
  stays a standalone non-serializing cell (its refs stay `name.value`); the rewrite only
161
189
  removes the bare runtime import so the sole reactive surface is `scope()`. */
162
- function cellDeclarationLines(
163
- statement: ts.Statement,
164
- printer: ts.Printer,
165
- source: ts.SourceFile,
166
- ): string[] | undefined {
190
+ function cellStatements(statement: ts.Statement): ts.Statement[] | undefined {
167
191
  if (!ts.isVariableStatement(statement)) {
168
192
  return undefined
169
193
  }
170
- const lines: string[] = []
194
+ const statements: ts.Statement[] = []
171
195
  for (const declaration of statement.declarationList.declarations) {
172
196
  const callee = signalCallee(declaration)
173
197
  if (
@@ -178,24 +202,18 @@ function cellDeclarationLines(
178
202
  return undefined
179
203
  }
180
204
  const args = (declaration.initializer as ts.CallExpression).arguments
181
- .map((argument) => printer.printNode(ts.EmitHint.Unspecified, argument, source))
182
- .join(', ')
183
- lines.push(`const ${declaration.name.text} = scope().${callee}(${args})`)
205
+ statements.push(constDeclaration(declaration.name.text, scopeMethodCall(callee, args)))
184
206
  }
185
- return lines
207
+ return statements
186
208
  }
187
209
 
188
210
  /* `let total = computed(compute)` → `const total = scope().derive("total", compute)`
189
211
  — a computed doc slot whose reader the references lower to `total()`. */
190
- function computedDeclarationLines(
191
- statement: ts.Statement,
192
- printer: ts.Printer,
193
- source: ts.SourceFile,
194
- ): string[] | undefined {
212
+ function computedStatements(statement: ts.Statement): ts.Statement[] | undefined {
195
213
  if (!ts.isVariableStatement(statement)) {
196
214
  return undefined
197
215
  }
198
- const lines: string[] = []
216
+ const statements: ts.Statement[] = []
199
217
  for (const declaration of statement.declarationList.declarations) {
200
218
  if (
201
219
  !ts.isIdentifier(declaration.name) ||
@@ -204,16 +222,25 @@ function computedDeclarationLines(
204
222
  ) {
205
223
  return undefined
206
224
  }
207
- const compute = (declaration.initializer as ts.CallExpression).arguments[0]
208
- const computeText =
209
- compute === undefined
210
- ? '() => undefined'
211
- : printer.printNode(ts.EmitHint.Unspecified, compute, source)
212
- lines.push(
213
- `const ${declaration.name.text} = scope().derive(${JSON.stringify(declaration.name.text)}, ${computeText})`,
225
+ const name = declaration.name.text
226
+ const compute =
227
+ (declaration.initializer as ts.CallExpression).arguments[0] ??
228
+ factory.createArrowFunction(
229
+ undefined,
230
+ undefined,
231
+ [],
232
+ undefined,
233
+ factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken),
234
+ factory.createIdentifier('undefined'),
235
+ )
236
+ statements.push(
237
+ constDeclaration(
238
+ name,
239
+ scopeMethodCall('derive', [factory.createStringLiteral(name), compute]),
240
+ ),
214
241
  )
215
242
  }
216
- return lines
243
+ return statements
217
244
  }
218
245
 
219
246
  /* True for `state(initial, transform)` — the write-coercion transform forces a
@@ -308,65 +335,99 @@ function propsBindingKey(element: ts.BindingElement): string {
308
335
 
309
336
  /* If `statement` is a `const {…, ...rest} = props()` destructure, returns one reactive
310
337
  computed per named binding — `scope().derive("name", () => $props["key"]?.() ?? default)`,
311
- read as `name()` — plus a `const rest = restProps($props, [consumed])` line for a rest
312
- binding; otherwise undefined. The `?? default` applies the binding's `= default` fallback
313
- when the prop is absent. */
314
- function propsDestructureLines(
315
- statement: ts.Statement,
316
- printer: ts.Printer,
317
- source: ts.SourceFile,
318
- ): string[] | undefined {
338
+ read as `name()` — plus a `const rest = restProps($props, [consumed])` for a rest
339
+ binding; otherwise undefined. The `?? default` applies the binding's `= default`
340
+ fallback when the prop is absent. */
341
+ function propsStatements(statement: ts.Statement): ts.Statement[] | undefined {
319
342
  if (!ts.isVariableStatement(statement)) {
320
343
  return undefined
321
344
  }
322
- const lines: string[] = []
345
+ const statements: ts.Statement[] = []
323
346
  for (const declaration of statement.declarationList.declarations) {
324
347
  if (signalCallee(declaration) !== 'props' || !ts.isObjectBindingPattern(declaration.name)) {
325
348
  return undefined
326
349
  }
327
350
  const { bindings, rest } = propsDestructure(declaration)
328
351
  for (const { local, key, initializer } of bindings) {
329
- const fallback =
352
+ /* `$props["key"]?.()` — the parent thunk, optionally called. */
353
+ const read = factory.createCallChain(
354
+ factory.createElementAccessExpression(
355
+ factory.createIdentifier('$props'),
356
+ factory.createStringLiteral(key),
357
+ ),
358
+ factory.createToken(ts.SyntaxKind.QuestionDotToken),
359
+ undefined,
360
+ [],
361
+ )
362
+ /* `?? default` only when the binding declared a `= default` fallback. */
363
+ const body =
330
364
  initializer === undefined
331
- ? ''
332
- : ` ?? (${printer.printNode(ts.EmitHint.Unspecified, initializer, source)})`
333
- lines.push(
334
- `const ${local} = scope().derive(${JSON.stringify(local)}, () => $props[${JSON.stringify(key)}]?.()${fallback})`,
365
+ ? read
366
+ : factory.createBinaryExpression(
367
+ read,
368
+ factory.createToken(ts.SyntaxKind.QuestionQuestionToken),
369
+ factory.createParenthesizedExpression(initializer),
370
+ )
371
+ const thunk = factory.createArrowFunction(
372
+ undefined,
373
+ undefined,
374
+ [],
375
+ undefined,
376
+ factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken),
377
+ body,
378
+ )
379
+ statements.push(
380
+ constDeclaration(
381
+ local,
382
+ scopeMethodCall('derive', [factory.createStringLiteral(local), thunk]),
383
+ ),
335
384
  )
336
385
  }
337
386
  /* The rest bag gathers every prop not named above (and not `$children`). */
338
387
  if (rest !== undefined) {
339
- const consumed = bindings.map((binding) => binding.key)
340
- lines.push(`const ${rest} = restProps($props, ${JSON.stringify(consumed)})`)
388
+ const consumed = bindings.map((binding) => factory.createStringLiteral(binding.key))
389
+ statements.push(
390
+ constDeclaration(
391
+ rest,
392
+ factory.createCallExpression(factory.createIdentifier('restProps'), undefined, [
393
+ factory.createIdentifier('$props'),
394
+ factory.createArrayLiteralExpression(consumed),
395
+ ]),
396
+ ),
397
+ )
341
398
  }
342
399
  }
343
- return lines
400
+ return statements
344
401
  }
345
402
 
346
403
  /* If `statement` declares `state(...)` bindings, returns `model.<name> = <init>`
347
- assignment lines (one per declaration); otherwise undefined. */
348
- function stateDeclarationAssignments(
349
- statement: ts.Statement,
350
- printer: ts.Printer,
351
- source: ts.SourceFile,
352
- ): string[] | undefined {
404
+ assignment statements (one per declaration); otherwise undefined. */
405
+ function stateAssignmentStatements(statement: ts.Statement): ts.Statement[] | undefined {
353
406
  if (!ts.isVariableStatement(statement)) {
354
407
  return undefined
355
408
  }
356
- const assignments: string[] = []
409
+ const statements: ts.Statement[] = []
357
410
  for (const declaration of statement.declarationList.declarations) {
358
411
  if (!ts.isIdentifier(declaration.name) || !isPlainStateSlot(declaration)) {
359
412
  /* Only a plain `state(initial)` becomes a slot; `state(initial, transform)`
360
- (and everything else) is a `.value` cell — print it verbatim so the
413
+ (and everything else) is a `.value` cell — pass it through so the
361
414
  runtime call (and its transform) survives. */
362
415
  return undefined
363
416
  }
364
- const initial = (declaration.initializer as ts.CallExpression).arguments[0]
365
- const initialText =
366
- initial === undefined
367
- ? 'undefined'
368
- : printer.printNode(ts.EmitHint.Unspecified, initial, source)
369
- assignments.push(`model.${declaration.name.text} = ${initialText}`)
417
+ const initial =
418
+ (declaration.initializer as ts.CallExpression).arguments[0] ??
419
+ factory.createIdentifier('undefined')
420
+ statements.push(
421
+ factory.createExpressionStatement(
422
+ factory.createAssignment(
423
+ factory.createPropertyAccessExpression(
424
+ factory.createIdentifier('model'),
425
+ declaration.name.text,
426
+ ),
427
+ initial,
428
+ ),
429
+ ),
430
+ )
370
431
  }
371
- return assignments
432
+ return statements
372
433
  }
@@ -1,3 +1,4 @@
1
+ import { assertExhaustive } from '../../shared/assertExhaustive.ts'
1
2
  import { HOLE_ATTRIBUTE } from '../runtime/HOLE_ATTRIBUTE.ts'
2
3
  import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
3
4
  import { asOutlet } from './asOutlet.ts'
@@ -5,7 +6,9 @@ import { bindListenEvent } from './bindListenEvent.ts'
5
6
  import { composeProps } from './composeProps.ts'
6
7
  import { groupBindParts } from './groupBindParts.ts'
7
8
  import { isControlFlow } from './isControlFlow.ts'
9
+ import { isWhitespaceText } from './isWhitespaceText.ts'
8
10
  import { lowerContext } from './lowerContext.ts'
11
+ import { resolveBranches } from './resolveBranches.ts'
9
12
  import { scopeAttr } from './scopeAttr.ts'
10
13
  import { skeletonContext } from './skeletonContext.ts'
11
14
  import { spreadExcludedNames } from './spreadExcludedNames.ts'
@@ -369,7 +372,13 @@ export function generateBuild(
369
372
  if (node.kind === 'snippet') {
370
373
  return generateSnippet(node)
371
374
  }
372
- return generateEach(node, parentVar, before)
375
+ if (node.kind === 'each') {
376
+ return generateEach(node, parentVar, before)
377
+ }
378
+ /* Every TemplateNode kind is handled above; `node` is `never` here. A new kind
379
+ reaching this point is a compiler gap — fail loud instead of silently routing
380
+ it to the wrong branch. */
381
+ return assertExhaustive(node, 'template node kind')
373
382
  }
374
383
 
375
384
  /* Builds a sibling list, coalescing maximal runs of fully-static element subtrees
@@ -477,27 +486,25 @@ export function generateBuild(
477
486
  parentVar: string,
478
487
  before: string,
479
488
  ): string {
480
- const isBranch = (which: 'then' | 'catch' | 'finally') => (child: TemplateNode) =>
481
- child.kind === 'branch' && child.branch === which
482
- const catchBranch = node.children.find(isBranch('catch'))
483
- const finallyChildren = branchChildren(node.children.find(isBranch('finally')))
489
+ const [thenBranch, catchBranch, finallyBranch] = resolveBranches(
490
+ node,
491
+ 'then',
492
+ 'catch',
493
+ 'finally',
494
+ )
495
+ const finallyChildren = finallyBranch?.children ?? []
484
496
  /* Blocking: no pending, the children are the resolved branch bound to `node.as`.
485
497
  Streaming: pending is the non-branch children, resolved is the `then` child. */
486
498
  const pending = node.blocking
487
499
  ? []
488
500
  : node.children.filter((child) => child.kind !== 'branch')
489
- const thenBranch = node.children.find(isBranch('then'))
490
501
  const thenThunk = node.blocking
491
502
  ? branchThunk(
492
503
  node.children.filter((child) => child.kind !== 'branch'),
493
504
  node.as ?? '_value',
494
505
  finallyChildren,
495
506
  )
496
- : branchThunk(
497
- branchChildren(thenBranch),
498
- branchVar(thenBranch) ?? '_value',
499
- finallyChildren,
500
- )
507
+ : branchThunk(thenBranch?.children ?? [], thenBranch?.as ?? '_value', finallyChildren)
501
508
  /* Neither catch nor finally → pass `undefined` so awaitBlock re-throws the
502
509
  rejection (surfacing it) instead of rendering an empty branch. A finally-only
503
510
  block keeps a catch thunk that renders just finally. */
@@ -505,8 +512,8 @@ export function generateBuild(
505
512
  catchBranch === undefined && finallyChildren.length === 0
506
513
  ? 'undefined'
507
514
  : branchThunk(
508
- branchChildren(catchBranch),
509
- branchVar(catchBranch) ?? '_error',
515
+ catchBranch?.children ?? [],
516
+ catchBranch?.as ?? '_error',
510
517
  finallyChildren,
511
518
  )
512
519
  const pendingThunk = hasRenderableContent(pending) ? branchThunk(pending) : 'undefined'
@@ -518,16 +525,6 @@ export function generateBuild(
518
525
  )
519
526
  }
520
527
 
521
- /* Children of a branch node (then/catch), or [] when the branch is absent. */
522
- function branchChildren(branch: TemplateNode | undefined): TemplateNode[] {
523
- return branch !== undefined && branch.kind === 'branch' ? branch.children : []
524
- }
525
-
526
- /* The value/error variable name a branch binds, if any. */
527
- function branchVar(branch: TemplateNode | undefined): string | undefined {
528
- return branch !== undefined && branch.kind === 'branch' ? branch.as : undefined
529
- }
530
-
531
528
  /* A branch's content as a void render thunk `(parent[, value]) => void` that
532
529
  builds its children — and an optional trailing `finally` branch — into
533
530
  `parent`. The full-range model tracks the built content between markers, so a
@@ -566,17 +563,6 @@ export function generateBuild(
566
563
  )
567
564
  }
568
565
 
569
- /* The branch child of a control block matching `which` (then/catch/finally). */
570
- function findBranch(
571
- children: TemplateNode[],
572
- which: 'then' | 'catch' | 'finally',
573
- ): Extract<TemplateNode, { kind: 'branch' }> | undefined {
574
- return children.find(
575
- (child): child is Extract<TemplateNode, { kind: 'branch' }> =>
576
- child.kind === 'branch' && child.branch === which,
577
- )
578
- }
579
-
580
566
  /* A sync error boundary: build the guarded subtree (++ finally); a throw while
581
567
  building swaps to the catch branch (++ finally). No catch → `undefined`, which
582
568
  makes the runtime re-throw to the nearest enclosing boundary. */
@@ -585,18 +571,14 @@ export function generateBuild(
585
571
  parentVar: string,
586
572
  before: string,
587
573
  ): string {
588
- const catchBranch = findBranch(node.children, 'catch')
589
- const finallyChildren = branchChildren(findBranch(node.children, 'finally'))
574
+ const [catchBranch, finallyBranch] = resolveBranches(node, 'catch', 'finally')
575
+ const finallyChildren = finallyBranch?.children ?? []
590
576
  const guarded = node.children.filter((child) => child.kind !== 'branch')
591
577
  const tryThunk = branchThunk(guarded, undefined, finallyChildren)
592
578
  const catchThunk =
593
579
  catchBranch === undefined
594
580
  ? 'undefined'
595
- : branchThunk(
596
- branchChildren(catchBranch),
597
- branchVar(catchBranch) ?? '_error',
598
- finallyChildren,
599
- )
581
+ : branchThunk(catchBranch.children, catchBranch.as ?? '_error', finallyChildren)
600
582
  return `tryBlock(${parentVar}, nextBlockId(), ${tryThunk}, ${catchThunk}, ${before});\n`
601
583
  }
602
584
 
@@ -607,13 +589,33 @@ export function generateBuild(
607
589
  parentVar: string,
608
590
  before: string,
609
591
  ): string {
610
- const elseBranch = node.children.find(
592
+ /* The `case` children are the chain's `elseif`/`else` branches in source order;
593
+ the rest are the `then` content. */
594
+ const branches = node.children.filter(
611
595
  (child): child is Extract<TemplateNode, { kind: 'case' }> => child.kind === 'case',
612
596
  )
613
597
  const thenChildren = node.children.filter((child) => child.kind !== 'case')
614
- const thenThunk = branchThunk(thenChildren)
615
- const elseThunk = elseBranch === undefined ? 'undefined' : branchThunk(elseBranch.children)
616
- return `when(${parentVar}, () => (${lowerExpression(node.condition)}), ${thenThunk}, ${elseThunk}, ${before});\n`
598
+ const hasElseif = branches.some((branch) => branch.condition !== undefined)
599
+ /* Fast path: a plain `if` (with optional `else`) is the binary `when` runtime. */
600
+ if (!hasElseif) {
601
+ const elseBranch = branches.find((branch) => branch.condition === undefined)
602
+ const thenThunk = branchThunk(thenChildren)
603
+ const elseThunk =
604
+ elseBranch === undefined ? 'undefined' : branchThunk(elseBranch.children)
605
+ return `when(${parentVar}, () => (${lowerExpression(node.condition)}), ${thenThunk}, ${elseThunk}, ${before});\n`
606
+ }
607
+ /* if/elseif/else is a cond-chain — reuse `switchBlock` over a constant `true`
608
+ subject with `Boolean`-coerced match thunks, so the first truthy branch wins
609
+ (`else` is the match-less default). */
610
+ const entries = [
611
+ `{ match: () => Boolean(${lowerExpression(node.condition)}), render: ${branchThunk(thenChildren)} }`,
612
+ ...branches.map((branch) =>
613
+ branch.condition !== undefined
614
+ ? `{ match: () => Boolean(${lowerExpression(branch.condition)}), render: ${branchThunk(branch.children)} }`
615
+ : `{ match: undefined, render: ${branchThunk(branch.children)} }`,
616
+ ),
617
+ ]
618
+ return `switchBlock(${parentVar}, () => true, [${entries.join(', ')}], ${before});\n`
617
619
  }
618
620
 
619
621
  /* A keyed each. Each row is a content RANGE (any content, tracked between the
@@ -635,11 +637,9 @@ export function generateBuild(
635
637
  optional `<template catch>` branch rendered (after the streamed rows) when the
636
638
  iterator rejects. Absent → `undefined`, so the rejection surfaces instead. */
637
639
  const fn = node.async ? 'eachAsync' : 'each'
638
- const catchBranch = node.children.find(
639
- (child) => child.kind === 'branch' && child.branch === 'catch',
640
- )
640
+ const [catchBranch] = resolveBranches(node, 'catch')
641
641
  const catchArg = node.async
642
- ? `, ${catchBranch === undefined ? 'undefined' : branchThunk(branchChildren(catchBranch), branchVar(catchBranch) ?? '_error')}`
642
+ ? `, ${catchBranch === undefined ? 'undefined' : branchThunk(catchBranch.children, catchBranch.as ?? '_error')}`
643
643
  : ''
644
644
  return (
645
645
  `${fn}(${parentVar}, () => (${lowerExpression(node.items)}), ` +
@@ -650,16 +650,6 @@ export function generateBuild(
650
650
  return generateChildren(rootNodes, hostVar)
651
651
  }
652
652
 
653
- /* A text node that is purely whitespace (no interpolation, only blank static
654
- parts). Both back-ends drop it, so it neither contributes markup nor breaks a
655
- static clone run — it stays transparent so `<a/>\n<b/>` still coalesces. */
656
- function isWhitespaceText(node: TemplateNode): boolean {
657
- return (
658
- node.kind === 'text' &&
659
- node.parts.every((part) => part.kind === 'static' && part.value.trim() === '')
660
- )
661
- }
662
-
663
653
  /*
664
654
  Whether an element subtree is fully static — no reactive/event/bind attributes,
665
655
  no nested `<script>`, and every descendant likewise static (static text, static