From 8664a6425ad6277b159bbbf3b82ff69d5161a997 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 21 Jan 2025 20:43:31 -0800 Subject: [PATCH 1/6] Bug fix for corrupted hook state - change hook state to a contextvar --------- Co-authored-by: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> --- src/reactpy/core/_life_cycle_hook.py | 29 +++++++++++++++++++++------- src/reactpy/core/serve.py | 27 +++++++++++++++----------- 2 files changed, 38 insertions(+), 18 deletions(-) diff --git a/src/reactpy/core/_life_cycle_hook.py b/src/reactpy/core/_life_cycle_hook.py index 88d3386a8..c1181beb4 100644 --- a/src/reactpy/core/_life_cycle_hook.py +++ b/src/reactpy/core/_life_cycle_hook.py @@ -2,11 +2,11 @@ import logging from asyncio import Event, Task, create_task, gather +from contextvars import ContextVar, Token from typing import Any, Callable, Protocol, TypeVar from anyio import Semaphore -from reactpy.core._thread_local import ThreadLocal from reactpy.core.types import ComponentType, Context, ContextProviderType T = TypeVar("T") @@ -18,12 +18,27 @@ async def __call__(self, stop: Event) -> None: ... logger = logging.getLogger(__name__) -_HOOK_STATE: ThreadLocal[list[LifeCycleHook]] = ThreadLocal(list) +_hook_state = ContextVar("_hook_state") + + +def create_hook_state(initial: list | None = None) -> Token[list]: + return _hook_state.set(initial or []) + + +def clear_hook_state(token: Token[list]) -> None: + hook_stack = _hook_state.get() + if hook_stack: + logger.warning("clear_hook_state: Hook stack was not empty") + _hook_state.reset(token) + + +def get_hook_state() -> list[LifeCycleHook]: + return _hook_state.get() def current_hook() -> LifeCycleHook: """Get the current :class:`LifeCycleHook`""" - hook_stack = _HOOK_STATE.get() + hook_stack = _hook_state.get() if not hook_stack: msg = "No life cycle hook is active. Are you rendering in a layout?" raise RuntimeError(msg) @@ -130,7 +145,7 @@ def __init__( self._scheduled_render = False self._rendered_atleast_once = False self._current_state_index = 0 - self._state: tuple[Any, ...] = () + self._state: list = [] self._effect_funcs: list[EffectFunc] = [] self._effect_tasks: list[Task[None]] = [] self._effect_stops: list[Event] = [] @@ -157,7 +172,7 @@ def use_state(self, function: Callable[[], T]) -> T: if not self._rendered_atleast_once: # since we're not initialized yet we're just appending state result = function() - self._state += (result,) + self._state.append(result) else: # once finalized we iterate over each succesively used piece of state result = self._state[self._current_state_index] @@ -232,7 +247,7 @@ def set_current(self) -> None: This method is called by a layout before entering the render method of this hook's associated component. """ - hook_stack = _HOOK_STATE.get() + hook_stack = get_hook_state() if hook_stack: parent = hook_stack[-1] self._context_providers.update(parent._context_providers) @@ -240,5 +255,5 @@ def set_current(self) -> None: def unset_current(self) -> None: """Unset this hook as the active hook in this thread""" - if _HOOK_STATE.get().pop() is not self: + if get_hook_state().pop() is not self: raise RuntimeError("Hook stack is in an invalid state") # nocov diff --git a/src/reactpy/core/serve.py b/src/reactpy/core/serve.py index 3a540af59..08d0ab797 100644 --- a/src/reactpy/core/serve.py +++ b/src/reactpy/core/serve.py @@ -9,6 +9,7 @@ from anyio.abc import TaskGroup from reactpy.config import REACTPY_DEBUG_MODE +from reactpy.core._life_cycle_hook import clear_hook_state, create_hook_state from reactpy.core.types import LayoutEventMessage, LayoutType, LayoutUpdateMessage logger = getLogger(__name__) @@ -58,18 +59,22 @@ async def _single_outgoing_loop( layout: LayoutType[LayoutUpdateMessage, LayoutEventMessage], send: SendCoroutine ) -> None: while True: - update = await layout.render() + token = create_hook_state() try: - await send(update) - except Exception: # nocov - if not REACTPY_DEBUG_MODE.current: - msg = ( - "Failed to send update. More info may be available " - "if you enabling debug mode by setting " - "`reactpy.config.REACTPY_DEBUG_MODE.current = True`." - ) - logger.error(msg) - raise + update = await layout.render() + try: + await send(update) + except Exception: # nocov + if not REACTPY_DEBUG_MODE.current: + msg = ( + "Failed to send update. More info may be available " + "if you enabling debug mode by setting " + "`reactpy.config.REACTPY_DEBUG_MODE.current = True`." + ) + logger.error(msg) + raise + finally: + clear_hook_state(token) async def _single_incoming_loop( From d490009b797c224268dff57332807db704d84570 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 21 Jan 2025 22:59:37 -0800 Subject: [PATCH 2/6] Refactor and fix type hints --- pyproject.toml | 6 ------ src/reactpy/core/_life_cycle_hook.py | 21 ++++++--------------- src/reactpy/core/serve.py | 4 ++-- 3 files changed, 8 insertions(+), 23 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8c348f1e9..6a09f589c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,7 +89,6 @@ flask = ["flask", "markupsafe>=1.1.1,<2.1", "flask-cors", "flask-sock"] tornado = ["tornado"] testing = ["playwright"] - ############################# # >>> Hatch Test Runner <<< # ############################# @@ -134,11 +133,6 @@ xfail_strict = true asyncio_mode = "auto" log_cli_level = "INFO" -[tool.hatch.envs.default.scripts] -test-cov = "playwright install && coverage run -m pytest {args:tests}" -cov-report = ["coverage report"] -cov = ["test-cov {args}", "cov-report"] - [tool.hatch.envs.default.env-vars] REACTPY_DEBUG_MODE = "1" diff --git a/src/reactpy/core/_life_cycle_hook.py b/src/reactpy/core/_life_cycle_hook.py index c1181beb4..615083ed0 100644 --- a/src/reactpy/core/_life_cycle_hook.py +++ b/src/reactpy/core/_life_cycle_hook.py @@ -17,28 +17,19 @@ async def __call__(self, stop: Event) -> None: ... logger = logging.getLogger(__name__) - -_hook_state = ContextVar("_hook_state") - - -def create_hook_state(initial: list | None = None) -> Token[list]: - return _hook_state.set(initial or []) +_HOOK_STATE: ContextVar[list[LifeCycleHook]] = ContextVar("_hook_state") def clear_hook_state(token: Token[list]) -> None: - hook_stack = _hook_state.get() + hook_stack = _HOOK_STATE.get() if hook_stack: logger.warning("clear_hook_state: Hook stack was not empty") - _hook_state.reset(token) - - -def get_hook_state() -> list[LifeCycleHook]: - return _hook_state.get() + _HOOK_STATE.reset(token) def current_hook() -> LifeCycleHook: """Get the current :class:`LifeCycleHook`""" - hook_stack = _hook_state.get() + hook_stack = _HOOK_STATE.get() if not hook_stack: msg = "No life cycle hook is active. Are you rendering in a layout?" raise RuntimeError(msg) @@ -247,7 +238,7 @@ def set_current(self) -> None: This method is called by a layout before entering the render method of this hook's associated component. """ - hook_stack = get_hook_state() + hook_stack = _HOOK_STATE.get() if hook_stack: parent = hook_stack[-1] self._context_providers.update(parent._context_providers) @@ -255,5 +246,5 @@ def set_current(self) -> None: def unset_current(self) -> None: """Unset this hook as the active hook in this thread""" - if get_hook_state().pop() is not self: + if _HOOK_STATE.get().pop() is not self: raise RuntimeError("Hook stack is in an invalid state") # nocov diff --git a/src/reactpy/core/serve.py b/src/reactpy/core/serve.py index 08d0ab797..d731d40b2 100644 --- a/src/reactpy/core/serve.py +++ b/src/reactpy/core/serve.py @@ -9,7 +9,7 @@ from anyio.abc import TaskGroup from reactpy.config import REACTPY_DEBUG_MODE -from reactpy.core._life_cycle_hook import clear_hook_state, create_hook_state +from reactpy.core._life_cycle_hook import _HOOK_STATE, clear_hook_state from reactpy.core.types import LayoutEventMessage, LayoutType, LayoutUpdateMessage logger = getLogger(__name__) @@ -59,7 +59,7 @@ async def _single_outgoing_loop( layout: LayoutType[LayoutUpdateMessage, LayoutEventMessage], send: SendCoroutine ) -> None: while True: - token = create_hook_state() + token = _HOOK_STATE.set([]) try: update = await layout.render() try: From c60ab84b3be00346e0d9ad713c96ebea073dd289 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 11 Feb 2025 12:45:35 -0800 Subject: [PATCH 3/6] Fix hook state failure in Pytest --- tests/conftest.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 2bcd5d3ea..b95d9870f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -44,6 +44,15 @@ def rebuild(): subprocess.run(["hatch", "build", "-t", "wheel"], check=True) # noqa: S607, S603 +@pytest.fixture(autouse=True, scope="function") +def create_hook_state(): + from reactpy.core._life_cycle_hook import _HOOK_STATE + + token = _HOOK_STATE.set([]) + yield token + _HOOK_STATE.reset(token) + + @pytest.fixture async def display(server, page): async with DisplayFixture(server, page) as display: From 146ceef09e6ac9c8f790225f0ec4d5877629f4b9 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 11 Feb 2025 14:09:09 -0800 Subject: [PATCH 4/6] Fix test failures --- src/reactpy/core/_life_cycle_hook.py | 60 ++++++++++++++++++---------- src/reactpy/core/_thread_local.py | 6 ++- src/reactpy/core/hooks.py | 16 ++++---- src/reactpy/core/serve.py | 6 +-- src/reactpy/pyscript/utils.py | 25 +++++++----- src/reactpy/testing/common.py | 4 +- src/reactpy/utils.py | 10 +++++ tests/conftest.py | 14 +++++-- tests/test_core/test_layout.py | 6 +-- tests/tooling/hooks.py | 4 +- 10 files changed, 97 insertions(+), 54 deletions(-) diff --git a/src/reactpy/core/_life_cycle_hook.py b/src/reactpy/core/_life_cycle_hook.py index 123674198..726346e3b 100644 --- a/src/reactpy/core/_life_cycle_hook.py +++ b/src/reactpy/core/_life_cycle_hook.py @@ -1,13 +1,16 @@ from __future__ import annotations import logging +import sys from asyncio import Event, Task, create_task, gather from contextvars import ContextVar, Token from typing import Any, Callable, Protocol, TypeVar from anyio import Semaphore +from reactpy.core._thread_local import ThreadLocal from reactpy.types import ComponentType, Context, ContextProviderType +from reactpy.utils import Singleton T = TypeVar("T") @@ -17,23 +20,40 @@ async def __call__(self, stop: Event) -> None: ... logger = logging.getLogger(__name__) -_HOOK_STATE: ContextVar[list[LifeCycleHook]] = ContextVar("_hook_state") -def clear_hook_state(token: Token[list]) -> None: - hook_stack = _HOOK_STATE.get() - if hook_stack: - logger.warning("clear_hook_state: Hook stack was not empty") - _HOOK_STATE.reset(token) +class __HookStack(Singleton): # pragma: no cover + """A singleton object which manages the current component tree's hooks. + Life cycle hooks can be stored in a thread local or context variable depending + on the platform.""" + + _state: ThreadLocal[list[LifeCycleHook]] | ContextVar[list[LifeCycleHook]] = ( + ThreadLocal(list) if sys.platform == "emscripten" else ContextVar("hook_state") + ) + + def get(self) -> list[LifeCycleHook]: + return self._state.get() + + def initialize(self) -> Token[list[LifeCycleHook]] | None: + return None if isinstance(self._state, ThreadLocal) else self._state.set([]) + + def reset(self, token: Token[list[LifeCycleHook]] | None) -> None: + if isinstance(self._state, ThreadLocal): + self._state.get().clear() + elif token: + self._state.reset(token) + else: + raise RuntimeError("Hook stack is an ContextVar but no token was provided") + + def current_hook(self) -> LifeCycleHook: + hook_stack = self.get() + if not hook_stack: + msg = "No life cycle hook is active. Are you rendering in a layout?" + raise RuntimeError(msg) + return hook_stack[-1] -def current_hook() -> LifeCycleHook: - """Get the current :class:`LifeCycleHook`""" - hook_stack = _HOOK_STATE.get() - if not hook_stack: - msg = "No life cycle hook is active. Are you rendering in a layout?" - raise RuntimeError(msg) - return hook_stack[-1] +HOOK_STACK = __HookStack() class LifeCycleHook: @@ -43,7 +63,7 @@ class LifeCycleHook: a component is first rendered until it is removed from the layout. The life cycle is ultimately driven by the layout itself, but components can "hook" into those events to perform actions. Components gain access to their own life cycle hook - by calling :func:`current_hook`. They can then perform actions such as: + by calling :func:`HOOK_STACK.current_hook`. They can then perform actions such as: 1. Adding state via :meth:`use_state` 2. Adding effects via :meth:`add_effect` @@ -63,7 +83,7 @@ class LifeCycleHook: .. testcode:: from reactpy.core._life_cycle_hook import LifeCycleHook - from reactpy.core.hooks import current_hook + from reactpy.core.hooks import HOOK_STACK # this function will come from a layout implementation schedule_render = lambda: ... @@ -81,15 +101,15 @@ class LifeCycleHook: ... # the component may access the current hook - assert current_hook() is hook + assert HOOK_STACK.current_hook() is hook # and save state or add effects - current_hook().use_state(lambda: ...) + HOOK_STACK.current_hook().use_state(lambda: ...) async def my_effect(stop_event): ... - current_hook().add_effect(my_effect) + HOOK_STACK.current_hook().add_effect(my_effect) finally: await hook.affect_component_did_render() @@ -238,7 +258,7 @@ def set_current(self) -> None: This method is called by a layout before entering the render method of this hook's associated component. """ - hook_stack = _HOOK_STATE.get() + hook_stack = HOOK_STACK.get() if hook_stack: parent = hook_stack[-1] self._context_providers.update(parent._context_providers) @@ -246,5 +266,5 @@ def set_current(self) -> None: def unset_current(self) -> None: """Unset this hook as the active hook in this thread""" - if _HOOK_STATE.get().pop() is not self: + if HOOK_STACK.get().pop() is not self: raise RuntimeError("Hook stack is in an invalid state") # nocov diff --git a/src/reactpy/core/_thread_local.py b/src/reactpy/core/_thread_local.py index b3d6a14b0..0d83f7e41 100644 --- a/src/reactpy/core/_thread_local.py +++ b/src/reactpy/core/_thread_local.py @@ -5,8 +5,10 @@ _StateType = TypeVar("_StateType") -class ThreadLocal(Generic[_StateType]): - """Utility for managing per-thread state information""" +class ThreadLocal(Generic[_StateType]): # pragma: no cover + """Utility for managing per-thread state information. This is only used in + environments where ContextVars are not available, such as the `pyodide` + executor.""" def __init__(self, default: Callable[[], _StateType]): self._default = default diff --git a/src/reactpy/core/hooks.py b/src/reactpy/core/hooks.py index 8adc2a9e9..a0a4e161c 100644 --- a/src/reactpy/core/hooks.py +++ b/src/reactpy/core/hooks.py @@ -19,7 +19,7 @@ from typing_extensions import TypeAlias from reactpy.config import REACTPY_DEBUG -from reactpy.core._life_cycle_hook import current_hook +from reactpy.core._life_cycle_hook import HOOK_STACK from reactpy.types import Connection, Context, Key, Location, State, VdomDict from reactpy.utils import Ref @@ -83,7 +83,7 @@ def __init__( else: self.value = initial_value - hook = current_hook() + hook = HOOK_STACK.current_hook() def dispatch(new: _Type | Callable[[_Type], _Type]) -> None: next_value = new(self.value) if callable(new) else new # type: ignore @@ -139,7 +139,7 @@ def use_effect( Returns: If not function is provided, a decorator. Otherwise ``None``. """ - hook = current_hook() + hook = HOOK_STACK.current_hook() dependencies = _try_to_infer_closure_values(function, dependencies) memoize = use_memo(dependencies=dependencies) cleanup_func: Ref[_EffectCleanFunc | None] = use_ref(None) @@ -212,7 +212,7 @@ def use_async_effect( Returns: If not function is provided, a decorator. Otherwise ``None``. """ - hook = current_hook() + hook = HOOK_STACK.current_hook() dependencies = _try_to_infer_closure_values(function, dependencies) memoize = use_memo(dependencies=dependencies) cleanup_func: Ref[_EffectCleanFunc | None] = use_ref(None) @@ -280,7 +280,7 @@ def use_debug_value( if REACTPY_DEBUG.current and old.current != new: old.current = new - logger.debug(f"{current_hook().component} {new}") + logger.debug(f"{HOOK_STACK.current_hook().component} {new}") def create_context(default_value: _Type) -> Context[_Type]: @@ -308,7 +308,7 @@ def use_context(context: Context[_Type]) -> _Type: See the full :ref:`Use Context` docs for more information. """ - hook = current_hook() + hook = HOOK_STACK.current_hook() provider = hook.get_context_provider(context) if provider is None: @@ -361,7 +361,7 @@ def __init__( self.value = value def render(self) -> VdomDict: - current_hook().set_context_provider(self) + HOOK_STACK.current_hook().set_context_provider(self) return {"tagName": "", "children": self.children} def __repr__(self) -> str: @@ -554,7 +554,7 @@ def use_ref(initial_value: _Type) -> Ref[_Type]: def _use_const(function: Callable[[], _Type]) -> _Type: - return current_hook().use_state(function) + return HOOK_STACK.current_hook().use_state(function) def _try_to_infer_closure_values( diff --git a/src/reactpy/core/serve.py b/src/reactpy/core/serve.py index 839ed45a1..a6397eee8 100644 --- a/src/reactpy/core/serve.py +++ b/src/reactpy/core/serve.py @@ -9,7 +9,7 @@ from anyio.abc import TaskGroup from reactpy.config import REACTPY_DEBUG -from reactpy.core._life_cycle_hook import _HOOK_STATE, clear_hook_state +from reactpy.core._life_cycle_hook import HOOK_STACK from reactpy.types import LayoutEventMessage, LayoutType, LayoutUpdateMessage logger = getLogger(__name__) @@ -64,7 +64,7 @@ async def _single_outgoing_loop( send: SendCoroutine, ) -> None: while True: - token = _HOOK_STATE.set([]) + token = HOOK_STACK.initialize() try: update = await layout.render() try: @@ -79,7 +79,7 @@ async def _single_outgoing_loop( logger.error(msg) raise finally: - clear_hook_state(token) + HOOK_STACK.reset(token) async def _single_incoming_loop( diff --git a/src/reactpy/pyscript/utils.py b/src/reactpy/pyscript/utils.py index b867d05f1..eb277cfb5 100644 --- a/src/reactpy/pyscript/utils.py +++ b/src/reactpy/pyscript/utils.py @@ -145,6 +145,8 @@ def extend_pyscript_config( def reactpy_version_string() -> str: # pragma: no cover + from reactpy.testing.common import GITHUB_ACTIONS + local_version = reactpy.__version__ # Get a list of all versions via `pip index versions` @@ -170,14 +172,16 @@ def reactpy_version_string() -> str: # pragma: no cover symbol_postion = line.index(latest_version_symbol) latest_version = line[symbol_postion + len(latest_version_symbol) :].strip() - # Return early if local version of ReactPy is available on PyPi - if local_version in known_versions: + # Return early if the version is available on PyPi and we're not in a CI environment + if local_version in known_versions and not GITHUB_ACTIONS: return f"reactpy=={local_version}" - # Begin determining an alternative method of installing ReactPy - - if not latest_version: - _logger.warning("Failed to determine the latest version of ReactPy on PyPi. ") + # We are now determining an alternative method of installing ReactPy for PyScript + if not GITHUB_ACTIONS: + _logger.warning( + "Your current version of ReactPy isn't available on PyPi. Since a packaged version " + "of ReactPy is required for PyScript, we are attempting to find an alternative method..." + ) # Build a local wheel for ReactPy, if needed dist_dir = Path(reactpy.__file__).parent.parent.parent / "dist" @@ -202,19 +206,18 @@ def reactpy_version_string() -> str: # pragma: no cover ) return f"reactpy=={latest_version}" _logger.error( - "Failed to build a local wheel for ReactPy and could not determine the latest version on PyPi. " + "Failed to build a local wheel for ReactPy, and could not determine the latest version on PyPi. " "PyScript functionality may not work as expected.", ) return f"reactpy=={local_version}" - # Move the local file to the web modules directory, if needed + # Move the local wheel file to the web modules directory, if needed wheel_file = Path(wheel_glob[0]) new_path = REACTPY_WEB_MODULES_DIR.current / wheel_file.name if not new_path.exists(): _logger.warning( - "'reactpy==%s' is not available on PyPi. " - "PyScript will utilize a local wheel of ReactPy instead.", - local_version, + "PyScript will utilize local wheel '%s'.", + wheel_file.name, ) shutil.copy(wheel_file, new_path) return f"{REACTPY_PATH_PREFIX.current}modules/{wheel_file.name}" diff --git a/src/reactpy/testing/common.py b/src/reactpy/testing/common.py index a71277747..cb015a672 100644 --- a/src/reactpy/testing/common.py +++ b/src/reactpy/testing/common.py @@ -14,7 +14,7 @@ from typing_extensions import ParamSpec from reactpy.config import REACTPY_TESTS_DEFAULT_TIMEOUT, REACTPY_WEB_MODULES_DIR -from reactpy.core._life_cycle_hook import LifeCycleHook, current_hook +from reactpy.core._life_cycle_hook import HOOK_STACK, LifeCycleHook from reactpy.core.events import EventHandler, to_event_handler_function @@ -153,7 +153,7 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: if self is None: raise RuntimeError("Hook catcher has been garbage collected") - hook = current_hook() + hook = HOOK_STACK.current_hook() if self.index_by_kwarg is not None: self.index[kwargs[self.index_by_kwarg]] = hook self.latest = hook diff --git a/src/reactpy/utils.py b/src/reactpy/utils.py index a8f3fd60f..bc79cc723 100644 --- a/src/reactpy/utils.py +++ b/src/reactpy/utils.py @@ -334,3 +334,13 @@ def import_dotted_path(dotted_path: str) -> Any: except AttributeError as error: msg = f'ReactPy failed to import "{component_name}" from "{module_name}"' raise AttributeError(msg) from error + + +class Singleton: + """A class that only allows one instance to be created.""" + + def __new__(cls, *args, **kw): + if not hasattr(cls, "_instance"): + orig = super() + cls._instance = orig.__new__(cls, *args, **kw) + return cls._instance diff --git a/tests/conftest.py b/tests/conftest.py index b95d9870f..d12706641 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -46,11 +46,19 @@ def rebuild(): @pytest.fixture(autouse=True, scope="function") def create_hook_state(): - from reactpy.core._life_cycle_hook import _HOOK_STATE + """This fixture is a bug fix related to `pytest_asyncio`. - token = _HOOK_STATE.set([]) + Usually the hook stack is created automatically within the display fixture, but context + variables aren't retained within `pytest_asyncio` async fixtures. As a workaround, + this fixture ensures that the hook stack is created before each test is run. + + Ref: https://github.com/pytest-dev/pytest-asyncio/issues/127 + """ + from reactpy.core._life_cycle_hook import HOOK_STACK + + token = HOOK_STACK.initialize() yield token - _HOOK_STATE.reset(token) + HOOK_STACK.reset(token) @pytest.fixture diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py index 8b38bc825..b4de2e7e9 100644 --- a/tests/test_core/test_layout.py +++ b/tests/test_core/test_layout.py @@ -343,7 +343,7 @@ async def test_root_component_life_cycle_hook_is_garbage_collected(): def add_to_live_hooks(constructor): def wrapper(*args, **kwargs): result = constructor(*args, **kwargs) - hook = reactpy.hooks.current_hook() + hook = reactpy.hooks.HOOK_STACK.current_hook() hook_id = id(hook) live_hooks.add(hook_id) finalize(hook, live_hooks.discard, hook_id) @@ -375,7 +375,7 @@ async def test_life_cycle_hooks_are_garbage_collected(): def add_to_live_hooks(constructor): def wrapper(*args, **kwargs): result = constructor(*args, **kwargs) - hook = reactpy.hooks.current_hook() + hook = reactpy.hooks.HOOK_STACK.current_hook() hook_id = id(hook) live_hooks.add(hook_id) finalize(hook, live_hooks.discard, hook_id) @@ -625,7 +625,7 @@ def Outer(): @reactpy.component def Inner(finalizer_id): if finalizer_id not in registered_finalizers: - hook = reactpy.hooks.current_hook() + hook = reactpy.hooks.HOOK_STACK.current_hook() finalize(hook, lambda: garbage_collect_items.append(finalizer_id)) registered_finalizers.add(finalizer_id) return reactpy.html.div(finalizer_id) diff --git a/tests/tooling/hooks.py b/tests/tooling/hooks.py index e5a4b6fb1..bb33172ed 100644 --- a/tests/tooling/hooks.py +++ b/tests/tooling/hooks.py @@ -1,8 +1,8 @@ -from reactpy.core.hooks import current_hook, use_state +from reactpy.core.hooks import HOOK_STACK, use_state def use_force_render(): - return current_hook().schedule_render + return HOOK_STACK.current_hook().schedule_render def use_toggle(init=False): From 3d555da63f7da230dc4106c7670e35d5f19899ce Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 11 Feb 2025 14:10:45 -0800 Subject: [PATCH 5/6] Fix lint error --- src/reactpy/core/_life_cycle_hook.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/reactpy/core/_life_cycle_hook.py b/src/reactpy/core/_life_cycle_hook.py index 726346e3b..8600b3f01 100644 --- a/src/reactpy/core/_life_cycle_hook.py +++ b/src/reactpy/core/_life_cycle_hook.py @@ -22,7 +22,7 @@ async def __call__(self, stop: Event) -> None: ... logger = logging.getLogger(__name__) -class __HookStack(Singleton): # pragma: no cover +class _HookStack(Singleton): # pragma: no cover """A singleton object which manages the current component tree's hooks. Life cycle hooks can be stored in a thread local or context variable depending on the platform.""" @@ -53,7 +53,7 @@ def current_hook(self) -> LifeCycleHook: return hook_stack[-1] -HOOK_STACK = __HookStack() +HOOK_STACK = _HookStack() class LifeCycleHook: From 8e5f05e69a1a93a86caabe7566d2da5c6f04c79e Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 11 Feb 2025 14:18:26 -0800 Subject: [PATCH 6/6] Add changelog --- docs/source/about/changelog.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index c90a8dcff..6be65b7e7 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -56,6 +56,7 @@ Unreleased **Fixed** - :pull:`1239` - Fixed a bug where script elements would not render to the DOM as plain text. +- :pull:`1254` - Fixed a bug where ``RuntimeError("Hook stack is in an invalid state")`` errors would be provided when using a webserver that reuses threads. v1.1.0 ------