fix(nav): the address keeps what the app rewrites around it (review round 3)
- The Feed press over a live search ends only the search: the feed's own view stays named. - Leaving a search steps back only off an entry the app pushed (_ytOwn); one a link or a reload opened is cleared in place, also where the Navigation API is missing. - useUrlMirror rewrites a changed view through replaceView, so a reload's overlay marks go; its first write and its landing writes keep them for an overlay that reopens. - Files and Playlists: a box emptied and filled again settles at once, never naming the ended search. - Playlists: a linked player is scoped to the playlist the store started with, and only the rail's own move to the first playlist is no pick. - Contract: writer objects are scanned to their matching brace, a computed or shorthand key is refused, and every useHistorySubview call hands a named definition. - Lint: playwright/no-skipped-test also refuses fixme (gate snapshot regenerated). - FEED_DEFAULT_SORT at the three remaining sites; linkedNotListed says to search by name.
This commit is contained in:
@@ -64,6 +64,13 @@ function pageRootParams(page: Page): Record<string, string | null> {
|
||||
};
|
||||
}
|
||||
|
||||
/** The address of the page under a live search, the search ended: no search, player or channel. */
|
||||
const searchEndParams = (): Record<string, string | null> => ({
|
||||
...closedChannelParams(),
|
||||
...nulls(PLAYER_KEYS),
|
||||
...ytSearchParams(null, DEFAULT_YT_COUNT),
|
||||
});
|
||||
|
||||
const nulls = (keys: readonly string[]) => Object.fromEntries(keys.map((k) => [k, null]));
|
||||
|
||||
/** No channel page: neither `chan` nor its view's keys, which the page itself writes (ChannelPage) —
|
||||
@@ -138,7 +145,8 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
// A channel page overlays the content column, so a nav click must close it even when the
|
||||
// underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
|
||||
// A live search on it is one too: the press opens the page's root, as over a channel.
|
||||
if (next === pageRef.current && !channelViewRef.current && ytSearchRef.current == null) {
|
||||
const samePage = next === pageRef.current && !channelViewRef.current;
|
||||
if (samePage && ytSearchRef.current == null) {
|
||||
then?.();
|
||||
return;
|
||||
}
|
||||
@@ -154,11 +162,13 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
// 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.
|
||||
// The press on a search over its own page ends only the search: the page's view is the one
|
||||
// on screen and stays named — its store, unchanged, writes nothing back in.
|
||||
rememberNavPosition();
|
||||
window.history.pushState(
|
||||
{ sfPage: next, sfScroll: nextNavId() },
|
||||
"",
|
||||
urlWithParams(pageRootParams(next)),
|
||||
urlWithParams(samePage ? searchEndParams() : pageRootParams(next)),
|
||||
);
|
||||
notifyNav();
|
||||
then?.();
|
||||
@@ -215,11 +225,11 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
// is the first entry of this document, so its new `_yt` mark has nothing of ours below it).
|
||||
replaceView({ ...st, _yt: q }, urlWithParams(ytSearchParams(q, ytCountFrom(here))));
|
||||
} else {
|
||||
// New sub-view entry, at the default count.
|
||||
// New sub-view entry, at the default count — ours (`_ytOwn`): leaving it steps back off it.
|
||||
setYtCountState(DEFAULT_YT_COUNT);
|
||||
const left = rememberNavPosition();
|
||||
window.history.pushState(
|
||||
{ ...left, sfPage: "feed", _yt: q },
|
||||
{ ...left, sfPage: "feed", _yt: q, _ytOwn: 1 },
|
||||
"",
|
||||
urlWithParams(ytSearchParams(q, DEFAULT_YT_COUNT)),
|
||||
);
|
||||
@@ -242,11 +252,13 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const exitYtSearch = useCallback(() => {
|
||||
// Pop our search entry (so Forward still works); fall back to a clear in place if we don't own
|
||||
// one, or there is no entry of this document below it (a full tab, see backStaysInDocument).
|
||||
if (window.history.state?._yt && backStaysInDocument()) window.history.back();
|
||||
// one — a search a link or a reload opened is marked (`_yt`) but was pushed by nobody: below it is
|
||||
// another site, or nothing — or there is no entry of this document below it (a full tab, see
|
||||
// backStaysInDocument).
|
||||
if (window.history.state?._ytOwn && backStaysInDocument()) window.history.back();
|
||||
else {
|
||||
endYtSearch();
|
||||
const { _yt: _q, ...st } = window.history.state ?? {};
|
||||
const { _yt: _q, _ytOwn: _own, ...st } = window.history.state ?? {};
|
||||
replaceView(st, urlWithParams(ytSearchParams(null, DEFAULT_YT_COUNT)));
|
||||
}
|
||||
}, [endYtSearch]);
|
||||
@@ -322,7 +334,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
if (named != null) {
|
||||
window.history.replaceState({ ...st, _yt: named, sfPage: mountPage }, "", url);
|
||||
} else {
|
||||
const { _yt: _staleYt, _ytScrape: _staleScrape, ...rest } = st;
|
||||
const { _yt: _staleYt, _ytScrape: _staleScrape, _ytOwn: _staleOwn, ...rest } = st;
|
||||
window.history.replaceState({ ...rest, sfPage: mountPage }, "", url);
|
||||
}
|
||||
function onPop(e: PopStateEvent) {
|
||||
|
||||
@@ -243,6 +243,33 @@ describe("NavigationProvider · the YouTube search in the address", () => {
|
||||
expect(window.history.length).toBe(depth + 1); // its own entry: Back returns to the search
|
||||
});
|
||||
|
||||
it("the Feed press on the search keeps the feed's own view in the address", () => {
|
||||
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&show=all&sort=oldest");
|
||||
const { result } = render();
|
||||
act(() => result.current.enterYtSearch("lofi"));
|
||||
act(() => result.current.setPage("feed"));
|
||||
expect(search().get("yt")).toBeNull();
|
||||
expect([search().get("show"), search().get("sort")]).toEqual(["all", "oldest"]);
|
||||
});
|
||||
|
||||
it("a search sent from the box is left by a step back off its own entry", () => {
|
||||
const { result } = render();
|
||||
act(() => result.current.enterYtSearch("lofi"));
|
||||
const back = vi.spyOn(window.history, "back").mockImplementation(() => {});
|
||||
act(() => result.current.exitYtSearch());
|
||||
expect(back).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("a search a link opened is left in place, not by a step back off the document", () => {
|
||||
window.history.replaceState(null, "", "/?page=feed&yt=lofi");
|
||||
const { result } = render();
|
||||
const back = vi.spyOn(window.history, "back");
|
||||
act(() => result.current.exitYtSearch());
|
||||
expect(back).not.toHaveBeenCalled();
|
||||
expect(result.current.ytSearch).toBeNull();
|
||||
expect(search().get("yt")).toBeNull();
|
||||
});
|
||||
|
||||
it("a landing on a channel opened from the search (an overlay over it closing) keeps the search", () => {
|
||||
const { result } = render();
|
||||
act(() => result.current.enterYtSearch("lofi"));
|
||||
|
||||
Reference in New Issue
Block a user