@abide/abide 0.37.0 → 0.38.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/CHANGELOG.md +30 -0
- package/package.json +1 -1
- package/src/abideResolverPlugin.ts +3 -17
- package/src/appEntry.ts +18 -8
- package/src/controlServerWorker.ts +8 -1
- package/src/lib/cli/parseArgvForRpc.ts +8 -3
- package/src/lib/mcp/createMcpServer.ts +8 -0
- package/src/lib/mcp/dispatchMcpRequest.ts +20 -2
- package/src/lib/mcp/toolResultFromResponse.ts +5 -0
- package/src/lib/server/rpc/parseArgs.ts +12 -1
- package/src/lib/server/runtime/acceptsGzip.ts +10 -1
- package/src/lib/server/runtime/gzipResponse.ts +2 -1
- package/src/lib/server/runtime/snapshotEntryFromCache.ts +2 -1
- package/src/lib/server/runtime/streamFromIterator.ts +8 -0
- package/src/lib/server/sockets/createSocketDispatcher.ts +7 -1
- package/src/lib/server/sockets/defineSocket.ts +6 -1
- package/src/lib/shared/cache.ts +6 -0
- package/src/lib/shared/createPushIterator.ts +7 -2
- package/src/lib/ui/COMPONENT_WRAPPER_PREFIX.ts +4 -0
- package/src/lib/ui/compile/abideUiPlugin.ts +2 -1
- package/src/lib/ui/compile/asOutlet.ts +29 -0
- package/src/lib/ui/compile/componentWrapperTag.ts +3 -1
- package/src/lib/ui/compile/generateBuild.ts +31 -31
- package/src/lib/ui/compile/generateSSR.ts +29 -19
- package/src/lib/ui/compile/parseTemplate.ts +10 -1
- package/src/lib/ui/compile/renameSignalRefs.ts +25 -2
- package/src/lib/ui/compile/skeletonContext.ts +97 -34
- package/src/lib/ui/compile/types/SkeletonContext.ts +6 -0
- package/src/lib/ui/dom/appendSnippet.ts +60 -20
- package/src/lib/ui/dom/fillBefore.ts +10 -0
- package/src/lib/ui/dom/mountSlot.ts +7 -2
- package/src/lib/ui/dom/scopeLabel.ts +5 -1
- package/src/lib/ui/dom/skeleton.ts +4 -1
- package/src/lib/ui/persist.ts +4 -1
- package/src/lib/ui/runtime/createDoc.ts +20 -7
- package/src/lib/ui/seedStreamedResolution.ts +10 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
|
|
2
|
+
import { asOutlet } from './asOutlet.ts'
|
|
2
3
|
import { componentWrapperTag } from './componentWrapperTag.ts'
|
|
3
4
|
import { groupBindParts } from './groupBindParts.ts'
|
|
4
5
|
import { isControlFlow } from './isControlFlow.ts'
|
|
@@ -18,6 +19,17 @@ import { VOID_TAGS } from './VOID_TAGS.ts'
|
|
|
18
19
|
const RANGE_OPEN = '<!--[-->'
|
|
19
20
|
const RANGE_CLOSE = '<!--]-->'
|
|
20
21
|
|
|
22
|
+
/* The `then`/`catch`/`finally` branch child of an await/try block, or undefined. */
|
|
23
|
+
function branchNamed(
|
|
24
|
+
children: TemplateNode[],
|
|
25
|
+
which: 'then' | 'catch' | 'finally',
|
|
26
|
+
): Extract<TemplateNode, { kind: 'branch' }> | undefined {
|
|
27
|
+
return children.find(
|
|
28
|
+
(child): child is Extract<TemplateNode, { kind: 'branch' }> =>
|
|
29
|
+
child.kind === 'branch' && child.branch === which,
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
21
33
|
/*
|
|
22
34
|
Server code generator: turns the parsed template into statements that push HTML
|
|
23
35
|
fragments onto an output array, reading the document synchronously (no DOM, no
|
|
@@ -64,11 +76,17 @@ export function generateSSR(
|
|
|
64
76
|
return children.map((child) => generate(child, target)).join('')
|
|
65
77
|
}
|
|
66
78
|
|
|
79
|
+
/* In a layout, rewrite `<slot/>` outlets to `OUTLET_TAG` elements up front (the same shared
|
|
80
|
+
`asOutlet` the client back-end runs), then drive both the skeleton context and the
|
|
81
|
+
traversal from this tree — one decision site for the outlet, and the outlet emitted bare
|
|
82
|
+
through the generic element path exactly as the client clones it. */
|
|
83
|
+
const rootNodes = isLayout ? nodes.map(asOutlet) : nodes
|
|
84
|
+
|
|
67
85
|
/* Per-node skeleton position, computed once. Both back-ends read this single source of
|
|
68
86
|
truth so their `<!--a-->` anchor placement cannot drift — the fresh-context boundaries
|
|
69
87
|
(control-flow branches, component/slot/snippet content) are enumerated there, not
|
|
70
88
|
re-tracked here as mutable state that a forgotten reset could leak past. */
|
|
71
|
-
const { inSkeleton, markText } = skeletonContext(
|
|
89
|
+
const { inSkeleton, markText } = skeletonContext(rootNodes)
|
|
72
90
|
|
|
73
91
|
/* A control-flow branch's content, generated exactly like a normal child list so
|
|
74
92
|
a branch holds ANY content (components, text, nested blocks). `generate` emits
|
|
@@ -214,8 +232,10 @@ export function generateSSR(
|
|
|
214
232
|
)
|
|
215
233
|
}
|
|
216
234
|
if (node.kind === 'element' && node.tag === 'slot') {
|
|
217
|
-
/*
|
|
218
|
-
|
|
235
|
+
/* `asOutlet` already rewrote a layout's top-level/element-nested `<slot/>` to an
|
|
236
|
+
`OUTLET_TAG` element (handled by the generic path below), so a `slot` node reaching
|
|
237
|
+
here in a layout is control-flow-nested — emit the same bare outlet placeholder the
|
|
238
|
+
client's nested-slot path clones, which the chain composer folds the child into. */
|
|
219
239
|
if (isLayout) {
|
|
220
240
|
return push(target, `<${OUTLET_TAG}></${OUTLET_TAG}>`)
|
|
221
241
|
}
|
|
@@ -291,17 +311,12 @@ export function generateSSR(
|
|
|
291
311
|
an empty boundary — its resolved branch is the children bound to `node.as` — and
|
|
292
312
|
flags the entry so `renderToStream` settles it before the first flush. */
|
|
293
313
|
function generateAwait(node: Extract<TemplateNode, { kind: 'await' }>, target: string): string {
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
(child): child is Extract<TemplateNode, { kind: 'branch' }> =>
|
|
297
|
-
child.kind === 'branch' && child.branch === which,
|
|
298
|
-
)
|
|
299
|
-
const catchBranch = branchOf('catch')
|
|
300
|
-
const finallyChildren = branchOf('finally')?.children ?? []
|
|
314
|
+
const catchBranch = branchNamed(node.children, 'catch')
|
|
315
|
+
const finallyChildren = branchNamed(node.children, 'finally')?.children ?? []
|
|
301
316
|
/* Resolved branch + its bound value: the children directly when blocking, the
|
|
302
317
|
`then` child when streaming. Pending (streaming only) is the non-branch
|
|
303
318
|
children. */
|
|
304
|
-
const thenBranch =
|
|
319
|
+
const thenBranch = branchNamed(node.children, 'then')
|
|
305
320
|
const resolvedChildren = node.blocking
|
|
306
321
|
? node.children.filter((child) => child.kind !== 'branch')
|
|
307
322
|
: (thenBranch?.children ?? [])
|
|
@@ -342,13 +357,8 @@ export function generateSSR(
|
|
|
342
357
|
an enclosing boundary / the 500 / the stream). Boundary comments let hydration
|
|
343
358
|
discard the server content if the client adoption fails. */
|
|
344
359
|
function generateTry(node: Extract<TemplateNode, { kind: 'try' }>, target: string): string {
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
(child): child is Extract<TemplateNode, { kind: 'branch' }> =>
|
|
348
|
-
child.kind === 'branch' && child.branch === which,
|
|
349
|
-
)
|
|
350
|
-
const catchBranch = branchOf('catch')
|
|
351
|
-
const finallyChildren = branchOf('finally')?.children ?? []
|
|
360
|
+
const catchBranch = branchNamed(node.children, 'catch')
|
|
361
|
+
const finallyChildren = branchNamed(node.children, 'finally')?.children ?? []
|
|
352
362
|
const guarded = node.children.filter((child) => child.kind !== 'branch')
|
|
353
363
|
const errName = catchBranch?.as ?? '_error'
|
|
354
364
|
const id = nextVar('$tid')
|
|
@@ -371,5 +381,5 @@ export function generateSSR(
|
|
|
371
381
|
return code
|
|
372
382
|
}
|
|
373
383
|
|
|
374
|
-
return generateInto(
|
|
384
|
+
return generateInto(rootNodes, '$out')
|
|
375
385
|
}
|
|
@@ -148,7 +148,7 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
|
|
|
148
148
|
} else {
|
|
149
149
|
attrs.push({ kind: 'expression', name, code, loc })
|
|
150
150
|
}
|
|
151
|
-
} else {
|
|
151
|
+
} else if (source.charAt(cursor) === '"' || source.charAt(cursor) === "'") {
|
|
152
152
|
const quote = source.charAt(cursor)
|
|
153
153
|
cursor += 1
|
|
154
154
|
let value = ''
|
|
@@ -158,6 +158,15 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
|
|
|
158
158
|
}
|
|
159
159
|
cursor += 1 // past closing quote
|
|
160
160
|
attrs.push({ kind: 'static', name, value })
|
|
161
|
+
} else {
|
|
162
|
+
/* Unquoted value (`<input type=text>`): runs to the next whitespace or
|
|
163
|
+
`>`, per the HTML unquoted-attribute rule. No delimiter to consume. */
|
|
164
|
+
let value = ''
|
|
165
|
+
while (cursor < source.length && !/[\s>]/.test(source.charAt(cursor))) {
|
|
166
|
+
value += source.charAt(cursor)
|
|
167
|
+
cursor += 1
|
|
168
|
+
}
|
|
169
|
+
attrs.push({ kind: 'static', name, value })
|
|
161
170
|
}
|
|
162
171
|
}
|
|
163
172
|
return attrs
|
|
@@ -35,8 +35,15 @@ export function renameSignalRefs(
|
|
|
35
35
|
if (ts.isPropertyAccessExpression(node)) {
|
|
36
36
|
skip.add(node.name)
|
|
37
37
|
}
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
/* A declaration name is a binding, not a value read. A plain identifier is
|
|
39
|
+
skipped directly; a destructuring pattern's leaf names are collected so a
|
|
40
|
+
bound name shadowing a signal (`const { count } = …`) is never rewritten. */
|
|
41
|
+
if (ts.isVariableDeclaration(node)) {
|
|
42
|
+
if (ts.isIdentifier(node.name)) {
|
|
43
|
+
skip.add(node.name)
|
|
44
|
+
} else {
|
|
45
|
+
collectBindingIdentifiers(node.name, skip)
|
|
46
|
+
}
|
|
40
47
|
}
|
|
41
48
|
if (ts.isParameter(node) && ts.isIdentifier(node.name)) {
|
|
42
49
|
skip.add(node.name)
|
|
@@ -180,6 +187,22 @@ function collectStatementBindings(statement: ts.Statement, into: Set<string>): v
|
|
|
180
187
|
}
|
|
181
188
|
}
|
|
182
189
|
|
|
190
|
+
/* The identifier NODES a binding name binds — a plain identifier or the leaves of a
|
|
191
|
+
destructuring pattern (object/array, including nested patterns and rest elements).
|
|
192
|
+
For `{ a: b }` only the bound name `b` is a binding; `a` is the source property.
|
|
193
|
+
Feeds the skip set so a destructured name shadowing a signal is never rewritten. */
|
|
194
|
+
function collectBindingIdentifiers(name: ts.BindingName, into: Set<ts.Node>): void {
|
|
195
|
+
if (ts.isIdentifier(name)) {
|
|
196
|
+
into.add(name)
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
for (const element of name.elements) {
|
|
200
|
+
if (ts.isBindingElement(element)) {
|
|
201
|
+
collectBindingIdentifiers(element.name, into)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
183
206
|
/* Every identifier bound by a binding name — a plain identifier or the leaves of a
|
|
184
207
|
destructuring pattern (object/array, including rest elements). */
|
|
185
208
|
function collectBindingNames(name: ts.BindingName, into: Set<string>): void {
|
|
@@ -5,74 +5,137 @@ import type { SkeletonContext } from './types/SkeletonContext.ts'
|
|
|
5
5
|
import type { TemplateNode } from './types/TemplateNode.ts'
|
|
6
6
|
|
|
7
7
|
/*
|
|
8
|
-
The single source of truth for where skeleton markers go
|
|
9
|
-
per node, whether it sits inside a parser-backed skeleton clone
|
|
10
|
-
whether its reactive text is interleaved (`markText`) — the two facts
|
|
11
|
-
`<!--a-->` anchor placement
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
anchor the client never emitted, desyncing hydration.
|
|
8
|
+
The single source of truth for where skeleton markers go AND what their hole indices are.
|
|
9
|
+
One top-down walk records, per node, whether it sits inside a parser-backed skeleton clone
|
|
10
|
+
(`inSkeleton`) and whether its reactive text is interleaved (`markText`) — the two facts
|
|
11
|
+
that decide `<!--a-->` anchor placement — and assigns each hole its `el`/`an` index.
|
|
12
|
+
`generateBuild` reads these indices instead of threading its own counter through a second
|
|
13
|
+
document-order walk, so the numbering cannot drift from the decisions: one walk owns both.
|
|
15
14
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
The index assignment is scoped per skeleton root (a `skeletonable` element not already in a
|
|
16
|
+
skeleton — the unit `generateSkeleton` instantiates with `{ el: 0, an: 0 }`; a standalone
|
|
17
|
+
component roots its own skeleton too). `el` numbers element holes in pre-order; `an` numbers
|
|
18
|
+
anchor holes (interleaved reactive text PARTS, control-flow blocks, `<slot>` outlets) in
|
|
19
|
+
document order — the orders the runtime's `indexElementHoles`/`scanAnchors` re-derive from
|
|
20
|
+
the realized DOM, so the compile-time numbers and the runtime positions line up.
|
|
21
|
+
|
|
22
|
+
A fresh-context boundary resets to NOT-in-skeleton (and to no active counter), because the
|
|
23
|
+
content there is built by its own runtime (a control-flow block's branch, a component's slot
|
|
24
|
+
content, a `<slot>`'s fallback, a snippet's body) — never cloned by the enclosing skeleton.
|
|
21
25
|
*/
|
|
22
26
|
export function skeletonContext(nodes: TemplateNode[]): SkeletonContext {
|
|
23
27
|
const inSkeleton = new WeakMap<TemplateNode, boolean>()
|
|
24
28
|
const markText = new WeakMap<TemplateNode, boolean>()
|
|
29
|
+
/* Element holes keyed by node; anchor holes keyed by node (control-flow/slot) OR by the
|
|
30
|
+
reactive text PART object (a text node carries one anchor per reactive part). */
|
|
31
|
+
const elIndex = new WeakMap<TemplateNode, number>()
|
|
32
|
+
const anIndex = new WeakMap<object, number>()
|
|
33
|
+
|
|
34
|
+
type Counter = { el: number; an: number }
|
|
25
35
|
|
|
26
|
-
/* Walk `node` carrying the context
|
|
27
|
-
|
|
28
|
-
function visit(
|
|
36
|
+
/* Walk `node` carrying the context AND the active skeleton counter that apply AT it
|
|
37
|
+
(`counter === undefined` outside any skeleton — no holes are numbered there). */
|
|
38
|
+
function visit(
|
|
39
|
+
node: TemplateNode,
|
|
40
|
+
nodeInSkeleton: boolean,
|
|
41
|
+
nodeMarkText: boolean,
|
|
42
|
+
counter: Counter | undefined,
|
|
43
|
+
): void {
|
|
29
44
|
inSkeleton.set(node, nodeInSkeleton)
|
|
30
45
|
markText.set(node, nodeMarkText)
|
|
31
46
|
|
|
32
|
-
/* Control-flow
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
its control-flow node and likewise resets.) */
|
|
47
|
+
/* Control-flow blocks, components, and snippets are fresh build contexts. The node
|
|
48
|
+
ITSELF is a hole in the enclosing skeleton (an anchor for a block, an element hole
|
|
49
|
+
for a component); its children re-enter the skeleton only via their own roots. */
|
|
36
50
|
if (isControlFlow(node) || node.kind === 'component' || node.kind === 'snippet') {
|
|
51
|
+
/* A control-flow block takes an anchor only inside an enclosing skeleton (a
|
|
52
|
+
standalone block routes through `generateIf`/etc, not the skeleton path). */
|
|
53
|
+
if (counter !== undefined && isControlFlow(node)) {
|
|
54
|
+
anIndex.set(node, counter.an++)
|
|
55
|
+
}
|
|
56
|
+
/* A component is its OWN element hole either way: in the enclosing skeleton's
|
|
57
|
+
counter when nested, else as the root (index 0) of its own standalone skeleton
|
|
58
|
+
(`generateChild` routes a lone component through `generateSkeleton` too). */
|
|
59
|
+
if (node.kind === 'component') {
|
|
60
|
+
const componentCounter = counter ?? { el: 0, an: 0 }
|
|
61
|
+
elIndex.set(node, componentCounter.el++)
|
|
62
|
+
}
|
|
37
63
|
for (const child of childrenOf(node)) {
|
|
38
|
-
visit(child, false, false)
|
|
64
|
+
visit(child, false, false, undefined)
|
|
39
65
|
}
|
|
40
66
|
return
|
|
41
67
|
}
|
|
42
|
-
/* A `branch`/`case` is a transparent grouping inside its control-flow block
|
|
43
|
-
|
|
44
|
-
|
|
68
|
+
/* A `branch`/`case` is a transparent grouping inside its control-flow block — pass the
|
|
69
|
+
already-reset context (and absent counter) through so a skeletonable element inside
|
|
70
|
+
it opens its own skeleton. */
|
|
45
71
|
if (node.kind === 'branch' || node.kind === 'case') {
|
|
46
72
|
for (const child of node.children) {
|
|
47
|
-
visit(child, nodeInSkeleton, nodeMarkText)
|
|
73
|
+
visit(child, nodeInSkeleton, nodeMarkText, counter)
|
|
74
|
+
}
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
if (node.kind === 'text') {
|
|
78
|
+
/* Interleaved reactive text (markText true): each reactive part takes an `<!--a-->`
|
|
79
|
+
anchor, numbered in document order. A text-leaf's text (markText false) binds
|
|
80
|
+
marker-free via the element, so its parts take no anchor. */
|
|
81
|
+
if (counter !== undefined && nodeMarkText) {
|
|
82
|
+
for (const part of node.parts) {
|
|
83
|
+
if (part.kind !== 'static') {
|
|
84
|
+
anIndex.set(part, counter.an++)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
48
87
|
}
|
|
49
88
|
return
|
|
50
89
|
}
|
|
51
90
|
if (node.kind !== 'element') {
|
|
52
|
-
return //
|
|
91
|
+
return // script / style carry no skeleton children and no hole
|
|
53
92
|
}
|
|
54
93
|
if (node.tag === 'slot') {
|
|
55
|
-
/* The slot
|
|
56
|
-
|
|
94
|
+
/* The slot outlet is an anchor hole in the enclosing skeleton; its children are
|
|
95
|
+
the fallback — a fresh context built by `mountSlot`. */
|
|
96
|
+
if (counter !== undefined) {
|
|
97
|
+
anIndex.set(node, counter.an++)
|
|
98
|
+
}
|
|
57
99
|
for (const child of node.children) {
|
|
58
|
-
visit(child, false, false)
|
|
100
|
+
visit(child, false, false, undefined)
|
|
59
101
|
}
|
|
60
102
|
return
|
|
61
103
|
}
|
|
62
|
-
/* A skeletonable element not already in a skeleton
|
|
63
|
-
|
|
64
|
-
|
|
104
|
+
/* A skeletonable element not already in a skeleton OPENS one (a fresh counter — the
|
|
105
|
+
`generateSkeleton` unit). An element already in a skeleton uses the enclosing
|
|
106
|
+
counter. A static element outside any skeleton numbers nothing. */
|
|
107
|
+
const opensSkeleton = !nodeInSkeleton && skeletonable(node)
|
|
65
108
|
const childInSkeleton = nodeInSkeleton || skeletonable(node)
|
|
109
|
+
const effectiveCounter = nodeInSkeleton
|
|
110
|
+
? counter
|
|
111
|
+
: opensSkeleton
|
|
112
|
+
? { el: 0, an: 0 }
|
|
113
|
+
: undefined
|
|
66
114
|
const childMarkText = childInSkeleton && !isTextLeaf(node)
|
|
115
|
+
|
|
116
|
+
if (effectiveCounter !== undefined) {
|
|
117
|
+
/* The element is a located hole when it carries a reactive attr/listener/bind, or
|
|
118
|
+
binds text-leaf reactive text on itself. Take its `el` index BEFORE recursing,
|
|
119
|
+
so holes number in pre-order — the order the runtime's path walk produces them. */
|
|
120
|
+
const hasReactiveAttr = node.attrs.some((attr) => attr.kind !== 'static')
|
|
121
|
+
const reactiveTextChild = node.children.find(
|
|
122
|
+
(child) =>
|
|
123
|
+
child.kind === 'text' && child.parts.some((part) => part.kind !== 'static'),
|
|
124
|
+
)
|
|
125
|
+
const textLeafBind = reactiveTextChild !== undefined && isTextLeaf(node)
|
|
126
|
+
if (hasReactiveAttr || textLeafBind) {
|
|
127
|
+
elIndex.set(node, effectiveCounter.el++)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
67
130
|
for (const child of node.children) {
|
|
68
|
-
visit(child, childInSkeleton, childMarkText)
|
|
131
|
+
visit(child, childInSkeleton, childMarkText, effectiveCounter)
|
|
69
132
|
}
|
|
70
133
|
}
|
|
71
134
|
|
|
72
135
|
for (const node of nodes) {
|
|
73
|
-
visit(node, false, false)
|
|
136
|
+
visit(node, false, false, undefined)
|
|
74
137
|
}
|
|
75
|
-
return { inSkeleton, markText }
|
|
138
|
+
return { inSkeleton, markText, elIndex, anIndex }
|
|
76
139
|
}
|
|
77
140
|
|
|
78
141
|
/* The child list of any node that has one (control-flow, component, snippet, element);
|
|
@@ -12,4 +12,10 @@ filled by one shared `skeletonContext` pass over the parsed tree.
|
|
|
12
12
|
export type SkeletonContext = {
|
|
13
13
|
inSkeleton: WeakMap<TemplateNode, boolean>
|
|
14
14
|
markText: WeakMap<TemplateNode, boolean>
|
|
15
|
+
/* Per-hole indices assigned in the same walk, so `generateBuild` reads its `sk.el`/`sk.an`
|
|
16
|
+
numbering rather than re-deriving it. `elIndex` keyed by element/component node;
|
|
17
|
+
`anIndex` keyed by control-flow/slot node OR by a reactive text PART object (a text node
|
|
18
|
+
carries one anchor per reactive part). */
|
|
19
|
+
elIndex: WeakMap<TemplateNode, number>
|
|
20
|
+
anIndex: WeakMap<object, number>
|
|
15
21
|
}
|
|
@@ -1,34 +1,74 @@
|
|
|
1
1
|
import { snippetPayload } from '../../shared/snippet.ts'
|
|
2
|
-
import {
|
|
2
|
+
import { effect } from '../effect.ts'
|
|
3
3
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
4
|
+
import { scope } from '../runtime/scope.ts'
|
|
5
|
+
import { scopeGroup } from '../runtime/scopeGroup.ts'
|
|
6
|
+
import { clearBetween } from './clearBetween.ts'
|
|
7
|
+
import { fillBefore } from './fillBefore.ts'
|
|
8
|
+
import { openMarker } from './openMarker.ts'
|
|
4
9
|
|
|
5
10
|
/*
|
|
6
|
-
A `{snippet(args)}` interpolation: mount the branded builder's nodes
|
|
7
|
-
|
|
8
|
-
places it correctly among siblings
|
|
9
|
-
component scope
|
|
10
|
-
|
|
11
|
+
A `{snippet(args)}` interpolation: mount the branded builder's nodes in a range
|
|
12
|
+
bounded by two comment markers. The content builds straight into `parent` —
|
|
13
|
+
sequential build order places it correctly among siblings — and its effects join
|
|
14
|
+
the surrounding component scope, so the body's reactive reads update fine-grained.
|
|
15
|
+
|
|
16
|
+
The CALL is reactive in its arguments: an effect re-reads `read()` so a change in
|
|
17
|
+
the argument expression (e.g. `{row(items())}`) tears the range down and rebuilds
|
|
18
|
+
with fresh args. `read()` returns a fresh builder closing over freshly-evaluated
|
|
19
|
+
args each call, so there is nothing to diff — the snippet re-mounts as a unit, the
|
|
20
|
+
same coarse model as `when`/`each` (args behave like props). The body's own reads
|
|
21
|
+
stay fine-grained within a single mount; the outer effect tracks only the args,
|
|
22
|
+
since `fillBefore` wraps the body in its own scope.
|
|
11
23
|
|
|
12
24
|
On hydrate the builder runs against the server-rendered nodes between the
|
|
13
25
|
`<!--abide:snippet-->`/`<!--/abide:snippet-->` markers — its `skeleton`/`appendText`
|
|
14
|
-
claim them in place
|
|
15
|
-
the
|
|
26
|
+
claim them in place — and the effect's first run is a no-op that only subscribes to
|
|
27
|
+
the args; a later argument change rebuilds fresh (the SSR markers stay as the range).
|
|
16
28
|
*/
|
|
17
29
|
// @documentation plumbing
|
|
18
30
|
export function appendSnippet(parent: Node, read: () => unknown): void {
|
|
19
|
-
const builder = snippetPayload(read())
|
|
20
|
-
if (typeof builder !== 'function') {
|
|
21
|
-
return
|
|
22
|
-
}
|
|
23
|
-
const mount = builder as (host: Node) => void
|
|
24
31
|
const hydration = RENDER.hydration
|
|
32
|
+
/* Mount scopes register with the owner so they dispose on owner teardown, not
|
|
33
|
+
only on an argument-driven rebuild via clearBetween. */
|
|
34
|
+
const group = scopeGroup()
|
|
35
|
+
let dispose: (() => void) | undefined
|
|
36
|
+
|
|
37
|
+
/* The branded builder, or undefined for anything else (a non-snippet value
|
|
38
|
+
mounts nothing — the range stays empty until the args yield a builder). */
|
|
39
|
+
const builderOf = (): ((host: Node) => void) | undefined => {
|
|
40
|
+
const payload = snippetPayload(read())
|
|
41
|
+
return typeof payload === 'function' ? (payload as (host: Node) => void) : undefined
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let open: Comment
|
|
45
|
+
let close: Comment
|
|
25
46
|
if (hydration !== undefined) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
47
|
+
open = openMarker(parent, 'abide:snippet')
|
|
48
|
+
const builder = builderOf()
|
|
49
|
+
if (builder !== undefined) {
|
|
50
|
+
dispose = group.track(scope(() => builder(parent))) // content claims the SSR nodes in place
|
|
51
|
+
}
|
|
52
|
+
close = openMarker(parent, '/abide:snippet')
|
|
53
|
+
} else {
|
|
54
|
+
open = openMarker(parent, 'abide:snippet')
|
|
55
|
+
close = openMarker(parent, '/abide:snippet')
|
|
56
|
+
const builder = builderOf()
|
|
57
|
+
if (builder !== undefined) {
|
|
58
|
+
dispose = group.track(fillBefore(close, builder))
|
|
59
|
+
}
|
|
32
60
|
}
|
|
33
|
-
|
|
61
|
+
|
|
62
|
+
/* The initial mount is built above (create or hydrate); the first effect run only
|
|
63
|
+
subscribes to the args via `builderOf`, then later argument changes rebuild. */
|
|
64
|
+
let first = true
|
|
65
|
+
effect(() => {
|
|
66
|
+
const builder = builderOf()
|
|
67
|
+
if (first) {
|
|
68
|
+
first = false
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
clearBetween(open, close, dispose)
|
|
72
|
+
dispose = builder !== undefined ? group.track(fillBefore(close, builder)) : undefined
|
|
73
|
+
})
|
|
34
74
|
}
|
|
@@ -18,6 +18,16 @@ would otherwise make the build helpers claim SSR nodes that don't exist for fres
|
|
|
18
18
|
content. The same cursor is restored after (mirrors awaitBlock/tryBlock/each).
|
|
19
19
|
*/
|
|
20
20
|
export function fillBefore(end: Node, content: (into: Node) => void): () => void {
|
|
21
|
+
/* A control-flow effect can fire one final time after its block was already
|
|
22
|
+
detached — e.g. an enclosing await/each block tears the branch down in the same
|
|
23
|
+
microtask flush that re-ran this effect, before the owner scope disposes it. The
|
|
24
|
+
end marker then has no live parent, so there is nowhere to build into; inserting
|
|
25
|
+
a fragment before a parentless comment throws HierarchyRequestError ("would yield
|
|
26
|
+
an incorrect node tree"). Skip the rebuild — owner teardown disposes this dead
|
|
27
|
+
block anyway. */
|
|
28
|
+
if (!end.parentNode) {
|
|
29
|
+
return () => {}
|
|
30
|
+
}
|
|
21
31
|
const fragment = document.createDocumentFragment()
|
|
22
32
|
const previousHydration = RENDER.hydration
|
|
23
33
|
RENDER.hydration = undefined
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
2
2
|
import { scope } from '../runtime/scope.ts'
|
|
3
|
+
import { scopeGroup } from '../runtime/scopeGroup.ts'
|
|
3
4
|
import { fillBefore } from './fillBefore.ts'
|
|
4
5
|
import { openMarker } from './openMarker.ts'
|
|
5
6
|
|
|
@@ -21,12 +22,16 @@ export function mountSlot(
|
|
|
21
22
|
before: Node | null = null,
|
|
22
23
|
): void {
|
|
23
24
|
const hydration = RENDER.hydration
|
|
25
|
+
/* The slot content's scope, registered with the owner so its effects/listeners
|
|
26
|
+
dispose on owner teardown (a navigation) — the slot never toggles, so the
|
|
27
|
+
group only ever tracks this one child. */
|
|
28
|
+
const group = scopeGroup()
|
|
24
29
|
openMarker(parent, '[', before)
|
|
25
30
|
if (hydration !== undefined) {
|
|
26
|
-
scope(() => render(parent)) // content claims the SSR range in place
|
|
31
|
+
group.track(scope(() => render(parent))) // content claims the SSR range in place
|
|
27
32
|
openMarker(parent, ']')
|
|
28
33
|
} else {
|
|
29
34
|
const end = openMarker(parent, ']', before)
|
|
30
|
-
fillBefore(end, render)
|
|
35
|
+
group.track(fillBefore(end, render))
|
|
31
36
|
}
|
|
32
37
|
}
|
|
@@ -6,10 +6,14 @@ component name; any other host (a page/layout outlet) yields its lowercased tag.
|
|
|
6
6
|
Dev-only — feeds the inspector's Reactive tab so a scope reads `<Counter>` rather
|
|
7
7
|
than an opaque counter id. Returns undefined when there's no element to name from.
|
|
8
8
|
*/
|
|
9
|
+
import { COMPONENT_WRAPPER_PREFIX } from '../COMPONENT_WRAPPER_PREFIX.ts'
|
|
10
|
+
|
|
9
11
|
export function scopeLabel(host: Element): string | undefined {
|
|
10
12
|
const tag = host.tagName?.toLowerCase()
|
|
11
13
|
if (tag === undefined) {
|
|
12
14
|
return undefined
|
|
13
15
|
}
|
|
14
|
-
return tag.startsWith(
|
|
16
|
+
return tag.startsWith(COMPONENT_WRAPPER_PREFIX)
|
|
17
|
+
? tag.slice(COMPONENT_WRAPPER_PREFIX.length)
|
|
18
|
+
: tag
|
|
15
19
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { COMPONENT_WRAPPER_PREFIX } from '../COMPONENT_WRAPPER_PREFIX.ts'
|
|
1
2
|
import { claimChild } from '../runtime/claimChild.ts'
|
|
2
3
|
import { HOLE_ATTRIBUTE } from '../runtime/HOLE_ATTRIBUTE.ts'
|
|
3
4
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
@@ -33,7 +34,9 @@ function isElement(node: Node): node is Element {
|
|
|
33
34
|
child's anchors and shift every parent index past it (same hazard as a block range,
|
|
34
35
|
but bounded by the wrapper element instead of `[`…`]` markers). */
|
|
35
36
|
function isComponentWrapper(node: Node): boolean {
|
|
36
|
-
return
|
|
37
|
+
return (
|
|
38
|
+
isElement(node) && (node.tagName ?? '').toLowerCase().startsWith(COMPONENT_WRAPPER_PREFIX)
|
|
39
|
+
)
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
/* A comment node's data, or undefined for elements/text. A comment is a node that is
|
package/src/lib/ui/persist.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { escapeKey } from './runtime/escapeKey.ts'
|
|
1
2
|
import { localStoragePersistence } from './runtime/localStoragePersistence.ts'
|
|
2
3
|
import { PATCH_BUS } from './runtime/PATCH_BUS.ts'
|
|
3
4
|
import type { Doc } from './runtime/types/Doc.ts'
|
|
@@ -99,8 +100,10 @@ export function persist(
|
|
|
99
100
|
function restore(doc: Doc, saved: unknown): void {
|
|
100
101
|
const current = doc.snapshot()
|
|
101
102
|
if (isPlainObject(saved) && isPlainObject(current)) {
|
|
103
|
+
/* `replace` takes a `/`-delimited escaped path, so a top-level key containing
|
|
104
|
+
`/` or `~` must be escaped to a single segment or it'd be mis-routed. */
|
|
102
105
|
for (const key of Object.keys(saved)) {
|
|
103
|
-
doc.replace(key, saved[key])
|
|
106
|
+
doc.replace(escapeKey(key), saved[key])
|
|
104
107
|
}
|
|
105
108
|
return
|
|
106
109
|
}
|
|
@@ -14,6 +14,13 @@ import { unescapeKey } from './unescapeKey.ts'
|
|
|
14
14
|
import { walkPath } from './walkPath.ts'
|
|
15
15
|
import { writeNode } from './writeNode.ts'
|
|
16
16
|
|
|
17
|
+
/* `path` minus its last segment — the parent container's path, '' at the root.
|
|
18
|
+
The same string `segments.slice(0, -1).join('/')` rebuilds, by one slice. */
|
|
19
|
+
function parentPathOf(path: string): string {
|
|
20
|
+
const lastSlash = path.lastIndexOf('/')
|
|
21
|
+
return lastSlash === -1 ? '' : path.slice(0, lastSlash)
|
|
22
|
+
}
|
|
23
|
+
|
|
17
24
|
/*
|
|
18
25
|
Builds a reactive document over `initial`. Each path read for the first time
|
|
19
26
|
mints a signal node; the node is the notification token, the (mutable) tree is
|
|
@@ -132,10 +139,7 @@ export function createDoc(initial: unknown): Doc {
|
|
|
132
139
|
remove (computed post-apply, below, to resolve an array append's index). */
|
|
133
140
|
const before = PATCH_BUS.active ? walkPath(tree, patch.path) : undefined
|
|
134
141
|
tree = applyPatchToTree(tree, patch, segments)
|
|
135
|
-
|
|
136
|
-
`segments.slice(0, -1).join('/')` rebuilds, taken by one slice instead. */
|
|
137
|
-
const lastSlash = patch.path.lastIndexOf('/')
|
|
138
|
-
const parentPath = lastSlash === -1 ? '' : patch.path.slice(0, lastSlash)
|
|
142
|
+
const parentPath = parentPathOf(patch.path)
|
|
139
143
|
const parentValue = walkPath(tree, parentPath).value
|
|
140
144
|
const leafKey = segments[segments.length - 1] as string | undefined
|
|
141
145
|
/* A structural change (add/remove, or an array element replaced by index)
|
|
@@ -194,8 +198,7 @@ export function createDoc(initial: unknown): Doc {
|
|
|
194
198
|
before: ReturnType<typeof walkPath> | undefined,
|
|
195
199
|
): Patch | undefined {
|
|
196
200
|
if (patch.op === 'add') {
|
|
197
|
-
const
|
|
198
|
-
const parentPath = lastSlash === -1 ? '' : patch.path.slice(0, lastSlash)
|
|
201
|
+
const parentPath = parentPathOf(patch.path)
|
|
199
202
|
const parent = walkPath(tree, parentPath).value
|
|
200
203
|
const resolved =
|
|
201
204
|
Array.isArray(parent) && patch.path.endsWith('/-')
|
|
@@ -223,9 +226,19 @@ export function createDoc(initial: unknown): Doc {
|
|
|
223
226
|
const node = nodeFor(path)
|
|
224
227
|
const segments = path.split('/').map(unescapeKey)
|
|
225
228
|
const leafKey = segments[segments.length - 1] as string
|
|
229
|
+
/* Auto-vivify missing ancestor objects so binding a nested path on a doc
|
|
230
|
+
booted shallow (e.g. `state({})`) doesn't crash, and a later `set` writes
|
|
231
|
+
into the LIVE tree (so snapshot/persist see it). Mirrors the container
|
|
232
|
+
assumption applyPatchToTree makes — except the patch path is authored, this
|
|
233
|
+
walk is compiler-emitted, so the intermediates may not exist yet. */
|
|
226
234
|
let parent = tree as Record<string, unknown>
|
|
227
235
|
for (const segment of segments.slice(0, -1)) {
|
|
228
|
-
|
|
236
|
+
let next = parent[segment]
|
|
237
|
+
if (next === null || typeof next !== 'object') {
|
|
238
|
+
next = {}
|
|
239
|
+
parent[segment] = next
|
|
240
|
+
}
|
|
241
|
+
parent = next as Record<string, unknown>
|
|
229
242
|
}
|
|
230
243
|
return {
|
|
231
244
|
get: () => readNode(node) as T,
|
|
@@ -18,5 +18,14 @@ export function seedStreamedResolution(resolution: StreamedResolution): void {
|
|
|
18
18
|
if ('miss' in resolution) {
|
|
19
19
|
return
|
|
20
20
|
}
|
|
21
|
-
|
|
21
|
+
/* Only seed when nothing live holds the key — or when the existing entry is itself
|
|
22
|
+
an unconsumed hydrated seed (`hydrated === true`, cleared by the first cache()
|
|
23
|
+
read). A live/settled non-hydrated entry is authoritative; clobbering it with a
|
|
24
|
+
stale snapshot would drop a fresher value (e.g. one a live fetch already wrote). */
|
|
25
|
+
const { entries } = activeCacheStore()
|
|
26
|
+
const existing = entries.get(resolution.key)
|
|
27
|
+
if (existing !== undefined && existing.hydrated !== true) {
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
entries.set(resolution.key, cacheEntryFromSnapshot(resolution))
|
|
22
31
|
}
|