FN-5817: prevent blank dashboard after auto-merge toggle

Ensure viewport mode updates stay in sync so toggling auto-merge no longer blanks the dashboard.

- update useViewportMode to initialize safely and react consistently to viewport/media-query changes
- add focused hook coverage for initial mode detection and transition behavior
- add dashboard/app regression tests to confirm the board remains rendered after auto-merge toggles
- add a changeset for @runfusion/fusion patch release

Files changed:
 .changeset/fn-5817-auto-merge-toggle-mobile.md     |  5 ++
 .../app/components/__tests__/App.test.tsx          | 66 ++++++++++++++++++++++
 .../__tests__/board-mobile-initial-render.test.tsx | 30 ++++++++++
 .../app/hooks/__tests__/useViewportMode.test.ts    | 34 +++++++++++
 packages/dashboard/app/hooks/useViewportMode.ts    | 28 +++++++--
 5 files changed, 159 insertions(+), 4 deletions(-)

Fusion-Task-Id: FN-5817

Fusion-Task-Lineage: 2fc044b1-5f37-44de-b70e-c60364a3d69c
This commit is contained in:
gsxdsm
2026-06-01 01:30:34 -07:00
parent edc5add852
commit 1aef3c9899
5 changed files with 159 additions and 4 deletions

View File

@@ -4127,6 +4127,72 @@ describe("App board branch filters", () => {
});
});
describe("FN-5817 mobile auto-merge toggle stability", () => {
it("keeps app shell mounted when toggling auto-merge on mobile", async () => {
mockUseViewportMode.mockReturnValue("mobile");
const updateSettingsSpy = vi.mocked(updateSettings);
updateSettingsSpy.mockResolvedValue({ ...defaultSettings, autoMerge: false });
mockUseTasks.mockImplementation(() => ({
tasks: [
{
id: "FN-5817",
title: "In review task",
description: "Regression task",
column: "in-review",
status: "in-review",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "",
updatedAt: "",
},
],
isStale: false,
createTask: mockCreateTask,
moveTask: vi.fn(),
pauseTask: vi.fn(),
unpauseTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
retryTask: vi.fn(),
resetTask: vi.fn(),
updateTask: vi.fn(),
duplicateTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
archiveAllDone: vi.fn(),
loadArchivedTasks: vi.fn(),
refreshTasks: vi.fn(),
ingestCreatedTasks: vi.fn(),
lastFetchTimeMs: Date.now(),
}));
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
render(<App />);
const toggle = await screen.findByRole("checkbox", { name: "Auto-merge" });
expect(screen.getByTestId("mobile-view-toggle")).toBeInTheDocument();
fireEvent.click(toggle);
await waitFor(() => {
expect(updateSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({ autoMerge: expect.any(Boolean) }),
DEFAULT_PROJECT_ID,
);
expect(screen.getByTestId("mobile-view-toggle")).toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: "Auto-merge" })).toBeInTheDocument();
});
expect(consoleErrorSpy).not.toHaveBeenCalled();
consoleErrorSpy.mockRestore();
});
});
describe("App shell connection status plumbing", () => {
it("loads shell connection status for native shell host", async () => {
mockShellHostContextValue.host = { kind: "desktop-shell", mode: "remote", connectionId: "p1", serverUrl: "https://fusion.example.com" };

View File

@@ -141,6 +141,36 @@ describe("Board mobile initial render stabilization (FN-4574)", () => {
viewportSpy.mockRestore();
});
it("does not throw when visualViewport resize listener lacks removeEventListener (Android seam)", () => {
const viewportSpy = mockViewport(375);
const visualViewportResizeListeners: Array<() => void> = [];
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
setTimeout(() => cb(0), 0);
return 1;
});
vi.stubGlobal("cancelAnimationFrame", vi.fn());
Object.defineProperty(window, "visualViewport", {
configurable: true,
value: {
scale: 1,
addEventListener: (_event: string, listener: () => void) => {
visualViewportResizeListeners.push(listener);
},
},
});
render(<Board {...boardProps} />);
expect(visualViewportResizeListeners).toHaveLength(1);
expect(() => {
visualViewportResizeListeners[0]();
}).not.toThrow();
viewportSpy.mockRestore();
});
it("is a desktop no-op and does not force pageshow re-anchor", () => {
const viewportSpy = mockViewport(1280);
const addEventListenerSpy = vi.spyOn(window, "addEventListener");

View File

@@ -0,0 +1,34 @@
import { renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useViewportMode } from "../useViewportMode";
describe("useViewportMode", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("supports legacy MediaQueryList listeners without runtime errors", () => {
const listeners: Array<() => void> = [];
const removeListener = vi.fn((listener: () => void) => {
const index = listeners.indexOf(listener);
if (index >= 0) listeners.splice(index, 1);
});
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.stubGlobal(
"matchMedia",
vi.fn((query: string) => ({
matches: query === "(max-width: 768px)",
media: query,
onchange: null,
addListener: (listener: () => void) => listeners.push(listener),
removeListener,
})),
);
renderHook(() => useViewportMode());
expect(consoleErrorSpy).not.toHaveBeenCalled();
consoleErrorSpy.mockRestore();
});
});

View File

@@ -34,11 +34,31 @@ export function useViewportMode(): ViewportMode {
}
};
mobileQuery.addEventListener("change", updateMode);
tabletQuery.addEventListener("change", updateMode);
const addChangeListener = (query: MediaQueryList, listener: () => void) => {
if (typeof query.addEventListener === "function") {
query.addEventListener("change", listener);
return;
}
if (typeof query.addListener === "function") {
query.addListener(listener);
}
};
const removeChangeListener = (query: MediaQueryList, listener: () => void) => {
if (typeof query.removeEventListener === "function") {
query.removeEventListener("change", listener);
return;
}
if (typeof query.removeListener === "function") {
query.removeListener(listener);
}
};
addChangeListener(mobileQuery, updateMode);
addChangeListener(tabletQuery, updateMode);
return () => {
mobileQuery.removeEventListener("change", updateMode);
tabletQuery.removeEventListener("change", updateMode);
removeChangeListener(mobileQuery, updateMode);
removeChangeListener(tabletQuery, updateMode);
};
}, []);