diff --git a/.changeset/history-depth-capped-length.md b/.changeset/history-depth-capped-length.md new file mode 100644 index 00000000..c3846b05 --- /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. Entries the router did not push, such as a `#anchor` jump, are stamped one above the last known depth as well. diff --git a/src/routers/history.ts b/src/routers/history.ts index 18c55c3f..082ac748 100644 --- a/src/routers/history.ts +++ b/src/routers/history.ts @@ -14,10 +14,14 @@ export function bindEvent(target: EventTarget, type: string, handler: EventListe let depth: number; export function saveCurrentDepth() { const h = window.history; - if (!h.state || h.state._depth == null) { - h.replaceState({ ...h.state, _depth: h.length - 1 }, ""); + let d = 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; `length` only counts once nothing is known (#694) + d = depth == null ? h.length - 1 : depth + 1; + h.replaceState({ ...h.state, _depth: d }, ""); } - depth = h.state?._depth ?? h.length - 1; + depth = h.state?._depth ?? d; } function keepDepth(state: any) { @@ -27,6 +31,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 +102,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 +151,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..d9fa561a 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,54 @@ 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("an entry pushed outside the router counts up from the last depth when history.length is capped", () => { + window.history.replaceState({ _depth: 49 }, "", "/"); + vi.spyOn(window.history, "length", "get").mockReturnValue(50); + const notify = vi.fn(); + const unbind = browserHistory().init!(notify); + window.history.pushState(null, "", "/#anchor"); + window.dispatchEvent(new PopStateEvent("popstate")); + unbind(); + expect(window.history.state._depth).toBe(50); + expect(notify).toHaveBeenCalledTimes(1); + }); + + 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]); + }); });