From 3aeb744a7fa217ed33054068dc4c651b197685c3 Mon Sep 17 00:00:00 2001 From: Carson Date: Mon, 13 Apr 2026 15:54:50 -0500 Subject: [PATCH 01/19] Avoid showing Plotly FigureWidget before resize settles --- js/src/output.ts | 164 ++++++++++++++++-- shinywidgets/static/output.js | 2 +- .../delayed_anywidget_resize/app.py | 68 ++++++++ .../test_delayed_anywidget_resize.py | 13 ++ .../test_example_plotly_afterplot_stable.py | 51 ++++++ tests/playwright/plotly_initial_settle/app.py | 32 ++++ .../test_plotly_initial_settle.py | 32 ++++ 7 files changed, 342 insertions(+), 20 deletions(-) create mode 100644 tests/playwright/delayed_anywidget_resize/app.py create mode 100644 tests/playwright/delayed_anywidget_resize/test_delayed_anywidget_resize.py create mode 100644 tests/playwright/example_plotly_afterplot_stable/test_example_plotly_afterplot_stable.py create mode 100644 tests/playwright/plotly_initial_settle/app.py create mode 100644 tests/playwright/plotly_initial_settle/test_plotly_initial_settle.py diff --git a/js/src/output.ts b/js/src/output.ts index 8cf3cf1..a0d9af7 100644 --- a/js/src/output.ts +++ b/js/src/output.ts @@ -85,7 +85,7 @@ class IPyWidgetOutput extends Shiny.OutputBinding { el.style.visibility = "hidden"; return; } else { - el.style.visibility = "inherit"; + el.style.visibility = "hidden"; } // Only forward the potential to fill if `output_widget(fillable=True)` @@ -114,27 +114,145 @@ class IPyWidgetOutput extends Shiny.OutputBinding { // The ipywidgets container (.lmWidget) const lmWidget = el.children[0] as HTMLElement; + await this._settleView(el, lmWidget, fill); + } + async _settleView(el: HTMLElement, lmWidget: HTMLElement, fill: boolean): Promise { + await this._waitForImplementation(lmWidget); + if (fill) { - this._onImplementation(lmWidget, () => this._doAddFillClasses(lmWidget)); + this._doAddFillClasses(lmWidget); + } + + const plotlyGraphDiv = this._findPlotlyGraphDiv(lmWidget); + if (plotlyGraphDiv) { + // Plotly FigureWidget may first render at its internal 360px fallback, + // then resize after paint. Keep it hidden until a direct Plotly resize + // completes so the first visible paint is already settled. + await this._settlePlotly(plotlyGraphDiv); + el.style.visibility = "inherit"; + return; } - this._onImplementation(lmWidget, this._doResize); + + await this._waitForAnimationFrames(2); + this._dispatchResize(); + await this._waitForAnimationFrames(2); + this._dispatchResize(); + await this._waitForAnimationFrames(2); + + el.style.visibility = "inherit"; } - _onImplementation(lmWidget: HTMLElement, callback: () => void): void { - if (this._hasImplementation(lmWidget)) { - callback(); + async _settlePlotly(plotEl: HTMLElement): Promise { + const plotly = (window as any).Plotly; + if (!plotly?.Plots?.resize) { + await this._waitForAnimationFrames(2); + this._dispatchResize(); + await this._waitForAnimationFrames(2); return; } - // Some widget implementation (e.g., ipyleaflet, pydeck) won't actually - // have rendered to the DOM at this point, so wait until they do - const mo = new MutationObserver((mutations) => { - if (this._hasImplementation(lmWidget)) { + await this._waitForPlotlyWidgetRender(plotEl); + await this._waitForPlotlyAfterPlot(plotEl); + await this._waitForAnimationFrames(1); + + const afterResize = this._waitForPlotlyAfterPlot(plotEl); + await plotly.Plots.resize(plotEl); + await afterResize; + + this._dispatchResize(); + await this._waitForAnimationFrames(1); + } + _waitForImplementation(lmWidget: HTMLElement): Promise { + if (this._hasImplementation(lmWidget)) { + return Promise.resolve(); + } + + // Some widget implementations won't actually populate their implementation + // subtree until after display_view() resolves, so observe the subtree rather + // than just lmWidget's direct children. + return new Promise((resolve) => { + const mo = new MutationObserver(() => { + if (this._hasImplementation(lmWidget)) { + mo.disconnect(); + resolve(); + } + }); + + mo.observe(lmWidget, {childList: true, subtree: true}); + + // Avoid keeping the widget hidden forever if the implementation never + // produces child nodes that we can observe. + window.setTimeout(() => { mo.disconnect(); - callback(); - } + resolve(); + }, 1000); + }); + } + _waitForAnimationFrames(n: number): Promise { + return new Promise((resolve) => { + const tick = (remaining: number) => { + if (remaining <= 0) { + resolve(); + return; + } + requestAnimationFrame(() => tick(remaining - 1)); + }; + tick(n); }); + } + _waitForPlotlyWidgetRender(plotEl: HTMLElement): Promise { + return new Promise((resolve) => { + const onRender = (evt: Event) => { + const target = (evt as CustomEvent).detail?.element; + if (target !== plotEl) return; + cleanup(); + resolve(); + }; + + const cleanup = () => { + document.removeEventListener("plotlywidget-after-render", onRender); + window.clearTimeout(timeoutId); + }; + + const timeoutId = window.setTimeout(() => { + cleanup(); + resolve(); + }, 1000); + + document.addEventListener("plotlywidget-after-render", onRender); + }); + } + _waitForPlotlyAfterPlot(plotEl: HTMLElement): Promise { + return new Promise((resolve) => { + const handler = () => { + cleanup(); + resolve(); + }; + + const cleanup = () => { + if (hasMethod(plotEl, "removeListener")) { + plotEl.removeListener("plotly_afterplot", handler); + } + window.clearTimeout(timeoutId); + }; - mo.observe(lmWidget, {childList: true}); + const timeoutId = window.setTimeout(() => { + cleanup(); + resolve(); + }, 1000); + + if (hasMethod(plotEl, "once")) { + plotEl.once("plotly_afterplot", handler); + return; + } + + if (hasMethod(plotEl, "on")) { + plotEl.on("plotly_afterplot", handler); + return; + } + + cleanup(); + resolve(); + }); } // In most cases, we can get widgets to fill through Python/CSS, but some widgets // (e.g., quak) don't have a Python API and use shadow DOM, which can only access @@ -148,12 +266,14 @@ class IPyWidgetOutput extends Shiny.OutputBinding { quakWidget.style.maxHeight = "unset"; } } - _doResize(): void { - // Trigger resize event to force layout (setTimeout() is needed for altair) - // TODO: debounce this call? - setTimeout(() => { - window.dispatchEvent(new Event('resize')) - }, 0); + _dispatchResize(): void { + window.dispatchEvent(new Event('resize')); + } + _findPlotlyGraphDiv(root: HTMLElement): HTMLElement | null { + const plotlyEl = root.matches(".js-plotly-plot") + ? root + : root.querySelector(".js-plotly-plot"); + return plotlyEl instanceof HTMLElement ? plotlyEl : null; } _hasImplementation(lmWidget: HTMLElement): boolean { const impl = lmWidget.children[0]; @@ -320,3 +440,9 @@ function errorMessage(err: unknown): string { interface DestroyMethod { destroy(): void; } + +interface PlotlyEventEmitter { + on(eventName: string, callback: () => void): void; + once(eventName: string, callback: () => void): void; + removeListener(eventName: string, callback: () => void): void; +} diff --git a/shinywidgets/static/output.js b/shinywidgets/static/output.js index 487a98e..ecacae2 100644 --- a/shinywidgets/static/output.js +++ b/shinywidgets/static/output.js @@ -36,7 +36,7 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpac \***********************/ /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _jupyter_widgets_html_manager__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! @jupyter-widgets/html-manager */ \"@jupyter-widgets/html-manager\");\n/* harmony import */ var _jupyter_widgets_html_manager__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(_jupyter_widgets_html_manager__WEBPACK_IMPORTED_MODULE_0__);\n/* harmony import */ var _comm__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./comm */ \"./src/comm.ts\");\n/* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./utils */ \"./src/utils.ts\");\nvar _a;\n\n\n\n/******************************************************************************\n * Define a custom HTMLManager for use with Shiny\n ******************************************************************************/\nclass OutputManager extends _jupyter_widgets_html_manager__WEBPACK_IMPORTED_MODULE_0__.HTMLManager {\n // In a soon-to-be-released version of @jupyter-widgets/html-manager,\n // display_view()'s first \"dummy\" argument will be removed... this shim simply\n // makes it so that our manager can work with either version\n // https://github.com/jupyter-widgets/ipywidgets/commit/159bbe4#diff-45c126b24c3c43d2cee5313364805c025e911c4721d45ff8a68356a215bfb6c8R42-R43\n async display_view(view, options) {\n const n_args = super.display_view.length;\n if (n_args === 3) {\n return super.display_view({}, view, options);\n }\n else {\n // @ts-ignore\n return super.display_view(view, options);\n }\n }\n}\n// Define our own custom module loader for Shiny\nconst shinyRequireLoader = async function (moduleName, moduleVersion) {\n // shiny provides a shim of require.js which allows