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:
7
.changeset/fn-7275-swipe-back-reliability.md
Normal file
7
.changeset/fn-7275-swipe-back-reliability.md
Normal 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.
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user