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
15 changes: 10 additions & 5 deletions reacton/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@

"""

import collections
import contextlib
import copy
import functools
import inspect
import logging
import os
import sys
import threading
import traceback
Expand All @@ -24,6 +26,7 @@
Any,
Callable,
ContextManager,
Deque,
Dict,
Generic,
List,
Expand Down Expand Up @@ -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] = []
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand Down
52 changes: 51 additions & 1 deletion reacton/core_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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():
Expand Down
Loading