@rangojs/router 0.0.0-experimental.133 → 0.0.0-experimental.135
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/bin/rango.js +7 -2
- package/dist/vite/index.js +41 -27
- package/package.json +23 -24
- package/skills/composability/SKILL.md +0 -1
- package/skills/handler-use/SKILL.md +7 -7
- package/skills/intercept/SKILL.md +38 -13
- package/skills/loader/SKILL.md +10 -0
- package/skills/migrate-nextjs/SKILL.md +3 -3
- package/skills/migrate-react-router/SKILL.md +144 -1
- package/skills/prerender/SKILL.md +20 -17
- package/skills/router-setup/SKILL.md +1 -2
- package/skills/testing/SKILL.md +1 -0
- package/skills/testing/render-handler.md +15 -14
- package/skills/use-cache/SKILL.md +11 -0
- package/skills/view-transitions/SKILL.md +43 -0
- package/src/browser/navigation-bridge.ts +65 -16
- package/src/browser/navigation-client.ts +27 -1
- package/src/browser/navigation-store.ts +82 -8
- package/src/browser/network-error-handler.ts +34 -7
- package/src/browser/partial-update.ts +43 -3
- package/src/browser/prefetch/cache.ts +8 -0
- package/src/browser/prefetch/fetch.ts +32 -4
- package/src/browser/react/NavigationProvider.tsx +195 -4
- package/src/browser/react/deferred-handle-resolution.ts +75 -0
- package/src/browser/response-adapter.ts +38 -9
- package/src/browser/types.ts +32 -1
- package/src/cache/cache-runtime.ts +26 -5
- package/src/cache/document-cache.ts +17 -1
- package/src/cache/profile-registry.ts +15 -0
- package/src/cache/read-through-swr.ts +15 -1
- package/src/handles/MetaTags.tsx +6 -0
- package/src/index.rsc.ts +6 -1
- package/src/index.ts +6 -4
- package/src/internal-debug.ts +11 -8
- package/src/render-error-thrower.tsx +20 -0
- package/src/route-content-wrapper.tsx +12 -5
- package/src/route-definition/dsl-helpers.ts +21 -32
- package/src/route-definition/helper-factories.ts +0 -2
- package/src/route-definition/helpers-types.ts +38 -39
- package/src/route-definition/index.ts +1 -2
- package/src/route-definition/resolve-handler-use.ts +0 -1
- package/src/route-definition/use-item-types.ts +3 -6
- package/src/route-types.ts +0 -5
- package/src/router/match-api.ts +5 -1
- package/src/router/match-middleware/background-revalidation.ts +40 -23
- package/src/router/match-middleware/cache-store.ts +39 -24
- package/src/router/segment-resolution/fresh.ts +4 -0
- package/src/router/segment-resolution/loader-cache.ts +14 -2
- package/src/router/segment-resolution/revalidation.ts +3 -0
- package/src/router/segment-resolution/view-transition-default.ts +35 -15
- package/src/rsc/progressive-enhancement.ts +56 -2
- package/src/rsc/rsc-rendering.ts +7 -2
- package/src/rsc/server-action.ts +25 -2
- package/src/rsc/transition-gate.ts +89 -0
- package/src/segment-system.tsx +59 -8
- package/src/server/context.ts +13 -0
- package/src/server/loader-registry.ts +13 -1
- package/src/server/request-context.ts +52 -3
- package/src/testing/index.ts +6 -0
- package/src/testing/render-handler.ts +14 -0
- package/src/testing/run-transition-when.ts +164 -0
- package/src/types/handler-context.ts +1 -1
- package/src/types/index.ts +2 -0
- package/src/types/segments.ts +100 -0
- package/src/urls/path-helper-types.ts +10 -7
- package/src/urls/urls-function.ts +0 -1
- package/src/vite/inject-client-debug.ts +36 -0
- package/src/vite/plugins/version-injector.ts +22 -7
- package/src/vite/plugins/virtual-entries.ts +28 -9
- package/src/vite/router-discovery.ts +8 -13
- package/src/network-error-thrower.tsx +0 -18
package/skills/testing/SKILL.md
CHANGED
|
@@ -88,6 +88,7 @@ Each primitive links to its sub-file (API + recipe + caveats).
|
|
|
88
88
|
| a loader's cookie / header / redirect output (auth-loader pattern) | unit (node) | [`runLoaderResult`](./loader.md) | `@rangojs/router/testing` |
|
|
89
89
|
| one middleware's ordering / short-circuit / cookie+header merge | unit (node) | [`runMiddleware`](./middleware.md) | `@rangojs/router/testing` |
|
|
90
90
|
| a `"use server"` action's cookie / header / flash output (even on `throw redirect()`) | unit (node) | [`runInRequestContext`](./server-actions.md) | `@rangojs/router/testing` |
|
|
91
|
+
| a `transition({ when })` gate (keep/drop) against nav source / target / action metadata | unit (node) | `runTransitionWhen` (`{ kept, whenContext }`) | `@rangojs/router/testing` |
|
|
91
92
|
| a handle's `collect`/accumulator, or a seeded handle read | unit | [`collectHandle` / seeded `handles`](./handles.md) | `@rangojs/router/testing` |
|
|
92
93
|
| a CLIENT component reading router context (`useParams`/`useReverse`/`Outlet`/`useNavigation`/`useLoader`) | unit (DOM) | [`renderRoute`](./client-components.md) | `@rangojs/router/testing/dom` |
|
|
93
94
|
| a redirect / status / headers / cookies / **response route** (json/text/html/xml/md), no Flight | integration | [`dispatch`](./response-routes.md) | `@rangojs/router/testing` |
|
|
@@ -8,20 +8,21 @@ A Rango route handler is a pure function `(ctx) => rsc` — the function you pas
|
|
|
8
8
|
|
|
9
9
|
### Options — `RenderHandlerOptions`
|
|
10
10
|
|
|
11
|
-
| Field
|
|
12
|
-
|
|
|
13
|
-
| `params`
|
|
14
|
-
| `env`
|
|
15
|
-
| `request`
|
|
16
|
-
| `headers`
|
|
17
|
-
| `vars`
|
|
18
|
-
| `routeName`
|
|
19
|
-
| `routeMap`
|
|
20
|
-
| `loaders`
|
|
21
|
-
| `clientComponents`
|
|
22
|
-
| `stateCookie`
|
|
23
|
-
| `cacheStore`
|
|
24
|
-
| `cacheProfiles`
|
|
11
|
+
| Field | Type | Meaning |
|
|
12
|
+
| ---------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
13
|
+
| `params` | `Record<string, string>` | Route params surfaced as `ctx.params`. |
|
|
14
|
+
| `env` | `TEnv` | Environment bindings surfaced as `ctx.env`. |
|
|
15
|
+
| `request` | `Request \| string` | Backing Request (string or `Request`); defaults to a localhost GET. |
|
|
16
|
+
| `headers` | `HeadersInit` | Request headers (e.g. `Cookie`) the handler reads via `cookies()`. |
|
|
17
|
+
| `vars` | `VarsInit` (object or `[[Var, value]]` tuples) | Variables a prior middleware set, read via `ctx.get(...)`. |
|
|
18
|
+
| `routeName` | `string` | Matched route name (drives `ctx.routeName` and scoped reverse). |
|
|
19
|
+
| `routeMap` | `Record<string, string>` | Route name -> pattern map enabling `ctx.reverse()`. |
|
|
20
|
+
| `loaders` | `ReadonlyArray<readonly [LoaderDefinition, unknown]>` | Seed the data `ctx.use(SomeLoader)` returns. Matched by loader reference; NO real loader runs. |
|
|
21
|
+
| `clientComponents` | `Record<string, unknown>` | `"use client"` components in the handler's RSC, so they serialize as real boundaries when `rangoUseClientTransform()` is not wired. Keyed by name. |
|
|
22
|
+
| `stateCookie` | `StateCookieSeed` (`{ prefix?, routerId?, version? }`) | Customize the rango state cookie a handler calling `invalidateClientCache()` rotates. The name is ALWAYS seeded (default `rango-state_router_0`) so the rotation `Set-Cookie` fires like production rather than no-opping; override `prefix`/`routerId` to match your `createRouter({ stateCookiePrefix, id })`, or `version` (the value is `{version}:{timestamp}`, default `"0"`). |
|
|
23
|
+
| `cacheStore` | `SegmentCacheStore` | Segment cache store backing a `"use cache"` function the handler invokes (e.g. `new MemorySegmentCacheStore()`). WITHOUT it, `registerCachedFunction` takes the uncached bypass and the cached path is NOT exercised (the runtime emits a one-time warning under the test runner). Pair with `cacheProfiles`. |
|
|
24
|
+
| `cacheProfiles` | `Record<string, CacheProfile>` | Cache profiles in the `createRouter({ cacheProfiles })` shape, required for `"use cache: profileName"` resolution once a `cacheStore` is wired. |
|
|
25
|
+
| `inActionRevalidation` | `boolean` | Render as if inside a server action's revalidation render (production sets this in `revalidateAfterAction`). A stale `"use cache"` entry whose profile sets `foregroundOnAction: true` then re-executes in the FOREGROUND (fresh result in this render) instead of SWR. Pair with `cacheStore` + `cacheProfiles` to exercise the opt-in. |
|
|
25
26
|
|
|
26
27
|
### Context — `HandlerContext` (what your handler receives)
|
|
27
28
|
|
|
@@ -63,12 +63,23 @@ createRouter({
|
|
|
63
63
|
short: { ttl: 60, swr: 120 },
|
|
64
64
|
long: { ttl: 3600, swr: 7200 },
|
|
65
65
|
products: { ttl: 300, swr: 600, tags: ["products"] },
|
|
66
|
+
// Opt-in: a stale entry re-executes in the foreground during a server
|
|
67
|
+
// action's revalidation render (fresh action response), instead of SWR.
|
|
68
|
+
cms: { ttl: 300, swr: 600, foregroundOnAction: true },
|
|
66
69
|
},
|
|
67
70
|
});
|
|
68
71
|
```
|
|
69
72
|
|
|
70
73
|
- `"use cache"` (no name) resolves to `default`.
|
|
71
74
|
- `"use cache: short"` resolves to the `short` profile.
|
|
75
|
+
- `foregroundOnAction: true` (default false): a stale entry serves stale +
|
|
76
|
+
revalidates in the background on a plain navigation (SWR), but re-executes in
|
|
77
|
+
the FOREGROUND during a server action's revalidation render so the action
|
|
78
|
+
response reflects a fresh value (only the store write is deferred). Use it for
|
|
79
|
+
mutation-related cached data; incidental TTL staleness on an ordinary action
|
|
80
|
+
stays SWR so the action is not turned into a synchronous cache-refresh barrier.
|
|
81
|
+
For strong read-your-own-writes after a mutation, prefer `updateTag()` (a hard
|
|
82
|
+
purge, so the action's own re-render is a fresh foreground miss).
|
|
72
83
|
- Unknown profile names throw at runtime, on the first invocation of the cached
|
|
73
84
|
function (the Vite transform does not validate names at build/boot). The error
|
|
74
85
|
is actionable -- it names the missing profile and shows the `createRouter({
|
|
@@ -209,6 +209,10 @@ interface TransitionConfig {
|
|
|
209
209
|
default?: string | Record<string, string>; // fallback for any phase
|
|
210
210
|
name?: string; // explicit view-transition-name
|
|
211
211
|
viewTransition?: "auto" | false; // boundary opt-out (see below)
|
|
212
|
+
// Conditional gate, evaluated server-side AFTER the route handler. Return
|
|
213
|
+
// false to drop this transition for the request, so the navigation streams its
|
|
214
|
+
// loading() fallback instead of holding. See the gate section below.
|
|
215
|
+
when?: (ctx: TransitionWhenContext) => boolean;
|
|
212
216
|
}
|
|
213
217
|
```
|
|
214
218
|
|
|
@@ -217,6 +221,45 @@ interface TransitionConfig {
|
|
|
217
221
|
- `name` lets you participate in cross-page morphs by name (advanced; you usually don't need this on a layout/route-level wrap).
|
|
218
222
|
- `viewTransition` toggles whether rango places its own `<ViewTransition>` boundary. `"auto"` (default) wraps as described above; `false` opts out — see the next section.
|
|
219
223
|
|
|
224
|
+
## Conditional transitions (`when`)
|
|
225
|
+
|
|
226
|
+
`transition({ when })` gates the hold per request. The predicate runs **server-side, AFTER the route handler** and outside any cache scope; return `false` to drop this segment's transition for the request (the navigation streams its `loading()` fallback instead of holding).
|
|
227
|
+
|
|
228
|
+
Its context mirrors the `revalidate()` predicate args — the same navigation/action metadata — plus `get`/`env` for post-handler reads:
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
import type { TransitionWhenContext } from "@rangojs/router";
|
|
232
|
+
|
|
233
|
+
// Hold only when the handler marked this request (handler sets, gate reads):
|
|
234
|
+
transition({ when: (ctx) => ctx.get(KeepScroll) === true });
|
|
235
|
+
|
|
236
|
+
// Hold only when arriving from a specific page (the navigation SOURCE):
|
|
237
|
+
transition({
|
|
238
|
+
when: ({ currentUrl }) => currentUrl?.pathname.startsWith("/list") === true,
|
|
239
|
+
});
|
|
240
|
+
transition({ when: ({ fromRouteName }) => fromRouteName === "products.list" });
|
|
241
|
+
|
|
242
|
+
// Hold only after a specific action revalidated the route:
|
|
243
|
+
transition({
|
|
244
|
+
when: ({ actionId }) => actionId === "src/actions/cart.ts#addToCart",
|
|
245
|
+
});
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
| field | meaning | populated |
|
|
249
|
+
| ------------------------------------------------------ | -------------------------------------------- | ------------------------------------------------------------------------------------- |
|
|
250
|
+
| `currentUrl` / `currentParams` / `fromRouteName` | navigation **source** | soft nav + action-success; `undefined` on initial full load and action/PE error paths |
|
|
251
|
+
| `nextUrl` / `nextParams` | navigation **target** | always |
|
|
252
|
+
| `toRouteName` (and `fromRouteName`) | route **name** | when the route is named (undefined for unnamed/auto-generated) |
|
|
253
|
+
| `actionId` / `actionUrl` / `actionResult` / `formData` | the server action that triggered this render | action-triggered renders only |
|
|
254
|
+
| `method` | `"GET"` (nav) / `"POST"` (action) | always |
|
|
255
|
+
| `get` / `env` | read handler/middleware vars + app env | always |
|
|
256
|
+
|
|
257
|
+
A predicate that throws is reported to `router.onError` (phase `"rendering"`) and treated as no-hold (conservative).
|
|
258
|
+
|
|
259
|
+
**Same-route content-holds need the transition present on the FIRST render.** The same-route hold works by giving the route a param-agnostic key so a param change reconciles instead of remounting — but that key is established when the route first mounts. A source gate that returns `false` on the initial full load (where `currentUrl`/`currentParams`/`fromRouteName` are undefined) drops the transition before the route mounts, so the route mounts _outside_ a transition scope and **every** later same-route param nav remounts (flashing the skeleton) regardless of what the gate decides on those navs. Write source gates so they hold when there is no source — e.g. `({ currentParams }) => currentParams?.tab !== "raw"` (true on the initial load) rather than `=== "details"` (false on the initial load) — when the same-route content-hold must engage. This only affects same-route param navigations; action-only or cross-route gating is unaffected (no shared param key is in play).
|
|
260
|
+
|
|
261
|
+
**Prefetch / cache caveat.** The gate runs during resolution, so a **prefetched** navigation decides at prefetch time — `currentUrl`/`currentParams`/`fromRouteName` reflect the page the prefetch fired from, not necessarily the click-time source — and a `cache()`/prerender hit replays the stored transition without re-running the predicate. A source-sensitive gate can therefore be frozen to prefetch/store-time state. This covers ~99% of navigations; if yours must reflect the exact click-time source, source-scope the prefetch (`<Link prefetchKey=":source">`) and don't `cache()` that segment.
|
|
262
|
+
|
|
220
263
|
## Opting out of the router boundary (place your own `<ViewTransition>`)
|
|
221
264
|
|
|
222
265
|
By default a `transition()` segment gets a rango-placed `<ViewTransition>` boundary — a cross-fade of the whole outlet/route. If you'd rather animate specific elements yourself (place `<ViewTransition name="...">` in your components), set `viewTransition: false`. The router then contributes **no boundary of its own** but still:
|
|
@@ -32,6 +32,7 @@ import { isInterceptOnlyCache } from "./intercept-utils.js";
|
|
|
32
32
|
import {
|
|
33
33
|
toNetworkError,
|
|
34
34
|
emitNetworkError,
|
|
35
|
+
emitNavigationError,
|
|
35
36
|
isBackgroundSuppressible,
|
|
36
37
|
} from "./network-error-handler.js";
|
|
37
38
|
import { debugLog } from "./logging.js";
|
|
@@ -215,17 +216,20 @@ export function createNavigationBridge(
|
|
|
215
216
|
store.setInterceptSourceUrl(null);
|
|
216
217
|
}
|
|
217
218
|
|
|
218
|
-
// Before navigating away, update the source page's cache with the latest
|
|
219
|
-
// This ensures the cache has correct handleData even if handles
|
|
219
|
+
// Before navigating away, update the source page's cache with the latest
|
|
220
|
+
// handleData. This ensures the cache has correct handleData even if handles
|
|
221
|
+
// were streaming. Use updateCacheHandleData (not cacheSegmentsForHistory):
|
|
222
|
+
// the source page's segments are unchanged, so this is a handleData refresh,
|
|
223
|
+
// not a commit. Critically it PRESERVES the entry's stale flag — when the
|
|
224
|
+
// source page has a deferred Meta still pending, its entry was marked stale
|
|
225
|
+
// (invalidate-on-pending) so a popstate return revalidates; re-committing it
|
|
226
|
+
// here would reset stale to false and serve the carried (pre-resolution)
|
|
227
|
+
// title as fresh. It also leaves the nav-instance token intact.
|
|
220
228
|
const sourceHistoryKey = store.getHistoryKey();
|
|
221
229
|
const sourceCached = store.getCachedSegments(sourceHistoryKey);
|
|
222
230
|
if (sourceCached?.segments && sourceCached.segments.length > 0) {
|
|
223
231
|
const currentHandleData = eventController.getHandleState().data;
|
|
224
|
-
store.
|
|
225
|
-
sourceHistoryKey,
|
|
226
|
-
sourceCached.segments,
|
|
227
|
-
currentHandleData,
|
|
228
|
-
);
|
|
232
|
+
store.updateCacheHandleData(sourceHistoryKey, currentHandleData);
|
|
229
233
|
}
|
|
230
234
|
|
|
231
235
|
// Check if we have cached segments for target URL
|
|
@@ -325,8 +329,15 @@ export function createNavigationBridge(
|
|
|
325
329
|
} as NavigateOptionsInternal);
|
|
326
330
|
}
|
|
327
331
|
|
|
328
|
-
|
|
329
|
-
|
|
332
|
+
// Aborted, or superseded by a newer navigation. A superseded nav may
|
|
333
|
+
// reject with a non-AbortError (e.g. a Flight decode that fails after its
|
|
334
|
+
// signal was aborted), so check the signal too -- otherwise we would
|
|
335
|
+
// render a boundary that clobbers the newer navigation's content.
|
|
336
|
+
if (
|
|
337
|
+
(error instanceof DOMException && error.name === "AbortError") ||
|
|
338
|
+
tx.handle.signal.aborted
|
|
339
|
+
) {
|
|
340
|
+
debugLog("[Browser] Navigation aborted or superseded");
|
|
330
341
|
return;
|
|
331
342
|
}
|
|
332
343
|
|
|
@@ -343,7 +354,13 @@ export function createNavigationBridge(
|
|
|
343
354
|
return;
|
|
344
355
|
}
|
|
345
356
|
|
|
346
|
-
|
|
357
|
+
// A response we could not process (undecodable Flight body, or an
|
|
358
|
+
// unanticipated failure building the response). Surface the route's
|
|
359
|
+
// error boundary rather than let the rejection abort the navigation
|
|
360
|
+
// silently. Prefetched responses funnel here too: a failed warm-prefetch
|
|
361
|
+
// payload rejects on consumption and propagates to this catch.
|
|
362
|
+
console.error("[Browser] Unprocessable navigation response:", error);
|
|
363
|
+
emitNavigationError(onUpdate, error, url);
|
|
347
364
|
} finally {
|
|
348
365
|
tx[Symbol.dispose]();
|
|
349
366
|
}
|
|
@@ -372,6 +389,14 @@ export function createNavigationBridge(
|
|
|
372
389
|
tx.with({ url: window.location.href, replace: true, scroll: false }),
|
|
373
390
|
);
|
|
374
391
|
} catch (error) {
|
|
392
|
+
// Aborted or superseded: bail without rendering a boundary (see navigate()).
|
|
393
|
+
if (
|
|
394
|
+
(error instanceof DOMException && error.name === "AbortError") ||
|
|
395
|
+
tx.handle.signal.aborted
|
|
396
|
+
) {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
375
400
|
const networkError = toNetworkError(error, {
|
|
376
401
|
url: window.location.href,
|
|
377
402
|
operation: "revalidation",
|
|
@@ -384,7 +409,12 @@ export function createNavigationBridge(
|
|
|
384
409
|
emitNetworkError(onUpdate, networkError, window.location.href);
|
|
385
410
|
return;
|
|
386
411
|
}
|
|
387
|
-
|
|
412
|
+
|
|
413
|
+
// refresh() shares the fetchPartialUpdate chokepoint with navigate()/
|
|
414
|
+
// popstate, so an unprocessable response must surface the error boundary
|
|
415
|
+
// here too rather than become an uncaught rejection.
|
|
416
|
+
console.error("[Browser] Unprocessable refresh response:", error);
|
|
417
|
+
emitNavigationError(onUpdate, error, window.location.href);
|
|
388
418
|
} finally {
|
|
389
419
|
tx[Symbol.dispose]();
|
|
390
420
|
}
|
|
@@ -527,8 +557,19 @@ export function createNavigationBridge(
|
|
|
527
557
|
// SWR: If stale, trigger background revalidation
|
|
528
558
|
if (isStale) {
|
|
529
559
|
debugLog("[Browser] Cache is stale, background revalidating...");
|
|
530
|
-
// Background revalidation - don't await, just fire and forget
|
|
531
|
-
|
|
560
|
+
// Background revalidation - don't await, just fire and forget.
|
|
561
|
+
// When the entry's handles are incomplete (a deferred Meta was still
|
|
562
|
+
// pending when the user navigated away — see handlesPending), send NO
|
|
563
|
+
// segment IDs so the server returns a FULL re-render with the handle
|
|
564
|
+
// stream. A normal stale revalidation sends the cached IDs and the
|
|
565
|
+
// server returns a diff-only payload that omits unchanged segments'
|
|
566
|
+
// handles, so a deferred Meta would never re-stream and the title
|
|
567
|
+
// would stay the pre-resolution carry. handlesPending is set only for
|
|
568
|
+
// the deferred-Meta-aborted case, so action/cross-tab SWR keeps the
|
|
569
|
+
// cheap diff path.
|
|
570
|
+
const segmentIds = cached?.handlesPending
|
|
571
|
+
? []
|
|
572
|
+
: cachedSegments.map((s) => s.id);
|
|
532
573
|
|
|
533
574
|
const tx = createNavigationTransaction(
|
|
534
575
|
store,
|
|
@@ -602,8 +643,13 @@ export function createNavigationBridge(
|
|
|
602
643
|
// Restore scroll position after fetch completes
|
|
603
644
|
handleNavigationEnd({ restore: true, isStreaming });
|
|
604
645
|
} catch (error) {
|
|
605
|
-
|
|
606
|
-
|
|
646
|
+
// Aborted or superseded by a newer navigation: bail without clobbering
|
|
647
|
+
// content with a boundary (see navigate()).
|
|
648
|
+
if (
|
|
649
|
+
(error instanceof DOMException && error.name === "AbortError") ||
|
|
650
|
+
tx.handle.signal.aborted
|
|
651
|
+
) {
|
|
652
|
+
debugLog("[Browser] Popstate navigation aborted or superseded");
|
|
607
653
|
return;
|
|
608
654
|
}
|
|
609
655
|
|
|
@@ -620,7 +666,10 @@ export function createNavigationBridge(
|
|
|
620
666
|
return;
|
|
621
667
|
}
|
|
622
668
|
|
|
623
|
-
|
|
669
|
+
// Unprocessable response on a back/forward navigation: surface the
|
|
670
|
+
// error boundary instead of an uncaught rejection (see navigate()).
|
|
671
|
+
console.error("[Browser] Unprocessable popstate response:", error);
|
|
672
|
+
emitNavigationError(onUpdate, error, url);
|
|
624
673
|
} finally {
|
|
625
674
|
tx[Symbol.dispose]();
|
|
626
675
|
}
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
import { NetworkError, ServerRedirect, isNetworkError } from "../errors.js";
|
|
9
9
|
import {
|
|
10
10
|
browserDebugLog,
|
|
11
|
+
debugLog,
|
|
11
12
|
isBrowserDebugEnabled,
|
|
12
13
|
startBrowserTransaction,
|
|
13
14
|
} from "./logging.js";
|
|
@@ -260,6 +261,10 @@ export function createNavigationClient(
|
|
|
260
261
|
|
|
261
262
|
let payloadPromise: Promise<RscPayload>;
|
|
262
263
|
let streamCompletePromise: Promise<void>;
|
|
264
|
+
// True only for a prefetch-cache hit whose stream had already fully drained
|
|
265
|
+
// (complete === true). A still-streaming hit and the fresh path stay false,
|
|
266
|
+
// so only a fully-warmed prefetch commits in a transition (no fallback flash).
|
|
267
|
+
let fullyPrefetched = false;
|
|
263
268
|
|
|
264
269
|
if (cachedEntry) {
|
|
265
270
|
if (tx) {
|
|
@@ -270,6 +275,9 @@ export function createNavigationClient(
|
|
|
270
275
|
}
|
|
271
276
|
payloadPromise = cachedEntry.payload;
|
|
272
277
|
streamCompletePromise = cachedEntry.streamComplete;
|
|
278
|
+
// Only a hit whose stream already fully drained is "fully prefetched";
|
|
279
|
+
// a still-streaming hit must keep streaming its fallbacks like a cold load.
|
|
280
|
+
fullyPrefetched = cachedEntry.complete;
|
|
273
281
|
} else if (inflightEntryPromise) {
|
|
274
282
|
if (tx) {
|
|
275
283
|
browserDebugLog(tx, "reusing inflight prefetch", {
|
|
@@ -299,6 +307,9 @@ export function createNavigationClient(
|
|
|
299
307
|
} else {
|
|
300
308
|
payloadPromise = entry.payload;
|
|
301
309
|
streamCompletePromise = entry.streamComplete;
|
|
310
|
+
// Adopted inflight is normally still streaming (false), but read the
|
|
311
|
+
// flag in case it completed between publish and adoption.
|
|
312
|
+
fullyPrefetched = entry.complete;
|
|
302
313
|
}
|
|
303
314
|
} else {
|
|
304
315
|
({ payload: payloadPromise, streamComplete: streamCompletePromise } =
|
|
@@ -306,7 +317,18 @@ export function createNavigationClient(
|
|
|
306
317
|
}
|
|
307
318
|
|
|
308
319
|
try {
|
|
320
|
+
// [VT-DIAG] Gated behind INTERNAL_RANGO_DEBUG. Times how long the RSC
|
|
321
|
+
// payload ROOT takes to resolve: ~full-stream duration means the root
|
|
322
|
+
// model is not flushed early (server/runtime buffering, e.g. wrangler
|
|
323
|
+
// dev gzip); fast means the block, if any, is downstream in render.
|
|
324
|
+
const vtDebugStart = isBrowserDebugEnabled() ? performance.now() : 0;
|
|
309
325
|
const payload = await payloadPromise;
|
|
326
|
+
if (isBrowserDebugEnabled()) {
|
|
327
|
+
debugLog("[VT-DIAG] payloadResolved", {
|
|
328
|
+
ms: Math.round(performance.now() - vtDebugStart),
|
|
329
|
+
isPartial: payload.metadata?.isPartial,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
310
332
|
|
|
311
333
|
if (tx) {
|
|
312
334
|
browserDebugLog(tx, "response received", {
|
|
@@ -315,7 +337,11 @@ export function createNavigationClient(
|
|
|
315
337
|
diffCount: payload.metadata?.diff?.length ?? 0,
|
|
316
338
|
});
|
|
317
339
|
}
|
|
318
|
-
return {
|
|
340
|
+
return {
|
|
341
|
+
payload,
|
|
342
|
+
streamComplete: streamCompletePromise,
|
|
343
|
+
fullyPrefetched,
|
|
344
|
+
};
|
|
319
345
|
} catch (error) {
|
|
320
346
|
// Convert network-level errors to NetworkError for proper handling
|
|
321
347
|
if (isNetworkError(error)) {
|
|
@@ -28,14 +28,28 @@ 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:
|
|
32
|
-
// stale
|
|
31
|
+
// Cache entry:
|
|
32
|
+
// [url-key, segments, stale, handleData?, routerId?, navInstance?, handlesPending?]
|
|
33
|
+
// stale=true means the data may be outdated and should be revalidated on access.
|
|
34
|
+
// navInstance is the monotonic nav-instance token (see navInstance below): it
|
|
35
|
+
// identifies the per-commit visit that owns this entry. generateHistoryKey is
|
|
36
|
+
// URL-only, so A->B->A reuses the same key; the token lets a late async
|
|
37
|
+
// resolution tell its own visit's entry apart from a newer same-URL visit's, so
|
|
38
|
+
// a stale nav can never clobber a fresher one.
|
|
39
|
+
// handlesPending=true means the entry's handle data is INCOMPLETE (a deferred
|
|
40
|
+
// Meta was still pending when the user navigated away, so it never streamed). A
|
|
41
|
+
// popstate return must REVALIDATE WITH A FULL RE-RENDER (no client segment IDs)
|
|
42
|
+
// to re-stream the handles — a diff-only revalidation omits unchanged segments'
|
|
43
|
+
// handles, so the deferred Meta would never land. Cleared once the deferred Meta
|
|
44
|
+
// resolves while the entry is still owned.
|
|
33
45
|
type HistoryCacheEntry = [
|
|
34
46
|
string,
|
|
35
47
|
ResolvedSegment[],
|
|
36
48
|
boolean,
|
|
37
49
|
HandleData?,
|
|
38
50
|
string?,
|
|
51
|
+
number?,
|
|
52
|
+
boolean?,
|
|
39
53
|
];
|
|
40
54
|
|
|
41
55
|
/**
|
|
@@ -44,7 +58,7 @@ type HistoryCacheEntry = [
|
|
|
44
58
|
* since mutations happen at the array level, not on individual data objects.
|
|
45
59
|
* This preserves any non-serializable types (React elements, functions, etc.)
|
|
46
60
|
*/
|
|
47
|
-
function cloneHandleData(handleData: HandleData): HandleData {
|
|
61
|
+
export function cloneHandleData(handleData: HandleData): HandleData {
|
|
48
62
|
const cloned: HandleData = {};
|
|
49
63
|
for (const [handleKey, segmentMap] of Object.entries(handleData)) {
|
|
50
64
|
cloned[handleKey] = {};
|
|
@@ -239,6 +253,14 @@ export function createNavigationStore(
|
|
|
239
253
|
// Oldest entries (at front) are removed when over cacheSize limit
|
|
240
254
|
const historyCache: HistoryCacheEntry[] = [];
|
|
241
255
|
|
|
256
|
+
// Monotonic nav-instance token. Bumped each time a cache entry is created or
|
|
257
|
+
// replaced in cacheSegmentsForHistory (i.e. once per commit). Because
|
|
258
|
+
// generateHistoryKey is URL-only, two visits to the same URL share a key; this
|
|
259
|
+
// token gives each visit a distinct identity so a late async handle resolution
|
|
260
|
+
// can tell whether it still owns the live page / the target cache entry, and
|
|
261
|
+
// never overwrite a newer same-URL visit's state.
|
|
262
|
+
let navInstance = 0;
|
|
263
|
+
|
|
242
264
|
// Current history key (set on navigation, stored in history.state)
|
|
243
265
|
let currentHistoryKey = config?.initialHistoryKey || generateHistoryKey();
|
|
244
266
|
|
|
@@ -248,6 +270,10 @@ export function createNavigationStore(
|
|
|
248
270
|
config.initialHistoryKey,
|
|
249
271
|
config.initialSegments,
|
|
250
272
|
false,
|
|
273
|
+
undefined,
|
|
274
|
+
undefined,
|
|
275
|
+
++navInstance,
|
|
276
|
+
false,
|
|
251
277
|
]);
|
|
252
278
|
}
|
|
253
279
|
|
|
@@ -565,6 +591,29 @@ export function createNavigationStore(
|
|
|
565
591
|
currentHistoryKey = key;
|
|
566
592
|
},
|
|
567
593
|
|
|
594
|
+
/**
|
|
595
|
+
* Current nav-instance token: the instance of the most recently committed
|
|
596
|
+
* navigation (the value last written by cacheSegmentsForHistory). A late
|
|
597
|
+
* async handle resolution captures this at the start of its own nav and
|
|
598
|
+
* compares it back here to detect whether a NEWER navigation has since
|
|
599
|
+
* committed (token advanced), guarding against a stale nav writing a fresher
|
|
600
|
+
* nav's live state.
|
|
601
|
+
*/
|
|
602
|
+
getNavInstance(): number {
|
|
603
|
+
return navInstance;
|
|
604
|
+
},
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* The nav-instance token recorded on a specific cache entry, or undefined if
|
|
608
|
+
* no entry exists for that key. Because the history key is URL-only, this is
|
|
609
|
+
* how a late resolution tells "the entry I seeded is still mine" from "a
|
|
610
|
+
* newer same-URL visit replaced my entry".
|
|
611
|
+
*/
|
|
612
|
+
getCacheEntryInstance(historyKey: string): number | undefined {
|
|
613
|
+
const entry = historyCache.find(([key]) => key === historyKey);
|
|
614
|
+
return entry ? entry[5] : undefined;
|
|
615
|
+
},
|
|
616
|
+
|
|
568
617
|
/**
|
|
569
618
|
* Store segments for a history entry
|
|
570
619
|
* Updates existing entry if key exists, otherwise adds new entry
|
|
@@ -583,6 +632,11 @@ export function createNavigationStore(
|
|
|
583
632
|
? cloneHandleData(handleData)
|
|
584
633
|
: undefined;
|
|
585
634
|
|
|
635
|
+
// Each commit (create or replace) is a new nav instance. The bump happens
|
|
636
|
+
// here, exactly once per cacheSegmentsForHistory call, so getNavInstance()
|
|
637
|
+
// reflects the visit whose entry this is.
|
|
638
|
+
const instance = ++navInstance;
|
|
639
|
+
|
|
586
640
|
// Check if entry already exists and update it
|
|
587
641
|
const existingIndex = historyCache.findIndex(
|
|
588
642
|
([key]) => key === historyKey,
|
|
@@ -594,6 +648,8 @@ export function createNavigationStore(
|
|
|
594
648
|
false,
|
|
595
649
|
clonedHandleData,
|
|
596
650
|
currentRouterId,
|
|
651
|
+
instance,
|
|
652
|
+
false, // fresh commit: handles complete unless a deferred apply marks it
|
|
597
653
|
];
|
|
598
654
|
} else {
|
|
599
655
|
// Add new entry at the end (not stale)
|
|
@@ -603,6 +659,8 @@ export function createNavigationStore(
|
|
|
603
659
|
false,
|
|
604
660
|
clonedHandleData,
|
|
605
661
|
currentRouterId,
|
|
662
|
+
instance,
|
|
663
|
+
false,
|
|
606
664
|
]);
|
|
607
665
|
// Remove oldest entries if over limit
|
|
608
666
|
while (historyCache.length > cacheSize) {
|
|
@@ -621,6 +679,7 @@ export function createNavigationStore(
|
|
|
621
679
|
stale: boolean;
|
|
622
680
|
handleData?: HandleData;
|
|
623
681
|
routerId?: string;
|
|
682
|
+
handlesPending?: boolean;
|
|
624
683
|
}
|
|
625
684
|
| undefined {
|
|
626
685
|
const entry = historyCache.find(([key]) => key === historyKey);
|
|
@@ -630,6 +689,7 @@ export function createNavigationStore(
|
|
|
630
689
|
stale: entry[2],
|
|
631
690
|
handleData: entry[3],
|
|
632
691
|
routerId: entry[4],
|
|
692
|
+
handlesPending: entry[6],
|
|
633
693
|
};
|
|
634
694
|
},
|
|
635
695
|
|
|
@@ -641,11 +701,23 @@ export function createNavigationStore(
|
|
|
641
701
|
},
|
|
642
702
|
|
|
643
703
|
/**
|
|
644
|
-
* Update only the handleData for an existing
|
|
645
|
-
* Does nothing if the cache entry doesn't exist
|
|
646
|
-
*
|
|
704
|
+
* Update only the handleData (and optionally the stale flag) for an existing
|
|
705
|
+
* cache entry. Does nothing if the cache entry doesn't exist.
|
|
706
|
+
*
|
|
707
|
+
* Used to fix stale handleData after async handles processing AND to flip an
|
|
708
|
+
* entry's stale / handlesPending bits for the deferred-Meta
|
|
709
|
+
* invalidate+revalidate path: while a nav's Meta is deferred-pending its
|
|
710
|
+
* entry is marked stale + handlesPending (a popstate return then revalidates
|
|
711
|
+
* with a full re-render instead of serving the carry/seed as fresh), and once
|
|
712
|
+
* the deferred Meta resolves both are cleared. When a flag is omitted the
|
|
713
|
+
* entry's current value is preserved.
|
|
647
714
|
*/
|
|
648
|
-
updateCacheHandleData(
|
|
715
|
+
updateCacheHandleData(
|
|
716
|
+
historyKey: string,
|
|
717
|
+
handleData: HandleData,
|
|
718
|
+
stale?: boolean,
|
|
719
|
+
handlesPending?: boolean,
|
|
720
|
+
): void {
|
|
649
721
|
const existingIndex = historyCache.findIndex(
|
|
650
722
|
([key]) => key === historyKey,
|
|
651
723
|
);
|
|
@@ -656,9 +728,11 @@ export function createNavigationStore(
|
|
|
656
728
|
historyCache[existingIndex] = [
|
|
657
729
|
entry[0],
|
|
658
730
|
entry[1],
|
|
659
|
-
entry[2],
|
|
731
|
+
stale ?? entry[2], // set stale when provided, else preserve current
|
|
660
732
|
clonedHandleData,
|
|
661
733
|
entry[4], // preserve routerId
|
|
734
|
+
entry[5], // preserve navInstance (entry ownership identity)
|
|
735
|
+
handlesPending ?? entry[6], // set when provided, else preserve current
|
|
662
736
|
];
|
|
663
737
|
}
|
|
664
738
|
},
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { NetworkError, isNetworkError } from "../errors.js";
|
|
2
|
-
import {
|
|
2
|
+
import { RenderErrorThrower } from "../render-error-thrower.js";
|
|
3
3
|
import type { UpdateSubscriber } from "./types.js";
|
|
4
4
|
import { createElement, startTransition } from "react";
|
|
5
5
|
|
|
@@ -24,18 +24,18 @@ export function toNetworkError(
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
27
|
+
* Render an error into the segment tree via the onUpdate subscriber so the
|
|
28
|
+
* nearest error boundary catches it. Wrapped in startTransition; RenderErrorThrower
|
|
29
|
+
* throws during render (async rejections do not reach boundaries on their own).
|
|
30
30
|
*/
|
|
31
|
-
|
|
31
|
+
function emitErrorToBoundary(
|
|
32
32
|
onUpdate: UpdateSubscriber,
|
|
33
|
-
error:
|
|
33
|
+
error: unknown,
|
|
34
34
|
pathname: string,
|
|
35
35
|
): void {
|
|
36
36
|
startTransition(() => {
|
|
37
37
|
onUpdate({
|
|
38
|
-
root: createElement(
|
|
38
|
+
root: createElement(RenderErrorThrower, { error }),
|
|
39
39
|
metadata: {
|
|
40
40
|
pathname,
|
|
41
41
|
segments: [],
|
|
@@ -45,6 +45,33 @@ export function emitNetworkError(
|
|
|
45
45
|
});
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Emit a NetworkError to the nearest error boundary (offline, failed fetch).
|
|
50
|
+
*/
|
|
51
|
+
export function emitNetworkError(
|
|
52
|
+
onUpdate: UpdateSubscriber,
|
|
53
|
+
error: NetworkError,
|
|
54
|
+
pathname: string,
|
|
55
|
+
): void {
|
|
56
|
+
emitErrorToBoundary(onUpdate, error, pathname);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Emit a navigation processing error to the nearest error boundary. Used when a
|
|
61
|
+
* navigation response cannot be processed (an undecodable Flight body, or any
|
|
62
|
+
* unanticipated failure while building the response) -- for both fresh and
|
|
63
|
+
* prefetched responses, since both funnel through the navigation catch. Without
|
|
64
|
+
* this, such a failure becomes an uncaught rejection that silently aborts the
|
|
65
|
+
* navigation instead of surfacing the route's error boundary.
|
|
66
|
+
*/
|
|
67
|
+
export function emitNavigationError(
|
|
68
|
+
onUpdate: UpdateSubscriber,
|
|
69
|
+
error: unknown,
|
|
70
|
+
pathname: string,
|
|
71
|
+
): void {
|
|
72
|
+
emitErrorToBoundary(onUpdate, error, pathname);
|
|
73
|
+
}
|
|
74
|
+
|
|
48
75
|
/**
|
|
49
76
|
* Check if an error is safe to suppress in background operations.
|
|
50
77
|
*
|