@rangojs/router 0.18.0 → 0.19.0
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/types/browser/event-controller.d.ts +3 -3
- package/dist/types/browser/history-state.d.ts +2 -0
- package/dist/types/browser/navigation-transaction.d.ts +20 -2
- package/dist/types/browser/partial-update.d.ts +16 -1
- package/dist/types/browser/prefetch/cache.d.ts +6 -6
- package/dist/types/browser/react/Link.d.ts +19 -9
- package/dist/types/browser/react/context.d.ts +2 -2
- package/dist/types/browser/react/location-state-shared.d.ts +95 -56
- package/dist/types/browser/react/location-state.d.ts +1 -1
- package/dist/types/browser/transition-when.d.ts +80 -0
- package/dist/types/browser/types.d.ts +104 -9
- package/dist/types/build/generate-manifest.d.ts +8 -0
- package/dist/types/cache/background-task.d.ts +24 -2
- package/dist/types/cache/cache-exec-scope.d.ts +31 -0
- package/dist/types/cache/cache-key-utils.d.ts +44 -13
- package/dist/types/cache/cache-policy.d.ts +1 -1
- package/dist/types/cache/cache-runtime.d.ts +7 -0
- package/dist/types/cache/cache-scope.d.ts +117 -35
- package/dist/types/cache/cache-tag.d.ts +24 -4
- package/dist/types/cache/cf/cf-cache-store.d.ts +55 -44
- package/dist/types/cache/cf/cf-shell-frame.d.ts +1 -1
- package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +0 -15
- package/dist/types/cache/document-cache.d.ts +10 -0
- package/dist/types/cache/handle-snapshot.d.ts +40 -8
- package/dist/types/cache/invalidation-order.d.ts +23 -0
- package/dist/types/cache/request-tag-mask.d.ts +54 -0
- package/dist/types/cache/shell-snapshot.d.ts +88 -141
- package/dist/types/cache/tag-invalidation.d.ts +38 -14
- package/dist/types/cache/types.d.ts +68 -61
- package/dist/types/cache/vercel/vercel-cache-store.d.ts +23 -16
- package/dist/types/client-urls/navigation.d.ts +28 -2
- package/dist/types/client-urls/server-projection.d.ts +5 -2
- package/dist/types/client-urls/types.d.ts +5 -5
- package/dist/types/client.d.ts +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.rsc.d.ts +1 -1
- package/dist/types/route-definition/helpers-types.d.ts +11 -11
- package/dist/types/route-name.d.ts +7 -0
- package/dist/types/router/error-handling.d.ts +1 -1
- package/dist/types/router/intercept-resolution.d.ts +2 -0
- package/dist/types/router/match-context.d.ts +5 -0
- package/dist/types/router/match-middleware/background-revalidation.d.ts +0 -4
- package/dist/types/router/router-options.d.ts +4 -1
- package/dist/types/router/segment-resolution/loader-cache.d.ts +18 -1
- package/dist/types/router/segment-resolution/loader-mask.d.ts +3 -4
- package/dist/types/router/segment-resolution/loader-snapshot.d.ts +14 -40
- package/dist/types/router/segment-resolution/mask-nested.d.ts +11 -3
- package/dist/types/router/segment-resolution/view-transition-default.d.ts +7 -5
- package/dist/types/router/transition-when.d.ts +24 -10
- package/dist/types/rsc/attach-transition-when.d.ts +18 -0
- package/dist/types/rsc/full-payload.d.ts +14 -7
- package/dist/types/rsc/response-cache-serve.d.ts +15 -12
- package/dist/types/rsc/shell-build-manifest.d.ts +4 -3
- package/dist/types/rsc/shell-capture-constants.d.ts +77 -20
- package/dist/types/rsc/shell-capture.d.ts +78 -81
- package/dist/types/rsc/shell-serve.d.ts +19 -48
- package/dist/types/rsc/types.d.ts +2 -0
- package/dist/types/segment-system.d.ts +7 -0
- package/dist/types/server/context.d.ts +89 -61
- package/dist/types/server/cookie-store.d.ts +30 -18
- package/dist/types/server/handle-store.d.ts +52 -1
- package/dist/types/server/request-context.d.ts +66 -45
- package/dist/types/server/request-headers.d.ts +44 -0
- package/dist/types/server.d.ts +1 -0
- package/dist/types/testing/e2e/index.d.ts +1 -1
- package/dist/types/testing/index.d.ts +3 -2
- package/dist/types/testing/internal/action-id.d.ts +7 -0
- package/dist/types/testing/location-state-key.d.ts +24 -0
- package/dist/types/testing/render-route.d.ts +17 -1
- package/dist/types/testing/run-transition-when.d.ts +44 -69
- package/dist/types/testing/serve-shell-request.d.ts +7 -6
- package/dist/types/testing/shell-status.d.ts +34 -22
- package/dist/types/transition-when-ref.d.ts +79 -0
- package/dist/types/types/cache-types.d.ts +15 -3
- package/dist/types/types/index.d.ts +1 -1
- package/dist/types/types/segments.d.ts +86 -71
- package/dist/types/urls/path-helper-types.d.ts +3 -4
- package/dist/types/urls/pattern-types.d.ts +3 -4
- package/dist/types/vite/plugins/transition-when-hoist.d.ts +45 -0
- package/dist/vite/index.js +960 -97
- package/package.json +1 -1
- package/skills/cache-guide/SKILL.md +76 -49
- package/skills/caching/SKILL.md +167 -69
- package/skills/client-urls/SKILL.md +1 -1
- package/skills/comparison/references/framework-comparison.md +8 -5
- package/skills/document-cache/SKILL.md +24 -0
- package/skills/hooks/SKILL.md +3 -1
- package/skills/hooks/navigation.md +23 -2
- package/skills/hooks/state.md +98 -6
- package/skills/intercept/SKILL.md +29 -12
- package/skills/loader/SKILL.md +52 -21
- package/skills/middleware/SKILL.md +18 -0
- package/skills/migrate-nextjs/SKILL.md +3 -3
- package/skills/observability/SKILL.md +6 -5
- package/skills/parallel/SKILL.md +1 -1
- package/skills/ppr/SKILL.md +129 -77
- package/skills/prerender/SKILL.md +3 -3
- package/skills/rango/SKILL.md +25 -6
- package/skills/route/SKILL.md +2 -0
- package/skills/testing/SKILL.md +27 -25
- package/skills/testing/cache-prerender.md +9 -9
- package/skills/testing/client-components.md +45 -3
- package/skills/testing/loader.md +3 -2
- package/skills/testing/middleware.md +1 -0
- package/skills/testing/predicates.md +49 -49
- package/skills/testing/render-handler.md +1 -0
- package/skills/testing/serve-shell-request.md +14 -0
- package/skills/testing/server-actions.md +9 -3
- package/skills/theme/SKILL.md +8 -4
- package/skills/typesafety/env-and-bindings.md +8 -0
- package/skills/use-cache/SKILL.md +64 -19
- package/skills/view-transitions/SKILL.md +150 -29
- package/src/__augment-tests__/augmented.check.ts +23 -0
- package/src/browser/event-controller.ts +4 -4
- package/src/browser/history-state.ts +52 -1
- package/src/browser/link-interceptor.ts +4 -0
- package/src/browser/navigation-bridge.ts +77 -18
- package/src/browser/navigation-client.ts +4 -3
- package/src/browser/navigation-store.ts +43 -1
- package/src/browser/navigation-transaction.ts +31 -11
- package/src/browser/partial-update.ts +146 -35
- package/src/browser/prefetch/cache.ts +6 -6
- package/src/browser/prefetch/fetch.ts +2 -2
- package/src/browser/react/Link.tsx +38 -33
- package/src/browser/react/NavigationProvider.tsx +2 -2
- package/src/browser/react/context.ts +2 -2
- package/src/browser/react/location-state-shared.ts +241 -49
- package/src/browser/react/location-state.ts +5 -2
- package/src/browser/rsc-router.tsx +4 -0
- package/src/browser/server-action-bridge.ts +84 -15
- package/src/browser/transition-when.ts +205 -0
- package/src/browser/types.ts +124 -10
- package/src/build/generate-manifest.ts +77 -1
- package/src/cache/background-task.ts +55 -2
- package/src/cache/cache-exec-scope.ts +48 -1
- package/src/cache/cache-key-utils.ts +56 -16
- package/src/cache/cache-policy.ts +9 -4
- package/src/cache/cache-runtime.ts +197 -144
- package/src/cache/cache-scope.ts +304 -112
- package/src/cache/cache-tag.ts +105 -13
- package/src/cache/cf/cf-cache-store.ts +152 -76
- package/src/cache/cf/cf-shell-frame.ts +2 -2
- package/src/cache/cf/cf-tag-marker-memo.ts +11 -100
- package/src/cache/document-cache.ts +53 -7
- package/src/cache/handle-snapshot.ts +52 -9
- package/src/cache/invalidation-order.ts +84 -0
- package/src/cache/request-tag-mask.ts +147 -0
- package/src/cache/shell-snapshot.ts +130 -348
- package/src/cache/tag-invalidation.ts +57 -51
- package/src/cache/types.ts +71 -67
- package/src/cache/vercel/vercel-cache-store.ts +61 -58
- package/src/client-urls/client-root.tsx +8 -4
- package/src/client-urls/client-urls.ts +8 -6
- package/src/client-urls/navigation.ts +55 -4
- package/src/client-urls/server-projection.ts +31 -10
- package/src/client-urls/types.ts +12 -5
- package/src/client.tsx +2 -0
- package/src/host/cookie-handler.ts +2 -1
- package/src/index.rsc.ts +3 -0
- package/src/index.ts +3 -0
- package/src/loader-redirect.tsx +7 -3
- package/src/prerender/build-shell-capture.ts +155 -146
- package/src/route-definition/dsl-helpers.ts +2 -0
- package/src/route-definition/helpers-types.ts +11 -11
- package/src/route-name.ts +12 -0
- package/src/router/content-negotiation.ts +4 -1
- package/src/router/error-handling.ts +5 -2
- package/src/router/handler-context.ts +10 -7
- package/src/router/intercept-resolution.ts +11 -1
- package/src/router/loader-resolution.ts +28 -3
- package/src/router/logging.ts +5 -3
- package/src/router/match-api.ts +62 -34
- package/src/router/match-context.ts +5 -0
- package/src/router/match-handlers.ts +9 -29
- package/src/router/match-middleware/background-revalidation.ts +3 -5
- package/src/router/match-middleware/cache-lookup.ts +35 -19
- package/src/router/match-middleware/cache-store.ts +15 -9
- package/src/router/match-result.ts +1 -0
- package/src/router/middleware.ts +2 -5
- package/src/router/navigation-snapshot.ts +6 -5
- package/src/router/prerender-match.ts +4 -0
- package/src/router/request-classification.ts +7 -4
- package/src/router/router-options.ts +4 -1
- package/src/router/segment-resolution/fresh.ts +0 -4
- package/src/router/segment-resolution/loader-cache.ts +150 -25
- package/src/router/segment-resolution/loader-mask.ts +3 -4
- package/src/router/segment-resolution/loader-snapshot.ts +15 -78
- package/src/router/segment-resolution/mask-nested.ts +47 -22
- package/src/router/segment-resolution/revalidation.ts +0 -3
- package/src/router/segment-resolution/view-transition-default.ts +6 -21
- package/src/router/telemetry.ts +6 -3
- package/src/router/transition-when.ts +34 -52
- package/src/router.ts +2 -1
- package/src/rsc/attach-transition-when.ts +47 -0
- package/src/rsc/full-payload.ts +30 -10
- package/src/rsc/handler.ts +12 -10
- package/src/rsc/loader-fetch.ts +2 -1
- package/src/rsc/manifest-init.ts +4 -0
- package/src/rsc/origin-guard.ts +8 -5
- package/src/rsc/progressive-enhancement.ts +7 -30
- package/src/rsc/response-cache-serve.ts +68 -80
- package/src/rsc/response-route-handler.ts +2 -2
- package/src/rsc/rsc-rendering.ts +206 -252
- package/src/rsc/server-action.ts +8 -20
- package/src/rsc/shell-build-manifest.ts +22 -20
- package/src/rsc/shell-capture-constants.ts +136 -20
- package/src/rsc/shell-capture.ts +352 -589
- package/src/rsc/shell-serve.ts +73 -109
- package/src/rsc/ssr-setup.ts +3 -2
- package/src/rsc/types.ts +2 -0
- package/src/segment-system.tsx +40 -7
- package/src/server/context.ts +189 -124
- package/src/server/cookie-store.ts +153 -127
- package/src/server/handle-store.ts +245 -27
- package/src/server/request-context.ts +123 -84
- package/src/server/request-headers.ts +134 -0
- package/src/server.ts +4 -0
- package/src/testing/dispatch.ts +4 -7
- package/src/testing/e2e/index.ts +1 -0
- package/src/testing/index.ts +5 -0
- package/src/testing/internal/action-id.ts +25 -0
- package/src/testing/internal/context.ts +1 -1
- package/src/testing/location-state-key.ts +45 -0
- package/src/testing/render-handler.ts +1 -1
- package/src/testing/render-route.tsx +109 -16
- package/src/testing/run-client-revalidate.ts +3 -19
- package/src/testing/run-loader.ts +5 -1
- package/src/testing/run-transition-when.ts +109 -172
- package/src/testing/serve-shell-request.ts +10 -7
- package/src/testing/shell-status.ts +60 -56
- package/src/transition-when-ref.ts +220 -0
- package/src/types/cache-types.ts +15 -3
- package/src/types/index.ts +3 -0
- package/src/types/segments.ts +94 -91
- package/src/urls/path-helper-types.ts +3 -4
- package/src/urls/pattern-types.ts +3 -4
- package/src/vite/discovery/discover-routers.ts +3 -0
- package/src/vite/discovery/shell-prerender-phase.ts +0 -2
- package/src/vite/discovery/virtual-module-codegen.ts +16 -6
- package/src/vite/plugins/transition-when-hoist.ts +1064 -0
- package/src/vite/rango.ts +2 -0
- package/src/vite/router-discovery.ts +75 -5
- package/dist/types/rsc/transition-gate.d.ts +0 -23
- package/src/rsc/transition-gate.ts +0 -86
|
@@ -227,8 +227,8 @@ interface TransitionConfig {
|
|
|
227
227
|
default?: string | Record<string, string>; // fallback for any phase
|
|
228
228
|
name?: string; // explicit view-transition-name
|
|
229
229
|
viewTransition?: "auto" | false; // boundary opt-out (see below)
|
|
230
|
-
//
|
|
231
|
-
//
|
|
230
|
+
// Browser predicate, once per navigation: false commits it urgently with no
|
|
231
|
+
// view transition. See "Conditional transitions".
|
|
232
232
|
when?: (ctx: TransitionWhenContext) => boolean;
|
|
233
233
|
}
|
|
234
234
|
```
|
|
@@ -240,47 +240,162 @@ interface TransitionConfig {
|
|
|
240
240
|
|
|
241
241
|
## Conditional transitions (`when`)
|
|
242
242
|
|
|
243
|
-
`transition({ when })`
|
|
243
|
+
`transition({ when })` decides, per navigation, whether the navigation holds.
|
|
244
|
+
It is a **browser** predicate: it runs once per navigation, at the first
|
|
245
|
+
commit that presents the destination, and never on the server, on the
|
|
246
|
+
document load, or on progressive-enhancement paths. Return `false` and the
|
|
247
|
+
navigation commits urgently: the `loading()` skeleton streams and no view
|
|
248
|
+
transition runs.
|
|
249
|
+
|
|
250
|
+
Write it inline in `urls()`. The Vite plugin hoists the literal into a
|
|
251
|
+
`"use client"` module, and the route carries it to the browser as a client
|
|
252
|
+
reference. The hoist applies to the router's `transition`: the `urls()`
|
|
253
|
+
helper (renamed or not) or `transition` imported from `@rangojs/router`,
|
|
254
|
+
with the function written directly as the `when` value (an arrow, a
|
|
255
|
+
`function`, or a `when() {}` method; `as` / `satisfies` wrappers are
|
|
256
|
+
fine). A config built elsewhere, such as `transition(cfg)`, is not hoisted:
|
|
257
|
+
export that predicate from a `"use client"` module instead.
|
|
244
258
|
|
|
245
|
-
|
|
259
|
+
```ts
|
|
260
|
+
// Hold unless the navigation leaves the "b" tab (the navigation SOURCE):
|
|
261
|
+
transition({ when: ({ from }) => from.params.tab !== "b" });
|
|
262
|
+
|
|
263
|
+
// Hold only for forward navigations inside /list:
|
|
264
|
+
transition({
|
|
265
|
+
when: ({ kind, to }) => kind !== "pop" && to.url.pathname.startsWith("/list"),
|
|
266
|
+
});
|
|
246
267
|
|
|
247
|
-
|
|
248
|
-
|
|
268
|
+
// Hold only after a specific action revalidated the route:
|
|
269
|
+
transition({ when: ({ isAction }) => isAction(addToCart) });
|
|
270
|
+
```
|
|
249
271
|
|
|
250
|
-
|
|
272
|
+
For a predicate you share between routes or unit-test, export it from a
|
|
273
|
+
`"use client"` module and import it into the urls file:
|
|
251
274
|
|
|
252
275
|
```ts
|
|
276
|
+
// src/transitions.ts
|
|
277
|
+
"use client";
|
|
253
278
|
import type { TransitionWhenContext } from "@rangojs/router";
|
|
279
|
+
import { Slide } from "./location-states.js";
|
|
254
280
|
|
|
255
|
-
|
|
256
|
-
|
|
281
|
+
export function slideWhen({ to }: TransitionWhenContext): boolean {
|
|
282
|
+
return Slide.read(to)?.animate !== false;
|
|
283
|
+
}
|
|
257
284
|
|
|
258
|
-
//
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
})
|
|
262
|
-
|
|
285
|
+
// src/urls.tsx
|
|
286
|
+
import { slideWhen } from "./transitions.js";
|
|
287
|
+
path("/photos/:id", PhotoPage, { name: "photo" }, () => [
|
|
288
|
+
transition({ default: "slide", when: slideWhen }),
|
|
289
|
+
]);
|
|
263
290
|
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
when: ({ actionId }) => actionId === "src/actions/cart.ts#addToCart",
|
|
267
|
-
});
|
|
291
|
+
// A Link that opts this navigation out through location state:
|
|
292
|
+
<Link to="/photos/2" state={[Slide({ animate: false })]}>Next</Link>;
|
|
268
293
|
```
|
|
269
294
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
295
|
+
Predicate modules (a `"use client"` export, or a hoisted inline literal) load
|
|
296
|
+
with the client entry chunk, because the router must be able to call them at
|
|
297
|
+
the first navigation. Keep them small: type imports and location-state
|
|
298
|
+
definitions only, and nothing that touches `window` at module scope (the
|
|
299
|
+
module is also evaluated during SSR).
|
|
300
|
+
|
|
301
|
+
The rule is "a predicate runs where its effect lands": the hold is a browser
|
|
302
|
+
commit decision, so the predicate runs in the browser. An inline literal may
|
|
303
|
+
reference module-level imports only when they are client-safe (they are
|
|
304
|
+
re-emitted into the hoisted module); a free server binding (a `const` in the
|
|
305
|
+
urls file, a handler variable, a component used as JSX) or a server-only
|
|
306
|
+
import is a build error that names it, and so are `import()`, `import.meta`
|
|
307
|
+
other than `import.meta.env`, `this` / `arguments` of the enclosing
|
|
308
|
+
function, and a `get when()` accessor. A server function passed by name, or a non-function, fails dev
|
|
309
|
+
startup, the build and HMR re-discovery with the route name and pattern.
|
|
278
310
|
|
|
279
|
-
|
|
311
|
+
### Context
|
|
280
312
|
|
|
281
|
-
|
|
313
|
+
```ts
|
|
314
|
+
interface RouteLocation {
|
|
315
|
+
url: URL;
|
|
316
|
+
params: Record<string, string>;
|
|
317
|
+
routeName: string | undefined;
|
|
318
|
+
state: unknown; // the entry's history.state; read slots with Def.read(location)
|
|
319
|
+
}
|
|
282
320
|
|
|
283
|
-
|
|
321
|
+
interface TransitionWhenContext {
|
|
322
|
+
kind: "push" | "replace" | "pop" | "action" | "revalidate";
|
|
323
|
+
from: RouteLocation; // the committed location being left
|
|
324
|
+
to: RouteLocation; // the destination; === from for "action" and "revalidate"
|
|
325
|
+
isAction: IsActionFn; // the revalidate() matcher; false off-action
|
|
326
|
+
action?: { id; formData; result; error }; // kind "action" only
|
|
327
|
+
}
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
| `kind` | started by | `to.state` |
|
|
331
|
+
| -------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
|
332
|
+
| `"push"` | `Link`, `router.push()` | the pushed state plus server `ctx.setLocationState()` (handler, middleware, action) |
|
|
333
|
+
| `"replace"` | `Link replace`, `router.replace()` | same as push |
|
|
334
|
+
| `"pop"` | back/forward | the target entry's stored state |
|
|
335
|
+
| `"action"` | a server action's revalidation, error boundary incl. | the current entry's state plus action-set state (none on the error lane: an error response sets no location state); `action.*` set |
|
|
336
|
+
| `"revalidate"` | `router.refresh()`, a stale entry's revalidation | the current entry's state |
|
|
337
|
+
|
|
338
|
+
- `Def.read(location)` reads a location-state slot from `from` or `to`
|
|
339
|
+
without clearing flash state.
|
|
340
|
+
- Middleware-set location state reaches `to.state` on a PPR replay HIT too
|
|
341
|
+
(middleware runs on every HIT); a route handler's does not, because a HIT
|
|
342
|
+
runs no handler. Set it from middleware when a `ppr` route decides on it.
|
|
343
|
+
- A `clientUrls()` cross-route navigation decides at its optimistic swap,
|
|
344
|
+
before the server responds, so its `to.state` holds only the pushed
|
|
345
|
+
state.
|
|
346
|
+
- On `"pop"`, `from.state` and `from.routeName` come from the router's
|
|
347
|
+
per-history-entry memory; after a reload they are `undefined`.
|
|
348
|
+
- `get()`, `env` and handler state do not exist in the browser.
|
|
349
|
+
|
|
350
|
+
### Combination and `false`
|
|
351
|
+
|
|
352
|
+
- The navigation holds only when **every** committed segment's `when`
|
|
353
|
+
returns true, kept or re-sent: a layout the navigation keeps still gates
|
|
354
|
+
it ([#989](https://github.com/rangojs/rango/issues/989)). The same function
|
|
355
|
+
on several segments is called once.
|
|
356
|
+
- `false` makes the whole navigation urgent, so no segment animates. Every
|
|
357
|
+
`<ViewTransition>` stays in the tree with its classes set to `"none"`, so a
|
|
358
|
+
same-route navigation that flips `true -> false -> true` never remounts
|
|
359
|
+
the route or loses its state
|
|
360
|
+
([#995](https://github.com/rangojs/rango/issues/995)).
|
|
361
|
+
- That holds for every kind, actions and revalidations included: a gated-off
|
|
362
|
+
action or revalidation commit is urgent too, so a segment it re-renders
|
|
363
|
+
streams its `loading()`. The router commits an action's result after the
|
|
364
|
+
response arrives, outside the transition React opens for a form action or
|
|
365
|
+
`useActionState` call, so React does not hold it either.
|
|
366
|
+
- A predicate that throws counts as `false` and is logged with
|
|
367
|
+
`console.error` (the conservative default: no hold).
|
|
368
|
+
- Intercept (modal) commits skip the evaluation, including a back/forward
|
|
369
|
+
that opens or closes a modal.
|
|
370
|
+
- A stale revalidation the user navigated away from before it landed is
|
|
371
|
+
discarded without calling the predicate.
|
|
372
|
+
- Intercept `from` differs: while a modal is open, an intercept selector's
|
|
373
|
+
`from` is the page under the modal (see the intercept skill); here it is
|
|
374
|
+
always the location on screen.
|
|
375
|
+
- In a `clientUrls()` group, `false` also opts a same-route navigation out of
|
|
376
|
+
the group's default hold.
|
|
377
|
+
|
|
378
|
+
`kind` lines up with the class keys in "Direction-aware transitions": a
|
|
379
|
+
`"pop"` restored from the history cache animates with `"navigation-back"`
|
|
380
|
+
(a pop that refetches uses `"navigation"`); `"push"`, `"replace"` and a
|
|
381
|
+
`router.refresh()` (`"revalidate"`) use `"navigation"`; a stale entry's
|
|
382
|
+
revalidation (`"revalidate"`) and an action's refetch (`"action"`) use
|
|
383
|
+
`"action"`. An action's own commit (`useActionState`, a form action) carries
|
|
384
|
+
no transition type (see the note under "Direction-aware transitions").
|
|
385
|
+
|
|
386
|
+
### Opting out per navigation
|
|
387
|
+
|
|
388
|
+
`router.push(url, { transition: false })`, `router.replace(url, {
|
|
389
|
+
transition: false })` and `<Link transition={false}>` present one
|
|
390
|
+
navigation the way `when` returning `false` does, without calling any
|
|
391
|
+
predicate. Back/forward, actions and revalidation have no per-navigation
|
|
392
|
+
options and are unaffected.
|
|
393
|
+
|
|
394
|
+
```tsx
|
|
395
|
+
<Link to="/photos/2" transition={false}>
|
|
396
|
+
Jump without the slide
|
|
397
|
+
</Link>
|
|
398
|
+
```
|
|
284
399
|
|
|
285
400
|
## Opting out of the router boundary (place your own `<ViewTransition>`)
|
|
286
401
|
|
|
@@ -361,6 +476,12 @@ await expect(page$).toBeVisible();
|
|
|
361
476
|
Waiting for `toHaveCount(1)` first is not enough: the count passes through 1
|
|
362
477
|
before the second host is inserted.
|
|
363
478
|
|
|
479
|
+
Unit-test a `when` with `runTransitionWhen(when, { kind, from, to, action })`
|
|
480
|
+
from `@rangojs/router/testing`, and a route's hold with `renderRoute`:
|
|
481
|
+
`router.navigate()` decides with `kind: "push"`, `router.refresh()` with
|
|
482
|
+
`kind: "revalidate"`. Pop, action and `clientUrls()` optimistic-swap
|
|
483
|
+
decisions are e2e territory. See the testing skill, `predicates.md`.
|
|
484
|
+
|
|
364
485
|
## Recommendations
|
|
365
486
|
|
|
366
487
|
**Put `<ParallelOutlet />` in layouts, not routes.** A route-level `transition` wraps the route component itself, so a `<ParallelOutlet />` rendered directly inside that route component remains inside the route VT subtree — modal opens on a route with a parallel outlet _will_ trigger the route's VT walker. The narrowing fix only applies at layout boundaries. If you combine intercept modals with route-level transitions, mount the slot one level up in a layout.
|
|
@@ -10,10 +10,14 @@ import type {
|
|
|
10
10
|
Handler,
|
|
11
11
|
MiddlewareContext,
|
|
12
12
|
ResponseHandlerContext,
|
|
13
|
+
RouteLocation,
|
|
13
14
|
RouteParams,
|
|
14
15
|
RouteSearchParams,
|
|
16
|
+
ServerRouteLocation,
|
|
17
|
+
TransitionWhenFn,
|
|
15
18
|
} from "../index.js";
|
|
16
19
|
import type { DefaultRouteName } from "../types/global-namespace.js";
|
|
20
|
+
import type { InterceptWhenFn } from "../server/context.js";
|
|
17
21
|
import { href } from "../href-client.js";
|
|
18
22
|
import type { Money, TestBindings } from "./augment.js";
|
|
19
23
|
|
|
@@ -69,6 +73,23 @@ type _routeName = Expect<
|
|
|
69
73
|
Equal<DefaultRouteName, "home" | "blog.post" | "search">
|
|
70
74
|
>;
|
|
71
75
|
|
|
76
|
+
// transition({ when }) / intercept({ when }) locations carry the public route
|
|
77
|
+
// name (internal names never reach them), typed like every routeName.
|
|
78
|
+
type _locationRouteName = Expect<
|
|
79
|
+
Equal<RouteLocation["routeName"], DefaultRouteName | undefined>
|
|
80
|
+
>;
|
|
81
|
+
type _serverLocationRouteName = Expect<
|
|
82
|
+
Equal<ServerRouteLocation["routeName"], DefaultRouteName | undefined>
|
|
83
|
+
>;
|
|
84
|
+
const transitionWhenTypo: TransitionWhenFn = ({ to }) =>
|
|
85
|
+
// @ts-expect-error - "blog.pots" is not a generated route name
|
|
86
|
+
to.routeName === "blog.pots";
|
|
87
|
+
const interceptWhenTypo: InterceptWhenFn = ({ from }) =>
|
|
88
|
+
// @ts-expect-error - "hom" is not a generated route name
|
|
89
|
+
from.routeName === "hom";
|
|
90
|
+
void transitionWhenTypo;
|
|
91
|
+
void interceptWhenTypo;
|
|
92
|
+
|
|
72
93
|
// RouteParams / RouteSearchParams resolve from the generated map with no
|
|
73
94
|
// explicit route map argument.
|
|
74
95
|
type _params = Expect<Equal<RouteParams<"blog.post">, { slug: string }>>;
|
|
@@ -131,6 +152,8 @@ type _flightOverride = Expect<Equal<Rango.FlightSerialize<Money>, number>>;
|
|
|
131
152
|
// Reference the top-level assertion aliases so they are unambiguously evaluated.
|
|
132
153
|
export type _Assertions = [
|
|
133
154
|
_routeName,
|
|
155
|
+
_locationRouteName,
|
|
156
|
+
_serverLocationRouteName,
|
|
134
157
|
_params,
|
|
135
158
|
_search,
|
|
136
159
|
_orderWireByPattern,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
import type {
|
|
3
3
|
NavigationLocation,
|
|
4
|
-
|
|
4
|
+
NavigateOptionsInternal,
|
|
5
5
|
TrackedActionState,
|
|
6
6
|
ActionLifecycleState,
|
|
7
7
|
InflightAction,
|
|
@@ -46,7 +46,7 @@ export interface NavigationEntry {
|
|
|
46
46
|
abort: AbortController;
|
|
47
47
|
phase: NavigationPhase;
|
|
48
48
|
startedAt: number;
|
|
49
|
-
options?:
|
|
49
|
+
options?: NavigateOptionsInternal & { skipLoadingState?: boolean };
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
/**
|
|
@@ -206,7 +206,7 @@ export interface EventController {
|
|
|
206
206
|
// Navigation operations
|
|
207
207
|
startNavigation(
|
|
208
208
|
url: string,
|
|
209
|
-
options?:
|
|
209
|
+
options?: NavigateOptionsInternal & { skipLoadingState?: boolean },
|
|
210
210
|
): NavigationHandle;
|
|
211
211
|
abortNavigation(): void;
|
|
212
212
|
|
|
@@ -632,7 +632,7 @@ export function createEventController(
|
|
|
632
632
|
|
|
633
633
|
function startNavigation(
|
|
634
634
|
url: string,
|
|
635
|
-
options?:
|
|
635
|
+
options?: NavigateOptionsInternal & { skipLoadingState?: boolean },
|
|
636
636
|
): NavigationHandle {
|
|
637
637
|
// Cancel existing navigation (switchMap semantics)
|
|
638
638
|
if (currentNavigation) {
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
|
+
isLocationStateDefinition,
|
|
2
3
|
isLocationStateEntry,
|
|
4
|
+
peekLocationStateKey,
|
|
5
|
+
replaceCurrentHistoryState,
|
|
3
6
|
resolveLocationStateEntries,
|
|
4
7
|
} from "./react/location-state-shared.js";
|
|
5
8
|
|
|
@@ -13,10 +16,49 @@ function isTypedLocationState(
|
|
|
13
16
|
return Object.keys(state).some((key) => key.startsWith("__rsc_ls_"));
|
|
14
17
|
}
|
|
15
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Dev-only guard for the state mistakes `HistoryState` rejects at compile
|
|
21
|
+
* time, for untyped callers: a typed entry without its array (its `__rsc_ls_*`
|
|
22
|
+
* fields would be spread onto history.state, so useLocationState reads
|
|
23
|
+
* undefined), an uncalled definition (pushState throws DataCloneError), and an
|
|
24
|
+
* array mixing entries with plain values (only `state[0]` picks the format, so
|
|
25
|
+
* the rest land under history.state["undefined"] or are never read).
|
|
26
|
+
*/
|
|
27
|
+
function assertNavigationState(state: unknown): void {
|
|
28
|
+
if (isLocationStateEntry(state)) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`[rango] navigation state is a single location-state entry (key "${state.__rsc_ls_key}"). ` +
|
|
31
|
+
"Wrap it in an array: { state: [MyState(value)] }. " +
|
|
32
|
+
"Without the array, useLocationState(MyState) reads undefined.",
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
const definition = (Array.isArray(state) ? state : [state]).find(
|
|
36
|
+
isLocationStateDefinition,
|
|
37
|
+
);
|
|
38
|
+
if (definition) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`[rango] navigation state contains a location-state definition (key "${peekLocationStateKey(definition) ?? "unset"}") instead of an entry. ` +
|
|
41
|
+
"Call it with the value: { state: [MyState(value)] }, not [MyState].",
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
if (Array.isArray(state) && state.some(isLocationStateEntry)) {
|
|
45
|
+
const index = state.findIndex((item) => !isLocationStateEntry(item));
|
|
46
|
+
if (index !== -1) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`[rango] navigation state mixes location-state entries with other values (index ${index}). ` +
|
|
49
|
+
"Pass only entries ({ state: [MyState(value), Other(value)] }); plain state cannot sit next to typed entries.",
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
16
55
|
/**
|
|
17
56
|
* Resolve navigation state - handles both LocationStateEntry[] and plain formats
|
|
18
57
|
*/
|
|
19
58
|
export function resolveNavigationState(state: unknown): unknown {
|
|
59
|
+
if (process.env.NODE_ENV !== "production") {
|
|
60
|
+
assertNavigationState(state);
|
|
61
|
+
}
|
|
20
62
|
if (
|
|
21
63
|
Array.isArray(state) &&
|
|
22
64
|
state.length > 0 &&
|
|
@@ -61,6 +103,15 @@ export function buildHistoryState(
|
|
|
61
103
|
return Object.keys(result).length > 0 ? result : null;
|
|
62
104
|
}
|
|
63
105
|
|
|
106
|
+
/** Check if a history state object contains location state keys. */
|
|
107
|
+
export function hasLocationState(state: unknown): boolean {
|
|
108
|
+
if (!state || typeof state !== "object") return false;
|
|
109
|
+
return (
|
|
110
|
+
"state" in state ||
|
|
111
|
+
Object.keys(state).some((k) => k.startsWith("__rsc_ls_"))
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
64
115
|
/**
|
|
65
116
|
* Stamp an `idx` on the next history entry's state and call push/replaceState.
|
|
66
117
|
* Push increments the current idx; replace keeps it. Initial entry idx is 0.
|
|
@@ -117,7 +168,7 @@ export function mergeLocationState(
|
|
|
117
168
|
...window.history.state,
|
|
118
169
|
...locationState,
|
|
119
170
|
};
|
|
120
|
-
|
|
171
|
+
replaceCurrentHistoryState(merged);
|
|
121
172
|
if (Object.keys(locationState).some((k) => k.startsWith("__rsc_ls_"))) {
|
|
122
173
|
window.dispatchEvent(new Event("__rsc_locationstate"));
|
|
123
174
|
}
|
|
@@ -334,6 +334,7 @@ export function setupLinkInterception(
|
|
|
334
334
|
const scrollAttr = link.getAttribute("data-scroll");
|
|
335
335
|
const replaceAttr = link.getAttribute("data-replace");
|
|
336
336
|
const revalidateAttr = link.getAttribute("data-revalidate");
|
|
337
|
+
const transitionAttr = link.getAttribute("data-transition");
|
|
337
338
|
|
|
338
339
|
const navigateOptions: NavigateOptions = {};
|
|
339
340
|
if (scrollAttr === "false") {
|
|
@@ -345,6 +346,9 @@ export function setupLinkInterception(
|
|
|
345
346
|
if (revalidateAttr === "false") {
|
|
346
347
|
navigateOptions.revalidate = false;
|
|
347
348
|
}
|
|
349
|
+
if (transitionAttr === "false") {
|
|
350
|
+
navigateOptions.transition = false;
|
|
351
|
+
}
|
|
348
352
|
|
|
349
353
|
onNavigate(href, navigateOptions);
|
|
350
354
|
};
|
|
@@ -14,7 +14,11 @@ import {
|
|
|
14
14
|
createNavigationTransaction,
|
|
15
15
|
resolveNavigationState,
|
|
16
16
|
} from "./navigation-transaction.js";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
buildHistoryState,
|
|
19
|
+
hasLocationState,
|
|
20
|
+
pushHistoryWithIdx,
|
|
21
|
+
} from "./history-state.js";
|
|
18
22
|
import {
|
|
19
23
|
handleNavigationStart,
|
|
20
24
|
handleNavigationEnd,
|
|
@@ -30,7 +34,11 @@ import {
|
|
|
30
34
|
setupDelegatedLinkPrefetch,
|
|
31
35
|
setupLinkInterception,
|
|
32
36
|
} from "./link-interceptor.js";
|
|
33
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
createPartialUpdater,
|
|
39
|
+
shouldStartViewTransition,
|
|
40
|
+
} from "./partial-update.js";
|
|
41
|
+
import { decideCommitGatedOff, mergeSegmentParams } from "./transition-when.js";
|
|
34
42
|
import { generateHistoryKey } from "./navigation-store.js";
|
|
35
43
|
import type { EventController } from "./event-controller.js";
|
|
36
44
|
import { isInterceptOnlyCache } from "./intercept-utils.js";
|
|
@@ -214,6 +222,7 @@ export function createNavigationBridge(
|
|
|
214
222
|
|
|
215
223
|
// Snapshot old state before pushState/replaceState overwrites it
|
|
216
224
|
const oldState = window.history.state;
|
|
225
|
+
const shallowRouteName = store.getHistoryEntryMemory()?.routeName;
|
|
217
226
|
|
|
218
227
|
// Update browser URL (carry intercept context into history state)
|
|
219
228
|
const historyState = buildHistoryState(
|
|
@@ -228,18 +237,11 @@ export function createNavigationBridge(
|
|
|
228
237
|
|
|
229
238
|
// Ensure new history entry has a scroll restoration key
|
|
230
239
|
ensureHistoryKey();
|
|
240
|
+
// Same route, new entry (transition({ when }) back/forward source).
|
|
241
|
+
store.rememberDisplayedEntry(shallowRouteName);
|
|
231
242
|
|
|
232
243
|
// Notify useLocationState() hooks when state changes
|
|
233
|
-
|
|
234
|
-
oldState &&
|
|
235
|
-
typeof oldState === "object" &&
|
|
236
|
-
("state" in oldState ||
|
|
237
|
-
Object.keys(oldState).some((k) => k.startsWith("__rsc_ls_")));
|
|
238
|
-
const hasNewState =
|
|
239
|
-
historyState &&
|
|
240
|
-
("state" in historyState ||
|
|
241
|
-
Object.keys(historyState).some((k) => k.startsWith("__rsc_ls_")));
|
|
242
|
-
if (hasOldState || hasNewState) {
|
|
244
|
+
if (hasLocationState(oldState) || hasLocationState(historyState)) {
|
|
243
245
|
window.dispatchEvent(new Event("__rsc_locationstate"));
|
|
244
246
|
}
|
|
245
247
|
|
|
@@ -344,9 +346,44 @@ export function createNavigationBridge(
|
|
|
344
346
|
state: resolvedState,
|
|
345
347
|
skipLoadingState: false,
|
|
346
348
|
});
|
|
349
|
+
// A cross-route clientUrls() navigation presents its destination
|
|
350
|
+
// optimistically at once: transition({ when }) decides THERE (the first
|
|
351
|
+
// presentation), over the destination route's predicate and those of
|
|
352
|
+
// the segments the navigation keeps (everything outside the group's
|
|
353
|
+
// route segment), and the canonical commit reuses the decision.
|
|
354
|
+
// `transition: false` gates the navigation off without calling any
|
|
355
|
+
// predicate, on both the optimistic swap and the canonical commit.
|
|
356
|
+
const transitionOptOut = options?.transition === false;
|
|
347
357
|
const clientUrlPresentation = beginClientUrlNavigation(
|
|
348
358
|
targetUrl,
|
|
349
359
|
tx.handle.signal,
|
|
360
|
+
transitionOptOut
|
|
361
|
+
? () => true
|
|
362
|
+
: (destination) =>
|
|
363
|
+
decideCommitGatedOff(
|
|
364
|
+
store,
|
|
365
|
+
(
|
|
366
|
+
store.getCachedSegments(store.getHistoryKey())?.segments ?? []
|
|
367
|
+
).filter((segment) => segment.clientGroup === undefined),
|
|
368
|
+
options?.replace ? "replace" : "push",
|
|
369
|
+
{
|
|
370
|
+
to: (from) => {
|
|
371
|
+
// Mount (include prefix) params carry over; the origin
|
|
372
|
+
// route's own params do not.
|
|
373
|
+
const params = { ...from.params };
|
|
374
|
+
for (const name of destination.originLocalParamNames) {
|
|
375
|
+
delete params[name];
|
|
376
|
+
}
|
|
377
|
+
return {
|
|
378
|
+
url: targetUrl,
|
|
379
|
+
params: { ...params, ...destination.params },
|
|
380
|
+
routeName: destination.routeName,
|
|
381
|
+
state: buildHistoryState(resolvedState),
|
|
382
|
+
};
|
|
383
|
+
},
|
|
384
|
+
extra: destination.when,
|
|
385
|
+
},
|
|
386
|
+
),
|
|
350
387
|
);
|
|
351
388
|
|
|
352
389
|
// REVALIDATE: Fetch fresh data from server
|
|
@@ -365,6 +402,9 @@ export function createNavigationBridge(
|
|
|
365
402
|
replace: options?.replace,
|
|
366
403
|
scroll: options?.scroll,
|
|
367
404
|
state: resolvedState,
|
|
405
|
+
transitionGatedOff: transitionOptOut
|
|
406
|
+
? true
|
|
407
|
+
: clientUrlPresentation?.transitionGatedOff,
|
|
368
408
|
}),
|
|
369
409
|
isLeavingIntercept
|
|
370
410
|
? { type: "leave-intercept" as const }
|
|
@@ -394,6 +434,7 @@ export function createNavigationBridge(
|
|
|
394
434
|
return this.navigate(redirectUrl, {
|
|
395
435
|
state: error.state,
|
|
396
436
|
replace: options?.replace,
|
|
437
|
+
transition: options?.transition,
|
|
397
438
|
_skipCache: true,
|
|
398
439
|
} as NavigateOptionsInternal);
|
|
399
440
|
}
|
|
@@ -457,6 +498,7 @@ export function createNavigationBridge(
|
|
|
457
498
|
false,
|
|
458
499
|
tx.handle.signal,
|
|
459
500
|
tx.with({ url: window.location.href, replace: true, scroll: false }),
|
|
501
|
+
{ type: "navigate", refresh: true },
|
|
460
502
|
);
|
|
461
503
|
} catch (error) {
|
|
462
504
|
// Aborted or superseded: bail without rendering a boundary (see navigate()).
|
|
@@ -567,6 +609,26 @@ export function createNavigationBridge(
|
|
|
567
609
|
const isStale = (cached?.stale ?? false) || isActionFenceActive();
|
|
568
610
|
|
|
569
611
|
if (cachedSegments && cachedSegments.length > 0) {
|
|
612
|
+
// transition({ when }) decides the restore (kind "pop") against the
|
|
613
|
+
// entry being left, so before the store moves to the restored entry.
|
|
614
|
+
// The decision the entry was committed with is not reused: it was
|
|
615
|
+
// made for a different source. A restore that opens or closes an
|
|
616
|
+
// intercept never holds or animates (hasTransition below), so it is
|
|
617
|
+
// not decided, like every intercept commit.
|
|
618
|
+
const cachedParams = mergeSegmentParams(cachedSegments);
|
|
619
|
+
const gatedOff =
|
|
620
|
+
!isIntercept &&
|
|
621
|
+
!isLeavingIntercept &&
|
|
622
|
+
decideCommitGatedOff(store, cachedSegments, "pop", {
|
|
623
|
+
to: () => ({
|
|
624
|
+
url,
|
|
625
|
+
params: cachedParams,
|
|
626
|
+
routeName: store.getHistoryEntryMemory(historyState?.key)
|
|
627
|
+
?.routeName,
|
|
628
|
+
state: historyState,
|
|
629
|
+
}),
|
|
630
|
+
});
|
|
631
|
+
|
|
570
632
|
// Update store to point to this history entry
|
|
571
633
|
store.setHistoryKey(historyKey);
|
|
572
634
|
store.setSegmentIds(cachedSegments.map((s) => s.id));
|
|
@@ -583,14 +645,11 @@ export function createNavigationBridge(
|
|
|
583
645
|
try {
|
|
584
646
|
const root = await renderSegments(cachedSegments, {
|
|
585
647
|
forceAwait: true,
|
|
648
|
+
transitionGatedOff: gatedOff,
|
|
586
649
|
});
|
|
587
|
-
|
|
650
|
+
store.rememberDisplayedEntry();
|
|
588
651
|
// Set params on event controller before onUpdate so both location
|
|
589
652
|
// and params are current when the debounced notify() fires.
|
|
590
|
-
const cachedParams: Record<string, string> = {};
|
|
591
|
-
for (const s of cachedSegments) {
|
|
592
|
-
if (s.params) Object.assign(cachedParams, s.params);
|
|
593
|
-
}
|
|
594
653
|
eventController.setParams(cachedParams);
|
|
595
654
|
|
|
596
655
|
const popstateUpdate = {
|
|
@@ -613,7 +672,7 @@ export function createNavigationBridge(
|
|
|
613
672
|
const hasTransition =
|
|
614
673
|
!isIntercept &&
|
|
615
674
|
!isLeavingIntercept &&
|
|
616
|
-
cachedSegments
|
|
675
|
+
shouldStartViewTransition(cachedSegments, gatedOff);
|
|
617
676
|
if (hasTransition) {
|
|
618
677
|
startTransition(() => {
|
|
619
678
|
if (addTransitionType) {
|
|
@@ -122,9 +122,10 @@ export function createNavigationClient(
|
|
|
122
122
|
|
|
123
123
|
// Check completed in-memory prefetch cache before making a network
|
|
124
124
|
// request. Try the source-scoped key first (populated when the server
|
|
125
|
-
// tagged the prefetch response as source-sensitive, e.g.
|
|
126
|
-
// or when a Link opted in with
|
|
127
|
-
// back to the wildcard slot shared
|
|
125
|
+
// tagged the prefetch response as source-sensitive, e.g. a route an
|
|
126
|
+
// intercept targets, or when a Link opted in with
|
|
127
|
+
// `prefetchKey=":source"`), then fall back to the wildcard slot shared
|
|
128
|
+
// across source pages.
|
|
128
129
|
// Both keys embed the Rango state, so state rotation (deploy or
|
|
129
130
|
// server-action invalidation) auto-invalidates both scopes.
|
|
130
131
|
// Skip cache for stale revalidation (needs fresh data), HMR (needs
|