Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 48 additions & 2 deletions src/bundled/channels/ChannelsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [P2] Preserve the pending return when an inactive close selects the deferred panel

With a side-pane thread T followed by Profile B, close B while its native return to T is pending, reopen Profile C, then close inactive T before the return lands. C remains selected, so closeTarget supplies C as the fallback although its visit was deferred. In browser-history.ts:361-375, the surviving previous visit is now the channel, not C; the returning guard is only in the matching-fallback branch, so dismissal synchronously replaces B with C. This identity check then fails and the route effect clears the old pending record. The already-issued native return still reaches retired T and skips to the channel, hiding C.

This reverses the final close/reopen actions of the new double-close regression and requires no unrelated navigation. Please preserve the pending traversal across this later close (including a historical fallback independent of the queued reopen), and add a held-return case that closes B, reopens C, closes T, releases, then verifies C remains visible with a fresh visit and Back reaches the channel. Source-inferred residual bug; no runtime reproduction.

)
pendingReturn.current = {
...pending,
from: current.id,
target: targetKey(fallback),
};
};
const secondary = useConversationTabs({
state: tabState,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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) {
Expand Down
139 changes: 139 additions & 0 deletions tests/browser/profiles.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Loading