Skip to content

fix(export): resolve PPTX export race condition with empty slides#72

Open
111wukong wants to merge 2 commits into
nexu-io:mainfrom
111wukong:fix/pptx-export-race-condition
Open

fix(export): resolve PPTX export race condition with empty slides#72
111wukong wants to merge 2 commits into
nexu-io:mainfrom
111wukong:fix/pptx-export-race-condition

Conversation

@111wukong

Copy link
Copy Markdown

When exporting a deck to PPTX or PNG-ZIP, renderSlideToBlob creates an off-screen iframe with srcdoc equal to the slide HTML. If fonts or Tailwind Play CDN styles have not been applied by the time the load event fires (or the 4-second timeout expires), the iframe document measures 0px scrollHeight and iframeToBlob throws "preview has no content yet" — even though the live preview renders the deck correctly.

Changes:

  • Increased the srcdoc load timeout from 4 to 8 seconds (Tailwind CDN can be slow on cold cache or constrained networks)
  • Added a double-requestAnimationFrame flush after the load guard so the browser has an opportunity to finish layout before the first capture
  • Added a single retry with 1.2-second backoff when the first capture attempt yields an empty document

Fixes #62

111wukong added 2 commits May 21, 2026 02:26
Users who route Claude Code through a custom model endpoint (e.g. via
`cc switch`) need to select `deepseek-v4-pro` or `deepseek-v4-flash`
from the model picker. Without these entries, the only way to switch
models is to leave html-anything, change the CLI config, and reload.

Closes nexu-io#65
The off-screen iframe used to capture each slide for PPTX / PNG-ZIP export
could report a 0px scrollHeight when fonts or Tailwind CDN styles had not
yet been applied, even after the `load` event fired. This caused
`iframeToBlob` to throw "preview has no content yet" for decks that
rendered correctly in the live preview.

Changes:
- Increased the srcdoc load timeout from 4 s → 8 s (Tailwind CDN can be slow)
- Added a double-rAF flush before the first capture to let the browser
  finish layout after the load event
- Added a single retry with 1.2 s backoff when the first capture fails

Fixes nexu-io#62
@lefarcen lefarcen requested a review from nettee May 20, 2026 19:53
@lefarcen lefarcen added size/S Small change: 20-99 changed lines risk/medium Medium risk change type/bugfix Bug fix labels May 20, 2026

@nettee nettee left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found two follow-ups in the new export retry path: one around retrying permanent screenshot errors, and one around the longer load timeout still falling through as if loading had succeeded. Details inline.

🔁 Powered by Looper · runner=reviewer · agent=opencode · An autonomous AI dev team for your GitHub repos.

Location: next/src/lib/export/deck.ts RIGHT lines 47-51

The new 8-second timer still resolves this promise through the same done() path as a real load event, so a slide that never finishes loading is treated as ready and then sent into the capture/retry path anyway. In practice that turns a missing-load failure into a much slower ~9.2s per-slide failure with a less specific downstream error. Please reject or throw on the timeout path instead of resolving it, and only enter the retry/capture logic after an actual load signal.

🔁 Powered by Looper · runner=reviewer · agent=opencode · An autonomous AI dev team for your GitHub repos.

Inline comment could not be anchored: inline anchor is outside the PR diff anchorable ranges

Comment on lines +63 to +72
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await iframeToBlob(iframe, { scale });
} catch (err) {
lastError = err;
if (attempt < maxAttempts) {
// Wait longer before retry — fonts / Tailwind CDN may still be
// inflight even after the load event fired.
await new Promise<void>((r) => setTimeout(r, 1200));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This retry loop currently catches every iframeToBlob() failure, but that helper also throws hard errors like "iframe not ready" and "screenshot failed", not just the transient "preview has no content yet" case. Retrying those permanent failures adds 1.2s of delay per slide and makes the final error harder to interpret without improving the outcome. Please narrow the retry to the specific empty-layout race (for example by checking err instanceof Error && err.message === "preview has no content yet") and rethrow other errors immediately.

🔁 Powered by Looper · runner=reviewer · agent=opencode · An autonomous AI dev team for your GitHub repos.
@lefarcen

Copy link
Copy Markdown

Hey @111wukong! 🎉 Great first contribution — the double-rAF flush + retry approach is the right instinct for the CDN timing problem.

@nettee left a review two days ago with a couple of follow-up items that are worth addressing before this lands:

  1. Timeout path still resolves (deck.ts line ~51): setTimeout(done, 8000) calls the same done() as a successful load event, so a slide that never finishes loading is silently treated as ready and enters the capture loop — you get a slow 9s+ failure with a less-informative error downstream. The fix is to reject the promise (or throw) on the timeout path instead of resolving it, and only enter the retry block when an actual load signal fired.

  2. Retry catches all errors: the current catch (err) inside the loop retries once on any error — including permanent screenshot failures that have nothing to do with a race condition. Consider checking the error type/message and only retrying the "no content" case, so a genuinely broken slide doesn't just take 1.2s longer to fail.

Both are pretty surgical fixes — a follow-up commit should be quick. Let me know if you'd like any guidance on the rejection pattern or error-type narrowing. ❤️

@lefarcen

Copy link
Copy Markdown

Heads-up: issue #66 describes the same PPTX export failure on Edge + Windows 11 (the "preview has no content yet" toast), so this fix should also help that reporter once it lands.

One note on the root cause: the retry approach here helps, but the underlying race is that iframe.contentDocument?.readyState === "complete" can fire on the initial blank document before the srcdoc starts loading — the retry papers over it by giving the srcdoc time to settle on the second attempt. Worth noting in case a more targeted follow-up (dropping the early-return on readyState and always waiting for the load event) is considered later.

Also, issue #66 has a second symptom — the full-slide PDF print producing a garbled layout — that this PR doesn't cover. That one is in exportDeckPrint: the <head> from slides[0].html carries parseDeck's injected body { display:flex; justify-content:center; min-height:100vh } into the print window, which breaks the multi-page layout. Leaving it tracked in #66 for a separate PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk/medium Medium risk change size/S Small change: 20-99 changed lines type/bugfix Bug fix

3 participants