FN-5751: fix dashboard auto-merge toggle race

Prevent rapid auto-merge toggles from desynchronizing dashboard settings state.

- Track auto-merge state in a ref so each toggle reads and writes the latest value
- Apply optimistic updates from the ref and roll back correctly on failed setting updates
- Add regression coverage for rapid double-toggle behavior in useAppSettings
- Add a patch changeset for @runfusion/fusion describing the dashboard blank-state fix

Files changed:
 .changeset/fn-5751-auto-merge-toggle.md            |  5 ++++
 .../app/hooks/__tests__/useAppSettings.test.ts     | 27 ++++++++++++++++++++++
 packages/dashboard/app/hooks/useAppSettings.ts     | 20 +++++++++++-----
 3 files changed, 46 insertions(+), 6 deletions(-)

Fusion-Task-Id: FN-5751

Fusion-Task-Lineage: 1a0090ff-4015-46b8-a974-3accbbdce919
This commit is contained in:
gsxdsm
2026-05-30 14:18:15 -07:00
parent 9c4e8edf39
commit bb0f693d79
3 changed files with 46 additions and 6 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fixes a dashboard regression where toggling the in-review Auto-merge switch could leave the UI in a broken/blank state until refresh. Auto-merge toggle state updates now remain consistent during rapid toggles, and regression coverage was added for the settings hook path.

View File

@@ -75,6 +75,33 @@ describe("useAppSettings", () => {
expect(mockUpdateSettings).toHaveBeenCalledWith({ autoMerge: true }, "proj_123"); expect(mockUpdateSettings).toHaveBeenCalledWith({ autoMerge: true }, "proj_123");
}); });
it("preserves consistent autoMerge state across rapid toggles", async () => {
const updateResolvers: Array<() => void> = [];
mockUpdateSettings.mockImplementation(
() => new Promise((resolve) => updateResolvers.push(() => resolve({} as never))),
);
const { result } = renderHook(() => useAppSettings("proj_123"));
await waitFor(() => {
expect(result.current.autoMerge).toBe(false);
});
await act(async () => {
const firstToggle = result.current.toggleAutoMerge();
const secondToggle = result.current.toggleAutoMerge();
expect(result.current.autoMerge).toBe(false);
updateResolvers.forEach((resolve) => resolve());
await Promise.all([firstToggle, secondToggle]);
});
expect(mockUpdateSettings).toHaveBeenNthCalledWith(1, { autoMerge: true }, "proj_123");
expect(mockUpdateSettings).toHaveBeenNthCalledWith(2, { autoMerge: false }, "proj_123");
expect(result.current.autoMerge).toBe(false);
});
it("rolls back optimistic state when toggle update fails", async () => { it("rolls back optimistic state when toggle update fails", async () => {
mockUpdateSettings.mockRejectedValueOnce(new Error("network")); mockUpdateSettings.mockRejectedValueOnce(new Error("network"));

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { fetchConfig, fetchSettings, updateSettings, updateGlobalSettings } from "../api"; import { fetchConfig, fetchSettings, updateSettings, updateGlobalSettings } from "../api";
import { setAutoReloadEnabled } from "../versionCheck"; import { setAutoReloadEnabled } from "../versionCheck";
@@ -63,6 +63,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
const [todosEnabled, setTodosEnabled] = useState(false); const [todosEnabled, setTodosEnabled] = useState(false);
const [goalsEnabled, setGoalsEnabled] = useState(false); const [goalsEnabled, setGoalsEnabled] = useState(false);
const [autoReloadOnVersionChange, setAutoReloadOnVersionChangeState] = useState(true); const [autoReloadOnVersionChange, setAutoReloadOnVersionChangeState] = useState(true);
const autoMergeRef = useRef(autoMerge);
/** /**
* Fetches config and settings from the backend and updates local state. * Fetches config and settings from the backend and updates local state.
@@ -124,16 +125,23 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
void refresh(); void refresh();
}, [refresh]); }, [refresh]);
useEffect(() => {
autoMergeRef.current = autoMerge;
}, [autoMerge]);
const toggleAutoMerge = useCallback(async () => { const toggleAutoMerge = useCallback(async () => {
const next = !autoMerge; const previousAutoMerge = autoMergeRef.current;
setAutoMerge(next); const nextAutoMerge = !previousAutoMerge;
autoMergeRef.current = nextAutoMerge;
setAutoMerge(nextAutoMerge);
try { try {
await updateSettings({ autoMerge: next }, projectId); await updateSettings({ autoMerge: nextAutoMerge }, projectId);
} catch { } catch {
setAutoMerge(!next); autoMergeRef.current = previousAutoMerge;
setAutoMerge(previousAutoMerge);
} }
}, [autoMerge, projectId]); }, [projectId]);
const toggleGlobalPause = useCallback(async () => { const toggleGlobalPause = useCallback(async () => {
const next = !globalPaused; const next = !globalPaused;