diff --git a/.changeset/fn-7258-mobile-board-scroll.md b/.changeset/fn-7258-mobile-board-scroll.md
new file mode 100644
index 0000000000..338feb1b4f
--- /dev/null
+++ b/.changeset/fn-7258-mobile-board-scroll.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": patch
+---
+
+summary: Preserve mobile board scroll after returning from task detail.
+category: fix
+dev: Restores the mobile board/card scroll snapshot after Back to board remounts the board.
diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md
index bccbbdf6f8..08966d1051 100644
--- a/docs/dashboard-guide.md
+++ b/docs/dashboard-guide.md
@@ -17,6 +17,8 @@ The installed mobile/PWA home-screen icons are generated from `packages/dashboar
The dashboard now handles browser back navigation consistently on desktop and mobile.
Using Back will first dismiss open modals and then step back through in-app view changes before leaving the app.
When task detail is open from a board card, mobile list row, right-dock/activity/onboarding link, deep link, or another task detail link, one browser/Android Back action closes the current detail first and restores the prior dashboard context (for example, nested task detail → previous task detail, or task detail → board).
+On mobile board-card detail, **Back to board** also restores the prior board/card scroll position so the same lane context remains visible.
+
This behavior used to be mobile-only, and now applies across all viewports.
Task Detail modal opens from onboarding, activity log, and task-to-task navigation now all register navigation history entries, so Android back swipe/button dismisses them consistently.
diff --git a/packages/dashboard/app/components/__tests__/navigation-history.test.tsx b/packages/dashboard/app/components/__tests__/navigation-history.test.tsx
index c76f7e8f4a..90b0d39aad 100644
--- a/packages/dashboard/app/components/__tests__/navigation-history.test.tsx
+++ b/packages/dashboard/app/components/__tests__/navigation-history.test.tsx
@@ -155,20 +155,24 @@ vi.mock("../../components/Board", () => ({
onOpenDetail: (task: Task) => void;
onOpenDetailWithTab?: (task: Task, initialTab: "changes" | "retries" | "workflow") => void;
}) => (
-
- {tasks.map((task) => (
-
-
onOpenDetail(task)}>
- {task.title}
-
- {task.modifiedFiles && task.modifiedFiles.length > 0 ? (
-
onOpenDetailWithTab?.(task, "changes")}>
- Files changed
-
- ) : null}
+
+
+
+ {tasks.map((task) => (
+
+ onOpenDetail(task)}>
+ {task.title}
+
+ {task.modifiedFiles && task.modifiedFiles.length > 0 ? (
+ onOpenDetailWithTab?.(task, "changes")}>
+ Files changed
+
+ ) : null}
+
+ ))}
- ))}
-
+
+
),
}));
@@ -674,6 +678,87 @@ describe("Navigation history integration", () => {
});
});
+ it("returns desktop board-detail Back to board to the board without breaking history", async () => {
+ mockUseViewportMode.mockReturnValue("desktop");
+ const task = makeTask("FN-1", "Desktop Detail Card");
+ mockUseTasks.mockImplementation(() => ({
+ tasks: [task],
+ createTask: mockCreateTask,
+ moveTask: vi.fn(),
+ deleteTask: vi.fn(),
+ mergeTask: vi.fn(),
+ retryTask: vi.fn(),
+ updateTask: vi.fn(),
+ duplicateTask: vi.fn(),
+ archiveTask: vi.fn(),
+ unarchiveTask: vi.fn(),
+ archiveAllDone: vi.fn(),
+ refreshTasks: vi.fn(),
+ }));
+
+ await renderAppAndWait();
+ const pushCallsBefore = (window.history.pushState as any).mock.calls.length;
+
+ fireEvent.click(screen.getByTestId("open-task-FN-1"));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("task-detail-main-panel-content")).toBeTruthy();
+ expect(screen.queryByTestId("board-view")).toBeNull();
+ });
+ expect((window.history.pushState as any).mock.calls.length).toBeGreaterThan(pushCallsBefore);
+
+ // FNXC:BoardNavigation 2026-06-29-20:45: Desktop board-card detail keeps the same full-panel Back-to-board history contract while mobile adds scroll restoration coverage.
+ fireEvent.click(screen.getByRole("button", { name: "Back to board" }));
+ dispatchPopState({ navIndex: 0 });
+
+ await waitFor(() => {
+ expect(screen.queryByTestId("task-detail-main-panel-content")).toBeNull();
+ expect(screen.getByTestId("board-view")).toBeTruthy();
+ });
+ });
+
+ it("restores mobile board scroll after Back to board", async () => {
+ mockUseViewportMode.mockReturnValue("mobile");
+ const task = makeTask("FN-1", "Scrolled Mobile Card");
+ mockUseTasks.mockImplementation(() => ({
+ tasks: [task],
+ createTask: mockCreateTask,
+ moveTask: vi.fn(),
+ deleteTask: vi.fn(),
+ mergeTask: vi.fn(),
+ retryTask: vi.fn(),
+ updateTask: vi.fn(),
+ duplicateTask: vi.fn(),
+ archiveTask: vi.fn(),
+ unarchiveTask: vi.fn(),
+ archiveAllDone: vi.fn(),
+ refreshTasks: vi.fn(),
+ }));
+
+ await renderMobileAppAndWait();
+
+ const board = screen.getByTestId("board-view");
+ const todoBody = screen.getByTestId("todo-column-body");
+ board.scrollLeft = 240;
+ todoBody.scrollTop = 380;
+
+ // FNXC:BoardNavigation 2026-06-29-20:45: Mobile Back-to-board must return to the clicked card's horizontal board offset and vertical lane offset after the full-panel detail replaces the board.
+ fireEvent.click(screen.getByTestId("open-task-FN-1"));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("task-detail-main-panel-content")).toBeTruthy();
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "Back to board" }));
+ dispatchPopState({ navIndex: 0 });
+
+ await waitFor(() => {
+ expect(screen.queryByTestId("task-detail-main-panel-content")).toBeNull();
+ expect(screen.getByTestId("board-view").scrollLeft).toBe(240);
+ expect(screen.getByTestId("todo-column-body").scrollTop).toBe(380);
+ });
+ });
+
it("opens board files-changed actions inline on the changes tab instead of in a modal", async () => {
const task = {
...makeTask("FN-1", "Inline Changes Detail"),
diff --git a/packages/dashboard/app/hooks/__tests__/useBoardScrollRestore.test.ts b/packages/dashboard/app/hooks/__tests__/useBoardScrollRestore.test.ts
index 8da0ee7f93..ce813a2730 100644
--- a/packages/dashboard/app/hooks/__tests__/useBoardScrollRestore.test.ts
+++ b/packages/dashboard/app/hooks/__tests__/useBoardScrollRestore.test.ts
@@ -34,7 +34,15 @@ describe("useBoardScrollRestore", () => {
});
it("restores the captured snapshot after returning to the board view", () => {
- const sentinel = { boardLeft: 42, boardTop: 7, columnTops: { c1: 3 } };
+ const sentinel = {
+ boardLeft: 42,
+ boardTop: 7,
+ columnTops: { c1: 3 },
+ projectContentLeft: 0,
+ projectContentTop: 0,
+ documentLeft: 0,
+ documentTop: 0,
+ };
mockedCapture.mockReturnValue(sentinel);
// Make the double requestAnimationFrame fire synchronously so the restore
@@ -67,4 +75,37 @@ describe("useBoardScrollRestore", () => {
expect(mockedRestore).toHaveBeenCalledTimes(1);
expect(mockedRestore).toHaveBeenCalledWith(sentinel);
});
+
+ it("retries while the board is not yet ready after returning to the board view", () => {
+ const sentinel = {
+ boardLeft: 240,
+ boardTop: 0,
+ columnTops: { todo: 380 },
+ projectContentLeft: 0,
+ projectContentTop: 0,
+ documentLeft: 0,
+ documentTop: 0,
+ };
+ mockedCapture.mockReturnValue(sentinel);
+ mockedRestore.mockReturnValueOnce(false).mockReturnValueOnce(true);
+
+ vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb: FrameRequestCallback) => {
+ cb(0);
+ return 0;
+ });
+
+ const { result, rerender } = renderHook(
+ ({ taskView }: { taskView: TaskView }) => useBoardScrollRestore(taskView),
+ { initialProps: { taskView: "task-detail" } },
+ );
+
+ act(() => {
+ result.current.capture();
+ result.current.requestRestore();
+ rerender({ taskView: "board" });
+ });
+
+ expect(mockedRestore).toHaveBeenCalledTimes(2);
+ expect(mockedRestore).toHaveBeenLastCalledWith(sentinel);
+ });
});
diff --git a/packages/dashboard/app/hooks/useBoardScrollRestore.ts b/packages/dashboard/app/hooks/useBoardScrollRestore.ts
index 903ba47b52..252f766e5e 100644
--- a/packages/dashboard/app/hooks/useBoardScrollRestore.ts
+++ b/packages/dashboard/app/hooks/useBoardScrollRestore.ts
@@ -1,6 +1,9 @@
/*
FNXC:BoardNavigation 2026-06-24-00:00:
Preserves horizontal board scroll and per-column vertical scroll across a board → task-detail → back-to-board round trip. capture() snapshots before opening detail; requestRestore() schedules a restore that fires (double requestAnimationFrame, after the board remounts) once the view returns to "board". Extracted from AppInner.
+
+FNXC:BoardNavigation 2026-06-29-20:45:
+Mobile Back-to-board must restore the clicked-card board position after the full-panel detail unmounts. Retry the restore for a bounded sequence of animation frames because mobile board layout stabilization and workflow-board hydration can temporarily leave #board unavailable or reset its offsets after the first post-return frame.
*/
import { useCallback, useEffect, useRef } from "react";
@@ -11,6 +14,8 @@ import {
} from "../utils/boardScrollSnapshot";
import type { TaskView } from "./useViewState";
+const MAX_RESTORE_ATTEMPTS = 6;
+
export interface UseBoardScrollRestoreResult {
capture: () => void;
requestRestore: () => void;
@@ -20,12 +25,6 @@ export function useBoardScrollRestore(taskView: TaskView): UseBoardScrollRestore
const boardScrollSnapshotRef = useRef
(null);
const pendingBoardScrollRestoreRef = useRef(false);
- const restore = useCallback(() => {
- if (restoreBoardScrollSnapshot(boardScrollSnapshotRef.current)) {
- pendingBoardScrollRestoreRef.current = false;
- }
- }, []);
-
const capture = useCallback(() => {
boardScrollSnapshotRef.current = captureBoardScrollSnapshot();
}, []);
@@ -42,16 +41,36 @@ export function useBoardScrollRestore(taskView: TaskView): UseBoardScrollRestore
const cancelFrame = typeof window.cancelAnimationFrame === "function"
? window.cancelAnimationFrame.bind(window)
: window.clearTimeout.bind(window);
- let firstFrame = 0;
- let secondFrame = 0;
- firstFrame = scheduleFrame(() => {
- secondFrame = scheduleFrame(restore);
+ const frameIds: number[] = [];
+ let attempts = 0;
+ let cancelled = false;
+
+ const schedule = (callback: FrameRequestCallback) => {
+ const id = scheduleFrame(callback);
+ frameIds.push(id);
+ };
+
+ const attemptRestore = () => {
+ if (cancelled || !pendingBoardScrollRestoreRef.current) return;
+ attempts += 1;
+ const restored = restoreBoardScrollSnapshot(boardScrollSnapshotRef.current);
+ if (restored) {
+ pendingBoardScrollRestoreRef.current = false;
+ return;
+ }
+ if (attempts < MAX_RESTORE_ATTEMPTS) {
+ schedule(attemptRestore);
+ }
+ };
+
+ schedule(() => {
+ schedule(attemptRestore);
});
return () => {
- cancelFrame(firstFrame);
- cancelFrame(secondFrame);
+ cancelled = true;
+ frameIds.forEach(cancelFrame);
};
- }, [restore, taskView]);
+ }, [taskView]);
return { capture, requestRestore };
}
diff --git a/packages/dashboard/app/utils/__tests__/boardScrollSnapshot.test.ts b/packages/dashboard/app/utils/__tests__/boardScrollSnapshot.test.ts
index 21a4ad982f..0d2b2125a8 100644
--- a/packages/dashboard/app/utils/__tests__/boardScrollSnapshot.test.ts
+++ b/packages/dashboard/app/utils/__tests__/boardScrollSnapshot.test.ts
@@ -1,18 +1,31 @@
-import { describe, expect, it } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
import { captureBoardScrollSnapshot, restoreBoardScrollSnapshot } from "../boardScrollSnapshot";
describe("boardScrollSnapshot", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ delete (document as Document & { scrollingElement?: Element | null }).scrollingElement;
+ Object.defineProperty(window, "scrollX", { configurable: true, writable: true, value: 0 });
+ Object.defineProperty(window, "scrollY", { configurable: true, writable: true, value: 0 });
+ document.body.innerHTML = "";
+ });
+
it("round-trips board horizontal scroll and per-column vertical scroll", () => {
document.body.innerHTML = `
-
-
-
-
+
`;
+ const projectContent = document.querySelector(".project-content") as HTMLElement;
const board = document.getElementById("board") as HTMLElement;
const todoBody = document.querySelector('[data-column="todo"] .column-body') as HTMLElement;
const activeBody = document.querySelector('[data-column="in-progress"] .column-body') as HTMLElement;
+ projectContent.scrollLeft = 4;
+ projectContent.scrollTop = 22;
board.scrollLeft = 240;
board.scrollTop = 12;
todoBody.scrollTop = 380;
@@ -20,22 +33,88 @@ describe("boardScrollSnapshot", () => {
const snapshot = captureBoardScrollSnapshot();
+ projectContent.scrollLeft = 0;
+ projectContent.scrollTop = 0;
board.scrollLeft = 0;
board.scrollTop = 0;
todoBody.scrollTop = 0;
activeBody.scrollTop = 0;
expect(restoreBoardScrollSnapshot(snapshot)).toBe(true);
+ expect(projectContent.scrollLeft).toBe(4);
+ expect(projectContent.scrollTop).toBe(22);
expect(board.scrollLeft).toBe(240);
expect(board.scrollTop).toBe(12);
expect(todoBody.scrollTop).toBe(380);
expect(activeBody.scrollTop).toBe(95);
});
+ it("round-trips document scroll without requiring the project-content shell", () => {
+ document.body.innerHTML = `
+
+
+
+ `;
+ Object.defineProperty(document, "scrollingElement", {
+ configurable: true,
+ value: document.documentElement,
+ });
+ Object.defineProperty(window, "scrollX", { configurable: true, writable: true, value: 0 });
+ Object.defineProperty(window, "scrollY", { configurable: true, writable: true, value: 0 });
+ const scrollTo = vi.spyOn(window, "scrollTo").mockImplementation((leftOrOptions?: number | ScrollToOptions, top?: number) => {
+ if (typeof leftOrOptions === "number") {
+ Object.defineProperty(window, "scrollX", { configurable: true, writable: true, value: leftOrOptions });
+ Object.defineProperty(window, "scrollY", { configurable: true, writable: true, value: top ?? window.scrollY });
+ return;
+ }
+ Object.defineProperty(window, "scrollX", { configurable: true, writable: true, value: leftOrOptions?.left ?? window.scrollX });
+ Object.defineProperty(window, "scrollY", { configurable: true, writable: true, value: leftOrOptions?.top ?? window.scrollY });
+ });
+ const board = document.getElementById("board") as HTMLElement;
+
+ board.scrollLeft = 40;
+ board.scrollTop = 8;
+ document.documentElement.scrollLeft = 13;
+ document.documentElement.scrollTop = 144;
+
+ const snapshot = captureBoardScrollSnapshot();
+
+ expect(snapshot).toMatchObject({
+ boardLeft: 40,
+ boardTop: 8,
+ projectContentLeft: 0,
+ projectContentTop: 0,
+ documentLeft: 13,
+ documentTop: 144,
+ });
+
+ board.scrollLeft = 0;
+ board.scrollTop = 0;
+ document.documentElement.scrollLeft = 0;
+ document.documentElement.scrollTop = 0;
+ Object.defineProperty(window, "scrollX", { configurable: true, writable: true, value: 0 });
+ Object.defineProperty(window, "scrollY", { configurable: true, writable: true, value: 0 });
+
+ expect(restoreBoardScrollSnapshot(snapshot)).toBe(true);
+ expect(document.documentElement.scrollLeft).toBe(13);
+ expect(document.documentElement.scrollTop).toBe(144);
+ expect(board.scrollLeft).toBe(40);
+ expect(board.scrollTop).toBe(8);
+ expect(scrollTo).toHaveBeenCalledWith(13, 144);
+ });
+
it("returns false when the board is not mounted", () => {
document.body.innerHTML = "";
expect(captureBoardScrollSnapshot()).toBeNull();
- expect(restoreBoardScrollSnapshot({ boardLeft: 10, boardTop: 0, columnTops: {} })).toBe(false);
+ expect(restoreBoardScrollSnapshot({
+ boardLeft: 10,
+ boardTop: 0,
+ columnTops: {},
+ projectContentLeft: 0,
+ projectContentTop: 0,
+ documentLeft: 0,
+ documentTop: 0,
+ })).toBe(false);
});
});
diff --git a/packages/dashboard/app/utils/boardScrollSnapshot.ts b/packages/dashboard/app/utils/boardScrollSnapshot.ts
index e91cf7b07d..ad1c09fd23 100644
--- a/packages/dashboard/app/utils/boardScrollSnapshot.ts
+++ b/packages/dashboard/app/utils/boardScrollSnapshot.ts
@@ -2,6 +2,10 @@ export interface BoardScrollSnapshot {
boardLeft: number;
boardTop: number;
columnTops: Record;
+ projectContentLeft: number;
+ projectContentTop: number;
+ documentLeft: number;
+ documentTop: number;
}
function getBoardDocument(doc?: Document): Document | null {
@@ -12,12 +16,19 @@ function getBoardDocument(doc?: Document): Document | null {
/*
FNXC:BoardNavigation 2026-06-22-20:15:
Board-card task detail replaces the board instead of overlaying it. Capture horizontal board scroll and per-column vertical scroll before opening detail, then restore after Back to board remounts the board so users return to the same lane/card context.
+
+FNXC:BoardNavigation 2026-06-29-20:45:
+Mobile Back-to-board must restore the clicked-card board position even when the browser parks scroll on the project-content/document shell during the full-panel task-detail transition. Snapshot the shell offsets alongside #board and .column-body; CSS keeps #board as the horizontal scroller and .column-body as the vertical lane scroller, but restoring the shell defensively prevents mobile viewport drift from hiding the clicked card after return.
*/
export function captureBoardScrollSnapshot(doc?: Document): BoardScrollSnapshot | null {
const ownerDocument = getBoardDocument(doc);
- const board = ownerDocument?.getElementById("board") as HTMLElement | null;
+ if (!ownerDocument) return null;
+ const board = ownerDocument.getElementById("board") as HTMLElement | null;
if (!board) return null;
+ const projectContent = ownerDocument.querySelector(".project-content");
+ const scrollingElement = ownerDocument.scrollingElement as HTMLElement | null;
+ const defaultView = ownerDocument.defaultView;
const columnTops: Record = {};
board.querySelectorAll(".column[data-column]").forEach((column) => {
const columnId = column.dataset.column;
@@ -31,15 +42,41 @@ export function captureBoardScrollSnapshot(doc?: Document): BoardScrollSnapshot
boardLeft: board.scrollLeft,
boardTop: board.scrollTop,
columnTops,
+ projectContentLeft: projectContent?.scrollLeft ?? 0,
+ projectContentTop: projectContent?.scrollTop ?? 0,
+ documentLeft: scrollingElement?.scrollLeft ?? defaultView?.scrollX ?? 0,
+ documentTop: scrollingElement?.scrollTop ?? defaultView?.scrollY ?? 0,
};
}
export function restoreBoardScrollSnapshot(snapshot: BoardScrollSnapshot | null, doc?: Document): boolean {
if (!snapshot) return false;
const ownerDocument = getBoardDocument(doc);
- const board = ownerDocument?.getElementById("board") as HTMLElement | null;
+ if (!ownerDocument) return false;
+ const board = ownerDocument.getElementById("board") as HTMLElement | null;
if (!board) return false;
+ const projectContent = ownerDocument.querySelector(".project-content");
+ const scrollingElement = ownerDocument.scrollingElement as HTMLElement | null;
+ const defaultView = ownerDocument.defaultView;
+ if (projectContent) {
+ projectContent.scrollLeft = snapshot.projectContentLeft ?? 0;
+ projectContent.scrollTop = snapshot.projectContentTop ?? 0;
+ }
+ if (scrollingElement) {
+ scrollingElement.scrollLeft = snapshot.documentLeft ?? 0;
+ scrollingElement.scrollTop = snapshot.documentTop ?? 0;
+ }
+ const documentLeft = snapshot.documentLeft ?? 0;
+ const documentTop = snapshot.documentTop ?? 0;
+ if (defaultView && (defaultView.scrollX !== documentLeft || defaultView.scrollY !== documentTop)) {
+ try {
+ defaultView.scrollTo(documentLeft, documentTop);
+ } catch {
+ // Test DOMs may expose scrollTo without implementing it; element offsets above still cover the restore contract.
+ }
+ }
+
board.scrollLeft = snapshot.boardLeft;
board.scrollTop = snapshot.boardTop;
board.querySelectorAll(".column[data-column]").forEach((column) => {