diff --git a/reacton/ipyvue.py b/reacton/ipyvue.py index 580c21e..d805834 100644 --- a/reacton/ipyvue.py +++ b/reacton/ipyvue.py @@ -3,7 +3,7 @@ import ipyvue import reacton as react -from reacton.core import get_render_context +from reacton.core import ComponentWidget, get_render_context def use_event(el: react.core.Element, event_and_modifiers, callback: Callable[[Any], Any]): @@ -11,6 +11,20 @@ def use_event(el: react.core.Element, event_and_modifiers, callback: Callable[[A callback_ref = react.use_ref(callback) callback_ref.current = callback + # Put the event name in the widget constructor arguments: the synced _events + # trait then goes along with the comm open message. The later on_event call + # (in the effect below) only updates _events when the event set differs, so + # this saves one update message per widget per event. When the element is + # reused from a previous render (memoized) and the widget already exists, + # on_event falls back to syncing _events itself. + if isinstance(el.component, ComponentWidget) and issubclass(el.component.widget, ipyvue.VueWidget): + events = el.kwargs.get("_events") + if events is None: + el.kwargs["_events"] = [event_and_modifiers] + elif event_and_modifiers not in events: + # do not mutate the list, it could be shared with a previous element + el.kwargs["_events"] = [*events, event_and_modifiers] + def add_event_handler(): vue_widget = cast(ipyvue.VueWidget, react.core.get_widget(el)) # we are basically copying the logic from reacton.core._event_handler_exception_wrapper @@ -32,6 +46,11 @@ def handler(*args): vue_widget.on_event(event_and_modifiers, handler) def cleanup(): + if rc._closing: + # the whole tree is going away: removing the handler would sync + # the _events trait to the frontend (one message per widget) + # right before the comm is closed anyway + return vue_widget.on_event(event_and_modifiers, handler, remove=True) return cleanup diff --git a/reacton/ipyvue_test.py b/reacton/ipyvue_test.py new file mode 100644 index 0000000..f7ebbb2 --- /dev/null +++ b/reacton/ipyvue_test.py @@ -0,0 +1,90 @@ +import unittest.mock + +import ipyvuetify +import ipyvue + +import reacton as react + +from . import ipyvuetify as v +from .ipyvue import use_event + + +def test_use_event_no_sync_messages(): + """The _events trait should be set at construction and never re-assigned. + + Re-assigning it (by on_event during the effect, or on_event(remove=True) + during close) sends one widget update message per widget per event. + """ + on_click = unittest.mock.Mock() + + @react.component + def Test(): + btn = v.Btn(children=["click me"]) + use_event(btn, "click", on_click) + return btn + + box, rc = react.render(Test(), handle_error=False) + btn = rc.find(ipyvuetify.Btn).widget + + events_changes = unittest.mock.Mock() + btn.observe(events_changes, "_events") + + # the event name went along with the constructor arguments + assert btn._events == ["click"] + # and the handler works + btn.fire_event("click", {}) + on_click.assert_called_once() + + rc.force_update() + events_changes.assert_not_called() + + rc.close() + events_changes.assert_not_called() + + +def test_use_event_removed_on_rerender(): + """When the widget persists but the event changes, _events must sync.""" + on_event = unittest.mock.Mock() + set_event_name = None + + @react.component + def Test(): + nonlocal set_event_name + event_name, set_event_name = react.use_state("click") + btn = v.Btn(children=["click me"]) + use_event(btn, event_name, on_event) + return btn + + box, rc = react.render(Test(), handle_error=False) + btn = rc.find(ipyvuetify.Btn).widget + assert btn._events == ["click"] + assert set_event_name is not None + set_event_name("dblclick") + assert btn._events == ["dblclick"] + btn.fire_event("dblclick", {}) + on_event.assert_called_once() + rc.close() + + +def test_use_event_component_element(): + """use_event on a component element (not a widget element) keeps working.""" + on_click = unittest.mock.Mock() + + @react.component + def Button(): + return v.Btn(children=["click me"]) + + @react.component + def Test(): + btn = Button() + use_event(btn, "click", on_click) + return btn + + box, rc = react.render(Test(), handle_error=False) + btn = rc.find(ipyvuetify.Btn).widget + assert isinstance(btn, ipyvue.VueWidget) + # falls back to syncing _events from the effect + assert btn._events == ["click"] + btn.fire_event("click", {}) + on_click.assert_called_once() + rc.close()