FN-8448: fix Back navigation from import detail

Ensure browser Back returns GitHub imports from issue detail to the issue list.

- Preserve navigation history position when More transitions to Import.
- Cover nested detail Back behavior in modal and embedded Import Tasks surfaces.
- Add a patch changeset for the navigation fix.

Files changed:
 .../fn-8448-github-import-back-to-issue-list.md    |  7 +++
 packages/dashboard/app/components/MobileNavBar.tsx | 17 ++++++--
 .../__tests__/GitHubImportModal.test.tsx           | 50 ++++++++++++++++++++++
 .../__tests__/MobileNavBar.swipe-back.test.tsx     | 18 +++++++-
 .../dashboard/app/hooks/useNavigationHistory.ts    | 27 +++++++++---
 5 files changed, 108 insertions(+), 11 deletions(-)

Fusion-Task-Id: FN-8448

Fusion-Task-Lineage: ffea2740-861f-464b-839e-030aef06fd34

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-20 23:28:38 -07:00
parent d83fae3fea
commit 048a2cdd33
5 changed files with 108 additions and 11 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Android and browser Back from a GitHub import detail returns to the issue list first.
category: fix
dev: Nested import detail history entry (FN-8228 seam) fixed so first Back clears selection on modal and embedded Import Tasks surfaces.

View File

@@ -245,8 +245,11 @@ export function MobileNavBar({
navigationHistory.pushNav({ type: "modal", close: closeMore });
}, [closeMore, isMoreOpen, navigationHistory]);
const dismissMore = useCallback(() => {
navigationHistory?.removeNav(closeMore);
const dismissMore = useCallback((forNavigation?: boolean) => {
navigationHistory?.removeNav(
closeMore,
forNavigation === true ? { preserveHistoryPosition: true } : undefined,
);
closeMore();
}, [closeMore, navigationHistory]);
@@ -315,9 +318,15 @@ export function MobileNavBar({
}
}, [dismissMore, resetSheetDrag]);
/*
FNXC:GitHubImportSwipeBack 2026-07-20-23:12:
More actions transition directly into their destination. Preserve the
current history position while removing More so its asynchronous back
consumption cannot dismiss Import or its nested candidate detail afterward.
*/
const handleMoreAction = useCallback(
(callback?: () => void) => {
dismissMore();
dismissMore(true);
callback?.();
},
[dismissMore],
@@ -521,7 +530,7 @@ export function MobileNavBar({
<>
<div
className="mobile-more-sheet-backdrop"
onClick={dismissMore}
onClick={() => dismissMore()}
/>
<div
ref={sheetRef}

View File

@@ -283,6 +283,17 @@ describe("GitHubImportModal", () => {
</MobileNavigationHarness>,
);
function EmbeddedNavigationHarness({ children, onViewRevert }: { children: ReactNode; onViewRevert: () => void }) {
const navigationHistory = useNavigationHistory({ enabled: true });
useEffect(() => {
navigationHistory.pushNav({ type: "view", revert: onViewRevert });
return () => navigationHistory.removeNav(onViewRevert);
}, [navigationHistory, onViewRevert]);
return <NavigationHistoryProvider value={navigationHistory}>{children}</NavigationHistoryProvider>;
}
it("builds a Planning Mode seed with the GitHub issue context", () => {
expect(buildIssuePlanningSeed({
number: 42,
@@ -481,6 +492,13 @@ describe("GitHubImportModal", () => {
expect(screen.queryByTestId(surface === "gitlab" ? "gitlab-import-preview-card" : "github-import-preview-card")).toBeNull();
expect(onClose).not.toHaveBeenCalled();
});
expect(screen.getByRole("button", {
name: surface === "issue"
? /Select issue #72/i
: surface === "pull"
? /Select pull request #73/i
: /#73 Swipe GitLab issue/i,
})).toBeInTheDocument();
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
expect(onClose).toHaveBeenCalledTimes(1);
@@ -489,6 +507,38 @@ describe("GitHubImportModal", () => {
}
});
it("keeps embedded Import Tasks mounted until its parent view receives a second Back", async () => {
const leaveImportTasks = vi.fn();
const originalBack = window.history.back;
window.history.back = vi.fn();
try {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([
{ number: 75, title: "Embedded swipe issue", body: "Body", html_url: "https://github.com/owner/repo/issues/75", labels: [], state: "open" },
]);
render(
<EmbeddedNavigationHarness onViewRevert={leaveImportTasks}>
<GitHubImportModal isOpen onClose={leaveImportTasks} onImport={onImport} tasks={[]} projectId="project-1" presentation="embedded" />
</EmbeddedNavigationHarness>,
);
fireEvent.click(await screen.findByRole("button", { name: /Select issue #75/i }));
await screen.findByTestId("github-import-preview-card");
dispatchDetailBack("popstate");
await waitFor(() => {
expect(screen.queryByTestId("github-import-preview-card")).toBeNull();
expect(screen.getByRole("button", { name: /Select issue #75/i })).toBeInTheDocument();
expect(leaveImportTasks).not.toHaveBeenCalled();
});
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
expect(leaveImportTasks).toHaveBeenCalledTimes(1);
} finally {
window.history.back = originalBack;
}
});
it("drains the detail entry when the sheet closes before a rapid reopen", async () => {
const originalBack = window.history.back;
window.history.back = vi.fn();

View File

@@ -132,8 +132,22 @@ describe("MobileNavBar More sheet navigation history", () => {
await expectProgrammaticCloseConsumesMoreEntry(() => fireEvent.click(document.querySelector(".mobile-more-sheet-backdrop")!));
});
it("consumes the More entry on item action", async () => {
await expectProgrammaticCloseConsumesMoreEntry(() => fireEvent.click(screen.getByTestId("mobile-more-item-activity")));
it("replaces the More entry before opening Import so delayed Back cannot consume it", async () => {
const importClose = vi.fn();
const props = createDefaultProps();
props.onOpenGitHubImport = () => {
navigationHistory?.pushNav({ type: "modal", close: importClose });
};
renderWithHistory(props);
await openMore();
fireEvent.click(screen.getByTestId("mobile-more-item-github"));
expect(window.history.back).not.toHaveBeenCalled();
expect(screen.queryByTestId("mobile-more-item-activity")).toBeNull();
dispatchPopState(0);
expect(importClose).toHaveBeenCalledOnce();
});
it("consumes the More entry on Escape", async () => {

View File

@@ -34,10 +34,14 @@ export interface UseNavigationHistoryResult {
/** Replace the top-of-stack entry and call history.replaceState. */
replaceCurrent: (entry: NavEntry) => void;
/**
* Remove a programmatically-dismissed entry and call history.back so the
* browser history entry created by pushNav is consumed as well.
* Remove a programmatically-dismissed entry. By default history.back()
* consumes its browser-history entry; `preserveHistoryPosition` instead
* replaces the current state for a close-then-navigate transition.
*/
removeNav: (closeOrRevert: () => void) => void;
removeNav: (
closeOrRevert: () => void,
options?: { preserveHistoryPosition?: boolean },
) => void;
}
const SELF_POP_FALLBACK_CLEAR_MS = 1_000;
@@ -158,7 +162,7 @@ export function useNavigationHistory(
);
const removeNav = useCallback(
(closeOrRevert: () => void) => {
(closeOrRevert: () => void, options?: { preserveHistoryPosition?: boolean }) => {
if (!enabledRef.current) return;
for (let i = stackRef.current.length - 1; i >= 0; i -= 1) {
@@ -166,6 +170,19 @@ export function useNavigationHistory(
if (getEntryCallback(entry) !== closeOrRevert) continue;
stackRef.current.splice(i, 1);
/*
FNXC:GitHubImportSwipeBack 2026-07-20-23:12:
A More-sheet action closes one history surface and immediately opens
another. Going back asynchronously after removing More can pop that
newly pushed Import/detail entry instead. Keep the browser at its
current position and rewrite its nav depth for this atomic transition.
*/
if (options?.preserveHistoryPosition) {
writeHistoryState("replace", stackRef.current.length);
return;
}
selfPopRef.current = true;
if (selfPopClearTimerRef.current !== null) {
@@ -180,7 +197,7 @@ export function useNavigationHistory(
return;
}
},
[], // stable — reads from refs
[writeHistoryState],
);
// Register popstate listener. Always registers in browser environments but