Skip to content

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

Merged
rusackas merged 3 commits into
apache:masterfrom
jenwitteng:upstream-fix-29719-force-render-export
Aug 10, 2026
Merged

rusackas merged 3 commits 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.tsx → Chart.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

@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 90.52632% with 9 lines in your changes missing coverage. Please review.
✅ Project coverage is 66.42%. Comparing base (b413060) to head (47e9bad).

Files with missing lines Patch % Lines
superset-frontend/src/utils/downloadAsPdf.ts 0.00% 5 Missing ⚠️
superset-frontend/src/utils/downloadAsImage.tsx 57.14% 3 Missing ⚠️
superset-frontend/src/utils/downloadUtils.ts 98.38% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #42561      +/-   ##
==========================================
+ Coverage   66.41%   66.42%   +0.01%     
==========================================
  Files        2858     2859       +1     
  Lines      161446   161540      +94     
  Branches    37190    37206      +16     
==========================================
+ Hits       107222   107308      +86     
- Misses      52187    52195       +8     
  Partials     2037     2037              
Flag Coverage Δ
javascript 73.29% <90.52%> (+0.02%) ⬆️

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.

@jenwitteng

Copy link
Copy Markdown
Contributor Author

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

yes merge them together is better i will do it.

jenwitteng pushed a commit to jenwitteng/superset that referenced this pull request Aug 6, 2026
…ard export

apache#42561 fixes the client-side Download as Image/PDF path exporting virtualized
rows as loading spinners, but forces every row into view in one instant
`window` event with no batching. On a dashboard with hundreds of charts,
that reintroduces the exact thundering-herd load DASHBOARD_VIRTUALIZATION
exists to prevent, just moved from page-load time to export time, with no
concurrency limit and no signal to the user that anything expensive is
happening.

This adds:

- Row-level batching. Rows now carry a `data-row-id` attribute; the export
  path force-renders them in groups of 5 rather than all at once, waiting
  (with a bounded per-batch timeout) for each batch before moving to the
  next. A chart stuck in one batch doesn't stall every later batch, since
  the per-batch wait is scoped to that batch's own row elements rather than
  the whole container. Dashboards small enough to fit in one batch keep the
  original single-event behavior unchanged.
- An upfront info toast ("Preparing N charts for export...") when a
  multi-batch export starts, so the user knows a large dashboard export is
  underway rather than wondering if the click did nothing. A live
  per-batch progress bar felt like a bigger UI commitment than this
  follow-up warranted; `onProgress` is exposed as an extensibility hook if
  that's wanted later.
- Test coverage for Row.tsx's force-in-view/restore-virtualization event
  handling, which had none before this PR despite being the component that
  does the actual work.
- Test coverage for the batching logic itself: grouping, per-batch timeout
  isolation, and the existing single-pass/flag-off paths (confirmed
  unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@pull-request-size pull-request-size Bot added size/XL and removed size/L labels Aug 6, 2026
@netlify

netlify Bot commented Aug 6, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for superset-docs-preview ready!

Name Link
🔨 Latest commit 47e9bad
🔍 Latest deploy log https://app.netlify.com/projects/superset-docs-preview/deploys/6a792f502545650008c2b76f
😎 Deploy Preview https://deploy-preview-42561--superset-docs-preview.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@bito-code-review

bito-code-review Bot commented Aug 6, 2026 •

Copy link
Copy Markdown
Contributor

Code Review Agent Run #21420b

Actionable Suggestions - 0
Review Details
  • Files reviewed - 4 · Commit Range: 9b31f3b..8569629
    • superset-frontend/src/dashboard/components/gridComponents/Row/Row.test.tsx
    • superset-frontend/src/dashboard/components/gridComponents/Row/Row.tsx
    • 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

@rusackas

rusackas commented Aug 6, 2026

Copy link
Copy Markdown
Member

Once CI is passing here, I think we'll be good to go (though I'll give it a final pass). Holler if you want help with that.

jenwitteng and others added 3 commits August 9, 2026 18:52
…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>
…ard export

apache#42561 fixes the client-side Download as Image/PDF path exporting virtualized
rows as loading spinners, but forces every row into view in one instant
`window` event with no batching. On a dashboard with hundreds of charts,
that reintroduces the exact thundering-herd load DASHBOARD_VIRTUALIZATION
exists to prevent, just moved from page-load time to export time, with no
concurrency limit and no signal to the user that anything expensive is
happening.

This adds:

- Row-level batching. Rows now carry a `data-row-id` attribute; the export
  path force-renders them in groups of 5 rather than all at once, waiting
  (with a bounded per-batch timeout) for each batch before moving to the
  next. A chart stuck in one batch doesn't stall every later batch, since
  the per-batch wait is scoped to that batch's own row elements rather than
  the whole container. Dashboards small enough to fit in one batch keep the
  original single-event behavior unchanged.
- An upfront info toast ("Preparing N charts for export...") when a
  multi-batch export starts, so the user knows a large dashboard export is
  underway rather than wondering if the click did nothing. A live
  per-batch progress bar felt like a bigger UI commitment than this
  follow-up warranted; `onProgress` is exposed as an extensibility hook if
  that's wanted later.
- Test coverage for Row.tsx's force-in-view/restore-virtualization event
  handling, which had none before this PR despite being the component that
  does the actual work.
- Test coverage for the batching logic itself: grouping, per-batch timeout
  isolation, and the existing single-pass/flag-off paths (confirmed
  unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bound the combined per-batch and final whole-container waits to a single
overall budget so a dashboard with many stalled batches can't burn a full
per-batch timeout on each one plus another full timeout on the final
check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@rusackas
rusackas force-pushed the upstream-fix-29719-force-render-export branch from 8569629 to 47e9bad Compare August 10, 2026 01:54
@rusackas
rusackas merged commit 4defdba into apache:master Aug 10, 2026
75 checks passed
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/XL

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

3 participants