@abide/abide 0.37.0 → 0.38.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +38 -0
  3. package/package.json +2 -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/gzipResponse.ts +2 -1
  14. package/src/lib/server/runtime/snapshotEntryFromCache.ts +2 -1
  15. package/src/lib/server/runtime/streamFromIterator.ts +8 -0
  16. package/src/lib/server/sockets/createSocketDispatcher.ts +7 -1
  17. package/src/lib/server/sockets/defineSocket.ts +6 -1
  18. package/src/lib/shared/cache.ts +6 -0
  19. package/src/lib/shared/createPushIterator.ts +7 -2
  20. package/src/lib/ui/COMPONENT_WRAPPER_PREFIX.ts +5 -0
  21. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
  22. package/src/lib/ui/compile/abideUiPlugin.ts +2 -1
  23. package/src/lib/ui/compile/asOutlet.ts +30 -0
  24. package/src/lib/ui/compile/compileModule.ts +13 -9
  25. package/src/lib/ui/compile/generateBuild.ts +83 -72
  26. package/src/lib/ui/compile/generateSSR.ts +56 -37
  27. package/src/lib/ui/compile/isAnchorPositioned.ts +19 -0
  28. package/src/lib/ui/compile/isControlFlow.ts +3 -1
  29. package/src/lib/ui/compile/isTextLeaf.ts +1 -1
  30. package/src/lib/ui/compile/parseTemplate.ts +10 -1
  31. package/src/lib/ui/compile/renameSignalRefs.ts +25 -2
  32. package/src/lib/ui/compile/skeletonContext.ts +97 -35
  33. package/src/lib/ui/compile/skeletonable.ts +3 -2
  34. package/src/lib/ui/compile/types/SkeletonContext.ts +6 -0
  35. package/src/lib/ui/dom/appendSnippet.ts +60 -20
  36. package/src/lib/ui/dom/commentData.ts +14 -0
  37. package/src/lib/ui/dom/disposeRange.ts +21 -0
  38. package/src/lib/ui/dom/fillBefore.ts +10 -0
  39. package/src/lib/ui/dom/fillBoundary.ts +38 -0
  40. package/src/lib/ui/dom/fillRange.ts +30 -0
  41. package/src/lib/ui/dom/hydrate.ts +11 -21
  42. package/src/lib/ui/dom/mount.ts +16 -25
  43. package/src/lib/ui/dom/mountChild.ts +27 -14
  44. package/src/lib/ui/dom/mountRange.ts +45 -0
  45. package/src/lib/ui/dom/mountSlot.ts +7 -2
  46. package/src/lib/ui/dom/outlet.ts +62 -0
  47. package/src/lib/ui/dom/scopeLabel.ts +13 -7
  48. package/src/lib/ui/dom/skeleton.ts +11 -27
  49. package/src/lib/ui/dom/withScope.ts +33 -0
  50. package/src/lib/ui/installHotBridge.ts +2 -0
  51. package/src/lib/ui/persist.ts +4 -1
  52. package/src/lib/ui/renderChain.ts +23 -15
  53. package/src/lib/ui/router.ts +78 -38
  54. package/src/lib/ui/runtime/OUTLET_MARKER.ts +10 -0
  55. package/src/lib/ui/runtime/OUTLET_TAG.ts +5 -6
  56. package/src/lib/ui/runtime/PENDING_OUTLET.ts +8 -0
  57. package/src/lib/ui/runtime/captureModelDoc.ts +12 -13
  58. package/src/lib/ui/runtime/createDoc.ts +20 -7
  59. package/src/lib/ui/runtime/hotReplace.ts +14 -10
  60. package/src/lib/ui/runtime/types/HotInstance.ts +11 -7
  61. package/src/lib/ui/runtime/types/Route.ts +6 -5
  62. package/src/lib/ui/runtime/types/UiComponent.ts +5 -0
  63. package/src/lib/ui/seedStreamedResolution.ts +10 -1
  64. package/src/lib/ui/compile/componentWrapperTag.ts +0 -13
  65. package/src/lib/ui/runtime/firstOutlet.ts +0 -22
@@ -0,0 +1,45 @@
1
+ import { RENDER } from '../runtime/RENDER.ts'
2
+ import { scope } from '../runtime/scope.ts'
3
+ import type { UiProps } from '../runtime/types/UiProps.ts'
4
+ import { disposeRange } from './disposeRange.ts'
5
+ import { fillRange } from './fillRange.ts'
6
+ import { openMarker } from './openMarker.ts'
7
+ import { withScope } from './withScope.ts'
8
+
9
+ /*
10
+ Mounts a nested child component as a marker-bounded range — the wrapper-free
11
+ replacement for the old `<abide-name display:contents>` host. A component positions
12
+ and hydrates exactly like a control-flow block: its content lives in a `[ … ]` range,
13
+ so the child's real root is a TRUE direct child of the parent and structural CSS
14
+ (`>`, `:first-child`, `space-x`, grid placement) reaches it with no indirection —
15
+ which `display:contents` could never give, since it hides the wrapper from layout but
16
+ not from the selector tree.
17
+
18
+ Create fills the range before the end marker (`fillRange`); hydrate claims the server
19
+ range in place (claim the start marker, build claims the content, claim the end
20
+ marker — mirrors `mountSlot`/`when`). `before` (a skeleton-located node, the block's
21
+ `anchorCursor`) places the range among static siblings on create; hydrate ignores it
22
+ (the claim cursor drives placement). Returns the markers + a disposer so the hot path
23
+ can rebuild in place.
24
+ */
25
+ // @documentation plumbing
26
+ export function mountRange(
27
+ parent: Node,
28
+ build: (host: Node, props?: UiProps) => void,
29
+ props: UiProps | undefined,
30
+ before: Node | null = null,
31
+ label: string | undefined = undefined,
32
+ ): { start: Comment; end: Comment; dispose: () => void } {
33
+ const hydration = RENDER.hydration
34
+ const start = openMarker(parent, '[', before)
35
+ if (hydration === undefined) {
36
+ const end = openMarker(parent, ']', before)
37
+ return fillRange(start, end, build, props, label)
38
+ }
39
+ /* Hydrate: adopt the server range in place. Establish the child's lexical scope
40
+ and render pass (same as `fillRange`), build claiming the existing nodes, then
41
+ claim the end marker the build's content stops before. */
42
+ const scoped = withScope(label, () => scope(() => build(parent, props)))
43
+ const end = openMarker(parent, ']')
44
+ return { start, end, dispose: disposeRange(scoped, start, end) }
45
+ }
@@ -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,62 @@
1
+ import { claimExpected } from '../runtime/claimExpected.ts'
2
+ import { OUTLET_CLOSE, OUTLET_OPEN } from '../runtime/OUTLET_MARKER.ts'
3
+ import { PENDING_OUTLET } from '../runtime/PENDING_OUTLET.ts'
4
+ import { RENDER } from '../runtime/RENDER.ts'
5
+ import { commentData } from './commentData.ts'
6
+
7
+ /*
8
+ A layout's `<slot/>` outlet: an empty `<!--abide:outlet-->`…`<!--/abide:outlet-->`
9
+ boundary the router fills with the next chain layer (`fillBoundary`). No wrapper
10
+ element, so the filled child lays out as a direct child of the slot's parent.
11
+
12
+ Create: insert the empty boundary at `before` (the anchor's cursor). Hydrate: claim the
13
+ open marker, then SKIP the server-rendered child content — the router re-claims it when
14
+ it fills the boundary — by advancing the parent's claim cursor past the MATCHING close
15
+ (depth-counting nested outlets), so the layout's own claims after the slot stay aligned.
16
+ Records the boundary in `PENDING_OUTLET` (and returns it) so the router learns where the
17
+ next chain layer mounts without scanning the DOM.
18
+ */
19
+ // @documentation plumbing
20
+ export function outlet(
21
+ parent: Node,
22
+ before: Node | null = null,
23
+ ): { open: Comment; close: Comment } {
24
+ const hydration = RENDER.hydration
25
+ if (hydration === undefined) {
26
+ const open = document.createComment(OUTLET_OPEN)
27
+ const close = document.createComment(OUTLET_CLOSE)
28
+ parent.insertBefore(open, before)
29
+ parent.insertBefore(close, before)
30
+ PENDING_OUTLET.current = { open, close }
31
+ return { open, close }
32
+ }
33
+ const open = claimExpected(hydration, parent, 'outlet open marker') as Comment
34
+ /* Skip to the matching close: depth-count outlet markers (nested child-layer slots
35
+ are balanced), so a layout wrapping another layout skips its WHOLE subtree. */
36
+ let depth = 1
37
+ let node: Node | null = open.nextSibling
38
+ while (node !== null) {
39
+ const data = commentData(node)
40
+ if (data === OUTLET_OPEN) {
41
+ depth += 1
42
+ } else if (data === OUTLET_CLOSE) {
43
+ depth -= 1
44
+ if (depth === 0) {
45
+ break
46
+ }
47
+ }
48
+ node = node.nextSibling
49
+ }
50
+ /* No matching close — the server stream ended inside the slot (a mid-render error
51
+ or truncation). Throw AT the divergence like `claimExpected`, rather than store a
52
+ null close that a later `clearBetween(open, null)` would over-clear to end-of-parent. */
53
+ if (node === null) {
54
+ throw new Error(
55
+ '[abide] hydration desync: outlet open marker has no matching close — the server DOM is truncated inside a layout slot.',
56
+ )
57
+ }
58
+ const close = node as Comment
59
+ hydration.next.set(parent, close.nextSibling)
60
+ PENDING_OUTLET.current = { open, close }
61
+ return { open, close }
62
+ }
@@ -1,15 +1,21 @@
1
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.
2
+ The human-readable name for the scope the direct-mount entry (`mount`/`hydrate`)
3
+ establishes, derived from its host element's tag. The router instead fills layers into
4
+ marker boundaries with no host element, so it passes each layer's route key as an
5
+ explicit label (`fillBoundary`); a nested child likewise passes its component name (see
6
+ `mountRange`). The `abide-` prefix strip turns a framework host tag like `abide-resolve`
7
+ into `resolve`; any other host yields its lowercased tag. Dev-only feeds the
8
+ inspector's Reactive tab a readable scope name. Returns undefined when there's no element
9
+ to name from.
8
10
  */
11
+ import { COMPONENT_WRAPPER_PREFIX } from '../COMPONENT_WRAPPER_PREFIX.ts'
12
+
9
13
  export function scopeLabel(host: Element): string | undefined {
10
14
  const tag = host.tagName?.toLowerCase()
11
15
  if (tag === undefined) {
12
16
  return undefined
13
17
  }
14
- return tag.startsWith('abide-') ? tag.slice('abide-'.length) : tag
18
+ return tag.startsWith(COMPONENT_WRAPPER_PREFIX)
19
+ ? tag.slice(COMPONENT_WRAPPER_PREFIX.length)
20
+ : tag
15
21
  }
@@ -1,6 +1,7 @@
1
1
  import { claimChild } from '../runtime/claimChild.ts'
2
2
  import { HOLE_ATTRIBUTE } from '../runtime/HOLE_ATTRIBUTE.ts'
3
3
  import { RENDER } from '../runtime/RENDER.ts'
4
+ import { commentData } from './commentData.ts'
4
5
  import { foreignWrapperTag } from './foreignWrapperTag.ts'
5
6
  import type { SkeletonHoles } from './types/SkeletonHoles.ts'
6
7
 
@@ -26,26 +27,6 @@ function isElement(node: Node): node is Element {
26
27
  return typeof (node as Element).hasAttribute === 'function'
27
28
  }
28
29
 
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
-
39
- /* A comment node's data, or undefined for elements/text. A comment is a node that is
40
- neither an element (`hasAttribute`) nor a text node (`splitText`); the mini-dom
41
- exposes no `nodeType`, so detect by method. */
42
- function commentData(node: Node): string | undefined {
43
- if (isElement(node) || typeof (node as Text).splitText === 'function') {
44
- return undefined
45
- }
46
- return (node as Comment).data
47
- }
48
-
49
30
  /* Block-range boundary markers. A control-flow block's rendered content sits between an
50
31
  OPEN and CLOSE comment: `[`…`]` for each rows / if / switch / slot ranges, and named
51
32
  `abide:…`…`/abide:…` boundaries for await / try / snippet / html. The skeleton's own
@@ -113,19 +94,22 @@ function indexElementHoles(container: Node, prefix: number[], paths: number[][])
113
94
  In hydrate mode the claimed tree is FULLY EXPANDED — a nested block's rendered content
114
95
  (each rows, branches, await/try boundaries) sits inline — so a naive descent would also
115
96
  collect the inner block's anchors, which belong to that block's OWN skeleton, shifting
116
- every index past the first block. Block content is bounded by range markers, so track
117
- depth per sibling list and take an anchor (and recurse into an element) only at depth 0,
118
- where the skeleton's own structure lives. In create mode the clone is shallow (the blocks
119
- have not built yet — no markers), so depth stays 0 and this is a plain document scan. */
97
+ every index past the first block. Block AND child-component content is bounded by range
98
+ markers (a component mounts as a `[`…`]` range at its anchor, like a block see
99
+ `mountRange`), so track depth per sibling list and take an anchor (and recurse into an
100
+ element) only at depth 0, where the skeleton's own structure lives. In create mode the
101
+ clone is shallow (the ranges have not built yet — no markers), so depth stays 0 and this
102
+ is a plain document scan. */
120
103
  function scanAnchors(nodes: ArrayLike<Node>, anchors: Node[]): void {
121
104
  let depth = 0
122
105
  for (let index = 0; index < nodes.length; index += 1) {
123
106
  const node = nodes[index] as Node
124
107
  const data = commentData(node)
125
108
  if (data === undefined) {
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)) {
109
+ /* Recurse into this skeleton's own elements at depth 0. A child component's
110
+ content sits inside its `[`…`]` range (depth > 0), so it is skipped like any
111
+ block range its anchors belong to the child's own skeleton. */
112
+ if (isElement(node) && depth === 0) {
129
113
  scanAnchors(node.childNodes, anchors)
130
114
  }
131
115
  } else if (isCloseMarker(data)) {
@@ -0,0 +1,33 @@
1
+ import { createScope } from '../createScope.ts'
2
+ import { CURRENT_SCOPE } from '../runtime/CURRENT_SCOPE.ts'
3
+ import { enterRenderPass } from '../runtime/enterRenderPass.ts'
4
+ import { exitRenderPass } from '../runtime/exitRenderPass.ts'
5
+ import type { Scope } from '../types/Scope.ts'
6
+
7
+ /*
8
+ The shared mount core every page/layout/component build path runs (`mount`, `hydrate`,
9
+ `fillRange`, `mountRange`, `fillBoundary`). Establishes the layer's lexical scope nested
10
+ under the current one in `awaiting` mode — so it adopts the model doc its first `doc()`
11
+ creates — brackets a render pass — so its `await`/`try` block ids draw from the shared
12
+ counter in SSR-stream order — runs `build`, then restores the previous scope (synchronous
13
+ build, so the restore is exact). `build` returns its reactivity stopper (from `scope` or
14
+ `fillBefore`); the caller composes its own DOM teardown (clear host vs clear range) around
15
+ the returned `stop`/`lexical`, keeping the one scope/render-pass contract in a single place.
16
+ */
17
+ export function withScope(
18
+ label: string | undefined,
19
+ build: () => () => void,
20
+ ): { stop: () => void; lexical: Scope } {
21
+ const parentScope = CURRENT_SCOPE.current
22
+ const lexical = createScope({}, parentScope, true, label)
23
+ enterRenderPass()
24
+ CURRENT_SCOPE.current = lexical
25
+ let stop: () => void = () => undefined
26
+ try {
27
+ stop = build()
28
+ } finally {
29
+ exitRenderPass()
30
+ CURRENT_SCOPE.current = parentScope
31
+ }
32
+ return { stop, lexical }
33
+ }
@@ -16,6 +16,7 @@ import { mount } from './dom/mount.ts'
16
16
  import { mountChild } from './dom/mountChild.ts'
17
17
  import { mountSlot } from './dom/mountSlot.ts'
18
18
  import { on } from './dom/on.ts'
19
+ import { outlet } from './dom/outlet.ts'
19
20
  import { skeleton } from './dom/skeleton.ts'
20
21
  import { switchBlock } from './dom/switchBlock.ts'
21
22
  import { tryBlock } from './dom/tryBlock.ts'
@@ -67,6 +68,7 @@ export function installHotBridge(): void {
67
68
  tryBlock,
68
69
  switchBlock,
69
70
  mountSlot,
71
+ outlet,
70
72
  mountChild,
71
73
  hydrate,
72
74
  escapeKey,
@@ -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
  }
@@ -1,12 +1,13 @@
1
1
  import { enterRenderPass } from './runtime/enterRenderPass.ts'
2
2
  import { exitRenderPass } from './runtime/exitRenderPass.ts'
3
- import { OUTLET_TAG } from './runtime/OUTLET_TAG.ts'
3
+ import { OUTLET_CLOSE, OUTLET_OPEN } from './runtime/OUTLET_MARKER.ts'
4
4
  import type { SsrRender } from './runtime/types/SsrRender.ts'
5
5
  import type { UiComponent } from './runtime/types/UiComponent.ts'
6
6
 
7
- const OUTLET_OPEN = `<${OUTLET_TAG}>`
8
- const OUTLET_CLOSE = `</${OUTLET_TAG}>`
9
- const OUTLET_PLACEHOLDER = `${OUTLET_OPEN}${OUTLET_CLOSE}`
7
+ const OPEN = `<!--${OUTLET_OPEN}-->`
8
+ const CLOSE = `<!--${OUTLET_CLOSE}-->`
9
+ /* A layout's empty outlet boundary, before the child layer is folded in. */
10
+ const OUTLET_PLACEHOLDER = `${OPEN}${CLOSE}`
10
11
 
11
12
  /*
12
13
  Server-renders a route's layout chain wrapped around its page into one SsrRender.
@@ -17,12 +18,13 @@ across all layers draws a unique id from the shared counter — in the same
17
18
  layer-sequential order the client hydrates them, keeping the streamed fragments and
18
19
  the resume manifest aligned.
19
20
 
20
- The html nests inner-to-outer: each parent's empty `<abide-outlet>` is filled with
21
- the accumulated child html — the outlet ELEMENT is kept (it stays the live mount
22
- container the client router fills/hydrates the child into, found by tag), so the
23
- SSR DOM and the client-nested DOM match exactly. Awaits concatenate (already
24
- uniquely numbered); state merges. A single component renders unchanged its own
25
- render with no wrapping. A layout missing its `<slot/>` is a build error surfaced here.
21
+ The html nests inner-to-outer: each parent layout's empty outlet boundary
22
+ (`<!--abide:outlet--><!--/abide:outlet-->`) is filled with the accumulated child html —
23
+ no `<abide-outlet>` ELEMENT, so the filled child lays out as a direct child of the
24
+ slot's parent (the router mounts/hydrates it as a marker range, see `outlet`/`fillBoundary`).
25
+ The whole chain is wrapped in a ROOT boundary the router fills into `#app`. Awaits
26
+ concatenate (already uniquely numbered); state merges. A layout missing its `<slot/>` is
27
+ a build error surfaced here.
26
28
  */
27
29
  export function renderChain(views: UiComponent[], params: Record<string, string>): SsrRender {
28
30
  enterRenderPass()
@@ -31,16 +33,22 @@ export function renderChain(views: UiComponent[], params: Record<string, string>
31
33
  let html = renders[renders.length - 1]?.html ?? ''
32
34
  for (let index = renders.length - 2; index >= 0; index -= 1) {
33
35
  const parent = renders[index] as SsrRender
34
- if (!parent.html.includes(OUTLET_PLACEHOLDER)) {
36
+ /* EXACTLY one outlet, not at-least-one: `.replace` fills only the first, and
37
+ the client router fills the LAST `outlet()` call's boundary (`PENDING_OUTLET`),
38
+ so a second outlet would mount the SSR child and the hydrated child into
39
+ DIFFERENT slots — a silent desync. Throw at build instead. */
40
+ if (parent.html.split(OUTLET_PLACEHOLDER).length - 1 !== 1) {
35
41
  throw new Error('[abide] a layout.abide must contain exactly one <slot/> outlet')
36
42
  }
37
- /* Fill the outlet element (keep it), function replacement so `$&`/`$\`` in
38
- the child html insert literally. */
43
+ /* Fold the child between the outlet markers (function replacement so `$&`/`$\``
44
+ in the child html insert literally). */
39
45
  const child = html
40
- html = parent.html.replace(OUTLET_PLACEHOLDER, () => OUTLET_OPEN + child + OUTLET_CLOSE)
46
+ html = parent.html.replace(OUTLET_PLACEHOLDER, () => OPEN + child + CLOSE)
41
47
  }
42
48
  return {
43
- html,
49
+ /* Root boundary — the router fills `#app` by claiming/creating this same
50
+ boundary and mounting the outermost layer (or lone page) into it. */
51
+ html: OPEN + html + CLOSE,
44
52
  awaits: renders.flatMap((render) => render.awaits),
45
53
  state: Object.assign({}, ...renders.map((render) => render.state)),
46
54
  }
@@ -1,12 +1,15 @@
1
1
  import { layoutChainForRoute } from '../shared/layoutChainForRoute.ts'
2
+ import { fillBoundary } from './dom/fillBoundary.ts'
3
+ import { outlet } from './dom/outlet.ts'
2
4
  import { effect } from './effect.ts'
3
5
  import { matchRoute } from './matchRoute.ts'
4
6
  import { navigate } from './navigate.ts'
5
7
  import { clientPage } from './runtime/clientPage.ts'
6
8
  import { enterRenderPass } from './runtime/enterRenderPass.ts'
7
9
  import { exitRenderPass } from './runtime/exitRenderPass.ts'
8
- import { firstOutlet } from './runtime/firstOutlet.ts'
9
10
  import { historyEntries } from './runtime/historyEntries.ts'
11
+ import { PENDING_OUTLET } from './runtime/PENDING_OUTLET.ts'
12
+ import { RENDER } from './runtime/RENDER.ts'
10
13
  import { runtimePath } from './runtime/runtimePath.ts'
11
14
  import type { AbideHistoryState } from './runtime/types/AbideHistoryState.ts'
12
15
  import type { NavVerdict } from './runtime/types/NavVerdict.ts'
@@ -14,13 +17,15 @@ import type { Route } from './runtime/types/Route.ts'
14
17
  import type { RouteLoader } from './runtime/types/RouteLoader.ts'
15
18
  import { untrack } from './runtime/untrack.ts'
16
19
 
17
- /* A layout mounted in the active chain: its route key (the directory URL — its
18
- identity for the diff), the disposer that stops its reactivity, and the outlet
19
- element the next layer mounts into. */
20
- type MountedLayout = { key: string; dispose: () => void; outlet: Element }
20
+ /* An outlet boundary the `<!--abide:outlet-->`…`<!--/abide:outlet-->` marker pair a
21
+ layer's content lives between (a layout's `<slot/>`, or the router's root boundary in
22
+ the mount host). The next chain layer fills it; no `<abide-outlet>` element. */
23
+ type Boundary = { open: Comment; close: Comment }
21
24
 
22
- /* A layout mount that returns no disposer still needs one for the chain teardown. */
23
- const noop = (): void => {}
25
+ /* A layout mounted in the active chain: its route key (the directory URL — its identity
26
+ for the diff), the disposer that stops its reactivity and clears its content, and the
27
+ boundary of its own `<slot/>` — where the next layer mounts. */
28
+ type MountedLayout = { key: string; dispose: () => void; slot: Boundary }
24
29
 
25
30
  /* The destination URL for a navigation `path`. On the server / headless there is no
26
31
  `location`, so resolve against a localhost origin; in the browser, against the real
@@ -68,6 +73,10 @@ export function router(
68
73
  /* The mounted layout chain (outermost first) + the page disposer. */
69
74
  const mountedLayouts: MountedLayout[] = []
70
75
  let disposePage: (() => void) | undefined
76
+ /* The root outlet boundary in `host` (`#app`) the outermost layer fills — established
77
+ once on the first mount (claimed from the SSR DOM when hydrating, created otherwise)
78
+ and reused across navigations, since `#app` itself never re-mounts. */
79
+ let rootBoundary: Boundary | undefined
71
80
  const patterns = Object.keys(loaders).filter((key) => key !== '*')
72
81
  const layoutKeys = Object.keys(layoutLoaders)
73
82
 
@@ -90,60 +99,94 @@ export function router(
90
99
  const resolvePage = resolver(loaders)
91
100
  const resolveLayout = resolver(layoutLoaders)
92
101
 
93
- /* Tear down the page and every layout from `index` inward (innermost first), then
94
- return the container the rebuild mounts into the surviving layout's outlet, or
95
- `host` when the whole chain goes. */
96
- const disposeFrom = (index: number): Element => {
102
+ /* Tear down the page and every layout from `index` inward (innermost first). Each
103
+ layer's disposer stops its reactivity and clears its content from its boundary
104
+ (the outermost cleared range removes the inner DOM too — harmless double-clears).
105
+ Leaves the boundary markers in place so the rebuild fills the same boundary. */
106
+ const disposeFrom = (index: number): void => {
97
107
  disposePage?.()
98
108
  disposePage = undefined
99
109
  for (let depth = mountedLayouts.length - 1; depth >= index; depth -= 1) {
100
110
  mountedLayouts[depth]?.dispose()
101
111
  }
102
- const base = index === 0 ? host : (mountedLayouts[index - 1] as MountedLayout).outlet
103
112
  mountedLayouts.length = index
104
- return base
105
113
  }
106
114
 
107
- /* Mount (or hydrate) the chain tail — layouts `[index..]` then the page into
108
- `base`, threading each layer into the previous one's outlet. Hydration brackets
109
- ONE render pass across all layers so await/try block ids stay unique and aligned
110
- with the SSR stream; a fresh mount needs no shared pass. */
115
+ /* The outlet boundary the layer at `index` fills: the root boundary for the outermost
116
+ layer, else the surviving parent layout's `<slot/>`. */
117
+ const baseBoundary = (index: number): Boundary =>
118
+ index === 0 ? (rootBoundary as Boundary) : (mountedLayouts[index - 1] as MountedLayout).slot
119
+
120
+ /* Build (or hydrate) the chain tail — layouts `[index..]` then the page — filling each
121
+ layer into the previous one's `<slot/>` boundary (the root boundary for the
122
+ outermost). `outlet()` records each layer's own slot in `PENDING_OUTLET` as the
123
+ layer builds, so the next layer knows where to mount — no DOM scan. Hydration
124
+ brackets ONE render pass + claim cursor across all layers so await/try block ids
125
+ stay unique and aligned with the SSR stream; a fresh mount needs no shared pass. */
111
126
  const buildFrom = (
112
- base: Element,
113
127
  index: number,
114
128
  chainKeys: string[],
115
129
  layoutViews: Route[],
116
130
  pageView: Route | undefined,
131
+ pageKey: string,
117
132
  params: Record<string, string>,
118
133
  hydrating: boolean,
119
134
  ): void => {
120
135
  const run = (): void => {
121
- let container = base
136
+ /* Establish the root boundary on the first mount — `outlet(host)` claims the
137
+ SSR root boundary (hydrating) or creates it (fresh), recording it in
138
+ `PENDING_OUTLET`. Reused across navigations thereafter. A fresh first mount
139
+ (no claim cursor — e.g. a non-hydratable page whose SSR shell can't be
140
+ adopted) discards whatever the server put in `#app` first, so the created
141
+ boundary is the only content. */
142
+ if (rootBoundary === undefined) {
143
+ if (RENDER.hydration === undefined) {
144
+ host.textContent = ''
145
+ }
146
+ outlet(host)
147
+ rootBoundary = PENDING_OUTLET.current as Boundary
148
+ }
149
+ let boundary = baseBoundary(index)
122
150
  for (let depth = index; depth < layoutViews.length; depth += 1) {
123
151
  const view = layoutViews[depth] as Route
124
- const dispose = hydrating
125
- ? (view.hydrate as NonNullable<Route['hydrate']>)(container, params)
126
- : (view(container, params) ?? noop)
127
- const outlet = firstOutlet(container)
128
- if (outlet === undefined) {
152
+ PENDING_OUTLET.current = undefined
153
+ const { dispose } = fillBoundary(
154
+ boundary.open,
155
+ boundary.close,
156
+ view.build,
157
+ params,
158
+ /* The layout's route key names its scope in the inspector's Reactive tab
159
+ (no host element to read a tag from — see `scopeLabel`). */
160
+ chainKeys[depth],
161
+ )
162
+ const slot = PENDING_OUTLET.current
163
+ if (slot === undefined) {
129
164
  throw new Error('[abide] a layout.abide must contain a <slot/> outlet')
130
165
  }
131
- mountedLayouts.push({ key: chainKeys[depth] as string, dispose, outlet })
132
- container = outlet
166
+ mountedLayouts.push({ key: chainKeys[depth] as string, dispose, slot })
167
+ boundary = slot
133
168
  }
134
169
  if (pageView === undefined) {
135
170
  return
136
171
  }
137
- disposePage = hydrating
138
- ? (pageView.hydrate as NonNullable<Route['hydrate']>)(container, params)
139
- : (pageView(container, params) ?? undefined)
172
+ disposePage = fillBoundary(
173
+ boundary.open,
174
+ boundary.close,
175
+ pageView.build,
176
+ params,
177
+ /* The page's route key names its scope in the inspector (see above). */
178
+ pageKey,
179
+ ).dispose
140
180
  }
141
181
  if (hydrating) {
182
+ const previous = RENDER.hydration
183
+ RENDER.hydration = { next: new Map() }
142
184
  enterRenderPass()
143
185
  try {
144
186
  run()
145
187
  } finally {
146
188
  exitRenderPass()
189
+ RENDER.hydration = previous
147
190
  }
148
191
  return
149
192
  }
@@ -329,11 +372,11 @@ export function router(
329
372
  ) {
330
373
  divergence += 1
331
374
  }
332
- const hydrating =
333
- first && pageView?.hydratable === true && pageView.hydrate !== undefined
375
+ const hydrating = first && pageView?.hydratable === true
334
376
  first = false
335
- /* The DOM mutation a navigation makes: tear the divergent chain down,
336
- clear its DOM (a fresh mount; hydration adopts in place), rebuild. */
377
+ /* The DOM mutation a navigation makes: tear the divergent chain down
378
+ (clearing its content from its boundary) and rebuild into the same
379
+ boundary (hydration adopts in place). */
337
380
  const swap = (): void => {
338
381
  /* Tear the outgoing page + divergent layouts down BEFORE publishing the
339
382
  new snapshot. Publishing first would re-run the doomed leaf page's
@@ -341,13 +384,10 @@ export function router(
341
384
  `undefined`, e.g. `Number(page.params.id)` → NaN → a bogus request)
342
385
  while it's still mounted. Disposing first kills that scope; surviving
343
386
  prefix layouts then update in place on publish. */
344
- const base = disposeFrom(divergence)
387
+ disposeFrom(divergence)
345
388
  const url = resolveUrl(path)
346
389
  clientPage.value = { route: chainRoute, params, url, navigating: false }
347
- if (!hydrating) {
348
- base.textContent = ''
349
- }
350
- buildFrom(base, divergence, chainKeys, layoutViews, pageView, params, hydrating)
390
+ buildFrom(divergence, chainKeys, layoutViews, pageView, key, params, hydrating)
351
391
  /* Reapply the destination entry's scroll once its DOM exists — a
352
392
  back/forward restores its offset, a fresh nav scrolls to the `#hash`
353
393
  anchor (now built) or the top. Runs on the initial paint too: with
@@ -0,0 +1,10 @@
1
+ /* The comment-marker boundary a layout's `<slot/>` outlet lowers to — replacing the old
2
+ `<abide-outlet>` ELEMENT, so the next chain layer the router fills in lays out as a true
3
+ direct child of the slot's parent (no wrapper box breaking the layout's flex/grid/`:first-child`).
4
+
5
+ `abide:outlet` / `/abide:outlet` deliberately match `skeleton`'s `isOpenMarker`/`isCloseMarker`
6
+ (the `abide:` / `/abide:` convention), so a layout's own hole-scanning treats the outlet's
7
+ future child content as a balanced range and skips it — exactly like an `await`/`try` boundary.
8
+ The router fills the boundary with the next layer (see `outlet`/`fillBoundary`). */
9
+ export const OUTLET_OPEN = 'abide:outlet'
10
+ export const OUTLET_CLOSE = '/abide:outlet'
@@ -1,8 +1,7 @@
1
- /* The element a layout's `<slot/>` lowers to: an empty structural container the
2
- router fills with the next layer of the route's layout chain (the nested layout
3
- or the page). SSR emits it empty so the renderer can fold the child's html into
4
- it; on the client the router mounts/hydrates the child into it and finds it by
5
- tag. Shared by both compiler back-ends, the SSR chain composer, and the router so
6
- they all agree on the marker. */
1
+ /* The compile-time AST tag a layout's `<slot/>` is rewritten to (`asOutlet`). It is a
2
+ sentinel only never a rendered element: both compiler back-ends lower it to an empty
3
+ `<!--abide:outlet-->`…`<!--/abide:outlet-->` comment boundary (`outlet` on the client,
4
+ the marker string in SSR) the router fills with the next chain layer. Shared by both
5
+ back-ends and `skeletonContext` so they all agree which node is the outlet. */
7
6
  // @documentation plumbing
8
7
  export const OUTLET_TAG = 'abide-outlet'
@@ -0,0 +1,8 @@
1
+ /* The outlet boundary the most recent `outlet()` call established (a layout's `<slot/>`
2
+ fill point, or the router's root boundary in `#app`). The router reads it right after
3
+ building/claiming a layer — synchronously, so it is exactly that layer's single slot —
4
+ to learn where the NEXT chain layer mounts, without scanning the DOM. A layer with no
5
+ slot (the leaf page) leaves it whatever the router reset it to. */
6
+ export const PENDING_OUTLET: { current: { open: Comment; close: Comment } | undefined } = {
7
+ current: undefined,
8
+ }