diff --git a/.changeset/fn-5936-auto-merge-mobile-fix.md b/.changeset/fn-5936-auto-merge-mobile-fix.md
new file mode 100644
index 0000000000..83a144b12a
--- /dev/null
+++ b/.changeset/fn-5936-auto-merge-mobile-fix.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": patch
+---
+
+Fix the dashboard auto-merge toggle blanking on mobile by keeping board stabilization tied to viewport events instead of a one-shot resize listener.
+
+The in-review board now stays visible when auto-merge is toggled across Android mobile, iOS mobile, tablet, and desktop layouts, with regression coverage for populated and empty columns plus rollback and error-boundary paths.
diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx
index 936931d017..423c39df46 100644
--- a/packages/dashboard/app/components/Board.tsx
+++ b/packages/dashboard/app/components/Board.tsx
@@ -236,25 +236,20 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
}
};
+ const visualViewport = window.visualViewport;
+ const handleViewportResize = () => {
+ scheduleStabilization();
+ };
+
scheduleStabilization();
window.addEventListener("pageshow", handlePageShow);
-
- const visualViewport = window.visualViewport;
- let handleViewportResize: (() => void) | null = null;
- if (visualViewport) {
- handleViewportResize = () => {
- scheduleStabilization();
- if (typeof visualViewport.removeEventListener === "function") {
- visualViewport.removeEventListener("resize", handleViewportResize!);
- }
- handleViewportResize = null;
- };
+ if (typeof visualViewport?.addEventListener === "function") {
visualViewport.addEventListener("resize", handleViewportResize);
}
return () => {
window.removeEventListener("pageshow", handlePageShow);
- if (handleViewportResize && typeof visualViewport?.removeEventListener === "function") {
+ if (typeof visualViewport?.removeEventListener === "function") {
visualViewport.removeEventListener("resize", handleViewportResize);
}
if (rafId !== null) {
diff --git a/packages/dashboard/app/components/__tests__/App.test.tsx b/packages/dashboard/app/components/__tests__/App.test.tsx
index e8b41cbf89..45b60c9f6b 100644
--- a/packages/dashboard/app/components/__tests__/App.test.tsx
+++ b/packages/dashboard/app/components/__tests__/App.test.tsx
@@ -1652,9 +1652,11 @@ describe("App auto-open Settings on unauthenticated", () => {
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
- // The Settings modal should be open showing Authentication content
- // fetchSettings is called twice: once by App useEffect, once by SettingsModal
- await waitFor(() => expect(fetchSettings).toHaveBeenCalledTimes(2));
+ // The Settings modal should be open showing Authentication content.
+ // App and SettingsModal both hydrate settings, and follow-up refreshes may
+ // legitimately add another fetch during initialization; the invariant here
+ // is that settings hydration happened before Authentication content renders.
+ await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Authentication section should be active — auth status is fetched when section is active
await waitFor(() => {
diff --git a/packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile.test.tsx b/packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile.test.tsx
new file mode 100644
index 0000000000..e93dc45750
--- /dev/null
+++ b/packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile.test.tsx
@@ -0,0 +1,418 @@
+import React, { useState } from "react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, fireEvent, act } from "@testing-library/react";
+import { Board } from "../Board";
+import { PageErrorBoundary } from "../ErrorBoundary";
+import type { Task } from "@fusion/core";
+
+vi.mock("../../api", () => ({
+ fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
+}));
+
+vi.mock("../../hooks/useBlockerFanout", () => ({
+ useBlockerFanout: () => new Map(),
+}));
+
+vi.mock("../../hooks/useConfirm", () => ({
+ useConfirm: () => ({ confirm: vi.fn() }),
+}));
+
+vi.mock("../../hooks/useFlashOnIncrease", () => ({
+ useFlashOnIncrease: () => false,
+}));
+
+vi.mock("../PluginSlot", () => ({
+ PluginSlot: () => null,
+}));
+
+vi.mock("../QuickEntryBox", () => ({
+ QuickEntryBox: () => null,
+}));
+
+vi.mock("../TaskCard", () => ({
+ TaskCard: ({ task, autoMergeEnabled }: { task: Task; autoMergeEnabled?: boolean }) => {
+ if (task.id === "FN-ERROR" && autoMergeEnabled === false) {
+ throw new Error("Auto-merge render failed");
+ }
+ return
task:{task.id}:{String(autoMergeEnabled)}
;
+ },
+}));
+
+vi.mock("../WorktreeGroup", () => ({
+ WorktreeGroup: ({ label, autoMergeEnabled }: { label: string; autoMergeEnabled?: boolean }) => (
+ worktree:{String(autoMergeEnabled)}
+ ),
+}));
+
+function ensureMatchMedia() {
+ if (!window.matchMedia) {
+ Object.defineProperty(window, "matchMedia", {
+ writable: true,
+ value: vi.fn(),
+ });
+ }
+}
+
+function mockViewport(width: number) {
+ ensureMatchMedia();
+ Object.defineProperty(window, "innerWidth", { value: width, configurable: true });
+ return vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
+ matches: query === "(max-width: 768px)" ? width <= 768 : false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ }));
+}
+
+function createVisualViewport(scale = 1) {
+ const resizeListeners = new Set<() => void>();
+ return {
+ scale,
+ addEventListener: vi.fn((event: string, listener: () => void) => {
+ if (event === "resize") {
+ resizeListeners.add(listener);
+ }
+ }),
+ removeEventListener: vi.fn((event: string, listener: () => void) => {
+ if (event === "resize") {
+ resizeListeners.delete(listener);
+ }
+ }),
+ dispatchResize: () => {
+ for (const listener of [...resizeListeners]) {
+ listener();
+ }
+ },
+ };
+}
+
+function createTask(id: string, column: Task["column"]): Task {
+ return {
+ id,
+ title: id,
+ description: `${id} description`,
+ column,
+ status: column === "in-review" ? "in-review" : undefined,
+ dependencies: [],
+ steps: [],
+ currentStep: 0,
+ log: [],
+ createdAt: "2026-06-03T00:00:00.000Z",
+ updatedAt: "2026-06-03T00:00:00.000Z",
+ } as Task;
+}
+
+function BaseBoardHarness({
+ tasks,
+ autoMerge,
+ onToggleAutoMerge,
+}: {
+ tasks: Task[];
+ autoMerge: boolean;
+ onToggleAutoMerge: () => void | Promise;
+}) {
+ return (
+
+ ({} as Task))}
+ onOpenDetail={vi.fn()}
+ addToast={vi.fn()}
+ onQuickCreate={vi.fn(async () => undefined)}
+ onNewTask={vi.fn()}
+ autoMerge={autoMerge}
+ onToggleAutoMerge={onToggleAutoMerge}
+ globalPaused={false}
+ />
+
+ );
+}
+
+function BoardHarness({ tasks, initialAutoMerge = true }: { tasks: Task[]; initialAutoMerge?: boolean }) {
+ const [autoMerge, setAutoMerge] = useState(initialAutoMerge);
+
+ return (
+ setAutoMerge((current) => !current)}
+ />
+ );
+}
+
+function RollbackBoardHarness({ tasks }: { tasks: Task[] }) {
+ const [autoMerge, setAutoMerge] = useState(true);
+
+ return (
+ {
+ const previousAutoMerge = autoMerge;
+ const nextAutoMerge = !previousAutoMerge;
+ setAutoMerge(nextAutoMerge);
+
+ try {
+ await Promise.reject(new Error("network"));
+ } catch {
+ setAutoMerge(previousAutoMerge);
+ }
+ }}
+ />
+ );
+}
+
+function installAnimationFrame() {
+ vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
+ setTimeout(() => cb(0), 0);
+ return 1;
+ });
+ vi.stubGlobal("cancelAnimationFrame", vi.fn());
+}
+
+function expectBoardVisible() {
+ expect(document.querySelector("main.board")).not.toBeNull();
+ expect(screen.getByText("In Review")).toBeInTheDocument();
+ expect(screen.queryByText("Something went wrong")).toBeNull();
+}
+
+describe("auto-merge toggle mobile blank regression", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ });
+
+ it("keeps the mobile board visible after an Android viewport resize triggered by toggling auto-merge", () => {
+ const viewportSpy = mockViewport(375);
+ const visualViewport = createVisualViewport(1);
+ Object.defineProperty(window, "visualViewport", {
+ configurable: true,
+ value: visualViewport,
+ });
+ installAnimationFrame();
+
+ render();
+
+ const board = document.querySelector("main.board") as HTMLElement;
+ expect(screen.getByTestId("task-card-FN-5936")).toHaveTextContent("true");
+ expectBoardVisible();
+
+ act(() => {
+ vi.runAllTimers();
+ });
+
+ board.scrollLeft = 240;
+ act(() => {
+ visualViewport.dispatchResize();
+ vi.runAllTimers();
+ });
+ expect(board.scrollLeft).toBe(0);
+
+ board.scrollLeft = 240;
+ fireEvent.click(screen.getByRole("checkbox", { name: "Auto-merge" }));
+
+ expect(screen.getByTestId("task-card-FN-5936")).toHaveTextContent("false");
+
+ board.scrollLeft = 240;
+ act(() => {
+ visualViewport.dispatchResize();
+ vi.runAllTimers();
+ });
+
+ expectBoardVisible();
+ expect(board.scrollLeft).toBe(0);
+ viewportSpy.mockRestore();
+ });
+
+ it("round-trips auto-merge on mobile Android with an empty in-review column without blanking", () => {
+ const viewportSpy = mockViewport(375);
+ const visualViewport = createVisualViewport(1);
+ Object.defineProperty(window, "visualViewport", {
+ configurable: true,
+ value: visualViewport,
+ });
+ installAnimationFrame();
+
+ render();
+ const board = document.querySelector("main.board") as HTMLElement;
+
+ act(() => {
+ vi.runAllTimers();
+ });
+
+ const toggle = screen.getByRole("checkbox", { name: "Auto-merge" });
+ expect(toggle).toBeChecked();
+ expectBoardVisible();
+
+ fireEvent.click(toggle);
+ expect(toggle).not.toBeChecked();
+ board.scrollLeft = 180;
+ act(() => {
+ visualViewport.dispatchResize();
+ vi.runAllTimers();
+ });
+ expectBoardVisible();
+ expect(board.scrollLeft).toBe(0);
+
+ fireEvent.click(toggle);
+ expect(toggle).toBeChecked();
+ board.scrollLeft = 180;
+ act(() => {
+ visualViewport.dispatchResize();
+ vi.runAllTimers();
+ });
+ expectBoardVisible();
+ expect(board.scrollLeft).toBe(0);
+ viewportSpy.mockRestore();
+ });
+
+ it("keeps populated task-card and worktree surfaces visible when auto-merge toggles on mobile", () => {
+ const viewportSpy = mockViewport(375);
+ const visualViewport = createVisualViewport(1);
+ Object.defineProperty(window, "visualViewport", {
+ configurable: true,
+ value: visualViewport,
+ });
+ installAnimationFrame();
+
+ render(
+ ,
+ );
+
+ act(() => {
+ vi.runAllTimers();
+ });
+
+ expect(screen.getByTestId("task-card-FN-5936")).toHaveTextContent("true");
+ expect(screen.getByTestId("worktree-group-Unassigned")).toHaveTextContent("true");
+
+ fireEvent.click(screen.getByRole("checkbox", { name: "Auto-merge" }));
+
+ expect(screen.getByTestId("task-card-FN-5936")).toHaveTextContent("false");
+ expect(screen.getByTestId("worktree-group-Unassigned")).toHaveTextContent("false");
+ expectBoardVisible();
+ viewportSpy.mockRestore();
+ });
+
+ it("re-anchors on the mobile iOS pageshow path after toggling auto-merge", () => {
+ const viewportSpy = mockViewport(375);
+ Object.defineProperty(window, "visualViewport", {
+ configurable: true,
+ value: createVisualViewport(1.1),
+ });
+ installAnimationFrame();
+
+ render();
+ const board = document.querySelector("main.board") as HTMLElement;
+
+ act(() => {
+ vi.runAllTimers();
+ });
+
+ fireEvent.click(screen.getByRole("checkbox", { name: "Auto-merge" }));
+ board.scrollLeft = 210;
+
+ const pageShow = new Event("pageshow") as PageTransitionEvent;
+ Object.defineProperty(pageShow, "persisted", { configurable: true, value: true });
+ act(() => {
+ window.dispatchEvent(pageShow);
+ vi.runAllTimers();
+ });
+
+ expectBoardVisible();
+ expect(board.scrollLeft).toBe(0);
+ viewportSpy.mockRestore();
+ });
+
+ it("keeps the board visible on tablet where the mobile stabilization effect is disabled", () => {
+ const viewportSpy = mockViewport(900);
+ installAnimationFrame();
+
+ render();
+
+ const toggle = screen.getByRole("checkbox", { name: "Auto-merge" });
+ expect(toggle).toBeChecked();
+ expectBoardVisible();
+
+ fireEvent.click(toggle);
+ expect(toggle).not.toBeChecked();
+ expect(screen.getByTestId("task-card-FN-TABLET")).toHaveTextContent("false");
+ expectBoardVisible();
+ viewportSpy.mockRestore();
+ });
+
+ it("keeps the board visible on desktop after toggling auto-merge", () => {
+ const viewportSpy = mockViewport(1280);
+ installAnimationFrame();
+
+ render();
+
+ fireEvent.click(screen.getByRole("checkbox", { name: "Auto-merge" }));
+
+ expect(screen.getByTestId("task-card-FN-DESKTOP")).toHaveTextContent("false");
+ expectBoardVisible();
+ viewportSpy.mockRestore();
+ });
+
+ it("keeps the mobile board visible when the toggle rolls back after an update failure", async () => {
+ const viewportSpy = mockViewport(375);
+ const visualViewport = createVisualViewport(1);
+ Object.defineProperty(window, "visualViewport", {
+ configurable: true,
+ value: visualViewport,
+ });
+ installAnimationFrame();
+
+ render();
+
+ const toggle = screen.getByRole("checkbox", { name: "Auto-merge" });
+ expect(toggle).toBeChecked();
+
+ await act(async () => {
+ fireEvent.click(toggle);
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(toggle).toBeChecked();
+ expect(screen.getByTestId("task-card-FN-ROLLBACK")).toHaveTextContent("true");
+ expectBoardVisible();
+ viewportSpy.mockRestore();
+ });
+
+ it("shows a visible page error boundary fallback instead of a blank board when a board child throws", () => {
+ const viewportSpy = mockViewport(375);
+ const visualViewport = createVisualViewport(1);
+ Object.defineProperty(window, "visualViewport", {
+ configurable: true,
+ value: visualViewport,
+ });
+ installAnimationFrame();
+ const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
+
+ render();
+
+ fireEvent.click(screen.getByRole("checkbox", { name: "Auto-merge" }));
+
+ expect(screen.getByText("Something went wrong")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
+
+ consoleErrorSpy.mockRestore();
+ viewportSpy.mockRestore();
+ });
+});
diff --git a/packages/dashboard/app/hooks/__tests__/useAppSettings.test.ts b/packages/dashboard/app/hooks/__tests__/useAppSettings.test.ts
index a3c8971ed3..23fcf751db 100644
--- a/packages/dashboard/app/hooks/__tests__/useAppSettings.test.ts
+++ b/packages/dashboard/app/hooks/__tests__/useAppSettings.test.ts
@@ -102,7 +102,24 @@ describe("useAppSettings", () => {
expect(result.current.autoMerge).toBe(false);
});
- it("rolls back optimistic state when toggle update fails", async () => {
+ it("rolls back optimistic autoMerge state when toggle update fails", async () => {
+ mockUpdateSettings.mockRejectedValueOnce(new Error("network"));
+
+ const { result } = renderHook(() => useAppSettings("proj_123"));
+
+ await waitFor(() => {
+ expect(result.current.autoMerge).toBe(false);
+ });
+
+ await act(async () => {
+ await result.current.toggleAutoMerge();
+ });
+
+ expect(result.current.autoMerge).toBe(false);
+ expect(mockUpdateSettings).toHaveBeenCalledWith({ autoMerge: true }, "proj_123");
+ });
+
+ it("rolls back optimistic state when global pause update fails", async () => {
mockUpdateSettings.mockRejectedValueOnce(new Error("network"));
const { result } = renderHook(() => useAppSettings("proj_123"));
@@ -168,6 +185,40 @@ describe("useAppSettings", () => {
});
});
+ it("coerces undefined autoMerge settings to false", async () => {
+ mockFetchSettings.mockResolvedValueOnce({
+ autoMerge: undefined,
+ globalPause: true,
+ enginePaused: false,
+ prAuthAvailable: true,
+ taskStuckTimeoutMs: 600000,
+ showQuickChatFAB: false,
+ } as never);
+
+ const { result } = renderHook(() => useAppSettings("proj_123"));
+
+ await waitFor(() => {
+ expect(result.current.autoMerge).toBe(false);
+ });
+ });
+
+ it("coerces truthy non-boolean autoMerge settings to true", async () => {
+ mockFetchSettings.mockResolvedValueOnce({
+ autoMerge: "enabled",
+ globalPause: true,
+ enginePaused: false,
+ prAuthAvailable: true,
+ taskStuckTimeoutMs: 600000,
+ showQuickChatFAB: false,
+ } as never);
+
+ const { result } = renderHook(() => useAppSettings("proj_123"));
+
+ await waitFor(() => {
+ expect(result.current.autoMerge).toBe(true);
+ });
+ });
+
it("propagates capacity risk settings from fetchSettings", async () => {
mockFetchSettings.mockResolvedValueOnce({
autoMerge: false,