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:
7
.changeset/fn-5936-auto-merge-mobile-fix.md
Normal file
7
.changeset/fn-5936-auto-merge-mobile-fix.md
Normal 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.
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user