diff --git a/.changeset/fn-7275-swipe-back-reliability.md b/.changeset/fn-7275-swipe-back-reliability.md new file mode 100644 index 0000000000..19a8cf80b3 --- /dev/null +++ b/.changeset/fn-7275-swipe-back-reliability.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Mobile back now reliably dismisses the open task detail, including right after closing and reopening it. +category: fix +dev: Hardens useNavigationHistory against close-reopen races and history/stack desync so popstate (and the fusion:native-back event) deterministically dismisses every task-detail surface. diff --git a/packages/dashboard/app/components/__tests__/TaskDetail.swipe-back.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetail.swipe-back.test.tsx index 53b47a0e0d..f6045f3b19 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetail.swipe-back.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetail.swipe-back.test.tsx @@ -587,7 +587,7 @@ describe("Task detail mobile swipe-back", () => { }); }); - it("pushes a fresh mobile nav entry after close and reopen from the list", async () => { + it("dismisses the reopened list-mobile detail after a close-and-quick-reopen race", async () => { const task = makeTask("FN-1", "Repeat Mobile List Detail"); mockUseTasks.mockImplementation(() => ({ tasks: [task], @@ -614,8 +614,13 @@ describe("Task detail mobile swipe-back", () => { }); expect(window.history.pushState).toHaveBeenCalledTimes(1); + /* + FNXC:TaskDetailSwipeBack 2026-06-30-09:31: + The list-mobile modal path uses the shared closeDetailTask callback, so the + race repro must prove a deferred removeNav self-pop cannot strand the next + reopen without a dismissible mobile history entry. + */ fireEvent.click(screen.getByTestId("task-detail-close")); - dispatchPopState({ navIndex: 0 }); await waitFor(() => { expect(screen.queryByTestId("task-detail-modal")).toBeNull(); }); @@ -625,9 +630,10 @@ describe("Task detail mobile swipe-back", () => { expect(screen.getByTestId("task-detail-modal")).toBeInTheDocument(); }); - expect(window.history.pushState).toHaveBeenCalledTimes(2); + dispatchPopState({ navIndex: 1 }); + expect(screen.getByTestId("task-detail-modal")).toBeInTheDocument(); - dispatchPopState({ navIndex: 0 }); + dispatchPopState({ navIndex: 1 }); await waitFor(() => { expect(screen.queryByTestId("task-detail-modal")).toBeNull(); expect(screen.getByTestId("list-view")).toBeInTheDocument(); diff --git a/packages/dashboard/app/components/__tests__/navigation-history.test.tsx b/packages/dashboard/app/components/__tests__/navigation-history.test.tsx index 90b0d39aad..1c89cee0e7 100644 --- a/packages/dashboard/app/components/__tests__/navigation-history.test.tsx +++ b/packages/dashboard/app/components/__tests__/navigation-history.test.tsx @@ -627,7 +627,7 @@ describe("Navigation history integration", () => { }); }); - it("dismisses task detail on mobile popstate after close and reopen", async () => { + it("dismisses task detail on mobile popstate after a close-and-quick-reopen race", async () => { mockUseViewportMode.mockReturnValue("mobile"); const task = makeTask("FN-1", "Mobile Swipe Detail"); mockUseTasks.mockImplementation(() => ({ @@ -647,7 +647,13 @@ describe("Navigation history integration", () => { await renderMobileAppAndWait(); - // FNXC:Navigation 2026-06-22-00:00: Board card click opens the full main-panel detail; the "Back to board" button reverts to the board, and a subsequent reopen + mobile popstate must also dismiss it (the regression this test guards). + /* + FNXC:TaskDetailSwipeBack 2026-06-30-09:29: + Reproduces the real FN-7275 race: Back-to-board queues a self-pop through + history.back(), but the user can reopen the detail before that popstate + resolves. The next swipe-back must still dismiss the reopened detail even + when history surfaces the stale pre-close navIndex first. + */ fireEvent.click(screen.getByTestId("open-task-FN-1")); await waitFor(() => { @@ -655,9 +661,6 @@ describe("Navigation history integration", () => { }); fireEvent.click(screen.getByRole("button", { name: "Back to board" })); - // removeNav drives history.back(); consume the self-triggered popstate - // before reopening so the next popstate represents the user's swipe-back. - dispatchPopState({ navIndex: 0 }); await waitFor(() => { expect(screen.queryByTestId("task-detail-main-panel-content")).toBeNull(); @@ -670,7 +673,10 @@ describe("Navigation history integration", () => { expect(screen.getByTestId("task-detail-main-panel-content")).toBeTruthy(); }); - dispatchPopState({ navIndex: 0 }); + dispatchPopState({ navIndex: 1 }); + expect(screen.getByTestId("task-detail-main-panel-content")).toBeTruthy(); + + dispatchPopState({ navIndex: 1 }); await waitFor(() => { expect(screen.queryByTestId("task-detail-main-panel-content")).toBeNull(); diff --git a/packages/dashboard/app/hooks/__tests__/useNavigationHistory.test.ts b/packages/dashboard/app/hooks/__tests__/useNavigationHistory.test.ts index bfe086500a..55a114adef 100644 --- a/packages/dashboard/app/hooks/__tests__/useNavigationHistory.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useNavigationHistory.test.ts @@ -187,6 +187,43 @@ describe("useNavigationHistory", () => { expect(pushStateSpy).toHaveBeenCalledTimes(2); }); + it("dismisses the reopened top entry after a deferred self-pop leaves stale navIndex state", () => { + const closeA = vi.fn(); + const closeB = vi.fn(); + const { result } = renderHookWithHistory(); + + act(() => { + result.current.pushNav({ type: "modal", close: closeA }); + result.current.removeNav(closeA); + result.current.pushNav({ type: "modal", close: closeB }); + }); + + // The deferred removeNav pop arrives after the reopen and is consumed. + dispatchPopState({ navIndex: 1 }); + expect(closeA).not.toHaveBeenCalled(); + expect(closeB).not.toHaveBeenCalled(); + + // FNXC:TaskDetailSwipeBack 2026-06-30-09:25: + // Mobile swipe-back must still dismiss the reopened surface even when + // history retained a stale navIndex from the pre-close entry. + dispatchPopState({ navIndex: 1 }); + + expect(closeB).toHaveBeenCalledTimes(1); + }); + + it("falls back to dismissing the top entry when popstate carries a stale navIndex", () => { + const close = vi.fn(); + const { result } = renderHookWithHistory(); + + act(() => { + result.current.pushNav({ type: "modal", close }); + }); + + dispatchPopState({ navIndex: 3 }); + + expect(close).toHaveBeenCalledTimes(1); + }); + it("keeps stack order consistent after removing the top entry", () => { const close1 = vi.fn(); const close2 = vi.fn(); @@ -297,7 +334,7 @@ describe("useNavigationHistory", () => { }); // 9. Duplicate-consecutive-push guard - it("skips duplicate consecutive pushes with the same callback", () => { + it("reconciles duplicate consecutive pushes with the same callback without adding history", () => { const close = vi.fn(); const { result } = renderHookWithHistory(); @@ -307,12 +344,12 @@ describe("useNavigationHistory", () => { expect(pushStateSpy).toHaveBeenCalledTimes(1); - // Push the same entry again — should be skipped act(() => { result.current.pushNav({ type: "modal", close }); }); - expect(pushStateSpy).toHaveBeenCalledTimes(1); // still only 1 call + expect(pushStateSpy).toHaveBeenCalledTimes(1); + expect(replaceStateSpy).toHaveBeenCalledWith(expect.objectContaining({ navIndex: 1 }), ""); }); // 10. Handles rapid popstate (iOS fast swipe) — pops multiple entries diff --git a/packages/dashboard/app/hooks/useNavigationHistory.ts b/packages/dashboard/app/hooks/useNavigationHistory.ts index 371d995eb5..308a7d41a1 100644 --- a/packages/dashboard/app/hooks/useNavigationHistory.ts +++ b/packages/dashboard/app/hooks/useNavigationHistory.ts @@ -71,6 +71,10 @@ export function useNavigationHistoryContext(): UseNavigationHistoryResult { * When `enabled` is false, all operations are no-ops and no `popstate` * listener is registered. */ +function getEntryCallback(entry: NavEntry): () => void { + return entry.type === "modal" ? entry.close : entry.revert; +} + export function useNavigationHistory( options: UseNavigationHistoryOptions, ): UseNavigationHistoryResult { @@ -95,6 +99,30 @@ export function useNavigationHistory( const enabledRef = useRef(enabled); enabledRef.current = enabled; + const readExistingState = useCallback(() => { + if (typeof window === "undefined") return {}; + return window.history.state && typeof window.history.state === "object" + ? window.history.state + : {}; + }, []); + + const writeHistoryState = useCallback( + (mode: "push" | "replace", navIndex: number) => { + const nextState = { + ...readExistingState(), + navIndex, + }; + + if (mode === "push") { + window.history.pushState(nextState, ""); + return; + } + + window.history.replaceState(nextState, ""); + }, + [readExistingState], + ); + const pushNav = useCallback( (entry: NavEntry) => { if (!enabledRef.current) return; @@ -102,26 +130,20 @@ export function useNavigationHistory( // Prevent re-push during pop handling if (isPoppingRef.current) return; - // Guard against duplicate consecutive pushes (rapid taps) + // Guard against duplicate consecutive pushes (rapid taps). If a stale + // top entry still uses the same callback, reconcile it in place instead + // of silently dropping the reopen and leaving history/stack out of sync. const top = stackRef.current[stackRef.current.length - 1]; - if (top) { - const topCallback = top.type === "modal" ? top.close : top.revert; - const newCallback = entry.type === "modal" ? entry.close : entry.revert; - if (topCallback === newCallback) return; + if (top && getEntryCallback(top) === getEntryCallback(entry)) { + stackRef.current[stackRef.current.length - 1] = entry; + writeHistoryState("replace", stackRef.current.length); + return; } stackRef.current.push(entry); - - // Preserve existing history.state properties (e.g. from useDeepLink) - // while adding our navIndex. - const navIndex = stackRef.current.length; - const existingState = - typeof window !== "undefined" && window.history.state - ? window.history.state - : {}; - window.history.pushState({ ...existingState, navIndex }, ""); + writeHistoryState("push", stackRef.current.length); }, - [], // stable — reads from refs + [writeHistoryState], ); const replaceCurrent = useCallback( @@ -130,16 +152,9 @@ export function useNavigationHistory( if (stackRef.current.length === 0) return; stackRef.current[stackRef.current.length - 1] = entry; - - // Preserve existing history.state properties while updating navIndex - const navIndex = stackRef.current.length; - const existingState = - typeof window !== "undefined" && window.history.state - ? window.history.state - : {}; - window.history.replaceState({ ...existingState, navIndex }, ""); + writeHistoryState("replace", stackRef.current.length); }, - [], // stable — reads from refs + [writeHistoryState], ); const removeNav = useCallback( @@ -148,8 +163,7 @@ export function useNavigationHistory( for (let i = stackRef.current.length - 1; i >= 0; i -= 1) { const entry = stackRef.current[i]; - const callback = entry.type === "modal" ? entry.close : entry.revert; - if (callback !== closeOrRevert) continue; + if (getEntryCallback(entry) !== closeOrRevert) continue; stackRef.current.splice(i, 1); selfPopRef.current = true; @@ -198,13 +212,25 @@ export function useNavigationHistory( return; } - const targetIndex = event.state?.navIndex ?? 0; + const targetIndex = typeof event.state?.navIndex === "number" ? event.state.navIndex : null; const currentLength = stackRef.current.length; - if (targetIndex >= currentLength) return; + if (currentLength === 0) return; - // Calculate how many entries were popped - const poppedCount = currentLength - targetIndex; + const staleOrDesyncedIndex = + targetIndex === null || + targetIndex < 0 || + targetIndex >= currentLength; + + /* + FNXC:TaskDetailSwipeBack 2026-06-30-09:40: + Mobile swipe-back must deterministically dismiss the top Fusion surface + even when browser history carries a stale navIndex from a close→reopen + race, remount, or interleaved non-Fusion pushState. Falling back to one + top-entry pop keeps the live stack authoritative instead of silently + no-oping on `targetIndex >= currentLength`. + */ + const poppedCount = staleOrDesyncedIndex ? 1 : currentLength - targetIndex; if (poppedCount <= 0) return; @@ -215,11 +241,7 @@ export function useNavigationHistory( for (let i = 0; i < poppedCount; i++) { const entry = stackRef.current.pop(); if (entry) { - if (entry.type === "modal") { - entry.close(); - } else { - entry.revert(); - } + getEntryCallback(entry)(); } } } finally { diff --git a/packages/dashboard/vitest.config.ts b/packages/dashboard/vitest.config.ts index 2476fcbdc6..1d482dd210 100644 --- a/packages/dashboard/vitest.config.ts +++ b/packages/dashboard/vitest.config.ts @@ -100,7 +100,7 @@ const qualityAppFoundationUiTests = [ const qualityAppHooksAndUtilsTests = [ // Hooks and utilities are fast, user-visible state/formatting behavior. "app/context/**/*.test.tsx", - "app/hooks/__tests__/{useAgents,useAgentLogs,useAgentLogs.resume-instrumentation,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodes.resume-instrumentation,useNodeSettingsSync,useProjects,useProjects.resume-instrumentation,useMeshState.resume-instrumentation,useManagedDockerNodes.resume-instrumentation,usePrChecksStream.resume-instrumentation,useDevServerLogs.resume-instrumentation,useResearch.resume-instrumentation,useBackgroundSessions.resume-instrumentation,useQuickChat,useTasks,useTasks.resume-instrumentation,useChatRooms,useTerminalSessions,useTheme,useToast,useUsageData,useViewportMode,useViewState,useMergeAdvanceNotice}.test.{ts,tsx}", + "app/hooks/__tests__/{useAgents,useAgentLogs,useAgentLogs.resume-instrumentation,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNavigationHistory,useNodes,useNodes.resume-instrumentation,useNodeSettingsSync,useProjects,useProjects.resume-instrumentation,useMeshState.resume-instrumentation,useManagedDockerNodes.resume-instrumentation,usePrChecksStream.resume-instrumentation,useDevServerLogs.resume-instrumentation,useResearch.resume-instrumentation,useBackgroundSessions.resume-instrumentation,useQuickChat,useTasks,useTasks.resume-instrumentation,useChatRooms,useTerminalSessions,useTheme,useToast,useUsageData,useViewportMode,useViewState,useMergeAdvanceNotice}.test.{ts,tsx}", "app/utils/**/*.test.{ts,tsx}", ]; @@ -149,6 +149,7 @@ const qualityAppComponentTests = [ "MemoryView", "MergeAdvanceNotice", "MessageComposer", + "navigation-history", "MessageComposer.autosize", "MobileNavBar", "NewTaskModal",