@abide/abide 0.41.0 → 0.41.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 +327 -326
- package/CHANGELOG.md +44 -0
- package/README.md +87 -78
- package/package.json +1 -1
- package/src/lib/ui/compile/SSR_ESCAPE.ts +3 -1
- package/src/lib/ui/compile/compileShadow.ts +5 -0
- package/src/lib/ui/compile/generateBuild.ts +11 -4
- package/src/lib/ui/compile/generateSSR.ts +17 -8
- package/src/lib/ui/compile/skeletonContext.ts +67 -79
- package/src/lib/ui/compile/templateAnchorAdapter.ts +61 -0
- package/src/lib/ui/compile/templateElementAdapter.ts +44 -0
- package/src/lib/ui/compile/walkAnchorOrder.ts +57 -0
- package/src/lib/ui/compile/walkElementOrder.ts +60 -0
- package/src/lib/ui/dom/appendSnippet.ts +9 -8
- package/src/lib/ui/dom/applyResolved.ts +3 -4
- package/src/lib/ui/dom/awaitBlock.ts +37 -31
- package/src/lib/ui/dom/buildDetachedRange.ts +30 -0
- package/src/lib/ui/dom/depthZeroNodes.ts +34 -0
- package/src/lib/ui/dom/domAnchorAdapter.ts +29 -0
- package/src/lib/ui/dom/domElementAdapter.ts +20 -0
- package/src/lib/ui/dom/each.ts +7 -11
- package/src/lib/ui/dom/eachAsync.ts +12 -17
- package/src/lib/ui/dom/isElement.ts +6 -0
- package/src/lib/ui/dom/markerDepthDelta.ts +19 -0
- package/src/lib/ui/dom/mountRange.ts +4 -3
- package/src/lib/ui/dom/mountSlot.ts +4 -3
- package/src/lib/ui/dom/on.ts +6 -1
- package/src/lib/ui/dom/replaceRange.ts +24 -0
- package/src/lib/ui/dom/skeleton.ts +35 -92
- package/src/lib/ui/dom/switchBlock.ts +13 -10
- package/src/lib/ui/dom/when.ts +13 -10
- package/src/lib/ui/runtime/RANGE_MARKER.ts +16 -0
- package/src/lib/ui/runtime/batch.ts +22 -0
- package/src/lib/ui/runtime/clientPage.ts +3 -8
- package/src/lib/ui/runtime/createDoc.ts +9 -13
- package/src/lib/ui/seedResolved.ts +28 -0
- package/src/lib/ui/startClient.ts +6 -4
- package/src/lib/ui/types/ResolvedFrame.ts +15 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { RANGE_CLOSE, RANGE_OPEN } from '../runtime/RANGE_MARKER.ts'
|
|
2
|
+
import { scope } from '../runtime/scope.ts'
|
|
3
|
+
import { enterNamespace } from './enterNamespace.ts'
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
Builds a `[ … ]`-bounded content range into a DETACHED fragment under a fresh reactive
|
|
7
|
+
scope, then returns the markers, the fragment, and the scope disposer — leaving
|
|
8
|
+
INSERTION to the caller. The create-path range builder for the keyed list runtimes
|
|
9
|
+
(`each` / `eachAsync`): `each` holds the fragment in `pending` for deferred placement
|
|
10
|
+
(its reconcile reorders rows), `eachAsync` inserts it immediately at the stream anchor
|
|
11
|
+
(arrival order). Both need the same bracketed-fragment build, so it lives here once.
|
|
12
|
+
|
|
13
|
+
Unlike `openMarker`, this never touches a live parent or the hydrate claim cursor — the
|
|
14
|
+
markers are created fresh and parked in a fragment — so it is a pure CREATE primitive;
|
|
15
|
+
the hydrate paths claim their server markers directly. `namespaceParent` sets the
|
|
16
|
+
ambient foreign namespace for the build (a row's svg/math children read it off the
|
|
17
|
+
fragment's eventual parent), matching `fillBefore`/`enterNamespace`.
|
|
18
|
+
*/
|
|
19
|
+
export function buildDetachedRange(
|
|
20
|
+
namespaceParent: Node,
|
|
21
|
+
build: (into: Node) => void,
|
|
22
|
+
): { start: Comment; end: Comment; fragment: DocumentFragment; dispose: () => void } {
|
|
23
|
+
const start = document.createComment(RANGE_OPEN)
|
|
24
|
+
const end = document.createComment(RANGE_CLOSE)
|
|
25
|
+
const fragment = document.createDocumentFragment()
|
|
26
|
+
fragment.appendChild(start)
|
|
27
|
+
const dispose = enterNamespace(namespaceParent, () => scope(() => build(fragment)))
|
|
28
|
+
fragment.appendChild(end)
|
|
29
|
+
return { start, end, fragment, dispose }
|
|
30
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { commentData } from './commentData.ts'
|
|
2
|
+
import { markerDepthDelta } from './markerDepthDelta.ts'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
A sibling list's DEPTH-0 nodes — those belonging to THIS skeleton's own structure, excluding
|
|
6
|
+
any node inside a block/component's `[`…`]` / `abide:` range (depth > 0), whose anchors belong
|
|
7
|
+
to that range's OWN skeleton. The realized-DOM analogue of the compiler's fresh-context
|
|
8
|
+
boundary: the shared anchor walk (`walkAnchorOrder`) classifies one node at a time and so can't
|
|
9
|
+
skip a sibling RUN, so the marker-depth skip lives here, feeding the walk only own-skeleton
|
|
10
|
+
nodes. In create mode the clone is shallow (no markers built yet), so depth stays 0 and every
|
|
11
|
+
node is returned — a plain document scan.
|
|
12
|
+
*/
|
|
13
|
+
export function depthZeroNodes(nodes: ArrayLike<Node>): Node[] {
|
|
14
|
+
const own: Node[] = []
|
|
15
|
+
let depth = 0
|
|
16
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
17
|
+
const node = nodes[index] as Node
|
|
18
|
+
const data = commentData(node)
|
|
19
|
+
if (data === undefined) {
|
|
20
|
+
if (depth === 0) {
|
|
21
|
+
own.push(node)
|
|
22
|
+
}
|
|
23
|
+
continue
|
|
24
|
+
}
|
|
25
|
+
const delta = markerDepthDelta(data)
|
|
26
|
+
if (delta !== 0) {
|
|
27
|
+
depth += delta
|
|
28
|
+
} else if (depth === 0) {
|
|
29
|
+
/* A non-marker comment (this skeleton's own `a` anchor) at depth 0. */
|
|
30
|
+
own.push(node)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return own
|
|
34
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { AnchorRole, AnchorWalkAdapter } from '../compile/walkAnchorOrder.ts'
|
|
2
|
+
import { ANCHOR } from '../runtime/RANGE_MARKER.ts'
|
|
3
|
+
import { commentData } from './commentData.ts'
|
|
4
|
+
import { depthZeroNodes } from './depthZeroNodes.ts'
|
|
5
|
+
import { isElement } from './isElement.ts'
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
The realized-DOM side of the shared anchor-ordering rule (`walkAnchorOrder`). Classifies each
|
|
9
|
+
recovered node by the SAME positions the template-AST side numbered (`templateAnchorAdapter`),
|
|
10
|
+
so `scanAnchors`'s collection lines up with the compiler's `anIndex`.
|
|
11
|
+
|
|
12
|
+
An `a` comment is this skeleton's own anchor (one position); an element is an own-skeleton
|
|
13
|
+
container to descend; anything else contributes nothing. `childrenOf` returns only depth-0
|
|
14
|
+
nodes — a nested block/component's marker-bracketed content is skipped exactly as the compiler
|
|
15
|
+
stops at a fresh-context boundary.
|
|
16
|
+
*/
|
|
17
|
+
export const domAnchorAdapter: AnchorWalkAdapter<Node> = {
|
|
18
|
+
classify: (node: Node): AnchorRole => {
|
|
19
|
+
const data = commentData(node)
|
|
20
|
+
if (data === ANCHOR) {
|
|
21
|
+
return { kind: 'anchor', positions: [node] }
|
|
22
|
+
}
|
|
23
|
+
if (data === undefined && isElement(node)) {
|
|
24
|
+
return { kind: 'recurse' }
|
|
25
|
+
}
|
|
26
|
+
return { kind: 'skip' }
|
|
27
|
+
},
|
|
28
|
+
childrenOf: (node: Node): readonly Node[] => depthZeroNodes(node.childNodes),
|
|
29
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ElementRole, ElementWalkAdapter } from '../compile/walkElementOrder.ts'
|
|
2
|
+
import { HOLE_ATTRIBUTE } from '../runtime/HOLE_ATTRIBUTE.ts'
|
|
3
|
+
import { isElement } from './isElement.ts'
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
The parsed-DOM side of the shared element-hole numbering rule (`walkElementOrder`). Classifies
|
|
7
|
+
each node of the parsed skeleton by the SAME holes the template-AST side numbered
|
|
8
|
+
(`templateElementAdapter`), so the runtime's collected element paths line up with the compiler's
|
|
9
|
+
`elIndex`. Runs over the SHALLOW parsed skeleton (blocks/components/slots are already `<!--a-->`
|
|
10
|
+
anchors, not elements), so a `HOLE_ATTRIBUTE` element is a hole and every element is descended;
|
|
11
|
+
everything else skips. The collected paths resolve against the expanded server DOM separately
|
|
12
|
+
(`resolveElementHole`, which skips nested ranges there).
|
|
13
|
+
*/
|
|
14
|
+
export const domElementAdapter: ElementWalkAdapter<Node> = {
|
|
15
|
+
classify: (node: Node): ElementRole =>
|
|
16
|
+
isElement(node)
|
|
17
|
+
? { kind: 'element', isHole: node.hasAttribute(HOLE_ATTRIBUTE) }
|
|
18
|
+
: { kind: 'skip' },
|
|
19
|
+
childrenOf: (node: Node): readonly Node[] => Array.from(node.childNodes),
|
|
20
|
+
}
|
package/src/lib/ui/dom/each.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { scope } from '../runtime/scope.ts'
|
|
|
6
6
|
import { scopeGroup } from '../runtime/scopeGroup.ts'
|
|
7
7
|
import type { State } from '../runtime/types/State.ts'
|
|
8
8
|
import { state } from '../state.ts'
|
|
9
|
-
import {
|
|
9
|
+
import { buildDetachedRange } from './buildDetachedRange.ts'
|
|
10
10
|
import { moveRange } from './moveRange.ts'
|
|
11
11
|
import { removeRange } from './removeRange.ts'
|
|
12
12
|
import type { EachRow } from './types/EachRow.ts'
|
|
@@ -70,17 +70,13 @@ export function each<T>(
|
|
|
70
70
|
hydration.next.set(parent, end.nextSibling)
|
|
71
71
|
return { start, end, dispose, cell, indexCell }
|
|
72
72
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
built into the detached fragment are namespaced, not built as HTML. */
|
|
79
|
-
const dispose = group.track(
|
|
80
|
-
enterNamespace(parent, () => scope(() => render(pending, cell as State<T>, indexCell))),
|
|
73
|
+
/* Shared detached-range create primitive: a `[ … ]` fragment built under `parent`'s
|
|
74
|
+
foreign namespace (so svg/math row children are namespaced, not built as HTML),
|
|
75
|
+
held in `pending` until placement inserts it. */
|
|
76
|
+
const { start, end, fragment, dispose } = buildDetachedRange(parent, (host) =>
|
|
77
|
+
render(host, cell as State<T>, indexCell),
|
|
81
78
|
)
|
|
82
|
-
|
|
83
|
-
return { start, end, dispose, cell, indexCell, pending }
|
|
79
|
+
return { start, end, dispose: group.track(dispose), cell, indexCell, pending: fragment }
|
|
84
80
|
}
|
|
85
81
|
|
|
86
82
|
/* Place a row so its range ends just before `cursor`: insert a fresh row's
|
|
@@ -2,11 +2,10 @@ import { effect } from '../effect.ts'
|
|
|
2
2
|
import { claimChild } from '../runtime/claimChild.ts'
|
|
3
3
|
import { OWNER } from '../runtime/OWNER.ts'
|
|
4
4
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
5
|
-
import { scope } from '../runtime/scope.ts'
|
|
6
5
|
import { scopeGroup } from '../runtime/scopeGroup.ts'
|
|
7
6
|
import type { State } from '../runtime/types/State.ts'
|
|
8
7
|
import { state } from '../state.ts'
|
|
9
|
-
import {
|
|
8
|
+
import { buildDetachedRange } from './buildDetachedRange.ts'
|
|
10
9
|
import { removeRange } from './removeRange.ts'
|
|
11
10
|
import type { EachRow } from './types/EachRow.ts'
|
|
12
11
|
|
|
@@ -49,24 +48,20 @@ export function eachAsync<T>(
|
|
|
49
48
|
parent.insertBefore(anchor, before) // `before` places rows before a static suffix
|
|
50
49
|
}
|
|
51
50
|
|
|
52
|
-
/* Build a content range
|
|
53
|
-
range (no item cell) — the data-row
|
|
51
|
+
/* Build a content range (the shared detached-range create primitive) and insert it
|
|
52
|
+
just before the anchor (arrival order). A bare range (no item cell) — the data-row
|
|
53
|
+
site adds the cell, the error branch needs none. */
|
|
54
54
|
const insertRange = (
|
|
55
55
|
build: (into: Node) => void,
|
|
56
56
|
): { start: Node; end: Node; dispose: () => void } => {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
)
|
|
64
|
-
|
|
65
|
-
/* Insert via the anchor's LIVE parent: when this `each` is a bare child of a
|
|
66
|
-
control-flow branch, the captured `parent` is the branch's build fragment,
|
|
67
|
-
emptied into the document once the enclosing block placed it. */
|
|
68
|
-
;(anchor.parentNode ?? parent).insertBefore(fragment, anchor)
|
|
69
|
-
return { start, end, dispose }
|
|
57
|
+
/* Namespace the build off the anchor's LIVE parent, and insert there too: when this
|
|
58
|
+
`each` is a bare child of a control-flow branch, the captured `parent` is the
|
|
59
|
+
branch's build fragment, emptied into the document once the enclosing block
|
|
60
|
+
placed it. */
|
|
61
|
+
const host = anchor.parentNode ?? parent
|
|
62
|
+
const { start, end, fragment, dispose } = buildDetachedRange(host, build)
|
|
63
|
+
host.insertBefore(fragment, anchor)
|
|
64
|
+
return { start, end, dispose: group.track(dispose) }
|
|
70
65
|
}
|
|
71
66
|
|
|
72
67
|
/* The mounted `<template catch>` range, disposed when a fresh run re-streams. */
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/* An element carries `hasAttribute`; comment/text nodes do not. Detected by method (not
|
|
2
|
+
`nodeType`) so every skeleton walk runs under the test mini-dom too. Shared by the
|
|
3
|
+
element-hole path resolver (`skeleton`) and both realized-DOM walk adapters. */
|
|
4
|
+
export function isElement(node: Node): node is Element {
|
|
5
|
+
return typeof (node as Element).hasAttribute === 'function'
|
|
6
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { RANGE_CLOSE, RANGE_OPEN } from '../runtime/RANGE_MARKER.ts'
|
|
2
|
+
|
|
3
|
+
/* The block-range nesting rule, the single source both marker-depth scans share
|
|
4
|
+
(`depthZeroNodes`'s anchor filter and `skeleton`'s `elementChildAt` element walk):
|
|
5
|
+
`+1` opening a range, `-1` closing one, `0` for any other comment. A control-flow
|
|
6
|
+
block's rendered content sits between an OPEN and CLOSE comment — `[`…`]` for each
|
|
7
|
+
rows / if / switch / slot ranges, named `abide:…`…`/abide:…` for await / try /
|
|
8
|
+
snippet / html; the skeleton's own anchor (`a`) sits OUTSIDE any range, so it scores
|
|
9
|
+
`0`. Keeping the rule here means a new marker family updates one place, so the two
|
|
10
|
+
scans can never disagree on what nests. */
|
|
11
|
+
export function markerDepthDelta(data: string): number {
|
|
12
|
+
if (data === RANGE_CLOSE || data.startsWith('/abide:')) {
|
|
13
|
+
return -1
|
|
14
|
+
}
|
|
15
|
+
if (data === RANGE_OPEN || data.startsWith('abide:')) {
|
|
16
|
+
return 1
|
|
17
|
+
}
|
|
18
|
+
return 0
|
|
19
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { RANGE_CLOSE, RANGE_OPEN } from '../runtime/RANGE_MARKER.ts'
|
|
1
2
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
2
3
|
import { scope } from '../runtime/scope.ts'
|
|
3
4
|
import type { UiProps } from '../runtime/types/UiProps.ts'
|
|
@@ -31,15 +32,15 @@ export function mountRange(
|
|
|
31
32
|
label: string | undefined = undefined,
|
|
32
33
|
): { start: Comment; end: Comment; dispose: () => void } {
|
|
33
34
|
const hydration = RENDER.hydration
|
|
34
|
-
const start = openMarker(parent,
|
|
35
|
+
const start = openMarker(parent, RANGE_OPEN, before)
|
|
35
36
|
if (hydration === undefined) {
|
|
36
|
-
const end = openMarker(parent,
|
|
37
|
+
const end = openMarker(parent, RANGE_CLOSE, before)
|
|
37
38
|
return fillRange(start, end, build, props, label)
|
|
38
39
|
}
|
|
39
40
|
/* Hydrate: adopt the server range in place. Establish the child's lexical scope
|
|
40
41
|
and render pass (same as `fillRange`), build claiming the existing nodes, then
|
|
41
42
|
claim the end marker the build's content stops before. */
|
|
42
43
|
const scoped = withScope(label, () => scope(() => build(parent, props)))
|
|
43
|
-
const end = openMarker(parent,
|
|
44
|
+
const end = openMarker(parent, RANGE_CLOSE)
|
|
44
45
|
return { start, end, dispose: disposeRange(scoped, start, end) }
|
|
45
46
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { RANGE_CLOSE, RANGE_OPEN } from '../runtime/RANGE_MARKER.ts'
|
|
1
2
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
2
3
|
import { scope } from '../runtime/scope.ts'
|
|
3
4
|
import { scopeGroup } from '../runtime/scopeGroup.ts'
|
|
@@ -26,12 +27,12 @@ export function mountSlot(
|
|
|
26
27
|
dispose on owner teardown (a navigation) — the slot never toggles, so the
|
|
27
28
|
group only ever tracks this one child. */
|
|
28
29
|
const group = scopeGroup()
|
|
29
|
-
openMarker(parent,
|
|
30
|
+
openMarker(parent, RANGE_OPEN, before)
|
|
30
31
|
if (hydration !== undefined) {
|
|
31
32
|
group.track(scope(() => render(parent))) // content claims the SSR range in place
|
|
32
|
-
openMarker(parent,
|
|
33
|
+
openMarker(parent, RANGE_CLOSE)
|
|
33
34
|
} else {
|
|
34
|
-
const end = openMarker(parent,
|
|
35
|
+
const end = openMarker(parent, RANGE_CLOSE, before)
|
|
35
36
|
group.track(fillBefore(end, render))
|
|
36
37
|
}
|
|
37
38
|
}
|
package/src/lib/ui/dom/on.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { batch } from '../runtime/batch.ts'
|
|
1
2
|
import { CURRENT_SCOPE } from '../runtime/CURRENT_SCOPE.ts'
|
|
2
3
|
import { inScope } from '../runtime/inScope.ts'
|
|
3
4
|
import { OWNER } from '../runtime/OWNER.ts'
|
|
@@ -20,7 +21,11 @@ export function on(element: Element, type: string, handler: EventListener): void
|
|
|
20
21
|
return
|
|
21
22
|
}
|
|
22
23
|
const captured = CURRENT_SCOPE.current
|
|
23
|
-
|
|
24
|
+
/* Coalesce the handler's writes: a handler setting several signals re-runs each
|
|
25
|
+
dependent effect once on batch exit, not once per write (the unbatched default
|
|
26
|
+
flushed eagerly per `trigger`). Stays synchronous — flush runs at handler-end
|
|
27
|
+
before it returns, so the causal stack (dispatch → handler → effects) is intact. */
|
|
28
|
+
const wrapped: EventListener = (event) => inScope(captured, () => batch(() => handler(event)))
|
|
24
29
|
element.addEventListener(type, wrapped)
|
|
25
30
|
if (OWNER.current !== undefined) {
|
|
26
31
|
OWNER.current.push(() => element.removeEventListener(type, wrapped))
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { clearBetween } from './clearBetween.ts'
|
|
2
|
+
import { fillBefore } from './fillBefore.ts'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
The one "replace a marker-bounded region's contents" operation. A control-flow block
|
|
6
|
+
that swaps a branch as a unit (if/else, switch, snippet) clears the live range
|
|
7
|
+
(disposing its scope and removing its nodes via `clearBetween`), then — if the new
|
|
8
|
+
branch has content — builds it fresh into the range before the close marker
|
|
9
|
+
(`fillBefore`). Returns the new content's disposer, or `undefined` for an empty
|
|
10
|
+
branch.
|
|
11
|
+
|
|
12
|
+
This is the single seam those blocks shared by copy-paste: same `clearBetween` +
|
|
13
|
+
conditional `fillBefore` shape, hand-rolled three times. Routing them through one
|
|
14
|
+
helper makes the region-update strategy a named, testable primitive.
|
|
15
|
+
*/
|
|
16
|
+
export function replaceRange(
|
|
17
|
+
start: Node,
|
|
18
|
+
end: Node,
|
|
19
|
+
dispose: (() => void) | undefined,
|
|
20
|
+
content: ((into: Node) => void) | undefined,
|
|
21
|
+
): (() => void) | undefined {
|
|
22
|
+
clearBetween(start, end, dispose)
|
|
23
|
+
return content !== undefined ? fillBefore(end, content) : undefined
|
|
24
|
+
}
|
|
@@ -1,8 +1,15 @@
|
|
|
1
|
+
import { walkAnchorOrder } from '../compile/walkAnchorOrder.ts'
|
|
2
|
+
import { walkElementOrder } from '../compile/walkElementOrder.ts'
|
|
1
3
|
import { claimChild } from '../runtime/claimChild.ts'
|
|
2
4
|
import { HOLE_ATTRIBUTE } from '../runtime/HOLE_ATTRIBUTE.ts'
|
|
3
5
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
4
6
|
import { commentData } from './commentData.ts'
|
|
7
|
+
import { depthZeroNodes } from './depthZeroNodes.ts'
|
|
8
|
+
import { domAnchorAdapter } from './domAnchorAdapter.ts'
|
|
9
|
+
import { domElementAdapter } from './domElementAdapter.ts'
|
|
5
10
|
import { foreignWrapperTag } from './foreignWrapperTag.ts'
|
|
11
|
+
import { isElement } from './isElement.ts'
|
|
12
|
+
import { markerDepthDelta } from './markerDepthDelta.ts'
|
|
6
13
|
import type { SkeletonHoles } from './types/SkeletonHoles.ts'
|
|
7
14
|
|
|
8
15
|
type CompiledSkeleton = {
|
|
@@ -21,23 +28,6 @@ type CompiledSkeleton = {
|
|
|
21
28
|
`templateFor` for the per-document rationale). */
|
|
22
29
|
const CACHES = new WeakMap<object, Map<string, CompiledSkeleton>>()
|
|
23
30
|
|
|
24
|
-
/* An element carries `hasAttribute`; text/comment nodes do not. Used instead of
|
|
25
|
-
`nodeType` so the walk runs under the test mini-dom too. */
|
|
26
|
-
function isElement(node: Node): node is Element {
|
|
27
|
-
return typeof (node as Element).hasAttribute === 'function'
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/* Block-range boundary markers. A control-flow block's rendered content sits between an
|
|
31
|
-
OPEN and CLOSE comment: `[`…`]` for each rows / if / switch / slot ranges, and named
|
|
32
|
-
`abide:…`…`/abide:…` boundaries for await / try / snippet / html. The skeleton's own
|
|
33
|
-
anchor (`a`) sits OUTSIDE any such range. */
|
|
34
|
-
function isOpenMarker(data: string): boolean {
|
|
35
|
-
return data === '[' || data.startsWith('abide:')
|
|
36
|
-
}
|
|
37
|
-
function isCloseMarker(data: string): boolean {
|
|
38
|
-
return data === ']' || data.startsWith('/abide:')
|
|
39
|
-
}
|
|
40
|
-
|
|
41
31
|
/* The `index`-th depth-0 ELEMENT among `children` — skipping text/comment nodes AND any
|
|
42
32
|
element nested inside a block's rendered range (between `[`…`]` / `abide:…` boundaries),
|
|
43
33
|
which belongs to that block's own skeleton. The compiler indexes element holes over the
|
|
@@ -57,69 +47,28 @@ function elementChildAt(children: ArrayLike<Node>, index: number): Element | und
|
|
|
57
47
|
}
|
|
58
48
|
seen += 1
|
|
59
49
|
}
|
|
60
|
-
|
|
61
|
-
depth -= 1
|
|
62
|
-
} else if (isOpenMarker(data)) {
|
|
63
|
-
depth += 1
|
|
50
|
+
continue
|
|
64
51
|
}
|
|
52
|
+
depth += markerDepthDelta(data)
|
|
65
53
|
}
|
|
66
54
|
return undefined
|
|
67
55
|
}
|
|
68
56
|
|
|
69
|
-
/*
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
let
|
|
75
|
-
for (let
|
|
76
|
-
|
|
77
|
-
if (!isElement(child)) {
|
|
78
|
-
continue
|
|
79
|
-
}
|
|
80
|
-
const path = [...prefix, elementIndex]
|
|
81
|
-
elementIndex += 1
|
|
82
|
-
if (child.hasAttribute(HOLE_ATTRIBUTE)) {
|
|
83
|
-
paths.push(path)
|
|
84
|
-
child.removeAttribute(HOLE_ATTRIBUTE)
|
|
85
|
-
}
|
|
86
|
-
indexElementHoles(child, path, paths)
|
|
57
|
+
/* Walks an element-only path from the top-level node list to the target element. A step
|
|
58
|
+
that resolves to nothing means the claimed server run is missing an element the skeleton
|
|
59
|
+
expects here — a hydration desync; throw AT it (naming the path) rather than returning
|
|
60
|
+
the undefined that derefs in the downstream `mountChild`/`attr`, far from the cause. */
|
|
61
|
+
function resolveElementHole(topLevel: ArrayLike<Node>, path: number[]): Element {
|
|
62
|
+
let node = elementChildAt(topLevel, path[0] as number)
|
|
63
|
+
for (let depth = 1; depth < path.length && node !== undefined; depth += 1) {
|
|
64
|
+
node = elementChildAt(node.childNodes, path[depth] as number)
|
|
87
65
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
anchors in the same order, so the arrays line up.
|
|
93
|
-
|
|
94
|
-
In hydrate mode the claimed tree is FULLY EXPANDED — a nested block's rendered content
|
|
95
|
-
(each rows, branches, await/try boundaries) sits inline — so a naive descent would also
|
|
96
|
-
collect the inner block's anchors, which belong to that block's OWN skeleton, shifting
|
|
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. */
|
|
103
|
-
function scanAnchors(nodes: ArrayLike<Node>, anchors: Node[]): void {
|
|
104
|
-
let depth = 0
|
|
105
|
-
for (let index = 0; index < nodes.length; index += 1) {
|
|
106
|
-
const node = nodes[index] as Node
|
|
107
|
-
const data = commentData(node)
|
|
108
|
-
if (data === undefined) {
|
|
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) {
|
|
113
|
-
scanAnchors(node.childNodes, anchors)
|
|
114
|
-
}
|
|
115
|
-
} else if (isCloseMarker(data)) {
|
|
116
|
-
depth -= 1
|
|
117
|
-
} else if (isOpenMarker(data)) {
|
|
118
|
-
depth += 1
|
|
119
|
-
} else if (data === 'a' && depth === 0) {
|
|
120
|
-
anchors.push(node)
|
|
121
|
-
}
|
|
66
|
+
if (node === undefined) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`[abide] hydration desync: skeleton element hole [${path.join(',')}] resolved to no node — the server DOM is missing an element the client build expects here.`,
|
|
69
|
+
)
|
|
122
70
|
}
|
|
71
|
+
return node
|
|
123
72
|
}
|
|
124
73
|
|
|
125
74
|
/* When `parent` is foreign (or a control-flow fragment inside foreign content), the
|
|
@@ -140,31 +89,20 @@ function compile(html: string, wrapper: string | undefined): CompiledSkeleton {
|
|
|
140
89
|
template.innerHTML = wrapper === undefined ? html : `<${wrapper}>${html}</${wrapper}>`
|
|
141
90
|
const source =
|
|
142
91
|
wrapper === undefined ? template.content : (template.content.firstChild as Node)
|
|
92
|
+
/* Element holes via the ONE shared rule (`walkElementOrder`) — the same element-only
|
|
93
|
+
pre-order the compiler numbers `elIndex` with. Record each `HOLE_ATTRIBUTE`
|
|
94
|
+
element's path and strip the marker so a clone never carries it into the live DOM. */
|
|
143
95
|
const elementPaths: number[][] = []
|
|
144
|
-
|
|
96
|
+
walkElementOrder(Array.from(source.childNodes), domElementAdapter, (node, path) => {
|
|
97
|
+
elementPaths.push(path)
|
|
98
|
+
;(node as Element).removeAttribute(HOLE_ATTRIBUTE)
|
|
99
|
+
})
|
|
145
100
|
compiled = { source, elementPaths, topLevelCount: source.childNodes.length }
|
|
146
101
|
cache.set(key, compiled)
|
|
147
102
|
}
|
|
148
103
|
return compiled
|
|
149
104
|
}
|
|
150
105
|
|
|
151
|
-
/* Walks an element-only path from the top-level node list to the target element. A step
|
|
152
|
-
that resolves to nothing means the claimed server run is missing an element the skeleton
|
|
153
|
-
expects here — a hydration desync; throw AT it (naming the path) rather than returning
|
|
154
|
-
the undefined that derefs in the downstream `mountChild`/`attr`, far from the cause. */
|
|
155
|
-
function resolveElementHole(topLevel: ArrayLike<Node>, path: number[]): Element {
|
|
156
|
-
let node = elementChildAt(topLevel, path[0] as number)
|
|
157
|
-
for (let depth = 1; depth < path.length && node !== undefined; depth += 1) {
|
|
158
|
-
node = elementChildAt(node.childNodes, path[depth] as number)
|
|
159
|
-
}
|
|
160
|
-
if (node === undefined) {
|
|
161
|
-
throw new Error(
|
|
162
|
-
`[abide] hydration desync: skeleton element hole [${path.join(',')}] resolved to no node — the server DOM is missing an element the client build expects here.`,
|
|
163
|
-
)
|
|
164
|
-
}
|
|
165
|
-
return node
|
|
166
|
-
}
|
|
167
|
-
|
|
168
106
|
/*
|
|
169
107
|
Realizes a compiled skeleton under `parent` and returns its holes: `el` the element
|
|
170
108
|
holes (attribute/listener/bind), in pre-order; `an` the anchor holes (reactive text,
|
|
@@ -197,8 +135,13 @@ export function skeleton(parent: Node, html: string): SkeletonHoles {
|
|
|
197
135
|
parent.appendChild(clone)
|
|
198
136
|
}
|
|
199
137
|
}
|
|
138
|
+
/* Anchor holes via the ONE shared ordering rule (`walkAnchorOrder`) — the same traversal
|
|
139
|
+
the compiler numbers `anIndex` with. The top-level list is depth-0-filtered up front (a
|
|
140
|
+
nested range can sit among the top-level run); the adapter filters each deeper level. */
|
|
200
141
|
const an: Node[] = []
|
|
201
|
-
|
|
142
|
+
walkAnchorOrder(depthZeroNodes(topLevel), domAnchorAdapter, (anchor) => {
|
|
143
|
+
an.push(anchor as Node)
|
|
144
|
+
})
|
|
202
145
|
return {
|
|
203
146
|
el: elementPaths.map((path) => resolveElementHole(topLevel, path)),
|
|
204
147
|
an,
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { effect } from '../effect.ts'
|
|
2
|
+
import { RANGE_CLOSE, RANGE_OPEN } from '../runtime/RANGE_MARKER.ts'
|
|
2
3
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
3
4
|
import { scope } from '../runtime/scope.ts'
|
|
4
5
|
import { scopeGroup } from '../runtime/scopeGroup.ts'
|
|
5
|
-
import { clearBetween } from './clearBetween.ts'
|
|
6
6
|
import { fillBefore } from './fillBefore.ts'
|
|
7
7
|
import { openMarker } from './openMarker.ts'
|
|
8
|
+
import { replaceRange } from './replaceRange.ts'
|
|
8
9
|
import type { SwitchCase } from './types/SwitchCase.ts'
|
|
9
10
|
|
|
10
11
|
/*
|
|
@@ -28,7 +29,7 @@ export function switchBlock(
|
|
|
28
29
|
): void {
|
|
29
30
|
const hydration = RENDER.hydration
|
|
30
31
|
/* The live case's scope, registered with the owner so it disposes on owner
|
|
31
|
-
teardown — not only when the subject switches cases via
|
|
32
|
+
teardown — not only when the subject switches cases via replaceRange. */
|
|
32
33
|
const group = scopeGroup()
|
|
33
34
|
let dispose: (() => void) | undefined
|
|
34
35
|
let activeIndex: number
|
|
@@ -49,16 +50,16 @@ export function switchBlock(
|
|
|
49
50
|
|
|
50
51
|
/* `before` places the range among static siblings on create (block before a suffix);
|
|
51
52
|
hydrate ignores it and uses the parked claim cursor. */
|
|
52
|
-
const start = openMarker(parent,
|
|
53
|
+
const start = openMarker(parent, RANGE_OPEN, before)
|
|
53
54
|
if (hydration !== undefined) {
|
|
54
55
|
activeIndex = select(subject())
|
|
55
56
|
const chosen = caseAt(activeIndex)
|
|
56
57
|
if (chosen !== undefined) {
|
|
57
58
|
dispose = group.track(scope(() => chosen.render(parent))) // claim the SSR nodes in place
|
|
58
59
|
}
|
|
59
|
-
end = openMarker(parent,
|
|
60
|
+
end = openMarker(parent, RANGE_CLOSE)
|
|
60
61
|
} else {
|
|
61
|
-
end = openMarker(parent,
|
|
62
|
+
end = openMarker(parent, RANGE_CLOSE, before)
|
|
62
63
|
activeIndex = select(subject())
|
|
63
64
|
const chosen = caseAt(activeIndex)
|
|
64
65
|
if (chosen !== undefined) {
|
|
@@ -71,12 +72,14 @@ export function switchBlock(
|
|
|
71
72
|
if (index === activeIndex) {
|
|
72
73
|
return
|
|
73
74
|
}
|
|
74
|
-
clearBetween(start, end, dispose)
|
|
75
|
-
dispose = undefined
|
|
76
75
|
activeIndex = index
|
|
77
76
|
const chosen = caseAt(index)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
77
|
+
/* Null `dispose` before `replaceRange` builds the new case: a reentrant switch
|
|
78
|
+
during that build (an effect in the new content writing the subject) would
|
|
79
|
+
otherwise re-enter with the already-disposed disposer and clear it twice. */
|
|
80
|
+
const prior = dispose
|
|
81
|
+
dispose = undefined
|
|
82
|
+
const next = replaceRange(start, end, prior, chosen && ((p) => chosen.render(p)))
|
|
83
|
+
dispose = next !== undefined ? group.track(next) : undefined
|
|
81
84
|
})
|
|
82
85
|
}
|
package/src/lib/ui/dom/when.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { effect } from '../effect.ts'
|
|
2
|
+
import { RANGE_CLOSE, RANGE_OPEN } from '../runtime/RANGE_MARKER.ts'
|
|
2
3
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
3
4
|
import { scope } from '../runtime/scope.ts'
|
|
4
5
|
import { scopeGroup } from '../runtime/scopeGroup.ts'
|
|
5
|
-
import { clearBetween } from './clearBetween.ts'
|
|
6
6
|
import { fillBefore } from './fillBefore.ts'
|
|
7
7
|
import { openMarker } from './openMarker.ts'
|
|
8
|
+
import { replaceRange } from './replaceRange.ts'
|
|
8
9
|
|
|
9
10
|
/*
|
|
10
11
|
Conditional binding — the runtime for `<template if>` (with optional `else`). The
|
|
@@ -34,7 +35,7 @@ export function when(
|
|
|
34
35
|
branch's own interpolations still track, each through its own effect. */
|
|
35
36
|
const chosenFor = (branch: 'then' | 'else') => (branch === 'then' ? render : renderElse)
|
|
36
37
|
/* The live branch's scope, registered with the owner so it disposes on owner
|
|
37
|
-
teardown — not only on a branch flip via
|
|
38
|
+
teardown — not only on a branch flip via replaceRange. */
|
|
38
39
|
const group = scopeGroup()
|
|
39
40
|
let dispose: (() => void) | undefined
|
|
40
41
|
let activeBranch: 'then' | 'else'
|
|
@@ -43,16 +44,16 @@ export function when(
|
|
|
43
44
|
/* `before` (a static node located by the skeleton) places the range among siblings on
|
|
44
45
|
create, so the block sits before a static suffix rather than at the parent's end.
|
|
45
46
|
Hydrate ignores it — the claim cursor (positioned past the prefix) drives placement. */
|
|
46
|
-
const start = openMarker(parent,
|
|
47
|
+
const start = openMarker(parent, RANGE_OPEN, before)
|
|
47
48
|
if (hydration !== undefined) {
|
|
48
49
|
activeBranch = condition() ? 'then' : 'else'
|
|
49
50
|
const chosen = chosenFor(activeBranch)
|
|
50
51
|
if (chosen !== undefined) {
|
|
51
52
|
dispose = group.track(scope(() => chosen(parent))) // content claims the SSR nodes in place
|
|
52
53
|
}
|
|
53
|
-
end = openMarker(parent,
|
|
54
|
+
end = openMarker(parent, RANGE_CLOSE)
|
|
54
55
|
} else {
|
|
55
|
-
end = openMarker(parent,
|
|
56
|
+
end = openMarker(parent, RANGE_CLOSE, before)
|
|
56
57
|
activeBranch = condition() ? 'then' : 'else'
|
|
57
58
|
const chosen = chosenFor(activeBranch)
|
|
58
59
|
if (chosen !== undefined) {
|
|
@@ -65,12 +66,14 @@ export function when(
|
|
|
65
66
|
if (branch === activeBranch) {
|
|
66
67
|
return
|
|
67
68
|
}
|
|
68
|
-
clearBetween(start, end, dispose)
|
|
69
|
-
dispose = undefined
|
|
70
69
|
activeBranch = branch
|
|
71
70
|
const chosen = chosenFor(branch)
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
71
|
+
/* Null `dispose` before `replaceRange` builds the new branch: a reentrant flip
|
|
72
|
+
during that build (an effect in the new content writing the condition) would
|
|
73
|
+
otherwise re-enter with the already-disposed disposer and clear it twice. */
|
|
74
|
+
const prior = dispose
|
|
75
|
+
dispose = undefined
|
|
76
|
+
const next = replaceRange(start, end, prior, chosen)
|
|
77
|
+
dispose = next !== undefined ? group.track(next) : undefined
|
|
75
78
|
})
|
|
76
79
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/* The comment-marker "wire alphabet" — the single source of truth for the sentinel
|
|
2
|
+
strings the SSR emit (`generateSSR`) writes into HTML comments and the hydrate scan
|
|
3
|
+
(`skeleton`) + every range-mount runtime (`when`/`switch`/`each`/`mountRange`/
|
|
4
|
+
`mountSlot`/`appendSnippet`) creates as `document.createComment` nodes. Both sides
|
|
5
|
+
reference THESE constants, so a marker the server writes and the marker the client
|
|
6
|
+
looks for can never drift on a literal.
|
|
7
|
+
|
|
8
|
+
A control-flow block's rendered content sits between an OPEN (`[`) and CLOSE (`]`)
|
|
9
|
+
comment; a snippet interpolation between `abide:snippet` / `/abide:snippet` (matching
|
|
10
|
+
`skeleton`'s `abide:` / `/abide:` named-boundary convention, like `OUTLET_MARKER`).
|
|
11
|
+
The skeleton's own positioning anchor (`a`) sits OUTSIDE any such range. */
|
|
12
|
+
export const RANGE_OPEN = '['
|
|
13
|
+
export const RANGE_CLOSE = ']'
|
|
14
|
+
export const ANCHOR = 'a'
|
|
15
|
+
export const SNIPPET_OPEN = 'abide:snippet'
|
|
16
|
+
export const SNIPPET_CLOSE = '/abide:snippet'
|