@abide/abide 0.38.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 (39) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +8 -0
  3. package/package.json +2 -1
  4. package/src/lib/ui/COMPONENT_WRAPPER_PREFIX.ts +4 -3
  5. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
  6. package/src/lib/ui/compile/asOutlet.ts +6 -5
  7. package/src/lib/ui/compile/compileModule.ts +13 -9
  8. package/src/lib/ui/compile/generateBuild.ts +55 -44
  9. package/src/lib/ui/compile/generateSSR.ts +30 -21
  10. package/src/lib/ui/compile/isAnchorPositioned.ts +19 -0
  11. package/src/lib/ui/compile/isControlFlow.ts +3 -1
  12. package/src/lib/ui/compile/isTextLeaf.ts +1 -1
  13. package/src/lib/ui/compile/skeletonContext.ts +19 -20
  14. package/src/lib/ui/compile/skeletonable.ts +3 -2
  15. package/src/lib/ui/dom/commentData.ts +14 -0
  16. package/src/lib/ui/dom/disposeRange.ts +21 -0
  17. package/src/lib/ui/dom/fillBoundary.ts +38 -0
  18. package/src/lib/ui/dom/fillRange.ts +30 -0
  19. package/src/lib/ui/dom/hydrate.ts +11 -21
  20. package/src/lib/ui/dom/mount.ts +16 -25
  21. package/src/lib/ui/dom/mountChild.ts +27 -14
  22. package/src/lib/ui/dom/mountRange.ts +45 -0
  23. package/src/lib/ui/dom/outlet.ts +62 -0
  24. package/src/lib/ui/dom/scopeLabel.ts +8 -6
  25. package/src/lib/ui/dom/skeleton.ts +11 -30
  26. package/src/lib/ui/dom/withScope.ts +33 -0
  27. package/src/lib/ui/installHotBridge.ts +2 -0
  28. package/src/lib/ui/renderChain.ts +23 -15
  29. package/src/lib/ui/router.ts +78 -38
  30. package/src/lib/ui/runtime/OUTLET_MARKER.ts +10 -0
  31. package/src/lib/ui/runtime/OUTLET_TAG.ts +5 -6
  32. package/src/lib/ui/runtime/PENDING_OUTLET.ts +8 -0
  33. package/src/lib/ui/runtime/captureModelDoc.ts +12 -13
  34. package/src/lib/ui/runtime/hotReplace.ts +14 -10
  35. package/src/lib/ui/runtime/types/HotInstance.ts +11 -7
  36. package/src/lib/ui/runtime/types/Route.ts +6 -5
  37. package/src/lib/ui/runtime/types/UiComponent.ts +5 -0
  38. package/src/lib/ui/compile/componentWrapperTag.ts +0 -15
  39. package/src/lib/ui/runtime/firstOutlet.ts +0 -22
@@ -0,0 +1,38 @@
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 { withScope } from './withScope.ts'
7
+
8
+ /*
9
+ Mounts a chain layer (layout or page) into an EXISTING outlet boundary — the markers
10
+ a parent layout's `<slot/>` left (`outlet`), or the router's root boundary in `#app`.
11
+ The router fills these boundaries instead of mounting into a host element, so the whole
12
+ page/layout chain composes through one range model (no `<abide-outlet>` wrapper).
13
+
14
+ Create: build the layer's content into a fragment that lands just before `close`
15
+ (`fillRange`). Hydrate: claim the server content in place — park the parent cursor at
16
+ `open.nextSibling` and let the build adopt the existing nodes (the outlet markers
17
+ themselves are located via `PENDING_OUTLET`, not claimed through the cursor). The disposer
18
+ stops the layer's reactivity and clears the boundary — the router calls it to tear a
19
+ divergent layer down before rebuilding the same boundary.
20
+ */
21
+ // @documentation plumbing
22
+ export function fillBoundary(
23
+ open: Comment,
24
+ close: Comment,
25
+ build: (host: Node, props?: UiProps) => void,
26
+ props: UiProps | undefined,
27
+ label: string | undefined,
28
+ ): { dispose: () => void } {
29
+ const hydration = RENDER.hydration
30
+ if (hydration === undefined) {
31
+ return fillRange(open, close, build, props, label)
32
+ }
33
+ /* Hydrate: adopt the server content between the markers in place. */
34
+ const parent = open.parentNode as Node
35
+ hydration.next.set(parent, open.nextSibling)
36
+ const scoped = withScope(label, () => scope(() => build(parent, props)))
37
+ return { dispose: disposeRange(scoped, open, close) }
38
+ }
@@ -0,0 +1,30 @@
1
+ import type { UiProps } from '../runtime/types/UiProps.ts'
2
+ import { disposeRange } from './disposeRange.ts'
3
+ import { fillBefore } from './fillBefore.ts'
4
+ import { withScope } from './withScope.ts'
5
+
6
+ /*
7
+ Builds a component's content fresh between two existing range markers (the create
8
+ path), under the component's own lexical scope and render pass — the range analog of
9
+ `mount` for a nested child. `build` appends into a fragment (via `fillBefore`) that
10
+ lands just before `end`, so the content sits in the `[ … ]` range the child mounts
11
+ into rather than at the parent's tail; that range is what makes a component
12
+ selector-transparent (a true direct child of its parent, no `<abide-name>` wrapper).
13
+
14
+ Brackets a render pass (a nested child continues the parent's block-id counter) and
15
+ establishes the child's lexical scope in `awaiting` mode so it adopts the model doc
16
+ its first `doc()` creates. The disposer stops the content's reactivity, disposes the
17
+ lexical scope, and clears the range — leaving the markers, so a hot swap rebuilds in
18
+ place. Shared by `mountRange` (create branch) and `hotReplace` (re-fill on edit).
19
+ */
20
+ // @documentation plumbing
21
+ export function fillRange(
22
+ start: Comment,
23
+ end: Comment,
24
+ build: (host: Node, props?: UiProps) => void,
25
+ props: UiProps | undefined,
26
+ label: string | undefined,
27
+ ): { start: Comment; end: Comment; dispose: () => void } {
28
+ const scoped = withScope(label, () => fillBefore(end, (fragment) => build(fragment, props)))
29
+ return { start, end, dispose: disposeRange(scoped, start, end) }
30
+ }
@@ -1,10 +1,7 @@
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
1
  import { RENDER } from '../runtime/RENDER.ts'
6
2
  import { scope } from '../runtime/scope.ts'
7
3
  import { scopeLabel } from './scopeLabel.ts'
4
+ import { withScope } from './withScope.ts'
8
5
 
9
6
  /*
10
7
  Adopts existing server-rendered DOM instead of rebuilding it. Runs `build(host)`
@@ -15,31 +12,24 @@ focus/scroll). Returns a disposer.
15
12
 
16
13
  Adopts the server DOM in place across the framework: static structure (elements
17
14
  + text + bindings), `if`/`else`, keyed `each`, `switch`, `try`, and child
18
- components (with slots) — they hydrate automatically because the wrapper is
19
- claimed while hydration is still active. `await` adopts too when it can resume
15
+ components (with slots) — they hydrate automatically because a child's marker range
16
+ is claimed while hydration is still active (see `mountRange`). `await` adopts too when it can resume
20
17
  the value (a streamed `RESUME[id]` or a warm-sync/cache read); only a genuinely-
21
18
  pending `await` — no resume, not cache-warm — discards its boundary and builds
22
19
  the pending branch fresh (see `awaitBlock`).
23
20
  */
24
21
  // @documentation plumbing
25
- export function hydrate(host: Element, build: (host: Element) => void): () => void {
22
+ export function hydrate(
23
+ host: Element,
24
+ build: (host: Element, props: unknown) => void,
25
+ props?: unknown,
26
+ ): () => void {
26
27
  const previous = RENDER.hydration
27
28
  RENDER.hydration = { next: new Map() }
28
- enterRenderPass()
29
- /* Same lexical scope establishment as `mount` — a hydrated component owns a scope
30
- too, adopting the model its build adopts. */
31
- const parentScope = CURRENT_SCOPE.current
32
- const lexical = createScope({}, parentScope, true, scopeLabel(host))
33
- CURRENT_SCOPE.current = lexical
34
29
  try {
35
- const stop = scope(() => {
36
- try {
37
- build(host)
38
- } finally {
39
- exitRenderPass()
40
- CURRENT_SCOPE.current = parentScope
41
- }
42
- })
30
+ /* Same shared mount core as `mount` (see `withScope`) a hydrated component owns a
31
+ scope too, adopting the model its build adopts — run with the claim cursor active. */
32
+ const { stop, lexical } = withScope(scopeLabel(host), () => scope(() => build(host, props)))
43
33
  return () => {
44
34
  stop()
45
35
  lexical.dispose()
@@ -1,38 +1,29 @@
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
1
  import { scope } from '../runtime/scope.ts'
6
2
  import { scopeLabel } from './scopeLabel.ts'
3
+ import { withScope } from './withScope.ts'
7
4
 
8
5
  /*
9
- Mounts a component into `host`: runs `build(host)` under an ownership scope so
10
- every binding it creates is collected, and returns a disposer that stops all
11
- reactivity and clears the host. `build` appends its nodes to `host` (via the dom
12
- bindings below). This is the runtime entry the compiler's component output calls.
6
+ Mounts a top-level page/layout into `host` (the router's outlet/root element): runs
7
+ `build(host, props)` under an ownership scope so every binding it creates is
8
+ collected, and returns a disposer that stops all reactivity and clears the host.
9
+ `build` appends its nodes to `host` (via the dom bindings below). This is the runtime
10
+ entry the router calls; a NESTED child instead mounts as a marker range (see
11
+ `mountRange`), so it leaves no wrapper element.
13
12
 
14
13
  Brackets a render pass so the outermost mount resets the block-id counter and an
15
14
  inlined child component's mount continues it — keeping await/try ids aligned with
16
15
  the SSR stream (see `enterRenderPass`).
17
16
  */
18
17
  // @documentation plumbing
19
- export function mount(host: Element, build: (host: Element) => void): () => void {
20
- enterRenderPass()
21
- /* Establish this component's lexical scope, nested under the enclosing one, in
22
- `awaiting` mode so it adopts the model doc the build's first `doc()` creates.
23
- `scope()` and its capabilities resolve to it during the build; the previous
24
- scope is restored after (synchronous build, so the restore is exact). */
25
- const parentScope = CURRENT_SCOPE.current
26
- const lexical = createScope({}, parentScope, true, scopeLabel(host))
27
- CURRENT_SCOPE.current = lexical
28
- const stop = scope(() => {
29
- try {
30
- build(host)
31
- } finally {
32
- exitRenderPass()
33
- CURRENT_SCOPE.current = parentScope
34
- }
35
- })
18
+ export function mount(
19
+ host: Element,
20
+ build: (host: Element, props: unknown) => void,
21
+ props?: unknown,
22
+ ): () => void {
23
+ /* Establish this component's lexical scope (nested, `awaiting` so it adopts the model
24
+ doc the build's first `doc()` creates) and render pass, run the build under it, and
25
+ restore the previous scope the shared mount core (see `withScope`). */
26
+ const { stop, lexical } = withScope(scopeLabel(host), () => scope(() => build(host, props)))
36
27
  return () => {
37
28
  stop()
38
29
  lexical.dispose()
@@ -3,32 +3,45 @@ import { hotReloadEnabled } from '../runtime/hotReloadEnabled.ts'
3
3
  import { OWNER } from '../runtime/OWNER.ts'
4
4
  import { registerHotInstance } from '../runtime/registerHotInstance.ts'
5
5
  import type { UiComponent } from '../runtime/types/UiComponent.ts'
6
+ import { mountRange } from './mountRange.ts'
6
7
 
7
8
  /*
8
- Mounts a child component into its wrapper host. Plain path (production, and dev
9
- without the hot bridge): run the factory exactly the bare call the compiler used
10
- to emit. Hot path (hotReloadEnabled and the factory carries a module id): keep the
11
- mount disposer and record the instance so an edit can dispose + re-run it in place,
12
- and file a cleanup with the mounting owner so the record and its scope leave
13
- together when the parent (or branch/row) tears down. The factory already mounts
14
- under its own scope (see `mount`), so the recorded disposer tears down just this
15
- instance.
9
+ Mounts a child component as a marker-bounded range at `before` in `parent` — no
10
+ wrapper element, so the child's root is a true direct child of the parent (see
11
+ `mountRange`). Plain path (production, and dev without the hot bridge): just run the
12
+ range mount with the component's own `build`. Hot path (hotReloadEnabled and the
13
+ factory carries a module id): keep the mount handle (its range markers + disposer)
14
+ and record the instance so an edit can re-fill the same range in place (see
15
+ `hotReplace`), and file a cleanup with the mounting owner so the record and its scope
16
+ leave together when the parent (or branch/row) tears down.
16
17
  */
17
18
  // @documentation plumbing
18
19
  export function mountChild(
19
- host: Element,
20
+ parent: Node,
20
21
  factory: UiComponent,
21
22
  props: Parameters<UiComponent>[1],
23
+ before: Node | null = null,
24
+ label?: string,
22
25
  ): void {
23
26
  const moduleId = factory.__abideId
24
27
  if (!hotReloadEnabled.current || moduleId === undefined) {
25
- factory(host, props)
28
+ mountRange(parent, factory.build, props, before, label)
26
29
  return
27
30
  }
28
- /* Capture the component's model alongside its disposer, so a later swap can carry
29
- its state across (see `hotReplace`). */
30
- const { dispose, model } = captureModelDoc(() => factory(host, props))
31
- const instance = { host, factory, props, dispose, model }
31
+ /* Capture the component's model alongside its mount handle, so a later swap can
32
+ carry its state across (see `hotReplace`). */
33
+ const { value: handle, model } = captureModelDoc(() =>
34
+ mountRange(parent, factory.build, props, before, label),
35
+ )
36
+ const instance = {
37
+ factory,
38
+ props,
39
+ label,
40
+ start: handle.start,
41
+ end: handle.end,
42
+ dispose: handle.dispose,
43
+ model,
44
+ }
32
45
  const remove = registerHotInstance(moduleId, instance)
33
46
  OWNER.current?.push(() => {
34
47
  instance.dispose()
@@ -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
+ }
@@ -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,10 +1,12 @@
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
  */
9
11
  import { COMPONENT_WRAPPER_PREFIX } from '../COMPONENT_WRAPPER_PREFIX.ts'
10
12
 
@@ -1,7 +1,7 @@
1
- import { COMPONENT_WRAPPER_PREFIX } from '../COMPONENT_WRAPPER_PREFIX.ts'
2
1
  import { claimChild } from '../runtime/claimChild.ts'
3
2
  import { HOLE_ATTRIBUTE } from '../runtime/HOLE_ATTRIBUTE.ts'
4
3
  import { RENDER } from '../runtime/RENDER.ts'
4
+ import { commentData } from './commentData.ts'
5
5
  import { foreignWrapperTag } from './foreignWrapperTag.ts'
6
6
  import type { SkeletonHoles } from './types/SkeletonHoles.ts'
7
7
 
@@ -27,28 +27,6 @@ function isElement(node: Node): node is Element {
27
27
  return typeof (node as Element).hasAttribute === 'function'
28
28
  }
29
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
-
42
- /* A comment node's data, or undefined for elements/text. A comment is a node that is
43
- neither an element (`hasAttribute`) nor a text node (`splitText`); the mini-dom
44
- exposes no `nodeType`, so detect by method. */
45
- function commentData(node: Node): string | undefined {
46
- if (isElement(node) || typeof (node as Text).splitText === 'function') {
47
- return undefined
48
- }
49
- return (node as Comment).data
50
- }
51
-
52
30
  /* Block-range boundary markers. A control-flow block's rendered content sits between an
53
31
  OPEN and CLOSE comment: `[`…`]` for each rows / if / switch / slot ranges, and named
54
32
  `abide:…`…`/abide:…` boundaries for await / try / snippet / html. The skeleton's own
@@ -116,19 +94,22 @@ function indexElementHoles(container: Node, prefix: number[], paths: number[][])
116
94
  In hydrate mode the claimed tree is FULLY EXPANDED — a nested block's rendered content
117
95
  (each rows, branches, await/try boundaries) sits inline — so a naive descent would also
118
96
  collect the inner block's anchors, which belong to that block's OWN skeleton, shifting
119
- every index past the first block. Block content is bounded by range markers, so track
120
- depth per sibling list and take an anchor (and recurse into an element) only at depth 0,
121
- where the skeleton's own structure lives. In create mode the clone is shallow (the blocks
122
- 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. */
123
103
  function scanAnchors(nodes: ArrayLike<Node>, anchors: Node[]): void {
124
104
  let depth = 0
125
105
  for (let index = 0; index < nodes.length; index += 1) {
126
106
  const node = nodes[index] as Node
127
107
  const data = commentData(node)
128
108
  if (data === undefined) {
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)) {
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) {
132
113
  scanAnchors(node.childNodes, anchors)
133
114
  }
134
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,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
  }