@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.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +8 -0
- package/package.json +2 -1
- package/src/lib/ui/COMPONENT_WRAPPER_PREFIX.ts +4 -3
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
- package/src/lib/ui/compile/asOutlet.ts +6 -5
- package/src/lib/ui/compile/compileModule.ts +13 -9
- package/src/lib/ui/compile/generateBuild.ts +55 -44
- package/src/lib/ui/compile/generateSSR.ts +30 -21
- 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/skeletonContext.ts +19 -20
- package/src/lib/ui/compile/skeletonable.ts +3 -2
- package/src/lib/ui/dom/commentData.ts +14 -0
- package/src/lib/ui/dom/disposeRange.ts +21 -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/outlet.ts +62 -0
- package/src/lib/ui/dom/scopeLabel.ts +8 -6
- package/src/lib/ui/dom/skeleton.ts +11 -30
- package/src/lib/ui/dom/withScope.ts +33 -0
- package/src/lib/ui/installHotBridge.ts +2 -0
- 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/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/compile/componentWrapperTag.ts +0 -15
- 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
|
|
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(
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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()
|
package/src/lib/ui/dom/mount.ts
CHANGED
|
@@ -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
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
bindings below). This is the runtime
|
|
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(
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
mount
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
28
|
+
mountRange(parent, factory.build, props, before, label)
|
|
26
29
|
return
|
|
27
30
|
}
|
|
28
|
-
/* Capture the component's model alongside its
|
|
29
|
-
its state across (see `hotReplace`). */
|
|
30
|
-
const {
|
|
31
|
-
|
|
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
|
|
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
|
*/
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
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 {
|
|
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
|
}
|