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); + }); +});