Browse Source

Provide app.storage.client as a location to store volatile data which only matters for the current connection (#2820)

* Implemented app.storage.session which enables the user to store data in the current Client instance - which in practice means "per browser tab".

* Replaced Client.state by ObservableDict
Moved context import to top of the file

* Renamed app.storage.session to app.storage.client.

Adjusted documentation of app.storage.client.

* Exchanged quotes
Added app.storage.client clear test

* Added documentation for app.storage.client
Dropped implementation client.current_client for now
Added exceptions for calls to app.storage.client from auto index or w/o client connection.
Added tests for changes

* Removed imports, simplified client availability check

* Updated documentation
Removed client connection state checks and tests

* Removed connection test_clear from

* Removed random import, not required for demo anymore

* Resolved merging conflicts with tab extension

* Merge fix

* Merge fix

* minimal updates to documentation

* code review

* Removed line duplication

* improve clearing of client storage

* fix typo

* add overview table

* renaming

* review documentation

---------

Co-authored-by: Rodja Trappe <rodja@zauberzeug.com>
Co-authored-by: Falko Schindler <falko@zauberzeug.com>
Michael Ikemann 1 year ago
parent
commit
1428e6dbf0

+ 2 - 0
nicegui/client.py

@@ -20,6 +20,7 @@ from .element import Element
 from .favicon import get_favicon_url
 from .favicon import get_favicon_url
 from .javascript_request import JavaScriptRequest
 from .javascript_request import JavaScriptRequest
 from .logging import log
 from .logging import log
+from .observables import ObservableDict
 from .outbox import Outbox
 from .outbox import Outbox
 from .version import __version__
 from .version import __version__
 
 
@@ -73,6 +74,7 @@ class Client:
         self._body_html = ''
         self._body_html = ''
 
 
         self.page = page
         self.page = page
+        self.storage = ObservableDict()
 
 
         self.connect_handlers: List[Union[Callable[..., Any], Awaitable]] = []
         self.connect_handlers: List[Union[Callable[..., Any], Awaitable]] = []
         self.disconnect_handlers: List[Union[Callable[..., Any], Awaitable]] = []
         self.disconnect_handlers: List[Union[Callable[..., Any], Awaitable]] = []

+ 19 - 0
nicegui/storage.py

@@ -17,6 +17,7 @@ from starlette.responses import Response
 
 
 from . import background_tasks, context, core, json, observables
 from . import background_tasks, context, core, json, observables
 from .logging import log
 from .logging import log
+from .observables import ObservableDict
 
 
 request_contextvar: contextvars.ContextVar[Optional[Request]] = contextvars.ContextVar('request_var', default=None)
 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)."""
         """General storage shared between all users that is persisted on the server (where NiceGUI is executed)."""
         return self._general
         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
     @property
     def tab(self) -> observables.ObservableDict:
     def tab(self) -> observables.ObservableDict:
         """A volatile storage that is only kept during the current tab session."""
         """A volatile storage that is only kept during the current tab session."""
@@ -183,6 +196,12 @@ class Storage:
         """Clears all storage."""
         """Clears all storage."""
         self._general.clear()
         self._general.clear()
         self._users.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()
         self._tabs.clear()
         for filepath in self.path.glob('storage-*.json'):
         for filepath in self.path.glob('storage-*.json'):
             filepath.unlink()
             filepath.unlink()

+ 37 - 0
tests/test_storage.py

@@ -1,5 +1,6 @@
 import asyncio
 import asyncio
 from pathlib import Path
 from pathlib import Path
+import pytest
 
 
 import httpx
 import httpx
 
 
@@ -227,3 +228,39 @@ def test_clear_tab_storage(screen: Screen):
     screen.click('clear')
     screen.click('clear')
     screen.wait(0.5)
     screen.wait(0.5)
     assert not tab_storages
     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('/')

+ 46 - 3
website/documentation/content/storage_documentation.py

@@ -14,13 +14,20 @@ doc.title('Storage')
 
 
 @doc.demo('Storage', '''
 @doc.demo('Storage', '''
     NiceGUI offers a straightforward mechanism for data persistence within your application. 
     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`:
     - `app.storage.tab`:
         Stored server-side in memory, this dictionary is unique to each tab session and can hold arbitrary objects.
         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.
         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) 
         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).
         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`:
     - `app.storage.user`:
         Stored server-side, each dictionary is associated with a unique identifier held in a browser session cookie.
         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.
         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>`_
     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.
     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.
     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():
 def storage_demo():
     from nicegui import app
     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.
     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():
 def tab_storage():
-    from nicegui import app
+    from nicegui import app, Client
 
 
     # @ui.page('/')
     # @ui.page('/')
-    # async def index(client):
+    # async def index(client: Client):
     #     await client.connected()
     #     await client.connected()
     with ui.column():  # HIDE
     with ui.column():  # HIDE
         app.storage.tab['count'] = app.storage.tab.get('count', 0) + 1
         app.storage.tab['count'] = app.storage.tab.get('count', 0) + 1
         ui.label(f'Tab reloaded {app.storage.tab["count"]} times')
         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)

+ 3 - 1
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 ', ':'))
                     element = ui.restructured_text(part.description.replace(':param ', ':'))
                 else:
                 else:
                     element = ui.markdown(part.description)
                     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:
             if part.ui:
                 part.ui()
                 part.ui()
             if part.demo:
             if part.demo: