@abide/abide 0.36.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/AGENTS.md +1 -1
- package/CHANGELOG.md +44 -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/buildInFlightSnapshot.ts +35 -0
- package/src/lib/server/runtime/buildInspectorSurface.ts +9 -1
- package/src/lib/server/runtime/createServer.ts +10 -1
- package/src/lib/server/runtime/gzipResponse.ts +2 -1
- package/src/lib/server/runtime/inFlightRequests.ts +13 -0
- package/src/lib/server/runtime/maybeMountInspector.ts +7 -0
- package/src/lib/server/runtime/runWithRequestScope.ts +7 -0
- package/src/lib/server/runtime/snapshotEntryFromCache.ts +2 -1
- package/src/lib/server/runtime/streamFromIterator.ts +8 -0
- package/src/lib/server/runtime/types/InspectorContext.ts +4 -0
- package/src/lib/server/runtime/types/InspectorInFlightRequest.ts +20 -0
- package/src/lib/server/runtime/types/InspectorInFlightSnapshot.ts +10 -0
- package/src/lib/server/runtime/types/InspectorPrompt.ts +15 -0
- package/src/lib/server/runtime/types/InspectorSurface.ts +6 -3
- 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/shared/emitLogRecord.ts +18 -5
- 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 +12 -20
- package/src/lib/ui/compile/generateBuild.ts +45 -36
- package/src/lib/ui/compile/generateSSR.ts +31 -21
- 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/createScope.ts +11 -0
- package/src/lib/ui/dom/appendSnippet.ts +60 -20
- package/src/lib/ui/dom/fillBefore.ts +10 -0
- package/src/lib/ui/dom/hydrate.ts +2 -1
- package/src/lib/ui/dom/mount.ts +2 -1
- package/src/lib/ui/dom/mountSlot.ts +7 -2
- package/src/lib/ui/dom/scopeLabel.ts +19 -0
- package/src/lib/ui/dom/skeleton.ts +16 -1
- package/src/lib/ui/installInspectorBridge.ts +138 -0
- package/src/lib/ui/navigate.ts +11 -3
- package/src/lib/ui/persist.ts +4 -1
- package/src/lib/ui/router.ts +77 -9
- package/src/lib/ui/runtime/createDoc.ts +20 -7
- package/src/lib/ui/runtime/historyEntries.ts +113 -0
- package/src/lib/ui/runtime/liveScopes.ts +15 -0
- package/src/lib/ui/runtime/types/AbideHistoryState.ts +9 -0
- package/src/lib/ui/seedStreamedResolution.ts +10 -1
- package/src/lib/ui/startClient.ts +6 -0
- package/src/lib/ui/types/Scope.ts +3 -0
- package/src/lib/ui/compile/HTML_TAGS.ts +0 -132
|
@@ -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
|
}
|
|
@@ -3,6 +3,7 @@ import { history } from './history.ts'
|
|
|
3
3
|
import { linked } from './linked.ts'
|
|
4
4
|
import { persist as persistDoc } from './persist.ts'
|
|
5
5
|
import { createDoc } from './runtime/createDoc.ts'
|
|
6
|
+
import { liveScopes } from './runtime/liveScopes.ts'
|
|
6
7
|
import type { Doc } from './runtime/types/Doc.ts'
|
|
7
8
|
import { state } from './state.ts'
|
|
8
9
|
import { sync } from './sync.ts'
|
|
@@ -28,6 +29,7 @@ export function createScope(
|
|
|
28
29
|
initial: unknown = {},
|
|
29
30
|
parent: Scope | undefined = undefined,
|
|
30
31
|
awaiting = false,
|
|
32
|
+
label: string | undefined = undefined,
|
|
31
33
|
): Scope {
|
|
32
34
|
/* Eager unless awaiting adoption; `data()` lazily mints an empty doc if a body
|
|
33
35
|
never created one (a stateless component that still reaches for its scope). */
|
|
@@ -41,6 +43,7 @@ export function createScope(
|
|
|
41
43
|
|
|
42
44
|
const self: Scope = {
|
|
43
45
|
id,
|
|
46
|
+
label,
|
|
44
47
|
parent,
|
|
45
48
|
read: (path) => data().read(path),
|
|
46
49
|
replace: (path, value) => data().replace(path, value),
|
|
@@ -85,7 +88,15 @@ export function createScope(
|
|
|
85
88
|
persistence = undefined
|
|
86
89
|
unsync?.()
|
|
87
90
|
unsync = undefined
|
|
91
|
+
if (liveScopes.enabled) {
|
|
92
|
+
liveScopes.scopes.delete(self)
|
|
93
|
+
}
|
|
88
94
|
},
|
|
89
95
|
}
|
|
96
|
+
/* Dev-only: register for the inspector's scope-tree view. Gated, so production
|
|
97
|
+
never touches the set. */
|
|
98
|
+
if (liveScopes.enabled) {
|
|
99
|
+
liveScopes.scopes.add(self)
|
|
100
|
+
}
|
|
90
101
|
return self
|
|
91
102
|
}
|
|
@@ -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
|
|
@@ -4,6 +4,7 @@ import { enterRenderPass } from '../runtime/enterRenderPass.ts'
|
|
|
4
4
|
import { exitRenderPass } from '../runtime/exitRenderPass.ts'
|
|
5
5
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
6
6
|
import { scope } from '../runtime/scope.ts'
|
|
7
|
+
import { scopeLabel } from './scopeLabel.ts'
|
|
7
8
|
|
|
8
9
|
/*
|
|
9
10
|
Adopts existing server-rendered DOM instead of rebuilding it. Runs `build(host)`
|
|
@@ -28,7 +29,7 @@ export function hydrate(host: Element, build: (host: Element) => void): () => vo
|
|
|
28
29
|
/* Same lexical scope establishment as `mount` — a hydrated component owns a scope
|
|
29
30
|
too, adopting the model its build adopts. */
|
|
30
31
|
const parentScope = CURRENT_SCOPE.current
|
|
31
|
-
const lexical = createScope({}, parentScope, true)
|
|
32
|
+
const lexical = createScope({}, parentScope, true, scopeLabel(host))
|
|
32
33
|
CURRENT_SCOPE.current = lexical
|
|
33
34
|
try {
|
|
34
35
|
const stop = scope(() => {
|
package/src/lib/ui/dom/mount.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { CURRENT_SCOPE } from '../runtime/CURRENT_SCOPE.ts'
|
|
|
3
3
|
import { enterRenderPass } from '../runtime/enterRenderPass.ts'
|
|
4
4
|
import { exitRenderPass } from '../runtime/exitRenderPass.ts'
|
|
5
5
|
import { scope } from '../runtime/scope.ts'
|
|
6
|
+
import { scopeLabel } from './scopeLabel.ts'
|
|
6
7
|
|
|
7
8
|
/*
|
|
8
9
|
Mounts a component into `host`: runs `build(host)` under an ownership scope so
|
|
@@ -22,7 +23,7 @@ export function mount(host: Element, build: (host: Element) => void): () => void
|
|
|
22
23
|
`scope()` and its capabilities resolve to it during the build; the previous
|
|
23
24
|
scope is restored after (synchronous build, so the restore is exact). */
|
|
24
25
|
const parentScope = CURRENT_SCOPE.current
|
|
25
|
-
const lexical = createScope({}, parentScope, true)
|
|
26
|
+
const lexical = createScope({}, parentScope, true, scopeLabel(host))
|
|
26
27
|
CURRENT_SCOPE.current = lexical
|
|
27
28
|
const stop = scope(() => {
|
|
28
29
|
try {
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The human-readable name for the scope a mount/hydrate establishes, derived from
|
|
3
|
+
its host element's tag. A nested component mounts into its `abide-<name>` wrapper
|
|
4
|
+
(see `componentWrapperTag`), so stripping the `abide-` prefix recovers the
|
|
5
|
+
component name; any other host (a page/layout outlet) yields its lowercased tag.
|
|
6
|
+
Dev-only — feeds the inspector's Reactive tab so a scope reads `<Counter>` rather
|
|
7
|
+
than an opaque counter id. Returns undefined when there's no element to name from.
|
|
8
|
+
*/
|
|
9
|
+
import { COMPONENT_WRAPPER_PREFIX } from '../COMPONENT_WRAPPER_PREFIX.ts'
|
|
10
|
+
|
|
11
|
+
export function scopeLabel(host: Element): string | undefined {
|
|
12
|
+
const tag = host.tagName?.toLowerCase()
|
|
13
|
+
if (tag === undefined) {
|
|
14
|
+
return undefined
|
|
15
|
+
}
|
|
16
|
+
return tag.startsWith(COMPONENT_WRAPPER_PREFIX)
|
|
17
|
+
? tag.slice(COMPONENT_WRAPPER_PREFIX.length)
|
|
18
|
+
: tag
|
|
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'
|
|
@@ -26,6 +27,18 @@ function isElement(node: Node): node is Element {
|
|
|
26
27
|
return typeof (node as Element).hasAttribute === 'function'
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
/* A child component's mount wrapper (`abide-<name>`, see `componentWrapperTag`). Its
|
|
31
|
+
content is a SEPARATE skeleton (the child's own), so the parent's walks must treat it
|
|
32
|
+
as opaque: in the shallow skeleton it's an empty leaf, so the compiler counts no
|
|
33
|
+
anchors inside it; on hydrate it's populated, so a descent would over-collect the
|
|
34
|
+
child's anchors and shift every parent index past it (same hazard as a block range,
|
|
35
|
+
but bounded by the wrapper element instead of `[`…`]` markers). */
|
|
36
|
+
function isComponentWrapper(node: Node): boolean {
|
|
37
|
+
return (
|
|
38
|
+
isElement(node) && (node.tagName ?? '').toLowerCase().startsWith(COMPONENT_WRAPPER_PREFIX)
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
29
42
|
/* A comment node's data, or undefined for elements/text. A comment is a node that is
|
|
30
43
|
neither an element (`hasAttribute`) nor a text node (`splitText`); the mini-dom
|
|
31
44
|
exposes no `nodeType`, so detect by method. */
|
|
@@ -113,7 +126,9 @@ function scanAnchors(nodes: ArrayLike<Node>, anchors: Node[]): void {
|
|
|
113
126
|
const node = nodes[index] as Node
|
|
114
127
|
const data = commentData(node)
|
|
115
128
|
if (data === undefined) {
|
|
116
|
-
|
|
129
|
+
/* Recurse into this skeleton's own elements, but NOT a child component's
|
|
130
|
+
wrapper — its anchors belong to the child's skeleton (see above). */
|
|
131
|
+
if (isElement(node) && depth === 0 && !isComponentWrapper(node)) {
|
|
117
132
|
scanAnchors(node.childNodes, anchors)
|
|
118
133
|
}
|
|
119
134
|
} else if (isCloseMarker(data)) {
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { effect } from './effect.ts'
|
|
2
|
+
import { clientPage } from './runtime/clientPage.ts'
|
|
3
|
+
import { historyEntries } from './runtime/historyEntries.ts'
|
|
4
|
+
import { liveScopes } from './runtime/liveScopes.ts'
|
|
5
|
+
import { PATCH_BUS } from './runtime/PATCH_BUS.ts'
|
|
6
|
+
import { runtimePath } from './runtime/runtimePath.ts'
|
|
7
|
+
|
|
8
|
+
/* Same-origin cross-tab channel the inspector page listens on. The app tab
|
|
9
|
+
publishes scope + router state; the inspector subscribes. Web-standard, so no
|
|
10
|
+
server route or buffer is involved — core publishes, the package consumes. */
|
|
11
|
+
const CHANNEL = 'abide:inspector'
|
|
12
|
+
|
|
13
|
+
/* Coalesce a burst of patches into one snapshot — the post is the cost, the read is cheap. */
|
|
14
|
+
const SNAPSHOT_DEBOUNCE_MS = 60
|
|
15
|
+
|
|
16
|
+
/* JSON round-trip to strip anything structured-clone can't carry (functions,
|
|
17
|
+
class instances, DOM nodes) before postMessage, degrading to undefined rather
|
|
18
|
+
than throwing into the channel. */
|
|
19
|
+
function cloneable(value: unknown): unknown {
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(JSON.stringify(value))
|
|
22
|
+
} catch {
|
|
23
|
+
return undefined
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/* The live scope forest as a flat list — the inspector rebuilds the tree from
|
|
28
|
+
each node's `parent` id (the Scope surface exposes no children accessor). */
|
|
29
|
+
function scopeNodes(): Array<{
|
|
30
|
+
id: string
|
|
31
|
+
label: string | undefined
|
|
32
|
+
parent: string | undefined
|
|
33
|
+
state: unknown
|
|
34
|
+
recorded: boolean
|
|
35
|
+
}> {
|
|
36
|
+
return Array.from(liveScopes.scopes, (scope) => ({
|
|
37
|
+
id: scope.id,
|
|
38
|
+
label: scope.label,
|
|
39
|
+
parent: scope.parent?.id,
|
|
40
|
+
state: cloneable(scope.snapshot()),
|
|
41
|
+
recorded: scope.canUndo() || scope.canRedo(),
|
|
42
|
+
}))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* Router state off the existing reactive holders — read, not subscribed, here;
|
|
46
|
+
the nav effect drives re-reads. */
|
|
47
|
+
function routerState() {
|
|
48
|
+
const page = clientPage.value
|
|
49
|
+
return {
|
|
50
|
+
path: runtimePath.value,
|
|
51
|
+
route: page.route,
|
|
52
|
+
params: page.params,
|
|
53
|
+
navigating: page.navigating,
|
|
54
|
+
url: page.url.href,
|
|
55
|
+
entry: historyEntries.current,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/*
|
|
60
|
+
Installs the client→inspector bridge: a BroadcastChannel the inspector page reads
|
|
61
|
+
to render its Reactive + Router tabs. Gated by `__abideInspect` (server-injected
|
|
62
|
+
only when the inspector is enabled) and called from startClient before the router
|
|
63
|
+
builds any scope — so registration is armed before the first scope exists. A
|
|
64
|
+
no-op where BroadcastChannel is absent (SSR, old runtimes). Mirrors
|
|
65
|
+
installHotBridge: dev instrumentation, not public surface.
|
|
66
|
+
*/
|
|
67
|
+
export function installInspectorBridge(): void {
|
|
68
|
+
/* No BroadcastChannel means no consumer for the scope registry — bail before
|
|
69
|
+
arming `liveScopes.enabled`, so createScope never pays scope tracking for a
|
|
70
|
+
set nothing reads. */
|
|
71
|
+
if (typeof BroadcastChannel === 'undefined') {
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
liveScopes.enabled = true
|
|
75
|
+
const channel = new BroadcastChannel(CHANNEL)
|
|
76
|
+
const tab = typeof crypto !== 'undefined' ? crypto.randomUUID() : String(performance.now())
|
|
77
|
+
const post = (message: object) => {
|
|
78
|
+
try {
|
|
79
|
+
channel.postMessage({ tab, ...message })
|
|
80
|
+
} catch {
|
|
81
|
+
/* A non-cloneable slipped through despite the JSON pass — drop the frame
|
|
82
|
+
rather than throw into a patch/nav handler. */
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const announce = () =>
|
|
87
|
+
post({
|
|
88
|
+
kind: 'announce',
|
|
89
|
+
url: typeof location !== 'undefined' ? location.href : '',
|
|
90
|
+
app: typeof document !== 'undefined' ? document.title : '',
|
|
91
|
+
})
|
|
92
|
+
const sendSnapshot = () =>
|
|
93
|
+
post({ kind: 'snapshot', scopes: scopeNodes(), router: routerState() })
|
|
94
|
+
|
|
95
|
+
let snapshotTimer: ReturnType<typeof setTimeout> | undefined
|
|
96
|
+
const scheduleSnapshot = () => {
|
|
97
|
+
clearTimeout(snapshotTimer)
|
|
98
|
+
snapshotTimer = setTimeout(sendSnapshot, SNAPSHOT_DEBOUNCE_MS)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* Inspector handshakes: `hello` (it just opened — everyone re-announce + push)
|
|
102
|
+
and `request` (it wants a fresh snapshot of this tab). */
|
|
103
|
+
channel.onmessage = (event) => {
|
|
104
|
+
const message = event.data as { kind?: string; tab?: string }
|
|
105
|
+
if (message.kind === 'hello') {
|
|
106
|
+
announce()
|
|
107
|
+
sendSnapshot()
|
|
108
|
+
} else if (message.kind === 'request' && message.tab === tab) {
|
|
109
|
+
sendSnapshot()
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/* Every doc mutation flows through PATCH_BUS; forward the patch as a live
|
|
114
|
+
mutation event and re-snapshot (debounced) so values stay current. */
|
|
115
|
+
PATCH_BUS.subscribe((patchEvent) => {
|
|
116
|
+
post({ kind: 'patch', op: patchEvent.patch.op, path: patchEvent.patch.path })
|
|
117
|
+
scheduleSnapshot()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
/* Reading the page + path States tracks them, so this re-runs on navigation. */
|
|
121
|
+
effect(() => {
|
|
122
|
+
clientPage.value
|
|
123
|
+
runtimePath.value
|
|
124
|
+
post({ kind: 'nav', router: routerState() })
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
if (typeof addEventListener === 'function') {
|
|
128
|
+
addEventListener('pagehide', () => {
|
|
129
|
+
post({ kind: 'bye' })
|
|
130
|
+
channel.close()
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
announce()
|
|
135
|
+
/* First snapshot once the router has built the initial scope tree (this runs
|
|
136
|
+
before router()). */
|
|
137
|
+
setTimeout(sendSnapshot, 0)
|
|
138
|
+
}
|
package/src/lib/ui/navigate.ts
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
|
+
import { historyEntries } from './runtime/historyEntries.ts'
|
|
1
2
|
import { runtimePath } from './runtime/runtimePath.ts'
|
|
2
3
|
|
|
3
4
|
/* Navigates to `path`: writes a history entry (when available) and updates the
|
|
4
5
|
reactive route, which re-mounts the matching page via `router`. `replace` swaps
|
|
5
6
|
the current entry instead of pushing — used when honouring a server redirect, so
|
|
6
|
-
the blocked URL isn't left behind in history.
|
|
7
|
+
the blocked URL isn't left behind in history. Each entry carries a monotonic
|
|
8
|
+
`abideEntry` id so the router can bucket/restore its scroll offset across the page
|
|
9
|
+
teardown the rebuild does. A push leaves the current entry behind — its scroll is
|
|
10
|
+
bucketed so back restores it — and mints a fresh id. A replace destroys the current
|
|
11
|
+
entry and lands fresh content (a redirect), so its saved scroll no longer applies:
|
|
12
|
+
the bucket is discarded and the id kept, so the new page restores to top/anchor. */
|
|
7
13
|
// @documentation navigate
|
|
8
14
|
export function navigate(path: string, replace = false): void {
|
|
9
15
|
if (typeof history !== 'undefined') {
|
|
10
16
|
if (replace) {
|
|
11
|
-
|
|
17
|
+
historyEntries.discard()
|
|
18
|
+
history.replaceState({ abideEntry: historyEntries.current }, '', path)
|
|
12
19
|
} else {
|
|
13
|
-
|
|
20
|
+
historyEntries.save()
|
|
21
|
+
history.pushState({ abideEntry: historyEntries.next() }, '', path)
|
|
14
22
|
}
|
|
15
23
|
}
|
|
16
24
|
runtimePath.value = path
|
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
|
}
|