Skip to content

fix(dashboard): force-render virtualized charts before client-side export - #42561

Open
jenwitteng wants to merge 1 commit into
apache:masterfrom
jenwitteng:upstream-fix-29719-force-render-export
Open

fix(dashboard): force-render virtualized charts before client-side export#42561
jenwitteng wants to merge 1 commit into
apache:masterfrom
jenwitteng:upstream-fix-29719-force-render-export

Conversation

@jenwitteng

Copy link
Copy Markdown
Contributor

SUMMARY

When DASHBOARD_VIRTUALIZATION is enabled, dashboard rows more than a viewport away are unmounted and replaced with a <Loading> spinner (Row.tsxChart.tsx shouldRenderChart). The client-side Download as Image / Download as PDF path captures the live DOM, so those off-screen charts are exported as loading spinners instead of the actual charts.

The existing isCurrentUserBot() (window.navigator.webdriver) bypass only disables virtualization for server-side headless capture (scheduled reports, thumbnails, the webdriver screenshot endpoints). A real user's browser has webdriver === false, so the client-side downloadAsImage('.dashboard') / downloadAsPdf('.dashboard') handlers — used by default when ENABLE_DASHBOARD_SCREENSHOT_ENDPOINTS / ENABLE_DASHBOARD_DOWNLOAD_WEBDRIVER_SCREENSHOT are off — capture unmounted rows.

This adds a small "force a full render before capturing" contract used only during export:

  • src/utils/downloadUtils.ts (new): forceLoadAllCharts() dispatches a superset-force-all-in-view window event (gated on DASHBOARD_VIRTUALIZATION), polls until the container's .loading spinners clear (60s timeout + a warning toast if it times out), and returns whether virtualization was active. restoreVirtualization() dispatches superset-restore-virtualization.
  • Row.tsx: on force-in-view, disconnect the IntersectionObservers and render; on restore, re-observe. No change to normal (non-export) virtualization behavior.
  • downloadAsPdf / downloadAsImage: force-load before capture and restore on every exit path — including the ag-grid "still loading" early return and the error paths — so a failed export never leaves virtualization disabled for the rest of the session.

Behavior is unchanged when DASHBOARD_VIRTUALIZATION is off, and unchanged for server-side/headless capture.

Context and maintainer discussion: #29719 (comment)

BEFORE/AFTER SCREENSHOTS OR ANIMATED GIF

Before: Exporting a tall dashboard (more than ~1 viewport of charts) as Image/PDF renders the on-screen charts followed by loading spinners for every off-screen chart.

After: All charts render in the exported image/PDF regardless of scroll position.

TESTING INSTRUCTIONS

  1. Ensure DASHBOARD_VIRTUALIZATION is enabled (default on).
  2. Open a dashboard tall enough that some charts are off-screen (e.g. 20+ charts in one tab), and let the visible charts finish loading.
  3. Use the dashboard menu → Download → Export to Image and Download → Export to PDF.
  4. The exported file should contain every chart rendered, not loading spinners for the off-screen ones.
  5. Repeat with DASHBOARD_VIRTUALIZATION disabled — behavior should be unchanged.

Unit tests: src/utils/downloadUtils.test.ts (force/restore event dispatch, timeout warning, flag-off no-op). Existing downloadAsImage.test.ts and Row.test.tsx suites pass unchanged.

ADDITIONAL INFORMATION

  • Has associated issue: Fixes Upon download as PDF, charts on a long dashboard render as loading image #29719
  • Required feature flags:
  • Changes UI
  • Includes DB Migration (follow approval process in SIP-59)
    • Migration is atomic, supports rollback & is backwards-compatible
    • Confirm DB migration upgrade and downgrade tested
    • Runtime estimates and downtime expectations provided
  • Introduces new feature or API
  • Removes existing feature or API

…port

When DASHBOARD_VIRTUALIZATION is enabled, dashboard rows more than a
viewport away are unmounted and replaced with a loading spinner. The
client-side "Download as Image/PDF" path captures the live DOM, so those
off-screen charts are exported as loading spinners instead of the actual
charts. The isCurrentUserBot() bypass only covers server-side (headless)
screenshots, so this affects every real-user client-side export.

Add a small force-render contract used only during export:

- downloadUtils.ts: forceLoadAllCharts() dispatches a
  superset-force-all-in-view window event (gated on the feature flag),
  polls until the container's .loading spinners clear (with a timeout and
  a warning toast), and returns whether virtualization was active.
  restoreVirtualization() dispatches superset-restore-virtualization.
- Row.tsx: on force-in-view, disconnect the IntersectionObservers and
  render; on restore, re-observe.
- downloadAsPdf / downloadAsImage: force-load before capture and restore
  on every exit path (including the ag-grid "still loading" early return
  and error paths) so a failed export never leaves virtualization
  disabled for the rest of the session.

Fixes: apache#29719

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Jenwit Amonpongitsara <jenwit.amonpongitsara@agoda.com>
@bito-code-review

bito-code-review Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #766331

Actionable Suggestions - 0
Additional Suggestions - 1
  • superset-frontend/src/dashboard/components/gridComponents/Row/Row.tsx - 1
    • Missing tests for export event handlers · Line 219-245
      The new `handleForceInView` (line 219) and `handleRestoreVirtualization` (line 224) handlers have no test coverage. Without assertions that verify disconnect/reconnect behavior and `isInView` state, bugs in this export-coordination logic will go undetected.
Review Details
  • Files reviewed - 6 · Commit Range: 9b31f3b..9b31f3b
    • superset-frontend/src/dashboard/components/gridComponents/Row/Row.tsx
    • superset-frontend/src/dashboard/constants.ts
    • superset-frontend/src/utils/downloadAsImage.tsx
    • superset-frontend/src/utils/downloadAsPdf.ts
    • superset-frontend/src/utils/downloadUtils.test.ts
    • superset-frontend/src/utils/downloadUtils.ts
  • Files skipped - 0
  • Tools
    • Eslint (Linter) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers a full AI review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

@dosubot dosubot Bot added change:frontend Requires changing the frontend dashboard:export Related to exporting dashboards labels Jul 29, 2026
Comment on lines +82 to +84
export function restoreVirtualization(): void {
window.dispatchEvent(new Event(RESTORE_VIRTUALIZATION_EVENT));
}

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.

Suggestion: The restore event is global and has no ownership or nesting protection. If two exports overlap, the first export to finish dispatches RESTORE_VIRTUALIZATION_EVENT while the second is still capturing, causing rows to reconnect their observers and potentially unmount off-screen charts during the second export. Coordinate exports with a shared lock or reference count so virtualization is restored only after the final active export completes. [race condition]

Severity Level: Major ⚠️
- ❌ Concurrent exports can capture loading spinners.
- ❌ Dashboard or chart export output may be incomplete.
- ⚠️ Global row state is restored before all captures finish.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset-frontend/src/utils/downloadUtils.ts
**Line:** 82:84
**Comment:**
	*Race Condition: The restore event is global and has no ownership or nesting protection. If two exports overlap, the first export to finish dispatches `RESTORE_VIRTUALIZATION_EVENT` while the second is still capturing, causing rows to reconnect their observers and potentially unmount off-screen charts during the second export. Coordinate exports with a shared lock or reference count so virtualization is restored only after the final active export completes.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

REAL, but genuinely MINOR

Confirmed reachable: restoreVirtualization() fires a global event with no ref-count, and the menu handlers (DownloadMenuItems/index.tsx:93-111) fire the exports without an isExporting guard. If you deliberately re-trigger a second export during the
first's multi-second capture, RESTORE(A) can revert far-off rows to spinners mid-capture of export B.

But it's bounded: requires DASHBOARD_VIRTUALIZATION + a tall (>4-viewport) dashboard + a deliberate second export in the capture window; only the second export degrades, the user gets an explicit "export may be incomplete" warning, and it self-heals
(every exit path restores). Not a merge blocker.

⚠️ Important: the bots' proposed ref-count fix is the wrong fix — one missed decrement on any exit path (e.g. the ag-grid early-return) would pin virtualization forced-on for the entire session, a worse bug. The correct hardening is an in-flight guard
that disables/early-returns the Export menu items while an export runs.

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.

Customized review instruction saved!

Instruction:

For export concurrency, prevent overlapping exports with an in-flight guard that disables or early-returns duplicate export actions; do not use a shared virtualization reference count that could remain stuck after a missed decrement.

Applied to:

  • superset-frontend/src/utils/downloadUtils.ts

💡 To manage or update this instruction, visit: CodeAnt AI Settings

@bito-code-review

Copy link
Copy Markdown
Contributor

The flagged race condition is correct. Because RESTORE_VIRTUALIZATION_EVENT is dispatched globally without tracking active exports, concurrent exports will interfere with each other's virtualization state.

To resolve this, you can implement a simple reference counter in superset-frontend/src/utils/downloadUtils.ts to ensure virtualization is only restored when the last active export finishes.

Proposed Fix

Update downloadUtils.ts to track active exports:

let activeExportCount = 0;

export async function forceLoadAllCharts(container: Element): Promise<boolean> {
  const useVirtualization = isFeatureEnabled(FeatureFlag.DashboardVirtualization);
  if (useVirtualization) {
    if (activeExportCount === 0) {
      window.dispatchEvent(new Event(FORCE_IN_VIEW_EVENT));
    }
    activeExportCount++;
    const allLoaded = await waitForChartsToLoad(container);
    // ... (rest of logic)
  }
  return useVirtualization;
}

export function restoreVirtualization(): void {
  activeExportCount = Math.max(0, activeExportCount - 1);
  if (activeExportCount === 0) {
    window.dispatchEvent(new Event(RESTORE_VIRTUALIZATION_EVENT));
  }
}

Would you like me to fetch all other comments on this PR to validate and implement fixes for them as well?

superset-frontend/src/utils/downloadUtils.ts

let activeExportCount = 0;

export async function forceLoadAllCharts(container: Element): Promise<boolean> {
  const useVirtualization = isFeatureEnabled(FeatureFlag.DashboardVirtualization);
  if (useVirtualization) {
    if (activeExportCount === 0) {
      window.dispatchEvent(new Event(FORCE_IN_VIEW_EVENT));
    }
    activeExportCount++;
    const allLoaded = await waitForChartsToLoad(container);
    // ... (rest of logic)
  }
  return useVirtualization;
}

export function restoreVirtualization(): void {
  activeExportCount = Math.max(0, activeExportCount - 1);
  if (activeExportCount === 0) {
    window.dispatchEvent(new Event(RESTORE_VIRTUALIZATION_EVENT));
  }
}

Comment on lines +219 to +223
const handleForceInView = () => {
observerEnabler?.disconnect();
observerDisabler?.disconnect();
setIsInView(true);
};

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.

Suggestion: The force handler unconditionally sets isInView to true without checking isComponentVisibleRef.current. For rows belonging to hidden tabs or otherwise invisible dashboard components, this bypasses the visibility guard used by the normal observer callbacks; Chart then treats the row as renderable and can trigger queries for charts that should remain inactive during export. Preserve the visibility check when forcing rows into view, or explicitly exclude invisible components from the force-render path. [incorrect condition logic]

Severity Level: Major ⚠️
- ⚠️ Inactive dashboard tabs may issue chart queries during export.
- ⚠️ Exports can trigger unnecessary hidden-chart rendering.
- ⚠️ Large tabbed dashboards may incur extra load.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset-frontend/src/dashboard/components/gridComponents/Row/Row.tsx
**Line:** 219:223
**Comment:**
	*Incorrect Condition Logic: The force handler unconditionally sets `isInView` to `true` without checking `isComponentVisibleRef.current`. For rows belonging to hidden tabs or otherwise invisible dashboard components, this bypasses the visibility guard used by the normal observer callbacks; `Chart` then treats the row as renderable and can trigger queries for charts that should remain inactive during export. Preserve the visibility check when forcing rows into view, or explicitly exclude invisible components from the force-render path.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

  • The dashboard grid Chart is wrapped in React.memo with a comparator (gridComponents/Chart/Chart.tsx:838-839) that short-circuits whenever !isComponentVisible. A hidden-tab row flipping isInView is swallowed at the memo boundary — the inner chart
    never re-renders, shouldRenderChart/runQuery are never re-evaluated, no mount, no query.
  • Inactive antd tab panes are display:none/unmounted and aren't in the .dashboard capture anyway.

Investigator + both skeptics unanimous: not real, no fix needed. Adding the guard would be a behavioral no-op.

@codecov

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 72.72727% with 15 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.27%. Comparing base (dac69f9) to head (9b31f3b).
⚠️ Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
...rc/dashboard/components/gridComponents/Row/Row.tsx 56.25% 7 Missing ⚠️
superset-frontend/src/utils/downloadAsPdf.ts 0.00% 5 Missing ⚠️
superset-frontend/src/utils/downloadAsImage.tsx 57.14% 3 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##           master   #42561   +/-   ##
=======================================
  Coverage   65.27%   65.27%           
=======================================
  Files        2797     2798    +1     
  Lines      157994   158048   +54     
  Branches    36104    36114   +10     
=======================================
+ Hits       103125   103169   +44     
- Misses      52874    52884   +10     
  Partials     1995     1995           
Flag Coverage Δ
javascript 71.40% <72.72%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@rusackas rusackas left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks @jenwitteng, checked the CodeAnt hidden-tab thread myself: Chart's memo comparator returns true whenever !isComponentVisible regardless of isInView (Chart.tsx around 804-823), so a hidden-tab row flipping isInView never reaches shouldRenderChart. Confirms your read that it's a false positive.

Agree on the restore-event race too, bounded enough not to block.

Wouldn't mind a quick test for Row's force/restore handlers before this merges, if you have time (or don't mind me adding one) but not blocking either. LGTM!

@rusackas

rusackas commented Aug 5, 2026

Copy link
Copy Markdown
Member

This is a solid improvement over what folks are doing today (globally disabling DASHBOARD_VIRTUALIZATION, which breaks more than just export). Couple of non-blocking follow-ups worth tracking though.

downloadUtils.test.ts covers the dispatch/timeout logic well, but Row.tsx is where the actual work happens, tearing down and re-attaching the observers on these events, and it has no test coverage for that.

Also, forceLoadAllCharts puts every row in view at once, no batching. Fine for tens of charts, but on a dashboard with hundreds it's the same thundering-herd load virtualization exists to prevent, just moved to export time.

I'll open a follow-up PR for both, plus maybe some way to signal a large export is in progress. Thanks for chasing this down, @jenwitteng!

@rusackas

rusackas commented Aug 5, 2026

Copy link
Copy Markdown
Member

Opened #42786 for this, batches the force-render in groups of 5 instead of all at once, plus the missing Row.tsx test coverage. Also added an upfront toast for large exports ("Preparing N charts...") since a full progress bar felt like more UI than this needed, but left an onProgress hook in case that's wanted down the line.

@jenwitteng

Copy link
Copy Markdown
Contributor Author

ok Thanks @rusackas

@rusackas

rusackas commented Aug 5, 2026

Copy link
Copy Markdown
Member

@jenwitteng do you want to merge that PR into this one, then merge them together? Curious your thoughts on the approach of it.

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

Labels

change:frontend Requires changing the frontend dashboard:export Related to exporting dashboards size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Upon download as PDF, charts on a long dashboard render as loading image

2 participants