@abide/abide 0.32.1 → 0.33.0
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 +3 -3
- package/CHANGELOG.md +93 -63
- package/package.json +6 -2
- package/src/lib/server/runtime/buildCacheSnapshot.ts +5 -4
- package/src/lib/server/runtime/types/InspectorCacheEntry.ts +1 -1
- package/src/lib/shared/cache.ts +43 -29
- package/src/lib/shared/types/CacheEntry.ts +12 -12
- package/src/lib/shared/types/CacheOptions.ts +17 -13
- package/src/lib/ui/README.md +3 -3
- package/src/lib/ui/compile/HTML_TAGS.ts +132 -0
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +4 -1
- package/src/lib/ui/compile/componentWrapperTag.ts +13 -10
- package/src/lib/ui/compile/generateBuild.ts +265 -121
- package/src/lib/ui/compile/generateSSR.ts +78 -37
- package/src/lib/ui/compile/parseTemplate.ts +52 -0
- package/src/lib/ui/compile/skeletonable.ts +80 -0
- package/src/lib/ui/dom/MATHML_NAMESPACE.ts +6 -0
- package/src/lib/ui/dom/SVG_NAMESPACE.ts +7 -0
- package/src/lib/ui/dom/anchorCursor.ts +24 -0
- package/src/lib/ui/dom/appendSnippet.ts +1 -1
- package/src/lib/ui/dom/appendTextAt.ts +70 -0
- package/src/lib/ui/dom/awaitBlock.ts +27 -7
- package/src/lib/ui/dom/cloneStatic.ts +15 -24
- package/src/lib/ui/dom/each.ts +44 -25
- package/src/lib/ui/dom/eachAsync.ts +6 -2
- package/src/lib/ui/dom/effectiveChildNamespace.ts +13 -0
- package/src/lib/ui/dom/enterNamespace.ts +20 -0
- package/src/lib/ui/dom/fillBefore.ts +20 -3
- package/src/lib/ui/dom/foreignWrapperTag.ts +22 -0
- package/src/lib/ui/dom/hydrate.ts +1 -1
- package/src/lib/ui/dom/inheritedNamespace.ts +19 -0
- package/src/lib/ui/dom/mountSlot.ts +32 -0
- package/src/lib/ui/dom/openMarker.ts +4 -2
- package/src/lib/ui/dom/skeleton.ts +202 -0
- package/src/lib/ui/dom/switchBlock.ts +10 -3
- package/src/lib/ui/dom/templateFor.ts +28 -0
- package/src/lib/ui/dom/tryBlock.ts +7 -5
- package/src/lib/ui/dom/types/SkeletonHoles.ts +8 -0
- package/src/lib/ui/dom/when.ts +6 -2
- package/src/lib/ui/installHotBridge.ts +8 -2
- package/src/lib/ui/runtime/HOLE_ATTRIBUTE.ts +9 -0
- package/src/lib/ui/runtime/RENDER.ts +7 -0
- package/src/lib/ui/runtime/createDoc.ts +11 -8
- package/src/lib/ui/runtime/types/PathWalk.ts +10 -0
- package/src/lib/ui/runtime/types/UiProps.ts +3 -4
- package/src/lib/ui/runtime/walkPath.ts +27 -0
- package/template/src/ui/pages/about/page.abide +4 -6
- package/template/src/ui/pages/layout.abide +21 -0
- package/template/src/ui/pages/page.abide +5 -8
- package/src/lib/ui/compile/partitionSlots.ts +0 -36
- package/src/lib/ui/dom/openChild.ts +0 -22
- package/src/lib/ui/runtime/pathExists.ts +0 -23
- package/src/lib/ui/runtime/valueAtPath.ts +0 -18
- package/template/src/ui/Layout.abide +0 -19
package/src/lib/ui/dom/each.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { claimChild } from '../runtime/claimChild.ts'
|
|
|
3
3
|
import { OWNER } from '../runtime/OWNER.ts'
|
|
4
4
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
5
5
|
import { scope } from '../runtime/scope.ts'
|
|
6
|
+
import { enterNamespace } from './enterNamespace.ts'
|
|
6
7
|
import { moveRange } from './moveRange.ts'
|
|
7
8
|
import { removeRange } from './removeRange.ts'
|
|
8
9
|
import type { EachRow } from './types/EachRow.ts'
|
|
@@ -31,6 +32,7 @@ export function each<T>(
|
|
|
31
32
|
items: () => Iterable<T>,
|
|
32
33
|
keyOf: (item: T) => string,
|
|
33
34
|
render: (parent: Node, item: T) => void,
|
|
35
|
+
before: Node | null = null,
|
|
34
36
|
): void {
|
|
35
37
|
const rows = new Map<string, EachRow>()
|
|
36
38
|
|
|
@@ -53,7 +55,9 @@ export function each<T>(
|
|
|
53
55
|
const end = document.createComment(']')
|
|
54
56
|
const pending = document.createDocumentFragment()
|
|
55
57
|
pending.appendChild(start)
|
|
56
|
-
|
|
58
|
+
/* Build under `parent`'s foreign namespace so foreign row elements (svg/math)
|
|
59
|
+
built into the detached fragment are namespaced, not built as HTML. */
|
|
60
|
+
const dispose = enterNamespace(parent, () => scope(() => render(pending, item)))
|
|
57
61
|
pending.appendChild(end)
|
|
58
62
|
return { start, end, dispose, pending }
|
|
59
63
|
}
|
|
@@ -88,7 +92,9 @@ export function each<T>(
|
|
|
88
92
|
adopting = true
|
|
89
93
|
} else {
|
|
90
94
|
anchor = document.createTextNode('')
|
|
91
|
-
|
|
95
|
+
/* `before` (a static node located by the skeleton) places the row anchor among
|
|
96
|
+
siblings on create, so rows land before a static suffix; null appends (tail). */
|
|
97
|
+
parent.insertBefore(anchor, before)
|
|
92
98
|
}
|
|
93
99
|
|
|
94
100
|
effect(() => {
|
|
@@ -99,31 +105,44 @@ export function each<T>(
|
|
|
99
105
|
adopting = false // rows already adopted in document order; nothing to move
|
|
100
106
|
return
|
|
101
107
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
108
|
+
/* All SSR rows were adopted in the pre-effect loop, so every reconcile build is
|
|
109
|
+
create mode. Clear the global claim cursor for the duration: a synchronous
|
|
110
|
+
write that reconciles *mid-hydrate* (RENDER.hydration still active — e.g. a
|
|
111
|
+
page setting shared state during the hydrate pass) would otherwise make
|
|
112
|
+
buildRow and its inner row render claim SSR nodes that don't exist for a
|
|
113
|
+
freshly keyed row. The same `next` Map is restored, so the outer hydration
|
|
114
|
+
cursor is untouched (mirrors awaitBlock/tryBlock). */
|
|
115
|
+
const previousHydration = RENDER.hydration
|
|
116
|
+
RENDER.hydration = undefined
|
|
117
|
+
try {
|
|
118
|
+
const list = Array.isArray(source) ? source : [...source]
|
|
119
|
+
const keys = list.map(keyOf)
|
|
120
|
+
const present = new Set(keys)
|
|
121
|
+
/* Prune departed rows first so their ranges don't sit between survivors and
|
|
122
|
+
throw off the in-place sibling checks below. */
|
|
123
|
+
for (const [key, row] of rows) {
|
|
124
|
+
if (!present.has(key)) {
|
|
125
|
+
row.dispose()
|
|
126
|
+
removeRange(row.start, row.end)
|
|
127
|
+
rows.delete(key)
|
|
128
|
+
}
|
|
112
129
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
130
|
+
/* Walk backwards from the anchor: `cursor` is the node the current row must
|
|
131
|
+
precede. A row already ending there keeps its place; only out-of-order (or
|
|
132
|
+
freshly built) rows move. */
|
|
133
|
+
let cursor: Node = anchor
|
|
134
|
+
for (let index = list.length - 1; index >= 0; index -= 1) {
|
|
135
|
+
const key = keys[index] as string
|
|
136
|
+
let row = rows.get(key)
|
|
137
|
+
if (row === undefined) {
|
|
138
|
+
row = buildRow(list[index] as T)
|
|
139
|
+
rows.set(key, row)
|
|
140
|
+
}
|
|
141
|
+
placeBefore(row, cursor)
|
|
142
|
+
cursor = row.start
|
|
124
143
|
}
|
|
125
|
-
|
|
126
|
-
|
|
144
|
+
} finally {
|
|
145
|
+
RENDER.hydration = previousHydration
|
|
127
146
|
}
|
|
128
147
|
})
|
|
129
148
|
|
|
@@ -3,6 +3,7 @@ import { claimChild } from '../runtime/claimChild.ts'
|
|
|
3
3
|
import { OWNER } from '../runtime/OWNER.ts'
|
|
4
4
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
5
5
|
import { scope } from '../runtime/scope.ts'
|
|
6
|
+
import { enterNamespace } from './enterNamespace.ts'
|
|
6
7
|
import { removeRange } from './removeRange.ts'
|
|
7
8
|
import type { EachRow } from './types/EachRow.ts'
|
|
8
9
|
|
|
@@ -28,6 +29,7 @@ export function eachAsync<T>(
|
|
|
28
29
|
render: (parent: Node, item: T) => void,
|
|
29
30
|
/* Absent → an iterator rejection surfaces instead of rendering a catch branch. */
|
|
30
31
|
renderCatch: ((parent: Node, error: unknown) => void) | undefined,
|
|
32
|
+
before: Node | null = null,
|
|
31
33
|
): void {
|
|
32
34
|
const rows = new Map<string, EachRow>()
|
|
33
35
|
const hydration = RENDER.hydration
|
|
@@ -35,7 +37,7 @@ export function eachAsync<T>(
|
|
|
35
37
|
if (hydration !== undefined) {
|
|
36
38
|
parent.insertBefore(anchor, claimChild(hydration, parent)) // no server rows to claim
|
|
37
39
|
} else {
|
|
38
|
-
parent.
|
|
40
|
+
parent.insertBefore(anchor, before) // `before` places rows before a static suffix
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
/* Build a content range and insert it just before the anchor (arrival order). */
|
|
@@ -44,7 +46,9 @@ export function eachAsync<T>(
|
|
|
44
46
|
const end = document.createComment(']')
|
|
45
47
|
const fragment = document.createDocumentFragment()
|
|
46
48
|
fragment.appendChild(start)
|
|
47
|
-
const dispose =
|
|
49
|
+
const dispose = enterNamespace(anchor.parentNode ?? parent, () =>
|
|
50
|
+
scope(() => build(fragment)),
|
|
51
|
+
)
|
|
48
52
|
fragment.appendChild(end)
|
|
49
53
|
/* Insert via the anchor's LIVE parent: when this `each` is a bare child of a
|
|
50
54
|
control-flow branch, the captured `parent` is the branch's build fragment,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { RENDER } from '../runtime/RENDER.ts'
|
|
2
|
+
import { inheritedNamespace } from './inheritedNamespace.ts'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
The foreign namespace a child built under `parent` belongs to. A real element dictates
|
|
6
|
+
it from its own namespace (`inheritedNamespace`); a detached `DocumentFragment` — a
|
|
7
|
+
control-flow block's build buffer — carries none, so it inherits the ambient context
|
|
8
|
+
the enclosing block set via `enterNamespace`. `foreignWrapperTag` reads this so a
|
|
9
|
+
`skeleton` parsed into a block's fragment wraps its markup in the right namespace.
|
|
10
|
+
*/
|
|
11
|
+
export function effectiveChildNamespace(parent: Node): string | undefined {
|
|
12
|
+
return (parent as Element).namespaceURI == null ? RENDER.namespace : inheritedNamespace(parent)
|
|
13
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { RENDER } from '../runtime/RENDER.ts'
|
|
2
|
+
import { effectiveChildNamespace } from './effectiveChildNamespace.ts'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
Runs a control-flow block's fragment build with the ambient foreign namespace set from
|
|
6
|
+
its insertion `parent`, then restores it. Foreign elements (svg/math children) the
|
|
7
|
+
block builds into its detached fragment read this context (the fragment carries no
|
|
8
|
+
namespace of its own). A foreign `parent` establishes the context; a fragment parent
|
|
9
|
+
keeps the current one, so a block nested inside foreign content stays foreign; a real
|
|
10
|
+
HTML parent (e.g. `<foreignObject>`'s content) resets it.
|
|
11
|
+
*/
|
|
12
|
+
export function enterNamespace<T>(parent: Node, build: () => T): T {
|
|
13
|
+
const previous = RENDER.namespace
|
|
14
|
+
RENDER.namespace = effectiveChildNamespace(parent)
|
|
15
|
+
try {
|
|
16
|
+
return build()
|
|
17
|
+
} finally {
|
|
18
|
+
RENDER.namespace = previous
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { RENDER } from '../runtime/RENDER.ts'
|
|
1
2
|
import { scope } from '../runtime/scope.ts'
|
|
3
|
+
import { enterNamespace } from './enterNamespace.ts'
|
|
2
4
|
|
|
3
5
|
/*
|
|
4
6
|
Builds `content` into a fragment under a fresh reactive scope, then inserts it just
|
|
@@ -7,10 +9,25 @@ the content append freely (elements, components, nested blocks all use the norma
|
|
|
7
9
|
append path) before it lands as a unit. Inserting via the marker's LIVE parent
|
|
8
10
|
(`end.parentNode`) keeps placement correct even after an enclosing block has moved
|
|
9
11
|
the markers from a build-time fragment into the document.
|
|
12
|
+
|
|
13
|
+
`fillBefore` is exclusively the *create* path — control-flow blocks adopt SSR nodes
|
|
14
|
+
in place (a direct `scope(render)`) and route only fresh builds here. So neutralize
|
|
15
|
+
the global claim cursor for the build: a rebuild that runs while the hydrate pass is
|
|
16
|
+
still active (e.g. a synchronous write that flips a `when`/`switch` mid-hydrate)
|
|
17
|
+
would otherwise make the build helpers claim SSR nodes that don't exist for fresh
|
|
18
|
+
content. The same cursor is restored after (mirrors awaitBlock/tryBlock/each).
|
|
10
19
|
*/
|
|
11
20
|
export function fillBefore(end: Node, content: (into: Node) => void): () => void {
|
|
12
21
|
const fragment = document.createDocumentFragment()
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
22
|
+
const previousHydration = RENDER.hydration
|
|
23
|
+
RENDER.hydration = undefined
|
|
24
|
+
try {
|
|
25
|
+
/* Build under the insertion parent's foreign namespace (if any), so foreign
|
|
26
|
+
elements built into the fragment are namespaced off `end`'s live parent. */
|
|
27
|
+
const dispose = enterNamespace(end.parentNode ?? end, () => scope(() => content(fragment)))
|
|
28
|
+
;(end.parentNode ?? end).insertBefore(fragment, end)
|
|
29
|
+
return dispose
|
|
30
|
+
} finally {
|
|
31
|
+
RENDER.hydration = previousHydration
|
|
32
|
+
}
|
|
16
33
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { effectiveChildNamespace } from './effectiveChildNamespace.ts'
|
|
2
|
+
import { MATHML_NAMESPACE } from './MATHML_NAMESPACE.ts'
|
|
3
|
+
import { SVG_NAMESPACE } from './SVG_NAMESPACE.ts'
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
The wrapper tag a static run must be parsed inside so its children land in `parent`'s
|
|
7
|
+
foreign namespace — `svg`/`math`, or undefined for HTML. A bare `<path>` fragment
|
|
8
|
+
parses into the HTML namespace; wrapping it in `<svg>` lets the parser namespace it.
|
|
9
|
+
`cloneStatic` uses this for a static run coalesced under a foreign parent that was
|
|
10
|
+
built imperatively, or under a control-flow block's fragment inside foreign content
|
|
11
|
+
(where `parent`'s effective namespace comes from the ambient context).
|
|
12
|
+
*/
|
|
13
|
+
export function foreignWrapperTag(parent: Node): string | undefined {
|
|
14
|
+
const namespace = effectiveChildNamespace(parent)
|
|
15
|
+
if (namespace === SVG_NAMESPACE) {
|
|
16
|
+
return 'svg'
|
|
17
|
+
}
|
|
18
|
+
if (namespace === MATHML_NAMESPACE) {
|
|
19
|
+
return 'math'
|
|
20
|
+
}
|
|
21
|
+
return undefined
|
|
22
|
+
}
|
|
@@ -5,7 +5,7 @@ import { scope } from '../runtime/scope.ts'
|
|
|
5
5
|
|
|
6
6
|
/*
|
|
7
7
|
Adopts existing server-rendered DOM instead of rebuilding it. Runs `build(host)`
|
|
8
|
-
with a claim cursor active, so the dom helpers (
|
|
8
|
+
with a claim cursor active, so the dom helpers (skeleton/appendText/appendStatic)
|
|
9
9
|
take the existing nodes rather than creating new ones — attaching event listeners
|
|
10
10
|
and reactive effects to the server's markup in place (no re-render, preserved
|
|
11
11
|
focus/scroll). Returns a disposer.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { MATHML_NAMESPACE } from './MATHML_NAMESPACE.ts'
|
|
2
|
+
import { SVG_NAMESPACE } from './SVG_NAMESPACE.ts'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
The foreign namespace that element children of `node` inherit — `SVG_NAMESPACE` under
|
|
6
|
+
an svg, `MATHML_NAMESPACE` under math — or undefined for ordinary HTML. `<foreignObject>`
|
|
7
|
+
is SVG's re-entry point back into HTML, so its children are HTML despite its own SVG
|
|
8
|
+
namespace.
|
|
9
|
+
*/
|
|
10
|
+
export function inheritedNamespace(node: Node): string | undefined {
|
|
11
|
+
const namespace = (node as Element).namespaceURI
|
|
12
|
+
if (namespace === SVG_NAMESPACE && (node as Element).localName !== 'foreignObject') {
|
|
13
|
+
return SVG_NAMESPACE
|
|
14
|
+
}
|
|
15
|
+
if (namespace === MATHML_NAMESPACE) {
|
|
16
|
+
return MATHML_NAMESPACE
|
|
17
|
+
}
|
|
18
|
+
return undefined
|
|
19
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { RENDER } from '../runtime/RENDER.ts'
|
|
2
|
+
import { scope } from '../runtime/scope.ts'
|
|
3
|
+
import { fillBefore } from './fillBefore.ts'
|
|
4
|
+
import { openMarker } from './openMarker.ts'
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
Mounts a component's `<slot>` content as a marker-bounded range, so a slot positions among
|
|
8
|
+
static siblings exactly like a control-flow block — by `before` (create) or the claim
|
|
9
|
+
cursor (hydrate). `render` appends the parent-supplied `$children`, or the slot's own
|
|
10
|
+
fallback when none was passed; it runs once (a slot never toggles), so there is no effect or
|
|
11
|
+
re-render — the markers exist only to delimit the range for create insertion and hydrate
|
|
12
|
+
claiming.
|
|
13
|
+
|
|
14
|
+
Create fills the range before the end marker; hydrate adopts the server range in place
|
|
15
|
+
(claiming from the parked cursor). Mirrors `when` without the conditional swap.
|
|
16
|
+
*/
|
|
17
|
+
// @readme plumbing
|
|
18
|
+
export function mountSlot(
|
|
19
|
+
parent: Node,
|
|
20
|
+
render: (host: Node) => void,
|
|
21
|
+
before: Node | null = null,
|
|
22
|
+
): void {
|
|
23
|
+
const hydration = RENDER.hydration
|
|
24
|
+
openMarker(parent, '[', before)
|
|
25
|
+
if (hydration !== undefined) {
|
|
26
|
+
scope(() => render(parent)) // content claims the SSR range in place
|
|
27
|
+
openMarker(parent, ']')
|
|
28
|
+
} else {
|
|
29
|
+
const end = openMarker(parent, ']', before)
|
|
30
|
+
fillBefore(end, render)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -9,7 +9,7 @@ HTML and the block can claim them positionally on hydrate — the boundary that
|
|
|
9
9
|
a branch hold ANY content (components, text, nested blocks, snippets) as a range,
|
|
10
10
|
rather than a list of single nodes.
|
|
11
11
|
*/
|
|
12
|
-
export function openMarker(parent: Node, data: string): Comment {
|
|
12
|
+
export function openMarker(parent: Node, data: string, before: Node | null = null): Comment {
|
|
13
13
|
const hydration = RENDER.hydration
|
|
14
14
|
if (hydration !== undefined) {
|
|
15
15
|
const node = claimChild(hydration, parent) as unknown as Comment
|
|
@@ -17,6 +17,8 @@ export function openMarker(parent: Node, data: string): Comment {
|
|
|
17
17
|
return node
|
|
18
18
|
}
|
|
19
19
|
const node = document.createComment(data)
|
|
20
|
-
|
|
20
|
+
/* `before` (a node already in `parent`) places the block among static siblings —
|
|
21
|
+
its content lands at that position; without it the marker appends (block at tail). */
|
|
22
|
+
parent.insertBefore(node, before)
|
|
21
23
|
return node
|
|
22
24
|
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { claimChild } from '../runtime/claimChild.ts'
|
|
2
|
+
import { HOLE_ATTRIBUTE } from '../runtime/HOLE_ATTRIBUTE.ts'
|
|
3
|
+
import { RENDER } from '../runtime/RENDER.ts'
|
|
4
|
+
import { foreignWrapperTag } from './foreignWrapperTag.ts'
|
|
5
|
+
import type { SkeletonHoles } from './types/SkeletonHoles.ts'
|
|
6
|
+
|
|
7
|
+
type CompiledSkeleton = {
|
|
8
|
+
/* The node whose children are the skeleton's top-level run — the template content,
|
|
9
|
+
or a foreign wrapper element (`<svg>`/`<math>`) the run was parsed inside. */
|
|
10
|
+
source: Node
|
|
11
|
+
/* Element holes, in pre-order — each an element-only-index path from a top-level
|
|
12
|
+
node. Element-only indexing keeps a path stable: a reactive text value is a text
|
|
13
|
+
node, so its width never shifts an element hole between the empty client skeleton
|
|
14
|
+
and the value-filled server DOM. */
|
|
15
|
+
elementPaths: number[][]
|
|
16
|
+
topLevelCount: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/* Parsed-once skeleton per unique string, keyed by the owning document (see
|
|
20
|
+
`templateFor` for the per-document rationale). */
|
|
21
|
+
const CACHES = new WeakMap<object, Map<string, CompiledSkeleton>>()
|
|
22
|
+
|
|
23
|
+
/* An element carries `hasAttribute`; text/comment nodes do not. Used instead of
|
|
24
|
+
`nodeType` so the walk runs under the test mini-dom too. */
|
|
25
|
+
function isElement(node: Node): node is Element {
|
|
26
|
+
return typeof (node as Element).hasAttribute === 'function'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* A comment node's data, or undefined for elements/text. A comment is a node that is
|
|
30
|
+
neither an element (`hasAttribute`) nor a text node (`splitText`); the mini-dom
|
|
31
|
+
exposes no `nodeType`, so detect by method. */
|
|
32
|
+
function commentData(node: Node): string | undefined {
|
|
33
|
+
if (isElement(node) || typeof (node as Text).splitText === 'function') {
|
|
34
|
+
return undefined
|
|
35
|
+
}
|
|
36
|
+
return (node as Comment).data
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/* Block-range boundary markers. A control-flow block's rendered content sits between an
|
|
40
|
+
OPEN and CLOSE comment: `[`…`]` for each rows / if / switch / slot ranges, and named
|
|
41
|
+
`abide:…`…`/abide:…` boundaries for await / try / snippet / html. The skeleton's own
|
|
42
|
+
anchor (`a`) sits OUTSIDE any such range. */
|
|
43
|
+
function isOpenMarker(data: string): boolean {
|
|
44
|
+
return data === '[' || data.startsWith('abide:')
|
|
45
|
+
}
|
|
46
|
+
function isCloseMarker(data: string): boolean {
|
|
47
|
+
return data === ']' || data.startsWith('/abide:')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* The `index`-th depth-0 ELEMENT among `children` — skipping text/comment nodes AND any
|
|
51
|
+
element nested inside a block's rendered range (between `[`…`]` / `abide:…` boundaries),
|
|
52
|
+
which belongs to that block's own skeleton. The compiler indexes element holes over the
|
|
53
|
+
SHALLOW template (block positions are `<!--a-->` anchors, no content), so on hydrate the
|
|
54
|
+
expanded tree must skip that inline content or a hole positioned after a block shifts. In
|
|
55
|
+
create mode the clone is shallow (no markers), so depth stays 0 — a plain element count. */
|
|
56
|
+
function elementChildAt(children: ArrayLike<Node>, index: number): Element {
|
|
57
|
+
let seen = 0
|
|
58
|
+
let depth = 0
|
|
59
|
+
for (let cursor = 0; cursor < children.length; cursor += 1) {
|
|
60
|
+
const child = children[cursor] as Node
|
|
61
|
+
const data = commentData(child)
|
|
62
|
+
if (data === undefined) {
|
|
63
|
+
if (isElement(child) && depth === 0) {
|
|
64
|
+
if (seen === index) {
|
|
65
|
+
return child
|
|
66
|
+
}
|
|
67
|
+
seen += 1
|
|
68
|
+
}
|
|
69
|
+
} else if (isCloseMarker(data)) {
|
|
70
|
+
depth -= 1
|
|
71
|
+
} else if (isOpenMarker(data)) {
|
|
72
|
+
depth += 1
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return undefined as unknown as Element
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* Records each element hole's element-only path in PRE-ORDER (the `HOLE_ATTRIBUTE`
|
|
79
|
+
marks which) and strips the marker — the compiler assigns element-hole indices in the
|
|
80
|
+
same pre-order, so the arrays line up without numbering the markers. */
|
|
81
|
+
function indexElementHoles(container: Node, prefix: number[], paths: number[][]): void {
|
|
82
|
+
const children = container.childNodes
|
|
83
|
+
let elementIndex = 0
|
|
84
|
+
for (let cursor = 0; cursor < children.length; cursor += 1) {
|
|
85
|
+
const child = children[cursor] as Node
|
|
86
|
+
if (!isElement(child)) {
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
const path = [...prefix, elementIndex]
|
|
90
|
+
elementIndex += 1
|
|
91
|
+
if (child.hasAttribute(HOLE_ATTRIBUTE)) {
|
|
92
|
+
paths.push(path)
|
|
93
|
+
child.removeAttribute(HOLE_ATTRIBUTE)
|
|
94
|
+
}
|
|
95
|
+
indexElementHoles(child, path, paths)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* Collects THIS skeleton's own anchor holes (`a` comments) in document order, present in
|
|
100
|
+
both the cloned skeleton and the server DOM (text-width-independent). The compiler emits
|
|
101
|
+
anchors in the same order, so the arrays line up.
|
|
102
|
+
|
|
103
|
+
In hydrate mode the claimed tree is FULLY EXPANDED — a nested block's rendered content
|
|
104
|
+
(each rows, branches, await/try boundaries) sits inline — so a naive descent would also
|
|
105
|
+
collect the inner block's anchors, which belong to that block's OWN skeleton, shifting
|
|
106
|
+
every index past the first block. Block content is bounded by range markers, so track
|
|
107
|
+
depth per sibling list and take an anchor (and recurse into an element) only at depth 0,
|
|
108
|
+
where the skeleton's own structure lives. In create mode the clone is shallow (the blocks
|
|
109
|
+
have not built yet — no markers), so depth stays 0 and this is a plain document scan. */
|
|
110
|
+
function scanAnchors(nodes: ArrayLike<Node>, anchors: Node[]): void {
|
|
111
|
+
let depth = 0
|
|
112
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
113
|
+
const node = nodes[index] as Node
|
|
114
|
+
const data = commentData(node)
|
|
115
|
+
if (data === undefined) {
|
|
116
|
+
if (isElement(node) && depth === 0) {
|
|
117
|
+
scanAnchors(node.childNodes, anchors)
|
|
118
|
+
}
|
|
119
|
+
} else if (isCloseMarker(data)) {
|
|
120
|
+
depth -= 1
|
|
121
|
+
} else if (isOpenMarker(data)) {
|
|
122
|
+
depth += 1
|
|
123
|
+
} else if (data === 'a' && depth === 0) {
|
|
124
|
+
anchors.push(node)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/* When `parent` is foreign (or a control-flow fragment inside foreign content), the
|
|
130
|
+
skeleton's own markup carries no foreign ancestor, so a bare `<circle>` would parse
|
|
131
|
+
into the HTML namespace. Parse it inside the matching wrapper so the parser
|
|
132
|
+
namespaces the run; key the cache by wrapper too, since one string can be realized
|
|
133
|
+
in either context. */
|
|
134
|
+
function compile(html: string, wrapper: string | undefined): CompiledSkeleton {
|
|
135
|
+
let cache = CACHES.get(document)
|
|
136
|
+
if (cache === undefined) {
|
|
137
|
+
cache = new Map()
|
|
138
|
+
CACHES.set(document, cache)
|
|
139
|
+
}
|
|
140
|
+
const key = wrapper === undefined ? html : `${wrapper} ${html}`
|
|
141
|
+
let compiled = cache.get(key)
|
|
142
|
+
if (compiled === undefined) {
|
|
143
|
+
const template = document.createElement('template')
|
|
144
|
+
template.innerHTML = wrapper === undefined ? html : `<${wrapper}>${html}</${wrapper}>`
|
|
145
|
+
const source =
|
|
146
|
+
wrapper === undefined ? template.content : (template.content.firstChild as Node)
|
|
147
|
+
const elementPaths: number[][] = []
|
|
148
|
+
indexElementHoles(source, [], elementPaths)
|
|
149
|
+
compiled = { source, elementPaths, topLevelCount: source.childNodes.length }
|
|
150
|
+
cache.set(key, compiled)
|
|
151
|
+
}
|
|
152
|
+
return compiled
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/* Walks an element-only path from the top-level node list to the target element. */
|
|
156
|
+
function resolveElementHole(topLevel: ArrayLike<Node>, path: number[]): Element {
|
|
157
|
+
let node = elementChildAt(topLevel, path[0] as number)
|
|
158
|
+
for (let depth = 1; depth < path.length; depth += 1) {
|
|
159
|
+
node = elementChildAt(node.childNodes, path[depth] as number)
|
|
160
|
+
}
|
|
161
|
+
return node
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/*
|
|
165
|
+
Realizes a compiled skeleton under `parent` and returns its holes: `el` the element
|
|
166
|
+
holes (attribute/listener/bind), in pre-order; `an` the anchor holes (reactive text,
|
|
167
|
+
control flow, components), in document order. The browser's parser is the sole
|
|
168
|
+
tree-builder here, so foreign content (SVG/MathML) lands in the correct namespace and
|
|
169
|
+
`cloneNode` preserves it — a hand-rolled `createElement` tree-builder could not.
|
|
170
|
+
|
|
171
|
+
Element holes resolve by element-only path (stable against text-value width, computed
|
|
172
|
+
client-side — no server marker). Anchor holes resolve by scanning for their `a` comment
|
|
173
|
+
markers (present in both clone and server DOM). Create mode clones the parsed top-level
|
|
174
|
+
nodes; hydrate mode claims the matching server run.
|
|
175
|
+
*/
|
|
176
|
+
// @readme plumbing
|
|
177
|
+
export function skeleton(parent: Node, html: string): SkeletonHoles {
|
|
178
|
+
const { source, elementPaths, topLevelCount } = compile(html, foreignWrapperTag(parent))
|
|
179
|
+
const hydration = RENDER.hydration
|
|
180
|
+
const topLevel: Node[] = []
|
|
181
|
+
if (hydration !== undefined) {
|
|
182
|
+
let node = claimChild(hydration, parent)
|
|
183
|
+
for (let count = 0; count < topLevelCount && node !== null; count += 1) {
|
|
184
|
+
topLevel.push(node)
|
|
185
|
+
node = node.nextSibling
|
|
186
|
+
}
|
|
187
|
+
hydration.next.set(parent, node)
|
|
188
|
+
} else {
|
|
189
|
+
const children = source.childNodes
|
|
190
|
+
for (let index = 0; index < children.length; index += 1) {
|
|
191
|
+
const clone = (children[index] as Node).cloneNode(true)
|
|
192
|
+
topLevel.push(clone)
|
|
193
|
+
parent.appendChild(clone)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const an: Node[] = []
|
|
197
|
+
scanAnchors(topLevel, an)
|
|
198
|
+
return {
|
|
199
|
+
el: elementPaths.map((path) => resolveElementHole(topLevel, path)),
|
|
200
|
+
an,
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -19,7 +19,12 @@ matching case in place, claim the end marker. The effect's first run picks the s
|
|
|
19
19
|
case and is a no-op; later changes swap the range.
|
|
20
20
|
*/
|
|
21
21
|
// @readme plumbing
|
|
22
|
-
export function switchBlock(
|
|
22
|
+
export function switchBlock(
|
|
23
|
+
parent: Node,
|
|
24
|
+
subject: () => unknown,
|
|
25
|
+
cases: SwitchCase[],
|
|
26
|
+
before: Node | null = null,
|
|
27
|
+
): void {
|
|
23
28
|
const hydration = RENDER.hydration
|
|
24
29
|
let dispose: (() => void) | undefined
|
|
25
30
|
let activeIndex: number
|
|
@@ -34,7 +39,9 @@ export function switchBlock(parent: Node, subject: () => unknown, cases: SwitchC
|
|
|
34
39
|
const caseAt = (index: number): SwitchCase | undefined =>
|
|
35
40
|
index === -1 ? undefined : cases[index]
|
|
36
41
|
|
|
37
|
-
|
|
42
|
+
/* `before` places the range among static siblings on create (block before a suffix);
|
|
43
|
+
hydrate ignores it and uses the parked claim cursor. */
|
|
44
|
+
const start = openMarker(parent, '[', before)
|
|
38
45
|
if (hydration !== undefined) {
|
|
39
46
|
activeIndex = select(subject())
|
|
40
47
|
const chosen = caseAt(activeIndex)
|
|
@@ -43,7 +50,7 @@ export function switchBlock(parent: Node, subject: () => unknown, cases: SwitchC
|
|
|
43
50
|
}
|
|
44
51
|
end = openMarker(parent, ']')
|
|
45
52
|
} else {
|
|
46
|
-
end = openMarker(parent, ']')
|
|
53
|
+
end = openMarker(parent, ']', before)
|
|
47
54
|
activeIndex = select(subject())
|
|
48
55
|
const chosen = caseAt(activeIndex)
|
|
49
56
|
if (chosen !== undefined) {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Parsed-once `<template>` per unique static-skeleton string, reused across every
|
|
3
|
+
mount. A `<template>` (not a detached `<div>`) so table/select content parses by
|
|
4
|
+
the real content model, exactly as the browser parsed the server markup. Backs
|
|
5
|
+
`cloneStatic`'s skeleton-string→template cache.
|
|
6
|
+
|
|
7
|
+
The cache is keyed by the owning `document`: a template belongs to the document
|
|
8
|
+
that created it, and its clones must land in that same document. In production there
|
|
9
|
+
is one document, so this is one inner map; under the test harness, which installs a
|
|
10
|
+
fresh `document` per file, it keeps each file's templates (and their node class)
|
|
11
|
+
from leaking into the next.
|
|
12
|
+
*/
|
|
13
|
+
const CACHES = new WeakMap<object, Map<string, HTMLTemplateElement>>()
|
|
14
|
+
|
|
15
|
+
export function templateFor(html: string): HTMLTemplateElement {
|
|
16
|
+
let cache = CACHES.get(document)
|
|
17
|
+
if (cache === undefined) {
|
|
18
|
+
cache = new Map()
|
|
19
|
+
CACHES.set(document, cache)
|
|
20
|
+
}
|
|
21
|
+
let template = cache.get(html)
|
|
22
|
+
if (template === undefined) {
|
|
23
|
+
template = document.createElement('template')
|
|
24
|
+
template.innerHTML = html
|
|
25
|
+
cache.set(html, template)
|
|
26
|
+
}
|
|
27
|
+
return template
|
|
28
|
+
}
|
|
@@ -2,6 +2,7 @@ import { claimChild } from '../runtime/claimChild.ts'
|
|
|
2
2
|
import { OWNER } from '../runtime/OWNER.ts'
|
|
3
3
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
4
4
|
import { discardBoundary } from './discardBoundary.ts'
|
|
5
|
+
import { enterNamespace } from './enterNamespace.ts'
|
|
5
6
|
|
|
6
7
|
/*
|
|
7
8
|
Synchronous error boundary — the runtime for `<template try>`. Builds the guarded
|
|
@@ -25,6 +26,7 @@ export function tryBlock(
|
|
|
25
26
|
id: number,
|
|
26
27
|
renderTry: (parent: Node) => void,
|
|
27
28
|
renderCatch?: (parent: Node, error: unknown) => void,
|
|
29
|
+
before: Node | null = null,
|
|
28
30
|
): void {
|
|
29
31
|
/* Run a void build under a fresh ownership scope; on throw, tear down the partial
|
|
30
32
|
effects/listeners it registered and rethrow so the caller can fall back. */
|
|
@@ -63,7 +65,7 @@ export function tryBlock(
|
|
|
63
65
|
RENDER.hydration = undefined
|
|
64
66
|
try {
|
|
65
67
|
const fragment = document.createDocumentFragment()
|
|
66
|
-
guard(() => renderCatch(fragment, error))
|
|
68
|
+
enterNamespace(parent, () => guard(() => renderCatch(fragment, error)))
|
|
67
69
|
parent.insertBefore(fragment, after)
|
|
68
70
|
} finally {
|
|
69
71
|
RENDER.hydration = previous
|
|
@@ -76,14 +78,14 @@ export function tryBlock(
|
|
|
76
78
|
(they never entered the document) before the catch builds. */
|
|
77
79
|
try {
|
|
78
80
|
const fragment = document.createDocumentFragment()
|
|
79
|
-
guard(() => renderTry(fragment))
|
|
80
|
-
parent.
|
|
81
|
+
enterNamespace(parent, () => guard(() => renderTry(fragment)))
|
|
82
|
+
parent.insertBefore(fragment, before)
|
|
81
83
|
} catch (error) {
|
|
82
84
|
if (renderCatch === undefined) {
|
|
83
85
|
throw error
|
|
84
86
|
}
|
|
85
87
|
const fragment = document.createDocumentFragment()
|
|
86
|
-
guard(() => renderCatch(fragment, error))
|
|
87
|
-
parent.
|
|
88
|
+
enterNamespace(parent, () => guard(() => renderCatch(fragment, error)))
|
|
89
|
+
parent.insertBefore(fragment, before)
|
|
88
90
|
}
|
|
89
91
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/* The holes a realized `skeleton` exposes for the build's attach code to wire up:
|
|
2
|
+
`el` the element holes (attribute/listener/bind nodes) in pre-order, `an` the anchor
|
|
3
|
+
holes (reactive text, control flow, components) in document order. Two arrays so the
|
|
4
|
+
compiler and SSR only agree on traversal order, never on synchronized indices. */
|
|
5
|
+
export type SkeletonHoles = {
|
|
6
|
+
el: Element[]
|
|
7
|
+
an: Node[]
|
|
8
|
+
}
|