@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.
- package/dist/bin/rango.js +343 -125
- package/dist/types/browser/react/use-router.d.ts +10 -3
- package/dist/types/browser/react/use-search-params.d.ts +57 -10
- package/dist/types/browser/types.d.ts +22 -0
- package/dist/types/build/merge-full-manifests.d.ts +3 -0
- package/dist/types/build/route-trie.d.ts +4 -73
- package/dist/types/build/route-types/per-module-writer.d.ts +6 -4
- package/dist/types/build/route-types/router-processing.d.ts +2 -3
- package/dist/types/cache/cache-exec-scope.d.ts +31 -0
- package/dist/types/cache/taint.d.ts +12 -6
- package/dist/types/client-urls/client-root.d.ts +38 -0
- package/dist/types/client-urls/client-urls.d.ts +5 -0
- package/dist/types/client-urls/navigation.d.ts +38 -0
- package/dist/types/client-urls/revalidation-protocol.d.ts +25 -0
- package/dist/types/client-urls/server-projection.d.ts +71 -0
- package/dist/types/client-urls/types.d.ts +147 -0
- package/dist/types/client.d.ts +12 -4
- package/dist/types/client.rsc.d.ts +4 -1
- package/dist/types/decode-loader-results.d.ts +37 -0
- package/dist/types/errors.d.ts +1 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/loader-redirect.d.ts +27 -0
- package/dist/types/outlet-context.d.ts +12 -0
- package/dist/types/outlet-provider.d.ts +3 -1
- package/dist/types/redirect-origin.d.ts +4 -0
- package/dist/types/route-content-wrapper.d.ts +42 -1
- package/dist/types/route-definition/helpers-types.d.ts +13 -2
- package/dist/types/router/error-handling.d.ts +35 -1
- package/dist/types/router/intercept-resolution.d.ts +12 -0
- package/dist/types/router/loader-resolution.d.ts +24 -2
- package/dist/types/router/revalidation.d.ts +7 -0
- package/dist/types/router/route-trie-builder.d.ts +77 -0
- package/dist/types/router/router-interfaces.d.ts +20 -0
- package/dist/types/router/segment-resolution/helpers.d.ts +1 -1
- package/dist/types/router/segment-resolution/loader-mask.d.ts +9 -9
- package/dist/types/router/trie-matching.d.ts +1 -1
- package/dist/types/rsc/manifest-init.d.ts +5 -5
- package/dist/types/rsc/shell-capture.d.ts +9 -0
- package/dist/types/rsc/shell-serve.d.ts +11 -0
- package/dist/types/rsc/types.d.ts +30 -0
- package/dist/types/segment-system.d.ts +2 -0
- package/dist/types/server/context.d.ts +10 -0
- package/dist/types/server/handle-store.d.ts +34 -3
- package/dist/types/server/request-context.d.ts +11 -1
- package/dist/types/server.d.ts +1 -0
- package/dist/types/ssr/index.d.ts +22 -0
- package/dist/types/ssr/ssr-root.d.ts +10 -0
- package/dist/types/testing/dom.entry.d.ts +1 -1
- package/dist/types/testing/render-route.d.ts +16 -6
- package/dist/types/testing/run-loader.d.ts +9 -0
- package/dist/types/types/boundaries.d.ts +22 -0
- package/dist/types/types/index.d.ts +1 -1
- package/dist/types/types/loader-types.d.ts +57 -5
- package/dist/types/types/segments.d.ts +7 -0
- package/dist/types/urls/path-helper-types.d.ts +13 -4
- package/dist/types/vite/discovery/client-urls-projection.d.ts +53 -0
- package/dist/types/vite/discovery/discover-routers.d.ts +1 -1
- package/dist/types/vite/discovery/state.d.ts +8 -1
- package/dist/types/vite/plugins/client-ref-dedup.d.ts +0 -11
- package/dist/vite/index.js +6159 -2959
- package/package.json +6 -5
- package/skills/breadcrumbs/SKILL.md +39 -9
- package/skills/catalog.json +7 -1
- package/skills/client-urls/SKILL.md +338 -0
- package/skills/comparison/references/framework-comparison.md +23 -9
- package/skills/hooks/SKILL.md +2 -2
- package/skills/hooks/data.md +11 -2
- package/skills/hooks/handle-and-actions.md +7 -0
- package/skills/hooks/outlets.md +26 -5
- package/skills/hooks/urls.md +40 -3
- package/skills/loader/SKILL.md +132 -20
- package/skills/migrate-nextjs/SKILL.md +70 -10
- package/skills/migrate-react-router/SKILL.md +49 -13
- package/skills/migrate-react-router/component-migration.md +18 -13
- package/skills/migrate-react-router/data-and-actions.md +14 -3
- package/skills/migrate-react-router/route-mapping.md +15 -2
- package/skills/parallel/SKILL.md +32 -1
- package/skills/ppr/SKILL.md +16 -6
- package/skills/prerender/SKILL.md +8 -4
- package/skills/rango/SKILL.md +21 -17
- package/skills/react-compiler/SKILL.md +3 -3
- package/skills/route/SKILL.md +5 -2
- package/skills/router-setup/SKILL.md +16 -2
- package/skills/scripts/SKILL.md +16 -6
- package/skills/shell-manifest/SKILL.md +16 -7
- package/skills/testing/SKILL.md +2 -2
- package/skills/testing/client-components.md +6 -0
- package/skills/testing/handles.md +30 -8
- package/skills/testing/loader.md +51 -49
- package/skills/testing/middleware.md +1 -1
- package/skills/theme/SKILL.md +8 -5
- package/src/bin/rango.ts +7 -3
- package/src/browser/navigation-bridge.ts +6 -0
- package/src/browser/navigation-client.ts +5 -0
- package/src/browser/partial-update.ts +65 -13
- package/src/browser/react/use-router.ts +40 -11
- package/src/browser/react/use-search-params.ts +140 -17
- package/src/browser/rsc-router.tsx +59 -0
- package/src/browser/server-action-bridge.ts +26 -0
- package/src/browser/types.ts +22 -0
- package/src/build/merge-full-manifests.ts +161 -0
- package/src/build/route-trie.ts +9 -332
- package/src/build/route-types/include-resolution.ts +66 -11
- package/src/build/route-types/per-module-writer.ts +11 -6
- package/src/build/route-types/router-processing.ts +184 -153
- package/src/build/runtime-discovery.ts +23 -12
- package/src/cache/cache-exec-scope.ts +47 -0
- package/src/cache/cache-runtime.ts +24 -25
- package/src/cache/taint.ts +28 -9
- package/src/client-urls/client-root.tsx +168 -0
- package/src/client-urls/client-urls.ts +776 -0
- package/src/client-urls/navigation.ts +237 -0
- package/src/client-urls/revalidation-protocol.ts +56 -0
- package/src/client-urls/server-projection.ts +670 -0
- package/src/client-urls/types.ts +201 -0
- package/src/client.rsc.tsx +12 -0
- package/src/client.tsx +49 -6
- package/src/decode-loader-results.ts +113 -0
- package/src/errors.ts +14 -0
- package/src/handles/deferred-resolution.ts +14 -7
- package/src/index.ts +1 -0
- package/src/loader-redirect.tsx +64 -0
- package/src/outlet-context.ts +12 -0
- package/src/outlet-provider.tsx +15 -1
- package/src/redirect-origin.ts +29 -0
- package/src/route-content-wrapper.tsx +96 -3
- package/src/route-definition/dsl-helpers.ts +28 -3
- package/src/route-definition/helpers-types.ts +13 -0
- package/src/route-definition/redirect.ts +17 -18
- package/src/router/error-handling.ts +65 -11
- package/src/router/intercept-resolution.ts +29 -0
- package/src/router/loader-resolution.ts +261 -28
- package/src/router/match-result.ts +7 -0
- package/src/router/revalidation.ts +24 -11
- package/src/router/route-trie-builder.ts +334 -0
- package/src/router/router-interfaces.ts +38 -0
- package/src/router/segment-resolution/fresh.ts +55 -2
- package/src/router/segment-resolution/helpers.ts +9 -11
- package/src/router/segment-resolution/loader-cache.ts +34 -23
- package/src/router/segment-resolution/loader-mask.ts +9 -9
- package/src/router/segment-resolution/revalidation.ts +23 -2
- package/src/router/trie-matching.ts +3 -3
- package/src/router.ts +46 -1
- package/src/rsc/full-payload.ts +6 -0
- package/src/rsc/handler.ts +10 -7
- package/src/rsc/loader-fetch.ts +2 -2
- package/src/rsc/manifest-init.ts +28 -9
- package/src/rsc/rsc-rendering.ts +15 -1
- package/src/rsc/shell-capture.ts +12 -0
- package/src/rsc/shell-serve.ts +15 -2
- package/src/rsc/ssr-setup.ts +10 -1
- package/src/rsc/types.ts +31 -2
- package/src/segment-system.tsx +83 -26
- package/src/server/context.ts +10 -0
- package/src/server/cookie-store.ts +19 -19
- package/src/server/handle-store.ts +185 -48
- package/src/server/request-context.ts +30 -6
- package/src/server.ts +7 -0
- package/src/ssr/index.tsx +37 -2
- package/src/ssr/ssr-root.tsx +29 -2
- package/src/testing/dom.entry.ts +1 -1
- package/src/testing/render-route.tsx +22 -8
- package/src/testing/run-loader.ts +51 -13
- package/src/types/boundaries.ts +19 -0
- package/src/types/index.ts +1 -0
- package/src/types/loader-types.ts +60 -5
- package/src/types/segments.ts +7 -0
- package/src/urls/include-helper.ts +22 -4
- package/src/urls/path-helper-types.ts +17 -1
- package/src/use-loader.tsx +67 -6
- package/src/vite/discovery/client-urls-projection.ts +322 -0
- package/src/vite/discovery/discover-routers.ts +43 -17
- package/src/vite/discovery/state.ts +11 -1
- package/src/vite/discovery/virtual-module-codegen.ts +20 -0
- package/src/vite/plugins/client-ref-dedup.ts +281 -19
- package/src/vite/plugins/expose-action-id.ts +45 -23
- package/src/vite/plugins/virtual-entries.ts +12 -3
- package/src/vite/router-discovery.ts +163 -12
package/skills/theme/SKILL.md
CHANGED
|
@@ -33,16 +33,15 @@ const router = createRouter<Env>({
|
|
|
33
33
|
});
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
## Server (in
|
|
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
|
|
43
|
-
|
|
41
|
+
// In a handler
|
|
42
|
+
path("/settings", (ctx) => {
|
|
44
43
|
const currentTheme = ctx.theme; // read from cookie
|
|
45
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
552
|
-
// above), so
|
|
553
|
-
//
|
|
554
|
-
//
|
|
555
|
-
//
|
|
556
|
-
//
|
|
557
|
-
//
|
|
558
|
-
//
|
|
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
|
|
572
|
-
// like a first load and the click has
|
|
573
|
-
// transition() routes keep the
|
|
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
|
|
13
|
-
*
|
|
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
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
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
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
14
|
-
* the pathname
|
|
15
|
-
*
|
|
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
|
|
21
|
-
*
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|