FN-7275: fix mobile task-detail swipe-back dismissal

Harden mobile navigation history so reopened task detail surfaces remain dismissible after stale back events.

- Reconcile duplicate navigation entries with replaceState while preserving existing history state.
- Treat stale or desynced popstate navIndex values as a top-entry dismiss instead of a no-op.
- Cover close-and-quick-reopen swipe-back races across the hook, task-detail modal, and app navigation tests.
- Add the navigation history tests to the dashboard quality test allowlists and include a patch changeset.

Files changed:
 .changeset/fn-7275-swipe-back-reliability.md       |  7 ++
 .../__tests__/TaskDetail.swipe-back.test.tsx       | 14 +++-
 .../__tests__/navigation-history.test.tsx          | 18 +++--
 .../hooks/__tests__/useNavigationHistory.test.ts   | 43 +++++++++-
 .../dashboard/app/hooks/useNavigationHistory.ts    | 92 ++++++++++++++--------
 packages/dashboard/vitest.config.ts                |  3 +-
 6 files changed, 128 insertions(+), 49 deletions(-)

Fusion-Task-Id: FN-7275

Fusion-Task-Lineage: f01e46c3-e6ad-45ef-a848-e8e2012ea3d1

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-30 08:20:07 -07:00
parent 4441b72bbc
commit 8f0fde8b82
6 changed files with 128 additions and 49 deletions

View File

@@ -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.

View File

@@ -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();

View File

@@ -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();

View File

@@ -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

View File

@@ -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 {

View File

@@ -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",