@rangojs/router 0.12.4 → 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.
Files changed (32) hide show
  1. package/dist/types/browser/optimistic-commit.d.ts +12 -0
  2. package/dist/types/browser/partial-update.d.ts +7 -0
  3. package/dist/types/browser/react/use-params.d.ts +3 -1
  4. package/dist/types/browser/react/use-pathname.d.ts +3 -1
  5. package/dist/types/client-urls/navigation.d.ts +5 -0
  6. package/dist/types/client-urls/optimistic-location.d.ts +17 -0
  7. package/dist/types/route-content-wrapper.d.ts +20 -0
  8. package/dist/types/server/context.d.ts +2 -0
  9. package/dist/types/types/segments.d.ts +3 -0
  10. package/dist/types/urls/pattern-types.d.ts +3 -0
  11. package/dist/vite/index.js +16 -7
  12. package/package.json +1 -1
  13. package/skills/client-urls/SKILL.md +5 -4
  14. package/src/browser/navigation-bridge.ts +11 -8
  15. package/src/browser/optimistic-commit.ts +32 -0
  16. package/src/browser/partial-update.ts +21 -2
  17. package/src/browser/react/use-params.ts +8 -1
  18. package/src/browser/react/use-pathname.ts +6 -2
  19. package/src/browser/react/use-search-params.ts +13 -2
  20. package/src/cache/segment-codec.ts +1 -0
  21. package/src/client-urls/client-root.tsx +76 -23
  22. package/src/client-urls/navigation.ts +11 -1
  23. package/src/client-urls/optimistic-location.ts +22 -0
  24. package/src/client-urls/server-projection.ts +9 -2
  25. package/src/route-content-wrapper.tsx +24 -3
  26. package/src/router/segment-resolution/fresh.ts +1 -0
  27. package/src/router/segment-resolution/revalidation.ts +1 -0
  28. package/src/segment-system.tsx +30 -4
  29. package/src/server/context.ts +2 -0
  30. package/src/types/segments.ts +3 -0
  31. package/src/urls/path-helper.ts +1 -0
  32. package/src/urls/pattern-types.ts +3 -0
@@ -0,0 +1,12 @@
1
+ import type { ViewTransitionClass } from "../types/segments.js";
2
+ /**
3
+ * Transition type added to the canonical commit of a navigation that already
4
+ * presented an optimistic clientUrls() destination (client-urls/client-root.tsx).
5
+ * The optimistic swap ran in a transition lane and got the route's configured
6
+ * <ViewTransition> animation; the commit then replaces the branch with the
7
+ * destination's own segment — identical pixels — so every router-placed
8
+ * boundary maps this type to "none" (withOptimisticCommitNone) and the user
9
+ * perceives one animated navigation, not two.
10
+ */
11
+ export declare const OPTIMISTIC_COMMIT_TRANSITION_TYPE = "rango-optimistic-commit";
12
+ export declare function withOptimisticCommitNone(value: ViewTransitionClass | undefined): ViewTransitionClass;
@@ -43,6 +43,13 @@ export type UpdateMode = {
43
43
  targetCacheHandleData?: Record<string, Record<string, unknown[]>>;
44
44
  /** Source URL for intercept restore (popstate cache miss) */
45
45
  interceptSourceUrl?: string;
46
+ /**
47
+ * The bridge already presented an optimistic clientUrls() destination
48
+ * for this navigation: transition-lane commits add
49
+ * OPTIMISTIC_COMMIT_TRANSITION_TYPE so router <ViewTransition>
50
+ * boundaries do not animate the identical repaint.
51
+ */
52
+ optimisticPresented?: boolean;
46
53
  } | {
47
54
  type: "leave-intercept";
48
55
  interceptSourceUrl?: string;
@@ -2,7 +2,9 @@
2
2
  * Hook to access the current route params.
3
3
  *
4
4
  * Returns the merged route params from the matched route.
5
- * Updates when navigation completes, not during pending navigation.
5
+ * Updates when navigation completes, not during pending navigation — except
6
+ * inside an optimistically rendered clientUrls() destination, where it
7
+ * reports THAT route's params (see OptimisticLocationContext).
6
8
  *
7
9
  * @example
8
10
  * ```tsx
@@ -2,7 +2,9 @@
2
2
  * Hook to access the current pathname.
3
3
  *
4
4
  * Returns the committed pathname string (excludes search params and hash).
5
- * Updates when navigation completes, not during pending navigation.
5
+ * Updates when navigation completes, not during pending navigation — except
6
+ * inside an optimistically rendered clientUrls() destination, where it
7
+ * reports THAT route's pathname (see OptimisticLocationContext).
6
8
  *
7
9
  * @example
8
10
  * ```tsx
@@ -1,6 +1,11 @@
1
1
  import type { ClientUrlPatterns } from "./types.js";
2
2
  export interface ClientUrlNavigationIntent {
3
3
  readonly routeId: string;
4
+ /** Destination params from the local trie match (definition-local). */
5
+ readonly params: Readonly<Record<string, string>>;
6
+ /** Absolute destination pathname (mount included) and search ("?..." or ""). */
7
+ readonly pathname: string;
8
+ readonly search: string;
4
9
  }
5
10
  export declare function setActiveInterceptTargets(targets: readonly string[] | undefined): void;
6
11
  export interface ClientUrlNavigationPresentation {
@@ -0,0 +1,17 @@
1
+ import { type Context } from "react";
2
+ /**
3
+ * Route identity of an optimistically rendered clientUrls() destination:
4
+ * the values the local trie match produced for the URL the user navigated
5
+ * to. Provided by ClientUrlsRoot around the optimistic branch ONLY, so
6
+ * useParams / usePathname / useSearchParams inside that branch describe the
7
+ * route being rendered while the same hooks in chrome outside it keep the
8
+ * committed location until the canonical response commits (or redirects, in
9
+ * which case the branch — and these values — are discarded).
10
+ */
11
+ export interface OptimisticLocation {
12
+ readonly params: Readonly<Record<string, string>>;
13
+ readonly pathname: string;
14
+ /** Search string including the leading "?" (or ""). */
15
+ readonly search: string;
16
+ }
17
+ export declare const OptimisticLocationContext: Context<OptimisticLocation | null>;
@@ -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.12.4",
3749
+ version: "0.14.0",
3750
3750
  description: "Django-inspired RSC router with composable URL patterns",
3751
3751
  keywords: [
3752
3752
  "react",
@@ -8057,6 +8057,10 @@ import {
8057
8057
  // src/browser/react/use-pathname.ts
8058
8058
  import { useContext as useContext6, useState as useState3, useEffect as useEffect4, useRef as useRef3 } from "react";
8059
8059
 
8060
+ // src/client-urls/optimistic-location.ts
8061
+ import { createContext as createContext4 } from "react";
8062
+ var OptimisticLocationContext = createContext4(null);
8063
+
8060
8064
  // src/browser/react/use-search-params.ts
8061
8065
  import {
8062
8066
  useCallback as useCallback2,
@@ -8072,8 +8076,8 @@ import { useContext as useContext8, useState as useState5, useEffect as useEffec
8072
8076
  var EMPTY_PARAMS = Object.freeze({});
8073
8077
 
8074
8078
  // src/browser/react/nonce-context.ts
8075
- import { createContext as createContext4, useContext as useContext9 } from "react";
8076
- var NonceContext = createContext4(void 0);
8079
+ import { createContext as createContext5, useContext as useContext9 } from "react";
8080
+ var NonceContext = createContext5(void 0);
8077
8081
 
8078
8082
  // src/browser/react/use-action.ts
8079
8083
  import {
@@ -8112,8 +8116,8 @@ import {
8112
8116
  } from "react";
8113
8117
 
8114
8118
  // src/theme/theme-context.ts
8115
- import { createContext as createContext5, useContext as useContext12 } from "react";
8116
- var ThemeContext = createContext5(null);
8119
+ import { createContext as createContext6, useContext as useContext12 } from "react";
8120
+ var ThemeContext = createContext6(null);
8117
8121
 
8118
8122
  // src/theme/constants.ts
8119
8123
  var THEME_COOKIE = {
@@ -8155,7 +8159,7 @@ import {
8155
8159
 
8156
8160
  // src/browser/react/use-link-status.ts
8157
8161
  import {
8158
- createContext as createContext6,
8162
+ createContext as createContext7,
8159
8163
  useContext as useContext13,
8160
8164
  useState as useState11,
8161
8165
  useEffect as useEffect11,
@@ -8163,7 +8167,7 @@ import {
8163
8167
  useOptimistic as useOptimistic3,
8164
8168
  startTransition as startTransition4
8165
8169
  } from "react";
8166
- var LinkContext = createContext6(
8170
+ var LinkContext = createContext7(
8167
8171
  null
8168
8172
  );
8169
8173
 
@@ -8988,7 +8992,10 @@ init_redirect_origin();
8988
8992
  import {
8989
8993
  createElement as createElement4,
8990
8994
  Fragment as Fragment4,
8995
+ Suspense as Suspense3,
8996
+ useDeferredValue,
8991
8997
  useEffect as useEffect16,
8998
+ useMemo as useMemo9,
8992
8999
  useState as useState15
8993
9000
  } from "react";
8994
9001
 
@@ -8997,6 +9004,8 @@ import { startTransition as startTransition6 } from "react";
8997
9004
 
8998
9005
  // src/client-urls/client-root.tsx
8999
9006
  import { jsx as jsx11, jsxs as jsxs4 } from "react/jsx-runtime";
9007
+ var PENDING_FOREVER = new Promise(() => {
9008
+ });
9000
9009
 
9001
9010
  // src/client-urls/server-projection.ts
9002
9011
  var SEARCH_SCHEMA_VALUES = /* @__PURE__ */ new Set([
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.12.4",
3
+ "version": "0.14.0",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -17,8 +17,9 @@ latency. `clientUrls()` makes that shape unrepresentable.
17
17
  This is the fastest-transition shape Rango has, and the natural fit for
18
18
  dashboard / admin / settings-style apps — high navigation frequency inside one
19
19
  layout, mostly tab/param/filter switches. Three things compound: the
20
- definition also matches in the browser, so a soft navigation presents
21
- optimistic pending UI immediately (`useOutlet().pending`) with no server
20
+ definition also matches in the browser, so a soft navigation renders the
21
+ destination component immediately (loader reads suspend into `loading()` or
22
+ an inline `<Suspense>`; `useOutlet().pending` flips for chrome) with no server
22
23
  round-trip to start; browser-run `revalidate()` predicates HOLD data across
23
24
  navigations that don't invalidate it (a tab switch re-runs nothing — only the
24
25
  decision crosses the wire); and any read that does refresh streams behind its
@@ -127,9 +128,9 @@ typing work exactly as for server routes (`/typesafety`).
127
128
  | `path()` | Options are `name`, `search`, `trailingSlash`, `ppr` (shell caching — see /ppr skill; loader routes need `loading()` or capture refuses); no response variants |
128
129
  | `layout()` | Must contain at least one `path()` |
129
130
  | `loader()` | `loader(Def, use?)` or `loader(Def, { ssr: false }, use?)` — see below |
130
- | `loading()` | Route/layout-level pending UI; inline `<Suspense>` at read sites is usually better |
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 |
131
132
  | `revalidate()` | Valid **inside a loader() use callback only**; runs in the browser |
132
- | `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 |
133
134
  | `intercept()` | Dot-local named target in the SAME definition; use may contain `loader()`/`loading()` |
134
135
 
135
136
  `include`, `parallel`, `cache`, `middleware`, `errorBoundary`,
@@ -365,15 +365,18 @@ export function createNavigationBridge(
365
365
  scroll: options?.scroll,
366
366
  state: resolvedState,
367
367
  }),
368
- hasUsableCache
369
- ? {
368
+ isLeavingIntercept
369
+ ? { type: "leave-intercept" as const }
370
+ : {
370
371
  type: "navigate" as const,
371
- targetCacheSegments: cachedSegments,
372
- targetCacheHandleData: cachedHandleData,
373
- }
374
- : isLeavingIntercept
375
- ? { type: "leave-intercept" as const }
376
- : undefined,
372
+ ...(hasUsableCache
373
+ ? {
374
+ targetCacheSegments: cachedSegments,
375
+ targetCacheHandleData: cachedHandleData,
376
+ }
377
+ : {}),
378
+ optimisticPresented: clientUrlPresentation !== null,
379
+ },
377
380
  );
378
381
  } catch (error) {
379
382
  // Server-side redirect with location state: the current transaction's
@@ -0,0 +1,32 @@
1
+ import type { ViewTransitionClass } from "../types/segments.js";
2
+
3
+ /**
4
+ * Transition type added to the canonical commit of a navigation that already
5
+ * presented an optimistic clientUrls() destination (client-urls/client-root.tsx).
6
+ * The optimistic swap ran in a transition lane and got the route's configured
7
+ * <ViewTransition> animation; the commit then replaces the branch with the
8
+ * destination's own segment — identical pixels — so every router-placed
9
+ * boundary maps this type to "none" (withOptimisticCommitNone) and the user
10
+ * perceives one animated navigation, not two.
11
+ */
12
+ export const OPTIMISTIC_COMMIT_TRANSITION_TYPE = "rango-optimistic-commit";
13
+
14
+ /**
15
+ * Merge the "none" mapping for the optimistic-commit type into a
16
+ * <ViewTransition> class prop. A string class becomes the `default` entry of
17
+ * a map; an absent prop yields the shared type-only map (React falls back to
18
+ * the boundary's `default` prop for other types).
19
+ */
20
+ const NONE_ONLY: ViewTransitionClass = Object.freeze({
21
+ [OPTIMISTIC_COMMIT_TRANSITION_TYPE]: "none",
22
+ });
23
+
24
+ export function withOptimisticCommitNone(
25
+ value: ViewTransitionClass | undefined,
26
+ ): ViewTransitionClass {
27
+ if (value === undefined) return NONE_ONLY;
28
+ if (typeof value === "string") {
29
+ return { default: value, [OPTIMISTIC_COMMIT_TRANSITION_TYPE]: "none" };
30
+ }
31
+ return { ...value, [OPTIMISTIC_COMMIT_TRANSITION_TYPE]: "none" };
32
+ }
@@ -30,6 +30,7 @@ import {
30
30
  validateExternalRedirect,
31
31
  } from "./validate-redirect-origin.js";
32
32
  import type { NavigationUpdate } from "./types.js";
33
+ import { OPTIMISTIC_COMMIT_TRANSITION_TYPE } from "./optimistic-commit.js";
33
34
  import {
34
35
  collectClientRevalidationDecisions,
35
36
  setActiveInterceptTargets,
@@ -98,6 +99,13 @@ export type UpdateMode =
98
99
  targetCacheHandleData?: Record<string, Record<string, unknown[]>>;
99
100
  /** Source URL for intercept restore (popstate cache miss) */
100
101
  interceptSourceUrl?: string;
102
+ /**
103
+ * The bridge already presented an optimistic clientUrls() destination
104
+ * for this navigation: transition-lane commits add
105
+ * OPTIMISTIC_COMMIT_TRANSITION_TYPE so router <ViewTransition>
106
+ * boundaries do not animate the identical repaint.
107
+ */
108
+ optimisticPresented?: boolean;
101
109
  }
102
110
  | { type: "leave-intercept"; interceptSourceUrl?: string }
103
111
  | { type: "stale-revalidation"; interceptSourceUrl?: string }
@@ -555,6 +563,8 @@ export function createPartialUpdater(
555
563
  debugLog("[partial-update] updating document");
556
564
 
557
565
  const hasTransition = shouldStartViewTransition(reconciled.segments);
566
+ const optimisticPresented =
567
+ mode.type === "navigate" && mode.optimisticPresented === true;
558
568
  // [VT-DIAG] Gated behind INTERNAL_RANGO_DEBUG. Reports which reconciled
559
569
  // segment still carries a transition after the server-side when-gate, and
560
570
  // whether the commit will be held in a startTransition. If `withTransition`
@@ -594,6 +604,9 @@ export function createPartialUpdater(
594
604
  startTransition(() => {
595
605
  if (addTransitionType) {
596
606
  addTransitionType("navigation");
607
+ if (optimisticPresented) {
608
+ addTransitionType(OPTIMISTIC_COMMIT_TRANSITION_TYPE);
609
+ }
597
610
  }
598
611
  onUpdate({
599
612
  root: newTree,
@@ -601,7 +614,7 @@ export function createPartialUpdater(
601
614
  scroll: scrollPayload,
602
615
  });
603
616
  });
604
- } else if (fullyPrefetched || isSameStructureNav) {
617
+ } else if (fullyPrefetched || isSameStructureNav || optimisticPresented) {
605
618
  // Content-hold commit, two triggers. Fully-prefetched nav: the payload
606
619
  // is fully resolved (forceAwait above), so the transition commits
607
620
  // synchronously — no fallback flash. Same-structure nav: the re-run
@@ -620,8 +633,14 @@ export function createPartialUpdater(
620
633
  // instead of revealing that boundary's fallback; its render happens
621
634
  // pre-commit inside the transition, so userland effects cannot run
622
635
  // first. Boundaries newly mounted by this nav still reveal their
623
- // 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.
624
640
  startTransition(() => {
641
+ if (optimisticPresented && addTransitionType) {
642
+ addTransitionType(OPTIMISTIC_COMMIT_TRANSITION_TYPE);
643
+ }
625
644
  onUpdate({
626
645
  root: newTree,
627
646
  metadata: payload.metadata!,
@@ -3,6 +3,7 @@
3
3
  import { useContext, useState, useEffect, useRef } from "react";
4
4
  import { NavigationStoreContext } from "./context.js";
5
5
  import { shallowEqual } from "./shallow-equal.js";
6
+ import { OptimisticLocationContext } from "../../client-urls/optimistic-location.js";
6
7
 
7
8
  const EMPTY_PARAMS: Record<string, string> = Object.freeze({});
8
9
 
@@ -10,7 +11,9 @@ const EMPTY_PARAMS: Record<string, string> = Object.freeze({});
10
11
  * Hook to access the current route params.
11
12
  *
12
13
  * Returns the merged route params from the matched route.
13
- * Updates when navigation completes, not during pending navigation.
14
+ * Updates when navigation completes, not during pending navigation — except
15
+ * inside an optimistically rendered clientUrls() destination, where it
16
+ * reports THAT route's params (see OptimisticLocationContext).
14
17
  *
15
18
  * @example
16
19
  * ```tsx
@@ -43,6 +46,7 @@ export function useParams<T>(
43
46
  selector?: (params: Record<string, string | undefined>) => T,
44
47
  ): T | Record<string, string | undefined> {
45
48
  const ctx = useContext(NavigationStoreContext);
49
+ const optimistic = useContext(OptimisticLocationContext);
46
50
 
47
51
  const [value, setValue] = useState<T | Record<string, string>>(() => {
48
52
  const params = ctx ? ctx.eventController.getParams() : EMPTY_PARAMS;
@@ -71,5 +75,8 @@ export function useParams<T>(
71
75
  return ctx.eventController.subscribe(update);
72
76
  }, []);
73
77
 
78
+ if (optimistic) {
79
+ return selector ? selector(optimistic.params) : optimistic.params;
80
+ }
74
81
  return value;
75
82
  }
@@ -2,12 +2,15 @@
2
2
 
3
3
  import { useContext, useState, useEffect, useRef } from "react";
4
4
  import { NavigationStoreContext } from "./context.js";
5
+ import { OptimisticLocationContext } from "../../client-urls/optimistic-location.js";
5
6
 
6
7
  /**
7
8
  * Hook to access the current pathname.
8
9
  *
9
10
  * Returns the committed pathname string (excludes search params and hash).
10
- * Updates when navigation completes, not during pending navigation.
11
+ * Updates when navigation completes, not during pending navigation — except
12
+ * inside an optimistically rendered clientUrls() destination, where it
13
+ * reports THAT route's pathname (see OptimisticLocationContext).
11
14
  *
12
15
  * @example
13
16
  * ```tsx
@@ -17,6 +20,7 @@ import { NavigationStoreContext } from "./context.js";
17
20
  */
18
21
  export function usePathname(): string {
19
22
  const ctx = useContext(NavigationStoreContext);
23
+ const optimistic = useContext(OptimisticLocationContext);
20
24
 
21
25
  const [pathname, setPathname] = useState<string>(() => {
22
26
  if (!ctx) {
@@ -43,5 +47,5 @@ export function usePathname(): string {
43
47
  return ctx.eventController.subscribe(update);
44
48
  }, []);
45
49
 
46
- return pathname;
50
+ return optimistic ? optimistic.pathname : pathname;
47
51
  }
@@ -10,6 +10,7 @@ import {
10
10
  } from "react";
11
11
  import { NavigationStoreContext } from "./context.js";
12
12
  import type { ReadonlyURLSearchParams } from "../types.js";
13
+ import { OptimisticLocationContext } from "../../client-urls/optimistic-location.js";
13
14
 
14
15
  /**
15
16
  * Accepted shapes for the setter: a full replacement for the search string.
@@ -110,6 +111,7 @@ function normalizeInit(init: SearchParamsInit): URLSearchParams {
110
111
  */
111
112
  export function useSearchParams(): [ReadonlyURLSearchParams, SetSearchParams] {
112
113
  const ctx = useContext(NavigationStoreContext);
114
+ const optimistic = useContext(OptimisticLocationContext);
113
115
 
114
116
  // Seed from the store location on BOTH sides (mirrors usePathname): the
115
117
  // SSR store carries the live request's search, the browser store carries
@@ -167,8 +169,17 @@ export function useSearchParams(): [ReadonlyURLSearchParams, SetSearchParams] {
167
169
  });
168
170
  }, []);
169
171
 
172
+ // Inside an optimistically rendered clientUrls() destination the read side
173
+ // is THAT route's search (see OptimisticLocationContext); the setter keeps
174
+ // navigating from the committed location.
175
+ const optimisticSearch = optimistic?.search;
170
176
  return useMemo(
171
- () => [searchParams, setSearchParams],
172
- [searchParams, setSearchParams],
177
+ () => [
178
+ optimisticSearch === undefined
179
+ ? searchParams
180
+ : new URLSearchParams(optimisticSearch),
181
+ setSearchParams,
182
+ ],
183
+ [searchParams, setSearchParams, optimisticSearch],
173
184
  );
174
185
  }
@@ -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
  }),
@@ -3,7 +3,10 @@
3
3
  import {
4
4
  createElement,
5
5
  Fragment,
6
+ Suspense,
7
+ useDeferredValue,
6
8
  useEffect,
9
+ useMemo,
7
10
  useState,
8
11
  type ReactNode,
9
12
  } from "react";
@@ -14,12 +17,18 @@ import {
14
17
  registerClientUrlGroup,
15
18
  type ClientUrlNavigationIntent,
16
19
  } from "./navigation.js";
20
+ import {
21
+ OptimisticLocationContext,
22
+ type OptimisticLocation,
23
+ } from "./optimistic-location.js";
17
24
  import type {
18
25
  ClientUrlInterceptRecord,
19
26
  ClientUrlPatterns,
20
27
  ClientUrlRouteRecord,
21
28
  } from "./types.js";
22
29
 
30
+ const PENDING_FOREVER: Promise<never> = new Promise<never>(() => {});
31
+
23
32
  function findRoute(
24
33
  definition: ClientUrlPatterns,
25
34
  routeId: string,
@@ -120,29 +129,65 @@ export function ClientUrlsRoot({
120
129
  [definition, mount, namePrefix],
121
130
  );
122
131
 
123
- const pendingRoute =
124
- intent && intent.routeId !== routeId
125
- ? findRoute(definition, intent.routeId)
126
- : null;
127
- // Presence must mirror the projection's hasLoading (`loading !== undefined`
128
- // in server-projection.ts): a falsy-but-valid node like loading("") is still
129
- // a configured destination loading state, not an absent one.
130
- const hasPendingLoading =
131
- pendingRoute !== null && pendingRoute.loading !== undefined;
132
- const route = hasPendingLoading
133
- ? pendingRoute
134
- : findRoute(definition, routeId);
135
- // ANY in-flight group navigation is pending — including same-route navs
136
- // (intent.routeId === routeId). For the search-only shape (filters, tabs)
137
- // the canonical commit is HELD in a transition (isSameStructureNav in
138
- // partial-update.ts) with no content swap to signal progress — this flag
139
- // is the only affordance. The urgent setIntent at nav start flips it
140
- // immediately; the transition-wrapped clear() entangles with the held
141
- // commit, so pending drops exactly when the data lands.
132
+ // Optimistic destination (design: docs/design/client-urls-optimistic-destination.md).
133
+ // `intent` is set urgently at navigation start so `pending` flips at once
134
+ // for chrome; the CONTENT swap keys off the deferred value so it renders in
135
+ // a transition lane: a destination that suspends with no boundary of its
136
+ // own keeps the previous content visible (React's transition hold the
137
+ // pre-existing contract for routes without loading()), one with loading()
138
+ // or inline <Suspense> at its reads presents immediately. Same-route intents
139
+ // never swap: held data + transition() own that case.
142
140
  const pending = intent !== null;
143
- let content: ReactNode = hasPendingLoading
144
- ? pendingRoute.loading
145
- : createElement(route.component, { key: route.id });
141
+ const presented = useDeferredValue(intent);
142
+ const optimisticRoute =
143
+ presented && presented.routeId !== routeId
144
+ ? findRoute(definition, presented.routeId)
145
+ : null;
146
+ const route = optimisticRoute ?? findRoute(definition, routeId);
147
+
148
+ // Pending entries for the destination's loaders: useLoader use()s a Promise
149
+ // found in `loaderStreams` (the streaming-loader lane), so a read suspends
150
+ // instead of throwing "not found in context". Nothing resolves them — the
151
+ // canonical commit mounts the destination's own segment with real data and
152
+ // unmounts this branch — so one shared promise serves every loader.
153
+ // Memoized on the intent: use() needs a stable identity across replays.
154
+ const optimistic = useMemo<{
155
+ streams: Record<string, Promise<never>>;
156
+ location: OptimisticLocation;
157
+ } | null>(
158
+ () =>
159
+ optimisticRoute && presented
160
+ ? {
161
+ streams: Object.fromEntries(
162
+ optimisticRoute.loaders.map((record) => [
163
+ record.loader.$$id,
164
+ PENDING_FOREVER,
165
+ ]),
166
+ ),
167
+ location: {
168
+ params: presented.params,
169
+ pathname: presented.pathname,
170
+ search: presented.search,
171
+ },
172
+ }
173
+ : null,
174
+ [optimisticRoute, presented],
175
+ );
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.
181
+ let content: ReactNode = createElement(route.component, { key: route.id });
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).
186
+ content = createElement(Suspense, {
187
+ fallback: route.loading,
188
+ children: content,
189
+ });
190
+ }
146
191
 
147
192
  for (let index = route.layouts.length - 1; index >= 0; index--) {
148
193
  const layoutKey = `${route.id}-layout-${index}`;
@@ -154,7 +199,15 @@ export function ClientUrlsRoot({
154
199
  });
155
200
  }
156
201
 
157
- 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
+ });
158
211
  }
159
212
 
160
213
  export function ClientUrlsLoading({
@@ -11,6 +11,11 @@ import type { ClientUrlPatterns } from "./types.js";
11
11
 
12
12
  export interface ClientUrlNavigationIntent {
13
13
  readonly routeId: string;
14
+ /** Destination params from the local trie match (definition-local). */
15
+ readonly params: Readonly<Record<string, string>>;
16
+ /** Absolute destination pathname (mount included) and search ("?..." or ""). */
17
+ readonly pathname: string;
18
+ readonly search: string;
14
19
  }
15
20
 
16
21
  interface ActiveClientUrlGroup {
@@ -112,7 +117,12 @@ export function beginClientUrlNavigation(
112
117
  if (activeInterceptTargets.has(canonicalName)) return null;
113
118
  }
114
119
 
115
- const intent: ClientUrlNavigationIntent = { routeId: match.routeKey };
120
+ const intent: ClientUrlNavigationIntent = {
121
+ routeId: match.routeKey,
122
+ params: match.params,
123
+ pathname: targetUrl.pathname,
124
+ search: targetUrl.search,
125
+ };
116
126
  group.intent = intent;
117
127
  group.setIntent(intent);
118
128
 
@@ -0,0 +1,22 @@
1
+ "use client";
2
+
3
+ import { createContext, type Context } from "react";
4
+
5
+ /**
6
+ * Route identity of an optimistically rendered clientUrls() destination:
7
+ * the values the local trie match produced for the URL the user navigated
8
+ * to. Provided by ClientUrlsRoot around the optimistic branch ONLY, so
9
+ * useParams / usePathname / useSearchParams inside that branch describe the
10
+ * route being rendered while the same hooks in chrome outside it keep the
11
+ * committed location until the canonical response commits (or redirects, in
12
+ * which case the branch — and these values — are discarded).
13
+ */
14
+ export interface OptimisticLocation {
15
+ readonly params: Readonly<Record<string, string>>;
16
+ readonly pathname: string;
17
+ /** Search string including the leading "?" (or ""). */
18
+ readonly search: string;
19
+ }
20
+
21
+ export const OptimisticLocationContext: Context<OptimisticLocation | null> =
22
+ createContext<OptimisticLocation | null>(null);
@@ -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
  : {}),
@@ -1,6 +1,7 @@
1
1
  import * as React from "react";
2
2
  import { createElement, type ReactNode, type ComponentType } from "react";
3
3
  import { OutletProvider } from "./outlet-provider.js";
4
+ import { withOptimisticCommitNone } from "./browser/optimistic-commit.js";
4
5
  import { MountContextProvider } from "./browser/react/mount-context.js";
5
6
  import type { ResolvedSegment, RootLayoutProps } from "./types.js";
6
7
  import { decodeLoaderResults } from "./decode-loader-results.js";
@@ -145,6 +146,20 @@ function createViewTransitionBoundary(
145
146
  const { viewTransition: _viewTransition, ...vtProps } = transition;
146
147
  return createElement(ReactViewTransition, {
147
148
  ...vtProps,
149
+ // The commit after an optimistic clientUrls() presentation repaints the
150
+ // same pixels; it must not animate a second time (browser/optimistic-commit.ts).
151
+ // `default` always carries the mapping; an unset direction already falls
152
+ // through to it, so only set directions need their own merge.
153
+ ...(vtProps.enter !== undefined && {
154
+ enter: withOptimisticCommitNone(vtProps.enter),
155
+ }),
156
+ ...(vtProps.exit !== undefined && {
157
+ exit: withOptimisticCommitNone(vtProps.exit),
158
+ }),
159
+ ...(vtProps.update !== undefined && {
160
+ update: withOptimisticCommitNone(vtProps.update),
161
+ }),
162
+ default: withOptimisticCommitNone(vtProps.default),
148
163
  children,
149
164
  });
150
165
  }
@@ -335,6 +350,13 @@ export async function renderSegments(
335
350
  `Expected layout, route, error, or notFound segment, got ${node.segment.type}`,
336
351
  );
337
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;
338
360
  const segNodeStart = segDebug ? performance.now() : 0;
339
361
 
340
362
  // Param-agnostic keys are opt-in via the transition() DSL (see
@@ -370,7 +392,10 @@ export async function renderSegments(
370
392
  .map(([k, v]) => `${k}=${v}`)
371
393
  .join(",")
372
394
  : "";
373
- 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;
374
399
 
375
400
  const loaderEntries = node.loaders.filter(
376
401
  (loader) => loader.loaderId && loader.loaderData !== undefined,
@@ -388,7 +413,7 @@ export async function renderSegments(
388
413
  }
389
414
 
390
415
  let nodeContent: ReactNode = null;
391
- if (isRenderableLoading(loading)) {
416
+ if (!clientGroup && isRenderableLoading(loading)) {
392
417
  // forceAwait (popstate, stale-revalidation, fully-prefetched nav) renders a
393
418
  // loading() route with the route content ALREADY resolved, so its
394
419
  // RouteContentWrapper Suspender does not suspend for a microtask and flash
@@ -484,13 +509,14 @@ export async function renderSegments(
484
509
  // escaping to app boundaries. Wrapped UNCONDITIONALLY on loader presence
485
510
  // — streams and forceAwait lanes must produce the same tree shape or
486
511
  // lane changes remount the subtree (docs/tree-structure.md).
487
- if (loaderEntries.length > 0) {
512
+ if (loaderEntries.length > 0 || clientGroup) {
488
513
  nodeContent = createElement(StreamedLoaderErrorBoundary, {
514
+ resetKey: idParamsKey,
489
515
  children: nodeContent,
490
516
  });
491
517
  }
492
518
 
493
- if (loading !== undefined && loading !== null) {
519
+ if (!clientGroup && loading !== undefined && loading !== null) {
494
520
  const loaderDataPromise = getMemoizedLoaderPromise(loaderEntries);
495
521
  let boundaryLoaderData: Promise<any[]> | any[] = loaderDataPromise;
496
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
  /**