@rangojs/router 0.0.0-experimental.66 → 0.0.0-experimental.66cdebe3
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/README.md +112 -17
- package/dist/vite/index.js +1462 -422
- package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/package.json +7 -5
- package/skills/breadcrumbs/SKILL.md +3 -1
- package/skills/handler-use/SKILL.md +364 -0
- package/skills/hooks/SKILL.md +54 -20
- package/skills/i18n/SKILL.md +276 -0
- package/skills/intercept/SKILL.md +45 -0
- package/skills/layout/SKILL.md +24 -0
- package/skills/links/SKILL.md +234 -16
- package/skills/loader/SKILL.md +70 -3
- package/skills/middleware/SKILL.md +34 -3
- package/skills/migrate-nextjs/SKILL.md +562 -0
- package/skills/migrate-react-router/SKILL.md +769 -0
- package/skills/parallel/SKILL.md +68 -0
- package/skills/rango/SKILL.md +26 -22
- package/skills/response-routes/SKILL.md +8 -0
- package/skills/route/SKILL.md +48 -0
- package/skills/server-actions/SKILL.md +739 -0
- package/skills/streams-and-websockets/SKILL.md +283 -0
- package/skills/typesafety/SKILL.md +9 -1
- package/skills/view-transitions/SKILL.md +212 -0
- package/src/browser/app-shell.ts +52 -0
- package/src/browser/event-controller.ts +44 -4
- package/src/browser/navigation-bridge.ts +151 -9
- package/src/browser/navigation-client.ts +64 -13
- package/src/browser/navigation-store.ts +25 -1
- package/src/browser/partial-update.ts +58 -12
- package/src/browser/prefetch/cache.ts +129 -21
- package/src/browser/prefetch/fetch.ts +148 -16
- package/src/browser/prefetch/queue.ts +36 -5
- package/src/browser/rango-state.ts +53 -13
- package/src/browser/react/Link.tsx +30 -2
- package/src/browser/react/NavigationProvider.tsx +95 -44
- package/src/browser/react/filter-segment-order.ts +51 -7
- package/src/browser/react/index.ts +3 -0
- package/src/browser/react/use-navigation.ts +22 -2
- package/src/browser/react/use-params.ts +17 -4
- package/src/browser/react/use-reverse.ts +99 -0
- package/src/browser/react/use-router.ts +8 -1
- package/src/browser/react/use-segments.ts +11 -8
- package/src/browser/rsc-router.tsx +34 -6
- package/src/browser/scroll-restoration.ts +69 -28
- package/src/browser/segment-reconciler.ts +36 -14
- package/src/browser/types.ts +19 -0
- package/src/build/route-trie.ts +52 -25
- package/src/cache/cf/cf-cache-store.ts +5 -7
- package/src/client.rsc.tsx +3 -0
- package/src/client.tsx +87 -175
- package/src/href-client.ts +4 -1
- package/src/index.rsc.ts +3 -0
- package/src/index.ts +44 -9
- package/src/outlet-context.ts +1 -1
- package/src/response-utils.ts +28 -0
- package/src/reverse.ts +62 -36
- package/src/route-definition/dsl-helpers.ts +175 -23
- package/src/route-definition/helpers-types.ts +63 -14
- package/src/route-definition/resolve-handler-use.ts +6 -0
- package/src/route-types.ts +7 -0
- package/src/router/handler-context.ts +21 -38
- package/src/router/lazy-includes.ts +6 -6
- package/src/router/loader-resolution.ts +3 -0
- package/src/router/manifest.ts +22 -13
- package/src/router/match-api.ts +4 -3
- package/src/router/match-handlers.ts +1 -0
- package/src/router/match-middleware/cache-lookup.ts +2 -1
- package/src/router/match-result.ts +101 -4
- package/src/router/middleware-types.ts +14 -25
- package/src/router/middleware.ts +54 -7
- package/src/router/pattern-matching.ts +101 -17
- package/src/router/revalidation.ts +15 -1
- package/src/router/segment-resolution/fresh.ts +13 -0
- package/src/router/segment-resolution/revalidation.ts +135 -101
- package/src/router/substitute-pattern-params.ts +56 -0
- package/src/router/trie-matching.ts +18 -13
- package/src/router/url-params.ts +49 -0
- package/src/router.ts +1 -2
- package/src/rsc/handler.ts +16 -8
- package/src/rsc/helpers.ts +69 -41
- package/src/rsc/progressive-enhancement.ts +4 -0
- package/src/rsc/response-route-handler.ts +14 -1
- package/src/rsc/rsc-rendering.ts +10 -0
- package/src/rsc/server-action.ts +4 -0
- package/src/rsc/types.ts +6 -0
- package/src/segment-content-promise.ts +67 -0
- package/src/segment-loader-promise.ts +122 -0
- package/src/segment-system.tsx +71 -70
- package/src/server/context.ts +26 -3
- package/src/server/request-context.ts +10 -42
- package/src/ssr/index.tsx +5 -1
- package/src/types/handler-context.ts +12 -39
- package/src/types/loader-types.ts +5 -6
- package/src/types/request-scope.ts +126 -0
- package/src/types/route-entry.ts +11 -0
- package/src/types/segments.ts +18 -1
- package/src/urls/include-helper.ts +24 -14
- package/src/urls/path-helper-types.ts +30 -4
- package/src/urls/response-types.ts +2 -10
- package/src/use-loader.tsx +4 -1
- package/src/vite/debug.ts +184 -0
- package/src/vite/discovery/discover-routers.ts +31 -3
- package/src/vite/discovery/gate-state.ts +171 -0
- package/src/vite/discovery/prerender-collection.ts +172 -84
- package/src/vite/discovery/self-gen-tracking.ts +27 -1
- package/src/vite/plugins/cjs-to-esm.ts +5 -0
- package/src/vite/plugins/client-ref-dedup.ts +16 -0
- package/src/vite/plugins/client-ref-hashing.ts +16 -4
- package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
- package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
- package/src/vite/plugins/expose-action-id.ts +52 -28
- package/src/vite/plugins/expose-id-utils.ts +12 -0
- package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
- package/src/vite/plugins/expose-internal-ids.ts +545 -304
- package/src/vite/plugins/performance-tracks.ts +17 -9
- package/src/vite/plugins/use-cache-transform.ts +56 -43
- package/src/vite/plugins/version-injector.ts +37 -11
- package/src/vite/rango.ts +49 -14
- package/src/vite/router-discovery.ts +558 -53
- package/src/vite/utils/banner.ts +1 -1
- package/src/vite/utils/package-resolution.ts +41 -1
- package/src/vite/utils/prerender-utils.ts +21 -6
|
@@ -97,6 +97,31 @@ export interface LinkProps extends Omit<
|
|
|
97
97
|
* @default "none"
|
|
98
98
|
*/
|
|
99
99
|
prefetch?: PrefetchStrategy;
|
|
100
|
+
/**
|
|
101
|
+
* Opt-in override for the prefetch cache scope.
|
|
102
|
+
*
|
|
103
|
+
* The default cache is source-agnostic: one shared entry per target,
|
|
104
|
+
* keyed on Rango state + target URL. This is correct for routes whose
|
|
105
|
+
* response shape doesn't depend on where the user navigates from.
|
|
106
|
+
*
|
|
107
|
+
* Set `":source"` when this Link's response would legitimately differ
|
|
108
|
+
* based on the source page — typically when the target route (or one
|
|
109
|
+
* of its layouts) uses a custom `revalidate()` handler that reads
|
|
110
|
+
* `currentUrl` / `currentParams`, and the wildcard entry would
|
|
111
|
+
* therefore serve the wrong diff to a navigation from a different
|
|
112
|
+
* source.
|
|
113
|
+
*
|
|
114
|
+
* Intercept responses are auto-scoped to the source via a server-side
|
|
115
|
+
* tag, so `":source"` is only needed for custom revalidation logic.
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* ```tsx
|
|
119
|
+
* // Route uses a `revalidate()` that branches on currentUrl — opt in
|
|
120
|
+
* // so prefetches don't bleed across source pages.
|
|
121
|
+
* <Link to="/dashboard" prefetch="hover" prefetchKey=":source" />
|
|
122
|
+
* ```
|
|
123
|
+
*/
|
|
124
|
+
prefetchKey?: ":source";
|
|
100
125
|
/**
|
|
101
126
|
* State to pass to history.pushState/replaceState.
|
|
102
127
|
* Accessible via useLocationState() hook.
|
|
@@ -184,6 +209,7 @@ export const Link: ForwardRefExoticComponent<
|
|
|
184
209
|
reloadDocument = false,
|
|
185
210
|
revalidate,
|
|
186
211
|
prefetch = "none",
|
|
212
|
+
prefetchKey,
|
|
187
213
|
state,
|
|
188
214
|
children,
|
|
189
215
|
onClick,
|
|
@@ -320,9 +346,10 @@ export const Link: ForwardRefExoticComponent<
|
|
|
320
346
|
segmentState.currentSegmentIds,
|
|
321
347
|
getAppVersion(),
|
|
322
348
|
ctx.store.getRouterId?.(),
|
|
349
|
+
prefetchKey,
|
|
323
350
|
);
|
|
324
351
|
}
|
|
325
|
-
}, [resolvedStrategy, resolvedTo, isExternal, ctx]);
|
|
352
|
+
}, [resolvedStrategy, resolvedTo, isExternal, ctx, prefetchKey]);
|
|
326
353
|
|
|
327
354
|
// Viewport/render prefetch: waits for idle before starting,
|
|
328
355
|
// uses concurrency-limited queue to avoid flooding.
|
|
@@ -344,6 +371,7 @@ export const Link: ForwardRefExoticComponent<
|
|
|
344
371
|
segmentState.currentSegmentIds,
|
|
345
372
|
getAppVersion(),
|
|
346
373
|
ctx.store.getRouterId?.(),
|
|
374
|
+
prefetchKey,
|
|
347
375
|
);
|
|
348
376
|
};
|
|
349
377
|
|
|
@@ -383,7 +411,7 @@ export const Link: ForwardRefExoticComponent<
|
|
|
383
411
|
unobserveForPrefetch(observedElement);
|
|
384
412
|
}
|
|
385
413
|
};
|
|
386
|
-
}, [resolvedStrategy, resolvedTo, isExternal, ctx]);
|
|
414
|
+
}, [resolvedStrategy, resolvedTo, isExternal, ctx, prefetchKey]);
|
|
387
415
|
|
|
388
416
|
return (
|
|
389
417
|
<a
|
|
@@ -3,10 +3,8 @@
|
|
|
3
3
|
import React, {
|
|
4
4
|
useState,
|
|
5
5
|
useEffect,
|
|
6
|
-
useLayoutEffect,
|
|
7
6
|
useCallback,
|
|
8
7
|
useMemo,
|
|
9
|
-
useRef,
|
|
10
8
|
use,
|
|
11
9
|
type ReactNode,
|
|
12
10
|
} from "react";
|
|
@@ -28,6 +26,7 @@ import { NonceContext } from "./nonce-context.js";
|
|
|
28
26
|
import type { ResolvedThemeConfig, Theme } from "../../theme/types.js";
|
|
29
27
|
import { cancelAllPrefetches } from "../prefetch/queue.js";
|
|
30
28
|
import { handleNavigationEnd } from "../scroll-restoration.js";
|
|
29
|
+
import type { AppShellRef } from "../app-shell.js";
|
|
31
30
|
|
|
32
31
|
/**
|
|
33
32
|
* Process handles from an async generator, updating the event controller
|
|
@@ -46,10 +45,22 @@ async function processHandles(
|
|
|
46
45
|
store: NavigationStore;
|
|
47
46
|
matched?: string[];
|
|
48
47
|
isPartial?: boolean;
|
|
48
|
+
/** Server's `resolvedIds`: every segment re-resolved this request,
|
|
49
|
+
* including null-component ones excluded from `diff`/`segments`.
|
|
50
|
+
* Drives cleanup of stale handle buckets when a re-resolved segment
|
|
51
|
+
* pushed nothing. */
|
|
52
|
+
resolvedIds?: string[];
|
|
49
53
|
historyKey: string;
|
|
50
54
|
},
|
|
51
55
|
): Promise<void> {
|
|
52
|
-
const {
|
|
56
|
+
const {
|
|
57
|
+
eventController,
|
|
58
|
+
store,
|
|
59
|
+
matched,
|
|
60
|
+
isPartial,
|
|
61
|
+
resolvedIds,
|
|
62
|
+
historyKey,
|
|
63
|
+
} = opts;
|
|
53
64
|
|
|
54
65
|
let yieldCount = 0;
|
|
55
66
|
for await (const handleData of handlesGenerator) {
|
|
@@ -64,7 +75,7 @@ async function processHandles(
|
|
|
64
75
|
}
|
|
65
76
|
|
|
66
77
|
yieldCount++;
|
|
67
|
-
eventController.setHandleData(handleData, matched, isPartial);
|
|
78
|
+
eventController.setHandleData(handleData, matched, isPartial, resolvedIds);
|
|
68
79
|
}
|
|
69
80
|
|
|
70
81
|
// Check again before final updates
|
|
@@ -72,12 +83,11 @@ async function processHandles(
|
|
|
72
83
|
return;
|
|
73
84
|
}
|
|
74
85
|
|
|
75
|
-
// For partial updates where the generator yielded nothing (
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
// route might not push any breadcrumbs, but we still need to remove the old ones.
|
|
86
|
+
// For partial updates where the generator yielded nothing (every
|
|
87
|
+
// re-resolved handler pushed nothing), still call setHandleData so the
|
|
88
|
+
// cleanup pass can clear out stale buckets for those segments.
|
|
79
89
|
if (yieldCount === 0 && matched) {
|
|
80
|
-
eventController.setHandleData({}, matched, true);
|
|
90
|
+
eventController.setHandleData({}, matched, true, resolvedIds);
|
|
81
91
|
}
|
|
82
92
|
|
|
83
93
|
// After handles processing completes, update the cache's handleData.
|
|
@@ -133,15 +143,23 @@ export interface NavigationProviderProps {
|
|
|
133
143
|
warmupEnabled?: boolean;
|
|
134
144
|
|
|
135
145
|
/**
|
|
136
|
-
* App version from server payload
|
|
137
|
-
*
|
|
146
|
+
* App version from server payload.
|
|
147
|
+
* Used only as a fallback when `appShellRef` is not supplied.
|
|
138
148
|
*/
|
|
139
149
|
version?: string;
|
|
140
150
|
|
|
141
151
|
/**
|
|
142
152
|
* URL prefix for all routes (from createRouter({ basename })).
|
|
153
|
+
* Used only as a fallback when `appShellRef` is not supplied.
|
|
143
154
|
*/
|
|
144
155
|
basename?: string;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Live app-shell ref. When provided, the context's `basename` and `version`
|
|
159
|
+
* properties become live getters that track app-switch updates without
|
|
160
|
+
* invalidating the memoized context value.
|
|
161
|
+
*/
|
|
162
|
+
appShellRef?: AppShellRef;
|
|
145
163
|
}
|
|
146
164
|
|
|
147
165
|
/**
|
|
@@ -175,6 +193,7 @@ export function NavigationProvider({
|
|
|
175
193
|
warmupEnabled,
|
|
176
194
|
version,
|
|
177
195
|
basename,
|
|
196
|
+
appShellRef,
|
|
178
197
|
}: NavigationProviderProps): ReactNode {
|
|
179
198
|
// Track current payload for rendering (this triggers re-renders)
|
|
180
199
|
const [payload, setPayload] = useState(initialPayload);
|
|
@@ -196,18 +215,39 @@ export function NavigationProvider({
|
|
|
196
215
|
await bridge.refresh();
|
|
197
216
|
}, []);
|
|
198
217
|
|
|
199
|
-
// Context value is stable (store, eventController, navigate, refresh never
|
|
200
|
-
|
|
201
|
-
|
|
218
|
+
// Context value is stable (store, eventController, navigate, refresh never
|
|
219
|
+
// change). When an appShellRef is supplied, `basename` and `version` are
|
|
220
|
+
// installed as live getters so app-switch transitions (which update the ref)
|
|
221
|
+
// propagate to consumers without forcing a tree-wide rerender.
|
|
222
|
+
const contextValue = useMemo<NavigationStoreContextValue>(() => {
|
|
223
|
+
if (appShellRef) {
|
|
224
|
+
const value = {
|
|
225
|
+
store,
|
|
226
|
+
eventController,
|
|
227
|
+
navigate,
|
|
228
|
+
refresh,
|
|
229
|
+
} as NavigationStoreContextValue;
|
|
230
|
+
Object.defineProperty(value, "basename", {
|
|
231
|
+
configurable: true,
|
|
232
|
+
enumerable: true,
|
|
233
|
+
get: () => appShellRef.get().basename,
|
|
234
|
+
});
|
|
235
|
+
Object.defineProperty(value, "version", {
|
|
236
|
+
configurable: true,
|
|
237
|
+
enumerable: true,
|
|
238
|
+
get: () => appShellRef.get().version,
|
|
239
|
+
});
|
|
240
|
+
return value;
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
202
243
|
store,
|
|
203
244
|
eventController,
|
|
204
245
|
navigate,
|
|
205
246
|
refresh,
|
|
206
247
|
version,
|
|
207
248
|
basename,
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
);
|
|
249
|
+
};
|
|
250
|
+
}, []);
|
|
211
251
|
|
|
212
252
|
// Connection warmup: keep TLS alive after idle periods.
|
|
213
253
|
// After 60s of no user interaction, marks connection as "cold".
|
|
@@ -313,40 +353,45 @@ export function NavigationProvider({
|
|
|
313
353
|
return unsub;
|
|
314
354
|
}, [eventController]);
|
|
315
355
|
|
|
316
|
-
// Pending scroll action to apply after React commits
|
|
317
|
-
const pendingScrollRef = useRef<NavigationUpdate["scroll"]>(undefined);
|
|
318
|
-
|
|
319
|
-
// Apply scroll after React commits the new content to the DOM
|
|
320
|
-
useLayoutEffect(() => {
|
|
321
|
-
const scrollAction = pendingScrollRef.current;
|
|
322
|
-
if (!scrollAction) return;
|
|
323
|
-
pendingScrollRef.current = undefined;
|
|
324
|
-
|
|
325
|
-
if (scrollAction.enabled === false) return;
|
|
326
|
-
|
|
327
|
-
handleNavigationEnd({
|
|
328
|
-
restore: scrollAction.restore,
|
|
329
|
-
scroll: scrollAction.enabled,
|
|
330
|
-
isStreaming: scrollAction.isStreaming,
|
|
331
|
-
});
|
|
332
|
-
});
|
|
333
|
-
|
|
334
356
|
// Subscribe to UI updates (for re-rendering the tree)
|
|
335
357
|
useEffect(() => {
|
|
336
358
|
const unsubscribe = store.onUpdate((update) => {
|
|
337
|
-
// Capture scroll intent — it will be applied in useLayoutEffect
|
|
338
|
-
// after React commits this state update to the DOM.
|
|
339
|
-
// Always assign (even undefined) to clear stale scroll from prior navigations,
|
|
340
|
-
// so server actions or error updates don't accidentally replay old scroll.
|
|
341
|
-
pendingScrollRef.current = update.scroll;
|
|
342
|
-
|
|
343
359
|
setPayload({
|
|
344
360
|
root: update.root,
|
|
345
361
|
metadata: update.metadata,
|
|
346
362
|
});
|
|
347
363
|
|
|
348
|
-
//
|
|
349
|
-
|
|
364
|
+
// Dispatch scroll handling on a microtask so it runs after the
|
|
365
|
+
// synchronous portion of this subscriber returns but before React
|
|
366
|
+
// processes the next macrotask. handleNavigationEnd is robust to
|
|
367
|
+
// commit timing — its scrollToTop/scrollToHash branches are
|
|
368
|
+
// synchronous against the (possibly old) DOM and reach Y=0 / a hash
|
|
369
|
+
// element regardless of layout state, while restoreScrollPosition
|
|
370
|
+
// internally rAFs the scrollTo so the new tree's layout has settled.
|
|
371
|
+
// (Prior to this, scroll dispatch went through a useRef +
|
|
372
|
+
// useLayoutEffect dance: subscriber wrote pendingScrollRef and
|
|
373
|
+
// setPayload, useLayoutEffect read the ref after commit. That dance
|
|
374
|
+
// missed popstate cache-restore commits — useLayoutEffect either
|
|
375
|
+
// never ran for the resulting commit, or the ref was already
|
|
376
|
+
// consumed/cleared by a prior render's effect — resulting in
|
|
377
|
+
// back-nav having NO scrollTo call at all.)
|
|
378
|
+
if (update.scroll && update.scroll.enabled !== false) {
|
|
379
|
+
const scrollAction = update.scroll;
|
|
380
|
+
queueMicrotask(() => {
|
|
381
|
+
handleNavigationEnd({
|
|
382
|
+
restore: scrollAction.restore,
|
|
383
|
+
scroll: scrollAction.enabled,
|
|
384
|
+
isStreaming: scrollAction.isStreaming,
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Update route params. Only reset when the server actually sends a params
|
|
390
|
+
// map — an absent `params` field means "no change" (e.g., legacy action
|
|
391
|
+
// responses that omitted params). Explicit `{}` still clears correctly.
|
|
392
|
+
if (update.metadata.params !== undefined) {
|
|
393
|
+
eventController.setParams(update.metadata.params);
|
|
394
|
+
}
|
|
350
395
|
|
|
351
396
|
// Update handle data progressively as it streams in
|
|
352
397
|
if (update.metadata.handles) {
|
|
@@ -359,6 +404,7 @@ export function NavigationProvider({
|
|
|
359
404
|
store,
|
|
360
405
|
matched: update.metadata.matched,
|
|
361
406
|
isPartial: update.metadata.isPartial,
|
|
407
|
+
resolvedIds: update.metadata.resolvedIds,
|
|
362
408
|
historyKey,
|
|
363
409
|
}).catch((err) =>
|
|
364
410
|
console.error("[NavigationProvider] Error consuming handles:", err),
|
|
@@ -377,6 +423,7 @@ export function NavigationProvider({
|
|
|
377
423
|
{}, // Empty data - all existing data not in matched will be cleaned up
|
|
378
424
|
update.metadata.matched,
|
|
379
425
|
true, // partial update - will clean up segments not in matched
|
|
426
|
+
update.metadata.resolvedIds,
|
|
380
427
|
);
|
|
381
428
|
}
|
|
382
429
|
});
|
|
@@ -398,7 +445,11 @@ export function NavigationProvider({
|
|
|
398
445
|
// Build the content tree
|
|
399
446
|
let content = <RootErrorBoundary>{root}</RootErrorBoundary>;
|
|
400
447
|
|
|
401
|
-
// Wrap with ThemeProvider when theme is enabled
|
|
448
|
+
// Wrap with ThemeProvider when theme is enabled. The ThemeProvider is
|
|
449
|
+
// document-lifetime: its config comes from the initial load and does NOT
|
|
450
|
+
// swap on cross-app transitions, because the ThemeProvider sits above the
|
|
451
|
+
// segment tree and a smooth (no-reload) app switch cannot safely remount
|
|
452
|
+
// it. A new theme config only takes effect on a full document load.
|
|
402
453
|
if (themeConfig) {
|
|
403
454
|
content = (
|
|
404
455
|
<ThemeProvider config={themeConfig} initialTheme={initialTheme}>
|
|
@@ -1,11 +1,55 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Build the handle-collection segment order from a raw `matched` list.
|
|
3
|
+
*
|
|
4
|
+
* Two responsibilities:
|
|
5
|
+
*
|
|
6
|
+
* 1. Drop loader sub-ids ("D" followed by a digit, e.g. "M0L0D1.user") —
|
|
7
|
+
* loaders never push handles.
|
|
8
|
+
*
|
|
9
|
+
* 2. Place each parallel slot id (contains ".@") immediately after its
|
|
10
|
+
* parent layout/route id. Raw segment-resolution emission order does NOT
|
|
11
|
+
* guarantee this: route-mounted parallels are resolved/pushed BEFORE the
|
|
12
|
+
* route handler's segment is appended (see fresh.ts:resolveSegment for
|
|
13
|
+
* routes, and revalidation.ts ~915-919), so matched can read
|
|
14
|
+
* `[..., R0.@panel, R0]`. collectHandleData consumes segmentOrder verbatim
|
|
15
|
+
* with later-wins semantics, so without normalization the route handler's
|
|
16
|
+
* Meta would override the slot's more-specific Meta — backwards.
|
|
17
|
+
*
|
|
18
|
+
* Slot-id format is `<parentShortCode>.@<slotName>`; `parentShortCode` never
|
|
19
|
+
* contains ".@", so splitting at the first ".@" reliably yields the parent.
|
|
4
20
|
*/
|
|
5
21
|
export function filterSegmentOrder(matched: string[]): string[] {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
22
|
+
const slotsByParent = new Map<string, string[]>();
|
|
23
|
+
const nonSlots: string[] = [];
|
|
24
|
+
const nonSlotSet = new Set<string>();
|
|
25
|
+
|
|
26
|
+
for (const id of matched) {
|
|
27
|
+
if (/D\d+\./.test(id)) continue;
|
|
28
|
+
const slotIdx = id.indexOf(".@");
|
|
29
|
+
if (slotIdx >= 0) {
|
|
30
|
+
const parent = id.slice(0, slotIdx);
|
|
31
|
+
const list = slotsByParent.get(parent);
|
|
32
|
+
if (list) {
|
|
33
|
+
list.push(id);
|
|
34
|
+
} else {
|
|
35
|
+
slotsByParent.set(parent, [id]);
|
|
36
|
+
}
|
|
37
|
+
} else {
|
|
38
|
+
nonSlots.push(id);
|
|
39
|
+
nonSlotSet.add(id);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const result: string[] = [];
|
|
44
|
+
for (const id of nonSlots) {
|
|
45
|
+
result.push(id);
|
|
46
|
+
const slots = slotsByParent.get(id);
|
|
47
|
+
if (slots) result.push(...slots);
|
|
48
|
+
}
|
|
49
|
+
// Defensive: any slot whose parent is missing from the filtered list still
|
|
50
|
+
// gets included rather than silently dropped. Shouldn't happen in practice.
|
|
51
|
+
for (const [parent, slots] of slotsByParent) {
|
|
52
|
+
if (!nonSlotSet.has(parent)) result.push(...slots);
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
11
55
|
}
|
|
@@ -20,6 +20,9 @@ export { useSegments, type SegmentsState } from "./use-segments.js";
|
|
|
20
20
|
// Handle data hook
|
|
21
21
|
export { useHandle } from "./use-handle.js";
|
|
22
22
|
|
|
23
|
+
// Mount-aware reverse hook
|
|
24
|
+
export { useReverse } from "./use-reverse.js";
|
|
25
|
+
|
|
23
26
|
// Client cache controls hook
|
|
24
27
|
export {
|
|
25
28
|
useClientCache,
|
|
@@ -53,6 +53,12 @@ export function useNavigation<T>(
|
|
|
53
53
|
});
|
|
54
54
|
const prevState = useRef(baseValue);
|
|
55
55
|
|
|
56
|
+
// Tracks whether the most recent setOptimisticValue call pinned the value
|
|
57
|
+
// to a non-idle state. Used to decide whether to emit a release update when
|
|
58
|
+
// returning to idle, so the optimistic store doesn't stay pinned if a
|
|
59
|
+
// parent transition (e.g. <Link> click) is still pending.
|
|
60
|
+
const optimisticPinnedRef = useRef(false);
|
|
61
|
+
|
|
56
62
|
// useOptimistic allows immediate updates during transitions/actions
|
|
57
63
|
const [value, setOptimisticValue] = useOptimistic(baseValue);
|
|
58
64
|
|
|
@@ -82,11 +88,25 @@ export function useNavigation<T>(
|
|
|
82
88
|
const hasInflightActions =
|
|
83
89
|
ctx.eventController.getInflightActions().size > 0;
|
|
84
90
|
|
|
85
|
-
|
|
86
|
-
|
|
91
|
+
const shouldPin = hasInflightActions || publicState.state !== "idle";
|
|
92
|
+
|
|
93
|
+
if (shouldPin) {
|
|
94
|
+
// Pin the optimistic store so the loading value shows immediately
|
|
95
|
+
// even if a parent transition (e.g. <Link> click) defers the
|
|
96
|
+
// urgent setBaseValue commit.
|
|
97
|
+
startTransition(() => {
|
|
98
|
+
setOptimisticValue(nextSelected);
|
|
99
|
+
});
|
|
100
|
+
optimisticPinnedRef.current = true;
|
|
101
|
+
} else if (optimisticPinnedRef.current) {
|
|
102
|
+
// Release a previously-pinned optimistic value. Without this,
|
|
103
|
+
// useOptimistic keeps returning the stale loading value while
|
|
104
|
+
// any parent transition is still pending, even after baseValue
|
|
105
|
+
// flipped to idle.
|
|
87
106
|
startTransition(() => {
|
|
88
107
|
setOptimisticValue(nextSelected);
|
|
89
108
|
});
|
|
109
|
+
optimisticPinnedRef.current = false;
|
|
90
110
|
}
|
|
91
111
|
|
|
92
112
|
// Always update base state so UI reflects current state
|
|
@@ -16,17 +16,30 @@ import { shallowEqual } from "./shallow-equal.js";
|
|
|
16
16
|
* const params = useParams();
|
|
17
17
|
* // { productId: "123" }
|
|
18
18
|
*
|
|
19
|
+
* // Annotate the expected shape via a generic
|
|
20
|
+
* const { productId } = useParams<{ productId: string }>();
|
|
21
|
+
*
|
|
19
22
|
* // With selector
|
|
20
23
|
* const productId = useParams(p => p.productId);
|
|
21
24
|
* ```
|
|
22
25
|
*/
|
|
23
|
-
|
|
26
|
+
// `T extends object` (not `Record<string, string | undefined>`) so that
|
|
27
|
+
// interface shapes pass the constraint — interfaces lack an implicit
|
|
28
|
+
// index signature and would otherwise be rejected. The generic is a
|
|
29
|
+
// shape annotation, not a runtime check; the body always returns the
|
|
30
|
+
// underlying params map unchanged. The default and selector input use
|
|
31
|
+
// `string | undefined` because absent optional params are omitted from
|
|
32
|
+
// the params record at runtime — the type must reflect that so callers
|
|
33
|
+
// don't write `p.locale.length` and crash when the segment is absent.
|
|
34
|
+
export function useParams<
|
|
35
|
+
T extends object = Record<string, string | undefined>,
|
|
36
|
+
>(): Readonly<T>;
|
|
24
37
|
export function useParams<T>(
|
|
25
|
-
selector: (params: Record<string, string>) => T,
|
|
38
|
+
selector: (params: Record<string, string | undefined>) => T,
|
|
26
39
|
): T;
|
|
27
40
|
export function useParams<T>(
|
|
28
|
-
selector?: (params: Record<string, string>) => T,
|
|
29
|
-
): T | Record<string, string> {
|
|
41
|
+
selector?: (params: Record<string, string | undefined>) => T,
|
|
42
|
+
): T | Record<string, string | undefined> {
|
|
30
43
|
const ctx = useContext(NavigationStoreContext);
|
|
31
44
|
|
|
32
45
|
const [value, setValue] = useState<T | Record<string, string>>(() => {
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback } from "react";
|
|
4
|
+
import type { LocalReverseFunction } from "../../reverse.js";
|
|
5
|
+
import { substitutePatternParams } from "../../router/substitute-pattern-params.js";
|
|
6
|
+
import { serializeSearchParams } from "../../search-params.js";
|
|
7
|
+
import { useMount } from "./use-mount.js";
|
|
8
|
+
import { useParams } from "./use-params.js";
|
|
9
|
+
|
|
10
|
+
type RouteEntry = string | { readonly path: string };
|
|
11
|
+
type LocalRouteMap = Readonly<Record<string, RouteEntry>>;
|
|
12
|
+
|
|
13
|
+
function getPattern(entry: RouteEntry | undefined): string | undefined {
|
|
14
|
+
if (entry === undefined) return undefined;
|
|
15
|
+
return typeof entry === "string" ? entry : entry.path;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Join an include mount prefix with a mount-relative pattern.
|
|
20
|
+
*
|
|
21
|
+
* `pattern === "/"` is the index of the local module — under a non-root
|
|
22
|
+
* mount it must collapse so `/` under `/blog` becomes `/blog`, not
|
|
23
|
+
* `/blog/`. This matches `ctx.reverse(".index")` on the server.
|
|
24
|
+
*/
|
|
25
|
+
function joinMount(mount: string, pattern: string): string {
|
|
26
|
+
if (pattern === "/") {
|
|
27
|
+
if (mount === "" || mount === "/") return "/";
|
|
28
|
+
return mount.endsWith("/") ? mount.slice(0, -1) : mount;
|
|
29
|
+
}
|
|
30
|
+
const normalizedMount = mount === "/" ? "" : mount.replace(/\/+$/, "");
|
|
31
|
+
return normalizedMount + pattern;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Mount-aware reverse function for a locally-imported `routes` map.
|
|
36
|
+
*
|
|
37
|
+
* Resolves dot-prefixed route names against the passed `routes` (typically
|
|
38
|
+
* a generated `routes` from a `urls()` module's `.gen.ts`), prefixes the
|
|
39
|
+
* result with the surrounding `include()` mount path, and substitutes
|
|
40
|
+
* params — auto-filling from the current matched route's params and
|
|
41
|
+
* letting explicit params override.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```tsx
|
|
45
|
+
* "use client";
|
|
46
|
+
* import { Link, useReverse } from "@rangojs/router/client";
|
|
47
|
+
* import { routes as blogRoutes } from "../urls/blog.gen.js";
|
|
48
|
+
*
|
|
49
|
+
* function BlogNav() {
|
|
50
|
+
* const reverse = useReverse(blogRoutes);
|
|
51
|
+
* return (
|
|
52
|
+
* <>
|
|
53
|
+
* <Link to={reverse(".index")}>Blog</Link>
|
|
54
|
+
* <Link to={reverse(".post", { postId: "hello" })}>Post</Link>
|
|
55
|
+
* </>
|
|
56
|
+
* );
|
|
57
|
+
* }
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export function useReverse<const TRoutes extends LocalRouteMap>(
|
|
61
|
+
routes: TRoutes,
|
|
62
|
+
): LocalReverseFunction<TRoutes> {
|
|
63
|
+
const mount = useMount();
|
|
64
|
+
const currentParams = useParams();
|
|
65
|
+
|
|
66
|
+
return useCallback(
|
|
67
|
+
((
|
|
68
|
+
name: string,
|
|
69
|
+
explicitParams?: Record<string, string | undefined>,
|
|
70
|
+
search?: Record<string, unknown>,
|
|
71
|
+
): string => {
|
|
72
|
+
if (!name.startsWith(".")) {
|
|
73
|
+
throw new Error(`Local route names must start with ".": "${name}"`);
|
|
74
|
+
}
|
|
75
|
+
const lookupName = name.slice(1);
|
|
76
|
+
const entry = (routes as LocalRouteMap)[lookupName];
|
|
77
|
+
const pattern = getPattern(entry);
|
|
78
|
+
if (pattern === undefined) {
|
|
79
|
+
throw new Error(`Unknown local route: "${name}"`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const joined = joinMount(mount, pattern);
|
|
83
|
+
|
|
84
|
+
const mergedParams = explicitParams
|
|
85
|
+
? { ...currentParams, ...explicitParams }
|
|
86
|
+
: currentParams;
|
|
87
|
+
|
|
88
|
+
const substituted = substitutePatternParams(joined, mergedParams, name);
|
|
89
|
+
|
|
90
|
+
if (search) {
|
|
91
|
+
const qs = serializeSearchParams(search);
|
|
92
|
+
if (qs) return `${substituted}?${qs}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return substituted;
|
|
96
|
+
}) as LocalReverseFunction<TRoutes>,
|
|
97
|
+
[routes, mount, currentParams],
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -13,6 +13,10 @@ import type { RouterInstance, RouterNavigateOptions } from "../types.js";
|
|
|
13
13
|
* useRouter() do not re-render on navigation state changes.
|
|
14
14
|
* For reactive navigation state, use useNavigation() instead.
|
|
15
15
|
*
|
|
16
|
+
* Methods read `basename` from the live context on each call so that
|
|
17
|
+
* cross-app navigation (app-switch) sees the current app's basename
|
|
18
|
+
* rather than the one captured at mount time.
|
|
19
|
+
*
|
|
16
20
|
* @example
|
|
17
21
|
* ```tsx
|
|
18
22
|
* const router = useRouter();
|
|
@@ -29,7 +33,10 @@ export function useRouter(): RouterInstance {
|
|
|
29
33
|
throw new Error("useRouter must be used within NavigationProvider");
|
|
30
34
|
}
|
|
31
35
|
|
|
32
|
-
// Stable reference: ctx is
|
|
36
|
+
// Stable reference: ctx itself is stable, and reads on each method call
|
|
37
|
+
// pick up live basename values from the context (backed by a live ref
|
|
38
|
+
// in NavigationProvider), so app-switch transitions are reflected without
|
|
39
|
+
// recreating this object.
|
|
33
40
|
return useMemo<RouterInstance>(() => {
|
|
34
41
|
/** Prefix a root-relative path with basename if not already prefixed. */
|
|
35
42
|
function withBasename(url: string): string {
|
|
@@ -25,15 +25,18 @@ function parsePathname(pathname: string): string[] {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
|
-
* Build segments state from event controller
|
|
28
|
+
* Build segments state from event controller. `segmentIds` is the
|
|
29
|
+
* route-only list (parallels and loaders stripped) — distinct from the
|
|
30
|
+
* controller's `segmentOrder` which drives handle collection and includes
|
|
31
|
+
* parallel slot ids.
|
|
29
32
|
*/
|
|
30
33
|
function buildSegmentsState(
|
|
31
34
|
location: URL,
|
|
32
|
-
|
|
35
|
+
routeSegmentIds: string[],
|
|
33
36
|
): SegmentsState {
|
|
34
37
|
return {
|
|
35
38
|
path: parsePathname(location.pathname),
|
|
36
|
-
segmentIds:
|
|
39
|
+
segmentIds: routeSegmentIds,
|
|
37
40
|
location,
|
|
38
41
|
};
|
|
39
42
|
}
|
|
@@ -74,7 +77,7 @@ export function useSegments<T>(
|
|
|
74
77
|
const handleState = ctx.eventController.getHandleState();
|
|
75
78
|
const segmentsState = buildSegmentsState(
|
|
76
79
|
location as URL,
|
|
77
|
-
handleState.
|
|
80
|
+
handleState.routeSegmentIds,
|
|
78
81
|
);
|
|
79
82
|
return selector ? selector(segmentsState) : segmentsState;
|
|
80
83
|
});
|
|
@@ -94,7 +97,7 @@ export function useSegments<T>(
|
|
|
94
97
|
// render-time setState calls.
|
|
95
98
|
const segmentsCache = useRef<{
|
|
96
99
|
location: URL;
|
|
97
|
-
|
|
100
|
+
routeSegmentIds: string[];
|
|
98
101
|
state: SegmentsState;
|
|
99
102
|
} | null>(null);
|
|
100
103
|
|
|
@@ -113,17 +116,17 @@ export function useSegments<T>(
|
|
|
113
116
|
if (
|
|
114
117
|
cache &&
|
|
115
118
|
cache.location === location &&
|
|
116
|
-
cache.
|
|
119
|
+
cache.routeSegmentIds === handleState.routeSegmentIds
|
|
117
120
|
) {
|
|
118
121
|
segmentsState = cache.state;
|
|
119
122
|
} else {
|
|
120
123
|
segmentsState = buildSegmentsState(
|
|
121
124
|
location as URL,
|
|
122
|
-
handleState.
|
|
125
|
+
handleState.routeSegmentIds,
|
|
123
126
|
);
|
|
124
127
|
segmentsCache.current = {
|
|
125
128
|
location: location as URL,
|
|
126
|
-
|
|
129
|
+
routeSegmentIds: handleState.routeSegmentIds,
|
|
127
130
|
state: segmentsState,
|
|
128
131
|
};
|
|
129
132
|
}
|