@rangojs/router 0.0.0-experimental.112 → 0.0.0-experimental.114
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 +74 -3
- package/dist/vite/index.js +133 -18
- package/package.json +1 -1
- package/skills/cache-guide/SKILL.md +35 -24
- package/skills/caching/SKILL.md +115 -7
- package/skills/document-cache/SKILL.md +78 -55
- package/skills/hooks/SKILL.md +40 -22
- package/skills/links/SKILL.md +10 -10
- package/skills/loader/SKILL.md +3 -3
- package/skills/rango/SKILL.md +16 -10
- package/skills/react-compiler/SKILL.md +168 -0
- package/skills/use-cache/SKILL.md +34 -5
- package/skills/view-transitions/SKILL.md +85 -3
- package/src/browser/react/location-state-shared.ts +93 -3
- package/src/browser/react/use-reverse.ts +19 -12
- package/src/build/route-types/per-module-writer.ts +4 -1
- package/src/build/route-types/router-processing.ts +14 -1
- package/src/build/route-types/source-scan.ts +118 -0
- package/src/cache/cache-scope.ts +28 -42
- package/src/cache/cf/cf-cache-store.ts +49 -6
- package/src/handle.ts +3 -5
- package/src/loader-store.ts +62 -25
- package/src/loader.rsc.ts +2 -5
- package/src/loader.ts +3 -10
- package/src/missing-id-error.ts +68 -0
- package/src/reverse.ts +16 -13
- package/src/route-definition/dsl-helpers.ts +5 -2
- package/src/route-definition/helpers-types.ts +31 -10
- package/src/router/loader-resolution.ts +16 -2
- package/src/router/match-middleware/cache-lookup.ts +44 -91
- package/src/router/match-middleware/cache-store.ts +3 -2
- package/src/router/router-options.ts +24 -0
- package/src/router/segment-resolution/fresh.ts +17 -4
- package/src/router/segment-resolution/revalidation.ts +17 -4
- package/src/router/segment-resolution/view-transition-default.ts +36 -0
- package/src/router/types.ts +8 -0
- package/src/router.ts +2 -0
- package/src/segment-system.tsx +59 -10
- package/src/server/context.ts +26 -0
- package/src/server/cookie-store.ts +28 -4
- package/src/types/handler-context.ts +5 -2
- package/src/types/segments.ts +18 -1
- package/src/urls/path-helper-types.ts +9 -1
- package/src/use-loader.tsx +89 -42
- package/src/vite/plugins/expose-ids/export-analysis.ts +68 -12
- package/src/vite/plugins/expose-internal-ids.ts +12 -4
- package/src/vite/plugins/use-cache-transform.ts +12 -10
- package/src/vite/router-discovery.ts +14 -2
|
@@ -282,6 +282,38 @@ async function* yieldFromStore<TEnv>(
|
|
|
282
282
|
}
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
+
/**
|
|
286
|
+
* Look up a prerendered (build-time cached) entry for the current route and, on
|
|
287
|
+
* a hit, yield its segments. Returns true when an entry was served (the caller
|
|
288
|
+
* should stop the pipeline) and false on a miss. Intercept navigations consult
|
|
289
|
+
* only the intercept-specific entry (`paramHash + "/i"`); a miss there falls
|
|
290
|
+
* through to the normal pipeline so intercept-resolution can run. Callers must
|
|
291
|
+
* guard on `prerenderStoreInstance` after `ensurePrerenderDeps()`.
|
|
292
|
+
*/
|
|
293
|
+
async function* tryPrerenderLookup<TEnv>(
|
|
294
|
+
ctx: MatchContext<TEnv>,
|
|
295
|
+
state: MatchPipelineState,
|
|
296
|
+
pipelineStart: number,
|
|
297
|
+
handleStoreRef?: HandleStore,
|
|
298
|
+
): AsyncGenerator<ResolvedSegment, boolean> {
|
|
299
|
+
const paramHash = _hashParams!(ctx.matched.params);
|
|
300
|
+
const isPassthroughPrerenderRoute = ctx.entries.some(
|
|
301
|
+
(entry) => entry.type === "route" && entry.isPassthrough === true,
|
|
302
|
+
);
|
|
303
|
+
const lookupHash = ctx.isIntercept ? paramHash + "/i" : paramHash;
|
|
304
|
+
const entry = await prerenderStoreInstance!.get(
|
|
305
|
+
ctx.matched.routeKey,
|
|
306
|
+
lookupHash,
|
|
307
|
+
{
|
|
308
|
+
pathname: ctx.pathname,
|
|
309
|
+
isPassthroughRoute: isPassthroughPrerenderRoute,
|
|
310
|
+
},
|
|
311
|
+
);
|
|
312
|
+
if (!entry) return false;
|
|
313
|
+
yield* yieldFromStore(entry, ctx, state, pipelineStart, handleStoreRef);
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
|
|
285
317
|
/**
|
|
286
318
|
* Async generator middleware type
|
|
287
319
|
*/
|
|
@@ -334,54 +366,13 @@ export function withCacheLookup<TEnv>(
|
|
|
334
366
|
if (!ctx.isAction && !isHmr && ctx.matched.pr) {
|
|
335
367
|
await ensurePrerenderDeps();
|
|
336
368
|
if (prerenderStoreInstance) {
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
369
|
+
const served = yield* tryPrerenderLookup(
|
|
370
|
+
ctx,
|
|
371
|
+
state,
|
|
372
|
+
pipelineStart,
|
|
373
|
+
handleStoreRef,
|
|
340
374
|
);
|
|
341
|
-
|
|
342
|
-
if (ctx.isIntercept) {
|
|
343
|
-
// Intercept navigation: try intercept-specific prerender entry
|
|
344
|
-
const entry = await prerenderStoreInstance.get(
|
|
345
|
-
ctx.matched.routeKey,
|
|
346
|
-
paramHash + "/i",
|
|
347
|
-
{
|
|
348
|
-
pathname: ctx.pathname,
|
|
349
|
-
isPassthroughRoute: isPassthroughPrerenderRoute,
|
|
350
|
-
},
|
|
351
|
-
);
|
|
352
|
-
if (entry) {
|
|
353
|
-
yield* yieldFromStore(
|
|
354
|
-
entry,
|
|
355
|
-
ctx,
|
|
356
|
-
state,
|
|
357
|
-
pipelineStart,
|
|
358
|
-
handleStoreRef,
|
|
359
|
-
);
|
|
360
|
-
return;
|
|
361
|
-
}
|
|
362
|
-
// No intercept prerender -- fall through to normal pipeline
|
|
363
|
-
// (skip non-intercept prerender to let intercept-resolution run)
|
|
364
|
-
} else {
|
|
365
|
-
// Normal navigation: existing behavior
|
|
366
|
-
const entry = await prerenderStoreInstance.get(
|
|
367
|
-
ctx.matched.routeKey,
|
|
368
|
-
paramHash,
|
|
369
|
-
{
|
|
370
|
-
pathname: ctx.pathname,
|
|
371
|
-
isPassthroughRoute: isPassthroughPrerenderRoute,
|
|
372
|
-
},
|
|
373
|
-
);
|
|
374
|
-
if (entry) {
|
|
375
|
-
yield* yieldFromStore(
|
|
376
|
-
entry,
|
|
377
|
-
ctx,
|
|
378
|
-
state,
|
|
379
|
-
pipelineStart,
|
|
380
|
-
handleStoreRef,
|
|
381
|
-
);
|
|
382
|
-
return;
|
|
383
|
-
}
|
|
384
|
-
}
|
|
375
|
+
if (served) return;
|
|
385
376
|
}
|
|
386
377
|
}
|
|
387
378
|
|
|
@@ -404,51 +395,13 @@ export function withCacheLookup<TEnv>(
|
|
|
404
395
|
if (hasStatic) {
|
|
405
396
|
await ensurePrerenderDeps();
|
|
406
397
|
if (prerenderStoreInstance) {
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
|
|
398
|
+
const served = yield* tryPrerenderLookup(
|
|
399
|
+
ctx,
|
|
400
|
+
state,
|
|
401
|
+
pipelineStart,
|
|
402
|
+
handleStoreRef,
|
|
410
403
|
);
|
|
411
|
-
|
|
412
|
-
if (ctx.isIntercept) {
|
|
413
|
-
const entry = await prerenderStoreInstance.get(
|
|
414
|
-
ctx.matched.routeKey,
|
|
415
|
-
paramHash + "/i",
|
|
416
|
-
{
|
|
417
|
-
pathname: ctx.pathname,
|
|
418
|
-
isPassthroughRoute: isPassthroughPrerenderRoute,
|
|
419
|
-
},
|
|
420
|
-
);
|
|
421
|
-
if (entry) {
|
|
422
|
-
yield* yieldFromStore(
|
|
423
|
-
entry,
|
|
424
|
-
ctx,
|
|
425
|
-
state,
|
|
426
|
-
pipelineStart,
|
|
427
|
-
handleStoreRef,
|
|
428
|
-
);
|
|
429
|
-
return;
|
|
430
|
-
}
|
|
431
|
-
// No intercept prerender -- fall through to normal pipeline
|
|
432
|
-
} else {
|
|
433
|
-
const entry = await prerenderStoreInstance.get(
|
|
434
|
-
ctx.matched.routeKey,
|
|
435
|
-
paramHash,
|
|
436
|
-
{
|
|
437
|
-
pathname: ctx.pathname,
|
|
438
|
-
isPassthroughRoute: isPassthroughPrerenderRoute,
|
|
439
|
-
},
|
|
440
|
-
);
|
|
441
|
-
if (entry) {
|
|
442
|
-
yield* yieldFromStore(
|
|
443
|
-
entry,
|
|
444
|
-
ctx,
|
|
445
|
-
state,
|
|
446
|
-
pipelineStart,
|
|
447
|
-
handleStoreRef,
|
|
448
|
-
);
|
|
449
|
-
return;
|
|
450
|
-
}
|
|
451
|
-
}
|
|
404
|
+
if (served) return;
|
|
452
405
|
}
|
|
453
406
|
}
|
|
454
407
|
}
|
|
@@ -169,10 +169,11 @@ export function withCacheStore<TEnv>(
|
|
|
169
169
|
// skip (client already had them). Segments where the handler intentionally
|
|
170
170
|
// returned null are not revalidation skips — re-rendering them will still
|
|
171
171
|
// produce null, so proactive caching would be wasted work.
|
|
172
|
-
const clientIdSet = new Set(ctx.clientSegmentIds);
|
|
173
172
|
const hasNullComponents = allSegmentsToCache.some(
|
|
174
173
|
(s) =>
|
|
175
|
-
s.component === null &&
|
|
174
|
+
s.component === null &&
|
|
175
|
+
s.type !== "loader" &&
|
|
176
|
+
ctx.clientSegmentSet.has(s.id),
|
|
176
177
|
);
|
|
177
178
|
|
|
178
179
|
const requestCtx = getRequestContext();
|
|
@@ -357,6 +357,30 @@ export interface RangoOptions<TEnv = any> {
|
|
|
357
357
|
*/
|
|
358
358
|
theme?: import("../theme/types.js").ThemeConfig | true;
|
|
359
359
|
|
|
360
|
+
/**
|
|
361
|
+
* Default for whether the router wraps `transition()` segments in its own
|
|
362
|
+
* React `<ViewTransition>` boundary (experimental React only).
|
|
363
|
+
*
|
|
364
|
+
* - "auto" (default): every route/layout that opts in via `transition()`
|
|
365
|
+
* gets a router-owned cross-fade.
|
|
366
|
+
* - false: the router never places its own boundary. Routes that use
|
|
367
|
+
* `transition()` still drive navigation through startTransition (so loaders
|
|
368
|
+
* hold instead of flashing a skeleton) and still let consumer-placed
|
|
369
|
+
* `<ViewTransition>` elements animate — the router just contributes no
|
|
370
|
+
* cross-fade of its own. This is the "router triggers, you place the
|
|
371
|
+
* transitions" model.
|
|
372
|
+
*
|
|
373
|
+
* A per-segment `transition({ viewTransition })` overrides this default.
|
|
374
|
+
*
|
|
375
|
+
* @example
|
|
376
|
+
* ```typescript
|
|
377
|
+
* // App-wide: drive + hold, but never auto-wrap. Place <ViewTransition>
|
|
378
|
+
* // yourself in components where you want a morph.
|
|
379
|
+
* const router = createRouter<AppEnv>({ viewTransition: false });
|
|
380
|
+
* ```
|
|
381
|
+
*/
|
|
382
|
+
viewTransition?: "auto" | false;
|
|
383
|
+
|
|
360
384
|
/**
|
|
361
385
|
* URL patterns to register with the router.
|
|
362
386
|
*
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
resolveLayoutComponent,
|
|
29
29
|
resolveWithErrorBoundary,
|
|
30
30
|
} from "./helpers.js";
|
|
31
|
+
import { applyViewTransitionDefault } from "./view-transition-default.js";
|
|
31
32
|
import { getRouterContext } from "../router-context.js";
|
|
32
33
|
import { resolveSink, safeEmit } from "../telemetry.js";
|
|
33
34
|
import {
|
|
@@ -224,7 +225,10 @@ export async function resolveSegment<TEnv>(
|
|
|
224
225
|
index: 0,
|
|
225
226
|
component,
|
|
226
227
|
loading: entry.loading === false ? null : entry.loading,
|
|
227
|
-
transition:
|
|
228
|
+
transition: applyViewTransitionDefault(
|
|
229
|
+
entry.transition,
|
|
230
|
+
deps.viewTransitionDefault,
|
|
231
|
+
),
|
|
228
232
|
params,
|
|
229
233
|
belongsToRoute: false,
|
|
230
234
|
layoutName: entry.id,
|
|
@@ -359,7 +363,10 @@ export async function resolveSegment<TEnv>(
|
|
|
359
363
|
index: 0,
|
|
360
364
|
component: component ?? null,
|
|
361
365
|
loading: entry.loading === false ? null : entry.loading,
|
|
362
|
-
transition:
|
|
366
|
+
transition: applyViewTransitionDefault(
|
|
367
|
+
entry.transition,
|
|
368
|
+
deps.viewTransitionDefault,
|
|
369
|
+
),
|
|
363
370
|
params,
|
|
364
371
|
belongsToRoute: true,
|
|
365
372
|
...(entry.mountPath ? { mountPath: entry.mountPath } : {}),
|
|
@@ -443,7 +450,10 @@ export async function resolveOrphanLayout<TEnv>(
|
|
|
443
450
|
belongsToRoute,
|
|
444
451
|
layoutName: orphan.id,
|
|
445
452
|
loading: orphan.loading === false ? null : orphan.loading,
|
|
446
|
-
transition:
|
|
453
|
+
transition: applyViewTransitionDefault(
|
|
454
|
+
orphan.transition,
|
|
455
|
+
deps.viewTransitionDefault,
|
|
456
|
+
),
|
|
447
457
|
...(orphan.mountPath ? { mountPath: orphan.mountPath } : {}),
|
|
448
458
|
});
|
|
449
459
|
|
|
@@ -565,7 +575,10 @@ export async function resolveParallelEntry<TEnv>(
|
|
|
565
575
|
index: 0,
|
|
566
576
|
component,
|
|
567
577
|
loading: parallelEntry.loading === false ? null : parallelEntry.loading,
|
|
568
|
-
transition:
|
|
578
|
+
transition: applyViewTransitionDefault(
|
|
579
|
+
parallelEntry.transition,
|
|
580
|
+
deps.viewTransitionDefault,
|
|
581
|
+
),
|
|
569
582
|
params,
|
|
570
583
|
slot,
|
|
571
584
|
belongsToRoute,
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
resolveLayoutComponent,
|
|
40
40
|
resolveWithErrorBoundary,
|
|
41
41
|
} from "./helpers.js";
|
|
42
|
+
import { applyViewTransitionDefault } from "./view-transition-default.js";
|
|
42
43
|
import { getRouterContext } from "../router-context.js";
|
|
43
44
|
import { resolveSink, safeEmit } from "../telemetry.js";
|
|
44
45
|
import {
|
|
@@ -593,7 +594,10 @@ export async function resolveParallelSegmentsWithRevalidation<TEnv>(
|
|
|
593
594
|
index: 0,
|
|
594
595
|
component,
|
|
595
596
|
loading: parallelEntry.loading === false ? null : parallelEntry.loading,
|
|
596
|
-
transition:
|
|
597
|
+
transition: applyViewTransitionDefault(
|
|
598
|
+
parallelEntry.transition,
|
|
599
|
+
deps.viewTransitionDefault,
|
|
600
|
+
),
|
|
597
601
|
params,
|
|
598
602
|
slot,
|
|
599
603
|
_handlerRan: handlerRan,
|
|
@@ -803,7 +807,10 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
|
|
|
803
807
|
index: 0,
|
|
804
808
|
component: resolvedComponent,
|
|
805
809
|
loading: entry.loading === false ? null : entry.loading,
|
|
806
|
-
transition:
|
|
810
|
+
transition: applyViewTransitionDefault(
|
|
811
|
+
entry.transition,
|
|
812
|
+
deps.viewTransitionDefault,
|
|
813
|
+
),
|
|
807
814
|
params,
|
|
808
815
|
belongsToRoute,
|
|
809
816
|
...(entry.type === "layout" || entry.type === "cache"
|
|
@@ -1137,7 +1144,10 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
|
|
|
1137
1144
|
belongsToRoute,
|
|
1138
1145
|
layoutName: orphan.id,
|
|
1139
1146
|
loading: orphan.loading === false ? null : orphan.loading,
|
|
1140
|
-
transition:
|
|
1147
|
+
transition: applyViewTransitionDefault(
|
|
1148
|
+
orphan.transition,
|
|
1149
|
+
deps.viewTransitionDefault,
|
|
1150
|
+
),
|
|
1141
1151
|
...(orphan.mountPath ? { mountPath: orphan.mountPath } : {}),
|
|
1142
1152
|
});
|
|
1143
1153
|
|
|
@@ -1294,7 +1304,10 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
|
|
|
1294
1304
|
index: 0,
|
|
1295
1305
|
component,
|
|
1296
1306
|
loading: parallelEntry.loading === false ? null : parallelEntry.loading,
|
|
1297
|
-
transition:
|
|
1307
|
+
transition: applyViewTransitionDefault(
|
|
1308
|
+
parallelEntry.transition,
|
|
1309
|
+
deps.viewTransitionDefault,
|
|
1310
|
+
),
|
|
1298
1311
|
params,
|
|
1299
1312
|
slot,
|
|
1300
1313
|
_handlerRan: handlerRan,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* View-transition boundary default resolution.
|
|
3
|
+
*
|
|
4
|
+
* Kept in its own module (rather than helpers.ts) because several resolution
|
|
5
|
+
* tests mock helpers.ts with an explicit export list; a shared util here is
|
|
6
|
+
* never mocked, so the fresh and revalidation paths always get the real
|
|
7
|
+
* implementation.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { EntryData } from "../../server/context";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolve the effective `viewTransition` for a segment's transition config.
|
|
14
|
+
*
|
|
15
|
+
* The per-segment value (set via the transition() DSL) always wins. When it is
|
|
16
|
+
* unset, the router-level createRouter({ viewTransition }) default is stamped
|
|
17
|
+
* in so the render gate reads the boundary decision off the segment — server
|
|
18
|
+
* and client, via the serialized segment — without the router option being
|
|
19
|
+
* threaded to the client. Only `false` is ever stamped; an unset (or "auto")
|
|
20
|
+
* value is left untouched because it already means "wrap" at the gate, which
|
|
21
|
+
* also avoids needless object allocation and payload growth. Used by both the
|
|
22
|
+
* fresh and revalidation resolution paths.
|
|
23
|
+
*/
|
|
24
|
+
export function applyViewTransitionDefault(
|
|
25
|
+
transition: EntryData["transition"],
|
|
26
|
+
viewTransitionDefault: "auto" | false | undefined,
|
|
27
|
+
): EntryData["transition"] {
|
|
28
|
+
if (!transition) return transition;
|
|
29
|
+
if (
|
|
30
|
+
transition.viewTransition === undefined &&
|
|
31
|
+
viewTransitionDefault === false
|
|
32
|
+
) {
|
|
33
|
+
return { ...transition, viewTransition: false };
|
|
34
|
+
}
|
|
35
|
+
return transition;
|
|
36
|
+
}
|
package/src/router/types.ts
CHANGED
|
@@ -98,6 +98,14 @@ export interface SegmentResolutionDeps<TEnv = any> {
|
|
|
98
98
|
) => ReactNode | NotFoundBoundaryHandler | null;
|
|
99
99
|
notFoundComponent?: ReactNode | ((props: { pathname: string }) => ReactNode);
|
|
100
100
|
callOnError: (error: unknown, phase: ErrorPhase, context: any) => void;
|
|
101
|
+
/**
|
|
102
|
+
* Router-level default for the per-segment `transition({ viewTransition })`
|
|
103
|
+
* flag, from createRouter({ viewTransition }). Resolved into each segment's
|
|
104
|
+
* transition config during resolution (only `false` is stamped) so the render
|
|
105
|
+
* gate reads the boundary decision off the segment on both server and client.
|
|
106
|
+
* Undefined is treated as "auto" (wrap).
|
|
107
|
+
*/
|
|
108
|
+
viewTransitionDefault?: "auto" | false;
|
|
101
109
|
}
|
|
102
110
|
|
|
103
111
|
/**
|
package/src/router.ts
CHANGED
|
@@ -155,6 +155,7 @@ export function createRouter<TEnv = any>(
|
|
|
155
155
|
timeouts: timeoutsOption,
|
|
156
156
|
onTimeout,
|
|
157
157
|
originCheck: originCheckOption,
|
|
158
|
+
viewTransition: viewTransitionOption = "auto",
|
|
158
159
|
} = options;
|
|
159
160
|
|
|
160
161
|
// Normalize basename: ensure leading slash, strip trailing slash.
|
|
@@ -534,6 +535,7 @@ export function createRouter<TEnv = any>(
|
|
|
534
535
|
findNearestNotFoundBoundary,
|
|
535
536
|
notFoundComponent: notFound,
|
|
536
537
|
callOnError,
|
|
538
|
+
viewTransitionDefault: viewTransitionOption,
|
|
537
539
|
};
|
|
538
540
|
|
|
539
541
|
// Match API dependencies
|
package/src/segment-system.tsx
CHANGED
|
@@ -99,8 +99,11 @@ function createViewTransitionBoundary(
|
|
|
99
99
|
transition: NonNullable<ResolvedSegment["transition"]>,
|
|
100
100
|
children: ReactNode,
|
|
101
101
|
): ReactNode {
|
|
102
|
+
// `viewTransition` is a router-specific flag (boundary opt-out), not a React
|
|
103
|
+
// <ViewTransition> prop — strip it so it never reaches React.
|
|
104
|
+
const { viewTransition: _viewTransition, ...vtProps } = transition;
|
|
102
105
|
return createElement(ReactViewTransition, {
|
|
103
|
-
...
|
|
106
|
+
...vtProps,
|
|
104
107
|
children,
|
|
105
108
|
});
|
|
106
109
|
}
|
|
@@ -216,6 +219,25 @@ export async function renderSegments(
|
|
|
216
219
|
}
|
|
217
220
|
// Separate segments by type, passing intercept segments for explicit injection
|
|
218
221
|
const tree = segmentTreeWalk(normalizedSegments, normalizedInterceptSegments);
|
|
222
|
+
|
|
223
|
+
// A route is "in a transition scope" when its own segment OR any layout in
|
|
224
|
+
// its matched chain declares transition(). Both transition() forms land here:
|
|
225
|
+
// the per-route item form sets transition on the route entry, and the block
|
|
226
|
+
// wrapper form sets it on a transparent ancestor layout (dsl-helpers.ts). When
|
|
227
|
+
// in scope, the route and its route-owned layouts use param-agnostic keys so a
|
|
228
|
+
// same-route navigation reconciles (holds content) instead of remounting. The
|
|
229
|
+
// value is a static property of the route's position in the tree, so it is the
|
|
230
|
+
// same on every render of that route (SSR, navigation, action) — the keys
|
|
231
|
+
// never drift. Cross-route navigation still remounts: different routes have
|
|
232
|
+
// different segment ids regardless of transition scope.
|
|
233
|
+
const inTransitionScope = normalizedSegments.some(
|
|
234
|
+
(s) =>
|
|
235
|
+
s.transition != null &&
|
|
236
|
+
(s.type === "layout" ||
|
|
237
|
+
s.type === "route" ||
|
|
238
|
+
s.type === "error" ||
|
|
239
|
+
s.type === "notFound"),
|
|
240
|
+
);
|
|
219
241
|
// Render content segments as siblings
|
|
220
242
|
let content: ReactNode = null;
|
|
221
243
|
for (const node of tree) {
|
|
@@ -228,17 +250,31 @@ export async function renderSegments(
|
|
|
228
250
|
);
|
|
229
251
|
const { component, id, params, loading } = node.segment;
|
|
230
252
|
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
253
|
+
// Param-agnostic keys are opt-in via the transition() DSL (see
|
|
254
|
+
// inTransitionScope above). A route (and its route-owned layouts) inside a
|
|
255
|
+
// transition scope drops the param from its key, so navigating between two
|
|
256
|
+
// param values of the SAME route (e.g. /product/1 -> /product/2) reconciles
|
|
257
|
+
// the route subtree instead of remounting it. Combined with the
|
|
258
|
+
// startTransition wrap that shouldStartViewTransition already applies to
|
|
259
|
+
// transition routes (browser/partial-update.ts), the previous content stays
|
|
260
|
+
// on screen while the new loaders resolve (stale-while-revalidate) instead
|
|
261
|
+
// of flashing the loading skeleton. This works on stable React; experimental
|
|
262
|
+
// React adds the animated <ViewTransition> cross-fade on top.
|
|
263
|
+
//
|
|
264
|
+
// Outside a transition scope the key stays param-bearing and the route
|
|
265
|
+
// remounts on param change (the default: a fresh skeleton and fresh
|
|
266
|
+
// component state).
|
|
267
|
+
//
|
|
268
|
+
// error/notFound always keep param-bearing keys: createErrorSegment reuses
|
|
269
|
+
// the boundary layout's shortCode as the error segment id (router/
|
|
270
|
+
// error-handling.ts), so a param-agnostic error key could collide with that
|
|
271
|
+
// layout's key within the same render.
|
|
237
272
|
const includeParams =
|
|
238
|
-
node.segment.type === "route" ||
|
|
239
273
|
node.segment.type === "error" ||
|
|
240
274
|
node.segment.type === "notFound" ||
|
|
241
|
-
(node.segment.type === "
|
|
275
|
+
((node.segment.type === "route" ||
|
|
276
|
+
(node.segment.type === "layout" && node.segment.belongsToRoute)) &&
|
|
277
|
+
!inTransitionScope);
|
|
242
278
|
|
|
243
279
|
const paramStr =
|
|
244
280
|
includeParams && params && Object.keys(params).length > 0
|
|
@@ -286,12 +322,25 @@ export async function renderSegments(
|
|
|
286
322
|
// subtree update on the layout-level VT — which would otherwise make
|
|
287
323
|
// React's commit walker fire `document.startViewTransition` and apply
|
|
288
324
|
// view-transition-names to the underlying main subtree (cover/title/etc.).
|
|
325
|
+
//
|
|
326
|
+
// `transition.viewTransition === false` opts out of the router-owned
|
|
327
|
+
// boundary only. Driving (the startTransition wrap in browser/partial-update.ts
|
|
328
|
+
// and the param-agnostic key/hold below) keys off transition *presence*, not
|
|
329
|
+
// this flag, so a boundary-less transition still holds content and lets
|
|
330
|
+
// consumer-placed <ViewTransition> elements animate. The global
|
|
331
|
+
// createRouter({ viewTransition }) default is resolved into this field
|
|
332
|
+
// during segment resolution (only `false` is stamped; unset/"auto" is left
|
|
333
|
+
// as-is and means "wrap"), so this gate needs no router-option threading.
|
|
289
334
|
let outletContent: ReactNode =
|
|
290
335
|
node.segment.type === "layout" ? content : null;
|
|
291
336
|
|
|
292
337
|
const transition = node.segment.transition;
|
|
293
338
|
|
|
294
|
-
if (
|
|
339
|
+
if (
|
|
340
|
+
ReactViewTransition &&
|
|
341
|
+
transition &&
|
|
342
|
+
transition.viewTransition !== false
|
|
343
|
+
) {
|
|
295
344
|
if (node.segment.type === "layout") {
|
|
296
345
|
outletContent = wrapDefaultOutletContent(outletContent, transition);
|
|
297
346
|
} else {
|
package/src/server/context.ts
CHANGED
|
@@ -748,6 +748,17 @@ const loaderScopeALS: AsyncLocalStorage<{ active: true }> = ((
|
|
|
748
748
|
globalThis as any
|
|
749
749
|
)[LOADER_SCOPE_KEY] ??= new AsyncLocalStorage<{ active: true }>());
|
|
750
750
|
|
|
751
|
+
// Purity-only scope: marks that a loader FUNCTION BODY is executing, regardless
|
|
752
|
+
// of how the loader was invoked (DSL via runInsideLoaderScope, or handler-
|
|
753
|
+
// invoked via ctx.use). Consulted ONLY by isInsideCacheScope() to exempt
|
|
754
|
+
// request-scoped reads. It deliberately does NOT affect isInsideLoaderScope(),
|
|
755
|
+
// so rendered()/barrier/deadlock gating (which must distinguish DSL from
|
|
756
|
+
// handler-invoked loaders) is unchanged.
|
|
757
|
+
const LOADER_BODY_SCOPE_KEY = Symbol.for("rangojs-router:loader-body-scope");
|
|
758
|
+
const loaderBodyScopeALS: AsyncLocalStorage<{ active: true }> = ((
|
|
759
|
+
globalThis as any
|
|
760
|
+
)[LOADER_BODY_SCOPE_KEY] ??= new AsyncLocalStorage<{ active: true }>());
|
|
761
|
+
|
|
751
762
|
/**
|
|
752
763
|
* Check if the current execution is inside a cache() DSL boundary.
|
|
753
764
|
* Returns false inside loader execution — loaders are always fresh
|
|
@@ -759,6 +770,10 @@ export function isInsideCacheScope(): boolean {
|
|
|
759
770
|
// function re-executes on every request. Skip the guard when running
|
|
760
771
|
// inside a loader.
|
|
761
772
|
if (loaderScopeALS.getStore()?.active) return false;
|
|
773
|
+
// Also exempt handler-invoked loaders: their bodies run in a loader-body
|
|
774
|
+
// scope (not the DSL loader scope above), so request-scoped reads inside any
|
|
775
|
+
// loader — however invoked — are safe (loaders always re-run fresh).
|
|
776
|
+
if (loaderBodyScopeALS.getStore()?.active) return false;
|
|
762
777
|
return true;
|
|
763
778
|
}
|
|
764
779
|
|
|
@@ -779,3 +794,14 @@ export function isInsideLoaderScope(): boolean {
|
|
|
779
794
|
export function runInsideLoaderScope<T>(fn: () => T): T {
|
|
780
795
|
return loaderScopeALS.run({ active: true }, fn);
|
|
781
796
|
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Run `fn` inside a loader BODY scope. Marks loader-function execution for the
|
|
800
|
+
* cache-purity guard only (isInsideCacheScope), WITHOUT affecting
|
|
801
|
+
* isInsideLoaderScope()/rendered() gating. Applied to every loader body (DSL
|
|
802
|
+
* and handler-invoked via ctx.use) so request-scoped reads inside a loader
|
|
803
|
+
* never trip the cache-scope guards — loaders always run fresh.
|
|
804
|
+
*/
|
|
805
|
+
export function runInsideLoaderBodyScope<T>(fn: () => T): T {
|
|
806
|
+
return loaderBodyScopeALS.run({ active: true }, fn);
|
|
807
|
+
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import type { CookieOptions } from "../router/middleware-types.js";
|
|
11
11
|
import { getRequestContext } from "./request-context.js";
|
|
12
|
+
import { isInsideCacheScope } from "./context.js";
|
|
12
13
|
import { INSIDE_CACHE_EXEC } from "../cache/taint.js";
|
|
13
14
|
|
|
14
15
|
/**
|
|
@@ -84,10 +85,23 @@ export interface ReadonlyHeaders {
|
|
|
84
85
|
type HeadersIterator<T> = IterableIterator<T>;
|
|
85
86
|
|
|
86
87
|
/**
|
|
87
|
-
* Throw if called inside a "use cache" function
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
88
|
+
* Throw if called inside a cache boundary — either a "use cache" function
|
|
89
|
+
* (`INSIDE_CACHE_EXEC` stamped on ctx by the cache runtime) or a `cache()`
|
|
90
|
+
* DSL boundary (`isInsideCacheScope()` — the render-store flag set while
|
|
91
|
+
* resolving a `type: "cache"` route entry).
|
|
92
|
+
*
|
|
93
|
+
* Reading request-scoped data (cookies, headers) inside a cached scope
|
|
94
|
+
* produces per-request values that are NOT reflected in the cache key, so
|
|
95
|
+
* they would be frozen into the shared cache entry and served to the wrong
|
|
96
|
+
* users. This is the same hazard for both scopes: a `cache()` boundary caches
|
|
97
|
+
* everything except loaders (it is the document-level "PPR shell"), so a read
|
|
98
|
+
* here is baked into the shell exactly like a `"use cache"` return value is
|
|
99
|
+
* baked into its cache entry.
|
|
100
|
+
*
|
|
101
|
+
* `isInsideCacheScope()` returns false inside loaders (loaders always run
|
|
102
|
+
* fresh on every request, even on a cache hit), so reading cookies()/headers()
|
|
103
|
+
* from a loader is allowed — loaders are the dynamic "holes" of a cached
|
|
104
|
+
* document.
|
|
91
105
|
*/
|
|
92
106
|
function assertNotInsideCacheContext(ctx: unknown, fnName: string): void {
|
|
93
107
|
if (
|
|
@@ -106,6 +120,16 @@ function assertNotInsideCacheContext(ctx: unknown, fnName: string): void {
|
|
|
106
120
|
` const data = await getCachedData(locale); // locale is now in the cache key`,
|
|
107
121
|
);
|
|
108
122
|
}
|
|
123
|
+
if (isInsideCacheScope()) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`${fnName}() cannot be called inside a cache() boundary. ` +
|
|
126
|
+
`A cache() scope caches everything except loaders, so request-scoped ` +
|
|
127
|
+
`data (cookies, headers) read here would be frozen into the shared ` +
|
|
128
|
+
`cached shell and served to other users. Read it inside a loader ` +
|
|
129
|
+
`instead — loaders always run fresh on every request, even on a cache hit:\n\n` +
|
|
130
|
+
` loader("user", () => getUser(cookies().get("session")?.value));`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
109
133
|
}
|
|
110
134
|
|
|
111
135
|
const HEADERS_MUTATION_METHODS = new Set(["set", "append", "delete"]);
|
|
@@ -567,8 +567,11 @@ export type ShouldRevalidateFn<TParams = GenericParams, TEnv = any> = (args: {
|
|
|
567
567
|
|
|
568
568
|
// ── Segment metadata (which segment is being evaluated) ──────────────
|
|
569
569
|
|
|
570
|
-
/**
|
|
571
|
-
|
|
570
|
+
/**
|
|
571
|
+
* The type of segment being revalidated. `"loader"` is passed to revalidate
|
|
572
|
+
* functions attached to a `loader(Fn, () => [revalidate(...)])` registration.
|
|
573
|
+
*/
|
|
574
|
+
segmentType: "layout" | "route" | "parallel" | "loader";
|
|
572
575
|
/** Layout name (e.g., `"root"`, `"shop"`, `"auth"`). Only set for layout segments. */
|
|
573
576
|
layoutName?: string;
|
|
574
577
|
/** Slot name (e.g., `"@sidebar"`, `"@modal"`). Only set for parallel segments. */
|
package/src/types/segments.ts
CHANGED
|
@@ -10,7 +10,10 @@ export type ViewTransitionClass = Record<string, string> | string;
|
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Configuration for React's <ViewTransition> component.
|
|
13
|
-
*
|
|
13
|
+
*
|
|
14
|
+
* The phase fields (enter/exit/update/share/default/name) map directly to
|
|
15
|
+
* ViewTransitionProps (minus children/ref/callbacks). The `viewTransition`
|
|
16
|
+
* field is router-specific and is stripped before the config reaches React.
|
|
14
17
|
*/
|
|
15
18
|
export interface TransitionConfig {
|
|
16
19
|
enter?: ViewTransitionClass;
|
|
@@ -19,6 +22,20 @@ export interface TransitionConfig {
|
|
|
19
22
|
share?: ViewTransitionClass;
|
|
20
23
|
default?: ViewTransitionClass;
|
|
21
24
|
name?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Whether the router wraps this segment's content in its own
|
|
27
|
+
* <ViewTransition> boundary.
|
|
28
|
+
*
|
|
29
|
+
* - "auto" (default): the router places the boundary, producing the
|
|
30
|
+
* router-owned cross-fade described by the phase fields above.
|
|
31
|
+
* - false: the router places no boundary. The navigation commit is still
|
|
32
|
+
* driven through startTransition (so loaders hold instead of flashing a
|
|
33
|
+
* skeleton, and consumer-placed <ViewTransition> elements still animate),
|
|
34
|
+
* but the router contributes no cross-fade of its own.
|
|
35
|
+
*
|
|
36
|
+
* When unset, inherits the createRouter({ viewTransition }) default.
|
|
37
|
+
*/
|
|
38
|
+
viewTransition?: "auto" | false;
|
|
22
39
|
}
|
|
23
40
|
|
|
24
41
|
/**
|
|
@@ -350,7 +350,15 @@ export type PathHelpers<TEnv> = {
|
|
|
350
350
|
};
|
|
351
351
|
|
|
352
352
|
/**
|
|
353
|
-
*
|
|
353
|
+
* Opt a route (or group of routes) into transition-driven navigation.
|
|
354
|
+
*
|
|
355
|
+
* Two independent layers: (1) startTransition, on all React versions, holds
|
|
356
|
+
* the previous content across a same-route nav (no skeleton flash) and is the
|
|
357
|
+
* precondition for any view transition; (2) on experimental React, an
|
|
358
|
+
* additional `<ViewTransition>` boundary cross-fades/morphs the swap. Pass
|
|
359
|
+
* `{ viewTransition: false }` to keep #1 without the router boundary. A view
|
|
360
|
+
* transition cannot fire without a startTransition. See
|
|
361
|
+
* skills/view-transitions for the startTransition x ViewTransition matrix.
|
|
354
362
|
*/
|
|
355
363
|
transition: {
|
|
356
364
|
(): TransitionItem;
|