Pass instance from target, if `ui.state` is refreshing (#4691)
This PR fixes #4690 on the case of `ui.state` refreshing the wrong
`ui.refreshable` instance, when there are multiple instances, ~~but
introduces a new issue that now `clock1.time.refresh` refreshes
whoever's `ui.state` was last refreshed, requiring
`clock1.time.refresh(_instance=clock1)` (printing "!!!Refresh called
without _instance, while self.instance is not None (culprit of Local
Scope B problem)")~~ Resolved.
Pass instance from target, if `ui.state` is refreshing, or else
`_instance=self.instance` takes precedence in:
```py
def __getattribute__(self, __name: str) -> Any:
attribute = object.__getattribute__(self, __name)
if __name == 'refresh':
def refresh(*args: Any, _instance=self.instance, **kwargs: Any) -> None:
self.instance = _instance
attribute(*args, **kwargs)
return refresh
return attribute
```
This PR would not affect Global scope, Local scope A and C, since for
those, `def __get__(self, instance, _) -> Self:` was never ran, and
`self.instance` is always `None`.
Test script:
```py
from datetime import datetime
import random
from nicegui import ui
GLOBALCOUNT = 0
def random_string():
return str(random.random())[:9]
def time_primitive(refresh_message=""):
global GLOBALCOUNT
rand, set_rand = ui.state(random_string())
with ui.row():
now_time = datetime.now()
ui.label(f'#{GLOBALCOUNT}').classes('font-mono')
ui.label(f'Time: {now_time}').classes('font-mono')
ui.label(f'Rand: {rand}').classes('font-mono')
ui.button('Set Rand', on_click=lambda: set_rand(random_string()))
print(GLOBALCOUNT, 'time_primitive', refresh_message, now_time, rand)
GLOBALCOUNT += 1
class Clock:
def __init__(self, refresh_message=""):
self.refresh_message = refresh_message
@ui.refreshable_method
def time(self):
time_primitive(self.refresh_message)
@ui.page('/local_refreshable_b')
def demo():
ui.label('Local refreshable and State demo B').classes('text-2xl')
clock1 = Clock("clock1")
clock1.time()
with ui.row():
ui.label(str(clock1))
ui.button('Refresh', on_click=clock1.time.refresh)
clock2 = Clock("clock2")
clock2.time()
with ui.row():
ui.label(str(clock2))
ui.button('Refresh', on_click=clock2.time.refresh)
ui.button('print empty line in output', on_click=lambda: print(''))
ui.link('Local refreshable B', '/local_refreshable_b')
ui.run(port=9000)
```
If you want to see how I came up with the solution, here is the
`refreshable.py` with probably the most aggressive print debugging
you'll ever see.
<details>
<summary>`refreshable.py`</summary>
```py
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable, ClassVar, Dict, Generic, List, Optional, Tuple, TypeVar, cast
from typing_extensions import Concatenate, ParamSpec, Self
from .. import background_tasks, core
from ..client import Client
from ..dataclasses import KWONLY_SLOTS
from ..element import Element
from ..helpers import is_coroutine_function
_S = TypeVar('_S')
_T = TypeVar('_T')
_P = ParamSpec('_P')
@dataclass(**KWONLY_SLOTS)
class RefreshableTarget:
container: RefreshableContainer
refreshable: refreshable
instance: Any
args: Tuple[Any, ...]
kwargs: Dict[str, Any]
current_target: ClassVar[Optional[RefreshableTarget]] = None
locals: List[Any] = field(default_factory=list)
next_index: int = 0
def run(self, func: Callable[..., _T]) -> _T:
"""Run the function and return the result."""
RefreshableTarget.current_target = self
self.next_index = 0
# pylint: disable=no-else-return
if is_coroutine_function(func):
async def wait_for_result() -> Any:
with self.container:
if self.instance is None:
result = func(*self.args, **self.kwargs)
else:
result = func(self.instance, *self.args, **self.kwargs)
assert isinstance(result, Awaitable)
return await result
return wait_for_result() # type: ignore
else:
with self.container:
if self.instance is None:
return func(*self.args, **self.kwargs)
else:
return func(self.instance, *self.args, **self.kwargs)
class RefreshableContainer(Element, component='refreshable.js'):
pass
class refreshable(Generic[_P, _T]):
def __init__(self, func: Callable[_P, _T]) -> None:
"""Refreshable UI functions
The ``@ui.refreshable`` decorator allows you to create functions that have a ``refresh`` method.
This method will automatically delete all elements created by the function and recreate them.
For decorating refreshable methods in classes, there is a ``@ui.refreshable_method`` decorator,
which is equivalent but prevents static type checking errors.
"""
self.func = func
self.instance = None
self.targets: List[RefreshableTarget] = []
def __get__(self, instance, _) -> Self:
print()
print(self)
print('__get__ ', self.instance if self.instance else "None".ljust(len(str(instance))),
'->', instance, 'SAME' if self.instance == instance else 'DIFF')
self.instance = instance
return self
def __getattribute__(self, __name: str) -> Any:
attribute = object.__getattribute__(self, __name)
if __name == 'refresh':
CAPTUREVAL = self.instance
print("Defined CAPTUREVAL as", CAPTUREVAL)
def refresh(*args: Any, _instance="DIDNOTPASSAVALUE", **kwargs: Any) -> None:
print()
if _instance == "DIDNOTPASSAVALUE"
_instance = CAPTUREVAL
print('!!!Refresh called without _instance, while self.instance is not None (culprit of Local Scope B problem)')
print(self)
print('__geta__', self.instance if self.instance else "None".ljust(len(str(_instance))), '->', _instance,
'SAME' if self.instance == _instance else 'DIFF')
self.instance = _instance
attribute(*args, **kwargs)
return refresh
return attribute
def __call__(self, *args: _P.args, **kwargs: _P.kwargs) -> _T:
self.prune()
target = RefreshableTarget(container=RefreshableContainer(), refreshable=self, instance=self.instance,
args=args, kwargs=kwargs)
self.targets.append(target)
return target.run(self.func)
def refresh(self, *args: Any, **kwargs: Any) -> None:
"""Refresh the UI elements created by this function.
This method accepts the same arguments as the function itself or a subset of them.
It will combine the arguments passed to the function with the arguments passed to this method.
"""
self.prune()
for target in self.targets:
print()
print(self)
print('Chk Tgt ', target.instance, '==' if target.instance == self.instance else '!=', self.instance)
if target.instance != self.instance:
continue
target.container.clear()
target.args = args or target.args
target.kwargs.update(kwargs)
try:
result = target.run(self.func)
except TypeError as e:
if 'got multiple values for argument' in str(e):
function = str(e).split()[0].split('.')[-1]
parameter = str(e).split()[-1]
raise TypeError(f'{parameter} needs to be consistently passed to {function} '
'either as positional or as keyword argument') from e
raise
if is_coroutine_function(self.func):
assert isinstance(result, Awaitable)
if core.loop and core.loop.is_running():
background_tasks.create(result, name=f'refresh {self.func.__name__}')
else:
core.app.on_startup(result)
def prune(self) -> None:
"""Remove all targets that are no longer on a page with a client connection.
This method is called automatically before each refresh.
"""
self.targets = [
target
for target in self.targets
if target.container.client.id in Client.instances and target.container.id in target.container.client.elements
]
class refreshable_method(Generic[_S, _P, _T], refreshable[_P, _T]):
def __init__(self, func: Callable[Concatenate[_S, _P], _T]) -> None:
"""Refreshable UI methods
The `@ui.refreshable_method` decorator allows you to create methods that have a `refresh` method.
This method will automatically delete all elements created by the function and recreate them.
"""
print('##### refreshable_method', self, func)
super().__init__(func) # type: ignore
def state(value: Any) -> Tuple[Any, Callable[[Any], None]]:
"""Create a state variable that automatically updates its refreshable UI container.
:param value: The initial value of the state variable.
:return: A tuple containing the current value and a function to update the value.
"""
target = cast(RefreshableTarget, RefreshableTarget.current_target)
print("Target at build time", target, target.instance)
if target.next_index >= len(target.locals):
target.locals.append(value)
else:
value = target.locals[target.next_index]
def set_value(new_value: Any, index=target.next_index) -> None:
if target.locals[index] == new_value:
return
target.locals[index] = new_value
print('__geta__ from here???')
target.refreshable.refresh(_instance=target.instance)
target.next_index += 1
return value, set_value
```
</details>
Notice the page loads like this:
```
##### refreshable_method <nicegui.functions.refreshable.refreshable_method object at 0x000001EB8984FB30> <function Clock.time at 0x000001EB8988C180>
NiceGUI ready to go on ...
<nicegui.functions.refreshable.refreshable_method object at 0x000001EB8984FB30>
__get__ None -> <__mp_main__.Clock object at 0x000001EB899E3320> DIFF
Target at build time RefreshableTarget(container=<nicegui.functions.refreshable.RefreshableContainer object at 0x000001EB898C72C0>, refreshable=<nicegui.functions.refreshable.refreshable_method object at 0x000001EB8984FB30>, instance=<__mp_main__.Clock object at 0x000001EB899E3320>, args=(), kwargs={}, locals=[], next_index=0) <__mp_main__.Clock object at 0x000001EB899E3320>
0 time_primitive clock1 2025-05-03 14:45:35.170569 0.6963193
<nicegui.functions.refreshable.refreshable_method object at 0x000001EB8984FB30>
__get__ <__mp_main__.Clock object at 0x000001EB899E3320> -> <__mp_main__.Clock object at 0x000001EB899E3320> SAME
<nicegui.functions.refreshable.refreshable_method object at 0x000001EB8984FB30>
__get__ <__mp_main__.Clock object at 0x000001EB899E3320> -> <__mp_main__.Clock object at 0x000001EB899E3F50> DIFF
Target at build time RefreshableTarget(container=<nicegui.functions.refreshable.RefreshableContainer object at 0x000001EB899E3EF0>, refreshable=<nicegui.functions.refreshable.refreshable_method object at 0x000001EB8984FB30>, instance=<__mp_main__.Clock object at 0x000001EB899E3F50>, args=(), kwargs={}, locals=[], next_index=0) <__mp_main__.Clock object at 0x000001EB899E3F50>
1 time_primitive clock2 2025-05-03 14:45:35.171835 0.0067774
<nicegui.functions.refreshable.refreshable_method object at 0x000001EB8984FB30>
__get__ <__mp_main__.Clock object at 0x000001EB899E3F50> -> <__mp_main__.Clock object at 0x000001EB899E3F50> SAME
```
So you see how `__get__` overwrote the target, but each state got it's
own copy of `RefreshableTarget` which it can leverage.
Revert `target.refreshable.refresh(_instance=target.instance)` back to
`target.refreshable.refresh()` and you will see "!!!Refresh called
without _instance, while self.instance is not None (culprit of Local
Scope B problem)" in the console, because (1) `__get__` was never
called, and (2) did not pass in `_instance`, so it just uses whatever
target was set, which is wrong.