diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index 1649efe221..26679b9fc8 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -987,10 +987,23 @@ function ChannelWorkspace({ const returnFocus = useRef< { trigger: HTMLElement; channelId: string | undefined } | undefined >(undefined); + // A close's native return lands a task or more after its dismissal. Until + // then the navigator still shows the retiring visit. + const pendingReturn = useRef<{ + from: string; + target: string; + reopen?: PanelOpening; + }>(undefined); const visitPanel = useCallback( (entry: PanelOpening) => { - const target = navigator?.snapshot().entry.target; + const { id, target } = navigator?.snapshot().entry ?? {}; if (!navigator || target?.kind !== "conversation") return; + // Opening now would revisit or stack on the retiring visit. The panel + // takes its own visit once the return lands. + if (pendingReturn.current?.from === id) { + pendingReturn.current.reopen = entry; + return; + } if ( target.channelId === entry.channelId && target.detail?.target === entry.target && @@ -1092,6 +1105,21 @@ function ChannelWorkspace({ }; } navigator.dismiss(visits, fallback); + // A later close can retire more visits before the return lands, moving + // where it lands. + const pending = + pendingReturn.current?.from === current.id + ? pendingReturn.current + : undefined; + if ( + (pending || visits.includes(current.id)) && + navigator.snapshot().entry.id === current.id + ) + pendingReturn.current = { + ...pending, + from: current.id, + target: targetKey(fallback), + }; }; const secondary = useConversationTabs({ state: tabState, @@ -1336,6 +1364,20 @@ function ChannelWorkspace({ (kept.pane === "main" ? tabState.getMainSelected() : tabState.getSelected()) === kept.id; + // A panel opened before the close's return landed supersedes that + // close, unless it has since closed or the return went elsewhere. + const returned = pendingReturn.current; + const reopen = + returned?.reopen && + navigation.entryId !== returned.from && + targetKey(navigation.target) === returned.target && + [tabState.getSelected(), tabState.getMainSelected()].includes( + panelTabId(returned.reopen), + ) + ? returned.reopen + : undefined; + if (returned && navigation.entryId !== returned.from) + pendingReturn.current = undefined; tabState.enterVisit( `${navigation.entryId}:${detail ? "detail" : routedThread ? "thread" : "channel"}`, detail ? undefined : routedThread, @@ -1344,7 +1386,9 @@ function ChannelWorkspace({ ? { channelId: currentId, target: detail.target } : undefined, ); - if (detail && currentId) { + // A re-render while the return is pending must not reapply the detail + // that was just closed. + if (detail && currentId && navigation.entryId !== returned?.from) { const entry = panelOpening( panels, currentId, @@ -1354,6 +1398,8 @@ function ChannelWorkspace({ if (entry && historyPanel(entry)) open(entry); else navigation.complete({ status: "failed", reason: "unavailable" }); } + // Reselect it over the restored selection and give it its own visit. + if (reopen) open(reopen); if (kept && navigation.entryId !== kept.from) { keptTabReturn.current = undefined; if (retained && targetKey(navigation.target) === kept.target) { diff --git a/tests/browser/profiles.spec.mjs b/tests/browser/profiles.spec.mjs index 5398bbd037..3212b310f5 100644 --- a/tests/browser/profiles.spec.mjs +++ b/tests/browser/profiles.spec.mjs @@ -318,6 +318,145 @@ test("closing a profile beside a main thread preserves the thread destination", .toBe(true); }); +// Close returns through native history, which lands a task or more later, and +// the dock keeps its exit motion. Hold both so a reopen arrives before them. +async function holdCloseReturn(page) { + await page.evaluate(() => { + const go = history.go.bind(history); + const held = []; + history.go = (delta) => held.push(delta); + // Pause the exit while it is still pending, so it cannot finish first. + const exit = new MutationObserver(() => { + const dock = document.querySelector("[data-panel-dock][data-closing]"); + for (const animation of [ + ...(dock?.getAnimations() ?? []), + ...(dock?.parentElement?.getAnimations() ?? []), + ]) + animation.pause(); + }); + exit.observe(document.body, { + subtree: true, + attributeFilter: ["data-closing"], + }); + window.releaseReturn = () => { + exit.disconnect(); + history.go = go; + for (const delta of held) go(delta); + return held.length; + }; + }); + return () => page.evaluate(() => window.releaseReturn()); +} + +async function openReturnFixture(page) { + await page.goto("/tests/fixtures/profiles.html"); + await page.waitForFunction(() => !!window.profilesFixture); + const read = async () => + (await page.evaluate(() => window.profilesFixture.navigation.snapshot())) + .entry; + return { + read, + ...(await page.evaluate(() => ({ + npubs: window.profilesFixture.npubs, + targets: window.profilesFixture.targets, + }))), + channel: (await read()).id, + panel: page.getByRole("complementary", { name: "Profile", exact: true }), + mention: page.getByRole("button", { + name: "View Mic profile", + exact: true, + }), + closing: page.locator("[data-panel-dock][data-closing]:not([hidden])"), + }; +} + +test("a Profile reopened before its close returns stays open with a fresh visit", async ({ + page, +}) => { + const errors = watchPageErrors(page); + const { read, npubs, targets, channel, panel, mention, closing } = + await openReturnFixture(page); + for (const activation of ["click", "Space"]) { + await mention.click(); + await expect(panel.locator("code")).toHaveText(npubs.mic); + const closed = (await read()).id; + const release = await holdCloseReturn(page); + let held; + try { + await page + .getByRole("tablist", { name: "Panel tabs" }) + .getByRole("button", { name: "Close Mic tab", exact: true }) + .click(); + await expect(panel).toHaveCount(0); + await expect(closing).toHaveCount(1); + if (activation === "click") await mention.click(); + else await mention.press(activation); + await expect(panel.locator("code")).toHaveText(npubs.mic); + expect((await read()).id).toBe(closed); + } finally { + held = await release(); + } + expect(held).toBe(1); + // The return lands; the reopened Profile then needs its own visit. + await expect + .poll(async () => { + const entry = await read(); + return entry.id !== closed && entry.target.detail?.target; + }) + .toBe(targets.mic); + await expect(panel.locator("code")).toHaveText(npubs.mic); + await page.goBack(); + await expect.poll(async () => (await read()).id).toBe(channel); + await expect(panel).toHaveCount(0); + } + expect(errors.unexplained()).toEqual([]); +}); + +test("a Profile opened after closing a thread and its Profile stays open with a fresh visit", async ({ + page, +}) => { + const errors = watchPageErrors(page); + const { read, npubs, targets, channel, panel, mention, closing } = + await openReturnFixture(page); + await page + .getByRole("button", { name: "View thread: 1 reply", exact: true }) + .click(); + await page + .getByRole("button", { name: "View Pinky profile", exact: true }) + .click(); + await expect(panel.locator("code")).toHaveText(npubs.pinky); + const closed = (await read()).id; + const tabs = page.getByRole("tablist", { name: "Panel tabs" }); + const release = await holdCloseReturn(page); + let held; + try { + await tabs + .getByRole("button", { name: "Close Pinky tab", exact: true }) + .click(); + // The second close retires the thread under the pending return, which + // then lands on the channel instead. + await tabs.getByRole("button", { name: /^Close .* tab$/ }).click(); + await expect(closing).toHaveCount(1); + await mention.click(); + await expect(panel.locator("code")).toHaveText(npubs.mic); + expect((await read()).id).toBe(closed); + } finally { + held = await release(); + } + expect(held).toBe(1); + await expect + .poll(async () => { + const entry = await read(); + return entry.id !== closed && entry.target.detail?.target; + }) + .toBe(targets.mic); + await expect(panel.locator("code")).toHaveText(npubs.mic); + await page.goBack(); + await expect.poll(async () => (await read()).id).toBe(channel); + await expect(panel).toHaveCount(0); + expect(errors.unexplained()).toEqual([]); +}); + test("profile plumbing: exact avatar/mention targets, thread enrichment, lifecycle and recovery", async ({ page, }, testInfo) => {