@rangojs/router 0.13.0 → 0.14.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/route-content-wrapper.d.ts +20 -0
- package/dist/types/server/context.d.ts +2 -0
- package/dist/types/types/segments.d.ts +3 -0
- package/dist/types/urls/pattern-types.d.ts +3 -0
- package/dist/vite/index.js +1 -1
- package/package.json +1 -1
- package/skills/client-urls/SKILL.md +1 -1
- package/src/browser/partial-update.ts +5 -2
- package/src/cache/segment-codec.ts +1 -0
- package/src/client-urls/client-root.tsx +18 -18
- package/src/client-urls/server-projection.ts +9 -2
- package/src/route-content-wrapper.tsx +24 -3
- package/src/router/segment-resolution/fresh.ts +1 -0
- package/src/router/segment-resolution/revalidation.ts +1 -0
- package/src/segment-system.tsx +15 -4
- package/src/server/context.ts +2 -0
- package/src/types/segments.ts +3 -0
- package/src/urls/path-helper.ts +1 -0
- package/src/urls/pattern-types.ts +3 -0
|
@@ -23,15 +23,35 @@ import type { ResolvedSegment } from "./types.js";
|
|
|
23
23
|
*/
|
|
24
24
|
export declare class StreamedLoaderErrorBoundary extends Component<{
|
|
25
25
|
children: ReactNode;
|
|
26
|
+
resetKey?: string;
|
|
26
27
|
}, {
|
|
27
28
|
error: unknown;
|
|
29
|
+
resetKey?: string;
|
|
28
30
|
}> {
|
|
29
31
|
state: {
|
|
30
32
|
error: unknown;
|
|
33
|
+
resetKey?: string;
|
|
31
34
|
};
|
|
32
35
|
static getDerivedStateFromError(error: unknown): {
|
|
33
36
|
error: unknown;
|
|
34
37
|
};
|
|
38
|
+
/**
|
|
39
|
+
* A caught marker (redirect, notFound, error fallback) belongs to ONE route
|
|
40
|
+
* + params. Group-keyed segments (ResolvedSegment.clientGroup) keep this
|
|
41
|
+
* instance alive across in-group navigations, so the error must clear when
|
|
42
|
+
* the route or params change — otherwise a redirect caught for /legacy
|
|
43
|
+
* keeps rendering LoaderRedirect for /state. `resetKey` is the segment's
|
|
44
|
+
* id-params identity, the cadence the per-route remount used to provide.
|
|
45
|
+
*/
|
|
46
|
+
static getDerivedStateFromProps(props: {
|
|
47
|
+
resetKey?: string;
|
|
48
|
+
}, state: {
|
|
49
|
+
error: unknown;
|
|
50
|
+
resetKey?: string;
|
|
51
|
+
}): {
|
|
52
|
+
error: unknown;
|
|
53
|
+
resetKey?: string;
|
|
54
|
+
} | null;
|
|
35
55
|
render(): ReactNode;
|
|
36
56
|
}
|
|
37
57
|
/**
|
|
@@ -48,6 +48,8 @@ export type EntryPropCommon = {
|
|
|
48
48
|
cache?: EntryCacheConfig;
|
|
49
49
|
/** URL prefix from include() scope, used for MountContext on client */
|
|
50
50
|
mountPath?: string;
|
|
51
|
+
/** clientUrls() group key (PathOptions.clientGroup); route entries only. */
|
|
52
|
+
clientGroup?: string;
|
|
51
53
|
};
|
|
52
54
|
/**
|
|
53
55
|
* Attachments resolved by walking the parent chain, not owned by the entry:
|
|
@@ -163,6 +163,9 @@ export interface ResolvedSegment {
|
|
|
163
163
|
error?: ErrorInfo;
|
|
164
164
|
notFoundInfo?: NotFoundInfo;
|
|
165
165
|
mountPath?: string;
|
|
166
|
+
/** clientUrls() group key (the include mount), shared by every route
|
|
167
|
+
* segment of one group; see the group-route branch in segment-system.tsx. */
|
|
168
|
+
clientGroup?: string;
|
|
166
169
|
/**
|
|
167
170
|
* @internal Server-side marker: true when the segment's handler actually ran
|
|
168
171
|
* this request (not skipped via the revalidate cache path). Used by
|
|
@@ -102,6 +102,9 @@ export interface PathOptions<TName extends string = string, TSearch extends Sear
|
|
|
102
102
|
trailingSlash?: TrailingSlashMode;
|
|
103
103
|
/** Response type marker (set by path.json(), etc.) */
|
|
104
104
|
[RESPONSE_TYPE]?: string;
|
|
105
|
+
/** @internal clientUrls() group key stamped by server-projection.ts; see
|
|
106
|
+
* ResolvedSegment.clientGroup. */
|
|
107
|
+
clientGroup?: string;
|
|
105
108
|
}
|
|
106
109
|
/**
|
|
107
110
|
* Result of urls() - contains the route definitions
|
package/dist/vite/index.js
CHANGED
|
@@ -3746,7 +3746,7 @@ import { resolve } from "node:path";
|
|
|
3746
3746
|
// package.json
|
|
3747
3747
|
var package_default = {
|
|
3748
3748
|
name: "@rangojs/router",
|
|
3749
|
-
version: "0.
|
|
3749
|
+
version: "0.14.0",
|
|
3750
3750
|
description: "Django-inspired RSC router with composable URL patterns",
|
|
3751
3751
|
keywords: [
|
|
3752
3752
|
"react",
|
package/package.json
CHANGED
|
@@ -130,7 +130,7 @@ typing work exactly as for server routes (`/typesafety`).
|
|
|
130
130
|
| `loader()` | `loader(Def, use?)` or `loader(Def, { ssr: false }, use?)` — see below |
|
|
131
131
|
| `loading()` | Route-level boundary around the optimistic render; inline `<Suspense>` at read sites keeps the destination's chrome visible while only the reads wait |
|
|
132
132
|
| `revalidate()` | Valid **inside a loader() use callback only**; runs in the browser |
|
|
133
|
-
| `transition()` | Data-only ViewTransition config — no `when
|
|
133
|
+
| `transition()` | Data-only ViewTransition animation config — no `when`; same-route navs in a group already hold previous content without it |
|
|
134
134
|
| `intercept()` | Dot-local named target in the SAME definition; use may contain `loader()`/`loading()` |
|
|
135
135
|
|
|
136
136
|
`include`, `parallel`, `cache`, `middleware`, `errorBoundary`,
|
|
@@ -614,7 +614,7 @@ export function createPartialUpdater(
|
|
|
614
614
|
scroll: scrollPayload,
|
|
615
615
|
});
|
|
616
616
|
});
|
|
617
|
-
} else if (fullyPrefetched || isSameStructureNav) {
|
|
617
|
+
} else if (fullyPrefetched || isSameStructureNav || optimisticPresented) {
|
|
618
618
|
// Content-hold commit, two triggers. Fully-prefetched nav: the payload
|
|
619
619
|
// is fully resolved (forceAwait above), so the transition commits
|
|
620
620
|
// synchronously — no fallback flash. Same-structure nav: the re-run
|
|
@@ -633,7 +633,10 @@ export function createPartialUpdater(
|
|
|
633
633
|
// instead of revealing that boundary's fallback; its render happens
|
|
634
634
|
// pre-commit inside the transition, so userland effects cannot run
|
|
635
635
|
// first. Boundaries newly mounted by this nav still reveal their
|
|
636
|
-
// fallbacks (React shows new boundaries inside transitions).
|
|
636
|
+
// fallbacks (React shows new boundaries inside transitions). An
|
|
637
|
+
// optimistic clientUrls() presentation commits here too: the group
|
|
638
|
+
// segment reconciles in place (clientGroup key), so a read that still
|
|
639
|
+
// suspends must hold the presented content, not flash a fallback.
|
|
637
640
|
startTransition(() => {
|
|
638
641
|
if (optimisticPresented && addTransitionType) {
|
|
639
642
|
addTransitionType(OPTIMISTIC_COMMIT_TRANSITION_TYPE);
|
|
@@ -174,13 +174,17 @@ export function ClientUrlsRoot({
|
|
|
174
174
|
[optimisticRoute, presented],
|
|
175
175
|
);
|
|
176
176
|
|
|
177
|
+
// The wrapper chain below is IDENTICAL in the optimistic and the canonical
|
|
178
|
+
// render (only prop values change): together with the group-keyed segment
|
|
179
|
+
// (segment-system.tsx, ResolvedSegment.clientGroup) that is what lets the
|
|
180
|
+
// destination instance survive the canonical commit.
|
|
177
181
|
let content: ReactNode = createElement(route.component, { key: route.id });
|
|
178
|
-
if (
|
|
179
|
-
// loading() is the route-level boundary
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
+
if (route.loading !== undefined) {
|
|
183
|
+
// loading() is the route-level boundary for group routes (segment-system
|
|
184
|
+
// places no LoaderBoundary around them); presence mirrors the
|
|
185
|
+
// projection's hasLoading (loading("") is still a configured fallback).
|
|
182
186
|
content = createElement(Suspense, {
|
|
183
|
-
fallback:
|
|
187
|
+
fallback: route.loading,
|
|
184
188
|
children: content,
|
|
185
189
|
});
|
|
186
190
|
}
|
|
@@ -195,19 +199,15 @@ export function ClientUrlsRoot({
|
|
|
195
199
|
});
|
|
196
200
|
}
|
|
197
201
|
|
|
198
|
-
|
|
199
|
-
content
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
});
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
return content;
|
|
202
|
+
return createElement(OutletProvider, {
|
|
203
|
+
content: null,
|
|
204
|
+
loaderStreams: optimistic?.streams,
|
|
205
|
+
pending,
|
|
206
|
+
children: createElement(OptimisticLocationContext.Provider, {
|
|
207
|
+
value: optimistic?.location ?? null,
|
|
208
|
+
children: content,
|
|
209
|
+
}),
|
|
210
|
+
});
|
|
211
211
|
}
|
|
212
212
|
|
|
213
213
|
export function ClientUrlsLoading({
|
|
@@ -481,8 +481,12 @@ function createLoaderStub(id: string): LoaderDefinition<unknown> {
|
|
|
481
481
|
};
|
|
482
482
|
}
|
|
483
483
|
|
|
484
|
-
function materializedPathOptions(
|
|
484
|
+
function materializedPathOptions(
|
|
485
|
+
route: ClientUrlProjectionRoute,
|
|
486
|
+
clientGroup: string,
|
|
487
|
+
): PathOptions {
|
|
485
488
|
return {
|
|
489
|
+
clientGroup,
|
|
486
490
|
...(route.name === null ? {} : { name: route.name }),
|
|
487
491
|
...(route.options.search ? { search: { ...route.options.search } } : {}),
|
|
488
492
|
...(route.options.trailingSlash
|
|
@@ -517,6 +521,9 @@ function materializeRouteItems(
|
|
|
517
521
|
// route-name prefix is available here. ClientUrlsRoot needs it to compose
|
|
518
522
|
// canonical names for intercept-target coordination in the browser.
|
|
519
523
|
const namePrefix = getNamePrefix();
|
|
524
|
+
// One key per group MOUNT (the include's URL prefix): renderSegments keys
|
|
525
|
+
// every route segment of the group by it (ResolvedSegment.clientGroup).
|
|
526
|
+
const clientGroup = getUrlPrefix() || "/";
|
|
520
527
|
|
|
521
528
|
// Helper calls attach to the CURRENT ctx.parent as they execute, so these
|
|
522
529
|
// builders must run where the items belong: at the module top level for the
|
|
@@ -533,7 +540,7 @@ function materializeRouteItems(
|
|
|
533
540
|
routeId: route.id,
|
|
534
541
|
namePrefix,
|
|
535
542
|
}),
|
|
536
|
-
materializedPathOptions(route),
|
|
543
|
+
materializedPathOptions(route, clientGroup),
|
|
537
544
|
() => [
|
|
538
545
|
...route.loaderIds.map((id, loaderIndex) =>
|
|
539
546
|
loader(
|
|
@@ -32,15 +32,36 @@ import { LoaderRedirect } from "./loader-redirect.js";
|
|
|
32
32
|
* Errors without any marker rethrow to the app's own boundaries.
|
|
33
33
|
*/
|
|
34
34
|
export class StreamedLoaderErrorBoundary extends Component<
|
|
35
|
-
{ children: ReactNode },
|
|
36
|
-
{ error: unknown }
|
|
35
|
+
{ children: ReactNode; resetKey?: string },
|
|
36
|
+
{ error: unknown; resetKey?: string }
|
|
37
37
|
> {
|
|
38
|
-
state: { error: unknown } = {
|
|
38
|
+
state: { error: unknown; resetKey?: string } = {
|
|
39
|
+
error: null,
|
|
40
|
+
resetKey: this.props.resetKey,
|
|
41
|
+
};
|
|
39
42
|
|
|
40
43
|
static getDerivedStateFromError(error: unknown): { error: unknown } {
|
|
41
44
|
return { error };
|
|
42
45
|
}
|
|
43
46
|
|
|
47
|
+
/**
|
|
48
|
+
* A caught marker (redirect, notFound, error fallback) belongs to ONE route
|
|
49
|
+
* + params. Group-keyed segments (ResolvedSegment.clientGroup) keep this
|
|
50
|
+
* instance alive across in-group navigations, so the error must clear when
|
|
51
|
+
* the route or params change — otherwise a redirect caught for /legacy
|
|
52
|
+
* keeps rendering LoaderRedirect for /state. `resetKey` is the segment's
|
|
53
|
+
* id-params identity, the cadence the per-route remount used to provide.
|
|
54
|
+
*/
|
|
55
|
+
static getDerivedStateFromProps(
|
|
56
|
+
props: { resetKey?: string },
|
|
57
|
+
state: { error: unknown; resetKey?: string },
|
|
58
|
+
): { error: unknown; resetKey?: string } | null {
|
|
59
|
+
if (props.resetKey !== state.resetKey) {
|
|
60
|
+
return { error: null, resetKey: props.resetKey };
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
44
65
|
render(): ReactNode {
|
|
45
66
|
const { error } = this.state;
|
|
46
67
|
if (error !== null && error !== undefined) {
|
|
@@ -907,6 +907,7 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
|
|
|
907
907
|
),
|
|
908
908
|
params,
|
|
909
909
|
belongsToRoute,
|
|
910
|
+
...(entry.clientGroup ? { clientGroup: entry.clientGroup } : {}),
|
|
910
911
|
...(entry.type === "layout" || entry.type === "cache"
|
|
911
912
|
? { layoutName: entry.id }
|
|
912
913
|
: {}),
|
package/src/segment-system.tsx
CHANGED
|
@@ -350,6 +350,13 @@ export async function renderSegments(
|
|
|
350
350
|
`Expected layout, route, error, or notFound segment, got ${node.segment.type}`,
|
|
351
351
|
);
|
|
352
352
|
const { component, id, params, loading } = node.segment;
|
|
353
|
+
// clientUrls() group routes: ONE React key and ONE wrapper shape per group
|
|
354
|
+
// mount (no LoaderBoundary/RouteContentWrapper — ClientUrlsRoot renders
|
|
355
|
+
// loading() itself), so an in-group navigation reconciles the mounted
|
|
356
|
+
// ClientUrlsRoot: the optimistic destination keeps its instance across the
|
|
357
|
+
// commit and same-route param navs hold instead of remounting. The
|
|
358
|
+
// server-side loading value still drives PPR masking and SSR.
|
|
359
|
+
const clientGroup = node.segment.clientGroup;
|
|
353
360
|
const segNodeStart = segDebug ? performance.now() : 0;
|
|
354
361
|
|
|
355
362
|
// Param-agnostic keys are opt-in via the transition() DSL (see
|
|
@@ -385,7 +392,10 @@ export async function renderSegments(
|
|
|
385
392
|
.map(([k, v]) => `${k}=${v}`)
|
|
386
393
|
.join(",")
|
|
387
394
|
: "";
|
|
388
|
-
|
|
395
|
+
// Route identity the per-route remount used to provide; group routes key
|
|
396
|
+
// by the group instead and pass it to the error boundary as its resetKey.
|
|
397
|
+
const idParamsKey = paramStr ? `${id}-${paramStr}` : id;
|
|
398
|
+
const key = clientGroup ? `cg:${clientGroup}` : idParamsKey;
|
|
389
399
|
|
|
390
400
|
const loaderEntries = node.loaders.filter(
|
|
391
401
|
(loader) => loader.loaderId && loader.loaderData !== undefined,
|
|
@@ -403,7 +413,7 @@ export async function renderSegments(
|
|
|
403
413
|
}
|
|
404
414
|
|
|
405
415
|
let nodeContent: ReactNode = null;
|
|
406
|
-
if (isRenderableLoading(loading)) {
|
|
416
|
+
if (!clientGroup && isRenderableLoading(loading)) {
|
|
407
417
|
// forceAwait (popstate, stale-revalidation, fully-prefetched nav) renders a
|
|
408
418
|
// loading() route with the route content ALREADY resolved, so its
|
|
409
419
|
// RouteContentWrapper Suspender does not suspend for a microtask and flash
|
|
@@ -499,13 +509,14 @@ export async function renderSegments(
|
|
|
499
509
|
// escaping to app boundaries. Wrapped UNCONDITIONALLY on loader presence
|
|
500
510
|
// — streams and forceAwait lanes must produce the same tree shape or
|
|
501
511
|
// lane changes remount the subtree (docs/tree-structure.md).
|
|
502
|
-
if (loaderEntries.length > 0) {
|
|
512
|
+
if (loaderEntries.length > 0 || clientGroup) {
|
|
503
513
|
nodeContent = createElement(StreamedLoaderErrorBoundary, {
|
|
514
|
+
resetKey: idParamsKey,
|
|
504
515
|
children: nodeContent,
|
|
505
516
|
});
|
|
506
517
|
}
|
|
507
518
|
|
|
508
|
-
if (loading !== undefined && loading !== null) {
|
|
519
|
+
if (!clientGroup && loading !== undefined && loading !== null) {
|
|
509
520
|
const loaderDataPromise = getMemoizedLoaderPromise(loaderEntries);
|
|
510
521
|
let boundaryLoaderData: Promise<any[]> | any[] = loaderDataPromise;
|
|
511
522
|
// SPIKE (streaming useLoader): per-loader streams for the boundary's
|
package/src/server/context.ts
CHANGED
|
@@ -69,6 +69,8 @@ export type EntryPropCommon = {
|
|
|
69
69
|
cache?: EntryCacheConfig;
|
|
70
70
|
/** URL prefix from include() scope, used for MountContext on client */
|
|
71
71
|
mountPath?: string;
|
|
72
|
+
/** clientUrls() group key (PathOptions.clientGroup); route entries only. */
|
|
73
|
+
clientGroup?: string;
|
|
72
74
|
};
|
|
73
75
|
|
|
74
76
|
/**
|
package/src/types/segments.ts
CHANGED
|
@@ -193,6 +193,9 @@ export interface ResolvedSegment {
|
|
|
193
193
|
notFoundInfo?: NotFoundInfo; // For notFound segments: the not found information
|
|
194
194
|
// Mount path from include() scope, used for MountContext.Provider wrapping
|
|
195
195
|
mountPath?: string;
|
|
196
|
+
/** clientUrls() group key (the include mount), shared by every route
|
|
197
|
+
* segment of one group; see the group-route branch in segment-system.tsx. */
|
|
198
|
+
clientGroup?: string;
|
|
196
199
|
/**
|
|
197
200
|
* @internal Server-side marker: true when the segment's handler actually ran
|
|
198
201
|
* this request (not skipped via the revalidate cache path). Used by
|
package/src/urls/path-helper.ts
CHANGED
|
@@ -154,6 +154,7 @@ export function createPathHelper<TEnv>(): PathFn<TEnv> {
|
|
|
154
154
|
handler: wrappedHandler,
|
|
155
155
|
pattern: prefixedPattern,
|
|
156
156
|
...(urlPrefix ? { mountPath: urlPrefix } : {}),
|
|
157
|
+
...(options?.clientGroup ? { clientGroup: options.clientGroup } : {}),
|
|
157
158
|
...(isPassthroughHandler(handler)
|
|
158
159
|
? {
|
|
159
160
|
isPrerender: true as const,
|
|
@@ -112,6 +112,9 @@ export interface PathOptions<
|
|
|
112
112
|
trailingSlash?: TrailingSlashMode;
|
|
113
113
|
/** Response type marker (set by path.json(), etc.) */
|
|
114
114
|
[RESPONSE_TYPE]?: string;
|
|
115
|
+
/** @internal clientUrls() group key stamped by server-projection.ts; see
|
|
116
|
+
* ResolvedSegment.clientGroup. */
|
|
117
|
+
clientGroup?: string;
|
|
115
118
|
}
|
|
116
119
|
|
|
117
120
|
/**
|