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");
|
const task = makeTask("FN-1", "Repeat Mobile List Detail");
|
||||||
mockUseTasks.mockImplementation(() => ({
|
mockUseTasks.mockImplementation(() => ({
|
||||||
tasks: [task],
|
tasks: [task],
|
||||||
@@ -614,8 +614,13 @@ describe("Task detail mobile swipe-back", () => {
|
|||||||
});
|
});
|
||||||
expect(window.history.pushState).toHaveBeenCalledTimes(1);
|
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"));
|
fireEvent.click(screen.getByTestId("task-detail-close"));
|
||||||
dispatchPopState({ navIndex: 0 });
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.queryByTestId("task-detail-modal")).toBeNull();
|
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(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(() => {
|
await waitFor(() => {
|
||||||
expect(screen.queryByTestId("task-detail-modal")).toBeNull();
|
expect(screen.queryByTestId("task-detail-modal")).toBeNull();
|
||||||
expect(screen.getByTestId("list-view")).toBeInTheDocument();
|
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");
|
mockUseViewportMode.mockReturnValue("mobile");
|
||||||
const task = makeTask("FN-1", "Mobile Swipe Detail");
|
const task = makeTask("FN-1", "Mobile Swipe Detail");
|
||||||
mockUseTasks.mockImplementation(() => ({
|
mockUseTasks.mockImplementation(() => ({
|
||||||
@@ -647,7 +647,13 @@ describe("Navigation history integration", () => {
|
|||||||
|
|
||||||
await renderMobileAppAndWait();
|
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"));
|
fireEvent.click(screen.getByTestId("open-task-FN-1"));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -655,9 +661,6 @@ describe("Navigation history integration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Back to board" }));
|
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(() => {
|
await waitFor(() => {
|
||||||
expect(screen.queryByTestId("task-detail-main-panel-content")).toBeNull();
|
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();
|
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(() => {
|
await waitFor(() => {
|
||||||
expect(screen.queryByTestId("task-detail-main-panel-content")).toBeNull();
|
expect(screen.queryByTestId("task-detail-main-panel-content")).toBeNull();
|
||||||
|
|||||||
@@ -187,6 +187,43 @@ describe("useNavigationHistory", () => {
|
|||||||
expect(pushStateSpy).toHaveBeenCalledTimes(2);
|
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", () => {
|
it("keeps stack order consistent after removing the top entry", () => {
|
||||||
const close1 = vi.fn();
|
const close1 = vi.fn();
|
||||||
const close2 = vi.fn();
|
const close2 = vi.fn();
|
||||||
@@ -297,7 +334,7 @@ describe("useNavigationHistory", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 9. Duplicate-consecutive-push guard
|
// 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 close = vi.fn();
|
||||||
const { result } = renderHookWithHistory();
|
const { result } = renderHookWithHistory();
|
||||||
|
|
||||||
@@ -307,12 +344,12 @@ describe("useNavigationHistory", () => {
|
|||||||
|
|
||||||
expect(pushStateSpy).toHaveBeenCalledTimes(1);
|
expect(pushStateSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
// Push the same entry again — should be skipped
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.pushNav({ type: "modal", close });
|
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
|
// 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`
|
* When `enabled` is false, all operations are no-ops and no `popstate`
|
||||||
* listener is registered.
|
* listener is registered.
|
||||||
*/
|
*/
|
||||||
|
function getEntryCallback(entry: NavEntry): () => void {
|
||||||
|
return entry.type === "modal" ? entry.close : entry.revert;
|
||||||
|
}
|
||||||
|
|
||||||
export function useNavigationHistory(
|
export function useNavigationHistory(
|
||||||
options: UseNavigationHistoryOptions,
|
options: UseNavigationHistoryOptions,
|
||||||
): UseNavigationHistoryResult {
|
): UseNavigationHistoryResult {
|
||||||
@@ -95,6 +99,30 @@ export function useNavigationHistory(
|
|||||||
const enabledRef = useRef(enabled);
|
const enabledRef = useRef(enabled);
|
||||||
enabledRef.current = 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(
|
const pushNav = useCallback(
|
||||||
(entry: NavEntry) => {
|
(entry: NavEntry) => {
|
||||||
if (!enabledRef.current) return;
|
if (!enabledRef.current) return;
|
||||||
@@ -102,26 +130,20 @@ export function useNavigationHistory(
|
|||||||
// Prevent re-push during pop handling
|
// Prevent re-push during pop handling
|
||||||
if (isPoppingRef.current) return;
|
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];
|
const top = stackRef.current[stackRef.current.length - 1];
|
||||||
if (top) {
|
if (top && getEntryCallback(top) === getEntryCallback(entry)) {
|
||||||
const topCallback = top.type === "modal" ? top.close : top.revert;
|
stackRef.current[stackRef.current.length - 1] = entry;
|
||||||
const newCallback = entry.type === "modal" ? entry.close : entry.revert;
|
writeHistoryState("replace", stackRef.current.length);
|
||||||
if (topCallback === newCallback) return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
stackRef.current.push(entry);
|
stackRef.current.push(entry);
|
||||||
|
writeHistoryState("push", stackRef.current.length);
|
||||||
// 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 }, "");
|
|
||||||
},
|
},
|
||||||
[], // stable — reads from refs
|
[writeHistoryState],
|
||||||
);
|
);
|
||||||
|
|
||||||
const replaceCurrent = useCallback(
|
const replaceCurrent = useCallback(
|
||||||
@@ -130,16 +152,9 @@ export function useNavigationHistory(
|
|||||||
if (stackRef.current.length === 0) return;
|
if (stackRef.current.length === 0) return;
|
||||||
|
|
||||||
stackRef.current[stackRef.current.length - 1] = entry;
|
stackRef.current[stackRef.current.length - 1] = entry;
|
||||||
|
writeHistoryState("replace", stackRef.current.length);
|
||||||
// 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 }, "");
|
|
||||||
},
|
},
|
||||||
[], // stable — reads from refs
|
[writeHistoryState],
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeNav = useCallback(
|
const removeNav = useCallback(
|
||||||
@@ -148,8 +163,7 @@ export function useNavigationHistory(
|
|||||||
|
|
||||||
for (let i = stackRef.current.length - 1; i >= 0; i -= 1) {
|
for (let i = stackRef.current.length - 1; i >= 0; i -= 1) {
|
||||||
const entry = stackRef.current[i];
|
const entry = stackRef.current[i];
|
||||||
const callback = entry.type === "modal" ? entry.close : entry.revert;
|
if (getEntryCallback(entry) !== closeOrRevert) continue;
|
||||||
if (callback !== closeOrRevert) continue;
|
|
||||||
|
|
||||||
stackRef.current.splice(i, 1);
|
stackRef.current.splice(i, 1);
|
||||||
selfPopRef.current = true;
|
selfPopRef.current = true;
|
||||||
@@ -198,13 +212,25 @@ export function useNavigationHistory(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetIndex = event.state?.navIndex ?? 0;
|
const targetIndex = typeof event.state?.navIndex === "number" ? event.state.navIndex : null;
|
||||||
const currentLength = stackRef.current.length;
|
const currentLength = stackRef.current.length;
|
||||||
|
|
||||||
if (targetIndex >= currentLength) return;
|
if (currentLength === 0) return;
|
||||||
|
|
||||||
// Calculate how many entries were popped
|
const staleOrDesyncedIndex =
|
||||||
const poppedCount = currentLength - targetIndex;
|
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;
|
if (poppedCount <= 0) return;
|
||||||
|
|
||||||
@@ -215,11 +241,7 @@ export function useNavigationHistory(
|
|||||||
for (let i = 0; i < poppedCount; i++) {
|
for (let i = 0; i < poppedCount; i++) {
|
||||||
const entry = stackRef.current.pop();
|
const entry = stackRef.current.pop();
|
||||||
if (entry) {
|
if (entry) {
|
||||||
if (entry.type === "modal") {
|
getEntryCallback(entry)();
|
||||||
entry.close();
|
|
||||||
} else {
|
|
||||||
entry.revert();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ const qualityAppFoundationUiTests = [
|
|||||||
const qualityAppHooksAndUtilsTests = [
|
const qualityAppHooksAndUtilsTests = [
|
||||||
// Hooks and utilities are fast, user-visible state/formatting behavior.
|
// Hooks and utilities are fast, user-visible state/formatting behavior.
|
||||||
"app/context/**/*.test.tsx",
|
"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}",
|
"app/utils/**/*.test.{ts,tsx}",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -149,6 +149,7 @@ const qualityAppComponentTests = [
|
|||||||
"MemoryView",
|
"MemoryView",
|
||||||
"MergeAdvanceNotice",
|
"MergeAdvanceNotice",
|
||||||
"MessageComposer",
|
"MessageComposer",
|
||||||
|
"navigation-history",
|
||||||
"MessageComposer.autosize",
|
"MessageComposer.autosize",
|
||||||
"MobileNavBar",
|
"MobileNavBar",
|
||||||
"NewTaskModal",
|
"NewTaskModal",
|
||||||
|
|||||||
Reference in New Issue
Block a user