diff --git a/pyproject.toml b/pyproject.toml index 7285495..092460e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ dependencies = [ "solara>=1.57.6", "bokeh>=3.8.2", "colorcet>=3.1.0", - "jupyter-bokeh>=4.0.5", + "jupyter-bokeh>=4.1.0", "vaex-core >=4.19.0", "vaex-hdf5", "diskcache>=5.6.3", diff --git a/src/sdss_explorer/dashboard/components/views/figurebokeh.py b/src/sdss_explorer/dashboard/components/views/figurebokeh.py index 7522cdb..51e22ea 100644 --- a/src/sdss_explorer/dashboard/components/views/figurebokeh.py +++ b/src/sdss_explorer/dashboard/components/views/figurebokeh.py @@ -1,13 +1,49 @@ from typing import Callable -from bokeh.plotting import figure import solara as sl +from bokeh.core.serialization import DeserializationError from bokeh.io import curdoc from bokeh.models import Plot +from bokeh.plotting import figure from bokeh.themes import Theme from jupyter_bokeh import BokehModel +class SafeBokehModel(BokehModel): + """BokehModel with the upstream teardown faults patched out. + + Both bugs are in jupyter_bokeh 4.1.0 and are patched here rather than in a + vendored fork, so we can track the pypi release directly. + + """ + + def close(self) -> None: + """Detaches document callbacks only while still registered. + + We have the explicit cleanup callback + solara cleans the widget, but + ipywidgets calls close() again from gc (via __del__), which raises KeyError + for an already deleted object + """ + # skips the BokehModel.close and goes straight to the ipywidgets teardown + super(BokehModel, self).close() + document = self._document + if document is not None: + registry = getattr(document.callbacks, "_change_callbacks", {}) + if self in registry: # only remove if we are in the registry + document.remove_on_change(self) + + def _sync_model(self, _model, content, _buffers) -> None: + """Drops frontend events that fail to deserialize. + + An event can reference a model already removed or replaced server side, + which otherwise raises DeserializationError from ipywidgets + """ + try: + super()._sync_model(_model, content, _buffers) + except DeserializationError: + return + + @sl.component_vue("bokeh_loaded.vue") def BokehLoaded(loaded: bool, on_loaded: Callable[[bool], None]): pass @@ -37,7 +73,7 @@ def FigureBokeh( loaded = sl.use_reactive(False) dark = sl.lab.use_dark_effective() BokehLoaded(loaded=loaded.value, on_loaded=loaded.set) - fig_element = BokehModel.element(model=fig) + fig_element = SafeBokehModel.element(model=fig) def update_data(): fig_widget: BokehModel = sl.get_widget(fig_element) @@ -70,16 +106,31 @@ def update_theme(): sl.use_effect(update_data, dependencies or fig) sl.use_effect(update_theme, [dark, loaded.value]) + def cleanup_widget(): + # explicitly adds a teardown cleanup callback for the widget on unmount + # the comm and document callbacks are detached manually, rather than on garbage collection + def cleanup(): + try: + fig_widget: BokehModel = sl.get_widget(fig_element) + except Exception: + return + if isinstance(fig_widget, BokehModel): + # close() detaches the doc callbacks + shuts the comm + fig_widget.close() + + return cleanup + + sl.use_effect(cleanup_widget, dependencies=[]) + def set_init_theme(): curdoc().theme = dark_theme if dark else light_theme sl.use_memo(set_init_theme, dependencies=[]) + # i attempted to make a loading spinner, but it did not work. if loaded.value: - # t.sleep(0.5) # FORCE LOCKOUT for theme rendering return fig_element # else: - # # NOTE: the returned object will be a v.Sheet until Bokeh is loaded # # BUG: this will show the JS error or even the figure itself temporarily before loading # with sl.Card(margin=0, elevation=0): # with sl.Row(justify="center"): diff --git a/src/sdss_explorer/util/logger.py b/src/sdss_explorer/util/logger.py index a6af94d..4fc84b9 100644 --- a/src/sdss_explorer/util/logger.py +++ b/src/sdss_explorer/util/logger.py @@ -102,6 +102,19 @@ def setup_logging( "level": file_log_level, "propagate": False, }, + # if a JS message hits after the widget is closed (server-side), it flags + # as a warning level + # this is most noticeable during grid edits because the entire grid + # is REBUILT on add/remove, which means it is fully reserialized. + # + # comm module :: base_comm.py l.255 + # + # we can just stop the warnings this way + "Comm": { + "handlers": ["console", "file"], + "level": "ERROR", + "propagate": False, + }, }, } diff --git a/uv.lock b/uv.lock index b736b9d..15d88d0 100644 --- a/uv.lock +++ b/uv.lock @@ -1134,15 +1134,15 @@ wheels = [ [[package]] name = "jupyter-bokeh" -version = "4.0.5" +version = "4.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "bokeh" }, { name = "ipywidgets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b4/fd/8f0213c704bf36b5f523ae5bf7dc367f3687e75dcc2354084b75c05d2b53/jupyter_bokeh-4.0.5.tar.gz", hash = "sha256:a33d6ab85588f13640b30765fa15d1111b055cbe44f67a65ca57d3593af8245d", size = 149140, upload-time = "2024-06-03T06:33:33.488Z" } +sdist = { url = "https://files.pythonhosted.org/packages/78/6b/f87742c806e544cc338095ed146ad3818508ad763e8d387dbc582d12af85/jupyter_bokeh-4.1.0.tar.gz", hash = "sha256:fc0719c653920b5a1b2b42b2f4091c43fb1570880819058d820a2ab198cb93bf", size = 759161, upload-time = "2026-06-09T11:01:53.781Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/78/33b2294aad62e5f95b89a89379c5995c2bd978018387ef8bec79f6dc272c/jupyter_bokeh-4.0.5-py3-none-any.whl", hash = "sha256:1110076c14c779071cf492646a1a871aefa8a477261e4721327a666e65df1a2c", size = 148593, upload-time = "2024-06-03T06:33:35.82Z" }, + { url = "https://files.pythonhosted.org/packages/5a/e8/aa3dbe6f34b76cffb416e571b27e7fff9412ace109d0ecedf373e2b7e0ee/jupyter_bokeh-4.1.0-py3-none-any.whl", hash = "sha256:f25abed6052de447b759eb80d09f6aea676ce031e077462cd978fa8e941850db", size = 1332993, upload-time = "2026-06-09T11:01:55.881Z" }, ] [[package]] @@ -2736,7 +2736,7 @@ requires-dist = [ { name = "fastapi" }, { name = "griffe-pydantic", marker = "extra == 'docs'" }, { name = "gunicorn", specifier = ">=20.1.0" }, - { name = "jupyter-bokeh", specifier = ">=4.0.5" }, + { name = "jupyter-bokeh", specifier = ">=4.1.0" }, { name = "mkdocs", marker = "extra == 'docs'" }, { name = "mkdocs-gen-files", marker = "extra == 'docs'" }, { name = "mkdocs-literate-nav", marker = "extra == 'docs'" },