From bf196cd8dfdb9bda25f71e69503bce8fe610dbfc Mon Sep 17 00:00:00 2001 From: sarankumar1325 <120080287+sarankumar1325@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:11:34 +0530 Subject: [PATCH] fix(history): stamp history depth from the current entry, not history.length Browsers cap session history (50 entries in Chromium and Firefox). Once the cap is reached, pushState drops the oldest entry and history.length stays the same, so `history.length - 1` gave every new entry the depth of the one below it. Back then computed a zero delta (useBeforeLeave was skipped) and scroll restoration shared one slot between entries. Router and HashRouter now stamp a pushed entry with the current depth + 1, and saveCurrentDepth() stamps an entry the router didn't push (e.g. a #anchor jump) one above the last known depth, falling back to history.length only when no depth is known. Replace keeps the current depth as before. This is the 1.x counterpart of #695 and #707 on `next`. Fixes #694 --- .changeset/history-depth-capped-length.md | 5 ++ src/lifecycle.ts | 25 +++++- src/routers/HashRouter.ts | 4 +- src/routers/Router.ts | 4 +- test/history-depth.spec.tsx | 98 +++++++++++++++++++++++ 5 files changed, 129 insertions(+), 7 deletions(-) create mode 100644 .changeset/history-depth-capped-length.md create mode 100644 test/history-depth.spec.tsx 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); + }); +});