From 18abcd287ce5fc1d16561de578c4196271f26ca7 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Thu, 3 Sep 2026 12:29:05 +0200 Subject: [PATCH] Close ipyvue Template orphans that are not shared ipyvue 3.0.0 only puts a Template widget in its per-file template_registry when there is a real comm. Without a kernel (tests, and any DummyComm setup) every VueTemplate gets its own Template, so the widget is owned by that VueTemplate and must die with it. reacton exempted every Template from orphan cleanup by class name, which leaked a Template per render on ipyvue 3 and made test_vue_orphan_not_close fail in the cleanup_guard fixture. Ask the registry instead of the class name, so the exemption applies exactly to the templates that really are shared. This keeps ipyvue 1.x behaviour, where templates are always registered. CI now pins ipyvue/ipyvuetify below 3 for the existing matrix and adds two py3.12 jobs on the 3.x line, so both majors stay green. --- .github/workflows/unittest.yml | 15 +++++++++++++-- reacton/core.py | 24 ++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/.github/workflows/unittest.yml b/.github/workflows/unittest.yml index 77880d6..6b32831 100644 --- a/.github/workflows/unittest.yml +++ b/.github/workflows/unittest.yml @@ -25,8 +25,19 @@ jobs: # run the suite against both the default renderer and the opt-in fast # one (REACTON_FAST=1), so both stay green reacton-fast: ["0", "1"] + # ipyvue/ipyvuetify 3 are the Vue 3 port. Pin the 1.x line by default and + # add one python version that runs against the 3.x line, so reacton keeps + # working with both. + ipyvue: ["<3"] + include: + - python-version: "3.12" + reacton-fast: "0" + ipyvue: ">=3" + - python-version: "3.12" + reacton-fast: "1" + ipyvue: ">=3" - name: unit-test (py${{ matrix.python-version }}, REACTON_FAST=${{ matrix.reacton-fast }}) + name: unit-test${{ matrix.ipyvue == '>=3' && '-vue3' || '' }} (py${{ matrix.python-version }}, REACTON_FAST=${{ matrix.reacton-fast }}) steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} @@ -39,7 +50,7 @@ jobs: path: ./dist - name: Install run: | - pip install `echo dist/*.whl`[dev] "bqplot<0.13" "pandas<3" "ruff==0.8.3" + pip install `echo dist/*.whl`[dev] "bqplot<0.13" "pandas<3" "ruff==0.8.3" "ipyvue${{ matrix.ipyvue }}" "ipyvuetify${{ matrix.ipyvue }}" - name: test env: REACTON_FAST: ${{ matrix.reacton-fast }} diff --git a/reacton/core.py b/reacton/core.py index c714691..ff9f815 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -170,6 +170,26 @@ def close_widget(widget: widgets.Widget): logger.warning("Widget %r does not have a close method, possibly a close trait was added", widget) +def _is_shared_ipyvue_template(widget: widgets.Widget) -> bool: + """Is this widget an ipyvue Template that is shared between VueTemplate instances? + + ipyvue keeps a per-file registry of Template widgets (ipyvue/Template.py), so several + VueTemplate widgets can point at the same Template. Such a Template outlives the + VueTemplate that created it, and we must not close it as an orphan. + ipyvue 3 only puts a Template in that registry when there is a real comm, so an + unregistered Template belongs to a single VueTemplate and is a normal orphan. + """ + cls = widget.__class__ + if cls.__name__ != "Template" or cls.__module__ != "ipyvue.Template": + return False + module = sys.modules.get(cls.__module__) + registry = getattr(module, "template_registry", None) + if registry is None: + # unknown ipyvue version: keep the old, conservative behaviour + return True + return any(template is widget for template in registry.values()) + + def _event_handler_exception_wrapper(f): """Wrap an event handler to catch exceptions and put them in a reacton context. @@ -2166,7 +2186,7 @@ def reconsolidate_children(): if orphan_ids: for orphan_widget in orphan_widgets: # these are shared widgets - if orphan_widget.__class__.__name__ == "Template" and orphan_widget.__class__.__module__ == "ipyvue.Template": + if _is_shared_ipyvue_template(orphan_widget): orphan_ids -= {orphan_widget.model_id} if el.is_shared: widget = self._shared_widgets[el] @@ -2750,7 +2770,7 @@ def _reconsolidate(self, el: Element, default_key: str, parent_key: str): orphan_widgets = set([_get_widgets_dict()[k] for k in orphan_ids]) for orphan_widget in orphan_widgets: # these are shared between widgets - if orphan_widget.__class__.__name__ == "Template" and orphan_widget.__class__.__module__ == "ipyvue.Template": + if _is_shared_ipyvue_template(orphan_widget): orphan_ids -= {orphan_widget.model_id} widget = self._shared_widgets[el] if el.is_shared else context.widgets[key] if widget.model_id not in self._orphans: