@rangojs/router 0.26.6 → 0.27.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 (57) hide show
  1. package/dist/bin/rango.js +1 -1
  2. package/dist/types/browser/action-coordinator.d.ts +0 -4
  3. package/dist/types/browser/action-fence.d.ts +11 -3
  4. package/dist/types/browser/event-controller.d.ts +18 -0
  5. package/dist/types/browser/history-state.d.ts +4 -0
  6. package/dist/types/browser/intercept-utils.d.ts +23 -0
  7. package/dist/types/browser/rsc-router.d.ts +1 -1
  8. package/dist/types/prefetch-markers.d.ts +12 -5
  9. package/dist/types/router/match-context.d.ts +8 -0
  10. package/dist/types/router/match-middleware/cache-lookup.d.ts +1 -1
  11. package/dist/types/router/match-middleware/cache-store.d.ts +2 -2
  12. package/dist/types/router/revalidation.d.ts +6 -1
  13. package/dist/types/router/router-context.d.ts +2 -17
  14. package/dist/types/rsc/shell-capture-constants.d.ts +1 -1
  15. package/dist/types/testing/serve-shell-request.d.ts +7 -0
  16. package/dist/types/types/handler-context.d.ts +9 -6
  17. package/dist/vite/index.js +1 -1
  18. package/package.json +1 -1
  19. package/skills/client-urls/SKILL.md +2 -1
  20. package/skills/document-cache/SKILL.md +5 -0
  21. package/skills/intercept/SKILL.md +36 -6
  22. package/skills/layout/SKILL.md +11 -7
  23. package/skills/loader/SKILL.md +30 -13
  24. package/skills/middleware/SKILL.md +1 -1
  25. package/skills/migrate-nextjs/SKILL.md +5 -4
  26. package/skills/parallel/SKILL.md +5 -4
  27. package/skills/ppr/SKILL.md +1 -0
  28. package/skills/rango/SKILL.md +18 -10
  29. package/skills/server-actions/SKILL.md +43 -5
  30. package/skills/testing/predicates.md +1 -1
  31. package/skills/testing/serve-shell-request.md +7 -7
  32. package/skills/ux-architecture/SKILL.md +3 -2
  33. package/src/browser/action-coordinator.ts +2 -13
  34. package/src/browser/action-fence.ts +11 -3
  35. package/src/browser/event-controller.ts +37 -0
  36. package/src/browser/history-state.ts +9 -1
  37. package/src/browser/intercept-utils.ts +51 -0
  38. package/src/browser/navigation-bridge.ts +20 -17
  39. package/src/browser/navigation-client.ts +5 -10
  40. package/src/browser/navigation-transaction.ts +3 -10
  41. package/src/browser/partial-update.ts +8 -0
  42. package/src/browser/rsc-router.tsx +16 -2
  43. package/src/browser/server-action-bridge.ts +223 -78
  44. package/src/cache/document-cache.ts +12 -6
  45. package/src/prefetch-markers.ts +13 -5
  46. package/src/router/match-context.ts +13 -0
  47. package/src/router/match-middleware/cache-lookup.ts +21 -10
  48. package/src/router/match-middleware/cache-store.ts +10 -4
  49. package/src/router/match-result.ts +1 -20
  50. package/src/router/navigation-snapshot.ts +2 -1
  51. package/src/router/revalidation.ts +33 -7
  52. package/src/router/router-context.ts +4 -22
  53. package/src/rsc/rsc-rendering.ts +12 -4
  54. package/src/rsc/shell-capture-constants.ts +2 -0
  55. package/src/ssr/ssr-root.tsx +1 -0
  56. package/src/testing/serve-shell-request.ts +20 -2
  57. package/src/types/handler-context.ts +9 -6
@@ -392,8 +392,9 @@ parallel(
392
392
  () => [
393
393
  loader(CartLoader),
394
394
  // Layout-level slot (kept after actions by default): also re-render after
395
- // cart actions. Under a path() the slot re-renders after every action anyway.
396
- revalidate((ctx) => ctx.isAction(CartActions) || undefined),
395
+ // cart actions and on Back after an action. Under a path() the slot
396
+ // re-renders after every action anyway.
397
+ revalidate((ctx) => ctx.isAction(CartActions) || ctx.stale || undefined),
397
398
  ]
398
399
  )
399
400
  ```
@@ -456,7 +457,7 @@ import type { Revalidate } from "@rangojs/router";
456
457
  import * as CartActions from "./actions/cart";
457
458
 
458
459
  export const revalidateCartData: Revalidate = (ctx) =>
459
- ctx.isAction(CartActions) || undefined;
460
+ ctx.stale || ctx.isAction(CartActions) || undefined;
460
461
 
461
462
  layout(CartLayout, () => [
462
463
  revalidate(revalidateCartData), // producer reruns
@@ -575,7 +576,7 @@ export const shopPatterns = urls(({
575
576
  () => [
576
577
  loader(CartLoader),
577
578
  loading(<CartSkeleton />),
578
- revalidate((ctx) => ctx.isAction(CartActions) || undefined),
579
+ revalidate((ctx) => ctx.isAction(CartActions) || ctx.stale || undefined),
579
580
  ]
580
581
  ),
581
582
 
@@ -482,6 +482,7 @@ The bounded bypass tokens, grouped by when they are decided:
482
482
  | Token | Decided | Meaning |
483
483
  | ----------------------------------------------------------------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
484
484
  | `method`, `dynamic`, `nonce`, `store-unavailable`, `passive-read-unsupported` | pre-read | request/route/store ineligible for replay |
485
+ | `stale` | pre-read | a stale request (`_rsc_stale`, Back/Forward onto a page an action made stale) renders live, as an action does: the record may predate the write |
485
486
  | `no-navigation-context` | pre-read | no `X-RSC-Router-Client-Path` and no same-origin `Referer`; a partial match is impossible |
486
487
  | `prerender-store` | pre-read or match | `Prerender()` route served by its baked artifact (pre-read probe of the normal variant; reclassified post-match when the store actually served, either variant) |
487
488
  | `intercept` | match | the navigation resolved to an intercept — replay is never armed for intercepts (they keep their normal cache path); no heal capture |
@@ -87,10 +87,12 @@ are immune to partial-revalidation staleness by construction.
87
87
  the entry's children too — a hard `false` on one side of a
88
88
  producer/consumer pair desyncs it.
89
89
  4. **Cross-entry sharing** — an outer `layout()` entry feeding descendants.
90
- Outer entries do NOT revalidate on actions by default (the revalidation
91
- trace calls this `action:parent-chain-skip`), so this rung always requires
92
- a shared revalidation contract: the same named `revalidate()` function on
93
- the producer and every consumer. See `/layout` → "Revalidation Contracts".
90
+ Outer entries do NOT revalidate on actions, or on Back/Forward onto a page
91
+ an action made stale, by default (the revalidation trace calls this
92
+ `action:parent-chain-skip` / `stale:parent-chain-skip`), so this rung always
93
+ requires a shared revalidation contract: the same named `revalidate()`
94
+ function on the producer and every consumer, returning `true` on `stale`
95
+ as well as on its actions. See `/layout` → "Revalidation Contracts".
94
96
  Before writing one, check whether the producer can move down a rung.
95
97
 
96
98
  The failure mode this ladder prevents: a consumer re-runs, its producer
@@ -249,7 +251,9 @@ _which_ action with `ctx.isAction(addToCart)` (rename-safe); branch on _what it
249
251
  returned_ with `ctx.actionResult` — the value your `"use server"` function
250
252
  returned, for outcome-conditional revalidation. The arg also exposes `actionId`
251
253
  (raw `path#export`), `actionUrl`, `formData` (form-based actions only), `method`,
252
- and `stale` (the `_rsc_stale` signal that an action ran in this or another tab).
254
+ and `stale` (the `_rsc_stale` signal that an action ran in this or another tab;
255
+ that Back/Forward revalidation takes an action's defaults, but `isAction()` is
256
+ false on it).
253
257
  `actionId`, `actionUrl`, `actionResult`, and `formData` are `undefined` on plain
254
258
  navigation; `method` is `"GET"` there and `"POST"` for an action. Call
255
259
  `ctx.isAction()` with no arguments to ask "was this any action?".
@@ -258,9 +262,10 @@ navigation; `method` is `"GET"` there and `"POST"` for an action. Call
258
262
  default already does: after an action, loaders, the route, and segments inside
259
263
  the `path()` re-run; layouts and parallels above the route are skipped.
260
264
 
261
- - **Add a signal** where the default skips: `ctx.isAction(CartActions) || undefined`
262
- on a parent layout re-renders it after cart actions and defers otherwise. On a
263
- loader or route segment it changes nothing.
265
+ - **Add a signal** where the default skips:
266
+ `ctx.isAction(CartActions) || ctx.stale || undefined` on a parent layout
267
+ re-renders it after cart actions and on Back after an action, and defers
268
+ otherwise. On a loader or route segment it changes nothing.
264
269
  - **Narrow after actions** where the default re-runs:
265
270
  `ctx.isAction() ? ctx.isAction(CartActions) : undefined` re-runs only for cart
266
271
  actions and keeps the navigation default (params or search changed).
@@ -270,8 +275,11 @@ the `path()` re-run; layouts and parallels above the route are skipped.
270
275
 
271
276
  ```ts
272
277
  // on a parent layout (skipped after actions by default): also re-render when
273
- // checkout succeeded; defer to the default otherwise
274
- revalidate((ctx) => (ctx.isAction(checkout) && ctx.actionResult?.ok) || undefined),
278
+ // checkout succeeded, and on Back after an action; defer to the default otherwise
279
+ revalidate(
280
+ (ctx) =>
281
+ (ctx.isAction(checkout) && ctx.actionResult?.ok) || ctx.stale || undefined,
282
+ ),
275
283
  ```
276
284
 
277
285
  The same `actionResult` idiom works in a `clientUrls()` group's loader
@@ -428,8 +428,9 @@ several actions, or a namespace import (`import * as CartActions`). Wrap it by
428
428
  what the segment's default already does:
429
429
 
430
430
  - **Add a signal** where the default skips (a parent layout or layout-level
431
- parallel): `ctx.isAction(CartActions) || undefined` re-renders it after cart
432
- actions and defers otherwise. On a loader or route segment it changes nothing.
431
+ parallel): `ctx.isAction(CartActions) || ctx.stale || undefined` re-renders it
432
+ after cart actions and on Back after an action, and defers otherwise. On a
433
+ loader or route segment it changes nothing.
433
434
  - **Narrow after actions** where the default re-runs (loaders, the route, and
434
435
  segments inside the `path()`):
435
436
  `ctx.isAction() ? ctx.isAction(CartActions) : undefined` re-runs only for cart
@@ -508,8 +509,10 @@ consumer:
508
509
  import type { Revalidate } from "@rangojs/router";
509
510
  import * as CartActions from "./actions/cart";
510
511
 
512
+ // True on a stale Back/Forward too: it re-runs CartPage, and a producer that
513
+ // sits it out leaves the page reading undefined.
511
514
  const revalidateCart: Revalidate = (ctx) =>
512
- ctx.isAction(CartActions) || undefined;
515
+ ctx.stale || ctx.isAction(CartActions) || undefined;
513
516
 
514
517
  urls(({ path, layout, loader, revalidate }) => [
515
518
  layout(CartLayout, () => [
@@ -534,6 +537,41 @@ revalidates), flushes the prefetch cache, rotates the router's state cookie,
534
537
  and tells other open tabs to do the same. This happens for every action that
535
538
  reached the server, including ones that threw. No setup is needed.
536
539
 
540
+ ### When the user navigates while the action runs
541
+
542
+ Users rarely wait for an action. They click "Add to cart" and then a link, or
543
+ go Back. That navigation is an ordinary one: a prefetched link shows its page at
544
+ once, as it would with no action in flight. That page may show data from before
545
+ the write, such as a layout's cart count the navigation kept. So once the last
546
+ action in flight settles, the router revalidates the page on screen once, in
547
+ the background: every loader on it re-runs, and the result commits with no
548
+ `loading()` fallback and no remount. `useNavigation().state` stays `"idle"`. This covers an action that failed too.
549
+
550
+ ```text
551
+ /product/1: click "Add to cart", then a link to /checkout
552
+ /checkout shows at once (layout: cart:0)
553
+ the action settles
554
+ one background revalidation (layout: cart:1)
555
+ ```
556
+
557
+ A few details:
558
+
559
+ - If the user stays on the page, nothing extra is sent. The action's own
560
+ response already re-ran that page's loaders.
561
+ - The revalidation waits for a navigation the user started and never cancels
562
+ it. Several actions in flight share one revalidation.
563
+ - A `redirect()` from the action replaces it: the redirect target renders
564
+ fresh.
565
+ - If an `intercept()` is on screen, nothing runs. Leaving the intercept with
566
+ Back restores the page underneath and revalidates it as stale (the
567
+ action marked the history cache stale). Only loaders that opt in with
568
+ `revalidate(({ stale }) => stale)` re-run there; the others keep their data
569
+ until a request re-runs them.
570
+ - It holds no segments, so every loader on the page re-runs whatever its
571
+ `revalidate()` says, and the document cache never answers it.
572
+ - Going Back or Forward out of an intercept does not abort an action sent from
573
+ the intercept. `useAction()` and your `await` get its real result.
574
+
537
575
  ### `keepClientCache()` — skip it for no-op actions
538
576
 
539
577
  Some hot actions change nothing a route renders: a debounced draft autosave, an
@@ -555,8 +593,8 @@ export async function saveDraft(formData: FormData) {
555
593
 
556
594
  What it suppresses, for this action only: the stale-marking of the history
557
595
  cache, the prefetch flush, the state-cookie rotation, the cross-tab broadcast,
558
- and the background re-fetch the client runs when the user navigated away while
559
- the action was in flight. What it does not change:
596
+ and the background revalidation the client runs when the user navigated while
597
+ the action was in flight (above). What it does not change:
560
598
 
561
599
  - The action's own response still carries the revalidation render your
562
600
  `revalidate()` rules select, and the client applies it.
@@ -80,7 +80,7 @@ Defaults model a same-URL navigation with no action, for a route-owned loader: a
80
80
  | `method` | `string` | Overrides the request method (`POST` with an `action`, otherwise `GET`). |
81
81
  | `segmentType` | `"layout" \| "route" \| "parallel" \| "loader"` | The segment type the predicate is attached to. Defaults to `"loader"`. |
82
82
  | `belongsToRoute` | `boolean` | Defaults to `true`; `false` models a layout's loader, which a navigation keeps by default. |
83
- | `stale` | `boolean` | The `stale` arg (a history-entry background revalidation). Defaults to `false`. |
83
+ | `stale` | `boolean` | The `stale` arg (a history-entry background revalidation), which takes an action's defaults. Defaults to `false`. |
84
84
  | `routeName` | `string` | `toRouteName` (and `routeName`). |
85
85
  | `fromRouteName` | `string` | `fromRouteName`. |
86
86
  | `env` | `TEnv` | Environment bindings of the seeded context. |
@@ -20,13 +20,13 @@ A `Prerender` route is served as in production: from the artifact `router.matchF
20
20
 
21
21
  ### Options — `ServeShellRequestOptions`
22
22
 
23
- | Field | Type | Meaning |
24
- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
25
- | `cacheStore` | `SegmentCacheStore` | The store for this request, in place of the store your `createRouter({ cache })` config returns. The rest of that config (`enabled`, `searchParams`) still applies. Omit it to use the router's own store. |
26
- | `env` | `TEnv` | Bindings, as `router.fetch(request, { env })` passes them. A function `cache` config receives them. |
27
- | `headers` | `HeadersInit` | Request headers (e.g. `cookie`). A document request defaults `accept` to `text/html`. |
28
- | `partial` | `true \| { from?: string; rewriteTarget?: string; segments?: string[]; prefetch?: boolean; fill?: boolean; interceptSource?: string }` | Serve the navigation request the browser sends instead of a document GET: from `from` (default the site root) with `segments` mounted (default none). `prefetch: true` sends it as a `<Link>` prefetch (`X-Rango-Prefetch`), so `prefetch: false` work is deferred. `fill: true` sends the follow-up request (`_rsc_fill=1`) the browser makes after adopting a prefetch; pass the prefetch's delivered ids, without the deferred ones, as `segments`, and the same URL as `from`. `interceptSource` sends the intercept source (`X-RSC-Router-Intercept-Source`) the browser sends with the fill of an intercept, whose `from` is that source too. `rewriteTarget` sends what the page at `from` carried as its payload's `metadata.rewriteTarget` when middleware rewrote it (`_rsc_rt`), as the browser does, so the server diffs against the route that rendered that page. |
29
- | `action` | `{ ref: Function; id: string; args?: unknown[]; segments?: string[]; rewriteTarget?: string }` | Send the request a JS server action call makes from the page at `url`: a POST of `args` (encoded as the browser encodes them) with `?_rsc_action`, the `rsc-action` header, `X-RSC-Router-Client-Path` and `_rsc_segments` (the page's mounted segment ids, default none), plus the page's `rewriteTarget` as for `partial`. Registers `ref` under `id` (`<module id>#<export name>`). Not with `partial` or `form`. |
23
+ | Field | Type | Meaning |
24
+ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
25
+ | `cacheStore` | `SegmentCacheStore` | The store for this request, in place of the store your `createRouter({ cache })` config returns. The rest of that config (`enabled`, `searchParams`) still applies. Omit it to use the router's own store. |
26
+ | `env` | `TEnv` | Bindings, as `router.fetch(request, { env })` passes them. A function `cache` config receives them. |
27
+ | `headers` | `HeadersInit` | Request headers (e.g. `cookie`). A document request defaults `accept` to `text/html`. |
28
+ | `partial` | `true \| { from?: string; rewriteTarget?: string; segments?: string[]; prefetch?: boolean; fill?: boolean; interceptSource?: string; stale?: boolean }` | Serve the navigation request the browser sends instead of a document GET: from `from` (default the site root) with `segments` mounted (default none). `prefetch: true` sends it as a `<Link>` prefetch (`X-Rango-Prefetch`), so `prefetch: false` work is deferred. `fill: true` sends the follow-up request (`_rsc_fill=1`) the browser makes after adopting a prefetch; pass the prefetch's delivered ids, without the deferred ones, as `segments`, and the same URL as `from`. `interceptSource` sends the intercept source (`X-RSC-Router-Intercept-Source`) the browser sends with the fill of an intercept, whose `from` is that source too. `rewriteTarget` sends what the page at `from` carried as its payload's `metadata.rewriteTarget` when middleware rewrote it (`_rsc_rt`), as the browser does, so the server diffs against the route that rendered that page. `stale: true` sends a stale request (`_rsc_stale`), as the browser sends it: every `revalidate()` sees `stale: true` (see `revalidate()`'s `stale`), segments take an action's revalidation defaults, and it renders live, as an action does: neither the route cache (`cache()`) nor the document cache serves or stores it, and a `ppr` route's `replayStatus` is `{ outcome: "BYPASS", reason: "stale" }`. |
29
+ | `action` | `{ ref: Function; id: string; args?: unknown[]; segments?: string[]; rewriteTarget?: string }` | Send the request a JS server action call makes from the page at `url`: a POST of `args` (encoded as the browser encodes them) with `?_rsc_action`, the `rsc-action` header, `X-RSC-Router-Client-Path` and `_rsc_segments` (the page's mounted segment ids, default none), plus the page's `rewriteTarget` as for `partial`. Registers `ref` under `id` (`<module id>#<export name>`). Not with `partial` or `form`. |
30
30
 
31
31
  ### Returns — `ServeShellRequestResult`
32
32
 
@@ -228,8 +228,9 @@ them right:
228
228
 
229
229
  - A boolean is a hard answer, `undefined` defers to the default. Pick by the
230
230
  default you are changing: the ternary narrows a loader that re-runs after
231
- every action; `isAction(X) || undefined` adds a re-run where the default
232
- skips (a server layout's own handler).
231
+ every action; `isAction(X) || stale || undefined` adds a re-run, after `X`
232
+ and on Back after an action, where the default skips (a server layout's own
233
+ handler).
233
234
  - `revalidate(true)` and `revalidate(false)` are shorthands for `() => true`
234
235
  and `() => false`.
235
236
  - Never `revalidate(false)` on data keyed by a param: product to product then
@@ -6,11 +6,7 @@ import type { ActionEntry } from "./event-controller.js";
6
6
  * reconciliation; this only covers successfully-reconciled partial responses.
7
7
  */
8
8
  type ActionScenario =
9
- | {
10
- type: "navigated-away";
11
- historyKeyChanged: boolean;
12
- onInterceptRoute: boolean;
13
- }
9
+ | { type: "navigated-away" }
14
10
  | { type: "hmr-missing" }
15
11
  | { type: "consolidation-needed"; segmentIds: string[] }
16
12
  | { type: "concurrent-skip"; otherFetchingCount: number }
@@ -41,8 +37,6 @@ export interface ActionOutcomeInput {
41
37
  reconciledSegmentCount: number;
42
38
  /** Number of matched segment IDs from server */
43
39
  matchedCount: number;
44
- /** Current intercept source URL (null when not on intercept route) */
45
- currentInterceptSource: string | null;
46
40
  }
47
41
 
48
42
  // Segment IDs to re-fetch when concurrent actions each revalidated different
@@ -85,12 +79,7 @@ export function classifyActionOutcome(
85
79
  input.currentPathname !== input.actionStartPathname ||
86
80
  input.currentLocationKey !== input.actionStartLocationKey
87
81
  ) {
88
- return {
89
- type: "navigated-away",
90
- historyKeyChanged:
91
- input.currentLocationKey !== input.actionStartLocationKey,
92
- onInterceptRoute: input.currentInterceptSource !== null,
93
- };
82
+ return { type: "navigated-away" };
94
83
  }
95
84
 
96
85
  if (input.reconciledSegmentCount < input.matchedCount) {
@@ -6,9 +6,11 @@
6
6
  * start. While the fence is up, the decision of whether the action invalidated
7
7
  * anything is deferred to the response:
8
8
  *
9
- * - prefetch consumption is suspended (a queued prefetch result is not served),
10
- * - a genuine navigation during the flight fetches with `cache: "no-store"` so
11
- * it cannot be served stale bytes from the browser's Vary-keyed HTTP cache,
9
+ * - a navigation during the flight is an ordinary one: a prefetch is served,
10
+ * and the page it commits may hold pre-write data until the follow-up below,
11
+ * - a navigation without a prefetch, and a prefetch, fetch with
12
+ * `cache: "no-store"` so they cannot be served stale bytes from the
13
+ * browser's Vary-keyed HTTP cache,
12
14
  * - popstate reads are treated as stale-while-revalidate.
13
15
  *
14
16
  * Nothing is wiped, rotated, or broadcast while the fence is up — that is what
@@ -16,6 +18,12 @@
16
18
  * actions compose: each action raises and lowers its own reference, and the
17
19
  * fence is down only when the count reaches zero.
18
20
  *
21
+ * The drop runs the batch's follow-up: when an action of the batch did not
22
+ * land on the page on screen, the action bridge refetches that page once,
23
+ * whole, after the navigation the user started, unless an intercept is on
24
+ * screen (server-action-bridge.ts followUp; the rules are R1-R7 in
25
+ * docs/internal/execution-model.md).
26
+ *
19
27
  * The refcount is a single module-level counter, not keyed by routerId.
20
28
  * Consumers (navigation-client.ts, prefetch/fetch.ts, navigation-bridge.ts)
21
29
  * read it unscoped, so an action in one router would suppress another router's
@@ -52,6 +52,18 @@ export interface NavigationEntry {
52
52
  phase: NavigationPhase;
53
53
  startedAt: number;
54
54
  options?: NavigateOptionsInternal & { skipLoadingState?: boolean };
55
+ /**
56
+ * Resolves once the navigation has ended: its transaction ended and every
57
+ * stream it opened closed. True when it committed, false when it ended
58
+ * without a commit (a failure) or was left (aborted, which settles it at
59
+ * once). A `prefetch: false` fill holds the transaction open
60
+ * (partial-update.ts awaits fillHoles before it resolves) and its stream
61
+ * until the fill's answer streamed, so a waiter never cuts a fill or a
62
+ * stream short. Only the action bridge's follow-up awaits it
63
+ * (server-action-bridge.ts followUp); no hook reads it, and settling it
64
+ * notifies nothing.
65
+ */
66
+ settled: Promise<boolean>;
55
67
  }
56
68
 
57
69
  /**
@@ -351,6 +363,12 @@ export interface EventController {
351
363
 
352
364
  // Direct state access for advanced use
353
365
  getCurrentNavigation(): NavigationEntry | null;
366
+ /**
367
+ * The newest navigation until it settles (NavigationEntry.settled): before
368
+ * its commit, and after it while its transaction or a stream is still
369
+ * open. Null once it settled or was left.
370
+ */
371
+ getUnsettledNavigation(): NavigationEntry | null;
354
372
  getInflightActions(): Map<string, ActionEntry>;
355
373
  /** Whether any concurrent actions have occurred (shared across all handles) */
356
374
  hadAnyConcurrentActions(): boolean;
@@ -513,6 +531,9 @@ export function createEventController(
513
531
  // links a request to its signal only until then): the navigation stops
514
532
  // reading it, and it streams to its end.
515
533
  let streamingNavigation: AbortController | null = null;
534
+ // The newest navigation until it settles (getUnsettledNavigation). Not
535
+ // derived state: no hook reads it, and moving it notifies nothing.
536
+ let unsettledNavigation: NavigationEntry | null = null;
516
537
 
517
538
  const inflightActions = new Map<string, ActionEntry>();
518
539
 
@@ -743,19 +764,31 @@ export function createEventController(
743
764
  }
744
765
 
745
766
  const abort = new AbortController();
767
+ let resolveSettled!: (committed: boolean) => void;
746
768
  const entry: NavigationEntry = {
747
769
  url,
748
770
  abort,
749
771
  phase: "fetching",
750
772
  startedAt: Date.now(),
751
773
  options,
774
+ settled: new Promise((resolve) => (resolveSettled = resolve)),
775
+ };
776
+ const settle = (committed: boolean): void => {
777
+ if (unsettledNavigation === entry) unsettledNavigation = null;
778
+ resolveSettled(committed);
752
779
  };
780
+ abort.signal.addEventListener("abort", () => settle(false));
753
781
 
754
782
  currentNavigation = entry;
783
+ unsettledNavigation = entry;
755
784
  notify();
756
785
 
757
786
  let completed = false;
787
+ let disposed = false;
758
788
  let openStreams = 0;
789
+ const settleIfEnded = (): void => {
790
+ if (disposed && openStreams === 0) settle(completed);
791
+ };
759
792
 
760
793
  return {
761
794
  abort,
@@ -780,6 +813,7 @@ export function createEventController(
780
813
  if (--openStreams === 0 && streamingNavigation === abort) {
781
814
  streamingNavigation = null;
782
815
  }
816
+ settleIfEnded();
783
817
  notify();
784
818
  },
785
819
  };
@@ -797,6 +831,8 @@ export function createEventController(
797
831
 
798
832
  // Disposable: cleanup if not completed (e.g., error thrown)
799
833
  [Symbol.dispose]() {
834
+ disposed = true;
835
+ settleIfEnded();
800
836
  // If aborted by another navigation, don't touch state
801
837
  if (abort.signal.aborted) return;
802
838
 
@@ -1287,6 +1323,7 @@ export function createEventController(
1287
1323
 
1288
1324
  // Direct access
1289
1325
  getCurrentNavigation: () => currentNavigation,
1326
+ getUnsettledNavigation: () => unsettledNavigation,
1290
1327
  getInflightActions: () => inflightActions,
1291
1328
  hadAnyConcurrentActions: () => hadAnyConcurrentActions,
1292
1329
  };
@@ -169,13 +169,21 @@ export function stripShellMissMarker(): void {
169
169
  *
170
170
  * A slot is recognised by its key alone (the `~r` suffix): at start-up the
171
171
  * module that defines it may not be loaded.
172
+ *
173
+ * The same pass drops the entry's intercept marks (`intercept`,
174
+ * `sourceUrl`): a document request never renders an intercept, so the entry
175
+ * is a full page for later Back/Forward.
172
176
  */
173
177
  export function clearLocationStateOnDocumentLoad(): void {
174
178
  const state: unknown = window.history.state;
175
179
  if (state === null || typeof state !== "object") return;
176
180
  let next: Record<string, unknown> | undefined;
177
181
  for (const key in state) {
178
- if (isLocationStateKey(key) && key.endsWith(CLEAR_ON_RELOAD_KEY_SUFFIX)) {
182
+ if (
183
+ (isLocationStateKey(key) && key.endsWith(CLEAR_ON_RELOAD_KEY_SUFFIX)) ||
184
+ key === "intercept" ||
185
+ key === "sourceUrl"
186
+ ) {
179
187
  next ??= { ...state };
180
188
  delete next[key];
181
189
  }
@@ -1,5 +1,7 @@
1
1
  import type { ResolvedSegment } from "./types.js";
2
2
  import type { SlotState } from "../types.js";
3
+ import { isPlaceholder } from "../types/segments.js";
4
+ import { loaderParentId } from "./react/filter-segment-order.js";
3
5
 
4
6
  /**
5
7
  * Check if a segment is an intercept segment.
@@ -51,3 +53,52 @@ export function hasActiveIntercept(slots?: Record<string, SlotState>): boolean {
51
53
  export function isInterceptOnlyCache(segments: ResolvedSegment[]): boolean {
52
54
  return segments.some(isInterceptSegment);
53
55
  }
56
+
57
+ /**
58
+ * An intercept's `matched`: the client's ids without any of the slot's (its
59
+ * id and every loader segment on it, another intercept's into the same slot
60
+ * included), then the slot's (interceptSegmentIds). One order for the
61
+ * prefetch, its fill, an action and a revisit: a fill whose `matched` differs
62
+ * from its tree's is rendered as a new tree (browser/partial-update.ts
63
+ * fillHoles). Shared by router/match-result.ts and wholeInterceptMatched.
64
+ */
65
+ export function interceptMatched(
66
+ clientIds: readonly string[],
67
+ slotIds: readonly string[],
68
+ ): string[] {
69
+ // loaderParentId leaves a non-loader id as it is: the slot's own id goes too.
70
+ return [
71
+ ...clientIds.filter((id) => loaderParentId(id) !== slotIds[0]),
72
+ ...slotIds,
73
+ ];
74
+ }
75
+
76
+ /**
77
+ * The `matched` the browser keeps for an intercept answer to a request that
78
+ * held no segments (a followed redirect, a Back/Forward revalidation with
79
+ * handles pending, the dev HMR refetch), or undefined to keep the answer's.
80
+ * The server never renders the page under an intercept and lists it only
81
+ * from the ids a request held, so such an answer has an empty outlet under
82
+ * the slot. The page it lands over (`held`) stays, as the server lists it for
83
+ * a request that holds it.
84
+ */
85
+ export function wholeInterceptMatched(
86
+ held: readonly ResolvedSegment[],
87
+ answer: {
88
+ matched?: string[];
89
+ segments?: ResolvedSegment[];
90
+ slots?: Record<string, SlotState>;
91
+ },
92
+ ): string[] | undefined {
93
+ if (!hasActiveIntercept(answer.slots)) return undefined;
94
+ const heldIds = held.filter((s) => !isPlaceholder(s)).map((s) => s.id);
95
+ if (heldIds.length === 0) return undefined;
96
+ const slot = answer.segments?.find(
97
+ (s) => isInterceptSegment(s) && s.type !== "loader",
98
+ );
99
+ if (!slot) return undefined;
100
+ const slotLoaderIds = (answer.matched ?? []).filter(
101
+ (id) => id !== slot.id && loaderParentId(id) === slot.id,
102
+ );
103
+ return interceptMatched(heldIds, [slot.id, ...slotLoaderIds]);
104
+ }
@@ -643,16 +643,17 @@ export function createNavigationBridge(
643
643
  const isIntercept = historyState?.intercept === true;
644
644
  const interceptSourceUrl = historyState?.sourceUrl;
645
645
 
646
- // Check if intercept context is changing (same URL, different intercept state)
647
- // If so, abort in-flight actions - their results would be for wrong context
646
+ // Actions in flight keep running across an intercept change, as across
647
+ // any navigation (server-action-bridge.ts followUp).
648
648
  const currentInterceptSource = store.getInterceptSourceUrl();
649
- const newInterceptSource = interceptSourceUrl ?? null;
650
- if (currentInterceptSource !== newInterceptSource) {
651
- debugLog(
652
- `[Browser] Intercept context changing (${currentInterceptSource} -> ${newInterceptSource}), aborting in-flight actions`,
653
- );
654
- eventController.abortAllActions();
655
- }
649
+ // An intercept is fetched only from its source page (the page under
650
+ // an open intercept counts as on screen). From any other page the
651
+ // entry loads as a document, which renders its URL's full page.
652
+ const loadsDocument =
653
+ isIntercept &&
654
+ generateHistoryKey(
655
+ currentInterceptSource ?? store.getSegmentState().currentUrl,
656
+ ) !== generateHistoryKey(interceptSourceUrl);
656
657
 
657
658
  // Popstate that exits an intercept to a non-intercept destination. The
658
659
  // fallback fetch path below needs `leave-intercept` mode so it filters
@@ -673,13 +674,6 @@ export function createNavigationBridge(
673
674
  historyKey,
674
675
  );
675
676
 
676
- // If this is an intercept, restore the intercept context
677
- if (isIntercept && interceptSourceUrl) {
678
- store.setInterceptSourceUrl(interceptSourceUrl);
679
- } else {
680
- store.setInterceptSourceUrl(null);
681
- }
682
-
683
677
  // Helper to check if streaming is in progress
684
678
  const isStreaming = () => eventController.getState().isStreaming;
685
679
 
@@ -701,6 +695,7 @@ export function createNavigationBridge(
701
695
  const isStale = (cached?.stale ?? false) || isActionFenceActive();
702
696
 
703
697
  if (isRestorable(cachedSegments)) {
698
+ store.setInterceptSourceUrl(interceptSourceUrl ?? null);
704
699
  // transition({ when }) decides the restore (kind "pop") against the
705
700
  // entry being left, so before the store moves to the restored entry.
706
701
  // The decision the entry was committed with is not reused: it was
@@ -872,7 +867,15 @@ export function createNavigationBridge(
872
867
  debugLog("[Browser] History cache miss for key:", historyKey);
873
868
  }
874
869
 
875
- // Fetch if not cached
870
+ // Fetch if not cached. The document load starts at the top: pagehide
871
+ // saves the scroll on screen under this entry, and the browser
872
+ // restores it after the reload. Instant, so `scroll-behavior: smooth`
873
+ // cannot leave it part-way when pagehide saves it.
874
+ if (loadsDocument) {
875
+ window.scrollTo({ top: 0, behavior: "instant" });
876
+ return window.location.reload();
877
+ }
878
+ store.setInterceptSourceUrl(interceptSourceUrl ?? null);
876
879
  const tx = createNavigationTransaction(store, eventController, url, {
877
880
  replace: true,
878
881
  });
@@ -38,7 +38,7 @@ import {
38
38
  type DecodedPrefetch,
39
39
  } from "./prefetch/cache.js";
40
40
  import { cancelAllPrefetches } from "./prefetch/loader.js";
41
- import { FILL_PARAM } from "../prefetch-markers.js";
41
+ import { FILL_PARAM, STALE_PARAM } from "../prefetch-markers.js";
42
42
 
43
43
  /**
44
44
  * Create a navigation client for fetching RSC payloads
@@ -108,7 +108,7 @@ export function createNavigationClient(
108
108
  fetchUrl.searchParams.set("_rsc_partial", "true");
109
109
  fetchUrl.searchParams.set("_rsc_segments", segmentIds.join(","));
110
110
  if (staleRevalidation) {
111
- fetchUrl.searchParams.set("_rsc_stale", "true");
111
+ fetchUrl.searchParams.set(STALE_PARAM, "true");
112
112
  }
113
113
  if (version) {
114
114
  fetchUrl.searchParams.set("_rsc_v", version);
@@ -144,15 +144,10 @@ export function createNavigationClient(
144
144
  // server-action invalidation) auto-invalidates both scopes.
145
145
  // Skip cache for stale revalidation (needs fresh data), HMR (needs
146
146
  // fresh modules), and intercept contexts (source-dependent responses).
147
- // Suspend prefetch consumption while an action is in flight: a queued
148
- // prefetch holds pre-mutation data and must not be served until the
149
- // action's response decides whether anything changed.
147
+ // A prefetch served during an action's flight holds pre-write data:
148
+ // the action bridge's follow-up refetches the page it commits.
150
149
  const canUsePrefetch =
151
- !staleRevalidation &&
152
- !hmr &&
153
- !fill &&
154
- !interceptSourceUrl &&
155
- !isActionFenceActive();
150
+ !staleRevalidation && !hmr && !fill && !interceptSourceUrl;
156
151
  const rangoState = getRangoState();
157
152
  const wildcardKey = buildPrefetchKey(rangoState, fetchUrl);
158
153
  const cacheKey = buildSourceKey(rangoState, previousUrl, fetchUrl);
@@ -156,7 +156,6 @@ export function createNavigationTransaction(
156
156
  url: string,
157
157
  options?: NavigateOptionsInternal & { skipLoadingState?: boolean },
158
158
  ): NavigationTransaction {
159
- let committed = false;
160
159
  const currentUrl = window.location.href;
161
160
 
162
161
  const handle = eventController.startNavigation(url, options);
@@ -165,8 +164,6 @@ export function createNavigationTransaction(
165
164
  * Commit the navigation - updates store and URL atomically
166
165
  */
167
166
  function commit(opts: CommitOptions): CommitResult {
168
- committed = true;
169
-
170
167
  const {
171
168
  url,
172
169
  segmentIds,
@@ -304,14 +301,10 @@ export function createNavigationTransaction(
304
301
  };
305
302
  },
306
303
 
304
+ // Reaches the handle committed or not: its dispose settles the entry
305
+ // (NavigationEntry.settled), and resets no state once it completed.
307
306
  [Symbol.dispose]() {
308
- if (handle.signal.aborted) {
309
- return;
310
- }
311
-
312
- if (!committed) {
313
- handle[Symbol.dispose]();
314
- }
307
+ if (!handle.signal.aborted) handle[Symbol.dispose]();
315
308
  },
316
309
  };
317
310
  }