Skip to content

Commit 9e88876

Browse files
Merge pull request #68 from widgetti/fix/fast-cleanup-before-new-effects
Fast renderer: clean up removed components before new effects run
2 parents ef0642c + f474df4 commit 9e88876

3 files changed

Lines changed: 173 additions & 0 deletions

File tree

‎reacton/core.py‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1205,6 +1205,11 @@ class ComponentContext:
12051205
# the render phase skipped this whole subtree (nothing changed), so the
12061206
# reconciliation phase can reuse the previous result without walking
12071207
clean_subtree: bool = False
1208+
# Fast renderer only: stale component effect cleanups are run at the same
1209+
# point where the default renderer removes stale elements, while widget
1210+
# closing stays deferred to the fast renderer's stale sweep.
1211+
fast_stale_effect_keys: Optional[List[str]] = None
1212+
fast_stale_effects_cleaned: Set[str] = field(default_factory=set)
12081213

12091214
# elements created in this context go there
12101215
owns: Set[Element] = field(default_factory=set)
@@ -1263,6 +1268,8 @@ def _teardown_component_context(context: ComponentContext):
12631268
context.exceptions_self = []
12641269
context.exceptions_children = []
12651270
context.context_managers = []
1271+
context.fast_stale_effect_keys = None
1272+
context.fast_stale_effects_cleaned = set()
12661273

12671274

12681275
@dataclass
@@ -2523,6 +2530,8 @@ def _render(self, element: Element, default_key: str, parent_key: str):
25232530
# the root element of a component determines which keys are in use,
25242531
# everything else is stale and gets removed during reconciliation
25252532
context.used_keys.clear()
2533+
context.fast_stale_effect_keys = None
2534+
context.fast_stale_effects_cleaned.clear()
25262535

25272536
el = element
25282537
key = el._key
@@ -2923,6 +2932,7 @@ def _reconsolidate(self, el: Element, default_key: str, parent_key: str):
29232932
self._shared_elements.add(el)
29242933
assert el in self._shared_elements_next
29252934
self._shared_elements_next.remove(el)
2935+
self._cleanup_stale_effects_for_context(context, parent_key)
29262936

29272937
def _process_effects(self, child_context: "ComponentContext", context: "ComponentContext"):
29282938
# NOTE: effect/cleanup exceptions are recorded on the context of the
@@ -2965,6 +2975,64 @@ def _store_widget(self, context: "ComponentContext", el: Element, key: str, widg
29652975
else:
29662976
context.widgets[key] = widget
29672977

2978+
def _cleanup_stale_effects_for_context(self, context: "ComponentContext", parent_key: str):
2979+
if context.fast_stale_effect_keys is None:
2980+
# reversed, so we can pop from the end and still go in sorted order
2981+
context.fast_stale_effect_keys = sorted(set(context.elements) - context.used_keys, reverse=True)
2982+
context_prev = self.context
2983+
try:
2984+
while context.fast_stale_effect_keys:
2985+
stale_key = context.fast_stale_effect_keys.pop()
2986+
if stale_key not in context.elements or stale_key in context.fast_stale_effects_cleaned:
2987+
continue
2988+
self.context = context
2989+
self._cleanup_stale_effects(context.elements[stale_key], stale_key, parent_key)
2990+
finally:
2991+
self.context = context_prev
2992+
2993+
def _cleanup_stale_effects(self, el: Element, default_key: str, parent_key: str):
2994+
key = el._key
2995+
if key is None:
2996+
key = default_key
2997+
assert key is not None
2998+
context = self.context
2999+
assert context is not None
3000+
if key in context.fast_stale_effects_cleaned:
3001+
return
3002+
if el.is_shared and (el in self._shared_elements_next or el not in self._shared_elements):
3003+
return
3004+
context.fast_stale_effects_cleaned.add(key)
3005+
3006+
if isinstance(el.component, ComponentFunction):
3007+
if el.is_shared:
3008+
self._visit_children(el, key, parent_key, self._cleanup_stale_effects)
3009+
child_context = context.children.get(key)
3010+
if child_context is None:
3011+
return
3012+
try:
3013+
self.context = child_context
3014+
child_context.exceptions_self = []
3015+
child_context.exceptions_children = []
3016+
for effect in child_context.effects:
3017+
try:
3018+
if not effect._cleaned_up:
3019+
effect.cleanup()
3020+
except BaseException as e:
3021+
effect._cleaned_up = True
3022+
logger.exception("Effect cleanup %r raised exception %r", effect.callable, e)
3023+
child_context.exceptions_self.append(e)
3024+
self._set_rerender_needed("Exception ocurred during effect")
3025+
_mark_needs_render_ancestors(child_context)
3026+
assert child_context.root_element is not None
3027+
self._cleanup_stale_effects(child_context.root_element, "/", parent_key=join_key(parent_key, key))
3028+
finally:
3029+
self.context = context
3030+
if child_context.exceptions_self or child_context.exceptions_children and not child_context.exception_handler:
3031+
context.exceptions_children.extend(child_context.exceptions_self)
3032+
context.exceptions_children.extend(child_context.exceptions_children)
3033+
else:
3034+
self._visit_children(el, key, parent_key, self._cleanup_stale_effects)
3035+
29683036
def _remove_element(self, el: Element, default_key: str, parent_key):
29693037
key = el._key
29703038
if key is None:

‎reacton/core_test.py‎

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4088,3 +4088,102 @@ def cleanup():
40884088
assert box.children[0].value == "value 1"
40894089
finally:
40904090
rc.close()
4091+
4092+
4093+
def test_stale_component_cleanup_runs_before_new_sibling_effect():
4094+
active = None
4095+
set_show_old = lambda x: None # noqa
4096+
4097+
def owner(name):
4098+
def effect():
4099+
nonlocal active
4100+
active = name
4101+
4102+
def cleanup():
4103+
nonlocal active
4104+
active = None
4105+
4106+
return cleanup
4107+
4108+
return effect
4109+
4110+
@react.component
4111+
def Old():
4112+
react.use_effect(owner("old"), [])
4113+
return w.Label(value="old")
4114+
4115+
@react.component
4116+
def New():
4117+
react.use_effect(owner("new"), [])
4118+
return w.Label(value="new")
4119+
4120+
@react.component
4121+
def App():
4122+
nonlocal set_show_old
4123+
show_old, set_show_old = react.use_state(True)
4124+
children = [w.Label(value="first").key("first")]
4125+
if show_old:
4126+
children.append(Old().key("old"))
4127+
else:
4128+
children.append(New().key("new"))
4129+
return w.VBox(children=children)
4130+
4131+
box, rc = react.render(App(), handle_error=False)
4132+
assert active == "old"
4133+
set_show_old(False)
4134+
assert active == "new"
4135+
rc.close()
4136+
4137+
4138+
def test_stale_component_cleanup_order_with_nested_removed_components():
4139+
log = []
4140+
set_show_old = lambda x: None # noqa
4141+
4142+
def logger(name):
4143+
def effect():
4144+
log.append(f"{name} run")
4145+
4146+
def cleanup():
4147+
log.append(f"{name} cleanup")
4148+
4149+
return cleanup
4150+
4151+
return effect
4152+
4153+
@react.component
4154+
def Inner(name):
4155+
react.use_effect(logger(f"{name}-inner"), [])
4156+
return w.Label(value=f"{name}-inner")
4157+
4158+
@react.component
4159+
def Old(name):
4160+
react.use_effect(logger(f"{name}-outer"), [])
4161+
return w.VBox(children=[Inner(name=name)])
4162+
4163+
@react.component
4164+
def New():
4165+
react.use_effect(logger("new"), [])
4166+
return w.Label(value="new")
4167+
4168+
@react.component
4169+
def App():
4170+
nonlocal set_show_old
4171+
show_old, set_show_old = react.use_state(True)
4172+
children = [w.Label(value="first").key("first")]
4173+
if show_old:
4174+
children.extend([Old(name="z").key("z"), Old(name="a").key("a")])
4175+
else:
4176+
children.append(New().key("new"))
4177+
return w.VBox(children=children)
4178+
4179+
box, rc = react.render(App(), handle_error=False)
4180+
log.clear()
4181+
set_show_old(False)
4182+
assert log == [
4183+
"a-outer cleanup",
4184+
"a-inner cleanup",
4185+
"z-outer cleanup",
4186+
"z-inner cleanup",
4187+
"new run",
4188+
]
4189+
rc.close()

‎reacton/fuzz_test.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,12 @@ def effect():
8181
if state % 5 == 4:
8282
set_state(state + 1)
8383

84+
def cleanup():
85+
# both renderers must also run cleanups in the same order
86+
log.append(("cleanup", id, state))
87+
88+
return cleanup
89+
8490
react.use_effect(effect, [state])
8591
children: List[Any] = [w.Label(value=f"node {id} {state}")]
8692
for i in range(rnd.randint(0, 4)):

0 commit comments

Comments
 (0)