From 71d8f106c9ea52532f21d07a766173db031dcaa3 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 4 May 2026 16:42:24 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=A9=B9=20fix:=20PPTX=20black=20screen=20?= =?UTF-8?q?=E2=80=94=20allow=20blob:=20workers=20+=20harden=20bootstrap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manual e2e of the PPTX CDN renderer surfaced a black screen with "Could not establish connection. Receiving end does not exist." unhandled-rejection — characteristic of a Web Worker that couldn't start. Root cause: pptx-preview's bundled echarts dep spins up Web Workers via blob: URLs for chart rendering. Our CSP had `default-src 'none'` and no `worker-src`, so workers fell back to default → blocked. The async failure deep inside echarts didn't surface through the outer `previewer.preview()` promise, so my bootstrap's `.catch` never fired, the loading state was removed, and the iframe sat with the body background showing through (dark navy in dark mode = "black screen"). Three changes: - Add `worker-src blob:` to the PPTX CSP. Allows blob:-only worker creation without permitting arbitrary worker URLs. - Bootstrap: window-level `unhandledrejection` and `error` listeners so rejections from inside bundled-dep async pipelines surface as the user-facing "Preview unavailable" fallback instead of going silent. - Bootstrap: 8-second timeout that checks `container.children.length` — if the renderer hasn't appended anything visible by then, assume silent failure and show the fallback. Also wipe `container.innerHTML` when showing the fallback so a partial render doesn't compete with the message. DOCX wrapper unchanged: docx-preview doesn't use workers, so the worker-src directive doesn't apply, and the existing fallback path already covers its failure modes. Tests ----- - Existing PPTX CSP test now also asserts `worker-src blob:` is present. - Existing fallback-message test extended to cover the new unhandledrejection/error/timeout listeners. packages/api/src/files: 467/467 ✅. --- packages/api/src/files/documents/html.spec.ts | 17 ++++++- packages/api/src/files/documents/html.ts | 48 ++++++++++++++++++- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/packages/api/src/files/documents/html.spec.ts b/packages/api/src/files/documents/html.spec.ts index 5b460b2e27..99f27f5756 100644 --- a/packages/api/src/files/documents/html.spec.ts +++ b/packages/api/src/files/documents/html.spec.ts @@ -373,14 +373,29 @@ describe('Office HTML producers', () => { expect(csp).toMatch(/form-action 'none'/); expect(csp).toMatch(/script-src https:\/\/cdn\.jsdelivr\.net 'unsafe-inline'/); expect(csp).not.toMatch(/unsafe-eval/); + /* PPTX must allow blob:-only Web Workers — pptx-preview's + * bundled echarts dep spins up workers via blob: URLs for + * chart rendering. Without this, the renderer's async + * pipeline throws unhandled rejections and the iframe shows + * a black screen. */ + expect(csp).toMatch(/worker-src blob:/); }); - test('exposes a fallback message that surfaces if the renderer fails to load', async () => { + test('exposes a fallback message that surfaces if the renderer fails to load or times out', async () => { const pptx = await buildPptx([{ title: 'X' }]); const html = await _internal.pptxToHtmlViaCdn(pptx); + // Visible loading state + fallback that swaps in on error. expect(html).toContain('Loading preview…'); expect(html).toContain('Preview unavailable'); + // The renderer-not-loaded check. expect(html).toContain("typeof pptxPreview === 'undefined'"); + // The unhandledrejection + error listeners — pptx-preview's + // bundled deps raise async rejections that don't surface + // through the outer Promise. + expect(html).toContain("addEventListener('unhandledrejection'"); + expect(html).toContain("addEventListener('error'"); + // The 8-second timeout safety net for silent renderer failures. + expect(html).toContain('renderer-timeout'); }); test('size-fallback threshold is the documented 350 KB', () => { diff --git a/packages/api/src/files/documents/html.ts b/packages/api/src/files/documents/html.ts index e073b71ccc..25656d753c 100644 --- a/packages/api/src/files/documents/html.ts +++ b/packages/api/src/files/documents/html.ts @@ -972,10 +972,20 @@ const MAX_PPTX_CDN_BINARY_BYTES = 350 * 1024; * once the renderer paints them. */ function buildPptxCdnDocument(base64: string): string { + /* PPTX-specific CSP relaxations vs DOCX: + * - `worker-src blob:` — pptx-preview's bundled echarts dep spins up + * Web Workers via blob: URLs for chart rendering. Without this, + * workers default to `default-src 'none'`, get blocked, and + * echarts throws an unhandled-rejection deep inside its async + * pipeline that we can't catch from the bootstrap script. The + * visible symptom is a black iframe with the renderer half- + * started. Allowing blob:-only workers is the minimum surface to + * unblock rendering without permitting arbitrary worker URLs. */ const csp = [ "default-src 'none'", "script-src https://cdn.jsdelivr.net 'unsafe-inline'", "style-src 'unsafe-inline'", + 'worker-src blob:', "img-src 'self' data: blob:", 'font-src data:', "connect-src 'none'", @@ -1006,15 +1016,39 @@ html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg); fon