@abide/abide 0.39.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.
Files changed (71) hide show
  1. package/AGENTS.md +318 -146
  2. package/CHANGELOG.md +89 -0
  3. package/README.md +77 -79
  4. package/package.json +1 -1
  5. package/src/lib/server/rpc/parseArgs.ts +10 -1
  6. package/src/lib/server/rpc/runWithVerbTimeout.ts +7 -9
  7. package/src/lib/server/rpc/types/VerbHelper.ts +18 -21
  8. package/src/lib/server/runtime/SSR_SWAP_SCRIPT.ts +8 -7
  9. package/src/lib/server/sockets/createSocketDispatcher.ts +12 -3
  10. package/src/lib/server/sockets/defineSocket.ts +3 -1
  11. package/src/lib/shared/REF_JSON_HEADER.ts +9 -0
  12. package/src/lib/shared/REF_JSON_TAGS.ts +31 -0
  13. package/src/lib/shared/assertExhaustive.ts +14 -0
  14. package/src/lib/shared/buildRpcRequest.ts +8 -1
  15. package/src/lib/shared/decodeRefJson.ts +110 -0
  16. package/src/lib/shared/encodeRefJson.ts +106 -0
  17. package/src/lib/test/createTestSocketChannel.ts +6 -2
  18. package/src/lib/ui/compile/TS_PRINTER.ts +7 -0
  19. package/src/lib/ui/compile/analyzeComponent.ts +15 -16
  20. package/src/lib/ui/compile/assertRuntimeHelpersBound.ts +66 -0
  21. package/src/lib/ui/compile/assertTranspiles.ts +19 -0
  22. package/src/lib/ui/compile/compileModule.ts +56 -34
  23. package/src/lib/ui/compile/compileSSR.ts +1 -3
  24. package/src/lib/ui/compile/compileShadow.ts +14 -7
  25. package/src/lib/ui/compile/desugarSignals.ts +168 -107
  26. package/src/lib/ui/compile/generateBuild.ts +49 -59
  27. package/src/lib/ui/compile/generateSSR.ts +30 -21
  28. package/src/lib/ui/compile/hoistCells.ts +2 -2
  29. package/src/lib/ui/compile/isWhitespaceText.ts +11 -0
  30. package/src/lib/ui/compile/lowerContext.ts +23 -10
  31. package/src/lib/ui/compile/lowerDocAccess.ts +29 -3
  32. package/src/lib/ui/compile/lowerScript.ts +64 -0
  33. package/src/lib/ui/compile/parseTemplate.ts +74 -0
  34. package/src/lib/ui/compile/renameSignalRefs.ts +160 -90
  35. package/src/lib/ui/compile/resolveBranches.ts +21 -0
  36. package/src/lib/ui/compile/stripEffects.ts +22 -17
  37. package/src/lib/ui/compile/types/AnalyzedComponent.ts +5 -0
  38. package/src/lib/ui/compile/types/TemplateNode.ts +12 -2
  39. package/src/lib/ui/createScope.ts +14 -0
  40. package/src/lib/ui/dom/appendText.ts +2 -3
  41. package/src/lib/ui/dom/appendTextAt.ts +2 -3
  42. package/src/lib/ui/dom/applyResolved.ts +5 -8
  43. package/src/lib/ui/dom/awaitBlock.ts +22 -2
  44. package/src/lib/ui/dom/each.ts +4 -0
  45. package/src/lib/ui/dom/on.ts +7 -0
  46. package/src/lib/ui/dom/parseRawNodes.ts +17 -0
  47. package/src/lib/ui/dom/switchBlock.ts +4 -0
  48. package/src/lib/ui/dom/when.ts +4 -0
  49. package/src/lib/ui/installInspectorBridge.ts +3 -1
  50. package/src/lib/ui/navigate.ts +28 -3
  51. package/src/lib/ui/renderToStream.ts +17 -9
  52. package/src/lib/ui/resumeSeedScript.ts +16 -6
  53. package/src/lib/ui/router.ts +108 -15
  54. package/src/lib/ui/runtime/NODE_STATE.ts +22 -0
  55. package/src/lib/ui/runtime/RESUME.ts +13 -6
  56. package/src/lib/ui/runtime/clientPage.ts +114 -9
  57. package/src/lib/ui/runtime/createComputedNode.ts +5 -3
  58. package/src/lib/ui/runtime/createEffectNode.ts +3 -1
  59. package/src/lib/ui/runtime/createSignalNode.ts +4 -2
  60. package/src/lib/ui/runtime/flushEffects.ts +8 -5
  61. package/src/lib/ui/runtime/historyEntries.ts +39 -1
  62. package/src/lib/ui/runtime/localStoragePersistence.ts +14 -8
  63. package/src/lib/ui/runtime/readNode.ts +8 -7
  64. package/src/lib/ui/runtime/runNode.ts +18 -2
  65. package/src/lib/ui/runtime/scope.ts +12 -1
  66. package/src/lib/ui/runtime/trigger.ts +40 -24
  67. package/src/lib/ui/runtime/types/ReactiveNode.ts +4 -1
  68. package/src/lib/ui/runtime/updateIfNecessary.ts +40 -0
  69. package/src/lib/ui/socketChannel.ts +5 -2
  70. package/src/lib/ui/tryEncodeResume.ts +20 -0
  71. package/src/lib/ui/types/Scope.ts +9 -3
@@ -1,6 +1,13 @@
1
1
  import { historyEntries } from './runtime/historyEntries.ts'
2
2
  import { runtimePath } from './runtime/runtimePath.ts'
3
3
 
4
+ /* Options for `navigate`. `replace` swaps the current history entry instead of pushing.
5
+ `keepScroll` carries the live scroll offset onto the destination so it isn't reset. */
6
+ export type NavigateOptions = {
7
+ replace?: boolean
8
+ keepScroll?: boolean
9
+ }
10
+
4
11
  /* Navigates to `path`: writes a history entry (when available) and updates the
5
12
  reactive route, which re-mounts the matching page via `router`. `replace` swaps
6
13
  the current entry instead of pushing — used when honouring a server redirect, so
@@ -9,16 +16,34 @@ import { runtimePath } from './runtime/runtimePath.ts'
9
16
  teardown the rebuild does. A push leaves the current entry behind — its scroll is
10
17
  bucketed so back restores it — and mints a fresh id. A replace destroys the current
11
18
  entry and lands fresh content (a redirect), so its saved scroll no longer applies:
12
- the bucket is discarded and the id kept, so the new page restores to top/anchor. */
19
+ the bucket is discarded and the id kept, so the new page restores to top/anchor.
20
+ `keepScroll` opts the destination out of that reset — for an in-page URL swap (e.g.
21
+ selecting another episode on the same detail page) where a top jump is jarring: the
22
+ live offset is bucketed under the destination entry id, so the post-rebuild `restore`
23
+ reapplies it instead of scrolling to top. */
13
24
  // @documentation navigate
14
- export function navigate(path: string, replace = false): void {
25
+ export function navigate(
26
+ path: string,
27
+ { replace = false, keepScroll = false }: NavigateOptions = {},
28
+ ): void {
15
29
  if (typeof history !== 'undefined') {
16
30
  if (replace) {
17
- historyEntries.discard()
31
+ /* keepScroll buckets the live offset under the (unchanged) entry id so
32
+ restore reapplies it; otherwise the superseded content drops its bucket. */
33
+ if (keepScroll) {
34
+ historyEntries.save()
35
+ } else {
36
+ historyEntries.discard()
37
+ }
18
38
  history.replaceState({ abideEntry: historyEntries.current }, '', path)
19
39
  } else {
20
40
  historyEntries.save()
21
41
  history.pushState({ abideEntry: historyEntries.next() }, '', path)
42
+ /* Re-bucket the same offset under the freshly minted id so the pushed entry
43
+ restores to it rather than to top. */
44
+ if (keepScroll) {
45
+ historyEntries.save()
46
+ }
22
47
  }
23
48
  }
24
49
  runtimePath.value = path
@@ -1,6 +1,7 @@
1
1
  import { resumeSeedScript } from './resumeSeedScript.ts'
2
2
  import type { ResumeEntry } from './runtime/RESUME.ts'
3
3
  import type { SsrAwait, SsrRender } from './runtime/types/SsrRender.ts'
4
+ import { tryEncodeResume } from './tryEncodeResume.ts'
4
5
 
5
6
  /*
6
7
  Out-of-order SSR streaming. Yields the shell first (so the browser paints
@@ -69,10 +70,15 @@ export async function* renderToStream(
69
70
  const resolved = await Promise.race(inflight.values())
70
71
  inflight.delete(resolved.id)
71
72
  enqueueNew()
72
- const encoded = encodeResume(resolved.resume)
73
+ /* An unserializable value (e.g. a cyclic media tree) streams its rendered HTML
74
+ with NO seed script: both swap consumers (SSR_SWAP_SCRIPT, applyResolved) skip
75
+ registration when the leading child isn't a parseable script, so hydration
76
+ re-runs this one branch's promise — degrading to a refetch instead of aborting
77
+ the whole stream. */
78
+ const encoded = encodeStreamResume(resolved.resume, resolved.id)
73
79
  yield resumeSeedScript(resumeDelta()) +
74
80
  `<abide-resolve data-id="${resolved.id}">` +
75
- `<script type="application/json">${encoded}</script>` +
81
+ (encoded === undefined ? '' : `<script type="application/json">${encoded}</script>`) +
76
82
  `${resolved.html}</abide-resolve>`
77
83
  }
78
84
  }
@@ -105,11 +111,13 @@ function settle(block: SsrAwait): Promise<Settled> {
105
111
  )
106
112
  }
107
113
 
108
- /* JSON for a `<script type="application/json">` data block: script content is raw
109
- text, so only `<` needs neutralizing (emitted as a unicode escape) to keep a
110
- literal `</script>` from closing the block early — quotes stay raw. Far cheaper
111
- than attribute escaping (no full-string `"`/`&` passes) and JSON.parse decodes it
112
- back. `applyResolved`/the inline swap script read it via `.textContent`. */
113
- function encodeResume(resume: ResumeEntry): string {
114
- return JSON.stringify(resume).replace(/</g, '\\u003c')
114
+ /* ref-json for a `<script type="application/json">` data block: script content is
115
+ raw text, so only `<` needs neutralizing (emitted as a unicode escape) to keep a
116
+ literal `</script>` from closing the block early — quotes stay raw, and the escape
117
+ survives `decodeRefJson`'s inner JSON.parse since `<` only ever appears inside JSON
118
+ strings. `tryEncodeResume` handles the serialize-or-refetch policy (undefined no
119
+ script the swap consumers skip registration → hydration re-runs that one promise).
120
+ `applyResolved`/the inline swap script store it via `.textContent`; `awaitBlock` decodes it. */
121
+ function encodeStreamResume(resume: ResumeEntry, id: number): string | undefined {
122
+ return tryEncodeResume(resume, id)?.replace(/</g, '\\u003c')
115
123
  }
@@ -1,17 +1,27 @@
1
1
  import { safeJsonForScript } from '../shared/safeJsonForScript.ts'
2
2
  import type { ResumeEntry } from './runtime/RESUME.ts'
3
+ import { tryEncodeResume } from './tryEncodeResume.ts'
3
4
 
4
5
  /* A self-contained `<script>` seeding the await-resume manifest with the blocking
5
6
  values rendered inline on the server, so client hydration adopts each resolved
6
7
  branch instead of re-running its promise. Empty when nothing blocking resolved.
7
- The payload runs as JS (`Object.assign`), so it's encoded via `safeJsonForScript`
8
- escaping `<`, `-->`, and U+2028/U+2029 so a serialized body value can't close
9
- the script early or parse as a line terminator. Shared by the buffered
10
- (`createUiPageRenderer`) and streaming (`renderToStream`) paths. */
8
+ Each entry is ref-json-encoded to a string (decoded at read in `awaitBlock`); the
9
+ id→string map runs as JS (`Object.assign`), so it's wrapped in `safeJsonForScript`
10
+ escaping `<`, `-->`, and U+2028/U+2029 so an encoded value can't close the script
11
+ early or parse as a line terminator. Shared by the buffered (`createUiPageRenderer`)
12
+ and streaming (`renderToStream`) paths. */
11
13
  // @documentation plumbing
12
14
  export function resumeSeedScript(resume: Record<number, ResumeEntry>): string {
13
- if (Object.keys(resume).length === 0) {
15
+ /* ref-json (not JSON) so a value carrying cycles or shared back-references — a
16
+ media tree with parent↔child links — seeds instead of being dropped. `tryEncodeResume`
17
+ drops just an unserializable entry (the client re-runs that one branch's promise),
18
+ keeping every other branch seeded rather than blanking the whole page. */
19
+ const encoded = Object.entries(resume).flatMap(([id, entry]) => {
20
+ const text = tryEncodeResume(entry, id)
21
+ return text === undefined ? [] : [[id, text] as const]
22
+ })
23
+ if (encoded.length === 0) {
14
24
  return ''
15
25
  }
16
- return `<script>Object.assign(window.__abideResume=window.__abideResume||{},${safeJsonForScript(resume)})</script>`
26
+ return `<script>Object.assign(window.__abideResume=window.__abideResume||{},${safeJsonForScript(Object.fromEntries(encoded))})</script>`
17
27
  }
@@ -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
@@ -351,15 +399,13 @@ export function router(
351
399
  /* handle() redirected: go where it pointed, replacing the blocked
352
400
  URL so back doesn't trap on it. The router re-probes the target. */
353
401
  if (decision.kind === 'redirect') {
354
- navigate(decision.path, true)
402
+ navigate(decision.path, { replace: true })
355
403
  return
356
404
  }
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 }
@@ -1,16 +1,23 @@
1
1
  /* The await-resume manifest: the resolved value (or error) of each streamed
2
- `await` block, keyed by its boundary id. The SSR stream serializes each value
2
+ `await` block, keyed by its boundary id. The SSR stream serializes each entry
3
3
  alongside its fragment; the client registers it (via `applyResolved` or the
4
4
  inline swap script), and hydration reads it so an `await` block adopts the
5
5
  resolved branch with the real value instead of re-running the promise.
6
6
 
7
- Backed by `globalThis.__abideResume` so the inline stream-swap script (vanilla,
8
- running during the stream before the bundle loads) and the framework share one
9
- store: whoever runs first creates it, the other adopts the same reference. */
7
+ Each value is the ref-json-encoded ResumeEntry STRING, not the decoded object:
8
+ the entry is encoded with the ref-json codec (so a resolved value carrying cycles
9
+ or shared back-references survives, where JSON would drop it) and decoded lazily
10
+ at the read site in `awaitBlock`. Storing the raw string keeps the inline
11
+ stream-swap script — vanilla, running before the bundle and the codec load — able
12
+ to register an entry without the decoder.
13
+
14
+ Backed by `globalThis.__abideResume` so the inline stream-swap script and the
15
+ framework share one store: whoever runs first creates it, the other adopts the
16
+ same reference. */
10
17
  export type ResumeEntry = { ok: true; value: unknown } | { ok: false; error: unknown }
11
18
 
12
- const globalScope = globalThis as { __abideResume?: Record<number, ResumeEntry> }
19
+ const globalScope = globalThis as { __abideResume?: Record<number, string> }
13
20
  globalScope.__abideResume ??= {}
14
21
 
15
22
  // @documentation plumbing
16
- export const RESUME: Record<number, ResumeEntry> = globalScope.__abideResume
23
+ export const RESUME: Record<number, string> = globalScope.__abideResume
@@ -1,16 +1,121 @@
1
1
  import type { PageSnapshot } from '../../shared/types/PageSnapshot.ts'
2
2
  import { state } from '../state.ts'
3
+ import { flushEffects } from './flushEffects.ts'
4
+ import { REACTIVE_CONTEXT } from './REACTIVE_CONTEXT.ts'
5
+ import type { State } from './types/State.ts'
3
6
 
4
7
  /*
5
8
  The client-side page snapshot the `page` proxy reads (startClient registers
6
- `() => clientPage.value` as the page resolver). It's a abide-ui signal, so a
7
- component reading page.url/params/route inside an effect re-runs when the router
8
- updates it on navigation. Server renders never touch this — there the resolver
9
- reads the per-request store instead.
9
+ `() => clientPage.value` as the page resolver). Server renders never touch this
10
+ there the resolver reads the per-request store instead.
11
+
12
+ GRANULAR by field: rather than one signal holding the whole snapshot (which woke
13
+ every `page.*` reader on any navigation), each field is its own cell and each param
14
+ key its own lazily-created cell. So a reader of `page.params.id` subscribes to the id
15
+ cell alone and is NOT woken when `page.params.rest` changes (stepping between episodes
16
+ on one detail page) — no manual `computed` memo needed at the call site.
17
+
18
+ The `.value` get/set API is unchanged, so the router and tests still read
19
+ `clientPage.value.url` and write `clientPage.value = {…}`: the getter returns a STABLE
20
+ snapshot whose field accessors do the granular reads (returning a stable object is what
21
+ stops `clientPage.value` itself from subscribing to everything — a subscription happens
22
+ only when a field is read), and the setter reconciles each field cell (an Object.is-equal
23
+ write is a no-op, so an unchanged id never fires).
10
24
  */
11
- export const clientPage = state<PageSnapshot>({
12
- route: '',
13
- params: {},
14
- url: typeof location === 'undefined' ? new URL('http://localhost/') : new URL(location.href),
15
- navigating: false,
25
+
26
+ const routeCell = state<string>('')
27
+ const navigatingCell = state<boolean>(false)
28
+ const urlCell = state<URL>(
29
+ typeof location === 'undefined' ? new URL('http://localhost/') : new URL(location.href),
30
+ )
31
+
32
+ /* Per-param-key cells, created on first read/write of a key. A page that reads
33
+ `page.params.id` mints the id cell and subscribes to it alone. Keys persist across
34
+ navigations (the Map is bounded by the app's param vocabulary); a route that drops a
35
+ key sets its cell to undefined so its readers wake to the absence. */
36
+ const paramCells = new Map<string, State<string | undefined>>()
37
+ function paramCell(key: string): State<string | undefined> {
38
+ let cell = paramCells.get(key)
39
+ if (cell === undefined) {
40
+ cell = state<string>()
41
+ paramCells.set(key, cell)
42
+ }
43
+ return cell
44
+ }
45
+
46
+ /* The surface `page.params` exposes: each key access is a granular cell read, so it
47
+ tracks per-key like the underlying object would, but reactively. */
48
+ const paramsProxy = new Proxy({} as Record<string, string>, {
49
+ get: (_target, key) => (typeof key === 'string' ? paramCell(key).value : undefined),
50
+ has: (_target, key) => typeof key === 'string' && paramCell(key).value !== undefined,
51
+ ownKeys: () =>
52
+ [...paramCells].filter(([, cell]) => cell.value !== undefined).map(([key]) => key),
53
+ getOwnPropertyDescriptor: (_target, key) => {
54
+ if (typeof key !== 'string') {
55
+ return undefined
56
+ }
57
+ const value = paramCell(key).value
58
+ if (value === undefined) {
59
+ return undefined
60
+ }
61
+ return { enumerable: true, configurable: true, value }
62
+ },
16
63
  })
64
+
65
+ /* The stable snapshot `clientPage.value` returns — reading a field does the granular
66
+ cell read, so a reader subscribes to that field alone. */
67
+ const snapshot: PageSnapshot = {
68
+ get route(): string {
69
+ return routeCell.value
70
+ },
71
+ get params(): Record<string, string> {
72
+ return paramsProxy
73
+ },
74
+ get url(): URL {
75
+ return urlCell.value
76
+ },
77
+ get navigating(): boolean {
78
+ return navigatingCell.value
79
+ },
80
+ }
81
+
82
+ /* Reconcile the param cells to `next`: write each key (Object.is in the cell skips a
83
+ no-op, so an unchanged id stays asleep), clear any key the new route dropped. The
84
+ spread-and-rewrite paths (`{ ...clientPage.value, navigating }`) hand our own proxy
85
+ straight back — a no-op, recognised by identity. */
86
+ function reconcileParams(next: Record<string, string>): void {
87
+ if (next === paramsProxy) {
88
+ return
89
+ }
90
+ for (const key of Object.keys(next)) {
91
+ paramCell(key).value = next[key]
92
+ }
93
+ for (const [key, cell] of paramCells) {
94
+ if (!(key in next)) {
95
+ cell.value = undefined
96
+ }
97
+ }
98
+ }
99
+
100
+ export const clientPage: { value: PageSnapshot } = {
101
+ get value(): PageSnapshot {
102
+ return snapshot
103
+ },
104
+ set value(next: PageSnapshot) {
105
+ /* Batch the field writes so a navigation publishes the whole snapshot atomically:
106
+ without it each cell write flushes effects separately, so a reader of two fields
107
+ (e.g. `page.url` + `page.params.id`) re-runs once per field and transiently
108
+ observes a half-updated snapshot (new url, stale id). Same batch idiom as
109
+ `createDoc` — flush once, after every cell is reconciled. */
110
+ REACTIVE_CONTEXT.batchDepth += 1
111
+ try {
112
+ routeCell.value = next.route
113
+ urlCell.value = next.url
114
+ navigatingCell.value = next.navigating
115
+ reconcileParams(next.params)
116
+ } finally {
117
+ REACTIVE_CONTEXT.batchDepth -= 1
118
+ }
119
+ flushEffects()
120
+ },
121
+ }
@@ -1,7 +1,9 @@
1
+ import { NODE_STATE } from './NODE_STATE.ts'
1
2
  import type { ReactiveNode } from './types/ReactiveNode.ts'
2
3
 
3
- /* Creates a lazy computed node. Born dirty so its first read computes; thereafter
4
- it recomputes only when a dependency triggers it. */
4
+ /* Creates a lazy computed node. Born DIRTY so its first read computes; thereafter a
5
+ read settles it recomputing only when the check walk finds a dependency whose
6
+ value actually changed. */
5
7
  export function createComputedNode(compute: () => unknown): ReactiveNode {
6
8
  return {
7
9
  value: undefined,
@@ -10,7 +12,7 @@ export function createComputedNode(compute: () => unknown): ReactiveNode {
10
12
  depsTail: undefined,
11
13
  subsHead: undefined,
12
14
  subsTail: undefined,
13
- dirty: true,
15
+ status: NODE_STATE.DIRTY,
14
16
  isEffect: false,
15
17
  }
16
18
  }
@@ -1,3 +1,4 @@
1
+ import { NODE_STATE } from './NODE_STATE.ts'
1
2
  import { OWNER } from './OWNER.ts'
2
3
  import { REACTIVE_CONTEXT } from './REACTIVE_CONTEXT.ts'
3
4
  import { runNode } from './runNode.ts'
@@ -42,7 +43,8 @@ export function createEffectNode(fn: () => EffectResult): () => void {
42
43
  depsTail: undefined,
43
44
  subsHead: undefined,
44
45
  subsTail: undefined,
45
- dirty: false,
46
+ /* Born DIRTY; the immediate `runNode` below captures deps and settles it CLEAN. */
47
+ status: NODE_STATE.DIRTY,
46
48
  isEffect: true,
47
49
  }
48
50
  runNode(node)
@@ -1,7 +1,9 @@
1
+ import { NODE_STATE } from './NODE_STATE.ts'
1
2
  import type { ReactiveNode } from './types/ReactiveNode.ts'
2
3
 
3
4
  /* Creates a writable leaf node holding `value` with no compute — the source a
4
- document path or a `state()` cell is backed by. */
5
+ document path or a `state()` cell is backed by. Always CLEAN: a signal has no
6
+ dependencies to settle; its value is whatever was last written. */
5
7
  export function createSignalNode(value: unknown): ReactiveNode {
6
8
  return {
7
9
  value,
@@ -10,7 +12,7 @@ export function createSignalNode(value: unknown): ReactiveNode {
10
12
  depsTail: undefined,
11
13
  subsHead: undefined,
12
14
  subsTail: undefined,
13
- dirty: false,
15
+ status: NODE_STATE.CLEAN,
14
16
  isEffect: false,
15
17
  }
16
18
  }
@@ -1,17 +1,20 @@
1
1
  import { REACTIVE_CONTEXT } from './REACTIVE_CONTEXT.ts'
2
- import { runNode } from './runNode.ts'
2
+ import { updateIfNecessary } from './updateIfNecessary.ts'
3
3
 
4
4
  /*
5
- Drains queued effects synchronously. Snapshots and clears the queue each pass so
6
- an effect that dirties further effects re-queues them for the next pass rather
7
- than mutating the set mid-iteration; loops until the graph settles.
5
+ Drains queued effects synchronously. Each is queued when it first leaves CLEAN, but
6
+ runs only if `updateIfNecessary` finds a dependency that truly changed a CHECK
7
+ effect whose deps all memoised back to equal values settles to CLEAN without
8
+ running its body. Snapshots and clears the queue each pass so an effect that dirties
9
+ further effects re-queues them for the next pass rather than mutating the set
10
+ mid-iteration; loops until the graph settles.
8
11
  */
9
12
  export function flushEffects(): void {
10
13
  while (REACTIVE_CONTEXT.pendingEffects.size > 0) {
11
14
  const batch = [...REACTIVE_CONTEXT.pendingEffects]
12
15
  REACTIVE_CONTEXT.pendingEffects.clear()
13
16
  for (const node of batch) {
14
- runNode(node)
17
+ updateIfNecessary(node)
15
18
  }
16
19
  }
17
20
  }
@@ -51,6 +51,39 @@ const offsets = new Map<number, [number, number]>()
51
51
  let current = 0
52
52
  let seq = 0
53
53
 
54
+ /* Generation token for an in-flight `restore` retry chain (below); a newer restore
55
+ bumps it so a stale chain — from a superseded navigation — stops re-applying. */
56
+ let restoreToken = 0
57
+ /* Frame budget for re-applying a saved offset while the page is still filling in.
58
+ ~half a second at 60fps — long enough for an async page's content to settle, short
59
+ enough not to fight a user who starts scrolling. */
60
+ const MAX_RESTORE_FRAMES = 30
61
+
62
+ /* Re-apply a saved offset until it sticks. A restore can land before the page's async
63
+ content has materialised — `disposeFrom` empties the document, `buildFrom` mounts a
64
+ page whose blocking `<template await>` is still pending, so the document is momentarily
65
+ short and the browser clamps the requested offset to its tiny max (an in-page episode
66
+ swap reset to the top this way). Each frame re-applies; once `scrollTo` is no longer
67
+ clamped (the content has grown tall enough to honour the offset) the chain stops, so
68
+ the common case — a page already tall — applies exactly once and schedules no frame. */
69
+ function reapplyOffset(offset: [number, number], token: number): void {
70
+ const apply = (framesLeft: number): void => {
71
+ // A newer restore superseded this chain, or the scroll surface vanished.
72
+ if (token !== restoreToken || typeof view.scrollTo !== 'function') {
73
+ return
74
+ }
75
+ view.scrollTo(offset[0], offset[1])
76
+ // The browser clamps to the current max; an honoured offset means the page is
77
+ // tall enough now — stop. Otherwise retry next frame as the content fills in.
78
+ const reached = (view.scrollY ?? 0) >= offset[1] && (view.scrollX ?? 0) >= offset[0]
79
+ if (reached || framesLeft <= 0 || typeof view.requestAnimationFrame !== 'function') {
80
+ return
81
+ }
82
+ view.requestAnimationFrame(() => apply(framesLeft - 1))
83
+ }
84
+ apply(MAX_RESTORE_FRAMES)
85
+ }
86
+
54
87
  export const historyEntries = {
55
88
  /* The active history entry's id — stamped into history.state by `navigate`. */
56
89
  get current(): number {
@@ -98,9 +131,14 @@ export const historyEntries = {
98
131
  if (typeof view.scrollTo !== 'function') {
99
132
  return
100
133
  }
134
+ /* Any restore supersedes a pending retry chain — including this top/anchor path,
135
+ so a later navigation to a fresh page can't have an earlier keepScroll swap's
136
+ re-apply fire its stale offset over it. */
137
+ restoreToken += 1
138
+ const token = restoreToken
101
139
  const offset = offsets.get(current) ?? persistedOffset()
102
140
  if (offset !== undefined) {
103
- view.scrollTo(offset[0], offset[1])
141
+ reapplyOffset(offset, token)
104
142
  return
105
143
  }
106
144
  const anchor = anchorFor(hash)