@abide/abide 0.34.2 → 0.36.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 +46 -0
- package/README.md +75 -69
- package/package.json +2 -1
- package/src/abideResolverPlugin.ts +53 -9
- package/src/lib/server/runtime/STREAMED_HTML_HEADER.ts +13 -0
- package/src/lib/server/runtime/buildPreloadManifest.ts +151 -0
- package/src/lib/server/runtime/createServer.ts +4 -1
- package/src/lib/server/runtime/createUiPageRenderer.ts +104 -9
- package/src/lib/server/runtime/flushingGzipStream.ts +62 -0
- package/src/lib/server/runtime/gzipResponse.ts +23 -11
- package/src/lib/server/runtime/serializeCacheSnapshot.ts +22 -33
- package/src/lib/shared/cache.ts +10 -3
- package/src/lib/shared/cacheManagedSlot.ts +10 -0
- package/src/lib/shared/withCacheManaged.ts +18 -0
- package/src/lib/ui/README.md +1 -1
- package/src/lib/ui/compile/REACTIVE_CALLEES.ts +6 -6
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
- package/src/lib/ui/compile/compileShadow.ts +60 -55
- package/src/lib/ui/compile/desugarSignals.ts +93 -38
- package/src/lib/ui/compile/lowerDocAccess.ts +26 -11
- package/src/lib/ui/compile/parseTemplate.ts +8 -5
- package/src/lib/ui/compile/prepareNestedScript.ts +2 -2
- package/src/lib/ui/compile/renameSignalRefs.ts +153 -23
- package/src/lib/ui/compile/types/TemplateAttr.ts +4 -2
- package/src/lib/ui/dom/applyResolved.ts +27 -7
- 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/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/installHotBridge.ts +2 -0
- package/src/lib/ui/remoteProxy.ts +32 -6
- package/src/lib/ui/router.ts +26 -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/escapeKey.ts +10 -4
- 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/unlinkDeps.ts +8 -0
- package/src/lib/ui/seedStreamedResolution.ts +22 -0
- package/src/lib/ui/startClient.ts +17 -3
- package/src/lib/shared/types/CacheSnapshot.ts +0 -16
|
@@ -9,6 +9,13 @@ touched — declaration names, parameter names, and property names are collected
|
|
|
9
9
|
into a skip set first, and object shorthand (`{ count }`) is expanded to
|
|
10
10
|
`{ count: model.count }`. This is the bridge from the signal surface the author
|
|
11
11
|
writes to the patch substrate underneath.
|
|
12
|
+
|
|
13
|
+
The rewrite is lexically scope-aware: a reference whose name is re-bound by an
|
|
14
|
+
enclosing scope (a function/arrow parameter or a nested local declaration) refers
|
|
15
|
+
to that inner binding, not the component signal, so it is left untouched. Without
|
|
16
|
+
this, a callback like `list.map(option => option.toUpperCase())` in a component
|
|
17
|
+
that also has an `option` prop (`const { option } = props()`) would have its loop
|
|
18
|
+
variable rewritten to `option()` and blow up at runtime (`option is not a function`).
|
|
12
19
|
*/
|
|
13
20
|
export function renameSignalRefs(
|
|
14
21
|
code: string,
|
|
@@ -18,6 +25,10 @@ export function renameSignalRefs(
|
|
|
18
25
|
): string {
|
|
19
26
|
const source = ts.createSourceFile('component.ts', code, ts.ScriptTarget.Latest, true)
|
|
20
27
|
|
|
28
|
+
/* The signal names that a nested binding can shadow — only these matter for
|
|
29
|
+
scope tracking, so we ignore every other local binding. */
|
|
30
|
+
const signalNames = new Set<string>([...stateNames, ...derivedNames, ...computedNames])
|
|
31
|
+
|
|
21
32
|
/* Identifier nodes that are names, not value reads — never rewritten. */
|
|
22
33
|
const skip = new Set<ts.Node>()
|
|
23
34
|
const collect = (node: ts.Node): void => {
|
|
@@ -45,33 +56,47 @@ export function renameSignalRefs(
|
|
|
45
56
|
|
|
46
57
|
const result = ts.transform(source, [
|
|
47
58
|
(context) => (root) => {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
+
/* Each visitor carries the set of signal names shadowed by the scopes it sits
|
|
60
|
+
inside; entering a scope that re-binds a signal name produces a fresh visitor
|
|
61
|
+
with that name added, so shadowing is per-branch (a sibling scope is unaffected). */
|
|
62
|
+
const makeVisitor = (shadowed: ReadonlySet<string>): ts.Visitor => {
|
|
63
|
+
const visit = (node: ts.Node): ts.Node => {
|
|
64
|
+
/* Shorthand `{ count }` → `{ count: model.count }` / `{ total: total.value }`,
|
|
65
|
+
unless a nearer scope shadows the name. */
|
|
66
|
+
if (ts.isShorthandPropertyAssignment(node) && !shadowed.has(node.name.text)) {
|
|
67
|
+
const replacement = referenceFor(
|
|
68
|
+
node.name.text,
|
|
69
|
+
stateNames,
|
|
70
|
+
derivedNames,
|
|
71
|
+
computedNames,
|
|
72
|
+
)
|
|
73
|
+
if (replacement !== undefined) {
|
|
74
|
+
return ts.factory.createPropertyAssignment(node.name.text, replacement)
|
|
75
|
+
}
|
|
59
76
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
77
|
+
if (ts.isIdentifier(node) && !skip.has(node) && !shadowed.has(node.text)) {
|
|
78
|
+
const replacement = referenceFor(
|
|
79
|
+
node.text,
|
|
80
|
+
stateNames,
|
|
81
|
+
derivedNames,
|
|
82
|
+
computedNames,
|
|
83
|
+
)
|
|
84
|
+
if (replacement !== undefined) {
|
|
85
|
+
return replacement
|
|
86
|
+
}
|
|
70
87
|
}
|
|
88
|
+
/* Recurse with the scope this node introduces folded in (same visitor when
|
|
89
|
+
it adds no shadowing name, so unscoped subtrees allocate nothing extra). */
|
|
90
|
+
const inner = extendShadowed(node, shadowed, signalNames)
|
|
91
|
+
return ts.visitEachChild(
|
|
92
|
+
node,
|
|
93
|
+
inner === shadowed ? visit : makeVisitor(inner),
|
|
94
|
+
context,
|
|
95
|
+
)
|
|
71
96
|
}
|
|
72
|
-
return
|
|
97
|
+
return visit
|
|
73
98
|
}
|
|
74
|
-
return ts.visitNode(root,
|
|
99
|
+
return ts.visitNode(root, makeVisitor(new Set())) as ts.SourceFile
|
|
75
100
|
},
|
|
76
101
|
])
|
|
77
102
|
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed })
|
|
@@ -80,6 +105,111 @@ export function renameSignalRefs(
|
|
|
80
105
|
return output
|
|
81
106
|
}
|
|
82
107
|
|
|
108
|
+
/* The shadowed-name set for `node`'s children: the parent set plus any signal name
|
|
109
|
+
that `node` re-binds as a new scope. Returns the parent set unchanged (same
|
|
110
|
+
reference) when `node` introduces no colliding binding, so the caller can skip
|
|
111
|
+
allocating a new visitor. */
|
|
112
|
+
function extendShadowed(
|
|
113
|
+
node: ts.Node,
|
|
114
|
+
shadowed: ReadonlySet<string>,
|
|
115
|
+
signalNames: ReadonlySet<string>,
|
|
116
|
+
): ReadonlySet<string> {
|
|
117
|
+
const introduced = new Set<string>()
|
|
118
|
+
collectScopeBindings(node, introduced)
|
|
119
|
+
const added = [...introduced].filter((name) => signalNames.has(name) && !shadowed.has(name))
|
|
120
|
+
if (added.length === 0) {
|
|
121
|
+
return shadowed
|
|
122
|
+
}
|
|
123
|
+
return new Set<string>([...shadowed, ...added])
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/* The names `node` binds when it opens a lexical scope — function/arrow parameters
|
|
127
|
+
(and the function's own name), block-level `let`/`const`/`function`/`class`,
|
|
128
|
+
`for`-header declarations, and the `catch` binding. Only names bound directly at
|
|
129
|
+
this scope; deeper scopes are folded in as the walk descends into them. */
|
|
130
|
+
function collectScopeBindings(node: ts.Node, into: Set<string>): void {
|
|
131
|
+
const parameters = functionParameters(node)
|
|
132
|
+
if (parameters !== undefined) {
|
|
133
|
+
for (const parameter of parameters) {
|
|
134
|
+
collectBindingNames(parameter.name, into)
|
|
135
|
+
}
|
|
136
|
+
if (
|
|
137
|
+
(ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node)) &&
|
|
138
|
+
node.name !== undefined
|
|
139
|
+
) {
|
|
140
|
+
into.add(node.name.text)
|
|
141
|
+
}
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
if (ts.isBlock(node) || ts.isModuleBlock(node) || ts.isCaseBlock(node)) {
|
|
145
|
+
const statements = ts.isCaseBlock(node)
|
|
146
|
+
? node.clauses.flatMap((clause) => [...clause.statements])
|
|
147
|
+
: node.statements
|
|
148
|
+
for (const statement of statements) {
|
|
149
|
+
collectStatementBindings(statement, into)
|
|
150
|
+
}
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
if (ts.isCatchClause(node) && node.variableDeclaration !== undefined) {
|
|
154
|
+
collectBindingNames(node.variableDeclaration.name, into)
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
if (ts.isForStatement(node) || ts.isForInStatement(node) || ts.isForOfStatement(node)) {
|
|
158
|
+
const initializer = node.initializer
|
|
159
|
+
if (initializer !== undefined && ts.isVariableDeclarationList(initializer)) {
|
|
160
|
+
for (const declaration of initializer.declarations) {
|
|
161
|
+
collectBindingNames(declaration.name, into)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* The names a single block-level statement binds: `var`/`let`/`const`, and named
|
|
168
|
+
`function`/`class` declarations. */
|
|
169
|
+
function collectStatementBindings(statement: ts.Statement, into: Set<string>): void {
|
|
170
|
+
if (ts.isVariableStatement(statement)) {
|
|
171
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
172
|
+
collectBindingNames(declaration.name, into)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (
|
|
176
|
+
(ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) &&
|
|
177
|
+
statement.name !== undefined
|
|
178
|
+
) {
|
|
179
|
+
into.add(statement.name.text)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/* Every identifier bound by a binding name — a plain identifier or the leaves of a
|
|
184
|
+
destructuring pattern (object/array, including rest elements). */
|
|
185
|
+
function collectBindingNames(name: ts.BindingName, into: Set<string>): void {
|
|
186
|
+
if (ts.isIdentifier(name)) {
|
|
187
|
+
into.add(name.text)
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
for (const element of name.elements) {
|
|
191
|
+
if (ts.isBindingElement(element)) {
|
|
192
|
+
collectBindingNames(element.name, into)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/* The parameter list of a function-like node, or undefined if `node` is not one. */
|
|
198
|
+
function functionParameters(node: ts.Node): ts.NodeArray<ts.ParameterDeclaration> | undefined {
|
|
199
|
+
if (
|
|
200
|
+
ts.isArrowFunction(node) ||
|
|
201
|
+
ts.isFunctionDeclaration(node) ||
|
|
202
|
+
ts.isFunctionExpression(node) ||
|
|
203
|
+
ts.isMethodDeclaration(node) ||
|
|
204
|
+
ts.isConstructorDeclaration(node) ||
|
|
205
|
+
ts.isGetAccessorDeclaration(node) ||
|
|
206
|
+
ts.isSetAccessorDeclaration(node)
|
|
207
|
+
) {
|
|
208
|
+
return node.parameters
|
|
209
|
+
}
|
|
210
|
+
return undefined
|
|
211
|
+
}
|
|
212
|
+
|
|
83
213
|
/* `model.<name>` for a state binding, `<name>()` for a computed doc-slot (the
|
|
84
214
|
string-free reader `scope().derive` returns), `<name>.value` for a runtime cell
|
|
85
215
|
(linked / lens / transform-state), else undefined. */
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/*
|
|
2
|
-
An element attribute. `static` is a literal
|
|
2
|
+
An element attribute. `static` is a literal — `bare` marks a valueless attribute
|
|
3
|
+
(`disabled`, not `disabled=""`), letting a component coerce it to `true` while a
|
|
4
|
+
native element still serialises it as `name=""`; `expression` is `name={code}` bound
|
|
3
5
|
reactively; `event` is `on<event>={code}` where `code` evaluates to the handler;
|
|
4
6
|
`bind` is `bind:<property>={lvalue}`, a two-way binding whose `code` is the
|
|
5
7
|
writable doc path read into the property and written back on input; `attach` is
|
|
@@ -9,7 +11,7 @@ absolute offset of `code` in the original `.abide` source (see TextPart) —
|
|
|
9
11
|
optional, set only when the parser tracks positions for the type-checking shadow.
|
|
10
12
|
*/
|
|
11
13
|
export type TemplateAttr =
|
|
12
|
-
| { kind: 'static'; name: string; value: string }
|
|
14
|
+
| { kind: 'static'; name: string; value: string; bare?: true }
|
|
13
15
|
| { kind: 'expression'; name: string; code: string; loc?: number }
|
|
14
16
|
| { kind: 'event'; event: string; code: string; loc?: number }
|
|
15
17
|
| { kind: 'bind'; property: string; code: string; loc?: number }
|
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
import { RESUME } from '../runtime/RESUME.ts'
|
|
2
|
+
import { seedStreamedResolution } from '../seedStreamedResolution.ts'
|
|
2
3
|
|
|
3
4
|
/*
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
Bundle-side consumer of an SSR stream chunk, the counterpart of the doc stream's inline
|
|
6
|
+
vanilla scripts (SSR_SWAP_SCRIPT's `__abideSwap` + `__abideResolve`) for a stream the
|
|
7
|
+
running bundle consumes itself — streaming SPA navigation, socket-delivered SSR. It routes
|
|
8
|
+
the two chunk kinds the stream interleaves:
|
|
9
|
+
|
|
10
|
+
- `<abide-cache>{StreamedResolution}</abide-cache>` → seed the streamed cache partition.
|
|
11
|
+
A script set via innerHTML never runs, so the cache channel rides a data frame here
|
|
12
|
+
(not the doc stream's `<script>__abideResolve(…)</script>`); seeding it keeps the
|
|
13
|
+
pending {#await} read warm instead of cold-missing to the network.
|
|
14
|
+
- `<abide-resolve data-id="ID"><script type="application/json">…</script>…</abide-resolve>`
|
|
15
|
+
→ register the value in the resume manifest (for hydration), find the matching
|
|
16
|
+
`<!--abide:await:ID-->…<!--/abide:await:ID-->` boundary in `root`, remove the pending
|
|
17
|
+
nodes between the markers, and insert the resolved content in their place.
|
|
18
|
+
|
|
19
|
+
The pending shell painted instantly; this swaps in each value as it arrives — completing
|
|
20
|
+
the out-of-order streaming loop on the client.
|
|
11
21
|
*/
|
|
12
22
|
// @documentation plumbing
|
|
13
23
|
export function applyResolved(root: Element, frame: string): void {
|
|
@@ -17,6 +27,16 @@ export function applyResolved(root: Element, frame: string): void {
|
|
|
17
27
|
if (resolved === null || resolved.getAttribute === undefined) {
|
|
18
28
|
return
|
|
19
29
|
}
|
|
30
|
+
/* A cache-seed frame warms the streamed cache partition — paired with the DOM swap so a
|
|
31
|
+
bundle-consumed stream can't adopt a resolved branch while dropping its cache key. */
|
|
32
|
+
if (resolved.nodeName === 'ABIDE-CACHE') {
|
|
33
|
+
try {
|
|
34
|
+
seedStreamedResolution(JSON.parse(resolved.textContent ?? 'null'))
|
|
35
|
+
} catch {
|
|
36
|
+
/* malformed payload — leave unseeded; the read falls back to a live fetch */
|
|
37
|
+
}
|
|
38
|
+
return
|
|
39
|
+
}
|
|
20
40
|
const id = resolved.getAttribute('data-id')
|
|
21
41
|
if (id === null) {
|
|
22
42
|
return
|
|
@@ -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
|
}
|
|
@@ -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
|
}
|
|
@@ -24,6 +24,7 @@ import { effect } from './effect.ts'
|
|
|
24
24
|
import { enterScope } from './enterScope.ts'
|
|
25
25
|
import { exitScope } from './exitScope.ts'
|
|
26
26
|
import { enterRenderPass } from './runtime/enterRenderPass.ts'
|
|
27
|
+
import { escapeKey } from './runtime/escapeKey.ts'
|
|
27
28
|
import { exitRenderPass } from './runtime/exitRenderPass.ts'
|
|
28
29
|
import { hotReloadEnabled } from './runtime/hotReloadEnabled.ts'
|
|
29
30
|
import { hotReplace } from './runtime/hotReplace.ts'
|
|
@@ -68,6 +69,7 @@ export function installHotBridge(): void {
|
|
|
68
69
|
mountSlot,
|
|
69
70
|
mountChild,
|
|
70
71
|
hydrate,
|
|
72
|
+
escapeKey,
|
|
71
73
|
nextBlockId,
|
|
72
74
|
enterRenderPass,
|
|
73
75
|
exitRenderPass,
|
|
@@ -8,6 +8,8 @@ import { trace } from '../shared/trace.ts'
|
|
|
8
8
|
import type { HttpVerb } from '../shared/types/HttpVerb.ts'
|
|
9
9
|
import type { RemoteFunction } from '../shared/types/RemoteFunction.ts'
|
|
10
10
|
import { withBase } from '../shared/withBase.ts'
|
|
11
|
+
import { currentAbortSignal } from './runtime/currentAbortSignal.ts'
|
|
12
|
+
import { REQUEST_SUPERSEDED } from './runtime/REQUEST_SUPERSEDED.ts'
|
|
11
13
|
|
|
12
14
|
/*
|
|
13
15
|
Client-side substitute for a verb-defined handler. The bundler emits one
|
|
@@ -54,17 +56,26 @@ export function remoteProxy<Args, Return>(
|
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
/*
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
Fetches under two optional aborts: the reactive scope that fired the call (so a
|
|
60
|
+
superseded/torn-down read cancels its in-flight request — currentAbortSignal) and
|
|
61
|
+
the env-configured client timeout (ABIDE_CLIENT_TIMEOUT, ms). Neither present →
|
|
62
|
+
the unbounded fetch, exactly as before. A timeout surfaces as a 504 HttpError so a
|
|
63
|
+
consumer reads an honest status instead of a raw DOMException → 500. Our scope abort
|
|
64
|
+
(reason REQUEST_SUPERSEDED) is swallowed into a never-settling promise: the reactive
|
|
65
|
+
owner is gone, so the result must neither resolve into a dead tree nor surface as a
|
|
66
|
+
rejection. Other rejections (genuine network failure) propagate untouched.
|
|
61
67
|
*/
|
|
62
68
|
function fetchWithTimeout(request: Request): Promise<Response> {
|
|
63
69
|
const timeout = rpcTimeoutSlot.ms
|
|
64
|
-
|
|
70
|
+
const timeoutSignal = timeout === undefined ? undefined : AbortSignal.timeout(timeout)
|
|
71
|
+
const signal = combineSignals(currentAbortSignal(), timeoutSignal)
|
|
72
|
+
if (signal === undefined) {
|
|
65
73
|
return fetch(request)
|
|
66
74
|
}
|
|
67
|
-
return fetch(request, { signal
|
|
75
|
+
return fetch(request, { signal }).catch((error: unknown) => {
|
|
76
|
+
if (error === REQUEST_SUPERSEDED) {
|
|
77
|
+
return new Promise<Response>(() => {})
|
|
78
|
+
}
|
|
68
79
|
if (error instanceof DOMException && error.name === 'TimeoutError') {
|
|
69
80
|
throw new HttpError(
|
|
70
81
|
new Response('client timeout', { status: 504, statusText: 'Gateway Timeout' }),
|
|
@@ -74,6 +85,21 @@ function fetchWithTimeout(request: Request): Promise<Response> {
|
|
|
74
85
|
})
|
|
75
86
|
}
|
|
76
87
|
|
|
88
|
+
/* One AbortSignal from the scope-abort and timeout sources: whichever is present,
|
|
89
|
+
AbortSignal.any when both, or undefined when neither (the unbounded fetch). */
|
|
90
|
+
function combineSignals(
|
|
91
|
+
scopeSignal: AbortSignal | undefined,
|
|
92
|
+
timeoutSignal: AbortSignal | undefined,
|
|
93
|
+
): AbortSignal | undefined {
|
|
94
|
+
if (scopeSignal === undefined) {
|
|
95
|
+
return timeoutSignal
|
|
96
|
+
}
|
|
97
|
+
if (timeoutSignal === undefined) {
|
|
98
|
+
return scopeSignal
|
|
99
|
+
}
|
|
100
|
+
return AbortSignal.any([scopeSignal, timeoutSignal])
|
|
101
|
+
}
|
|
102
|
+
|
|
77
103
|
/*
|
|
78
104
|
abide's per-RPC headers: the page traceparent (continues the server trace) and,
|
|
79
105
|
only while offline, the offline marker so the handler's online() reflects the
|