@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.
@@ -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
@@ -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.13.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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -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` (that is a server-executed predicate) |
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);
@@ -215,6 +215,7 @@ export async function serializeSegments(
215
215
  loaderIds: segment.loaderIds,
216
216
  transition: segment.transition,
217
217
  mountPath: segment.mountPath,
218
+ clientGroup: segment.clientGroup,
218
219
  },
219
220
  };
220
221
  }),
@@ -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 (optimisticRoute && optimisticRoute.loading !== undefined) {
179
- // loading() is the route-level boundary around the optimistic render;
180
- // presence mirrors the projection's hasLoading (a falsy-but-valid node
181
- // like loading("") is still a configured fallback).
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: optimisticRoute.loading,
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
- if (optimistic) {
199
- content = createElement(OutletProvider, {
200
- content: null,
201
- loaderStreams: optimistic.streams,
202
- pending,
203
- children: createElement(OptimisticLocationContext.Provider, {
204
- value: optimistic.location,
205
- children: content,
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(route: ClientUrlProjectionRoute): PathOptions {
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 } = { error: null };
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) {
@@ -468,6 +468,7 @@ export async function resolveSegment<TEnv>(
468
468
  ),
469
469
  params,
470
470
  belongsToRoute: true,
471
+ ...(entry.clientGroup ? { clientGroup: entry.clientGroup } : {}),
471
472
  ...(entry.mountPath ? { mountPath: entry.mountPath } : {}),
472
473
  });
473
474
  } else {
@@ -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
  : {}),
@@ -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
- const key = paramStr ? `${id}-${paramStr}` : id;
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
@@ -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
  /**
@@ -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
@@ -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
  /**