@rangojs/router 0.0.0-experimental.d20dd405 → 0.0.0-experimental.dacec167
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 +8 -8
- package/dist/bin/rango.js +147 -57
- package/dist/testing/vitest.js +82 -0
- package/dist/vite/index.js +914 -485
- package/package.json +55 -11
- package/skills/bundle-analysis/SKILL.md +159 -0
- package/skills/cache-guide/SKILL.md +220 -30
- package/skills/caching/SKILL.md +116 -8
- package/skills/composability/SKILL.md +27 -2
- package/skills/document-cache/SKILL.md +78 -55
- package/skills/handler-use/SKILL.md +1 -1
- package/skills/hooks/SKILL.md +196 -21
- package/skills/host-router/SKILL.md +45 -20
- package/skills/intercept/SKILL.md +1 -4
- package/skills/layout/SKILL.md +4 -7
- package/skills/links/SKILL.md +22 -10
- package/skills/loader/SKILL.md +149 -6
- package/skills/middleware/SKILL.md +13 -9
- package/skills/migrate-nextjs/SKILL.md +1 -1
- package/skills/mime-routes/SKILL.md +27 -0
- package/skills/observability/SKILL.md +137 -0
- package/skills/parallel/SKILL.md +3 -6
- package/skills/prerender/SKILL.md +14 -33
- package/skills/rango/SKILL.md +242 -26
- package/skills/react-compiler/SKILL.md +168 -0
- package/skills/response-routes/SKILL.md +58 -9
- package/skills/route/SKILL.md +9 -4
- package/skills/router-setup/SKILL.md +3 -3
- package/skills/server-actions/SKILL.md +53 -41
- package/skills/testing/SKILL.md +778 -0
- package/skills/typesafety/SKILL.md +310 -26
- package/skills/use-cache/SKILL.md +34 -5
- package/skills/view-transitions/SKILL.md +85 -3
- package/src/__augment-tests__/augment.ts +81 -0
- package/src/__augment-tests__/augmented.check.ts +117 -0
- package/src/browser/action-coordinator.ts +53 -36
- package/src/browser/event-controller.ts +42 -66
- package/src/browser/history-state.ts +21 -0
- package/src/browser/index.ts +3 -3
- package/src/browser/navigation-bridge.ts +9 -67
- package/src/browser/navigation-client.ts +12 -15
- package/src/browser/navigation-store.ts +7 -8
- package/src/browser/navigation-transaction.ts +10 -28
- package/src/browser/partial-update.ts +8 -16
- package/src/browser/react/NavigationProvider.tsx +55 -65
- package/src/browser/react/location-state-shared.ts +175 -4
- package/src/browser/react/location-state.ts +39 -13
- package/src/browser/react/use-handle.ts +17 -9
- package/src/browser/react/use-params.ts +3 -4
- package/src/browser/react/use-reverse.ts +19 -12
- package/src/browser/react/use-router.ts +14 -1
- package/src/browser/response-adapter.ts +25 -0
- package/src/browser/rsc-router.tsx +30 -16
- package/src/browser/scroll-restoration.ts +30 -25
- package/src/browser/segment-structure-assert.ts +2 -2
- package/src/browser/server-action-bridge.ts +23 -30
- package/src/browser/types.ts +2 -0
- package/src/build/collect-fallback-refs.ts +107 -0
- package/src/build/generate-manifest.ts +60 -35
- package/src/build/generate-route-types.ts +2 -0
- package/src/build/index.ts +2 -0
- package/src/build/route-types/codegen.ts +4 -4
- package/src/build/route-types/include-resolution.ts +1 -1
- package/src/build/route-types/per-module-writer.ts +7 -4
- package/src/build/route-types/router-processing.ts +55 -14
- package/src/build/route-types/scan-filter.ts +1 -1
- package/src/build/route-types/source-scan.ts +118 -0
- package/src/build/runtime-discovery.ts +9 -20
- package/src/cache/cache-scope.ts +28 -42
- package/src/cache/cf/cf-cache-store.ts +49 -6
- package/src/client.tsx +5 -7
- package/src/context-var.ts +5 -5
- package/src/decode-loader-results.ts +36 -0
- package/src/errors.ts +30 -1
- package/src/handle.ts +26 -13
- package/src/host/index.ts +2 -2
- package/src/host/router.ts +129 -57
- package/src/host/types.ts +31 -2
- package/src/host/utils.ts +1 -1
- package/src/href-client.ts +136 -19
- package/src/index.rsc.ts +6 -4
- package/src/index.ts +13 -6
- package/src/loader-store.ts +500 -0
- package/src/loader.rsc.ts +21 -6
- package/src/loader.ts +3 -10
- package/src/missing-id-error.ts +68 -0
- package/src/prerender.ts +4 -4
- package/src/response-utils.ts +9 -0
- package/src/reverse.ts +16 -13
- package/src/route-content-wrapper.tsx +6 -28
- package/src/route-definition/dsl-helpers.ts +238 -263
- package/src/route-definition/helper-factories.ts +29 -139
- package/src/route-definition/helpers-types.ts +37 -14
- package/src/route-definition/use-item-types.ts +32 -0
- package/src/route-types.ts +19 -41
- package/src/router/basename.ts +14 -0
- package/src/router/content-negotiation.ts +15 -2
- package/src/router/error-handling.ts +1 -1
- package/src/router/intercept-resolution.ts +4 -18
- package/src/router/lazy-includes.ts +2 -2
- package/src/router/loader-resolution.ts +16 -2
- package/src/router/match-handlers.ts +62 -20
- package/src/router/match-middleware/cache-lookup.ts +44 -91
- package/src/router/match-middleware/cache-store.ts +3 -2
- package/src/router/match-result.ts +32 -30
- package/src/router/metrics.ts +1 -1
- package/src/router/middleware-types.ts +1 -1
- package/src/router/middleware.ts +46 -78
- package/src/router/prerender-match.ts +1 -1
- package/src/router/preview-match.ts +3 -1
- package/src/router/request-classification.ts +4 -28
- package/src/router/revalidation.ts +43 -1
- package/src/router/router-interfaces.ts +45 -28
- package/src/router/router-options.ts +40 -1
- package/src/router/router-registry.ts +2 -5
- package/src/router/segment-resolution/fresh.ts +19 -6
- package/src/router/segment-resolution/revalidation.ts +19 -6
- package/src/router/segment-resolution/view-transition-default.ts +36 -0
- package/src/router/telemetry.ts +99 -0
- package/src/router/types.ts +8 -0
- package/src/router.ts +37 -21
- package/src/rsc/handler-context.ts +2 -2
- package/src/rsc/handler.ts +20 -65
- package/src/rsc/helpers.ts +22 -2
- package/src/rsc/index.ts +1 -1
- package/src/rsc/origin-guard.ts +28 -10
- package/src/rsc/response-route-handler.ts +32 -52
- package/src/rsc/rsc-rendering.ts +27 -53
- package/src/rsc/runtime-warnings.ts +9 -10
- package/src/rsc/server-action.ts +13 -37
- package/src/rsc/ssr-setup.ts +16 -0
- package/src/rsc/types.ts +2 -2
- package/src/search-params.ts +4 -4
- package/src/segment-system.tsx +64 -49
- package/src/serialize.ts +243 -0
- package/src/server/context.ts +118 -51
- package/src/server/cookie-store.ts +28 -4
- package/src/server/request-context.ts +10 -0
- package/src/static-handler.ts +1 -1
- package/src/testing/cache-status.ts +166 -0
- package/src/testing/collect-handle.ts +63 -0
- package/src/testing/dispatch.ts +440 -0
- package/src/testing/dom.entry.ts +22 -0
- package/src/testing/e2e/fixture.ts +154 -0
- package/src/testing/e2e/index.ts +149 -0
- package/src/testing/e2e/matchers.ts +51 -0
- package/src/testing/e2e/page-helpers.ts +272 -0
- package/src/testing/e2e/parity.ts +306 -0
- package/src/testing/e2e/server.ts +183 -0
- package/src/testing/flight-matchers.ts +104 -0
- package/src/testing/flight-runtime.d.ts +57 -0
- package/src/testing/flight-tree.ts +320 -0
- package/src/testing/flight.entry.ts +39 -0
- package/src/testing/flight.ts +197 -0
- package/src/testing/generated-routes.ts +223 -0
- package/src/testing/index.ts +106 -0
- package/src/testing/internal/context.ts +331 -0
- package/src/testing/internal/flight-client-globals.ts +30 -0
- package/src/testing/render-route.tsx +565 -0
- package/src/testing/run-loader.ts +341 -0
- package/src/testing/run-middleware.ts +188 -0
- package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
- package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
- package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
- package/src/testing/vitest-stubs/version.ts +5 -0
- package/src/testing/vitest.ts +270 -0
- package/src/types/global-namespace.ts +39 -26
- package/src/types/handler-context.ts +56 -11
- package/src/types/index.ts +1 -0
- package/src/types/segments.ts +18 -1
- package/src/urls/include-helper.ts +10 -53
- package/src/urls/index.ts +0 -3
- package/src/urls/path-helper-types.ts +11 -3
- package/src/urls/path-helper.ts +17 -52
- package/src/urls/pattern-types.ts +36 -19
- package/src/urls/response-types.ts +20 -19
- package/src/urls/type-extraction.ts +26 -116
- package/src/urls/urls-function.ts +1 -5
- package/src/use-loader.tsx +413 -42
- package/src/vite/debug.ts +1 -0
- package/src/vite/discovery/bundle-postprocess.ts +6 -6
- package/src/vite/discovery/discover-routers.ts +70 -48
- package/src/vite/discovery/discovery-errors.ts +194 -0
- package/src/vite/discovery/prerender-collection.ts +19 -25
- package/src/vite/discovery/route-types-writer.ts +40 -84
- package/src/vite/discovery/state.ts +33 -0
- package/src/vite/discovery/virtual-module-codegen.ts +13 -23
- package/src/vite/index.ts +2 -0
- package/src/vite/plugin-types.ts +67 -0
- package/src/vite/plugins/cjs-to-esm.ts +3 -7
- package/src/vite/plugins/client-ref-hashing.ts +12 -1
- package/src/vite/plugins/cloudflare-protocol-stub.ts +1 -1
- package/src/vite/plugins/expose-action-id.ts +2 -2
- package/src/vite/plugins/expose-id-utils.ts +12 -8
- package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
- package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
- package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
- package/src/vite/plugins/expose-internal-ids.ts +47 -67
- package/src/vite/plugins/performance-tracks.ts +12 -16
- package/src/vite/plugins/use-cache-transform.ts +13 -11
- package/src/vite/plugins/version-injector.ts +2 -12
- package/src/vite/plugins/version-plugin.ts +59 -2
- package/src/vite/plugins/virtual-entries.ts +2 -2
- package/src/vite/rango.ts +67 -15
- package/src/vite/router-discovery.ts +208 -63
- package/src/vite/utils/ast-handler-extract.ts +15 -15
- package/src/vite/utils/bundle-analysis.ts +4 -2
- package/src/vite/utils/client-chunks.ts +190 -0
- package/src/vite/utils/forward-user-plugins.ts +193 -0
- package/src/vite/utils/manifest-utils.ts +21 -5
- package/src/vite/utils/shared-utils.ts +107 -26
- package/src/browser/action-response-classifier.ts +0 -99
|
@@ -283,18 +283,17 @@ export function createNavigationStore(
|
|
|
283
283
|
/**
|
|
284
284
|
* Create a debounced function that batches rapid calls
|
|
285
285
|
*/
|
|
286
|
+
// A non-keyed notifier is the keyed one restricted to a single constant key;
|
|
287
|
+
// its own keyed instance means the "" key never collides with action keys.
|
|
286
288
|
function createDebouncedNotifier<T extends (...args: any[]) => void>(
|
|
287
289
|
fn: T,
|
|
288
290
|
ms: number = 20,
|
|
289
291
|
): T {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
fn(...args);
|
|
296
|
-
}, ms);
|
|
297
|
-
}) as T;
|
|
292
|
+
const keyed = createKeyedDebouncedNotifier(
|
|
293
|
+
(_key: string, ...args: any[]) => fn(...args),
|
|
294
|
+
ms,
|
|
295
|
+
);
|
|
296
|
+
return ((...args: Parameters<T>) => keyed("", ...args)) as T;
|
|
298
297
|
}
|
|
299
298
|
|
|
300
299
|
/**
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
} from "./scroll-restoration.js";
|
|
12
12
|
import type { EventController, NavigationHandle } from "./event-controller.js";
|
|
13
13
|
import { debugLog } from "./logging.js";
|
|
14
|
-
import { buildHistoryState } from "./history-state.js";
|
|
14
|
+
import { buildHistoryState, pushHistoryWithIdx } from "./history-state.js";
|
|
15
15
|
|
|
16
16
|
// Re-export for consumers that import from navigation-transaction
|
|
17
17
|
export { resolveNavigationState } from "./history-state.js";
|
|
@@ -186,12 +186,8 @@ export function createNavigationTransaction(
|
|
|
186
186
|
// Used to detect when location state is being cleared.
|
|
187
187
|
const oldState = window.history.state;
|
|
188
188
|
|
|
189
|
-
// Update browser URL
|
|
190
|
-
|
|
191
|
-
window.history.replaceState(historyState, "", url);
|
|
192
|
-
} else {
|
|
193
|
-
window.history.pushState(historyState, "", url);
|
|
194
|
-
}
|
|
189
|
+
// Update browser URL (stamps history.state.idx for back() first-entry detection)
|
|
190
|
+
pushHistoryWithIdx(historyState, url, replace ?? false);
|
|
195
191
|
// Ensure new history entry has a scroll restoration key
|
|
196
192
|
ensureHistoryKey();
|
|
197
193
|
|
|
@@ -240,30 +236,16 @@ export function createNavigationTransaction(
|
|
|
240
236
|
segments: ResolvedSegment[],
|
|
241
237
|
overrides?: BoundCommitOverrides,
|
|
242
238
|
) => {
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
// Allow overrides to force replace (e.g., for intercepts)
|
|
247
|
-
const finalReplace =
|
|
248
|
-
overrides?.replace !== undefined ? overrides.replace : opts.replace;
|
|
249
|
-
// Intercept info: overrides take precedence, fallback to opts
|
|
250
|
-
const intercept =
|
|
251
|
-
overrides?.intercept !== undefined
|
|
252
|
-
? overrides.intercept
|
|
253
|
-
: opts.intercept;
|
|
239
|
+
const finalScroll = overrides?.scroll ?? opts.scroll;
|
|
240
|
+
const finalReplace = overrides?.replace ?? opts.replace;
|
|
241
|
+
const intercept = overrides?.intercept ?? opts.intercept;
|
|
254
242
|
const interceptSourceUrl =
|
|
255
|
-
overrides?.interceptSourceUrl
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
//
|
|
259
|
-
const cacheOnly =
|
|
260
|
-
overrides?.cacheOnly !== undefined
|
|
261
|
-
? overrides.cacheOnly
|
|
262
|
-
: opts.cacheOnly;
|
|
263
|
-
// User state: overrides take precedence, fallback to opts
|
|
243
|
+
overrides?.interceptSourceUrl ?? opts.interceptSourceUrl;
|
|
244
|
+
const cacheOnly = overrides?.cacheOnly ?? opts.cacheOnly;
|
|
245
|
+
// state is `unknown` (null is meaningful) so `??` would wrongly drop a
|
|
246
|
+
// null override; serverState always comes from overrides, never opts.
|
|
264
247
|
const state =
|
|
265
248
|
overrides?.state !== undefined ? overrides.state : opts.state;
|
|
266
|
-
// Server-set location state: only from overrides (set by partial-update)
|
|
267
249
|
const serverState = overrides?.serverState;
|
|
268
250
|
return commit({
|
|
269
251
|
...opts,
|
|
@@ -103,7 +103,7 @@ export type UpdateMode =
|
|
|
103
103
|
/** Source URL for intercept restore (popstate cache miss) */
|
|
104
104
|
interceptSourceUrl?: string;
|
|
105
105
|
}
|
|
106
|
-
| { type: "leave-intercept" }
|
|
106
|
+
| { type: "leave-intercept"; interceptSourceUrl?: string }
|
|
107
107
|
| { type: "stale-revalidation"; interceptSourceUrl?: string }
|
|
108
108
|
| { type: "action"; interceptSourceUrl?: string };
|
|
109
109
|
|
|
@@ -169,13 +169,7 @@ export function createPartialUpdater(
|
|
|
169
169
|
// Capture history key at start for stale revalidation consistency check
|
|
170
170
|
const historyKeyAtStart = store.getHistoryKey();
|
|
171
171
|
|
|
172
|
-
|
|
173
|
-
const interceptSourceUrl =
|
|
174
|
-
mode.type === "stale-revalidation" ||
|
|
175
|
-
mode.type === "action" ||
|
|
176
|
-
mode.type === "navigate"
|
|
177
|
-
? mode.interceptSourceUrl
|
|
178
|
-
: undefined;
|
|
172
|
+
const interceptSourceUrl = mode.interceptSourceUrl;
|
|
179
173
|
|
|
180
174
|
// When leaving intercept, filter out intercept-specific segments
|
|
181
175
|
let segments: string[];
|
|
@@ -218,13 +212,11 @@ export function createPartialUpdater(
|
|
|
218
212
|
// When navigating with targetCacheSegments, use those for consistency.
|
|
219
213
|
// Otherwise fall back to current page's segments (for same-route revalidation).
|
|
220
214
|
const targetCache =
|
|
221
|
-
mode.type === "navigate"
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const cachedSegsSource =
|
|
227
|
-
targetCache && targetCache.length > 0 ? "history-cache" : "current-page";
|
|
215
|
+
mode.type === "navigate" && mode.targetCacheSegments?.length
|
|
216
|
+
? mode.targetCacheSegments
|
|
217
|
+
: undefined;
|
|
218
|
+
const cachedSegs = targetCache ?? getCurrentCachedSegments();
|
|
219
|
+
const cachedSegsSource = targetCache ? "history-cache" : "current-page";
|
|
228
220
|
debugLog(
|
|
229
221
|
`[Browser] cachedSegs source: ${cachedSegsSource} (${cachedSegs.length} segments: ${cachedSegs.map((s) => s.id).join(", ")})`,
|
|
230
222
|
);
|
|
@@ -318,7 +310,7 @@ export function createPartialUpdater(
|
|
|
318
310
|
.filter(Boolean) as ResolvedSegment[];
|
|
319
311
|
|
|
320
312
|
// When navigating with cached segments to a different route, render them.
|
|
321
|
-
if (mode.type === "navigate" && targetCache
|
|
313
|
+
if (mode.type === "navigate" && targetCache) {
|
|
322
314
|
debugLog(
|
|
323
315
|
"[Browser] No diff but navigating with cached segments - rendering target route",
|
|
324
316
|
);
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
import React, {
|
|
4
4
|
useState,
|
|
5
5
|
useEffect,
|
|
6
|
+
useLayoutEffect,
|
|
6
7
|
useCallback,
|
|
7
8
|
useMemo,
|
|
9
|
+
useRef,
|
|
8
10
|
use,
|
|
9
11
|
type ReactNode,
|
|
10
12
|
} from "react";
|
|
@@ -26,7 +28,7 @@ import { NonceContext } from "./nonce-context.js";
|
|
|
26
28
|
import type { ResolvedThemeConfig, Theme } from "../../theme/types.js";
|
|
27
29
|
import { cancelAllPrefetches } from "../prefetch/queue.js";
|
|
28
30
|
import { handleNavigationEnd } from "../scroll-restoration.js";
|
|
29
|
-
import type
|
|
31
|
+
import { createAppShellRef, type AppShellRef } from "../app-shell.js";
|
|
30
32
|
|
|
31
33
|
/**
|
|
32
34
|
* Process handles from an async generator, updating the event controller
|
|
@@ -215,38 +217,33 @@ export function NavigationProvider({
|
|
|
215
217
|
await bridge.refresh();
|
|
216
218
|
}, []);
|
|
217
219
|
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
|
|
220
|
+
// basename/version are always read through a shell ref so the context value
|
|
221
|
+
// has a single shape: a supplied appShellRef stays live (app-switch updates
|
|
222
|
+
// it), the standalone fallback is a frozen ref over the mount-time props.
|
|
223
|
+
const fallbackShellRef = useRef<AppShellRef | null>(null);
|
|
224
|
+
if (!fallbackShellRef.current) {
|
|
225
|
+
fallbackShellRef.current = createAppShellRef({ basename, version });
|
|
226
|
+
}
|
|
227
|
+
const shellRef = appShellRef ?? fallbackShellRef.current;
|
|
228
|
+
|
|
222
229
|
const contextValue = useMemo<NavigationStoreContextValue>(() => {
|
|
223
|
-
|
|
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 {
|
|
230
|
+
const value = {
|
|
243
231
|
store,
|
|
244
232
|
eventController,
|
|
245
233
|
navigate,
|
|
246
234
|
refresh,
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
235
|
+
} as NavigationStoreContextValue;
|
|
236
|
+
Object.defineProperty(value, "basename", {
|
|
237
|
+
configurable: true,
|
|
238
|
+
enumerable: true,
|
|
239
|
+
get: () => shellRef.get().basename,
|
|
240
|
+
});
|
|
241
|
+
Object.defineProperty(value, "version", {
|
|
242
|
+
configurable: true,
|
|
243
|
+
enumerable: true,
|
|
244
|
+
get: () => shellRef.get().version,
|
|
245
|
+
});
|
|
246
|
+
return value;
|
|
250
247
|
}, []);
|
|
251
248
|
|
|
252
249
|
// Connection warmup: keep TLS alive after idle periods.
|
|
@@ -353,39 +350,38 @@ export function NavigationProvider({
|
|
|
353
350
|
return unsub;
|
|
354
351
|
}, [eventController]);
|
|
355
352
|
|
|
353
|
+
// Pending scroll action to apply after React commits
|
|
354
|
+
const pendingScrollRef = useRef<NavigationUpdate["scroll"]>(undefined);
|
|
355
|
+
|
|
356
|
+
// Apply scroll after React commits the new content to the DOM
|
|
357
|
+
useLayoutEffect(() => {
|
|
358
|
+
const scrollAction = pendingScrollRef.current;
|
|
359
|
+
if (!scrollAction) return;
|
|
360
|
+
pendingScrollRef.current = undefined;
|
|
361
|
+
|
|
362
|
+
if (scrollAction.enabled === false) return;
|
|
363
|
+
|
|
364
|
+
handleNavigationEnd({
|
|
365
|
+
restore: scrollAction.restore,
|
|
366
|
+
scroll: scrollAction.enabled,
|
|
367
|
+
isStreaming: scrollAction.isStreaming,
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
356
371
|
// Subscribe to UI updates (for re-rendering the tree)
|
|
357
372
|
useEffect(() => {
|
|
358
373
|
const unsubscribe = store.onUpdate((update) => {
|
|
374
|
+
// Capture scroll intent — it will be applied in useLayoutEffect
|
|
375
|
+
// after React commits this state update to the DOM.
|
|
376
|
+
// Always assign (even undefined) to clear stale scroll from prior navigations,
|
|
377
|
+
// so server actions or error updates don't accidentally replay old scroll.
|
|
378
|
+
pendingScrollRef.current = update.scroll;
|
|
379
|
+
|
|
359
380
|
setPayload({
|
|
360
381
|
root: update.root,
|
|
361
382
|
metadata: update.metadata,
|
|
362
383
|
});
|
|
363
384
|
|
|
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
385
|
// Update route params. Only reset when the server actually sends a params
|
|
390
386
|
// map — an absent `params` field means "no change" (e.g., legacy action
|
|
391
387
|
// responses that omitted params). Explicit `{}` still clears correctly.
|
|
@@ -409,21 +405,15 @@ export function NavigationProvider({
|
|
|
409
405
|
}).catch((err) =>
|
|
410
406
|
console.error("[NavigationProvider] Error consuming handles:", err),
|
|
411
407
|
);
|
|
412
|
-
} else if (update.metadata.cachedHandleData) {
|
|
413
|
-
// For back/forward navigation from cache, restore the cached handleData
|
|
414
|
-
// This restores breadcrumbs to the exact state they were when the page was cached
|
|
415
|
-
eventController.setHandleData(
|
|
416
|
-
update.metadata.cachedHandleData,
|
|
417
|
-
update.metadata.matched,
|
|
418
|
-
false, // full replace - restore entire cached state
|
|
419
|
-
);
|
|
420
408
|
} else if (update.metadata.matched) {
|
|
421
|
-
//
|
|
409
|
+
// cachedHandleData present -> full restore (back/forward); absent ->
|
|
410
|
+
// partial cleanup of segments no longer matched.
|
|
411
|
+
const cached = update.metadata.cachedHandleData;
|
|
422
412
|
eventController.setHandleData(
|
|
423
|
-
{},
|
|
413
|
+
cached ?? {},
|
|
424
414
|
update.metadata.matched,
|
|
425
|
-
|
|
426
|
-
update.metadata.resolvedIds,
|
|
415
|
+
cached === undefined,
|
|
416
|
+
cached === undefined ? update.metadata.resolvedIds : undefined,
|
|
427
417
|
);
|
|
428
418
|
}
|
|
429
419
|
});
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* No "use client" directive so it can be imported from RSC
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import type { ReactElement } from "react";
|
|
7
|
+
|
|
6
8
|
/**
|
|
7
9
|
* Internal entry representing a state value with its unique key.
|
|
8
10
|
* When __rsc_ls_lazy is true, __rsc_ls_value holds a getter function
|
|
@@ -22,6 +24,88 @@ export interface LocationStateOptions {
|
|
|
22
24
|
flash?: boolean;
|
|
23
25
|
}
|
|
24
26
|
|
|
27
|
+
type LocationStateUnsafeFn = (...args: never[]) => unknown;
|
|
28
|
+
|
|
29
|
+
// Broadest constructor signature (`abstract` covers both abstract and concrete
|
|
30
|
+
// classes). A class passed as state has a `new` signature, not a call signature,
|
|
31
|
+
// so it slips past LocationStateUnsafeFn; at runtime the lazy-getter path
|
|
32
|
+
// (`typeof value === "function"`) then mistakes it for a getter and throws.
|
|
33
|
+
type LocationStateUnsafeCtor = abstract new (...args: never[]) => unknown;
|
|
34
|
+
|
|
35
|
+
// `unknown` cannot be verified serializable, so it is rejected (callers must
|
|
36
|
+
// supply a concrete type). `any` deliberately defeats type checking and is NOT
|
|
37
|
+
// guardable — it is assignable to the branded error too, so the check always
|
|
38
|
+
// passes; it remains an explicit escape hatch.
|
|
39
|
+
type IsAny<T> = 0 extends 1 & T ? true : false;
|
|
40
|
+
type IsUnknown<T> =
|
|
41
|
+
IsAny<T> extends true ? false : unknown extends T ? true : false;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Branded error surfaced when a value that cannot live in location state is
|
|
45
|
+
* used. Location state is written into `history.state`, which uses the
|
|
46
|
+
* structured clone algorithm; React elements, functions, and symbols throw a
|
|
47
|
+
* `DataCloneError` at runtime. Carries a human-readable reason so the compile
|
|
48
|
+
* error explains the fix.
|
|
49
|
+
*/
|
|
50
|
+
export type LocationStateUnsafe<Reason extends string> = {
|
|
51
|
+
readonly __rango_location_state_unsafe: Reason;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Maps `T` to itself when it is safe to store in location state, or to a branded
|
|
56
|
+
* {@link LocationStateUnsafe} error for the disallowed parts: `unknown`, React
|
|
57
|
+
* elements (RSC/JSX content), functions, class constructors, and symbols.
|
|
58
|
+
* Recurses through arrays, `Map`, `Set`, and plain objects; structured-clone
|
|
59
|
+
* built-ins (`Date`, `RegExp`, typed arrays, `Blob`, `File`, `FormData`) pass
|
|
60
|
+
* through. Consumed by {@link ValidateLocationState}, which is intersected into a
|
|
61
|
+
* definition's value parameter so posting RSC content is a COMPILE error, not a
|
|
62
|
+
* runtime `DataCloneError`. (`any` is unguardable and remains an escape hatch.)
|
|
63
|
+
*/
|
|
64
|
+
export type LocationStateSafe<T> =
|
|
65
|
+
IsUnknown<T> extends true
|
|
66
|
+
? LocationStateUnsafe<"location state needs an explicit, concrete type; `unknown` cannot be verified as serializable">
|
|
67
|
+
: T extends LocationStateUnsafeFn
|
|
68
|
+
? LocationStateUnsafe<"functions cannot be stored in location state">
|
|
69
|
+
: T extends LocationStateUnsafeCtor
|
|
70
|
+
? LocationStateUnsafe<"class constructors cannot be stored in location state">
|
|
71
|
+
: T extends symbol
|
|
72
|
+
? LocationStateUnsafe<"symbols cannot be stored in location state">
|
|
73
|
+
: T extends ReactElement
|
|
74
|
+
? LocationStateUnsafe<"React/RSC content cannot be stored in location state; store plain data and render it on arrival">
|
|
75
|
+
: T extends string | number | boolean | bigint | null | undefined
|
|
76
|
+
? T
|
|
77
|
+
: T extends
|
|
78
|
+
| Date
|
|
79
|
+
| RegExp
|
|
80
|
+
| ArrayBuffer
|
|
81
|
+
| ArrayBufferView
|
|
82
|
+
| Blob
|
|
83
|
+
| File
|
|
84
|
+
| FormData
|
|
85
|
+
? T
|
|
86
|
+
: T extends ReadonlyMap<infer K, infer V>
|
|
87
|
+
? ReadonlyMap<LocationStateSafe<K>, LocationStateSafe<V>>
|
|
88
|
+
: T extends ReadonlySet<infer V>
|
|
89
|
+
? ReadonlySet<LocationStateSafe<V>>
|
|
90
|
+
: T extends readonly unknown[]
|
|
91
|
+
? { [K in keyof T]: LocationStateSafe<T[K]> }
|
|
92
|
+
: T extends object
|
|
93
|
+
? { [K in keyof T]: LocationStateSafe<T[K]> }
|
|
94
|
+
: T;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* `unknown` (a no-op) when `T` is safe to store in location state, otherwise a
|
|
98
|
+
* branded {@link LocationStateUnsafe} object. Intersected into the value
|
|
99
|
+
* parameter of a definition's call and `write()` so POSTING RSC content (or any
|
|
100
|
+
* non-serializable value) is a compile error whose text carries the reason —
|
|
101
|
+
* without a `TState extends ...` self-constraint, which TypeScript rejects as
|
|
102
|
+
* circular (TS2313). For safe `T`, `value & unknown` collapses back to `value`,
|
|
103
|
+
* so valid usage is unchanged.
|
|
104
|
+
*/
|
|
105
|
+
export type ValidateLocationState<T> = [T] extends [LocationStateSafe<T>]
|
|
106
|
+
? unknown
|
|
107
|
+
: LocationStateUnsafe<"location state must be serializable: React/RSC content, functions, and symbols cannot be stored — pass plain data and render it on arrival">;
|
|
108
|
+
|
|
25
109
|
/**
|
|
26
110
|
* Type-safe location state definition
|
|
27
111
|
*
|
|
@@ -34,8 +118,43 @@ export interface LocationStateDefinition<TArgs extends unknown[], TState> {
|
|
|
34
118
|
__rsc_ls_key: string;
|
|
35
119
|
/** Whether this state auto-clears after first read */
|
|
36
120
|
readonly __rsc_ls_flash: boolean;
|
|
37
|
-
/**
|
|
121
|
+
/**
|
|
122
|
+
* Read the current value from history.state.
|
|
123
|
+
*
|
|
124
|
+
* Returns undefined during SSR (no `window`). To stay hydration-safe, do
|
|
125
|
+
* NOT call read() inline during the initial render — the server returns
|
|
126
|
+
* undefined while the client may have a value preserved in history.state
|
|
127
|
+
* (e.g. after a hard reload of an entry that earlier called write()),
|
|
128
|
+
* which causes a hydration mismatch. Call read() inside an event handler
|
|
129
|
+
* or a useEffect post-mount instead, or use useLocationState() if you
|
|
130
|
+
* want React to manage subscription/hydration for you.
|
|
131
|
+
*/
|
|
38
132
|
read(): TState | undefined;
|
|
133
|
+
/**
|
|
134
|
+
* Statically write the value into the current history entry under this
|
|
135
|
+
* definition's key, preserving any other keys already on history.state
|
|
136
|
+
* (e.g. router bookkeeping, other LocationState slots).
|
|
137
|
+
*
|
|
138
|
+
* This is the non-reactive counterpart to read(): it does not dispatch any
|
|
139
|
+
* event, so components reading via useLocationState() will NOT re-render
|
|
140
|
+
* until the next navigation/popstate. Use it when you only need the value
|
|
141
|
+
* to be there on the next read() or on the next mount (including after
|
|
142
|
+
* back/forward and hard refresh of the same entry).
|
|
143
|
+
*
|
|
144
|
+
* Client-only: throws when called on the server (no history available).
|
|
145
|
+
*/
|
|
146
|
+
write(value: TState & ValidateLocationState<TState>): void;
|
|
147
|
+
/**
|
|
148
|
+
* Statically remove this definition's slot from the current history entry,
|
|
149
|
+
* leaving any other keys on history.state untouched. Idempotent: removing
|
|
150
|
+
* a slot that isn't present is a no-op.
|
|
151
|
+
*
|
|
152
|
+
* Same non-reactive semantics as write(): no event is dispatched, so
|
|
153
|
+
* useLocationState() readers will NOT re-render until the next navigation.
|
|
154
|
+
*
|
|
155
|
+
* Client-only: throws when called on the server (no history available).
|
|
156
|
+
*/
|
|
157
|
+
delete(): void;
|
|
39
158
|
}
|
|
40
159
|
|
|
41
160
|
/**
|
|
@@ -70,18 +189,30 @@ export interface LocationStateDefinition<TArgs extends unknown[], TState> {
|
|
|
70
189
|
*
|
|
71
190
|
* // Read without hook (snapshot, client-side only)
|
|
72
191
|
* const snap = ProductState.read();
|
|
192
|
+
*
|
|
193
|
+
* // Static write to current history entry (non-reactive, client-side only).
|
|
194
|
+
* // Survives back/forward and hard refresh; useLocationState() readers will
|
|
195
|
+
* // NOT see the new value until the next navigation. Pair with .read() or a
|
|
196
|
+
* // fresh mount.
|
|
197
|
+
* ProductState.write({ name: "Widget", price: 9.99 });
|
|
198
|
+
*
|
|
199
|
+
* // Manually clear the slot (non-reactive, client-side only).
|
|
200
|
+
* ProductState.delete();
|
|
73
201
|
* ```
|
|
74
202
|
*/
|
|
75
203
|
export function createLocationState<TState>(
|
|
76
204
|
options?: LocationStateOptions,
|
|
77
|
-
): LocationStateDefinition<
|
|
205
|
+
): LocationStateDefinition<
|
|
206
|
+
[(TState | (() => TState)) & ValidateLocationState<TState>],
|
|
207
|
+
TState
|
|
208
|
+
> {
|
|
78
209
|
const flash = options?.flash ?? false;
|
|
79
210
|
let _key: string | undefined;
|
|
80
211
|
|
|
81
212
|
function getKey(): string {
|
|
82
213
|
if (!_key && process.env.NODE_ENV === "development") {
|
|
83
214
|
throw new Error(
|
|
84
|
-
"[
|
|
215
|
+
"[rango] createLocationState key not set. " +
|
|
85
216
|
"Make sure the exposeInternalIds Vite plugin is enabled and " +
|
|
86
217
|
"the state is exported with: export const MyState = createLocationState(...)",
|
|
87
218
|
);
|
|
@@ -128,7 +259,47 @@ export function createLocationState<TState>(
|
|
|
128
259
|
enumerable: true,
|
|
129
260
|
});
|
|
130
261
|
|
|
131
|
-
|
|
262
|
+
Object.defineProperty(fn, "write", {
|
|
263
|
+
value: (value: TState): void => {
|
|
264
|
+
if (typeof window === "undefined") {
|
|
265
|
+
throw new Error(
|
|
266
|
+
"[rango] LocationState.write() is client-only. " +
|
|
267
|
+
"It mutates window.history.state and cannot run on the server.",
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
const key = getKey();
|
|
271
|
+
const current = window.history.state ?? {};
|
|
272
|
+
window.history.replaceState(
|
|
273
|
+
{ ...current, [key]: value },
|
|
274
|
+
"",
|
|
275
|
+
window.location.href,
|
|
276
|
+
);
|
|
277
|
+
},
|
|
278
|
+
enumerable: true,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
Object.defineProperty(fn, "delete", {
|
|
282
|
+
value: (): void => {
|
|
283
|
+
if (typeof window === "undefined") {
|
|
284
|
+
throw new Error(
|
|
285
|
+
"[rango] LocationState.delete() is client-only. " +
|
|
286
|
+
"It mutates window.history.state and cannot run on the server.",
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
const key = getKey();
|
|
290
|
+
const current = window.history.state;
|
|
291
|
+
if (current == null || !(key in current)) return;
|
|
292
|
+
const next = { ...current };
|
|
293
|
+
delete next[key];
|
|
294
|
+
window.history.replaceState(next, "", window.location.href);
|
|
295
|
+
},
|
|
296
|
+
enumerable: true,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
return fn as unknown as LocationStateDefinition<
|
|
300
|
+
[(TState | (() => TState)) & ValidateLocationState<TState>],
|
|
301
|
+
TState
|
|
302
|
+
>;
|
|
132
303
|
}
|
|
133
304
|
|
|
134
305
|
/**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useState, useEffect } from "react";
|
|
3
|
+
import { useState, useEffect, useRef } from "react";
|
|
4
4
|
import type { LocationStateDefinition } from "./location-state-shared.js";
|
|
5
5
|
|
|
6
6
|
// Re-export shared utilities and types
|
|
@@ -13,6 +13,24 @@ export {
|
|
|
13
13
|
type LocationStateOptions,
|
|
14
14
|
} from "./location-state-shared.js";
|
|
15
15
|
|
|
16
|
+
function readLocationStateValue<TState>(
|
|
17
|
+
key: string | undefined,
|
|
18
|
+
): TState | undefined {
|
|
19
|
+
if (typeof window === "undefined") return undefined;
|
|
20
|
+
if (key) {
|
|
21
|
+
return window.history.state?.[key] as TState | undefined;
|
|
22
|
+
}
|
|
23
|
+
// Plain state: stored under history.state.state
|
|
24
|
+
return window.history.state?.state as TState | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function hasHydrated(): boolean {
|
|
28
|
+
return (
|
|
29
|
+
typeof document !== "undefined" &&
|
|
30
|
+
document.documentElement.hasAttribute("data-hydrated")
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
16
34
|
/**
|
|
17
35
|
* Hook to read location state from history.state
|
|
18
36
|
*
|
|
@@ -48,30 +66,33 @@ export function useLocationState<TArgs extends unknown[], TState>(
|
|
|
48
66
|
const key = definition?.__rsc_ls_key;
|
|
49
67
|
const isFlash = definition?.__rsc_ls_flash ?? false;
|
|
50
68
|
|
|
69
|
+
// Track whether the initial render returned undefined because the page
|
|
70
|
+
// hadn't hydrated yet. If so, the mount effect catches up by reading
|
|
71
|
+
// history.state once. If not, we already have the right value and must
|
|
72
|
+
// not re-read on mount — under StrictMode, the flash-cleanup effect runs
|
|
73
|
+
// before the second setup pass, so a re-read would clobber the captured
|
|
74
|
+
// value with the now-cleared `undefined`.
|
|
75
|
+
const initialReadDeferredRef = useRef(false);
|
|
76
|
+
|
|
51
77
|
const [state, setState] = useState<TState | undefined>(() => {
|
|
52
|
-
if (
|
|
53
|
-
|
|
54
|
-
return
|
|
78
|
+
if (!hasHydrated()) {
|
|
79
|
+
initialReadDeferredRef.current = true;
|
|
80
|
+
return undefined;
|
|
55
81
|
}
|
|
56
|
-
|
|
57
|
-
return window.history.state?.state as TState | undefined;
|
|
82
|
+
return readLocationStateValue<TState>(key);
|
|
58
83
|
});
|
|
59
84
|
|
|
60
85
|
// Subscribe to popstate and programmatic state changes
|
|
61
86
|
useEffect(() => {
|
|
62
87
|
const handlePopstate = () => {
|
|
63
|
-
|
|
64
|
-
setState(window.history.state?.[key] as TState | undefined);
|
|
65
|
-
} else {
|
|
66
|
-
setState(window.history.state?.state as TState | undefined);
|
|
67
|
-
}
|
|
88
|
+
setState(readLocationStateValue<TState>(key));
|
|
68
89
|
};
|
|
69
90
|
|
|
70
91
|
// Handle programmatic state changes (same-page navigation with
|
|
71
92
|
// ctx.setLocationState where components don't remount)
|
|
72
93
|
const handleLocationState = () => {
|
|
73
94
|
if (key) {
|
|
74
|
-
const val =
|
|
95
|
+
const val = readLocationStateValue<TState>(key);
|
|
75
96
|
if (isFlash) {
|
|
76
97
|
// For flash state, only update if there's a new value
|
|
77
98
|
if (val !== undefined) {
|
|
@@ -81,10 +102,15 @@ export function useLocationState<TArgs extends unknown[], TState>(
|
|
|
81
102
|
setState(val);
|
|
82
103
|
}
|
|
83
104
|
} else {
|
|
84
|
-
setState(
|
|
105
|
+
setState(readLocationStateValue<TState>(key));
|
|
85
106
|
}
|
|
86
107
|
};
|
|
87
108
|
|
|
109
|
+
if (initialReadDeferredRef.current) {
|
|
110
|
+
initialReadDeferredRef.current = false;
|
|
111
|
+
setState(readLocationStateValue<TState>(key));
|
|
112
|
+
}
|
|
113
|
+
|
|
88
114
|
window.addEventListener("popstate", handlePopstate);
|
|
89
115
|
window.addEventListener("__rsc_locationstate", handleLocationState);
|
|
90
116
|
return () => {
|