diff --git a/.changeset/history-depth-capped-length.md b/.changeset/history-depth-capped-length.md
new file mode 100644
index 00000000..0aad0177
--- /dev/null
+++ b/.changeset/history-depth-capped-length.md
@@ -0,0 +1,5 @@
+---
+"@solidjs/router": patch
+---
+
+Stamp history depth from the current entry instead of `history.length`, so `useBeforeLeave` and scroll restoration keep working on Back once the browser's session history is full (#694)
diff --git a/src/lifecycle.ts b/src/lifecycle.ts
index b1cc15ab..79eb9b27 100644
--- a/src/lifecycle.ts
+++ b/src/lifecycle.ts
@@ -47,10 +47,16 @@ export function createBeforeLeave(): BeforeLeaveLifecycle {
let depth: number;
export function saveCurrentDepth() {
- if (!window.history.state || window.history.state._depth == null) {
- window.history.replaceState({ ...window.history.state, _depth: window.history.length - 1 }, "");
+ const h = window.history;
+ let d = h.state && h.state._depth;
+ if (d == null) {
+ // An entry the router didn't push (e.g. a `#anchor` jump) sits one above
+ // the last known one. `history.length` stops growing once the browser's
+ // session history is full, so only fall back to it when nothing is known.
+ d = depth == null ? h.length - 1 : depth + 1;
+ h.replaceState({ ...h.state, _depth: d }, "");
}
- depth = window.history.state._depth;
+ depth = (h.state && h.state._depth) ?? d;
}
if (!isServer) {
saveCurrentDepth();
@@ -63,6 +69,19 @@ export function keepDepth(state: any) {
};
}
+// A pushed entry sits one above the current one. Stamp it from the current
+// depth, not `history.length`: once the browser's session history is full (50
+// entries in Chromium and Firefox) a push drops the oldest entry and `length`
+// stays the same, so `length - 1` would give every new entry the depth of the
+// one below it.
+export function nextDepth(state: any) {
+ const current = (window.history.state && window.history.state._depth) ?? depth;
+ return {
+ ...state,
+ _depth: (current ?? window.history.length - 1) + 1
+ };
+}
+
export function notifyIfNotBlocked(
notify: (value?: string | LocationChange) => void,
block: (delta: number | null) => boolean
diff --git a/src/routers/HashRouter.ts b/src/routers/HashRouter.ts
index 89313a91..ec273c67 100644
--- a/src/routers/HashRouter.ts
+++ b/src/routers/HashRouter.ts
@@ -2,7 +2,7 @@ import type { JSX } from "solid-js";
import { setupNativeEvents } from "../data/events.js";
import type { BaseRouterProps } from "./components.jsx";
import { createRouter, scrollToHash, bindEvent } from "./createRouter.js";
-import { createBeforeLeave, keepDepth, notifyIfNotBlocked, saveCurrentDepth } from "../lifecycle.js";
+import { createBeforeLeave, keepDepth, nextDepth, notifyIfNotBlocked, saveCurrentDepth } from "../lifecycle.js";
export function hashParser(str: string) {
const to = str.replace(/^.*?#/, "");
@@ -27,7 +27,7 @@ export function HashRouter(props: HashRouterProps): JSX.Element {
if (replace) {
window.history.replaceState(keepDepth(state), "", "#" + value);
} else {
- window.history.pushState(state, "", "#" + value);
+ window.history.pushState(nextDepth(state), "", "#" + value);
}
const hashIndex = value.indexOf("#");
const hash = hashIndex >= 0 ? value.slice(hashIndex + 1) : "";
diff --git a/src/routers/Router.ts b/src/routers/Router.ts
index d6a74283..2d6976c0 100644
--- a/src/routers/Router.ts
+++ b/src/routers/Router.ts
@@ -4,7 +4,7 @@ import { StaticRouter } from "./StaticRouter.js";
import { setupNativeEvents } from "../data/events.js";
import type { BaseRouterProps } from "./components.jsx";
import type { JSX } from "solid-js";
-import { createBeforeLeave, keepDepth, notifyIfNotBlocked, saveCurrentDepth } from "../lifecycle.js";
+import { createBeforeLeave, keepDepth, nextDepth, notifyIfNotBlocked, saveCurrentDepth } from "../lifecycle.js";
import { createScrollRestoration } from "./scrollRestoration.js";
export type RouterProps = BaseRouterProps & {
@@ -40,7 +40,7 @@ export function Router(props: RouterProps): JSX.Element {
if (replace) {
window.history.replaceState(keepDepth(state), "", value);
} else {
- window.history.pushState(state, "", value);
+ window.history.pushState(nextDepth(state), "", value);
}
scrollToHash(decodeURIComponent(window.location.hash.slice(1)), scroll);
saveCurrentDepth();
diff --git a/test/history-depth.spec.tsx b/test/history-depth.spec.tsx
new file mode 100644
index 00000000..6f4f5217
--- /dev/null
+++ b/test/history-depth.spec.tsx
@@ -0,0 +1,98 @@
+import { render } from "solid-js/web";
+import { vi } from "vitest";
+import { HashRouter, Router, Route, useNavigate } from "../src/index.js";
+import type { Navigator } from "../src/index.js";
+import { notifyIfNotBlocked } from "../src/lifecycle.js";
+
+// #694: browsers cap session history (50 entries in Chromium and Firefox).
+// Once full, a push drops the oldest entry and history.length stops growing,
+// so depths taken from history.length repeat.
+describe("history depth when history.length is capped (#694)", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ document.body.innerHTML = "";
+ });
+
+ const url = () => window.location.pathname + window.location.search + window.location.hash;
+
+ async function pushThree(RouterComponent: typeof Router | typeof HashRouter, hash = false) {
+ let navigate!: Navigator;
+ const Page = () => {
+ navigate = useNavigate();
+ return null;
+ };
+ const dispose = render(
+ () => (
+
+
+
+ ),
+ document.body
+ );
+ try {
+ const depths: number[] = [];
+ for (let i = 1; i <= 3; i++) {
+ navigate(`/${i}`);
+ await vi.waitFor(() => expect(url()).toBe(hash ? `/#/${i}` : `/${i}`));
+ depths.push(window.history.state._depth);
+ }
+ navigate("/3?replaced", { replace: true });
+ await vi.waitFor(() => expect(url()).toBe(hash ? "/#/3?replaced" : "/3?replaced"));
+ depths.push(window.history.state._depth);
+ return depths;
+ } finally {
+ dispose();
+ }
+ }
+
+ test("Router stamps increasing depths on push", async () => {
+ window.history.replaceState({ _depth: 48 }, "", "/");
+ vi.spyOn(window.history, "length", "get").mockReturnValue(50);
+ expect(await pushThree(Router)).toEqual([49, 50, 51, 51]);
+ });
+
+ test("HashRouter stamps increasing depths on push", async () => {
+ window.history.replaceState({ _depth: 48 }, "", "/");
+ vi.spyOn(window.history, "length", "get").mockReturnValue(50);
+ expect(await pushThree(HashRouter, true)).toEqual([49, 50, 51, 51]);
+ });
+
+ test("Router keeps user state when stamping the depth on push", async () => {
+ window.history.replaceState({ _depth: 3 }, "", "/");
+ let navigate!: Navigator;
+ const dispose = render(
+ () => (
+
+ {
+ navigate = useNavigate();
+ return null;
+ }}
+ />
+
+ ),
+ document.body
+ );
+ try {
+ navigate("/next", { state: { from: "test" } });
+ await vi.waitFor(() => expect(url()).toBe("/next"));
+ expect(window.history.state).toEqual({ from: "test", _depth: 4 });
+ } finally {
+ dispose();
+ }
+ });
+
+ test("an entry pushed outside the router counts up from the last depth", () => {
+ window.history.replaceState({ _depth: 49 }, "", "/");
+ vi.spyOn(window.history, "length", "get").mockReturnValue(50);
+ const notify = vi.fn();
+ const handler = notifyIfNotBlocked(notify, () => false);
+ handler(); // record the current depth
+ notify.mockClear();
+ window.history.pushState(null, "", "/#anchor");
+ handler();
+ expect(window.history.state._depth).toBe(50);
+ expect(notify).toHaveBeenCalledTimes(1);
+ });
+});