diff --git a/packages/workshop-backend/__tests__/browser-export.test.ts b/packages/workshop-backend/__tests__/browser-export.test.ts index ea660b534..1b0b8529a 100644 --- a/packages/workshop-backend/__tests__/browser-export.test.ts +++ b/packages/workshop-backend/__tests__/browser-export.test.ts @@ -13,6 +13,7 @@ type Harness = { clientInitialized: () => boolean; gadgetDisposed: () => boolean; pdfRequested: () => boolean; + renderSettled: () => boolean; exportDocument: () => string; exportDocumentCsp: () => string | undefined; blobRequestContinued: () => boolean; @@ -33,6 +34,7 @@ function makeHarness(pdfChunks = ["%PDF-1.4"], closePdf = true) { let documentTitle: string | undefined; let gadgetDisposed = false; let pdfRequested = false; + let renderSettled = false; let exportDocument = ""; let exportDocumentCsp: string | undefined; let blobRequestContinued = false; @@ -62,9 +64,16 @@ function makeHarness(pdfChunks = ["%PDF-1.4"], closePdf = true) { clientInitialized = true; return Promise.resolve(); } + if (fn.toString().includes("MutationObserver")) { + if (!isolated) throw new Error("DOM settling ran in the main world."); + expect(clientInitialized).toBe(true); + renderSettled = true; + return Promise.resolve(); + } if (fn.toString().includes("document.title")) { if (!isolated) throw new Error("Document title was assigned in the main world."); expect(clientInitialized).toBe(true); + expect(renderSettled).toBe(true); documentTitle = typeof args[0] === "string" ? args[0] : undefined; return Promise.resolve(); } @@ -175,6 +184,7 @@ function makeHarness(pdfChunks = ["%PDF-1.4"], closePdf = true) { clientInitialized: () => clientInitialized, gadgetDisposed: () => gadgetDisposed, pdfRequested: () => pdfRequested, + renderSettled: () => renderSettled, exportDocument: () => exportDocument, exportDocumentCsp: () => exportDocumentCsp, blobRequestContinued: () => blobRequestContinued, @@ -298,11 +308,12 @@ describe("limitStream", () => { }); describe("renderGadgetInBrowser", () => { - it("waits for the client module, streams a PDF, and releases the browser", async () => { + it("waits for the client module and DOM to settle, then streams a PDF", async () => { let { stream, harness } = render(); expect(await collect(await stream)).toBe("%PDF-1.4"); expect(harness.clientInitialized()).toBe(true); + expect(harness.renderSettled()).toBe(true); expect(harness.pdfRequested()).toBe(true); expect(harness.mediaType()).toBe("print"); expect(harness.browserClosed()).toBe(true); diff --git a/packages/workshop-backend/browser/browser-export-page.ts b/packages/workshop-backend/browser/browser-export-page.ts index 1b57d44c3..34194307c 100644 --- a/packages/workshop-backend/browser/browser-export-page.ts +++ b/packages/workshop-backend/browser/browser-export-page.ts @@ -39,6 +39,28 @@ export async function waitForClientModule(): Promise { // Functions that run in the isolated realm: +/** Waits until the rendered DOM has remained unchanged for the requested interval. */ +export function waitForDomSettled(quietMs: number): Promise { + return new Promise(resolve => { + let timer: ReturnType; + const observer = new MutationObserver(() => { + clearTimeout(timer); + timer = setTimeout(finish, quietMs); + }); + function finish() { + observer.disconnect(); + resolve(); + } + observer.observe(document.documentElement, { + subtree: true, + childList: true, + attributes: true, + characterData: true, + }); + timer = setTimeout(finish, quietMs); + }); +} + /** Assigns the title used by PDF viewers and the static HTML snapshot. */ export function setDocumentTitle(title: string): void { document.title = title; diff --git a/packages/workshop-backend/src/browser-export.ts b/packages/workshop-backend/src/browser-export.ts index 4297d5ae8..fc2136b7c 100644 --- a/packages/workshop-backend/src/browser-export.ts +++ b/packages/workshop-backend/src/browser-export.ts @@ -11,6 +11,7 @@ import { sendToBrowser, setDocumentTitle, waitForClientModule, + waitForDomSettled, } from "./generated/browser-export-page.js"; import { createExportDeadline, limitExportStream, MAX_EXPORT_BYTES } from "./export-limits"; @@ -21,6 +22,8 @@ type BrowserExportLogFields = { const logger = createLogger({ component: "workshop.browser-export" }); +/** Compatibility fallback for clients that schedule DOM work outside top-level await. */ +const DOM_SETTLE_MS = 250; /** Budget for releasing the browser session once an export has settled. */ const BROWSER_CLOSE_TIMEOUT_MS = 10_000; /** Maximum number of pending Worker-to-browser RPC messages. */ @@ -233,6 +236,7 @@ export async function renderGadgetInBrowser( await page.evaluate(waitForClientModule); const frame = page.mainFrame() as FrameWithIsolatedRealm; const isolatedRealm = frame.isolatedRealm(); + await isolatedRealm.evaluate(waitForDomSettled, DOM_SETTLE_MS); await isolatedRealm.evaluate(setDocumentTitle, documentTitle); switch (format.contentType) { case "application/pdf": diff --git a/packages/workshop-backend/src/overseer.ts b/packages/workshop-backend/src/overseer.ts index 98c5f34bc..fa05cf4a6 100644 --- a/packages/workshop-backend/src/overseer.ts +++ b/packages/workshop-backend/src/overseer.ts @@ -2576,7 +2576,7 @@ class OverseerImpl implements AgentHooks { let bundle = this.getGadgetUiBundle(gadgetId, chatId); if (!bundle) throw new Error("This Gadget does not have a UI to export."); let title = this.getGadgetRecord(gadgetId).title; - return renderGadgetInBrowser(browser, bundle.jsCode, title, exportGadget.move(), format); + return renderGadgetInBrowser(browser, bundle.jsCode, title, exportGadget.dup(), format); } }