Skip to content
Draft
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
ff8b88e
Browser Data Store: Retransmission Avoidance
evnchn May 26, 2025
5da0689
~40% (30KB -> 18KB) less data transmitted for documentation page
evnchn May 26, 2025
34e6e3e
mypy minor fix: no assign str to dict
evnchn May 26, 2025
554e5a4
mypy minor fix: Optional[str] as value type
evnchn May 26, 2025
85d1a88
missing docstring
evnchn May 26, 2025
2ad9e3c
Cached elements working! Apply `element.cache_name = 'myname'`
evnchn May 27, 2025
7ec2a62
mypy: oops messed up the type hint
evnchn May 27, 2025
ac312d8
Merge branch 'main' into browser-data-store
evnchn Jun 1, 2025
461241c
Move to `.cache(id)` method. id optional but still better to provide
evnchn Jun 1, 2025
6fb3957
`from_cache` partially working: works for labels, not for ui.table fo…
evnchn Jun 1, 2025
96fa7c3
Revert "`from_cache` partially working: works for labels, not for ui.…
evnchn Jun 2, 2025
23e9f37
Code cleanup from DeepSeek
evnchn Jun 2, 2025
087b2d9
Caching children elements + option to disable
evnchn Jun 6, 2025
b3416e6
Warn and do nothing, if the same cache name is used in the same page
evnchn Jun 6, 2025
f6513b8
Tests for fetch from browser data store
evnchn Jun 6, 2025
9c3fcfa
Use new paradigm: Drop `fetch_*` methods
evnchn Jun 8, 2025
348b548
drop 'side_tree_hierarchy'
evnchn Jun 8, 2025
ea574d8
Drop fetch_* test
evnchn Jun 8, 2025
6a41ec9
Cache `ui.tree` properly with `static_prop_keys`-defined caching stra…
evnchn Jun 9, 2025
e4cb5b6
Unify caches among happy face svg elements by excluding class from ca…
evnchn Jun 9, 2025
be3900a
Properly cache svgs
evnchn Jun 9, 2025
89c2d55
don't use compression for outbox (that's a topic for another PR)
evnchn Jun 9, 2025
04c7d28
Bugfix: Merge the props
evnchn Jun 11, 2025
6ecb1c5
Set `static_prop_keys` in the element itself
evnchn Jun 11, 2025
d11d62a
Merge branch 'main' into browser-data-store
evnchn Jun 16, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion nicegui/app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import urllib
from enum import Enum
from pathlib import Path
from typing import Any, Awaitable, Callable, Iterator, List, Optional, Union
from typing import Any, Awaitable, Callable, Dict, Iterator, List, Optional, Union

from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse
Expand Down Expand Up @@ -45,6 +45,7 @@ def __init__(self, **kwargs) -> None:
self._connect_handlers: List[Union[Callable[..., Any], Awaitable]] = []
self._disconnect_handlers: List[Union[Callable[..., Any], Awaitable]] = []
self._exception_handlers: List[Callable[..., Any]] = [log.exception]
self.browser_data_store: Dict[str, Optional[str]] = {}

@property
def is_starting(self) -> bool:
Expand Down
24 changes: 23 additions & 1 deletion nicegui/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from .dependencies import generate_resources
from .element import Element
from .favicon import get_favicon_url
from .helpers import hash_data_store_entry
from .javascript_request import JavaScriptRequest
from .logging import log
from .observables import ObservableDict
Expand Down Expand Up @@ -88,6 +89,8 @@ def __init__(self, page: page, *, request: Optional[Request]) -> None:

self._temporary_socket_id: Optional[str] = None

self.last_element_hashes: Dict[str, str] = {}

@property
def is_auto_index_client(self) -> bool:
"""Return True if this client is the auto-index client."""
Expand Down Expand Up @@ -128,8 +131,22 @@ def build_response(self, request: Request, status_code: int = 200) -> Response:
"""Build a FastAPI response for the client."""
self.outbox.updates.clear()
prefix = request.headers.get('X-Forwarded-Prefix', request.scope.get('root_path', ''))
for element in self.elements.values():
element._populate_browser_data_store_if_needed() # pylint: disable=protected-access
client_declared_data_store_entries_string: str = request.cookies.get('nicegui_data_store', '{}')
client_declared_data_store_entries: Dict[str, str] = json.loads(client_declared_data_store_entries_string)
filtered_browser_data_store: Dict[str, Optional[str]] = {
key: value
for key, value in core.app.browser_data_store.items()
if hash_data_store_entry(value) != client_declared_data_store_entries.get(key, '')
}
# value = None, for keys which the client declare contain, but not exist in the server's browser data store anymore
for key in client_declared_data_store_entries:
if key not in core.app.browser_data_store or core.app.browser_data_store[key] is None:
filtered_browser_data_store[key] = None
filtered_browser_data_store_string = json.dumps(filtered_browser_data_store)
elements = json.dumps({
id: element._to_dict() for id, element in self.elements.items() # pylint: disable=protected-access
id: element._to_dict(caching=True) for id, element in self.elements.items() # pylint: disable=protected-access
})
socket_io_js_query_params = {
**core.app.config.socket_io_js_query_params,
Expand Down Expand Up @@ -165,6 +182,11 @@ def build_response(self, request: Request, status_code: int = 200) -> Response:
'prefix': prefix,
'tailwind': core.app.config.tailwind,
'prod_js': core.app.config.prod_js,
'filtered_browser_data_store': filtered_browser_data_store_string.replace('&', '&')
.replace('<', '&lt;')
.replace('>', '&gt;')
.replace('`', '&#96;')
.replace('$', '&#36;'),
'socket_io_js_query_params': socket_io_js_query_params,
'socket_io_js_extra_headers': core.app.config.socket_io_js_extra_headers,
'socket_io_js_transports': core.app.config.socket_io_js_transports,
Expand Down
137 changes: 133 additions & 4 deletions nicegui/element.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,21 @@
import re
from copy import copy
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Dict, Iterator, List, Optional, Sequence, Union, cast
from typing import (
TYPE_CHECKING,
Any,
Callable,
ClassVar,
Dict,
Iterator,
List,
Optional,
Sequence,
Set,
Tuple,
Union,
cast,
)

from typing_extensions import Self

Expand All @@ -22,12 +36,15 @@
)
from .elements.mixins.visibility import Visibility
from .event_listener import EventListener
from .helpers import hash_data_store_entry
from .props import Props
from .slot import Slot
from .style import Style
from .tailwind import Tailwind
from .version import __version__

DYNAMIC_KEYS = {'events', 'children'}

if TYPE_CHECKING:
from .client import Client

Expand Down Expand Up @@ -80,11 +97,40 @@ def __init__(self, tag: Optional[str] = None, *, _client: Optional[Client] = Non
self.parent_slot = slot_stack[-1]
self.parent_slot.children.append(self)

self.static_cache_name: Optional[str] = None
self.hash_based_id: bool = False
self.cache_apply_to_child: bool = False
self.child_id_per_slot_name: Dict[str, int] = {}

self.dynamic_keys: Set[str] = DYNAMIC_KEYS.copy()
self.static_props_keys: Set[str] = set()

self.tailwind = Tailwind(self)

self.client.outbox.enqueue_update(self)
if self.parent_slot:
self.client.outbox.enqueue_update(self.parent_slot.parent)
parent = self.parent_slot.parent
self.client.outbox.enqueue_update(parent)
if parent.cache_apply_to_child:
slot_name = self.parent_slot.name
child_id = parent.child_id_per_slot_name.setdefault(slot_name, 0)
self.static_cache_name = f'{parent.cache_name}.{slot_name}:{child_id}'
parent.child_id_per_slot_name[slot_name] += 1
self.hash_based_id = parent.hash_based_id
self.cache_apply_to_child = True

@property
def cache_name(self) -> Optional[str]:
"""The name of the cache for this element.

It will be the static cache name if set, or the dynamic cache name derived from hash if automatic cache name is used.
Otherwise, it will be `None`.
"""
if self.static_cache_name is not None:
return self.static_cache_name
if self.hash_based_id:
return f'auto#{hash_data_store_entry(json.dumps(self._to_dict_internal()[0]))}'
return None

def __init_subclass__(cls, *,
component: Union[str, Path, None] = None,
Expand Down Expand Up @@ -206,8 +252,19 @@ def _collect_slot_dict(self) -> Dict[str, Any]:
if slot != self.default_slot
}

def _to_dict(self) -> Dict[str, Any]:
return {
def _populate_browser_data_store_if_needed(self) -> None:
"""Populate the browser data store with the element's data if needed."""
if self.cache_name is not None:
cache_content = json.dumps(self._to_dict_internal()[0])
if self.static_cache_name is not None and self.cache_name in self.client.last_element_hashes and \
hash_data_store_entry(cache_content) != self.client.last_element_hashes[self.cache_name]:
raise ValueError(f'More than one element, whose content differs, are using the same cache name "{self.cache_name}". '
'Please use a different cache name for each element or ensure that the content is the same.')
core.app.browser_data_store[self.cache_name] = cache_content
self.client.last_element_hashes[self.cache_name] = hash_data_store_entry(cache_content)

def _to_dict_internal(self) -> Tuple[Dict[str, Any], Dict[str, Any]]:
element_dict = {
'tag': self.tag,
**({'text': self._text} if self._text is not None else {}),
**{
Expand Down Expand Up @@ -236,6 +293,51 @@ def _to_dict(self) -> Dict[str, Any]:
},
}

props_dict_static = {
key: value
for key, value in self._props.items()
if key in self.static_props_keys
}

props_dict_dynamic = {
key: value
for key, value in self._props.items()
if key not in self.static_props_keys
}

element_dict_static = {
key: value
for key, value in element_dict.items()
if key not in self.dynamic_keys and key != 'props'
}
element_dict_static['props'] = props_dict_static

element_dict_dynamic = {
key: value
for key, value in element_dict.items()
if key in self.dynamic_keys and key != 'props'
}
element_dict_dynamic['props'] = props_dict_dynamic

return element_dict_static, element_dict_dynamic

def _to_dict(self, *, caching: bool = False) -> Dict[str, Any]:
element_dict_static, element_dict_dynamic = self._to_dict_internal()
if self.cache_name is None or not caching:
# return the normal un-filtered dict
merged_props = {**element_dict_static.get('props', {}), **element_dict_dynamic.get('props', {})}
return {
**element_dict_static,
**element_dict_dynamic,
'props': merged_props,
}
else:
# return the cached format
return {
'CACHE': self.cache_name,
**element_dict_dynamic,
}

@property
def classes(self) -> Classes[Self]:
"""The classes of the element."""
Expand Down Expand Up @@ -516,6 +618,10 @@ def _handle_delete(self) -> None:

This method can be overridden in subclasses to perform cleanup tasks.
"""
# Delete the cache, if the ID is hash-based.
# As the hash changes, the old key would be kept in memory otherwise.
if self.hash_based_id and self.cache_name is not None and self.cache_name in core.app.browser_data_store:
core.app.browser_data_store[self.cache_name] = None

@property
def is_deleted(self) -> bool:
Expand Down Expand Up @@ -560,3 +666,26 @@ def html_id(self) -> str:
*Added in version 2.16.0*
"""
return f'c{self.id}'

def cache(self, cache_name: Optional[str] = None, *, apply_to_child: bool = False) -> Self:
"""Cache the element in the browser data store.

:param cache_name: name of the cache entry (default: None, which uses the element's interal hash automatically)
:param apply_to_child: whether to apply the cache to all child elements as well (default: False)
"""
if cache_name is not None and ('@' in cache_name or '#' in cache_name or '.' in cache_name):
raise ValueError('Cache names must not contain "@", "#", or "." characters.')
self.static_cache_name = cache_name
self.hash_based_id = cache_name is None
self.cache_apply_to_child = apply_to_child
return self

def disable_cache(self) -> Self:
"""Disable caching for the element.

This is useful when if the element is inside a parent element that is cached with apply_to_child is True.
"""
self.static_cache_name = None
self.hash_based_id = False
self.cache_apply_to_child = False
return self
2 changes: 1 addition & 1 deletion nicegui/elements/html.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ def __init__(self, content: str = '', *, tag: str = 'div') -> None:
You can also use `ui.add_head_html` to add html code into the head of the document and `ui.add_body_html`
to add it into the body.

:param content: the HTML code to be displayed
:param content: (cached) the HTML code to be displayed
:param tag: the HTML tag to wrap the content in (default: "div")
"""
super().__init__(tag=tag, content=content)
1 change: 1 addition & 0 deletions nicegui/elements/mixins/content_element.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class ContentElement(Element):

def __init__(self, *, content: str, **kwargs: Any) -> None:
super().__init__(**kwargs)
self.static_props_keys.add(self.CONTENT_PROP)
self.content = content
self._handle_content_change(content)

Expand Down
3 changes: 2 additions & 1 deletion nicegui/elements/tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def __init__(self,

To use checkboxes and ``on_tick``, set the ``tick_strategy`` parameter to "leaf", "leaf-filtered" or "strict".

:param nodes: hierarchical list of node objects
:param nodes: (cached) hierarchical list of node objects
:param node_key: property name of each node object that holds its unique id (default: "id")
:param label_key: property name of each node object that holds its label (default: "label")
:param children_key: property name of each node object that holds its list of children (default: "children")
Expand All @@ -37,6 +37,7 @@ def __init__(self,
"""
super().__init__(tag='q-tree', filter=None)
self._props['nodes'] = nodes
self.static_props_keys.add('nodes')
self._props['node-key'] = node_key
self._props['label-key'] = label_key
self._props['children-key'] = children_key
Expand Down
7 changes: 7 additions & 0 deletions nicegui/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ def is_file(path: Optional[Union[str, Path]]) -> bool:
return False


def hash_data_store_entry(entry: Optional[str]) -> str:
"""Hashes the data store entry, which must be a string."""
if entry is None:
return ''
return hashlib.sha256(entry.encode('utf-8')).hexdigest()


def hash_file_path(path: Path, *, max_time: Optional[float] = None) -> str:
"""Hash the given path based on its string representation and optionally the last modification time of given files."""
hasher = hashlib.sha256(path.as_posix().encode())
Expand Down
Loading