@rangojs/router 0.0.0-experimental.100 → 0.0.0-experimental.102

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 CHANGED
@@ -1050,7 +1050,8 @@ export const renderHTML = createSSRHandler({
1050
1050
  // src/vite/plugins/version-plugin.ts
1051
1051
  var version_plugin_exports = {};
1052
1052
  __export(version_plugin_exports, {
1053
- createVersionPlugin: () => createVersionPlugin
1053
+ createVersionPlugin: () => createVersionPlugin,
1054
+ isViteDepCachePath: () => isViteDepCachePath
1054
1055
  });
1055
1056
  import { parseAst } from "vite";
1056
1057
  function isCodeModule(id) {
@@ -1145,6 +1146,7 @@ function createVersionPlugin() {
1145
1146
  let currentVersion = buildVersion;
1146
1147
  let isDev = false;
1147
1148
  let server = null;
1149
+ let resolvedCacheDir;
1148
1150
  const clientModuleSignatures = /* @__PURE__ */ new Map();
1149
1151
  let versionCounter = 0;
1150
1152
  const bumpVersion = (reason) => {
@@ -1163,6 +1165,7 @@ function createVersionPlugin() {
1163
1165
  enforce: "pre",
1164
1166
  configResolved(config) {
1165
1167
  isDev = config.command === "serve";
1168
+ resolvedCacheDir = config.cacheDir ? String(config.cacheDir).replace(/\\/g, "/") : void 0;
1166
1169
  },
1167
1170
  configureServer(devServer) {
1168
1171
  server = devServer;
@@ -1204,6 +1207,7 @@ function createVersionPlugin() {
1204
1207
  if (!isDev) return;
1205
1208
  const isRscModule = this.environment?.name === "rsc";
1206
1209
  if (!isRscModule) return;
1210
+ if (isViteDepCachePath(ctx.file, resolvedCacheDir)) return;
1207
1211
  if (ctx.modules.length === 1 && ctx.modules[0].id === "\0" + VIRTUAL_IDS.version) {
1208
1212
  return;
1209
1213
  }
@@ -1233,6 +1237,17 @@ function createVersionPlugin() {
1233
1237
  }
1234
1238
  };
1235
1239
  }
1240
+ function isViteDepCachePath(filePath, cacheDir) {
1241
+ if (!filePath) return false;
1242
+ const normalized = filePath.replace(/\\/g, "/");
1243
+ if (cacheDir) {
1244
+ const normalizedCacheDir = cacheDir.replace(/\\/g, "/").replace(/\/+$/, "");
1245
+ if (normalized === normalizedCacheDir || normalized.startsWith(normalizedCacheDir + "/")) {
1246
+ return true;
1247
+ }
1248
+ }
1249
+ return /\/node_modules\/\.vite[^/]*\//.test(normalized) || normalized.includes("/.vite-isolated/");
1250
+ }
1236
1251
  var init_version_plugin = __esm({
1237
1252
  "src/vite/plugins/version-plugin.ts"() {
1238
1253
  "use strict";
@@ -2040,7 +2040,7 @@ import { resolve } from "node:path";
2040
2040
  // package.json
2041
2041
  var package_default = {
2042
2042
  name: "@rangojs/router",
2043
- version: "0.0.0-experimental.100",
2043
+ version: "0.0.0-experimental.102",
2044
2044
  description: "Django-inspired RSC router with composable URL patterns",
2045
2045
  keywords: [
2046
2046
  "react",
@@ -3077,6 +3077,7 @@ function createVersionPlugin() {
3077
3077
  let currentVersion = buildVersion;
3078
3078
  let isDev = false;
3079
3079
  let server = null;
3080
+ let resolvedCacheDir;
3080
3081
  const clientModuleSignatures = /* @__PURE__ */ new Map();
3081
3082
  let versionCounter = 0;
3082
3083
  const bumpVersion = (reason) => {
@@ -3095,6 +3096,7 @@ function createVersionPlugin() {
3095
3096
  enforce: "pre",
3096
3097
  configResolved(config) {
3097
3098
  isDev = config.command === "serve";
3099
+ resolvedCacheDir = config.cacheDir ? String(config.cacheDir).replace(/\\/g, "/") : void 0;
3098
3100
  },
3099
3101
  configureServer(devServer) {
3100
3102
  server = devServer;
@@ -3136,6 +3138,7 @@ function createVersionPlugin() {
3136
3138
  if (!isDev) return;
3137
3139
  const isRscModule = this.environment?.name === "rsc";
3138
3140
  if (!isRscModule) return;
3141
+ if (isViteDepCachePath(ctx.file, resolvedCacheDir)) return;
3139
3142
  if (ctx.modules.length === 1 && ctx.modules[0].id === "\0" + VIRTUAL_IDS.version) {
3140
3143
  return;
3141
3144
  }
@@ -3165,6 +3168,17 @@ function createVersionPlugin() {
3165
3168
  }
3166
3169
  };
3167
3170
  }
3171
+ function isViteDepCachePath(filePath, cacheDir) {
3172
+ if (!filePath) return false;
3173
+ const normalized = filePath.replace(/\\/g, "/");
3174
+ if (cacheDir) {
3175
+ const normalizedCacheDir = cacheDir.replace(/\\/g, "/").replace(/\/+$/, "");
3176
+ if (normalized === normalizedCacheDir || normalized.startsWith(normalizedCacheDir + "/")) {
3177
+ return true;
3178
+ }
3179
+ }
3180
+ return /\/node_modules\/\.vite[^/]*\//.test(normalized) || normalized.includes("/.vite-isolated/");
3181
+ }
3168
3182
 
3169
3183
  // src/vite/utils/shared-utils.ts
3170
3184
  import * as Vite from "vite";
@@ -3784,7 +3798,8 @@ function createDiscoveryState(entryPath, opts) {
3784
3798
  devServerOrigin: null,
3785
3799
  devServer: null,
3786
3800
  selfWrittenGenFiles: /* @__PURE__ */ new Map(),
3787
- SELF_WRITE_WINDOW_MS: 5e3
3801
+ SELF_WRITE_WINDOW_MS: 5e3,
3802
+ lastDiscoveryError: null
3788
3803
  };
3789
3804
  }
3790
3805
 
@@ -5768,10 +5783,25 @@ ${err.stack}`
5768
5783
  () => writeRouteTypesFiles(s)
5769
5784
  );
5770
5785
  }
5786
+ if (s.lastDiscoveryError) {
5787
+ debugDiscovery?.(
5788
+ "hmr: cleared lastDiscoveryError (%s) after successful rediscovery",
5789
+ s.lastDiscoveryError.message
5790
+ );
5791
+ s.lastDiscoveryError = null;
5792
+ }
5771
5793
  } catch (err) {
5794
+ s.lastDiscoveryError = {
5795
+ message: err?.message ?? String(err),
5796
+ at: Date.now()
5797
+ };
5772
5798
  console.warn(
5773
5799
  `[rsc-router] Runtime re-discovery failed: ${err.message}`
5774
5800
  );
5801
+ debugDiscovery?.(
5802
+ "hmr: lastDiscoveryError set (%s) \u2014 manifest preserved at last-good; recovery mode active (any in-scan source change will trigger rediscovery)",
5803
+ err?.message
5804
+ );
5775
5805
  } finally {
5776
5806
  debugDiscovery?.(
5777
5807
  "hmr re-discovery done (%sms)",
@@ -5819,23 +5849,52 @@ ${err.stack}`
5819
5849
  };
5820
5850
  const handleRouteFileChange = (filePath) => {
5821
5851
  if (maybeHandleGeneratedRouteFileMutation(filePath)) return;
5822
- if (!filePath.endsWith(".ts") && !filePath.endsWith(".tsx") && !filePath.endsWith(".js") && !filePath.endsWith(".jsx"))
5852
+ if (!filePath.endsWith(".ts") && !filePath.endsWith(".tsx") && !filePath.endsWith(".js") && !filePath.endsWith(".jsx")) {
5853
+ if (s.lastDiscoveryError) {
5854
+ debugDiscovery?.(
5855
+ "watcher: skip non-source %s [LASTERR %s]",
5856
+ filePath,
5857
+ s.lastDiscoveryError.message
5858
+ );
5859
+ }
5823
5860
  return;
5824
- if (s.scanFilter && !s.scanFilter(filePath)) return;
5861
+ }
5862
+ if (s.scanFilter && !s.scanFilter(filePath)) {
5863
+ if (s.lastDiscoveryError) {
5864
+ debugDiscovery?.(
5865
+ "watcher: skip scan-filter %s [LASTERR %s]",
5866
+ filePath,
5867
+ s.lastDiscoveryError.message
5868
+ );
5869
+ }
5870
+ return;
5871
+ }
5872
+ const inRecoveryMode = !!s.lastDiscoveryError;
5825
5873
  try {
5826
5874
  const source = readFileSync6(filePath, "utf-8");
5827
5875
  const trimmed = source.trimStart();
5828
- if (trimmed.startsWith('"use client"') || trimmed.startsWith("'use client'"))
5829
- return;
5876
+ const isUseClient = trimmed.startsWith('"use client"') || trimmed.startsWith("'use client'");
5877
+ if (!inRecoveryMode && isUseClient) return;
5830
5878
  const hasUrls = source.includes("urls(");
5831
5879
  const hasCreateRouter = /\bcreateRouter\s*[<(]/.test(source);
5832
- if (!hasUrls && !hasCreateRouter) return;
5833
- debugDiscovery?.(
5834
- "watcher: %s matches (urls=%s, router=%s)",
5835
- filePath,
5836
- hasUrls,
5837
- hasCreateRouter
5838
- );
5880
+ if (!inRecoveryMode && !hasUrls && !hasCreateRouter) return;
5881
+ if (inRecoveryMode) {
5882
+ debugDiscovery?.(
5883
+ "watcher: recovery rediscovery for %s (urls=%s, router=%s, useClient=%s) [LASTERR %s]",
5884
+ filePath,
5885
+ hasUrls,
5886
+ hasCreateRouter,
5887
+ isUseClient,
5888
+ s.lastDiscoveryError.message
5889
+ );
5890
+ } else {
5891
+ debugDiscovery?.(
5892
+ "watcher: %s matches (urls=%s, router=%s)",
5893
+ filePath,
5894
+ hasUrls,
5895
+ hasCreateRouter
5896
+ );
5897
+ }
5839
5898
  if (hasCreateRouter) {
5840
5899
  const nestedRouterConflict = findNestedRouterConflict([
5841
5900
  ...s.cachedRouterFiles ?? [],
@@ -5853,7 +5912,15 @@ ${err.stack}`
5853
5912
  gate.noteRouteEvent();
5854
5913
  }
5855
5914
  scheduleRouteRegeneration();
5856
- } catch {
5915
+ } catch (readErr) {
5916
+ if (s.lastDiscoveryError) {
5917
+ debugDiscovery?.(
5918
+ "watcher: read error %s: %s [LASTERR %s]",
5919
+ filePath,
5920
+ readErr?.message,
5921
+ s.lastDiscoveryError.message
5922
+ );
5923
+ }
5857
5924
  }
5858
5925
  };
5859
5926
  server.watcher.on("add", handleRouteFileChange);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.100",
3
+ "version": "0.0.0-experimental.102",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -190,6 +190,47 @@ function SearchResults() {
190
190
  }
191
191
  ```
192
192
 
193
+ **Shared refetch behavior**:
194
+
195
+ When the loader is registered on the route via `loader()`, a plain
196
+ `load()` call (no options, or a trivially-defaulted GET with no
197
+ `params` and no `body`) broadcasts its result to every component
198
+ reading the same loader id. Layout, page, and parallel-slot reads
199
+ all converge on the new value:
200
+
201
+ ```tsx
202
+ // Layout button calls load() — the page read below sees the update too.
203
+ function Layout() {
204
+ const { data, load } = useLoader(CartLoader);
205
+ return <button onClick={() => load()}>Refresh ({data.count})</button>;
206
+ }
207
+ function Page() {
208
+ const { data } = useLoader(CartLoader); // updates with the layout's load()
209
+ return <span>{data.count} items</span>;
210
+ }
211
+ ```
212
+
213
+ `isLoading` and `error` follow the same scope. `throwOnError: true`
214
+ render-throws are scoped to the **originating** hook — sibling readers
215
+ see the error in their `error` state but their boundaries are not
216
+ triggered by someone else's failure. A successful follow-up `load()`
217
+ clears the shared error.
218
+
219
+ **`load()` calls that stay local** (no broadcast, per-hook state, same
220
+ semantics as the old per-component `useState`):
221
+
222
+ - `load({ params: { ... } })` — explicit params.
223
+ - `load({ method: "POST", body })` — mutations.
224
+ - Any `load()` on a `useFetchLoader(loader)` whose loader is **not**
225
+ registered on the current route. Two unrelated components calling
226
+ `load()` on the same fetchable-but-unregistered loader keep
227
+ independent results.
228
+
229
+ So the search/list pattern still works — two components calling
230
+ `load({ params: { q } })` with different `q` values each keep their
231
+ own result; they do not collapse to last-write-wins through a shared
232
+ store.
233
+
193
234
  **Load options**:
194
235
 
195
236
  ```tsx
@@ -511,6 +552,43 @@ const flash = FlashMessage.read();
511
552
  const product = ProductState.read();
512
553
  ```
513
554
 
555
+ > **Hydration:** `.read()` returns `undefined` on the server but may return
556
+ > a real value on the first client render (history state survives reload).
557
+ > Do not call `.read()` directly during the initial render of a component;
558
+ > call it from an event handler or inside a `useEffect` post-mount. For
559
+ > reactive hydration-safe access, use `useLocationState()` instead.
560
+
561
+ ### .write() / .delete() (static, non-reactive)
562
+
563
+ Static counterparts to `.read()`. Both mutate the current history entry's
564
+ `history.state` via `replaceState`, preserving any other keys (router
565
+ bookkeeping, other location state slots). Both are client-only; they throw
566
+ when called on the server.
567
+
568
+ Neither dispatches an event, so components reading via `useLocationState`
569
+ will NOT re-render until the next navigation/popstate. Pair with `.read()`
570
+ (or a fresh mount via back/forward/reload) instead.
571
+
572
+ ```tsx
573
+ "use client";
574
+ import { ProductState } from "./state";
575
+
576
+ // Persisted across hard refresh and back/forward of this entry.
577
+ ProductState.write({ name: "Widget", price: 9.99 });
578
+
579
+ // Read later (or on next mount).
580
+ const current = ProductState.read();
581
+
582
+ // Manually clear the slot. Idempotent if it isn't set.
583
+ ProductState.delete();
584
+ ```
585
+
586
+ | Method | Updates `history.state` | Fires `useLocationState` rerender | SSR behavior |
587
+ | ----------- | ----------------------- | --------------------------------- | ------------------- |
588
+ | `.read()` | no | n/a (returns snapshot) | returns `undefined` |
589
+ | `.write()` | yes (replace this slot) | no | throws |
590
+ | `.delete()` | yes (remove this slot) | no | throws |
591
+
514
592
  ## Cache Hooks
515
593
 
516
594
  ### useClientCache()
@@ -606,6 +606,13 @@ export const FileUploadLoader = createLoader(async (ctx) => {
606
606
 
607
607
  Client usage — see `/hooks useFetchLoader` for the full client-side pattern.
608
608
 
609
+ > **Refetch sharing**: when the loader is registered on the route via
610
+ > `loader()`, a plain `load()` call (no `params`, no `body`) broadcasts
611
+ > the new value to every component reading the same loader id —
612
+ > `useLoader` reads in layouts, pages, and parallel slots all converge.
613
+ > Calls with `params` or a non-GET method stay local to the call site.
614
+ > See `/hooks` → "Shared refetch behavior" for the full contract.
615
+
609
616
  ## Complete Example
610
617
 
611
618
  ```typescript
@@ -61,6 +61,27 @@ export function buildHistoryState(
61
61
  return Object.keys(result).length > 0 ? result : null;
62
62
  }
63
63
 
64
+ /**
65
+ * Stamp an `idx` on the next history entry's state and call push/replaceState.
66
+ * Push increments the current idx; replace keeps it. Initial entry idx is 0.
67
+ * Used by useRouter().back() to detect "first entry in this session" without
68
+ * relying on the Navigation API.
69
+ */
70
+ export function pushHistoryWithIdx(
71
+ state: Record<string, unknown> | null,
72
+ url: string,
73
+ replace: boolean,
74
+ ): void {
75
+ const oldIdx = (window.history.state as { idx?: number } | null)?.idx ?? 0;
76
+ const newIdx = replace ? oldIdx : oldIdx + 1;
77
+ const finalState = { ...(state ?? {}), idx: newIdx };
78
+ if (replace) {
79
+ window.history.replaceState(finalState, "", url);
80
+ } else {
81
+ window.history.pushState(finalState, "", url);
82
+ }
83
+ }
84
+
64
85
  /**
65
86
  * Merge server-set location state into the current history entry.
66
87
  * Replaces the current history state and dispatches notification event
@@ -13,7 +13,7 @@ import {
13
13
  createNavigationTransaction,
14
14
  resolveNavigationState,
15
15
  } from "./navigation-transaction.js";
16
- import { buildHistoryState } from "./history-state.js";
16
+ import { buildHistoryState, pushHistoryWithIdx } from "./history-state.js";
17
17
  import {
18
18
  handleNavigationStart,
19
19
  handleNavigationEnd,
@@ -204,11 +204,7 @@ export function createNavigationBridge(
204
204
  },
205
205
  {},
206
206
  );
207
- if (options.replace) {
208
- window.history.replaceState(historyState, "", url);
209
- } else {
210
- window.history.pushState(historyState, "", url);
211
- }
207
+ pushHistoryWithIdx(historyState, url, options?.replace ?? false);
212
208
 
213
209
  // Ensure new history entry has a scroll restoration key
214
210
  ensureHistoryKey();
@@ -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
- if (replace) {
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
 
@@ -34,8 +34,43 @@ export interface LocationStateDefinition<TArgs extends unknown[], TState> {
34
34
  __rsc_ls_key: string;
35
35
  /** Whether this state auto-clears after first read */
36
36
  readonly __rsc_ls_flash: boolean;
37
- /** Read the current value from history.state (client-side only, undefined during SSR) */
37
+ /**
38
+ * Read the current value from history.state.
39
+ *
40
+ * Returns undefined during SSR (no `window`). To stay hydration-safe, do
41
+ * NOT call read() inline during the initial render — the server returns
42
+ * undefined while the client may have a value preserved in history.state
43
+ * (e.g. after a hard reload of an entry that earlier called write()),
44
+ * which causes a hydration mismatch. Call read() inside an event handler
45
+ * or a useEffect post-mount instead, or use useLocationState() if you
46
+ * want React to manage subscription/hydration for you.
47
+ */
38
48
  read(): TState | undefined;
49
+ /**
50
+ * Statically write the value into the current history entry under this
51
+ * definition's key, preserving any other keys already on history.state
52
+ * (e.g. router bookkeeping, other LocationState slots).
53
+ *
54
+ * This is the non-reactive counterpart to read(): it does not dispatch any
55
+ * event, so components reading via useLocationState() will NOT re-render
56
+ * until the next navigation/popstate. Use it when you only need the value
57
+ * to be there on the next read() or on the next mount (including after
58
+ * back/forward and hard refresh of the same entry).
59
+ *
60
+ * Client-only: throws when called on the server (no history available).
61
+ */
62
+ write(value: TState): void;
63
+ /**
64
+ * Statically remove this definition's slot from the current history entry,
65
+ * leaving any other keys on history.state untouched. Idempotent: removing
66
+ * a slot that isn't present is a no-op.
67
+ *
68
+ * Same non-reactive semantics as write(): no event is dispatched, so
69
+ * useLocationState() readers will NOT re-render until the next navigation.
70
+ *
71
+ * Client-only: throws when called on the server (no history available).
72
+ */
73
+ delete(): void;
39
74
  }
40
75
 
41
76
  /**
@@ -70,6 +105,15 @@ export interface LocationStateDefinition<TArgs extends unknown[], TState> {
70
105
  *
71
106
  * // Read without hook (snapshot, client-side only)
72
107
  * const snap = ProductState.read();
108
+ *
109
+ * // Static write to current history entry (non-reactive, client-side only).
110
+ * // Survives back/forward and hard refresh; useLocationState() readers will
111
+ * // NOT see the new value until the next navigation. Pair with .read() or a
112
+ * // fresh mount.
113
+ * ProductState.write({ name: "Widget", price: 9.99 });
114
+ *
115
+ * // Manually clear the slot (non-reactive, client-side only).
116
+ * ProductState.delete();
73
117
  * ```
74
118
  */
75
119
  export function createLocationState<TState>(
@@ -128,6 +172,43 @@ export function createLocationState<TState>(
128
172
  enumerable: true,
129
173
  });
130
174
 
175
+ Object.defineProperty(fn, "write", {
176
+ value: (value: TState): void => {
177
+ if (typeof window === "undefined") {
178
+ throw new Error(
179
+ "[rsc-router] LocationState.write() is client-only. " +
180
+ "It mutates window.history.state and cannot run on the server.",
181
+ );
182
+ }
183
+ const key = getKey();
184
+ const current = window.history.state ?? {};
185
+ window.history.replaceState(
186
+ { ...current, [key]: value },
187
+ "",
188
+ window.location.href,
189
+ );
190
+ },
191
+ enumerable: true,
192
+ });
193
+
194
+ Object.defineProperty(fn, "delete", {
195
+ value: (): void => {
196
+ if (typeof window === "undefined") {
197
+ throw new Error(
198
+ "[rsc-router] LocationState.delete() is client-only. " +
199
+ "It mutates window.history.state and cannot run on the server.",
200
+ );
201
+ }
202
+ const key = getKey();
203
+ const current = window.history.state;
204
+ if (current == null || !(key in current)) return;
205
+ const next = { ...current };
206
+ delete next[key];
207
+ window.history.replaceState(next, "", window.location.href);
208
+ },
209
+ enumerable: true,
210
+ });
211
+
131
212
  return fn as LocationStateDefinition<[TState | (() => TState)], TState>;
132
213
  }
133
214
 
@@ -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 (typeof window === "undefined") return undefined;
53
- if (key) {
54
- return window.history.state?.[key] as TState | undefined;
78
+ if (!hasHydrated()) {
79
+ initialReadDeferredRef.current = true;
80
+ return undefined;
55
81
  }
56
- // Plain state: stored under history.state.state
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
- if (key) {
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 = window.history.state?.[key] as TState | undefined;
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(window.history.state?.state as TState | undefined);
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 () => {
@@ -72,7 +72,20 @@ export function useRouter(): RouterInstance {
72
72
  },
73
73
 
74
74
  back(): void {
75
- window.history.back();
75
+ // Avoid escaping the host on the first entry of this session.
76
+ // Prefer the Navigation API; fall back to the router-stamped
77
+ // history.state.idx (set by pushHistoryWithIdx) for older browsers.
78
+ const nav = (window as { navigation?: { canGoBack: boolean } })
79
+ .navigation;
80
+ const canGoBack =
81
+ nav && typeof nav.canGoBack === "boolean"
82
+ ? nav.canGoBack
83
+ : ((window.history.state as { idx?: number } | null)?.idx ?? 0) > 0;
84
+ if (canGoBack) {
85
+ window.history.back();
86
+ } else {
87
+ ctx.navigate(withBasename("/"), { replace: true });
88
+ }
76
89
  },
77
90
 
78
91
  forward(): void {