@rangojs/router 0.0.0-experimental.54 → 0.0.0-experimental.55

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.
@@ -1745,7 +1745,7 @@ import { resolve } from "node:path";
1745
1745
  // package.json
1746
1746
  var package_default = {
1747
1747
  name: "@rangojs/router",
1748
- version: "0.0.0-experimental.54",
1748
+ version: "0.0.0-experimental.55",
1749
1749
  description: "Django-inspired RSC router with composable URL patterns",
1750
1750
  keywords: [
1751
1751
  "react",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.54",
3
+ "version": "0.0.0-experimental.55",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Mutable app version — updated after HMR revalidation.
3
+ * Read by prefetch, navigation, and context code.
4
+ */
5
+
6
+ let currentVersion: string | undefined;
7
+
8
+ export function getAppVersion(): string | undefined {
9
+ return currentVersion;
10
+ }
11
+
12
+ export function setAppVersion(version: string | undefined): void {
13
+ currentVersion = version;
14
+ }
@@ -4,6 +4,7 @@ import type {
4
4
  NavigateOptionsInternal,
5
5
  ResolvedSegment,
6
6
  } from "./types.js";
7
+ import { setAppVersion } from "./app-version.js";
7
8
  import * as React from "react";
8
9
  import { startTransition } from "react";
9
10
  import {
@@ -67,8 +68,8 @@ export interface NavigationBridgeConfigWithController extends NavigationBridgeCo
67
68
  export function createNavigationBridge(
68
69
  config: NavigationBridgeConfigWithController,
69
70
  ): NavigationBridge {
70
- const { store, client, eventController, onUpdate, renderSegments, version } =
71
- config;
71
+ const { store, client, eventController, onUpdate, renderSegments } = config;
72
+ let version = config.version;
72
73
 
73
74
  // Create shared partial updater
74
75
  const fetchPartialUpdate = createPartialUpdater({
@@ -76,7 +77,7 @@ export function createNavigationBridge(
76
77
  client,
77
78
  onUpdate,
78
79
  renderSegments,
79
- version,
80
+ getVersion: () => version,
80
81
  });
81
82
 
82
83
  return {
@@ -632,6 +633,12 @@ export function createNavigationBridge(
632
633
  window.removeEventListener("pageshow", handlePageShow);
633
634
  };
634
635
  },
636
+
637
+ updateVersion(newVersion: string): void {
638
+ version = newVersion;
639
+ setAppVersion(newVersion);
640
+ store.clearHistoryCache();
641
+ },
635
642
  };
636
643
  }
637
644
 
@@ -107,6 +107,54 @@ export function createNavigationClient(
107
107
  resolveStreamComplete = resolve;
108
108
  });
109
109
 
110
+ /**
111
+ * Validate RSC control headers on any response (fresh, cached, or
112
+ * in-flight). Handles version-mismatch reloads and server redirects.
113
+ * Returns the response unchanged when no control header is present.
114
+ */
115
+ const validateRscHeaders = (
116
+ response: Response,
117
+ source: string,
118
+ ): Response | Promise<Response> => {
119
+ // Version mismatch — server wants a full page reload
120
+ const reload = extractRscHeaderUrl(response, "X-RSC-Reload");
121
+ if (reload === "blocked") {
122
+ resolveStreamComplete();
123
+ return emptyResponse();
124
+ }
125
+ if (reload) {
126
+ if (tx) {
127
+ browserDebugLog(tx, `version mismatch, reloading (${source})`, {
128
+ reloadUrl: reload.url,
129
+ });
130
+ }
131
+ window.location.href = reload.url;
132
+ // Block further processing — page is reloading
133
+ return new Promise<Response>(() => {});
134
+ }
135
+
136
+ // Server-side redirect without state: the server returned 204 with
137
+ // X-RSC-Redirect instead of a 3xx (which fetch would auto-follow
138
+ // to a URL rendering full HTML). Throw ServerRedirect so the
139
+ // navigation bridge catches it and re-navigates with _skipCache.
140
+ const redirect = extractRscHeaderUrl(response, "X-RSC-Redirect");
141
+ if (redirect === "blocked") {
142
+ resolveStreamComplete();
143
+ return emptyResponse();
144
+ }
145
+ if (redirect) {
146
+ if (tx) {
147
+ browserDebugLog(tx, `server redirect (${source})`, {
148
+ redirectUrl: redirect.url,
149
+ });
150
+ }
151
+ resolveStreamComplete();
152
+ throw new ServerRedirect(redirect.url, undefined);
153
+ }
154
+
155
+ return response;
156
+ };
157
+
110
158
  /** Start a fresh navigation fetch (no cache / inflight hit). */
111
159
  const doFreshFetch = (): Promise<Response> => {
112
160
  if (tx) {
@@ -127,43 +175,11 @@ export function createNavigationClient(
127
175
  },
128
176
  signal,
129
177
  }).then((response) => {
130
- // Check for version mismatch - server wants us to reload
131
- const reload = extractRscHeaderUrl(response, "X-RSC-Reload");
132
- if (reload === "blocked") {
133
- resolveStreamComplete();
134
- return emptyResponse();
135
- }
136
- if (reload) {
137
- if (tx) {
138
- browserDebugLog(tx, "version mismatch, reloading", {
139
- reloadUrl: reload.url,
140
- });
141
- }
142
- window.location.href = reload.url;
143
- return new Promise<Response>(() => {});
144
- }
145
-
146
- // Server-side redirect without state: the server returned 204 with
147
- // X-RSC-Redirect instead of a 3xx (which fetch would auto-follow
148
- // to a URL rendering full HTML). Throw ServerRedirect so the
149
- // navigation bridge catches it and re-navigates with _skipCache.
150
- const redirect = extractRscHeaderUrl(response, "X-RSC-Redirect");
151
- if (redirect === "blocked") {
152
- resolveStreamComplete();
153
- return emptyResponse();
154
- }
155
- if (redirect) {
156
- if (tx) {
157
- browserDebugLog(tx, "server redirect", {
158
- redirectUrl: redirect.url,
159
- });
160
- }
161
- resolveStreamComplete();
162
- throw new ServerRedirect(redirect.url, undefined);
163
- }
178
+ const validated = validateRscHeaders(response, "fetch");
179
+ if (validated instanceof Promise) return validated;
164
180
 
165
181
  return teeWithCompletion(
166
- response,
182
+ validated,
167
183
  () => {
168
184
  if (tx) browserDebugLog(tx, "stream complete");
169
185
  resolveStreamComplete();
@@ -179,11 +195,12 @@ export function createNavigationClient(
179
195
  if (tx) {
180
196
  browserDebugLog(tx, "prefetch cache hit", { key: cacheKey });
181
197
  }
182
- // Cached response body is already fully buffered (arrayBuffer),
183
- // so stream completion is immediate.
184
198
  responsePromise = Promise.resolve(cachedResponse).then((response) => {
199
+ const validated = validateRscHeaders(response, "prefetch cache");
200
+ if (validated instanceof Promise) return validated;
201
+
185
202
  return teeWithCompletion(
186
- response,
203
+ validated,
187
204
  () => {
188
205
  if (tx) browserDebugLog(tx, "stream complete (from cache)");
189
206
  resolveStreamComplete();
@@ -203,8 +220,11 @@ export function createNavigationClient(
203
220
  return doFreshFetch();
204
221
  }
205
222
 
223
+ const validated = validateRscHeaders(response, "inflight prefetch");
224
+ if (validated instanceof Promise) return validated;
225
+
206
226
  return teeWithCompletion(
207
- response,
227
+ validated,
208
228
  () => {
209
229
  if (tx) {
210
230
  browserDebugLog(tx, "stream complete (from inflight prefetch)");
@@ -39,8 +39,8 @@ export interface PartialUpdateConfig {
39
39
  segments: ResolvedSegment[],
40
40
  options?: RenderSegmentsOptions,
41
41
  ) => Promise<ReactNode> | ReactNode;
42
- /** RSC version received from server (from initial payload metadata) */
43
- version?: string;
42
+ /** RSC version getter returns the current version (may change after HMR) */
43
+ getVersion?: () => string | undefined;
44
44
  }
45
45
 
46
46
  /**
@@ -104,7 +104,13 @@ export type PartialUpdater = (
104
104
  export function createPartialUpdater(
105
105
  config: PartialUpdateConfig,
106
106
  ): PartialUpdater {
107
- const { store, client, onUpdate, renderSegments, version } = config;
107
+ const {
108
+ store,
109
+ client,
110
+ onUpdate,
111
+ renderSegments,
112
+ getVersion = () => undefined,
113
+ } = config;
108
114
 
109
115
  /**
110
116
  * Get current page's cached segments as an array
@@ -193,7 +199,7 @@ export function createPartialUpdater(
193
199
  // (action redirect sends empty segments for a fresh render).
194
200
  staleRevalidation:
195
201
  mode.type === "stale-revalidation" || segments.length === 0,
196
- version,
202
+ version: getVersion(),
197
203
  });
198
204
  // Mark navigation as streaming (response received, now parsing RSC).
199
205
  // Called after fetchPartial so pendingUrl stays set during the network wait,
@@ -32,6 +32,7 @@ export type LinkState =
32
32
  | StateOrGetter<Record<string, unknown>>;
33
33
 
34
34
  import { prefetchDirect, prefetchQueued } from "../prefetch/fetch.js";
35
+ import { getAppVersion } from "../app-version.js";
35
36
  import {
36
37
  observeForPrefetch,
37
38
  unobserveForPrefetch,
@@ -289,7 +290,7 @@ export const Link: ForwardRefExoticComponent<
289
290
  // prefetch — prefetchDirect bypasses the queue, and hasPrefetch
290
291
  // deduplicates if the viewport prefetch already completed.
291
292
  const segmentState = ctx.store.getSegmentState();
292
- prefetchDirect(to, segmentState.currentSegmentIds, ctx.version);
293
+ prefetchDirect(to, segmentState.currentSegmentIds, getAppVersion());
293
294
  }
294
295
  }, [resolvedStrategy, to, isExternal, ctx]);
295
296
 
@@ -308,7 +309,7 @@ export const Link: ForwardRefExoticComponent<
308
309
  const triggerPrefetch = () => {
309
310
  if (cancelled) return;
310
311
  const segmentState = ctx.store.getSegmentState();
311
- prefetchQueued(to, segmentState.currentSegmentIds, ctx.version);
312
+ prefetchQueued(to, segmentState.currentSegmentIds, getAppVersion());
312
313
  };
313
314
 
314
315
  // Schedule prefetch only when the app is idle (no navigation/streaming).
@@ -134,7 +134,7 @@ export interface NavigationProviderProps {
134
134
 
135
135
  /**
136
136
  * App version from server payload (stable, immutable).
137
- * Forwarded to prefetch requests for version mismatch detection.
137
+ * Forwarded to context for cache key building.
138
138
  */
139
139
  version?: string;
140
140
  }
@@ -43,8 +43,7 @@ export interface NavigationStoreContextValue {
43
43
  refresh: () => Promise<void>;
44
44
 
45
45
  /**
46
- * App version from server payload (stable, immutable).
47
- * Used in prefetch requests for version mismatch detection.
46
+ * App version from the initial server payload.
48
47
  */
49
48
  version: string | undefined;
50
49
  }
@@ -3,6 +3,7 @@
3
3
  import { useContext, useMemo } from "react";
4
4
  import { NavigationStoreContext } from "./context.js";
5
5
  import { prefetchDirect } from "../prefetch/fetch.js";
6
+ import { getAppVersion } from "../app-version.js";
6
7
  import type { RouterInstance, RouterNavigateOptions } from "../types.js";
7
8
 
8
9
  /**
@@ -46,7 +47,7 @@ export function useRouter(): RouterInstance {
46
47
  prefetch(url: string): void {
47
48
  const segmentState = ctx.store?.getSegmentState();
48
49
  if (segmentState) {
49
- prefetchDirect(url, segmentState.currentSegmentIds, ctx.version);
50
+ prefetchDirect(url, segmentState.currentSegmentIds, getAppVersion());
50
51
  }
51
52
  },
52
53
 
@@ -23,6 +23,7 @@ import type { EventController } from "./event-controller.js";
23
23
  import type { ResolvedThemeConfig, Theme } from "../theme/types.js";
24
24
  import { initRangoState } from "./rango-state.js";
25
25
  import { initPrefetchCache } from "./prefetch/cache.js";
26
+ import { setAppVersion } from "./app-version.js";
26
27
  import {
27
28
  isInterceptSegment,
28
29
  splitInterceptSegments,
@@ -204,6 +205,7 @@ export async function initBrowserApp(
204
205
  // Initialize the localStorage state key for cache invalidation.
205
206
  // Uses the build version so a new deploy automatically busts all cached prefetches.
206
207
  initRangoState(version ?? "0");
208
+ setAppVersion(version);
207
209
 
208
210
  // Initialize the in-memory prefetch cache TTL from server config.
209
211
  // A value of 0 disables the cache; undefined falls back to the module default.
@@ -230,7 +232,6 @@ export async function initBrowserApp(
230
232
  deps,
231
233
  onUpdate: (update) => store.emitUpdate(update),
232
234
  renderSegments,
233
- version,
234
235
  onNavigate: (url, options) => {
235
236
  if (!navigateFn) {
236
237
  window.location.href = url;
@@ -248,7 +249,7 @@ export async function initBrowserApp(
248
249
  client,
249
250
  onUpdate: (update) => store.emitUpdate(update),
250
251
  renderSegments,
251
- version,
252
+ version: version,
252
253
  });
253
254
 
254
255
  // Connect action redirect → navigation bridge (now that both are initialized)
@@ -328,6 +329,21 @@ export async function initBrowserApp(
328
329
  throw new Error("HMR refetch returned invalid payload");
329
330
  }
330
331
 
332
+ // Update version BEFORE rebuilding state so that
333
+ // clearHistoryCache() runs first, then the fresh segment
334
+ // cache entry we create below survives.
335
+ const newVersion = payload.metadata.version;
336
+ if (newVersion && newVersion !== version) {
337
+ console.log(
338
+ "[RSCRouter] HMR: version changed",
339
+ version,
340
+ "→",
341
+ newVersion,
342
+ "clearing caches",
343
+ );
344
+ navigationBridge.updateVersion(newVersion);
345
+ }
346
+
331
347
  if (payload.metadata?.isPartial) {
332
348
  const segments = payload.metadata.segments || [];
333
349
  const matched = payload.metadata.matched || [];
@@ -29,6 +29,7 @@ import {
29
29
  } from "./response-adapter.js";
30
30
  import { mergeLocationState } from "./history-state.js";
31
31
  import { classifyActionOutcome } from "./action-coordinator.js";
32
+ import { getAppVersion } from "./app-version.js";
32
33
 
33
34
  // Polyfill Symbol.dispose/asyncDispose for Safari and older browsers
34
35
  if (typeof Symbol.dispose === "undefined") {
@@ -43,8 +44,6 @@ if (typeof Symbol.asyncDispose === "undefined") {
43
44
  */
44
45
  export interface ServerActionBridgeConfigWithController extends ServerActionBridgeConfig {
45
46
  eventController: EventController;
46
- /** RSC version from initial payload metadata */
47
- version?: string;
48
47
  /** Callback to trigger SPA navigation (for action redirects) */
49
48
  onNavigate?: (
50
49
  url: string,
@@ -75,7 +74,6 @@ export function createServerActionBridge(
75
74
  deps,
76
75
  onUpdate,
77
76
  renderSegments,
78
- version,
79
77
  onNavigate,
80
78
  } = config;
81
79
 
@@ -86,7 +84,7 @@ export function createServerActionBridge(
86
84
  client,
87
85
  onUpdate,
88
86
  renderSegments,
89
- version,
87
+ getVersion: getAppVersion,
90
88
  });
91
89
 
92
90
  /**
@@ -165,6 +163,7 @@ export function createServerActionBridge(
165
163
  segmentState.currentSegmentIds.join(","),
166
164
  );
167
165
  // Add version param for version mismatch detection
166
+ const version = getAppVersion();
168
167
  if (version) {
169
168
  url.searchParams.set("_rsc_v", version);
170
169
  }
@@ -526,6 +526,8 @@ export interface NavigationBridge {
526
526
  refresh(): Promise<void>;
527
527
  handlePopstate(): Promise<void>;
528
528
  registerLinkInterception(): () => void;
529
+ /** Update the RSC version (e.g. after HMR). Clears prefetch cache. */
530
+ updateVersion(newVersion: string): void;
529
531
  }
530
532
 
531
533
  /**
@@ -316,7 +316,10 @@ export function withCacheLookup<TEnv>(
316
316
 
317
317
  // Prerender lookup: check build-time cached data before runtime cache.
318
318
  // Prerender data is available regardless of runtime cache configuration.
319
- if (!ctx.isAction && ctx.matched.pr) {
319
+ // Skip for HMR requests — the dev prerender endpoint reads from a stale
320
+ // RouterRegistry snapshot; rendering fresh ensures edits are visible.
321
+ const isHmr = !!ctx.request.headers.get("X-RSC-HMR");
322
+ if (!ctx.isAction && !isHmr && ctx.matched.pr) {
320
323
  await ensurePrerenderDeps();
321
324
  if (prerenderStoreInstance) {
322
325
  const paramHash = _hashParams!(ctx.matched.params);