@abide/abide 0.41.0 → 0.42.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 +333 -326
- package/CHANGELOG.md +92 -0
- package/README.md +91 -83
- package/package.json +1 -1
- package/src/abideLsp.ts +46 -8
- package/src/abideResolverPlugin.ts +3 -5
- package/src/lib/server/runtime/devClientFingerprint.ts +2 -1
- package/src/lib/shared/escapeRegex.ts +9 -0
- package/src/lib/shared/fileName.ts +9 -0
- package/src/lib/shared/fileStem.ts +3 -1
- package/src/lib/shared/programNameForPackage.ts +3 -1
- package/src/lib/shared/stripImport.ts +3 -1
- package/src/lib/ui/compile/ABIDE_SEMANTIC_TOKENS_LEGEND.ts +45 -0
- package/src/lib/ui/compile/SSR_ESCAPE.ts +3 -1
- package/src/lib/ui/compile/abideUiPlugin.ts +2 -1
- package/src/lib/ui/compile/compileShadow.ts +46 -9
- package/src/lib/ui/compile/createShadowLanguageService.ts +48 -0
- package/src/lib/ui/compile/encodeSemanticTokens.ts +37 -0
- package/src/lib/ui/compile/generateBuild.ts +13 -6
- package/src/lib/ui/compile/generateSSR.ts +23 -14
- package/src/lib/ui/compile/makeVarNamer.ts +10 -0
- package/src/lib/ui/compile/offsetToPosition.ts +9 -0
- package/src/lib/ui/compile/parseTemplate.ts +524 -104
- package/src/lib/ui/compile/skeletonContext.ts +67 -79
- package/src/lib/ui/compile/structuralBlockTokens.ts +101 -0
- package/src/lib/ui/compile/templateAnchorAdapter.ts +61 -0
- package/src/lib/ui/compile/templateElementAdapter.ts +44 -0
- package/src/lib/ui/compile/types/SemanticToken.ts +11 -0
- package/src/lib/ui/compile/types/TemplateNode.ts +9 -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/appendText.ts +1 -5
- package/src/lib/ui/dom/applyResolved.ts +4 -9
- 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/isComment.ts +6 -0
- 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 +12 -16
- package/src/lib/ui/runtime/pathSegments.ts +10 -0
- 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
- package/template/.zed/settings.json +4 -0
- package/template/src/ui/pages/page.abide +4 -4
|
@@ -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'
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { flushEffects } from './flushEffects.ts'
|
|
2
|
+
import { REACTIVE_CONTEXT } from './REACTIVE_CONTEXT.ts'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
Runs `fn` with reactive writes coalesced: effects dirtied inside queue once and
|
|
6
|
+
flush a single time when the outermost batch exits, so a burst of writes (e.g. an
|
|
7
|
+
event handler setting several signals) re-runs each dependent effect once instead
|
|
8
|
+
of once per write. Nests safely — only the depth-0 exit flushes — so a batched
|
|
9
|
+
write that calls into another batched write (a handler invoking a doc patch) still
|
|
10
|
+
flushes once, at the top. Same idiom `createDoc`/`clientPage` inline, factored out.
|
|
11
|
+
*/
|
|
12
|
+
export function batch<T>(fn: () => T): T {
|
|
13
|
+
REACTIVE_CONTEXT.batchDepth += 1
|
|
14
|
+
try {
|
|
15
|
+
return fn()
|
|
16
|
+
} finally {
|
|
17
|
+
REACTIVE_CONTEXT.batchDepth -= 1
|
|
18
|
+
if (REACTIVE_CONTEXT.batchDepth === 0) {
|
|
19
|
+
flushEffects()
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { PageSnapshot } from '../../shared/types/PageSnapshot.ts'
|
|
2
2
|
import { state } from '../state.ts'
|
|
3
|
-
import {
|
|
4
|
-
import { REACTIVE_CONTEXT } from './REACTIVE_CONTEXT.ts'
|
|
3
|
+
import { batch } from './batch.ts'
|
|
5
4
|
import type { State } from './types/State.ts'
|
|
6
5
|
|
|
7
6
|
/*
|
|
@@ -107,15 +106,11 @@ export const clientPage: { value: PageSnapshot } = {
|
|
|
107
106
|
(e.g. `page.url` + `page.params.id`) re-runs once per field and transiently
|
|
108
107
|
observes a half-updated snapshot (new url, stale id). Same batch idiom as
|
|
109
108
|
`createDoc` — flush once, after every cell is reconciled. */
|
|
110
|
-
|
|
111
|
-
try {
|
|
109
|
+
batch(() => {
|
|
112
110
|
routeCell.value = next.route
|
|
113
111
|
urlCell.value = next.url
|
|
114
112
|
navigatingCell.value = next.navigating
|
|
115
113
|
reconcileParams(next.params)
|
|
116
|
-
}
|
|
117
|
-
REACTIVE_CONTEXT.batchDepth -= 1
|
|
118
|
-
}
|
|
119
|
-
flushEffects()
|
|
114
|
+
})
|
|
120
115
|
},
|
|
121
116
|
}
|
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
import { applyPatchToTree } from './applyPatchToTree.ts'
|
|
2
|
+
import { batch } from './batch.ts'
|
|
2
3
|
import { createComputedNode } from './createComputedNode.ts'
|
|
3
4
|
import { createSignalNode } from './createSignalNode.ts'
|
|
4
|
-
import { flushEffects } from './flushEffects.ts'
|
|
5
5
|
import { PATCH_BUS } from './PATCH_BUS.ts'
|
|
6
|
-
import {
|
|
6
|
+
import { pathSegments } from './pathSegments.ts'
|
|
7
7
|
import { readNode } from './readNode.ts'
|
|
8
8
|
import { trigger } from './trigger.ts'
|
|
9
9
|
import type { Cell } from './types/Cell.ts'
|
|
10
10
|
import type { Doc } from './types/Doc.ts'
|
|
11
11
|
import type { Patch } from './types/Patch.ts'
|
|
12
12
|
import type { ReactiveNode } from './types/ReactiveNode.ts'
|
|
13
|
-
import { unescapeKey } from './unescapeKey.ts'
|
|
14
13
|
import { walkPath } from './walkPath.ts'
|
|
15
14
|
import { writeNode } from './writeNode.ts'
|
|
16
15
|
|
|
@@ -133,7 +132,7 @@ export function createDoc(initial: unknown): Doc {
|
|
|
133
132
|
function apply(patch: Patch): void {
|
|
134
133
|
/* Segments index the tree, so they carry the REAL keys (unescaped); the path
|
|
135
134
|
strings (parentPath, node-map keys) stay escaped, re-walked through walkPath. */
|
|
136
|
-
const segments = patch.path === '' ? [] : patch.path
|
|
135
|
+
const segments = patch.path === '' ? [] : pathSegments(patch.path)
|
|
137
136
|
/* Capture the pre-image only when a consumer is listening (the inverse's only
|
|
138
137
|
cost): a replace/remove inverts to the value it overwrote, an add to a
|
|
139
138
|
remove (computed post-apply, below, to resolve an array append's index). */
|
|
@@ -157,8 +156,7 @@ export function createDoc(initial: unknown): Doc {
|
|
|
157
156
|
const nonShiftingAdd =
|
|
158
157
|
patch.op === 'add' &&
|
|
159
158
|
(!parentIsArray || leafKey === '-' || Number(leafKey) === arrayLength - 1)
|
|
160
|
-
|
|
161
|
-
try {
|
|
159
|
+
batch(() => {
|
|
162
160
|
if (segments.length === 0) {
|
|
163
161
|
wakeSubtree('', true, true)
|
|
164
162
|
} else if (!structural) {
|
|
@@ -178,15 +176,13 @@ export function createDoc(initial: unknown): Doc {
|
|
|
178
176
|
} else {
|
|
179
177
|
wakeSubtree(parentPath, true, true)
|
|
180
178
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}
|
|
189
|
-
flushEffects()
|
|
179
|
+
/* Announce the change before effects flush, so a patch an effect emits in
|
|
180
|
+
reaction lands AFTER this one on the bus — the journal stays chronological.
|
|
181
|
+
Emitting inside the batch keeps it ahead of the depth-0 flush on batch exit. */
|
|
182
|
+
if (PATCH_BUS.active) {
|
|
183
|
+
PATCH_BUS.emit({ doc: self, patch, inverse: inverseOf(patch, before) })
|
|
184
|
+
}
|
|
185
|
+
})
|
|
190
186
|
}
|
|
191
187
|
|
|
192
188
|
/* The patch that undoes `patch`, from the pre-image `before` (a value the change
|
|
@@ -224,7 +220,7 @@ export function createDoc(initial: unknown): Doc {
|
|
|
224
220
|
*/
|
|
225
221
|
function cell<T>(path: string): Cell<T> {
|
|
226
222
|
const node = nodeFor(path)
|
|
227
|
-
const segments = path
|
|
223
|
+
const segments = pathSegments(path)
|
|
228
224
|
const leafKey = segments[segments.length - 1] as string
|
|
229
225
|
/* Auto-vivify missing ancestor objects so binding a nested path on a doc
|
|
230
226
|
booted shallow (e.g. `state({})`) doesn't crash, and a later `set` writes
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { unescapeKey } from './unescapeKey.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Splits an escaped JSON-Pointer-style doc path into its REAL key segments
|
|
5
|
+
(each segment unescaped). The path strings (parentPath, node-map keys) stay
|
|
6
|
+
escaped and re-walked through `walkPath`; segments index the live tree.
|
|
7
|
+
*/
|
|
8
|
+
export function pathSegments(path: string): string[] {
|
|
9
|
+
return path.split('/').map(unescapeKey)
|
|
10
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { RESUME } from './runtime/RESUME.ts'
|
|
2
|
+
import { seedStreamedResolution } from './seedStreamedResolution.ts'
|
|
3
|
+
import type { ResolvedFrame } from './types/ResolvedFrame.ts'
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
The single client intake seam for SSR warm-state seeding. Both warm-seed channels —
|
|
7
|
+
the cache-snapshot channel (a settled `cache()` value, keyed by cache key) and the
|
|
8
|
+
await-resume channel (an `await`-block resolved value, keyed by boundary id) — answer
|
|
9
|
+
the same "ship a server-settled value so hydration doesn't re-fetch" question, but land
|
|
10
|
+
in two distinct stores: the cache STORE (read by `cache()`) and the RESUME MANIFEST
|
|
11
|
+
(read by `awaitBlock` on adopt). This routes a discriminated `ResolvedFrame` to the
|
|
12
|
+
matching store so every consumer — startClient's boot drain, the live `__abideResolve`,
|
|
13
|
+
applyResolved's stream swap — registers through ONE call instead of poking each store
|
|
14
|
+
inline. The codecs stay split by source: the cache value is an HTTP body capped at plain
|
|
15
|
+
JSON (it must agree with the live `decodeResponse` read), the resume value is an in-process
|
|
16
|
+
graph carried as ref-json text and decoded lazily at the read site.
|
|
17
|
+
*/
|
|
18
|
+
// @documentation plumbing
|
|
19
|
+
export function seedResolved(frame: ResolvedFrame): void {
|
|
20
|
+
if (frame.kind === 'cache') {
|
|
21
|
+
seedStreamedResolution(frame.resolution)
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
/* The resume value rides as raw ref-json text; store it unparsed so the inline
|
|
25
|
+
stream-swap script (vanilla, runs before the bundle's codec loads) can register
|
|
26
|
+
through this same seam. `awaitBlock` decodes it at the read. */
|
|
27
|
+
RESUME[frame.id] = frame.resume
|
|
28
|
+
}
|
|
@@ -11,7 +11,7 @@ import { probeNavigation } from './probeNavigation.ts'
|
|
|
11
11
|
import { router } from './router.ts'
|
|
12
12
|
import { clientPage } from './runtime/clientPage.ts'
|
|
13
13
|
import type { RouteLoader } from './runtime/types/RouteLoader.ts'
|
|
14
|
-
import {
|
|
14
|
+
import { seedResolved } from './seedResolved.ts'
|
|
15
15
|
|
|
16
16
|
/* The server's __SSR__ payload this entry consumes. */
|
|
17
17
|
type SsrPayload = { cache?: CacheSnapshotEntry[]; base?: string }
|
|
@@ -65,14 +65,16 @@ export function startClient(
|
|
|
65
65
|
const streamed =
|
|
66
66
|
(globalThis as { __abideResumeCache?: StreamedResolution[] }).__abideResumeCache ?? []
|
|
67
67
|
for (const resolution of [...(ssr.cache ?? []), ...streamed]) {
|
|
68
|
-
|
|
68
|
+
seedResolved({ kind: 'cache', resolution })
|
|
69
69
|
}
|
|
70
70
|
/* Keep the cache channel live past boot: replace the head's buffering collector with
|
|
71
71
|
the store-connected sink so a post-load resolution — streaming SPA navigation or a
|
|
72
72
|
socket-delivered SSR frame, both routed through applyResolved — seeds the store
|
|
73
|
-
directly instead of pushing to a buffer nothing drains again.
|
|
73
|
+
directly instead of pushing to a buffer nothing drains again. The inline doc-stream
|
|
74
|
+
script only ever hands this a cache `StreamedResolution`, so wrap it as a cache frame
|
|
75
|
+
through the one intake seam. */
|
|
74
76
|
;(globalThis as { __abideResolve?: (resolution: StreamedResolution) => void }).__abideResolve =
|
|
75
|
-
|
|
77
|
+
(resolution) => seedResolved({ kind: 'cache', resolution })
|
|
76
78
|
|
|
77
79
|
return router(target, routes, layoutRoutes, probeNavigation)
|
|
78
80
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { StreamedResolution } from '../../shared/types/StreamedResolution.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
A warm-state-seed frame for the single client intake (`seedResolved`), discriminated by
|
|
5
|
+
`kind`. Both kinds ship a server-settled value so hydration adopts without a re-fetch,
|
|
6
|
+
but route to distinct stores:
|
|
7
|
+
- `cache` — a `StreamedResolution` for the cache store, read by a warm `cache()` call.
|
|
8
|
+
- `resume` — an `await`-block boundary id plus its ref-json-encoded value STRING for the
|
|
9
|
+
RESUME manifest, decoded lazily by `awaitBlock` when it adopts the branch.
|
|
10
|
+
The payloads stay distinct (cache snapshot vs boundary-keyed value); the unified thing is
|
|
11
|
+
the intake seam, not the payload.
|
|
12
|
+
*/
|
|
13
|
+
export type ResolvedFrame =
|
|
14
|
+
| { kind: 'cache'; resolution: StreamedResolution }
|
|
15
|
+
| { kind: 'resume'; id: number; resume: string }
|
|
@@ -3,17 +3,17 @@
|
|
|
3
3
|
Root page — served at GET /. Every folder under src/ui/pages/ that contains a
|
|
4
4
|
page.abide mounts at that folder's URL; the root layout.abide wraps it.
|
|
5
5
|
|
|
6
|
-
The blocking await-block below (the `then`
|
|
6
|
+
The blocking await-block below (the `then` clause sits in the `{#await}` head)
|
|
7
7
|
resolves on the server during SSR and renders inline — no pending placeholder. The
|
|
8
8
|
decoded body is captured into the per-request cache, serialized into the HTML, and
|
|
9
|
-
replayed on the client during hydration with no second fetch. A `then` *
|
|
9
|
+
replayed on the client during hydration with no second fetch. A `{:then}` *branch*
|
|
10
10
|
instead would stream the resolution in out of order.
|
|
11
11
|
*/
|
|
12
12
|
import { cache } from '@abide/abide/shared/cache'
|
|
13
13
|
import { getHello } from '$server/rpc/getHello.ts'
|
|
14
14
|
</script>
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
{#await cache(getHello)() then hello}
|
|
17
17
|
<h1>{hello.message}</h1>
|
|
18
|
-
|
|
18
|
+
{/await}
|
|
19
19
|
<p>Edit <code>src/ui/pages/page.abide</code> and the page hot-reloads.</p>
|