From d05ca2154f1ffb3c2265e67793975b4757107ef7 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 29 Jun 2026 21:02:34 -0700 Subject: [PATCH] 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) --- .changeset/fn-7258-mobile-board-scroll.md | 7 ++ docs/dashboard-guide.md | 2 + .../__tests__/navigation-history.test.tsx | 111 ++++++++++++++++-- .../__tests__/useBoardScrollRestore.test.ts | 43 ++++++- .../app/hooks/useBoardScrollRestore.ts | 45 +++++-- .../__tests__/boardScrollSnapshot.test.ts | 91 +++++++++++++- .../app/utils/boardScrollSnapshot.ts | 41 ++++++- 7 files changed, 305 insertions(+), 35 deletions(-) create mode 100644 .changeset/fn-7258-mobile-board-scroll.md 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) => ( -
- - {task.modifiedFiles && task.modifiedFiles.length > 0 ? ( - - ) : null} +
+
+
+ {tasks.map((task) => ( +
+ + {task.modifiedFiles && task.modifiedFiles.length > 0 ? ( + + ) : 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) => {