Skip to content

Commit 30f9b46

Browse files
committed
Close #166. Cleanup orphaned widget models (i.e., models that are created as a side-effect of a output render) when they're no longer needed (i.e., the next time the output renders or if the session closes)
1 parent d625c3f commit 30f9b46

File tree

5 files changed

+88
-23
lines changed

5 files changed

+88
-23
lines changed

js/src/comm.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,9 @@ export class ShinyComm {
9696
metadata?: any,
9797
buffers?: ArrayBuffer[] | ArrayBufferView[]
9898
): string {
99-
// I don't think we need to do anything here?
99+
// The client-side model/comm has closed, let the server know so it can clean up
100+
// the corresponding model/comm on its end.
101+
Shiny.setInputValue("shinywidgets_comm_close", this.comm_id, {priority: "event"});
100102
return this.comm_id;
101103
}
102104

js/src/output.ts

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { ErrorsMessageValue } from 'rstudio-shiny/srcts/types/src/shiny/shi
99
******************************************************************************/
1010

1111
class OutputManager extends HTMLManager {
12+
orphaned_models: string[];
1213
// In a soon-to-be-released version of @jupyter-widgets/html-manager,
1314
// display_view()'s first "dummy" argument will be removed... this shim simply
1415
// makes it so that our manager can work with either version
@@ -91,20 +92,31 @@ class IPyWidgetOutput extends Shiny.OutputBinding {
9192
if (fill) el.classList.add("forward-fill-potential");
9293

9394
// At this time point, we should've already handled an 'open' message, and so
94-
// the model should be ready to use
95+
// the model should already be registered
9596
const model = await manager.get_model(data.model_id);
9697
if (!model) {
9798
throw new Error(`No model found for id ${data.model_id}`);
9899
}
99100

101+
// Create a view and display it
100102
const view = await manager.create_view(model, {});
101103
await manager.display_view(view, {el: el});
102104

103-
// Don't allow more than one .lmWidget container, which can happen
104-
// when the view is displayed more than once
105-
// TODO: It's probably better to get view(s) from m.views and .remove() them
106-
while (el.childNodes.length > 1) {
107-
el.removeChild(el.childNodes[0]);
105+
// If the model was orphaned, close it (and thus, the view as well) now
106+
if (manager.orphaned_models.length > 0) {
107+
for (const model_id of manager.orphaned_models) {
108+
const model = await manager.get_model(model_id);
109+
if (model) {
110+
// Closing the model removes the previous view
111+
await model.close();
112+
// .close() isn't enough to remove manager's reference to it,
113+
// and apparently the only way to remove it is through the `comm:close` event
114+
// https://github.com/jupyter-widgets/ipywidgets/blob/303cae4/packages/base-manager/src/manager-base.ts#L330-L337
115+
// https://github.com/jupyter-widgets/ipywidgets/blob/303cae4/packages/base/src/widget.ts#L251-L253
116+
model.trigger("comm:close");
117+
}
118+
}
119+
manager.orphaned_models = [];
108120
}
109121

110122
// The ipywidgets container (.lmWidget)
@@ -189,21 +201,34 @@ Shiny.addCustomMessageHandler("shinywidgets_comm_open", (msg_txt) => {
189201
// Basically out version of https://github.com/jupyterlab/jupyterlab/blob/d33de15/packages/services/src/kernel/default.ts#L1200-L1215
190202
Shiny.addCustomMessageHandler("shinywidgets_comm_msg", (msg_txt) => {
191203
const msg = jsonParse(msg_txt);
192-
manager.get_model(msg.content.comm_id).then(m => {
204+
const id = msg.content.comm_id;
205+
const model = manager.get_model(id);
206+
if (!model) {
207+
console.error(`Couldn't handle message for model ${id} because it doesn't exist.`);
208+
return;
209+
}
210+
model.then(m => {
193211
// @ts-ignore for some reason IClassicComm doesn't have this method, but we do
194212
m.comm.handle_msg(msg);
195213
});
196214
});
197215

198-
// TODO: test that this actually works
216+
217+
// When `widget.close()` happens server-side, don't `.close()` client-side until the
218+
// next render. This is because we currently trigger a close _during_ the server-side
219+
// render, and thus, immediately closing the model removes the view before a new one is
220+
// ready.
221+
manager.orphaned_models = [];
199222
Shiny.addCustomMessageHandler("shinywidgets_comm_close", (msg_txt) => {
200223
const msg = jsonParse(msg_txt);
201-
manager.get_model(msg.content.comm_id).then(m => {
202-
// @ts-ignore for some reason IClassicComm doesn't have this method, but we do
203-
m.comm.handle_close(msg)
204-
});
224+
manager.orphaned_models.push(msg.content.comm_id);
205225
});
206226

227+
// At least currently, all widgets must be created within a session scope, so we can
228+
// clear the state (i.e., .close() all the widget models) when the session ends
229+
$(document).on("shiny:disconnected", () => {
230+
manager.clear_state();
231+
});
207232

208233
// Our version of https://github.com/jupyter-widgets/widget-cookiecutter/blob/9694718/%7B%7Bcookiecutter.github_project_name%7D%7D/js/lib/extension.js#L8
209234
function setBaseURL(x: string = '') {

shinywidgets/_render_widget_base.py

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
from shiny import req
1313
from shiny.reactive._core import Context, get_current_context
1414
from shiny.render.renderer import Jsonifiable, Renderer, ValueFn
15-
from traitlets import Unicode
1615

1716
from ._as_widget import as_widget
1817
from ._dependencies import widget_pkg
@@ -71,6 +70,9 @@ def __init__(
7170
self._contexts: set[Context] = set()
7271

7372
async def render(self) -> Jsonifiable | None:
73+
# Record the ids of any widgets created by executing the user function
74+
widgets_before = set(WIDGET_INSTANCE_MAP.keys())
75+
7476
value = await self.fn()
7577

7678
# Attach value/widget attributes to user func so they can be accessed (in other reactive contexts)
@@ -89,16 +91,23 @@ async def render(self) -> Jsonifiable | None:
8991

9092
self._widget = cast(WidgetT, widget)
9193

94+
# Get the widgets created within this render function
95+
widgets_after = set(WIDGET_INSTANCE_MAP.keys())
96+
widgets_diff = widgets_after - widgets_before
97+
98+
# When this render context gets invalidated, close widgets introduced by the
99+
# user function. This _could_ be problematic if widget object get hoisted out
100+
# of the function and used elsewhere, but if you're doing that, you're likely
101+
# to have other problems anyway.
102+
get_current_context().on_invalidate(lambda: close_widgets(widgets_diff))
103+
92104
# Don't actually display anything unless this is a DOMWidget
93105
if not isinstance(widget, DOMWidget):
94106
return None
95107

96108
return {
97109
"model_id": str(
98-
cast(
99-
Unicode,
100-
widget.model_id, # pyright: ignore[reportUnknownMemberType]
101-
)
110+
cast(str, widget.model_id) # pyright: ignore[reportUnknownMemberType]
102111
),
103112
"fill": fill,
104113
}
@@ -217,3 +226,17 @@ def set_layout_defaults(widget: Widget) -> Tuple[Widget, bool]:
217226
widget.chart = chart
218227

219228
return (widget, fill)
229+
230+
231+
# Dictionary of all "active" widgets (ipywidgets automatically adds to this dictionary as
232+
# new widgets are created, but they won't get removed until the widget is explictly closed)
233+
WIDGET_INSTANCE_MAP = cast(dict[str, Widget], Widget.widgets) # pyright: ignore[reportUnknownMemberType]
234+
235+
# Given a set of widget ids, close those widgets. Closing should not only
236+
# remove the widget reference, but also send a message to the client to remove
237+
# the corresponding model from the widget manager.
238+
def close_widgets(ids: set[str]):
239+
for id in ids:
240+
widget = WIDGET_INSTANCE_MAP.get(id)
241+
if widget is not None:
242+
widget.close()

shinywidgets/_shinywidgets.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from ._cdn import SHINYWIDGETS_CDN_ONLY, SHINYWIDGETS_EXTENSION_WARNING
2727
from ._comm import BufferType, ShinyComm, ShinyCommManager
2828
from ._dependencies import require_dependency
29+
from ._render_widget_base import WIDGET_INSTANCE_MAP
2930
from ._utils import is_instance_of_class, package_dir
3031

3132
__all__ = (
@@ -122,14 +123,28 @@ def init_shiny_widget(w: Widget):
122123

123124
# Handle messages from the client. Note that widgets like qgrid send client->server messages
124125
# to figure out things like what filter to be shown in the table.
125-
@reactive.Effect
126+
@reactive.effect
126127
@reactive.event(session.input.shinywidgets_comm_send)
127128
def _():
128129
msg_txt = session.input.shinywidgets_comm_send()
129130
msg = json.loads(msg_txt)
130131
comm_id = msg["content"]["comm_id"]
131-
comm: ShinyComm = COMM_MANAGER.comms[comm_id]
132-
comm.handle_msg(msg)
132+
if comm_id in COMM_MANAGER.comms:
133+
comm: ShinyComm = COMM_MANAGER.comms[comm_id]
134+
comm.handle_msg(msg)
135+
136+
# Handle a close message from the client.
137+
@reactive.effect
138+
@reactive.event(session.input.shinywidgets_comm_close)
139+
def _():
140+
comm_id = session.input.shinywidgets_comm_close()
141+
# It seems the "proper" thing to do here is to .handle_close() on the comm
142+
# but in practice, if we do that, ipywidgets will keep a reference to the
143+
# widget instance via this widgets property, so it won't be garbage collected,
144+
# unless we also call .close() on the widget instance.
145+
w_obj = WIDGET_INSTANCE_MAP.get(comm_id)
146+
if w_obj:
147+
w_obj.close()
133148

134149
def _restore_state():
135150
if old_comm_klass is not None:

0 commit comments

Comments
 (0)