Skip to content

fix desktop image export for runtime JSX - #6725

Open
mturac wants to merge 2 commits into
nexu-io:mainfrom
mturac:izon/fix-image-export-ready-6658
Open

fix desktop image export for runtime JSX#6725
mturac wants to merge 2 commits into
nexu-io:mainfrom
mturac:izon/fix-image-export-ready-6658

Conversation

@mturac

@mturac mturac commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Fixes #6658

Why

Issue #6658 reports that od export can save a blank PNG when a prototype uses Babel standalone to compile JSX in the browser. The existing export wait covers fonts and images, but it can finish before the runtime JSX app mounts. The command then reports success even though the captured page is still empty.

This follows up on the confirmed bug with a narrow readiness check for runtime JSX documents. Static HTML, CSS, and canvas exports keep the existing fast path.

What users will see

Image export now waits for a runtime JSX app to mount before capture. If the app or late resources do not become ready within 30 seconds, export fails with a clear timeout instead of writing a blank image.

PDF export keeps its existing behavior.

Surface area

  • UI: new page / dialog / panel / menu item / setting / empty state in apps/web or apps/desktop (including Electron menu bar)
  • Keyboard shortcut: new or changed
  • CLI / env var: new od subcommand or flag, new tools-dev / tools-pack flag, or new OD_* env var
  • API / contract: new /api/* endpoint, new SSE event, or changed shape in packages/contracts
  • Extension point: new entry under skills/, design-systems/, design-templates/, or craft/, or change to the skills protocol
  • i18n keys: added new translation keys (see TRANSLATIONS.md for the locale workflow)
  • New top-level dependency: adding any new entry to the root package.json (dependencies or devDependencies); workspace-package package.json files are out of scope. Include a paragraph on what we get vs. what bytes we ship (see CONTRIBUTING.md → Code style)
  • Default behavior change: changes what existing users experience without opting in (default model, default setting, file/SQLite schema, auto-network on startup, auto-install)
  • None: internal refactor, docs, tests, or translation update only

Screenshots

Not applicable. This changes the headless export path and does not add a UI entry point.

Bug fix verification

  • Test path: apps/desktop/tests/main/artifact-export-readiness.test.ts
  • Red on main: not run as a separate checkout. The test covers the new readiness boundary, which does not exist on main; the current code moves from resource settling directly to capture.
  • Green on this branch: yes. The tests cover delayed Babel/JSX mount, an empty render root, timeout errors, resource settling, image orchestration, and PDF isolation.

Validation

  • pnpm guard
  • pnpm typecheck: the desktop package and other checked workspaces passed, but the root command stopped in apps/web because the local three module is not installed
  • pnpm --filter @open-design/desktop typecheck
  • pnpm --filter @open-design/desktop build
  • ./apps/desktop/node_modules/.bin/vitest run --root apps/desktop -c vitest.config.ts tests/main/artifact-export-readiness.test.ts tests/main/artifact-export-image-height.test.ts: 10 tests passed
  • pnpm --filter @open-design/desktop test: 185 tests passed; 13 existing Electron-dependent suites could not start because the local Electron binary is not installed
  • git diff --cached --check

@mturac
mturac requested a review from a team as a code owner August 10, 2026 18:46

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fa4a09fe4f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/desktop/src/main/artifact-export-readiness.ts Outdated
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Visual regression review

Head: e255b48 · Base: bf71452

3 changed · 43 unchanged · 0 new without baseline · 0 failed

Changed cases

Case Main PR Diff
visual-home-plugin-use-staged
1,670 px (0.13%)
main pr diff
visual-home-staged-attachment
4,150 px (0.32%)
main pr diff
visual-topbar-execution-switcher
1,302 px (0.10%)
main pr diff
Unchanged cases
Case Main PR Diff
visual-avatar-local-agent-list
57 px (0.00%)
main pr diff
visual-avatar-local-agent-list-panel
0 px (0.00%)
main pr diff
visual-avatar-menu
0 px (0.00%)
main pr diff
visual-avatar-menu-panel
0 px (0.00%)
main pr diff
visual-avatar-open-design-model-picker
162 px (0.01%)
main pr diff
visual-critical-settings
0 px (0.00%)
main pr diff
visual-critical-workspace
254 px (0.02%)
main pr diff
visual-critical-workspace-preview
54 px (0.00%)
main pr diff
visual-design-system-detail
0 px (0.00%)
main pr diff
visual-design-systems
0 px (0.00%)
main pr diff
visual-home
69 px (0.01%)
main pr diff
visual-home-catalog
0 px (0.00%)
main pr diff
visual-home-context-picker
136 px (0.01%)
main pr diff
visual-home-context-picker-popover
0 px (0.00%)
main pr diff
visual-home-plugin-filter
0 px (0.00%)
main pr diff
visual-home-plugin-use-with-query
0 px (0.00%)
main pr diff
visual-integrations-use-everywhere
0 px (0.00%)
main pr diff
visual-new-project-modal
0 px (0.00%)
main pr diff
visual-plugin-details
0 px (0.00%)
main pr diff
visual-plugin-share-menu
0 px (0.00%)
main pr diff

Visual diff is advisory only and does not block merging.

@lefarcen lefarcen added size/L PR changes 300-700 lines risk/high High risk: apps/desktop, daemon, auth, migration, workflows, package deps type/bugfix Bug fix labels Aug 11, 2026
@lefarcen
lefarcen requested a review from mrcfps August 11, 2026 06:42
@lefarcen lefarcen added the needs-validation Runtime change detected; needs human or /explore agent validation. label Aug 11, 2026
@lefarcen

Copy link
Copy Markdown
Contributor

Thanks for the focused fix here — I’ve marked this for QA validation because it changes the headless image export path users rely on. Once the review/product gates clear, we’ll queue a manual validation pass before merge.

@mrcfps mrcfps left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks @mturac — this is a focused, well-scoped fix for #6658. Waiting for runtime JSX mount (including the repo-owned .ui-kit-loading placeholder) before capture, while keeping static HTML and PDF on the fast path, matches the bug and the design-system contract.

I have one non-blocking lifecycle concern on the new resource-timeout wrapper (inline). The readiness script, host boundary errors, and test matrix otherwise look solid.

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

Comment on lines +118 to +135
export async function waitForArtifactResources(
operation: Promise<void>,
timeoutMs = DEFAULT_IMAGE_RENDER_TIMEOUT_MS,
): Promise<void> {
const timeout = validatedTimeout(timeoutMs);
let timer: ReturnType<typeof setTimeout> | undefined;
const timedOut = new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error(`Image export timed out waiting for late resources (${timeout}ms)`)),
timeout,
);
});
try {
await Promise.race([operation, timedOut]);
} finally {
if (timer) clearTimeout(timer);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: orphaned operation after Promise.race timeout can crash desktop

Problem: When timedOut wins, operation is left pending with no handler. Callers pass waitForPrintableContent(window) here (and again from renderImage after resize). On timeout, exportArtifact returns the error and then window.destroy()s in finally. Destroying the BrowserWindow while webContents.executeJavaScript is still in flight typically rejects that promise — and nothing is awaiting it anymore.

Why it matters: Desktop installs a fatal unhandledRejection filter (apps/desktop/src/main/uncaught-exception.ts) that logs non-harmless rejections and rethrows via setImmediate, which takes down the main process. So a slow/stuck resource settle that correctly fails the export with a timeout can still crash the whole desktop app a tick later — worse for agent/od export workflows than a clean { ok: false } response.

Evidence:

  • await Promise.race([operation, timedOut]) does not attach a rejection handler to the loser.
  • exportArtifact always destroys the window after a thrown timeout.
  • Desktop treats unhandled rejections as fatal (not swallowed).

Suggested change: Attach a sink before racing so a late reject/resolve cannot become unhandled, and keep the race semantics:

export async function waitForArtifactResources(
  operation: Promise<void>,
  timeoutMs = DEFAULT_IMAGE_RENDER_TIMEOUT_MS,
): Promise<void> {
  const timeout = validatedTimeout(timeoutMs);
  let timer: ReturnType<typeof setTimeout> | undefined;
  const timedOut = new Promise<never>((_, reject) => {
    timer = setTimeout(
      () => reject(new Error(`Image export timed out waiting for late resources (${timeout}ms)`)),
      timeout,
    );
  });
  // Prevent destroy-after-timeout from surfacing as a fatal unhandledRejection.
  void operation.catch(() => {});
  try {
    await Promise.race([operation, timedOut]);
  } finally {
    if (timer) clearTimeout(timer);
  }
}

A small unit test that rejects operation after the timeout wins (and asserts no unhandled rejection / that the timeout error is still thrown) would lock this in.

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

@xxiaoxiong xxiaoxiong left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM. Solves #6658 cleanly. The blank PNG edge case for Babel-compiled runtime JSX comes from the existing waitForPrintableContent finishing before the JSX app actually mounts. The new artifact-export-readiness.ts adds a runtime-JSX-specific readiness check that only delays the image path (PDF keeps the existing fast path):

  1. artifactRenderReadinessScript (injected into isolated world 1001): if no script[type="text/babel"|"text/jsx"], resolve(true) immediately — preserves the static HTML / canvas / CSS fast path. Otherwise probes #root / #app / [data-reactroot] / [data-v-app], records initial .ui-kit-loading placeholder, and waits via MutationObserver + double-rAF until the placeholder is replaced OR a meaningful child mounts. 30s ceiling on the readiness promise.

  2. waitForArtifactContent wires PDF to settleResources() only, image to readiness first then resources. Both resource waits now also have an explicit late-resources timeout so a hanging requestAnimationFrame / late fetch fails the export instead of leaving a blank image.

  3. Image renderImage() second waitForPrintableContent after content resize now goes through waitForArtifactResources so it is also bounded.

Tests cover: isolated world call contract, no-JSX fast path, runtime JSX waiting for mount, MutationObserver settling via double-rAF, timeout error message, PDF path bypass, late-resource timeout, and resize-after-mount path. artifactRenderTarget abstraction makes the webContents mockable.

@xxiaoxiong xxiaoxiong left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM. Solves #6658 cleanly. The blank PNG edge case for Babel-compiled runtime JSX comes from the existing waitForPrintableContent finishing before the JSX app actually mounts. The new artifact-export-readiness.ts adds a runtime-JSX-specific readiness check that only delays the image path (PDF keeps the existing fast path): (1) Readiness script injected into isolated world 1001: if no script[type=text/babel|text/jsx], resolve(true) immediately—preserves the static HTML / canvas / CSS fast path. Otherwise probes #root / #app / [data-reactroot] / [data-v-app], records initial .ui-kit-loading placeholder, and waits via MutationObserver + double-rAF until the placeholder is replaced OR a meaningful child mounts. 30s ceiling. (2) waitForArtifactContent wires PDF to settleResources only, image to readiness first then resources. Both resource waits now also have an explicit late-resources timeout. (3) Image renderImage second waitForPrintableContent after content resize now goes through waitForArtifactResources so it is also bounded. Tests cover: isolated world call contract, no-JSX fast path, runtime JSX waiting for mount, MutationObserver settling via double-rAF, timeout error, PDF path bypass, late-resource timeout, and resize-after-mount path. artifactRenderTarget abstraction makes the webContents mockable. A+ fix.

@xxiaoxiong xxiaoxiong left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — runtime JSX export readiness is a genuinely hard problem and this PR handles it cleanly.

Verified:

  • artifactRenderReadinessScript(timeout): runs in the renderer's isolated world (ID 1001) and returns a Promise. Only treats <script type='text/babel'|'text/jsx'> artifacts as runtime-JSX; static-rendered artifacts resolve true immediately. MutationObserver on document.documentElement (characterData + childList + subtree) catches React/Vue mounts. To avoid flapping on intermediate states, it requires hasMountedContent() to be true across two requestAnimationFrame ticks before resolving — classic 'paint settled' check.
  • Placeholder detection: renderPlaceholder(root) recognises the ui-kit-loading class so an initial skeleton isn't mistaken for content. Captured per-root in an initialPlaceholders Map at script start, then compared after each mutation.
  • Multiple roots: probes #root, #app, [data-reactroot], [data-v-app] (covers React 16/18 + Vue 2/3 conventions). Falls back to document.body direct children when none match.
  • Timeout path: finish(false) after timeout ms → caller (waitForRenderedArtifactContent) throws 'Image export timed out waiting for runtime-rendered content (Xms)'. validatedTimeout rejects non-finite / non-positive inputs upfront.
  • waitForArtifactContent(format, target, settleResources): PDF bypasses the runtime JSX wait (print-ready != React-mounted); image path runs waitForRenderedArtifactContent then races settleResources() against a late-resource timeout. Two distinct failure modes, two distinct error messages. Good.
  • jsdom added to apps/desktop devDeps — appropriate for unit-testing the readiness script without booting Electron.

One stylistic observation (non-blocking): the injected script is a String-built IIFE because it has to run in the renderer's isolated world; the inline var-style + ES5 syntax is intentional for compatibility with the isolated-world script-injection API. Don't refactor to ES2015+ syntax without confirming the world's script parser accepts it.

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

Labels

needs-validation Runtime change detected; needs human or /explore agent validation. risk/high High risk: apps/desktop, daemon, auth, migration, workflows, package deps size/L PR changes 300-700 lines type/bugfix Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] 0.18.1: od export writes a blank image for prototypes that compile JSX at runtime (Babel standalone) — capture happens before React mounts

4 participants