You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by el...@apache.org on 2026/08/11 20:45:50 UTC
(superset) branch fix-reports-echarts-paint-readiness created (now 5c0364099c4)
This is an automated email from the ASF dual-hosted git repository.
eschutho pushed a change to branch fix-reports-echarts-paint-readiness
in repository https://gitbox.apache.org/repos/asf/superset.git
at 5c0364099c4 fix(reports): wait for ECharts paint before capturing report screenshots
This branch includes the following new commits:
new 5c0364099c4 fix(reports): wait for ECharts paint before capturing report screenshots
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(superset) 01/01: fix(reports): wait for ECharts paint before capturing report screenshots
Posted by el...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
eschutho pushed a commit to branch fix-reports-echarts-paint-readiness
in repository https://gitbox.apache.org/repos/asf/superset.git
commit 5c0364099c4735d23bf001f7d8654c69fcd61605
Author: Elizabeth Thompson <es...@gmail.com>
AuthorDate: Tue Aug 11 20:45:19 2026 +0000
fix(reports): wait for ECharts paint before capturing report screenshots
Scheduled dashboard reports could deliver a screenshot with blank chart
regions. The readiness gate treated a holder as "rendered" once
.slice_container was present, but that mounts when data arrives -- before the
ECharts canvas is drawn -- so the capture could fire on an empty canvas.
Tag the ECharts host .echarts-host and add .echarts-render-finished only in
the ECharts finished event, the single signal that the canvas is fully
painted (chartStatus/onRenderSuccess both fire pre-paint). The readiness gate
now treats a holder with an unpainted host as non-terminal (mounted_unpainted)
and waits for it, failing loud on timeout instead of capturing blank. Applies
to the dashboard gate and the single chart-container gate.
Only ECharts hosts are gated: DOM/SVG vizzes paint on commit and non-ECharts
canvas vizzes have no .echarts-host, so they are unaffected.
Co-Authored-By: Claude Opus 4.8 <no...@anthropic.com>
---
.../plugin-chart-echarts/src/components/Echart.tsx | 26 ++++++++++++-
superset/utils/screenshot_utils.py | 43 ++++++++++++++++++++--
tests/unit_tests/utils/test_screenshot_utils.py | 19 ++++++++++
3 files changed, 84 insertions(+), 4 deletions(-)
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx
index 496882780fa..3306f0a4e84 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx
@@ -138,6 +138,15 @@ export function isReportScreenshotMode(): boolean {
}
}
+// Report-screenshot readiness contract (see superset/utils/screenshot_utils.py).
+// `echarts-host` marks the canvas host element; `echarts-render-finished` is
+// toggled OFF before each setOption and ON in the ECharts `finished` event --
+// the only signal that the canvas is fully painted (chartStatus/onRenderSuccess
+// both fire pre-paint). The readiness gate treats a host that lacks
+// `echarts-render-finished` as not-yet-painted so it never captures a blank chart.
+export const ECHARTS_HOST_CLASS = 'echarts-host';
+export const ECHARTS_RENDER_FINISHED_CLASS = 'echarts-render-finished';
+
function Echart(
{
width,
@@ -201,6 +210,11 @@ function Echart(
width,
height,
});
+ // Paint marker for the report-screenshot readiness gate. `finished`
+ // is the only event that guarantees the canvas is fully drawn.
+ chartRef.current.on('finished', () => {
+ divRef.current?.classList.add(ECHARTS_RENDER_FINISHED_CLASS);
+ });
}
// did mount
handleSizeChange({ width, height });
@@ -321,6 +335,9 @@ function Echart(
}
)?.dataZoom
: undefined;
+ // Clear the paint marker before (re)drawing; the `finished` handler
+ // re-adds it once the new frame is fully rendered.
+ divRef.current?.classList.remove(ECHARTS_RENDER_FINISHED_CLASS);
chartRef.current?.setOption(themedEchartOptions, {
notMerge,
replaceMerge: notMerge ? undefined : ['series'],
@@ -412,7 +429,14 @@ function Echart(
handleSizeChange({ width, height });
}, [width, height, handleSizeChange]);
- return <Styles ref={divRef} height={height} width={width} />;
+ return (
+ <Styles
+ ref={divRef}
+ className={ECHARTS_HOST_CLASS}
+ height={height}
+ width={width}
+ />
+ );
}
export default forwardRef(Echart);
diff --git a/superset/utils/screenshot_utils.py b/superset/utils/screenshot_utils.py
index 121fc6b0443..365ff5b5624 100644
--- a/superset/utils/screenshot_utils.py
+++ b/superset/utils/screenshot_utils.py
@@ -144,6 +144,21 @@ TERMINAL_MARKER_SELECTOR = (
)
CHART_ID_CLASS_PATTERN = r"\bdashboard-chart-id-(\d+)\b"
+# ECharts paint marker. The frontend
+# (plugins/plugin-chart-echarts/src/components/Echart.tsx) tags the canvas host
+# ``.echarts-host`` and adds ``.echarts-render-finished`` only in the ECharts
+# ``finished`` event -- the sole signal that the canvas is fully painted.
+# ``.slice_container`` alone is a pre-paint signal (it mounts when data arrives,
+# before the canvas is drawn; chartStatus/onRenderSuccess fire pre-paint too), so
+# a holder that still contains an unpainted host is treated as not-yet-rendered and
+# the report screenshot waits for it instead of capturing a blank chart. Only
+# ECharts hosts are gated; DOM/SVG vizzes paint on commit and non-ECharts canvas
+# vizzes (deck.gl/mapbox/etc.) have no ``.echarts-host`` so they are unaffected.
+ECHARTS_UNPAINTED_HOST_SELECTOR = r".echarts-host:not(.echarts-render-finished)"
+CHART_ERROR_OR_EMPTY_SELECTOR = (
+ f"{ALERT_SELECTOR}, {EMPTY_SELECTOR}, {MISSING_CHART_SELECTOR}"
+)
+
# Shared body for holder readiness and timeout diagnostics. A holder is ready
# only after a terminal marker appears and its loading marker disappears.
UNREADY_CHART_HOLDERS_JS_BODY = f"""
@@ -158,8 +173,19 @@ UNREADY_CHART_HOLDERS_JS_BODY = f"""
'{SLICE_CONTAINER_SELECTOR}'
) !== null;
const stillLoading = holder.querySelector('{LOADING_SELECTOR}') !== null;
- const isReady = holder.querySelector('{TERMINAL_MARKER_SELECTOR}') !== null;
- if (stillLoading || !isReady) {{
+ const hasErrorOrEmpty = holder.querySelector(
+ '{CHART_ERROR_OR_EMPTY_SELECTOR}'
+ ) !== null;
+ const hasUnpaintedEchart = holder.querySelector(
+ '{ECHARTS_UNPAINTED_HOST_SELECTOR}'
+ ) !== null;
+ // Ready = a settled error/empty/missing state, or a slice container
+ // whose ECharts canvas has finished painting. An unpainted ECharts host
+ // keeps the holder unready so a blank chart is never captured.
+ const isReady = !stillLoading && (
+ hasErrorOrEmpty || (hasSliceContainer && !hasUnpaintedEchart)
+ );
+ if (!isReady) {{
const chartIdMatch = holder.className.match(/{CHART_ID_CLASS_PATTERN}/);
const chartId = chartIdMatch ? chartIdMatch[1] : null;
let state;
@@ -167,6 +193,8 @@ UNREADY_CHART_HOLDERS_JS_BODY = f"""
state = 'spinner_mounted';
}} else if (stillLoading) {{
state = 'waiting_on_database';
+ }} else if (hasSliceContainer && hasUnpaintedEchart) {{
+ state = 'mounted_unpainted';
}} else {{
state = 'nothing_mounted';
}}
@@ -208,6 +236,11 @@ FIND_CHART_HOLDER_STATES_JS = f"""
) !== null) {{
return {{ chartId, state: 'empty' }};
}}
+ if (hasSliceContainer && holder.querySelector(
+ '{ECHARTS_UNPAINTED_HOST_SELECTOR}'
+ ) !== null) {{
+ return {{ chartId, state: 'mounted_unpainted' }};
+ }}
if (hasSliceContainer) {{
return {{ chartId, state: 'rendered' }};
}}
@@ -237,7 +270,8 @@ CHART_CONTAINER_READY_JS = f"""
const chart = document.querySelector('.chart-container');
return chart !== null
&& chart.querySelector('{LOADING_SELECTOR}') === null
- && chart.querySelector('{TERMINAL_MARKER_SELECTOR}') !== null;
+ && chart.querySelector('{TERMINAL_MARKER_SELECTOR}') !== null
+ && chart.querySelector('{ECHARTS_UNPAINTED_HOST_SELECTOR}') === null;
}}
"""
@@ -249,6 +283,9 @@ CHART_CONTAINER_STATE_JS = f"""
const chart = document.querySelector('.chart-container');
if (chart === null) {{ return 'missing'; }}
if (chart.querySelector('{LOADING_SELECTOR}') !== null) {{ return 'loading'; }}
+ if (chart.querySelector('{ECHARTS_UNPAINTED_HOST_SELECTOR}') !== null) {{
+ return 'mounted_unpainted';
+ }}
if (chart.querySelector('{TERMINAL_MARKER_SELECTOR}') !== null) {{
return 'terminal';
}}
diff --git a/tests/unit_tests/utils/test_screenshot_utils.py b/tests/unit_tests/utils/test_screenshot_utils.py
index cb772e45d78..23b8d86499d 100644
--- a/tests/unit_tests/utils/test_screenshot_utils.py
+++ b/tests/unit_tests/utils/test_screenshot_utils.py
@@ -1148,3 +1148,22 @@ class TestTileWaitBudget:
assert args[1] == i + 1 # tile index
assert args[2] == 3 # total tiles
assert args[-1] == " [cache_key=xyz]"
+
+
+def test_readiness_predicates_gate_on_unpainted_echarts_hosts() -> None:
+ """The report gate, the single-chart gate, and the diagnostics query all
+ key on the ECharts paint marker so a pre-paint canvas is never captured."""
+ from superset.utils.screenshot_utils import (
+ CHART_CONTAINER_READY_JS,
+ ECHARTS_UNPAINTED_HOST_SELECTOR,
+ FIND_CHART_HOLDER_STATES_JS,
+ REPORT_CHART_HOLDERS_READY_JS,
+ )
+
+ assert (
+ ECHARTS_UNPAINTED_HOST_SELECTOR
+ == ".echarts-host:not(.echarts-render-finished)"
+ )
+ assert ECHARTS_UNPAINTED_HOST_SELECTOR in REPORT_CHART_HOLDERS_READY_JS
+ assert ECHARTS_UNPAINTED_HOST_SELECTOR in CHART_CONTAINER_READY_JS
+ assert "mounted_unpainted" in FIND_CHART_HOLDER_STATES_JS