diff --git a/frontend/src/features/navigation/NavigationProvider.dom.test.tsx b/frontend/src/features/navigation/NavigationProvider.dom.test.tsx index 7d2ecdd8..cd188695 100644 --- a/frontend/src/features/navigation/NavigationProvider.dom.test.tsx +++ b/frontend/src/features/navigation/NavigationProvider.dom.test.tsx @@ -231,7 +231,7 @@ describe("NavigationProvider · a link carrying another page's keys", () => { it("opens a channel link with only the channel page's keys, even over the page they belong to", () => { window.history.replaceState({}, "", "/?q=music&sort=oldest&chan=UC1&chtab=about&chtypes=ns"); render(); - expect(window.location.search).toBe("?chan=UC1&chtab=about&chtypes=ns"); + expect(window.location.search).toBe("?chan=UC1&chtab=about&chtypes=ns&page=feed"); }); }); @@ -242,10 +242,10 @@ describe("NavigationProvider · the channel page's address", () => { window.history.replaceState({ sfPage: "feed" }, "", "/?q=music&sort=oldest"); const { result } = render(); act(() => result.current.openChannel("UC1", "One")); - expect(window.location.search).toBe("?chan=UC1"); + expect(window.location.search).toBe("?page=feed&chan=UC1"); act(() => window.history.back()); await vi.waitFor(() => expect(result.current.channelView).toBeNull()); - expect(window.location.search).toBe("?q=music&sort=oldest"); + expect(window.location.search).toBe("?q=music&sort=oldest&page=feed"); }); it("says no page owns the address while the channel stands over it", () => { @@ -277,7 +277,7 @@ describe("NavigationProvider · the channel page's address", () => { const { result } = render(); stubNavigation([false]); act(() => result.current.closeChannel()); - expect(window.location.search).toBe(""); + expect(window.location.search).toBe("?page=feed"); }); }); @@ -293,7 +293,7 @@ describe("NavigationProvider · the player's address", () => { it("a channel link names no player: opening the channel closes it", () => { window.history.replaceState({}, "", "/?q=music&watch=v1&wrel=v2&wtab=related"); render(); - expect(channelUrl("UC1")).toBe("/?chan=UC1"); + expect(channelUrl("UC1")).toBe("/?page=feed&chan=UC1"); }); }); @@ -310,7 +310,7 @@ describe("NavigationProvider · Back", () => { expect(result.current.page).toBe("feed"); // …and the entry says so too, so the address bar and a later Feed click agree with the screen. expect(window.history.state).toMatchObject({ sfPage: "feed" }); - expect(window.location.search).toBe("?keep=1"); + expect(window.location.search).toBe("?page=feed&keep=1"); }); it("lands where a retired page's content went, and the entry names that page", () => { @@ -427,7 +427,7 @@ describe("NavigationProvider · a page the account cannot open", () => { window.history.replaceState(null, "", "/?page=scheduler&keep=1"); const { result } = render(); expect(result.current.page).toBe("feed"); - expect(window.location.search).toBe("?keep=1"); + expect(window.location.search).toBe("?page=feed&keep=1"); expect(window.history.state).toMatchObject({ sfPage: "feed" }); }); @@ -463,7 +463,7 @@ describe("NavigationProvider · a page the account cannot open", () => { qc.setQueryData(qk.me(), member); }); await settle(); - expect(window.location.search).toBe(""); + expect(window.location.search).toBe("?page=feed"); expect(window.history.state).toMatchObject({ sfPage: "feed" }); expect(getAccountRaw(PAGE_KEY)).toBe("feed"); act(() => { @@ -483,7 +483,7 @@ describe("NavigationProvider · a page the account cannot open", () => { }); await settle(); expect(result.current.channelView).toBeNull(); - expect(window.location.search).toBe(""); + expect(window.location.search).toBe("?page=feed"); expect(window.history.state?._chan).toBeUndefined(); }); }); diff --git a/frontend/src/features/navigation/NavigationProvider.tsx b/frontend/src/features/navigation/NavigationProvider.tsx index 80d0fd23..78ff10fa 100644 --- a/frontend/src/features/navigation/NavigationProvider.tsx +++ b/frontend/src/features/navigation/NavigationProvider.tsx @@ -44,12 +44,13 @@ import { // // The contexts, their value types and the hooks live in navigationContext.ts. -/** The address bar of `page`'s root: the page (feed implicit), no channel or player over it, and every - * module's own keys cleared — a page switch resets to that module's root, and its store mirrors what - * it holds back in. */ +/** The address bar of `page`'s root: the page — the feed too: a bare address is the landing (Home, or + * the remembered page), so a feed left unnamed reloaded or bookmarked as another page — no channel or + * player over it, and every module's own keys cleared — a page switch resets to that module's root, + * and its store mirrors what it holds back in. */ function pageRootParams(page: Page): Record { return { - page: page === "feed" ? null : page, + page, ...closedChannelParams(), ...nulls(PLAYER_KEYS), ...nulls(moduleUrlKeys()), @@ -126,7 +127,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) { setAccountRaw(PAGE_KEY, next); // Push an in-app history entry so browser/mouse Back steps through pages instead of leaving // the app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its - // root, and the address bar reflects the page (feed is implicit) while clearing `chan` and + // root, and the address bar reflects the page (the feed too) while clearing `chan` and // every module's own keys (pageRootParams) — a page switch resets to that module's root, and // each module's store mirrors its state back in on its page. The item it was opened from is // still focused: the state setters above have not re-rendered anything yet. @@ -247,7 +248,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) { // so it restores and re-fetches free (ytSearch above already initialised from it). _chan/_chanName // are always kept so a channel page survives F5. const st = window.history.state || {}; - // Mirror the resolved initial page to the address bar (feed implicit) so a page reached from the + // Mirror the resolved initial page to the address bar (the feed too) so a page reached from the // stored landing pref — not just from the URL — is itself bookmarkable. urlWithParams preserves any // ?chan / ?v / ?pl / filter params the loaders already read. // A retired page's link — or the remembered last page, when that is what opened — also opens its @@ -261,7 +262,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) { const url = urlWithParams({ ...nulls(shown ? moduleUrlKeys() : foreignUrlKeys(mountPage)), ...retiredParams(linked ?? (resolvePage(remembered) === mountPage ? remembered : null)), - page: mountPage === "feed" ? null : mountPage, + page: mountPage, }); if (st._yt && st._ytScrape) { window.history.replaceState({ ...st, sfPage: mountPage }, "", url); @@ -282,7 +283,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) { window.history.replaceState( { ...window.history.state, sfPage: p }, "", - urlWithParams({ ...retiredParams(sf), page: p === "feed" ? null : p }), + urlWithParams({ ...retiredParams(sf), page: p }), ); setPageState(p); setAccountRaw(PAGE_KEY, p); diff --git a/frontend/src/features/player/PlayerModal.url.dom.test.tsx b/frontend/src/features/player/PlayerModal.url.dom.test.tsx index 061db317..40743885 100644 --- a/frontend/src/features/player/PlayerModal.url.dom.test.tsx +++ b/frontend/src/features/player/PlayerModal.url.dom.test.tsx @@ -165,6 +165,22 @@ describe("the player's address", () => { expect(window.location.search).toBe(""); }); + it("a link's related video that arrives after the queue stepped on stays off the next item", async () => { + window.history.replaceState({ sfPage: "feed" }, "", "/?watch=A&wrel=rel2"); + let arrive: (v: Video) => void = () => {}; + vi.spyOn(api, "videoRow").mockReturnValue(new Promise