@abide/abide 0.35.0 → 0.37.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 (50) hide show
  1. package/AGENTS.md +196 -215
  2. package/CHANGELOG.md +24 -0
  3. package/README.md +75 -69
  4. package/package.json +1 -1
  5. package/src/lib/server/runtime/buildInFlightSnapshot.ts +35 -0
  6. package/src/lib/server/runtime/buildInspectorSurface.ts +9 -1
  7. package/src/lib/server/runtime/createServer.ts +10 -1
  8. package/src/lib/server/runtime/inFlightRequests.ts +13 -0
  9. package/src/lib/server/runtime/maybeMountInspector.ts +7 -0
  10. package/src/lib/server/runtime/runWithRequestScope.ts +7 -0
  11. package/src/lib/server/runtime/types/InspectorContext.ts +4 -0
  12. package/src/lib/server/runtime/types/InspectorInFlightRequest.ts +20 -0
  13. package/src/lib/server/runtime/types/InspectorInFlightSnapshot.ts +10 -0
  14. package/src/lib/server/runtime/types/InspectorPrompt.ts +15 -0
  15. package/src/lib/server/runtime/types/InspectorSurface.ts +6 -3
  16. package/src/lib/shared/cache.ts +10 -3
  17. package/src/lib/shared/cacheManagedSlot.ts +10 -0
  18. package/src/lib/shared/emitLogRecord.ts +18 -5
  19. package/src/lib/shared/withCacheManaged.ts +18 -0
  20. package/src/lib/ui/compile/componentWrapperTag.ts +10 -20
  21. package/src/lib/ui/compile/generateBuild.ts +18 -9
  22. package/src/lib/ui/compile/generateSSR.ts +2 -2
  23. package/src/lib/ui/createScope.ts +11 -0
  24. package/src/lib/ui/dom/awaitBlock.ts +7 -3
  25. package/src/lib/ui/dom/each.ts +8 -15
  26. package/src/lib/ui/dom/eachAsync.ts +8 -9
  27. package/src/lib/ui/dom/hydrate.ts +2 -1
  28. package/src/lib/ui/dom/mount.ts +2 -1
  29. package/src/lib/ui/dom/scopeLabel.ts +15 -0
  30. package/src/lib/ui/dom/skeleton.ts +13 -1
  31. package/src/lib/ui/dom/switchBlock.ts +7 -3
  32. package/src/lib/ui/dom/tryBlock.ts +16 -5
  33. package/src/lib/ui/dom/when.ts +7 -3
  34. package/src/lib/ui/installInspectorBridge.ts +138 -0
  35. package/src/lib/ui/navigate.ts +11 -3
  36. package/src/lib/ui/remoteProxy.ts +32 -6
  37. package/src/lib/ui/router.ts +94 -10
  38. package/src/lib/ui/runtime/REQUEST_SUPERSEDED.ts +8 -0
  39. package/src/lib/ui/runtime/abortNode.ts +22 -0
  40. package/src/lib/ui/runtime/currentAbortSignal.ts +26 -0
  41. package/src/lib/ui/runtime/historyEntries.ts +113 -0
  42. package/src/lib/ui/runtime/liveScopes.ts +15 -0
  43. package/src/lib/ui/runtime/reactiveAbortState.ts +15 -0
  44. package/src/lib/ui/runtime/runNode.ts +9 -0
  45. package/src/lib/ui/runtime/scopeGroup.ts +40 -0
  46. package/src/lib/ui/runtime/types/AbideHistoryState.ts +9 -0
  47. package/src/lib/ui/runtime/unlinkDeps.ts +8 -0
  48. package/src/lib/ui/startClient.ts +6 -0
  49. package/src/lib/ui/types/Scope.ts +3 -0
  50. package/src/lib/ui/compile/HTML_TAGS.ts +0 -132
@@ -1,23 +1,13 @@
1
- import { HTML_TAGS } from './HTML_TAGS.ts'
2
-
3
1
  /*
4
- The element tag a component instance mounts into. Normally the component name
5
- lowercased readable in devtools, a real box like any abide wrapper. But a name
6
- that lowercases to a real HTML element (`Button`→`button`, `Input`→`input`) yields a
7
- wrapper with a content model the parser enforces: void elements self-close, and
8
- `<button>`/`<a>`/table/list/select families reject or foster the component's own
9
- markup as the wrapper's siblings so on hydration the skeleton locates the wrapper
10
- empty, claims `null`, and `attr` throws on it. Those names map to a hyphenated
11
- custom-element tag (a custom element is never void and has no content model) made
12
- layout-transparent with `display:contents`, so the component's real root still lays
13
- out as a direct child of the parent the way the (parse-broken) wrapper would have.
14
- A name that is NOT a known HTML element (the common case — `Card`, `Dropdown`) is an
15
- inert unknown tag that holds any content untouched, so it stays as-is. Both back-ends
16
- call this so the SSR string and the client build agree on the wrapper.
2
+ The element tag a component instance mounts into: always `abide-<name>` lowercased. The
3
+ `abide-` prefix makes every wrapper a valid custom element (contains a hyphen) never
4
+ void, no content model so it holds the component's own markup untouched and hydrates
5
+ cleanly, regardless of whether the name collides with an HTML element (`Button`, `Input`).
6
+ Emitted with `display:contents` (see the back-ends) so the wrapper stays out of layout: a
7
+ pure mount host whose real root lays out as a direct child of the parent, keeping the
8
+ component invisible to `grid`/`subgrid`/`flex`. Both back-ends call this so the SSR string
9
+ and the client build agree on the wrapper.
17
10
  */
18
- export function componentWrapperTag(name: string): { tag: string; transparent: boolean } {
19
- const lower = name.toLowerCase()
20
- return HTML_TAGS.has(lower)
21
- ? { tag: `abide-${lower}`, transparent: true }
22
- : { tag: lower, transparent: false }
11
+ export function componentWrapperTag(name: string): string {
12
+ return `abide-${name.toLowerCase()}`
23
13
  }
@@ -4,9 +4,9 @@ import { bindListenEvent } from './bindListenEvent.ts'
4
4
  import { componentWrapperTag } from './componentWrapperTag.ts'
5
5
  import { groupBindParts } from './groupBindParts.ts'
6
6
  import { isControlFlow } from './isControlFlow.ts'
7
- import { isTextLeaf } from './isTextLeaf.ts'
8
7
  import { lowerContext } from './lowerContext.ts'
9
8
  import { scopeAttr } from './scopeAttr.ts'
9
+ import { skeletonContext } from './skeletonContext.ts'
10
10
  import { staticAttr } from './staticAttr.ts'
11
11
  import { staticTextPart } from './staticTextPart.ts'
12
12
  import type { TemplateNode } from './types/TemplateNode.ts'
@@ -32,6 +32,13 @@ export function generateBuild(
32
32
  let counter = 0
33
33
  const nextVar = (prefix: string): string => `${prefix}${counter++}`
34
34
 
35
+ /* Per-node skeleton position from the SAME pass the SSR back-end reads — so the client's
36
+ anchor/text-leaf decisions consult one source of truth instead of re-deriving the
37
+ position structurally (the drift the shared context exists to prevent). `asOutlet`
38
+ only rewrites layout `<slot>`s and preserves text-node identity, so the original
39
+ `nodes` key the same text markers the build traversal looks up. */
40
+ const { markText } = skeletonContext(nodes)
41
+
35
42
  /* The shared signal→`model` lowering + branch-scoped nested-script deref scope. */
36
43
  const {
37
44
  expression: lowerExpression,
@@ -135,13 +142,12 @@ export function generateBuild(
135
142
  }
136
143
  if (node.kind === 'component') {
137
144
  /* The wrapper element is a positioned hole in the skeleton; the child mounts
138
- into the located node (idempotent display:contents for a transparent wrap,
139
- static so it lives in the markup). */
140
- const { tag, transparent } = componentWrapperTag(node.name)
145
+ into the located node. display:contents (idempotent, static so it lives in
146
+ the markup) keeps the wrapper out of layout — a pure mount host. */
147
+ const tag = componentWrapperTag(node.name)
141
148
  const { code } = mountComponent(node, `${skVar}.el[${counter.el++}]`)
142
149
  binds.push(code)
143
- const style = transparent ? ' style="display:contents"' : ''
144
- return `<${tag} ${HOLE_ATTRIBUTE}${style}></${tag}>`
150
+ return `<${tag} ${HOLE_ATTRIBUTE} style="display:contents"></${tag}>`
145
151
  }
146
152
  if (node.kind === 'script') {
147
153
  /* A nested `<script>` (scoped reactive block) emits no markup — its lowered body
@@ -172,13 +178,16 @@ export function generateBuild(
172
178
  return '<!--a-->'
173
179
  }
174
180
  const hasReactiveAttr = node.attrs.some((attr) => attr.kind !== 'static')
175
- const hasReactiveText = node.children.some(
181
+ const reactiveTextChild = node.children.find(
176
182
  (child) => child.kind === 'text' && child.parts.some((part) => part.kind !== 'static'),
177
183
  )
178
184
  /* A text-leaf (only text/style children) with reactive text binds marker-free via
179
185
  `generateChildren` on the located element; otherwise reactive text is interleaved
180
- and uses `<!--a-->` anchors during the child recursion below. */
181
- const textLeafBind = hasReactiveText && isTextLeaf(node)
186
+ and uses `<!--a-->` anchors during the child recursion below. The shared context
187
+ records the leaf's text as NOT interleaved (`markText` false) — read that flag the
188
+ SSR back-end also reads, rather than re-deriving leaf-ness via `isTextLeaf` here. */
189
+ const textLeafBind =
190
+ reactiveTextChild !== undefined && markText.get(reactiveTextChild) === false
182
191
  let openTag = `<${node.tag}`
183
192
  let elVar = ''
184
193
  if (hasReactiveAttr || textLeafBind) {
@@ -184,7 +184,7 @@ export function generateSSR(
184
184
  the same wrapper the client mounts into, so SSR and client agree.
185
185
  Props pass as thunks; slot content passes as a string-returning
186
186
  `$children` the child invokes from its <slot>. */
187
- const { tag, transparent } = componentWrapperTag(node.name)
187
+ const tag = componentWrapperTag(node.name)
188
188
  const parts = node.props.map(
189
189
  (prop) => `${JSON.stringify(prop.name)}: () => (${lowerExpression(prop.code)})`,
190
190
  )
@@ -206,7 +206,7 @@ export function generateSSR(
206
206
  the enclosing render body, including from branch closures.) */
207
207
  const result = nextVar('$child')
208
208
  return (
209
- push(target, `<${tag}${transparent ? ' style="display:contents"' : ''}>`) +
209
+ push(target, `<${tag} style="display:contents">`) +
210
210
  `const ${result} = ${node.name}.render({ ${parts.join(', ')} });\n` +
211
211
  `${target}.push(${result}.html);\n` +
212
212
  `for (const $a of ${result}.awaits) { $awaits.push($a); }\n` +
@@ -3,6 +3,7 @@ import { history } from './history.ts'
3
3
  import { linked } from './linked.ts'
4
4
  import { persist as persistDoc } from './persist.ts'
5
5
  import { createDoc } from './runtime/createDoc.ts'
6
+ import { liveScopes } from './runtime/liveScopes.ts'
6
7
  import type { Doc } from './runtime/types/Doc.ts'
7
8
  import { state } from './state.ts'
8
9
  import { sync } from './sync.ts'
@@ -28,6 +29,7 @@ export function createScope(
28
29
  initial: unknown = {},
29
30
  parent: Scope | undefined = undefined,
30
31
  awaiting = false,
32
+ label: string | undefined = undefined,
31
33
  ): Scope {
32
34
  /* Eager unless awaiting adoption; `data()` lazily mints an empty doc if a body
33
35
  never created one (a stateless component that still reaches for its scope). */
@@ -41,6 +43,7 @@ export function createScope(
41
43
 
42
44
  const self: Scope = {
43
45
  id,
46
+ label,
44
47
  parent,
45
48
  read: (path) => data().read(path),
46
49
  replace: (path, value) => data().replace(path, value),
@@ -85,7 +88,15 @@ export function createScope(
85
88
  persistence = undefined
86
89
  unsync?.()
87
90
  unsync = undefined
91
+ if (liveScopes.enabled) {
92
+ liveScopes.scopes.delete(self)
93
+ }
88
94
  },
89
95
  }
96
+ /* Dev-only: register for the inspector's scope-tree view. Gated, so production
97
+ never touches the set. */
98
+ if (liveScopes.enabled) {
99
+ liveScopes.scopes.add(self)
100
+ }
90
101
  return self
91
102
  }
@@ -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
  }
@@ -4,6 +4,7 @@ import { enterRenderPass } from '../runtime/enterRenderPass.ts'
4
4
  import { exitRenderPass } from '../runtime/exitRenderPass.ts'
5
5
  import { RENDER } from '../runtime/RENDER.ts'
6
6
  import { scope } from '../runtime/scope.ts'
7
+ import { scopeLabel } from './scopeLabel.ts'
7
8
 
8
9
  /*
9
10
  Adopts existing server-rendered DOM instead of rebuilding it. Runs `build(host)`
@@ -28,7 +29,7 @@ export function hydrate(host: Element, build: (host: Element) => void): () => vo
28
29
  /* Same lexical scope establishment as `mount` — a hydrated component owns a scope
29
30
  too, adopting the model its build adopts. */
30
31
  const parentScope = CURRENT_SCOPE.current
31
- const lexical = createScope({}, parentScope, true)
32
+ const lexical = createScope({}, parentScope, true, scopeLabel(host))
32
33
  CURRENT_SCOPE.current = lexical
33
34
  try {
34
35
  const stop = scope(() => {
@@ -3,6 +3,7 @@ import { CURRENT_SCOPE } from '../runtime/CURRENT_SCOPE.ts'
3
3
  import { enterRenderPass } from '../runtime/enterRenderPass.ts'
4
4
  import { exitRenderPass } from '../runtime/exitRenderPass.ts'
5
5
  import { scope } from '../runtime/scope.ts'
6
+ import { scopeLabel } from './scopeLabel.ts'
6
7
 
7
8
  /*
8
9
  Mounts a component into `host`: runs `build(host)` under an ownership scope so
@@ -22,7 +23,7 @@ export function mount(host: Element, build: (host: Element) => void): () => void
22
23
  `scope()` and its capabilities resolve to it during the build; the previous
23
24
  scope is restored after (synchronous build, so the restore is exact). */
24
25
  const parentScope = CURRENT_SCOPE.current
25
- const lexical = createScope({}, parentScope, true)
26
+ const lexical = createScope({}, parentScope, true, scopeLabel(host))
26
27
  CURRENT_SCOPE.current = lexical
27
28
  const stop = scope(() => {
28
29
  try {
@@ -0,0 +1,15 @@
1
+ /*
2
+ The human-readable name for the scope a mount/hydrate establishes, derived from
3
+ its host element's tag. A nested component mounts into its `abide-<name>` wrapper
4
+ (see `componentWrapperTag`), so stripping the `abide-` prefix recovers the
5
+ component name; any other host (a page/layout outlet) yields its lowercased tag.
6
+ Dev-only — feeds the inspector's Reactive tab so a scope reads `<Counter>` rather
7
+ than an opaque counter id. Returns undefined when there's no element to name from.
8
+ */
9
+ export function scopeLabel(host: Element): string | undefined {
10
+ const tag = host.tagName?.toLowerCase()
11
+ if (tag === undefined) {
12
+ return undefined
13
+ }
14
+ return tag.startsWith('abide-') ? tag.slice('abide-'.length) : tag
15
+ }
@@ -26,6 +26,16 @@ function isElement(node: Node): node is Element {
26
26
  return typeof (node as Element).hasAttribute === 'function'
27
27
  }
28
28
 
29
+ /* A child component's mount wrapper (`abide-<name>`, see `componentWrapperTag`). Its
30
+ content is a SEPARATE skeleton (the child's own), so the parent's walks must treat it
31
+ as opaque: in the shallow skeleton it's an empty leaf, so the compiler counts no
32
+ anchors inside it; on hydrate it's populated, so a descent would over-collect the
33
+ child's anchors and shift every parent index past it (same hazard as a block range,
34
+ but bounded by the wrapper element instead of `[`…`]` markers). */
35
+ function isComponentWrapper(node: Node): boolean {
36
+ return isElement(node) && (node.tagName ?? '').toLowerCase().startsWith('abide-')
37
+ }
38
+
29
39
  /* A comment node's data, or undefined for elements/text. A comment is a node that is
30
40
  neither an element (`hasAttribute`) nor a text node (`splitText`); the mini-dom
31
41
  exposes no `nodeType`, so detect by method. */
@@ -113,7 +123,9 @@ function scanAnchors(nodes: ArrayLike<Node>, anchors: Node[]): void {
113
123
  const node = nodes[index] as Node
114
124
  const data = commentData(node)
115
125
  if (data === undefined) {
116
- if (isElement(node) && depth === 0) {
126
+ /* Recurse into this skeleton's own elements, but NOT a child component's
127
+ wrapper — its anchors belong to the child's skeleton (see above). */
128
+ if (isElement(node) && depth === 0 && !isComponentWrapper(node)) {
117
129
  scanAnchors(node.childNodes, anchors)
118
130
  }
119
131
  } else if (isCloseMarker(data)) {
@@ -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
  }
@@ -0,0 +1,138 @@
1
+ import { effect } from './effect.ts'
2
+ import { clientPage } from './runtime/clientPage.ts'
3
+ import { historyEntries } from './runtime/historyEntries.ts'
4
+ import { liveScopes } from './runtime/liveScopes.ts'
5
+ import { PATCH_BUS } from './runtime/PATCH_BUS.ts'
6
+ import { runtimePath } from './runtime/runtimePath.ts'
7
+
8
+ /* Same-origin cross-tab channel the inspector page listens on. The app tab
9
+ publishes scope + router state; the inspector subscribes. Web-standard, so no
10
+ server route or buffer is involved — core publishes, the package consumes. */
11
+ const CHANNEL = 'abide:inspector'
12
+
13
+ /* Coalesce a burst of patches into one snapshot — the post is the cost, the read is cheap. */
14
+ const SNAPSHOT_DEBOUNCE_MS = 60
15
+
16
+ /* JSON round-trip to strip anything structured-clone can't carry (functions,
17
+ class instances, DOM nodes) before postMessage, degrading to undefined rather
18
+ than throwing into the channel. */
19
+ function cloneable(value: unknown): unknown {
20
+ try {
21
+ return JSON.parse(JSON.stringify(value))
22
+ } catch {
23
+ return undefined
24
+ }
25
+ }
26
+
27
+ /* The live scope forest as a flat list — the inspector rebuilds the tree from
28
+ each node's `parent` id (the Scope surface exposes no children accessor). */
29
+ function scopeNodes(): Array<{
30
+ id: string
31
+ label: string | undefined
32
+ parent: string | undefined
33
+ state: unknown
34
+ recorded: boolean
35
+ }> {
36
+ return Array.from(liveScopes.scopes, (scope) => ({
37
+ id: scope.id,
38
+ label: scope.label,
39
+ parent: scope.parent?.id,
40
+ state: cloneable(scope.snapshot()),
41
+ recorded: scope.canUndo() || scope.canRedo(),
42
+ }))
43
+ }
44
+
45
+ /* Router state off the existing reactive holders — read, not subscribed, here;
46
+ the nav effect drives re-reads. */
47
+ function routerState() {
48
+ const page = clientPage.value
49
+ return {
50
+ path: runtimePath.value,
51
+ route: page.route,
52
+ params: page.params,
53
+ navigating: page.navigating,
54
+ url: page.url.href,
55
+ entry: historyEntries.current,
56
+ }
57
+ }
58
+
59
+ /*
60
+ Installs the client→inspector bridge: a BroadcastChannel the inspector page reads
61
+ to render its Reactive + Router tabs. Gated by `__abideInspect` (server-injected
62
+ only when the inspector is enabled) and called from startClient before the router
63
+ builds any scope — so registration is armed before the first scope exists. A
64
+ no-op where BroadcastChannel is absent (SSR, old runtimes). Mirrors
65
+ installHotBridge: dev instrumentation, not public surface.
66
+ */
67
+ export function installInspectorBridge(): void {
68
+ /* No BroadcastChannel means no consumer for the scope registry — bail before
69
+ arming `liveScopes.enabled`, so createScope never pays scope tracking for a
70
+ set nothing reads. */
71
+ if (typeof BroadcastChannel === 'undefined') {
72
+ return
73
+ }
74
+ liveScopes.enabled = true
75
+ const channel = new BroadcastChannel(CHANNEL)
76
+ const tab = typeof crypto !== 'undefined' ? crypto.randomUUID() : String(performance.now())
77
+ const post = (message: object) => {
78
+ try {
79
+ channel.postMessage({ tab, ...message })
80
+ } catch {
81
+ /* A non-cloneable slipped through despite the JSON pass — drop the frame
82
+ rather than throw into a patch/nav handler. */
83
+ }
84
+ }
85
+
86
+ const announce = () =>
87
+ post({
88
+ kind: 'announce',
89
+ url: typeof location !== 'undefined' ? location.href : '',
90
+ app: typeof document !== 'undefined' ? document.title : '',
91
+ })
92
+ const sendSnapshot = () =>
93
+ post({ kind: 'snapshot', scopes: scopeNodes(), router: routerState() })
94
+
95
+ let snapshotTimer: ReturnType<typeof setTimeout> | undefined
96
+ const scheduleSnapshot = () => {
97
+ clearTimeout(snapshotTimer)
98
+ snapshotTimer = setTimeout(sendSnapshot, SNAPSHOT_DEBOUNCE_MS)
99
+ }
100
+
101
+ /* Inspector handshakes: `hello` (it just opened — everyone re-announce + push)
102
+ and `request` (it wants a fresh snapshot of this tab). */
103
+ channel.onmessage = (event) => {
104
+ const message = event.data as { kind?: string; tab?: string }
105
+ if (message.kind === 'hello') {
106
+ announce()
107
+ sendSnapshot()
108
+ } else if (message.kind === 'request' && message.tab === tab) {
109
+ sendSnapshot()
110
+ }
111
+ }
112
+
113
+ /* Every doc mutation flows through PATCH_BUS; forward the patch as a live
114
+ mutation event and re-snapshot (debounced) so values stay current. */
115
+ PATCH_BUS.subscribe((patchEvent) => {
116
+ post({ kind: 'patch', op: patchEvent.patch.op, path: patchEvent.patch.path })
117
+ scheduleSnapshot()
118
+ })
119
+
120
+ /* Reading the page + path States tracks them, so this re-runs on navigation. */
121
+ effect(() => {
122
+ clientPage.value
123
+ runtimePath.value
124
+ post({ kind: 'nav', router: routerState() })
125
+ })
126
+
127
+ if (typeof addEventListener === 'function') {
128
+ addEventListener('pagehide', () => {
129
+ post({ kind: 'bye' })
130
+ channel.close()
131
+ })
132
+ }
133
+
134
+ announce()
135
+ /* First snapshot once the router has built the initial scope tree (this runs
136
+ before router()). */
137
+ setTimeout(sendSnapshot, 0)
138
+ }