diff --git a/reacton/core.py b/reacton/core.py index 228f487..c7fe72d 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -405,7 +405,7 @@ def hold_trait_notifications_extra(*args, **kwargs): raise RuntimeError(f"Could not create widget {self.component.widget} with {kwargs}") from e for name, callback in listeners.items(): if callback is not None: - self._add_widget_event_listener(widget, name, callback) + self._add_widget_event_listener(widget, name, callback, kwargs=kwargs) after = set(_get_widgets_dict()) orphans = (after - before) - {widget.model_id} return widget, orphans @@ -420,7 +420,7 @@ def _update_widget(self, widget: widgets.Widget, el_prev: "Element", kwargs): # update values for name, value in kwargs.items(): if name.startswith("on_") and name not in args: - self._update_widget_event_listener(widget, name, value, el_prev.kwargs.get(name)) + self._update_widget_event_listener(widget, name, value, el_prev.kwargs.get(name), kwargs=kwargs) else: self._update_widget_prop(widget, name, value) @@ -440,22 +440,31 @@ def _update_widget(self, widget: widgets.Widget, el_prev: "Element", kwargs): def _update_widget_prop(self, widget, name, value): setattr(widget, name, value) - def _update_widget_event_listener(self, widget: widgets.Widget, name: str, callback: Optional[Callable], callback_prev: Optional[Callable]): + def _update_widget_event_listener(self, widget: widgets.Widget, name: str, callback: Optional[Callable], callback_prev: Optional[Callable], kwargs): # it's an event listener if callback != callback_prev and callback_prev is not None: self._remove_widget_event_listener(widget, name, callback_prev) if callback is not None and callback != callback_prev: - self._add_widget_event_listener(widget, name, callback) + self._add_widget_event_listener(widget, name, callback, kwargs=kwargs) - def _add_widget_event_listener(self, widget: widgets.Widget, name: str, callback: Callable): + def _add_widget_event_listener(self, widget: widgets.Widget, name: str, callback: Callable, kwargs): target_name = name[3:] callback_exception_safe = _event_handler_exception_wrapper(callback) + rc = get_render_context() def on_change(change): if are_events_supressed(): return logger.info("event %r on %r with %r", name, widget, change) + render_count = rc.render_count callback_exception_safe(change["new"]) + if render_count == rc.render_count: + # we got an event from a child, but we did not rerender + # this means that the widget can be in a state that is not consistent + # with the element. Note that this similar to how react does things. + # Also note that SolidJS and VueJS have the same problem, and can show + # and inconsistent state. + self._update_widget(widget, self, kwargs) key = (widget.model_id, name, callback) self._callback_wrappers[key] = on_change diff --git a/reacton/core_test.py b/reacton/core_test.py index d19390b..613aae9 100644 --- a/reacton/core_test.py +++ b/reacton/core_test.py @@ -3204,3 +3204,61 @@ def Test(): set_state(1) rc.render(w.HTML(value="recover").key("HTML")) rc.close() + + +def test_widget_out_of_sync_no_state_change(): + @react.component + def Test(): + value, set_value = react.use_state("AA") + + def on_value(new_value): + # breakpoint() + set_value(new_value.upper()) + + # add layout to make sure kwargs are transformed from elements to widgets + return w.Text(value=value, on_value=on_value, layout=w.Layout(width="100%")) + + box, rc = react.render(Test(), handle_error=False) + text = rc.find(widgets.Text).widget + assert text.value == "AA" + text.value = "bb" + assert text.value == "BB" + text.value = "Bb" + assert text.value == "BB" + rc.close() + + +def test_widget_out_of_sync_no_state_change_in_child(): + # similar to above, but make sure we also test the case where we do + # re-render the parent only, since this might skip the child reconciliation + # in the future if we optimize that part. + + @react.component + def UpperCaseText(on_value): + value, set_value = react.use_state("AA") + + def on_value_self(new_value): + # breakpoint() + set_value(new_value.upper()) + on_value(new_value) + + # add layout to make sure kwargs are transformed from elements to widgets + return w.Text(value=value, on_value=on_value_self, layout=w.Layout(width="100%")) + + @react.component + def Test(): + count, set_count = react.use_state(0) + + def force_rerender(): + set_count(count + 1) + + UpperCaseText(on_value=lambda x: force_rerender()) + + box, rc = react.render(Test(), handle_error=False) + text = rc.find(widgets.Text).widget + assert text.value == "AA" + text.value = "bb" + assert text.value == "BB" + text.value = "Bb" + assert text.value == "BB" + rc.close() diff --git a/reacton/ipywidgets.py b/reacton/ipywidgets.py index 99ece34..c36dbec 100644 --- a/reacton/ipywidgets.py +++ b/reacton/ipywidgets.py @@ -88,7 +88,7 @@ def set_index(index): class ButtonElement(reacton.core.Element): - def _add_widget_event_listener(self, widget: widgets.Widget, name: str, callback: Callable): + def _add_widget_event_listener(self, widget: widgets.Widget, name: str, callback: Callable, kwargs): if name == "on_click": callback_exception_safe = _event_handler_exception_wrapper(callback) @@ -100,7 +100,7 @@ def on_click(change): widget.on_click(on_click) else: - super()._add_widget_event_listener(widget, name, callback) + super()._add_widget_event_listener(widget, name, callback, kwargs) def _remove_widget_event_listener(self, widget: widgets.Widget, name: str, callback: Callable): if name == "on_click":