diff --git a/.changeset/history-depth-capped-length.md b/.changeset/history-depth-capped-length.md new file mode 100644 index 00000000..5e033db3 --- /dev/null +++ b/.changeset/history-depth-capped-length.md @@ -0,0 +1,5 @@ +--- +"@solidjs/router": patch +--- + +Stamp a pushed history entry's `_depth` as one above the current entry instead of `history.length - 1`. Browsers cap session history (50 entries in Chromium and Firefox), so once the cap was reached every new entry got the same depth. That broke scroll restoration and `useBeforeLeave` back-navigation deltas on long sessions. diff --git a/src/routers/history.ts b/src/routers/history.ts index 18c55c3f..b70d4c0e 100644 --- a/src/routers/history.ts +++ b/src/routers/history.ts @@ -27,6 +27,17 @@ function keepDepth(state: any) { }; } +// A pushed entry sits one above the current one. Stamp it from `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. +function nextDepth(state: any) { + return { + ...state, + _depth: (depth ?? window.history.length - 1) + 1 + }; +} + function notifyIfNotBlocked( notify: (value?: string | LocationChange) => void, block: (delta: number | null) => boolean @@ -87,7 +98,7 @@ export function browserHistory(): RouterHistory { 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(); @@ -136,7 +147,7 @@ export function hashHistory(): RouterHistory { 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/test/browser-history.spec.ts b/test/browser-history.spec.ts index c49e7987..a1e4aaed 100644 --- a/test/browser-history.spec.ts +++ b/test/browser-history.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test, vi } from "vitest"; -import { browserHistory } from "../src/index.js"; +import { browserHistory, hashHistory } from "../src/index.js"; describe("browserHistory", () => { afterEach(() => vi.restoreAllMocks()); @@ -10,4 +10,42 @@ describe("browserHistory", () => { expect(() => browserHistory()).not.toThrow(); }); + + // #694: browsers cap session history (50 entries in Chromium and Firefox). + // Once full, a push drops the oldest entry and history.length stops growing. + test("stamps increasing depths on push when history.length is capped", () => { + window.history.replaceState({ _depth: 48 }, "", "/"); + vi.spyOn(window.history, "length", "get").mockReturnValue(50); + const history = browserHistory(); + + const depths: number[] = []; + for (let i = 1; i <= 3; i++) { + history.set({ value: `/${i}` }); + depths.push(window.history.state._depth); + } + expect(depths).toEqual([49, 50, 51]); + + history.set({ value: "/3?replaced", replace: true }); + expect(window.history.state._depth).toBe(51); + }); + + test("keeps user state when stamping the depth on push", () => { + window.history.replaceState({ _depth: 3 }, "", "/"); + const history = browserHistory(); + history.set({ value: "/next", state: { from: "test" } }); + expect(window.history.state).toEqual({ from: "test", _depth: 4 }); + }); + + test("hashHistory stamps increasing depths on push when history.length is capped", () => { + window.history.replaceState({ _depth: 48 }, "", "/"); + vi.spyOn(window.history, "length", "get").mockReturnValue(50); + const history = hashHistory(); + + const depths: number[] = []; + for (let i = 1; i <= 3; i++) { + history.set({ value: `/${i}` }); + depths.push(window.history.state._depth); + } + expect(depths).toEqual([49, 50, 51]); + }); });