@abide/abide 0.40.0 → 0.40.1
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 +318 -146
- package/CHANGELOG.md +42 -0
- package/README.md +77 -79
- package/package.json +1 -1
- package/src/lib/shared/assertExhaustive.ts +14 -0
- package/src/lib/ui/compile/TS_PRINTER.ts +7 -0
- package/src/lib/ui/compile/analyzeComponent.ts +15 -16
- package/src/lib/ui/compile/assertRuntimeHelpersBound.ts +66 -0
- package/src/lib/ui/compile/assertTranspiles.ts +19 -0
- package/src/lib/ui/compile/compileModule.ts +56 -34
- package/src/lib/ui/compile/compileSSR.ts +1 -3
- package/src/lib/ui/compile/desugarSignals.ts +168 -107
- package/src/lib/ui/compile/generateBuild.ts +8 -1
- package/src/lib/ui/compile/generateSSR.ts +7 -0
- package/src/lib/ui/compile/hoistCells.ts +2 -2
- package/src/lib/ui/compile/lowerContext.ts +23 -10
- package/src/lib/ui/compile/lowerDocAccess.ts +29 -3
- package/src/lib/ui/compile/lowerScript.ts +64 -0
- package/src/lib/ui/compile/renameSignalRefs.ts +160 -102
- package/src/lib/ui/compile/stripEffects.ts +22 -17
- package/src/lib/ui/compile/types/AnalyzedComponent.ts +5 -0
- package/src/lib/ui/dom/awaitBlock.ts +8 -1
- package/src/lib/ui/dom/each.ts +4 -0
- package/src/lib/ui/dom/switchBlock.ts +4 -0
- package/src/lib/ui/dom/when.ts +4 -0
- package/src/lib/ui/installInspectorBridge.ts +3 -1
- package/src/lib/ui/router.ts +107 -14
- package/src/lib/ui/runtime/NODE_STATE.ts +22 -0
- package/src/lib/ui/runtime/clientPage.ts +114 -9
- package/src/lib/ui/runtime/createComputedNode.ts +5 -3
- package/src/lib/ui/runtime/createEffectNode.ts +3 -1
- package/src/lib/ui/runtime/createSignalNode.ts +4 -2
- package/src/lib/ui/runtime/flushEffects.ts +8 -5
- package/src/lib/ui/runtime/historyEntries.ts +39 -1
- package/src/lib/ui/runtime/readNode.ts +8 -7
- package/src/lib/ui/runtime/runNode.ts +18 -2
- package/src/lib/ui/runtime/scope.ts +12 -1
- package/src/lib/ui/runtime/trigger.ts +40 -24
- package/src/lib/ui/runtime/types/ReactiveNode.ts +4 -1
- package/src/lib/ui/runtime/updateIfNecessary.ts +40 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import ts from 'typescript'
|
|
2
|
+
import { TS_PRINTER } from './TS_PRINTER.ts'
|
|
2
3
|
|
|
3
4
|
/*
|
|
4
5
|
Rewrites references to a component's signal bindings into the document form the
|
|
@@ -24,104 +25,177 @@ export function renameSignalRefs(
|
|
|
24
25
|
computedNames: ReadonlySet<string> = new Set(),
|
|
25
26
|
): string {
|
|
26
27
|
const source = ts.createSourceFile('component.ts', code, ts.ScriptTarget.Latest, true)
|
|
28
|
+
const result = ts.transform(source, [
|
|
29
|
+
signalRefsTransformer(stateNames, derivedNames, computedNames),
|
|
30
|
+
])
|
|
31
|
+
const output = TS_PRINTER.printFile(result.transformed[0] as ts.SourceFile)
|
|
32
|
+
result.dispose()
|
|
33
|
+
return output
|
|
34
|
+
}
|
|
27
35
|
|
|
36
|
+
/*
|
|
37
|
+
The signal-rewrite as a `ts.TransformerFactory`, so the script pipeline can chain it
|
|
38
|
+
with `docAccessTransformer` over a SINGLE parsed tree (see `lowerScript`) instead of
|
|
39
|
+
print-then-reparse between passes. `renameSignalRefs` is the standalone string wrapper
|
|
40
|
+
kept for per-expression callers (`lowerContext`).
|
|
41
|
+
*/
|
|
42
|
+
export function signalRefsTransformer(
|
|
43
|
+
stateNames: ReadonlySet<string>,
|
|
44
|
+
derivedNames: ReadonlySet<string>,
|
|
45
|
+
computedNames: ReadonlySet<string> = new Set(),
|
|
46
|
+
): ts.TransformerFactory<ts.SourceFile> {
|
|
28
47
|
/* The signal names that a nested binding can shadow — only these matter for
|
|
29
48
|
scope tracking, so we ignore every other local binding. */
|
|
30
49
|
const signalNames = new Set<string>([...stateNames, ...derivedNames, ...computedNames])
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
50
|
+
return (context) => (root) => {
|
|
51
|
+
/* The identifier nodes that are names, not value reads — collected by walking
|
|
52
|
+
each parent and recording its name children (`parent.name`, a label, a
|
|
53
|
+
specifier). Read structurally, NOT via `child.parent`, so it holds on
|
|
54
|
+
synthesized nodes too — `desugar`'s rewritten declarations carry no parent
|
|
55
|
+
pointers, and chaining this transformer after `desugarTransformer` over one
|
|
56
|
+
tree must still classify their refs correctly. */
|
|
57
|
+
const nameSlots = collectNameSlots(root)
|
|
58
|
+
/* Each visitor carries the set of signal names shadowed by the scopes it sits
|
|
59
|
+
inside; entering a scope that re-binds a signal name produces a fresh visitor
|
|
60
|
+
with that name added, so shadowing is per-branch (a sibling scope is unaffected). */
|
|
61
|
+
const makeVisitor = (shadowed: ReadonlySet<string>): ts.Visitor => {
|
|
62
|
+
const visit = (node: ts.Node): ts.Node => {
|
|
63
|
+
/* Type space never holds a value read. A type alias, an interface, or any
|
|
64
|
+
type annotation can name a signal — a prop-type member `option?: …`, a
|
|
65
|
+
`typeof x` — without it being a runtime reference. Leave the whole type
|
|
66
|
+
subtree untouched so it isn't rewritten into a call/access (`option()`)
|
|
67
|
+
and emitted as broken code (types erase at build anyway). */
|
|
68
|
+
if (
|
|
69
|
+
ts.isTypeAliasDeclaration(node) ||
|
|
70
|
+
ts.isInterfaceDeclaration(node) ||
|
|
71
|
+
ts.isTypeNode(node)
|
|
72
|
+
) {
|
|
73
|
+
return node
|
|
74
|
+
}
|
|
75
|
+
/* Import/export specifiers are binding/module names, never value reads.
|
|
76
|
+
An aliased import whose original name collides with a signal
|
|
77
|
+
(`import { pending as p }` next to a `pending` prop) would otherwise
|
|
78
|
+
have its `pending` specifier rewritten to the reader form, corrupting
|
|
79
|
+
the declaration. Leave the whole subtree untouched. */
|
|
80
|
+
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
|
|
81
|
+
return node
|
|
82
|
+
}
|
|
83
|
+
/* Shorthand `{ count }` → `{ count: model.count }` / `{ total: total.value }`,
|
|
84
|
+
unless a nearer scope shadows the name. */
|
|
85
|
+
if (ts.isShorthandPropertyAssignment(node) && !shadowed.has(node.name.text)) {
|
|
86
|
+
const replacement = referenceFor(
|
|
87
|
+
node.name.text,
|
|
88
|
+
stateNames,
|
|
89
|
+
derivedNames,
|
|
90
|
+
computedNames,
|
|
91
|
+
)
|
|
92
|
+
if (replacement !== undefined) {
|
|
93
|
+
return ts.factory.createPropertyAssignment(node.name.text, replacement)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (ts.isIdentifier(node) && !nameSlots.has(node) && !shadowed.has(node.text)) {
|
|
97
|
+
const replacement = referenceFor(
|
|
98
|
+
node.text,
|
|
99
|
+
stateNames,
|
|
100
|
+
derivedNames,
|
|
101
|
+
computedNames,
|
|
102
|
+
)
|
|
103
|
+
if (replacement !== undefined) {
|
|
104
|
+
return replacement
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/* Recurse with the scope this node introduces folded in (same visitor when
|
|
108
|
+
it adds no shadowing name, so unscoped subtrees allocate nothing extra). */
|
|
109
|
+
const inner = extendShadowed(node, shadowed, signalNames)
|
|
110
|
+
return ts.visitEachChild(
|
|
111
|
+
node,
|
|
112
|
+
inner === shadowed ? visit : makeVisitor(inner),
|
|
113
|
+
context,
|
|
114
|
+
)
|
|
46
115
|
}
|
|
116
|
+
return visit
|
|
47
117
|
}
|
|
48
|
-
|
|
49
|
-
|
|
118
|
+
return ts.visitNode(root, makeVisitor(new Set())) as ts.SourceFile
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/*
|
|
123
|
+
Collects every identifier NODE that occupies a name/label/specifier slot — a position
|
|
124
|
+
that DECLARES, NAMES, or LABELS rather than READS. Walks each PARENT and records its
|
|
125
|
+
name children (`parent.name`, a label, a specifier), so it reads structure top-down
|
|
126
|
+
and never touches `child.parent` — it therefore holds on synthesized nodes (e.g.
|
|
127
|
+
`desugar`'s rewritten declarations), which carry no parent pointers. The visitor then
|
|
128
|
+
rewrites any identifier NOT in this set (rewrite is the default — value reads appear
|
|
129
|
+
under almost any expression parent and can't be allow-listed), so this set is the
|
|
130
|
+
exhaustive list of non-read slots. It is positional, so `pending` in
|
|
131
|
+
`import { pending as p }` and `pending` in `pending(query)` are told apart by where
|
|
132
|
+
they sit. A non-read slot missing here would be misread as a value read and rewritten
|
|
133
|
+
into broken syntax, so completeness is NOT self-evident — it is guarded empirically by
|
|
134
|
+
the syntax fuzz corpus (`uiCompileSyntaxFuzz.test.ts`), which transpiles each pass's
|
|
135
|
+
output and fails on any corruption.
|
|
136
|
+
*/
|
|
137
|
+
function collectNameSlots(root: ts.Node): Set<ts.Node> {
|
|
138
|
+
const slots = new Set<ts.Node>()
|
|
139
|
+
const add = (name: ts.Node | undefined): void => {
|
|
140
|
+
if (name !== undefined && ts.isIdentifier(name)) {
|
|
141
|
+
slots.add(name)
|
|
50
142
|
}
|
|
51
|
-
|
|
52
|
-
|
|
143
|
+
}
|
|
144
|
+
const visit = (node: ts.Node): void => {
|
|
145
|
+
/* `obj.NAME` — the member name; the object side (`.expression`) is the read. */
|
|
146
|
+
if (ts.isPropertyAccessExpression(node)) {
|
|
147
|
+
add(node.name)
|
|
53
148
|
}
|
|
149
|
+
/* `NAME: value`, and method/property/accessor/enum-member names in classes,
|
|
150
|
+
object literals, and type members. */
|
|
54
151
|
if (
|
|
55
|
-
|
|
56
|
-
node
|
|
152
|
+
ts.isPropertyAssignment(node) ||
|
|
153
|
+
ts.isMethodDeclaration(node) ||
|
|
154
|
+
ts.isPropertyDeclaration(node) ||
|
|
155
|
+
ts.isGetAccessorDeclaration(node) ||
|
|
156
|
+
ts.isSetAccessorDeclaration(node) ||
|
|
157
|
+
ts.isMethodSignature(node) ||
|
|
158
|
+
ts.isPropertySignature(node) ||
|
|
159
|
+
ts.isEnumMember(node)
|
|
160
|
+
) {
|
|
161
|
+
add(node.name)
|
|
162
|
+
}
|
|
163
|
+
/* Declaration names: const/let/var, parameters, named functions and classes. */
|
|
164
|
+
if (
|
|
165
|
+
ts.isVariableDeclaration(node) ||
|
|
166
|
+
ts.isParameter(node) ||
|
|
167
|
+
ts.isFunctionDeclaration(node) ||
|
|
168
|
+
ts.isFunctionExpression(node) ||
|
|
169
|
+
ts.isClassDeclaration(node) ||
|
|
170
|
+
ts.isClassExpression(node)
|
|
57
171
|
) {
|
|
58
|
-
|
|
172
|
+
add(node.name)
|
|
173
|
+
}
|
|
174
|
+
/* A destructure element binds (`{ a }` / `[a]`) or renames (`{ a: b }`): both
|
|
175
|
+
the bound name and the source key are names, not reads. */
|
|
176
|
+
if (ts.isBindingElement(node)) {
|
|
177
|
+
add(node.name)
|
|
178
|
+
add(node.propertyName)
|
|
179
|
+
}
|
|
180
|
+
/* Labels: `NAME:` and `break NAME` / `continue NAME`. */
|
|
181
|
+
if (ts.isLabeledStatement(node)) {
|
|
182
|
+
add(node.label)
|
|
183
|
+
}
|
|
184
|
+
if (ts.isBreakStatement(node) || ts.isContinueStatement(node)) {
|
|
185
|
+
add(node.label)
|
|
186
|
+
}
|
|
187
|
+
/* Import/export specifier and clause names. */
|
|
188
|
+
if (ts.isImportSpecifier(node) || ts.isExportSpecifier(node)) {
|
|
189
|
+
add(node.name)
|
|
190
|
+
add(node.propertyName)
|
|
59
191
|
}
|
|
60
|
-
ts.
|
|
192
|
+
if (ts.isImportClause(node) || ts.isNamespaceImport(node) || ts.isNamespaceExport(node)) {
|
|
193
|
+
add(node.name)
|
|
194
|
+
}
|
|
195
|
+
ts.forEachChild(node, visit)
|
|
61
196
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const result = ts.transform(source, [
|
|
65
|
-
(context) => (root) => {
|
|
66
|
-
/* Each visitor carries the set of signal names shadowed by the scopes it sits
|
|
67
|
-
inside; entering a scope that re-binds a signal name produces a fresh visitor
|
|
68
|
-
with that name added, so shadowing is per-branch (a sibling scope is unaffected). */
|
|
69
|
-
const makeVisitor = (shadowed: ReadonlySet<string>): ts.Visitor => {
|
|
70
|
-
const visit = (node: ts.Node): ts.Node => {
|
|
71
|
-
/* Type space never holds a value read. A type alias, an interface, or any
|
|
72
|
-
type annotation can name a signal — a prop-type member `option?: …`, a
|
|
73
|
-
`typeof x` — without it being a runtime reference. Leave the whole type
|
|
74
|
-
subtree untouched so it isn't rewritten into a call/access (`option()`)
|
|
75
|
-
and emitted as broken code (types erase at build anyway). */
|
|
76
|
-
if (
|
|
77
|
-
ts.isTypeAliasDeclaration(node) ||
|
|
78
|
-
ts.isInterfaceDeclaration(node) ||
|
|
79
|
-
ts.isTypeNode(node)
|
|
80
|
-
) {
|
|
81
|
-
return node
|
|
82
|
-
}
|
|
83
|
-
/* Shorthand `{ count }` → `{ count: model.count }` / `{ total: total.value }`,
|
|
84
|
-
unless a nearer scope shadows the name. */
|
|
85
|
-
if (ts.isShorthandPropertyAssignment(node) && !shadowed.has(node.name.text)) {
|
|
86
|
-
const replacement = referenceFor(
|
|
87
|
-
node.name.text,
|
|
88
|
-
stateNames,
|
|
89
|
-
derivedNames,
|
|
90
|
-
computedNames,
|
|
91
|
-
)
|
|
92
|
-
if (replacement !== undefined) {
|
|
93
|
-
return ts.factory.createPropertyAssignment(node.name.text, replacement)
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
if (ts.isIdentifier(node) && !skip.has(node) && !shadowed.has(node.text)) {
|
|
97
|
-
const replacement = referenceFor(
|
|
98
|
-
node.text,
|
|
99
|
-
stateNames,
|
|
100
|
-
derivedNames,
|
|
101
|
-
computedNames,
|
|
102
|
-
)
|
|
103
|
-
if (replacement !== undefined) {
|
|
104
|
-
return replacement
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
/* Recurse with the scope this node introduces folded in (same visitor when
|
|
108
|
-
it adds no shadowing name, so unscoped subtrees allocate nothing extra). */
|
|
109
|
-
const inner = extendShadowed(node, shadowed, signalNames)
|
|
110
|
-
return ts.visitEachChild(
|
|
111
|
-
node,
|
|
112
|
-
inner === shadowed ? visit : makeVisitor(inner),
|
|
113
|
-
context,
|
|
114
|
-
)
|
|
115
|
-
}
|
|
116
|
-
return visit
|
|
117
|
-
}
|
|
118
|
-
return ts.visitNode(root, makeVisitor(new Set())) as ts.SourceFile
|
|
119
|
-
},
|
|
120
|
-
])
|
|
121
|
-
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed })
|
|
122
|
-
const output = printer.printFile(result.transformed[0] as ts.SourceFile)
|
|
123
|
-
result.dispose()
|
|
124
|
-
return output
|
|
197
|
+
visit(root)
|
|
198
|
+
return slots
|
|
125
199
|
}
|
|
126
200
|
|
|
127
201
|
/* The shadowed-name set for `node`'s children: the parent set plus any signal name
|
|
@@ -199,22 +273,6 @@ function collectStatementBindings(statement: ts.Statement, into: Set<string>): v
|
|
|
199
273
|
}
|
|
200
274
|
}
|
|
201
275
|
|
|
202
|
-
/* The identifier NODES a binding name binds — a plain identifier or the leaves of a
|
|
203
|
-
destructuring pattern (object/array, including nested patterns and rest elements).
|
|
204
|
-
For `{ a: b }` only the bound name `b` is a binding; `a` is the source property.
|
|
205
|
-
Feeds the skip set so a destructured name shadowing a signal is never rewritten. */
|
|
206
|
-
function collectBindingIdentifiers(name: ts.BindingName, into: Set<ts.Node>): void {
|
|
207
|
-
if (ts.isIdentifier(name)) {
|
|
208
|
-
into.add(name)
|
|
209
|
-
return
|
|
210
|
-
}
|
|
211
|
-
for (const element of name.elements) {
|
|
212
|
-
if (ts.isBindingElement(element)) {
|
|
213
|
-
collectBindingIdentifiers(element.name, into)
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
276
|
/* Every identifier bound by a binding name — a plain identifier or the leaves of a
|
|
219
277
|
destructuring pattern (object/array, including rest elements). */
|
|
220
278
|
function collectBindingNames(name: ts.BindingName, into: Set<string>): void {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import ts from 'typescript'
|
|
2
|
+
import { TS_PRINTER } from './TS_PRINTER.ts'
|
|
2
3
|
|
|
3
4
|
/*
|
|
4
5
|
Removes `effect(...)` calls from a script for the SSR back-end. Effects are client
|
|
@@ -10,23 +11,27 @@ binding keeps a defined (unused) name. Client compilation keeps effects untouche
|
|
|
10
11
|
*/
|
|
11
12
|
export function stripEffects(code: string): string {
|
|
12
13
|
const source = ts.createSourceFile('script.ts', code, ts.ScriptTarget.Latest, true)
|
|
13
|
-
const result = ts.transform(source, [
|
|
14
|
-
|
|
15
|
-
const visit = (node: ts.Node): ts.Node => {
|
|
16
|
-
if (
|
|
17
|
-
ts.isCallExpression(node) &&
|
|
18
|
-
ts.isIdentifier(node.expression) &&
|
|
19
|
-
node.expression.text === 'effect'
|
|
20
|
-
) {
|
|
21
|
-
return ts.factory.createIdentifier('undefined')
|
|
22
|
-
}
|
|
23
|
-
return ts.visitEachChild(node, visit, context)
|
|
24
|
-
}
|
|
25
|
-
return ts.visitNode(root, visit) as ts.SourceFile
|
|
26
|
-
},
|
|
27
|
-
])
|
|
28
|
-
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed })
|
|
29
|
-
const output = printer.printFile(result.transformed[0] as ts.SourceFile)
|
|
14
|
+
const result = ts.transform(source, [stripEffectsTransformer()])
|
|
15
|
+
const output = TS_PRINTER.printFile(result.transformed[0] as ts.SourceFile)
|
|
30
16
|
result.dispose()
|
|
31
17
|
return output
|
|
32
18
|
}
|
|
19
|
+
|
|
20
|
+
/* The effect-stripping as a `ts.TransformerFactory`, so the SSR script path can run it
|
|
21
|
+
over the tree `lowerScript` already built (one extra print, no reparse). `stripEffects`
|
|
22
|
+
is the standalone string wrapper kept for the per-nested-`<script>` SSR callers. */
|
|
23
|
+
export function stripEffectsTransformer(): ts.TransformerFactory<ts.SourceFile> {
|
|
24
|
+
return (context) => (root) => {
|
|
25
|
+
const visit = (node: ts.Node): ts.Node => {
|
|
26
|
+
if (
|
|
27
|
+
ts.isCallExpression(node) &&
|
|
28
|
+
ts.isIdentifier(node.expression) &&
|
|
29
|
+
node.expression.text === 'effect'
|
|
30
|
+
) {
|
|
31
|
+
return ts.factory.createIdentifier('undefined')
|
|
32
|
+
}
|
|
33
|
+
return ts.visitEachChild(node, visit, context)
|
|
34
|
+
}
|
|
35
|
+
return ts.visitNode(root, visit) as ts.SourceFile
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -7,7 +7,12 @@ The shared front-end result for a component, consumed by both the client
|
|
|
7
7
|
(so template expressions rewrite consistently), and the parsed template tree.
|
|
8
8
|
*/
|
|
9
9
|
export type AnalyzedComponent = {
|
|
10
|
+
/* The lowered client script — keeps `effect(...)` calls (client lifecycle). */
|
|
10
11
|
script: string
|
|
12
|
+
/* The same lowered script with `effect(...)` calls stripped, for the SSR render
|
|
13
|
+
(effects emit no HTML and must not run on the server). Produced from the one
|
|
14
|
+
parse `script` came from, not a downstream re-parse. */
|
|
15
|
+
ssrScript: string
|
|
11
16
|
/* Top-level import statements hoisted out of the script (e.g. child
|
|
12
17
|
components), placed at module scope by the module wrapper. */
|
|
13
18
|
imports: string
|
|
@@ -223,7 +223,14 @@ export function awaitBlock(
|
|
|
223
223
|
subscribes to its reactive source (a cache key). A cache-remote read is warm
|
|
224
224
|
on resume — it serves the snapshot without a network round-trip, so adoption
|
|
225
225
|
stays no-flash AND a later cache.invalidate re-runs the block. Without this
|
|
226
|
-
read a resume-adopted block has no deps and invalidate is a no-op.
|
|
226
|
+
read a resume-adopted block has no deps and invalidate is a no-op.
|
|
227
|
+
|
|
228
|
+
ONLY the promise read is tracked. The warm-sync resolve, the hydration adopt,
|
|
229
|
+
and the pending render all BUILD the branch through `scope`, which builds
|
|
230
|
+
untracked — so the branch's own reactive reads don't subscribe THIS effect
|
|
231
|
+
(otherwise the whole block re-runs and re-suspends on any branch-state change,
|
|
232
|
+
e.g. a sibling route param updating in place). The branch's own child effects
|
|
233
|
+
still track normally; the block re-runs only when the promise source does. */
|
|
227
234
|
const result = promiseThunk()
|
|
228
235
|
if (first) {
|
|
229
236
|
first = false
|
package/src/lib/ui/dom/each.ts
CHANGED
|
@@ -45,6 +45,10 @@ export function each<T>(
|
|
|
45
45
|
claim): claim the start marker, build content in place, claim the end marker.
|
|
46
46
|
Create mode: markers + content in a fragment, held in `pending` until placement
|
|
47
47
|
inserts it. */
|
|
48
|
+
/* Reconcile runs inside the effect below; the row build doesn't subscribe it
|
|
49
|
+
(`scope` builds untracked), so a raw reactive read in the row content — e.g. a
|
|
50
|
+
nested `<script>` body — can't re-reconcile the whole list. Only `items()` drives
|
|
51
|
+
the each; each row's own interpolations track through their own effects. */
|
|
48
52
|
const buildRow = (item: T): EachRow => {
|
|
49
53
|
const hydration = RENDER.hydration
|
|
50
54
|
if (hydration !== undefined) {
|
|
@@ -42,6 +42,10 @@ export function switchBlock(
|
|
|
42
42
|
}
|
|
43
43
|
const caseAt = (index: number): SwitchCase | undefined =>
|
|
44
44
|
index === -1 ? undefined : cases[index]
|
|
45
|
+
/* The chosen case builds through `scope` (directly on hydrate, via `fillBefore` on a
|
|
46
|
+
swap), which builds untracked — so a raw reactive read in the case content doesn't
|
|
47
|
+
subscribe the swap effect below; only `subject()` (and each case's `match()`) drives
|
|
48
|
+
the swap. The case's own interpolations still track, each through its own effect. */
|
|
45
49
|
|
|
46
50
|
/* `before` places the range among static siblings on create (block before a suffix);
|
|
47
51
|
hydrate ignores it and uses the parked claim cursor. */
|
package/src/lib/ui/dom/when.ts
CHANGED
|
@@ -28,6 +28,10 @@ export function when(
|
|
|
28
28
|
before: Node | null = null,
|
|
29
29
|
): void {
|
|
30
30
|
const hydration = RENDER.hydration
|
|
31
|
+
/* The chosen branch builds through `scope` (directly on hydrate, via `fillBefore`
|
|
32
|
+
on a swap), which builds untracked — so a raw reactive read in the branch content
|
|
33
|
+
doesn't subscribe the swap effect below; only `condition()` drives the toggle. The
|
|
34
|
+
branch's own interpolations still track, each through its own effect. */
|
|
31
35
|
const chosenFor = (branch: 'then' | 'else') => (branch === 'then' ? render : renderElse)
|
|
32
36
|
/* The live branch's scope, registered with the owner so it disposes on owner
|
|
33
37
|
teardown — not only on a branch flip via clearBetween. */
|
|
@@ -49,7 +49,9 @@ function routerState() {
|
|
|
49
49
|
return {
|
|
50
50
|
path: runtimePath.value,
|
|
51
51
|
route: page.route,
|
|
52
|
-
|
|
52
|
+
/* `page.params` is a reactive Proxy — structuredClone (postMessage) can't carry
|
|
53
|
+
a Proxy and throws, dropping every router frame. Spread to a plain object. */
|
|
54
|
+
params: { ...page.params },
|
|
53
55
|
navigating: page.navigating,
|
|
54
56
|
url: page.url.href,
|
|
55
57
|
entry: historyEntries.current,
|
package/src/lib/ui/router.ts
CHANGED
|
@@ -36,6 +36,47 @@ const resolveUrl = (path: string): URL =>
|
|
|
36
36
|
? new URL(`http://localhost${path}`)
|
|
37
37
|
: new URL(path, location.origin)
|
|
38
38
|
|
|
39
|
+
/* A full browser load is the recovery for an import/probe failure — offline, a hashed
|
|
40
|
+
chunk name rotated by a deploy, a transient 5xx. But a DETERMINISTIC failure (a chunk
|
|
41
|
+
that throws every load) would reload forever. Bound it per destination: after
|
|
42
|
+
MAX_RECOVERY_RELOADS consecutive reloads of the same URL, stop and leave the error
|
|
43
|
+
visible instead of thrashing. `sessionStorage` so the count survives the reload it
|
|
44
|
+
triggers (and clears with the tab); absent it (SSR / privacy mode), fall back to a
|
|
45
|
+
single reload. `clearRecoveryReloads` resets the count once that URL mounts cleanly,
|
|
46
|
+
so a later genuine blip gets its reload again. */
|
|
47
|
+
const MAX_RECOVERY_RELOADS = 2
|
|
48
|
+
const reloadCountKey = (url: string): string => `abide:reload:${url}`
|
|
49
|
+
|
|
50
|
+
function boundedReload(url: string): void {
|
|
51
|
+
if (typeof location === 'undefined') {
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
let count = 0
|
|
55
|
+
try {
|
|
56
|
+
count = Number(sessionStorage.getItem(reloadCountKey(url)) ?? '0')
|
|
57
|
+
sessionStorage.setItem(reloadCountKey(url), String(count + 1))
|
|
58
|
+
} catch {
|
|
59
|
+
/* sessionStorage blocked — proceed with an unbounded single reload. */
|
|
60
|
+
}
|
|
61
|
+
if (count >= MAX_RECOVERY_RELOADS) {
|
|
62
|
+
console.error(
|
|
63
|
+
`[abide] gave up reloading ${url} after ${count} attempts — the page keeps failing to load. See the error above.`,
|
|
64
|
+
)
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
location.href = url
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/* A URL mounted cleanly — forget its reload history so a future transient failure there
|
|
71
|
+
is allowed to recover by reloading again. */
|
|
72
|
+
function clearRecoveryReloads(url: string): void {
|
|
73
|
+
try {
|
|
74
|
+
sessionStorage.removeItem(reloadCountKey(url))
|
|
75
|
+
} catch {
|
|
76
|
+
/* nothing persisted — nothing to clear. */
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
39
80
|
/*
|
|
40
81
|
A minimal client router on the History API. `router` matches the current path
|
|
41
82
|
against the route patterns (literal / `[name]` / `[...rest]`, via matchRoute),
|
|
@@ -73,6 +114,11 @@ export function router(
|
|
|
73
114
|
/* The mounted layout chain (outermost first) + the page disposer. */
|
|
74
115
|
const mountedLayouts: MountedLayout[] = []
|
|
75
116
|
let disposePage: (() => void) | undefined
|
|
117
|
+
/* The route key of the currently mounted leaf page — its identity for the
|
|
118
|
+
same-page in-place diff: when a navigation resolves to this same key with an
|
|
119
|
+
unchanged layout chain, only params/url differ, so the page stays mounted and
|
|
120
|
+
updates through the reactive `page` proxy (no teardown). */
|
|
121
|
+
let mountedPageKey: string | undefined
|
|
76
122
|
/* The root outlet boundary in `host` (`#app`) the outermost layer fills — established
|
|
77
123
|
once on the first mount (claimed from the SSR DOM when hydrating, created otherwise)
|
|
78
124
|
and reused across navigations, since `#app` itself never re-mounts. */
|
|
@@ -167,6 +213,7 @@ export function router(
|
|
|
167
213
|
boundary = slot
|
|
168
214
|
}
|
|
169
215
|
if (pageView === undefined) {
|
|
216
|
+
mountedPageKey = undefined
|
|
170
217
|
return
|
|
171
218
|
}
|
|
172
219
|
disposePage = fillBoundary(
|
|
@@ -177,6 +224,7 @@ export function router(
|
|
|
177
224
|
/* The page's route key names its scope in the inspector (see above). */
|
|
178
225
|
pageKey,
|
|
179
226
|
).dispose
|
|
227
|
+
mountedPageKey = pageKey
|
|
180
228
|
}
|
|
181
229
|
if (hydrating) {
|
|
182
230
|
const previous = RENDER.hydration
|
|
@@ -357,9 +405,7 @@ export function router(
|
|
|
357
405
|
/* handle() blocked it / redirected off-origin / the probe failed:
|
|
358
406
|
let the browser load the server's real response. */
|
|
359
407
|
if (decision.kind === 'reload') {
|
|
360
|
-
|
|
361
|
-
location.href = decision.url
|
|
362
|
-
}
|
|
408
|
+
boundedReload(decision.url)
|
|
363
409
|
return
|
|
364
410
|
}
|
|
365
411
|
const layoutViews = resolvedLayouts.filter(
|
|
@@ -377,6 +423,36 @@ export function router(
|
|
|
377
423
|
}
|
|
378
424
|
const hydrating = first && pageView?.hydratable === true
|
|
379
425
|
first = false
|
|
426
|
+
/* Same page, same layout chain — only params/url differ (e.g. stepping
|
|
427
|
+
between episodes on one detail page). The whole structure survives, so
|
|
428
|
+
publish the new snapshot on the reactive `page` proxy and let the mounted
|
|
429
|
+
page + layouts re-derive in place — no teardown, no rebuild, so local
|
|
430
|
+
state, scroll, and DOM are kept (the persistence layouts already get,
|
|
431
|
+
now extended to the leaf). A reader keyed on an *unchanged* param (a page
|
|
432
|
+
whose data is `cache(...)({ id: page.params.id })`) doesn't re-fire at
|
|
433
|
+
all — value-memoised computeds stop the equal id from waking it — so the
|
|
434
|
+
page's blocking await never re-suspends. No view transition: nothing
|
|
435
|
+
structurally swaps. */
|
|
436
|
+
const targetUrl = resolveUrl(path)
|
|
437
|
+
const samePageInPlace =
|
|
438
|
+
pageView !== undefined &&
|
|
439
|
+
key === mountedPageKey &&
|
|
440
|
+
divergence === mountedLayouts.length &&
|
|
441
|
+
divergence === chainKeys.length &&
|
|
442
|
+
/* A differing query is page data — it still rebuilds, matching the
|
|
443
|
+
hash-only fast path's guard. Only path-param changes within the same
|
|
444
|
+
route key (e.g. the episode segment) take the in-place route. */
|
|
445
|
+
clientPage.value.url.search === targetUrl.search
|
|
446
|
+
if (samePageInPlace) {
|
|
447
|
+
clientPage.value = {
|
|
448
|
+
route: chainRoute,
|
|
449
|
+
params,
|
|
450
|
+
url: targetUrl,
|
|
451
|
+
navigating: false,
|
|
452
|
+
}
|
|
453
|
+
historyEntries.restore(targetUrl.hash)
|
|
454
|
+
return
|
|
455
|
+
}
|
|
380
456
|
/* The DOM mutation a navigation makes: tear the divergent chain down
|
|
381
457
|
(clearing its content from its boundary) and rebuild into the same
|
|
382
458
|
boundary (hydration adopts in place). */
|
|
@@ -413,6 +489,23 @@ export function router(
|
|
|
413
489
|
(a fresh load with no persisted offset falls through to hash/top). */
|
|
414
490
|
historyEntries.restore(url.hash)
|
|
415
491
|
}
|
|
492
|
+
/* Build / hydrate is the deterministic surface — a codegen defect or a
|
|
493
|
+
throw in user render code fails the SAME way every load, so reloading
|
|
494
|
+
would loop forever. Catch it HERE (not in the outer `.catch`, which a
|
|
495
|
+
hydrating swap's synchronous throw would otherwise reach): surface the
|
|
496
|
+
error and stop, never reload. A clean mount clears any prior recovery
|
|
497
|
+
reloads for this URL so a later transient failure can recover again. */
|
|
498
|
+
const commit = (): void => {
|
|
499
|
+
try {
|
|
500
|
+
swap()
|
|
501
|
+
clearRecoveryReloads(resolveUrl(path).href)
|
|
502
|
+
} catch (error) {
|
|
503
|
+
console.error(
|
|
504
|
+
`[abide] page at ${path} threw while mounting — not reloading (a reload would re-run the same failure):`,
|
|
505
|
+
error,
|
|
506
|
+
)
|
|
507
|
+
}
|
|
508
|
+
}
|
|
416
509
|
/* Wrap the swap in a View Transition where the browser supports it, so
|
|
417
510
|
the page change cross-fades (and shared `view-transition-name` elements
|
|
418
511
|
morph) — the synchronous swap is exactly the mutation the API snapshots
|
|
@@ -423,23 +516,23 @@ export function router(
|
|
|
423
516
|
typeof document !== 'undefined' &&
|
|
424
517
|
'startViewTransition' in document
|
|
425
518
|
) {
|
|
426
|
-
document.startViewTransition(
|
|
519
|
+
document.startViewTransition(commit)
|
|
427
520
|
} else {
|
|
428
|
-
|
|
521
|
+
commit()
|
|
429
522
|
}
|
|
430
523
|
})
|
|
431
|
-
.catch(() => {
|
|
432
|
-
/* A page/layout chunk
|
|
433
|
-
chunk filename rotated by a deploy, or a transient asset 5xx
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
524
|
+
.catch((error) => {
|
|
525
|
+
/* A page/layout chunk IMPORT (or the probe) rejected — offline, a hashed
|
|
526
|
+
chunk filename rotated by a deploy, or a transient asset 5xx: recoverable,
|
|
527
|
+
so a full browser load is the right fallback (and clears the latched
|
|
528
|
+
navigating:true, so a bound spinner doesn't spin forever). Bounded, so a
|
|
529
|
+
chunk that fails to import every time can't reload-loop. Deterministic
|
|
530
|
+
render throws don't land here — `commit` swallows them above. */
|
|
437
531
|
if (token !== sequence || disposed) {
|
|
438
532
|
return
|
|
439
533
|
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
}
|
|
534
|
+
console.error(`[abide] failed to load page at ${path} — reloading:`, error)
|
|
535
|
+
boundedReload(resolveUrl(path).href)
|
|
443
536
|
})
|
|
444
537
|
})
|
|
445
538
|
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The three settle-states a reactive node moves through, for value-memoised
|
|
3
|
+
(push-pull) propagation. CLEAN: the value is current. CHECK: a *transitive*
|
|
4
|
+
dependency may have changed — the value can't be trusted until the direct deps are
|
|
5
|
+
refreshed. DIRTY: a *direct* dependency changed — recompute.
|
|
6
|
+
|
|
7
|
+
A signal write marks its direct subscribers DIRTY and the rest of their cone
|
|
8
|
+
CHECK; a read then settles a node by refreshing only the deps that the check walk
|
|
9
|
+
finds actually changed. A computed that recomputes to an `Object.is`-equal value
|
|
10
|
+
leaves its subscribers untouched, so an unchanged value never wakes downstream —
|
|
11
|
+
the memoisation a single bare boolean `dirty` flag (no CHECK tier, no value
|
|
12
|
+
compare) couldn't express.
|
|
13
|
+
*/
|
|
14
|
+
/* Typed as `number` (not `as const` literals) so a `node.status === CHECK` guard
|
|
15
|
+
doesn't narrow the field to the literal `1` — the recursive `updateIfNecessary`
|
|
16
|
+
call mutates `status` out of band, which TS can't see, and a literal narrowing
|
|
17
|
+
would make the following `=== DIRTY` check a "no overlap" error. */
|
|
18
|
+
export const NODE_STATE: {
|
|
19
|
+
readonly CLEAN: number
|
|
20
|
+
readonly CHECK: number
|
|
21
|
+
readonly DIRTY: number
|
|
22
|
+
} = { CLEAN: 0, CHECK: 1, DIRTY: 2 }
|