@abide/abide 0.34.2 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/AGENTS.md +196 -215
  2. package/CHANGELOG.md +46 -0
  3. package/README.md +75 -69
  4. package/package.json +2 -1
  5. package/src/abideResolverPlugin.ts +53 -9
  6. package/src/lib/server/runtime/STREAMED_HTML_HEADER.ts +13 -0
  7. package/src/lib/server/runtime/buildPreloadManifest.ts +151 -0
  8. package/src/lib/server/runtime/createServer.ts +4 -1
  9. package/src/lib/server/runtime/createUiPageRenderer.ts +104 -9
  10. package/src/lib/server/runtime/flushingGzipStream.ts +62 -0
  11. package/src/lib/server/runtime/gzipResponse.ts +23 -11
  12. package/src/lib/server/runtime/serializeCacheSnapshot.ts +22 -33
  13. package/src/lib/shared/cache.ts +10 -3
  14. package/src/lib/shared/cacheManagedSlot.ts +10 -0
  15. package/src/lib/shared/withCacheManaged.ts +18 -0
  16. package/src/lib/ui/README.md +1 -1
  17. package/src/lib/ui/compile/REACTIVE_CALLEES.ts +6 -6
  18. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
  19. package/src/lib/ui/compile/compileShadow.ts +60 -55
  20. package/src/lib/ui/compile/desugarSignals.ts +93 -38
  21. package/src/lib/ui/compile/lowerDocAccess.ts +26 -11
  22. package/src/lib/ui/compile/parseTemplate.ts +8 -5
  23. package/src/lib/ui/compile/prepareNestedScript.ts +2 -2
  24. package/src/lib/ui/compile/renameSignalRefs.ts +153 -23
  25. package/src/lib/ui/compile/types/TemplateAttr.ts +4 -2
  26. package/src/lib/ui/dom/applyResolved.ts +27 -7
  27. package/src/lib/ui/dom/awaitBlock.ts +7 -3
  28. package/src/lib/ui/dom/each.ts +8 -15
  29. package/src/lib/ui/dom/eachAsync.ts +8 -9
  30. package/src/lib/ui/dom/switchBlock.ts +7 -3
  31. package/src/lib/ui/dom/tryBlock.ts +16 -5
  32. package/src/lib/ui/dom/when.ts +7 -3
  33. package/src/lib/ui/installHotBridge.ts +2 -0
  34. package/src/lib/ui/remoteProxy.ts +32 -6
  35. package/src/lib/ui/router.ts +26 -10
  36. package/src/lib/ui/runtime/REQUEST_SUPERSEDED.ts +8 -0
  37. package/src/lib/ui/runtime/abortNode.ts +22 -0
  38. package/src/lib/ui/runtime/currentAbortSignal.ts +26 -0
  39. package/src/lib/ui/runtime/escapeKey.ts +10 -4
  40. package/src/lib/ui/runtime/reactiveAbortState.ts +15 -0
  41. package/src/lib/ui/runtime/runNode.ts +9 -0
  42. package/src/lib/ui/runtime/scopeGroup.ts +40 -0
  43. package/src/lib/ui/runtime/unlinkDeps.ts +8 -0
  44. package/src/lib/ui/seedStreamedResolution.ts +22 -0
  45. package/src/lib/ui/startClient.ts +17 -3
  46. package/src/lib/shared/types/CacheSnapshot.ts +0 -16
@@ -9,6 +9,13 @@ touched — declaration names, parameter names, and property names are collected
9
9
  into a skip set first, and object shorthand (`{ count }`) is expanded to
10
10
  `{ count: model.count }`. This is the bridge from the signal surface the author
11
11
  writes to the patch substrate underneath.
12
+
13
+ The rewrite is lexically scope-aware: a reference whose name is re-bound by an
14
+ enclosing scope (a function/arrow parameter or a nested local declaration) refers
15
+ to that inner binding, not the component signal, so it is left untouched. Without
16
+ this, a callback like `list.map(option => option.toUpperCase())` in a component
17
+ that also has an `option` prop (`const { option } = props()`) would have its loop
18
+ variable rewritten to `option()` and blow up at runtime (`option is not a function`).
12
19
  */
13
20
  export function renameSignalRefs(
14
21
  code: string,
@@ -18,6 +25,10 @@ export function renameSignalRefs(
18
25
  ): string {
19
26
  const source = ts.createSourceFile('component.ts', code, ts.ScriptTarget.Latest, true)
20
27
 
28
+ /* The signal names that a nested binding can shadow — only these matter for
29
+ scope tracking, so we ignore every other local binding. */
30
+ const signalNames = new Set<string>([...stateNames, ...derivedNames, ...computedNames])
31
+
21
32
  /* Identifier nodes that are names, not value reads — never rewritten. */
22
33
  const skip = new Set<ts.Node>()
23
34
  const collect = (node: ts.Node): void => {
@@ -45,33 +56,47 @@ export function renameSignalRefs(
45
56
 
46
57
  const result = ts.transform(source, [
47
58
  (context) => (root) => {
48
- const visit = (node: ts.Node): ts.Node => {
49
- /* Shorthand `{ count }` `{ count: model.count }` / `{ total: total.value }`. */
50
- if (ts.isShorthandPropertyAssignment(node)) {
51
- const replacement = referenceFor(
52
- node.name.text,
53
- stateNames,
54
- derivedNames,
55
- computedNames,
56
- )
57
- if (replacement !== undefined) {
58
- return ts.factory.createPropertyAssignment(node.name.text, replacement)
59
+ /* Each visitor carries the set of signal names shadowed by the scopes it sits
60
+ inside; entering a scope that re-binds a signal name produces a fresh visitor
61
+ with that name added, so shadowing is per-branch (a sibling scope is unaffected). */
62
+ const makeVisitor = (shadowed: ReadonlySet<string>): ts.Visitor => {
63
+ const visit = (node: ts.Node): ts.Node => {
64
+ /* Shorthand `{ count }` → `{ count: model.count }` / `{ total: total.value }`,
65
+ unless a nearer scope shadows the name. */
66
+ if (ts.isShorthandPropertyAssignment(node) && !shadowed.has(node.name.text)) {
67
+ const replacement = referenceFor(
68
+ node.name.text,
69
+ stateNames,
70
+ derivedNames,
71
+ computedNames,
72
+ )
73
+ if (replacement !== undefined) {
74
+ return ts.factory.createPropertyAssignment(node.name.text, replacement)
75
+ }
59
76
  }
60
- }
61
- if (ts.isIdentifier(node) && !skip.has(node)) {
62
- const replacement = referenceFor(
63
- node.text,
64
- stateNames,
65
- derivedNames,
66
- computedNames,
67
- )
68
- if (replacement !== undefined) {
69
- return replacement
77
+ if (ts.isIdentifier(node) && !skip.has(node) && !shadowed.has(node.text)) {
78
+ const replacement = referenceFor(
79
+ node.text,
80
+ stateNames,
81
+ derivedNames,
82
+ computedNames,
83
+ )
84
+ if (replacement !== undefined) {
85
+ return replacement
86
+ }
70
87
  }
88
+ /* Recurse with the scope this node introduces folded in (same visitor when
89
+ it adds no shadowing name, so unscoped subtrees allocate nothing extra). */
90
+ const inner = extendShadowed(node, shadowed, signalNames)
91
+ return ts.visitEachChild(
92
+ node,
93
+ inner === shadowed ? visit : makeVisitor(inner),
94
+ context,
95
+ )
71
96
  }
72
- return ts.visitEachChild(node, visit, context)
97
+ return visit
73
98
  }
74
- return ts.visitNode(root, visit) as ts.SourceFile
99
+ return ts.visitNode(root, makeVisitor(new Set())) as ts.SourceFile
75
100
  },
76
101
  ])
77
102
  const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed })
@@ -80,6 +105,111 @@ export function renameSignalRefs(
80
105
  return output
81
106
  }
82
107
 
108
+ /* The shadowed-name set for `node`'s children: the parent set plus any signal name
109
+ that `node` re-binds as a new scope. Returns the parent set unchanged (same
110
+ reference) when `node` introduces no colliding binding, so the caller can skip
111
+ allocating a new visitor. */
112
+ function extendShadowed(
113
+ node: ts.Node,
114
+ shadowed: ReadonlySet<string>,
115
+ signalNames: ReadonlySet<string>,
116
+ ): ReadonlySet<string> {
117
+ const introduced = new Set<string>()
118
+ collectScopeBindings(node, introduced)
119
+ const added = [...introduced].filter((name) => signalNames.has(name) && !shadowed.has(name))
120
+ if (added.length === 0) {
121
+ return shadowed
122
+ }
123
+ return new Set<string>([...shadowed, ...added])
124
+ }
125
+
126
+ /* The names `node` binds when it opens a lexical scope — function/arrow parameters
127
+ (and the function's own name), block-level `let`/`const`/`function`/`class`,
128
+ `for`-header declarations, and the `catch` binding. Only names bound directly at
129
+ this scope; deeper scopes are folded in as the walk descends into them. */
130
+ function collectScopeBindings(node: ts.Node, into: Set<string>): void {
131
+ const parameters = functionParameters(node)
132
+ if (parameters !== undefined) {
133
+ for (const parameter of parameters) {
134
+ collectBindingNames(parameter.name, into)
135
+ }
136
+ if (
137
+ (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node)) &&
138
+ node.name !== undefined
139
+ ) {
140
+ into.add(node.name.text)
141
+ }
142
+ return
143
+ }
144
+ if (ts.isBlock(node) || ts.isModuleBlock(node) || ts.isCaseBlock(node)) {
145
+ const statements = ts.isCaseBlock(node)
146
+ ? node.clauses.flatMap((clause) => [...clause.statements])
147
+ : node.statements
148
+ for (const statement of statements) {
149
+ collectStatementBindings(statement, into)
150
+ }
151
+ return
152
+ }
153
+ if (ts.isCatchClause(node) && node.variableDeclaration !== undefined) {
154
+ collectBindingNames(node.variableDeclaration.name, into)
155
+ return
156
+ }
157
+ if (ts.isForStatement(node) || ts.isForInStatement(node) || ts.isForOfStatement(node)) {
158
+ const initializer = node.initializer
159
+ if (initializer !== undefined && ts.isVariableDeclarationList(initializer)) {
160
+ for (const declaration of initializer.declarations) {
161
+ collectBindingNames(declaration.name, into)
162
+ }
163
+ }
164
+ }
165
+ }
166
+
167
+ /* The names a single block-level statement binds: `var`/`let`/`const`, and named
168
+ `function`/`class` declarations. */
169
+ function collectStatementBindings(statement: ts.Statement, into: Set<string>): void {
170
+ if (ts.isVariableStatement(statement)) {
171
+ for (const declaration of statement.declarationList.declarations) {
172
+ collectBindingNames(declaration.name, into)
173
+ }
174
+ }
175
+ if (
176
+ (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) &&
177
+ statement.name !== undefined
178
+ ) {
179
+ into.add(statement.name.text)
180
+ }
181
+ }
182
+
183
+ /* Every identifier bound by a binding name — a plain identifier or the leaves of a
184
+ destructuring pattern (object/array, including rest elements). */
185
+ function collectBindingNames(name: ts.BindingName, into: Set<string>): void {
186
+ if (ts.isIdentifier(name)) {
187
+ into.add(name.text)
188
+ return
189
+ }
190
+ for (const element of name.elements) {
191
+ if (ts.isBindingElement(element)) {
192
+ collectBindingNames(element.name, into)
193
+ }
194
+ }
195
+ }
196
+
197
+ /* The parameter list of a function-like node, or undefined if `node` is not one. */
198
+ function functionParameters(node: ts.Node): ts.NodeArray<ts.ParameterDeclaration> | undefined {
199
+ if (
200
+ ts.isArrowFunction(node) ||
201
+ ts.isFunctionDeclaration(node) ||
202
+ ts.isFunctionExpression(node) ||
203
+ ts.isMethodDeclaration(node) ||
204
+ ts.isConstructorDeclaration(node) ||
205
+ ts.isGetAccessorDeclaration(node) ||
206
+ ts.isSetAccessorDeclaration(node)
207
+ ) {
208
+ return node.parameters
209
+ }
210
+ return undefined
211
+ }
212
+
83
213
  /* `model.<name>` for a state binding, `<name>()` for a computed doc-slot (the
84
214
  string-free reader `scope().derive` returns), `<name>.value` for a runtime cell
85
215
  (linked / lens / transform-state), else undefined. */
@@ -1,5 +1,7 @@
1
1
  /*
2
- An element attribute. `static` is a literal; `expression` is `name={code}` bound
2
+ An element attribute. `static` is a literal `bare` marks a valueless attribute
3
+ (`disabled`, not `disabled=""`), letting a component coerce it to `true` while a
4
+ native element still serialises it as `name=""`; `expression` is `name={code}` bound
3
5
  reactively; `event` is `on<event>={code}` where `code` evaluates to the handler;
4
6
  `bind` is `bind:<property>={lvalue}`, a two-way binding whose `code` is the
5
7
  writable doc path read into the property and written back on input; `attach` is
@@ -9,7 +11,7 @@ absolute offset of `code` in the original `.abide` source (see TextPart) —
9
11
  optional, set only when the parser tracks positions for the type-checking shadow.
10
12
  */
11
13
  export type TemplateAttr =
12
- | { kind: 'static'; name: string; value: string }
14
+ | { kind: 'static'; name: string; value: string; bare?: true }
13
15
  | { kind: 'expression'; name: string; code: string; loc?: number }
14
16
  | { kind: 'event'; event: string; code: string; loc?: number }
15
17
  | { kind: 'bind'; property: string; code: string; loc?: number }
@@ -1,13 +1,23 @@
1
1
  import { RESUME } from '../runtime/RESUME.ts'
2
+ import { seedStreamedResolution } from '../seedStreamedResolution.ts'
2
3
 
3
4
  /*
4
- Client consumer of an SSR stream fragment. Parses a streamed
5
- `<abide-resolve data-id="ID"><script type="application/json">…</script>…</abide-resolve>`
6
- frame, registers its serialized value in the resume manifest (for later hydration), finds the
7
- matching `<!--abide:await:ID-->…<!--/abide:await:ID-->` boundary in `root`, removes
8
- the pending nodes between the markers, and inserts the resolved content in their
9
- place. The pending shell painted instantly; this swaps in each value as it
10
- arrives completing the out-of-order streaming loop on the client.
5
+ Bundle-side consumer of an SSR stream chunk, the counterpart of the doc stream's inline
6
+ vanilla scripts (SSR_SWAP_SCRIPT's `__abideSwap` + `__abideResolve`) for a stream the
7
+ running bundle consumes itself streaming SPA navigation, socket-delivered SSR. It routes
8
+ the two chunk kinds the stream interleaves:
9
+
10
+ - `<abide-cache>{StreamedResolution}</abide-cache>` seed the streamed cache partition.
11
+ A script set via innerHTML never runs, so the cache channel rides a data frame here
12
+ (not the doc stream's `<script>__abideResolve(…)</script>`); seeding it keeps the
13
+ pending {#await} read warm instead of cold-missing to the network.
14
+ - `<abide-resolve data-id="ID"><script type="application/json">…</script>…</abide-resolve>`
15
+ → register the value in the resume manifest (for hydration), find the matching
16
+ `<!--abide:await:ID-->…<!--/abide:await:ID-->` boundary in `root`, remove the pending
17
+ nodes between the markers, and insert the resolved content in their place.
18
+
19
+ The pending shell painted instantly; this swaps in each value as it arrives — completing
20
+ the out-of-order streaming loop on the client.
11
21
  */
12
22
  // @documentation plumbing
13
23
  export function applyResolved(root: Element, frame: string): void {
@@ -17,6 +27,16 @@ export function applyResolved(root: Element, frame: string): void {
17
27
  if (resolved === null || resolved.getAttribute === undefined) {
18
28
  return
19
29
  }
30
+ /* A cache-seed frame warms the streamed cache partition — paired with the DOM swap so a
31
+ bundle-consumed stream can't adopt a resolved branch while dropping its cache key. */
32
+ if (resolved.nodeName === 'ABIDE-CACHE') {
33
+ try {
34
+ seedStreamedResolution(JSON.parse(resolved.textContent ?? 'null'))
35
+ } catch {
36
+ /* malformed payload — leave unseeded; the read falls back to a live fetch */
37
+ }
38
+ return
39
+ }
20
40
  const id = resolved.getAttribute('data-id')
21
41
  if (id === null) {
22
42
  return
@@ -3,6 +3,7 @@ import { claimChild } from '../runtime/claimChild.ts'
3
3
  import { RENDER } from '../runtime/RENDER.ts'
4
4
  import { RESUME } from '../runtime/RESUME.ts'
5
5
  import { scope } from '../runtime/scope.ts'
6
+ import { scopeGroup } from '../runtime/scopeGroup.ts'
6
7
  import { discardBoundary } from './discardBoundary.ts'
7
8
  import { enterNamespace } from './enterNamespace.ts'
8
9
 
@@ -41,6 +42,9 @@ export function awaitBlock(
41
42
  before: Node | null = null,
42
43
  ): void {
43
44
  const hydration = RENDER.hydration
45
+ /* The live branch's scope, registered with the owner so it disposes on owner
46
+ teardown — not only when a settle/re-run swaps branches via detach. */
47
+ const group = scopeGroup()
44
48
  let active: { nodes: Node[]; dispose: () => void } | undefined
45
49
  let anchor: Node | undefined
46
50
  let first = true
@@ -67,8 +71,8 @@ export function awaitBlock(
67
71
  const place = (build: (parent: Node) => void): void => {
68
72
  detach()
69
73
  const fragment = document.createDocumentFragment()
70
- const dispose = enterNamespace(anchor?.parentNode ?? parent, () =>
71
- scope(() => build(fragment)),
74
+ const dispose = group.track(
75
+ enterNamespace(anchor?.parentNode ?? parent, () => scope(() => build(fragment))),
72
76
  )
73
77
  const nodes = [...fragment.childNodes]
74
78
  ;(anchor?.parentNode ?? parent).insertBefore(fragment, anchor ?? null)
@@ -112,7 +116,7 @@ export function awaitBlock(
112
116
  const adopt = (open: Node | null, build: (parent: Node) => void): void => {
113
117
  const cursor = hydration as NonNullable<typeof hydration>
114
118
  cursor.next.set(parent, open?.nextSibling ?? null)
115
- const dispose = scope(() => build(parent))
119
+ const dispose = group.track(scope(() => build(parent)))
116
120
  const close = claimChild(cursor, parent)
117
121
  cursor.next.set(parent, close?.nextSibling ?? null)
118
122
  const nodes: Node[] = []
@@ -1,9 +1,9 @@
1
1
  import { effect } from '../effect.ts'
2
2
  import { claimChild } from '../runtime/claimChild.ts'
3
3
  import { claimExpected } from '../runtime/claimExpected.ts'
4
- import { OWNER } from '../runtime/OWNER.ts'
5
4
  import { RENDER } from '../runtime/RENDER.ts'
6
5
  import { scope } from '../runtime/scope.ts'
6
+ import { scopeGroup } from '../runtime/scopeGroup.ts'
7
7
  import { enterNamespace } from './enterNamespace.ts'
8
8
  import { moveRange } from './moveRange.ts'
9
9
  import { removeRange } from './removeRange.ts'
@@ -36,6 +36,9 @@ export function each<T>(
36
36
  before: Node | null = null,
37
37
  ): void {
38
38
  const rows = new Map<string, EachRow>()
39
+ /* Each row's scope, registered with the owner so every live row disposes on owner
40
+ teardown (the effect's own disposer only unsubscribes it from `items()`). */
41
+ const group = scopeGroup()
39
42
 
40
43
  /* Build a row's range. Hydrate mode (only while the claim cursor is active —
41
44
  read fresh, since a row built by a post-hydration reconcile must create, not
@@ -47,7 +50,7 @@ export function each<T>(
47
50
  if (hydration !== undefined) {
48
51
  const start = claimExpected(hydration, parent, 'each row start marker')
49
52
  hydration.next.set(parent, start.nextSibling)
50
- const dispose = scope(() => render(parent, item))
53
+ const dispose = group.track(scope(() => render(parent, item)))
51
54
  const end = claimExpected(hydration, parent, 'each row end marker')
52
55
  hydration.next.set(parent, end.nextSibling)
53
56
  return { start, end, dispose }
@@ -58,7 +61,9 @@ export function each<T>(
58
61
  pending.appendChild(start)
59
62
  /* Build under `parent`'s foreign namespace so foreign row elements (svg/math)
60
63
  built into the detached fragment are namespaced, not built as HTML. */
61
- const dispose = enterNamespace(parent, () => scope(() => render(pending, item)))
64
+ const dispose = group.track(
65
+ enterNamespace(parent, () => scope(() => render(pending, item))),
66
+ )
62
67
  pending.appendChild(end)
63
68
  return { start, end, dispose, pending }
64
69
  }
@@ -146,16 +151,4 @@ export function each<T>(
146
151
  RENDER.hydration = previousHydration
147
152
  }
148
153
  })
149
-
150
- /* Dispose every row still live when the enclosing scope tears down (the effect's
151
- own disposer only unsubscribes it from `items()`). The host's DOM is cleared by
152
- `mount`, so disposal need not remove the nodes. */
153
- if (OWNER.current !== undefined) {
154
- OWNER.current.push(() => {
155
- for (const row of rows.values()) {
156
- row.dispose()
157
- }
158
- rows.clear()
159
- })
160
- }
161
154
  }
@@ -3,6 +3,7 @@ import { claimChild } from '../runtime/claimChild.ts'
3
3
  import { OWNER } from '../runtime/OWNER.ts'
4
4
  import { RENDER } from '../runtime/RENDER.ts'
5
5
  import { scope } from '../runtime/scope.ts'
6
+ import { scopeGroup } from '../runtime/scopeGroup.ts'
6
7
  import { enterNamespace } from './enterNamespace.ts'
7
8
  import { removeRange } from './removeRange.ts'
8
9
  import type { EachRow } from './types/EachRow.ts'
@@ -32,6 +33,9 @@ export function eachAsync<T>(
32
33
  before: Node | null = null,
33
34
  ): void {
34
35
  const rows = new Map<string, EachRow>()
36
+ /* Each row's (and the error branch's) scope, registered with the owner so they
37
+ dispose on owner teardown; the block's own teardown only stops the stream. */
38
+ const group = scopeGroup()
35
39
  const hydration = RENDER.hydration
36
40
  const anchor = document.createTextNode('')
37
41
  if (hydration !== undefined) {
@@ -46,8 +50,8 @@ export function eachAsync<T>(
46
50
  const end = document.createComment(']')
47
51
  const fragment = document.createDocumentFragment()
48
52
  fragment.appendChild(start)
49
- const dispose = enterNamespace(anchor.parentNode ?? parent, () =>
50
- scope(() => build(fragment)),
53
+ const dispose = group.track(
54
+ enterNamespace(anchor.parentNode ?? parent, () => scope(() => build(fragment))),
51
55
  )
52
56
  fragment.appendChild(end)
53
57
  /* Insert via the anchor's LIVE parent: when this `each` is a bare child of a
@@ -127,18 +131,13 @@ export function eachAsync<T>(
127
131
  })
128
132
 
129
133
  /* Stop the live stream when the enclosing scope tears down: bump the generation so
130
- the drain abandons its loop, `return()` the iterator to release the source, drop
131
- the error branch, and dispose every surviving row. */
134
+ the drain abandons its loop and `return()` the iterator to release the source. The
135
+ rows and error branch are disposed by the group (their scopes were tracked). */
132
136
  if (OWNER.current !== undefined) {
133
137
  OWNER.current.push(() => {
134
138
  generation += 1
135
139
  iterator?.return?.(undefined)?.catch(() => undefined)
136
140
  iterator = undefined
137
- clearError()
138
- for (const row of rows.values()) {
139
- row.dispose()
140
- }
141
- rows.clear()
142
141
  })
143
142
  }
144
143
  }
@@ -1,6 +1,7 @@
1
1
  import { effect } from '../effect.ts'
2
2
  import { RENDER } from '../runtime/RENDER.ts'
3
3
  import { scope } from '../runtime/scope.ts'
4
+ import { scopeGroup } from '../runtime/scopeGroup.ts'
4
5
  import { clearBetween } from './clearBetween.ts'
5
6
  import { fillBefore } from './fillBefore.ts'
6
7
  import { openMarker } from './openMarker.ts'
@@ -26,6 +27,9 @@ export function switchBlock(
26
27
  before: Node | null = null,
27
28
  ): void {
28
29
  const hydration = RENDER.hydration
30
+ /* The live case's scope, registered with the owner so it disposes on owner
31
+ teardown — not only when the subject switches cases via clearBetween. */
32
+ const group = scopeGroup()
29
33
  let dispose: (() => void) | undefined
30
34
  let activeIndex: number
31
35
  let end: Comment
@@ -46,7 +50,7 @@ export function switchBlock(
46
50
  activeIndex = select(subject())
47
51
  const chosen = caseAt(activeIndex)
48
52
  if (chosen !== undefined) {
49
- dispose = scope(() => chosen.render(parent)) // claim the SSR nodes in place
53
+ dispose = group.track(scope(() => chosen.render(parent))) // claim the SSR nodes in place
50
54
  }
51
55
  end = openMarker(parent, ']')
52
56
  } else {
@@ -54,7 +58,7 @@ export function switchBlock(
54
58
  activeIndex = select(subject())
55
59
  const chosen = caseAt(activeIndex)
56
60
  if (chosen !== undefined) {
57
- dispose = fillBefore(end, (p) => chosen.render(p))
61
+ dispose = group.track(fillBefore(end, (p) => chosen.render(p)))
58
62
  }
59
63
  }
60
64
 
@@ -68,7 +72,7 @@ export function switchBlock(
68
72
  activeIndex = index
69
73
  const chosen = caseAt(index)
70
74
  if (chosen !== undefined) {
71
- dispose = fillBefore(end, (p) => chosen.render(p))
75
+ dispose = group.track(fillBefore(end, (p) => chosen.render(p)))
72
76
  }
73
77
  })
74
78
  }
@@ -1,6 +1,7 @@
1
1
  import { claimChild } from '../runtime/claimChild.ts'
2
2
  import { OWNER } from '../runtime/OWNER.ts'
3
3
  import { RENDER } from '../runtime/RENDER.ts'
4
+ import { scopeGroup } from '../runtime/scopeGroup.ts'
4
5
  import { discardBoundary } from './discardBoundary.ts'
5
6
  import { enterNamespace } from './enterNamespace.ts'
6
7
 
@@ -28,20 +29,30 @@ export function tryBlock(
28
29
  renderCatch?: (parent: Node, error: unknown) => void,
29
30
  before: Node | null = null,
30
31
  ): void {
31
- /* Run a void build under a fresh ownership scope; on throw, tear down the partial
32
- effects/listeners it registered and rethrow so the caller can fall back. */
32
+ /* The guarded subtree's scope, registered with the owner so it disposes on owner
33
+ teardown. The block renders once, so there is at most one tracked subtree (the
34
+ try branch, or the catch branch if try threw). */
35
+ const group = scopeGroup()
36
+ /* Run a void build under a fresh ownership scope. On success, hand its disposers to
37
+ the group so the subtree tears down with the owner (they were previously dropped —
38
+ the leak). On throw, tear down the partial build now and rethrow so the caller can
39
+ fall back to the catch branch. */
33
40
  const guard = (build: () => void): void => {
34
41
  const previous = OWNER.current
35
42
  const disposers: Array<() => void> = []
36
43
  OWNER.current = disposers
44
+ const disposeAll = (): void => {
45
+ for (let index = disposers.length - 1; index >= 0; index -= 1) {
46
+ disposers[index]?.()
47
+ }
48
+ }
37
49
  try {
38
50
  build()
39
51
  OWNER.current = previous
52
+ group.track(disposeAll)
40
53
  } catch (error) {
41
54
  OWNER.current = previous
42
- for (let index = disposers.length - 1; index >= 0; index -= 1) {
43
- disposers[index]?.()
44
- }
55
+ disposeAll()
45
56
  throw error
46
57
  }
47
58
  }
@@ -1,6 +1,7 @@
1
1
  import { effect } from '../effect.ts'
2
2
  import { RENDER } from '../runtime/RENDER.ts'
3
3
  import { scope } from '../runtime/scope.ts'
4
+ import { scopeGroup } from '../runtime/scopeGroup.ts'
4
5
  import { clearBetween } from './clearBetween.ts'
5
6
  import { fillBefore } from './fillBefore.ts'
6
7
  import { openMarker } from './openMarker.ts'
@@ -28,6 +29,9 @@ export function when(
28
29
  ): void {
29
30
  const hydration = RENDER.hydration
30
31
  const chosenFor = (branch: 'then' | 'else') => (branch === 'then' ? render : renderElse)
32
+ /* The live branch's scope, registered with the owner so it disposes on owner
33
+ teardown — not only on a branch flip via clearBetween. */
34
+ const group = scopeGroup()
31
35
  let dispose: (() => void) | undefined
32
36
  let activeBranch: 'then' | 'else'
33
37
  let end: Comment
@@ -40,7 +44,7 @@ export function when(
40
44
  activeBranch = condition() ? 'then' : 'else'
41
45
  const chosen = chosenFor(activeBranch)
42
46
  if (chosen !== undefined) {
43
- dispose = scope(() => chosen(parent)) // content claims the SSR nodes in place
47
+ dispose = group.track(scope(() => chosen(parent))) // content claims the SSR nodes in place
44
48
  }
45
49
  end = openMarker(parent, ']')
46
50
  } else {
@@ -48,7 +52,7 @@ export function when(
48
52
  activeBranch = condition() ? 'then' : 'else'
49
53
  const chosen = chosenFor(activeBranch)
50
54
  if (chosen !== undefined) {
51
- dispose = fillBefore(end, chosen)
55
+ dispose = group.track(fillBefore(end, chosen))
52
56
  }
53
57
  }
54
58
 
@@ -62,7 +66,7 @@ export function when(
62
66
  activeBranch = branch
63
67
  const chosen = chosenFor(branch)
64
68
  if (chosen !== undefined) {
65
- dispose = fillBefore(end, chosen)
69
+ dispose = group.track(fillBefore(end, chosen))
66
70
  }
67
71
  })
68
72
  }
@@ -24,6 +24,7 @@ import { effect } from './effect.ts'
24
24
  import { enterScope } from './enterScope.ts'
25
25
  import { exitScope } from './exitScope.ts'
26
26
  import { enterRenderPass } from './runtime/enterRenderPass.ts'
27
+ import { escapeKey } from './runtime/escapeKey.ts'
27
28
  import { exitRenderPass } from './runtime/exitRenderPass.ts'
28
29
  import { hotReloadEnabled } from './runtime/hotReloadEnabled.ts'
29
30
  import { hotReplace } from './runtime/hotReplace.ts'
@@ -68,6 +69,7 @@ export function installHotBridge(): void {
68
69
  mountSlot,
69
70
  mountChild,
70
71
  hydrate,
72
+ escapeKey,
71
73
  nextBlockId,
72
74
  enterRenderPass,
73
75
  exitRenderPass,
@@ -8,6 +8,8 @@ import { trace } from '../shared/trace.ts'
8
8
  import type { HttpVerb } from '../shared/types/HttpVerb.ts'
9
9
  import type { RemoteFunction } from '../shared/types/RemoteFunction.ts'
10
10
  import { withBase } from '../shared/withBase.ts'
11
+ import { currentAbortSignal } from './runtime/currentAbortSignal.ts'
12
+ import { REQUEST_SUPERSEDED } from './runtime/REQUEST_SUPERSEDED.ts'
11
13
 
12
14
  /*
13
15
  Client-side substitute for a verb-defined handler. The bundler emits one
@@ -54,17 +56,26 @@ export function remoteProxy<Args, Return>(
54
56
  }
55
57
 
56
58
  /*
57
- Applies the env-configured client timeout (ABIDE_CLIENT_TIMEOUT, ms) when one
58
- is set; an unset slot fetches unbounded, exactly as before. A timeout abort
59
- surfaces as a 504 HttpError so a consumer reads an honest status instead of a
60
- raw DOMException 500. Other rejections (genuine network failure) propagate untouched.
59
+ Fetches under two optional aborts: the reactive scope that fired the call (so a
60
+ superseded/torn-down read cancels its in-flight request currentAbortSignal) and
61
+ the env-configured client timeout (ABIDE_CLIENT_TIMEOUT, ms). Neither present
62
+ the unbounded fetch, exactly as before. A timeout surfaces as a 504 HttpError so a
63
+ consumer reads an honest status instead of a raw DOMException → 500. Our scope abort
64
+ (reason REQUEST_SUPERSEDED) is swallowed into a never-settling promise: the reactive
65
+ owner is gone, so the result must neither resolve into a dead tree nor surface as a
66
+ rejection. Other rejections (genuine network failure) propagate untouched.
61
67
  */
62
68
  function fetchWithTimeout(request: Request): Promise<Response> {
63
69
  const timeout = rpcTimeoutSlot.ms
64
- if (timeout === undefined) {
70
+ const timeoutSignal = timeout === undefined ? undefined : AbortSignal.timeout(timeout)
71
+ const signal = combineSignals(currentAbortSignal(), timeoutSignal)
72
+ if (signal === undefined) {
65
73
  return fetch(request)
66
74
  }
67
- return fetch(request, { signal: AbortSignal.timeout(timeout) }).catch((error: unknown) => {
75
+ return fetch(request, { signal }).catch((error: unknown) => {
76
+ if (error === REQUEST_SUPERSEDED) {
77
+ return new Promise<Response>(() => {})
78
+ }
68
79
  if (error instanceof DOMException && error.name === 'TimeoutError') {
69
80
  throw new HttpError(
70
81
  new Response('client timeout', { status: 504, statusText: 'Gateway Timeout' }),
@@ -74,6 +85,21 @@ function fetchWithTimeout(request: Request): Promise<Response> {
74
85
  })
75
86
  }
76
87
 
88
+ /* One AbortSignal from the scope-abort and timeout sources: whichever is present,
89
+ AbortSignal.any when both, or undefined when neither (the unbounded fetch). */
90
+ function combineSignals(
91
+ scopeSignal: AbortSignal | undefined,
92
+ timeoutSignal: AbortSignal | undefined,
93
+ ): AbortSignal | undefined {
94
+ if (scopeSignal === undefined) {
95
+ return timeoutSignal
96
+ }
97
+ if (timeoutSignal === undefined) {
98
+ return scopeSignal
99
+ }
100
+ return AbortSignal.any([scopeSignal, timeoutSignal])
101
+ }
102
+
77
103
  /*
78
104
  abide's per-RPC headers: the page traceparent (continues the server trace) and,
79
105
  only while offline, the offline marker so the handler's online() reflects the