Skip to content

Commit 72d16f5

Browse files
authored
Cleanup orphaned widget models (#167)
1 parent d625c3f commit 72d16f5

File tree

6 files changed

+149
-91
lines changed

6 files changed

+149
-91
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [UNRELEASED]
99

10-
10+
* Fixed a memory leak issue. (#167)
1111

1212
## [0.3.4] - 2024-10-29
1313

js/src/output.ts

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -100,13 +100,6 @@ class IPyWidgetOutput extends Shiny.OutputBinding {
100100
const view = await manager.create_view(model, {});
101101
await manager.display_view(view, {el: el});
102102

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]);
108-
}
109-
110103
// The ipywidgets container (.lmWidget)
111104
const lmWidget = el.children[0] as HTMLElement;
112105

@@ -189,21 +182,46 @@ Shiny.addCustomMessageHandler("shinywidgets_comm_open", (msg_txt) => {
189182
// Basically out version of https://github.com/jupyterlab/jupyterlab/blob/d33de15/packages/services/src/kernel/default.ts#L1200-L1215
190183
Shiny.addCustomMessageHandler("shinywidgets_comm_msg", (msg_txt) => {
191184
const msg = jsonParse(msg_txt);
192-
manager.get_model(msg.content.comm_id).then(m => {
193-
// @ts-ignore for some reason IClassicComm doesn't have this method, but we do
194-
m.comm.handle_msg(msg);
195-
});
185+
const id = msg.content.comm_id;
186+
const model = manager.get_model(id);
187+
if (!model) {
188+
console.error(`Couldn't handle message for model ${id} because it doesn't exist.`);
189+
return;
190+
}
191+
model
192+
.then(m => {
193+
// @ts-ignore for some reason IClassicComm doesn't have this method, but we do
194+
m.comm.handle_msg(msg);
195+
})
196+
.catch(console.error);
196197
});
197198

198-
// TODO: test that this actually works
199+
200+
// Handle the closing of a widget/comm/model
199201
Shiny.addCustomMessageHandler("shinywidgets_comm_close", (msg_txt) => {
200202
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-
});
203+
const id = msg.content.comm_id;
204+
const model = manager.get_model(id);
205+
if (!model) {
206+
console.error(`Couldn't close model ${id} because it doesn't exist.`);
207+
return;
208+
}
209+
model
210+
.then(m => {
211+
// Closing the model removes the corresponding view from the DOM
212+
m.close();
213+
// .close() isn't enough to remove manager's reference to it,
214+
// and apparently the only way to remove it is through the `comm:close` event
215+
// https://github.com/jupyter-widgets/ipywidgets/blob/303cae4/packages/base-manager/src/manager-base.ts#L330-L337
216+
// https://github.com/jupyter-widgets/ipywidgets/blob/303cae4/packages/base/src/widget.ts#L251-L253
217+
m.trigger("comm:close");
218+
})
219+
.catch(console.error);
205220
});
206221

222+
$(document).on("shiny:disconnected", () => {
223+
manager.clear_state();
224+
});
207225

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

shinywidgets/_comm.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,10 @@ def close(
9797
return
9898
self._closed = True
9999
data = self._closed_data if data is None else data
100-
self._publish_msg(
101-
"shinywidgets_comm_close", data=data, metadata=metadata, buffers=buffers
102-
)
100+
if get_current_session():
101+
self._publish_msg(
102+
"shinywidgets_comm_close", data=data, metadata=metadata, buffers=buffers
103+
)
103104
if not deleting:
104105
# If deleting, the comm can't be unregistered
105106
self.comm_manager.unregister_comm(self)
@@ -169,10 +170,17 @@ def _publish_msg(
169170
def _send():
170171
run_coro_hybrid(session.send_custom_message(msg_type, msg_txt)) # type: ignore
171172

172-
# N.B., if we don't do this on flush, then if you initialize a widget
173-
# outside of a reactive context, run_coro_sync() will complain with
173+
# N.B., if messages are sent immediately, run_coro_sync() could fail with
174174
# 'async function yielded control; it did not finish in one iteration.'
175-
session.on_flush(_send)
175+
# if executed outside of a reactive context.
176+
if msg_type == "shinywidgets_comm_close":
177+
# The primary way widgets are closed are when a new widget is rendered in
178+
# its place (see render_widget_base). By sending close on_flushed(), we
179+
# ensure to close the 'old' widget after the new one is created. (avoiding a
180+
# "flicker" of the old widget being removed before the new one is created)
181+
session.on_flushed(_send)
182+
else:
183+
session.on_flush(_send)
176184

177185
# This is the method that ipywidgets.widgets.Widget uses to respond to client-side changes
178186
def on_msg(self, callback: MsgCallback) -> None:

shinywidgets/_shinywidgets.py

Lines changed: 98 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
import copy
44
import json
55
import os
6-
from typing import Any, Optional, Sequence, Union, cast
6+
from contextlib import contextmanager
7+
from typing import TYPE_CHECKING, Any, Optional, Sequence, TypeGuard, Union, cast
78
from uuid import uuid4
89
from weakref import WeakSet
910

@@ -26,13 +27,17 @@
2627
from ._cdn import SHINYWIDGETS_CDN_ONLY, SHINYWIDGETS_EXTENSION_WARNING
2728
from ._comm import BufferType, ShinyComm, ShinyCommManager
2829
from ._dependencies import require_dependency
29-
from ._utils import is_instance_of_class, package_dir
30+
from ._render_widget_base import has_current_context
31+
from ._utils import package_dir
3032

3133
__all__ = (
3234
"register_widget",
3335
"reactive_read",
3436
)
3537

38+
if TYPE_CHECKING:
39+
from traitlets.traitlets import Instance
40+
3641

3742
# --------------------------------------------------------------------------------------------
3843
# When a widget is initialized, also initialize a communication channel (via the Shiny
@@ -54,19 +59,43 @@ def init_shiny_widget(w: Widget):
5459
while hasattr(session, "_parent"):
5560
session = cast(Session, session._parent) # pyright: ignore
5661

57-
# Previous versions of ipywidgets (< 8.0.5) had
58-
# `Widget.comm = Instance('ipykernel.comm.Comm')`
59-
# which meant we'd get a runtime error when setting `Widget.comm = ShinyComm()`.
60-
# In more recent versions, this is no longer necessary since they've (correctly)
61-
# changed comm from an Instance() to Any().
62-
# https://github.com/jupyter-widgets/ipywidgets/pull/3533/files#diff-522bb5e7695975cba0199c6a3d6df5be827035f4dc18ed6da22ac216b5615c77R482
63-
old_comm_klass = None
64-
if is_instance_of_class(Widget.comm, "Instance", "traitlets.traitlets"): # type: ignore
65-
old_comm_klass = copy.copy(Widget.comm.klass) # type: ignore
66-
Widget.comm.klass = object # type: ignore
62+
# If this is the first time we've seen this session, initialize some things
63+
if session not in SESSIONS:
64+
SESSIONS.add(session)
65+
66+
# Somewhere inside ipywidgets, it makes requests for static files
67+
# under the publicPath set by the webpack.config.js file.
68+
session.app._dependency_handler.mount(
69+
"/dist/",
70+
StaticFiles(directory=os.path.join(package_dir("shinywidgets"), "static")),
71+
name="shinywidgets-static-resources",
72+
)
73+
74+
# Handle messages from the client. Note that widgets like qgrid send client->server messages
75+
# to figure out things like what filter to be shown in the table.
76+
@reactive.effect
77+
@reactive.event(session.input.shinywidgets_comm_send)
78+
def _():
79+
msg_txt = session.input.shinywidgets_comm_send()
80+
msg = json.loads(msg_txt)
81+
comm_id = msg["content"]["comm_id"]
82+
if comm_id in COMM_MANAGER.comms:
83+
comm: ShinyComm = COMM_MANAGER.comms[comm_id]
84+
comm.handle_msg(msg)
85+
86+
def _cleanup_session_state():
87+
SESSIONS.remove(session)
88+
# Cleanup any widgets that were created in this session
89+
for id in SESSION_WIDGET_ID_MAP[session.id]:
90+
widget = WIDGET_INSTANCE_MAP.get(id)
91+
if widget:
92+
widget.close()
93+
del SESSION_WIDGET_ID_MAP[session.id]
94+
95+
session.on_ended(_cleanup_session_state)
6796

6897
# Get the initial state of the widget
69-
state, buffer_paths, buffers = _remove_buffers(w.get_state()) # type: ignore
98+
state, buffer_paths, buffers = _remove_buffers(w.get_state())
7099

71100
# Make sure window.require() calls made by 3rd party widgets
72101
# (via handle_comm_open() -> new_model() -> loadClass() -> requireLoader())
@@ -81,17 +110,29 @@ def init_shiny_widget(w: Widget):
81110
if getattr(w, "_model_id", None) is None:
82111
w._model_id = uuid4().hex
83112

113+
id = cast(str, w._model_id) # pyright: ignore[reportUnknownMemberType]
114+
84115
# Initialize the comm...this will also send the initial state of the widget
85-
w.comm = ShinyComm(
86-
comm_id=w._model_id, # pyright: ignore
87-
comm_manager=COMM_MANAGER,
88-
target_name="jupyter.widgets",
89-
data={"state": state, "buffer_paths": buffer_paths},
90-
buffers=cast(BufferType, buffers),
91-
# TODO: should this be hard-coded?
92-
metadata={"version": __protocol_version__},
93-
html_deps=session._process_ui(TagList(widget_dep))["deps"],
94-
)
116+
with widget_comm_patch():
117+
w.comm = ShinyComm(
118+
comm_id=id,
119+
comm_manager=COMM_MANAGER,
120+
target_name="jupyter.widgets",
121+
data={"state": state, "buffer_paths": buffer_paths},
122+
buffers=cast(BufferType, buffers),
123+
# TODO: should this be hard-coded?
124+
metadata={"version": __protocol_version__},
125+
html_deps=session._process_ui(TagList(widget_dep))["deps"],
126+
)
127+
128+
# If we're in a reactive context, close this widget when the context is invalidated
129+
if has_current_context():
130+
ctx = get_current_context()
131+
ctx.on_invalidate(lambda: w.close())
132+
133+
# Keep track of what session this widget belongs to (so we can close it when the
134+
# session ends)
135+
SESSION_WIDGET_ID_MAP.setdefault(session.id, []).append(id)
95136

96137
# Some widget's JS make external requests for static files (e.g.,
97138
# ipyleaflet markers) under this resource path. Note that this assumes that
@@ -107,45 +148,21 @@ def init_shiny_widget(w: Widget):
107148
name=f"{widget_dep.name}-nbextension-static-resources",
108149
)
109150

110-
# everything after this point should be done once per session
111-
if session in SESSIONS:
112-
return
113-
SESSIONS.add(session) # type: ignore
114-
115-
# Somewhere inside ipywidgets, it makes requests for static files
116-
# under the publicPath set by the webpack.config.js file.
117-
session.app._dependency_handler.mount(
118-
"/dist/",
119-
StaticFiles(directory=os.path.join(package_dir("shinywidgets"), "static")),
120-
name="shinywidgets-static-resources",
121-
)
122-
123-
# Handle messages from the client. Note that widgets like qgrid send client->server messages
124-
# to figure out things like what filter to be shown in the table.
125-
@reactive.Effect
126-
@reactive.event(session.input.shinywidgets_comm_send)
127-
def _():
128-
msg_txt = session.input.shinywidgets_comm_send()
129-
msg = json.loads(msg_txt)
130-
comm_id = msg["content"]["comm_id"]
131-
comm: ShinyComm = COMM_MANAGER.comms[comm_id]
132-
comm.handle_msg(msg)
133-
134-
def _restore_state():
135-
if old_comm_klass is not None:
136-
Widget.comm.klass = old_comm_klass # type: ignore
137-
SESSIONS.remove(session) # type: ignore
138-
139-
session.on_ended(_restore_state)
140-
141151

142152
# TODO: can we restore the widget constructor in a sensible way?
143153
Widget.on_widget_constructed(init_shiny_widget) # type: ignore
144154

145155
# Use WeakSet() over Set() so that the session can be garbage collected
146-
SESSIONS = WeakSet() # type: ignore
156+
SESSIONS: WeakSet[Session] = WeakSet()
147157
COMM_MANAGER = ShinyCommManager()
148158

159+
# Dictionary mapping session id to widget ids
160+
# The key is the session id, and the value is a list of widget ids
161+
SESSION_WIDGET_ID_MAP: dict[str, list[str]] = {}
162+
163+
# Dictionary of all "active" widgets (ipywidgets automatically adds to this dictionary as
164+
# new widgets are created, but they won't get removed until the widget is explictly closed)
165+
WIDGET_INSTANCE_MAP = cast(dict[str, Widget], Widget.widgets) # pyright: ignore[reportUnknownMemberType]
149166

150167
# --------------------------------------
151168
# Reactivity
@@ -216,6 +233,32 @@ def _():
216233

217234
return w
218235

236+
# Previous versions of ipywidgets (< 8.0.5) had
237+
# `Widget.comm = Instance('ipykernel.comm.Comm')`
238+
# which meant we'd get a runtime error when setting `Widget.comm = ShinyComm()`.
239+
# In more recent versions, this is no longer necessary since they've (correctly)
240+
# changed comm from an Instance() to Any().
241+
# https://github.com/jupyter-widgets/ipywidgets/pull/3533/files#diff-522bb5e7695975cba0199c6a3d6df5be827035f4dc18ed6da22ac216b5615c77R482
242+
@contextmanager
243+
def widget_comm_patch():
244+
if not is_traitlet_instance(Widget.comm):
245+
yield
246+
return
247+
248+
comm_klass = copy.copy(Widget.comm.klass)
249+
Widget.comm.klass = object
250+
251+
yield
252+
253+
Widget.comm.klass = comm_klass
254+
255+
256+
def is_traitlet_instance(x: object) -> "TypeGuard[Instance]":
257+
try:
258+
from traitlets.traitlets import Instance
259+
except ImportError:
260+
return False
261+
return isinstance(x, Instance)
219262

220263
# It doesn't, at the moment, seem feasible to establish a comm with statically rendered widgets,
221264
# and partially for this reason, it may not be sensible to provide an input-like API for them.

shinywidgets/_utils.py

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import importlib
22
import os
33
import tempfile
4-
from typing import Optional
4+
55

66
# similar to base::system.file()
77
def package_dir(package: str) -> str:
@@ -10,14 +10,3 @@ def package_dir(package: str) -> str:
1010
if pkg_file is None:
1111
raise ImportError(f"Couldn't load package {package}")
1212
return os.path.dirname(pkg_file)
13-
14-
15-
def is_instance_of_class(
16-
x: object, class_name: str, module_name: Optional[str] = None
17-
) -> bool:
18-
typ = type(x)
19-
res = typ.__name__ == class_name
20-
if module_name is None:
21-
return res
22-
else:
23-
return res and typ.__module__ == module_name

0 commit comments

Comments
 (0)