diff --git a/craft/src/hooks/useWhpApi.timeout.test.tsx b/craft/src/hooks/useWhpApi.timeout.test.tsx index 563640b..85f2a6e 100644 --- a/craft/src/hooks/useWhpApi.timeout.test.tsx +++ b/craft/src/hooks/useWhpApi.timeout.test.tsx @@ -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 () => { diff --git a/craft/src/hooks/useWhpApi.ts b/craft/src/hooks/useWhpApi.ts index 75071e3..a697b7f 100644 --- a/craft/src/hooks/useWhpApi.ts +++ b/craft/src/hooks/useWhpApi.ts @@ -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; diff --git a/craft/src/panels/topbar/TopBar.test.tsx b/craft/src/panels/topbar/TopBar.test.tsx index 02db1f2..e91d488 100644 --- a/craft/src/panels/topbar/TopBar.test.tsx +++ b/craft/src/panels/topbar/TopBar.test.tsx @@ -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 = {}) => JSON.stringify({ ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: { tag: 'div', style }, displayName: 'Container', custom: {}, hidden: false, nodes: [], linkedNodes: {} }, diff --git a/craft/src/panels/topbar/TopBar.tsx b/craft/src/panels/topbar/TopBar.tsx index b8d47f3..d0f5ea6 100644 --- a/craft/src/panels/topbar/TopBar.tsx +++ b/craft/src/panels/topbar/TopBar.tsx @@ -52,6 +52,8 @@ export const TopBar: React.FC = ({ 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(null); const [publishWarnings, setPublishWarnings] = useState([]); @@ -185,6 +187,8 @@ export const TopBar: React.FC = ({ 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 = ({ 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 = ({ showGuides, onToggleGuides }) => aria-label="Publish to live site" > {publishing ? ( - <> Publishing... + <> {publishSlow ? 'Still publishing...' : 'Publishing...'} ) : ( <> Publish )}