From 4ba7c966d71f8f6cc7763fa2754975910ec350df Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 5 Oct 2026 01:13:06 +0200 Subject: [PATCH] fix(nav): the dead-page rewrite does not restore a spent focus stamp It spread the event's snapshot, which still carried the sfNav stamp lib/history's own listener had just taken off the entry, so a later landing there (an overlay closing) refocused a stale item. It now spreads the live entry (test red on the snapshot). Also: format.test's comment named a test image that never runs vitest; it is the gate host's locale. --- .../src/components/NavigationProvider.dom.test.tsx | 13 +++++++++++++ frontend/src/components/NavigationProvider.tsx | 9 +++++++-- frontend/src/lib/format.test.ts | 4 ++-- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/NavigationProvider.dom.test.tsx b/frontend/src/components/NavigationProvider.dom.test.tsx index 8c0bf528..23cc87c1 100644 --- a/frontend/src/components/NavigationProvider.dom.test.tsx +++ b/frontend/src/components/NavigationProvider.dom.test.tsx @@ -191,6 +191,19 @@ describe("NavigationProvider · Back", () => { expect(window.history.state).toMatchObject({ sfPage: "feed" }); expect(window.location.search).toBe("?keep=1"); }); + + // lib/history's own listener takes a spent focus stamp off the entry it lands on; the rewrite must + // not put it back, or the next landing there (an overlay closing) would refocus a stale item. + it("does not put back the focus stamp the landing spent", () => { + render(); + const st = { sfPage: "messages", sfNav: { list: 0, lists: 1 } }; + act(() => { + window.history.replaceState(st, "", "/?page=messages"); + window.dispatchEvent(new PopStateEvent("popstate", { state: st })); + }); + expect(window.history.state).toMatchObject({ sfPage: "feed" }); + expect(window.history.state).not.toHaveProperty("sfNav"); + }); }); describe("NavigationProvider · never under an open overlay", () => { diff --git a/frontend/src/components/NavigationProvider.tsx b/frontend/src/components/NavigationProvider.tsx index 36f56d93..fbbc571f 100644 --- a/frontend/src/components/NavigationProvider.tsx +++ b/frontend/src/components/NavigationProvider.tsx @@ -200,11 +200,16 @@ export function NavigationProvider({ children }: { children: ReactNode }) { // An entry can name a page this build no longer has (one pushed before a module was removed, // reached by Back after a reload onto the new build); that lands on the feed, not on a crash. // The entry is rewritten to say so: left alone, the address bar would keep naming the dead page - // and a click on Feed would be a no-op (setPage's same-page guard). + // and a click on Feed would be a no-op (setPage's same-page guard). It spreads the LIVE entry, + // not `e.state`: lib/history's listener ran first and already took the spent `sfNav` off it. const sf: unknown = e.state?.sfPage; const p: Page = typeof sf === "string" && isPage(sf) ? sf : "feed"; if (typeof sf === "string" && sf !== p) - window.history.replaceState({ ...e.state, sfPage: p }, "", urlWithParams({ page: null })); + window.history.replaceState( + { ...window.history.state, sfPage: p }, + "", + urlWithParams({ page: null }), + ); setPageState(p); setAccountRaw(PAGE_KEY, p); // The YouTube-search sub-view and the channel page ride in history.state, so Back/Forward diff --git a/frontend/src/lib/format.test.ts b/frontend/src/lib/format.test.ts index 29abd9d3..e3773ece 100644 --- a/frontend/src/lib/format.test.ts +++ b/frontend/src/lib/format.test.ts @@ -132,8 +132,8 @@ describe("relativeFromMs", () => { // A bare `n.toLocaleString()` groups in the BROWSER's locale, so an English UI in a Hungarian browser // printed "1 234 567" (and a Hungarian UI in an English browser "1,234,567"). The case below asserts // BOTH languages and that the two differ, rather than comparing against the runtime's own default: -// the default is whatever locale the machine running the suite has (Hungarian on the dev box, English -// in the test image), so a one-language assertion passes under the old bug on half the machines. +// the default is whatever locale the host running the gate has (Hungarian on the dev box, English or +// C on a Linux host), so a one-language assertion passes under the old bug on half the machines. describe("formatNumber", () => { afterEach(() => { i18nStub.language = "en";