@rangojs/router 0.26.5 → 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 (59) hide show
  1. package/dist/bin/rango.js +2 -2
  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/types/vite/plugins/server-ref-hashing.d.ts +8 -1
  18. package/dist/vite/index.js +10 -6
  19. package/package.json +2 -2
  20. package/skills/client-urls/SKILL.md +2 -1
  21. package/skills/document-cache/SKILL.md +5 -0
  22. package/skills/intercept/SKILL.md +36 -6
  23. package/skills/layout/SKILL.md +11 -7
  24. package/skills/loader/SKILL.md +30 -13
  25. package/skills/middleware/SKILL.md +1 -1
  26. package/skills/migrate-nextjs/SKILL.md +5 -4
  27. package/skills/parallel/SKILL.md +5 -4
  28. package/skills/ppr/SKILL.md +1 -0
  29. package/skills/rango/SKILL.md +18 -10
  30. package/skills/server-actions/SKILL.md +43 -5
  31. package/skills/testing/predicates.md +1 -1
  32. package/skills/testing/serve-shell-request.md +7 -7
  33. package/skills/ux-architecture/SKILL.md +3 -2
  34. package/src/browser/action-coordinator.ts +2 -13
  35. package/src/browser/action-fence.ts +11 -3
  36. package/src/browser/event-controller.ts +37 -0
  37. package/src/browser/history-state.ts +9 -1
  38. package/src/browser/intercept-utils.ts +51 -0
  39. package/src/browser/navigation-bridge.ts +20 -17
  40. package/src/browser/navigation-client.ts +5 -10
  41. package/src/browser/navigation-transaction.ts +3 -10
  42. package/src/browser/partial-update.ts +8 -0
  43. package/src/browser/rsc-router.tsx +16 -2
  44. package/src/browser/server-action-bridge.ts +223 -78
  45. package/src/cache/document-cache.ts +12 -6
  46. package/src/prefetch-markers.ts +13 -5
  47. package/src/router/match-context.ts +13 -0
  48. package/src/router/match-middleware/cache-lookup.ts +21 -10
  49. package/src/router/match-middleware/cache-store.ts +10 -4
  50. package/src/router/match-result.ts +1 -20
  51. package/src/router/navigation-snapshot.ts +2 -1
  52. package/src/router/revalidation.ts +33 -7
  53. package/src/router/router-context.ts +4 -22
  54. package/src/rsc/rsc-rendering.ts +12 -4
  55. package/src/rsc/shell-capture-constants.ts +2 -0
  56. package/src/ssr/ssr-root.tsx +1 -0
  57. package/src/testing/serve-shell-request.ts +20 -2
  58. package/src/types/handler-context.ts +9 -6
  59. package/src/vite/plugins/server-ref-hashing.ts +16 -5
package/dist/bin/rango.js CHANGED
@@ -1644,7 +1644,7 @@ var init_package = __esm({
1644
1644
  "package.json"() {
1645
1645
  package_default = {
1646
1646
  name: "@rangojs/router",
1647
- version: "0.26.5",
1647
+ version: "0.27.0",
1648
1648
  description: "Django-inspired RSC router with composable URL patterns",
1649
1649
  keywords: [
1650
1650
  "react",
@@ -1852,7 +1852,7 @@ var init_package = __esm({
1852
1852
  },
1853
1853
  dependencies: {
1854
1854
  "@types/debug": "^4.1.12",
1855
- "@vitejs/plugin-rsc": "^0.5.36",
1855
+ "@vitejs/plugin-rsc": "^0.5.38",
1856
1856
  debug: "^4.4.1",
1857
1857
  "magic-string": "^0.30.17",
1858
1858
  picomatch: "^4.0.4",
@@ -6,8 +6,6 @@ import type { ActionEntry } from "./event-controller.js";
6
6
  */
7
7
  type ActionScenario = {
8
8
  type: "navigated-away";
9
- historyKeyChanged: boolean;
10
- onInterceptRoute: boolean;
11
9
  } | {
12
10
  type: "hmr-missing";
13
11
  } | {
@@ -44,8 +42,6 @@ export interface ActionOutcomeInput {
44
42
  reconciledSegmentCount: number;
45
43
  /** Number of matched segment IDs from server */
46
44
  matchedCount: number;
47
- /** Current intercept source URL (null when not on intercept route) */
48
- currentInterceptSource: string | null;
49
45
  }
50
46
  /**
51
47
  * Classify a post-reconciliation action outcome. Ordered priority chain: each
@@ -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
@@ -20,6 +20,18 @@ export interface NavigationEntry {
20
20
  options?: NavigateOptionsInternal & {
21
21
  skipLoadingState?: boolean;
22
22
  };
23
+ /**
24
+ * Resolves once the navigation has ended: its transaction ended and every
25
+ * stream it opened closed. True when it committed, false when it ended
26
+ * without a commit (a failure) or was left (aborted, which settles it at
27
+ * once). A `prefetch: false` fill holds the transaction open
28
+ * (partial-update.ts awaits fillHoles before it resolves) and its stream
29
+ * until the fill's answer streamed, so a waiter never cuts a fill or a
30
+ * stream short. Only the action bridge's follow-up awaits it
31
+ * (server-action-bridge.ts followUp); no hook reads it, and settling it
32
+ * notifies nothing.
33
+ */
34
+ settled: Promise<boolean>;
23
35
  }
24
36
  /**
25
37
  * Entry tracking an in-flight action
@@ -279,6 +291,12 @@ export interface EventController {
279
291
  /** True once after a `treeless` commit. */
280
292
  takeTreelessLocationState(): boolean;
281
293
  getCurrentNavigation(): NavigationEntry | null;
294
+ /**
295
+ * The newest navigation until it settles (NavigationEntry.settled): before
296
+ * its commit, and after it while its transaction or a stream is still
297
+ * open. Null once it settled or was left.
298
+ */
299
+ getUnsettledNavigation(): NavigationEntry | null;
282
300
  getInflightActions(): Map<string, ActionEntry>;
283
301
  /** Whether any concurrent actions have occurred (shared across all handles) */
284
302
  hadAnyConcurrentActions(): boolean;
@@ -46,6 +46,10 @@ export declare function stripShellMissMarker(): void;
46
46
  *
47
47
  * A slot is recognised by its key alone (the `~r` suffix): at start-up the
48
48
  * module that defines it may not be loaded.
49
+ *
50
+ * The same pass drops the entry's intercept marks (`intercept`,
51
+ * `sourceUrl`): a document request never renders an intercept, so the entry
52
+ * is a full page for later Back/Forward.
49
53
  */
50
54
  export declare function clearLocationStateOnDocumentLoad(): void;
51
55
  /**
@@ -31,3 +31,26 @@ export declare function hasActiveIntercept(slots?: Record<string, SlotState>): b
31
31
  * whether interception happens depends on the current page context.
32
32
  */
33
33
  export declare function isInterceptOnlyCache(segments: ResolvedSegment[]): boolean;
34
+ /**
35
+ * An intercept's `matched`: the client's ids without any of the slot's (its
36
+ * id and every loader segment on it, another intercept's into the same slot
37
+ * included), then the slot's (interceptSegmentIds). One order for the
38
+ * prefetch, its fill, an action and a revisit: a fill whose `matched` differs
39
+ * from its tree's is rendered as a new tree (browser/partial-update.ts
40
+ * fillHoles). Shared by router/match-result.ts and wholeInterceptMatched.
41
+ */
42
+ export declare function interceptMatched(clientIds: readonly string[], slotIds: readonly string[]): string[];
43
+ /**
44
+ * The `matched` the browser keeps for an intercept answer to a request that
45
+ * held no segments (a followed redirect, a Back/Forward revalidation with
46
+ * handles pending, the dev HMR refetch), or undefined to keep the answer's.
47
+ * The server never renders the page under an intercept and lists it only
48
+ * from the ids a request held, so such an answer has an empty outlet under
49
+ * the slot. The page it lands over (`held`) stays, as the server lists it for
50
+ * a request that holds it.
51
+ */
52
+ export declare function wholeInterceptMatched(held: readonly ResolvedSegment[], answer: {
53
+ matched?: string[];
54
+ segments?: ResolvedSegment[];
55
+ slots?: Record<string, SlotState>;
56
+ }): string[] | undefined;
@@ -21,7 +21,7 @@ export interface InitBrowserAppOptions {
21
21
  storeOptions?: {
22
22
  /**
23
23
  * Maximum number of history entries to cache
24
- * @default 10
24
+ * @default 20
25
25
  */
26
26
  cacheSize?: number;
27
27
  };
@@ -1,14 +1,21 @@
1
1
  /**
2
- * The wire markers of prefetches and of `prefetch: false`
3
- * (docs/design/prefetch-false.md): shared by the browser and the server
4
- * (router/segment-resolution/prefetch-deferral.ts requestKind,
5
- * rsc/prefetch-render-error.ts). Dependency free, like segment-fragments.ts:
6
- * the browser must not import the server modules that use them.
2
+ * The wire markers of prefetches, of `prefetch: false`
3
+ * (docs/design/prefetch-false.md) and of stale requests: shared by the browser
4
+ * and the server (router/segment-resolution/prefetch-deferral.ts requestKind,
5
+ * rsc/prefetch-render-error.ts, rsc/rsc-rendering.ts,
6
+ * cache/document-cache.ts, router/navigation-snapshot.ts). Dependency free,
7
+ * like segment-fragments.ts: the browser must not import the server modules
8
+ * that use them.
7
9
  */
8
10
  /** Request header of a `<Link>` prefetch (browser/prefetch/fetch.ts). */
9
11
  export declare const PREFETCH_HEADER = "X-Rango-Prefetch";
10
12
  /** Query param of a fill request (browser/navigation-client.ts). */
11
13
  export declare const FILL_PARAM = "_rsc_fill";
14
+ /**
15
+ * Query param of a stale request (browser/navigation-client.ts): `revalidate()`
16
+ * sees `stale: true` (router/navigation-snapshot.ts).
17
+ */
18
+ export declare const STALE_PARAM = "_rsc_stale";
12
19
  /**
13
20
  * The last row of a prefetch whose render reported an error
14
21
  * (rsc/prefetch-render-error.ts): a Flight hint with a code React does not
@@ -166,6 +166,14 @@ export interface MatchContext<TEnv = any> {
166
166
  }>;
167
167
  isFullMatch: boolean;
168
168
  }
169
+ /**
170
+ * An action, or a stale partial (`_rsc_stale`: Back/Forward onto an entry
171
+ * marked stale, `refresh()`, the cross-tab refresh, the action follow-up, a
172
+ * zero-segment refetch), renders live: no route-cache or prerender-store read
173
+ * a live handler can replace, and no store. An action's render is never
174
+ * stored, so a record can be older than the copy the client holds after it.
175
+ */
176
+ export declare function rendersLive(ctx: Pick<MatchContext, "isAction" | "stale">): boolean;
169
177
  /**
170
178
  * Mutable state that flows through the pipeline
171
179
  */
@@ -93,7 +93,7 @@
93
93
  */
94
94
  import type { ResolvedSegment } from "../../types.js";
95
95
  import { type EntryData } from "../../server/context.js";
96
- import type { MatchContext, MatchPipelineState } from "../match-context.js";
96
+ import { type MatchContext, type MatchPipelineState } from "../match-context.js";
97
97
  import type { PrerenderStore } from "../../prerender/store.js";
98
98
  import type { PrerenderTargetObject, ResolvedPrerender } from "../../prerender/on-demand.js";
99
99
  import { type RequestContext } from "../../server/request-context.js";
@@ -19,7 +19,7 @@
19
19
  * +---------------------+
20
20
  * | Should skip cache? |
21
21
  * | - !cacheScope |──yes──> return
22
- * | - isAction |
22
+ * | - rendersLive |
23
23
  * | - cacheHit |
24
24
  * | - method !== GET |
25
25
  * +---------------------+
@@ -101,7 +101,7 @@
101
101
  * - Non-GET request (only GET requests are cacheable)
102
102
  */
103
103
  import type { ResolvedSegment } from "../../types.js";
104
- import type { MatchContext, MatchPipelineState } from "../match-context.js";
104
+ import { type MatchContext, type MatchPipelineState } from "../match-context.js";
105
105
  import type { GeneratorMiddleware } from "./cache-lookup.js";
106
106
  /**
107
107
  * Creates cache store middleware
@@ -40,8 +40,13 @@ export interface EvaluateRevalidationOptions<TEnv> {
40
40
  context: HandlerContext<any, TEnv>;
41
41
  /** Action context if triggered by action */
42
42
  actionContext?: ActionContext;
43
- /** If true, this is a stale cache revalidation request */
43
+ /** The browser's `_rsc_stale` signal: seeds the action defaults. */
44
44
  stale?: boolean;
45
+ /**
46
+ * The route-cache entry is past its TTL (SWR). Predicates see it as `stale`;
47
+ * it never seeds the action defaults.
48
+ */
49
+ cacheStale?: boolean;
45
50
  /** Trace source hint for the revalidation trace */
46
51
  traceSource?: RevalidationTraceEntry["source"];
47
52
  /**
@@ -4,6 +4,7 @@ import type { HandlerContext, ResolvedSegment, ShouldRevalidateFn } from "../typ
4
4
  import type { RouteMatchResult } from "./pattern-matching.js";
5
5
  import type { TelemetrySink } from "./telemetry.js";
6
6
  import type { ResolveSegmentOptions } from "./segment-resolution.js";
7
+ import type { EvaluateRevalidationOptions } from "./revalidation.js";
7
8
  /**
8
9
  * Revalidation context passed to segment resolution
9
10
  */
@@ -70,23 +71,7 @@ export interface RouterContext<TEnv = any> {
70
71
  items: T[];
71
72
  matchedIds: string[];
72
73
  }>;
73
- evaluateRevalidation: (params: {
74
- segment: ResolvedSegment;
75
- prevParams: Record<string, string>;
76
- getPrevSegment: (() => Promise<ResolvedSegment | undefined>) | null;
77
- request: Request;
78
- prevUrl: URL;
79
- nextUrl: URL;
80
- revalidations: Array<{
81
- name: string;
82
- fn: any;
83
- }>;
84
- routeKey: string;
85
- context: HandlerContext<any, TEnv>;
86
- actionContext?: any;
87
- stale?: boolean;
88
- traceSource?: "segment-resolution" | "cache-hit" | "loader" | "parallel" | "orphan-layout" | "route-handler" | "layout-handler" | "intercept-loader";
89
- }) => Promise<boolean>;
74
+ evaluateRevalidation: (params: EvaluateRevalidationOptions<TEnv>) => Promise<boolean>;
90
75
  getRequestContext: () => {
91
76
  waitUntil: (fn: () => Promise<void>) => void;
92
77
  _handleStore?: any;
@@ -41,7 +41,7 @@ export declare const PPR_REPLAY_STATUS_HEADER: string;
41
41
  * producer (rsc-rendering.ts) and the parser (testing/shell-status.ts): a
42
42
  * reason the parser does not list parses as null.
43
43
  */
44
- export declare const PPR_REPLAY_BYPASS_REASONS: readonly ["method", "dynamic", "nonce", "store-unavailable", "passive-read-unsupported", "no-navigation-context", "prerender-store", "intercept", "cache-disabled", "read-error", "no-entry", "invalid-version", "corrupt-entry", "no-segment-snapshot", "snapshot-miss", "explicit-cache-hit", "rewritten"];
44
+ export declare const PPR_REPLAY_BYPASS_REASONS: readonly ["method", "stale", "dynamic", "nonce", "store-unavailable", "passive-read-unsupported", "no-navigation-context", "prerender-store", "intercept", "cache-disabled", "read-error", "no-entry", "invalid-version", "corrupt-entry", "no-segment-snapshot", "snapshot-miss", "explicit-cache-hit", "rewritten"];
45
45
  /** One of {@link PPR_REPLAY_BYPASS_REASONS}. */
46
46
  export type PprReplayBypassReason = (typeof PPR_REPLAY_BYPASS_REASONS)[number];
47
47
  /**
@@ -76,6 +76,12 @@ export interface ServeShellRequestOptions<TEnv = any> {
76
76
  * that source. `rewriteTarget` sends the `metadata.rewriteTarget` the page at
77
77
  * `from` carried when middleware rewrote it (`_rsc_rt`), as the browser
78
78
  * does: the server then diffs against the route that rendered that page.
79
+ * `stale: true` sends a stale request (`_rsc_stale`), as the browser sends
80
+ * it: every `revalidate()` sees `stale: true` (see `revalidate()`'s `stale`)
81
+ * and segments take an action's revalidation defaults; it renders live, as
82
+ * an action does, so neither the route cache nor the document cache answers
83
+ * or stores it, and a `ppr` route's `replayStatus` is
84
+ * `{ outcome: "BYPASS", reason: "stale" }`.
79
85
  */
80
86
  partial?: true | {
81
87
  from?: string;
@@ -84,6 +90,7 @@ export interface ServeShellRequestOptions<TEnv = any> {
84
90
  prefetch?: boolean;
85
91
  fill?: boolean;
86
92
  interceptSource?: string;
93
+ stale?: boolean;
87
94
  };
88
95
  /**
89
96
  * POST this form as a submit before hydration: the no-JS request the
@@ -510,10 +510,10 @@ export type IsActionFn = (...actions: ActionRef[]) => boolean;
510
510
  * ```ts
511
511
  * import * as CartActions from "./actions/cart";
512
512
  *
513
- * // Idiom A — "mine, else defer": re-render on my actions, otherwise return
514
- * // undefined so the segment's default decision still applies (and downstream
515
- * // revalidators get a say).
516
- * revalidate((ctx) => ctx.isAction(CartActions) || undefined)
513
+ * // Idiom A — "mine, else defer": re-render on my actions and on Back after
514
+ * // an action, otherwise return undefined so the segment's default decision
515
+ * // still applies (and downstream revalidators get a say).
516
+ * revalidate((ctx) => ctx.isAction(CartActions) || ctx.stale || undefined)
517
517
  *
518
518
  * // Idiom B — "mine only": re-render on my actions and suppress everything
519
519
  * // else (isAction returns a raw boolean, so an unrelated action yields false).
@@ -629,8 +629,11 @@ export type ShouldRevalidateFn<TParams = GenericParams, TEnv = any> = (args: {
629
629
  */
630
630
  toRouteName?: DefaultRouteName;
631
631
  /**
632
- * `true` when the browser signals that data may be stale — typically because
633
- * a server action was executed in this or another tab (`_rsc_stale` header).
632
+ * `true` on a revalidation of a page the browser marked stale (the
633
+ * `_rsc_stale` query param): Back/Forward onto a history entry after a
634
+ * server action in this or another tab, or while one is in flight. A request
635
+ * that holds no segments (the follow-up after an action, `refresh()`) is
636
+ * marked stale too, but there every segment renders and no predicate runs.
634
637
  *
635
638
  * This is NOT segment cache staleness (loaders are never segment-cached).
636
639
  * Use this to decide whether loader data should be re-fetched after an
@@ -3,7 +3,14 @@ import type { Plugin } from "vite";
3
3
  * Replace dev-style server-reference ids with their production hashes. The temp
4
4
  * server renders Static/Prerender in a dev/serve Vite server, so plugin-rsc mints
5
5
  * a dev-style id (e.g. "/src/urls/prerender.tsx"); rewriting only the id (group 2)
6
- * leaves the hoisted function and its trailing encrypted .bind(...) untouched.
6
+ * leaves the hoisted function untouched.
7
+ *
8
+ * The same id also prefixes the action id ("<id>#<name>") that plugin-rsc
9
+ * >= 0.5.37 passes to encryptActionBoundArgs/decryptActionBoundArgs as AES-GCM
10
+ * additional data. The production bundle decrypts under "<hash>#<name>", so bound
11
+ * args encrypted at build time under the dev-style id fail with OperationError on
12
+ * invocation. Rewriting every `"<id>#` literal to `"<hash>#` binds them to the
13
+ * runtime's id.
7
14
  * Exported for testing; used by hashServerRefs. Returns null if nothing changed.
8
15
  */
9
16
  export declare function transformServerRefs(code: string, projectRoot: string): string | null;
@@ -7078,7 +7078,7 @@ function resolveVersionsFrom(table, routerId2) {
7078
7078
  // package.json
7079
7079
  var package_default = {
7080
7080
  name: "@rangojs/router",
7081
- version: "0.26.5",
7081
+ version: "0.27.0",
7082
7082
  description: "Django-inspired RSC router with composable URL patterns",
7083
7083
  keywords: [
7084
7084
  "react",
@@ -7286,7 +7286,7 @@ var package_default = {
7286
7286
  },
7287
7287
  dependencies: {
7288
7288
  "@types/debug": "^4.1.12",
7289
- "@vitejs/plugin-rsc": "^0.5.36",
7289
+ "@vitejs/plugin-rsc": "^0.5.38",
7290
7290
  debug: "^4.4.1",
7291
7291
  "magic-string": "^0.30.17",
7292
7292
  picomatch: "^4.0.4",
@@ -10468,18 +10468,22 @@ init_client_ref_hashing();
10468
10468
  var debug9 = createRangoDebugger(NS.transform);
10469
10469
  function transformServerRefs(code, projectRoot) {
10470
10470
  if (!code.includes("registerServerReference")) return null;
10471
- let hasReplacement = false;
10472
- const result = code.replace(
10471
+ const hashes = /* @__PURE__ */ new Map();
10472
+ let result = code.replace(
10473
10473
  registerServerReferenceRegex(),
10474
10474
  (match, _value, refKey) => {
10475
10475
  const cleanKey = refKey.split("?")[0];
10476
10476
  const hash = computeProductionHash(projectRoot, cleanKey);
10477
10477
  if (hash === cleanKey) return match;
10478
- hasReplacement = true;
10478
+ hashes.set(refKey, hash);
10479
10479
  return match.replace(`"${refKey}"`, `"${hash}"`);
10480
10480
  }
10481
10481
  );
10482
- return hasReplacement ? result : null;
10482
+ if (hashes.size === 0) return null;
10483
+ for (const [refKey, hash] of hashes) {
10484
+ result = result.replaceAll(`"${refKey}#`, `"${hash}#`);
10485
+ }
10486
+ return result;
10483
10487
  }
10484
10488
  function hashServerRefs(projectRoot) {
10485
10489
  const counter = createCounter(debug9, "hash-server-refs");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.26.5",
3
+ "version": "0.27.0",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -194,7 +194,7 @@
194
194
  },
195
195
  "dependencies": {
196
196
  "@types/debug": "^4.1.12",
197
- "@vitejs/plugin-rsc": "^0.5.36",
197
+ "@vitejs/plugin-rsc": "^0.5.38",
198
198
  "debug": "^4.4.1",
199
199
  "magic-string": "^0.30.17",
200
200
  "picomatch": "^4.0.4",
@@ -326,7 +326,8 @@ Per request: a JS action POST runs the predicate with `method: "POST"`,
326
326
  `FormData` argument) and `actionUrl`; the default is true. An in-group
327
327
  navigation GET defaults to true when params or search changed. A prefetch
328
328
  partial runs the predicate, so a prefetched in-group navigation honours it. A
329
- stale back/forward revalidation sees `stale: true`. Document requests and
329
+ stale back/forward revalidation sees `stale: true` and takes an action's
330
+ defaults (the default is true). Document requests and
330
331
  no-JS form posts hold no segments, so every loader renders whatever a predicate
331
332
  says. On the action-triggered refetch GET (consolidation) `isAction()` is false,
332
333
  as for `urls()`. `currentParams`/`nextParams` are the server's route params,
@@ -202,6 +202,11 @@ suffixes are still appended.
202
202
 
203
203
  - Server actions (`_rsc_action`)
204
204
  - Loader requests (`_rsc_loader`)
205
+ - Stale requests (`_rsc_stale`): the client marks stale the follow-up
206
+ revalidation after an action, a Back/Forward entry's revalidation, and any
207
+ request that holds no segments (`router.refresh()`, a cross-tab refresh).
208
+ Each renders at the origin and is not stored, because its data may predate
209
+ a write and the cache cannot tell.
205
210
  - Non-GET requests
206
211
  - Responses without an `s-maxage` `Cache-Control` directive
207
212
  - Responses whose `Cache-Control` also contains `private`, `no-store`, or an
@@ -64,11 +64,29 @@ export const urlpatterns = urls(({ path, layout, intercept, loader, loading }) =
64
64
 
65
65
  ## Navigation Behavior
66
66
 
67
- | Navigation Type | What Renders |
68
- | ------------------------------ | ---------------------------------------------------- |
69
- | Click link `/shop/product/abc` | `<ProductModal />` in `@modal`, background preserved |
70
- | Direct URL `/shop/product/abc` | Full `<ProductPage />` page |
71
- | Browser back | Close modal, restore previous state |
67
+ | Navigation Type | What Renders |
68
+ | ----------------------------------------------------- | ---------------------------------------------------- |
69
+ | Click link `/shop/product/abc` | `<ProductModal />` in `@modal`, background preserved |
70
+ | Direct URL `/shop/product/abc`, or a reload | Full `<ProductPage />` page |
71
+ | Back from the open intercept | The intercept closes; the source page stays |
72
+ | Back/Forward onto the intercept's entry | The intercept over its source page |
73
+ | The same, uncached, from a page other than the source | Full `<ProductPage />` page, as a document load |
74
+
75
+ Only a client navigation from the source page shows an intercept. Back/Forward
76
+ restores an intercept's entry from the history cache (`initBrowserApp`'s
77
+ `storeOptions.cacheSize`, 20 by default), or fetches it when its source page is
78
+ on screen. From any other page (after a reload, or once the entry was evicted)
79
+ it costs a full page load, from the top, which drops `clearOnReload` location
80
+ state like any document load, and the entry is a full page from then on. In
81
+ dev, an edit that changes the app version clears the history cache, so the
82
+ next Back/Forward onto an intercept entry takes that path.
83
+
84
+ A server action on `/shop` that calls `redirect("/shop/product/abc")` opens the
85
+ intercept too, and so does a link from `/shop` to a route that redirects
86
+ there. The page under it is the browser's copy, as on a link click: it
87
+ keeps its last render, so it shows its pre-action data until the intercept
88
+ closes. An action submitted from inside an open intercept leaves that page the
89
+ same way.
72
90
 
73
91
  ## Intercept with Layout
74
92
 
@@ -146,6 +164,18 @@ the modal keeps the data of the others, as it does on a link to another
146
164
  intercepted route that reads the same loader. In `clientUrls()` the predicate
147
165
  runs on the server too (`/client-urls`).
148
166
 
167
+ The page under an intercept does not re-render while the intercept is on
168
+ screen. An action that settles after the intercept opened (one sent from the
169
+ page underneath just before the user opened it) runs no background
170
+ revalidation. The page underneath keeps its data until the user leaves the
171
+ intercept. Leaving it with Back revalidates that page as stale (the
172
+ action marked the history cache stale): loaders that opt in with
173
+ `revalidate(({ stale }) => stale)` re-run. Going Back or Forward out of the
174
+ intercept while an action from it is in flight does not abort the action:
175
+ `useAction()` gets its result, and the page underneath gets the background
176
+ revalidation (`/server-actions`, "When the user navigates while the action
177
+ runs").
178
+
149
179
  A `revalidate()` placed directly in the intercept's `use()` (or returned from
150
180
  its handler's `.use`) throws at definition time. Attach it to the loader:
151
181
 
@@ -177,7 +207,7 @@ import type { Revalidate } from "@rangojs/router";
177
207
  import * as ProductActions from "./actions/product";
178
208
 
179
209
  export const revalidateProductShell: Revalidate = (ctx) =>
180
- ctx.isAction(ProductActions) || undefined;
210
+ ctx.stale || ctx.isAction(ProductActions) || undefined;
181
211
 
182
212
  layout(ProductLayout, () => [
183
213
  revalidate(revalidateProductShell), // producer reruns
@@ -238,7 +238,8 @@ A standalone `layout()` renders when it is first mounted and is then kept as-is:
238
238
  params auto-filled) keep the old params too; render param-dependent nav in a
239
239
  client component with `useReverse(routes)` (`/links`).
240
240
  - **Actions** skip it too (the revalidation trace calls this
241
- `action:parent-chain-skip`).
241
+ `action:parent-chain-skip`), and so does Back/Forward onto a page an action
242
+ made stale (`stale:parent-chain-skip`).
242
243
 
243
244
  Orphan layouts inside a `path()` are the opposite: they belong to the route
244
245
  entry and re-render with it (on param/search changes and on every action).
@@ -249,9 +250,9 @@ or `undefined` to defer to the default:
249
250
  ```typescript
250
251
  import * as CartActions from "./actions/cart";
251
252
 
252
- // Re-render after any cart action
253
+ // Re-render after any cart action, and on Back after an action
253
254
  layout(<CartLayout />, () => [
254
- revalidate((ctx) => ctx.isAction(CartActions) || undefined),
255
+ revalidate((ctx) => ctx.isAction(CartActions) || ctx.stale || undefined),
255
256
 
256
257
  path("/cart", CartPage, { name: "cart" }),
257
258
  ])
@@ -292,7 +293,7 @@ import type { Revalidate } from "@rangojs/router";
292
293
  import { addToCart } from "./actions/cart";
293
294
 
294
295
  export const revalidateCartData: Revalidate = (ctx) =>
295
- ctx.isAction(addToCart) || undefined;
296
+ ctx.stale || ctx.isAction(addToCart) || undefined;
296
297
  ```
297
298
 
298
299
  ```typescript
@@ -305,8 +306,11 @@ layout(<CartLayout />, () => [
305
306
  ```
306
307
 
307
308
  On the layout the contract adds the re-render the default skips after an
308
- action. The route segment already re-runs after every action, so there it
309
- changes nothing by default; the shared name documents the dependency.
309
+ action, and on Back/Forward onto a page an action made stale (`ctx.stale`).
310
+ The route segment already re-runs on both, so there it changes nothing by
311
+ default; the shared name documents the dependency. Leave out `ctx.stale` and
312
+ Back after any action re-runs the page without its layout: the page reads
313
+ `undefined`.
310
314
 
311
315
  If a segment depends on multiple upstream domains, compose multiple
312
316
  contracts (`revalidateAuthData`, `revalidateCartData`, and so on).
@@ -319,7 +323,7 @@ import { revalidate, type Revalidate } from "@rangojs/router";
319
323
  import * as AuthActions from "./actions/auth";
320
324
 
321
325
  export const revalidateAuthData: Revalidate = (ctx) =>
322
- ctx.isAction(AuthActions) || undefined;
326
+ ctx.stale || ctx.isAction(AuthActions) || undefined;
323
327
  export const revalidateAuth = () => [revalidate(revalidateAuthData)];
324
328
  ```
325
329
 
@@ -396,12 +396,26 @@ Segment defaults when no revalidator decides (from
396
396
  `src/router/revalidation.ts`). A segment the client does not hold yet always
397
397
  renders.
398
398
 
399
- | Segment | After an action (POST) | Navigation |
400
- | -------------------------------------------------------------------- | ---------------------- | ----------------------------------- |
401
- | Route (`path()`) | `true` | `true` when params or search change |
402
- | Owned by the route (loaders, layouts, parallels inside the `path()`) | `true` | `true` when params or search change |
403
- | Loader above the route (e.g. on a parent `layout()`) | `true` | `false` |
404
- | Layout or parallel above the route | `false` | `false` |
399
+ | Segment | After an action (POST), or a stale Back/Forward | Navigation |
400
+ | -------------------------------------------------------------------- | ----------------------------------------------- | ----------------------------------- |
401
+ | Route (`path()`) | `true` | `true` when params or search change |
402
+ | Owned by the route (loaders, layouts, parallels inside the `path()`) | `true` | `true` when params or search change |
403
+ | Loader above the route (e.g. on a parent `layout()`) | `true` | `false` |
404
+ | Layout or parallel above the route | `false` | `false` |
405
+
406
+ A stale Back/Forward is the background revalidation of a page a server action
407
+ (in this tab or another) marked stale: Back renders the cached page, then
408
+ revalidates it with `stale: true`. It takes an action's defaults, so a layout's
409
+ cart count shows the write with no opt-in. It renders live, as an action does:
410
+ no route-cache record or PPR replay answers it. It is not an action:
411
+ `isAction()` is false and `actionId` is undefined. To keep a loader as it was
412
+ on that request:
413
+
414
+ ```typescript
415
+ loader(RecommendationsLoader, () => [
416
+ revalidate(({ stale }) => (stale ? false : undefined)),
417
+ ]);
418
+ ```
405
419
 
406
420
  #### `|| undefined` (defer) vs `?? false` (hard) — pick deliberately
407
421
 
@@ -421,9 +435,10 @@ Pick the idiom by what the segment's default already does (table above). After
421
435
  an action, loaders, the route, and segments inside the `path()` already re-run;
422
436
  layouts and parallels above the route are skipped:
423
437
 
424
- - **Add a signal where the default skips.** `ctx.isAction(X) || undefined` on a
425
- parent layout or a layout-level parallel re-renders it after `X` and defers
426
- otherwise. On a loader, the route, or a segment inside the `path()` it
438
+ - **Add a signal where the default skips.**
439
+ `ctx.isAction(X) || ctx.stale || undefined` on a parent layout or a
440
+ layout-level parallel re-renders it after `X` and on Back after an action,
441
+ and defers otherwise. On a loader, the route, or a segment inside the `path()` it
427
442
  changes nothing: they already re-run after every action, and on navigation it
428
443
  defers.
429
444
  - **Narrow after actions where the default re-runs.**
@@ -487,10 +502,12 @@ the same named revalidation contract across producer and consumer segments.
487
502
  import type { Revalidate } from "@rangojs/router";
488
503
  import * as AccountActions from "./actions/account";
489
504
 
490
- // Match by reference with ctx.isAction() (rename-safe), and defer (|| undefined)
491
- // so these contracts compose — a hard `false` would short-circuit the rest.
505
+ // Match by reference with ctx.isAction() (rename-safe), true on a stale
506
+ // Back/Forward (the page re-runs, so its producer must), and defer
507
+ // (|| undefined) so these contracts compose — a hard `false` would
508
+ // short-circuit the rest.
492
509
  export const revalidateAccountScope: Revalidate = (ctx) =>
493
- ctx.isAction(AccountActions) || undefined;
510
+ ctx.stale || ctx.isAction(AccountActions) || undefined;
494
511
 
495
512
  // urls.tsx
496
513
  layout(AccountLayout, () => [
@@ -955,7 +972,7 @@ function ProductPage() {
955
972
 
956
973
  A skeleton that has appeared stays on screen for at least about 300 ms before React replaces it with what it was waiting for: React does not replace a fallback sooner than 300 ms after the last fallback committed ([#1078](https://github.com/rangojs/rango/issues/1078)). On a cold click that mounts the `loading()` boundary, a loader that finishes sooner than that still shows its skeleton for about 300 ms, while a slower loader is not delayed. The router leaves this to React: an early skeleton is preferred to holding the click, and a fully prefetched click commits complete and shows none. A nested `loading()` whose data lands after the outer one has been revealed shows its own fallback, and the 300 ms starts again. The loaders feature page on the docs site has the measured figures. Under `transition()` a related cost applies; see [view-transitions](../view-transitions/SKILL.md).
957
974
 
958
- A loader that throws (anything but `notFound()` or `redirect()`) throws at its `useLoader` read on every client lane: a plain or fully prefetched click, Back/Forward restored from the history cache, and the rerun after a server action. A prefetch in which a loader threw is never used, in memory or from the browser's HTTP cache: the click asks the server again, so a loader that has recovered shows its data. The nearest boundary to the read catches it (your React error boundary around the read, else the route's `errorBoundary()`, else the router's root one), and the layouts above keep their instance. The background refresh after Back/Forward onto a stale page is the exception: a loader that fails there leaves the page on screen and logs `[Browser] Background revalidation failed`. Not covered: an `intercept()`'s loaders, and a parallel slot's loaders under its own `loading()`, which are decoded above the slot's content.
975
+ A loader that throws (anything but `notFound()` or `redirect()`) throws at its `useLoader` read on every client lane: a plain or fully prefetched click, Back/Forward restored from the history cache, and the rerun after a server action. A prefetch in which a loader threw is never used, in memory or from the browser's HTTP cache: the click asks the server again, so a loader that has recovered shows its data. The nearest boundary to the read catches it (your React error boundary around the read, else the route's `errorBoundary()`, else the router's root one), and the layouts above keep their instance. The background refresh after Back/Forward onto a stale page is the exception: it re-runs every loader on the defaults (see the defaults table), and a loader that fails there leaves the page on screen as it was and logs `[Browser] Background revalidation failed`. Not covered: an `intercept()`'s loaders, and a parallel slot's loaders under its own `loading()`, which are decoded above the slot's content.
959
976
 
960
977
  ## Loader Authority: notFound() and redirect()
961
978