@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/src/segment-system.tsx
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
LoaderBoundary,
|
|
11
11
|
} from "./route-content-wrapper.js";
|
|
12
12
|
import { RootErrorBoundary } from "./root-error-boundary.js";
|
|
13
|
+
import { INTERNAL_RANGO_DEBUG } from "./internal-debug.js";
|
|
13
14
|
import { getMemoizedContentPromise } from "./segment-content-promise.js";
|
|
14
15
|
import {
|
|
15
16
|
buildLoaderPromise,
|
|
@@ -316,14 +317,47 @@ export async function renderSegments(
|
|
|
316
317
|
resolvedComponent = await component;
|
|
317
318
|
}
|
|
318
319
|
|
|
319
|
-
let nodeContent: ReactNode =
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
320
|
+
let nodeContent: ReactNode = null;
|
|
321
|
+
if (isRenderableLoading(loading)) {
|
|
322
|
+
// forceAwait (popstate, stale-revalidation, fully-prefetched nav) renders a
|
|
323
|
+
// loading() route with the route content ALREADY resolved, so its
|
|
324
|
+
// RouteContentWrapper Suspender does not suspend for a microtask and flash
|
|
325
|
+
// the loading() fallback on a NORMAL (non-transition) commit. The router
|
|
326
|
+
// data is known-ready on these paths, so awaiting the content here is free.
|
|
327
|
+
// The wrapper tree is unchanged (RouteContentWrapper is still created with
|
|
328
|
+
// the same key/fallback) — only the `content` prop is a resolved node
|
|
329
|
+
// instead of a pending promise, which Suspender renders synchronously. This
|
|
330
|
+
// mirrors the forceAwait loaderData unwrap above; a CLIENT component that
|
|
331
|
+
// suspends on mount inside the content still reveals a fallback (it is not
|
|
332
|
+
// pre-resolved).
|
|
333
|
+
const contentPromise = getMemoizedContentPromise(resolvedComponent);
|
|
334
|
+
const loadingContent: Promise<ReactNode> | ReactNode = forceAwait
|
|
335
|
+
? await contentPromise
|
|
336
|
+
: contentPromise;
|
|
337
|
+
nodeContent = createElement(RouteContentWrapper, {
|
|
338
|
+
key: `suspense-loading-${id}`,
|
|
339
|
+
content: loadingContent,
|
|
340
|
+
fallback: loading,
|
|
341
|
+
segmentId: id,
|
|
342
|
+
});
|
|
343
|
+
} else {
|
|
344
|
+
// [VT-DIAG] Gated behind INTERNAL_RANGO_DEBUG. A segment in the no-loading()
|
|
345
|
+
// branch whose component decodes as a Promise/lazy gets registered into
|
|
346
|
+
// temporalLazyRefs and awaited before commit (see below) — which on builds
|
|
347
|
+
// where the segment component arrives deferred defeats client-nav streaming.
|
|
348
|
+
if (INTERNAL_RANGO_DEBUG && typeof window === "object") {
|
|
349
|
+
const c = resolvedComponent as unknown;
|
|
350
|
+
console.log("[VT-DIAG] renderSegments no-loading-branch segment", {
|
|
351
|
+
id,
|
|
352
|
+
type: node.segment.type,
|
|
353
|
+
componentIsPromise: c instanceof Promise,
|
|
354
|
+
componentIsLazy:
|
|
355
|
+
c != null && typeof c === "object" && "_payload" in c,
|
|
356
|
+
componentTypeof: typeof c,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
nodeContent = registerLazyRef(resolvedComponent);
|
|
360
|
+
}
|
|
327
361
|
|
|
328
362
|
// Wrap with <ViewTransition> if transition config exists (React experimental only).
|
|
329
363
|
// An empty config ({}) creates a bare <ViewTransition> boundary that participates
|
|
@@ -462,7 +496,24 @@ export async function renderSegments(
|
|
|
462
496
|
children: content,
|
|
463
497
|
});
|
|
464
498
|
if (typeof window === "object") {
|
|
499
|
+
// [VT-DIAG] Gated behind INTERNAL_RANGO_DEBUG. If this await dominates the
|
|
500
|
+
// navigation time, a deferred/lazy segment component is being fully resolved
|
|
501
|
+
// before commit, which defeats client-nav streaming. The await itself is
|
|
502
|
+
// functional (it preloads lazy chunk refs); only the timing log is gated.
|
|
503
|
+
const vtDebug = INTERNAL_RANGO_DEBUG && temporalLazyRefs.length > 0;
|
|
504
|
+
const vtDebugStart = vtDebug ? performance.now() : 0;
|
|
505
|
+
if (vtDebug) {
|
|
506
|
+
console.log("[VT-DIAG] renderSegments awaiting temporalLazyRefs", {
|
|
507
|
+
count: temporalLazyRefs.length,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
465
510
|
await Promise.allSettled(temporalLazyRefs);
|
|
511
|
+
if (vtDebug) {
|
|
512
|
+
console.log("[VT-DIAG] renderSegments temporalLazyRefs settled", {
|
|
513
|
+
count: temporalLazyRefs.length,
|
|
514
|
+
ms: Math.round(performance.now() - vtDebugStart),
|
|
515
|
+
});
|
|
516
|
+
}
|
|
466
517
|
}
|
|
467
518
|
|
|
468
519
|
let result: ReactNode = errorBoundaryWrapped;
|
package/src/server/context.ts
CHANGED
|
@@ -150,6 +150,19 @@ export type InterceptWhenFn<TEnv = any> = (
|
|
|
150
150
|
ctx: InterceptSelectorContext<TEnv>,
|
|
151
151
|
) => boolean;
|
|
152
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Config object passed to intercept() (its 4th argument). `when` gates whether
|
|
155
|
+
* the intercept activates on a soft navigation — a single match-time selector or
|
|
156
|
+
* an array of them (ALL must return true; omit to always activate). This is the
|
|
157
|
+
* intercept counterpart to transition({ when }); both express conditional
|
|
158
|
+
* behavior as a config field rather than a separate DSL helper.
|
|
159
|
+
*
|
|
160
|
+
* @internal This type is an implementation detail and may change without notice.
|
|
161
|
+
*/
|
|
162
|
+
export interface InterceptConfig<TEnv = any> {
|
|
163
|
+
when?: InterceptWhenFn<TEnv> | InterceptWhenFn<TEnv>[];
|
|
164
|
+
}
|
|
165
|
+
|
|
153
166
|
/**
|
|
154
167
|
* Intercept entry stored in EntryData
|
|
155
168
|
* Contains the slot name, route to intercept, and handler
|
|
@@ -67,7 +67,19 @@ export async function getLoaderLazy(
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
//
|
|
70
|
+
// The remaining dev fallback (parse the id as "src/path/file.ts#ExportName"
|
|
71
|
+
// and import it by path) only makes sense in dev, where ids ARE file paths
|
|
72
|
+
// and the dev loader manifest is intentionally empty. In production ids are
|
|
73
|
+
// hashed ("<hash>#ExportName") and every resolvable loader is reached above
|
|
74
|
+
// via the in-memory registry or the lazy import manifest. The hash is not a
|
|
75
|
+
// path, so a production fall-through would run import("/<hash>") and throw a
|
|
76
|
+
// misleading "No such module <hash>" 500 instead of reporting the loader as
|
|
77
|
+
// unregistered. Return undefined in production so a genuinely unknown loader
|
|
78
|
+
// is a clean 404 "not found in registry" from handleLoaderFetch.
|
|
79
|
+
if (process.env.NODE_ENV === "production") {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
71
83
|
const hashIndex = id.indexOf("#");
|
|
72
84
|
if (hashIndex !== -1) {
|
|
73
85
|
const filePath = id.slice(0, hashIndex);
|
|
@@ -48,6 +48,7 @@ import { getFetchableLoader } from "./fetchable-loader-store.js";
|
|
|
48
48
|
import type { SegmentCacheStore } from "../cache/types.js";
|
|
49
49
|
import type { Theme, ResolvedThemeConfig } from "../theme/types.js";
|
|
50
50
|
import type { ExecutionContext, RequestScope } from "../types/request-scope.js";
|
|
51
|
+
import type { TransitionWhenFn } from "../types/segments.js";
|
|
51
52
|
import type { ResolvedTracing } from "../router/tracing.js";
|
|
52
53
|
import { fireAndForgetWaitUntil } from "../types/request-scope.js";
|
|
53
54
|
import {
|
|
@@ -161,6 +162,15 @@ export interface RequestContext<
|
|
|
161
162
|
/** @internal Handle store for tracking handle data across segments */
|
|
162
163
|
_handleStore: HandleStore;
|
|
163
164
|
|
|
165
|
+
/**
|
|
166
|
+
* @internal transition({ when }) predicates for segments matched this request,
|
|
167
|
+
* keyed by segment id. Collected during resolution (the function is stripped
|
|
168
|
+
* from the serialized segment config), then evaluated post-handler in
|
|
169
|
+
* rsc-rendering — outside any cache scope — to drop the transition of any
|
|
170
|
+
* segment whose predicate returns false.
|
|
171
|
+
*/
|
|
172
|
+
_transitionWhen?: Array<{ id: string; when: TransitionWhenFn }>;
|
|
173
|
+
|
|
164
174
|
/** @internal Cache store for segment caching (optional, used by CacheScope) */
|
|
165
175
|
_cacheStore?: SegmentCacheStore;
|
|
166
176
|
|
|
@@ -292,6 +302,30 @@ export interface RequestContext<
|
|
|
292
302
|
/** @internal Previous route key (from the navigation source), used for revalidation */
|
|
293
303
|
_prevRouteKey?: string;
|
|
294
304
|
|
|
305
|
+
/**
|
|
306
|
+
* @internal Navigation/action source data the transition({ when }) gate reads
|
|
307
|
+
* to build its ShouldRevalidateFn-shaped predicate context. currentUrl/Params
|
|
308
|
+
* come from the navigation snapshot (set at match time); action* are stashed
|
|
309
|
+
* at the action-bearing gate call sites. All undefined when there is no source
|
|
310
|
+
* (initial full load) or no action (plain navigation).
|
|
311
|
+
*/
|
|
312
|
+
_gateCurrentUrl?: URL;
|
|
313
|
+
_gateCurrentParams?: Record<string, string>;
|
|
314
|
+
_gateActionId?: string;
|
|
315
|
+
_gateActionUrl?: URL;
|
|
316
|
+
_gateActionResult?: unknown;
|
|
317
|
+
_gateFormData?: FormData;
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* @internal True while the post-action revalidation render is running (set by
|
|
321
|
+
* revalidateAfterAction). The "use cache" runtime reads this to prefer
|
|
322
|
+
* freshness over a fast stale response during an action: a stale entry
|
|
323
|
+
* re-executes in the foreground (so the action response reflects the refreshed
|
|
324
|
+
* value) with only the store write deferred, instead of serving stale and
|
|
325
|
+
* revalidating in the background. A plain navigation (flag unset) keeps SWR.
|
|
326
|
+
*/
|
|
327
|
+
_inActionRevalidation?: boolean;
|
|
328
|
+
|
|
295
329
|
/**
|
|
296
330
|
* @internal Render barrier for experimental `rendered()` API.
|
|
297
331
|
* Resolves when all non-loader segments have settled and handle data
|
|
@@ -413,6 +447,7 @@ export type PublicRequestContext<
|
|
|
413
447
|
| "setCookie"
|
|
414
448
|
| "deleteCookie"
|
|
415
449
|
| "_handleStore"
|
|
450
|
+
| "_transitionWhen"
|
|
416
451
|
| "_cacheStore"
|
|
417
452
|
| "_explicitTaggedStores"
|
|
418
453
|
| "_requestTags"
|
|
@@ -422,6 +457,13 @@ export type PublicRequestContext<
|
|
|
422
457
|
| "_locationState"
|
|
423
458
|
| "_routeName"
|
|
424
459
|
| "_prevRouteKey"
|
|
460
|
+
| "_gateCurrentUrl"
|
|
461
|
+
| "_gateCurrentParams"
|
|
462
|
+
| "_gateActionId"
|
|
463
|
+
| "_gateActionUrl"
|
|
464
|
+
| "_gateActionResult"
|
|
465
|
+
| "_gateFormData"
|
|
466
|
+
| "_inActionRevalidation"
|
|
425
467
|
| "_reportedErrors"
|
|
426
468
|
| "_renderBarrier"
|
|
427
469
|
| "_resolveRenderBarrier"
|
|
@@ -527,11 +569,17 @@ export function setRequestContextParams(
|
|
|
527
569
|
*/
|
|
528
570
|
export function setRequestContextPrevRouteKey(
|
|
529
571
|
prevRouteKey: string | undefined,
|
|
572
|
+
currentUrl?: URL,
|
|
573
|
+
currentParams?: Record<string, string>,
|
|
530
574
|
): void {
|
|
531
575
|
const ctx = requestContextStorage.getStore();
|
|
532
|
-
if (ctx
|
|
533
|
-
|
|
534
|
-
}
|
|
576
|
+
if (!ctx) return;
|
|
577
|
+
if (prevRouteKey !== undefined) ctx._prevRouteKey = prevRouteKey;
|
|
578
|
+
// Source URL/params for the transition({ when }) gate (effectiveFromUrl /
|
|
579
|
+
// effectiveFromMatch.params from the navigation snapshot). Same write point as
|
|
580
|
+
// _prevRouteKey, which doubles as fromRouteName.
|
|
581
|
+
if (currentUrl !== undefined) ctx._gateCurrentUrl = currentUrl;
|
|
582
|
+
if (currentParams !== undefined) ctx._gateCurrentParams = currentParams;
|
|
535
583
|
}
|
|
536
584
|
|
|
537
585
|
/**
|
|
@@ -842,6 +890,7 @@ export function createRequestContext<TEnv>(
|
|
|
842
890
|
method: request.method,
|
|
843
891
|
|
|
844
892
|
_handleStore: handleStore,
|
|
893
|
+
_transitionWhen: [],
|
|
845
894
|
_cacheStore: cacheStore,
|
|
846
895
|
_explicitTaggedStores: explicitTaggedStores,
|
|
847
896
|
_requestTags: new Set<string>(),
|
package/src/testing/index.ts
CHANGED
|
@@ -47,6 +47,12 @@ export type {
|
|
|
47
47
|
TestLoaderContext,
|
|
48
48
|
} from "./run-loader.js";
|
|
49
49
|
|
|
50
|
+
export { runTransitionWhen } from "./run-transition-when.js";
|
|
51
|
+
export type {
|
|
52
|
+
RunTransitionWhenOptions,
|
|
53
|
+
RunTransitionWhenResult,
|
|
54
|
+
} from "./run-transition-when.js";
|
|
55
|
+
|
|
50
56
|
export { dispatch } from "./dispatch.js";
|
|
51
57
|
export type { DispatchOptions } from "./dispatch.js";
|
|
52
58
|
|
|
@@ -116,6 +116,15 @@ export interface RenderHandlerOptions<TEnv = any> {
|
|
|
116
116
|
* `"use cache: profileName"` resolution once a `cacheStore` is wired.
|
|
117
117
|
*/
|
|
118
118
|
cacheProfiles?: Record<string, CacheProfile>;
|
|
119
|
+
/**
|
|
120
|
+
* Render as if inside a server action's revalidation render (production sets
|
|
121
|
+
* this in revalidateAfterAction). A stale `"use cache"` entry whose profile
|
|
122
|
+
* opts into `foregroundOnAction` then re-executes in the FOREGROUND (fresh
|
|
123
|
+
* result in this render) instead of being served stale + revalidated in the
|
|
124
|
+
* background. Without it, a stale entry keeps SWR. Pair with `cacheStore` +
|
|
125
|
+
* `cacheProfiles` to exercise the `foregroundOnAction` opt-in.
|
|
126
|
+
*/
|
|
127
|
+
inActionRevalidation?: boolean;
|
|
119
128
|
/**
|
|
120
129
|
* Theme config in the same shape `createRouter({ theme })` takes (e.g. `true`
|
|
121
130
|
* or `{ themes: [...] }`). Without it `ctx.theme`/`ctx.setTheme` are inert,
|
|
@@ -238,6 +247,11 @@ export async function renderHandler<TEnv = any>(
|
|
|
238
247
|
opts.theme === undefined ? undefined : resolveThemeConfig(opts.theme),
|
|
239
248
|
});
|
|
240
249
|
|
|
250
|
+
// Simulate an action revalidation render (production sets this in
|
|
251
|
+
// revalidateAfterAction) so a `foregroundOnAction` cache profile foregrounds a
|
|
252
|
+
// stale entry. See the foregroundOnAction option doc.
|
|
253
|
+
if (opts.inActionRevalidation) reqCtx._inActionRevalidation = true;
|
|
254
|
+
|
|
241
255
|
const loaderSeeds = new Map<unknown, unknown>(opts.loaders ?? []);
|
|
242
256
|
const handlePushes = new Map<Handle<any, any>, unknown[]>();
|
|
243
257
|
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runTransitionWhen — unit-test a transition({ when }) predicate in isolation.
|
|
3
|
+
*
|
|
4
|
+
* Runs the SAME two server functions the router uses — applyViewTransitionDefault
|
|
5
|
+
* (strips the `when` function from the serialized config and records the
|
|
6
|
+
* predicate on the request context) and gateTransitions (assembles the
|
|
7
|
+
* TransitionWhenContext and evaluates the predicate post-handler). So the
|
|
8
|
+
* predicate sees exactly the navigation/action metadata it would at runtime
|
|
9
|
+
* (currentUrl/currentParams/fromRouteName, nextUrl/nextParams/toRouteName,
|
|
10
|
+
* actionId/actionUrl/actionResult/formData/method, get/env), and `kept` reflects
|
|
11
|
+
* whether the transition would apply this request. The result also exposes the
|
|
12
|
+
* assembled `whenContext` so tests can assert the exact fields without reaching
|
|
13
|
+
* into private request-context state.
|
|
14
|
+
*
|
|
15
|
+
* This is the public way to exercise a transition gate: the full
|
|
16
|
+
* match -> render pipeline that wires these together only runs under real RSC
|
|
17
|
+
* rendering (which the Flight primitives do not drive), so without this primitive
|
|
18
|
+
* a consumer could not test their predicate through @rangojs/router/testing.
|
|
19
|
+
*
|
|
20
|
+
* Synchronous: a transition predicate returns a boolean and the gate has no I/O.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
runWithRequestContext,
|
|
25
|
+
type RequestContext,
|
|
26
|
+
} from "../server/request-context.js";
|
|
27
|
+
import { applyViewTransitionDefault } from "../router/segment-resolution/view-transition-default.js";
|
|
28
|
+
import { gateTransitions } from "../rsc/transition-gate.js";
|
|
29
|
+
import { createTestRequestContext, type VarsInit } from "./internal/context.js";
|
|
30
|
+
import type {
|
|
31
|
+
ResolvedSegment,
|
|
32
|
+
TransitionConfig,
|
|
33
|
+
TransitionWhenContext,
|
|
34
|
+
} from "../types/segments.js";
|
|
35
|
+
import type { OnErrorCallback } from "../types/error-types.js";
|
|
36
|
+
|
|
37
|
+
const toURL = (v: string | URL, base: URL): URL =>
|
|
38
|
+
typeof v === "string" ? new URL(v, base.origin) : v;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Options for runTransitionWhen. All navigation/action fields are optional and
|
|
42
|
+
* default to "absent", matching what the gate sees for an initial full load with
|
|
43
|
+
* no action: omit `currentUrl`/`currentParams`/`fromRouteName` to model the
|
|
44
|
+
* navigation source being unavailable, and omit the `action*` fields to model a
|
|
45
|
+
* plain (non-action) navigation.
|
|
46
|
+
*/
|
|
47
|
+
export interface RunTransitionWhenOptions<TEnv = any> {
|
|
48
|
+
/** The navigation TARGET request (drives `nextUrl`): a Request or URL/path string. Defaults to `http://localhost/`. */
|
|
49
|
+
request?: Request | string;
|
|
50
|
+
/** Route params for the target (`nextParams`). */
|
|
51
|
+
params?: Record<string, string>;
|
|
52
|
+
/** Target route name (`toRouteName`). */
|
|
53
|
+
toRouteName?: string;
|
|
54
|
+
/** Environment bindings surfaced as `env` (and `ctx.env`). */
|
|
55
|
+
env?: TEnv;
|
|
56
|
+
/** Variables a handler/middleware would have set this request, readable via the predicate's `get()`. */
|
|
57
|
+
vars?: VarsInit;
|
|
58
|
+
/** Navigation SOURCE url (`currentUrl`): a URL or path string. */
|
|
59
|
+
currentUrl?: string | URL;
|
|
60
|
+
/** Source route params (`currentParams`). */
|
|
61
|
+
currentParams?: Record<string, string>;
|
|
62
|
+
/** Source route name (`fromRouteName`). */
|
|
63
|
+
fromRouteName?: string;
|
|
64
|
+
/** Id of the action that triggered a revalidation (`actionId`). */
|
|
65
|
+
actionId?: string;
|
|
66
|
+
/** Url the action was submitted from (`actionUrl`). */
|
|
67
|
+
actionUrl?: string | URL;
|
|
68
|
+
/** The action's return value (`actionResult`). */
|
|
69
|
+
actionResult?: unknown;
|
|
70
|
+
/** FormData from a form action (`formData`). */
|
|
71
|
+
formData?: FormData;
|
|
72
|
+
/** Receives an error thrown by the predicate (the gate reports to `router.onError`, phase `"rendering"`). */
|
|
73
|
+
onError?: OnErrorCallback;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Result of runTransitionWhen.
|
|
78
|
+
*/
|
|
79
|
+
export interface RunTransitionWhenResult<TEnv = any> {
|
|
80
|
+
/** True if the transition would apply this request (predicate returned non-false, or there is no `when`). */
|
|
81
|
+
kept: boolean;
|
|
82
|
+
/** Convenience inverse of `kept`. */
|
|
83
|
+
dropped: boolean;
|
|
84
|
+
/**
|
|
85
|
+
* The production-assembled predicate context. Undefined when the config has
|
|
86
|
+
* no `when` predicate.
|
|
87
|
+
*/
|
|
88
|
+
whenContext?: TransitionWhenContext<Record<string, string>, TEnv>;
|
|
89
|
+
/** The underlying RequestContext, for additional assertions (`ctx.get(...)`, etc.). */
|
|
90
|
+
ctx: RequestContext<TEnv>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function runTransitionWhen<TEnv = any>(
|
|
94
|
+
config: TransitionConfig,
|
|
95
|
+
opts: RunTransitionWhenOptions<TEnv> = {},
|
|
96
|
+
): RunTransitionWhenResult<TEnv> {
|
|
97
|
+
const { ctx } = createTestRequestContext<TEnv>({
|
|
98
|
+
env: opts.env,
|
|
99
|
+
request: opts.request,
|
|
100
|
+
vars: opts.vars,
|
|
101
|
+
params: opts.params,
|
|
102
|
+
});
|
|
103
|
+
const reqCtx = ctx as unknown as RequestContext<TEnv>;
|
|
104
|
+
|
|
105
|
+
// Target route name (the public field the gate reads for `toRouteName`).
|
|
106
|
+
if (opts.toRouteName !== undefined)
|
|
107
|
+
reqCtx.routeName = opts.toRouteName as RequestContext<TEnv>["routeName"];
|
|
108
|
+
// Source (match-time) data the gate reads for currentUrl/currentParams/fromRouteName.
|
|
109
|
+
if (opts.currentUrl !== undefined)
|
|
110
|
+
reqCtx._gateCurrentUrl = toURL(opts.currentUrl, reqCtx.url);
|
|
111
|
+
if (opts.currentParams !== undefined)
|
|
112
|
+
reqCtx._gateCurrentParams = opts.currentParams;
|
|
113
|
+
if (opts.fromRouteName !== undefined)
|
|
114
|
+
reqCtx._prevRouteKey = opts.fromRouteName;
|
|
115
|
+
// Action data the gate reads at the action-bearing call sites.
|
|
116
|
+
if (opts.actionId !== undefined) reqCtx._gateActionId = opts.actionId;
|
|
117
|
+
if (opts.actionUrl !== undefined)
|
|
118
|
+
reqCtx._gateActionUrl = toURL(opts.actionUrl, reqCtx.url);
|
|
119
|
+
if (opts.actionResult !== undefined)
|
|
120
|
+
reqCtx._gateActionResult = opts.actionResult;
|
|
121
|
+
if (opts.formData !== undefined) reqCtx._gateFormData = opts.formData;
|
|
122
|
+
|
|
123
|
+
let whenContext:
|
|
124
|
+
| TransitionWhenContext<Record<string, string>, TEnv>
|
|
125
|
+
| undefined;
|
|
126
|
+
const when = config.when;
|
|
127
|
+
const configForGate: TransitionConfig = when
|
|
128
|
+
? {
|
|
129
|
+
...config,
|
|
130
|
+
when: (c) => {
|
|
131
|
+
whenContext = c as TransitionWhenContext<
|
|
132
|
+
Record<string, string>,
|
|
133
|
+
TEnv
|
|
134
|
+
>;
|
|
135
|
+
return when(c);
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
: config;
|
|
139
|
+
|
|
140
|
+
return runWithRequestContext(reqCtx, () => {
|
|
141
|
+
// The real resolution-time collection + post-handler gate, so the predicate
|
|
142
|
+
// sees the production-assembled TransitionWhenContext.
|
|
143
|
+
const serialized = applyViewTransitionDefault(
|
|
144
|
+
configForGate,
|
|
145
|
+
undefined,
|
|
146
|
+
"tx-when-seg",
|
|
147
|
+
);
|
|
148
|
+
const segment = {
|
|
149
|
+
id: "tx-when-seg",
|
|
150
|
+
namespace: "r",
|
|
151
|
+
type: "route",
|
|
152
|
+
index: 0,
|
|
153
|
+
component: null,
|
|
154
|
+
transition: serialized,
|
|
155
|
+
} as ResolvedSegment;
|
|
156
|
+
gateTransitions(
|
|
157
|
+
[segment],
|
|
158
|
+
reqCtx as Parameters<typeof gateTransitions>[1],
|
|
159
|
+
opts.onError,
|
|
160
|
+
);
|
|
161
|
+
const kept = segment.transition !== undefined;
|
|
162
|
+
return { kept, dropped: !kept, whenContext, ctx: reqCtx };
|
|
163
|
+
});
|
|
164
|
+
}
|
|
@@ -504,7 +504,7 @@ export type RevalidateParams<TParams = GenericParams, TEnv = any> = Parameters<
|
|
|
504
504
|
* @param args.context - App context (db, user, etc.)
|
|
505
505
|
* @param args.actionResult - Result from action (future support)
|
|
506
506
|
* @param args.formData - Form data from action (future support)
|
|
507
|
-
* @param args.
|
|
507
|
+
* @param args.method - HTTP method: "GET" for navigation, "POST" for server actions
|
|
508
508
|
*
|
|
509
509
|
* @returns Hard decision (boolean), soft suggestion (object), or defer
|
|
510
510
|
* (`void` / `null` / `undefined`) to keep the running suggestion as-is.
|
package/src/types/index.ts
CHANGED
package/src/types/segments.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
import type { ErrorInfo, NotFoundInfo } from "./boundaries.js";
|
|
3
|
+
import type { RevalidateParams, HandlerContext } from "./handler-context.js";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* CSS class(es) for a ViewTransition phase.
|
|
@@ -8,6 +9,96 @@ import type { ErrorInfo, NotFoundInfo } from "./boundaries.js";
|
|
|
8
9
|
*/
|
|
9
10
|
export type ViewTransitionClass = Record<string, string> | string;
|
|
10
11
|
|
|
12
|
+
/**
|
|
13
|
+
* The context a transition({ when }) predicate receives.
|
|
14
|
+
*
|
|
15
|
+
* It mirrors the {@link ShouldRevalidateFn} args a `revalidate()` predicate
|
|
16
|
+
* gets — the same navigation/action metadata — so the two read the same shape,
|
|
17
|
+
* plus `get`/`env` for post-handler reads. There is no full `HandlerContext`
|
|
18
|
+
* here: the gate runs at the RSC-payload layer with the request context, not a
|
|
19
|
+
* handler context, so handler-only sugar (`search`/`build`/`dev`/`headers`) is
|
|
20
|
+
* absent by design. `get` is the way to read what the handler/middleware set
|
|
21
|
+
* via `ctx.set(...)` this request.
|
|
22
|
+
*
|
|
23
|
+
* Field availability (all source fields are optional — never fabricated):
|
|
24
|
+
* - `currentUrl` / `currentParams` / `fromRouteName` (the navigation SOURCE) are
|
|
25
|
+
* populated on soft navigations and action-success revalidations. They are
|
|
26
|
+
* undefined on an initial full document load and on action-error / no-JS error
|
|
27
|
+
* paths that skip the navigation snapshot — there is no prior page to name.
|
|
28
|
+
* - `nextUrl` / `nextParams` / `get` / `env` / `method` are always present;
|
|
29
|
+
* `toRouteName` is present only when the target route is named (undefined for
|
|
30
|
+
* unnamed/auto-generated routes, like `fromRouteName`).
|
|
31
|
+
* - `actionId` / `actionUrl` / `actionResult` / `formData` are populated only
|
|
32
|
+
* when a server action triggered the render; `method` is "POST" then, "GET"
|
|
33
|
+
* otherwise. On no-JS (progressive-enhancement) action paths `actionId` may be
|
|
34
|
+
* undefined when React cannot surface the action's stable id: the success
|
|
35
|
+
* re-render still sets `actionUrl`/`formData` for a recognized action, but the
|
|
36
|
+
* error-boundary re-render exposes `actionUrl` only when `actionId` resolved.
|
|
37
|
+
* Malformed form bodies that fail before action detection expose no action
|
|
38
|
+
* fields. Treat `actionId` as "the action, if known", not as "was this an
|
|
39
|
+
* action".
|
|
40
|
+
*
|
|
41
|
+
* PREFETCH / CACHE CAVEAT (read this before gating on the source): the gate runs
|
|
42
|
+
* server-side during resolution. A PREFETCHED navigation renders at prefetch
|
|
43
|
+
* time, so `currentUrl`/`currentParams`/`fromRouteName` reflect the page the
|
|
44
|
+
* prefetch fired from, NOT necessarily the page the user actually navigates from
|
|
45
|
+
* — the decision is baked into the stored Flight payload and replayed verbatim.
|
|
46
|
+
* A `cache()`/prerender hit replays the stored transition with the predicate NOT
|
|
47
|
+
* re-run at all. So a source-sensitive predicate can be frozen to prefetch-time
|
|
48
|
+
* or store-time state. This is accepted (~99% of navigations match), but if your
|
|
49
|
+
* gate must reflect the exact click-time source, source-scope the prefetch
|
|
50
|
+
* (`<Link prefetchKey=":source">`) and do not `cache()` that segment.
|
|
51
|
+
*/
|
|
52
|
+
export type TransitionWhenContext<
|
|
53
|
+
TParams = Record<string, string>,
|
|
54
|
+
TEnv = unknown,
|
|
55
|
+
> = Partial<
|
|
56
|
+
Pick<
|
|
57
|
+
RevalidateParams<TParams, TEnv>,
|
|
58
|
+
"currentUrl" | "currentParams" | "fromRouteName"
|
|
59
|
+
>
|
|
60
|
+
> &
|
|
61
|
+
Pick<
|
|
62
|
+
RevalidateParams<TParams, TEnv>,
|
|
63
|
+
| "nextUrl"
|
|
64
|
+
| "nextParams"
|
|
65
|
+
| "toRouteName"
|
|
66
|
+
| "actionId"
|
|
67
|
+
| "actionUrl"
|
|
68
|
+
| "actionResult"
|
|
69
|
+
| "formData"
|
|
70
|
+
| "method"
|
|
71
|
+
> &
|
|
72
|
+
Pick<HandlerContext<any, TEnv>, "get" | "env">;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Predicate that gates whether a transition() applies for the current request.
|
|
76
|
+
*
|
|
77
|
+
* Evaluated server-side AFTER the route's handler runs (so `get(...)` can read
|
|
78
|
+
* handler/middleware-set state) and outside any cache scope. Return false to
|
|
79
|
+
* drop this segment's transition for the request; return true to apply it. The
|
|
80
|
+
* context ({@link TransitionWhenContext}) carries the same navigation/action
|
|
81
|
+
* metadata a `revalidate()` predicate sees plus `get`/`env`. If it throws, the
|
|
82
|
+
* error is reported to the router's onError (phase "rendering") and the
|
|
83
|
+
* transition is dropped (the navigation does not hold).
|
|
84
|
+
*
|
|
85
|
+
* Distinct from intercept()'s `when` config selector, which runs at MATCH time
|
|
86
|
+
* over `{ from, to, params, segments, … }`; a transition `when` runs
|
|
87
|
+
* post-handler over the resolved payload.
|
|
88
|
+
*
|
|
89
|
+
* Scope: dropping a transition removes only THIS segment's contribution to the
|
|
90
|
+
* navigation's hold. The startTransition hold is navigation-wide — it engages if
|
|
91
|
+
* any matched segment still has a transition — so `when: false` makes the
|
|
92
|
+
* navigation stream its loading fallback only when no other matched segment
|
|
93
|
+
* keeps a transition (the common case: a single transition on the route).
|
|
94
|
+
*
|
|
95
|
+
* Evaluated on every fresh (cache-miss) resolution; it is NOT re-run when a
|
|
96
|
+
* segment is replayed from the runtime cache or a build-time prerender, and a
|
|
97
|
+
* prefetched navigation freezes it to prefetch-time state — see the caveat on
|
|
98
|
+
* {@link TransitionWhenContext}.
|
|
99
|
+
*/
|
|
100
|
+
export type TransitionWhenFn = (ctx: TransitionWhenContext) => boolean;
|
|
101
|
+
|
|
11
102
|
/**
|
|
12
103
|
* Configuration for React's <ViewTransition> component.
|
|
13
104
|
*
|
|
@@ -36,6 +127,15 @@ export interface TransitionConfig {
|
|
|
36
127
|
* When unset, inherits the createRouter({ viewTransition }) default.
|
|
37
128
|
*/
|
|
38
129
|
viewTransition?: "auto" | false;
|
|
130
|
+
/**
|
|
131
|
+
* Optional server-side predicate that gates this transition per request. When
|
|
132
|
+
* present and it returns false (evaluated post-handler), the router drops this
|
|
133
|
+
* segment's transition for the request, so the navigation streams its loading
|
|
134
|
+
* fallback instead of holding. The predicate is server-only and never
|
|
135
|
+
* serialized to the client; only its resolved effect (transition kept or
|
|
136
|
+
* dropped) crosses. See {@link TransitionWhenFn}.
|
|
137
|
+
*/
|
|
138
|
+
when?: TransitionWhenFn;
|
|
39
139
|
}
|
|
40
140
|
|
|
41
141
|
/**
|
|
@@ -28,7 +28,6 @@ import type {
|
|
|
28
28
|
ParallelUseItem,
|
|
29
29
|
InterceptUseItem,
|
|
30
30
|
LoaderUseItem,
|
|
31
|
-
WhenItem,
|
|
32
31
|
TypedCacheItem,
|
|
33
32
|
TransitionItem,
|
|
34
33
|
TypedTransitionItem,
|
|
@@ -42,7 +41,6 @@ import type {
|
|
|
42
41
|
PassthroughHandlerDefinition,
|
|
43
42
|
} from "../prerender.js";
|
|
44
43
|
import type { StaticHandlerDefinition } from "../static-handler.js";
|
|
45
|
-
import type { InterceptWhenFn } from "../server/context";
|
|
46
44
|
import type {
|
|
47
45
|
ResponseHandler,
|
|
48
46
|
ResponseHandlerContext,
|
|
@@ -275,12 +273,18 @@ export type PathHelpers<TEnv> = {
|
|
|
275
273
|
slotName: `@${string}`,
|
|
276
274
|
routeName: string,
|
|
277
275
|
handler: ReactNode | Handler<any, any, TEnv>,
|
|
276
|
+
config?:
|
|
277
|
+
| import("../server/context.js").InterceptConfig<TEnv>
|
|
278
|
+
| (() => InterceptUseItem[]),
|
|
278
279
|
use?: () => InterceptUseItem[],
|
|
279
280
|
) => InterceptItem
|
|
280
281
|
: (
|
|
281
282
|
slotName: `@${string}`,
|
|
282
283
|
routeName: (keyof Rango.GeneratedRouteMap & string) | `.${string}`,
|
|
283
284
|
handler: ReactNode | Handler<any, any, TEnv>,
|
|
285
|
+
config?:
|
|
286
|
+
| import("../server/context.js").InterceptConfig<TEnv>
|
|
287
|
+
| (() => InterceptUseItem[]),
|
|
284
288
|
use?: () => InterceptUseItem[],
|
|
285
289
|
) => InterceptItem;
|
|
286
290
|
|
|
@@ -335,11 +339,6 @@ export type PathHelpers<TEnv> = {
|
|
|
335
339
|
fallback: ReactNode | NotFoundBoundaryHandler,
|
|
336
340
|
) => NotFoundBoundaryItem;
|
|
337
341
|
|
|
338
|
-
/**
|
|
339
|
-
* Define a condition for when an intercept should activate
|
|
340
|
-
*/
|
|
341
|
-
when: (fn: InterceptWhenFn) => WhenItem;
|
|
342
|
-
|
|
343
342
|
/**
|
|
344
343
|
* Define cache configuration for segments
|
|
345
344
|
*/
|
|
@@ -365,6 +364,10 @@ export type PathHelpers<TEnv> = {
|
|
|
365
364
|
* `{ viewTransition: false }` to keep #1 without the router boundary. A view
|
|
366
365
|
* transition cannot fire without a startTransition. See
|
|
367
366
|
* skills/view-transitions for the startTransition x ViewTransition matrix.
|
|
367
|
+
*
|
|
368
|
+
* Pass `when: (ctx) => boolean` to gate the transition per request: it runs
|
|
369
|
+
* server-side after the route handler (can read `ctx.get(...)`), and returning
|
|
370
|
+
* false drops the transition so the navigation streams its loading() skeleton.
|
|
368
371
|
*/
|
|
369
372
|
transition: {
|
|
370
373
|
(): TransitionItem;
|
|
@@ -58,7 +58,6 @@ export function urls<
|
|
|
58
58
|
loading: baseHelpers.loading,
|
|
59
59
|
errorBoundary: baseHelpers.errorBoundary,
|
|
60
60
|
notFoundBoundary: baseHelpers.notFoundBoundary,
|
|
61
|
-
when: baseHelpers.when,
|
|
62
61
|
cache: baseHelpers.cache as PathHelpers<TEnv>["cache"],
|
|
63
62
|
transition: baseHelpers.transition as PathHelpers<TEnv>["transition"],
|
|
64
63
|
};
|