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:
5
.changeset/fn-5751-auto-merge-toggle.md
Normal file
5
.changeset/fn-5751-auto-merge-toggle.md
Normal 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.
|
||||||
@@ -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"));
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user