Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion reacton/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -2489,6 +2489,10 @@ class _RenderContextFast(_RenderContext):
# without walking either.
# ------------------------------------------------------------------

# > 0 while the render phase walks the new children of a widget that replaces a
# widget of another type (see _render)
_replacing = 0

def _set_rerender_needed(self, reason: str):
self._rerender_needed_reasons.append(RerenderReason(reason=reason))
self._rerender_needed = True
Expand Down Expand Up @@ -2537,7 +2541,18 @@ def _render(self, element: Element, default_key: str, parent_key: str):
del context.children_next[key]
# the element arguments are part of this component's element tree
if el.kwargs:
self._visit_children(el, key, parent_key, self._render)
el_reconciled = context.elements.get(key)
if el_reconciled is not None and el_reconciled.component != el.component:
# reconciliation replaces the widget at this key, and first removes the
# old subtree, including the component contexts in it: the walk below
# must not keep one of those as it is (see the fast path further down)
self._replacing += 1
try:
self._visit_children(el, key, parent_key, self._render)
finally:
self._replacing -= 1
else:
self._visit_children(el, key, parent_key, self._render)
return

assert isinstance(el.component, ComponentFunction)
Expand All @@ -2555,6 +2570,7 @@ def _render(self, element: Element, default_key: str, parent_key: str):
not self._walk_all
and el is el_prev
and not el.is_shared
and not self._replacing
and context_previous is not None
and context.children.get(key) is context_previous
and not context_previous.needs_render
Expand Down
25 changes: 25 additions & 0 deletions reacton/core_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -3794,3 +3794,28 @@ def App():
set_offset(0)
assert [child.description for child in vbox.children[1:]] == ["0", "1"]
rc.close()


def test_replace_parent_same_child_element():
# like test_replace_parent, but the child element is the same object in every render
# (it comes from outside): replacing its parent widget removes the child's subtree,
# so the child cannot keep its previous widget
set_vertical = lambda x: None # noqa
child = ButtonComponentFunction(description="Hi")

@react.component
def Test(child):
nonlocal set_vertical
vertical, set_vertical = react.use_state(True)
Container = w.VBox if vertical else w.HBox
with w.VBox() as main:
Container(children=[child])
return main

box, rc = react.render(Test(child), handle_error=False)
assert len(rc.find(widgets.Button)) == 1
set_vertical(False)
assert len(rc.find(widgets.HBox).find(widgets.Button)) == 1
set_vertical(True)
assert len(rc.find(widgets.Button)) == 1
rc.close()
160 changes: 160 additions & 0 deletions reacton/fuzz_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# Compare the fast renderer (REACTON_FAST=1) with the default renderer on random component
# trees: the fast renderer skips parts of the tree that did not change, and after every
# (batch of) state change(s) it must give the same widgets and run the same effects, in the
# same order, as the default renderer that walks everything.
import os
import random
import unittest.mock
import zlib
from typing import Any, Callable, Dict, List, Optional

import ipywidgets as widgets
import pytest

import reacton
import reacton as react

from . import core
from . import ipywidgets as w
from .core_test import cleanup_guard # noqa: F401 (autouse: no leaked widgets or callbacks)


def _random_app(registry, log):
# component trees that change shape with their state (container type flips, a changing
# number of children, keys, shuffles, fragments, components whose root is a component,
# leaves whose root widget changes type, effects that set state, caught exceptions)
def h(*args):
# not hash(): the hash of a tuple differs between Python versions, and every version
# must generate the same trees
return zlib.crc32(repr(args).encode())

@react.component
def Leaf(id):
value, set_value = react.use_state(0)
registry[id] = set_value
if value == 0 and h(id) % 5 == 0:
# state set during the first render (a second render pass)
set_value(1)
kind = h(id, value) % 4
if kind == 0:
return w.Label(value=f"leaf {id} {value}")
if kind == 1:
return reacton.Fragment(children=[w.Button(description=f"f{id}.{i}") for i in range(value % 3)])
return w.Button(description=f"leaf {id} {value}")

@react.component
def Wrapper(id):
return Leaf(id * 7 + 1)

@react.component
def Thrower(id):
value, set_value = react.use_state(0)
registry[id] = set_value
if value == 7 or (value == 0 and h(id) % 3 == 0):
# also raises in its first render (in a new subtree)
raise ValueError(f"boom {id}")
return w.Button(description=f"thrower {id} {value}")

@react.component
def Catcher(id):
exception, clear = react.use_exception()
state, set_state = react.use_state(0)

def set_value(value):
clear()
set_state(value)

registry[id] = set_value
if exception:
return w.Label(value=f"caught {exception}")
return w.HBox(children=[Thrower(id * 3 + 2), Leaf(id * 3 + 1)])

@react.component
def Node(id, depth):
state, set_state = react.use_state(0)
registry[id] = set_state
seed = h(id, state)
rnd = random.Random(seed)

def effect():
log.append(("effect", id, state))
if state % 5 == 4:
set_state(state + 1)

react.use_effect(effect, [state])
children: List[Any] = [w.Label(value=f"node {id} {state}")]
for i in range(rnd.randint(0, 4)):
child_id = id * 10 + i
r = rnd.random()
if depth < 3 and r < 0.4:
child = Node(child_id, depth + 1)
elif r < 0.6:
child = Wrapper(child_id)
elif r < 0.7:
child = Catcher(child_id)
else:
child = Leaf(child_id)
if rnd.random() < 0.3:
child = child.key(f"k{child_id}")
children.append(child)
if rnd.random() < 0.2:
rnd.shuffle(children)
if depth == 0:
# the root keeps its widget type: without a container, reacton does not support a
# root component whose widget changes
return w.VBox(children=children)
if seed % 7 == 0:
return reacton.Fragment(children=children)
return (w.VBox if seed % 3 else w.HBox)(children=children)

return Node


def _widget_signature(widget):
# a closed widget still has its traits: compare that it is open too
closed = widget.comm is None
if isinstance(widget, widgets.Box):
return (type(widget).__name__, closed, [_widget_signature(child) for child in widget.children])
return (type(widget).__name__, closed, getattr(widget, "value", None), getattr(widget, "description", None))


def _run_random_updates(fast: bool, seed: int, steps: int, batches: Optional[List] = None):
registry: Dict[int, Callable] = {}
log: List[tuple] = []
Node = _random_app(registry, log)
record = batches is None
batches = [] if batches is None else batches
choices = random.Random(seed)
results = []
with unittest.mock.patch.dict(os.environ, {"REACTON_FAST": "1" if fast else "0"}):
widget, rc = react.render_fixed(Node(1, 0), handle_error=False)
assert isinstance(rc, core._RenderContextFast) == fast
for step in range(steps):
if record:
ids = sorted(registry)
batches.append([(choices.choice(ids), choices.randint(0, 9)) for _ in range(choices.choice([1, 1, 1, 2, 3]))])
batch = batches[step]
log.clear()
with rc:
for id, value in batch:
if id in registry:
registry[id](value)
results.append((_widget_signature(rc.last_root_widget), sorted(registry), list(log)))
rc.close()
return results, batches


@pytest.mark.parametrize("seed", range(12))
def test_renderers_agree_on_random_updates(seed):
# the fast renderer skips (and only partially walks) parts of the tree: after every
# (batch of) state change(s) it must give the same widgets, and run the same effects in
# the same order, as the default renderer that walks everything
level = core.logger.level
core.logger.setLevel(core.logging.CRITICAL) # the thrower logs tracebacks
try:
expected, batches = _run_random_updates(False, seed, 25)
got, _ = _run_random_updates(True, seed, 25, batches)
finally:
core.logger.setLevel(level)
for step, (a, b) in enumerate(zip(expected, got)):
assert a == b, f"step {step}, batch {batches[step]}"
Loading