@abide/abide 0.35.0 → 0.37.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.
Files changed (50) hide show
  1. package/AGENTS.md +196 -215
  2. package/CHANGELOG.md +24 -0
  3. package/README.md +75 -69
  4. package/package.json +1 -1
  5. package/src/lib/server/runtime/buildInFlightSnapshot.ts +35 -0
  6. package/src/lib/server/runtime/buildInspectorSurface.ts +9 -1
  7. package/src/lib/server/runtime/createServer.ts +10 -1
  8. package/src/lib/server/runtime/inFlightRequests.ts +13 -0
  9. package/src/lib/server/runtime/maybeMountInspector.ts +7 -0
  10. package/src/lib/server/runtime/runWithRequestScope.ts +7 -0
  11. package/src/lib/server/runtime/types/InspectorContext.ts +4 -0
  12. package/src/lib/server/runtime/types/InspectorInFlightRequest.ts +20 -0
  13. package/src/lib/server/runtime/types/InspectorInFlightSnapshot.ts +10 -0
  14. package/src/lib/server/runtime/types/InspectorPrompt.ts +15 -0
  15. package/src/lib/server/runtime/types/InspectorSurface.ts +6 -3
  16. package/src/lib/shared/cache.ts +10 -3
  17. package/src/lib/shared/cacheManagedSlot.ts +10 -0
  18. package/src/lib/shared/emitLogRecord.ts +18 -5
  19. package/src/lib/shared/withCacheManaged.ts +18 -0
  20. package/src/lib/ui/compile/componentWrapperTag.ts +10 -20
  21. package/src/lib/ui/compile/generateBuild.ts +18 -9
  22. package/src/lib/ui/compile/generateSSR.ts +2 -2
  23. package/src/lib/ui/createScope.ts +11 -0
  24. package/src/lib/ui/dom/awaitBlock.ts +7 -3
  25. package/src/lib/ui/dom/each.ts +8 -15
  26. package/src/lib/ui/dom/eachAsync.ts +8 -9
  27. package/src/lib/ui/dom/hydrate.ts +2 -1
  28. package/src/lib/ui/dom/mount.ts +2 -1
  29. package/src/lib/ui/dom/scopeLabel.ts +15 -0
  30. package/src/lib/ui/dom/skeleton.ts +13 -1
  31. package/src/lib/ui/dom/switchBlock.ts +7 -3
  32. package/src/lib/ui/dom/tryBlock.ts +16 -5
  33. package/src/lib/ui/dom/when.ts +7 -3
  34. package/src/lib/ui/installInspectorBridge.ts +138 -0
  35. package/src/lib/ui/navigate.ts +11 -3
  36. package/src/lib/ui/remoteProxy.ts +32 -6
  37. package/src/lib/ui/router.ts +94 -10
  38. package/src/lib/ui/runtime/REQUEST_SUPERSEDED.ts +8 -0
  39. package/src/lib/ui/runtime/abortNode.ts +22 -0
  40. package/src/lib/ui/runtime/currentAbortSignal.ts +26 -0
  41. package/src/lib/ui/runtime/historyEntries.ts +113 -0
  42. package/src/lib/ui/runtime/liveScopes.ts +15 -0
  43. package/src/lib/ui/runtime/reactiveAbortState.ts +15 -0
  44. package/src/lib/ui/runtime/runNode.ts +9 -0
  45. package/src/lib/ui/runtime/scopeGroup.ts +40 -0
  46. package/src/lib/ui/runtime/types/AbideHistoryState.ts +9 -0
  47. package/src/lib/ui/runtime/unlinkDeps.ts +8 -0
  48. package/src/lib/ui/startClient.ts +6 -0
  49. package/src/lib/ui/types/Scope.ts +3 -0
  50. package/src/lib/ui/compile/HTML_TAGS.ts +0 -132
@@ -1,16 +1,24 @@
1
+ import { historyEntries } from './runtime/historyEntries.ts'
1
2
  import { runtimePath } from './runtime/runtimePath.ts'
2
3
 
3
4
  /* Navigates to `path`: writes a history entry (when available) and updates the
4
5
  reactive route, which re-mounts the matching page via `router`. `replace` swaps
5
6
  the current entry instead of pushing — used when honouring a server redirect, so
6
- the blocked URL isn't left behind in history. */
7
+ the blocked URL isn't left behind in history. Each entry carries a monotonic
8
+ `abideEntry` id so the router can bucket/restore its scroll offset across the page
9
+ teardown the rebuild does. A push leaves the current entry behind — its scroll is
10
+ bucketed so back restores it — and mints a fresh id. A replace destroys the current
11
+ 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. */
7
13
  // @documentation navigate
8
14
  export function navigate(path: string, replace = false): void {
9
15
  if (typeof history !== 'undefined') {
10
16
  if (replace) {
11
- history.replaceState({}, '', path)
17
+ historyEntries.discard()
18
+ history.replaceState({ abideEntry: historyEntries.current }, '', path)
12
19
  } else {
13
- history.pushState({}, '', path)
20
+ historyEntries.save()
21
+ history.pushState({ abideEntry: historyEntries.next() }, '', path)
14
22
  }
15
23
  }
16
24
  runtimePath.value = path
@@ -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
- Applies the env-configured client timeout (ABIDE_CLIENT_TIMEOUT, ms) when one
58
- is set; an unset slot fetches unbounded, exactly as before. A timeout abort
59
- surfaces as a 504 HttpError so a consumer reads an honest status instead of a
60
- raw DOMException 500. Other rejections (genuine network failure) propagate untouched.
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
- if (timeout === undefined) {
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: AbortSignal.timeout(timeout) }).catch((error: unknown) => {
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
@@ -6,7 +6,9 @@ import { clientPage } from './runtime/clientPage.ts'
6
6
  import { enterRenderPass } from './runtime/enterRenderPass.ts'
7
7
  import { exitRenderPass } from './runtime/exitRenderPass.ts'
8
8
  import { firstOutlet } from './runtime/firstOutlet.ts'
9
+ import { historyEntries } from './runtime/historyEntries.ts'
9
10
  import { runtimePath } from './runtime/runtimePath.ts'
11
+ import type { AbideHistoryState } from './runtime/types/AbideHistoryState.ts'
10
12
  import type { NavVerdict } from './runtime/types/NavVerdict.ts'
11
13
  import type { Route } from './runtime/types/Route.ts'
12
14
  import type { RouteLoader } from './runtime/types/RouteLoader.ts'
@@ -20,6 +22,15 @@ type MountedLayout = { key: string; dispose: () => void; outlet: Element }
20
22
  /* A layout mount that returns no disposer still needs one for the chain teardown. */
21
23
  const noop = (): void => {}
22
24
 
25
+ /* The destination URL for a navigation `path`. On the server / headless there is no
26
+ `location`, so resolve against a localhost origin; in the browser, against the real
27
+ origin — `location` is already updated to `path` by the time a swap reads it, so this
28
+ matches `new URL(location.href)`. */
29
+ const resolveUrl = (path: string): URL =>
30
+ typeof location === 'undefined'
31
+ ? new URL(`http://localhost${path}`)
32
+ : new URL(path, location.origin)
33
+
23
34
  /*
24
35
  A minimal client router on the History API. `router` matches the current path
25
36
  against the route patterns (literal / `[name]` / `[...rest]`, via matchRoute),
@@ -34,6 +45,11 @@ is disposed and rebuilt, and the page (always the leaf) re-mounts every time. Th
34
45
  reactive `page` proxy means a persisted layout reading route/params updates in place
35
46
  without a remount. Each chunk loads only on first visit, cached after.
36
47
 
48
+ Scroll restoration is manual (`historyEntries`): because the page is rebuilt after the
49
+ browser would restore scroll, each history entry's offset is bucketed by an `abideEntry`
50
+ id and reapplied once the destination DOM exists — back/forward returns to its offset, a
51
+ fresh navigation scrolls to the top.
52
+
37
53
  `probe` (when given) runs each post-boot navigation's destination through the
38
54
  server's app.handle first, so auth/redirect gating applies to client navigation
39
55
  just as it does to a fresh load; its verdict either clears the mount, soft-redirects
@@ -134,9 +150,22 @@ export function router(
134
150
  run()
135
151
  }
136
152
 
153
+ const entryOf = (): number =>
154
+ (history.state as AbideHistoryState | null)?.abideEntry ?? historyEntries.current
137
155
  const onPopState = (): void => {
156
+ /* Bucket the leaving entry's scroll (current still its id) before adopting the
157
+ one back/forward landed on; `swap` restores the adopted entry's offset once
158
+ its DOM is rebuilt. */
159
+ historyEntries.save()
160
+ historyEntries.adopt(entryOf())
138
161
  runtimePath.value = location.pathname + location.search + location.hash
139
162
  }
163
+ const onPageHide = (): void => {
164
+ /* Mirror the live scroll into the active entry's state before it unloads, so a
165
+ reload can recover it — the in-memory bucket is gone and `manual` keeps the
166
+ browser from restoring. */
167
+ historyEntries.persist()
168
+ }
140
169
  const onClick = (event: MouseEvent): void => {
141
170
  /* Let the browser own anything that isn't a plain primary-button click:
142
171
  modified clicks (open in a new tab/window), middle/right buttons, and
@@ -175,7 +204,22 @@ export function router(
175
204
  navigate(destination.pathname + destination.search + destination.hash)
176
205
  }
177
206
  if (typeof window !== 'undefined') {
207
+ /* Own scroll restoration: the browser would restore against the pre-teardown
208
+ DOM. Adopt the initial entry's id (survives a reload) and stamp it onto the
209
+ landing entry — merging so any `scroll` a prior unload persisted into this
210
+ entry's state stays put for the first-paint `restore` to recover. */
211
+ if ('scrollRestoration' in history) {
212
+ history.scrollRestoration = 'manual'
213
+ }
214
+ historyEntries.adopt(entryOf())
215
+ const landingState = (history.state as AbideHistoryState | null) ?? {}
216
+ history.replaceState(
217
+ { ...landingState, abideEntry: historyEntries.current },
218
+ '',
219
+ location.href,
220
+ )
178
221
  window.addEventListener('popstate', onPopState)
222
+ window.addEventListener('pagehide', onPageHide)
179
223
  document.addEventListener('click', onClick as EventListener)
180
224
  }
181
225
 
@@ -200,6 +244,29 @@ export function router(
200
244
  /* The route matches on the pathname only; the query/hash ride along for
201
245
  the probe (so server gating sees them) and for clientPage.url. */
202
246
  const pathname = path.split(/[?#]/)[0] ?? path
247
+ /* A same-document navigation — only the `#hash` (and thus scroll) differs from
248
+ the mounted page — needs no teardown: the live page stays, page.url
249
+ republishes (so `page.url.hash` updates in place), and we restore the entry's
250
+ scroll bucket or scroll to the anchor. A differing pathname or query still
251
+ rebuilds (a query is page data). Skipped on first paint — nothing is mounted. */
252
+ const targetUrl = resolveUrl(path)
253
+ const mountedUrl = clientPage.value.url
254
+ if (
255
+ !first &&
256
+ mountedUrl.pathname === targetUrl.pathname &&
257
+ mountedUrl.search === targetUrl.search &&
258
+ mountedUrl.hash !== targetUrl.hash
259
+ ) {
260
+ /* Invalidate any in-flight full navigation so its late `.then` can't
261
+ rebuild over the page this hash hop keeps mounted (the token guard
262
+ only bails on a NEWER sequence). That bailed `.then` is also the only
263
+ writer of `navigating: false`, so clear the flag here — a hash hop is
264
+ synchronous and settles immediately. */
265
+ sequence += 1
266
+ clientPage.value = { ...clientPage.value, url: targetUrl, navigating: false }
267
+ historyEntries.restore(targetUrl.hash)
268
+ return
269
+ }
203
270
  const matched = matchRoute(patterns, pathname)
204
271
  const key = matched?.route ?? '*'
205
272
  const params = matched?.params ?? {}
@@ -209,6 +276,17 @@ export function router(
209
276
  const chainKeys = layoutChainForRoute(chainRoute, layoutKeys)
210
277
  sequence += 1
211
278
  const token = sequence
279
+ /* Flag the outgoing page as navigating for the resolve window — chunk
280
+ import + probe + the view transition all run before the swap commits.
281
+ Published on the CURRENT snapshot (route/params unchanged) so a spinner
282
+ bound to `page.navigating` shows over the page being left; `swap`
283
+ republishes with the destination and `navigating: false` on commit.
284
+ Skipped on first paint — there is no page to leave. An instant SPA hop
285
+ (chunk cached, no probe) flips it back within the same microtask, so the
286
+ browser never paints the intermediate state — no flash. */
287
+ if (!first && !clientPage.value.navigating) {
288
+ clientPage.value = { ...clientPage.value, navigating: true }
289
+ }
212
290
  /* First paint adopts a document the server already ran handle() on;
213
291
  only later navigations re-run it through the probe. */
214
292
  const verdict: Promise<NavVerdict> =
@@ -238,16 +316,6 @@ export function router(
238
316
  }
239
317
  return
240
318
  }
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
319
  const layoutViews = resolvedLayouts.filter(
252
320
  (view): view is Route => view !== undefined,
253
321
  )
@@ -267,11 +335,26 @@ export function router(
267
335
  /* The DOM mutation a navigation makes: tear the divergent chain down,
268
336
  clear its DOM (a fresh mount; hydration adopts in place), rebuild. */
269
337
  const swap = (): void => {
338
+ /* Tear the outgoing page + divergent layouts down BEFORE publishing the
339
+ new snapshot. Publishing first would re-run the doomed leaf page's
340
+ computeds against the new route's params (a missing `[id]` reads back
341
+ `undefined`, e.g. `Number(page.params.id)` → NaN → a bogus request)
342
+ while it's still mounted. Disposing first kills that scope; surviving
343
+ prefix layouts then update in place on publish. */
270
344
  const base = disposeFrom(divergence)
345
+ const url = resolveUrl(path)
346
+ clientPage.value = { route: chainRoute, params, url, navigating: false }
271
347
  if (!hydrating) {
272
348
  base.textContent = ''
273
349
  }
274
350
  buildFrom(base, divergence, chainKeys, layoutViews, pageView, params, hydrating)
351
+ /* Reapply the destination entry's scroll once its DOM exists — a
352
+ back/forward restores its offset, a fresh nav scrolls to the `#hash`
353
+ anchor (now built) or the top. Runs on the initial paint too: with
354
+ `scrollRestoration='manual'` the browser does NOT restore a reload's
355
+ offset, so first paint recovers it from the persisted `history.state`
356
+ (a fresh load with no persisted offset falls through to hash/top). */
357
+ historyEntries.restore(url.hash)
275
358
  }
276
359
  /* Wrap the swap in a View Transition where the browser supports it, so
277
360
  the page change cross-fades (and shared `view-transition-name` elements
@@ -295,6 +378,7 @@ export function router(
295
378
  disposed = true
296
379
  if (typeof window !== 'undefined') {
297
380
  window.removeEventListener('popstate', onPopState)
381
+ window.removeEventListener('pagehide', onPageHide)
298
382
  document.removeEventListener('click', onClick as EventListener)
299
383
  }
300
384
  stop()
@@ -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,113 @@
1
+ import type { AbideHistoryState } from './types/AbideHistoryState.ts'
2
+
3
+ /*
4
+ Per-history-entry scroll buckets for manual scroll restoration. The browser's own
5
+ restoration is disabled (`history.scrollRestoration = 'manual'` at router boot)
6
+ because the router tears the page down and rebuilds it AFTER the browser would have
7
+ restored scroll — so the offset is lost against a node that no longer exists. Instead
8
+ each history entry carries a monotonic id (stamped into `history.state.abideEntry` by
9
+ `navigate`), and this module buckets that entry's scroll offset: `save()` records the
10
+ outgoing offset before history moves, `restore()` reapplies the destination entry's
11
+ offset after its DOM is rebuilt (or, for an entry seen for the first time — a fresh
12
+ navigation — honours a `#hash` anchor when one resolves, else scrolls to the top). A
13
+ back/forward `adopt`s the entry id read from history.state; a `replace` `discard`s the
14
+ current bucket (its content is superseded). The in-memory `offsets` Map covers the
15
+ same-session back/forward path; `persist()` also mirrors the live scroll into
16
+ `history.state` (on pagehide, when state still reflects the active entry) so a RELOAD
17
+ — where the Map is gone and `scrollRestoration='manual'` keeps the browser from
18
+ restoring — can still recover it via `restore()`'s `persistedOffset` fallback.
19
+
20
+ Scroll APIs are reached off `globalThis` (guarded) so the module is a no-op on the
21
+ server and in headless tests that never install a window. Shared mutable singleton on
22
+ one object, reached without a barrel — mirrors `reactiveAbortState`/`clientPage`.
23
+ */
24
+
25
+ /* The scroll surface: the real `Window` shape, made `Partial` so every member is
26
+ optional — the module degrades to a no-op on the server / in headless tests that
27
+ never install a window. */
28
+ const view = globalThis as unknown as Partial<Window>
29
+
30
+ /* The active entry's offset persisted in `history.state` — survives a reload (the
31
+ in-memory `offsets` Map does not). Honoured only when the stored id matches `current`,
32
+ so a foreign history entry's state never restores the wrong scroll. */
33
+ function persistedOffset(): [number, number] | undefined {
34
+ const state = view.history?.state as AbideHistoryState | null
35
+ if (state?.abideEntry === current && Array.isArray(state.scroll)) {
36
+ return state.scroll
37
+ }
38
+ return undefined
39
+ }
40
+
41
+ /* The element a `#hash` addresses, if the document holds one. */
42
+ function anchorFor(hash: string | undefined): HTMLElement | undefined {
43
+ if (hash === undefined || hash.length <= 1 || view.document === undefined) {
44
+ return undefined
45
+ }
46
+ return view.document.getElementById(hash.slice(1)) ?? undefined
47
+ }
48
+
49
+ /* The active entry's scroll offset is buckets.get(current); `seq` mints fresh ids. */
50
+ const offsets = new Map<number, [number, number]>()
51
+ let current = 0
52
+ let seq = 0
53
+
54
+ export const historyEntries = {
55
+ /* The active history entry's id — stamped into history.state by `navigate`. */
56
+ get current(): number {
57
+ return current
58
+ },
59
+ /* Mint the next entry id for a pushed history entry, making it active. */
60
+ next(): number {
61
+ current = seq += 1
62
+ return current
63
+ },
64
+ /* A back/forward landed on an existing entry — make it active so the following
65
+ save/restore target its bucket. Keeps `seq` ahead of any adopted id. */
66
+ adopt(entry: number): void {
67
+ current = entry
68
+ if (entry > seq) {
69
+ seq = entry
70
+ }
71
+ },
72
+ /* Bucket the active entry's current scroll offset, before history moves away. */
73
+ save(): void {
74
+ if (typeof view.scrollTo === 'function') {
75
+ offsets.set(current, [view.scrollX ?? 0, view.scrollY ?? 0])
76
+ }
77
+ },
78
+ /* Drop the active entry's bucket — a replace lands fresh content, so the saved
79
+ scroll no longer applies. (The replace's own `replaceState` overwrites the
80
+ persisted copy in `history.state`, so the durable fallback clears too.) */
81
+ discard(): void {
82
+ offsets.delete(current)
83
+ },
84
+ /* Mirror the live scroll into the active entry's `history.state` so it survives a
85
+ reload (the in-memory Map does not). Called on pagehide — when `history.state`
86
+ still reflects the active entry — merging to keep its `abideEntry` stamp. */
87
+ persist(): void {
88
+ if (typeof view.scrollTo !== 'function' || view.history === undefined) {
89
+ return
90
+ }
91
+ const state = (view.history.state as AbideHistoryState | null) ?? {}
92
+ view.history.replaceState({ ...state, scroll: [view.scrollX ?? 0, view.scrollY ?? 0] }, '')
93
+ },
94
+ /* Reapply the active entry's scroll once its DOM exists. A back/forward to a saved
95
+ entry returns to its offset (in-memory, else the reload-durable persisted copy);
96
+ a fresh entry honours a `#hash` anchor when one resolves, else scrolls to the top. */
97
+ restore(hash?: string): void {
98
+ if (typeof view.scrollTo !== 'function') {
99
+ return
100
+ }
101
+ const offset = offsets.get(current) ?? persistedOffset()
102
+ if (offset !== undefined) {
103
+ view.scrollTo(offset[0], offset[1])
104
+ return
105
+ }
106
+ const anchor = anchorFor(hash)
107
+ if (anchor !== undefined) {
108
+ anchor.scrollIntoView()
109
+ return
110
+ }
111
+ view.scrollTo(0, 0)
112
+ },
113
+ }
@@ -0,0 +1,15 @@
1
+ import type { Scope } from '../types/Scope.ts'
2
+
3
+ /*
4
+ Dev-only registry of every live scope, for the inspector's Reactive tab. `scopes`
5
+ stays empty and untouched unless installInspectorBridge flips `enabled` (gated by
6
+ the server-injected `__abideInspect`), so production allocates and tracks nothing.
7
+ createScope adds on construction and removes on dispose; the bridge reconstructs
8
+ the scope forest from each entry's `id` + `parent.id` (the Scope surface exposes
9
+ no children accessor, so the flat set + parent links is the traversal path). One
10
+ mutable singleton object — mirrors `reactiveAbortState`, reached without a barrel.
11
+ */
12
+ export const liveScopes: { enabled: boolean; scopes: Set<Scope> } = {
13
+ enabled: false,
14
+ scopes: new Set(),
15
+ }
@@ -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
+ }
@@ -0,0 +1,9 @@
1
+ /* The shape abide stamps into `History.state`: a monotonic `abideEntry` id the router
2
+ buckets scroll by, and the last `scroll` offset persisted for that entry (so a reload
3
+ can recover it — the in-memory bucket does not survive). Both optional: a foreign or
4
+ bare entry (one another script pushed, or the first landing before a stamp) carries
5
+ neither, which the readers treat as "no abide data". */
6
+ export type AbideHistoryState = {
7
+ abideEntry?: number
8
+ scroll?: [number, number]
9
+ }
@@ -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
@@ -6,6 +6,7 @@ import { setPageResolver } from '../shared/setPageResolver.ts'
6
6
  import type { CacheSnapshotEntry } from '../shared/types/CacheSnapshotEntry.ts'
7
7
  import type { StreamedResolution } from '../shared/types/StreamedResolution.ts'
8
8
  import { installHotBridge } from './installHotBridge.ts'
9
+ import { installInspectorBridge } from './installInspectorBridge.ts'
9
10
  import { probeNavigation } from './probeNavigation.ts'
10
11
  import { router } from './router.ts'
11
12
  import { clientPage } from './runtime/clientPage.ts'
@@ -42,6 +43,11 @@ export function startClient(
42
43
  if ((globalThis as { __abideDev?: boolean }).__abideDev) {
43
44
  installHotBridge()
44
45
  }
46
+ /* Inspector only: the server injects `__abideInspect` when ABIDE_ENABLE_INSPECTOR
47
+ is on, so the scope/router bridge arms before the router builds any scope. */
48
+ if ((globalThis as { __abideInspect?: boolean }).__abideInspect) {
49
+ installInspectorBridge()
50
+ }
45
51
  const ssr = (globalThis as { __SSR__?: SsrPayload }).__SSR__ ?? {}
46
52
  setBaseResolver(() => ssr.base ?? '')
47
53
  /* The `page` proxy reads route/params/url off the router-updated snapshot. */
@@ -22,6 +22,9 @@ the ONLY public entry; everything else is a method reached through it (the
22
22
  */
23
23
  export type Scope = {
24
24
  readonly id: string
25
+ /* Dev-only display name (the host component/element it mounted into) for the
26
+ inspector's Reactive tab; undefined for SSR/detached/child scopes. */
27
+ readonly label?: string
25
28
  readonly parent: Scope | undefined
26
29
  /* data — mirrors Doc */
27
30
  read: <T>(path: string) => T