fix(settings): toast and block save when node settings fail to load (#1654)

* fix(settings): toast and block save when node settings fail to load

Failed GET /settings no longer seeds defaults as the dirty baseline. Shared
loader pins node identity, shows an error callout until an authoritative load
succeeds, and keeps Save disabled across all eight node-scoped sections.

* test(settings): cover load-failure toast and App Store refresh pin

Remove the incorrect settings-load-failure docs image. Assert toast ownership for current vs stale/aborted loads, and pin App Store cache refresh to the PATCH node across a mid-save switch.

* fix(settings): reject malformed settings bodies and skip pre-node bootstrap load

Treat non-object 200 /settings payloads as load failures so null or array bodies cannot seed editable defaults. Do not fetch or toast while the active node id is still undefined, so hard refresh settles to a single failure toast.
This commit is contained in:
Anso
2026-07-20 21:40:35 -04:00
committed by GitHub
parent 090a0d73ac
commit ad00517a0e
15 changed files with 1086 additions and 217 deletions
@@ -15,6 +15,8 @@ import { SettingsField } from './SettingsField';
import { SettingsActions, SettingsPrimaryButton } from './SettingsActions';
import { useMastheadStats } from './MastheadStatsContext';
import { useSettingsDirty } from './useSettingsDirty';
import { useNodeSettingsLoad } from './useNodeSettingsLoad';
import { SettingsLoadGate } from './SettingsLoadError';
interface DeveloperSectionProps {
onDirtyChange?: (dirty: boolean) => void;
@@ -39,15 +41,17 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
const { activeNode } = useNodes();
const readOnly = !isAdmin;
const { settings, setSettings, hasChanges, reset, markSaved } = useSettingsDirty<DeveloperFields>({ ...DEFAULT_DEVELOPER });
const [isLoading, setIsLoading] = useState(false);
const { phase, isCurrentNodeLoaded, load, isSaveOwner, captureSaveGuard } = useNodeSettingsLoad(activeNode?.id);
const [isSaving, setIsSaving] = useState(false);
const reportDirty = isCurrentNodeLoaded && hasChanges;
useEffect(() => {
onDirtyChange?.(hasChanges);
}, [hasChanges, onDirtyChange]);
onDirtyChange?.(reportDirty);
}, [reportDirty, onDirtyChange]);
useMastheadStats(
isLoading
!isCurrentNodeLoaded
? null
: [
{
@@ -59,30 +63,27 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
);
useEffect(() => {
const fetchSettings = async () => {
setIsLoading(true);
try {
const nodeRes = await apiFetch('/settings');
const nodeData: Record<string, string> = nodeRes.ok ? await nodeRes.json() : {};
const safe: DeveloperFields = {
developer_mode: (nodeData.developer_mode as '0' | '1') ?? DEFAULT_SETTINGS.developer_mode,
};
reset(safe);
} catch (e) {
console.error('Failed to fetch developer settings', e);
} finally {
setIsLoading(false);
}
let cancelled = false;
setIsSaving(false);
void (async () => {
const nodeData = await load();
if (cancelled || !nodeData) return;
const safe: DeveloperFields = {
developer_mode: (nodeData.developer_mode as '0' | '1') ?? DEFAULT_SETTINGS.developer_mode,
};
reset(safe);
})();
return () => {
cancelled = true;
};
fetchSettings();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeNode?.id]);
}, [activeNode?.id, load, reset]);
const onSettingChange = <K extends keyof DeveloperFields>(key: K, value: DeveloperFields[K]) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
const saveSettings = async () => {
const saveGuard = captureSaveGuard();
const submitted = { ...settings };
const payload = {
developer_mode: submitted.developer_mode,
@@ -91,8 +92,10 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
try {
const res = await apiFetch('/settings', {
method: 'PATCH',
nodeId: saveGuard.nodeId,
body: JSON.stringify(payload),
});
if (!isSaveOwner(saveGuard)) return;
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to save settings.');
@@ -104,15 +107,15 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
detail: { changedKeys: Object.keys(payload) },
}));
} catch (e: unknown) {
if (!isSaveOwner(saveGuard)) return;
toast.error((e as Error)?.message || 'Something went wrong.');
} finally {
setIsSaving(false);
if (isSaveOwner(saveGuard)) setIsSaving(false);
}
};
if (isLoading) return <SectionSkeleton />;
return (
<SettingsLoadGate phase={phase} isCurrentNodeLoaded={isCurrentNodeLoaded} skeleton={<SectionSkeleton />}>
<fieldset disabled={readOnly} className="m-0 flex min-w-0 flex-col gap-10 border-0 p-0">
<SettingsSection title="Diagnostics">
<SettingsField
@@ -129,7 +132,7 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? 'unsaved changes' : undefined)}>
{!readOnly && (
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges}>
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges || !isCurrentNodeLoaded}>
{isSaving ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
@@ -142,5 +145,6 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
)}
</SettingsActions>
</fieldset>
</SettingsLoadGate>
);
}