diff --git a/craft/src/hooks/useWhpApi.export-cache.test.ts b/craft/src/hooks/useWhpApi.export-cache.test.ts
index 4c2db7d..7c8d645 100644
--- a/craft/src/hooks/useWhpApi.export-cache.test.ts
+++ b/craft/src/hooks/useWhpApi.export-cache.test.ts
@@ -2,7 +2,8 @@ import { describe, test, expect, vi, beforeEach } from 'vitest';
vi.mock('../utils/html-export', () => ({
exportBodyHtml: vi.fn((state: string) => {
- if (state.startsWith('BAD')) throw new Error('export boom');
+ // Like the real exportBodyHtml: failures are swallowed into an empty result.
+ if (state.startsWith('BAD')) return { html: '', css: '' };
return { html: `
${state}
`, css: `/*${state}*/` };
}),
}));
diff --git a/craft/src/hooks/useWhpApi.ts b/craft/src/hooks/useWhpApi.ts
index 873c65d..17b7041 100644
--- a/craft/src/hooks/useWhpApi.ts
+++ b/craft/src/hooks/useWhpApi.ts
@@ -80,7 +80,13 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
const hit = exportCache.get(state);
if (hit) return hit;
const result = exportBodyHtml(state);
- exportCache.set(state, result);
+ // exportBodyHtml swallows its own errors and returns empty html; a
+ // non-empty state yielding '' is a failed export, so retry it next build.
+ if (result.html === '' && state !== '') {
+ console.error('HTML export produced no output; not caching, will retry next save.');
+ } else {
+ exportCache.set(state, result);
+ }
return result;
};
diff --git a/craft/src/panels/topbar/TopBar.autosave-gate.test.tsx b/craft/src/panels/topbar/TopBar.autosave-gate.test.tsx
new file mode 100644
index 0000000..b2fc167
--- /dev/null
+++ b/craft/src/panels/topbar/TopBar.autosave-gate.test.tsx
@@ -0,0 +1,105 @@
+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';
+
+/**
+ * Autosave must not depend on isDirty() alone: with a context that reports
+ * dirty but a load that failed, the load gate has to hold on its own.
+ * (A real provider can't produce that state, since markDirty is ignored
+ * until loaded, so the guard is stubbed.)
+ */
+vi.mock('@craftjs/core', () => ({
+ useEditor: (collector?: (state: unknown, query: unknown) => Record) => {
+ const query = { serialize: () => '{}', history: { canUndo: () => false, canRedo: () => false } };
+ const actions = { history: { undo: vi.fn(), redo: vi.fn() }, deserialize: vi.fn() };
+ return { actions, query, ...(collector ? collector({}, query) : {}) };
+ },
+}));
+vi.mock('./TemplateModal', () => ({ TemplateModal: () => null }));
+vi.mock('./HeadCodeModal', () => ({ HeadCodeModal: () => null }));
+vi.mock('../sitesmith/SitesmithButton', () => ({ SitesmithButton: () => null }));
+
+// save() is stubbed (the real one also refuses before load) so this test
+// exercises TopBar's own gate, not the API hook's.
+const saveMock = vi.hoisted(() => vi.fn());
+vi.mock('../../hooks/useWhpApi', () => ({
+ useWhpApi: () => ({
+ save: saveMock, publish: vi.fn(), load: vi.fn(), uploadAsset: vi.fn(), isWHP: true,
+ }),
+}));
+
+const guardState = vi.hoisted(() => ({ loadState: 'failed' as 'failed' | 'loaded' }));
+vi.mock('../../state/SaveGuardContext', () => ({
+ useSaveGuard: () => ({
+ loadState: guardState.loadState,
+ setLoadState: () => {},
+ emptyRefusal: null,
+ setEmptyRefusal: () => {},
+ forceEmptyNextSave: { current: false },
+ armForceEmptyNextSave: () => {},
+ dirty: true,
+ isDirty: () => true,
+ getVersion: () => 1,
+ markDirty: () => {},
+ beginSave: () => ({ version: 1 }),
+ endSave: () => {},
+ saving: false,
+ suppressBeforeUnload: () => {},
+ }),
+}));
+
+import { TopBar } from './TopBar';
+import { EditorConfigProvider } from '../../state/EditorConfigContext';
+import { WhpConfig } from '../../types';
+
+const whpConfig: WhpConfig = {
+ user: 'u', apiUrl: '/api', csrfToken: 't', siteId: 1,
+ siteDomain: 'example.com', siteName: 'Example', backUrl: '/', isRoot: false,
+};
+
+let container: HTMLDivElement;
+let root: Root;
+const saves = () => saveMock.mock.calls;
+
+function mount() {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ act(() => {
+ root = createRoot(container);
+ root.render(
+
+ {}} showGuides onToggleGuides={() => {}} />
+ ,
+ );
+ });
+}
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ saveMock.mockReset();
+ saveMock.mockResolvedValue({ success: true });
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+});
+afterEach(() => {
+ act(() => { root.unmount(); });
+ container.remove();
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+});
+
+describe('autosave gate', () => {
+ test('a dirty editor whose load failed makes no save call', async () => {
+ guardState.loadState = 'failed';
+ mount();
+ await act(async () => { vi.advanceTimersByTime(90_000); });
+ expect(saves()).toHaveLength(0);
+ });
+
+ test('control: the same dirty editor, once loaded, does autosave', async () => {
+ guardState.loadState = 'loaded';
+ mount();
+ await act(async () => { vi.advanceTimersByTime(30_000); });
+ expect(saves()).toHaveLength(1);
+ });
+});