@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
|
@@ -1,87 +1,72 @@
|
|
|
1
1
|
import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
|
|
2
|
-
import { isAnchorPositioned } from './isAnchorPositioned.ts'
|
|
3
2
|
import { isControlFlow } from './isControlFlow.ts'
|
|
4
3
|
import { isTextLeaf } from './isTextLeaf.ts'
|
|
5
4
|
import { skeletonable } from './skeletonable.ts'
|
|
5
|
+
import { templateAnchorAdapter } from './templateAnchorAdapter.ts'
|
|
6
|
+
import { templateElementAdapter } from './templateElementAdapter.ts'
|
|
6
7
|
import type { SkeletonContext } from './types/SkeletonContext.ts'
|
|
7
8
|
import type { TemplateNode } from './types/TemplateNode.ts'
|
|
9
|
+
import { walkAnchorOrder } from './walkAnchorOrder.ts'
|
|
10
|
+
import { walkElementOrder } from './walkElementOrder.ts'
|
|
8
11
|
|
|
9
12
|
/*
|
|
10
13
|
The single source of truth for where skeleton markers go AND what their hole indices are.
|
|
11
|
-
One top-down walk records, per node, whether it sits inside a parser-backed skeleton
|
|
12
|
-
(`inSkeleton`)
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
One top-down `visit` walk records, per node, whether it sits inside a parser-backed skeleton
|
|
15
|
+
clone (`inSkeleton`), whether its reactive text is interleaved (`markText`), and which
|
|
16
|
+
`skeletonable` elements OPEN a skeleton (`skeletonRoots`). Both hole axes are then numbered by
|
|
17
|
+
the two shared ordering rules — `walkElementOrder` (`el`) and `walkAnchorOrder` (`an`) — run
|
|
18
|
+
once per root, so `generateBuild` reads `sk.el`/`sk.an` rather than re-deriving the numbering,
|
|
19
|
+
and the runtime recovers the same positions through the SAME two walks. One rule per axis, both
|
|
20
|
+
sides; the numbering cannot drift from the decisions.
|
|
16
21
|
|
|
17
22
|
The index assignment is scoped per skeleton root (a `skeletonable` element not already in a
|
|
18
|
-
skeleton — the unit `generateSkeleton` instantiates with `{ el: 0, an: 0 }`). `el`
|
|
19
|
-
element holes in pre-order; `an` numbers anchor
|
|
20
|
-
control-flow blocks, child components, `<slot>` outlets)
|
|
21
|
-
document order
|
|
22
|
-
the realized DOM, so the compile-time numbers and the runtime positions line up.
|
|
23
|
+
skeleton — the unit `generateSkeleton` instantiates with a fresh `{ el: 0, an: 0 }`). `el`
|
|
24
|
+
numbers element holes in pre-order (the root element itself can be one); `an` numbers anchor
|
|
25
|
+
holes (interleaved reactive text PARTS, control-flow blocks, child components, `<slot>` outlets)
|
|
26
|
+
in document order.
|
|
23
27
|
|
|
24
|
-
A fresh-context boundary resets to NOT-in-skeleton
|
|
25
|
-
|
|
26
|
-
|
|
28
|
+
A fresh-context boundary resets to NOT-in-skeleton, because the content there is built by its
|
|
29
|
+
own runtime (a control-flow block's branch, a component's slot content, a `<slot>`'s fallback,
|
|
30
|
+
a snippet's body) — never cloned by the enclosing skeleton.
|
|
27
31
|
*/
|
|
28
32
|
export function skeletonContext(nodes: TemplateNode[]): SkeletonContext {
|
|
29
33
|
const inSkeleton = new WeakMap<TemplateNode, boolean>()
|
|
30
34
|
const markText = new WeakMap<TemplateNode, boolean>()
|
|
31
|
-
/* Element holes keyed by node; anchor holes keyed by node (control-flow/component/slot)
|
|
32
|
-
|
|
35
|
+
/* Element holes keyed by node; anchor holes keyed by node (control-flow/component/slot) OR
|
|
36
|
+
by the reactive text PART object (a text node carries one anchor per reactive part). Both
|
|
37
|
+
numbered AFTER this context pass by the shared walks, once per skeleton root. */
|
|
33
38
|
const elIndex = new WeakMap<TemplateNode, number>()
|
|
34
39
|
const anIndex = new WeakMap<object, number>()
|
|
40
|
+
/* The skeletonable elements that OPEN a skeleton — each owns a fresh `el`/`an` numbering, so
|
|
41
|
+
the shared walks run once per root. Collected in pre-order here, numbered below. */
|
|
42
|
+
const skeletonRoots: TemplateNode[] = []
|
|
35
43
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
(`counter === undefined` outside any skeleton — no holes are numbered there). */
|
|
40
|
-
function visit(
|
|
41
|
-
node: TemplateNode,
|
|
42
|
-
nodeInSkeleton: boolean,
|
|
43
|
-
nodeMarkText: boolean,
|
|
44
|
-
counter: Counter | undefined,
|
|
45
|
-
): void {
|
|
44
|
+
/* Record the boundary context (`inSkeleton`/`markText`) at `node` and collect skeleton
|
|
45
|
+
roots. Hole NUMBERING is no longer threaded here — the two shared walks own it. */
|
|
46
|
+
function visit(node: TemplateNode, nodeInSkeleton: boolean, nodeMarkText: boolean): void {
|
|
46
47
|
inSkeleton.set(node, nodeInSkeleton)
|
|
47
48
|
markText.set(node, nodeMarkText)
|
|
48
49
|
|
|
49
50
|
/* Control-flow blocks, components, and snippets are fresh build contexts. The node
|
|
50
|
-
ITSELF is
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
ITSELF is an anchor in the enclosing skeleton (a block OR a component mounts as a
|
|
52
|
+
marker-bounded range at that anchor); its children re-enter the skeleton only via
|
|
53
|
+
their own roots. */
|
|
53
54
|
if (isControlFlow(node) || node.kind === 'component' || node.kind === 'snippet') {
|
|
54
|
-
/* A block or a component takes an anchor only inside an enclosing skeleton (a
|
|
55
|
-
standalone one routes through `generateIf`/`mountChild`, not the skeleton path);
|
|
56
|
-
a snippet declares a builder and is never anchor-positioned (`isAnchorPositioned`). */
|
|
57
|
-
if (counter !== undefined && isAnchorPositioned(node)) {
|
|
58
|
-
anIndex.set(node, counter.an++)
|
|
59
|
-
}
|
|
60
55
|
for (const child of childrenOf(node)) {
|
|
61
|
-
visit(child, false, false
|
|
56
|
+
visit(child, false, false)
|
|
62
57
|
}
|
|
63
58
|
return
|
|
64
59
|
}
|
|
65
60
|
/* A `branch`/`case` is a transparent grouping inside its control-flow block — pass the
|
|
66
|
-
already-reset context
|
|
67
|
-
|
|
61
|
+
already-reset context through so a skeletonable element inside it opens its own
|
|
62
|
+
skeleton. */
|
|
68
63
|
if (node.kind === 'branch' || node.kind === 'case') {
|
|
69
64
|
for (const child of node.children) {
|
|
70
|
-
visit(child, nodeInSkeleton, nodeMarkText
|
|
65
|
+
visit(child, nodeInSkeleton, nodeMarkText)
|
|
71
66
|
}
|
|
72
67
|
return
|
|
73
68
|
}
|
|
74
69
|
if (node.kind === 'text') {
|
|
75
|
-
/* Interleaved reactive text (markText true): each reactive part takes an `<!--a-->`
|
|
76
|
-
anchor, numbered in document order. A text-leaf's text (markText false) binds
|
|
77
|
-
marker-free via the element, so its parts take no anchor. */
|
|
78
|
-
if (counter !== undefined && nodeMarkText) {
|
|
79
|
-
for (const part of node.parts) {
|
|
80
|
-
if (part.kind !== 'static') {
|
|
81
|
-
anIndex.set(part, counter.an++)
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
70
|
return
|
|
86
71
|
}
|
|
87
72
|
if (node.kind !== 'element') {
|
|
@@ -89,51 +74,54 @@ export function skeletonContext(nodes: TemplateNode[]): SkeletonContext {
|
|
|
89
74
|
}
|
|
90
75
|
if (node.tag === 'slot' || node.tag === OUTLET_TAG) {
|
|
91
76
|
/* A component `<slot>` content fill OR a layout's `OUTLET_TAG` router fill point
|
|
92
|
-
(`asOutlet`)
|
|
93
|
-
|
|
94
|
-
fallback (a fresh context); an outlet has none. */
|
|
95
|
-
if (counter !== undefined) {
|
|
96
|
-
anIndex.set(node, counter.an++)
|
|
97
|
-
}
|
|
77
|
+
(`asOutlet`): its children are a fresh context (the `<slot>` fallback / an outlet
|
|
78
|
+
has none). The slot's own anchor is numbered by the shared walk. */
|
|
98
79
|
for (const child of node.children) {
|
|
99
|
-
visit(child, false, false
|
|
80
|
+
visit(child, false, false)
|
|
100
81
|
}
|
|
101
82
|
return
|
|
102
83
|
}
|
|
103
|
-
/* A skeletonable element not already in a skeleton OPENS one (
|
|
104
|
-
|
|
105
|
-
|
|
84
|
+
/* A skeletonable element not already in a skeleton OPENS one (the `generateSkeleton`
|
|
85
|
+
unit). An element already in a skeleton stays in it. A static element outside any
|
|
86
|
+
skeleton numbers nothing. */
|
|
106
87
|
const opensSkeleton = !nodeInSkeleton && skeletonable(node)
|
|
107
88
|
const childInSkeleton = nodeInSkeleton || skeletonable(node)
|
|
108
|
-
const effectiveCounter = nodeInSkeleton
|
|
109
|
-
? counter
|
|
110
|
-
: opensSkeleton
|
|
111
|
-
? { el: 0, an: 0 }
|
|
112
|
-
: undefined
|
|
113
89
|
const childMarkText = childInSkeleton && !isTextLeaf(node)
|
|
114
90
|
|
|
115
|
-
if (
|
|
116
|
-
|
|
117
|
-
binds text-leaf reactive text on itself. Take its `el` index BEFORE recursing,
|
|
118
|
-
so holes number in pre-order — the order the runtime's path walk produces them. */
|
|
119
|
-
const hasReactiveAttr = node.attrs.some((attr) => attr.kind !== 'static')
|
|
120
|
-
const reactiveTextChild = node.children.find(
|
|
121
|
-
(child) =>
|
|
122
|
-
child.kind === 'text' && child.parts.some((part) => part.kind !== 'static'),
|
|
123
|
-
)
|
|
124
|
-
const textLeafBind = reactiveTextChild !== undefined && isTextLeaf(node)
|
|
125
|
-
if (hasReactiveAttr || textLeafBind) {
|
|
126
|
-
elIndex.set(node, effectiveCounter.el++)
|
|
127
|
-
}
|
|
91
|
+
if (opensSkeleton) {
|
|
92
|
+
skeletonRoots.push(node)
|
|
128
93
|
}
|
|
129
94
|
for (const child of node.children) {
|
|
130
|
-
visit(child, childInSkeleton, childMarkText
|
|
95
|
+
visit(child, childInSkeleton, childMarkText)
|
|
131
96
|
}
|
|
132
97
|
}
|
|
133
98
|
|
|
134
99
|
for (const node of nodes) {
|
|
135
|
-
visit(node, false, false
|
|
100
|
+
visit(node, false, false)
|
|
136
101
|
}
|
|
102
|
+
|
|
103
|
+
/* Number element holes via the ONE shared rule (`walkElementOrder`), once per skeleton root
|
|
104
|
+
so each root's holes start at 0 — the element-only pre-order the runtime's clone walk
|
|
105
|
+
re-derives. The walk starts AT the root: the root element itself is a located hole when it
|
|
106
|
+
binds. */
|
|
107
|
+
for (const root of skeletonRoots) {
|
|
108
|
+
let next = 0
|
|
109
|
+
walkElementOrder([root], templateElementAdapter, (node) => {
|
|
110
|
+
elIndex.set(node, next++)
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/* Number anchor holes via the ONE shared rule (`walkAnchorOrder`), once per skeleton root.
|
|
115
|
+
The root's own children are walked — the root element is a located hole, never an
|
|
116
|
+
anchor. */
|
|
117
|
+
const anchorAdapter = templateAnchorAdapter(inSkeleton, markText)
|
|
118
|
+
for (const root of skeletonRoots) {
|
|
119
|
+
let next = 0
|
|
120
|
+
walkAnchorOrder(childrenOf(root), anchorAdapter, (position) => {
|
|
121
|
+
anIndex.set(position, next++)
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
137
125
|
return { inSkeleton, markText, elIndex, anIndex }
|
|
138
126
|
}
|
|
139
127
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { isAnchorPositioned } from './isAnchorPositioned.ts'
|
|
2
|
+
import type { TemplateNode } from './types/TemplateNode.ts'
|
|
3
|
+
import type { AnchorRole, AnchorWalkAdapter } from './walkAnchorOrder.ts'
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
The template-AST side of the shared anchor-ordering rule (`walkAnchorOrder`). Classifies each
|
|
7
|
+
parsed node by the SAME positions the realized-DOM side recovers, so the compiler's `anIndex`
|
|
8
|
+
numbering and the runtime's `scanAnchors` collection cannot disagree.
|
|
9
|
+
|
|
10
|
+
Anchor positions, in document order: a control-flow/component node and a `<slot>`/outlet
|
|
11
|
+
element each contribute themselves (one anchor, fresh-context body — not descended); a text
|
|
12
|
+
node whose reactive parts are interleaved contributes one anchor per non-static part. A
|
|
13
|
+
skeleton-structure element is a container we descend; everything else (static text, a
|
|
14
|
+
text-leaf's marker-free text, script/style, a node outside any skeleton) contributes nothing.
|
|
15
|
+
|
|
16
|
+
`inSkeleton`/`markText` come from the context pass (the element-hole + boundary axes), read here
|
|
17
|
+
so the adapter stays a pure classifier — the anchor counter never re-derives the context.
|
|
18
|
+
*/
|
|
19
|
+
export function templateAnchorAdapter(
|
|
20
|
+
inSkeleton: WeakMap<TemplateNode, boolean>,
|
|
21
|
+
markText: WeakMap<TemplateNode, boolean>,
|
|
22
|
+
): AnchorWalkAdapter<TemplateNode> {
|
|
23
|
+
return {
|
|
24
|
+
classify: (node: TemplateNode): AnchorRole => {
|
|
25
|
+
/* A node outside an active skeleton numbers nothing — its block/text mounts on the
|
|
26
|
+
host directly (top-level / inside a branch), no anchor. */
|
|
27
|
+
if (inSkeleton.get(node) !== true) {
|
|
28
|
+
/* It may still be a non-skeleton CONTAINER whose descendants open their own
|
|
29
|
+
skeletons (a static wrapper element), so recurse into elements, skip leaves. */
|
|
30
|
+
return node.kind === 'element' ? { kind: 'recurse' } : { kind: 'skip' }
|
|
31
|
+
}
|
|
32
|
+
/* An anchor-positioned node IS one anchor and its body is a fresh context. */
|
|
33
|
+
if (isAnchorPositioned(node)) {
|
|
34
|
+
return { kind: 'anchor', positions: [node] }
|
|
35
|
+
}
|
|
36
|
+
/* A component/snippet inside a skeleton that isn't anchor-positioned (a snippet
|
|
37
|
+
declares a builder) — fresh context, no anchor, no descent. */
|
|
38
|
+
if (node.kind === 'component' || node.kind === 'snippet') {
|
|
39
|
+
return { kind: 'skip' }
|
|
40
|
+
}
|
|
41
|
+
/* Interleaved reactive text: one anchor per non-static part, document order. */
|
|
42
|
+
if (node.kind === 'text') {
|
|
43
|
+
return markText.get(node) === true
|
|
44
|
+
? {
|
|
45
|
+
kind: 'anchor',
|
|
46
|
+
positions: node.parts.filter((part) => part.kind !== 'static'),
|
|
47
|
+
}
|
|
48
|
+
: { kind: 'skip' }
|
|
49
|
+
}
|
|
50
|
+
/* A skeleton-structure element — descend into its children. */
|
|
51
|
+
if (node.kind === 'element') {
|
|
52
|
+
return { kind: 'recurse' }
|
|
53
|
+
}
|
|
54
|
+
return { kind: 'skip' }
|
|
55
|
+
},
|
|
56
|
+
/* A branch/case is a transparent grouping inside its block — the context pass already
|
|
57
|
+
records its children's reset state, so the walk descends straight through it. */
|
|
58
|
+
childrenOf: (node: TemplateNode): readonly TemplateNode[] =>
|
|
59
|
+
'children' in node ? node.children : [],
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
|
|
2
|
+
import { isControlFlow } from './isControlFlow.ts'
|
|
3
|
+
import { isTextLeaf } from './isTextLeaf.ts'
|
|
4
|
+
import type { TemplateNode } from './types/TemplateNode.ts'
|
|
5
|
+
import type { ElementRole, ElementWalkAdapter } from './walkElementOrder.ts'
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
The template-AST side of the shared element-hole numbering rule (`walkElementOrder`). Classifies
|
|
9
|
+
each parsed node by the SAME holes the parsed-DOM side recovers (`domElementAdapter`), so the
|
|
10
|
+
compiler's `elIndex` numbering and the runtime's `HOLE_ATTRIBUTE` path collection cannot
|
|
11
|
+
disagree.
|
|
12
|
+
|
|
13
|
+
A skeleton element is a numbered element (descended into); it is a HOLE when it carries a
|
|
14
|
+
reactive attribute/listener/bind, or binds reactive text marker-free as a text leaf — exactly
|
|
15
|
+
the elements `generateSkeleton` stamps with `HOLE_ATTRIBUTE`. A control-flow block, component,
|
|
16
|
+
snippet, `<slot>`, or outlet is a fresh build context (its content numbers in its own skeleton),
|
|
17
|
+
and a text/script/style/branch/case carries no element index — all skip.
|
|
18
|
+
*/
|
|
19
|
+
export const templateElementAdapter: ElementWalkAdapter<TemplateNode> = {
|
|
20
|
+
classify: (node: TemplateNode): ElementRole => {
|
|
21
|
+
/* Fresh build contexts — their content is numbered by their own skeleton, not here. */
|
|
22
|
+
if (isControlFlow(node) || node.kind === 'component' || node.kind === 'snippet') {
|
|
23
|
+
return { kind: 'skip' }
|
|
24
|
+
}
|
|
25
|
+
if (node.kind !== 'element') {
|
|
26
|
+
return { kind: 'skip' } // text / script / style / standalone branch|case
|
|
27
|
+
}
|
|
28
|
+
/* A `<slot>` fill point or a layout outlet — fresh context (slot fallback / outlet has
|
|
29
|
+
none), anchor-positioned, never an element hole. */
|
|
30
|
+
if (node.tag === 'slot' || node.tag === OUTLET_TAG) {
|
|
31
|
+
return { kind: 'skip' }
|
|
32
|
+
}
|
|
33
|
+
const hasReactiveAttr = node.attrs.some((attr) => attr.kind !== 'static')
|
|
34
|
+
const hasReactiveTextChild = node.children.some(
|
|
35
|
+
(child) => child.kind === 'text' && child.parts.some((part) => part.kind !== 'static'),
|
|
36
|
+
)
|
|
37
|
+
return {
|
|
38
|
+
kind: 'element',
|
|
39
|
+
isHole: hasReactiveAttr || (hasReactiveTextChild && isTextLeaf(node)),
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
childrenOf: (node: TemplateNode): readonly TemplateNode[] =>
|
|
43
|
+
'children' in node ? node.children : [],
|
|
44
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The ONE document-order anchor-numbering rule, shared by every side that recovers a skeleton's
|
|
3
|
+
`<!--a-->` anchor positions. The same "why" — assign every block anchor, slot, and
|
|
4
|
+
interleaved-reactive-text part a position in document order, recover it on the other side — was
|
|
5
|
+
computed twice with no shared definition: `skeletonContext` re-derived it over the template AST
|
|
6
|
+
(assigning `anIndex`), `scanAnchors` re-derived it over the realized DOM (collecting the live
|
|
7
|
+
`a` comments). Each hand-mirrored the same traversal, so a change to one drifted silently from
|
|
8
|
+
the other — the index-desync class the runtime anchor guards exist to catch.
|
|
9
|
+
|
|
10
|
+
This module owns the traversal SHAPE as a sibling-list scan. A per-substrate
|
|
11
|
+
`AnchorWalkAdapter` classifies each node it meets into one of three roles, and that
|
|
12
|
+
classification — plus the document-order scan over it — IS the shared rule. The two substrates
|
|
13
|
+
differ only in how a fresh-context region is delimited (a control-flow/component/slot SUBTREE on
|
|
14
|
+
the AST side; a `[`…`]` / `abide:` marker-bracketed sibling RUN on the realized-DOM side), so
|
|
15
|
+
the adapter, not this walk, skips the region; the walk guarantees both sides emit the same
|
|
16
|
+
positions in the same order.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/* What a node contributes as the scan reaches it, in document order. */
|
|
20
|
+
export type AnchorRole =
|
|
21
|
+
/* Emit this node's anchor positions (zero or more — a reactive text node carries one per
|
|
22
|
+
non-static part), then do NOT descend. A block/component/slot is itself one anchor whose
|
|
23
|
+
body is a fresh context; an interleaved reactive text node is several. */
|
|
24
|
+
| { kind: 'anchor'; positions: readonly object[] }
|
|
25
|
+
/* An own-skeleton container — descend into its children, contributing no anchor itself (a
|
|
26
|
+
static/host element). */
|
|
27
|
+
| { kind: 'recurse' }
|
|
28
|
+
/* Contribute nothing and do not descend — a static text leaf, a script/style, or a
|
|
29
|
+
boundary the adapter handles out-of-band (range markers on the DOM side). */
|
|
30
|
+
| { kind: 'skip' }
|
|
31
|
+
|
|
32
|
+
/* The single substrate fact the shared walk needs: classify a node. The adapter owns substrate
|
|
33
|
+
detail (what an anchor is, what a fresh-context boundary is, how to reach children); the walk
|
|
34
|
+
owns only document order. */
|
|
35
|
+
export type AnchorWalkAdapter<TNode> = {
|
|
36
|
+
classify: (node: TNode) => AnchorRole
|
|
37
|
+
childrenOf: (node: TNode) => readonly TNode[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* Scan `nodes` in document order, calling `emit` once per anchor position in the order both
|
|
41
|
+
sides must agree on. Pure: the adapter decides roles, this owns only the order. */
|
|
42
|
+
export function walkAnchorOrder<TNode>(
|
|
43
|
+
nodes: readonly TNode[],
|
|
44
|
+
adapter: AnchorWalkAdapter<TNode>,
|
|
45
|
+
emit: (position: object) => void,
|
|
46
|
+
): void {
|
|
47
|
+
for (const node of nodes) {
|
|
48
|
+
const role = adapter.classify(node)
|
|
49
|
+
if (role.kind === 'anchor') {
|
|
50
|
+
for (const position of role.positions) {
|
|
51
|
+
emit(position)
|
|
52
|
+
}
|
|
53
|
+
} else if (role.kind === 'recurse') {
|
|
54
|
+
walkAnchorOrder(adapter.childrenOf(node), adapter, emit)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The ONE pre-order element-hole numbering rule, shared by every side that positions a skeleton's
|
|
3
|
+
located elements. The same "why" — number the bound elements in pre-order, recover them on the
|
|
4
|
+
other side — was computed twice with no shared definition: `skeletonContext` threaded an `el`
|
|
5
|
+
counter over the template AST (assigning `elIndex`), `indexElementHoles` re-walked the parsed
|
|
6
|
+
skeleton DOM (recording each `HOLE_ATTRIBUTE` element's path). Each hand-mirrored the same
|
|
7
|
+
element-only pre-order, so a change to one drifted silently from the other — the index-desync
|
|
8
|
+
class `resolveElementHole` exists to catch.
|
|
9
|
+
|
|
10
|
+
This module owns the traversal SHAPE: an element-only pre-order over a sibling list, threading
|
|
11
|
+
an element-only path. A per-substrate `ElementWalkAdapter` classifies each node — a numbered
|
|
12
|
+
element (a hole emits, all elements descend) or a skip (non-element / fresh-context boundary) —
|
|
13
|
+
and that classification, plus the path-threading scan over it, IS the shared rule. The two
|
|
14
|
+
substrates differ only in what marks a hole (a reactive attr/text-leaf on the AST side, the
|
|
15
|
+
`HOLE_ATTRIBUTE` on the parsed-DOM side) and where a fresh context begins (a control-flow/
|
|
16
|
+
component/slot SUBTREE on the AST side; the parsed skeleton already prunes those to `<!--a-->`
|
|
17
|
+
anchors, so the DOM side only meets its own elements), which the adapter owns — the walk
|
|
18
|
+
guarantees both number the same elements in the same element-only order.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/* What a node contributes as the pre-order scan reaches it. */
|
|
22
|
+
export type ElementRole =
|
|
23
|
+
/* An element in this skeleton: it consumes an element-only index (so a later sibling's
|
|
24
|
+
path stays stable), emits its hole position when `isHole`, and is descended into. */
|
|
25
|
+
| { kind: 'element'; isHole: boolean }
|
|
26
|
+
/* A non-element (text/comment) or a fresh-context boundary (control-flow/component/slot):
|
|
27
|
+
contributes no index, no hole, and is not descended. */
|
|
28
|
+
| { kind: 'skip' }
|
|
29
|
+
|
|
30
|
+
/* The substrate facts the shared walk needs: classify a node, reach its children. The adapter
|
|
31
|
+
owns substrate detail (what an element hole is, how to reach children); the walk owns only
|
|
32
|
+
the element-only pre-order and the path it threads. */
|
|
33
|
+
export type ElementWalkAdapter<TNode> = {
|
|
34
|
+
classify: (node: TNode) => ElementRole
|
|
35
|
+
childrenOf: (node: TNode) => readonly TNode[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* Scan `nodes` in element-only pre-order, calling `emit` once per hole with its element-only
|
|
39
|
+
path — the order and path both sides must agree on. Pure: the adapter decides roles, this
|
|
40
|
+
owns only the order and the path prefix. */
|
|
41
|
+
export function walkElementOrder<TNode>(
|
|
42
|
+
nodes: readonly TNode[],
|
|
43
|
+
adapter: ElementWalkAdapter<TNode>,
|
|
44
|
+
emit: (node: TNode, path: number[]) => void,
|
|
45
|
+
prefix: number[] = [],
|
|
46
|
+
): void {
|
|
47
|
+
let elementIndex = 0
|
|
48
|
+
for (const node of nodes) {
|
|
49
|
+
const role = adapter.classify(node)
|
|
50
|
+
if (role.kind === 'skip') {
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
const path = [...prefix, elementIndex]
|
|
54
|
+
elementIndex += 1
|
|
55
|
+
if (role.isHole) {
|
|
56
|
+
emit(node, path)
|
|
57
|
+
}
|
|
58
|
+
walkElementOrder(adapter.childrenOf(node), adapter, emit, path)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { snippetPayload } from '../../shared/snippet.ts'
|
|
2
2
|
import { effect } from '../effect.ts'
|
|
3
|
+
import { SNIPPET_CLOSE, SNIPPET_OPEN } from '../runtime/RANGE_MARKER.ts'
|
|
3
4
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
4
5
|
import { scope } from '../runtime/scope.ts'
|
|
5
6
|
import { scopeGroup } from '../runtime/scopeGroup.ts'
|
|
6
|
-
import { clearBetween } from './clearBetween.ts'
|
|
7
7
|
import { fillBefore } from './fillBefore.ts'
|
|
8
8
|
import { openMarker } from './openMarker.ts'
|
|
9
|
+
import { replaceRange } from './replaceRange.ts'
|
|
9
10
|
|
|
10
11
|
/*
|
|
11
12
|
A `{snippet(args)}` interpolation: mount the branded builder's nodes in a range
|
|
@@ -30,7 +31,7 @@ the args; a later argument change rebuilds fresh (the SSR markers stay as the ra
|
|
|
30
31
|
export function appendSnippet(parent: Node, read: () => unknown): void {
|
|
31
32
|
const hydration = RENDER.hydration
|
|
32
33
|
/* Mount scopes register with the owner so they dispose on owner teardown, not
|
|
33
|
-
only on an argument-driven rebuild via
|
|
34
|
+
only on an argument-driven rebuild via replaceRange. */
|
|
34
35
|
const group = scopeGroup()
|
|
35
36
|
let dispose: (() => void) | undefined
|
|
36
37
|
|
|
@@ -44,15 +45,15 @@ export function appendSnippet(parent: Node, read: () => unknown): void {
|
|
|
44
45
|
let open: Comment
|
|
45
46
|
let close: Comment
|
|
46
47
|
if (hydration !== undefined) {
|
|
47
|
-
open = openMarker(parent,
|
|
48
|
+
open = openMarker(parent, SNIPPET_OPEN)
|
|
48
49
|
const builder = builderOf()
|
|
49
50
|
if (builder !== undefined) {
|
|
50
51
|
dispose = group.track(scope(() => builder(parent))) // content claims the SSR nodes in place
|
|
51
52
|
}
|
|
52
|
-
close = openMarker(parent,
|
|
53
|
+
close = openMarker(parent, SNIPPET_CLOSE)
|
|
53
54
|
} else {
|
|
54
|
-
open = openMarker(parent,
|
|
55
|
-
close = openMarker(parent,
|
|
55
|
+
open = openMarker(parent, SNIPPET_OPEN)
|
|
56
|
+
close = openMarker(parent, SNIPPET_CLOSE)
|
|
56
57
|
const builder = builderOf()
|
|
57
58
|
if (builder !== undefined) {
|
|
58
59
|
dispose = group.track(fillBefore(close, builder))
|
|
@@ -68,7 +69,7 @@ export function appendSnippet(parent: Node, read: () => unknown): void {
|
|
|
68
69
|
first = false
|
|
69
70
|
return
|
|
70
71
|
}
|
|
71
|
-
|
|
72
|
-
dispose =
|
|
72
|
+
const next = replaceRange(open, close, dispose, builder)
|
|
73
|
+
dispose = next !== undefined ? group.track(next) : undefined
|
|
73
74
|
})
|
|
74
75
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { seedStreamedResolution } from '../seedStreamedResolution.ts'
|
|
1
|
+
import { seedResolved } from '../seedResolved.ts'
|
|
3
2
|
|
|
4
3
|
/*
|
|
5
4
|
Bundle-side consumer of an SSR stream chunk, the counterpart of the doc stream's inline
|
|
@@ -31,7 +30,7 @@ export function applyResolved(root: Element, frame: string): void {
|
|
|
31
30
|
bundle-consumed stream can't adopt a resolved branch while dropping its cache key. */
|
|
32
31
|
if (resolved.nodeName === 'ABIDE-CACHE') {
|
|
33
32
|
try {
|
|
34
|
-
|
|
33
|
+
seedResolved({ kind: 'cache', resolution: JSON.parse(resolved.textContent ?? 'null') })
|
|
35
34
|
} catch {
|
|
36
35
|
/* malformed payload — leave unseeded; the read falls back to a live fetch */
|
|
37
36
|
}
|
|
@@ -47,7 +46,7 @@ export function applyResolved(root: Element, frame: string): void {
|
|
|
47
46
|
recording it lets a later hydrate adopt this branch (no re-fetch). */
|
|
48
47
|
const payload = resolved.firstChild as Element | null
|
|
49
48
|
if (payload !== null && payload.nodeName === 'SCRIPT') {
|
|
50
|
-
|
|
49
|
+
seedResolved({ kind: 'resume', id: Number(id), resume: payload.textContent ?? '' })
|
|
51
50
|
payload.remove()
|
|
52
51
|
}
|
|
53
52
|
const open = `abide:await:${id}`
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { decodeRefJson } from '../../shared/decodeRefJson.ts'
|
|
2
2
|
import { effect } from '../effect.ts'
|
|
3
3
|
import { claimChild } from '../runtime/claimChild.ts'
|
|
4
|
+
import { RANGE_CLOSE, RANGE_OPEN } from '../runtime/RANGE_MARKER.ts'
|
|
4
5
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
5
6
|
import type { ResumeEntry } from '../runtime/RESUME.ts'
|
|
6
7
|
import { RESUME } from '../runtime/RESUME.ts'
|
|
@@ -8,14 +9,17 @@ import { scope } from '../runtime/scope.ts'
|
|
|
8
9
|
import { scopeGroup } from '../runtime/scopeGroup.ts'
|
|
9
10
|
import type { State } from '../runtime/types/State.ts'
|
|
10
11
|
import { state } from '../state.ts'
|
|
12
|
+
import { buildDetachedRange } from './buildDetachedRange.ts'
|
|
11
13
|
import { discardBoundary } from './discardBoundary.ts'
|
|
12
|
-
import {
|
|
14
|
+
import { removeRange } from './removeRange.ts'
|
|
13
15
|
|
|
14
16
|
/*
|
|
15
17
|
Async binding — the runtime for `<template await>`. Renders the pending branch,
|
|
16
18
|
then swaps to the resolved branch (with the value) or the error branch on settle.
|
|
17
|
-
Each branch
|
|
18
|
-
branch inserts
|
|
19
|
+
Each branch's content lives in a RANGE bounded by two comment markers (`[`…`]`), the
|
|
20
|
+
same model `when`/`switch`/`each` use — so a multi-root branch inserts as a unit
|
|
21
|
+
(`buildDetachedRange`) and detaches as a unit (`removeRange`), rather than tracking and
|
|
22
|
+
removing a node array by hand.
|
|
19
23
|
|
|
20
24
|
The read runs inside a abide-ui `effect`, so it's reactive: `abide/shared/cache`'s
|
|
21
25
|
store subscribes the key it reads to this effect (createSubscriber is abide-ui-
|
|
@@ -49,7 +53,7 @@ export function awaitBlock(
|
|
|
49
53
|
/* The live branch's scope, registered with the owner so it disposes on owner
|
|
50
54
|
teardown — not only when a settle/re-run swaps branches via detach. */
|
|
51
55
|
const group = scopeGroup()
|
|
52
|
-
let active: {
|
|
56
|
+
let active: { start: Comment; end: Comment; dispose: () => void } | undefined
|
|
53
57
|
let anchor: Node | undefined
|
|
54
58
|
let first = true
|
|
55
59
|
/* Bumped each run so a prior run's in-flight promise can't clobber a newer one. */
|
|
@@ -67,29 +71,26 @@ export function awaitBlock(
|
|
|
67
71
|
const detach = (): void => {
|
|
68
72
|
if (active !== undefined) {
|
|
69
73
|
active.dispose()
|
|
70
|
-
/*
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
node.parentNode?.removeChild(node)
|
|
76
|
-
}
|
|
74
|
+
/* `removeRange` evicts the markers AND everything between them via the end
|
|
75
|
+
marker's LIVE parent — not the captured `parent`, which (when this await is a
|
|
76
|
+
bare child of a control-flow branch) is the branch's build fragment, emptied
|
|
77
|
+
into the document once the enclosing block placed it. */
|
|
78
|
+
removeRange(active.start, active.end)
|
|
77
79
|
active = undefined
|
|
78
80
|
}
|
|
79
81
|
}
|
|
80
82
|
|
|
81
|
-
/* Replace the current content with a freshly-built branch, before the anchor. The
|
|
82
|
-
|
|
83
|
-
— appends freely),
|
|
83
|
+
/* Replace the current content with a freshly-built branch, before the anchor. The branch
|
|
84
|
+
builds into a detached `[`…`]`-bracketed fragment (so any content — components, text,
|
|
85
|
+
nested blocks — appends freely), the same create primitive the keyed-list runtimes use,
|
|
86
|
+
which lands as a marker-bounded range the next swap detaches with `removeRange`. */
|
|
84
87
|
const place = (build: (parent: Node) => void): void => {
|
|
85
88
|
detach()
|
|
86
|
-
const
|
|
87
|
-
const dispose =
|
|
88
|
-
|
|
89
|
-
)
|
|
90
|
-
|
|
91
|
-
;(anchor?.parentNode ?? parent).insertBefore(fragment, anchor ?? null)
|
|
92
|
-
active = { nodes, dispose }
|
|
89
|
+
const namespaceParent = anchor?.parentNode ?? parent
|
|
90
|
+
const { start, end, fragment, dispose } = buildDetachedRange(namespaceParent, build)
|
|
91
|
+
const tracked = group.track(dispose)
|
|
92
|
+
namespaceParent.insertBefore(fragment, anchor ?? null)
|
|
93
|
+
active = { start, end, dispose: tracked }
|
|
93
94
|
}
|
|
94
95
|
|
|
95
96
|
/* Settle to a resolved value. then→then updates the cell in place — the branch and its
|
|
@@ -151,23 +152,28 @@ export function awaitBlock(
|
|
|
151
152
|
)
|
|
152
153
|
}
|
|
153
154
|
|
|
154
|
-
/* Adopt an SSR-resolved branch in place (its content claims the existing nodes),
|
|
155
|
-
|
|
156
|
-
content is everything the build claimed between
|
|
155
|
+
/* Adopt an SSR-resolved branch in place (its content claims the existing nodes), then
|
|
156
|
+
wrap the adopted region in `[`…`]` markers and park an anchor just before the close
|
|
157
|
+
marker for later swaps. The adopted content is everything the build claimed between
|
|
158
|
+
the open and close markers; bracketing it makes the adopted branch a marker-bounded
|
|
159
|
+
range identical to a freshly-`place`d one, so the FIRST swap detaches it with
|
|
160
|
+
`removeRange` like every later swap (no node-array special case). */
|
|
157
161
|
const adopt = (open: Node | null, build: (parent: Node) => void): void => {
|
|
158
162
|
const cursor = hydration as NonNullable<typeof hydration>
|
|
159
|
-
|
|
163
|
+
const firstAdopted = open?.nextSibling ?? null
|
|
164
|
+
cursor.next.set(parent, firstAdopted)
|
|
160
165
|
const dispose = group.track(scope(() => build(parent)))
|
|
161
166
|
const close = claimChild(cursor, parent)
|
|
162
167
|
cursor.next.set(parent, close?.nextSibling ?? null)
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
+
/* Bracket the adopted nodes: `[` before the first claimed node (or before `close`
|
|
169
|
+
for an empty branch), `]` then the anchor just before `close`. */
|
|
170
|
+
const start = document.createComment(RANGE_OPEN)
|
|
171
|
+
parent.insertBefore(start, firstAdopted ?? close)
|
|
172
|
+
const end = document.createComment(RANGE_CLOSE)
|
|
173
|
+
parent.insertBefore(end, close)
|
|
168
174
|
anchor = document.createTextNode('')
|
|
169
175
|
parent.insertBefore(anchor, close)
|
|
170
|
-
active = {
|
|
176
|
+
active = { start, end, dispose }
|
|
171
177
|
}
|
|
172
178
|
|
|
173
179
|
/* Discard the SSR boundary and (re)build the block from the live promise, fresh
|