@abide/abide 0.35.0 → 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 +10 -0
- package/README.md +75 -69
- package/package.json +1 -1
- 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/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/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/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
|
@@ -8,6 +8,8 @@ import { trace } from '../shared/trace.ts'
|
|
|
8
8
|
import type { HttpVerb } from '../shared/types/HttpVerb.ts'
|
|
9
9
|
import type { RemoteFunction } from '../shared/types/RemoteFunction.ts'
|
|
10
10
|
import { withBase } from '../shared/withBase.ts'
|
|
11
|
+
import { currentAbortSignal } from './runtime/currentAbortSignal.ts'
|
|
12
|
+
import { REQUEST_SUPERSEDED } from './runtime/REQUEST_SUPERSEDED.ts'
|
|
11
13
|
|
|
12
14
|
/*
|
|
13
15
|
Client-side substitute for a verb-defined handler. The bundler emits one
|
|
@@ -54,17 +56,26 @@ export function remoteProxy<Args, Return>(
|
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
/*
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
Fetches under two optional aborts: the reactive scope that fired the call (so a
|
|
60
|
+
superseded/torn-down read cancels its in-flight request — currentAbortSignal) and
|
|
61
|
+
the env-configured client timeout (ABIDE_CLIENT_TIMEOUT, ms). Neither present →
|
|
62
|
+
the unbounded fetch, exactly as before. A timeout surfaces as a 504 HttpError so a
|
|
63
|
+
consumer reads an honest status instead of a raw DOMException → 500. Our scope abort
|
|
64
|
+
(reason REQUEST_SUPERSEDED) is swallowed into a never-settling promise: the reactive
|
|
65
|
+
owner is gone, so the result must neither resolve into a dead tree nor surface as a
|
|
66
|
+
rejection. Other rejections (genuine network failure) propagate untouched.
|
|
61
67
|
*/
|
|
62
68
|
function fetchWithTimeout(request: Request): Promise<Response> {
|
|
63
69
|
const timeout = rpcTimeoutSlot.ms
|
|
64
|
-
|
|
70
|
+
const timeoutSignal = timeout === undefined ? undefined : AbortSignal.timeout(timeout)
|
|
71
|
+
const signal = combineSignals(currentAbortSignal(), timeoutSignal)
|
|
72
|
+
if (signal === undefined) {
|
|
65
73
|
return fetch(request)
|
|
66
74
|
}
|
|
67
|
-
return fetch(request, { signal
|
|
75
|
+
return fetch(request, { signal }).catch((error: unknown) => {
|
|
76
|
+
if (error === REQUEST_SUPERSEDED) {
|
|
77
|
+
return new Promise<Response>(() => {})
|
|
78
|
+
}
|
|
68
79
|
if (error instanceof DOMException && error.name === 'TimeoutError') {
|
|
69
80
|
throw new HttpError(
|
|
70
81
|
new Response('client timeout', { status: 504, statusText: 'Gateway Timeout' }),
|
|
@@ -74,6 +85,21 @@ function fetchWithTimeout(request: Request): Promise<Response> {
|
|
|
74
85
|
})
|
|
75
86
|
}
|
|
76
87
|
|
|
88
|
+
/* One AbortSignal from the scope-abort and timeout sources: whichever is present,
|
|
89
|
+
AbortSignal.any when both, or undefined when neither (the unbounded fetch). */
|
|
90
|
+
function combineSignals(
|
|
91
|
+
scopeSignal: AbortSignal | undefined,
|
|
92
|
+
timeoutSignal: AbortSignal | undefined,
|
|
93
|
+
): AbortSignal | undefined {
|
|
94
|
+
if (scopeSignal === undefined) {
|
|
95
|
+
return timeoutSignal
|
|
96
|
+
}
|
|
97
|
+
if (timeoutSignal === undefined) {
|
|
98
|
+
return scopeSignal
|
|
99
|
+
}
|
|
100
|
+
return AbortSignal.any([scopeSignal, timeoutSignal])
|
|
101
|
+
}
|
|
102
|
+
|
|
77
103
|
/*
|
|
78
104
|
abide's per-RPC headers: the page traceparent (continues the server trace) and,
|
|
79
105
|
only while offline, the offline marker so the handler's online() reflects the
|
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
|
+
}
|
|
@@ -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
|