Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

* Fixed an issue where Plotly `FigureWidget` outputs in filling layouts could briefly render at the wrong height and then visibly jump to their final size on initial render or rerender. Plotly outputs now wait for Plotly's own resize cycle before being revealed, and Playwright coverage was added for the first visible Plotly paint. (#208, #236)
* Fixed an issue where browser-originated widget buffers were not decoded back into bytes on the server before being forwarded to ipywidgets, which broke binary `update` and `custom` comm traffic from widgets running in the browser. Added Playwright regression coverage for both paths. (#151, #230)
* Hardened widget teardown for re-rendered views so replacing a widget no longer emits known cleanup noise like `Widget is not attached` or dead-comm sync errors. Added Playwright regression coverage for repeated rerenders across plotly, altair, bokeh, and ipyleaflet, and wired that suite into GitHub Actions on Python 3.12. (#223)
* Fixed an issue where parent widget updates could arrive before newly introduced child widget models were opened client-side, causing one-interaction lag or dropped updates in cases like `ipyleaflet` marker insertion. (#225)
Expand Down
51 changes: 46 additions & 5 deletions js/src/output.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { HTMLManager, requireLoader } from '@jupyter-widgets/html-manager';
import { ShinyComm } from './comm';
import { findPlotlyGraphDiv, waitForPlotlyReadyToReveal } from './plotly';
import { jsonParse } from './utils';
import type { ErrorsMessageValue } from 'rstudio-shiny/srcts/types/src/shiny/shinyapp';

Expand Down Expand Up @@ -76,18 +77,23 @@ class IPyWidgetOutput extends Shiny.OutputBinding {
}
onValueError(el: HTMLElement, err: ErrorsMessageValue): void {
Shiny.unbindAll(el);
el.style.visibility = "inherit";
this.renderError(el, err);
}
async renderValue(el: HTMLElement, data): Promise<void> {
const hiddenVisibility = "hidden";
const revealVisibility = "inherit";
const renderToken = this._nextRenderToken(el);

// Allow for a None/null value to hide the widget (css inspired by htmlwidgets)
if (!data) {
el.style.visibility = "hidden";
el.style.visibility = hiddenVisibility;
return;
} else {
el.style.visibility = "inherit";
}

const isPlotlyWidget = data.widget_pkg === "plotly";
el.style.visibility = isPlotlyWidget ? hiddenVisibility : revealVisibility;
Comment thread
cpsievert marked this conversation as resolved.

Comment thread
cpsievert marked this conversation as resolved.
// Only forward the potential to fill if `output_widget(fillable=True)`
// _and_ the widget instance wants to fill
const fill = data.fill && el.classList.contains("html-fill-container");
Expand Down Expand Up @@ -117,7 +123,42 @@ class IPyWidgetOutput extends Shiny.OutputBinding {
if (fill) {
this._onImplementation(lmWidget, () => this._doAddFillClasses(lmWidget));
}
this._onImplementation(lmWidget, this._doResize);
if (!isPlotlyWidget) {
this._onImplementation(lmWidget, () => this._doResize());
} else {
this._onImplementation(lmWidget, () => {
if (!this._isCurrentRenderToken(el, renderToken)) {
return;
}

const plotEl = findPlotlyGraphDiv(lmWidget);
if (!plotEl) {
this._doResize();
if (this._isCurrentRenderToken(el, renderToken)) {
el.style.visibility = revealVisibility;
}
return;
}

// Plotly FigureWidget may first render at its internal 360px fallback,
Comment thread
cpsievert marked this conversation as resolved.
// then resize after paint. Keep it hidden until a direct Plotly resize
// completes so the first visible paint is already settled.
void waitForPlotlyReadyToReveal(plotEl, () => this._doResize()).finally(() => {
if (this._isCurrentRenderToken(el, renderToken)) {
el.style.visibility = revealVisibility;
}
});
});
}
}
_nextRenderToken(el: HTMLElement): number {
const trackedEl = el as HTMLElement & { __shinywidgetsRenderToken?: number };
const nextToken = (trackedEl.__shinywidgetsRenderToken ?? 0) + 1;
trackedEl.__shinywidgetsRenderToken = nextToken;
return nextToken;
}
_isCurrentRenderToken(el: HTMLElement, token: number): boolean {
return (el as HTMLElement & { __shinywidgetsRenderToken?: number }).__shinywidgetsRenderToken === token;
}
_onImplementation(lmWidget: HTMLElement, callback: () => void): void {
if (this._hasImplementation(lmWidget)) {
Expand All @@ -127,7 +168,7 @@ class IPyWidgetOutput extends Shiny.OutputBinding {

// 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) => {
const mo = new MutationObserver((_mutations) => {
if (this._hasImplementation(lmWidget)) {
mo.disconnect();
callback();
Expand Down
75 changes: 75 additions & 0 deletions js/src/plotly.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
interface PlotlyEventEmitter {
on(eventName: string, callback: () => void): void;
once(eventName: string, callback: () => void): void;
removeListener(eventName: string, callback: () => void): void;
}

const plotlyAfterPlotTimeoutMs = 5000;

export function findPlotlyGraphDiv(root: HTMLElement): HTMLElement | null {
const plotlyEl = root.matches(".js-plotly-plot")
? root
: root.querySelector(".js-plotly-plot");
return plotlyEl instanceof HTMLElement ? plotlyEl : null;
}

export async function waitForPlotlyReadyToReveal(
plotEl: HTMLElement,
dispatchResize: () => void,
): Promise<void> {
const plotly = (window as any).Plotly;
await waitForPlotlyAfterPlot(plotEl);

if (plotly?.Plots?.resize) {
try {
const afterResize = waitForPlotlyAfterPlot(plotEl);
await plotly.Plots.resize(plotEl);
await afterResize;
} catch (err) {
console.error("Error resizing Plotly widget before reveal:", err);
}
Comment thread
cpsievert marked this conversation as resolved.
}

dispatchResize();
}
Comment thread
cpsievert marked this conversation as resolved.

function waitForPlotlyAfterPlot(plotEl: HTMLElement): Promise<void> {
return new Promise((resolve) => {
const handler = () => {
cleanup();
resolve();
};

const cleanup = () => {
if (hasMethod<PlotlyEventEmitter, "removeListener">(plotEl, "removeListener")) {
plotEl.removeListener("plotly_afterplot", handler);
}
window.clearTimeout(timeoutId);
};

const timeoutId = window.setTimeout(() => {
Comment thread
cpsievert marked this conversation as resolved.
cleanup();
resolve();
}, plotlyAfterPlotTimeoutMs);

Comment thread
cpsievert marked this conversation as resolved.
if (hasMethod<PlotlyEventEmitter, "once">(plotEl, "once")) {
plotEl.once("plotly_afterplot", handler);
return;
}

if (hasMethod<PlotlyEventEmitter, "on">(plotEl, "on")) {
plotEl.on("plotly_afterplot", handler);
return;
}

cleanup();
resolve();
});
}

function hasMethod<T, K extends keyof T>(
x: unknown,
key: K,
): x is T & Record<K, (...args: any[]) => unknown> {
return !!x && typeof (x as any)[key] === "function";
}
1 change: 1 addition & 0 deletions shinywidgets/_render_widget_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ async def _render(self) -> Jsonifiable | None:
return {
"model_id": str(widget.model_id),
"fill": fill,
"widget_pkg": widget_pkg(widget),
}

@property
Expand Down
Loading
Loading