diff --git a/src/__tests__/agent/renderFrameWait.test.ts b/src/__tests__/agent/renderFrameWait.test.ts new file mode 100644 index 000000000..1d296385c --- /dev/null +++ b/src/__tests__/agent/renderFrameWait.test.ts @@ -0,0 +1,54 @@ +/** + * `waitForAgentRenderFrame` must resolve when the frame's readiness marker + * lands, not on a timer tick. It used to poll every 16ms; in a background tab + * chained timers are throttled (to one per minute in Chrome), which stalled + * MCP render snapshots for minutes. + */ +import { afterEach, describe, expect, it } from 'bun:test' +import { waitForAgentRenderFrame } from '@site/agent' + +function mountFrame(breakpointId: string): HTMLIFrameElement { + const frame = document.createElement('div') + frame.dataset.breakpointId = breakpointId + const iframe = document.createElement('iframe') + frame.appendChild(iframe) + document.body.appendChild(frame) + iframe.contentDocument!.body.dataset.breakpointId = breakpointId + return iframe +} + +afterEach(() => { + document.body.innerHTML = '' +}) + +describe('waitForAgentRenderFrame', () => { + it('resolves on the readiness marker even when timers are throttled', async () => { + // Simulate a background tab: every timer fires at least a second late. + const realSetTimeout = globalThis.setTimeout + globalThis.setTimeout = ((fn: () => void, ms?: number) => + realSetTimeout(fn, Math.max(ms ?? 0, 1_000))) as typeof setTimeout + try { + const iframe = mountFrame('desktop') + const startedAt = performance.now() + const waiting = waitForAgentRenderFrame({ breakpointId: 'desktop', source: 'visible' }, 5_000) + await Promise.resolve() + iframe.dataset.instaticCanvasDocumentLoaded = 'true' + expect(await waiting).not.toBeNull() + expect(performance.now() - startedAt).toBeLessThan(500) + } finally { + globalThis.setTimeout = realSetTimeout + } + }) + + it('resolves when the frame mounts after the wait began', async () => { + const waiting = waitForAgentRenderFrame({ breakpointId: 'tablet', source: 'visible' }, 2_000) + const iframe = mountFrame('tablet') + iframe.dataset.instaticCanvasDocumentLoaded = 'true' + expect(await waiting).not.toBeNull() + }) + + it('returns null at the deadline when the frame never becomes ready', async () => { + mountFrame('mobile') + expect(await waitForAgentRenderFrame({ breakpointId: 'mobile', source: 'visible' }, 60)).toBeNull() + }) +}) diff --git a/src/admin/pages/site/agent/renderEvidence.ts b/src/admin/pages/site/agent/renderEvidence.ts index 5a973ac9c..76803a2a8 100644 --- a/src/admin/pages/site/agent/renderEvidence.ts +++ b/src/admin/pages/site/agent/renderEvidence.ts @@ -14,7 +14,10 @@ const MAX_COMPUTED_BACKGROUND_IMAGE_LENGTH = 500 const COMPUTED_STYLE_TRUNCATION_SUFFIX = '...[truncated]' const OVERFLOW_TOLERANCE_PX = 2 const FRAME_WAIT_TIMEOUT_MS = 5_000 -const FRAME_WAIT_POLL_MS = 16 +// Fallback only: frame readiness normally arrives through the MutationObserver +// in `waitForAgentRenderFrame`, which background-tab timer throttling (down to +// one chained timer per minute in Chrome) does not delay. +const FRAME_WAIT_FALLBACK_POLL_MS = 250 // Anthropic rejects any image dimension > 8000px outright (400), and internally // downsizes the long edge to ~1568px before the model ever sees it. So we cap @@ -167,18 +170,52 @@ export function findAgentRenderFrame({ return null } -/** Wait for React to mount and commit an exact canvas iframe. */ +/** + * Wait for React to mount and commit an exact canvas iframe. + * + * Event-driven: re-checks whenever the canvas DOM or a readiness marker + * (`data-instatic-canvas-document-loaded`, `data-agent-snapshot-ready`) + * changes. A tight timer poll here stalled MCP render snapshots for minutes in + * a background tab, where chained timers are throttled. + */ export async function waitForAgentRenderFrame( query: AgentRenderFrameQuery, timeoutMs: number = FRAME_WAIT_TIMEOUT_MS, ): Promise { const deadline = Date.now() + timeoutMs - while (Date.now() <= deadline) { - const frame = findAgentRenderFrame({ ...query, requireReady: true }) - if (frame) return frame - await new Promise((resolve) => setTimeout(resolve, FRAME_WAIT_POLL_MS)) + let wake: (() => void) | null = null + const observer = new MutationObserver(() => wake?.()) + observer.observe(document.body, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: [ + 'data-instatic-canvas-document-loaded', + 'data-agent-snapshot-ready', + 'data-breakpoint-id', + 'data-agent-snapshot-breakpoint-id', + 'data-agent-snapshot-request-id', + ], + }) + try { + for (;;) { + const frame = findAgentRenderFrame({ ...query, requireReady: true }) + if (frame) return frame + const remaining = deadline - Date.now() + if (remaining <= 0) return null + await new Promise((resolve) => { + const timer = setTimeout(done, Math.min(FRAME_WAIT_FALLBACK_POLL_MS, remaining)) + function done(): void { + clearTimeout(timer) + wake = null + resolve() + } + wake = done + }) + } + } finally { + observer.disconnect() } - return null } function isAgentRenderFrameReady(frame: HTMLElement, requestId?: string): boolean {