Skip to content

automatically infer closure arguments #520

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Dec 15, 2021
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
59 changes: 33 additions & 26 deletions docs/source/reference-material/hooks-api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
===================

Expand Down Expand Up @@ -221,38 +237,32 @@ 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 <memoization>`_ 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 <https://github.com/idom-team/idom/issues/202>`_ to help
enforce this.

You may manually specify what values the callback depends on in the :ref:`same way
as effects <Manual Effect Conditions>` using the ``dependencies`` parameter.


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 <memoization>`_ 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.
Expand All @@ -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 <https://github.com/idom-team/idom/issues/202>`_
to help enforce this.
You may manually specify what values the callback depends on in the :ref:`same way
as effects <Manual Effect Conditions>` using the ``dependencies`` parameter.


Use Ref
Expand Down
2 changes: 1 addition & 1 deletion requirements/check-style.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
101 changes: 74 additions & 27 deletions src/idom/core/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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"] = {}


Expand Down
Loading