fix(site-builder): autosave only when dirty; memoise per-state page export
Refs #33 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
86b82ed154
commit
c863767283
5 files changed
+156
-9
No files matched your search
@@ -30,7 +30,7 @@ import { LoadGate } from './LoadGate';
|
||||
import { TopBar } from '../panels/topbar/TopBar';
|
||||
import { SiteDesignPanel } from '../panels/right/SiteDesignPanel';
|
||||
import { EditorConfigProvider } from '../state/EditorConfigContext';
|
||||
import { SaveGuardProvider } from '../state/SaveGuardContext';
|
||||
import { SaveGuardProvider, useSaveGuard } from '../state/SaveGuardContext';
|
||||
import { PageProvider } from '../state/PageContext';
|
||||
import { SiteDesignProvider } from '../state/SiteDesignContext';
|
||||
import { WhpConfig } from '../types';
|
||||
@@ -55,6 +55,9 @@ 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);
|
||||
@@ -68,6 +71,7 @@ function mount() {
|
||||
<LoadGate />
|
||||
<TopBar device="desktop" onDeviceChange={() => {}} showGuides onToggleGuides={() => {}} />
|
||||
<SiteDesignPanel />
|
||||
<GuardProbe />
|
||||
</PageProvider>
|
||||
</SiteDesignProvider>
|
||||
</SaveGuardProvider>
|
||||
@@ -179,6 +183,8 @@ describe('load gate', () => {
|
||||
mount();
|
||||
await flush();
|
||||
expect(q('[data-testid="load-gate"]')).toBeNull();
|
||||
// Autosave only fires for unsaved changes.
|
||||
act(() => { guard.markDirty(); });
|
||||
await act(async () => { vi.advanceTimersByTime(30000); });
|
||||
expect(saveCalls()).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -203,13 +203,39 @@ describe('dirty lifecycle and status pill', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Draft badge', () => {
|
||||
test('a no-op autosave does not flip the badge to Draft; a save after real changes does', async () => {
|
||||
describe('autosave only when dirty', () => {
|
||||
test('an idle 30s tick makes no request; an edit makes exactly one on the next tick', async () => {
|
||||
respondWith({});
|
||||
await mount();
|
||||
await act(async () => { vi.advanceTimersByTime(90_000); });
|
||||
await flush();
|
||||
expect(calls('save')).toHaveLength(0);
|
||||
|
||||
edit();
|
||||
await act(async () => { vi.advanceTimersByTime(30_000); });
|
||||
await flush();
|
||||
expect(calls('save')).toHaveLength(1);
|
||||
|
||||
// Saved and clean again: later ticks stay quiet.
|
||||
await act(async () => { vi.advanceTimersByTime(60_000); });
|
||||
await flush();
|
||||
expect(calls('save')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('the Save button still saves a clean editor', async () => {
|
||||
respondWith({});
|
||||
await mount();
|
||||
await clickSave();
|
||||
expect(calls('save')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Draft badge', () => {
|
||||
test('a manual save with nothing changed does not flip the badge to Draft; a save after real changes does', async () => {
|
||||
respondWith({});
|
||||
await mount();
|
||||
await clickSave();
|
||||
expect(calls('save')).toHaveLength(1);
|
||||
expect(q('.publish-badge.draft')).toBeNull();
|
||||
|
||||
edit();
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
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');
|
||||
return { html: `<p>${state}</p>`, css: `/*${state}*/` };
|
||||
}),
|
||||
}));
|
||||
|
||||
import { exportBodyHtml } from '../utils/html-export';
|
||||
import { buildSavePayload, clearExportCache } from './useWhpApi';
|
||||
import { PageData } from '../types';
|
||||
import { DEFAULT_SITE_DESIGN } from '../state/SiteDesignContext';
|
||||
|
||||
const spy = vi.mocked(exportBodyHtml);
|
||||
const page = (id: string, craftState: string | null): PageData => ({ id, name: id, slug: id, craftState });
|
||||
const header = page('__header__', 'H1');
|
||||
const footer = page('__footer__', 'F1');
|
||||
|
||||
function build(pages: PageData[], live: string, activePageId = pages[0].id, h = header, f = footer) {
|
||||
return buildSavePayload({
|
||||
siteId: 1, siteName: 's', liveCraftState: live, pages, headerPage: h, footerPage: f,
|
||||
activePageId, isEditingHeader: false, isEditingFooter: false,
|
||||
headCode: DEFAULT_SITE_DESIGN.headCode, design: DEFAULT_SITE_DESIGN,
|
||||
});
|
||||
}
|
||||
const exported = () => spy.mock.calls.map(([s]) => s);
|
||||
|
||||
beforeEach(() => {
|
||||
clearExportCache();
|
||||
spy.mockClear();
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe('buildSavePayload export memoisation', () => {
|
||||
test('an identical second build only re-exports the live canvas page', () => {
|
||||
const pages = [page('home', 'HOME'), page('a', 'A'), page('b', 'B')];
|
||||
build(pages, 'LIVE1');
|
||||
expect(exported().sort()).toEqual(['A', 'B', 'F1', 'H1', 'LIVE1']);
|
||||
spy.mockClear();
|
||||
const p = build(pages, 'LIVE2');
|
||||
expect(exported()).toEqual(['LIVE2']);
|
||||
expect(p.pages.map((x) => x.html)).toEqual(['<p>LIVE2</p>', '<p>A</p>', '<p>B</p>']);
|
||||
});
|
||||
|
||||
test('only the page whose state changed is re-exported', () => {
|
||||
const pages = [page('home', 'HOME'), page('a', 'A'), page('b', 'B')];
|
||||
build(pages, 'LIVE');
|
||||
spy.mockClear();
|
||||
const p = build([pages[0], page('a', 'A2'), pages[2]], 'LIVE');
|
||||
expect(exported().sort()).toEqual(['A2', 'LIVE']);
|
||||
expect(p.pages[1].html).toBe('<p>A2</p>');
|
||||
expect(p.pages[2].html).toBe('<p>B</p>');
|
||||
});
|
||||
|
||||
test('header and footer are memoised the same way', () => {
|
||||
const pages = [page('home', 'HOME')];
|
||||
build(pages, 'LIVE');
|
||||
spy.mockClear();
|
||||
const p = build(pages, 'LIVE', 'home', page('__header__', 'H2'));
|
||||
expect(exported().sort()).toEqual(['H2', 'LIVE']);
|
||||
expect(p.header_html).toBe('<p>H2</p>');
|
||||
expect(p.footer_html).toBe('<p>F1</p>');
|
||||
});
|
||||
|
||||
test('a failed export is not cached and is retried on the next build', () => {
|
||||
const pages = [page('home', 'HOME'), page('x', 'BAD1')];
|
||||
const first = build(pages, 'LIVE');
|
||||
expect(first.pages[1].html).toBe('');
|
||||
spy.mockClear();
|
||||
build(pages, 'LIVE');
|
||||
expect(exported()).toContain('BAD1');
|
||||
});
|
||||
|
||||
test('a deleted page is evicted, so re-adding its state exports again', () => {
|
||||
const pages = [page('home', 'HOME'), page('a', 'A')];
|
||||
build(pages, 'LIVE');
|
||||
build([pages[0]], 'LIVE');
|
||||
spy.mockClear();
|
||||
build(pages, 'LIVE');
|
||||
expect(exported()).toContain('A');
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,20 @@ import { repairOrphanNodes } from '../utils/orphan-repair';
|
||||
import { PageData } from '../types';
|
||||
import { useSaveGuard } from '../state/SaveGuardContext';
|
||||
|
||||
/**
|
||||
* Per-state export memo. A craft-state string fully determines its exported
|
||||
* HTML/CSS, so unchanged pages skip the export on every autosave. Only
|
||||
* stored (non-live) states are cached, failures never are, and each build
|
||||
* evicts every entry it did not use, so deleted/edited pages cannot pile up.
|
||||
*/
|
||||
type ExportResult = ReturnType<typeof exportBodyHtml>;
|
||||
const exportCache = new Map<string, ExportResult>();
|
||||
|
||||
/** Test hook: forget every memoised export. */
|
||||
export function clearExportCache() {
|
||||
exportCache.clear();
|
||||
}
|
||||
|
||||
export interface BuildSavePayloadInput {
|
||||
siteId: number;
|
||||
siteName: string;
|
||||
@@ -58,6 +72,18 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
|
||||
|
||||
const isPageActive = !isEditingHeader && !isEditingFooter;
|
||||
|
||||
// Stored states touched by this build; the cache is pruned to this set.
|
||||
const usedStates = new Set<string>();
|
||||
/** Export a stored (not live) state, reusing the memo. Throws like exportBodyHtml. */
|
||||
const exportStored = (state: string): ExportResult => {
|
||||
usedStates.add(state);
|
||||
const hit = exportCache.get(state);
|
||||
if (hit) return hit;
|
||||
const result = exportBodyHtml(state);
|
||||
exportCache.set(state, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
// I-1 (data-loss): `activePageId` can go dangling -- e.g. the original
|
||||
// Home page (id 'home') is deleted, its replacement gets a fresh id
|
||||
// (`page_<ts>`), and a reload re-initializes `activePageId` back to the
|
||||
@@ -79,7 +105,7 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
|
||||
let headerHtml = '';
|
||||
try {
|
||||
if (headerCraftState) {
|
||||
headerHtml = exportBodyHtml(headerCraftState).html;
|
||||
headerHtml = (isEditingHeader ? exportBodyHtml(headerCraftState) : exportStored(headerCraftState)).html;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Header HTML export failed:', e);
|
||||
@@ -88,7 +114,7 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
|
||||
let footerHtml = '';
|
||||
try {
|
||||
if (footerCraftState) {
|
||||
footerHtml = exportBodyHtml(footerCraftState).html;
|
||||
footerHtml = (isEditingFooter ? exportBodyHtml(footerCraftState) : exportStored(footerCraftState)).html;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Footer HTML export failed:', e);
|
||||
@@ -117,7 +143,7 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
|
||||
currentCraftState = landingPage?.craftState ?? null;
|
||||
try {
|
||||
if (currentCraftState) {
|
||||
const result = exportBodyHtml(currentCraftState);
|
||||
const result = exportStored(currentCraftState);
|
||||
currentHtml = result.html;
|
||||
css = result.css;
|
||||
}
|
||||
@@ -141,7 +167,7 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
|
||||
pageHtml = currentHtml;
|
||||
} else if (page.craftState) {
|
||||
try {
|
||||
pageHtml = exportBodyHtml(page.craftState).html;
|
||||
pageHtml = exportStored(page.craftState).html;
|
||||
} catch (e) {
|
||||
console.error(`HTML export failed for page ${page.name}:`, e);
|
||||
}
|
||||
@@ -179,6 +205,10 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
|
||||
seo: page.seo,
|
||||
}));
|
||||
|
||||
for (const key of exportCache.keys()) {
|
||||
if (!usedStates.has(key)) exportCache.delete(key);
|
||||
}
|
||||
|
||||
return {
|
||||
site_id: siteId,
|
||||
name: siteName,
|
||||
|
||||
@@ -106,9 +106,11 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
||||
// Paused until the site has loaded, and while the empty-save banner is up.
|
||||
if (!isWHP || !canSave || emptyRefusal) return;
|
||||
// A running publish does its own save; a second one would race it.
|
||||
const interval = setInterval(() => { if (!publishingRef.current) void runSave(); }, 30000);
|
||||
// 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]);
|
||||
}, [isWHP, canSave, emptyRefusal, runSave, isDirty]);
|
||||
|
||||
const handleSave = runSave;
|
||||
|
||||
|
||||
Reference in new issue
Block a user