diff --git a/.changeset/fn-7342-board-scroll-reset.md b/.changeset/fn-7342-board-scroll-reset.md new file mode 100644 index 0000000000..bf254338c0 --- /dev/null +++ b/.changeset/fn-7342-board-scroll-reset.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Preserve board column scroll during dashboard refresh and viewport stabilization. +category: fix +dev: Narrows mobile board stabilization so task/workflow refresh and resize events pin document drift without resetting #board.scrollLeft. diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index 4d121033b6..6cde058609 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -261,11 +261,15 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o return stableGrouped; }, [tasks, doneSortMode]); + /* + FNXC:BoardNavigation 2026-06-30-17:42: + Periodic task/workflow refreshes, rerenders, window resize, and visualViewport resize must not override intentional board-column scroll while the Board is already visible. Keep FN-001/FN-4574 stabilization focused on page-level horizontal drift and layout reflow; #board is the user's horizontal scroller, so it must not be forced back to triage. + */ // FN-4574 + FN-001 diagnosis: on iOS Safari, the mobile board can occasionally // snap against stale layout/visualViewport metrics before flex columns resolve, // both on initial mount and on pageshow/bfcache restore after backgrounding. // We keep the FN-001 baseline (`scroll-snap-type: x proximity` + - // `overflow-anchor: none`) and only stabilize via reflow + scroll offset + // `overflow-anchor: none`) and only stabilize via reflow + document scroll // normalization; do NOT reintroduce `scroll-snap-type: x mandatory`. useEffect(() => { const mobileQuery = window.matchMedia(MOBILE_MEDIA_QUERY); @@ -278,7 +282,6 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o void boardEl.offsetWidth; if (mobileQuery.matches) { resetDocumentHorizontalScroll(); - boardEl.scrollLeft = 0; } }; diff --git a/packages/dashboard/app/components/__tests__/Board.test.tsx b/packages/dashboard/app/components/__tests__/Board.test.tsx index 8e5498a344..75ee4bafd4 100644 --- a/packages/dashboard/app/components/__tests__/Board.test.tsx +++ b/packages/dashboard/app/components/__tests__/Board.test.tsx @@ -199,6 +199,49 @@ function renderBoard(props = {}) { return render(); } +function installMobileBoardStabilizationHarness() { + const originalMatchMedia = window.matchMedia; + const originalRequestAnimationFrame = window.requestAnimationFrame; + const originalCancelAnimationFrame = window.cancelAnimationFrame; + const visualViewportDescriptor = Object.getOwnPropertyDescriptor(window, "visualViewport"); + const visualViewportTarget = new EventTarget() as EventTarget & { scale: number }; + visualViewportTarget.scale = 1; + + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: query.includes("768px"), + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + window.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + callback(performance.now()); + return 1; + }); + window.cancelAnimationFrame = vi.fn(); + Object.defineProperty(window, "visualViewport", { + configurable: true, + value: visualViewportTarget, + }); + + return { + visualViewport: visualViewportTarget, + restore() { + window.matchMedia = originalMatchMedia; + window.requestAnimationFrame = originalRequestAnimationFrame; + window.cancelAnimationFrame = originalCancelAnimationFrame; + if (visualViewportDescriptor) { + Object.defineProperty(window, "visualViewport", visualViewportDescriptor); + } else { + delete (window as typeof window & { visualViewport?: VisualViewport }).visualViewport; + } + }, + }; +} + async function openWorkflowSwitcher() { const trigger = await screen.findByTestId("workflow-switcher"); fireEvent.click(trigger); @@ -224,6 +267,46 @@ describe("Board", () => { expect(main.id).toBe("board"); }); + it("preserves intentional board column scroll during mobile resize stabilization", () => { + const harness = installMobileBoardStabilizationHarness(); + try { + const { rerender } = renderBoard({ + tasks: [ + { id: "FN-SCROLL-1", description: "Later lane", column: "in-review", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-01T00:00:00.000Z" } as Task, + ], + }); + const board = screen.getByRole("main") as HTMLElement; + board.scrollLeft = 360; + + rerender(); + act(() => { + window.dispatchEvent(new Event("resize")); + }); + + expect(board.scrollLeft).toBe(360); + expect(document.documentElement.scrollLeft).toBe(0); + } finally { + harness.restore(); + } + }); + + it("preserves intentional board column scroll during mobile visualViewport stabilization", () => { + const harness = installMobileBoardStabilizationHarness(); + try { + renderBoard(); + const board = screen.getByRole("main") as HTMLElement; + board.scrollLeft = 480; + + act(() => { + harness.visualViewport.dispatchEvent(new Event("resize")); + }); + + expect(board.scrollLeft).toBe(480); + } finally { + harness.restore(); + } + }); + it("FN-4380: does not eagerly fetch GitHub badge status on board mount", () => { vi.useFakeTimers(); try { @@ -1666,6 +1749,32 @@ describe("Board", () => { ]); }); + it("preserves all-workflows board scroll during mobile visualViewport refresh stabilization", async () => { + const harness = installMobileBoardStabilizationHarness(); + try { + enableFlag( + { "FN-1": "builtin:coding", "FN-2": "wf-custom" }, + [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW], + ); + renderBoard({ tasks: [mkTask({ id: "FN-1", column: "todo" }), mkTask({ id: "FN-2", column: "intake" })] }); + + await selectWorkflow(ALL_WORKFLOWS_BOARD_VIEW_ID); + const board = screen.getByRole("main") as HTMLElement; + expect(board.className).toContain("board-workflow-columns"); + board.scrollLeft = 520; + + act(() => { + harness.visualViewport.dispatchEvent(new Event("resize")); + window.dispatchEvent(new Event("resize")); + }); + + expect(board.scrollLeft).toBe(520); + expect(screen.getByTestId("column-intake")).toHaveAttribute("data-tasks", expect.stringContaining("FN-2")); + } finally { + harness.restore(); + } + }); + it("archived column is collapsible in workflow mode", async () => { enableFlag({ "FN-9": "builtin:coding" }); renderBoard({ tasks: [mkTask({ id: "FN-9", column: "archived" })] });