@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.
- package/AGENTS.md +318 -146
- package/CHANGELOG.md +89 -0
- package/README.md +77 -79
- package/package.json +1 -1
- package/src/lib/server/rpc/parseArgs.ts +10 -1
- package/src/lib/server/rpc/runWithVerbTimeout.ts +7 -9
- package/src/lib/server/rpc/types/VerbHelper.ts +18 -21
- package/src/lib/server/runtime/SSR_SWAP_SCRIPT.ts +8 -7
- package/src/lib/server/sockets/createSocketDispatcher.ts +12 -3
- package/src/lib/server/sockets/defineSocket.ts +3 -1
- package/src/lib/shared/REF_JSON_HEADER.ts +9 -0
- package/src/lib/shared/REF_JSON_TAGS.ts +31 -0
- package/src/lib/shared/assertExhaustive.ts +14 -0
- package/src/lib/shared/buildRpcRequest.ts +8 -1
- package/src/lib/shared/decodeRefJson.ts +110 -0
- package/src/lib/shared/encodeRefJson.ts +106 -0
- package/src/lib/test/createTestSocketChannel.ts +6 -2
- 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/compileShadow.ts +14 -7
- package/src/lib/ui/compile/desugarSignals.ts +168 -107
- package/src/lib/ui/compile/generateBuild.ts +49 -59
- package/src/lib/ui/compile/generateSSR.ts +30 -21
- package/src/lib/ui/compile/hoistCells.ts +2 -2
- package/src/lib/ui/compile/isWhitespaceText.ts +11 -0
- 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/parseTemplate.ts +74 -0
- package/src/lib/ui/compile/renameSignalRefs.ts +160 -90
- package/src/lib/ui/compile/resolveBranches.ts +21 -0
- package/src/lib/ui/compile/stripEffects.ts +22 -17
- package/src/lib/ui/compile/types/AnalyzedComponent.ts +5 -0
- package/src/lib/ui/compile/types/TemplateNode.ts +12 -2
- package/src/lib/ui/createScope.ts +14 -0
- package/src/lib/ui/dom/appendText.ts +2 -3
- package/src/lib/ui/dom/appendTextAt.ts +2 -3
- package/src/lib/ui/dom/applyResolved.ts +5 -8
- package/src/lib/ui/dom/awaitBlock.ts +22 -2
- package/src/lib/ui/dom/each.ts +4 -0
- package/src/lib/ui/dom/on.ts +7 -0
- package/src/lib/ui/dom/parseRawNodes.ts +17 -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/navigate.ts +28 -3
- package/src/lib/ui/renderToStream.ts +17 -9
- package/src/lib/ui/resumeSeedScript.ts +16 -6
- package/src/lib/ui/router.ts +108 -15
- package/src/lib/ui/runtime/NODE_STATE.ts +22 -0
- package/src/lib/ui/runtime/RESUME.ts +13 -6
- 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/localStoragePersistence.ts +14 -8
- 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
- package/src/lib/ui/socketChannel.ts +5 -2
- package/src/lib/ui/tryEncodeResume.ts +20 -0
- package/src/lib/ui/types/Scope.ts +9 -3
package/src/lib/ui/navigate.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/*
|
|
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
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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(
|
|
26
|
+
return `<script>Object.assign(window.__abideResume=window.__abideResume||{},${safeJsonForScript(Object.fromEntries(encoded))})</script>`
|
|
17
27
|
}
|
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
|
|
@@ -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
|
-
|
|
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 }
|
|
@@ -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
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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,
|
|
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,
|
|
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).
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
4
|
-
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
2
|
+
import { updateIfNecessary } from './updateIfNecessary.ts'
|
|
3
3
|
|
|
4
4
|
/*
|
|
5
|
-
Drains queued effects synchronously.
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
141
|
+
reapplyOffset(offset, token)
|
|
104
142
|
return
|
|
105
143
|
}
|
|
106
144
|
const anchor = anchorFor(hash)
|