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" })] });