@abide/abide 0.36.0 → 0.38.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 (61) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +44 -0
  3. package/package.json +1 -1
  4. package/src/abideResolverPlugin.ts +3 -17
  5. package/src/appEntry.ts +18 -8
  6. package/src/controlServerWorker.ts +8 -1
  7. package/src/lib/cli/parseArgvForRpc.ts +8 -3
  8. package/src/lib/mcp/createMcpServer.ts +8 -0
  9. package/src/lib/mcp/dispatchMcpRequest.ts +20 -2
  10. package/src/lib/mcp/toolResultFromResponse.ts +5 -0
  11. package/src/lib/server/rpc/parseArgs.ts +12 -1
  12. package/src/lib/server/runtime/acceptsGzip.ts +10 -1
  13. package/src/lib/server/runtime/buildInFlightSnapshot.ts +35 -0
  14. package/src/lib/server/runtime/buildInspectorSurface.ts +9 -1
  15. package/src/lib/server/runtime/createServer.ts +10 -1
  16. package/src/lib/server/runtime/gzipResponse.ts +2 -1
  17. package/src/lib/server/runtime/inFlightRequests.ts +13 -0
  18. package/src/lib/server/runtime/maybeMountInspector.ts +7 -0
  19. package/src/lib/server/runtime/runWithRequestScope.ts +7 -0
  20. package/src/lib/server/runtime/snapshotEntryFromCache.ts +2 -1
  21. package/src/lib/server/runtime/streamFromIterator.ts +8 -0
  22. package/src/lib/server/runtime/types/InspectorContext.ts +4 -0
  23. package/src/lib/server/runtime/types/InspectorInFlightRequest.ts +20 -0
  24. package/src/lib/server/runtime/types/InspectorInFlightSnapshot.ts +10 -0
  25. package/src/lib/server/runtime/types/InspectorPrompt.ts +15 -0
  26. package/src/lib/server/runtime/types/InspectorSurface.ts +6 -3
  27. package/src/lib/server/sockets/createSocketDispatcher.ts +7 -1
  28. package/src/lib/server/sockets/defineSocket.ts +6 -1
  29. package/src/lib/shared/cache.ts +6 -0
  30. package/src/lib/shared/createPushIterator.ts +7 -2
  31. package/src/lib/shared/emitLogRecord.ts +18 -5
  32. package/src/lib/ui/COMPONENT_WRAPPER_PREFIX.ts +4 -0
  33. package/src/lib/ui/compile/abideUiPlugin.ts +2 -1
  34. package/src/lib/ui/compile/asOutlet.ts +29 -0
  35. package/src/lib/ui/compile/componentWrapperTag.ts +12 -20
  36. package/src/lib/ui/compile/generateBuild.ts +45 -36
  37. package/src/lib/ui/compile/generateSSR.ts +31 -21
  38. package/src/lib/ui/compile/parseTemplate.ts +10 -1
  39. package/src/lib/ui/compile/renameSignalRefs.ts +25 -2
  40. package/src/lib/ui/compile/skeletonContext.ts +97 -34
  41. package/src/lib/ui/compile/types/SkeletonContext.ts +6 -0
  42. package/src/lib/ui/createScope.ts +11 -0
  43. package/src/lib/ui/dom/appendSnippet.ts +60 -20
  44. package/src/lib/ui/dom/fillBefore.ts +10 -0
  45. package/src/lib/ui/dom/hydrate.ts +2 -1
  46. package/src/lib/ui/dom/mount.ts +2 -1
  47. package/src/lib/ui/dom/mountSlot.ts +7 -2
  48. package/src/lib/ui/dom/scopeLabel.ts +19 -0
  49. package/src/lib/ui/dom/skeleton.ts +16 -1
  50. package/src/lib/ui/installInspectorBridge.ts +138 -0
  51. package/src/lib/ui/navigate.ts +11 -3
  52. package/src/lib/ui/persist.ts +4 -1
  53. package/src/lib/ui/router.ts +77 -9
  54. package/src/lib/ui/runtime/createDoc.ts +20 -7
  55. package/src/lib/ui/runtime/historyEntries.ts +113 -0
  56. package/src/lib/ui/runtime/liveScopes.ts +15 -0
  57. package/src/lib/ui/runtime/types/AbideHistoryState.ts +9 -0
  58. package/src/lib/ui/seedStreamedResolution.ts +10 -1
  59. package/src/lib/ui/startClient.ts +6 -0
  60. package/src/lib/ui/types/Scope.ts +3 -0
  61. package/src/lib/ui/compile/HTML_TAGS.ts +0 -132
@@ -5,74 +5,137 @@ import type { SkeletonContext } from './types/SkeletonContext.ts'
5
5
  import type { TemplateNode } from './types/TemplateNode.ts'
6
6
 
7
7
  /*
8
- The single source of truth for where skeleton markers go. One top-down walk records,
9
- per node, whether it sits inside a parser-backed skeleton clone (`inSkeleton`) and
10
- whether its reactive text is interleaved (`markText`) — the two facts that decide
11
- `<!--a-->` anchor placement. Both back-ends read this instead of re-deriving the
12
- position (the client structurally, the server as mutable traversal state), which is
13
- what let them drift: a fresh-context boundary the server forgot to reset leaked an
14
- anchor the client never emitted, desyncing hydration.
8
+ The single source of truth for where skeleton markers go AND what their hole indices are.
9
+ One top-down walk records, per node, whether it sits inside a parser-backed skeleton clone
10
+ (`inSkeleton`) and whether its reactive text is interleaved (`markText`) — the two facts
11
+ that decide `<!--a-->` anchor placement and assigns each hole its `el`/`an` index.
12
+ `generateBuild` reads these indices instead of threading its own counter through a second
13
+ document-order walk, so the numbering cannot drift from the decisions: one walk owns both.
15
14
 
16
- A fresh-context boundary resets to NOT-in-skeleton, because the content there is
17
- built by its own runtime (a control-flow block's branch, a component's slot content,
18
- a `<slot>`'s fallback, a snippet's body) never cloned by the enclosing skeleton
19
- so an enclosing skeletonable subtree must not stamp markers onto it. Enumerating
20
- every such boundary HERE, once, makes "forgetting to reset one" impossible.
15
+ The index assignment is scoped per skeleton root (a `skeletonable` element not already in a
16
+ skeleton the unit `generateSkeleton` instantiates with `{ el: 0, an: 0 }`; a standalone
17
+ component roots its own skeleton too). `el` numbers element holes in pre-order; `an` numbers
18
+ anchor holes (interleaved reactive text PARTS, control-flow blocks, `<slot>` outlets) in
19
+ document order the orders the runtime's `indexElementHoles`/`scanAnchors` re-derive from
20
+ the realized DOM, so the compile-time numbers and the runtime positions line up.
21
+
22
+ A fresh-context boundary resets to NOT-in-skeleton (and to no active counter), because the
23
+ content there is built by its own runtime (a control-flow block's branch, a component's slot
24
+ content, a `<slot>`'s fallback, a snippet's body) — never cloned by the enclosing skeleton.
21
25
  */
22
26
  export function skeletonContext(nodes: TemplateNode[]): SkeletonContext {
23
27
  const inSkeleton = new WeakMap<TemplateNode, boolean>()
24
28
  const markText = new WeakMap<TemplateNode, boolean>()
29
+ /* Element holes keyed by node; anchor holes keyed by node (control-flow/slot) OR by the
30
+ reactive text PART object (a text node carries one anchor per reactive part). */
31
+ const elIndex = new WeakMap<TemplateNode, number>()
32
+ const anIndex = new WeakMap<object, number>()
33
+
34
+ type Counter = { el: number; an: number }
25
35
 
26
- /* Walk `node` carrying the context that applies AT it; recurse into children with the
27
- context that applies to THEM. */
28
- function visit(node: TemplateNode, nodeInSkeleton: boolean, nodeMarkText: boolean): void {
36
+ /* Walk `node` carrying the context AND the active skeleton counter that apply AT it
37
+ (`counter === undefined` outside any skeleton — no holes are numbered there). */
38
+ function visit(
39
+ node: TemplateNode,
40
+ nodeInSkeleton: boolean,
41
+ nodeMarkText: boolean,
42
+ counter: Counter | undefined,
43
+ ): void {
29
44
  inSkeleton.set(node, nodeInSkeleton)
30
45
  markText.set(node, nodeMarkText)
31
46
 
32
- /* Control-flow branches, component slot content, and snippet bodies are fresh build
33
- contexts their children re-enter the skeleton only via their own skeletonable
34
- elements, so reset both flags. (A standalone branch/case is reached as a child of
35
- its control-flow node and likewise resets.) */
47
+ /* Control-flow blocks, components, and snippets are fresh build contexts. The node
48
+ ITSELF is a hole in the enclosing skeleton (an anchor for a block, an element hole
49
+ for a component); its children re-enter the skeleton only via their own roots. */
36
50
  if (isControlFlow(node) || node.kind === 'component' || node.kind === 'snippet') {
51
+ /* A control-flow block takes an anchor only inside an enclosing skeleton (a
52
+ standalone block routes through `generateIf`/etc, not the skeleton path). */
53
+ if (counter !== undefined && isControlFlow(node)) {
54
+ anIndex.set(node, counter.an++)
55
+ }
56
+ /* A component is its OWN element hole either way: in the enclosing skeleton's
57
+ counter when nested, else as the root (index 0) of its own standalone skeleton
58
+ (`generateChild` routes a lone component through `generateSkeleton` too). */
59
+ if (node.kind === 'component') {
60
+ const componentCounter = counter ?? { el: 0, an: 0 }
61
+ elIndex.set(node, componentCounter.el++)
62
+ }
37
63
  for (const child of childrenOf(node)) {
38
- visit(child, false, false)
64
+ visit(child, false, false, undefined)
39
65
  }
40
66
  return
41
67
  }
42
- /* A `branch`/`case` is a transparent grouping inside its control-flow block (its
43
- children are generated directly, the wrapper never emits markup) pass the
44
- already-reset context through so a skeletonable element inside it re-enters. */
68
+ /* A `branch`/`case` is a transparent grouping inside its control-flow block — pass the
69
+ already-reset context (and absent counter) through so a skeletonable element inside
70
+ it opens its own skeleton. */
45
71
  if (node.kind === 'branch' || node.kind === 'case') {
46
72
  for (const child of node.children) {
47
- visit(child, nodeInSkeleton, nodeMarkText)
73
+ visit(child, nodeInSkeleton, nodeMarkText, counter)
74
+ }
75
+ return
76
+ }
77
+ if (node.kind === 'text') {
78
+ /* Interleaved reactive text (markText true): each reactive part takes an `<!--a-->`
79
+ anchor, numbered in document order. A text-leaf's text (markText false) binds
80
+ marker-free via the element, so its parts take no anchor. */
81
+ if (counter !== undefined && nodeMarkText) {
82
+ for (const part of node.parts) {
83
+ if (part.kind !== 'static') {
84
+ anIndex.set(part, counter.an++)
85
+ }
86
+ }
48
87
  }
49
88
  return
50
89
  }
51
90
  if (node.kind !== 'element') {
52
- return // text / script / style carry no skeleton children
91
+ return // script / style carry no skeleton children and no hole
53
92
  }
54
93
  if (node.tag === 'slot') {
55
- /* The slot's own children are its fallback a fresh context built by `mountSlot`,
56
- not the enclosing clone. */
94
+ /* The slot outlet is an anchor hole in the enclosing skeleton; its children are
95
+ the fallback a fresh context built by `mountSlot`. */
96
+ if (counter !== undefined) {
97
+ anIndex.set(node, counter.an++)
98
+ }
57
99
  for (const child of node.children) {
58
- visit(child, false, false)
100
+ visit(child, false, false, undefined)
59
101
  }
60
102
  return
61
103
  }
62
- /* A skeletonable element not already in a skeleton opens one; its descendants are in
63
- skeleton. Reactive text interleaves (takes an anchor) unless this element is a
64
- text-leaf (only text/style children), which binds its text marker-free. */
104
+ /* A skeletonable element not already in a skeleton OPENS one (a fresh counter — the
105
+ `generateSkeleton` unit). An element already in a skeleton uses the enclosing
106
+ counter. A static element outside any skeleton numbers nothing. */
107
+ const opensSkeleton = !nodeInSkeleton && skeletonable(node)
65
108
  const childInSkeleton = nodeInSkeleton || skeletonable(node)
109
+ const effectiveCounter = nodeInSkeleton
110
+ ? counter
111
+ : opensSkeleton
112
+ ? { el: 0, an: 0 }
113
+ : undefined
66
114
  const childMarkText = childInSkeleton && !isTextLeaf(node)
115
+
116
+ if (effectiveCounter !== undefined) {
117
+ /* The element is a located hole when it carries a reactive attr/listener/bind, or
118
+ binds text-leaf reactive text on itself. Take its `el` index BEFORE recursing,
119
+ so holes number in pre-order — the order the runtime's path walk produces them. */
120
+ const hasReactiveAttr = node.attrs.some((attr) => attr.kind !== 'static')
121
+ const reactiveTextChild = node.children.find(
122
+ (child) =>
123
+ child.kind === 'text' && child.parts.some((part) => part.kind !== 'static'),
124
+ )
125
+ const textLeafBind = reactiveTextChild !== undefined && isTextLeaf(node)
126
+ if (hasReactiveAttr || textLeafBind) {
127
+ elIndex.set(node, effectiveCounter.el++)
128
+ }
129
+ }
67
130
  for (const child of node.children) {
68
- visit(child, childInSkeleton, childMarkText)
131
+ visit(child, childInSkeleton, childMarkText, effectiveCounter)
69
132
  }
70
133
  }
71
134
 
72
135
  for (const node of nodes) {
73
- visit(node, false, false)
136
+ visit(node, false, false, undefined)
74
137
  }
75
- return { inSkeleton, markText }
138
+ return { inSkeleton, markText, elIndex, anIndex }
76
139
  }
77
140
 
78
141
  /* The child list of any node that has one (control-flow, component, snippet, element);
@@ -12,4 +12,10 @@ filled by one shared `skeletonContext` pass over the parsed tree.
12
12
  export type SkeletonContext = {
13
13
  inSkeleton: WeakMap<TemplateNode, boolean>
14
14
  markText: WeakMap<TemplateNode, boolean>
15
+ /* Per-hole indices assigned in the same walk, so `generateBuild` reads its `sk.el`/`sk.an`
16
+ numbering rather than re-deriving it. `elIndex` keyed by element/component node;
17
+ `anIndex` keyed by control-flow/slot node OR by a reactive text PART object (a text node
18
+ carries one anchor per reactive part). */
19
+ elIndex: WeakMap<TemplateNode, number>
20
+ anIndex: WeakMap<object, number>
15
21
  }
@@ -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
  }
@@ -1,34 +1,74 @@
1
1
  import { snippetPayload } from '../../shared/snippet.ts'
2
- import { claimChild } from '../runtime/claimChild.ts'
2
+ import { effect } from '../effect.ts'
3
3
  import { RENDER } from '../runtime/RENDER.ts'
4
+ import { scope } from '../runtime/scope.ts'
5
+ import { scopeGroup } from '../runtime/scopeGroup.ts'
6
+ import { clearBetween } from './clearBetween.ts'
7
+ import { fillBefore } from './fillBefore.ts'
8
+ import { openMarker } from './openMarker.ts'
4
9
 
5
10
  /*
6
- A `{snippet(args)}` interpolation: mount the branded builder's nodes at this
7
- position. The builder builds straight into `parent` — sequential build order
8
- places it correctly among siblings, and its effects join the surrounding
9
- component scope (so they tear down with it). The body's reactive reads update
10
- fine-grained via those effects; an enclosing `each` re-mounts on list changes.
11
+ A `{snippet(args)}` interpolation: mount the branded builder's nodes in a range
12
+ bounded by two comment markers. The content builds straight into `parent` —
13
+ sequential build order places it correctly among siblings and its effects join
14
+ the surrounding component scope, so the body's reactive reads update fine-grained.
15
+
16
+ The CALL is reactive in its arguments: an effect re-reads `read()` so a change in
17
+ the argument expression (e.g. `{row(items())}`) tears the range down and rebuilds
18
+ with fresh args. `read()` returns a fresh builder closing over freshly-evaluated
19
+ args each call, so there is nothing to diff — the snippet re-mounts as a unit, the
20
+ same coarse model as `when`/`each` (args behave like props). The body's own reads
21
+ stay fine-grained within a single mount; the outer effect tracks only the args,
22
+ since `fillBefore` wraps the body in its own scope.
11
23
 
12
24
  On hydrate the builder runs against the server-rendered nodes between the
13
25
  `<!--abide:snippet-->`/`<!--/abide:snippet-->` markers — its `skeleton`/`appendText`
14
- claim them in place. The cursor is advanced past the open marker before, and past
15
- the close marker after, so the markers themselves are skipped.
26
+ claim them in place and the effect's first run is a no-op that only subscribes to
27
+ the args; a later argument change rebuilds fresh (the SSR markers stay as the range).
16
28
  */
17
29
  // @documentation plumbing
18
30
  export function appendSnippet(parent: Node, read: () => unknown): void {
19
- const builder = snippetPayload(read())
20
- if (typeof builder !== 'function') {
21
- return
22
- }
23
- const mount = builder as (host: Node) => void
24
31
  const hydration = RENDER.hydration
32
+ /* Mount scopes register with the owner so they dispose on owner teardown, not
33
+ only on an argument-driven rebuild via clearBetween. */
34
+ const group = scopeGroup()
35
+ let dispose: (() => void) | undefined
36
+
37
+ /* The branded builder, or undefined for anything else (a non-snippet value
38
+ mounts nothing — the range stays empty until the args yield a builder). */
39
+ const builderOf = (): ((host: Node) => void) | undefined => {
40
+ const payload = snippetPayload(read())
41
+ return typeof payload === 'function' ? (payload as (host: Node) => void) : undefined
42
+ }
43
+
44
+ let open: Comment
45
+ let close: Comment
25
46
  if (hydration !== undefined) {
26
- const open = claimChild(hydration, parent)
27
- hydration.next.set(parent, open === null ? null : open.nextSibling)
28
- mount(parent)
29
- const close = claimChild(hydration, parent)
30
- hydration.next.set(parent, close === null ? null : close.nextSibling)
31
- return
47
+ open = openMarker(parent, 'abide:snippet')
48
+ const builder = builderOf()
49
+ if (builder !== undefined) {
50
+ dispose = group.track(scope(() => builder(parent))) // content claims the SSR nodes in place
51
+ }
52
+ close = openMarker(parent, '/abide:snippet')
53
+ } else {
54
+ open = openMarker(parent, 'abide:snippet')
55
+ close = openMarker(parent, '/abide:snippet')
56
+ const builder = builderOf()
57
+ if (builder !== undefined) {
58
+ dispose = group.track(fillBefore(close, builder))
59
+ }
32
60
  }
33
- mount(parent)
61
+
62
+ /* The initial mount is built above (create or hydrate); the first effect run only
63
+ subscribes to the args via `builderOf`, then later argument changes rebuild. */
64
+ let first = true
65
+ effect(() => {
66
+ const builder = builderOf()
67
+ if (first) {
68
+ first = false
69
+ return
70
+ }
71
+ clearBetween(open, close, dispose)
72
+ dispose = builder !== undefined ? group.track(fillBefore(close, builder)) : undefined
73
+ })
34
74
  }
@@ -18,6 +18,16 @@ would otherwise make the build helpers claim SSR nodes that don't exist for fres
18
18
  content. The same cursor is restored after (mirrors awaitBlock/tryBlock/each).
19
19
  */
20
20
  export function fillBefore(end: Node, content: (into: Node) => void): () => void {
21
+ /* A control-flow effect can fire one final time after its block was already
22
+ detached — e.g. an enclosing await/each block tears the branch down in the same
23
+ microtask flush that re-ran this effect, before the owner scope disposes it. The
24
+ end marker then has no live parent, so there is nowhere to build into; inserting
25
+ a fragment before a parentless comment throws HierarchyRequestError ("would yield
26
+ an incorrect node tree"). Skip the rebuild — owner teardown disposes this dead
27
+ block anyway. */
28
+ if (!end.parentNode) {
29
+ return () => {}
30
+ }
21
31
  const fragment = document.createDocumentFragment()
22
32
  const previousHydration = RENDER.hydration
23
33
  RENDER.hydration = undefined
@@ -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 {
@@ -1,5 +1,6 @@
1
1
  import { RENDER } from '../runtime/RENDER.ts'
2
2
  import { scope } from '../runtime/scope.ts'
3
+ import { scopeGroup } from '../runtime/scopeGroup.ts'
3
4
  import { fillBefore } from './fillBefore.ts'
4
5
  import { openMarker } from './openMarker.ts'
5
6
 
@@ -21,12 +22,16 @@ export function mountSlot(
21
22
  before: Node | null = null,
22
23
  ): void {
23
24
  const hydration = RENDER.hydration
25
+ /* The slot content's scope, registered with the owner so its effects/listeners
26
+ dispose on owner teardown (a navigation) — the slot never toggles, so the
27
+ group only ever tracks this one child. */
28
+ const group = scopeGroup()
24
29
  openMarker(parent, '[', before)
25
30
  if (hydration !== undefined) {
26
- scope(() => render(parent)) // content claims the SSR range in place
31
+ group.track(scope(() => render(parent))) // content claims the SSR range in place
27
32
  openMarker(parent, ']')
28
33
  } else {
29
34
  const end = openMarker(parent, ']', before)
30
- fillBefore(end, render)
35
+ group.track(fillBefore(end, render))
31
36
  }
32
37
  }
@@ -0,0 +1,19 @@
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
+ import { COMPONENT_WRAPPER_PREFIX } from '../COMPONENT_WRAPPER_PREFIX.ts'
10
+
11
+ export function scopeLabel(host: Element): string | undefined {
12
+ const tag = host.tagName?.toLowerCase()
13
+ if (tag === undefined) {
14
+ return undefined
15
+ }
16
+ return tag.startsWith(COMPONENT_WRAPPER_PREFIX)
17
+ ? tag.slice(COMPONENT_WRAPPER_PREFIX.length)
18
+ : tag
19
+ }
@@ -1,3 +1,4 @@
1
+ import { COMPONENT_WRAPPER_PREFIX } from '../COMPONENT_WRAPPER_PREFIX.ts'
1
2
  import { claimChild } from '../runtime/claimChild.ts'
2
3
  import { HOLE_ATTRIBUTE } from '../runtime/HOLE_ATTRIBUTE.ts'
3
4
  import { RENDER } from '../runtime/RENDER.ts'
@@ -26,6 +27,18 @@ function isElement(node: Node): node is Element {
26
27
  return typeof (node as Element).hasAttribute === 'function'
27
28
  }
28
29
 
30
+ /* A child component's mount wrapper (`abide-<name>`, see `componentWrapperTag`). Its
31
+ content is a SEPARATE skeleton (the child's own), so the parent's walks must treat it
32
+ as opaque: in the shallow skeleton it's an empty leaf, so the compiler counts no
33
+ anchors inside it; on hydrate it's populated, so a descent would over-collect the
34
+ child's anchors and shift every parent index past it (same hazard as a block range,
35
+ but bounded by the wrapper element instead of `[`…`]` markers). */
36
+ function isComponentWrapper(node: Node): boolean {
37
+ return (
38
+ isElement(node) && (node.tagName ?? '').toLowerCase().startsWith(COMPONENT_WRAPPER_PREFIX)
39
+ )
40
+ }
41
+
29
42
  /* A comment node's data, or undefined for elements/text. A comment is a node that is
30
43
  neither an element (`hasAttribute`) nor a text node (`splitText`); the mini-dom
31
44
  exposes no `nodeType`, so detect by method. */
@@ -113,7 +126,9 @@ function scanAnchors(nodes: ArrayLike<Node>, anchors: Node[]): void {
113
126
  const node = nodes[index] as Node
114
127
  const data = commentData(node)
115
128
  if (data === undefined) {
116
- if (isElement(node) && depth === 0) {
129
+ /* Recurse into this skeleton's own elements, but NOT a child component's
130
+ wrapper — its anchors belong to the child's skeleton (see above). */
131
+ if (isElement(node) && depth === 0 && !isComponentWrapper(node)) {
117
132
  scanAnchors(node.childNodes, anchors)
118
133
  }
119
134
  } else if (isCloseMarker(data)) {
@@ -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
+ }
@@ -1,16 +1,24 @@
1
+ import { historyEntries } from './runtime/historyEntries.ts'
1
2
  import { runtimePath } from './runtime/runtimePath.ts'
2
3
 
3
4
  /* Navigates to `path`: writes a history entry (when available) and updates the
4
5
  reactive route, which re-mounts the matching page via `router`. `replace` swaps
5
6
  the current entry instead of pushing — used when honouring a server redirect, so
6
- the blocked URL isn't left behind in history. */
7
+ the blocked URL isn't left behind in history. Each entry carries a monotonic
8
+ `abideEntry` id so the router can bucket/restore its scroll offset across the page
9
+ teardown the rebuild does. A push leaves the current entry behind — its scroll is
10
+ bucketed so back restores it — and mints a fresh id. A replace destroys the current
11
+ entry and lands fresh content (a redirect), so its saved scroll no longer applies:
12
+ the bucket is discarded and the id kept, so the new page restores to top/anchor. */
7
13
  // @documentation navigate
8
14
  export function navigate(path: string, replace = false): void {
9
15
  if (typeof history !== 'undefined') {
10
16
  if (replace) {
11
- history.replaceState({}, '', path)
17
+ historyEntries.discard()
18
+ history.replaceState({ abideEntry: historyEntries.current }, '', path)
12
19
  } else {
13
- history.pushState({}, '', path)
20
+ historyEntries.save()
21
+ history.pushState({ abideEntry: historyEntries.next() }, '', path)
14
22
  }
15
23
  }
16
24
  runtimePath.value = path
@@ -1,3 +1,4 @@
1
+ import { escapeKey } from './runtime/escapeKey.ts'
1
2
  import { localStoragePersistence } from './runtime/localStoragePersistence.ts'
2
3
  import { PATCH_BUS } from './runtime/PATCH_BUS.ts'
3
4
  import type { Doc } from './runtime/types/Doc.ts'
@@ -99,8 +100,10 @@ export function persist(
99
100
  function restore(doc: Doc, saved: unknown): void {
100
101
  const current = doc.snapshot()
101
102
  if (isPlainObject(saved) && isPlainObject(current)) {
103
+ /* `replace` takes a `/`-delimited escaped path, so a top-level key containing
104
+ `/` or `~` must be escaped to a single segment or it'd be mis-routed. */
102
105
  for (const key of Object.keys(saved)) {
103
- doc.replace(key, saved[key])
106
+ doc.replace(escapeKey(key), saved[key])
104
107
  }
105
108
  return
106
109
  }