diff --git a/docs/source/reference-material/hooks-api.rst b/docs/source/reference-material/hooks-api.rst index 15b1ccc8a..f2967376e 100644 --- a/docs/source/reference-material/hooks-api.rst +++ b/docs/source/reference-material/hooks-api.rst @@ -124,7 +124,7 @@ then closing a connection: .. code-block:: def establish_connection(): - connection = open_connection(url) + connection = open_connection() return lambda: close_connection(connection) use_effect(establish_connection) @@ -139,19 +139,21 @@ Conditional Effects ................... By default, effects are triggered after every successful render to ensure that all state -referenced by the effect is up to date. However you can limit the number of times an -effect is fired by specifying exactly what state the effect depends on. In doing so -the effect will only occur when the given state changes: +referenced by the effect is up to date. However, when an effect function references +non-global variables, the effect will only if the value of that variable changes. For +example, imagine that we had an effect that connected to a ``url`` state variable: .. code-block:: + url, set_url = use_state("https://example.com") + def establish_connection(): connection = open_connection(url) return lambda: close_connection(connection) - use_effect(establish_connection, [url]) + use_effect(establish_connection) -Now a new connection will only be established if a new ``url`` is provided. +Here, a new connection will be established whenever a new ``url`` is set. Async Effects @@ -181,6 +183,20 @@ There are **three important subtleties** to note about using asynchronous effect and before the next effect following a subsequent update. +Manual Effect Conditions +........................ + +In some cases, you may want to explicitely declare when an effect should be triggered. +You can do this by passing ``dependencies`` to ``use_effect``. Each of the following values +produce different effect behaviors: + +- ``use_effect(..., dependencies=None)`` - triggers and cleans up on every render. +- ``use_effect(..., dependencies=[])`` - only triggers on the first and cleans up after + the last render. +- ``use_effect(..., dependencies=[x, y])`` - triggers on the first render and on subsequent renders if + ``x`` or ``y`` have changed. + + Supplementary Hooks =================== @@ -221,21 +237,17 @@ Use Callback .. code-block:: - memoized_callback = use_callback(lambda: do_something(a, b), [a, b]) + memoized_callback = use_callback(lambda: do_something(a, b)) A derivative of :ref:`Use Memo`, the ``use_callback`` hook returns a `memoized `_ callback. This is useful when passing callbacks to child -components which check reference equality to prevent unnecessary renders. The of -``memoized_callback`` will only change when the given dependencies do. +components which check reference equality to prevent unnecessary renders. The +``memoized_callback`` will only change when any local variables is references do. .. note:: - The list of "dependencies" are not passed as arguments to the function. Ostensibly - though, that is what they represent. Thus any variable referenced by the function - must be listed as dependencies. We're - `working on a linter `_ to help - enforce this. - + You may manually specify what values the callback depends on in the :ref:`same way + as effects ` using the ``dependencies`` parameter. Use Memo @@ -243,16 +255,14 @@ Use Memo .. code-block:: - memoized_value = use_memo(lambda: compute_something_expensive(a, b), [a, b]) + memoized_value = use_memo(lambda: compute_something_expensive(a, b)) Returns a `memoized `_ value. By passing a constructor function accepting no arguments and an array of dependencies for that constructor, the ``use_callback`` hook will return the value computed by the constructor. The ``memoized_value`` will only -be recomputed when a value in the array of dependencies changes. This optimizes -performance because you don't need to ``compute_something_expensive`` on every render. - -If the array of dependencies is ``None`` then the constructor will be called on every -render. +be recomputed if a local variable referenced by the constructor changes (e.g. ``a`` or +``b`` here). This optimizes performance because you don't need to +``compute_something_expensive`` on every render. Unlike ``use_effect`` the constructor function is called during each render (instead of after) and should not incur side effects. @@ -265,11 +275,8 @@ after) and should not incur side effects. .. note:: - The list of "dependencies" are not passed as arguments to the function ostensibly - though, that is what they represent. Thus any variable referenced by the function - must be listed as dependencies. We're - `working on a linter `_ - to help enforce this. + You may manually specify what values the callback depends on in the :ref:`same way + as effects ` using the ``dependencies`` parameter. Use Ref diff --git a/requirements/check-style.txt b/requirements/check-style.txt index 1a15e1860..dc10ef89a 100644 --- a/requirements/check-style.txt +++ b/requirements/check-style.txt @@ -2,5 +2,5 @@ black flake8 flake8-print pep8-naming -flake8-idom-hooks >=0.4.0 +flake8-idom-hooks >=0.5.0 isort >=5.7.0 diff --git a/src/idom/core/hooks.py b/src/idom/core/hooks.py index 98e473d88..85bbc2449 100644 --- a/src/idom/core/hooks.py +++ b/src/idom/core/hooks.py @@ -8,7 +8,9 @@ import asyncio from logging import getLogger from threading import get_ident as get_thread_id +from types import FunctionType from typing import ( + TYPE_CHECKING, Any, Awaitable, Callable, @@ -30,6 +32,11 @@ from idom.utils import Ref +if not TYPE_CHECKING: + # make flake8 think that this variable exists + ellipsis = type(...) + + __all__ = [ "use_state", "use_effect", @@ -101,36 +108,42 @@ def dispatch( @overload def use_effect( - function: None = None, args: Optional[Sequence[Any]] = None + function: None = None, + dependencies: Sequence[Any] | ellipsis | None = ..., ) -> Callable[[_EffectApplyFunc], None]: ... @overload def use_effect( - function: _EffectApplyFunc, args: Optional[Sequence[Any]] = None + function: _EffectApplyFunc, + dependencies: Sequence[Any] | ellipsis | None = ..., ) -> None: ... def use_effect( function: Optional[_EffectApplyFunc] = None, - args: Optional[Sequence[Any]] = None, + dependencies: Sequence[Any] | ellipsis | None = ..., ) -> Optional[Callable[[_EffectApplyFunc], None]]: """See the full :ref:`Use Effect` docs for details Parameters: function: Applies the effect and can return a clean-up function - args: - Dependencies for the effect. If provided the effect will only trigger when - these args change. + dependencies: + Dependencies for the effect. The effect will only trigger if the identity + of any value in the given sequence changes (i.e. their :func:`id` is + different). By default these are inferred based on local variables that are + referenced by the given function. Returns: If not function is provided, a decorator. Otherwise ``None``. """ hook = current_hook() - memoize = use_memo(args=args) + + dependencies = _try_to_infer_closure_values(function, dependencies) + memoize = use_memo(dependencies=dependencies) last_clean_callback: Ref[Optional[_EffectCleanFunc]] = use_ref(None) def add_effect(function: _EffectApplyFunc) -> None: @@ -209,32 +222,40 @@ def dispatch(action: _ActionType) -> None: @overload def use_callback( - function: None = None, args: Optional[Sequence[Any]] = None + function: None = None, + dependencies: Sequence[Any] | ellipsis | None = ..., ) -> Callable[[_CallbackFunc], _CallbackFunc]: ... @overload def use_callback( - function: _CallbackFunc, args: Optional[Sequence[Any]] = None + function: _CallbackFunc, + dependencies: Sequence[Any] | ellipsis | None = ..., ) -> _CallbackFunc: ... def use_callback( function: Optional[_CallbackFunc] = None, - args: Optional[Sequence[Any]] = (), + dependencies: Sequence[Any] | ellipsis | None = ..., ) -> Union[_CallbackFunc, Callable[[_CallbackFunc], _CallbackFunc]]: """See the full :ref:`Use Callback` docs for details Parameters: - function: the function whose identity will be preserved - args: The identity the ``function`` will be udpated when these ``args`` change. + function: + The function whose identity will be preserved + dependencies: + Dependencies of the callback. The identity the ``function`` will be udpated + if the identity of any value in the given sequence changes (i.e. their + :func:`id` is different). By default these are inferred based on local + variables that are referenced by the given function. Returns: The current function """ - memoize = use_memo(args=args) + dependencies = _try_to_infer_closure_values(function, dependencies) + memoize = use_memo(dependencies=dependencies) def setup(function: _CallbackFunc) -> _CallbackFunc: return memoize(lambda: function) @@ -254,46 +275,55 @@ def __call__(self, func: Callable[[], _StateType]) -> _StateType: @overload def use_memo( - function: None = None, args: Optional[Sequence[Any]] = None + function: None = None, + dependencies: Sequence[Any] | ellipsis | None = ..., ) -> _LambdaCaller: ... @overload def use_memo( - function: Callable[[], _StateType], args: Optional[Sequence[Any]] = None + function: Callable[[], _StateType], + dependencies: Sequence[Any] | ellipsis | None = ..., ) -> _StateType: ... def use_memo( function: Optional[Callable[[], _StateType]] = None, - args: Optional[Sequence[Any]] = None, + dependencies: Sequence[Any] | ellipsis | None = ..., ) -> Union[_StateType, Callable[[Callable[[], _StateType]], _StateType]]: """See the full :ref:`Use Memo` docs for details Parameters: - function: The function to be memoized. - args: The ``function`` will be recomputed when these args change. + function: + The function to be memoized. + dependencies: + Dependencies for the memoized function. The memo will only be recomputed if + the identity of any value in the given sequence changes (i.e. their + :func:`id` is different). By default these are inferred based on local + variables that are referenced by the given function. Returns: The current state """ + dependencies = _try_to_infer_closure_values(function, dependencies) + memo: _Memo[_StateType] = _use_const(_Memo) if memo.empty(): # we need to initialize on the first run changed = True - memo.args = () if args is None else args - elif args is None: + memo.deps = () if dependencies is None else dependencies + elif dependencies is None: changed = True - memo.args = () + memo.deps = () elif ( - len(memo.args) != len(args) - # if args are same length check identity for each item - or any(current is not new for current, new in zip(memo.args, args)) + len(memo.deps) != len(dependencies) + # if deps are same length check identity for each item + or any(current is not new for current, new in zip(memo.deps, dependencies)) ): - memo.args = args + memo.deps = dependencies changed = True else: changed = False @@ -320,10 +350,10 @@ def setup(function: Callable[[], _StateType]) -> _StateType: class _Memo(Generic[_StateType]): """Simple object for storing memoization data""" - __slots__ = "value", "args" + __slots__ = "value", "deps" value: _StateType - args: Sequence[Any] + deps: Sequence[Any] def empty(self) -> bool: try: @@ -350,6 +380,23 @@ def _use_const(function: Callable[[], _StateType]) -> _StateType: return current_hook().use_state(function) +def _try_to_infer_closure_values( + func: Callable[..., Any] | None, + values: Sequence[Any] | ellipsis | None, +) -> Sequence[Any] | None: + if values is ...: + if isinstance(func, FunctionType): + return ( + [cell.cell_contents for cell in func.__closure__] + if func.__closure__ + else [] + ) + else: + return None + else: + return cast("Sequence[Any] | None", values) + + _current_life_cycle_hook: Dict[int, "LifeCycleHook"] = {} diff --git a/tests/test_core/test_hooks.py b/tests/test_core/test_hooks.py index fc34f332b..b62cdc53d 100644 --- a/tests/test_core/test_hooks.py +++ b/tests/test_core/test_hooks.py @@ -331,7 +331,7 @@ async def test_use_effect_cleanup_occurs_before_next_effect(): @idom.component @component_hook.capture def ComponentWithEffect(): - @idom.hooks.use_effect + @idom.hooks.use_effect(dependencies=None) def effect(): if cleanup_triggered.current: cleanup_triggered_before_next_effect.current = True @@ -394,7 +394,7 @@ def cleanup(): assert cleanup_triggered_before_next_render.current -async def test_memoized_effect_on_recreated_if_args_change(): +async def test_memoized_effect_on_recreated_if_dependencies_change(): component_hook = HookCatcher() set_state_callback = idom.Ref(None) effect_run_count = idom.Ref(0) @@ -407,7 +407,7 @@ async def test_memoized_effect_on_recreated_if_args_change(): def ComponentWithMemoizedEffect(): state, set_state_callback.current = idom.hooks.use_state(first_value) - @idom.hooks.use_effect(args=[state]) + @idom.hooks.use_effect(dependencies=[state]) def effect(): effect_run_count.current += 1 @@ -447,7 +447,7 @@ async def test_memoized_effect_cleanup_only_triggered_before_new_effect(): def ComponentWithEffect(): state, set_state_callback.current = idom.hooks.use_state(first_value) - @idom.hooks.use_effect(args=[state]) + @idom.hooks.use_effect(dependencies=[state]) def effect(): def cleanup(): cleanup_trigger_count.current += 1 @@ -485,7 +485,7 @@ async def effect(): with idom.Layout(ComponentWithAsyncEffect()) as layout: await layout.render() - await effect_ran.wait() + await asyncio.wait_for(effect_ran.wait(), 1) async def test_use_async_effect_cleanup(): @@ -496,7 +496,7 @@ async def test_use_async_effect_cleanup(): @idom.component @component_hook.capture def ComponentWithAsyncEffect(): - @idom.hooks.use_effect + @idom.hooks.use_effect(dependencies=None) # force this to run every time async def effect(): effect_ran.set() return cleanup_ran.set @@ -506,7 +506,7 @@ async def effect(): with idom.Layout(ComponentWithAsyncEffect()) as layout: await layout.render() - await effect_ran.wait() + cleanup_ran.wait() component_hook.latest.schedule_render() await layout.render() @@ -524,7 +524,7 @@ async def test_use_async_effect_cancel(caplog): @idom.component @component_hook.capture def ComponentWithLongWaitingEffect(): - @idom.hooks.use_effect + @idom.hooks.use_effect(dependencies=None) # force this to run every time async def effect(): effect_ran.set() try: @@ -575,7 +575,7 @@ async def test_error_in_effect_cleanup_is_gracefully_handled(caplog): @idom.component @component_hook.capture def ComponentWithEffect(): - @idom.hooks.use_effect + @idom.hooks.use_effect(dependencies=None) # force this to run every time def ok_effect(): def bad_cleanup(): raise ValueError("Something went wong :(") @@ -709,7 +709,7 @@ async def test_use_callback_memoization(): def ComponentWithRef(): state, set_state_hook.current = idom.hooks.use_state(0) - @idom.hooks.use_callback(args=[state]) # use the deco form for coverage + @idom.hooks.use_callback(dependencies=[state]) # use the deco form for coverage def cb(): return None @@ -756,7 +756,7 @@ def ComponentWithMemo(): assert len(used_values) == 3 -async def test_use_memo_always_runs_if_args_are_none(): +async def test_use_memo_always_runs_if_dependencies_are_none(): component_hook = HookCatcher() used_values = [] @@ -765,7 +765,7 @@ async def test_use_memo_always_runs_if_args_are_none(): @idom.component @component_hook.capture def ComponentWithMemo(): - value = idom.hooks.use_memo(lambda: next(iter_values)) + value = idom.hooks.use_memo(lambda: next(iter_values), dependencies=None) used_values.append(value) return idom.html.div() @@ -779,19 +779,19 @@ def ComponentWithMemo(): assert used_values == [1, 2, 3] -async def test_use_memo_with_stored_args_is_empty_tuple_after_args_are_none(): +async def test_use_memo_with_stored_deps_is_empty_tuple_after_deps_are_none(): component_hook = HookCatcher() used_values = [] iter_values = iter([1, 2, 3]) - args_used_in_memo = idom.Ref(()) + deps_used_in_memo = idom.Ref(()) @idom.component @component_hook.capture def ComponentWithMemo(): value = idom.hooks.use_memo( lambda: next(iter_values), - args_used_in_memo.current, # noqa: ROH202 + deps_used_in_memo.current, # noqa: ROH202 ) used_values.append(value) return idom.html.div() @@ -799,16 +799,16 @@ def ComponentWithMemo(): with idom.Layout(ComponentWithMemo()) as layout: await layout.render() component_hook.latest.schedule_render() - args_used_in_memo.current = None + deps_used_in_memo.current = None await layout.render() component_hook.latest.schedule_render() - args_used_in_memo.current = () + deps_used_in_memo.current = () await layout.render() assert used_values == [1, 2, 2] -async def test_use_memo_never_runs_if_args_args_empty_list(): +async def test_use_memo_never_runs_if_deps_is_empty_list(): component_hook = HookCatcher() used_values = [] @@ -860,3 +860,59 @@ def bad_callback(): first_log_line = next(iter(caplog.records)).msg.split("\n", 1)[0] assert re.match(f"Failed to schedule render via {bad_callback}", first_log_line) + + +async def test_use_effect_automatically_infers_closure_values(): + set_count = idom.Ref() + did_effect = asyncio.Event() + + @idom.component + def CounterWithEffect(): + count, set_count.current = idom.hooks.use_state(0) + + @idom.hooks.use_effect + def some_effect_that_uses_count(): + """should automatically trigger on count change""" + count # use count in this closure + did_effect.set() + + return idom.html.div() + + with idom.Layout(CounterWithEffect()) as layout: + await layout.render() + await did_effect.wait() + did_effect.clear() + + for i in range(1, 3): + set_count.current(i) + await layout.render() + await did_effect.wait() + did_effect.clear() + + +async def test_use_memo_automatically_infers_closure_values(): + set_count = idom.Ref() + did_memo = asyncio.Event() + + @idom.component + def CounterWithEffect(): + count, set_count.current = idom.hooks.use_state(0) + + @idom.hooks.use_memo + def some_memo_func_that_uses_count(): + """should automatically trigger on count change""" + count # use count in this closure + did_memo.set() + + return idom.html.div() + + with idom.Layout(CounterWithEffect()) as layout: + await layout.render() + await did_memo.wait() + did_memo.clear() + + for i in range(1, 3): + set_count.current(i) + await layout.render() + await did_memo.wait() + did_memo.clear()