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:
7
.changeset/fn-8448-github-import-back-to-issue-list.md
Normal file
7
.changeset/fn-8448-github-import-back-to-issue-list.md
Normal 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.
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user