Repository: zauberzeug/nicegui
Pull request #2859 (2024-04-09 14:32:27)

Automatically await async results in `ui.timer`
Inspired by #2856, this PR allows to pass lambda statements to `ui.timer` that call a coroutine, returning an awaitable result:

```py
async def slow_notification(msg: str):
    await asyncio.sleep(0.1)
    ui.notify(msg)

ui.timer(1.0, lambda: slow_notification('Hello, world!'))
```


Patch:
diff --git a/nicegui/client.py b/nicegui/client.py
index 74c266ed..06c6bf68 100644
--- a/nicegui/client.py
+++ b/nicegui/client.py
@@ -20,6 +20,7 @@ from .element import Element
 from .favicon import get_favicon_url
 from .javascript_request import JavaScriptRequest
 from .logging import log
+from .observables import ObservableDict
 from .outbox import Outbox
 from .version import __version__
 
@@ -73,6 +74,7 @@ class Client:
         self._body_html = ''
 
         self.page = page
+        self.storage = ObservableDict()
 
         self.connect_handlers: List[Union[Callable[..., Any], Awaitable]] = []
         self.disconnect_handlers: List[Union[Callable[..., Any], Awaitable]] = []
diff --git a/nicegui/elements/timer.py b/nicegui/elements/timer.py
index 550fa510..b9b624b5 100644
--- a/nicegui/elements/timer.py
+++ b/nicegui/elements/timer.py
@@ -1,9 +1,10 @@
 import asyncio
 import time
 from contextlib import nullcontext
-from typing import Any, Callable, Optional
+from typing import Any, Callable, Optional, Awaitable
 
-from .. import background_tasks, core, helpers
+from .. import background_tasks, core
+from ..awaitable_response import AwaitableResponse
 from ..binding import BindableProperty
 from ..client import Client
 from ..element import Element
@@ -91,7 +92,7 @@ class Timer(Element, component='timer.js'):
         try:
             assert self.callback is not None
             result = self.callback()
-            if helpers.is_coroutine_function(self.callback):
+            if isinstance(result, Awaitable) and not isinstance(result, AwaitableResponse):
                 await result
         except Exception as e:
             core.app.handle_exception(e)
diff --git a/nicegui/storage.py b/nicegui/storage.py
index 64086869..1cbdecfe 100644
--- a/nicegui/storage.py
+++ b/nicegui/storage.py
@@ -17,6 +17,7 @@ from starlette.responses import Response
 
 from . import background_tasks, context, core, json, observables
 from .logging import log
+from .observables import ObservableDict
 
 request_contextvar: contextvars.ContextVar[Optional[Request]] = contextvars.ContextVar('request_var', default=None)
 
@@ -156,6 +157,18 @@ class Storage:
         """General storage shared between all users that is persisted on the server (where NiceGUI is executed)."""
         return self._general
 
+    @property
+    def client(self) -> ObservableDict:
+        """A volatile storage that is only kept during the current connection to the client.
+
+        Like `app.storage.tab` data is unique per browser tab but is even more volatile as it is already discarded
+        when the connection to the client is lost through a page reload or a navigation.
+        """
+        if self._is_in_auto_index_context():
+            raise RuntimeError('app.storage.client can only be used with page builder functions '
+                               '(https://nicegui.io/documentation/page)')
+        return context.get_client().storage
+
     @property
     def tab(self) -> observables.ObservableDict:
         """A volatile storage that is only kept during the current tab session."""
@@ -183,6 +196,12 @@ class Storage:
         """Clears all storage."""
         self._general.clear()
         self._users.clear()
+        try:
+            client = context.get_client()
+        except RuntimeError:
+            pass  # no client, could be a pytest
+        else:
+            client.storage.clear()
         self._tabs.clear()
         for filepath in self.path.glob('storage-*.json'):
             filepath.unlink()
diff --git a/website/documentation/content/storage_documentation.py b/website/documentation/content/storage_documentation.py
index ccd045b5..2a0441a9 100644
--- a/website/documentation/content/storage_documentation.py
+++ b/website/documentation/content/storage_documentation.py
@@ -14,13 +14,20 @@ doc.title('Storage')
 
 @doc.demo('Storage', '''
     NiceGUI offers a straightforward mechanism for data persistence within your application. 
-    It features four built-in storage types:
+    It features five built-in storage types:
 
     - `app.storage.tab`:
         Stored server-side in memory, this dictionary is unique to each tab session and can hold arbitrary objects.
         Data will be lost when restarting the server until <https://github.com/zauberzeug/nicegui/discussions/2841> is implemented.
         This storage is only available within [page builder functions](/documentation/page) 
         and requires an established connection, obtainable via [`await client.connected()`](/documentation/page#wait_for_client_connection).
+    - `app.storage.client`:
+        Also stored server-side in memory, this dictionary is unique to each client connection and can hold arbitrary objects.
+        Data will be discarded when the page is reloaded or the user navigates to another page.
+        Unlike data stored in `app.storage.tab` which can be persisted on the server even for days, 
+        `app.storage.client` helps caching resource-hungry objects such as a streaming or database connection you need to keep alive 
+        for dynamic site updates but would like to discard as soon as the user leaves the page or closes the browser. 
+        This storage is only available within [page builder functions](/documentation/page).
     - `app.storage.user`:
         Stored server-side, each dictionary is associated with a unique identifier held in a browser session cookie.
         Unique to each user, this storage is accessible across all their browser tabs.
@@ -35,6 +42,16 @@ doc.title('Storage')
     The user storage and browser storage are only available within `page builder functions </documentation/page>`_
     because they are accessing the underlying `Request` object from FastAPI.
     Additionally these two types require the `storage_secret` parameter in`ui.run()` to encrypt the browser session cookie.
+    
+    | Storage type                | `tab`  | `client` | `user` | `general` | `browser` |
+    |-----------------------------|--------|----------|--------|-----------|-----------|
+    | Location                    | Server | Server   | Server | Server    | Browser   |
+    | Across tabs                 | No     | No       | Yes    | Yes       | Yes       |
+    | Across browsers             | No     | No       | No     | Yes       | No        |
+    | Across page reloads         | Yes    | No       | Yes    | Yes       | Yes       |
+    | Needs page builder function | Yes    | Yes      | Yes    | No        | Yes       |
+    | Needs client connection     | Yes    | No       | No     | No        | No        |
+    | Write only before response  | No     | No       | No     | No        | Yes       |
 ''')
 def storage_demo():
     from nicegui import app
@@ -99,11 +116,37 @@ def ui_state():
     It is also more secure to use such a volatile storage for scenarios like logging into a bank account or accessing a password manager.
 ''')
 def tab_storage():
-    from nicegui import app
+    from nicegui import app, Client
 
     # @ui.page('/')
-    # async def index(client):
+    # async def index(client: Client):
     #     await client.connected()
     with ui.column():  # HIDE
         app.storage.tab['count'] = app.storage.tab.get('count', 0) + 1
         ui.label(f'Tab reloaded {app.storage.tab["count"]} times')
+        ui.button('Reload page', on_click=ui.navigate.reload)
+
+
+@doc.demo('Short-term memory', '''
+    The goal of `app.storage.client` is to store data only for the duration of the current page visit.
+    In difference to data stored in `app.storage.tab`
+    - which is persisted between page changes and even browser restarts as long as the tab is kept open -
+    the data in `app.storage.client` will be discarded if the user closes the browser, reloads the page or navigates to another page.
+    This is beneficial for resource-hungry, intentionally short-lived or sensitive data.
+    An example is a database connection, which should be closed as soon as the user leaves the page.
+    Additionally, this storage useful if you want to return a page with default settings every time a user reloads.
+    Meanwhile, it keeps the data alive during in-page navigation.
+    This is also helpful when updating elements on the site at intervals, such as a live feed.
+''')
+def short_term_memory():
+    from nicegui import app
+
+    # @ui.page('/')
+    # async def index():
+    with ui.column():  # HIDE
+        cache = app.storage.client
+        cache['count'] = 0
+        ui.label().bind_text_from(cache, 'count', lambda n: f'Updated {n} times')
+        ui.button('Update content',
+                  on_click=lambda: cache.update(count=cache['count'] + 1))
+        ui.button('Reload page', on_click=ui.navigate.reload)
diff --git a/website/documentation/rendering.py b/website/documentation/rendering.py
index c0c71d22..cba64a79 100644
--- a/website/documentation/rendering.py
+++ b/website/documentation/rendering.py
@@ -43,7 +43,9 @@ def render_page(documentation: DocumentationPage, *, with_menu: bool = True) ->
                     element = ui.restructured_text(part.description.replace(':param ', ':'))
                 else:
                     element = ui.markdown(part.description)
-                element.classes('bold-links arrow-links rst-param-tables')
+                element.classes('bold-links arrow-links')
+                if ':param' in part.description:
+                    element.classes('rst-param-tables')
             if part.ui:
                 part.ui()
             if part.demo:


Test patch:
diff --git a/tests/test_storage.py b/tests/test_storage.py
index 8db90f2a..a9d3ecad 100644
--- a/tests/test_storage.py
+++ b/tests/test_storage.py
@@ -1,5 +1,6 @@
 import asyncio
 from pathlib import Path
+import pytest
 
 import httpx
 
@@ -227,3 +228,39 @@ def test_clear_tab_storage(screen: Screen):
     screen.click('clear')
     screen.wait(0.5)
     assert not tab_storages
+
+
+def test_client_storage(screen: Screen):
+    def increment():
+        app.storage.client['counter'] = app.storage.client['counter'] + 1
+
+    @ui.page('/')
+    def page():
+        app.storage.client['counter'] = 123
+        ui.button('Increment').on_click(increment)
+        ui.label().bind_text(app.storage.client, 'counter')
+
+    screen.open('/')
+    screen.should_contain('123')
+    screen.click('Increment')
+    screen.wait_for('124')
+
+    screen.switch_to(1)
+    screen.open('/')
+    screen.should_contain('123')
+
+    screen.switch_to(0)
+    screen.should_contain('124')
+
+
+def test_clear_client_storage(screen: Screen):
+    with pytest.raises(RuntimeError):  # no context (auto index)
+        app.storage.client.clear()
+
+    @ui.page('/')
+    def page():
+        app.storage.client['counter'] = 123
+        app.storage.client.clear()
+        assert app.storage.client == {}
+
+    screen.open('/')
diff --git a/tests/test_timer.py b/tests/test_timer.py
index cd678309..cced9804 100644
--- a/tests/test_timer.py
+++ b/tests/test_timer.py
@@ -77,20 +77,6 @@ def test_setting_visibility(screen: Screen, once: bool):
     screen.should_not_contain('Some Label')
 
 
-def test_awaiting_coroutine(screen: Screen):
-    user = {'name': 'John Doe'}
-
-    async def update_user():
-        await asyncio.sleep(0.1)
-        user['name'] = 'Jane Doe'
-
-    ui.timer(1, update_user)
-
-    screen.open('/')
-    screen.wait(1)
-    assert user['name'] == 'Jane Doe'
-
-
 def test_timer_on_deleted_container(screen: Screen):
     state = {'count': 0}
     with ui.row() as outer_container:
@@ -105,3 +91,27 @@ def test_timer_on_deleted_container(screen: Screen):
     count = state['count']
     screen.wait(0.5)
     assert state['count'] == count, 'timer is not running anymore after deleting the container'
+
+
+def test_different_callbacks(screen: Screen):
+    def sync_function():
+        ui.label('a synchronous function')
+
+    async def async_function():
+        await asyncio.sleep(0.1)
+        ui.label('an asynchronous function')
+
+    async def async_lambda(msg: str):
+        await asyncio.sleep(0.1)
+        ui.label(f'an asynchronous lambda: {msg}')
+
+    ui.timer(0.1, sync_function, once=True)
+    ui.timer(0.1, async_function, once=True)
+    ui.timer(0.1, lambda: ui.label('a synchronous lambda'), once=True)
+    ui.timer(0.1, lambda: async_lambda('Hi!'), once=True)
+
+    screen.open('/')
+    screen.should_contain('a synchronous function')
+    screen.should_contain('an asynchronous function')
+    screen.should_contain('a synchronous lambda')
+    screen.should_contain('an asynchronous lambda: Hi!')