FN-5936: fix auto-merge board stabilization on mobile

Keep the dashboard board visible when auto-merge toggles across viewport changes.

- keep board stabilization subscribed to visualViewport resize events instead of removing the listener after the first resize
- add regression coverage for Android, iOS, tablet, desktop, empty-column, rollback, and error-boundary auto-merge toggle paths
- relax settings hydration assertions and expand app-settings tests for auto-merge rollback and coercion behavior

Files changed:
 .changeset/fn-5936-auto-merge-mobile-fix.md        |   7 +
 packages/dashboard/app/components/Board.tsx        |  19 +-
 packages/dashboard/app/components/__tests__/App.test.tsx          |   8 +-
 packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile.test.tsx        | 418 +++++++++++++++++++++
 packages/dashboard/app/hooks/__tests__/useAppSettings.test.ts     |  53 ++-
 5 files changed, 489 insertions(+), 16 deletions(-)

Fusion-Task-Id: FN-5936

Fusion-Task-Lineage: 6e937d0e-dc60-4863-a884-001228dd0cd1
This commit is contained in:
gsxdsm
2026-06-03 07:15:04 -07:00
parent ad740775b3
commit 419f688afb
5 changed files with 489 additions and 16 deletions

View File

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

View File

@@ -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) {

View File

@@ -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(() => {

View File

@@ -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 <div data-testid={`task-card-${task.id}`}>task:{task.id}:{String(autoMergeEnabled)}</div>;
},
}));
vi.mock("../WorktreeGroup", () => ({
WorktreeGroup: ({ label, autoMergeEnabled }: { label: string; autoMergeEnabled?: boolean }) => (
<div data-testid={`worktree-group-${label}`}>worktree:{String(autoMergeEnabled)}</div>
),
}));
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<void>;
}) {
return (
<PageErrorBoundary>
<Board
tasks={tasks}
maxConcurrent={2}
onMoveTask={vi.fn(async () => ({} as Task))}
onOpenDetail={vi.fn()}
addToast={vi.fn()}
onQuickCreate={vi.fn(async () => undefined)}
onNewTask={vi.fn()}
autoMerge={autoMerge}
onToggleAutoMerge={onToggleAutoMerge}
globalPaused={false}
/>
</PageErrorBoundary>
);
}
function BoardHarness({ tasks, initialAutoMerge = true }: { tasks: Task[]; initialAutoMerge?: boolean }) {
const [autoMerge, setAutoMerge] = useState(initialAutoMerge);
return (
<BaseBoardHarness
tasks={tasks}
autoMerge={autoMerge}
onToggleAutoMerge={() => setAutoMerge((current) => !current)}
/>
);
}
function RollbackBoardHarness({ tasks }: { tasks: Task[] }) {
const [autoMerge, setAutoMerge] = useState(true);
return (
<BaseBoardHarness
tasks={tasks}
autoMerge={autoMerge}
onToggleAutoMerge={async () => {
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(<BoardHarness tasks={[createTask("FN-5936", "in-review")]} />);
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(<BoardHarness tasks={[]} />);
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(
<BoardHarness
tasks={[
createTask("FN-5936", "in-review"),
createTask("FN-IP", "in-progress"),
]}
/>,
);
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(<BoardHarness tasks={[createTask("FN-IOS", "in-review")]} />);
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(<BoardHarness tasks={[createTask("FN-TABLET", "in-review")]} />);
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(<BoardHarness tasks={[createTask("FN-DESKTOP", "in-review")]} />);
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(<RollbackBoardHarness tasks={[createTask("FN-ROLLBACK", "in-review")]} />);
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(<BoardHarness tasks={[createTask("FN-ERROR", "in-review")]} />);
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();
});
});

View File

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