FN-7258: restore mobile board scroll on return

Preserve the board lane position when mobile users return from full-panel task detail.

- Capture and restore board, column, project-content, and document scroll offsets for board detail navigation.
- Retry board scroll restoration across bounded animation frames after remount and hydration.
- Cover mobile and desktop Back to board behavior with focused tests and document the mobile scroll contract.
- Add a patch changeset for the published CLI package.

Files changed:
 .changeset/fn-7258-mobile-board-scroll.md          |   7 ++
 docs/dashboard-guide.md                            |   2 +
 .../__tests__/navigation-history.test.tsx          | 111 ++++++++++++++++++---
 .../hooks/__tests__/useBoardScrollRestore.test.ts  |  43 +++++++-
 .../dashboard/app/hooks/useBoardScrollRestore.ts   |  45 ++++++---
 .../utils/__tests__/boardScrollSnapshot.test.ts    |  91 +++++++++++++++--
 .../dashboard/app/utils/boardScrollSnapshot.ts     |  41 +++++++-
 7 files changed, 305 insertions(+), 35 deletions(-)

Fusion-Task-Id: FN-7258

Fusion-Task-Lineage: 97faf031-4244-4117-9948-45491c364134

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-29 21:02:34 -07:00
parent 9fb706944e
commit d05ca2154f
7 changed files with 305 additions and 35 deletions

View File

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

View File

@@ -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.
<!-- FNXC:BoardNavigationDocs 2026-06-29-20:45: Mobile full-panel task detail temporarily replaces the board, so the user-facing navigation guide must document that Back to board restores the board/card scroll context instead of returning to the top of the board. -->
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.

View File

@@ -155,20 +155,24 @@ vi.mock("../../components/Board", () => ({
onOpenDetail: (task: Task) => void;
onOpenDetailWithTab?: (task: Task, initialTab: "changes" | "retries" | "workflow") => void;
}) => (
<div data-testid="board-view">
{tasks.map((task) => (
<div key={task.id}>
<button type="button" data-testid={`open-task-${task.id}`} onClick={() => onOpenDetail(task)}>
{task.title}
</button>
{task.modifiedFiles && task.modifiedFiles.length > 0 ? (
<button type="button" data-testid={`open-task-changes-${task.id}`} onClick={() => onOpenDetailWithTab?.(task, "changes")}>
Files changed
</button>
) : null}
<main id="board" className="board" data-testid="board-view">
<section className="column" data-column="todo">
<div className="column-body" data-testid="todo-column-body">
{tasks.map((task) => (
<div key={task.id}>
<button type="button" data-testid={`open-task-${task.id}`} onClick={() => onOpenDetail(task)}>
{task.title}
</button>
{task.modifiedFiles && task.modifiedFiles.length > 0 ? (
<button type="button" data-testid={`open-task-changes-${task.id}`} onClick={() => onOpenDetailWithTab?.(task, "changes")}>
Files changed
</button>
) : null}
</div>
))}
</div>
))}
</div>
</section>
</main>
),
}));
@@ -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"),

View File

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

View File

@@ -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<BoardScrollSnapshot | null>(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 };
}

View File

@@ -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 = `
<main id="board">
<section class="column" data-column="todo"><div class="column-body"></div></section>
<section class="column" data-column="in-progress"><div class="column-body"></div></section>
</main>
<div class="project-content">
<main id="board">
<section class="column" data-column="todo"><div class="column-body"></div></section>
<section class="column" data-column="in-progress"><div class="column-body"></div></section>
</main>
</div>
`;
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 = `
<main id="board">
<section class="column" data-column="todo"><div class="column-body"></div></section>
</main>
`;
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);
});
});

View File

@@ -2,6 +2,10 @@ export interface BoardScrollSnapshot {
boardLeft: number;
boardTop: number;
columnTops: Record<string, number>;
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<HTMLElement>(".project-content");
const scrollingElement = ownerDocument.scrollingElement as HTMLElement | null;
const defaultView = ownerDocument.defaultView;
const columnTops: Record<string, number> = {};
board.querySelectorAll<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".column[data-column]").forEach((column) => {