@abide/abide 0.34.2 → 0.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +196 -215
- package/CHANGELOG.md +46 -0
- package/README.md +75 -69
- package/package.json +2 -1
- package/src/abideResolverPlugin.ts +53 -9
- package/src/lib/server/runtime/STREAMED_HTML_HEADER.ts +13 -0
- package/src/lib/server/runtime/buildPreloadManifest.ts +151 -0
- package/src/lib/server/runtime/createServer.ts +4 -1
- package/src/lib/server/runtime/createUiPageRenderer.ts +104 -9
- package/src/lib/server/runtime/flushingGzipStream.ts +62 -0
- package/src/lib/server/runtime/gzipResponse.ts +23 -11
- package/src/lib/server/runtime/serializeCacheSnapshot.ts +22 -33
- package/src/lib/shared/cache.ts +10 -3
- package/src/lib/shared/cacheManagedSlot.ts +10 -0
- package/src/lib/shared/withCacheManaged.ts +18 -0
- package/src/lib/ui/README.md +1 -1
- package/src/lib/ui/compile/REACTIVE_CALLEES.ts +6 -6
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
- package/src/lib/ui/compile/compileShadow.ts +60 -55
- package/src/lib/ui/compile/desugarSignals.ts +93 -38
- package/src/lib/ui/compile/lowerDocAccess.ts +26 -11
- package/src/lib/ui/compile/parseTemplate.ts +8 -5
- package/src/lib/ui/compile/prepareNestedScript.ts +2 -2
- package/src/lib/ui/compile/renameSignalRefs.ts +153 -23
- package/src/lib/ui/compile/types/TemplateAttr.ts +4 -2
- package/src/lib/ui/dom/applyResolved.ts +27 -7
- package/src/lib/ui/dom/awaitBlock.ts +7 -3
- package/src/lib/ui/dom/each.ts +8 -15
- package/src/lib/ui/dom/eachAsync.ts +8 -9
- package/src/lib/ui/dom/switchBlock.ts +7 -3
- package/src/lib/ui/dom/tryBlock.ts +16 -5
- package/src/lib/ui/dom/when.ts +7 -3
- package/src/lib/ui/installHotBridge.ts +2 -0
- package/src/lib/ui/remoteProxy.ts +32 -6
- package/src/lib/ui/router.ts +26 -10
- package/src/lib/ui/runtime/REQUEST_SUPERSEDED.ts +8 -0
- package/src/lib/ui/runtime/abortNode.ts +22 -0
- package/src/lib/ui/runtime/currentAbortSignal.ts +26 -0
- package/src/lib/ui/runtime/escapeKey.ts +10 -4
- package/src/lib/ui/runtime/reactiveAbortState.ts +15 -0
- package/src/lib/ui/runtime/runNode.ts +9 -0
- package/src/lib/ui/runtime/scopeGroup.ts +40 -0
- package/src/lib/ui/runtime/unlinkDeps.ts +8 -0
- package/src/lib/ui/seedStreamedResolution.ts +22 -0
- package/src/lib/ui/startClient.ts +17 -3
- package/src/lib/shared/types/CacheSnapshot.ts +0 -16
package/src/lib/ui/router.ts
CHANGED
|
@@ -209,6 +209,17 @@ export function router(
|
|
|
209
209
|
const chainKeys = layoutChainForRoute(chainRoute, layoutKeys)
|
|
210
210
|
sequence += 1
|
|
211
211
|
const token = sequence
|
|
212
|
+
/* Flag the outgoing page as navigating for the resolve window — chunk
|
|
213
|
+
import + probe + the view transition all run before the swap commits.
|
|
214
|
+
Published on the CURRENT snapshot (route/params unchanged) so a spinner
|
|
215
|
+
bound to `page.navigating` shows over the page being left; `swap`
|
|
216
|
+
republishes with the destination and `navigating: false` on commit.
|
|
217
|
+
Skipped on first paint — there is no page to leave. An instant SPA hop
|
|
218
|
+
(chunk cached, no probe) flips it back within the same microtask, so the
|
|
219
|
+
browser never paints the intermediate state — no flash. */
|
|
220
|
+
if (!first && !clientPage.value.navigating) {
|
|
221
|
+
clientPage.value = { ...clientPage.value, navigating: true }
|
|
222
|
+
}
|
|
212
223
|
/* First paint adopts a document the server already ran handle() on;
|
|
213
224
|
only later navigations re-run it through the probe. */
|
|
214
225
|
const verdict: Promise<NavVerdict> =
|
|
@@ -238,16 +249,6 @@ export function router(
|
|
|
238
249
|
}
|
|
239
250
|
return
|
|
240
251
|
}
|
|
241
|
-
/* Publish the active page so the `page` proxy resolves route/params/url. */
|
|
242
|
-
clientPage.value = {
|
|
243
|
-
route: chainRoute,
|
|
244
|
-
params,
|
|
245
|
-
url:
|
|
246
|
-
typeof location === 'undefined'
|
|
247
|
-
? new URL(`http://localhost${path}`)
|
|
248
|
-
: new URL(location.href),
|
|
249
|
-
navigating: false,
|
|
250
|
-
}
|
|
251
252
|
const layoutViews = resolvedLayouts.filter(
|
|
252
253
|
(view): view is Route => view !== undefined,
|
|
253
254
|
)
|
|
@@ -267,7 +268,22 @@ export function router(
|
|
|
267
268
|
/* The DOM mutation a navigation makes: tear the divergent chain down,
|
|
268
269
|
clear its DOM (a fresh mount; hydration adopts in place), rebuild. */
|
|
269
270
|
const swap = (): void => {
|
|
271
|
+
/* Tear the outgoing page + divergent layouts down BEFORE publishing the
|
|
272
|
+
new snapshot. Publishing first would re-run the doomed leaf page's
|
|
273
|
+
computeds against the new route's params (a missing `[id]` reads back
|
|
274
|
+
`undefined`, e.g. `Number(page.params.id)` → NaN → a bogus request)
|
|
275
|
+
while it's still mounted. Disposing first kills that scope; surviving
|
|
276
|
+
prefix layouts then update in place on publish. */
|
|
270
277
|
const base = disposeFrom(divergence)
|
|
278
|
+
clientPage.value = {
|
|
279
|
+
route: chainRoute,
|
|
280
|
+
params,
|
|
281
|
+
url:
|
|
282
|
+
typeof location === 'undefined'
|
|
283
|
+
? new URL(`http://localhost${path}`)
|
|
284
|
+
: new URL(location.href),
|
|
285
|
+
navigating: false,
|
|
286
|
+
}
|
|
271
287
|
if (!hydrating) {
|
|
272
288
|
base.textContent = ''
|
|
273
289
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The abort reason used to cancel an RPC bound to a reactive computation when that
|
|
3
|
+
computation re-runs (its result is superseded) or its scope disposes. A unique
|
|
4
|
+
symbol so remoteProxy can distinguish OUR cancellation — which it swallows into a
|
|
5
|
+
never-settling promise, never a rejection — from a genuine network fault or a
|
|
6
|
+
caller's own abort.
|
|
7
|
+
*/
|
|
8
|
+
export const REQUEST_SUPERSEDED: unique symbol = Symbol('abide.request.superseded')
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { REQUEST_SUPERSEDED } from './REQUEST_SUPERSEDED.ts'
|
|
2
|
+
import { reactiveAbortState } from './reactiveAbortState.ts'
|
|
3
|
+
import type { ReactiveNode } from './types/ReactiveNode.ts'
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
Aborts the RPC(s) a reactive computation left in flight — called when the node
|
|
7
|
+
re-runs (the prior run's result is superseded) and when its scope disposes (the
|
|
8
|
+
result would land in a torn-down tree). The `armed` gate keeps this a single
|
|
9
|
+
boolean check until some reactive RPC has actually bound a controller. The abort
|
|
10
|
+
reason is REQUEST_SUPERSEDED so remoteProxy can tell our cancellation from a real
|
|
11
|
+
fault and swallow it rather than surface a rejection.
|
|
12
|
+
*/
|
|
13
|
+
export function abortNode(node: ReactiveNode): void {
|
|
14
|
+
if (!reactiveAbortState.armed) {
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
const controller = reactiveAbortState.controllers.get(node)
|
|
18
|
+
if (controller !== undefined) {
|
|
19
|
+
reactiveAbortState.controllers.delete(node)
|
|
20
|
+
controller.abort(REQUEST_SUPERSEDED)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { cacheManagedSlot } from '../../shared/cacheManagedSlot.ts'
|
|
2
|
+
import { REACTIVE_CONTEXT } from './REACTIVE_CONTEXT.ts'
|
|
3
|
+
import { reactiveAbortState } from './reactiveAbortState.ts'
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
The AbortSignal bound to the currently-running reactive computation (effect or
|
|
7
|
+
computed), its controller created lazily on first use. An RPC invoked inside a
|
|
8
|
+
reactive read passes this to fetch, so the request aborts when that computation
|
|
9
|
+
re-runs (superseded) or its scope disposes (navigated away). Returns undefined —
|
|
10
|
+
leaving the fetch unbound by scope — in two cases: there is no reactive owner to
|
|
11
|
+
bind to (an event-handler call), or the call is cache-managed (the cache owns the
|
|
12
|
+
shared flight's lifetime; binding it to one reader would abort it for the others).
|
|
13
|
+
*/
|
|
14
|
+
export function currentAbortSignal(): AbortSignal | undefined {
|
|
15
|
+
const node = REACTIVE_CONTEXT.observer
|
|
16
|
+
if (node === undefined || cacheManagedSlot.active) {
|
|
17
|
+
return undefined
|
|
18
|
+
}
|
|
19
|
+
let controller = reactiveAbortState.controllers.get(node)
|
|
20
|
+
if (controller === undefined) {
|
|
21
|
+
controller = new AbortController()
|
|
22
|
+
reactiveAbortState.controllers.set(node, controller)
|
|
23
|
+
reactiveAbortState.armed = true
|
|
24
|
+
}
|
|
25
|
+
return controller.signal
|
|
26
|
+
}
|
|
@@ -4,10 +4,16 @@ Escapes one object key into a JSON Pointer reference token (RFC 6901): `~`→`~0
|
|
|
4
4
|
survives a `/`-joined path instead of being mis-split into segments. `~` is
|
|
5
5
|
escaped first so a `/`→`~1` substitution isn't re-escaped. The common key (a
|
|
6
6
|
plain identifier) contains neither char, so the scan returns it untouched.
|
|
7
|
+
|
|
8
|
+
Coerces first: lowerDocAccess wraps every dynamic path segment in this call, and a
|
|
9
|
+
segment is often a number (an array index `lines[i]`) — String() mirrors the `+`
|
|
10
|
+
join that built the path before, leaving numerics as their decimal text.
|
|
7
11
|
*/
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
12
|
+
// @documentation plumbing
|
|
13
|
+
export function escapeKey(key: string | number): string {
|
|
14
|
+
const segment = String(key)
|
|
15
|
+
if (!segment.includes('~') && !segment.includes('/')) {
|
|
16
|
+
return segment
|
|
11
17
|
}
|
|
12
|
-
return
|
|
18
|
+
return segment.replace(/~/g, '~0').replace(/\//g, '~1')
|
|
13
19
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ReactiveNode } from './types/ReactiveNode.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Shared state for scope-bound RPC abort. `controllers` maps a reactive computation
|
|
5
|
+
(effect/computed) to the AbortController of the RPC(s) it fired, kept OFF
|
|
6
|
+
ReactiveNode — like an effect's cleanup closure — so signals and the read/write hot
|
|
7
|
+
path pay nothing. `armed` flips true the first time an RPC binds a controller and
|
|
8
|
+
gates the runNode/unlinkDeps lookups, so an app that never fires a reactive RPC pays
|
|
9
|
+
only one boolean check per compute run. Mirrors REACTIVE_CONTEXT's single-object
|
|
10
|
+
pattern (shared mutable singletons on one object, reached without a barrel).
|
|
11
|
+
*/
|
|
12
|
+
export const reactiveAbortState: {
|
|
13
|
+
controllers: WeakMap<ReactiveNode, AbortController>
|
|
14
|
+
armed: boolean
|
|
15
|
+
} = { controllers: new WeakMap(), armed: false }
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { abortNode } from './abortNode.ts'
|
|
1
2
|
import { endTracking } from './endTracking.ts'
|
|
2
3
|
import { REACTIVE_CONTEXT } from './REACTIVE_CONTEXT.ts'
|
|
4
|
+
import { reactiveAbortState } from './reactiveAbortState.ts'
|
|
3
5
|
import type { ReactiveNode } from './types/ReactiveNode.ts'
|
|
4
6
|
|
|
5
7
|
/*
|
|
@@ -12,6 +14,13 @@ taken this run leaves its edges past the cursor, so they are dropped here. Retur
|
|
|
12
14
|
the fresh value (used by lazy computed reads).
|
|
13
15
|
*/
|
|
14
16
|
export function runNode(node: ReactiveNode): unknown {
|
|
17
|
+
/* This run supersedes the prior one — abort any RPC it left in flight before
|
|
18
|
+
re-tracking, so a stale request never lands after a newer one started. The
|
|
19
|
+
`armed` gate inlines here so an app that never fires a reactive RPC pays one
|
|
20
|
+
property read on this hot path, not a call. */
|
|
21
|
+
if (reactiveAbortState.armed) {
|
|
22
|
+
abortNode(node)
|
|
23
|
+
}
|
|
15
24
|
/* Rewind: track() walks forward from here, reusing matching edges in order. */
|
|
16
25
|
node.depsTail = undefined
|
|
17
26
|
const previous = REACTIVE_CONTEXT.observer
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { OWNER } from './OWNER.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
A set of child-scope disposers that composes with the enclosing owner. `scope()`
|
|
5
|
+
returns a disposer but does NOT self-register, so a control-flow block's live
|
|
6
|
+
children — a branch, a list row — would outlive an owner teardown (a navigation):
|
|
7
|
+
the leak every block otherwise hand-wires against, several incorrectly. A block
|
|
8
|
+
creates one group, then `track`s each child it builds:
|
|
9
|
+
|
|
10
|
+
`track(dispose)` adopts a child's disposer and returns a wrapper that disposes it
|
|
11
|
+
AND drops it from the group — call the wrapper on a flip/prune (it's idempotent, so
|
|
12
|
+
a second call is a no-op). Whatever stays tracked is disposed once, when the
|
|
13
|
+
enclosing owner tears down. A swapped-out child is already dropped, so owner
|
|
14
|
+
teardown only ever runs the children still live.
|
|
15
|
+
|
|
16
|
+
This is the single mechanism `when`/`switch`/`await`/`try`/`each`/`eachAsync` share
|
|
17
|
+
in place of each re-deriving owner composition (where the divergence bred the leaks).
|
|
18
|
+
*/
|
|
19
|
+
export function scopeGroup(): { track: (dispose: () => void) => () => void } {
|
|
20
|
+
const live = new Set<() => void>()
|
|
21
|
+
const track = (dispose: () => void): (() => void) => {
|
|
22
|
+
live.add(dispose)
|
|
23
|
+
return () => {
|
|
24
|
+
if (live.delete(dispose)) {
|
|
25
|
+
dispose()
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/* One teardown for the whole group; disposes whatever children are still live when
|
|
30
|
+
the owner goes. Outside any scope (a standalone block) the group owns nothing. */
|
|
31
|
+
if (OWNER.current !== undefined) {
|
|
32
|
+
OWNER.current.push(() => {
|
|
33
|
+
for (const dispose of live) {
|
|
34
|
+
dispose()
|
|
35
|
+
}
|
|
36
|
+
live.clear()
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
return { track }
|
|
40
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { abortNode } from './abortNode.ts'
|
|
1
2
|
import { detachLink } from './detachLink.ts'
|
|
3
|
+
import { reactiveAbortState } from './reactiveAbortState.ts'
|
|
2
4
|
import type { ReactiveNode } from './types/ReactiveNode.ts'
|
|
3
5
|
|
|
4
6
|
/*
|
|
@@ -9,6 +11,12 @@ dead observer, then empties the node's own list. The `Set`-era equivalent was
|
|
|
9
11
|
`for (dep of node.deps) dep.observers.delete(node); node.deps.clear()`.
|
|
10
12
|
*/
|
|
11
13
|
export function unlinkDeps(node: ReactiveNode): void {
|
|
14
|
+
/* Disposing tears this computation out of the graph — abort any RPC it left in
|
|
15
|
+
flight, its result would land in a scope that no longer exists. Gated inline so
|
|
16
|
+
the common disarmed dispose pays one property read, not a call. */
|
|
17
|
+
if (reactiveAbortState.armed) {
|
|
18
|
+
abortNode(node)
|
|
19
|
+
}
|
|
12
20
|
let link = node.depsHead
|
|
13
21
|
while (link !== undefined) {
|
|
14
22
|
const next = link.nextDep
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { activeCacheStore } from '../shared/activeCacheStore.ts'
|
|
2
|
+
import { cacheEntryFromSnapshot } from '../shared/cacheEntryFromSnapshot.ts'
|
|
3
|
+
import type { StreamedResolution } from '../shared/types/StreamedResolution.ts'
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
Seeds one streamed cache resolution into the active store — the single sink for the
|
|
7
|
+
streamed (pending {#await}) cache partition. A full CacheSnapshotEntry warms the entry
|
|
8
|
+
so a `cache()` read resolves synchronously (no wire round-trip) and `<template await>`
|
|
9
|
+
adopts without a refetch; a `{ key, miss }` marker — a body the server couldn't snapshot
|
|
10
|
+
(binary / rejected / evicted) — is a no-op, so that read falls back to a live fetch.
|
|
11
|
+
|
|
12
|
+
Shared by startClient's boot drain, the live `window.__abideResolve`, and applyResolved,
|
|
13
|
+
so no resolved-frame consumer can swap DOM while silently dropping the cache channel —
|
|
14
|
+
the asymmetry that made streamed reads cold-miss to the network.
|
|
15
|
+
*/
|
|
16
|
+
// @documentation plumbing
|
|
17
|
+
export function seedStreamedResolution(resolution: StreamedResolution): void {
|
|
18
|
+
if ('miss' in resolution) {
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
activeCacheStore().entries.set(resolution.key, cacheEntryFromSnapshot(resolution))
|
|
22
|
+
}
|
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
import { cacheEntryFromSnapshot } from '../shared/cacheEntryFromSnapshot.ts'
|
|
2
1
|
import { createCacheStore } from '../shared/createCacheStore.ts'
|
|
3
2
|
import { setBaseResolver } from '../shared/setBaseResolver.ts'
|
|
4
3
|
import { setCacheStoreResolver } from '../shared/setCacheStoreResolver.ts'
|
|
5
4
|
import { setGlobalCacheStoreResolver } from '../shared/setGlobalCacheStoreResolver.ts'
|
|
6
5
|
import { setPageResolver } from '../shared/setPageResolver.ts'
|
|
7
6
|
import type { CacheSnapshotEntry } from '../shared/types/CacheSnapshotEntry.ts'
|
|
7
|
+
import type { StreamedResolution } from '../shared/types/StreamedResolution.ts'
|
|
8
8
|
import { installHotBridge } from './installHotBridge.ts'
|
|
9
9
|
import { probeNavigation } from './probeNavigation.ts'
|
|
10
10
|
import { router } from './router.ts'
|
|
11
11
|
import { clientPage } from './runtime/clientPage.ts'
|
|
12
12
|
import type { RouteLoader } from './runtime/types/RouteLoader.ts'
|
|
13
|
+
import { seedStreamedResolution } from './seedStreamedResolution.ts'
|
|
13
14
|
|
|
14
15
|
/* The server's __SSR__ payload this entry consumes. */
|
|
15
16
|
type SsrPayload = { cache?: CacheSnapshotEntry[]; base?: string }
|
|
@@ -50,9 +51,22 @@ export function startClient(
|
|
|
50
51
|
setCacheStoreResolver(() => store)
|
|
51
52
|
/* One tab store: cache(fn, { global: true }) shares it, so global is a no-op here. */
|
|
52
53
|
setGlobalCacheStoreResolver(() => store)
|
|
53
|
-
|
|
54
|
-
|
|
54
|
+
/* Seed both SSR cache partitions through the one streamed-resolution sink: `ssr.cache`
|
|
55
|
+
(inline — reads settled at render-return, in __SSR__) and `__abideResumeCache` (pending
|
|
56
|
+
{#await} reads whose `__abideResolve(...)` chunks the stream pushed during parse, before
|
|
57
|
+
this deferred bundle ran). A warm entry lets a `cache()` read resolve synchronously so
|
|
58
|
+
`<template await>` adopts without a refetch; a miss marker re-fetches live. */
|
|
59
|
+
const streamed =
|
|
60
|
+
(globalThis as { __abideResumeCache?: StreamedResolution[] }).__abideResumeCache ?? []
|
|
61
|
+
for (const resolution of [...(ssr.cache ?? []), ...streamed]) {
|
|
62
|
+
seedStreamedResolution(resolution)
|
|
55
63
|
}
|
|
64
|
+
/* Keep the cache channel live past boot: replace the head's buffering collector with
|
|
65
|
+
the store-connected sink so a post-load resolution — streaming SPA navigation or a
|
|
66
|
+
socket-delivered SSR frame, both routed through applyResolved — seeds the store
|
|
67
|
+
directly instead of pushing to a buffer nothing drains again. */
|
|
68
|
+
;(globalThis as { __abideResolve?: (resolution: StreamedResolution) => void }).__abideResolve =
|
|
69
|
+
seedStreamedResolution
|
|
56
70
|
|
|
57
71
|
return router(target, routes, layoutRoutes, probeNavigation)
|
|
58
72
|
}
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import type { CacheEntry } from './CacheEntry.ts'
|
|
2
|
-
import type { CacheSnapshotEntry } from './CacheSnapshotEntry.ts'
|
|
3
|
-
|
|
4
|
-
/*
|
|
5
|
-
The SSR partition of a request's cache, read after `render()` returns.
|
|
6
|
-
`inline` are entries settled by then (consumed via `await`, so render blocked
|
|
7
|
-
on them) — they ship in the first chunk's `__SSR__` blob. `pending` are
|
|
8
|
-
GET/DELETE entries still in flight (consumed via `{#await}`, which render emits
|
|
9
|
-
as a pending branch without blocking) — the streamer awaits each and pushes a
|
|
10
|
-
`__abideResolve` chunk as it lands. Non-GET/DELETE pending entries are dropped:
|
|
11
|
-
they can't be snapshotted, so the client re-fetches them live on cache miss.
|
|
12
|
-
*/
|
|
13
|
-
export type CacheSnapshot = {
|
|
14
|
-
inline: CacheSnapshotEntry[]
|
|
15
|
-
pending: CacheEntry[]
|
|
16
|
-
}
|