@rangojs/router 0.0.0-experimental.debug-cache-fix → 0.0.0-experimental.dfdb0387
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/README.md +76 -18
- package/dist/bin/rango.js +130 -47
- package/dist/vite/index.js +702 -231
- package/package.json +2 -2
- package/skills/cache-guide/SKILL.md +32 -0
- package/skills/caching/SKILL.md +8 -0
- package/skills/links/SKILL.md +3 -1
- package/skills/loader/SKILL.md +53 -43
- package/skills/middleware/SKILL.md +2 -0
- package/skills/prerender/SKILL.md +110 -68
- package/skills/route/SKILL.md +31 -0
- package/skills/router-setup/SKILL.md +87 -2
- package/skills/typesafety/SKILL.md +10 -0
- package/src/__internal.ts +1 -1
- package/src/browser/app-version.ts +14 -0
- package/src/browser/navigation-bridge.ts +16 -3
- package/src/browser/navigation-client.ts +98 -46
- package/src/browser/navigation-store.ts +43 -8
- package/src/browser/partial-update.ts +32 -5
- package/src/browser/prefetch/cache.ts +16 -6
- package/src/browser/prefetch/fetch.ts +52 -6
- package/src/browser/prefetch/queue.ts +61 -29
- package/src/browser/prefetch/resource-ready.ts +77 -0
- package/src/browser/react/Link.tsx +67 -8
- package/src/browser/react/NavigationProvider.tsx +13 -4
- package/src/browser/react/context.ts +7 -2
- package/src/browser/react/use-handle.ts +9 -58
- package/src/browser/react/use-router.ts +21 -8
- package/src/browser/rsc-router.tsx +26 -3
- package/src/browser/scroll-restoration.ts +10 -8
- package/src/browser/segment-reconciler.ts +26 -0
- package/src/browser/server-action-bridge.ts +8 -6
- package/src/browser/types.ts +27 -5
- package/src/build/generate-manifest.ts +6 -6
- package/src/build/generate-route-types.ts +3 -0
- package/src/build/route-types/include-resolution.ts +8 -1
- package/src/build/route-types/router-processing.ts +211 -72
- package/src/build/route-types/scan-filter.ts +8 -1
- package/src/cache/cache-scope.ts +12 -14
- package/src/cache/taint.ts +55 -0
- package/src/client.tsx +2 -56
- package/src/context-var.ts +72 -2
- package/src/handle.ts +40 -0
- package/src/index.rsc.ts +3 -1
- package/src/index.ts +12 -0
- package/src/prerender/store.ts +5 -4
- package/src/prerender.ts +138 -77
- package/src/reverse.ts +22 -1
- package/src/route-definition/dsl-helpers.ts +42 -19
- package/src/route-definition/helpers-types.ts +10 -6
- package/src/route-definition/index.ts +3 -0
- package/src/route-definition/redirect.ts +9 -1
- package/src/route-definition/resolve-handler-use.ts +149 -0
- package/src/route-types.ts +11 -0
- package/src/router/content-negotiation.ts +100 -1
- package/src/router/handler-context.ts +79 -23
- package/src/router/intercept-resolution.ts +9 -4
- package/src/router/loader-resolution.ts +156 -21
- package/src/router/match-api.ts +124 -189
- package/src/router/match-middleware/cache-lookup.ts +26 -7
- package/src/router/match-middleware/segment-resolution.ts +53 -0
- package/src/router/match-result.ts +82 -4
- package/src/router/middleware-types.ts +6 -8
- package/src/router/middleware.ts +2 -5
- package/src/router/navigation-snapshot.ts +182 -0
- package/src/router/prerender-match.ts +110 -10
- package/src/router/preview-match.ts +30 -102
- package/src/router/request-classification.ts +310 -0
- package/src/router/route-snapshot.ts +245 -0
- package/src/router/router-interfaces.ts +36 -4
- package/src/router/router-options.ts +37 -11
- package/src/router/segment-resolution/fresh.ts +80 -9
- package/src/router/segment-resolution/helpers.ts +29 -24
- package/src/router/segment-resolution/revalidation.ts +91 -8
- package/src/router/types.ts +1 -0
- package/src/router.ts +54 -5
- package/src/rsc/handler.ts +472 -372
- package/src/rsc/loader-fetch.ts +23 -3
- package/src/rsc/manifest-init.ts +5 -1
- package/src/rsc/progressive-enhancement.ts +14 -2
- package/src/rsc/rsc-rendering.ts +10 -1
- package/src/rsc/server-action.ts +8 -0
- package/src/rsc/ssr-setup.ts +2 -2
- package/src/rsc/types.ts +9 -1
- package/src/server/context.ts +50 -1
- package/src/server/handle-store.ts +19 -0
- package/src/server/loader-registry.ts +9 -8
- package/src/server/request-context.ts +175 -15
- package/src/ssr/index.tsx +3 -0
- package/src/static-handler.ts +18 -6
- package/src/types/cache-types.ts +4 -4
- package/src/types/handler-context.ts +37 -19
- package/src/types/loader-types.ts +36 -9
- package/src/types/route-entry.ts +1 -1
- package/src/types/segments.ts +1 -0
- package/src/urls/path-helper-types.ts +9 -2
- package/src/urls/path-helper.ts +47 -12
- package/src/urls/pattern-types.ts +12 -0
- package/src/urls/response-types.ts +16 -6
- package/src/use-loader.tsx +77 -5
- package/src/vite/discovery/bundle-postprocess.ts +30 -33
- package/src/vite/discovery/discover-routers.ts +5 -1
- package/src/vite/discovery/prerender-collection.ts +128 -74
- package/src/vite/discovery/state.ts +13 -4
- package/src/vite/index.ts +4 -0
- package/src/vite/plugin-types.ts +60 -5
- package/src/vite/plugins/expose-id-utils.ts +12 -0
- package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
- package/src/vite/plugins/expose-internal-ids.ts +257 -40
- package/src/vite/plugins/performance-tracks.ts +88 -0
- package/src/vite/plugins/refresh-cmd.ts +88 -26
- package/src/vite/rango.ts +19 -2
- package/src/vite/router-discovery.ts +178 -37
- package/src/vite/utils/prerender-utils.ts +18 -0
- package/src/vite/utils/shared-utils.ts +3 -2
package/skills/route/SKILL.md
CHANGED
|
@@ -181,6 +181,37 @@ String keys still work (`ctx.set("key", value)` / `ctx.get("key")`), but
|
|
|
181
181
|
Only route handlers and middleware can call `ctx.set()`. Layouts, parallels,
|
|
182
182
|
and intercepts can only read via `ctx.get()`.
|
|
183
183
|
|
|
184
|
+
#### Non-cacheable context variables
|
|
185
|
+
|
|
186
|
+
Mark a var as non-cacheable when it holds inherently request-specific data
|
|
187
|
+
(sessions, auth tokens, per-request IDs). There are two ways:
|
|
188
|
+
|
|
189
|
+
```typescript
|
|
190
|
+
// Var-level: every value written to this var is non-cacheable
|
|
191
|
+
const Session = createVar<SessionData>({ cache: false });
|
|
192
|
+
|
|
193
|
+
// Write-level: escalate a normally-cacheable var for this specific write
|
|
194
|
+
const Theme = createVar<string>();
|
|
195
|
+
ctx.set(Theme, userTheme, { cache: false });
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
"Least cacheable wins" — if either the var definition or the write site says
|
|
199
|
+
`cache: false`, the value is non-cacheable.
|
|
200
|
+
|
|
201
|
+
Reading a non-cacheable var inside `cache()` or `"use cache"` throws at
|
|
202
|
+
runtime. This prevents request-specific data from leaking into cached output:
|
|
203
|
+
|
|
204
|
+
```typescript
|
|
205
|
+
// This throws — Session is non-cacheable
|
|
206
|
+
async function CachedWidget(ctx) {
|
|
207
|
+
"use cache";
|
|
208
|
+
const session = ctx.get(Session); // Error: non-cacheable var read inside cache scope
|
|
209
|
+
return <Widget />;
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Cacheable vars (the default) can be read freely inside cache scopes.
|
|
214
|
+
|
|
184
215
|
### Revalidation Contracts for Handler Data
|
|
185
216
|
|
|
186
217
|
Handler-first guarantees apply within a single full render pass. For partial
|
|
@@ -78,16 +78,21 @@ interface RSCRouterOptions<TEnv> {
|
|
|
78
78
|
// Document component wrapping entire app
|
|
79
79
|
document?: ComponentType<{ children: ReactNode }>;
|
|
80
80
|
|
|
81
|
+
// URL prefix for sub-path deployments (e.g. "/admin")
|
|
82
|
+
// All routes, reverse(), href(), Link, redirect(), and router.use()
|
|
83
|
+
// patterns are automatically prefixed. Route names stay unprefixed.
|
|
84
|
+
basename?: string;
|
|
85
|
+
|
|
81
86
|
// Enable per-request performance timeline (console waterfall + Server-Timing header)
|
|
82
87
|
debugPerformance?: boolean;
|
|
83
88
|
|
|
84
89
|
// Default error boundary
|
|
85
90
|
defaultErrorBoundary?: ReactNode | ErrorBoundaryHandler;
|
|
86
91
|
|
|
87
|
-
// Default not-found boundary
|
|
92
|
+
// Default not-found boundary for notFound() thrown in handlers/loaders
|
|
88
93
|
defaultNotFoundBoundary?: ReactNode | NotFoundBoundaryHandler;
|
|
89
94
|
|
|
90
|
-
// Component for 404
|
|
95
|
+
// Component for 404 (no route match, or notFound() without a boundary)
|
|
91
96
|
notFound?: ReactNode | ((props: { pathname: string }) => ReactNode);
|
|
92
97
|
|
|
93
98
|
// Error logging callback
|
|
@@ -124,6 +129,36 @@ interface RSCRouterOptions<TEnv> {
|
|
|
124
129
|
}
|
|
125
130
|
```
|
|
126
131
|
|
|
132
|
+
## Basename (Sub-Path Deployment)
|
|
133
|
+
|
|
134
|
+
When your app is served under a sub-path (e.g. `/admin` or `/v2`), set `basename`:
|
|
135
|
+
|
|
136
|
+
```typescript
|
|
137
|
+
const router = createRouter({
|
|
138
|
+
basename: "/admin",
|
|
139
|
+
document: Document,
|
|
140
|
+
}).routes(({ path, include }) => [
|
|
141
|
+
path("/", Dashboard, { name: "home" }), // matches /admin
|
|
142
|
+
path("/users", Users, { name: "users" }), // matches /admin/users
|
|
143
|
+
include("/api", apiPatterns, { name: "api" }), // matches /admin/api/*
|
|
144
|
+
]);
|
|
145
|
+
|
|
146
|
+
router.reverse("home"); // "/admin"
|
|
147
|
+
router.reverse("users"); // "/admin/users"
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Router-owned APIs are basename-aware:
|
|
151
|
+
|
|
152
|
+
- `reverse()` returns prefixed paths
|
|
153
|
+
- `<Link to="/users">` renders `<a href="/admin/users">`
|
|
154
|
+
- `redirect("/login")` redirects to `"/admin/login"`
|
|
155
|
+
- `router.use("/users/*", mw)` matches `/admin/users/*`
|
|
156
|
+
- `useRouter().push("/users")` navigates to `/admin/users`
|
|
157
|
+
- Route names stay unprefixed (`"home"`, not `"admin.home"`)
|
|
158
|
+
|
|
159
|
+
Note: `href()` is a raw path helper and does **not** auto-prefix with basename.
|
|
160
|
+
Use `reverse()` or `<Link>` for basename-aware URLs.
|
|
161
|
+
|
|
127
162
|
## Using the Request Handler
|
|
128
163
|
|
|
129
164
|
The router provides a `fetch` method to handle RSC requests:
|
|
@@ -290,6 +325,56 @@ const router = createRouter({
|
|
|
290
325
|
export default router;
|
|
291
326
|
```
|
|
292
327
|
|
|
328
|
+
## Not Found Handling
|
|
329
|
+
|
|
330
|
+
Two distinct 404 scenarios:
|
|
331
|
+
|
|
332
|
+
**1. No route matches the URL** — the router renders the `notFound` component from `createRouter()` config. This is automatic.
|
|
333
|
+
|
|
334
|
+
**2. A handler/loader calls `notFound()`** — signals that the route matched but the data doesn't exist (e.g., invalid product ID).
|
|
335
|
+
|
|
336
|
+
```typescript
|
|
337
|
+
import { notFound } from "@rangojs/router";
|
|
338
|
+
|
|
339
|
+
// In a handler or loader
|
|
340
|
+
path("/product/:slug", async (ctx) => {
|
|
341
|
+
const product = await db.getProduct(ctx.params.slug);
|
|
342
|
+
if (!product) notFound("Product not found");
|
|
343
|
+
return <ProductPage product={product} />;
|
|
344
|
+
});
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
### Fallback chain for `notFound()`
|
|
348
|
+
|
|
349
|
+
When `notFound()` is thrown, the router looks for a fallback in this order:
|
|
350
|
+
|
|
351
|
+
1. **`notFoundBoundary()`** — nearest boundary in the route tree (route-level)
|
|
352
|
+
2. **`defaultNotFoundBoundary`** — from `createRouter()` config (app-level)
|
|
353
|
+
3. **`notFound`** — from `createRouter()` config (same component used for no-route-match)
|
|
354
|
+
4. **Default `<h1>Not Found</h1>`** — built-in fallback
|
|
355
|
+
|
|
356
|
+
All cases set HTTP 404 status.
|
|
357
|
+
|
|
358
|
+
### notFoundBoundary
|
|
359
|
+
|
|
360
|
+
Wrap routes with `notFoundBoundary()` for route-specific not-found UI:
|
|
361
|
+
|
|
362
|
+
```typescript
|
|
363
|
+
urls(({ path, layout }) => [
|
|
364
|
+
layout(ShopLayout, () => [
|
|
365
|
+
notFoundBoundary(({ notFound: info }) => (
|
|
366
|
+
<div>
|
|
367
|
+
<h1>Not Found</h1>
|
|
368
|
+
<p>{info.message}</p>
|
|
369
|
+
</div>
|
|
370
|
+
)),
|
|
371
|
+
path("/product/:slug", ProductPage),
|
|
372
|
+
]),
|
|
373
|
+
]);
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
`notFoundBoundary` receives `{ notFound: NotFoundInfo }` where `NotFoundInfo` contains `message`, `segmentId`, `segmentType`, and `pathname`.
|
|
377
|
+
|
|
293
378
|
## Including Sub-patterns
|
|
294
379
|
|
|
295
380
|
```typescript
|
|
@@ -369,8 +369,18 @@ interface PaginationData {
|
|
|
369
369
|
perPage: number;
|
|
370
370
|
}
|
|
371
371
|
export const Pagination = createVar<PaginationData>();
|
|
372
|
+
|
|
373
|
+
// Non-cacheable var — reading inside cache() or "use cache" throws at runtime
|
|
374
|
+
const Session = createVar<SessionData>({ cache: false });
|
|
372
375
|
```
|
|
373
376
|
|
|
377
|
+
`createVar` accepts an optional options object. The `cache` option (default
|
|
378
|
+
`true`) controls whether the var's values can be read inside cache scopes.
|
|
379
|
+
Write-level escalation is also supported: `ctx.set(Var, value, { cache: false })`
|
|
380
|
+
marks a specific write as non-cacheable even if the var itself is cacheable.
|
|
381
|
+
"Least cacheable wins" — if either says `cache: false`, the value throws on
|
|
382
|
+
read inside `cache()` or `"use cache"`.
|
|
383
|
+
|
|
374
384
|
### Producer (handler or middleware)
|
|
375
385
|
|
|
376
386
|
```typescript
|
package/src/__internal.ts
CHANGED
|
@@ -225,7 +225,7 @@ export type {
|
|
|
225
225
|
* @internal
|
|
226
226
|
* Type guard for prerender handler definitions.
|
|
227
227
|
*/
|
|
228
|
-
export { isPrerenderHandler } from "./prerender.js";
|
|
228
|
+
export { isPrerenderHandler, isPassthroughHandler } from "./prerender.js";
|
|
229
229
|
|
|
230
230
|
/**
|
|
231
231
|
* @internal
|
|
@@ -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 {
|
|
@@ -447,6 +448,12 @@ export function createNavigationBridge(
|
|
|
447
448
|
store.setCurrentUrl(url);
|
|
448
449
|
store.setPath(new URL(url).pathname);
|
|
449
450
|
|
|
451
|
+
// Restore router identity from cache so subsequent navigations
|
|
452
|
+
// don't falsely detect an app switch.
|
|
453
|
+
if (cached?.routerId) {
|
|
454
|
+
store.setRouterId?.(cached.routerId);
|
|
455
|
+
}
|
|
456
|
+
|
|
450
457
|
// Render from cache - force await to skip loading fallbacks
|
|
451
458
|
try {
|
|
452
459
|
const root = await renderSegments(cachedSegments, {
|
|
@@ -632,6 +639,12 @@ export function createNavigationBridge(
|
|
|
632
639
|
window.removeEventListener("pageshow", handlePageShow);
|
|
633
640
|
};
|
|
634
641
|
},
|
|
642
|
+
|
|
643
|
+
updateVersion(newVersion: string): void {
|
|
644
|
+
version = newVersion;
|
|
645
|
+
setAppVersion(newVersion);
|
|
646
|
+
store.clearHistoryCache();
|
|
647
|
+
},
|
|
635
648
|
};
|
|
636
649
|
}
|
|
637
650
|
|
|
@@ -61,6 +61,7 @@ export function createNavigationClient(
|
|
|
61
61
|
staleRevalidation,
|
|
62
62
|
interceptSourceUrl,
|
|
63
63
|
version,
|
|
64
|
+
routerId,
|
|
64
65
|
hmr,
|
|
65
66
|
} = options;
|
|
66
67
|
|
|
@@ -88,6 +89,9 @@ export function createNavigationClient(
|
|
|
88
89
|
if (version) {
|
|
89
90
|
fetchUrl.searchParams.set("_rsc_v", version);
|
|
90
91
|
}
|
|
92
|
+
if (routerId) {
|
|
93
|
+
fetchUrl.searchParams.set("_rsc_rid", routerId);
|
|
94
|
+
}
|
|
91
95
|
|
|
92
96
|
// Check completed in-memory prefetch cache before making a network request.
|
|
93
97
|
// The cache key includes the source URL (previousUrl) because the
|
|
@@ -97,16 +101,86 @@ export function createNavigationClient(
|
|
|
97
101
|
//
|
|
98
102
|
const canUsePrefetch = !staleRevalidation && !hmr && !interceptSourceUrl;
|
|
99
103
|
const cacheKey = buildPrefetchKey(previousUrl, fetchUrl);
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
+
// Wildcard key matches prefetch entries stored with a custom prefetchKey
|
|
105
|
+
// (Link's prefetchKey prop stores under "*" instead of the source URL).
|
|
106
|
+
const wildcardKey = "*\0" + fetchUrl.pathname + fetchUrl.search;
|
|
107
|
+
|
|
108
|
+
let cachedResponse: Response | null = null;
|
|
109
|
+
let hitKey: string | null = null;
|
|
110
|
+
if (canUsePrefetch) {
|
|
111
|
+
cachedResponse = consumePrefetch(cacheKey);
|
|
112
|
+
if (cachedResponse) {
|
|
113
|
+
hitKey = cacheKey;
|
|
114
|
+
} else {
|
|
115
|
+
cachedResponse = consumePrefetch(wildcardKey);
|
|
116
|
+
if (cachedResponse) hitKey = wildcardKey;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let inflightResponsePromise: Promise<Response | null> | null = null;
|
|
121
|
+
if (canUsePrefetch && !cachedResponse) {
|
|
122
|
+
inflightResponsePromise = consumeInflightPrefetch(cacheKey);
|
|
123
|
+
if (inflightResponsePromise) {
|
|
124
|
+
hitKey = cacheKey;
|
|
125
|
+
} else {
|
|
126
|
+
inflightResponsePromise = consumeInflightPrefetch(wildcardKey);
|
|
127
|
+
if (inflightResponsePromise) hitKey = wildcardKey;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
104
130
|
// Track when the stream completes
|
|
105
131
|
let resolveStreamComplete: () => void;
|
|
106
132
|
const streamComplete = new Promise<void>((resolve) => {
|
|
107
133
|
resolveStreamComplete = resolve;
|
|
108
134
|
});
|
|
109
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Validate RSC control headers on any response (fresh, cached, or
|
|
138
|
+
* in-flight). Handles version-mismatch reloads and server redirects.
|
|
139
|
+
* Returns the response unchanged when no control header is present.
|
|
140
|
+
*/
|
|
141
|
+
const validateRscHeaders = (
|
|
142
|
+
response: Response,
|
|
143
|
+
source: string,
|
|
144
|
+
): Response | Promise<Response> => {
|
|
145
|
+
// Version mismatch — server wants a full page reload
|
|
146
|
+
const reload = extractRscHeaderUrl(response, "X-RSC-Reload");
|
|
147
|
+
if (reload === "blocked") {
|
|
148
|
+
resolveStreamComplete();
|
|
149
|
+
return emptyResponse();
|
|
150
|
+
}
|
|
151
|
+
if (reload) {
|
|
152
|
+
if (tx) {
|
|
153
|
+
browserDebugLog(tx, `version mismatch, reloading (${source})`, {
|
|
154
|
+
reloadUrl: reload.url,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
window.location.href = reload.url;
|
|
158
|
+
// Block further processing — page is reloading
|
|
159
|
+
return new Promise<Response>(() => {});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Server-side redirect without state: the server returned 204 with
|
|
163
|
+
// X-RSC-Redirect instead of a 3xx (which fetch would auto-follow
|
|
164
|
+
// to a URL rendering full HTML). Throw ServerRedirect so the
|
|
165
|
+
// navigation bridge catches it and re-navigates with _skipCache.
|
|
166
|
+
const redirect = extractRscHeaderUrl(response, "X-RSC-Redirect");
|
|
167
|
+
if (redirect === "blocked") {
|
|
168
|
+
resolveStreamComplete();
|
|
169
|
+
return emptyResponse();
|
|
170
|
+
}
|
|
171
|
+
if (redirect) {
|
|
172
|
+
if (tx) {
|
|
173
|
+
browserDebugLog(tx, `server redirect (${source})`, {
|
|
174
|
+
redirectUrl: redirect.url,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
resolveStreamComplete();
|
|
178
|
+
throw new ServerRedirect(redirect.url, undefined);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return response;
|
|
182
|
+
};
|
|
183
|
+
|
|
110
184
|
/** Start a fresh navigation fetch (no cache / inflight hit). */
|
|
111
185
|
const doFreshFetch = (): Promise<Response> => {
|
|
112
186
|
if (tx) {
|
|
@@ -127,43 +201,11 @@ export function createNavigationClient(
|
|
|
127
201
|
},
|
|
128
202
|
signal,
|
|
129
203
|
}).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
|
-
}
|
|
204
|
+
const validated = validateRscHeaders(response, "fetch");
|
|
205
|
+
if (validated instanceof Promise) return validated;
|
|
164
206
|
|
|
165
207
|
return teeWithCompletion(
|
|
166
|
-
|
|
208
|
+
validated,
|
|
167
209
|
() => {
|
|
168
210
|
if (tx) browserDebugLog(tx, "stream complete");
|
|
169
211
|
resolveStreamComplete();
|
|
@@ -177,13 +219,17 @@ export function createNavigationClient(
|
|
|
177
219
|
|
|
178
220
|
if (cachedResponse) {
|
|
179
221
|
if (tx) {
|
|
180
|
-
browserDebugLog(tx, "prefetch cache hit", {
|
|
222
|
+
browserDebugLog(tx, "prefetch cache hit", {
|
|
223
|
+
key: hitKey,
|
|
224
|
+
wildcard: hitKey === wildcardKey,
|
|
225
|
+
});
|
|
181
226
|
}
|
|
182
|
-
// Cached response body is already fully buffered (arrayBuffer),
|
|
183
|
-
// so stream completion is immediate.
|
|
184
227
|
responsePromise = Promise.resolve(cachedResponse).then((response) => {
|
|
228
|
+
const validated = validateRscHeaders(response, "prefetch cache");
|
|
229
|
+
if (validated instanceof Promise) return validated;
|
|
230
|
+
|
|
185
231
|
return teeWithCompletion(
|
|
186
|
-
|
|
232
|
+
validated,
|
|
187
233
|
() => {
|
|
188
234
|
if (tx) browserDebugLog(tx, "stream complete (from cache)");
|
|
189
235
|
resolveStreamComplete();
|
|
@@ -193,7 +239,10 @@ export function createNavigationClient(
|
|
|
193
239
|
});
|
|
194
240
|
} else if (inflightResponsePromise) {
|
|
195
241
|
if (tx) {
|
|
196
|
-
browserDebugLog(tx, "reusing inflight prefetch", {
|
|
242
|
+
browserDebugLog(tx, "reusing inflight prefetch", {
|
|
243
|
+
key: hitKey,
|
|
244
|
+
wildcard: hitKey === wildcardKey,
|
|
245
|
+
});
|
|
197
246
|
}
|
|
198
247
|
responsePromise = inflightResponsePromise.then(async (response) => {
|
|
199
248
|
if (!response) {
|
|
@@ -203,8 +252,11 @@ export function createNavigationClient(
|
|
|
203
252
|
return doFreshFetch();
|
|
204
253
|
}
|
|
205
254
|
|
|
255
|
+
const validated = validateRscHeaders(response, "inflight prefetch");
|
|
256
|
+
if (validated instanceof Promise) return validated;
|
|
257
|
+
|
|
206
258
|
return teeWithCompletion(
|
|
207
|
-
|
|
259
|
+
validated,
|
|
208
260
|
() => {
|
|
209
261
|
if (tx) {
|
|
210
262
|
browserDebugLog(tx, "stream complete (from inflight prefetch)");
|
|
@@ -219,8 +271,8 @@ export function createNavigationClient(
|
|
|
219
271
|
}
|
|
220
272
|
|
|
221
273
|
try {
|
|
222
|
-
// Deserialize RSC payload
|
|
223
274
|
const payload = await deps.createFromFetch<RscPayload>(responsePromise);
|
|
275
|
+
|
|
224
276
|
if (tx) {
|
|
225
277
|
browserDebugLog(tx, "response received", {
|
|
226
278
|
isPartial: payload.metadata?.isPartial,
|
|
@@ -28,9 +28,15 @@ const DEFAULT_ACTION_STATE: TrackedActionState = {
|
|
|
28
28
|
// Maximum number of history entries to cache (URLs visited)
|
|
29
29
|
const HISTORY_CACHE_SIZE = 20;
|
|
30
30
|
|
|
31
|
-
// Cache entry: [url-key, segments, stale, handleData?]
|
|
31
|
+
// Cache entry: [url-key, segments, stale, handleData?, routerId?]
|
|
32
32
|
// stale=true means the data may be outdated and should be revalidated on access
|
|
33
|
-
type HistoryCacheEntry = [
|
|
33
|
+
type HistoryCacheEntry = [
|
|
34
|
+
string,
|
|
35
|
+
ResolvedSegment[],
|
|
36
|
+
boolean,
|
|
37
|
+
HandleData?,
|
|
38
|
+
string?,
|
|
39
|
+
];
|
|
34
40
|
|
|
35
41
|
/**
|
|
36
42
|
* Shallow clone handleData to avoid reference sharing between cache entries.
|
|
@@ -258,6 +264,11 @@ export function createNavigationStore(
|
|
|
258
264
|
// Used to maintain intercept context during action revalidation
|
|
259
265
|
let interceptSourceUrl: string | null = null;
|
|
260
266
|
|
|
267
|
+
// Router identity - tracks which router is currently active.
|
|
268
|
+
// When this changes on a partial response, the client forces a full
|
|
269
|
+
// tree replacement instead of reconciling with stale segments.
|
|
270
|
+
let currentRouterId: string | undefined;
|
|
271
|
+
|
|
261
272
|
// Action state tracking (for useAction hook)
|
|
262
273
|
// Maps action function ID to its tracked state
|
|
263
274
|
const actionStates = new Map<string, TrackedActionState>();
|
|
@@ -571,10 +582,17 @@ export function createNavigationStore(
|
|
|
571
582
|
segments,
|
|
572
583
|
false,
|
|
573
584
|
clonedHandleData,
|
|
585
|
+
currentRouterId,
|
|
574
586
|
];
|
|
575
587
|
} else {
|
|
576
588
|
// Add new entry at the end (not stale)
|
|
577
|
-
historyCache.push([
|
|
589
|
+
historyCache.push([
|
|
590
|
+
historyKey,
|
|
591
|
+
segments,
|
|
592
|
+
false,
|
|
593
|
+
clonedHandleData,
|
|
594
|
+
currentRouterId,
|
|
595
|
+
]);
|
|
578
596
|
// Remove oldest entries if over limit
|
|
579
597
|
while (historyCache.length > cacheSize) {
|
|
580
598
|
historyCache.shift();
|
|
@@ -586,14 +604,22 @@ export function createNavigationStore(
|
|
|
586
604
|
* Get cached segments for a history entry
|
|
587
605
|
* Returns { segments, stale, handleData } or undefined if not cached
|
|
588
606
|
*/
|
|
589
|
-
getCachedSegments(
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
607
|
+
getCachedSegments(historyKey: string):
|
|
608
|
+
| {
|
|
609
|
+
segments: ResolvedSegment[];
|
|
610
|
+
stale: boolean;
|
|
611
|
+
handleData?: HandleData;
|
|
612
|
+
routerId?: string;
|
|
613
|
+
}
|
|
593
614
|
| undefined {
|
|
594
615
|
const entry = historyCache.find(([key]) => key === historyKey);
|
|
595
616
|
if (!entry) return undefined;
|
|
596
|
-
return {
|
|
617
|
+
return {
|
|
618
|
+
segments: entry[1],
|
|
619
|
+
stale: entry[2],
|
|
620
|
+
handleData: entry[3],
|
|
621
|
+
routerId: entry[4],
|
|
622
|
+
};
|
|
597
623
|
},
|
|
598
624
|
|
|
599
625
|
/**
|
|
@@ -621,6 +647,7 @@ export function createNavigationStore(
|
|
|
621
647
|
entry[1],
|
|
622
648
|
entry[2],
|
|
623
649
|
clonedHandleData,
|
|
650
|
+
entry[4], // preserve routerId
|
|
624
651
|
];
|
|
625
652
|
}
|
|
626
653
|
},
|
|
@@ -687,6 +714,14 @@ export function createNavigationStore(
|
|
|
687
714
|
interceptSourceUrl = url;
|
|
688
715
|
},
|
|
689
716
|
|
|
717
|
+
getRouterId(): string | undefined {
|
|
718
|
+
return currentRouterId;
|
|
719
|
+
},
|
|
720
|
+
|
|
721
|
+
setRouterId(id: string): void {
|
|
722
|
+
currentRouterId = id;
|
|
723
|
+
},
|
|
724
|
+
|
|
690
725
|
// ========================================================================
|
|
691
726
|
// UI Update Notifications
|
|
692
727
|
// ========================================================================
|
|
@@ -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
|
|
@@ -182,6 +188,11 @@ export function createPartialUpdater(
|
|
|
182
188
|
targetCache && targetCache.length > 0
|
|
183
189
|
? targetCache
|
|
184
190
|
: getCurrentCachedSegments();
|
|
191
|
+
const cachedSegsSource =
|
|
192
|
+
targetCache && targetCache.length > 0 ? "history-cache" : "current-page";
|
|
193
|
+
debugLog(
|
|
194
|
+
`[Browser] cachedSegs source: ${cachedSegsSource} (${cachedSegs.length} segments: ${cachedSegs.map((s) => s.id).join(", ")})`,
|
|
195
|
+
);
|
|
185
196
|
|
|
186
197
|
// Fetch partial payload (no abort signal - RSC doesn't support it well)
|
|
187
198
|
let fetchResult: Awaited<ReturnType<NavigationClient["fetchPartial"]>>;
|
|
@@ -193,7 +204,8 @@ export function createPartialUpdater(
|
|
|
193
204
|
// (action redirect sends empty segments for a fresh render).
|
|
194
205
|
staleRevalidation:
|
|
195
206
|
mode.type === "stale-revalidation" || segments.length === 0,
|
|
196
|
-
version,
|
|
207
|
+
version: getVersion(),
|
|
208
|
+
routerId: store.getRouterId?.(),
|
|
197
209
|
});
|
|
198
210
|
// Mark navigation as streaming (response received, now parsing RSC).
|
|
199
211
|
// Called after fetchPartial so pendingUrl stays set during the network wait,
|
|
@@ -206,6 +218,21 @@ export function createPartialUpdater(
|
|
|
206
218
|
streamingToken.end();
|
|
207
219
|
});
|
|
208
220
|
|
|
221
|
+
// Detect app switch: if routerId changed, the navigation crossed into
|
|
222
|
+
// a different router (e.g., via host router path mount). Downgrade
|
|
223
|
+
// partial to full so the entire tree is replaced without reconciliation
|
|
224
|
+
// against stale segments from the previous app.
|
|
225
|
+
if (payload.metadata?.routerId) {
|
|
226
|
+
const prevRouterId = store.getRouterId?.();
|
|
227
|
+
if (prevRouterId && prevRouterId !== payload.metadata.routerId) {
|
|
228
|
+
debugLog(
|
|
229
|
+
`[Browser] App switch detected (${prevRouterId} → ${payload.metadata.routerId}), forcing full update`,
|
|
230
|
+
);
|
|
231
|
+
payload.metadata.isPartial = false;
|
|
232
|
+
}
|
|
233
|
+
store.setRouterId?.(payload.metadata.routerId);
|
|
234
|
+
}
|
|
235
|
+
|
|
209
236
|
// Handle server-side redirect with state
|
|
210
237
|
if (payload.metadata?.redirect) {
|
|
211
238
|
if (signal?.aborted) {
|
|
@@ -259,7 +286,7 @@ export function createPartialUpdater(
|
|
|
259
286
|
existingSegments,
|
|
260
287
|
);
|
|
261
288
|
|
|
262
|
-
//
|
|
289
|
+
// tx.commit() cached the source page's handleData because
|
|
263
290
|
// eventController hasn't been updated yet. Overwrite with the
|
|
264
291
|
// correct cached handleData to prevent cache corruption on
|
|
265
292
|
// subsequent navigations to this same URL.
|
|
@@ -61,13 +61,23 @@ const inflightPromises = new Map<string, Promise<Response | null>>();
|
|
|
61
61
|
let generation = 0;
|
|
62
62
|
|
|
63
63
|
/**
|
|
64
|
-
* Build a
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
64
|
+
* Build a cache key for prefetched responses.
|
|
65
|
+
*
|
|
66
|
+
* By default the key includes the source page href so the same target
|
|
67
|
+
* prefetched from different pages gets separate entries (the server's
|
|
68
|
+
* diff response depends on the source page context).
|
|
69
|
+
*
|
|
70
|
+
* When `prefetchKey` is provided, the source portion is replaced with
|
|
71
|
+
* a `*` sentinel so all custom-keyed entries share one cache slot per
|
|
72
|
+
* target — enabling source-agnostic cache reuse.
|
|
68
73
|
*/
|
|
69
|
-
export function buildPrefetchKey(
|
|
70
|
-
|
|
74
|
+
export function buildPrefetchKey(
|
|
75
|
+
sourceHref: string,
|
|
76
|
+
targetUrl: URL,
|
|
77
|
+
prefetchKey?: string | ((from: string) => string),
|
|
78
|
+
): string {
|
|
79
|
+
const source = prefetchKey != null ? "*" : sourceHref;
|
|
80
|
+
return source + "\0" + targetUrl.pathname + targetUrl.search;
|
|
71
81
|
}
|
|
72
82
|
|
|
73
83
|
/**
|