Repository: zauberzeug/nicegui
Pull request #2820 (2024-04-04 08:22:33)

Provide app.storage.client as a location to store volatile data which only matters for the current connection
As discussed in [pull request #2811](https://github.com/zauberzeug/nicegui/pull/2811) and the discussion [Add app.storage.tab or similar #1308](https://github.com/zauberzeug/nicegui/discussions/1308) I added a feature which allows the storage of data per Client connection.

This - in practice - and when either implementing an own single page application approach, e.g. based upon the example/single_page_app or when [#2811]((https://github.com/zauberzeug/nicegui/pull/2811)) is merged enables developers to create **per-browser tab user data instances**, for example with different logins and/or settings with the same comfort of as currently when using **app.storage.user** - so without handing over another per-page instance from event handler to handler.

rodja: I just started a first proof-of-conecept for `app.storage.tab` in https://github.com/zauberzeug/nicegui/pull/2837.
Alyxion: Added the missing checks + unit tests, removed Client.current_client and added the sections to the documentation page based upon your examples in the .tab variant.
Alyxion: Updated. I already referred [#2837](https://github.com/zauberzeug/nicegui/pull/2837) in the documentation to explain when to use **tab** and when to use **client**. The count in *It features four built-in storage types:* then needs to be adjusted accordingly when boths PRs should be united in the main branch.
Alyxion: Resolved the conflicts with the new main branch and adjusted the doc accordingly.

Patch:
diff --git a/examples/chat_with_ai/main.py b/examples/chat_with_ai/main.py
index 22703a94..e9be81c0 100755
--- a/examples/chat_with_ai/main.py
+++ b/examples/chat_with_ai/main.py
@@ -1,43 +1,33 @@
 #!/usr/bin/env python3
-from typing import List, Tuple
-
-from langchain.chains import ConversationChain
-from langchain.chat_models import ChatOpenAI
+from langchain_openai import ChatOpenAI
 from log_callback_handler import NiceGuiLogElementCallbackHandler
 
-from nicegui import context, ui
+from nicegui import ui
 
 OPENAI_API_KEY = 'not-set'  # TODO: set your OpenAI API key here
 
 
 @ui.page('/')
 def main():
-    llm = ConversationChain(llm=ChatOpenAI(model_name='gpt-3.5-turbo', openai_api_key=OPENAI_API_KEY))
-
-    messages: List[Tuple[str, str]] = []
-    thinking: bool = False
-
-    @ui.refreshable
-    def chat_messages() -> None:
-        for name, text in messages:
-            ui.chat_message(text=text, name=name, sent=name == 'You')
-        if thinking:
-            ui.spinner(size='3rem').classes('self-center')
-        if context.get_client().has_socket_connection:
-            ui.run_javascript('window.scrollTo(0, document.body.scrollHeight)')
+    llm = ChatOpenAI(model_name='gpt-3.5-turbo', streaming=True, openai_api_key=OPENAI_API_KEY)
 
     async def send() -> None:
-        nonlocal thinking
-        message = text.value
-        messages.append(('You', text.value))
-        thinking = True
+        question = text.value
         text.value = ''
-        chat_messages.refresh()
 
-        response = await llm.arun(message, callbacks=[NiceGuiLogElementCallbackHandler(log)])
-        messages.append(('Bot', response))
-        thinking = False
-        chat_messages.refresh()
+        with message_container:
+            ui.chat_message(text=question, name='You', sent=True)
+            response_message = ui.chat_message(name='Bot', sent=False)
+            spinner = ui.spinner(type='dots')
+
+        response = ''
+        async for chunk in llm.astream(question, config={'callbacks': [NiceGuiLogElementCallbackHandler(log)]}):
+            response += chunk.content
+            response_message.clear()
+            with response_message:
+                ui.html(response)
+            ui.run_javascript('window.scrollTo(0, document.body.scrollHeight)')
+        message_container.remove(spinner)
 
     ui.add_css(r'a:link, a:visited {color: inherit !important; text-decoration: none; font-weight: 500}')
 
@@ -49,8 +39,7 @@ def main():
         chat_tab = ui.tab('Chat')
         logs_tab = ui.tab('Logs')
     with ui.tab_panels(tabs, value=chat_tab).classes('w-full max-w-2xl mx-auto flex-grow items-stretch'):
-        with ui.tab_panel(chat_tab).classes('items-stretch'):
-            chat_messages()
+        message_container = ui.tab_panel(chat_tab).classes('items-stretch')
         with ui.tab_panel(logs_tab):
             log = ui.log().classes('w-full h-full')
 
diff --git a/examples/chat_with_ai/requirements.txt b/examples/chat_with_ai/requirements.txt
index b8d4f304..d1a841bb 100644
--- a/examples/chat_with_ai/requirements.txt
+++ b/examples/chat_with_ai/requirements.txt
@@ -1,3 +1,3 @@
 langchain>=0.0.142
+langchain_openai
 nicegui
-openai
\ No newline at end of file
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/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/button_documentation.py b/website/documentation/content/button_documentation.py
index 2520dfa1..510f3b46 100644
--- a/website/documentation/content/button_documentation.py
+++ b/website/documentation/content/button_documentation.py
@@ -86,4 +86,33 @@ def toggle_button() -> None:
     ToggleButton('Toggle me')
 
 
+@doc.demo('Floating Action Button', '''
+    As described in the [Quasar documentation](https://quasar.dev/vue-components/floating-action-button),
+    a Floating Action Button (FAB) is simply a "page-sticky" with a button inside.
+    With the "fab" prop, the button will be rounded and gets a shadow.
+    Color can be freely chosen, but most often it is an accent color.
+''')
+def fab() -> None:
+    ui.colors(accent='#6AD4DD')
+    # with ui.page_sticky(x_offset=18, y_offset=18):
+    with ui.row().classes('w-full h-full justify-end items-end'):  # HIDE
+        ui.button(icon='home', on_click=lambda: ui.notify('home')) \
+            .props('fab color=accent')
+
+
+@doc.demo('Expandable Floating Action Button', '''
+    The [Quasar FAB (q-fab)](https://quasar.dev/vue-components/floating-action-button),
+    is a button that reveals multiple actions when clicked.
+    While it is not a separate element in NiceGUI, it can be easily created using the generic `ui.element`.
+''')
+def expandable_fab() -> None:
+    with ui.element('q-fab').props('icon=navigation color=green'):
+        ui.element('q-fab-action').props('icon=train color=green-5') \
+            .on('click', lambda: ui.notify('train'))
+        ui.element('q-fab-action').props('icon=sailing color=green-5') \
+            .on('click', lambda: ui.notify('boat'))
+        ui.element('q-fab-action').props('icon=rocket color=green-5') \
+            .on('click', lambda: ui.notify('rocket'))
+
+
 doc.reference(ui.button)
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/nicegui/testing/conftest.py b/nicegui/testing/conftest.py
index bb043103..109ba0b1 100644
--- a/nicegui/testing/conftest.py
+++ b/nicegui/testing/conftest.py
@@ -25,7 +25,7 @@ icecream.install()
 @pytest.fixture
 def chrome_options(chrome_options: webdriver.ChromeOptions) -> webdriver.ChromeOptions:
     """Configure the Chrome driver options."""
-    chrome_options.add_argument('disable-dev-shm-using')
+    chrome_options.add_argument('disable-dev-shm-usage')
     chrome_options.add_argument('no-sandbox')
     chrome_options.add_argument('headless')
     # check if we are running on GitHub Actions
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('/')