Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions src/__tests__/agent/renderFrameWait.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
51 changes: 44 additions & 7 deletions src/admin/pages/site/agent/renderEvidence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<HTMLElement | null> {
const deadline = Date.now() + timeoutMs
while (Date.now() <= deadline) {
const frame = findAgentRenderFrame({ ...query, requireReady: true })
if (frame) return frame
await new Promise<void>((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<void>((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 {
Expand Down
Loading