Repository: zauberzeug/nicegui (python)

Issue:
Bindable Properties: RuntimeError: dictionary changed size during iteration
### Description

Hello,

currently we observe the following exception in different parts of our code:

```python
Traceback (most recent call last):
  [...]
  File "/opt/conda/envs/.../lib/python3.10/site-packages/nicegui/element.py", line 489, in delete
    self.parent_slot.parent.remove(self)
  File "/opt/conda/envs/.../lib/python3.10/site-packages/nicegui/element.py", line 481, in remove
    self.client.remove_elements(element.descendants(include_self=True))
  File "/opt/conda/envs/.../lib/python3.10/site-packages/nicegui/client.py", line 319, in remove_elements
    binding.remove(element_list)
  File "/opt/conda/envs/.../lib/python3.10/site-packages/nicegui/binding.py", line 203, in remove
    for (obj_id, name), finalizer in list(bindable_properties.items()):
RuntimeError: dictionary changed size during iteration
```

It is triggered if ``.delete()`` for an UI element is called or ``.refresh()`` for a refreshable UI element, but it does not happen every time when the two methods are called. It is important to note that those elements do not have any bindable properties, but there are other elements in the application that use bindable properties.

The exception occurs from time to time if there are many users working with the same NiceGUI application. At the moment there is no way to reproduce the issue.

I noticed that there were some changes in 2.11.1 that are related to line 203: https://github.com/zauberzeug/nicegui/pull/4122
Thus this issue might be related to adding ``weakref``, but I am not sure. We have not observe the issue before updating our service from 2.9.1 to 2.11.1.

What I can not understand is why the code below from ``binding.py`` can actually throw ```RuntimeError: dictionary changed size during iteration```:

```python
for (obj_id, name), finalizer in list(bindable_properties.items()):
    if obj_id in object_ids:
        del bindable_properties[(obj_id, name)]
        finalizer.detach()
```
The error must happen while ``bindable_properties.items()`` is running, but this would also mean that there is another thread modifying ```bindable_properties``` while iterating over the dictionary. As far as I know NiceGUI is based on async code and I can not see how a context switch is possible during an ``.items()`` call.

I know that there is the possibility that ```bindable_properties``` is modified outside the for loop in line 178:
```python
bindable_properties.setdefault(key, weakref.finalize(owner, lambda: bindable_properties.pop(key, None)))
```

But still I can not understand why the lambda method might be called while iterating over ``bindable_properties.items()``

It would be great if someone could help to fix this issue. Many thanks in advance.

NiceGUI Version:
2.11.1


Pull request description:
Avoid RuntimeError while iterating over bindable properties
This PR tries to fix #4419 by disabling the GC while copying the dictionary of bindable properties. Calling `list()` iterates over the dictionary, which can case a "RuntimeError: dictionary changed size during iteration" if the garbage collector runs at the same time and causes the finalizers to pop items from the dictionary.

The PR is still a draft because there is another iteration over `bindable_properties` in the `_make_copyable` function. So technically there is still a chance for the RuntimeError.

1. So we might need to disable the GC once again in `_make_copyable`.
2. Or shouldn't the finalizers pop themselves, but add to a set of obsolete keys instead?
3. Alternatively we could try to use some kind of lock.

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 77dc5d5a..f1136157 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -5,7 +5,7 @@ default_stages: [commit]
 
 repos:
   - repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: v0.8.1
+    rev: v0.11.3
     hooks:
       - id: ruff
         args: [--fix]
diff --git a/nicegui/air.py b/nicegui/air.py
index c6f40b5d..157b606d 100644
--- a/nicegui/air.py
+++ b/nicegui/air.py
@@ -70,13 +70,28 @@ class Air:
                 new_js_object = match.group(1).decode().rstrip('}') + ", 'fly_instance_id' : '" + instance_id + "'}"
                 content = content.replace(match.group(0), f'const query = {new_js_object}'.encode())
             compressed = gzip.compress(content)
-            response.headers.update({'content-encoding': 'gzip', 'content-length': str(len(compressed))})
-            return {
+            total_size = len(compressed)
+            response.headers.update({'content-encoding': 'gzip', 'content-length': str(total_size)})
+            result = {
                 'status_code': response.status_code,
                 'headers': response.headers.multi_items(),
                 'content': compressed,
             }
 
+            # NOTE: chunk large responses to stay within the SocketIO limit
+            if total_size > 1_000_000:
+                async def chunk_iterator() -> AsyncIterator[bytes]:
+                    chunk_size = 512 * 1024
+                    for i in range(0, total_size, chunk_size):
+                        yield compressed[i:i + chunk_size]
+                stream_id = str(uuid4())
+                self.streams[stream_id] = Stream(data=chunk_iterator(), response=response)
+                result['stream_id'] = stream_id
+                result['total_size'] = total_size
+                del result['content']
+
+            return result
+
         @self.relay.on('range-request')
         async def _handle_range_request(data: Dict[str, Any]) -> Dict[str, Any]:
             headers: Dict[str, Any] = data['headers']
@@ -156,7 +171,8 @@ class Air:
 
         @self.relay.on('connect_error')
         async def _handle_connect_error(data) -> None:
-            self.log.debug(f'Connection error: {data}')
+            message = data.get('message', 'Unknown error') if isinstance(data, dict) else data
+            self.log.warning(f'Connection error: {message}')
 
         @self.relay.on('event')
         def _handle_event(data: Dict[str, Any]) -> None:
diff --git a/nicegui/binding.py b/nicegui/binding.py
index 2c2bf8fe..1f32629f 100644
--- a/nicegui/binding.py
+++ b/nicegui/binding.py
@@ -3,6 +3,7 @@ from __future__ import annotations
 import asyncio
 import copyreg
 import dataclasses
+import threading
 import time
 import weakref
 from collections import defaultdict
@@ -35,6 +36,7 @@ MAX_PROPAGATION_TIME = 0.01
 
 bindings: DefaultDict[Tuple[int, str], List] = defaultdict(list)
 bindable_properties: Dict[Tuple[int, str], weakref.finalize] = {}
+bindable_properties_lock = threading.Lock()
 active_links: List[Tuple[Any, str, Any, str, Callable[[Any], Any]]] = []
 
 TC = TypeVar('TC', bound=type)
@@ -179,7 +181,11 @@ class BindableProperty:
             return
         setattr(owner, '___' + self.name, value)
         key = (id(owner), str(self.name))
-        bindable_properties.setdefault(key, weakref.finalize(owner, lambda: bindable_properties.pop(key, None)))
+
+        def remove_bindable_property():
+            with bindable_properties_lock:
+                bindable_properties.pop(key, None)
+        bindable_properties.setdefault(key, weakref.finalize(owner, remove_bindable_property))
         _propagate(owner, self.name)
         if value_changed and self._change_handler is not None:
             self._change_handler(owner, value)
@@ -204,10 +210,11 @@ def remove(objects: Iterable[Any]) -> None:
         ]
         if not binding_list:
             del bindings[key]
-    for (obj_id, name), finalizer in list(bindable_properties.items()):
-        if obj_id in object_ids:
-            del bindable_properties[(obj_id, name)]
-            finalizer.detach()
+    with bindable_properties_lock:
+        for (obj_id, name), finalizer in list(bindable_properties.items()):
+            if obj_id in object_ids:
+                del bindable_properties[(obj_id, name)]
+                finalizer.detach()
 
 
 def reset() -> None:
diff --git a/nicegui/elements/chat_message.py b/nicegui/elements/chat_message.py
index 4aad032e..f55ca19b 100644
--- a/nicegui/elements/chat_message.py
+++ b/nicegui/elements/chat_message.py
@@ -1,11 +1,11 @@
 import html
 from typing import List, Optional, Union
 
-from ..element import Element
 from .html import Html
+from .mixins.label_element import LabelElement
 
 
-class ChatMessage(Element):
+class ChatMessage(LabelElement):
 
     def __init__(self,
                  text: Union[str, List[str]] = ..., *,  # type: ignore
@@ -28,7 +28,7 @@ class ChatMessage(Element):
         :param sent: render as a sent message (so from current user) (default: False)
         :param text_html: render text as HTML (default: False)
         """
-        super().__init__('q-chat-message')
+        super().__init__(tag='q-chat-message', label=label)
 
         if text is ...:
             text = []
@@ -40,8 +40,6 @@ class ChatMessage(Element):
 
         if name is not None:
             self._props['name'] = name
-        if label is not None:
-            self._props['label'] = label
         if stamp is not None:
             self._props['stamp'] = stamp
         if avatar is not None:
diff --git a/nicegui/elements/color_input.py b/nicegui/elements/color_input.py
index 9c6a7158..6c5c5d83 100644
--- a/nicegui/elements/color_input.py
+++ b/nicegui/elements/color_input.py
@@ -4,10 +4,11 @@ from ..events import Handler, ValueChangeEventArguments
 from .button import Button as button
 from .color_picker import ColorPicker as color_picker
 from .mixins.disableable_element import DisableableElement
+from .mixins.label_element import LabelElement
 from .mixins.value_element import ValueElement
 
 
-class ColorInput(ValueElement, DisableableElement):
+class ColorInput(LabelElement, ValueElement, DisableableElement):
     LOOPBACK = False
 
     def __init__(self,
@@ -27,9 +28,7 @@ class ColorInput(ValueElement, DisableableElement):
         :param on_change: callback to execute when the value changes
         :param preview: change button background to selected color (default: False)
         """
-        super().__init__(tag='q-input', value=value, on_value_change=on_change)
-        if label is not None:
-            self._props['label'] = label
+        super().__init__(tag='q-input', label=label, value=value, on_value_change=on_change)
         if placeholder is not None:
             self._props['placeholder'] = placeholder
 
diff --git a/nicegui/elements/input.py b/nicegui/elements/input.py
index ac487d3d..c6b60a00 100644
--- a/nicegui/elements/input.py
+++ b/nicegui/elements/input.py
@@ -3,10 +3,11 @@ from typing import Any, List, Optional, Union
 from ..events import Handler, ValueChangeEventArguments
 from .icon import Icon
 from .mixins.disableable_element import DisableableElement
+from .mixins.label_element import LabelElement
 from .mixins.validation_element import ValidationDict, ValidationElement, ValidationFunction
 
 
-class Input(ValidationElement, DisableableElement, component='input.js'):
+class Input(LabelElement, ValidationElement, DisableableElement, component='input.js'):
     VALUE_PROP: str = 'value'
     LOOPBACK = False
 
@@ -49,9 +50,7 @@ class Input(ValidationElement, DisableableElement, component='input.js'):
         :param autocomplete: optional list of strings for autocompletion
         :param validation: dictionary of validation rules or a callable that returns an optional error message (default: None for no validation)
         """
-        super().__init__(value=value, on_value_change=on_change, validation=validation)
-        if label is not None:
-            self._props['label'] = label
+        super().__init__(label=label, value=value, on_value_change=on_change, validation=validation)
         if placeholder is not None:
             self._props['placeholder'] = placeholder
         self._props['type'] = 'password' if password else 'text'
diff --git a/nicegui/elements/mixins/label_element.py b/nicegui/elements/mixins/label_element.py
new file mode 100644
index 00000000..3758b197
--- /dev/null
+++ b/nicegui/elements/mixins/label_element.py
@@ -0,0 +1,89 @@
+from typing import Any, Callable, Optional, cast
+
+from typing_extensions import Self
+
+from ...binding import BindableProperty, bind, bind_from, bind_to
+from ...element import Element
+
+
+class LabelElement(Element):
+    label = BindableProperty(
+        on_change=lambda sender, label: cast(Self, sender)._handle_label_change(label))  # pylint: disable=protected-access
+
+    def __init__(self, *, label: Optional[str], **kwargs: Any) -> None:
+        super().__init__(**kwargs)
+        self.label = label
+        if label is not None:
+            self._props['label'] = label
+
+    def bind_label_to(self,
+                      target_object: Any,
+                      target_name: str = 'label',
+                      forward: Callable[..., Any] = lambda x: x,
+                      ) -> Self:
+        """Bind the label of this element to the target object's target_name property.
+
+        The binding works one way only, from this element to the target.
+        The update happens immediately and whenever a value changes.
+
+        :param target_object: The object to bind to.
+        :param target_name: The name of the property to bind to.
+        :param forward: A function to apply to the value before applying it to the target.
+        """
+        bind_to(self, 'label', target_object, target_name, forward)
+        return self
+
+    def bind_label_from(self,
+                        target_object: Any,
+                        target_name: str = 'label',
+                        backward: Callable[..., Any] = lambda x: x,
+                        ) -> Self:
+        """Bind the label of this element from the target object's target_name property.
+
+        The binding works one way only, from the target to this element.
+        The update happens immediately and whenever a value changes.
+
+        :param target_object: The object to bind from.
+        :param target_name: The name of the property to bind from.
+        :param backward: A function to apply to the value before applying it to this element.
+        """
+        bind_from(self, 'label', target_object, target_name, backward)
+        return self
+
+    def bind_label(self,
+                   target_object: Any,
+                   target_name: str = 'label', *,
+                   forward: Callable[..., Any] = lambda x: x,
+                   backward: Callable[..., Any] = lambda x: x,
+                   ) -> Self:
+        """Bind the label of this element to the target object's target_name property.
+
+        The binding works both ways, from this element to the target and from the target to this element.
+        The update happens immediately and whenever a value changes.
+        The backward binding takes precedence for the initial synchronization.
+
+        :param target_object: The object to bind to.
+        :param target_name: The name of the property to bind to.
+        :param forward: A function to apply to the value before applying it to the target.
+        :param backward: A function to apply to the value before applying it to this element.
+        """
+        bind(self, 'label', target_object, target_name, forward=forward, backward=backward)
+        return self
+
+    def set_label(self, label: Optional[str]) -> None:
+        """Set the label of this element.
+
+        :param label: The new label.
+        """
+        self.label = label
+
+    def _handle_label_change(self, label: Optional[str]) -> None:
+        """Called when the label of this element changes.
+
+        :param label: The new label.
+        """
+        if label is None:
+            del self._props['label']
+        else:
+            self._props['label'] = label
+        self.update()
diff --git a/nicegui/elements/number.py b/nicegui/elements/number.py
index 39f3bccc..553e7b1d 100644
--- a/nicegui/elements/number.py
+++ b/nicegui/elements/number.py
@@ -2,10 +2,11 @@ from typing import Any, Optional, Union
 
 from ..events import GenericEventArguments, Handler, ValueChangeEventArguments
 from .mixins.disableable_element import DisableableElement
+from .mixins.label_element import LabelElement
 from .mixins.validation_element import ValidationDict, ValidationElement, ValidationFunction
 
 
-class Number(ValidationElement, DisableableElement):
+class Number(LabelElement, ValidationElement, DisableableElement):
     LOOPBACK = False
 
     def __init__(self,
@@ -46,10 +47,8 @@ class Number(ValidationElement, DisableableElement):
         :param validation: dictionary of validation rules or a callable that returns an optional error message (default: None for no validation)
         """
         self.format = format
-        super().__init__(tag='q-input', value=value, on_value_change=on_change, validation=validation)
+        super().__init__(tag='q-input', label=label, value=value, on_value_change=on_change, validation=validation)
         self._props['type'] = 'number'
-        if label is not None:
-            self._props['label'] = label
         if placeholder is not None:
             self._props['placeholder'] = placeholder
         if min is not None:
diff --git a/nicegui/elements/select.py b/nicegui/elements/select.py
index c008fe77..6322111c 100644
--- a/nicegui/elements/select.py
+++ b/nicegui/elements/select.py
@@ -5,10 +5,11 @@ from typing import Any, Callable, Dict, Iterator, List, Literal, Optional, Union
 from ..events import GenericEventArguments, Handler, ValueChangeEventArguments
 from .choice_element import ChoiceElement
 from .mixins.disableable_element import DisableableElement
+from .mixins.label_element import LabelElement
 from .mixins.validation_element import ValidationDict, ValidationElement, ValidationFunction
 
 
-class Select(ValidationElement, ChoiceElement, DisableableElement, component='select.js'):
+class Select(LabelElement, ValidationElement, ChoiceElement, DisableableElement, component='select.js'):
 
     def __init__(self,
                  options: Union[List, Dict], *,
@@ -60,9 +61,7 @@ class Select(ValidationElement, ChoiceElement, DisableableElement, component='se
                 value = [value]
             else:
                 value = value[:]  # NOTE: avoid modifying the original list which could be the list of options (#3014)
-        super().__init__(options=options, value=value, on_change=on_change, validation=validation)
-        if label is not None:
-            self._props['label'] = label
+        super().__init__(label=label, options=options, value=value, on_change=on_change, validation=validation)
         if isinstance(key_generator, Generator):
             next(key_generator)  # prime the key generator, prepare it to receive the first value
         self.key_generator = key_generator
diff --git a/nicegui/elements/tabs.py b/nicegui/elements/tabs.py
index fce5b7b6..5d714f44 100644
--- a/nicegui/elements/tabs.py
+++ b/nicegui/elements/tabs.py
@@ -6,6 +6,7 @@ from ..context import context
 from ..events import Handler, ValueChangeEventArguments
 from .mixins.disableable_element import DisableableElement
 from .mixins.icon_element import IconElement
+from .mixins.label_element import LabelElement
 from .mixins.value_element import ValueElement
 
 
@@ -29,7 +30,7 @@ class Tabs(ValueElement):
         return value.props['name'] if isinstance(value, (Tab, TabPanel)) else value
 
 
-class Tab(IconElement, DisableableElement):
+class Tab(LabelElement, IconElement, DisableableElement):
 
     def __init__(self, name: str, label: Optional[str] = None, icon: Optional[str] = None) -> None:
         """Tab
@@ -41,9 +42,10 @@ class Tab(IconElement, DisableableElement):
         :param label: label of the tab (default: `None`, meaning the same as `name`)
         :param icon: icon of the tab (default: `None`)
         """
-        super().__init__(tag='q-tab', icon=icon)
+        if label is None:
+            label = name
+        super().__init__(tag='q-tab', label=label, icon=icon)
         self._props['name'] = name
-        self._props['label'] = label if label is not None else name
         self.tabs = context.slot.parent
 
 
diff --git a/nicegui/elements/upload.py b/nicegui/elements/upload.py
index d3bbcffd..cdd5c2fa 100644
--- a/nicegui/elements/upload.py
+++ b/nicegui/elements/upload.py
@@ -7,15 +7,17 @@ from typing_extensions import Self
 from ..events import Handler, MultiUploadEventArguments, UiEventArguments, UploadEventArguments, handle_event
 from ..nicegui import app
 from .mixins.disableable_element import DisableableElement
+from .mixins.label_element import LabelElement
 
 
-class Upload(DisableableElement, component='upload.js'):
+class Upload(LabelElement, DisableableElement, component='upload.js'):
 
     def __init__(self, *,
                  multiple: bool = False,
                  max_file_size: Optional[int] = None,
                  max_total_size: Optional[int] = None,
                  max_files: Optional[int] = None,
+                 on_begin_upload: Optional[Handler[UiEventArguments]] = None,
                  on_upload: Optional[Handler[UploadEventArguments]] = None,
                  on_multi_upload: Optional[Handler[MultiUploadEventArguments]] = None,
                  on_rejected: Optional[Handler[UiEventArguments]] = None,
@@ -26,19 +28,29 @@ class Upload(DisableableElement, component='upload.js'):
 
         Based on Quasar's `QUploader <https://quasar.dev/vue-components/uploader>`_ component.
 
+        Upload event handlers are called in the following order:
+
+        1. ``on_begin_upload``: The client begins uploading one or more files to the server.
+        2. ``on_upload``: The upload of an individual file is complete.
+        3. ``on_multi_upload``: The upload of all selected files is complete.
+
+        The following event handler is already called during the file selection process:
+
+        - ``on_rejected``: One or more files have been rejected.
+
         :param multiple: allow uploading multiple files at once (default: `False`)
         :param max_file_size: maximum file size in bytes (default: `0`)
         :param max_total_size: maximum total size of all files in bytes (default: `0`)
         :param max_files: maximum number of files (default: `0`)
+        :param on_begin_upload: callback to execute when upload begins  (*added in version 2.14.0*)
         :param on_upload: callback to execute for each uploaded file
         :param on_multi_upload: callback to execute after multiple files have been uploaded
-        :param on_rejected: callback to execute for each rejected file
+        :param on_rejected: callback to execute when one or more files have been rejected during file selection
         :param label: label for the uploader (default: `''`)
         :param auto_upload: automatically upload files when they are selected (default: `False`)
         """
-        super().__init__()
+        super().__init__(label=label)
         self._props['multiple'] = multiple
-        self._props['label'] = label
         self._props['auto-upload'] = auto_upload
         self._props['url'] = f'/_nicegui/client/{self.client.id}/upload/{self.id}'
 
@@ -54,11 +66,14 @@ class Upload(DisableableElement, component='upload.js'):
         if multiple and on_multi_upload:
             self._props['batch'] = True
 
+        self._begin_upload_handlers = [on_begin_upload] if on_begin_upload else []
         self._upload_handlers = [on_upload] if on_upload else []
         self._multi_upload_handlers = [on_multi_upload] if on_multi_upload else []
 
         @app.post(self._props['url'])
         async def upload_route(request: Request) -> Dict[str, str]:
+            for begin_upload_handler in self._begin_upload_handlers:
+                handle_event(begin_upload_handler, UiEventArguments(sender=self, client=self.client))
             form = await request.form()
             uploads = [cast(UploadFile, data) for data in form.values()]
             self.handle_uploads(uploads)
@@ -91,6 +106,11 @@ class Upload(DisableableElement, component='upload.js'):
         for multi_upload_handler in self._multi_upload_handlers:
             handle_event(multi_upload_handler, multi_upload_args)
 
+    def on_begin_upload(self, callback: Handler[UiEventArguments]) -> Self:
+        """Add a callback to be invoked when the upload begins."""
+        self._begin_upload_handlers.append(callback)
+        return self
+
     def on_upload(self, callback: Handler[UploadEventArguments]) -> Self:
         """Add a callback to be invoked when a file is uploaded."""
         self._upload_handlers.append(callback)
@@ -102,7 +122,7 @@ class Upload(DisableableElement, component='upload.js'):
         return self
 
     def on_rejected(self, callback: Handler[UiEventArguments]) -> Self:
-        """Add a callback to be invoked when a file is rejected."""
+        """Add a callback to be invoked when one or more files have been rejected during file selection."""
         self.on('rejected', lambda: handle_event(callback, UiEventArguments(sender=self, client=self.client)), args=[])
         return self
 
diff --git a/nicegui/page.py b/nicegui/page.py
index 3a69fbc1..b7b9760a 100644
--- a/nicegui/page.py
+++ b/nicegui/page.py
@@ -123,10 +123,13 @@ class page:
                     with client:
                         return await result
                 task = background_tasks.create(wait_for_result())
+                task_wait_for_connection = background_tasks.create(
+                    client._waiting_for_connection.wait(),  # pylint: disable=protected-access
+                )
                 try:
                     await asyncio.wait([
                         task,
-                        asyncio.create_task(client._waiting_for_connection.wait()),  # pylint: disable=protected-access
+                        task_wait_for_connection,
                     ], timeout=self.response_timeout, return_when=asyncio.FIRST_COMPLETED)
                 except asyncio.TimeoutError as e:
                     raise TimeoutError(f'Response not ready after {self.response_timeout} seconds') from e
diff --git a/nicegui/page_layout.py b/nicegui/page_layout.py
index b869c687..7c23a0da 100644
--- a/nicegui/page_layout.py
+++ b/nicegui/page_layout.py
@@ -132,8 +132,9 @@ class Drawer(ValueElement, default_classes='nicegui-drawer'):
 
         if value is None and not self.client.is_auto_index_client:
             async def _request_value() -> None:
-                js_code = f'!getHtmlElement({self.id}).parentElement.classList.contains("q-layout--prevent-focus")'
-                self.value = await context.client.run_javascript(js_code)
+                self.value = await context.client.run_javascript(
+                    f'!getHtmlElement({self.id}).parentElement.classList.contains("q-layout--prevent-focus")  // __IS_DRAWER_OPEN__'
+                )
             self.client.on_connect(_request_value)
 
     def toggle(self) -> None:
diff --git a/poetry.lock b/poetry.lock
index ca120994..053a62e4 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -3778,14 +3778,14 @@ wsproto = ">=0.14"
 
 [[package]]
 name = "typing-extensions"
-version = "4.12.2"
+version = "4.13.1"
 description = "Backported and Experimental Type Hints for Python 3.8+"
 optional = false
 python-versions = ">=3.8"
 groups = ["main", "dev"]
 files = [
-    {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
-    {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
+    {file = "typing_extensions-4.13.1-py3-none-any.whl", hash = "sha256:4b6cf02909eb5495cfbc3f6e8fd49217e6cc7944e145cdda8caa3734777f9e69"},
+    {file = "typing_extensions-4.13.1.tar.gz", hash = "sha256:98795af00fb9640edec5b8e31fc647597b4691f099ad75f469a2616be1a76dff"},
 ]
 
 [[package]]
diff --git a/website/documentation/content/user_documentation.py b/website/documentation/content/user_documentation.py
index 33b9bced..22948b47 100644
--- a/website/documentation/content/user_documentation.py
+++ b/website/documentation/content/user_documentation.py
@@ -248,11 +248,46 @@ def multiple_users():
         ''')
 
 
+doc.text('Simulate JavasScript', '''
+    The `User` class has a `javascript_rules` dictionary to simulate JavaScript execution.
+    The key is a compiled regular expression and the value is a function that returns the JavaScript response.
+    The function will be called with the match object of the regular expression on the JavaScript command.
+
+    *Added in version 2.14.0*
+''')
+
+
+@doc.ui
+def simulate_javascript():
+    with ui.row().classes('gap-4 items-stretch'):
+        with python_window(classes='w-[500px]', title='some UI code'):
+            ui.markdown('''
+                ```python
+                @ui.page('/')
+                async def page():
+                    await context.client.connected()
+                    date = await ui.run_javascript('Math.sqrt(1764)')
+                    ui.label(date)
+                ```
+            ''')
+
+        with python_window(classes='w-[500px]', title='user assertions'):
+            ui.markdown('''
+                ```python
+                user.javascript_rules[re.compile(r'Math.sqrt\\((\\d+)\\)')] = \\
+                    lambda match: int(match.group(1))**0.5
+                await user.open('/')
+                await user.should_see('42')
+                ```
+            ''')
+
+
 doc.text('Comparison with the screen fixture', '''
     By cutting out the browser, test execution becomes much faster than the [`screen` fixture](/documentation/screen).
-    Of course, some features like screenshots or browser-specific behavior are not available.
     See our [pytests example](https://github.com/zauberzeug/nicegui/tree/main/examples/pytests)
     which implements the same tests with both fixtures.
+    Of course, some features like screenshots or browser-specific behavior are not available,
+    but in most cases the speed of the `user` fixture makes it the first choice.
 ''')
 
 doc.reference(User, title='User Reference')


Test patch:
diff --git a/nicegui/testing/user.py b/nicegui/testing/user.py
index 5ae2a7f2..e046e042 100644
--- a/nicegui/testing/user.py
+++ b/nicegui/testing/user.py
@@ -2,7 +2,7 @@ from __future__ import annotations
 
 import asyncio
 import re
-from typing import Any, List, Optional, Set, Type, TypeVar, Union, overload
+from typing import Any, Callable, Dict, List, Optional, Set, Type, TypeVar, Union, overload
 from uuid import uuid4
 
 import httpx
@@ -11,6 +11,7 @@ import socketio
 from nicegui import Client, ElementFilter, ui
 from nicegui.element import Element
 from nicegui.nicegui import _on_handshake
+from nicegui.outbox import Message
 
 from .user_download import UserDownload
 from .user_interaction import UserInteraction
@@ -35,6 +36,9 @@ class User:
         self.navigate = UserNavigate(self)
         self.notify = UserNotify()
         self.download = UserDownload(self)
+        self.javascript_rules: Dict[re.Pattern, Callable[[re.Match], Any]] = {
+            re.compile('.*__IS_DRAWER_OPEN__'): lambda _: True,  # see https://github.com/zauberzeug/nicegui/issues/4508
+        }
 
     @property
     def _client(self) -> Client:
@@ -69,8 +73,26 @@ class User:
         self.back_history.append(path)
         if clear_forward_history:
             self.forward_history.clear()
+        self._patch_outbox_emit_function()
         return self.client
 
+    def _patch_outbox_emit_function(self) -> None:
+        original_emit = self._client.outbox._emit
+
+        async def simulated_emit(message: Message) -> None:
+            await original_emit(message)
+            _, type_, data = message
+            if type_ == 'run_javascript':
+                for rule, result in self.javascript_rules.items():
+                    match = rule.match(data['code'])
+                    if match:
+                        self._client.handle_javascript_response({
+                            'request_id': data['request_id'],
+                            'result': result(match),
+                        })
+
+        self._client.outbox._emit = simulated_emit  # type: ignore
+
     @overload
     async def should_see(self,
                          target: Union[str, Type[T]],
diff --git a/nicegui/testing/user_plugin.py b/nicegui/testing/user_plugin.py
index 0ca74207..d5d695e0 100644
--- a/nicegui/testing/user_plugin.py
+++ b/nicegui/testing/user_plugin.py
@@ -38,6 +38,7 @@ def prepare_simulated_auto_index_client(request):
 @pytest.fixture
 async def user(nicegui_reset_globals,  # noqa: F811, pylint: disable=unused-argument
                prepare_simulated_auto_index_client,  # pylint: disable=unused-argument
+               caplog: pytest.LogCaptureFixture,
                request: pytest.FixtureRequest,
                ) -> AsyncGenerator[User, None]:
     """Create a new user fixture."""
@@ -48,6 +49,9 @@ async def user(nicegui_reset_globals,  # noqa: F811, pylint: disable=unused-argu
     ui.navigate = Navigate()
     ui.notify = notify
     ui.download = download
+    logs = caplog.get_records('call')
+    if logs:
+        pytest.fail('There were unexpected logs.', pytrace=False)
 
 
 @pytest.fixture
diff --git a/tests/test_user_simulation.py b/tests/test_user_simulation.py
index c499335c..3f3bc308 100644
--- a/tests/test_user_simulation.py
+++ b/tests/test_user_simulation.py
@@ -1,4 +1,5 @@
 import csv
+import re
 from io import BytesIO
 from typing import Callable, Dict, Type, Union
 
@@ -540,3 +541,27 @@ async def test_typing_to_disabled_element(user: User) -> None:
     assert target.value == initial_value
     await user.should_see(initial_value)
     await user.should_not_see(given_new_input)
+
+
+async def test_drawer(user: User):
+    @ui.page('/')
+    def test_page():
+        with ui.left_drawer() as drawer:
+            ui.label('Hello')
+        ui.label().bind_text_from(drawer, 'value', lambda v: f'Drawer: {v}')
+
+    await user.open('/')
+    await user.should_see('Hello')
+    await user.should_see('Drawer: True')
+
+
+async def test_run_javascript(user: User):
+    @ui.page('/')
+    async def page():
+        await ui.context.client.connected()
+        date = await ui.run_javascript('Math.sqrt(1764)')
+        ui.label(date)
+
+    user.javascript_rules[re.compile(r'Math.sqrt\((\d+)\)')] = lambda match: int(match.group(1))**0.5
+    await user.open('/')
+    await user.should_see('42')