@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.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +38 -0
- package/package.json +2 -1
- package/src/abideResolverPlugin.ts +3 -17
- package/src/appEntry.ts +18 -8
- package/src/controlServerWorker.ts +8 -1
- package/src/lib/cli/parseArgvForRpc.ts +8 -3
- package/src/lib/mcp/createMcpServer.ts +8 -0
- package/src/lib/mcp/dispatchMcpRequest.ts +20 -2
- package/src/lib/mcp/toolResultFromResponse.ts +5 -0
- package/src/lib/server/rpc/parseArgs.ts +12 -1
- package/src/lib/server/runtime/acceptsGzip.ts +10 -1
- package/src/lib/server/runtime/gzipResponse.ts +2 -1
- package/src/lib/server/runtime/snapshotEntryFromCache.ts +2 -1
- package/src/lib/server/runtime/streamFromIterator.ts +8 -0
- package/src/lib/server/sockets/createSocketDispatcher.ts +7 -1
- package/src/lib/server/sockets/defineSocket.ts +6 -1
- package/src/lib/shared/cache.ts +6 -0
- package/src/lib/shared/createPushIterator.ts +7 -2
- package/src/lib/ui/COMPONENT_WRAPPER_PREFIX.ts +5 -0
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
- package/src/lib/ui/compile/abideUiPlugin.ts +2 -1
- package/src/lib/ui/compile/asOutlet.ts +30 -0
- package/src/lib/ui/compile/compileModule.ts +13 -9
- package/src/lib/ui/compile/generateBuild.ts +83 -72
- package/src/lib/ui/compile/generateSSR.ts +56 -37
- package/src/lib/ui/compile/isAnchorPositioned.ts +19 -0
- package/src/lib/ui/compile/isControlFlow.ts +3 -1
- package/src/lib/ui/compile/isTextLeaf.ts +1 -1
- package/src/lib/ui/compile/parseTemplate.ts +10 -1
- package/src/lib/ui/compile/renameSignalRefs.ts +25 -2
- package/src/lib/ui/compile/skeletonContext.ts +97 -35
- package/src/lib/ui/compile/skeletonable.ts +3 -2
- package/src/lib/ui/compile/types/SkeletonContext.ts +6 -0
- package/src/lib/ui/dom/appendSnippet.ts +60 -20
- package/src/lib/ui/dom/commentData.ts +14 -0
- package/src/lib/ui/dom/disposeRange.ts +21 -0
- package/src/lib/ui/dom/fillBefore.ts +10 -0
- package/src/lib/ui/dom/fillBoundary.ts +38 -0
- package/src/lib/ui/dom/fillRange.ts +30 -0
- package/src/lib/ui/dom/hydrate.ts +11 -21
- package/src/lib/ui/dom/mount.ts +16 -25
- package/src/lib/ui/dom/mountChild.ts +27 -14
- package/src/lib/ui/dom/mountRange.ts +45 -0
- package/src/lib/ui/dom/mountSlot.ts +7 -2
- package/src/lib/ui/dom/outlet.ts +62 -0
- package/src/lib/ui/dom/scopeLabel.ts +13 -7
- package/src/lib/ui/dom/skeleton.ts +11 -27
- package/src/lib/ui/dom/withScope.ts +33 -0
- package/src/lib/ui/installHotBridge.ts +2 -0
- package/src/lib/ui/persist.ts +4 -1
- package/src/lib/ui/renderChain.ts +23 -15
- package/src/lib/ui/router.ts +78 -38
- package/src/lib/ui/runtime/OUTLET_MARKER.ts +10 -0
- package/src/lib/ui/runtime/OUTLET_TAG.ts +5 -6
- package/src/lib/ui/runtime/PENDING_OUTLET.ts +8 -0
- package/src/lib/ui/runtime/captureModelDoc.ts +12 -13
- package/src/lib/ui/runtime/createDoc.ts +20 -7
- package/src/lib/ui/runtime/hotReplace.ts +14 -10
- package/src/lib/ui/runtime/types/HotInstance.ts +11 -7
- package/src/lib/ui/runtime/types/Route.ts +6 -5
- package/src/lib/ui/runtime/types/UiComponent.ts +5 -0
- package/src/lib/ui/seedStreamedResolution.ts +10 -1
- package/src/lib/ui/compile/componentWrapperTag.ts +0 -13
- 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
|
|
3
|
-
its host element's tag.
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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(
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
127
|
-
|
|
128
|
-
|
|
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,
|
package/src/lib/ui/persist.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
8
|
-
const
|
|
9
|
-
|
|
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
|
|
21
|
-
the accumulated child html —
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
/*
|
|
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, () =>
|
|
46
|
+
html = parent.html.replace(OUTLET_PLACEHOLDER, () => OPEN + child + CLOSE)
|
|
41
47
|
}
|
|
42
48
|
return {
|
|
43
|
-
|
|
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
|
}
|
package/src/lib/ui/router.ts
CHANGED
|
@@ -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
|
-
/*
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
type
|
|
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
|
|
23
|
-
|
|
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)
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
/*
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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,
|
|
132
|
-
|
|
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 =
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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
|
-
|
|
387
|
+
disposeFrom(divergence)
|
|
345
388
|
const url = resolveUrl(path)
|
|
346
389
|
clientPage.value = { route: chainRoute, params, url, navigating: false }
|
|
347
|
-
|
|
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
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
+
}
|