diff --git a/reacton/core.py b/reacton/core.py index 93441c8..54092be 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -7,11 +7,13 @@ """ +import collections import contextlib import copy import functools import inspect import logging +import os import sys import threading import traceback @@ -24,6 +26,7 @@ Any, Callable, ContextManager, + Deque, Dict, Generic, List, @@ -1274,7 +1277,11 @@ def __init__(self, element: Element, container: widgets.Widget = None, children_ self.last_root_widget: widgets.Widget = None self._is_rendering = False self._rerender_needed = False - self._rerender_needed_reasons: List[RerenderReason] = [] + # the reasons are only read for the "too many renders" error message, and a reason + # holds the previous and next state value: keeping all of them kept every old state + # value alive until close(). REACTON_RERENDER_REASONS keeps more, for debugging. + max_reasons = max(1, int(os.environ.get("REACTON_RERENDER_REASONS", "2"))) + self._rerender_needed_reasons: Deque[RerenderReason] = collections.deque(maxlen=max_reasons) self.thread_lock = threading.Lock() self._closing = False self.tracebacks: List[TracebackType] = [] @@ -1654,10 +1661,10 @@ def format(reason: RerenderReason): f += f"Triggered at: {''.join(reason.trigger_stack)}\n" return f - self._rerender_needed_reasons[-1] msg = f"Too many renders triggered, your render loop does not stop\nLast reason: {format(self._rerender_needed_reasons[-1])}\n" if len(self._rerender_needed_reasons) >= 2: - msg += f"Previous reasons: {format(self._rerender_needed_reasons[-2])}\n" + previous = reversed(list(self._rerender_needed_reasons)[:-1]) + msg += f"Previous reasons: {''.join(format(reason) for reason in previous)}\n" raise RuntimeError(msg) logger.info("Entering nested render phase: %r", self._rerender_needed_reasons[-1]) self._rerender_needed = False @@ -3022,8 +3029,6 @@ def _remove_stale_root_elements(self, parent_key): def _render_context_class(): - import os - return _RenderContextFast if os.environ.get("REACTON_FAST") == "1" else _RenderContext diff --git a/reacton/core_test.py b/reacton/core_test.py index 9e24e35..00c8625 100644 --- a/reacton/core_test.py +++ b/reacton/core_test.py @@ -3110,11 +3110,31 @@ def Infinite(): rc.render(rc.element) assert "Too many renders triggered" in str(e) - assert len(rc._rerender_needed_reasons) >= 50 + assert "Last reason: Reason: state changed" in str(e) + assert "Previous reasons: Reason: state changed" in str(e) + assert Infinite.render_count >= 50 # type: ignore rc.close() +def test_debug_infinite_loop_more_reasons(monkeypatch): + monkeypatch.setenv("REACTON_RERENDER_REASONS", "5") + + @reacton.component + def Infinite(): + state, set_state = reacton.use_state(0) + set_state(state + 1) + return w.Button(description=str(state)) + + with pytest.raises(RuntimeError) as e: + rc = core._RenderContext(Infinite(), handle_error=False) + rc.render(rc.element) + + assert rc._rerender_needed_reasons.maxlen == 5 + assert str(e.value).count("Reason: state changed") == 5 + rc.close() + + def test_batch_update(): set_state = lambda x: None # noqa @@ -3247,6 +3267,36 @@ def test_no_reference_cycles_after_close(component): gc.enable() +def test_state_changes_do_not_keep_old_values_alive(): + # every state change used to append a RerenderReason with the previous and next + # value to the render context, so a long-lived page kept every old state value + # alive until it was closed + class Big: + pass + + set_value: Callable[[Big], None] = lambda x: None # noqa + + @react.component + def Test(): + nonlocal set_value + value, set_value = react.use_state(Big()) + return w.Button(description=str(id(value))) + + box, rc = react.render(Test(), handle_error=False) + refs = [] + for i in range(20): + big = Big() + refs.append(weakref.ref(big)) + set_value(big) + del big + gc.collect() + alive = sum(ref() is not None for ref in refs) + # the current value, and the values of the last two rerender reasons + assert alive <= 3 + assert len(rc._rerender_needed_reasons) <= 2 + rc.close() + + def test_fragment(): @reacton.component def Children():