@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,4 +1,5 @@
1
1
  import ts from 'typescript'
2
+ import { TS_PRINTER } from './TS_PRINTER.ts'
2
3
 
3
4
  /*
4
5
  Rewrites references to a component's signal bindings into the document form the
@@ -24,92 +25,177 @@ export function renameSignalRefs(
24
25
  computedNames: ReadonlySet<string> = new Set(),
25
26
  ): string {
26
27
  const source = ts.createSourceFile('component.ts', code, ts.ScriptTarget.Latest, true)
28
+ const result = ts.transform(source, [
29
+ signalRefsTransformer(stateNames, derivedNames, computedNames),
30
+ ])
31
+ const output = TS_PRINTER.printFile(result.transformed[0] as ts.SourceFile)
32
+ result.dispose()
33
+ return output
34
+ }
27
35
 
36
+ /*
37
+ The signal-rewrite as a `ts.TransformerFactory`, so the script pipeline can chain it
38
+ with `docAccessTransformer` over a SINGLE parsed tree (see `lowerScript`) instead of
39
+ print-then-reparse between passes. `renameSignalRefs` is the standalone string wrapper
40
+ kept for per-expression callers (`lowerContext`).
41
+ */
42
+ export function signalRefsTransformer(
43
+ stateNames: ReadonlySet<string>,
44
+ derivedNames: ReadonlySet<string>,
45
+ computedNames: ReadonlySet<string> = new Set(),
46
+ ): ts.TransformerFactory<ts.SourceFile> {
28
47
  /* The signal names that a nested binding can shadow — only these matter for
29
48
  scope tracking, so we ignore every other local binding. */
30
49
  const signalNames = new Set<string>([...stateNames, ...derivedNames, ...computedNames])
31
-
32
- /* Identifier nodes that are names, not value reads — never rewritten. */
33
- const skip = new Set<ts.Node>()
34
- const collect = (node: ts.Node): void => {
35
- if (ts.isPropertyAccessExpression(node)) {
36
- skip.add(node.name)
37
- }
38
- /* A declaration name is a binding, not a value read. A plain identifier is
39
- skipped directly; a destructuring pattern's leaf names are collected so a
40
- bound name shadowing a signal (`const { count } = …`) is never rewritten. */
41
- if (ts.isVariableDeclaration(node)) {
42
- if (ts.isIdentifier(node.name)) {
43
- skip.add(node.name)
44
- } else {
45
- collectBindingIdentifiers(node.name, skip)
50
+ return (context) => (root) => {
51
+ /* The identifier nodes that are names, not value reads — collected by walking
52
+ each parent and recording its name children (`parent.name`, a label, a
53
+ specifier). Read structurally, NOT via `child.parent`, so it holds on
54
+ synthesized nodes too — `desugar`'s rewritten declarations carry no parent
55
+ pointers, and chaining this transformer after `desugarTransformer` over one
56
+ tree must still classify their refs correctly. */
57
+ const nameSlots = collectNameSlots(root)
58
+ /* Each visitor carries the set of signal names shadowed by the scopes it sits
59
+ inside; entering a scope that re-binds a signal name produces a fresh visitor
60
+ with that name added, so shadowing is per-branch (a sibling scope is unaffected). */
61
+ const makeVisitor = (shadowed: ReadonlySet<string>): ts.Visitor => {
62
+ const visit = (node: ts.Node): ts.Node => {
63
+ /* Type space never holds a value read. A type alias, an interface, or any
64
+ type annotation can name a signal — a prop-type member `option?: …`, a
65
+ `typeof x` — without it being a runtime reference. Leave the whole type
66
+ subtree untouched so it isn't rewritten into a call/access (`option()`)
67
+ and emitted as broken code (types erase at build anyway). */
68
+ if (
69
+ ts.isTypeAliasDeclaration(node) ||
70
+ ts.isInterfaceDeclaration(node) ||
71
+ ts.isTypeNode(node)
72
+ ) {
73
+ return node
74
+ }
75
+ /* Import/export specifiers are binding/module names, never value reads.
76
+ An aliased import whose original name collides with a signal
77
+ (`import { pending as p }` next to a `pending` prop) would otherwise
78
+ have its `pending` specifier rewritten to the reader form, corrupting
79
+ the declaration. Leave the whole subtree untouched. */
80
+ if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
81
+ return node
82
+ }
83
+ /* Shorthand `{ count }` → `{ count: model.count }` / `{ total: total.value }`,
84
+ unless a nearer scope shadows the name. */
85
+ if (ts.isShorthandPropertyAssignment(node) && !shadowed.has(node.name.text)) {
86
+ const replacement = referenceFor(
87
+ node.name.text,
88
+ stateNames,
89
+ derivedNames,
90
+ computedNames,
91
+ )
92
+ if (replacement !== undefined) {
93
+ return ts.factory.createPropertyAssignment(node.name.text, replacement)
94
+ }
95
+ }
96
+ if (ts.isIdentifier(node) && !nameSlots.has(node) && !shadowed.has(node.text)) {
97
+ const replacement = referenceFor(
98
+ node.text,
99
+ stateNames,
100
+ derivedNames,
101
+ computedNames,
102
+ )
103
+ if (replacement !== undefined) {
104
+ return replacement
105
+ }
106
+ }
107
+ /* Recurse with the scope this node introduces folded in (same visitor when
108
+ it adds no shadowing name, so unscoped subtrees allocate nothing extra). */
109
+ const inner = extendShadowed(node, shadowed, signalNames)
110
+ return ts.visitEachChild(
111
+ node,
112
+ inner === shadowed ? visit : makeVisitor(inner),
113
+ context,
114
+ )
46
115
  }
116
+ return visit
47
117
  }
48
- if (ts.isParameter(node) && ts.isIdentifier(node.name)) {
49
- skip.add(node.name)
118
+ return ts.visitNode(root, makeVisitor(new Set())) as ts.SourceFile
119
+ }
120
+ }
121
+
122
+ /*
123
+ Collects every identifier NODE that occupies a name/label/specifier slot — a position
124
+ that DECLARES, NAMES, or LABELS rather than READS. Walks each PARENT and records its
125
+ name children (`parent.name`, a label, a specifier), so it reads structure top-down
126
+ and never touches `child.parent` — it therefore holds on synthesized nodes (e.g.
127
+ `desugar`'s rewritten declarations), which carry no parent pointers. The visitor then
128
+ rewrites any identifier NOT in this set (rewrite is the default — value reads appear
129
+ under almost any expression parent and can't be allow-listed), so this set is the
130
+ exhaustive list of non-read slots. It is positional, so `pending` in
131
+ `import { pending as p }` and `pending` in `pending(query)` are told apart by where
132
+ they sit. A non-read slot missing here would be misread as a value read and rewritten
133
+ into broken syntax, so completeness is NOT self-evident — it is guarded empirically by
134
+ the syntax fuzz corpus (`uiCompileSyntaxFuzz.test.ts`), which transpiles each pass's
135
+ output and fails on any corruption.
136
+ */
137
+ function collectNameSlots(root: ts.Node): Set<ts.Node> {
138
+ const slots = new Set<ts.Node>()
139
+ const add = (name: ts.Node | undefined): void => {
140
+ if (name !== undefined && ts.isIdentifier(name)) {
141
+ slots.add(name)
50
142
  }
51
- if (ts.isPropertyAssignment(node) && ts.isIdentifier(node.name)) {
52
- skip.add(node.name)
143
+ }
144
+ const visit = (node: ts.Node): void => {
145
+ /* `obj.NAME` — the member name; the object side (`.expression`) is the read. */
146
+ if (ts.isPropertyAccessExpression(node)) {
147
+ add(node.name)
53
148
  }
149
+ /* `NAME: value`, and method/property/accessor/enum-member names in classes,
150
+ object literals, and type members. */
54
151
  if (
55
- (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node)) &&
56
- node.name !== undefined
152
+ ts.isPropertyAssignment(node) ||
153
+ ts.isMethodDeclaration(node) ||
154
+ ts.isPropertyDeclaration(node) ||
155
+ ts.isGetAccessorDeclaration(node) ||
156
+ ts.isSetAccessorDeclaration(node) ||
157
+ ts.isMethodSignature(node) ||
158
+ ts.isPropertySignature(node) ||
159
+ ts.isEnumMember(node)
160
+ ) {
161
+ add(node.name)
162
+ }
163
+ /* Declaration names: const/let/var, parameters, named functions and classes. */
164
+ if (
165
+ ts.isVariableDeclaration(node) ||
166
+ ts.isParameter(node) ||
167
+ ts.isFunctionDeclaration(node) ||
168
+ ts.isFunctionExpression(node) ||
169
+ ts.isClassDeclaration(node) ||
170
+ ts.isClassExpression(node)
57
171
  ) {
58
- skip.add(node.name)
172
+ add(node.name)
173
+ }
174
+ /* A destructure element binds (`{ a }` / `[a]`) or renames (`{ a: b }`): both
175
+ the bound name and the source key are names, not reads. */
176
+ if (ts.isBindingElement(node)) {
177
+ add(node.name)
178
+ add(node.propertyName)
179
+ }
180
+ /* Labels: `NAME:` and `break NAME` / `continue NAME`. */
181
+ if (ts.isLabeledStatement(node)) {
182
+ add(node.label)
183
+ }
184
+ if (ts.isBreakStatement(node) || ts.isContinueStatement(node)) {
185
+ add(node.label)
59
186
  }
60
- ts.forEachChild(node, collect)
187
+ /* Import/export specifier and clause names. */
188
+ if (ts.isImportSpecifier(node) || ts.isExportSpecifier(node)) {
189
+ add(node.name)
190
+ add(node.propertyName)
191
+ }
192
+ if (ts.isImportClause(node) || ts.isNamespaceImport(node) || ts.isNamespaceExport(node)) {
193
+ add(node.name)
194
+ }
195
+ ts.forEachChild(node, visit)
61
196
  }
62
- collect(source)
63
-
64
- const result = ts.transform(source, [
65
- (context) => (root) => {
66
- /* Each visitor carries the set of signal names shadowed by the scopes it sits
67
- inside; entering a scope that re-binds a signal name produces a fresh visitor
68
- with that name added, so shadowing is per-branch (a sibling scope is unaffected). */
69
- const makeVisitor = (shadowed: ReadonlySet<string>): ts.Visitor => {
70
- const visit = (node: ts.Node): ts.Node => {
71
- /* Shorthand `{ count }` → `{ count: model.count }` / `{ total: total.value }`,
72
- unless a nearer scope shadows the name. */
73
- if (ts.isShorthandPropertyAssignment(node) && !shadowed.has(node.name.text)) {
74
- const replacement = referenceFor(
75
- node.name.text,
76
- stateNames,
77
- derivedNames,
78
- computedNames,
79
- )
80
- if (replacement !== undefined) {
81
- return ts.factory.createPropertyAssignment(node.name.text, replacement)
82
- }
83
- }
84
- if (ts.isIdentifier(node) && !skip.has(node) && !shadowed.has(node.text)) {
85
- const replacement = referenceFor(
86
- node.text,
87
- stateNames,
88
- derivedNames,
89
- computedNames,
90
- )
91
- if (replacement !== undefined) {
92
- return replacement
93
- }
94
- }
95
- /* Recurse with the scope this node introduces folded in (same visitor when
96
- it adds no shadowing name, so unscoped subtrees allocate nothing extra). */
97
- const inner = extendShadowed(node, shadowed, signalNames)
98
- return ts.visitEachChild(
99
- node,
100
- inner === shadowed ? visit : makeVisitor(inner),
101
- context,
102
- )
103
- }
104
- return visit
105
- }
106
- return ts.visitNode(root, makeVisitor(new Set())) as ts.SourceFile
107
- },
108
- ])
109
- const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed })
110
- const output = printer.printFile(result.transformed[0] as ts.SourceFile)
111
- result.dispose()
112
- return output
197
+ visit(root)
198
+ return slots
113
199
  }
114
200
 
115
201
  /* The shadowed-name set for `node`'s children: the parent set plus any signal name
@@ -187,22 +273,6 @@ function collectStatementBindings(statement: ts.Statement, into: Set<string>): v
187
273
  }
188
274
  }
189
275
 
190
- /* The identifier NODES a binding name binds — a plain identifier or the leaves of a
191
- destructuring pattern (object/array, including nested patterns and rest elements).
192
- For `{ a: b }` only the bound name `b` is a binding; `a` is the source property.
193
- Feeds the skip set so a destructured name shadowing a signal is never rewritten. */
194
- function collectBindingIdentifiers(name: ts.BindingName, into: Set<ts.Node>): void {
195
- if (ts.isIdentifier(name)) {
196
- into.add(name)
197
- return
198
- }
199
- for (const element of name.elements) {
200
- if (ts.isBindingElement(element)) {
201
- collectBindingIdentifiers(element.name, into)
202
- }
203
- }
204
- }
205
-
206
276
  /* Every identifier bound by a binding name — a plain identifier or the leaves of a
207
277
  destructuring pattern (object/array, including rest elements). */
208
278
  function collectBindingNames(name: ts.BindingName, into: Set<string>): void {
@@ -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
+ }
@@ -1,4 +1,5 @@
1
1
  import ts from 'typescript'
2
+ import { TS_PRINTER } from './TS_PRINTER.ts'
2
3
 
3
4
  /*
4
5
  Removes `effect(...)` calls from a script for the SSR back-end. Effects are client
@@ -10,23 +11,27 @@ binding keeps a defined (unused) name. Client compilation keeps effects untouche
10
11
  */
11
12
  export function stripEffects(code: string): string {
12
13
  const source = ts.createSourceFile('script.ts', code, ts.ScriptTarget.Latest, true)
13
- const result = ts.transform(source, [
14
- (context) => (root) => {
15
- const visit = (node: ts.Node): ts.Node => {
16
- if (
17
- ts.isCallExpression(node) &&
18
- ts.isIdentifier(node.expression) &&
19
- node.expression.text === 'effect'
20
- ) {
21
- return ts.factory.createIdentifier('undefined')
22
- }
23
- return ts.visitEachChild(node, visit, context)
24
- }
25
- return ts.visitNode(root, visit) as ts.SourceFile
26
- },
27
- ])
28
- const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed })
29
- const output = printer.printFile(result.transformed[0] as ts.SourceFile)
14
+ const result = ts.transform(source, [stripEffectsTransformer()])
15
+ const output = TS_PRINTER.printFile(result.transformed[0] as ts.SourceFile)
30
16
  result.dispose()
31
17
  return output
32
18
  }
19
+
20
+ /* The effect-stripping as a `ts.TransformerFactory`, so the SSR script path can run it
21
+ over the tree `lowerScript` already built (one extra print, no reparse). `stripEffects`
22
+ is the standalone string wrapper kept for the per-nested-`<script>` SSR callers. */
23
+ export function stripEffectsTransformer(): ts.TransformerFactory<ts.SourceFile> {
24
+ return (context) => (root) => {
25
+ const visit = (node: ts.Node): ts.Node => {
26
+ if (
27
+ ts.isCallExpression(node) &&
28
+ ts.isIdentifier(node.expression) &&
29
+ node.expression.text === 'effect'
30
+ ) {
31
+ return ts.factory.createIdentifier('undefined')
32
+ }
33
+ return ts.visitEachChild(node, visit, context)
34
+ }
35
+ return ts.visitNode(root, visit) as ts.SourceFile
36
+ }
37
+ }
@@ -7,7 +7,12 @@ The shared front-end result for a component, consumed by both the client
7
7
  (so template expressions rewrite consistently), and the parsed template tree.
8
8
  */
9
9
  export type AnalyzedComponent = {
10
+ /* The lowered client script — keeps `effect(...)` calls (client lifecycle). */
10
11
  script: string
12
+ /* The same lowered script with `effect(...)` calls stripped, for the SSR render
13
+ (effects emit no HTML and must not run on the server). Produced from the one
14
+ parse `script` came from, not a downstream re-parse. */
15
+ ssrScript: string
11
16
  /* Top-level import statements hoisted out of the script (e.g. child
12
17
  components), placed at module scope by the module wrapper. */
13
18
  imports: string
@@ -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) {
@@ -210,7 +223,14 @@ export function awaitBlock(
210
223
  subscribes to its reactive source (a cache key). A cache-remote read is warm
211
224
  on resume — it serves the snapshot without a network round-trip, so adoption
212
225
  stays no-flash AND a later cache.invalidate re-runs the block. Without this
213
- read a resume-adopted block has no deps and invalidate is a no-op. */
226
+ read a resume-adopted block has no deps and invalidate is a no-op.
227
+
228
+ ONLY the promise read is tracked. The warm-sync resolve, the hydration adopt,
229
+ and the pending render all BUILD the branch through `scope`, which builds
230
+ untracked — so the branch's own reactive reads don't subscribe THIS effect
231
+ (otherwise the whole block re-runs and re-suspends on any branch-state change,
232
+ e.g. a sibling route param updating in place). The branch's own child effects
233
+ still track normally; the block re-runs only when the promise source does. */
214
234
  const result = promiseThunk()
215
235
  if (first) {
216
236
  first = false
@@ -45,6 +45,10 @@ export function each<T>(
45
45
  claim): claim the start marker, build content in place, claim the end marker.
46
46
  Create mode: markers + content in a fragment, held in `pending` until placement
47
47
  inserts it. */
48
+ /* Reconcile runs inside the effect below; the row build doesn't subscribe it
49
+ (`scope` builds untracked), so a raw reactive read in the row content — e.g. a
50
+ nested `<script>` body — can't re-reconcile the whole list. Only `items()` drives
51
+ the each; each row's own interpolations track through their own effects. */
48
52
  const buildRow = (item: T): EachRow => {
49
53
  const hydration = RENDER.hydration
50
54
  if (hydration !== undefined) {
@@ -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
+ }
@@ -42,6 +42,10 @@ export function switchBlock(
42
42
  }
43
43
  const caseAt = (index: number): SwitchCase | undefined =>
44
44
  index === -1 ? undefined : cases[index]
45
+ /* The chosen case builds through `scope` (directly on hydrate, via `fillBefore` on a
46
+ swap), which builds untracked — so a raw reactive read in the case content doesn't
47
+ subscribe the swap effect below; only `subject()` (and each case's `match()`) drives
48
+ the swap. The case's own interpolations still track, each through its own effect. */
45
49
 
46
50
  /* `before` places the range among static siblings on create (block before a suffix);
47
51
  hydrate ignores it and uses the parked claim cursor. */
@@ -28,6 +28,10 @@ export function when(
28
28
  before: Node | null = null,
29
29
  ): void {
30
30
  const hydration = RENDER.hydration
31
+ /* The chosen branch builds through `scope` (directly on hydrate, via `fillBefore`
32
+ on a swap), which builds untracked — so a raw reactive read in the branch content
33
+ doesn't subscribe the swap effect below; only `condition()` drives the toggle. The
34
+ branch's own interpolations still track, each through its own effect. */
31
35
  const chosenFor = (branch: 'then' | 'else') => (branch === 'then' ? render : renderElse)
32
36
  /* The live branch's scope, registered with the owner so it disposes on owner
33
37
  teardown — not only on a branch flip via clearBetween. */
@@ -49,7 +49,9 @@ function routerState() {
49
49
  return {
50
50
  path: runtimePath.value,
51
51
  route: page.route,
52
- params: page.params,
52
+ /* `page.params` is a reactive Proxy — structuredClone (postMessage) can't carry
53
+ a Proxy and throws, dropping every router frame. Spread to a plain object. */
54
+ params: { ...page.params },
53
55
  navigating: page.navigating,
54
56
  url: page.url.href,
55
57
  entry: historyEntries.current,