@abide/abide 0.40.0 → 0.40.2

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.
Files changed (40) hide show
  1. package/AGENTS.md +319 -146
  2. package/CHANGELOG.md +48 -0
  3. package/README.md +80 -77
  4. package/package.json +1 -1
  5. package/src/lib/shared/assertExhaustive.ts +14 -0
  6. package/src/lib/ui/compile/TS_PRINTER.ts +7 -0
  7. package/src/lib/ui/compile/analyzeComponent.ts +15 -16
  8. package/src/lib/ui/compile/assertRuntimeHelpersBound.ts +66 -0
  9. package/src/lib/ui/compile/assertTranspiles.ts +19 -0
  10. package/src/lib/ui/compile/compileModule.ts +56 -34
  11. package/src/lib/ui/compile/compileSSR.ts +1 -3
  12. package/src/lib/ui/compile/desugarSignals.ts +168 -107
  13. package/src/lib/ui/compile/generateBuild.ts +8 -1
  14. package/src/lib/ui/compile/generateSSR.ts +7 -0
  15. package/src/lib/ui/compile/hoistCells.ts +2 -2
  16. package/src/lib/ui/compile/lowerContext.ts +23 -10
  17. package/src/lib/ui/compile/lowerDocAccess.ts +29 -3
  18. package/src/lib/ui/compile/lowerScript.ts +64 -0
  19. package/src/lib/ui/compile/renameSignalRefs.ts +160 -102
  20. package/src/lib/ui/compile/stripEffects.ts +22 -17
  21. package/src/lib/ui/compile/types/AnalyzedComponent.ts +5 -0
  22. package/src/lib/ui/dom/awaitBlock.ts +8 -1
  23. package/src/lib/ui/dom/each.ts +4 -0
  24. package/src/lib/ui/dom/switchBlock.ts +4 -0
  25. package/src/lib/ui/dom/when.ts +4 -0
  26. package/src/lib/ui/installInspectorBridge.ts +3 -1
  27. package/src/lib/ui/router.ts +107 -14
  28. package/src/lib/ui/runtime/NODE_STATE.ts +22 -0
  29. package/src/lib/ui/runtime/clientPage.ts +114 -9
  30. package/src/lib/ui/runtime/createComputedNode.ts +5 -3
  31. package/src/lib/ui/runtime/createEffectNode.ts +3 -1
  32. package/src/lib/ui/runtime/createSignalNode.ts +4 -2
  33. package/src/lib/ui/runtime/flushEffects.ts +8 -5
  34. package/src/lib/ui/runtime/historyEntries.ts +39 -1
  35. package/src/lib/ui/runtime/readNode.ts +8 -7
  36. package/src/lib/ui/runtime/runNode.ts +18 -2
  37. package/src/lib/ui/runtime/scope.ts +12 -1
  38. package/src/lib/ui/runtime/trigger.ts +40 -24
  39. package/src/lib/ui/runtime/types/ReactiveNode.ts +4 -1
  40. 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
- /* Identifier nodes that are names, not value reads — never rewritten. */
33
- const skip = new Set<ts.Node>()
34
- const collect = (node: ts.Node): void => {
35
- if (ts.isPropertyAccessExpression(node)) {
36
- skip.add(node.name)
37
- }
38
- /* A declaration name is a binding, not a value read. A plain identifier is
39
- skipped directly; a destructuring pattern's leaf names are collected so a
40
- bound name shadowing a signal (`const { count } = …`) is never rewritten. */
41
- if (ts.isVariableDeclaration(node)) {
42
- if (ts.isIdentifier(node.name)) {
43
- skip.add(node.name)
44
- } else {
45
- collectBindingIdentifiers(node.name, skip)
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
- if (ts.isParameter(node) && ts.isIdentifier(node.name)) {
49
- skip.add(node.name)
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
- if (ts.isPropertyAssignment(node) && ts.isIdentifier(node.name)) {
52
- skip.add(node.name)
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
- (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node)) &&
56
- node.name !== undefined
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
- skip.add(node.name)
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.forEachChild(node, collect)
192
+ if (ts.isImportClause(node) || ts.isNamespaceImport(node) || ts.isNamespaceExport(node)) {
193
+ add(node.name)
194
+ }
195
+ ts.forEachChild(node, visit)
61
196
  }
62
- collect(source)
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
- (context) => (root) => {
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
@@ -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. */
@@ -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
- params: page.params,
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,
@@ -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
- if (typeof location !== 'undefined') {
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(swap)
519
+ document.startViewTransition(commit)
427
520
  } else {
428
- swap()
521
+ commit()
429
522
  }
430
523
  })
431
- .catch(() => {
432
- /* A page/layout chunk import (or the probe) rejected — offline, a hashed
433
- chunk filename rotated by a deploy, or a transient asset 5xx. Without
434
- this the navigating:true latched above never clears (a bound spinner
435
- spins forever) and the rejection surfaces as an unhandledrejection.
436
- Fall back to a full browser load so the server serves the target. */
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
- if (typeof location !== 'undefined') {
441
- location.href = resolveUrl(path).href
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 }