Repository: zauberzeug/nicegui
Pull request #2837 (2024-04-06 13:56:26)

Provide app.storage.tab
This pull request picks up on the idea from https://github.com/zauberzeug/nicegui/pull/2820#discussion_r1554523968 and other discussion around this topic to implement `app.storage.tab`: a volatile storage which keeps a state dictionary as long as the browser tab lives. That means the data survives page reloads and switching urls.

It is implemented by storing the dictionary in the memory of the server. To decide which dictionary needs to be accessed the pull request introduces a `client.tab_id` which is automatically set when doing the websocket handshake.

### ToDos

- [x] When to delete the storage dicts? A tab could revisit the page even after a few days...
- [x] add tests
- [x] add demo


Patch:
diff --git a/nicegui/client.py b/nicegui/client.py
index d3b642b6..74c266ed 100644
--- a/nicegui/client.py
+++ b/nicegui/client.py
@@ -58,6 +58,7 @@ class Client:
         self.shared = shared
         self.on_air = False
         self._disconnect_task: Optional[asyncio.Task] = None
+        self.tab_id: Optional[str] = None
 
         self.outbox = Outbox(self)
 
diff --git a/nicegui/elements/echart.js b/nicegui/elements/echart.js
index c89e90b7..65e95877 100644
--- a/nicegui/elements/echart.js
+++ b/nicegui/elements/echart.js
@@ -64,7 +64,7 @@ export default {
     run_chart_method(name, ...args) {
       if (name.startsWith(":")) {
         name = name.slice(1);
-        args = args.map((arg) => new Function("return " + arg)());
+        args = args.map((arg) => new Function(`return (${arg})`)());
       }
       return runMethod(this.chart, name, args);
     },
diff --git a/nicegui/elements/json_editor.js b/nicegui/elements/json_editor.js
index 9f33681b..7186997e 100644
--- a/nicegui/elements/json_editor.js
+++ b/nicegui/elements/json_editor.js
@@ -35,7 +35,7 @@ export default {
       if (this.editor) {
         if (name.startsWith(":")) {
           name = name.slice(1);
-          args = args.map((arg) => new Function("return " + arg)());
+          args = args.map((arg) => new Function(`return (${arg})`)());
         }
         return runMethod(this.editor, name, args);
       }
diff --git a/nicegui/elements/leaflet.js b/nicegui/elements/leaflet.js
index 2a81d653..a112401a 100644
--- a/nicegui/elements/leaflet.js
+++ b/nicegui/elements/leaflet.js
@@ -125,7 +125,7 @@ export default {
     run_map_method(name, ...args) {
       if (name.startsWith(":")) {
         name = name.slice(1);
-        args = args.map((arg) => new Function("return " + arg)());
+        args = args.map((arg) => new Function(`return (${arg})`)());
       }
       return runMethod(this.map, name, args);
     },
@@ -135,7 +135,7 @@ export default {
         if (layer.id !== id) return;
         if (name.startsWith(":")) {
           name = name.slice(1);
-          args = args.map((arg) => new Function("return " + arg)());
+          args = args.map((arg) => new Function(`return (${arg})`)());
         }
         result = runMethod(layer, name, args);
       });
diff --git a/nicegui/native/native.py b/nicegui/native/native.py
index 858ed9de..931ecf38 100644
--- a/nicegui/native/native.py
+++ b/nicegui/native/native.py
@@ -70,6 +70,9 @@ try:
         def resize(self, width: int, height: int, fix_point: FixPoint = FixPoint.NORTH | FixPoint.WEST) -> None:
             self._send(width, height, fix_point)
 
+        def maximize(self) -> None:
+            self._send()
+
         def minimize(self) -> None:
             self._send()
 
diff --git a/nicegui/nicegui.py b/nicegui/nicegui.py
index b3f9bad5..f93c77dd 100644
--- a/nicegui/nicegui.py
+++ b/nicegui/nicegui.py
@@ -129,6 +129,7 @@ async def _startup() -> None:
     background_tasks.create(binding.refresh_loop(), name='refresh bindings')
     background_tasks.create(Client.prune_instances(), name='prune clients')
     background_tasks.create(Slot.prune_stacks(), name='prune slot stacks')
+    background_tasks.create(core.app.storage.prune_tab_storage(), name='prune tab storage')
     air.connect()
 
 
@@ -158,11 +159,12 @@ async def _exception_handler_500(request: Request, exception: Exception) -> Resp
 
 
 @sio.on('handshake')
-async def _on_handshake(sid: str, client_id: str) -> bool:
-    client = Client.instances.get(client_id)
+async def _on_handshake(sid: str, data: Dict[str, str]) -> bool:
+    client = Client.instances.get(data['client_id'])
     if not client:
         return False
     client.environ = sio.get_environ(sid)
+    client.tab_id = data['tab_id']
     await sio.enter_room(sid, client.id)
     client.handle_handshake()
     return True
diff --git a/nicegui/observables.py b/nicegui/observables.py
index 9b84ca09..bd3516f6 100644
--- a/nicegui/observables.py
+++ b/nicegui/observables.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import abc
+import time
 from typing import Any, Callable, Collection, Dict, Iterable, List, Optional, SupportsIndex, Union
 
 from . import events
@@ -16,6 +17,7 @@ class ObservableCollection(abc.ABC):
                  ) -> None:
         super().__init__(factory() if data is None else data)  # type: ignore
         self._parent = _parent
+        self.last_modified = time.time()
         self._change_handlers: List[Callable] = [on_change] if on_change else []
 
     @property
@@ -27,6 +29,7 @@ class ObservableCollection(abc.ABC):
         return change_handlers
 
     def _handle_change(self) -> None:
+        self.last_modified = time.time()
         for handler in self.change_handlers:
             events.handle_event(handler, events.ObservableChangeEventArguments(sender=self))
 
diff --git a/nicegui/static/nicegui.js b/nicegui/static/nicegui.js
index df797ffb..03ad3e3d 100644
--- a/nicegui/static/nicegui.js
+++ b/nicegui/static/nicegui.js
@@ -153,7 +153,7 @@ function renderRecursively(elements, id) {
       handler = (...args) => {
         const data = {
           id: id,
-          client_id: window.client_id,
+          client_id: window.clientId,
           listener_id: event.listener_id,
           args: stringifyEventArgs(args, event.args),
         };
@@ -212,7 +212,7 @@ function runJavascript(code, request_id) {
     })
     .then((result) => {
       if (request_id) {
-        window.socket.emit("javascript_response", { request_id, client_id: window.client_id, result });
+        window.socket.emit("javascript_response", { request_id, client_id: window.clientId, result });
       }
     });
 }
@@ -264,7 +264,7 @@ function createApp(elements, options) {
     },
     mounted() {
       mounted_app = this;
-      window.client_id = options.query.client_id;
+      window.clientId = options.query.client_id;
       const url = window.location.protocol === "https:" ? "wss://" : "ws://" + window.location.host;
       window.path_prefix = options.prefix;
       window.socket = io(url, {
@@ -275,9 +275,14 @@ function createApp(elements, options) {
       });
       const messageHandlers = {
         connect: () => {
-          window.socket.emit("handshake", window.client_id, (ok) => {
+          let tabId = sessionStorage.getItem("__nicegui_tab_id");
+          if (!tabId) {
+            tabId = crypto.randomUUID();
+            sessionStorage.setItem("__nicegui_tab_id", tabId);
+          }
+          window.socket.emit("handshake", { client_id: window.clientId, tab_id: tabId }, (ok) => {
             if (!ok) {
-              console.log("reloading because handshake failed for client_id " + window.client_id);
+              console.log("reloading because handshake failed for clientId " + window.clientId);
               window.location.reload();
             }
             document.getElementById("popup").style.opacity = 0;
diff --git a/nicegui/static/utils/dynamic_properties.js b/nicegui/static/utils/dynamic_properties.js
index 60ac4fc3..d8ab0a23 100644
--- a/nicegui/static/utils/dynamic_properties.js
+++ b/nicegui/static/utils/dynamic_properties.js
@@ -11,7 +11,7 @@ export function convertDynamicProperties(obj, recursive) {
   for (const [attr, value] of Object.entries(obj)) {
     if (attr.startsWith(":")) {
       try {
-        obj[attr.slice(1)] = new Function("return " + value)();
+        obj[attr.slice(1)] = new Function(`return (${value})`)();
         delete obj[attr];
       } catch (e) {
         console.error(`Error while converting ${attr} attribute to function:`, e);
diff --git a/nicegui/storage.py b/nicegui/storage.py
index 657f8650..64086869 100644
--- a/nicegui/storage.py
+++ b/nicegui/storage.py
@@ -1,7 +1,10 @@
+import asyncio
 import contextvars
 import os
+import time
 import uuid
 from collections.abc import MutableMapping
+from datetime import timedelta
 from pathlib import Path
 from typing import Any, Dict, Iterator, Optional, Union
 
@@ -17,6 +20,8 @@ from .logging import log
 
 request_contextvar: contextvars.ContextVar[Optional[Request]] = contextvars.ContextVar('request_var', default=None)
 
+PURGE_INTERVAL = timedelta(minutes=5).total_seconds()
+
 
 class ReadOnlyDict(MutableMapping):
 
@@ -94,9 +99,11 @@ class Storage:
 
     def __init__(self) -> None:
         self.path = Path(os.environ.get('NICEGUI_STORAGE_PATH', '.nicegui')).resolve()
-        self.migrate_to_utf8()
+        self.migrate_to_utf8()  # DEPRECATED: remove this in 2.0 release
+        self.max_tab_storage_age = timedelta(days=30).total_seconds()
         self._general = PersistentDict(self.path / 'storage-general.json', encoding='utf-8')
         self._users: Dict[str, PersistentDict] = {}
+        self._tabs: Dict[str, observables.ObservableDict] = {}
 
     @property
     def browser(self) -> Union[ReadOnlyDict, Dict]:
@@ -120,7 +127,7 @@ class Storage:
         return request.session
 
     @property
-    def user(self) -> Dict:
+    def user(self) -> PersistentDict:
         """Individual user storage that is persisted on the server (where NiceGUI is executed).
 
         The data is stored in a file on the server.
@@ -145,14 +152,38 @@ class Storage:
             return False  # no client
 
     @property
-    def general(self) -> Dict:
+    def general(self) -> PersistentDict:
         """General storage shared between all users that is persisted on the server (where NiceGUI is executed)."""
         return self._general
 
+    @property
+    def tab(self) -> observables.ObservableDict:
+        """A volatile storage that is only kept during the current tab session."""
+        if self._is_in_auto_index_context():
+            raise RuntimeError('app.storage.tab can only be used with page builder functions '
+                               '(https://nicegui.io/documentation/page)')
+        client = context.get_client()
+        if not client.has_socket_connection:
+            raise RuntimeError('app.storage.tab can only be used with a client connection; '
+                               'see https://nicegui.io/documentation/page#wait_for_client_connection to await it')
+        assert client.tab_id is not None
+        if client.tab_id not in self._tabs:
+            self._tabs[client.tab_id] = observables.ObservableDict()
+        return self._tabs[client.tab_id]
+
+    async def prune_tab_storage(self) -> None:
+        """Regularly prune tab storage that is older than the configured `max_tab_storage_age`."""
+        while True:
+            for tab_id, tab in list(self._tabs.items()):
+                if time.time() > tab.last_modified + self.max_tab_storage_age:
+                    del self._tabs[tab_id]
+            await asyncio.sleep(PURGE_INTERVAL)
+
     def clear(self) -> None:
         """Clears all storage."""
         self._general.clear()
         self._users.clear()
+        self._tabs.clear()
         for filepath in self.path.glob('storage-*.json'):
             filepath.unlink()
 
diff --git a/website/documentation/content/button_documentation.py b/website/documentation/content/button_documentation.py
index 876e425b..2520dfa1 100644
--- a/website/documentation/content/button_documentation.py
+++ b/website/documentation/content/button_documentation.py
@@ -27,13 +27,14 @@ def icons() -> None:
 async def await_button_click() -> None:
     # @ui.page('/')
     # async def index():
-    b = ui.button('Step')
-    await b.clicked()
-    ui.label('One')
-    await b.clicked()
-    ui.label('Two')
-    await b.clicked()
-    ui.label('Three')
+    with ui.column():  # HIDE
+        b = ui.button('Step')
+        await b.clicked()
+        ui.label('One')
+        await b.clicked()
+        ui.label('Two')
+        await b.clicked()
+        ui.label('Three')
 
 
 @doc.demo('Disable button with a context manager', '''
diff --git a/website/documentation/content/storage_documentation.py b/website/documentation/content/storage_documentation.py
index 79277691..ccd045b5 100644
--- a/website/documentation/content/storage_documentation.py
+++ b/website/documentation/content/storage_documentation.py
@@ -13,9 +13,14 @@ doc.title('Storage')
 
 
 @doc.demo('Storage', '''
-    NiceGUI offers a straightforward method for data persistence within your application. 
-    It features three built-in storage types:
-
+    NiceGUI offers a straightforward mechanism for data persistence within your application. 
+    It features four 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.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.
@@ -86,3 +91,19 @@ def ui_state():
     #         .classes('w-full').bind_value(app.storage.user, 'note')
     # END OF DEMO
     ui.textarea('This note is kept between visits').classes('w-full').bind_value(app.storage.user, 'note')
+
+
+@doc.demo('Storing data per browser tab', '''
+    When storing data in `app.storage.tab`, a single user can open multiple tabs of the same app, each with its own storage data.
+    This may be beneficial in certain scenarios like search or when performing data analysis.
+    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
+
+    # @ui.page('/')
+    # async def index(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')


Test patch:
diff --git a/tests/test_storage.py b/tests/test_storage.py
index d26257e3..8db90f2a 100644
--- a/tests/test_storage.py
+++ b/tests/test_storage.py
@@ -3,7 +3,9 @@ from pathlib import Path
 
 import httpx
 
-from nicegui import Client, app, background_tasks, ui
+from nicegui import Client, app, background_tasks, context
+from nicegui import storage as storage_module
+from nicegui import ui
 from nicegui.testing import Screen
 
 
@@ -163,3 +165,65 @@ def test_rapid_storage(screen: Screen):
     screen.click('test')
     screen.wait(0.5)
     assert Path('.nicegui', 'storage-general.json').read_text('utf-8') == '{"one":1,"two":2,"three":3}'
+
+
+def test_tab_storage_is_local(screen: Screen):
+    @ui.page('/')
+    async def page():
+        await context.get_client().connected()
+        app.storage.tab['count'] = app.storage.tab.get('count', 0) + 1
+        ui.label().bind_text_from(app.storage.tab, 'count')
+
+    screen.open('/')
+    screen.should_contain('1')
+    screen.open('/')
+    screen.should_contain('2')
+
+    screen.switch_to(1)
+    screen.open('/')
+    screen.should_contain('1')
+
+    screen.switch_to(0)
+    screen.open('/')
+    screen.should_contain('3')
+
+
+def test_tab_storage_is_auto_removed(screen: Screen):
+    storage_module.PURGE_INTERVAL = 0.1
+    app.storage.max_tab_storage_age = 0.5
+
+    @ui.page('/')
+    async def page():
+        await context.get_client().connected()
+        app.storage.tab['count'] = app.storage.tab.get('count', 0) + 1
+        ui.label().bind_text_from(app.storage.tab, 'count')
+
+    screen.open('/')
+    screen.should_contain('1')
+    screen.open('/')
+    screen.should_contain('2')
+
+    screen.wait(1)
+    screen.open('/')
+    screen.should_contain('1')
+
+
+def test_clear_tab_storage(screen: Screen):
+    storage_module.PURGE_INTERVAL = 60
+
+    @ui.page('/')
+    async def page():
+        await context.get_client().connected()
+        app.storage.tab['test'] = '123'
+        ui.button('clear', on_click=app.storage.clear)
+
+    screen.open('/')
+    screen.should_contain('clear')
+
+    tab_storages = app.storage._tabs  # pylint: disable=protected-access
+    assert len(tab_storages) == 1
+    assert list(tab_storages.values())[0] == {'test': '123'}
+
+    screen.click('clear')
+    screen.wait(0.5)
+    assert not tab_storages