@rangojs/router 0.0.0-experimental.ea6d5eec → 0.0.0-experimental.ede38110
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 +719 -240
- package/package.json +3 -3
- package/skills/cache-guide/SKILL.md +32 -0
- package/skills/caching/SKILL.md +8 -0
- package/skills/handler-use/SKILL.md +362 -0
- package/skills/intercept/SKILL.md +20 -0
- package/skills/layout/SKILL.md +22 -0
- package/skills/links/SKILL.md +3 -1
- package/skills/loader/SKILL.md +53 -43
- package/skills/middleware/SKILL.md +34 -3
- package/skills/migrate-nextjs/SKILL.md +560 -0
- package/skills/migrate-react-router/SKILL.md +764 -0
- package/skills/parallel/SKILL.md +185 -0
- package/skills/prerender/SKILL.md +110 -68
- package/skills/rango/SKILL.md +24 -22
- package/skills/route/SKILL.md +55 -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/event-controller.ts +5 -0
- package/src/browser/navigation-bridge.ts +37 -5
- package/src/browser/navigation-client.ts +107 -75
- package/src/browser/navigation-store.ts +43 -8
- package/src/browser/partial-update.ts +51 -6
- package/src/browser/prefetch/cache.ts +22 -12
- package/src/browser/prefetch/fetch.ts +81 -20
- 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-navigation.ts +11 -10
- package/src/browser/react/use-router.ts +21 -8
- package/src/browser/rsc-router.tsx +45 -3
- package/src/browser/scroll-restoration.ts +10 -8
- package/src/browser/segment-reconciler.ts +36 -9
- 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-trie.ts +50 -24
- 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-runtime.ts +15 -11
- package/src/cache/cache-scope.ts +46 -5
- package/src/cache/document-cache.ts +17 -7
- package/src/cache/taint.ts +55 -0
- package/src/client.tsx +84 -230
- package/src/context-var.ts +72 -2
- package/src/debug.ts +2 -2
- package/src/handle.ts +40 -0
- package/src/index.rsc.ts +3 -1
- package/src/index.ts +46 -6
- package/src/prerender/store.ts +5 -4
- package/src/prerender.ts +138 -77
- package/src/reverse.ts +25 -1
- package/src/route-definition/dsl-helpers.ts +224 -37
- package/src/route-definition/helpers-types.ts +67 -19
- 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 +18 -0
- package/src/router/content-negotiation.ts +100 -1
- package/src/router/handler-context.ts +82 -23
- package/src/router/intercept-resolution.ts +9 -4
- package/src/router/lazy-includes.ts +7 -6
- package/src/router/loader-resolution.ts +156 -21
- package/src/router/logging.ts +1 -1
- package/src/router/manifest.ts +28 -15
- package/src/router/match-api.ts +124 -189
- package/src/router/match-middleware/background-revalidation.ts +30 -2
- package/src/router/match-middleware/cache-lookup.ts +94 -17
- package/src/router/match-middleware/cache-store.ts +53 -10
- package/src/router/match-middleware/intercept-resolution.ts +9 -7
- package/src/router/match-middleware/segment-resolution.ts +60 -5
- package/src/router/match-result.ts +104 -10
- package/src/router/metrics.ts +6 -1
- 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-context.ts +1 -0
- package/src/router/router-interfaces.ts +36 -4
- package/src/router/router-options.ts +37 -11
- package/src/router/segment-resolution/fresh.ts +198 -20
- package/src/router/segment-resolution/helpers.ts +29 -24
- package/src/router/segment-resolution/loader-cache.ts +1 -0
- package/src/router/segment-resolution/revalidation.ts +433 -296
- package/src/router/types.ts +1 -0
- package/src/router.ts +55 -6
- 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/segment-content-promise.ts +67 -0
- package/src/segment-loader-promise.ts +122 -0
- package/src/segment-system.tsx +109 -23
- package/src/server/context.ts +166 -17
- 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 +4 -0
- package/src/static-handler.ts +18 -6
- package/src/types/cache-types.ts +4 -4
- package/src/types/handler-context.ts +137 -33
- package/src/types/loader-types.ts +36 -9
- package/src/types/route-entry.ts +12 -1
- package/src/types/segments.ts +2 -0
- package/src/urls/include-helper.ts +24 -14
- package/src/urls/path-helper-types.ts +39 -6
- package/src/urls/path-helper.ts +48 -13
- 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/banner.ts +3 -3
- package/src/vite/utils/prerender-utils.ts +37 -5
- 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
|
|
@@ -352,6 +383,30 @@ urls(({ path, layout }) => [
|
|
|
352
383
|
])
|
|
353
384
|
```
|
|
354
385
|
|
|
386
|
+
## Handler-attached `.use`
|
|
387
|
+
|
|
388
|
+
Page handlers can carry their own loader, middleware, error boundaries, parallels, and other defaults via a `.use` callback — so the page is self-contained and reusable across mount sites without re-wiring the same items.
|
|
389
|
+
|
|
390
|
+
```typescript
|
|
391
|
+
const ProductPage: Handler<"/product/:slug"> = async (ctx) => {
|
|
392
|
+
const product = await ctx.use(ProductLoader);
|
|
393
|
+
return <ProductView product={product} />;
|
|
394
|
+
};
|
|
395
|
+
ProductPage.use = () => [
|
|
396
|
+
loader(ProductLoader),
|
|
397
|
+
loading(<ProductSkeleton />),
|
|
398
|
+
middleware(async (ctx, next) => {
|
|
399
|
+
await next();
|
|
400
|
+
ctx.header("Cache-Control", "private, max-age=60");
|
|
401
|
+
}),
|
|
402
|
+
];
|
|
403
|
+
|
|
404
|
+
// Mount site has no per-page wiring — defaults travel with the handler.
|
|
405
|
+
path("/product/:slug", ProductPage, { name: "product" });
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
Explicit `use()` at the mount site merges with `handler.use` (handler defaults first, explicit second). See [skills/handler-use](../handler-use/SKILL.md) for the merge order, allowed item types per mount site, and override semantics.
|
|
409
|
+
|
|
355
410
|
## Complete Example
|
|
356
411
|
|
|
357
412
|
```typescript
|
|
@@ -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
|
+
}
|
|
@@ -79,6 +79,8 @@ export interface DerivedNavigationState {
|
|
|
79
79
|
state: "idle" | "loading";
|
|
80
80
|
/** Whether any operation is streaming */
|
|
81
81
|
isStreaming: boolean;
|
|
82
|
+
/** Whether a navigation is active (fetching or streaming, before commit) */
|
|
83
|
+
isNavigating: boolean;
|
|
82
84
|
/** Current committed location */
|
|
83
85
|
location: NavigationLocation;
|
|
84
86
|
/** URL being navigated to (null if idle) */
|
|
@@ -389,6 +391,9 @@ export function createEventController(
|
|
|
389
391
|
return {
|
|
390
392
|
state,
|
|
391
393
|
isStreaming,
|
|
394
|
+
// True when a navigation is active (fetching or streaming, before
|
|
395
|
+
// commit). Broader than pendingUrl which clears during streaming.
|
|
396
|
+
isNavigating: currentNavigation !== null,
|
|
392
397
|
location,
|
|
393
398
|
// pendingUrl only during fetching phase - once streaming starts (URL changed), not pending.
|
|
394
399
|
// Background revalidations (skipLoadingState) don't expose a pending URL.
|
|
@@ -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 {
|
|
@@ -260,18 +261,24 @@ export function createNavigationBridge(
|
|
|
260
261
|
// 2. routes that CAN be intercepted - we don't know if this navigation will intercept
|
|
261
262
|
// 3. when leaving intercept - we need fresh non-intercept segments from server
|
|
262
263
|
// 4. redirect-with-state - force re-render so hooks read fresh state
|
|
264
|
+
// 5. stale cache - server action invalidated it, need fresh data with loading state
|
|
263
265
|
const hasUsableCache =
|
|
264
266
|
cachedSegments &&
|
|
265
267
|
cachedSegments.length > 0 &&
|
|
266
268
|
!isInterceptOnlyCache(cachedSegments) &&
|
|
267
269
|
!hasInterceptCache &&
|
|
268
270
|
!isLeavingIntercept &&
|
|
271
|
+
!cached?.stale &&
|
|
269
272
|
!options?._skipCache;
|
|
270
273
|
|
|
274
|
+
// Forward navigations always await fetchPartialUpdate before rendering,
|
|
275
|
+
// so useNavigation should always report "loading". skipLoadingState is
|
|
276
|
+
// only used for popstate background revalidation (line ~526) where
|
|
277
|
+
// cached content renders instantly without a network wait.
|
|
271
278
|
const tx = createNavigationTransaction(store, eventController, url, {
|
|
272
279
|
...options,
|
|
273
280
|
state: resolvedState,
|
|
274
|
-
skipLoadingState:
|
|
281
|
+
skipLoadingState: false,
|
|
275
282
|
});
|
|
276
283
|
|
|
277
284
|
// REVALIDATE: Fetch fresh data from server
|
|
@@ -411,6 +418,15 @@ export function createNavigationBridge(
|
|
|
411
418
|
eventController.abortAllActions();
|
|
412
419
|
}
|
|
413
420
|
|
|
421
|
+
// Popstate that exits an intercept to a non-intercept destination. The
|
|
422
|
+
// fallback fetch path below needs `leave-intercept` mode so it filters
|
|
423
|
+
// the cached @modal segment from the request and forces a re-render —
|
|
424
|
+
// otherwise a cache-miss popstate whose server response has an empty
|
|
425
|
+
// diff hits the "no changes" branch in partial-update and the modal
|
|
426
|
+
// stays on screen.
|
|
427
|
+
const isLeavingIntercept =
|
|
428
|
+
!isIntercept && currentInterceptSource !== null;
|
|
429
|
+
|
|
414
430
|
// Compute history key from URL (with intercept suffix if applicable)
|
|
415
431
|
const historyKey = generateHistoryKey(url, { intercept: isIntercept });
|
|
416
432
|
|
|
@@ -447,6 +463,12 @@ export function createNavigationBridge(
|
|
|
447
463
|
store.setCurrentUrl(url);
|
|
448
464
|
store.setPath(new URL(url).pathname);
|
|
449
465
|
|
|
466
|
+
// Restore router identity from cache so subsequent navigations
|
|
467
|
+
// don't falsely detect an app switch.
|
|
468
|
+
if (cached?.routerId) {
|
|
469
|
+
store.setRouterId?.(cached.routerId);
|
|
470
|
+
}
|
|
471
|
+
|
|
450
472
|
// Render from cache - force await to skip loading fallbacks
|
|
451
473
|
try {
|
|
452
474
|
const root = await renderSegments(cachedSegments, {
|
|
@@ -555,7 +577,11 @@ export function createNavigationBridge(
|
|
|
555
577
|
intercept: isIntercept,
|
|
556
578
|
interceptSourceUrl,
|
|
557
579
|
}),
|
|
558
|
-
isIntercept
|
|
580
|
+
isIntercept
|
|
581
|
+
? { type: "navigate", interceptSourceUrl }
|
|
582
|
+
: isLeavingIntercept
|
|
583
|
+
? { type: "leave-intercept" }
|
|
584
|
+
: undefined,
|
|
559
585
|
);
|
|
560
586
|
// Restore scroll position after fetch completes
|
|
561
587
|
handleNavigationEnd({ restore: true, isStreaming });
|
|
@@ -632,6 +658,12 @@ export function createNavigationBridge(
|
|
|
632
658
|
window.removeEventListener("pageshow", handlePageShow);
|
|
633
659
|
};
|
|
634
660
|
},
|
|
661
|
+
|
|
662
|
+
updateVersion(newVersion: string): void {
|
|
663
|
+
version = newVersion;
|
|
664
|
+
setAppVersion(newVersion);
|
|
665
|
+
store.clearHistoryCache();
|
|
666
|
+
},
|
|
635
667
|
};
|
|
636
668
|
}
|
|
637
669
|
|
|
@@ -19,8 +19,8 @@ import {
|
|
|
19
19
|
} from "./response-adapter.js";
|
|
20
20
|
import {
|
|
21
21
|
buildPrefetchKey,
|
|
22
|
-
consumePrefetch,
|
|
23
22
|
consumeInflightPrefetch,
|
|
23
|
+
consumePrefetch,
|
|
24
24
|
} from "./prefetch/cache.js";
|
|
25
25
|
|
|
26
26
|
/**
|
|
@@ -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,29 +89,98 @@ 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
|
-
// Check in-memory prefetch cache before making a network request.
|
|
96
|
+
// Check completed in-memory prefetch cache before making a network request.
|
|
93
97
|
// The cache key includes the source URL (previousUrl) because the
|
|
94
98
|
// server's diff response depends on the source page context.
|
|
95
99
|
// Skip cache for stale revalidation (needs fresh data), HMR (needs
|
|
96
100
|
// fresh modules), and intercept contexts (source-dependent responses).
|
|
101
|
+
//
|
|
97
102
|
const canUsePrefetch = !staleRevalidation && !hmr && !interceptSourceUrl;
|
|
98
103
|
const cacheKey = buildPrefetchKey(previousUrl, fetchUrl);
|
|
99
|
-
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
+
}
|
|
107
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
|
+
}
|
|
108
130
|
// Track when the stream completes
|
|
109
131
|
let resolveStreamComplete: () => void;
|
|
110
132
|
const streamComplete = new Promise<void>((resolve) => {
|
|
111
133
|
resolveStreamComplete = resolve;
|
|
112
134
|
});
|
|
113
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
|
+
|
|
114
184
|
/** Start a fresh navigation fetch (no cache / inflight hit). */
|
|
115
185
|
const doFreshFetch = (): Promise<Response> => {
|
|
116
186
|
if (tx) {
|
|
@@ -131,50 +201,11 @@ export function createNavigationClient(
|
|
|
131
201
|
},
|
|
132
202
|
signal,
|
|
133
203
|
}).then((response) => {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
if (reload === "blocked") {
|
|
137
|
-
resolveStreamComplete();
|
|
138
|
-
return emptyResponse();
|
|
139
|
-
}
|
|
140
|
-
if (reload) {
|
|
141
|
-
if (tx) {
|
|
142
|
-
browserDebugLog(tx, "version mismatch, reloading", {
|
|
143
|
-
reloadUrl: reload.url,
|
|
144
|
-
});
|
|
145
|
-
}
|
|
146
|
-
window.location.href = reload.url;
|
|
147
|
-
return new Promise<Response>(() => {});
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// Server-side redirect without state: the server returned 204 with
|
|
151
|
-
// X-RSC-Redirect instead of a 3xx (which fetch would auto-follow
|
|
152
|
-
// to a URL rendering full HTML). Throw ServerRedirect so the
|
|
153
|
-
// navigation bridge catches it and re-navigates with _skipCache.
|
|
154
|
-
const redirect = extractRscHeaderUrl(response, "X-RSC-Redirect");
|
|
155
|
-
if (redirect === "blocked") {
|
|
156
|
-
resolveStreamComplete();
|
|
157
|
-
return emptyResponse();
|
|
158
|
-
}
|
|
159
|
-
if (redirect) {
|
|
160
|
-
if (tx) {
|
|
161
|
-
browserDebugLog(tx, "server redirect", {
|
|
162
|
-
redirectUrl: redirect.url,
|
|
163
|
-
});
|
|
164
|
-
}
|
|
165
|
-
resolveStreamComplete();
|
|
166
|
-
throw new ServerRedirect(redirect.url, undefined);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
if (!response.ok) {
|
|
170
|
-
resolveStreamComplete();
|
|
171
|
-
throw new Error(
|
|
172
|
-
`Partial RSC fetch failed: ${response.status} ${response.statusText}`,
|
|
173
|
-
);
|
|
174
|
-
}
|
|
204
|
+
const validated = validateRscHeaders(response, "fetch");
|
|
205
|
+
if (validated instanceof Promise) return validated;
|
|
175
206
|
|
|
176
207
|
return teeWithCompletion(
|
|
177
|
-
|
|
208
|
+
validated,
|
|
178
209
|
() => {
|
|
179
210
|
if (tx) browserDebugLog(tx, "stream complete");
|
|
180
211
|
resolveStreamComplete();
|
|
@@ -188,13 +219,17 @@ export function createNavigationClient(
|
|
|
188
219
|
|
|
189
220
|
if (cachedResponse) {
|
|
190
221
|
if (tx) {
|
|
191
|
-
browserDebugLog(tx, "prefetch cache hit", {
|
|
222
|
+
browserDebugLog(tx, "prefetch cache hit", {
|
|
223
|
+
key: hitKey,
|
|
224
|
+
wildcard: hitKey === wildcardKey,
|
|
225
|
+
});
|
|
192
226
|
}
|
|
193
|
-
// Cached response body is already fully buffered (arrayBuffer),
|
|
194
|
-
// so stream completion is immediate.
|
|
195
227
|
responsePromise = Promise.resolve(cachedResponse).then((response) => {
|
|
228
|
+
const validated = validateRscHeaders(response, "prefetch cache");
|
|
229
|
+
if (validated instanceof Promise) return validated;
|
|
230
|
+
|
|
196
231
|
return teeWithCompletion(
|
|
197
|
-
|
|
232
|
+
validated,
|
|
198
233
|
() => {
|
|
199
234
|
if (tx) browserDebugLog(tx, "stream complete (from cache)");
|
|
200
235
|
resolveStreamComplete();
|
|
@@ -202,33 +237,30 @@ export function createNavigationClient(
|
|
|
202
237
|
signal,
|
|
203
238
|
);
|
|
204
239
|
});
|
|
205
|
-
} else if (
|
|
240
|
+
} else if (inflightResponsePromise) {
|
|
206
241
|
if (tx) {
|
|
207
|
-
browserDebugLog(tx, "reusing inflight prefetch", {
|
|
242
|
+
browserDebugLog(tx, "reusing inflight prefetch", {
|
|
243
|
+
key: hitKey,
|
|
244
|
+
wildcard: hitKey === wildcardKey,
|
|
245
|
+
});
|
|
208
246
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
// a fresh navigation fetch.
|
|
212
|
-
responsePromise = inflightPrefetch.then((prefetchResponse) => {
|
|
213
|
-
if (!prefetchResponse) {
|
|
247
|
+
responsePromise = inflightResponsePromise.then(async (response) => {
|
|
248
|
+
if (!response) {
|
|
214
249
|
if (tx) {
|
|
215
|
-
browserDebugLog(
|
|
216
|
-
tx,
|
|
217
|
-
"inflight prefetch failed, falling back to fetch",
|
|
218
|
-
);
|
|
250
|
+
browserDebugLog(tx, "inflight prefetch unavailable, refetching");
|
|
219
251
|
}
|
|
220
252
|
return doFreshFetch();
|
|
221
253
|
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
}
|
|
254
|
+
|
|
255
|
+
const validated = validateRscHeaders(response, "inflight prefetch");
|
|
256
|
+
if (validated instanceof Promise) return validated;
|
|
257
|
+
|
|
227
258
|
return teeWithCompletion(
|
|
228
|
-
|
|
259
|
+
validated,
|
|
229
260
|
() => {
|
|
230
|
-
if (tx)
|
|
261
|
+
if (tx) {
|
|
231
262
|
browserDebugLog(tx, "stream complete (from inflight prefetch)");
|
|
263
|
+
}
|
|
232
264
|
resolveStreamComplete();
|
|
233
265
|
},
|
|
234
266
|
signal,
|
|
@@ -239,8 +271,8 @@ export function createNavigationClient(
|
|
|
239
271
|
}
|
|
240
272
|
|
|
241
273
|
try {
|
|
242
|
-
// Deserialize RSC payload
|
|
243
274
|
const payload = await deps.createFromFetch<RscPayload>(responsePromise);
|
|
275
|
+
|
|
244
276
|
if (tx) {
|
|
245
277
|
browserDebugLog(tx, "response received", {
|
|
246
278
|
isPartial: payload.metadata?.isPartial,
|