@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 +16 -1
- package/dist/vite/index.js +81 -14
- package/package.json +1 -1
- package/skills/hooks/SKILL.md +78 -0
- package/skills/loader/SKILL.md +7 -0
- package/src/browser/history-state.ts +21 -0
- package/src/browser/navigation-bridge.ts +2 -6
- package/src/browser/navigation-transaction.ts +3 -7
- package/src/browser/react/location-state-shared.ts +82 -1
- package/src/browser/react/location-state.ts +39 -13
- package/src/browser/react/use-router.ts +14 -1
- package/src/browser/scroll-restoration.ts +22 -14
- package/src/href-client.ts +4 -1
- package/src/loader-store.ts +202 -0
- package/src/use-loader.tsx +181 -37
- package/src/vite/discovery/state.ts +9 -0
- package/src/vite/plugins/version-plugin.ts +57 -0
- package/src/vite/router-discovery.ts +73 -14
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";
|
package/dist/vite/index.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
5829
|
-
|
|
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
|
-
|
|
5834
|
-
|
|
5835
|
-
|
|
5836
|
-
|
|
5837
|
-
|
|
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
package/skills/hooks/SKILL.md
CHANGED
|
@@ -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()
|
package/skills/loader/SKILL.md
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 (
|
|
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 () => {
|
|
@@ -72,7 +72,20 @@ export function useRouter(): RouterInstance {
|
|
|
72
72
|
},
|
|
73
73
|
|
|
74
74
|
back(): void {
|
|
75
|
-
|
|
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 {
|