fix(api): 180s publish timeout, "Still publishing..." after 30s

Save/load keep 30s; publish of a large site legitimately runs longer and was
reported as failed at 30s while the server kept going.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-10-07 13:19:11 -07:00
1 parent 6d6994f009
commit ff826a03c4
4 files changed
+38 -5

No files matched your search

+11 -2
View File
@@ -23,7 +23,7 @@ import { EditorConfigProvider } from '../state/EditorConfigContext';
import { SaveGuardProvider, useSaveGuard } from '../state/SaveGuardContext';
import { PageProvider } from '../state/PageContext';
import { SiteDesignProvider } from '../state/SiteDesignContext';
import { useWhpApi, API_TIMEOUT_MS } from './useWhpApi';
import { useWhpApi, API_TIMEOUT_MS, PUBLISH_TIMEOUT_MS } from './useWhpApi';
import { WhpConfig } from '../types';
const whpConfig: WhpConfig = {
@@ -119,10 +119,19 @@ describe('request timeout (item 11)', () => {
await act(async () => { void api.publish().then((r) => { res = r; }); });
await flush();
expect(calls('publish')).toHaveLength(1);
// A large site legitimately takes longer than a save: 30s is NOT a failure.
await act(async () => { vi.advanceTimersByTime(API_TIMEOUT_MS); });
await flush();
expect(res).toBe('pending');
await act(async () => { vi.advanceTimersByTime(PUBLISH_TIMEOUT_MS - API_TIMEOUT_MS); });
await flush();
expect(res).toMatchObject({ success: false, error: 'timeout' });
expect(res.message).toMatch(/^Publish failed: the server did not respond/);
expect(res.message).toMatch(/^Publish failed: the server did not respond within 180 seconds/);
});
test('timeouts: save/load 30s, publish 180s', () => {
expect(API_TIMEOUT_MS).toBe(30_000);
expect(PUBLISH_TIMEOUT_MS).toBe(180_000);
});
test('a hung load rejects with a readable error', async () => {
+4 -2
View File
@@ -263,8 +263,10 @@ export interface ApiResult {
[key: string]: unknown;
}
/** How long save / publish / load may take before the request is abandoned. */
/** How long save / load may take before the request is abandoned. */
export const API_TIMEOUT_MS = 30_000;
/** Publish compiles and copies every page and asset: a large site legitimately takes minutes. */
export const PUBLISH_TIMEOUT_MS = 180_000;
/** A request that got no complete answer within API_TIMEOUT_MS. */
export class ApiTimeoutError extends Error {}
@@ -527,7 +529,7 @@ export function useWhpApi() {
},
);
return readApiResult(resp, 'Publish');
});
}, PUBLISH_TIMEOUT_MS);
} catch (e) {
if (e instanceof ApiTimeoutError) return timeoutResult(e);
throw e;
+16
View File
@@ -344,6 +344,22 @@ describe('TopBar search button (#49 integration)', () => {
});
});
describe('TopBar slow publish', () => {
test('after 30s the button reads "Still publishing..." instead of failing', async () => {
vi.useFakeTimers();
let resolve!: (v: unknown) => void;
publishMock.mockImplementation(() => new Promise((r) => { resolve = r; }));
render();
await clickPublish();
expect(publishButton().textContent).toContain('Publishing...');
expect(publishButton().textContent).not.toContain('Still publishing');
await act(async () => { vi.advanceTimersByTime(30_000); });
expect(publishButton().textContent).toContain('Still publishing...');
await act(async () => { resolve({ success: true }); await Promise.resolve(); await Promise.resolve(); });
expect(publishButton().textContent).not.toContain('Still publishing');
});
});
describe('TopBar Preview head parity (item 14)', () => {
const rootState = (style: Record<string, string> = {}) => JSON.stringify({
ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: { tag: 'div', style }, displayName: 'Container', custom: {}, hidden: false, nodes: [], linkedNodes: {} },
+7 -1
View File
@@ -52,6 +52,8 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
const [saving, setSaving] = useState(false);
const [saveFailed, setSaveFailed] = useState(false);
const [publishing, setPublishing] = useState(false);
// A big site can take well over 30s to publish: say so instead of looking stuck.
const [publishSlow, setPublishSlow] = useState(false);
const [published, setPublished] = useState(false);
const [errorNotice, setErrorNotice] = useState<string | null>(null);
const [publishWarnings, setPublishWarnings] = useState<string[]>([]);
@@ -185,6 +187,8 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
const versionAtStart = getVersion();
publishingRef.current = true;
setPublishing(true);
setPublishSlow(false);
const slowTimer = setTimeout(() => setPublishSlow(true), 30_000);
setPublishWarnings([]);
try {
const result = await publish();
@@ -217,8 +221,10 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
console.error('Publish failed:', e);
setErrorNotice(messageOf(e, 'Publish'));
} finally {
clearTimeout(slowTimer);
publishingRef.current = false;
setPublishing(false);
setPublishSlow(false);
}
}, [publish, canSave, getVersion, setEmptyRefusal, pendingChecks, computeChecks]);
@@ -593,7 +599,7 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
aria-label="Publish to live site"
>
{publishing ? (
<><i className="fa fa-spinner fa-spin" /> Publishing...</>
<><i className="fa fa-spinner fa-spin" /> {publishSlow ? 'Still publishing...' : 'Publishing...'}</>
) : (
<><i className="fa fa-globe" /> Publish</>
)}