@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.
Files changed (46) hide show
  1. package/AGENTS.md +196 -215
  2. package/CHANGELOG.md +46 -0
  3. package/README.md +75 -69
  4. package/package.json +2 -1
  5. package/src/abideResolverPlugin.ts +53 -9
  6. package/src/lib/server/runtime/STREAMED_HTML_HEADER.ts +13 -0
  7. package/src/lib/server/runtime/buildPreloadManifest.ts +151 -0
  8. package/src/lib/server/runtime/createServer.ts +4 -1
  9. package/src/lib/server/runtime/createUiPageRenderer.ts +104 -9
  10. package/src/lib/server/runtime/flushingGzipStream.ts +62 -0
  11. package/src/lib/server/runtime/gzipResponse.ts +23 -11
  12. package/src/lib/server/runtime/serializeCacheSnapshot.ts +22 -33
  13. package/src/lib/shared/cache.ts +10 -3
  14. package/src/lib/shared/cacheManagedSlot.ts +10 -0
  15. package/src/lib/shared/withCacheManaged.ts +18 -0
  16. package/src/lib/ui/README.md +1 -1
  17. package/src/lib/ui/compile/REACTIVE_CALLEES.ts +6 -6
  18. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
  19. package/src/lib/ui/compile/compileShadow.ts +60 -55
  20. package/src/lib/ui/compile/desugarSignals.ts +93 -38
  21. package/src/lib/ui/compile/lowerDocAccess.ts +26 -11
  22. package/src/lib/ui/compile/parseTemplate.ts +8 -5
  23. package/src/lib/ui/compile/prepareNestedScript.ts +2 -2
  24. package/src/lib/ui/compile/renameSignalRefs.ts +153 -23
  25. package/src/lib/ui/compile/types/TemplateAttr.ts +4 -2
  26. package/src/lib/ui/dom/applyResolved.ts +27 -7
  27. package/src/lib/ui/dom/awaitBlock.ts +7 -3
  28. package/src/lib/ui/dom/each.ts +8 -15
  29. package/src/lib/ui/dom/eachAsync.ts +8 -9
  30. package/src/lib/ui/dom/switchBlock.ts +7 -3
  31. package/src/lib/ui/dom/tryBlock.ts +16 -5
  32. package/src/lib/ui/dom/when.ts +7 -3
  33. package/src/lib/ui/installHotBridge.ts +2 -0
  34. package/src/lib/ui/remoteProxy.ts +32 -6
  35. package/src/lib/ui/router.ts +26 -10
  36. package/src/lib/ui/runtime/REQUEST_SUPERSEDED.ts +8 -0
  37. package/src/lib/ui/runtime/abortNode.ts +22 -0
  38. package/src/lib/ui/runtime/currentAbortSignal.ts +26 -0
  39. package/src/lib/ui/runtime/escapeKey.ts +10 -4
  40. package/src/lib/ui/runtime/reactiveAbortState.ts +15 -0
  41. package/src/lib/ui/runtime/runNode.ts +9 -0
  42. package/src/lib/ui/runtime/scopeGroup.ts +40 -0
  43. package/src/lib/ui/runtime/unlinkDeps.ts +8 -0
  44. package/src/lib/ui/seedStreamedResolution.ts +22 -0
  45. package/src/lib/ui/startClient.ts +17 -3
  46. package/src/lib/shared/types/CacheSnapshot.ts +0 -16
@@ -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
- export function escapeKey(key: string): string {
9
- if (!key.includes('~') && !key.includes('/')) {
10
- return key
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 key.replace(/~/g, '~0').replace(/\//g, '~1')
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
- for (const entry of ssr.cache ?? []) {
54
- store.entries.set(entry.key, cacheEntryFromSnapshot(entry))
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
- }