fix(settings): clear stale pending and unsaved indicators after save (#1370)

* fix(settings): clear stale pending and unsaved indicators after save

Settings sections held their saved baseline in a mutable ref and computed
the dirty count with useMemo keyed on the live values, so updating the ref
on a successful save never re-ran the calculation. The masthead pending
count and the sidebar unsaved dot stayed stale until the section remounted,
making operators think the save had failed.

Move the baseline into state behind a shared useSettingsDirty hook with
separate load (reset) and save-success (markSaved) operations. markSaved
adopts the submitted snapshot as the baseline only, so an edit made while a
save is in flight survives and a failed save stays dirty and retryable.
Migrate the five sections that used the pattern.

* test(settings): await the save-failure retry assertion to avoid a race

In the failed-save reconcile test, wait for the Save button to re-enable
after the PATCH settles instead of asserting synchronously, so the retry
check cannot race the isSaving reset.
This commit is contained in:
Anso
2026-06-14 13:42:01 -04:00
committed by GitHub
parent 3c116466d9
commit 49f1b49ac6
8 changed files with 388 additions and 88 deletions
@@ -1,4 +1,4 @@
import { useState, useRef, useEffect } from 'react';
import { useState, useEffect } from 'react';
import { TogglePill } from '@/components/ui/toggle-pill';
import { Skeleton } from '@/components/ui/skeleton';
import { useAuth } from '@/context/AuthContext';
@@ -14,6 +14,7 @@ import { SettingsSection } from './SettingsSection';
import { SettingsField } from './SettingsField';
import { SettingsActions, SettingsPrimaryButton } from './SettingsActions';
import { useMastheadStats } from './MastheadStatsContext';
import { useSettingsDirty } from './useSettingsDirty';
interface DeveloperSectionProps {
onDirtyChange?: (dirty: boolean) => void;
@@ -37,13 +38,10 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
const { isAdmin } = useAuth();
const { activeNode } = useNodes();
const readOnly = !isAdmin;
const [settings, setSettings] = useState<DeveloperFields>({ ...DEFAULT_DEVELOPER });
const serverSettingsRef = useRef<DeveloperFields>({ ...DEFAULT_DEVELOPER });
const { settings, setSettings, hasChanges, reset, markSaved } = useSettingsDirty<DeveloperFields>({ ...DEFAULT_DEVELOPER });
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const hasChanges = settings.developer_mode !== serverSettingsRef.current.developer_mode;
useEffect(() => {
onDirtyChange?.(hasChanges);
}, [hasChanges, onDirtyChange]);
@@ -69,8 +67,7 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
const safe: DeveloperFields = {
developer_mode: (nodeData.developer_mode as '0' | '1') ?? DEFAULT_SETTINGS.developer_mode,
};
setSettings(safe);
serverSettingsRef.current = { ...safe };
reset(safe);
} catch (e) {
console.error('Failed to fetch developer settings', e);
} finally {
@@ -86,8 +83,9 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
};
const saveSettings = async () => {
const submitted = { ...settings };
const payload = {
developer_mode: settings.developer_mode,
developer_mode: submitted.developer_mode,
};
setIsSaving(true);
try {
@@ -100,7 +98,7 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
toast.error(err?.error || err?.message || 'Failed to save settings.');
return;
}
serverSettingsRef.current = { ...settings };
markSaved(submitted);
toast.success('Developer settings saved.');
window.dispatchEvent(new CustomEvent<SenchoSettingsChangedDetail>(SENCHO_SETTINGS_CHANGED, {
detail: { changedKeys: Object.keys(payload) },