Skip to content

Commit 0e49c52

Browse files
authored
Only cleanup widgets that get initialized in an output context (#191)
1 parent c46111f commit 0e49c52

File tree

3 files changed

+37
-17
lines changed

3 files changed

+37
-17
lines changed

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ All notable changes to shinywidgets will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [UNRELEASED]
9+
10+
* Widgets initialized inside a `reactive.effect()` are no longer automatically removed when the effect invalidates. (#191)
11+
812
## [0.5.2] - 2025-04-04
913

1014
* Constructing a widget inside of a `shiny.reactive.ExtendedTask()` no longer errors out. (#188)

shinywidgets/_render_widget_base.py

+28-1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
get_current_context, # pyright: ignore[reportPrivateImportUsage]
1212
)
1313
from shiny.render.renderer import Jsonifiable, Renderer, ValueFn
14+
from shiny.session import require_active_session
1415

1516
from ._as_widget import as_widget
1617
from ._dependencies import widget_pkg
@@ -69,7 +70,8 @@ def __init__(
6970
self._contexts: set[Context] = set()
7071

7172
async def render(self) -> Jsonifiable | None:
72-
value = await self.fn()
73+
with WidgetRenderContext(self.output_id):
74+
value = await self.fn()
7375

7476
# Attach value/widget attributes to user func so they can be accessed (in other reactive contexts)
7577
self._value = value
@@ -213,3 +215,28 @@ def set_layout_defaults(widget: Widget) -> Tuple[Widget, bool]:
213215
widget.chart = chart
214216

215217
return (widget, fill)
218+
219+
class WidgetRenderContext:
220+
"""
221+
Let the session when a widget is currently being rendered.
222+
223+
This is used to ensure that widget's that are initialized in a render_widget()
224+
context are cleaned up properly when that context is re-entered.
225+
"""
226+
def __init__(self, output_id):
227+
self.session = require_active_session(None)
228+
self.output_id = output_id
229+
self._old_id = self.session.__dict__.get("__shinywidget_current_output_id")
230+
231+
def __enter__(self):
232+
self.session.__dict__["__shinywidget_current_output_id"] = self.output_id
233+
return self
234+
235+
def __exit__(self, exc_type, exc_value, traceback):
236+
self.session.__dict__["__shinywidget_current_output_id"] = self._old_id
237+
return False
238+
239+
@staticmethod
240+
def is_rendering_widget(session):
241+
id = session.__dict__.get("__shinywidget_current_output_id")
242+
return id is not None

shinywidgets/_shinywidgets.py

+5-16
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
from ._cdn import SHINYWIDGETS_CDN_ONLY, SHINYWIDGETS_EXTENSION_WARNING
3030
from ._comm import BufferType, OrphanedShinyComm, ShinyComm, ShinyCommManager
3131
from ._dependencies import require_dependency
32-
from ._render_widget_base import has_current_context
32+
from ._render_widget_base import WidgetRenderContext, has_current_context
3333
from ._utils import package_dir
3434

3535
__all__ = (
@@ -60,8 +60,7 @@ def init_shiny_widget(w: Widget):
6060
return
6161
# Break out of any module-specific session. Otherwise, input.shinywidgets_comm_send
6262
# will be some module-specific copy.
63-
while hasattr(session, "_parent"):
64-
session = cast(Session, session._parent) # pyright: ignore
63+
session = session.root_scope()
6564

6665
# If this is the first time we've seen this session, initialize some things
6766
if session not in SESSIONS:
@@ -148,12 +147,8 @@ def _open_shiny_comm():
148147

149148
_open_shiny_comm.destroy()
150149

151-
# If we're in a reactive context, close this widget when the context is invalidated
152-
# TODO: this should probably only be done in an output context, but I'm pretty sure
153-
# we don't have a decent way to determine that at the moment. In theory, doing this
154-
# in _any_ reactive context be problematic if you have an effect() that adds one
155-
# widget to another (i.e., a marker to a map) and want that marker to persist through
156-
# the next invalidation. The example provided in #174 is one such example.
150+
# If the widget initialized in a reactive _output_ context, then cleanup the widget
151+
# when the context gets invalidated.
157152
if has_current_context():
158153
ctx = get_current_context()
159154

@@ -170,13 +165,7 @@ def on_close():
170165
if id in WIDGET_INSTANCE_MAP:
171166
del WIDGET_INSTANCE_MAP[id]
172167

173-
# This could be running in a shiny.reactive.ExtendedTask, in which case,
174-
# the context is a DenialContext. As a result, on_invalidate() will throw
175-
# (since reading/invalidating reactive sources isn't allowed in this context).
176-
# For now, we just don't clean up the widget in this case.
177-
# TODO: this line can likely be removed once we start closing iff we're in a
178-
# output context (see TODO comment above)
179-
if "DenialContext" != ctx.__class__.__name__:
168+
if WidgetRenderContext.is_rendering_widget(session):
180169
ctx.on_invalidate(on_close)
181170

182171
# Keep track of what session this widget belongs to (so we can close it when the

0 commit comments

Comments
 (0)