feat(site-builder): detect two-tab save conflicts; Reload latest / Keep mine modal

Refs #31

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-10-07 10:19:07 -07:00
1 parent a20c9bae28
commit a6db5438b2
6 files changed
+398 -13

No files matched your search

+232
View File
@@ -0,0 +1,232 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
/**
* Data-safety: a failed initial load must never let autosave overwrite the
* saved site with a blank canvas. Drives the real LoadGate + TopBar +
* SiteDesignPanel against a mocked `fetch` (the backend contract) and fake
* timers; only `@craftjs/core`'s useEditor and sibling chrome are stubbed.
*/
const undoMock = vi.fn();
const deserializeMock = vi.fn();
vi.mock('@craftjs/core', () => ({
useEditor: (collector?: (state: unknown, query: unknown) => Record<string, unknown>) => {
const query = {
serialize: () => '{}',
history: { canUndo: () => false, canRedo: () => false },
};
const actions = { history: { undo: undoMock, redo: vi.fn() }, deserialize: deserializeMock };
const collected = collector ? collector({}, query) : {};
return { actions, query, ...collected };
},
}));
vi.mock('../panels/topbar/TemplateModal', () => ({ TemplateModal: () => null }));
vi.mock('../panels/topbar/HeadCodeModal', () => ({ HeadCodeModal: () => null }));
vi.mock('../panels/sitesmith/SitesmithButton', () => ({ SitesmithButton: () => null }));
import { LoadGate } from './LoadGate';
import { TopBar } from '../panels/topbar/TopBar';
import { SiteDesignPanel } from '../panels/right/SiteDesignPanel';
import { EditorConfigProvider } from '../state/EditorConfigContext';
import { SaveGuardProvider, useSaveGuard } from '../state/SaveGuardContext';
import { PageProvider } from '../state/PageContext';
import { SiteDesignProvider } from '../state/SiteDesignContext';
import { WhpConfig } from '../types';
const whpConfig: WhpConfig = {
user: 'u', apiUrl: '/api', csrfToken: 't', siteId: 1,
siteDomain: 'example.com', siteName: 'Example', backUrl: '/', isRoot: false,
};
const jsonResp = (body: unknown, status = 200) => ({
ok: status >= 200 && status < 300,
status,
json: async () => body,
});
const reloadMock = vi.fn();
let container: HTMLDivElement;
let root: Root;
let fetchMock: ReturnType<typeof vi.fn>;
const saveCalls = () => fetchMock.mock.calls.filter(([u]) => String(u).includes('action=save'));
const saveBody = (i: number) => JSON.parse(saveCalls()[i][1].body);
const q = (sel: string) => container.querySelector(sel) as HTMLElement | null;
const flush = () => act(async () => { await Promise.resolve(); await Promise.resolve(); });
let guard: ReturnType<typeof useSaveGuard>;
const GuardProbe: React.FC = () => { guard = useSaveGuard(); return null; };
function mount() {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(
<EditorConfigProvider config={whpConfig}>
<SaveGuardProvider reloadPage={reloadMock}>
<SiteDesignProvider>
<PageProvider>
<LoadGate />
<TopBar device="desktop" onDeviceChange={() => {}} showGuides onToggleGuides={() => {}} />
<SiteDesignPanel />
<GuardProbe />
</PageProvider>
</SiteDesignProvider>
</SaveGuardProvider>
</EditorConfigProvider>,
);
});
}
beforeEach(() => {
vi.useFakeTimers();
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
vi.spyOn(console, 'error').mockImplementation(() => {});
undoMock.mockClear();
deserializeMock.mockReset();
});
afterEach(() => {
act(() => { root.unmount(); });
container.remove();
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
beforeEach(() => { reloadMock.mockReset(); });
const loadedThen = (rev: string | null, saveResponses: unknown[]) => {
const saves = [...saveResponses];
fetchMock.mockImplementation(async (url: string) =>
String(url).includes('action=load')
? jsonResp({ success: true, project: null, rev })
: (saves.shift() ?? jsonResp({ success: true })));
};
const clickSave = async () => {
await act(async () => { q('button[aria-label="Save Draft"]')!.click(); });
await flush();
};
const conflict409 = jsonResp({ success: false, error: 'conflict', message: 'm', current_rev: 'r9' }, 409);
describe('revision tracking', () => {
test('base_rev from load is sent, and rev updates after each successful save', async () => {
loadedThen('r1', [jsonResp({ success: true, rev: 'r2' }), jsonResp({ success: true, rev: 'r3' })]);
mount();
await flush();
await clickSave();
await clickSave();
expect(saveBody(0).base_rev).toBe('r1');
expect(saveBody(1).base_rev).toBe('r2');
expect(saveBody(0)._rev).toBeUndefined();
});
test('a project-less site sends base_rev: null (key present)', async () => {
loadedThen(null, []);
mount();
await flush();
await clickSave();
expect('base_rev' in saveBody(0)).toBe(true);
expect(saveBody(0).base_rev).toBeNull();
});
test('autosave sends base_rev too', async () => {
loadedThen('r1', []);
mount();
await flush();
act(() => { guard.markDirty(); });
await act(async () => { vi.advanceTimersByTime(30000); });
expect(saveBody(0).base_rev).toBe('r1');
});
test('a load without rev (older backend) sends no base_rev', async () => {
fetchMock.mockImplementation(async (url: string) =>
jsonResp(String(url).includes('action=load') ? { success: true, project: null } : { success: true }));
mount();
await flush();
await clickSave();
expect('base_rev' in saveBody(0)).toBe(false);
});
});
describe('conflict modal', () => {
test('a 409 conflict opens the modal (not the toast) and pauses autosave', async () => {
loadedThen('r1', [conflict409]);
mount();
await flush();
await clickSave();
expect(q('[data-testid="conflict-modal"]')!.textContent).toContain(
'This site was changed in another tab or device since you opened it.',
);
expect(q('[data-testid="error-notice"]')).toBeNull();
act(() => { guard.markDirty(); });
await act(async () => { vi.advanceTimersByTime(90000); });
expect(saveCalls()).toHaveLength(1);
});
test('Keep mine re-saves with force_overwrite, stores the new rev and closes the modal', async () => {
loadedThen('r1', [conflict409, jsonResp({ success: true, rev: 'r10' }), jsonResp({ success: true })]);
mount();
await flush();
await clickSave();
await act(async () => { q('[data-testid="conflict-keep-mine"]')!.click(); });
await flush();
expect(saveBody(0).force_overwrite).toBeUndefined();
expect(saveBody(1).force_overwrite).toBe(true);
expect(q('[data-testid="conflict-modal"]')).toBeNull();
await clickSave();
expect(saveBody(2).base_rev).toBe('r10');
expect(saveBody(2).force_overwrite).toBeUndefined();
});
test('Reload latest suppresses the unload prompt, reloads, and sends no save', async () => {
loadedThen('r1', [conflict409]);
mount();
await flush();
await clickSave();
const prevented: boolean[] = [];
act(() => { guard.markDirty(); });
act(() => { q('[data-testid="conflict-reload"]')!.click(); });
const ev = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(ev);
prevented.push(ev.defaultPrevented);
expect(reloadMock).toHaveBeenCalledTimes(1);
expect(prevented).toEqual([false]);
expect(saveCalls()).toHaveLength(1);
});
test('a conflict inside publish opens the modal and never publishes', async () => {
loadedThen('r1', [conflict409]);
mount();
await flush();
await act(async () => { q('button[aria-label="Publish to live site"]')!.click(); });
await flush();
expect(q('[data-testid="conflict-modal"]')).not.toBeNull();
expect(fetchMock.mock.calls.filter(([u]) => String(u).includes('action=publish'))).toHaveLength(0);
expect(q('[data-testid="error-notice"]')).toBeNull();
});
test('Keep mine refused as empty falls to the banner, and Save anyway still overwrites', async () => {
loadedThen('r1', [
conflict409,
jsonResp({ success: false, error: 'empty_overwrite_refused', message: 'e' }, 409),
jsonResp({ success: true, rev: 'r5' }),
]);
mount();
await flush();
await clickSave();
await act(async () => { q('[data-testid="conflict-keep-mine"]')!.click(); });
await flush();
expect(q('[data-testid="conflict-modal"]')).toBeNull();
expect(q('[data-testid="empty-save-banner"]')).not.toBeNull();
await act(async () => { q('[data-testid="empty-save-anyway"]')!.click(); });
await flush();
expect(saveBody(2).force_overwrite).toBe(true);
expect(saveBody(2).force_empty).toBe(true);
});
});
+28 -5
View File
@@ -277,6 +277,8 @@ async function readApiResult(resp: Response, action: 'Save' | 'Publish'): Promis
export interface SaveOptions {
forceEmpty?: boolean;
/** "Keep mine": bypass the another-tab conflict check. */
forceOverwrite?: boolean;
}
export function useWhpApi() {
@@ -296,7 +298,10 @@ export function useWhpApi() {
} = usePages();
const { design, updateDesign } = useSiteDesign();
const { loadState, forceEmptyNextSave, beginSave, endSave } = useSaveGuard();
const {
loadState, forceEmptyNextSave, beginSave, endSave,
getRev, setRev, setConflict, forceOverwriteNextSave,
} = useSaveGuard();
const save = useCallback(async (opts?: SaveOptions): Promise<ApiResult | null> => {
if (!isWHP || !whpConfig) return null;
@@ -314,7 +319,9 @@ export function useWhpApi() {
// isEditingFooter).
const liveCraftState = query.serialize();
const baseRev = getRev();
const forceEmpty = !!opts?.forceEmpty || forceEmptyNextSave.current;
const forceOverwrite = !!opts?.forceOverwrite || forceOverwriteNextSave.current;
const payload = buildSavePayload({
siteId: whpConfig.siteId,
@@ -336,7 +343,14 @@ export function useWhpApi() {
'Content-Type': 'application/json',
'X-CSRF-Token': whpConfig.csrfToken,
},
body: JSON.stringify(forceEmpty ? { ...payload, force_empty: true } : payload),
body: JSON.stringify({
...payload,
// Present on every save so the server can detect a newer copy; left
// out only when load never reported a rev (an older backend).
...(baseRev !== undefined ? { base_rev: baseRev } : {}),
...(forceEmpty ? { force_empty: true } : {}),
...(forceOverwrite ? { force_overwrite: true } : {}),
}),
});
// 409 empty_overwrite_refused is an expected outcome the UI handles, so
@@ -350,20 +364,25 @@ export function useWhpApi() {
message: body.message || 'This would replace your saved site with an empty page.',
};
}
return body ?? { success: false, error: 'conflict' };
// Opens the conflict modal (here, so the save inside publish raises it too).
const message = body?.message || 'This site was changed in another tab or device since you opened it.';
setConflict(message);
return { ...(body ?? {}), success: false, error: 'conflict', message };
}
const result = await readApiResult(resp, 'Save');
// The one-shot force_empty is spent only once a save actually went through.
if (result.success) {
if (forceEmpty) forceEmptyNextSave.current = false;
if (forceOverwrite) forceOverwriteNextSave.current = false;
if (typeof result.rev === 'string') setRev(result.rev);
succeeded = true;
}
return result;
} finally {
endSave(ticket, succeeded);
}
}, [isWHP, whpConfig, loadState, forceEmptyNextSave, beginSave, endSave, query, pages, activePageId, headerPage, footerPage, isEditingHeader, isEditingFooter, design]);
}, [isWHP, whpConfig, loadState, forceEmptyNextSave, forceOverwriteNextSave, getRev, setRev, setConflict, beginSave, endSave, query, pages, activePageId, headerPage, footerPage, isEditingHeader, isEditingFooter, design]);
const publish = useCallback(async (): Promise<ApiResult | null> => {
if (!isWHP || !whpConfig) return null;
@@ -420,6 +439,10 @@ export function useWhpApi() {
throw new Error(`Load failed: ${data?.message || data?.error || 'server reported failure'}`);
}
// The baseline for every later save's conflict check. proj._rev (inside
// the project) is ignored: buildSavePayload never echoes it back.
if (typeof data.rev === 'string' || data.rev === null) setRev(data.rev);
// project: null is a success (brand-new site, nothing saved yet).
if (data.project) {
const proj = data.project;
@@ -514,7 +537,7 @@ export function useWhpApi() {
}
}
return data;
}, [isWHP, whpConfig, actions, setHeaderCraftState, setFooterCraftState, setPagesCraftState, setActivePageIdDirect, isEditingHeader, isEditingFooter, updateDesign]);
}, [isWHP, whpConfig, actions, setHeaderCraftState, setFooterCraftState, setPagesCraftState, setActivePageIdDirect, isEditingHeader, isEditingFooter, updateDesign, setRev]);
const uploadAsset = useCallback(
async (file: File) => {
+30
View File
@@ -0,0 +1,30 @@
import React from 'react';
export interface ConflictModalProps {
/** Why the save was refused (the server's message); a fixed fallback when empty. */
message?: string | null;
/** True while "Keep mine" is re-saving. */
busy: boolean;
/** Set when "Keep mine" failed, so the customer sees why the modal is still up. */
error?: string | null;
onReload: () => void;
onKeepMine: () => void;
}
/** Blocking choice shown when the server holds a newer copy of the site than
* the one this editor opened (another tab or device saved). Autosave stays
* paused while it's up; there is deliberately no dismiss. */
export const ConflictModal: React.FC<ConflictModalProps> = ({ busy, error, onReload, onKeepMine }) => (
<div className="conflict-backdrop" data-testid="conflict-modal">
<div className="conflict-modal" role="alertdialog" aria-modal="true" aria-labelledby="conflict-modal-title">
<h3 id="conflict-modal-title">Site changed elsewhere</h3>
<p>This site was changed in another tab or device since you opened it.</p>
<p>Reload latest discards your changes here and loads the newer copy. Keep mine overwrites the newer copy with what you have now.</p>
{error && <p className="conflict-modal-error" role="alert" data-testid="conflict-error">{error}</p>}
<div className="conflict-modal-actions">
<button type="button" onClick={onReload} disabled={busy} data-testid="conflict-reload">Reload latest</button>
<button type="button" onClick={onKeepMine} disabled={busy} data-testid="conflict-keep-mine">Keep mine</button>
</div>
</div>
</div>
);
+37 -6
View File
@@ -13,6 +13,7 @@ import { ReportIssueModal } from './ReportIssueModal';
import { TopBarOverflowMenu } from './TopBarOverflowMenu';
import { PublishWarnings } from './PublishWarnings';
import { EmptySaveBanner } from './EmptySaveBanner';
import { ConflictModal } from './ConflictModal';
import { ErrorNotice } from './ErrorNotice';
import { SaveStatusPill, SaveStatus } from './SaveStatusPill';
import { useSaveGuard } from '../../state/SaveGuardContext';
@@ -34,7 +35,10 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
canRedo: query.history.canRedo(),
}));
const { save, publish } = useWhpApi();
const { loadState, emptyRefusal, setEmptyRefusal, dirty, isDirty, getVersion } = useSaveGuard();
const {
loadState, emptyRefusal, setEmptyRefusal, dirty, isDirty, getVersion,
conflict, setConflict, armForceOverwriteNextSave, reloadLatest,
} = useSaveGuard();
const canSave = loadState === 'loaded';
const { headerPage, footerPage, pages, activePageId } = usePages();
const { design } = useSiteDesign();
@@ -80,9 +84,13 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
setSaving(true);
try {
const result = await save(opts);
if (result?.error === 'empty_overwrite_refused') {
if (result?.error === 'conflict') {
// The conflict modal (opened by save()) owns this outcome, not the error toast.
} else if (result?.error === 'empty_overwrite_refused') {
setConflict(null);
setEmptyRefusal(result.message ?? null);
} else if (result?.success) {
setConflict(null);
setEmptyRefusal(null);
setSaveFailed(false);
setErrorNotice(null);
@@ -99,18 +107,18 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
savingRef.current = false;
setSaving(false);
}
}, [save, canSave, isDirty, setEmptyRefusal]);
}, [save, canSave, isDirty, setEmptyRefusal, setConflict]);
// Auto-save every 30 seconds
useEffect(() => {
// Paused until the site has loaded, and while the empty-save banner is up.
if (!isWHP || !canSave || emptyRefusal) return;
if (!isWHP || !canSave || emptyRefusal || conflict) return;
// A running publish does its own save; a second one would race it.
// Read through refs/isDirty() at tick time, never a stale closure.
const autosaveDue = () => isDirty() && !publishingRef.current;
const interval = setInterval(() => { if (autosaveDue()) void runSave(); }, 30000);
return () => clearInterval(interval);
}, [isWHP, canSave, emptyRefusal, runSave, isDirty]);
}, [isWHP, canSave, emptyRefusal, conflict, runSave, isDirty]);
const handleSave = runSave;
@@ -123,7 +131,10 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
setPublishWarnings([]);
try {
const result = await publish();
if (result?.error === 'empty_overwrite_refused') {
if (result?.error === 'conflict') {
// Nothing was published: the conflict modal (opened by save()) takes over.
setSaveFailed(true);
} else if (result?.error === 'empty_overwrite_refused') {
setEmptyRefusal(result.message ?? null);
} else if (result?.success) {
const unchanged = getVersion() === versionAtStart;
@@ -151,6 +162,24 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
}
}, [publish, canSave, getVersion, setEmptyRefusal]);
// "Keep mine": arm force_overwrite so it survives an empty-overwrite refusal
// followed by "Save anyway", then re-save. runSave closes the modal on success.
const handleKeepMine = useCallback(async () => {
setErrorNotice(null);
armForceOverwriteNextSave();
await runSave();
}, [armForceOverwriteNextSave, runSave]);
const conflictModal = conflict ? (
<ConflictModal
message={conflict}
busy={saving}
error={errorNotice}
onReload={reloadLatest}
onKeepMine={() => { void handleKeepMine(); }}
/>
) : null;
const emptyBanner = emptyRefusal ? (
<EmptySaveBanner
onSaveAnyway={() => { void handleSave({ forceEmpty: true }); }}
@@ -252,6 +281,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
<nav className="topbar topbar-mobile">
<PublishWarnings warnings={publishWarnings} onDismiss={() => setPublishWarnings([])} />
{emptyBanner}
{conflictModal}
<ErrorNotice message={errorNotice} onDismiss={() => setErrorNotice(null)} />
<div className="topbar-left">
{isWHP && (
@@ -346,6 +376,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
<nav className="topbar">
<PublishWarnings warnings={publishWarnings} onDismiss={() => setPublishWarnings([])} />
{emptyBanner}
{conflictModal}
<ErrorNotice message={errorNotice} onDismiss={() => setErrorNotice(null)} />
<div className="topbar-left">
{isWHP && (
+38 -2
View File
@@ -32,6 +32,18 @@ interface SaveGuardContextValue {
saving: boolean;
/** Skip the unsaved-changes prompt for the next unload (e.g. a deliberate "Reload latest"). */
suppressBeforeUnload: () => void;
/** Server revision the editor last loaded or saved: a string, null (no project on the
* server yet), or undefined (unknown -- an older backend sent none, so saves skip the check). */
getRev: () => string | null | undefined;
setRev: (rev: string | null) => void;
/** Server message while the "changed in another tab" modal is up; autosave stays paused. */
conflict: string | null;
setConflict: (message: string | null) => void;
/** One-shot: the next save sends force_overwrite (armed by "Keep mine", spent by a save that goes through). */
forceOverwriteNextSave: React.MutableRefObject<boolean>;
armForceOverwriteNextSave: () => void;
/** "Reload latest": skip the unsaved-changes prompt, then reload the whole editor. */
reloadLatest: () => void;
}
/** Identifies the editor state a save was built from. */
@@ -56,17 +68,29 @@ const defaultValue: SaveGuardContextValue = {
endSave: () => {},
saving: false,
suppressBeforeUnload: () => {},
getRev: () => undefined,
setRev: () => {},
conflict: null,
setConflict: () => {},
forceOverwriteNextSave: { current: false },
armForceOverwriteNextSave: () => {},
reloadLatest: () => {},
};
const SaveGuardContext = createContext<SaveGuardContextValue>(defaultValue);
export const useSaveGuard = () => useContext(SaveGuardContext);
export const SaveGuardProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
export const SaveGuardProvider: React.FC<{ children: ReactNode; /** Injectable for tests; defaults to a full page reload. */ reloadPage?: () => void }> = ({ children, reloadPage }) => {
const { isWHP } = useEditorConfig();
const [loadState, setLoadState] = useState<LoadState>(isWHP ? 'loading' : 'loaded');
const [emptyRefusal, setEmptyRefusal] = useState<string | null>(null);
const forceEmptyNextSave = useRef(false);
const [conflict, setConflict] = useState<string | null>(null);
const forceOverwriteNextSave = useRef(false);
const revRef = useRef<string | null | undefined>(undefined);
const getRev = useCallback(() => revRef.current, []);
const setRev = useCallback((rev: string | null) => { revRef.current = rev; }, []);
// Dirty tracking. `versionRef` counts changes so a save that raced with an
// edit cannot mark that edit clean.
@@ -107,6 +131,11 @@ export const SaveGuardProvider: React.FC<{ children: ReactNode }> = ({ children
}, []);
const suppressBeforeUnload = useCallback(() => { suppressRef.current = true; }, []);
const reloadLatest = useCallback(() => {
suppressRef.current = true;
(reloadPage ?? (() => window.location.reload()))();
}, [reloadPage]);
const saving = savesInFlight > 0;
const needsPrompt = isWHP && (dirty || saving);
useEffect(() => {
@@ -135,7 +164,14 @@ export const SaveGuardProvider: React.FC<{ children: ReactNode }> = ({ children
endSave,
saving,
suppressBeforeUnload,
}), [loadState, emptyRefusal, dirty, saving, isDirty, getVersion, markDirty, beginSave, endSave, suppressBeforeUnload]);
getRev,
setRev,
conflict,
setConflict,
forceOverwriteNextSave,
armForceOverwriteNextSave: () => { forceOverwriteNextSave.current = true; },
reloadLatest,
}), [loadState, emptyRefusal, dirty, saving, isDirty, getVersion, markDirty, beginSave, endSave, suppressBeforeUnload, getRev, setRev, conflict, reloadLatest]);
return <SaveGuardContext.Provider value={value}>{children}</SaveGuardContext.Provider>;
};
+33
View File
@@ -2020,3 +2020,36 @@ body {
cursor: pointer;
padding: 0 4px;
}
.conflict-backdrop {
position: fixed;
inset: 0;
z-index: var(--z-modal);
display: flex;
padding: 16px;
background: rgba(15, 23, 42, 0.7);
}
/* Another-tab conflict modal */
.conflict-modal {
max-width: 420px;
margin: auto;
padding: 20px;
background: #1e293b;
border: 1px solid #475569;
border-radius: 8px;
color: #e2e8f0;
font-size: 14px;
}
.conflict-modal h3 { margin: 0 0 8px; font-size: 16px; }
.conflict-modal p { margin: 0 0 8px; }
.conflict-modal-error { color: #fecaca; }
.conflict-modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 12px; }
.conflict-modal-actions button {
background: none;
border: 1px solid #94a3b8;
border-radius: 4px;
color: inherit;
cursor: pointer;
padding: 4px 12px;
}
.conflict-modal-actions button:disabled { opacity: 0.5; cursor: default; }