Skip to content

Commit

Permalink
feat: run side effects when reactive variable change
Browse files Browse the repository at this point in the history
Exposed as a decorator:
solara.lab.reactive_effect

The return value of the decorated function is can be a cleanup
function, similar to solara.dev/api/use_effect
  • Loading branch information
maartenbreddels committed Mar 10, 2024
1 parent 3760636 commit 2853b61
Show file tree
Hide file tree
Showing 3 changed files with 102 additions and 1 deletion.
2 changes: 1 addition & 1 deletion solara/lab/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from .utils import cookies, headers # noqa: F401, F403
from ..server.kernel_context import on_kernel_start # noqa: F401
from ..tasks import reactive_task, task, use_task, Task, TaskResult # noqa: F401, F403
from ..toestand import computed # noqa: F401
from ..toestand import computed, reactive_effect # noqa: F401


def __getattr__(name):
Expand Down
37 changes: 37 additions & 0 deletions solara/toestand.py
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,43 @@ def wrapper(f: Callable[[], T]):
else:
return wrapper(f)

class ReactiveEffect:
def __init__(self, f: Callable[[], Optional[Callable[[], None]]]):
import solara.server.kernel_context

self.f = f

def run(*ignore):
if self._last_cleanup.value:
self._last_cleanup.value()
with self._auto_subscriber.value:
self._last_cleanup.value = self.f()

def init():
run()

def cleanup():
if self._last_cleanup.value:
self._last_cleanup.value()
self._last_cleanup.value = None

return cleanup

solara.server.kernel_context.on_kernel_start(init)
self._auto_subscriber = Singleton(lambda: AutoSubscribeContextManager(run))
self._last_cleanup = Reactive(cast(Optional[Callable[[], None]], None))


def reactive_effect(f: Callable[[], Optional[Callable[[], None]]]):
"""Decorator to run an effect function when a dependency changes.
The dependencies can be reactive variables, and the return value is a
cleanup function that will be called before the effect function is called
again, or when the virtual kernel is stopped.
"""
return ReactiveEffect(f)


class ReactiveField(Reactive[T]):
def __init__(self, field: "FieldBase"):
Expand Down
64 changes: 64 additions & 0 deletions tests/unit/toestand_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -1205,3 +1205,67 @@ def test_computed_reload(no_kernel_context):
assert text.widget.v_model == "4.0"
finally:
app.close()

def test_reactive_effect(no_kernel_context):
from solara.lab import reactive_effect

x = Reactive(1)
y = Reactive(2)
calls = 0
cleanups = 0

z = -1

@reactive_effect
def update_z():
# breakpoint()
nonlocal z, calls, cleanups
calls += 1
if x.value == 0:
z = 42
else:
z = x.value + y.value

def cleanup():
nonlocal cleanups
cleanups += 1

return cleanup

kernel1 = kernel.Kernel()
kernel2 = kernel.Kernel()
assert kernel_context.current_context[kernel_context.get_current_thread_key()] is None

context1 = kernel_context.VirtualKernelContext(id="t1", kernel=kernel1, session_id="session-1")

with context1:
assert update_z._auto_subscriber.value.reactive_used == {x, y}
assert calls == 1
assert cleanups == 0
assert z == 3
x.value = 2
assert z == 4
assert calls == 2
assert cleanups == 1
x.value = 0
assert update_z._auto_subscriber.value.reactive_used == {x}
assert calls == 3
assert cleanups == 2
# y.value = 1000
# assert calls == 3
# assert cleanups == 2

context2 = kernel_context.VirtualKernelContext(id="t2", kernel=kernel2, session_id="session-2")
with context2:
assert z == 3
assert calls == 4
assert cleanups == 2
x.value = 5
assert z == 7
assert calls == 5
assert cleanups == 3

context1.close()
assert cleanups == 4
context2.close()
assert cleanups == 5

0 comments on commit 2853b61

Please sign in to comment.