From 7c2338faaa832a990988f4ae74328c5c3eec3703 Mon Sep 17 00:00:00 2001 From: Norbert Gocht Date: Wed, 14 Oct 2026 18:44:26 +0200 Subject: [PATCH] fix(agent): wait for snapshot frames on DOM events, not a 16ms timer poll waitForAgentRenderFrame polled every 16ms with chained setTimeouts. In a background tab Chrome throttles chained timers to once per second, and to once per minute after five minutes hidden, so an MCP render snapshot routed to an unfocused editor could stall for minutes. It now re-checks on a MutationObserver over the canvas DOM and readiness markers; observer callbacks are microtasks and are not throttled. A 250ms timer stays as a fallback. Co-Authored-By: Claude Opus 5.5 --- src/__tests__/agent/renderFrameWait.test.ts | 54 ++++++++++++++++++++ src/admin/pages/site/agent/renderEvidence.ts | 51 +++++++++++++++--- 2 files changed, 98 insertions(+), 7 deletions(-) create mode 100644 src/__tests__/agent/renderFrameWait.test.ts 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 {