@abide/abide 0.39.0 → 0.40.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 (39) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +47 -0
  3. package/package.json +1 -1
  4. package/src/lib/server/rpc/parseArgs.ts +10 -1
  5. package/src/lib/server/rpc/runWithVerbTimeout.ts +7 -9
  6. package/src/lib/server/rpc/types/VerbHelper.ts +18 -21
  7. package/src/lib/server/runtime/SSR_SWAP_SCRIPT.ts +8 -7
  8. package/src/lib/server/sockets/createSocketDispatcher.ts +12 -3
  9. package/src/lib/server/sockets/defineSocket.ts +3 -1
  10. package/src/lib/shared/REF_JSON_HEADER.ts +9 -0
  11. package/src/lib/shared/REF_JSON_TAGS.ts +31 -0
  12. package/src/lib/shared/buildRpcRequest.ts +8 -1
  13. package/src/lib/shared/decodeRefJson.ts +110 -0
  14. package/src/lib/shared/encodeRefJson.ts +106 -0
  15. package/src/lib/test/createTestSocketChannel.ts +6 -2
  16. package/src/lib/ui/compile/compileShadow.ts +14 -7
  17. package/src/lib/ui/compile/generateBuild.ts +41 -58
  18. package/src/lib/ui/compile/generateSSR.ts +23 -21
  19. package/src/lib/ui/compile/isWhitespaceText.ts +11 -0
  20. package/src/lib/ui/compile/parseTemplate.ts +74 -0
  21. package/src/lib/ui/compile/renameSignalRefs.ts +12 -0
  22. package/src/lib/ui/compile/resolveBranches.ts +21 -0
  23. package/src/lib/ui/compile/types/TemplateNode.ts +12 -2
  24. package/src/lib/ui/createScope.ts +14 -0
  25. package/src/lib/ui/dom/appendText.ts +2 -3
  26. package/src/lib/ui/dom/appendTextAt.ts +2 -3
  27. package/src/lib/ui/dom/applyResolved.ts +5 -8
  28. package/src/lib/ui/dom/awaitBlock.ts +14 -1
  29. package/src/lib/ui/dom/on.ts +7 -0
  30. package/src/lib/ui/dom/parseRawNodes.ts +17 -0
  31. package/src/lib/ui/navigate.ts +28 -3
  32. package/src/lib/ui/renderToStream.ts +17 -9
  33. package/src/lib/ui/resumeSeedScript.ts +16 -6
  34. package/src/lib/ui/router.ts +1 -1
  35. package/src/lib/ui/runtime/RESUME.ts +13 -6
  36. package/src/lib/ui/runtime/localStoragePersistence.ts +14 -8
  37. package/src/lib/ui/socketChannel.ts +5 -2
  38. package/src/lib/ui/tryEncodeResume.ts +20 -0
  39. package/src/lib/ui/types/Scope.ts +9 -3
@@ -5,7 +5,9 @@ import { bindListenEvent } from './bindListenEvent.ts'
5
5
  import { composeProps } from './composeProps.ts'
6
6
  import { groupBindParts } from './groupBindParts.ts'
7
7
  import { isControlFlow } from './isControlFlow.ts'
8
+ import { isWhitespaceText } from './isWhitespaceText.ts'
8
9
  import { lowerContext } from './lowerContext.ts'
10
+ import { resolveBranches } from './resolveBranches.ts'
9
11
  import { scopeAttr } from './scopeAttr.ts'
10
12
  import { skeletonContext } from './skeletonContext.ts'
11
13
  import { spreadExcludedNames } from './spreadExcludedNames.ts'
@@ -477,27 +479,25 @@ export function generateBuild(
477
479
  parentVar: string,
478
480
  before: string,
479
481
  ): 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')))
482
+ const [thenBranch, catchBranch, finallyBranch] = resolveBranches(
483
+ node,
484
+ 'then',
485
+ 'catch',
486
+ 'finally',
487
+ )
488
+ const finallyChildren = finallyBranch?.children ?? []
484
489
  /* Blocking: no pending, the children are the resolved branch bound to `node.as`.
485
490
  Streaming: pending is the non-branch children, resolved is the `then` child. */
486
491
  const pending = node.blocking
487
492
  ? []
488
493
  : node.children.filter((child) => child.kind !== 'branch')
489
- const thenBranch = node.children.find(isBranch('then'))
490
494
  const thenThunk = node.blocking
491
495
  ? branchThunk(
492
496
  node.children.filter((child) => child.kind !== 'branch'),
493
497
  node.as ?? '_value',
494
498
  finallyChildren,
495
499
  )
496
- : branchThunk(
497
- branchChildren(thenBranch),
498
- branchVar(thenBranch) ?? '_value',
499
- finallyChildren,
500
- )
500
+ : branchThunk(thenBranch?.children ?? [], thenBranch?.as ?? '_value', finallyChildren)
501
501
  /* Neither catch nor finally → pass `undefined` so awaitBlock re-throws the
502
502
  rejection (surfacing it) instead of rendering an empty branch. A finally-only
503
503
  block keeps a catch thunk that renders just finally. */
@@ -505,8 +505,8 @@ export function generateBuild(
505
505
  catchBranch === undefined && finallyChildren.length === 0
506
506
  ? 'undefined'
507
507
  : branchThunk(
508
- branchChildren(catchBranch),
509
- branchVar(catchBranch) ?? '_error',
508
+ catchBranch?.children ?? [],
509
+ catchBranch?.as ?? '_error',
510
510
  finallyChildren,
511
511
  )
512
512
  const pendingThunk = hasRenderableContent(pending) ? branchThunk(pending) : 'undefined'
@@ -518,16 +518,6 @@ export function generateBuild(
518
518
  )
519
519
  }
520
520
 
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
521
  /* A branch's content as a void render thunk `(parent[, value]) => void` that
532
522
  builds its children — and an optional trailing `finally` branch — into
533
523
  `parent`. The full-range model tracks the built content between markers, so a
@@ -566,17 +556,6 @@ export function generateBuild(
566
556
  )
567
557
  }
568
558
 
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
559
  /* A sync error boundary: build the guarded subtree (++ finally); a throw while
581
560
  building swaps to the catch branch (++ finally). No catch → `undefined`, which
582
561
  makes the runtime re-throw to the nearest enclosing boundary. */
@@ -585,18 +564,14 @@ export function generateBuild(
585
564
  parentVar: string,
586
565
  before: string,
587
566
  ): string {
588
- const catchBranch = findBranch(node.children, 'catch')
589
- const finallyChildren = branchChildren(findBranch(node.children, 'finally'))
567
+ const [catchBranch, finallyBranch] = resolveBranches(node, 'catch', 'finally')
568
+ const finallyChildren = finallyBranch?.children ?? []
590
569
  const guarded = node.children.filter((child) => child.kind !== 'branch')
591
570
  const tryThunk = branchThunk(guarded, undefined, finallyChildren)
592
571
  const catchThunk =
593
572
  catchBranch === undefined
594
573
  ? 'undefined'
595
- : branchThunk(
596
- branchChildren(catchBranch),
597
- branchVar(catchBranch) ?? '_error',
598
- finallyChildren,
599
- )
574
+ : branchThunk(catchBranch.children, catchBranch.as ?? '_error', finallyChildren)
600
575
  return `tryBlock(${parentVar}, nextBlockId(), ${tryThunk}, ${catchThunk}, ${before});\n`
601
576
  }
602
577
 
@@ -607,13 +582,33 @@ export function generateBuild(
607
582
  parentVar: string,
608
583
  before: string,
609
584
  ): string {
610
- const elseBranch = node.children.find(
585
+ /* The `case` children are the chain's `elseif`/`else` branches in source order;
586
+ the rest are the `then` content. */
587
+ const branches = node.children.filter(
611
588
  (child): child is Extract<TemplateNode, { kind: 'case' }> => child.kind === 'case',
612
589
  )
613
590
  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`
591
+ const hasElseif = branches.some((branch) => branch.condition !== undefined)
592
+ /* Fast path: a plain `if` (with optional `else`) is the binary `when` runtime. */
593
+ if (!hasElseif) {
594
+ const elseBranch = branches.find((branch) => branch.condition === undefined)
595
+ const thenThunk = branchThunk(thenChildren)
596
+ const elseThunk =
597
+ elseBranch === undefined ? 'undefined' : branchThunk(elseBranch.children)
598
+ return `when(${parentVar}, () => (${lowerExpression(node.condition)}), ${thenThunk}, ${elseThunk}, ${before});\n`
599
+ }
600
+ /* if/elseif/else is a cond-chain — reuse `switchBlock` over a constant `true`
601
+ subject with `Boolean`-coerced match thunks, so the first truthy branch wins
602
+ (`else` is the match-less default). */
603
+ const entries = [
604
+ `{ match: () => Boolean(${lowerExpression(node.condition)}), render: ${branchThunk(thenChildren)} }`,
605
+ ...branches.map((branch) =>
606
+ branch.condition !== undefined
607
+ ? `{ match: () => Boolean(${lowerExpression(branch.condition)}), render: ${branchThunk(branch.children)} }`
608
+ : `{ match: undefined, render: ${branchThunk(branch.children)} }`,
609
+ ),
610
+ ]
611
+ return `switchBlock(${parentVar}, () => true, [${entries.join(', ')}], ${before});\n`
617
612
  }
618
613
 
619
614
  /* A keyed each. Each row is a content RANGE (any content, tracked between the
@@ -635,11 +630,9 @@ export function generateBuild(
635
630
  optional `<template catch>` branch rendered (after the streamed rows) when the
636
631
  iterator rejects. Absent → `undefined`, so the rejection surfaces instead. */
637
632
  const fn = node.async ? 'eachAsync' : 'each'
638
- const catchBranch = node.children.find(
639
- (child) => child.kind === 'branch' && child.branch === 'catch',
640
- )
633
+ const [catchBranch] = resolveBranches(node, 'catch')
641
634
  const catchArg = node.async
642
- ? `, ${catchBranch === undefined ? 'undefined' : branchThunk(branchChildren(catchBranch), branchVar(catchBranch) ?? '_error')}`
635
+ ? `, ${catchBranch === undefined ? 'undefined' : branchThunk(catchBranch.children, catchBranch.as ?? '_error')}`
643
636
  : ''
644
637
  return (
645
638
  `${fn}(${parentVar}, () => (${lowerExpression(node.items)}), ` +
@@ -650,16 +643,6 @@ export function generateBuild(
650
643
  return generateChildren(rootNodes, hostVar)
651
644
  }
652
645
 
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
646
  /*
664
647
  Whether an element subtree is fully static — no reactive/event/bind attributes,
665
648
  no nested `<script>`, and every descendant likewise static (static text, static
@@ -5,6 +5,7 @@ import { composeProps } from './composeProps.ts'
5
5
  import { groupBindParts } from './groupBindParts.ts'
6
6
  import { isAnchorPositioned } from './isAnchorPositioned.ts'
7
7
  import { lowerContext } from './lowerContext.ts'
8
+ import { resolveBranches } from './resolveBranches.ts'
8
9
  import { scopeAttr } from './scopeAttr.ts'
9
10
  import { skeletonContext } from './skeletonContext.ts'
10
11
  import { spreadExcludedNames } from './spreadExcludedNames.ts'
@@ -21,17 +22,6 @@ import { VOID_TAGS } from './VOID_TAGS.ts'
21
22
  const RANGE_OPEN = '<!--[-->'
22
23
  const RANGE_CLOSE = '<!--]-->'
23
24
 
24
- /* The `then`/`catch`/`finally` branch child of an await/try block, or undefined. */
25
- function branchNamed(
26
- children: TemplateNode[],
27
- which: 'then' | 'catch' | 'finally',
28
- ): Extract<TemplateNode, { kind: 'branch' }> | undefined {
29
- return children.find(
30
- (child): child is Extract<TemplateNode, { kind: 'branch' }> =>
31
- child.kind === 'branch' && child.branch === which,
32
- )
33
- }
34
-
35
25
  /*
36
26
  Server code generator: turns the parsed template into statements that push HTML
37
27
  fragments onto an output array, reading the document synchronously (no DOM, no
@@ -212,11 +202,19 @@ export function generateSSR(
212
202
  .join('')
213
203
  }
214
204
  if (node.kind === 'if') {
215
- const elseBranch = node.children.find((child) => child.kind === 'case')
205
+ /* `case` children are the `elseif`/`else` branches in source order; the rest are
206
+ the `then` content. Each `elseif` becomes an `else if`, the match-less `else`
207
+ the trailing `else`. */
208
+ const branches = node.children.filter(
209
+ (child): child is Extract<TemplateNode, { kind: 'case' }> => child.kind === 'case',
210
+ )
216
211
  const thenChildren = node.children.filter((child) => child.kind !== 'case')
217
212
  let code = `if (${lowerExpression(node.condition)}) {\n${branchContent(thenChildren, target)}}`
218
- if (elseBranch !== undefined && elseBranch.kind === 'case') {
219
- code += ` else {\n${branchContent(elseBranch.children, target)}}`
213
+ for (const branch of branches) {
214
+ code +=
215
+ branch.condition !== undefined
216
+ ? ` else if (${lowerExpression(branch.condition)}) {\n${branchContent(branch.children, target)}}`
217
+ : ` else {\n${branchContent(branch.children, target)}}`
220
218
  }
221
219
  return `${anchor}${openRange(target)}${code}\n${closeRange(target)}`
222
220
  }
@@ -442,8 +440,8 @@ export function generateSSR(
442
440
  node: Extract<TemplateNode, { kind: 'await' }>,
443
441
  target: string,
444
442
  ): string {
445
- const catchBranch = branchNamed(node.children, 'catch')
446
- const finallyChildren = branchNamed(node.children, 'finally')?.children ?? []
443
+ const [catchBranch, finallyBranch] = resolveBranches(node, 'catch', 'finally')
444
+ const finallyChildren = finallyBranch?.children ?? []
447
445
  const resolvedChildren = node.children.filter((child) => child.kind !== 'branch')
448
446
  const resolvedAs = node.as ?? '_value'
449
447
  const errorAs = catchBranch?.as ?? '_error'
@@ -480,9 +478,13 @@ export function generateSSR(
480
478
  node: Extract<TemplateNode, { kind: 'await' }>,
481
479
  target: string,
482
480
  ): string {
483
- const catchBranch = branchNamed(node.children, 'catch')
484
- const finallyChildren = branchNamed(node.children, 'finally')?.children ?? []
485
- const thenBranch = branchNamed(node.children, 'then')
481
+ const [thenBranch, catchBranch, finallyBranch] = resolveBranches(
482
+ node,
483
+ 'then',
484
+ 'catch',
485
+ 'finally',
486
+ )
487
+ const finallyChildren = finallyBranch?.children ?? []
486
488
  const pending = node.children.filter((child) => child.kind !== 'branch')
487
489
  const id = nextVar('$aid')
488
490
  let code = `const ${id} = $ctx.next++;\n`
@@ -510,8 +512,8 @@ export function generateSSR(
510
512
  an enclosing boundary / the 500 / the stream). Boundary comments let hydration
511
513
  discard the server content if the client adoption fails. */
512
514
  function generateTry(node: Extract<TemplateNode, { kind: 'try' }>, target: string): string {
513
- const catchBranch = branchNamed(node.children, 'catch')
514
- const finallyChildren = branchNamed(node.children, 'finally')?.children ?? []
515
+ const [catchBranch, finallyBranch] = resolveBranches(node, 'catch', 'finally')
516
+ const finallyChildren = finallyBranch?.children ?? []
515
517
  const guarded = node.children.filter((child) => child.kind !== 'branch')
516
518
  const errName = catchBranch?.as ?? '_error'
517
519
  const id = nextVar('$tid')
@@ -0,0 +1,11 @@
1
+ import type { TemplateNode } from './types/TemplateNode.ts'
2
+
3
+ /* A text node that is purely whitespace (no interpolation, only blank static
4
+ parts). Both back-ends drop it, so it neither contributes markup nor breaks a
5
+ static clone run — it stays transparent so `<a/>\n<b/>` still coalesces. */
6
+ export function isWhitespaceText(node: TemplateNode): boolean {
7
+ return (
8
+ node.kind === 'text' &&
9
+ node.parts.every((part) => part.kind === 'static' && part.value.trim() === '')
10
+ )
11
+ }
@@ -1,4 +1,5 @@
1
1
  import { decodeHtmlEntities } from './decodeHtmlEntities.ts'
2
+ import { isWhitespaceText } from './isWhitespaceText.ts'
2
3
  import type { TemplateAttr } from './types/TemplateAttr.ts'
3
4
  import type { TemplateNode } from './types/TemplateNode.ts'
4
5
  import type { TextPart } from './types/TextPart.ts'
@@ -303,6 +304,13 @@ function rejectStrayBranches(
303
304
  '[abide] <template else>/<template case> must be nested inside its <template if>/<template switch> — a sibling branch is not supported',
304
305
  )
305
306
  }
307
+ /* `elseif` is an `if`-chain branch; inside a `switch` it would silently read as the
308
+ default (match-less), so reject the cross-construct mix. */
309
+ if (node.kind === 'case' && node.condition !== undefined && parentKind === 'switch') {
310
+ throw new Error(
311
+ '[abide] <template elseif> belongs to a <template if> chain, not a <template switch> — use <template case>',
312
+ )
313
+ }
306
314
  if (
307
315
  node.kind === 'branch' &&
308
316
  parentKind !== 'await' &&
@@ -313,12 +321,62 @@ function rejectStrayBranches(
313
321
  '[abide] <template then>/<template catch>/<template finally> must be nested inside its <template await>/<template try>/<template each await>',
314
322
  )
315
323
  }
324
+ if (node.kind === 'if' || node.kind === 'switch') {
325
+ rejectMisplacedBranchContent(node)
326
+ }
316
327
  if ('children' in node) {
317
328
  rejectStrayBranches(node.children, node.kind)
318
329
  }
319
330
  }
320
331
  }
321
332
 
333
+ /* A `<template if>` chain's `then` content precedes its first branch tag
334
+ (`elseif`/`else`); a `<template switch>` renders only its branch tags. So rendered
335
+ content sitting AFTER the first branch in an `if` — or ANYWHERE in a `switch` —
336
+ belongs to no branch: today it silently folds into `then` / is dropped. Reject it so
337
+ the misplacement surfaces. Whitespace stays transparent, and `<script>`/`<style>` are
338
+ directives (scoping, not rendered output), so both remain legal anywhere. */
339
+ function rejectMisplacedBranchContent(
340
+ node: Extract<TemplateNode, { kind: 'if' | 'switch' }>,
341
+ ): void {
342
+ const firstBranch = node.children.findIndex((child) => child.kind === 'case')
343
+ node.children.forEach((child, index) => {
344
+ const isRenderedContent =
345
+ child.kind !== 'case' &&
346
+ child.kind !== 'script' &&
347
+ child.kind !== 'style' &&
348
+ !isWhitespaceText(child)
349
+ /* In a switch nothing but branches renders, so every position is illegal; in an if
350
+ only content past the first branch is (the leading then-content is legal). */
351
+ const illegalPosition =
352
+ node.kind === 'switch' || (firstBranch !== -1 && index > firstBranch)
353
+ if (isRenderedContent && illegalPosition) {
354
+ throw new Error(
355
+ node.kind === 'switch'
356
+ ? '[abide] a <template switch> renders only its <template case>/<template default> branches — move stray content into a branch'
357
+ : '[abide] content after a <template elseif>/<template else> belongs to no branch — the then-content must precede the first branch tag',
358
+ )
359
+ }
360
+ })
361
+ /* In an `if` chain the match-less `<template else>` is the trailing block, so any
362
+ branch after it (a second `else`, or an `elseif`) compiles to invalid `} else {…}
363
+ else if {…}` (SSR/type-shadow) or a silently-shadowed branch (the `switchBlock`
364
+ default wins). Reject so the misordering surfaces here, not as opaque codegen. */
365
+ if (node.kind === 'if') {
366
+ const branches = node.children.filter(
367
+ (child): child is Extract<TemplateNode, { kind: 'case' }> => child.kind === 'case',
368
+ )
369
+ const elseIndex = branches.findIndex(
370
+ (branch) => branch.match === undefined && branch.condition === undefined,
371
+ )
372
+ if (elseIndex !== -1 && elseIndex < branches.length - 1) {
373
+ throw new Error(
374
+ '[abide] <template else> must be the last branch of its <template if> chain — no <template elseif>/<template else> may follow it',
375
+ )
376
+ }
377
+ }
378
+ }
379
+
322
380
  /* Turns a component's attributes into props. A component has no directives —
323
381
  every attribute is a prop under its written name, so `on*`/`bind:`/`attach`
324
382
  round-trip to their original names (the kinds the tag-blind attribute parser
@@ -468,6 +526,22 @@ function toControlFlow(attrs: TemplateAttr[], children: TemplateNode[]): Templat
468
526
  }
469
527
  return { kind: 'case', match: matchCode, children, loc: attrLoc(caseAttr) }
470
528
  }
529
+ /* `<template elseif={c}>` is a match-less case carrying a condition — a branch of the
530
+ enclosing `<template if>` chain, truthy-tested in source order. */
531
+ const elseif = find('elseif')
532
+ if (elseif !== undefined) {
533
+ const conditionCode = attrText(elseif)
534
+ if (conditionCode === undefined) {
535
+ throw new Error('[abide] <template elseif> requires a condition expression')
536
+ }
537
+ return {
538
+ kind: 'case',
539
+ match: undefined,
540
+ condition: conditionCode,
541
+ children,
542
+ loc: attrLoc(elseif),
543
+ }
544
+ }
471
545
  if (find('default') !== undefined || find('else') !== undefined) {
472
546
  return { kind: 'case', match: undefined, children } // default (switch) / else (if)
473
547
  }
@@ -68,6 +68,18 @@ export function renameSignalRefs(
68
68
  with that name added, so shadowing is per-branch (a sibling scope is unaffected). */
69
69
  const makeVisitor = (shadowed: ReadonlySet<string>): ts.Visitor => {
70
70
  const visit = (node: ts.Node): ts.Node => {
71
+ /* Type space never holds a value read. A type alias, an interface, or any
72
+ type annotation can name a signal — a prop-type member `option?: …`, a
73
+ `typeof x` — without it being a runtime reference. Leave the whole type
74
+ subtree untouched so it isn't rewritten into a call/access (`option()`)
75
+ and emitted as broken code (types erase at build anyway). */
76
+ if (
77
+ ts.isTypeAliasDeclaration(node) ||
78
+ ts.isInterfaceDeclaration(node) ||
79
+ ts.isTypeNode(node)
80
+ ) {
81
+ return node
82
+ }
71
83
  /* Shorthand `{ count }` → `{ count: model.count }` / `{ total: total.value }`,
72
84
  unless a nearer scope shadows the name. */
73
85
  if (ts.isShorthandPropertyAssignment(node) && !shadowed.has(node.name.text)) {
@@ -0,0 +1,21 @@
1
+ import type { TemplateNode } from './types/TemplateNode.ts'
2
+
3
+ type Branch = Extract<TemplateNode, { kind: 'branch' }>
4
+ type BranchName = Branch['branch']
5
+
6
+ /* Extracts the `then`/`catch`/`finally` branch children of a control-flow block
7
+ (await/try), one slot per requested name in the order asked, so callers
8
+ destructure them directly. A requested branch that is absent yields `undefined`
9
+ — the caller reads `?.children`/`?.as` for its content and bound var. This is
10
+ the one branch-lookup site both back-ends share, replacing the per-generator
11
+ `findBranch`/`branchNamed`/`branchChildren`/`branchVar` copies. */
12
+ export function resolveBranches(
13
+ node: Extract<TemplateNode, { children: TemplateNode[] }>,
14
+ ...which: BranchName[]
15
+ ): (Branch | undefined)[] {
16
+ return which.map((name) =>
17
+ node.children.find(
18
+ (child): child is Branch => child.kind === 'branch' && child.branch === name,
19
+ ),
20
+ )
21
+ }
@@ -4,7 +4,7 @@ import type { TextPart } from './TextPart.ts'
4
4
  /*
5
5
  A parsed template node. `text` carries interpolation parts; `element` carries
6
6
  attributes and children; `each` is the `<template each as key>` control flow over
7
- a list. (if/await/switch are future siblings.)
7
+ a list; `if`/`await`/`switch`/`try` are its control-flow siblings.
8
8
 
9
9
  `loc` (where present) is the absolute offset of the node's primary expression in
10
10
  the original `.abide` source — additive, set only when the parser tracks
@@ -67,7 +67,17 @@ export type TemplateNode =
67
67
  children: TemplateNode[]
68
68
  }
69
69
  | { kind: 'switch'; subject: string; children: TemplateNode[]; loc?: number }
70
- | { kind: 'case'; match: string | undefined; children: TemplateNode[]; loc?: number }
70
+ /* A branch of a `<template switch>` (`match` set) or `<template if>` chain. Inside an
71
+ `if`, `<template elseif={c}>` sets `condition` (match-less, truthy-tested), and
72
+ `<template else>` leaves both unset (the default). `loc` points at whichever
73
+ expression the node carries (`match` or `condition`). */
74
+ | {
75
+ kind: 'case'
76
+ match: string | undefined
77
+ condition?: string
78
+ children: TemplateNode[]
79
+ loc?: number
80
+ }
71
81
  /* A `<template name="row" args={item}>` snippet: a named, scope-capturing
72
82
  builder declared once and called like a function (`{row(item)}`). `params`
73
83
  is the raw `args` source spliced into the builder's parameter list. */
@@ -37,6 +37,9 @@ export function createScope(
37
37
  const data = (): Doc => (document ??= createDoc({}))
38
38
  const id = parent === undefined ? `scope-${nextId++}` : `${parent.id}.${nextId++}`
39
39
  const children: Scope[] = []
40
+ /* Context values shared down the tree, held apart from the reactive doc (which
41
+ a child does not inherit): keyed by name, read by the closest ancestor walk. */
42
+ const shared = new Map<string, unknown>()
40
43
  let past: History | undefined
41
44
  let persistence: PersistHandle | undefined
42
45
  let unsync: (() => void) | undefined
@@ -64,6 +67,16 @@ export function createScope(
64
67
  return created
65
68
  },
66
69
  root: () => (parent === undefined ? self : parent.root()),
70
+ /* Reference store — no tracking, so a lookup never subscribes; reactivity comes
71
+ from what is shared (a cell/scope), not from the share. */
72
+ share: (key, value) => {
73
+ shared.set(key, value)
74
+ },
75
+ /* Closest-ancestor resolve: own map first (self can read what it shared), then
76
+ defer up via each scope's own `shared`. `has` distinguishes a shared
77
+ `undefined` from "not provided"; undefined at the root means no provider. */
78
+ shared: <T>(key: string): T | undefined =>
79
+ shared.has(key) ? (shared.get(key) as T) : parent?.shared<T>(key),
67
80
  record: (options) => {
68
81
  past ??= history(data(), options)
69
82
  },
@@ -82,6 +95,7 @@ export function createScope(
82
95
  created.dispose()
83
96
  }
84
97
  children.length = 0
98
+ shared.clear()
85
99
  past?.dispose()
86
100
  past = undefined
87
101
  persistence?.dispose()
@@ -4,6 +4,7 @@ import { effect } from '../effect.ts'
4
4
  import { claimChild } from '../runtime/claimChild.ts'
5
5
  import { RENDER } from '../runtime/RENDER.ts'
6
6
  import { appendSnippet } from './appendSnippet.ts'
7
+ import { parseRawNodes } from './parseRawNodes.ts'
7
8
 
8
9
  const CLOSE = '/abide:html'
9
10
 
@@ -73,9 +74,7 @@ function appendRawHtml(parent: Node, read: () => unknown): void {
73
74
  for (const node of nodes) {
74
75
  parent.removeChild(node)
75
76
  }
76
- const holder = document.createElement('div')
77
- holder.innerHTML = value
78
- nodes = [...holder.childNodes]
77
+ nodes = parseRawNodes(parent, value)
79
78
  for (const node of nodes) {
80
79
  parent.insertBefore(node, anchor)
81
80
  }
@@ -4,6 +4,7 @@ import { effect } from '../effect.ts'
4
4
  import { RENDER } from '../runtime/RENDER.ts'
5
5
  import { appendSnippet } from './appendSnippet.ts'
6
6
  import { appendText } from './appendText.ts'
7
+ import { parseRawNodes } from './parseRawNodes.ts'
7
8
 
8
9
  /*
9
10
  A reactive `{expr}` interpolation mounted at a skeleton anchor comment (`<!--a-->`), used
@@ -51,9 +52,7 @@ export function appendTextAt(anchor: Node, read: () => unknown): void {
51
52
  for (const node of nodes) {
52
53
  parent.removeChild(node)
53
54
  }
54
- const holder = document.createElement('div')
55
- holder.innerHTML = rawHtmlString(read()) ?? ''
56
- nodes = [...holder.childNodes]
55
+ nodes = parseRawNodes(parent, rawHtmlString(read()) ?? '')
57
56
  /* Insert the fresh markup just after the anchor (its live re-insertion point). */
58
57
  const after = anchor.nextSibling
59
58
  for (const node of nodes) {
@@ -41,16 +41,13 @@ export function applyResolved(root: Element, frame: string): void {
41
41
  if (id === null) {
42
42
  return
43
43
  }
44
- /* The resolved value rides in a leading <script type=application/json>; parse and
45
- remove it so only the resolved markup moves into the boundary. Recording it lets a
46
- later hydrate adopt this branch (no re-fetch). */
44
+ /* The resolved value rides in a leading <script type=application/json> as ref-json
45
+ text; store it raw and remove the node so only the resolved markup moves into the
46
+ boundary. Decoding is deferred to the read in `awaitBlock` (which has the codec);
47
+ recording it lets a later hydrate adopt this branch (no re-fetch). */
47
48
  const payload = resolved.firstChild as Element | null
48
49
  if (payload !== null && payload.nodeName === 'SCRIPT') {
49
- try {
50
- RESUME[Number(id)] = JSON.parse(payload.textContent ?? 'null')
51
- } catch {
52
- /* malformed payload — leave unregistered, hydration re-runs the promise */
53
- }
50
+ RESUME[Number(id)] = payload.textContent ?? ''
54
51
  payload.remove()
55
52
  }
56
53
  const open = `abide:await:${id}`
@@ -1,6 +1,8 @@
1
+ import { decodeRefJson } from '../../shared/decodeRefJson.ts'
1
2
  import { effect } from '../effect.ts'
2
3
  import { claimChild } from '../runtime/claimChild.ts'
3
4
  import { RENDER } from '../runtime/RENDER.ts'
5
+ import type { ResumeEntry } from '../runtime/RESUME.ts'
4
6
  import { RESUME } from '../runtime/RESUME.ts'
5
7
  import { scope } from '../runtime/scope.ts'
6
8
  import { scopeGroup } from '../runtime/scopeGroup.ts'
@@ -161,7 +163,18 @@ export function awaitBlock(
161
163
  const firstHydrate = (result: unknown): void => {
162
164
  const cursor = hydration as NonNullable<typeof hydration>
163
165
  const open = claimChild(cursor, parent)
164
- const entry = RESUME[id]
166
+ /* RESUME holds the ref-json-encoded entry STRING; decode here, where the codec
167
+ lives. A decode failure (malformed/absent payload) reads as "no resume" — fall
168
+ through to the live promise rather than crash hydration. */
169
+ const raw = RESUME[id]
170
+ let entry: ResumeEntry | undefined
171
+ if (raw !== undefined) {
172
+ try {
173
+ entry = decodeRefJson(raw) as ResumeEntry
174
+ } catch {
175
+ entry = undefined
176
+ }
177
+ }
165
178
  if (entry !== undefined) {
166
179
  let build: (host: Node) => void
167
180
  if (entry.ok) {
@@ -12,6 +12,13 @@ resolves the component, not whatever is current when the event fires.
12
12
  */
13
13
  // @documentation plumbing
14
14
  export function on(element: Element, type: string, handler: EventListener): void {
15
+ /* A caller that omits an optional `on*` prop forwards it as undefined, yet the
16
+ compiler still emits this on() call — so skip attaching a non-function rather
17
+ than invoke undefined when the event fires (e.g. input/keydown on a search box,
18
+ which would throw "handler is not a function" on every keystroke). */
19
+ if (typeof handler !== 'function') {
20
+ return
21
+ }
15
22
  const captured = CURRENT_SCOPE.current
16
23
  const wrapped: EventListener = (event) => inScope(captured, () => handler(event))
17
24
  element.addEventListener(type, wrapped)
@@ -0,0 +1,17 @@
1
+ import { foreignWrapperTag } from './foreignWrapperTag.ts'
2
+
3
+ /*
4
+ Parses a trusted raw-markup string into detached nodes in the SAME foreign namespace
5
+ as `parent`. The HTML fragment parser namespaces by its context element, so markup
6
+ bound inside an <svg>/<math> must parse inside a matching wrapper — otherwise a bare
7
+ `<path>` lands in the HTML namespace and never renders. Mirrors `cloneStatic`'s
8
+ foreign handling, but parses per-call into a throwaway `<template>` rather than the
9
+ skeleton cache: raw values are dynamic, so caching every distinct string would leak.
10
+ */
11
+ export function parseRawNodes(parent: Node, markup: string): Node[] {
12
+ const wrapper = foreignWrapperTag(parent)
13
+ const template = document.createElement('template')
14
+ template.innerHTML = wrapper === undefined ? markup : `<${wrapper}>${markup}</${wrapper}>`
15
+ const source = wrapper === undefined ? template.content : (template.content.firstChild as Node)
16
+ return [...source.childNodes]
17
+ }