@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.
- package/dist/vite/index.js +1 -1
- package/package.json +1 -1
- package/src/browser/app-version.ts +14 -0
- package/src/browser/navigation-bridge.ts +10 -3
- package/src/browser/navigation-client.ts +59 -39
- package/src/browser/partial-update.ts +10 -4
- package/src/browser/react/Link.tsx +3 -2
- package/src/browser/react/NavigationProvider.tsx +1 -1
- package/src/browser/react/context.ts +1 -2
- package/src/browser/react/use-router.ts +2 -1
- package/src/browser/rsc-router.tsx +18 -2
- package/src/browser/server-action-bridge.ts +3 -4
- package/src/browser/types.ts +2 -0
- package/src/router/match-middleware/cache-lookup.ts +4 -1
package/dist/vite/index.js
CHANGED
|
@@ -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.
|
|
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
|
@@ -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
|
|
71
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
43
|
-
|
|
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 {
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
}
|
package/src/browser/types.ts
CHANGED
|
@@ -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
|
-
|
|
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);
|