Skip to content

Commit 52a88f9

Browse files
fix: inconsistent state between widget and model possible without render
When no internal state changes happens on the Python side, we will not re-render even though the widget state may have changed. This means that the widget model and our model (the element) is out of sync. SolidJS and VueJS have a similar issue.
1 parent 51e6eda commit 52a88f9

File tree

3 files changed

+74
-7
lines changed

3 files changed

+74
-7
lines changed

reacton/core.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -405,7 +405,7 @@ def hold_trait_notifications_extra(*args, **kwargs):
405405
raise RuntimeError(f"Could not create widget {self.component.widget} with {kwargs}") from e
406406
for name, callback in listeners.items():
407407
if callback is not None:
408-
self._add_widget_event_listener(widget, name, callback)
408+
self._add_widget_event_listener(widget, name, callback, kwargs=kwargs)
409409
after = set(_get_widgets_dict())
410410
orphans = (after - before) - {widget.model_id}
411411
return widget, orphans
@@ -420,7 +420,7 @@ def _update_widget(self, widget: widgets.Widget, el_prev: "Element", kwargs):
420420
# update values
421421
for name, value in kwargs.items():
422422
if name.startswith("on_") and name not in args:
423-
self._update_widget_event_listener(widget, name, value, el_prev.kwargs.get(name))
423+
self._update_widget_event_listener(widget, name, value, el_prev.kwargs.get(name), kwargs=kwargs)
424424
else:
425425
self._update_widget_prop(widget, name, value)
426426

@@ -440,22 +440,31 @@ def _update_widget(self, widget: widgets.Widget, el_prev: "Element", kwargs):
440440
def _update_widget_prop(self, widget, name, value):
441441
setattr(widget, name, value)
442442

443-
def _update_widget_event_listener(self, widget: widgets.Widget, name: str, callback: Optional[Callable], callback_prev: Optional[Callable]):
443+
def _update_widget_event_listener(self, widget: widgets.Widget, name: str, callback: Optional[Callable], callback_prev: Optional[Callable], kwargs):
444444
# it's an event listener
445445
if callback != callback_prev and callback_prev is not None:
446446
self._remove_widget_event_listener(widget, name, callback_prev)
447447
if callback is not None and callback != callback_prev:
448-
self._add_widget_event_listener(widget, name, callback)
448+
self._add_widget_event_listener(widget, name, callback, kwargs=kwargs)
449449

450-
def _add_widget_event_listener(self, widget: widgets.Widget, name: str, callback: Callable):
450+
def _add_widget_event_listener(self, widget: widgets.Widget, name: str, callback: Callable, kwargs):
451451
target_name = name[3:]
452452
callback_exception_safe = _event_handler_exception_wrapper(callback)
453+
rc = get_render_context()
453454

454455
def on_change(change):
455456
if are_events_supressed():
456457
return
457458
logger.info("event %r on %r with %r", name, widget, change)
459+
render_count = rc.render_count
458460
callback_exception_safe(change["new"])
461+
if render_count == rc.render_count:
462+
# we got an event from a child, but we did not rerender
463+
# this means that the widget can be in a state that is not consistent
464+
# with the element. Note that this similar to how react does things.
465+
# Also note that SolidJS and VueJS have the same problem, and can show
466+
# and inconsistent state.
467+
self._update_widget(widget, self, kwargs)
459468

460469
key = (widget.model_id, name, callback)
461470
self._callback_wrappers[key] = on_change

reacton/core_test.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3204,3 +3204,61 @@ def Test():
32043204
set_state(1)
32053205
rc.render(w.HTML(value="recover").key("HTML"))
32063206
rc.close()
3207+
3208+
3209+
def test_widget_out_of_sync_no_state_change():
3210+
@react.component
3211+
def Test():
3212+
value, set_value = react.use_state("AA")
3213+
3214+
def on_value(new_value):
3215+
# breakpoint()
3216+
set_value(new_value.upper())
3217+
3218+
# add layout to make sure kwargs are transformed from elements to widgets
3219+
return w.Text(value=value, on_value=on_value, layout=w.Layout(width="100%"))
3220+
3221+
box, rc = react.render(Test(), handle_error=False)
3222+
text = rc.find(widgets.Text).widget
3223+
assert text.value == "AA"
3224+
text.value = "bb"
3225+
assert text.value == "BB"
3226+
text.value = "Bb"
3227+
assert text.value == "BB"
3228+
rc.close()
3229+
3230+
3231+
def test_widget_out_of_sync_no_state_change_in_child():
3232+
# similar to above, but make sure we also test the case where we do
3233+
# re-render the parent only, since this might skip the child reconciliation
3234+
# in the future if we optimize that part.
3235+
3236+
@react.component
3237+
def UpperCaseText(on_value):
3238+
value, set_value = react.use_state("AA")
3239+
3240+
def on_value_self(new_value):
3241+
# breakpoint()
3242+
set_value(new_value.upper())
3243+
on_value(new_value)
3244+
3245+
# add layout to make sure kwargs are transformed from elements to widgets
3246+
return w.Text(value=value, on_value=on_value_self, layout=w.Layout(width="100%"))
3247+
3248+
@react.component
3249+
def Test():
3250+
count, set_count = react.use_state(0)
3251+
3252+
def force_rerender():
3253+
set_count(count + 1)
3254+
3255+
UpperCaseText(on_value=lambda x: force_rerender())
3256+
3257+
box, rc = react.render(Test(), handle_error=False)
3258+
text = rc.find(widgets.Text).widget
3259+
assert text.value == "AA"
3260+
text.value = "bb"
3261+
assert text.value == "BB"
3262+
text.value = "Bb"
3263+
assert text.value == "BB"
3264+
rc.close()

reacton/ipywidgets.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ def set_index(index):
8888

8989

9090
class ButtonElement(reacton.core.Element):
91-
def _add_widget_event_listener(self, widget: widgets.Widget, name: str, callback: Callable):
91+
def _add_widget_event_listener(self, widget: widgets.Widget, name: str, callback: Callable, kwargs):
9292
if name == "on_click":
9393
callback_exception_safe = _event_handler_exception_wrapper(callback)
9494

@@ -100,7 +100,7 @@ def on_click(change):
100100
widget.on_click(on_click)
101101

102102
else:
103-
super()._add_widget_event_listener(widget, name, callback)
103+
super()._add_widget_event_listener(widget, name, callback, kwargs)
104104

105105
def _remove_widget_event_listener(self, widget: widgets.Widget, name: str, callback: Callable):
106106
if name == "on_click":

0 commit comments

Comments
 (0)