@rangojs/router 0.5.2 → 0.7.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 (178) hide show
  1. package/dist/bin/rango.js +343 -125
  2. package/dist/types/browser/react/use-router.d.ts +10 -3
  3. package/dist/types/browser/react/use-search-params.d.ts +57 -10
  4. package/dist/types/browser/types.d.ts +22 -0
  5. package/dist/types/build/merge-full-manifests.d.ts +3 -0
  6. package/dist/types/build/route-trie.d.ts +4 -73
  7. package/dist/types/build/route-types/per-module-writer.d.ts +6 -4
  8. package/dist/types/build/route-types/router-processing.d.ts +2 -3
  9. package/dist/types/cache/cache-exec-scope.d.ts +31 -0
  10. package/dist/types/cache/taint.d.ts +12 -6
  11. package/dist/types/client-urls/client-root.d.ts +38 -0
  12. package/dist/types/client-urls/client-urls.d.ts +5 -0
  13. package/dist/types/client-urls/navigation.d.ts +38 -0
  14. package/dist/types/client-urls/revalidation-protocol.d.ts +25 -0
  15. package/dist/types/client-urls/server-projection.d.ts +71 -0
  16. package/dist/types/client-urls/types.d.ts +147 -0
  17. package/dist/types/client.d.ts +12 -4
  18. package/dist/types/client.rsc.d.ts +4 -1
  19. package/dist/types/decode-loader-results.d.ts +37 -0
  20. package/dist/types/errors.d.ts +1 -0
  21. package/dist/types/index.d.ts +1 -1
  22. package/dist/types/loader-redirect.d.ts +27 -0
  23. package/dist/types/outlet-context.d.ts +12 -0
  24. package/dist/types/outlet-provider.d.ts +3 -1
  25. package/dist/types/redirect-origin.d.ts +4 -0
  26. package/dist/types/route-content-wrapper.d.ts +42 -1
  27. package/dist/types/route-definition/helpers-types.d.ts +13 -2
  28. package/dist/types/router/error-handling.d.ts +35 -1
  29. package/dist/types/router/intercept-resolution.d.ts +12 -0
  30. package/dist/types/router/loader-resolution.d.ts +24 -2
  31. package/dist/types/router/revalidation.d.ts +7 -0
  32. package/dist/types/router/route-trie-builder.d.ts +77 -0
  33. package/dist/types/router/router-interfaces.d.ts +20 -0
  34. package/dist/types/router/segment-resolution/helpers.d.ts +1 -1
  35. package/dist/types/router/segment-resolution/loader-mask.d.ts +9 -9
  36. package/dist/types/router/trie-matching.d.ts +1 -1
  37. package/dist/types/rsc/manifest-init.d.ts +5 -5
  38. package/dist/types/rsc/shell-capture.d.ts +9 -0
  39. package/dist/types/rsc/shell-serve.d.ts +11 -0
  40. package/dist/types/rsc/types.d.ts +30 -0
  41. package/dist/types/segment-system.d.ts +2 -0
  42. package/dist/types/server/context.d.ts +10 -0
  43. package/dist/types/server/handle-store.d.ts +34 -3
  44. package/dist/types/server/request-context.d.ts +11 -1
  45. package/dist/types/server.d.ts +1 -0
  46. package/dist/types/ssr/index.d.ts +22 -0
  47. package/dist/types/ssr/ssr-root.d.ts +10 -0
  48. package/dist/types/testing/dom.entry.d.ts +1 -1
  49. package/dist/types/testing/render-route.d.ts +16 -6
  50. package/dist/types/testing/run-loader.d.ts +9 -0
  51. package/dist/types/types/boundaries.d.ts +22 -0
  52. package/dist/types/types/index.d.ts +1 -1
  53. package/dist/types/types/loader-types.d.ts +57 -5
  54. package/dist/types/types/segments.d.ts +7 -0
  55. package/dist/types/urls/path-helper-types.d.ts +13 -4
  56. package/dist/types/vite/discovery/client-urls-projection.d.ts +53 -0
  57. package/dist/types/vite/discovery/discover-routers.d.ts +1 -1
  58. package/dist/types/vite/discovery/state.d.ts +8 -1
  59. package/dist/types/vite/plugins/client-ref-dedup.d.ts +0 -11
  60. package/dist/vite/index.js +6159 -2959
  61. package/package.json +6 -5
  62. package/skills/breadcrumbs/SKILL.md +39 -9
  63. package/skills/catalog.json +7 -1
  64. package/skills/client-urls/SKILL.md +338 -0
  65. package/skills/comparison/references/framework-comparison.md +23 -9
  66. package/skills/hooks/SKILL.md +2 -2
  67. package/skills/hooks/data.md +11 -2
  68. package/skills/hooks/handle-and-actions.md +7 -0
  69. package/skills/hooks/outlets.md +26 -5
  70. package/skills/hooks/urls.md +40 -3
  71. package/skills/loader/SKILL.md +132 -20
  72. package/skills/migrate-nextjs/SKILL.md +70 -10
  73. package/skills/migrate-react-router/SKILL.md +49 -13
  74. package/skills/migrate-react-router/component-migration.md +18 -13
  75. package/skills/migrate-react-router/data-and-actions.md +14 -3
  76. package/skills/migrate-react-router/route-mapping.md +15 -2
  77. package/skills/parallel/SKILL.md +32 -1
  78. package/skills/ppr/SKILL.md +16 -6
  79. package/skills/prerender/SKILL.md +8 -4
  80. package/skills/rango/SKILL.md +21 -17
  81. package/skills/react-compiler/SKILL.md +3 -3
  82. package/skills/route/SKILL.md +5 -2
  83. package/skills/router-setup/SKILL.md +16 -2
  84. package/skills/scripts/SKILL.md +16 -6
  85. package/skills/shell-manifest/SKILL.md +16 -7
  86. package/skills/testing/SKILL.md +2 -2
  87. package/skills/testing/client-components.md +6 -0
  88. package/skills/testing/handles.md +30 -8
  89. package/skills/testing/loader.md +51 -49
  90. package/skills/testing/middleware.md +1 -1
  91. package/skills/theme/SKILL.md +8 -5
  92. package/src/bin/rango.ts +7 -3
  93. package/src/browser/navigation-bridge.ts +6 -0
  94. package/src/browser/navigation-client.ts +5 -0
  95. package/src/browser/partial-update.ts +65 -13
  96. package/src/browser/react/use-router.ts +40 -11
  97. package/src/browser/react/use-search-params.ts +140 -17
  98. package/src/browser/rsc-router.tsx +59 -0
  99. package/src/browser/server-action-bridge.ts +26 -0
  100. package/src/browser/types.ts +22 -0
  101. package/src/build/merge-full-manifests.ts +161 -0
  102. package/src/build/route-trie.ts +9 -332
  103. package/src/build/route-types/include-resolution.ts +66 -11
  104. package/src/build/route-types/per-module-writer.ts +11 -6
  105. package/src/build/route-types/router-processing.ts +184 -153
  106. package/src/build/runtime-discovery.ts +23 -12
  107. package/src/cache/cache-exec-scope.ts +47 -0
  108. package/src/cache/cache-runtime.ts +24 -25
  109. package/src/cache/taint.ts +28 -9
  110. package/src/client-urls/client-root.tsx +168 -0
  111. package/src/client-urls/client-urls.ts +776 -0
  112. package/src/client-urls/navigation.ts +237 -0
  113. package/src/client-urls/revalidation-protocol.ts +56 -0
  114. package/src/client-urls/server-projection.ts +670 -0
  115. package/src/client-urls/types.ts +201 -0
  116. package/src/client.rsc.tsx +12 -0
  117. package/src/client.tsx +49 -6
  118. package/src/decode-loader-results.ts +113 -0
  119. package/src/errors.ts +14 -0
  120. package/src/handles/deferred-resolution.ts +14 -7
  121. package/src/index.ts +1 -0
  122. package/src/loader-redirect.tsx +64 -0
  123. package/src/outlet-context.ts +12 -0
  124. package/src/outlet-provider.tsx +15 -1
  125. package/src/redirect-origin.ts +29 -0
  126. package/src/route-content-wrapper.tsx +96 -3
  127. package/src/route-definition/dsl-helpers.ts +28 -3
  128. package/src/route-definition/helpers-types.ts +13 -0
  129. package/src/route-definition/redirect.ts +17 -18
  130. package/src/router/error-handling.ts +65 -11
  131. package/src/router/intercept-resolution.ts +29 -0
  132. package/src/router/loader-resolution.ts +261 -28
  133. package/src/router/match-result.ts +7 -0
  134. package/src/router/revalidation.ts +24 -11
  135. package/src/router/route-trie-builder.ts +334 -0
  136. package/src/router/router-interfaces.ts +38 -0
  137. package/src/router/segment-resolution/fresh.ts +55 -2
  138. package/src/router/segment-resolution/helpers.ts +9 -11
  139. package/src/router/segment-resolution/loader-cache.ts +34 -23
  140. package/src/router/segment-resolution/loader-mask.ts +9 -9
  141. package/src/router/segment-resolution/revalidation.ts +23 -2
  142. package/src/router/trie-matching.ts +3 -3
  143. package/src/router.ts +46 -1
  144. package/src/rsc/full-payload.ts +6 -0
  145. package/src/rsc/handler.ts +10 -7
  146. package/src/rsc/loader-fetch.ts +2 -2
  147. package/src/rsc/manifest-init.ts +28 -9
  148. package/src/rsc/rsc-rendering.ts +15 -1
  149. package/src/rsc/shell-capture.ts +12 -0
  150. package/src/rsc/shell-serve.ts +15 -2
  151. package/src/rsc/ssr-setup.ts +10 -1
  152. package/src/rsc/types.ts +31 -2
  153. package/src/segment-system.tsx +83 -26
  154. package/src/server/context.ts +10 -0
  155. package/src/server/cookie-store.ts +19 -19
  156. package/src/server/handle-store.ts +185 -48
  157. package/src/server/request-context.ts +30 -6
  158. package/src/server.ts +7 -0
  159. package/src/ssr/index.tsx +37 -2
  160. package/src/ssr/ssr-root.tsx +29 -2
  161. package/src/testing/dom.entry.ts +1 -1
  162. package/src/testing/render-route.tsx +22 -8
  163. package/src/testing/run-loader.ts +51 -13
  164. package/src/types/boundaries.ts +19 -0
  165. package/src/types/index.ts +1 -0
  166. package/src/types/loader-types.ts +60 -5
  167. package/src/types/segments.ts +7 -0
  168. package/src/urls/include-helper.ts +22 -4
  169. package/src/urls/path-helper-types.ts +17 -1
  170. package/src/use-loader.tsx +67 -6
  171. package/src/vite/discovery/client-urls-projection.ts +322 -0
  172. package/src/vite/discovery/discover-routers.ts +43 -17
  173. package/src/vite/discovery/state.ts +11 -1
  174. package/src/vite/discovery/virtual-module-codegen.ts +20 -0
  175. package/src/vite/plugins/client-ref-dedup.ts +281 -19
  176. package/src/vite/plugins/expose-action-id.ts +45 -23
  177. package/src/vite/plugins/virtual-entries.ts +12 -3
  178. package/src/vite/router-discovery.ts +163 -12
@@ -33,16 +33,15 @@ const router = createRouter<Env>({
33
33
  });
34
34
  ```
35
35
 
36
- ## Server (in loaders/middleware)
36
+ ## Server (in handlers/middleware)
37
37
 
38
38
  ```typescript
39
- import { createLoader } from "@rangojs/router";
40
39
  import type { Middleware } from "@rangojs/router";
41
40
 
42
- // In a loader
43
- export const SettingsLoader = createLoader(async (ctx) => {
41
+ // In a handler
42
+ path("/settings", (ctx) => {
44
43
  const currentTheme = ctx.theme; // read from cookie
45
- return { theme: currentTheme };
44
+ return <SettingsPage theme={currentTheme} />;
46
45
  });
47
46
 
48
47
  // In middleware
@@ -53,6 +52,10 @@ export const themeMiddleware: Middleware = async (ctx, next) => {
53
52
  };
54
53
  ```
55
54
 
55
+ Theme accessors are handler/middleware-only — the loader context has no
56
+ `ctx.theme`/`ctx.setTheme`. A loader that needs the theme reads the cookie
57
+ directly via `cookies()` (loaders always run fresh, so the read is safe).
58
+
56
59
  ## Client
57
60
 
58
61
  ```tsx
package/src/bin/rango.ts CHANGED
@@ -144,7 +144,10 @@ function runStaticGeneration(args: string[], mode: "default" | "static") {
144
144
  if (/\bcreateRouter\s*[<(]/.test(source)) {
145
145
  routerFiles.push(filePath);
146
146
  }
147
- if (source.includes("urls(")) {
147
+ // Both spellings, matching the writer's own gate: "clientUrls(" does
148
+ // NOT contain the lowercase "urls(" token (capital U), so a
149
+ // clientUrls-only module fails the server-token sniff alone.
150
+ if (source.includes("urls(") || source.includes("clientUrls(")) {
148
151
  urlsFiles.push(filePath);
149
152
  }
150
153
  } catch (err) {
@@ -255,8 +258,9 @@ async function runRuntimeDiscovery(args: string[], configFile?: string) {
255
258
  if (/\bcreateRouter\s*[<(]/.test(source)) {
256
259
  routerEntries.push(filePath);
257
260
  }
258
- // Also generate per-module types for urls files
259
- if (source.includes("urls(")) {
261
+ // Also generate per-module types for urls files. Both spellings — a
262
+ // clientUrls-only module has no lowercase "urls(" token (capital U).
263
+ if (source.includes("urls(") || source.includes("clientUrls(")) {
260
264
  writePerModuleRouteTypesForFile(filePath);
261
265
  }
262
266
  } catch {
@@ -48,6 +48,7 @@ import {
48
48
  schedulePrefetchWhenRouterIdle,
49
49
  } from "./prefetch/loader.js";
50
50
  import type { PrefetchStrategy } from "../router/prefetch-default.js";
51
+ import { beginClientUrlNavigation } from "../client-urls/navigation.js";
51
52
 
52
53
  // Polyfill Symbol.dispose for Safari and older browsers
53
54
  if (typeof Symbol.dispose === "undefined") {
@@ -342,6 +343,10 @@ export function createNavigationBridge(
342
343
  state: resolvedState,
343
344
  skipLoadingState: false,
344
345
  });
346
+ const clientUrlPresentation = beginClientUrlNavigation(
347
+ targetUrl,
348
+ tx.handle.signal,
349
+ );
345
350
 
346
351
  // REVALIDATE: Fetch fresh data from server
347
352
  try {
@@ -422,6 +427,7 @@ export function createNavigationBridge(
422
427
  console.error("[Browser] Unprocessable navigation response:", error);
423
428
  emitNavigationError(onUpdate, error, url);
424
429
  } finally {
430
+ clientUrlPresentation?.clear();
425
431
  tx[Symbol.dispose]();
426
432
  }
427
433
  },
@@ -14,6 +14,7 @@ import {
14
14
  } from "./logging.js";
15
15
  import { getRangoState } from "./rango-state.js";
16
16
  import { isActionFenceActive } from "./action-fence.js";
17
+ import { CLIENT_REVALIDATION_HEADER } from "../client-urls/revalidation-protocol.js";
17
18
  import {
18
19
  expandPayloadFragments,
19
20
  SEGMENT_FRAGMENT_CAPABILITY_HEADER,
@@ -79,6 +80,7 @@ export function createNavigationClient(
79
80
  previousUrl,
80
81
  signal,
81
82
  staleRevalidation,
83
+ clientRevalidation,
82
84
  interceptSourceUrl,
83
85
  version,
84
86
  routerId,
@@ -261,6 +263,9 @@ export function createNavigationClient(
261
263
  ...(fragmentRecovery && {
262
264
  [SEGMENT_FRAGMENT_RECOVERY_HEADER]: "1",
263
265
  }),
266
+ ...(clientRevalidation && {
267
+ [CLIENT_REVALIDATION_HEADER]: clientRevalidation,
268
+ }),
264
269
  ...(tx && { "X-RSC-Router-Request-Id": tx.requestId }),
265
270
  ...(interceptSourceUrl && {
266
271
  "X-RSC-Router-Intercept-Source": interceptSourceUrl,
@@ -30,6 +30,10 @@ import {
30
30
  validateExternalRedirect,
31
31
  } from "./validate-redirect-origin.js";
32
32
  import type { NavigationUpdate } from "./types.js";
33
+ import {
34
+ collectClientRevalidationDecisions,
35
+ setActiveInterceptTargets,
36
+ } from "../client-urls/navigation.js";
33
37
 
34
38
  function toScrollPayload(
35
39
  scroll: boolean | undefined,
@@ -187,6 +191,22 @@ export function createPartialUpdater(
187
191
  );
188
192
  }
189
193
 
194
+ // Client-run per-loader revalidation: execute the held clientUrls route's
195
+ // revalidate() predicates now and ship their decisions with the request.
196
+ // Fails soft to null (locked server defaults) when no group is active or
197
+ // URLs do not parse.
198
+ let clientRevalidation: string | null = null;
199
+ try {
200
+ clientRevalidation = collectClientRevalidationDecisions({
201
+ currentUrl: new URL(previousUrl, window.location.origin),
202
+ nextUrl: new URL(url, window.location.origin),
203
+ isAction: false,
204
+ stale: mode.type === "stale-revalidation",
205
+ });
206
+ } catch {
207
+ clientRevalidation = null;
208
+ }
209
+
190
210
  let fetchResult: Awaited<ReturnType<NavigationClient["fetchPartial"]>>;
191
211
  fetchResult = await client.fetchPartial({
192
212
  targetUrl: url,
@@ -194,6 +214,7 @@ export function createPartialUpdater(
194
214
  previousUrl,
195
215
  staleRevalidation:
196
216
  mode.type === "stale-revalidation" || segments.length === 0,
217
+ clientRevalidation,
197
218
  version: getVersion(),
198
219
  routerId: store.getRouterId?.(),
199
220
  });
@@ -467,6 +488,23 @@ export function createPartialUpdater(
467
488
  payload.metadata?.slots,
468
489
  );
469
490
 
491
+ // Same-structure revalidation: every segment this commit will render
492
+ // already exists on screen (by id) — the navigation mounts nothing new,
493
+ // it only refreshes what the user is looking at. The hold this enables
494
+ // manifests on SEARCH-only navs (filters, tabs — search is never in the
495
+ // segment key, so the subtree reconciles); a PARAM nav outside a
496
+ // transition() scope still remounts via its param-bearing key
497
+ // (segment-system.tsx) and reveals its fresh skeleton inside the
498
+ // transition, unchanged. Compared against the CURRENT page's cache, not
499
+ // cachedSegs: for a popstate restore cachedSegs is the TARGET's history
500
+ // cache, which says nothing about what is visible now. Restricted to
501
+ // plain navigations — leave-intercept must close its modal urgently,
502
+ // and action/stale modes have their own transition branch.
503
+ const onScreenIds = new Set(getCurrentCachedSegments().map((s) => s.id));
504
+ const isSameStructureNav =
505
+ mode.type === "navigate" &&
506
+ reconciled.segments.every((s) => onScreenIds.has(s.id));
507
+
470
508
  const effectiveInterceptSource =
471
509
  interceptSourceUrl || segmentState.currentUrl;
472
510
  if (mode.type !== "action" && mode.type !== "stale-revalidation") {
@@ -523,6 +561,13 @@ export function createPartialUpdater(
523
561
  all: reconciled.segments.map((s) => s.id),
524
562
  });
525
563
  }
564
+ // Refresh the browser-local intercept-target set for the location being
565
+ // committed — the clientUrls matcher declines optimistic presentation
566
+ // for targets an intercept would claim from here. Back/forward commits
567
+ // served purely from the history cache keep the previous set (no fresh
568
+ // metadata); popstate navigations get no optimistic presentation anyway.
569
+ setActiveInterceptTargets(payload.metadata?.interceptTargets);
570
+
526
571
  const scrollPayload = toScrollPayload(navScroll);
527
572
 
528
573
  if (mode.type === "action" || mode.type === "stale-revalidation") {
@@ -547,15 +592,22 @@ export function createPartialUpdater(
547
592
  scroll: scrollPayload,
548
593
  });
549
594
  });
550
- } else if (fullyPrefetched) {
551
- // Fully-prefetched nav: the payload is fully resolved (forceAwait
552
- // above), so commit inside a transition to hold the current UI across
553
- // the synchronous resolution — no fallback flash. No addTransitionType:
554
- // this is the React content-hold, not a view transition. Deliberate
555
- // trade-off (#622 introduced, #624 reverted, then reinstated): a client
556
- // component that suspends during its FIRST render (use() of a promise
557
- // created at mount see ClientMountSuspense in the e2e test-app) under
558
- // an ALREADY-REVEALED boundary holds the old content until it resolves
595
+ } else if (fullyPrefetched || isSameStructureNav) {
596
+ // Content-hold commit, two triggers. Fully-prefetched nav: the payload
597
+ // is fully resolved (forceAwait above), so the transition commits
598
+ // synchronously — no fallback flash. Same-structure nav: the re-run
599
+ // loaders are still streaming, and an urgent commit would re-suspend
600
+ // the ALREADY-REVEALED boundaries replacing visible content with its
601
+ // own fallback for the loader's full duration (the PLP filter-change
602
+ // flash). Inside a transition React holds the current UI until the
603
+ // suspended data lands, the action treatment; nothing new mounts (by
604
+ // the isSameStructureNav definition), so no fallback is being withheld
605
+ // from a first paint. No addTransitionType: this is the React
606
+ // content-hold, not a view transition. Deliberate trade-off (#622
607
+ // introduced, #624 reverted, then reinstated): a client component that
608
+ // suspends during its FIRST render (use() of a promise created at
609
+ // mount — see ClientMountSuspense in the e2e test-app) under an
610
+ // ALREADY-REVEALED boundary holds the old content until it resolves
559
611
  // instead of revealing that boundary's fallback; its render happens
560
612
  // pre-commit inside the transition, so userland effects cannot run
561
613
  // first. Boundaries newly mounted by this nav still reveal their
@@ -568,10 +620,10 @@ export function createPartialUpdater(
568
620
  });
569
621
  });
570
622
  } else {
571
- // Cold/partially-prefetched nav: normal commit so fallbacks stream
572
- // like a first load and the click has visible feedback. Explicit
573
- // transition() routes keep the content-hold via the hasTransition
574
- // branch above (the opt-in).
623
+ // Cold/partially-prefetched nav that mounts NEW segments: normal
624
+ // commit so fallbacks stream like a first load and the click has
625
+ // visible feedback. Explicit transition() routes keep the
626
+ // content-hold via the hasTransition branch above (the opt-in).
575
627
  onUpdate({
576
628
  root: newTree,
577
629
  metadata: payload.metadata!,
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useContext, useMemo } from "react";
4
4
  import { NavigationStoreContext } from "./context.js";
5
+ import { useMount } from "./use-mount.js";
5
6
  import { prefetchDirect } from "../prefetch/loader.js";
6
7
  import { getAppVersion } from "../app-version.js";
7
8
  import type { RouterInstance, RouterNavigateOptions } from "../types.js";
@@ -9,8 +10,8 @@ import type { RouterInstance, RouterNavigateOptions } from "../types.js";
9
10
  /**
10
11
  * Hook to access router actions (push, replace, refresh, prefetch, back, forward).
11
12
  *
12
- * Returns a STABLE reference that never changes, so components using
13
- * useRouter() do not re-render on navigation state changes.
13
+ * Returns a STABLE reference per component, so components using useRouter()
14
+ * do not re-render on navigation state changes.
14
15
  * For reactive navigation state, use useNavigation() instead.
15
16
  *
16
17
  * Methods read `basename` from the context on each call. It is set once from
@@ -18,10 +19,17 @@ import type { RouterInstance, RouterNavigateOptions } from "../types.js";
18
19
  * is a full document load (X-RSC-Reload), so the target app mounts fresh with
19
20
  * its own basename.
20
21
  *
22
+ * RELATIVE paths (no leading `/`, no scheme/query/hash) resolve against the
23
+ * current include() mount: `router.push("cart")` inside `include("/shop")`
24
+ * navigates to `/shop/cart`. Absolute paths stay APP-absolute — unlike
25
+ * basename, the mount is scoped, and absolute pushes legitimately target
26
+ * outside it, so they are never auto-prefixed.
27
+ *
21
28
  * @example
22
29
  * ```tsx
23
30
  * const router = useRouter();
24
- * router.push("/products");
31
+ * router.push("/products"); // app-absolute
32
+ * router.push("cart"); // mount-relative
25
33
  * router.replace("/login", { scroll: false });
26
34
  * router.prefetch("/dashboard");
27
35
  * router.back();
@@ -29,15 +37,17 @@ import type { RouterInstance, RouterNavigateOptions } from "../types.js";
29
37
  */
30
38
  export function useRouter(): RouterInstance {
31
39
  const ctx = useContext(NavigationStoreContext);
40
+ const mount = useMount();
32
41
 
33
42
  if (!ctx) {
34
43
  throw new Error("useRouter must be used within NavigationProvider");
35
44
  }
36
45
 
37
- // Stable reference: ctx itself is stable, and reads on each method call
38
- // pick up live basename values from the context (backed by a live ref
39
- // in NavigationProvider), so app-switch transitions are reflected without
40
- // recreating this object.
46
+ // Stable reference per component: ctx is stable and mount is a static
47
+ // property of the component's position in the tree; reads on each method
48
+ // call pick up live basename values from the context (backed by a live
49
+ // ref in NavigationProvider), so app-switch transitions are reflected
50
+ // without recreating this object.
41
51
  return useMemo<RouterInstance>(() => {
42
52
  /** Prefix a root-relative path with basename if not already prefixed. */
43
53
  function withBasename(url: string): string {
@@ -47,13 +57,32 @@ export function useRouter(): RouterInstance {
47
57
  return url === "/" ? bn : bn + url;
48
58
  }
49
59
 
60
+ /**
61
+ * Resolve a navigation/prefetch target. RELATIVE paths — a leading word
62
+ * character or `./`, never a scheme (`http:`, `mailto:`), query, or
63
+ * hash form — join onto the include mount and then take the basename
64
+ * pass like any root-relative path. Everything else passes through
65
+ * unchanged.
66
+ */
67
+ function resolveTarget(url: string): string {
68
+ const relative = url.startsWith("./") ? url.slice(2) : url;
69
+ if (
70
+ !/^[A-Za-z0-9]/.test(relative) ||
71
+ /^[a-z][a-z0-9+.-]*:/i.test(relative)
72
+ ) {
73
+ return withBasename(url);
74
+ }
75
+ const base = mount === "/" ? "" : mount.replace(/\/$/, "");
76
+ return withBasename(`${base}/${relative}`);
77
+ }
78
+
50
79
  return {
51
80
  push(url: string, options?: RouterNavigateOptions): Promise<void> {
52
- return ctx.navigate(withBasename(url), { ...options, replace: false });
81
+ return ctx.navigate(resolveTarget(url), { ...options, replace: false });
53
82
  },
54
83
 
55
84
  replace(url: string, options?: RouterNavigateOptions): Promise<void> {
56
- return ctx.navigate(withBasename(url), { ...options, replace: true });
85
+ return ctx.navigate(resolveTarget(url), { ...options, replace: true });
57
86
  },
58
87
 
59
88
  refresh(): Promise<void> {
@@ -64,7 +93,7 @@ export function useRouter(): RouterInstance {
64
93
  const segmentState = ctx.store?.getSegmentState();
65
94
  if (segmentState) {
66
95
  prefetchDirect(
67
- withBasename(url),
96
+ resolveTarget(url),
68
97
  segmentState.currentSegmentIds,
69
98
  getAppVersion(),
70
99
  ctx.store?.getRouterId?.(),
@@ -94,5 +123,5 @@ export function useRouter(): RouterInstance {
94
123
  window.history.forward();
95
124
  },
96
125
  };
97
- }, []);
126
+ }, [mount]);
98
127
  }
@@ -1,34 +1,129 @@
1
1
  "use client";
2
2
 
3
- import { useContext, useState, useEffect, useRef } from "react";
3
+ import {
4
+ useCallback,
5
+ useContext,
6
+ useEffect,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ } from "react";
4
11
  import { NavigationStoreContext } from "./context.js";
5
12
  import type { ReadonlyURLSearchParams } from "../types.js";
6
13
 
7
14
  /**
8
- * Hook to access the current URL search params.
15
+ * Accepted shapes for the setter: a full replacement for the search string.
16
+ * Record values are stringified; array values append one entry per element;
17
+ * null/undefined values are skipped (ergonomic conditional spreads).
18
+ */
19
+ export type SearchParamsInit =
20
+ | string
21
+ | URLSearchParams
22
+ | ReadonlyURLSearchParams
23
+ | Record<
24
+ string,
25
+ | string
26
+ | number
27
+ | boolean
28
+ | readonly (string | number | boolean)[]
29
+ | null
30
+ | undefined
31
+ >;
32
+
33
+ export interface SetSearchParamsOptions {
34
+ /** Replace the current history entry instead of pushing (default false). */
35
+ replace?: boolean;
36
+ /** Scroll behavior for the navigation (default: router default, scroll). */
37
+ scroll?: boolean;
38
+ /**
39
+ * Set false to skip the server fetch and only update the URL (default
40
+ * true). Purely client-derived search state (open accordions, view modes)
41
+ * needs no loader re-run; all location-aware hooks still update. Same
42
+ * contract as NavigateOptions.revalidate — it only applies because the
43
+ * setter never changes the pathname.
44
+ */
45
+ revalidate?: boolean;
46
+ }
47
+
48
+ export type SetSearchParams = (
49
+ init: SearchParamsInit | ((prev: URLSearchParams) => SearchParamsInit),
50
+ options?: SetSearchParamsOptions,
51
+ ) => Promise<void>;
52
+
53
+ function normalizeInit(init: SearchParamsInit): URLSearchParams {
54
+ if (typeof init === "string") return new URLSearchParams(init);
55
+ if (init instanceof URLSearchParams) return new URLSearchParams(init);
56
+ const params = new URLSearchParams();
57
+ for (const [key, value] of Object.entries(init)) {
58
+ if (value == null) continue;
59
+ if (Array.isArray(value)) {
60
+ for (const entry of value) params.append(key, String(entry));
61
+ } else {
62
+ params.append(key, String(value));
63
+ }
64
+ }
65
+ return params;
66
+ }
67
+
68
+ /**
69
+ * Hook to read and write the current URL search params
70
+ * (React Router-style tuple).
9
71
  *
10
- * Returns a read-only URLSearchParams object from the committed location.
11
- * Updates when navigation completes, not during pending navigation.
72
+ * The first element is a read-only URLSearchParams from the COMMITTED
73
+ * location — it updates when navigation completes, not during a pending
74
+ * navigation. During document SSR it carries the LIVE request's search (the
75
+ * SSR store is seeded from SSRRenderOptions.search), and the browser's
76
+ * first render seeds from its own store location (window.location) — the
77
+ * same URL, so hydration matches. Ppr capture/resume renders seed the SHELL
78
+ * KEY's search (sorted, cache.searchParams filter applied — search is part
79
+ * of shell identity like every other ppr key), so static-part reads bake
80
+ * markup consistent with the shell's own key. Two edges: a param EXCLUDED
81
+ * by cache.searchParams is absent in shell renders but present in the
82
+ * browser (exclusion declares "does not affect markup" — reading one
83
+ * anyway hydration-mismatches), and toString() renders sorted order while
84
+ * the browser holds the raw URL order.
12
85
  *
13
- * Note: During SSR the search params are not available (the server only sends
14
- * the pathname). The hook returns empty params during SSR and syncs from
15
- * the browser URL on mount.
86
+ * The setter REPLACES the whole search string (React Router semantics) and
87
+ * navigates to the current pathname with the new params a same-route
88
+ * navigation, so route loaders re-evaluate per their revalidate() contract
89
+ * and the commit holds previous content (the same-structure transition
90
+ * lane). Pass a function to merge with the current params: it receives a
91
+ * MUTABLE copy read at call time. The hash is dropped, like React Router.
16
92
  *
17
93
  * @example
18
94
  * ```tsx
19
- * const searchParams = useSearchParams();
20
- * const query = searchParams.get("q"); // "react"
21
- * const page = searchParams.get("page"); // "2"
95
+ * const [searchParams, setSearchParams] = useSearchParams();
96
+ * const category = searchParams.get("category");
97
+ *
98
+ * // Replace the whole search string
99
+ * setSearchParams({ category: "electronics" });
100
+ *
101
+ * // Merge with what's there now
102
+ * setSearchParams((prev) => {
103
+ * prev.set("page", "2");
104
+ * return prev;
105
+ * });
106
+ *
107
+ * // Filter UIs usually want replace + preserved scroll
108
+ * setSearchParams({ category: "home" }, { replace: true, scroll: false });
22
109
  * ```
23
110
  */
24
- export function useSearchParams(): ReadonlyURLSearchParams {
111
+ export function useSearchParams(): [ReadonlyURLSearchParams, SetSearchParams] {
25
112
  const ctx = useContext(NavigationStoreContext);
26
113
 
27
- const [searchParams, setSearchParams] = useState<ReadonlyURLSearchParams>(
28
- () => new URLSearchParams(),
29
- );
114
+ // Seed from the store location on BOTH sides (mirrors usePathname): the
115
+ // SSR store carries the live request's search, the browser store carries
116
+ // window.location — same URL, so the hydration renders agree. No ctx
117
+ // (no provider) seeds empty.
118
+ const [searchParams, setSearchParamsState] =
119
+ useState<ReadonlyURLSearchParams>(() => {
120
+ const location = ctx?.eventController.getState().location as
121
+ | URL
122
+ | undefined;
123
+ return new URLSearchParams(location?.searchParams);
124
+ });
30
125
 
31
- const prevSearch = useRef("");
126
+ const prevSearch = useRef(searchParams.toString());
32
127
 
33
128
  useEffect(() => {
34
129
  if (!ctx) return;
@@ -38,7 +133,7 @@ export function useSearchParams(): ReadonlyURLSearchParams {
38
133
  const nextSearch = location.searchParams.toString();
39
134
  if (nextSearch !== prevSearch.current) {
40
135
  prevSearch.current = nextSearch;
41
- setSearchParams(new URLSearchParams(nextSearch));
136
+ setSearchParamsState(new URLSearchParams(nextSearch));
42
137
  }
43
138
  };
44
139
 
@@ -47,5 +142,33 @@ export function useSearchParams(): ReadonlyURLSearchParams {
47
142
  return ctx.eventController.subscribe(update);
48
143
  }, []);
49
144
 
50
- return searchParams;
145
+ // Reads the committed location at CALL time (not the render closure), so a
146
+ // functional init always merges against fresh params even when the caller's
147
+ // closure is stale.
148
+ const setSearchParams = useCallback<SetSearchParams>((init, options) => {
149
+ if (!ctx) {
150
+ throw new Error(
151
+ "useSearchParams setter must be used within NavigationProvider",
152
+ );
153
+ }
154
+ const location = ctx.eventController.getState().location as URL;
155
+ const resolved =
156
+ typeof init === "function"
157
+ ? init(new URLSearchParams(location.searchParams))
158
+ : init;
159
+ const search = normalizeInit(resolved).toString();
160
+ const url = search ? `${location.pathname}?${search}` : location.pathname;
161
+ return ctx.navigate(url, {
162
+ replace: options?.replace ?? false,
163
+ ...(options?.scroll !== undefined ? { scroll: options.scroll } : {}),
164
+ ...(options?.revalidate !== undefined
165
+ ? { revalidate: options.revalidate }
166
+ : {}),
167
+ });
168
+ }, []);
169
+
170
+ return useMemo(
171
+ () => [searchParams, setSearchParams],
172
+ [searchParams, setSearchParams],
173
+ );
51
174
  }
@@ -8,6 +8,7 @@ import {
8
8
  generateHistoryKey,
9
9
  } from "./navigation-store.js";
10
10
  import { createEventController } from "./event-controller.js";
11
+ import { resolveDeferredHandleValues } from "../handles/deferred-resolution.js";
11
12
  import { validateRedirectOrigin } from "./validate-redirect-origin.js";
12
13
  import { createNavigationClient } from "./navigation-client.js";
13
14
  import { createServerActionBridge } from "./server-action-bridge.js";
@@ -38,6 +39,7 @@ import {
38
39
  } from "./intercept-utils.js";
39
40
  import { createAppShellRef } from "./app-shell.js";
40
41
  import { bootLog, IS_BROWSER_DEBUG } from "./logging.js";
42
+ import { setActiveInterceptTargets } from "../client-urls/navigation.js";
41
43
 
42
44
  // Vite HMR types are provided by vite/client
43
45
 
@@ -205,6 +207,11 @@ export async function initBrowserApp(
205
207
  const version = initialPayload.metadata?.version;
206
208
  initRangoState(version ?? "0", initialPayload.metadata?.stateCookieName);
207
209
 
210
+ // Seed the intercept-target set for the initial location so the FIRST
211
+ // clientUrls navigation already declines intercepted targets (refreshed on
212
+ // every commit in partial-update.ts).
213
+ setActiveInterceptTargets(initialPayload.metadata?.interceptTargets);
214
+
208
215
  // Create navigation store with history-based caching
209
216
  const store = createNavigationStore({
210
217
  initialLocation: window.location,
@@ -268,6 +275,43 @@ export async function initBrowserApp(
268
275
  // Update the initial cache entry with the processed handleData
269
276
  // The cache entry was created by createNavigationStore but without handleData
270
277
  store.updateCacheHandleData(initialHistoryKey, lastHandleData);
278
+
279
+ // Late handle channel: pushes from streaming loader bodies that missed the
280
+ // handler barrier (ctx.handle() writes mid-body). Consumed NON-BLOCKING —
281
+ // hydration must not wait on loader bodies — and applied through the same
282
+ // controller/cache path as the initial snapshot, so useHandle readers
283
+ // re-render as meta/breadcrumbs stream in.
284
+ const lateGenerator = initialPayload.metadata?.handlesLate;
285
+ if (lateGenerator) {
286
+ void (async () => {
287
+ try {
288
+ // Never apply before React commits hydration — a pre-commit
289
+ // application mutates the state useHandle initializers read during
290
+ // the hydration render (mismatch). Yields buffer in the generator
291
+ // meanwhile; nothing is lost.
292
+ await hydrationCommitted;
293
+ for await (const rawLateData of lateGenerator) {
294
+ // Handles are async by design: a push(promise) value resolves
295
+ // whenever it resolves — only SSR'd handles are awaited server-
296
+ // side. The main document snapshot arrives server-resolved
297
+ // (resolvedHandleStream); this late channel is raw, so resolve
298
+ // deferred values here exactly like the nav lane's processHandles
299
+ // does before applying.
300
+ const lateData = await resolveDeferredHandleValues(rawLateData);
301
+ bootLog("handles: late push applied", {
302
+ segments: Object.keys(lateData),
303
+ });
304
+ eventController.setHandleData(
305
+ lateData,
306
+ initialPayload.metadata?.matched,
307
+ );
308
+ store.updateCacheHandleData(initialHistoryKey, lateData);
309
+ }
310
+ } catch (err) {
311
+ console.error("[rsc-router] Error consuming late handles:", err);
312
+ }
313
+ })();
314
+ }
271
315
  } else {
272
316
  bootLog("handles: none in payload");
273
317
  }
@@ -568,6 +612,18 @@ export async function initBrowserApp(
568
612
  // re-runs the root effect; the second flush is not a second hydration).
569
613
  let hydrationCommitLogged = false;
570
614
 
615
+ // Hydration-commit barrier for the late handle channel. useHandle's useState
616
+ // initializer reads the eventController's LIVE state during the hydration
617
+ // render, so applying a late handle update BEFORE hydration commits changes
618
+ // that state out from under React — the classic "external changing data"
619
+ // hydration mismatch (seen when a loader's push lost the handler-barrier race
620
+ // by milliseconds and its late yield landed mid-hydration). The root effect
621
+ // below resolves this; the late consumer awaits it before applying anything.
622
+ let resolveHydrationCommitted!: () => void;
623
+ const hydrationCommitted: Promise<void> = new Promise((resolve) => {
624
+ resolveHydrationCommitted = resolve;
625
+ });
626
+
571
627
  /**
572
628
  * Get the browser app context. Throws if initBrowserApp hasn't been called.
573
629
  */
@@ -643,6 +699,9 @@ export function Rango(_props: RangoProps): React.ReactElement {
643
699
  hydrationCommitLogged = true;
644
700
  bootLog("hydration commit (root effect flushed)");
645
701
  }
702
+ // Release the late handle channel (see hydrationCommitted above).
703
+ // StrictMode double-invoke: resolving twice is a no-op.
704
+ resolveHydrationCommitted();
646
705
  return cleanupPrefetch;
647
706
  }, [bridge, linkInterceptionEnabled]);
648
707