@abide/abide 0.35.0 → 0.37.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 +196 -215
- package/CHANGELOG.md +24 -0
- package/README.md +75 -69
- package/package.json +1 -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/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/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/shared/cache.ts +10 -3
- package/src/lib/shared/cacheManagedSlot.ts +10 -0
- package/src/lib/shared/emitLogRecord.ts +18 -5
- package/src/lib/shared/withCacheManaged.ts +18 -0
- package/src/lib/ui/compile/componentWrapperTag.ts +10 -20
- package/src/lib/ui/compile/generateBuild.ts +18 -9
- package/src/lib/ui/compile/generateSSR.ts +2 -2
- package/src/lib/ui/createScope.ts +11 -0
- package/src/lib/ui/dom/awaitBlock.ts +7 -3
- package/src/lib/ui/dom/each.ts +8 -15
- package/src/lib/ui/dom/eachAsync.ts +8 -9
- package/src/lib/ui/dom/hydrate.ts +2 -1
- package/src/lib/ui/dom/mount.ts +2 -1
- package/src/lib/ui/dom/scopeLabel.ts +15 -0
- package/src/lib/ui/dom/skeleton.ts +13 -1
- package/src/lib/ui/dom/switchBlock.ts +7 -3
- package/src/lib/ui/dom/tryBlock.ts +16 -5
- package/src/lib/ui/dom/when.ts +7 -3
- package/src/lib/ui/installInspectorBridge.ts +138 -0
- package/src/lib/ui/navigate.ts +11 -3
- package/src/lib/ui/remoteProxy.ts +32 -6
- package/src/lib/ui/router.ts +94 -10
- package/src/lib/ui/runtime/REQUEST_SUPERSEDED.ts +8 -0
- package/src/lib/ui/runtime/abortNode.ts +22 -0
- package/src/lib/ui/runtime/currentAbortSignal.ts +26 -0
- package/src/lib/ui/runtime/historyEntries.ts +113 -0
- package/src/lib/ui/runtime/liveScopes.ts +15 -0
- package/src/lib/ui/runtime/reactiveAbortState.ts +15 -0
- package/src/lib/ui/runtime/runNode.ts +9 -0
- package/src/lib/ui/runtime/scopeGroup.ts +40 -0
- package/src/lib/ui/runtime/types/AbideHistoryState.ts +9 -0
- package/src/lib/ui/runtime/unlinkDeps.ts +8 -0
- 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
|
@@ -1,23 +1,13 @@
|
|
|
1
|
-
import { HTML_TAGS } from './HTML_TAGS.ts'
|
|
2
|
-
|
|
3
1
|
/*
|
|
4
|
-
The element tag a component instance mounts into
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
layout-transparent with `display:contents`, so the component's real root still lays
|
|
13
|
-
out as a direct child of the parent the way the (parse-broken) wrapper would have.
|
|
14
|
-
A name that is NOT a known HTML element (the common case — `Card`, `Dropdown`) is an
|
|
15
|
-
inert unknown tag that holds any content untouched, so it stays as-is. Both back-ends
|
|
16
|
-
call this so the SSR string and the client build agree on the wrapper.
|
|
2
|
+
The element tag a component instance mounts into: always `abide-<name>` lowercased. The
|
|
3
|
+
`abide-` prefix makes every wrapper a valid custom element (contains a hyphen) — never
|
|
4
|
+
void, no content model — so it holds the component's own markup untouched and hydrates
|
|
5
|
+
cleanly, regardless of whether the name collides with an HTML element (`Button`, `Input`).
|
|
6
|
+
Emitted with `display:contents` (see the back-ends) so the wrapper stays out of layout: a
|
|
7
|
+
pure mount host whose real root lays out as a direct child of the parent, keeping the
|
|
8
|
+
component invisible to `grid`/`subgrid`/`flex`. Both back-ends call this so the SSR string
|
|
9
|
+
and the client build agree on the wrapper.
|
|
17
10
|
*/
|
|
18
|
-
export function componentWrapperTag(name: string):
|
|
19
|
-
|
|
20
|
-
return HTML_TAGS.has(lower)
|
|
21
|
-
? { tag: `abide-${lower}`, transparent: true }
|
|
22
|
-
: { tag: lower, transparent: false }
|
|
11
|
+
export function componentWrapperTag(name: string): string {
|
|
12
|
+
return `abide-${name.toLowerCase()}`
|
|
23
13
|
}
|
|
@@ -4,9 +4,9 @@ import { bindListenEvent } from './bindListenEvent.ts'
|
|
|
4
4
|
import { componentWrapperTag } from './componentWrapperTag.ts'
|
|
5
5
|
import { groupBindParts } from './groupBindParts.ts'
|
|
6
6
|
import { isControlFlow } from './isControlFlow.ts'
|
|
7
|
-
import { isTextLeaf } from './isTextLeaf.ts'
|
|
8
7
|
import { lowerContext } from './lowerContext.ts'
|
|
9
8
|
import { scopeAttr } from './scopeAttr.ts'
|
|
9
|
+
import { skeletonContext } from './skeletonContext.ts'
|
|
10
10
|
import { staticAttr } from './staticAttr.ts'
|
|
11
11
|
import { staticTextPart } from './staticTextPart.ts'
|
|
12
12
|
import type { TemplateNode } from './types/TemplateNode.ts'
|
|
@@ -32,6 +32,13 @@ export function generateBuild(
|
|
|
32
32
|
let counter = 0
|
|
33
33
|
const nextVar = (prefix: string): string => `${prefix}${counter++}`
|
|
34
34
|
|
|
35
|
+
/* Per-node skeleton position from the SAME pass the SSR back-end reads — so the client's
|
|
36
|
+
anchor/text-leaf decisions consult one source of truth instead of re-deriving the
|
|
37
|
+
position structurally (the drift the shared context exists to prevent). `asOutlet`
|
|
38
|
+
only rewrites layout `<slot>`s and preserves text-node identity, so the original
|
|
39
|
+
`nodes` key the same text markers the build traversal looks up. */
|
|
40
|
+
const { markText } = skeletonContext(nodes)
|
|
41
|
+
|
|
35
42
|
/* The shared signal→`model` lowering + branch-scoped nested-script deref scope. */
|
|
36
43
|
const {
|
|
37
44
|
expression: lowerExpression,
|
|
@@ -135,13 +142,12 @@ export function generateBuild(
|
|
|
135
142
|
}
|
|
136
143
|
if (node.kind === 'component') {
|
|
137
144
|
/* The wrapper element is a positioned hole in the skeleton; the child mounts
|
|
138
|
-
into the located node
|
|
139
|
-
|
|
140
|
-
const
|
|
145
|
+
into the located node. display:contents (idempotent, static so it lives in
|
|
146
|
+
the markup) keeps the wrapper out of layout — a pure mount host. */
|
|
147
|
+
const tag = componentWrapperTag(node.name)
|
|
141
148
|
const { code } = mountComponent(node, `${skVar}.el[${counter.el++}]`)
|
|
142
149
|
binds.push(code)
|
|
143
|
-
|
|
144
|
-
return `<${tag} ${HOLE_ATTRIBUTE}${style}></${tag}>`
|
|
150
|
+
return `<${tag} ${HOLE_ATTRIBUTE} style="display:contents"></${tag}>`
|
|
145
151
|
}
|
|
146
152
|
if (node.kind === 'script') {
|
|
147
153
|
/* A nested `<script>` (scoped reactive block) emits no markup — its lowered body
|
|
@@ -172,13 +178,16 @@ export function generateBuild(
|
|
|
172
178
|
return '<!--a-->'
|
|
173
179
|
}
|
|
174
180
|
const hasReactiveAttr = node.attrs.some((attr) => attr.kind !== 'static')
|
|
175
|
-
const
|
|
181
|
+
const reactiveTextChild = node.children.find(
|
|
176
182
|
(child) => child.kind === 'text' && child.parts.some((part) => part.kind !== 'static'),
|
|
177
183
|
)
|
|
178
184
|
/* A text-leaf (only text/style children) with reactive text binds marker-free via
|
|
179
185
|
`generateChildren` on the located element; otherwise reactive text is interleaved
|
|
180
|
-
and uses `<!--a-->` anchors during the child recursion below.
|
|
181
|
-
|
|
186
|
+
and uses `<!--a-->` anchors during the child recursion below. The shared context
|
|
187
|
+
records the leaf's text as NOT interleaved (`markText` false) — read that flag the
|
|
188
|
+
SSR back-end also reads, rather than re-deriving leaf-ness via `isTextLeaf` here. */
|
|
189
|
+
const textLeafBind =
|
|
190
|
+
reactiveTextChild !== undefined && markText.get(reactiveTextChild) === false
|
|
182
191
|
let openTag = `<${node.tag}`
|
|
183
192
|
let elVar = ''
|
|
184
193
|
if (hasReactiveAttr || textLeafBind) {
|
|
@@ -184,7 +184,7 @@ export function generateSSR(
|
|
|
184
184
|
the same wrapper the client mounts into, so SSR and client agree.
|
|
185
185
|
Props pass as thunks; slot content passes as a string-returning
|
|
186
186
|
`$children` the child invokes from its <slot>. */
|
|
187
|
-
const
|
|
187
|
+
const tag = componentWrapperTag(node.name)
|
|
188
188
|
const parts = node.props.map(
|
|
189
189
|
(prop) => `${JSON.stringify(prop.name)}: () => (${lowerExpression(prop.code)})`,
|
|
190
190
|
)
|
|
@@ -206,7 +206,7 @@ export function generateSSR(
|
|
|
206
206
|
the enclosing render body, including from branch closures.) */
|
|
207
207
|
const result = nextVar('$child')
|
|
208
208
|
return (
|
|
209
|
-
push(target, `<${tag}
|
|
209
|
+
push(target, `<${tag} style="display:contents">`) +
|
|
210
210
|
`const ${result} = ${node.name}.render({ ${parts.join(', ')} });\n` +
|
|
211
211
|
`${target}.push(${result}.html);\n` +
|
|
212
212
|
`for (const $a of ${result}.awaits) { $awaits.push($a); }\n` +
|
|
@@ -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
|
}
|
|
@@ -3,6 +3,7 @@ import { claimChild } from '../runtime/claimChild.ts'
|
|
|
3
3
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
4
4
|
import { RESUME } from '../runtime/RESUME.ts'
|
|
5
5
|
import { scope } from '../runtime/scope.ts'
|
|
6
|
+
import { scopeGroup } from '../runtime/scopeGroup.ts'
|
|
6
7
|
import { discardBoundary } from './discardBoundary.ts'
|
|
7
8
|
import { enterNamespace } from './enterNamespace.ts'
|
|
8
9
|
|
|
@@ -41,6 +42,9 @@ export function awaitBlock(
|
|
|
41
42
|
before: Node | null = null,
|
|
42
43
|
): void {
|
|
43
44
|
const hydration = RENDER.hydration
|
|
45
|
+
/* The live branch's scope, registered with the owner so it disposes on owner
|
|
46
|
+
teardown — not only when a settle/re-run swaps branches via detach. */
|
|
47
|
+
const group = scopeGroup()
|
|
44
48
|
let active: { nodes: Node[]; dispose: () => void } | undefined
|
|
45
49
|
let anchor: Node | undefined
|
|
46
50
|
let first = true
|
|
@@ -67,8 +71,8 @@ export function awaitBlock(
|
|
|
67
71
|
const place = (build: (parent: Node) => void): void => {
|
|
68
72
|
detach()
|
|
69
73
|
const fragment = document.createDocumentFragment()
|
|
70
|
-
const dispose =
|
|
71
|
-
scope(() => build(fragment)),
|
|
74
|
+
const dispose = group.track(
|
|
75
|
+
enterNamespace(anchor?.parentNode ?? parent, () => scope(() => build(fragment))),
|
|
72
76
|
)
|
|
73
77
|
const nodes = [...fragment.childNodes]
|
|
74
78
|
;(anchor?.parentNode ?? parent).insertBefore(fragment, anchor ?? null)
|
|
@@ -112,7 +116,7 @@ export function awaitBlock(
|
|
|
112
116
|
const adopt = (open: Node | null, build: (parent: Node) => void): void => {
|
|
113
117
|
const cursor = hydration as NonNullable<typeof hydration>
|
|
114
118
|
cursor.next.set(parent, open?.nextSibling ?? null)
|
|
115
|
-
const dispose = scope(() => build(parent))
|
|
119
|
+
const dispose = group.track(scope(() => build(parent)))
|
|
116
120
|
const close = claimChild(cursor, parent)
|
|
117
121
|
cursor.next.set(parent, close?.nextSibling ?? null)
|
|
118
122
|
const nodes: Node[] = []
|
package/src/lib/ui/dom/each.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { effect } from '../effect.ts'
|
|
2
2
|
import { claimChild } from '../runtime/claimChild.ts'
|
|
3
3
|
import { claimExpected } from '../runtime/claimExpected.ts'
|
|
4
|
-
import { OWNER } from '../runtime/OWNER.ts'
|
|
5
4
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
6
5
|
import { scope } from '../runtime/scope.ts'
|
|
6
|
+
import { scopeGroup } from '../runtime/scopeGroup.ts'
|
|
7
7
|
import { enterNamespace } from './enterNamespace.ts'
|
|
8
8
|
import { moveRange } from './moveRange.ts'
|
|
9
9
|
import { removeRange } from './removeRange.ts'
|
|
@@ -36,6 +36,9 @@ export function each<T>(
|
|
|
36
36
|
before: Node | null = null,
|
|
37
37
|
): void {
|
|
38
38
|
const rows = new Map<string, EachRow>()
|
|
39
|
+
/* Each row's scope, registered with the owner so every live row disposes on owner
|
|
40
|
+
teardown (the effect's own disposer only unsubscribes it from `items()`). */
|
|
41
|
+
const group = scopeGroup()
|
|
39
42
|
|
|
40
43
|
/* Build a row's range. Hydrate mode (only while the claim cursor is active —
|
|
41
44
|
read fresh, since a row built by a post-hydration reconcile must create, not
|
|
@@ -47,7 +50,7 @@ export function each<T>(
|
|
|
47
50
|
if (hydration !== undefined) {
|
|
48
51
|
const start = claimExpected(hydration, parent, 'each row start marker')
|
|
49
52
|
hydration.next.set(parent, start.nextSibling)
|
|
50
|
-
const dispose = scope(() => render(parent, item))
|
|
53
|
+
const dispose = group.track(scope(() => render(parent, item)))
|
|
51
54
|
const end = claimExpected(hydration, parent, 'each row end marker')
|
|
52
55
|
hydration.next.set(parent, end.nextSibling)
|
|
53
56
|
return { start, end, dispose }
|
|
@@ -58,7 +61,9 @@ export function each<T>(
|
|
|
58
61
|
pending.appendChild(start)
|
|
59
62
|
/* Build under `parent`'s foreign namespace so foreign row elements (svg/math)
|
|
60
63
|
built into the detached fragment are namespaced, not built as HTML. */
|
|
61
|
-
const dispose =
|
|
64
|
+
const dispose = group.track(
|
|
65
|
+
enterNamespace(parent, () => scope(() => render(pending, item))),
|
|
66
|
+
)
|
|
62
67
|
pending.appendChild(end)
|
|
63
68
|
return { start, end, dispose, pending }
|
|
64
69
|
}
|
|
@@ -146,16 +151,4 @@ export function each<T>(
|
|
|
146
151
|
RENDER.hydration = previousHydration
|
|
147
152
|
}
|
|
148
153
|
})
|
|
149
|
-
|
|
150
|
-
/* Dispose every row still live when the enclosing scope tears down (the effect's
|
|
151
|
-
own disposer only unsubscribes it from `items()`). The host's DOM is cleared by
|
|
152
|
-
`mount`, so disposal need not remove the nodes. */
|
|
153
|
-
if (OWNER.current !== undefined) {
|
|
154
|
-
OWNER.current.push(() => {
|
|
155
|
-
for (const row of rows.values()) {
|
|
156
|
-
row.dispose()
|
|
157
|
-
}
|
|
158
|
-
rows.clear()
|
|
159
|
-
})
|
|
160
|
-
}
|
|
161
154
|
}
|
|
@@ -3,6 +3,7 @@ import { claimChild } from '../runtime/claimChild.ts'
|
|
|
3
3
|
import { OWNER } from '../runtime/OWNER.ts'
|
|
4
4
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
5
5
|
import { scope } from '../runtime/scope.ts'
|
|
6
|
+
import { scopeGroup } from '../runtime/scopeGroup.ts'
|
|
6
7
|
import { enterNamespace } from './enterNamespace.ts'
|
|
7
8
|
import { removeRange } from './removeRange.ts'
|
|
8
9
|
import type { EachRow } from './types/EachRow.ts'
|
|
@@ -32,6 +33,9 @@ export function eachAsync<T>(
|
|
|
32
33
|
before: Node | null = null,
|
|
33
34
|
): void {
|
|
34
35
|
const rows = new Map<string, EachRow>()
|
|
36
|
+
/* Each row's (and the error branch's) scope, registered with the owner so they
|
|
37
|
+
dispose on owner teardown; the block's own teardown only stops the stream. */
|
|
38
|
+
const group = scopeGroup()
|
|
35
39
|
const hydration = RENDER.hydration
|
|
36
40
|
const anchor = document.createTextNode('')
|
|
37
41
|
if (hydration !== undefined) {
|
|
@@ -46,8 +50,8 @@ export function eachAsync<T>(
|
|
|
46
50
|
const end = document.createComment(']')
|
|
47
51
|
const fragment = document.createDocumentFragment()
|
|
48
52
|
fragment.appendChild(start)
|
|
49
|
-
const dispose =
|
|
50
|
-
scope(() => build(fragment)),
|
|
53
|
+
const dispose = group.track(
|
|
54
|
+
enterNamespace(anchor.parentNode ?? parent, () => scope(() => build(fragment))),
|
|
51
55
|
)
|
|
52
56
|
fragment.appendChild(end)
|
|
53
57
|
/* Insert via the anchor's LIVE parent: when this `each` is a bare child of a
|
|
@@ -127,18 +131,13 @@ export function eachAsync<T>(
|
|
|
127
131
|
})
|
|
128
132
|
|
|
129
133
|
/* Stop the live stream when the enclosing scope tears down: bump the generation so
|
|
130
|
-
the drain abandons its loop
|
|
131
|
-
|
|
134
|
+
the drain abandons its loop and `return()` the iterator to release the source. The
|
|
135
|
+
rows and error branch are disposed by the group (their scopes were tracked). */
|
|
132
136
|
if (OWNER.current !== undefined) {
|
|
133
137
|
OWNER.current.push(() => {
|
|
134
138
|
generation += 1
|
|
135
139
|
iterator?.return?.(undefined)?.catch(() => undefined)
|
|
136
140
|
iterator = undefined
|
|
137
|
-
clearError()
|
|
138
|
-
for (const row of rows.values()) {
|
|
139
|
-
row.dispose()
|
|
140
|
-
}
|
|
141
|
-
rows.clear()
|
|
142
141
|
})
|
|
143
142
|
}
|
|
144
143
|
}
|
|
@@ -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 {
|
|
@@ -0,0 +1,15 @@
|
|
|
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
|
+
export function scopeLabel(host: Element): string | undefined {
|
|
10
|
+
const tag = host.tagName?.toLowerCase()
|
|
11
|
+
if (tag === undefined) {
|
|
12
|
+
return undefined
|
|
13
|
+
}
|
|
14
|
+
return tag.startsWith('abide-') ? tag.slice('abide-'.length) : tag
|
|
15
|
+
}
|
|
@@ -26,6 +26,16 @@ function isElement(node: Node): node is Element {
|
|
|
26
26
|
return typeof (node as Element).hasAttribute === 'function'
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/* A child component's mount wrapper (`abide-<name>`, see `componentWrapperTag`). Its
|
|
30
|
+
content is a SEPARATE skeleton (the child's own), so the parent's walks must treat it
|
|
31
|
+
as opaque: in the shallow skeleton it's an empty leaf, so the compiler counts no
|
|
32
|
+
anchors inside it; on hydrate it's populated, so a descent would over-collect the
|
|
33
|
+
child's anchors and shift every parent index past it (same hazard as a block range,
|
|
34
|
+
but bounded by the wrapper element instead of `[`…`]` markers). */
|
|
35
|
+
function isComponentWrapper(node: Node): boolean {
|
|
36
|
+
return isElement(node) && (node.tagName ?? '').toLowerCase().startsWith('abide-')
|
|
37
|
+
}
|
|
38
|
+
|
|
29
39
|
/* A comment node's data, or undefined for elements/text. A comment is a node that is
|
|
30
40
|
neither an element (`hasAttribute`) nor a text node (`splitText`); the mini-dom
|
|
31
41
|
exposes no `nodeType`, so detect by method. */
|
|
@@ -113,7 +123,9 @@ function scanAnchors(nodes: ArrayLike<Node>, anchors: Node[]): void {
|
|
|
113
123
|
const node = nodes[index] as Node
|
|
114
124
|
const data = commentData(node)
|
|
115
125
|
if (data === undefined) {
|
|
116
|
-
|
|
126
|
+
/* Recurse into this skeleton's own elements, but NOT a child component's
|
|
127
|
+
wrapper — its anchors belong to the child's skeleton (see above). */
|
|
128
|
+
if (isElement(node) && depth === 0 && !isComponentWrapper(node)) {
|
|
117
129
|
scanAnchors(node.childNodes, anchors)
|
|
118
130
|
}
|
|
119
131
|
} else if (isCloseMarker(data)) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { effect } from '../effect.ts'
|
|
2
2
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
3
3
|
import { scope } from '../runtime/scope.ts'
|
|
4
|
+
import { scopeGroup } from '../runtime/scopeGroup.ts'
|
|
4
5
|
import { clearBetween } from './clearBetween.ts'
|
|
5
6
|
import { fillBefore } from './fillBefore.ts'
|
|
6
7
|
import { openMarker } from './openMarker.ts'
|
|
@@ -26,6 +27,9 @@ export function switchBlock(
|
|
|
26
27
|
before: Node | null = null,
|
|
27
28
|
): void {
|
|
28
29
|
const hydration = RENDER.hydration
|
|
30
|
+
/* The live case's scope, registered with the owner so it disposes on owner
|
|
31
|
+
teardown — not only when the subject switches cases via clearBetween. */
|
|
32
|
+
const group = scopeGroup()
|
|
29
33
|
let dispose: (() => void) | undefined
|
|
30
34
|
let activeIndex: number
|
|
31
35
|
let end: Comment
|
|
@@ -46,7 +50,7 @@ export function switchBlock(
|
|
|
46
50
|
activeIndex = select(subject())
|
|
47
51
|
const chosen = caseAt(activeIndex)
|
|
48
52
|
if (chosen !== undefined) {
|
|
49
|
-
dispose = scope(() => chosen.render(parent)) // claim the SSR nodes in place
|
|
53
|
+
dispose = group.track(scope(() => chosen.render(parent))) // claim the SSR nodes in place
|
|
50
54
|
}
|
|
51
55
|
end = openMarker(parent, ']')
|
|
52
56
|
} else {
|
|
@@ -54,7 +58,7 @@ export function switchBlock(
|
|
|
54
58
|
activeIndex = select(subject())
|
|
55
59
|
const chosen = caseAt(activeIndex)
|
|
56
60
|
if (chosen !== undefined) {
|
|
57
|
-
dispose = fillBefore(end, (p) => chosen.render(p))
|
|
61
|
+
dispose = group.track(fillBefore(end, (p) => chosen.render(p)))
|
|
58
62
|
}
|
|
59
63
|
}
|
|
60
64
|
|
|
@@ -68,7 +72,7 @@ export function switchBlock(
|
|
|
68
72
|
activeIndex = index
|
|
69
73
|
const chosen = caseAt(index)
|
|
70
74
|
if (chosen !== undefined) {
|
|
71
|
-
dispose = fillBefore(end, (p) => chosen.render(p))
|
|
75
|
+
dispose = group.track(fillBefore(end, (p) => chosen.render(p)))
|
|
72
76
|
}
|
|
73
77
|
})
|
|
74
78
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { claimChild } from '../runtime/claimChild.ts'
|
|
2
2
|
import { OWNER } from '../runtime/OWNER.ts'
|
|
3
3
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
4
|
+
import { scopeGroup } from '../runtime/scopeGroup.ts'
|
|
4
5
|
import { discardBoundary } from './discardBoundary.ts'
|
|
5
6
|
import { enterNamespace } from './enterNamespace.ts'
|
|
6
7
|
|
|
@@ -28,20 +29,30 @@ export function tryBlock(
|
|
|
28
29
|
renderCatch?: (parent: Node, error: unknown) => void,
|
|
29
30
|
before: Node | null = null,
|
|
30
31
|
): void {
|
|
31
|
-
/*
|
|
32
|
-
|
|
32
|
+
/* The guarded subtree's scope, registered with the owner so it disposes on owner
|
|
33
|
+
teardown. The block renders once, so there is at most one tracked subtree (the
|
|
34
|
+
try branch, or the catch branch if try threw). */
|
|
35
|
+
const group = scopeGroup()
|
|
36
|
+
/* Run a void build under a fresh ownership scope. On success, hand its disposers to
|
|
37
|
+
the group so the subtree tears down with the owner (they were previously dropped —
|
|
38
|
+
the leak). On throw, tear down the partial build now and rethrow so the caller can
|
|
39
|
+
fall back to the catch branch. */
|
|
33
40
|
const guard = (build: () => void): void => {
|
|
34
41
|
const previous = OWNER.current
|
|
35
42
|
const disposers: Array<() => void> = []
|
|
36
43
|
OWNER.current = disposers
|
|
44
|
+
const disposeAll = (): void => {
|
|
45
|
+
for (let index = disposers.length - 1; index >= 0; index -= 1) {
|
|
46
|
+
disposers[index]?.()
|
|
47
|
+
}
|
|
48
|
+
}
|
|
37
49
|
try {
|
|
38
50
|
build()
|
|
39
51
|
OWNER.current = previous
|
|
52
|
+
group.track(disposeAll)
|
|
40
53
|
} catch (error) {
|
|
41
54
|
OWNER.current = previous
|
|
42
|
-
|
|
43
|
-
disposers[index]?.()
|
|
44
|
-
}
|
|
55
|
+
disposeAll()
|
|
45
56
|
throw error
|
|
46
57
|
}
|
|
47
58
|
}
|
package/src/lib/ui/dom/when.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { effect } from '../effect.ts'
|
|
2
2
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
3
3
|
import { scope } from '../runtime/scope.ts'
|
|
4
|
+
import { scopeGroup } from '../runtime/scopeGroup.ts'
|
|
4
5
|
import { clearBetween } from './clearBetween.ts'
|
|
5
6
|
import { fillBefore } from './fillBefore.ts'
|
|
6
7
|
import { openMarker } from './openMarker.ts'
|
|
@@ -28,6 +29,9 @@ export function when(
|
|
|
28
29
|
): void {
|
|
29
30
|
const hydration = RENDER.hydration
|
|
30
31
|
const chosenFor = (branch: 'then' | 'else') => (branch === 'then' ? render : renderElse)
|
|
32
|
+
/* The live branch's scope, registered with the owner so it disposes on owner
|
|
33
|
+
teardown — not only on a branch flip via clearBetween. */
|
|
34
|
+
const group = scopeGroup()
|
|
31
35
|
let dispose: (() => void) | undefined
|
|
32
36
|
let activeBranch: 'then' | 'else'
|
|
33
37
|
let end: Comment
|
|
@@ -40,7 +44,7 @@ export function when(
|
|
|
40
44
|
activeBranch = condition() ? 'then' : 'else'
|
|
41
45
|
const chosen = chosenFor(activeBranch)
|
|
42
46
|
if (chosen !== undefined) {
|
|
43
|
-
dispose = scope(() => chosen(parent)) // content claims the SSR nodes in place
|
|
47
|
+
dispose = group.track(scope(() => chosen(parent))) // content claims the SSR nodes in place
|
|
44
48
|
}
|
|
45
49
|
end = openMarker(parent, ']')
|
|
46
50
|
} else {
|
|
@@ -48,7 +52,7 @@ export function when(
|
|
|
48
52
|
activeBranch = condition() ? 'then' : 'else'
|
|
49
53
|
const chosen = chosenFor(activeBranch)
|
|
50
54
|
if (chosen !== undefined) {
|
|
51
|
-
dispose = fillBefore(end, chosen)
|
|
55
|
+
dispose = group.track(fillBefore(end, chosen))
|
|
52
56
|
}
|
|
53
57
|
}
|
|
54
58
|
|
|
@@ -62,7 +66,7 @@ export function when(
|
|
|
62
66
|
activeBranch = branch
|
|
63
67
|
const chosen = chosenFor(branch)
|
|
64
68
|
if (chosen !== undefined) {
|
|
65
|
-
dispose = fillBefore(end, chosen)
|
|
69
|
+
dispose = group.track(fillBefore(end, chosen))
|
|
66
70
|
}
|
|
67
71
|
})
|
|
68
72
|
}
|
|
@@ -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
|
+
}
|