diff --git a/.changeset/fn-8448-github-import-back-to-issue-list.md b/.changeset/fn-8448-github-import-back-to-issue-list.md new file mode 100644 index 0000000000..30006296ea --- /dev/null +++ b/.changeset/fn-8448-github-import-back-to-issue-list.md @@ -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. diff --git a/packages/dashboard/app/components/MobileNavBar.tsx b/packages/dashboard/app/components/MobileNavBar.tsx index 1407ec4d84..747553a044 100644 --- a/packages/dashboard/app/components/MobileNavBar.tsx +++ b/packages/dashboard/app/components/MobileNavBar.tsx @@ -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({ <>
dismissMore()} />
{ , ); + 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 {children}; + } + 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( + + + , + ); + 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(); diff --git a/packages/dashboard/app/components/__tests__/MobileNavBar.swipe-back.test.tsx b/packages/dashboard/app/components/__tests__/MobileNavBar.swipe-back.test.tsx index 89f4f56b07..01bbd3a2b4 100644 --- a/packages/dashboard/app/components/__tests__/MobileNavBar.swipe-back.test.tsx +++ b/packages/dashboard/app/components/__tests__/MobileNavBar.swipe-back.test.tsx @@ -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 () => { diff --git a/packages/dashboard/app/hooks/useNavigationHistory.ts b/packages/dashboard/app/hooks/useNavigationHistory.ts index 308a7d41a1..f5209c85ad 100644 --- a/packages/dashboard/app/hooks/useNavigationHistory.ts +++ b/packages/dashboard/app/hooks/useNavigationHistory.ts @@ -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