Repository: zauberzeug/nicegui
Pull request #4822 (2025-06-01 09:35:40)

Perf: Crackdown on style recals
### Motivation

Discord, someone was complaining that NiceGUI website slow and takes up over 20% of the CPU: https://discord.com/channels/1089836369431498784/1357872936694059078

### Implementation

- Drop the following which constantly causes style recals:
  - Drop the `star-pulse` animation (no replacement so far)
  - Drop the use of `ui.spinner` in favor of custom-made loading GIF

### Progress

- [x] I chose a meaningful title that completes the sentence: "If applied, this PR will..."
- [x] The implementation is complete.
- [x] Pytests have been added (or are not necessary).
- [x] Documentation has been added (or is not necessary).

### Results

Considering `/documentation/section_text_elements`

#### Before

<img width="404" alt="{BE3FE9C2-A4DC-4CEA-8D8A-316B9ED30C4A}" src="https://github.com/user-attachments/assets/df960e73-b83a-4012-a365-383366bfa201" />

#### After

<img width="404" alt="{8312AF0F-BED1-4868-86A3-9939AA05F627}" src="https://github.com/user-attachments/assets/50d580e7-e9ba-4754-a5e8-421e15118be8" />

### Script for generating the loading GIF

Surprised to find no royalty-free loading GIF. Proceed to made myself one:

```py
from PIL import Image, ImageDraw

import numpy as np

def create_loading_gif(size=128):
    frames = []
    num_dots = 8
    radius = size / 2.5
    center = (size // 2, size // 2)
    color = (88, 152, 212)

    for i in range(num_dots):
        frame = Image.new('RGBA', (size, size), (255, 255, 255, 0))
        draw = ImageDraw.Draw(frame)

        for j in range(num_dots):
            angle = 2 * np.pi * j / num_dots
            x = center[0] + radius * np.cos(angle)
            y = center[1] + radius * np.sin(angle)
            opacity = 255 if (0 < (j - i)%num_dots <= 3) else 0
            print(f"Drawing dot {j} at angle {angle:.2f} radians with opacity {color + (opacity,)}")
            draw.ellipse([(x - 10, y - 10), (x + 10, y + 10)], fill=color + (opacity,))

        frames.append(frame)

    # Save as GIF
    frames[0].save('loading.gif', save_all=True, append_images=frames[1:], optimize=False, duration=100, loop=0, disposal=2)

create_loading_gif()
```


EmberLightVFX: Feels like this should be reported to Quasar too 😄
evnchn: IMO No, since style recals are unavoidable if you use CSS animations 

Patch:
diff --git a/.cursor/rules/coding.mdc b/.cursor/rules/coding.mdc
index 3344454e..0e558be5 100644
--- a/.cursor/rules/coding.mdc
+++ b/.cursor/rules/coding.mdc
@@ -3,26 +3,14 @@ description: writing code
 globs: *.py
 alwaysApply: false
 ---
-# Coding Style
+# Coding Guidelines for AI Assistants
 
-- Always prefer simple solutions
-- Avoid having files over 200-300 lines of code. Refactor at that point
-- Use single quotes for strings in Python
-- Use f-strings wherever possible for better readability (except in performance-critical sections which should be marked with "NOTE:" comments)
-- Follow autopep8 formatting with 120 character line length
-- Each sentence in documentation should be on a new line
-- Use ruff for linting and code checks
+**Primary Reference**: @CONTRIBUTING.md
 
-# Workflow
+## AI-Specific Notes
 
-- Never overwrite .env file without first asking and confirming
-- Avoid duplication of code whenever possible, which means checking for other areas of the codebase that might already have similar code and functionality
-- Be careful to only make changes that are requested or are well understood and related to the change being requested
-- When fixing an issue or bug, do not introduce a new pattern or technology without first exhausting all options for the existing implementation. And if you finally do this, make sure to remove the old implementation afterwards so we don't have duplicate logic
-- Keep the codebase very clean and organized
-- Write tests for new features
-- Run tests before submitting any changes
-- Format code using autopep8 before submitting changes
-- Use pre-commit hooks to ensure coding style compliance
-- When adding new features, include corresponding tests
-- For documentation changes, ensure each sentence is on a new line
+- Never overwrite .env files without explicit confirmation
+- When making changes, always check for existing similar functionality in the codebase to avoid duplication
+- Prefer editing existing files over creating new ones unless absolutely necessary
+- For files over 200-300 lines, suggest refactoring opportunities
+- When fixing bugs, exhaust existing implementation options before introducing new patterns
diff --git a/.dockerignore b/.dockerignore
index df0fcc5c..4f1d0cef 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -23,3 +23,4 @@ tests/.pytest_cache/**/*
 **/.DS_Store
 **/.idea
 **/.vscode
+package-lock.json
diff --git a/.gitignore b/.gitignore
index 2007e552..51c7d3be 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,5 @@ venv
 *.sqlite*
 .DS_Store
 .env
+node_modules/
+package-lock.json
diff --git a/.syncignore b/.syncignore
index bb62f4ec..b278e2d5 100644
--- a/.syncignore
+++ b/.syncignore
@@ -18,3 +18,5 @@ venv
 tests/
 .git/
 .mypy_cache/
+node_modules/
+package-lock.json
diff --git a/.vscode/settings.json b/.vscode/settings.json
index d7eaf213..b7e771e4 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -2,6 +2,9 @@
   "editor.defaultFormatter": "esbenp.prettier-vscode",
   "editor.formatOnSave": true,
   "prettier.printWidth": 120,
+  "[json]": {
+    "prettier.printWidth": 1
+  },
   "python.testing.pytestArgs": ["."],
   "python.testing.pytestEnabled": true,
   "python.testing.unittestEnabled": false,
diff --git a/CITATION.cff b/CITATION.cff
index a5d231b2..152ef7c7 100644
--- a/CITATION.cff
+++ b/CITATION.cff
@@ -8,8 +8,8 @@ authors:
     given-names: Rodja
     orcid: https://orcid.org/0009-0009-4735-6227
 title: "NiceGUI: Web-based user interfaces with Python. The nice way."
-version: 2.18.0
-date-released: "2025-05-23"
+version: 2.20.0
+date-released: "2025-06-13"
 url: https://github.com/zauberzeug/nicegui
 doi: 10.5281/zenodo.7785516
 identifiers:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 75646d8c..d533b05a 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -19,6 +19,11 @@ By participating, you agree to abide by its terms.
 We are excited that you want to contribute code to NiceGUI.
 We're always looking for bug fixes, performance improvements, and new features.
 
+### AI Assistant Integration
+
+This project is designed to work well with AI assistants like Cursor, GitHub Copilot, and others.
+The `.cursor/rules/` directory contains guidelines specifically for AI assistants that complement this contributing guide.
+
 ## Setup
 
 ### Dev Container
@@ -46,7 +51,7 @@ Thereby enabling you to use your local version of NiceGUI in other projects.
 To run the tests you need some additional setup which is described in [tests/README.md](https://github.com/zauberzeug/nicegui/blob/main/tests/README.md).
 
 There is no special Python version required for development.
-At Zauberzeug we mainly use 3.11.
+At Zauberzeug we mainly use 3.12.
 This means we sometimes miss some incompatibilities with older versions.
 But these will hopefully be uncovered by the GitHub Actions (see below).
 Also we use the 3.8 Docker container described below to verify compatibility in cases of uncertainty.
@@ -91,6 +96,33 @@ There are cases where one or the other arrangement of, e.g., function arguments
 Then we like the flexibility to either put all arguments on separate lines or only put the lengthy event handler
 on a second line and leave the other arguments as they are.
 
+### Style Principles
+
+- Always prefer simple solutions
+- Avoid having files over 200-300 lines of code. Refactor at that point
+- Use single quotes for strings in Python, double quotes in JavaScript
+- Use f-strings wherever possible for better readability (except in performance-critical sections which should be marked with "NOTE:" comments)
+- Follow autopep8 formatting with 120 character line length
+- Each sentence in documentation should be on a new line
+- Use ruff for linting and code checks
+
+### Workflow Guidelines
+
+- Always simplify the implementation as much as possible:
+  - Avoid duplication of code whenever possible, which means checking for other areas of the codebase that might already have similar code and functionality
+  - Remove obsolete code
+  - Ensure the code is not too complicated
+  - Strive to have minimal maintenance burden and self explanatory code without the need of additional comments
+- Be careful to only make changes that are requested or are well understood and related to the change being requested
+- When fixing an issue or bug, do not introduce a new pattern or technology without first exhausting all options for the existing implementation. And if you finally do this, make sure to remove the old implementation afterwards so we don't have duplicate logic
+- Keep the codebase very clean and organized
+- Write tests for new features
+- Run tests before submitting any changes
+- Format code using autopep8 before submitting changes
+- Use pre-commit hooks to ensure coding style compliance
+- When adding new features, include corresponding tests
+- For documentation, ensure each sentence is on a new line
+
 ### Linting
 
 We use [pre-commit](https://github.com/pre-commit/pre-commit) to make sure the coding style is enforced.
@@ -194,6 +226,31 @@ To list your addition on the website itself, you can use the `example_link` func
 ["In-depth examples" section heading](https://github.com/zauberzeug/nicegui/blob/8a86d2064f8f4464f3819ac5c6763a2cb2d0e990/main.py#L242).
 The title should match the example folder name when [snake case converted](https://github.com/zauberzeug/nicegui/blob/8a86d2064f8f4464f3819ac5c6763a2cb2d0e990/website/style.py#L31).
 
+## Node dependencies
+
+We use [npm.json](https://github.com/zauberzeug/nicegui/blob/main/npm.json) to pin the versions of the node dependencies.
+They are usually updated by the maintainers during major releases.
+
+To update or add new dependencies, we follow these steps:
+
+1. Use `npm` or other derivative tools, modify the `package.json` file with new versions or add dependencies.
+   - We **never** modify the version in `package.json` or `npm.json` directly, since there may be conflicts between versions.
+   - `npx npm-check-updates -u --target semver` is a good starting point for updating dependencies.
+2. Run `npm install` to install the new dependencies.
+   Any conflicts in installation will be caught at this moment.
+3. Run `pin_versions.py` to update the `npm.json` file.
+4. Run `npm.py` to download dependencies into the `nicegui/static/` and `nicegui/elements/lib/` directories.
+5. Remember to commit `package.json` in addition to the new dependencies and `DEPENDENCIES.md`, such that Dependabot can catch outdated dependencies with security issues.
+
+Apart from updating Node libraries, the following tools are used to update other resources:
+
+- scripts/codemirror/bundle.bash for managing the CodeMirror dependency
+- fetch_google_fonts.py for fetching the Google Fonts
+- fetch_languages.py to update the list of supported languages in language.py
+- fetch_milestone.py to prepare the release notes for a given milestone
+- fetch_sponsors.py to update the list of sponsors on the website and in the README.md file
+- fetch_tailwind.py to update NiceGUI's Tailwind API
+
 ## Pull requests
 
 To get started, fork the repository on GitHub, clone it somewhere on your filesystem, commit and push your changes,
diff --git a/development.dockerfile b/development.dockerfile
index c4c17109..67dbf0e5 100644
--- a/development.dockerfile
+++ b/development.dockerfile
@@ -2,11 +2,10 @@ FROM python:3.8-slim
 
 RUN apt update && apt install curl build-essential -y
 
-# We use Poetry for dependency management
-RUN curl -sSL https://install.python-poetry.org | python3 - && \
-    cd /usr/local/bin && \
-    ln -s ~/.local/bin/poetry && \
-    poetry config virtualenvs.create false
+# keep toolchain compatible with Python 3.8
+RUN python -m pip install --no-cache-dir --upgrade "virtualenv<20.31" "pip<25" "setuptools<80" wheel \
+    && python -m pip install --no-cache-dir "poetry~=1.8" \
+    && poetry config virtualenvs.create false
 
 WORKDIR /app
 
diff --git a/nicegui/air.py b/nicegui/air.py
index 5d2ab77d..42e0e86f 100644
--- a/nicegui/air.py
+++ b/nicegui/air.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import asyncio
 import gzip
 import json
 import logging
@@ -225,7 +226,7 @@ class Air:
         self.connecting = True
         try:
             if self.relay.connected:
-                await helpers.wait_for(self.disconnect(), timeout=5)
+                await asyncio.wait_for(self.disconnect(), timeout=5)
             self.log.debug('Connecting...')
             await self.relay.connect(
                 f'{RELAY_HOST}?device_token={self.token}',
diff --git a/nicegui/app/app.py b/nicegui/app/app.py
index 88a3286b..aceec190 100644
--- a/nicegui/app/app.py
+++ b/nicegui/app/app.py
@@ -45,6 +45,7 @@ class App(FastAPI):
         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._page_exception_handler: Optional[Callable[..., Any]] = None
 
     @property
     def is_starting(self) -> bool:
@@ -132,6 +133,17 @@ class App(FastAPI):
             if helpers.is_coroutine_function(handler):
                 background_tasks.create(result, name=f'exception {handler.__name__}')
 
+    def on_page_exception(self, handler: Callable) -> None:
+        """Called when an exception occurs in a page and allows to create a custom error page.
+
+        The callback can accept an optional ``Exception`` as argument.
+        All UI elements created in the callback are displayed on the error page.
+        Asynchronous handlers are currently not supported.
+
+        *Added in version 2.20.0*
+        """
+        self._page_exception_handler = handler
+
     def shutdown(self) -> None:
         """Shut down NiceGUI.
 
@@ -285,6 +297,7 @@ class App(FastAPI):
         self._connect_handlers.clear()
         self._disconnect_handlers.clear()
         self._exception_handlers[:] = [log.exception]
+        self.config = AppConfig()
 
     @staticmethod
     def clients(path: str) -> Iterator[Client]:
diff --git a/nicegui/app/app_config.py b/nicegui/app/app_config.py
index 579d586d..3ba09189 100644
--- a/nicegui/app/app_config.py
+++ b/nicegui/app/app_config.py
@@ -23,6 +23,11 @@ class AppConfig:
                 'skipHijack': False,
             },
         })
+    vue_config_script: str = r'''
+        app.use(Quasar, {config: vue_config});
+        Quasar.lang.set(Quasar.lang[language.replace('-', '')]);
+        Quasar.Dark.set(dark === None ? "auto" : dark);
+    '''
 
     reload: bool = field(init=False)
     title: str = field(init=False)
@@ -33,6 +38,7 @@ class AppConfig:
     binding_refresh_interval: float = field(init=False)
     reconnect_timeout: float = field(init=False)
     message_history_length: int = field(init=False)
+    cache_control_directives: str = field(init=False)
     tailwind: bool = field(init=False)
     prod_js: bool = field(init=False)
     show_welcome_message: bool = field(init=False)
@@ -49,6 +55,7 @@ class AppConfig:
                        binding_refresh_interval: float,
                        reconnect_timeout: float,
                        message_history_length: int,
+                       cache_control_directives: str = 'public, max-age=31536000, immutable, stale-while-revalidate=31536000',
                        tailwind: bool,
                        prod_js: bool,
                        show_welcome_message: bool,
@@ -63,6 +70,7 @@ class AppConfig:
         self.binding_refresh_interval = binding_refresh_interval
         self.reconnect_timeout = reconnect_timeout
         self.message_history_length = message_history_length
+        self.cache_control_directives = cache_control_directives
         self.tailwind = tailwind
         self.prod_js = prod_js
         self.show_welcome_message = show_welcome_message
diff --git a/nicegui/background_tasks.py b/nicegui/background_tasks.py
index e8da51a2..e38bbb1c 100644
--- a/nicegui/background_tasks.py
+++ b/nicegui/background_tasks.py
@@ -5,7 +5,7 @@ import asyncio
 import weakref
 from typing import Any, Awaitable, Callable, Coroutine, Dict, Set, TypeVar
 
-from . import core, helpers
+from . import core
 from .logging import log
 
 running_tasks: Set[asyncio.Task] = set()
@@ -22,7 +22,7 @@ def create(coroutine: Awaitable, *, name: str = 'unnamed task') -> asyncio.Task:
     See https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task.
     """
     assert core.loop is not None
-    coroutine = coroutine if asyncio.iscoroutine(coroutine) else helpers.wait_for(coroutine, None)
+    coroutine = coroutine if asyncio.iscoroutine(coroutine) else asyncio.wait_for(coroutine, None)
     task: asyncio.Task = core.loop.create_task(coroutine, name=name)
     task.add_done_callback(_handle_task_result)
     running_tasks.add(task)
@@ -91,7 +91,7 @@ async def teardown() -> None:
         if tasks:
             await asyncio.sleep(0)  # NOTE: ensure the loop can cancel the tasks before it shuts down
             try:
-                await helpers.wait_for(asyncio.gather(*tasks, return_exceptions=True), timeout=2.0)
+                await asyncio.wait_for(asyncio.gather(*tasks, return_exceptions=True), timeout=2.0)
             except asyncio.TimeoutError:
                 log.error('Could not cancel %s tasks within timeout: %s',
                           len(tasks),
diff --git a/nicegui/binding.py b/nicegui/binding.py
index 095cc84a..361aeb84 100644
--- a/nicegui/binding.py
+++ b/nicegui/binding.py
@@ -36,9 +36,9 @@ MAX_PROPAGATION_TIME = 0.01
 
 propagation_visited: ContextVar[Optional[Set[Tuple[int, str]]]] = ContextVar('propagation_visited', default=None)
 
-bindings: DefaultDict[Tuple[int, str], List] = defaultdict(list)
+bindings: DefaultDict[Tuple[int, str], List[Tuple[Any, Any, str, Optional[Callable[[Any], Any]]]]] = defaultdict(list)
 bindable_properties: weakref.WeakValueDictionary[Tuple[int, str], Any] = weakref.WeakValueDictionary()
-active_links: List[Tuple[Any, str, Any, str, Callable[[Any], Any]]] = []
+active_links: List[Tuple[Any, str, Any, str, Optional[Callable[[Any], Any]]]] = []
 
 TC = TypeVar('TC', bound=type)
 T = TypeVar('T')
@@ -93,7 +93,8 @@ def _refresh_step() -> None:
         for link in active_links:
             (source_obj, source_name, target_obj, target_name, transform) = link
             if _has_attribute(source_obj, source_name):
-                value = transform(_get_attribute(source_obj, source_name))
+                source_value = _get_attribute(source_obj, source_name)
+                value = transform(source_value) if transform else source_value
                 if not _has_attribute(target_obj, target_name) or _get_attribute(target_obj, target_name) != value:
                     _set_attribute(target_obj, target_name, value)
                     _propagate(target_obj, target_name)
@@ -117,13 +118,14 @@ def _propagate(source_obj: Any, source_name: str) -> None:
             if (id(target_obj), target_name) in visited:
                 continue
 
-            target_value = transform(source_value)
+            target_value = transform(source_value) if transform else source_value
             if not _has_attribute(target_obj, target_name) or _get_attribute(target_obj, target_name) != target_value:
                 _set_attribute(target_obj, target_name, target_value)
                 _propagate(target_obj, target_name)
 
 
-def bind_to(self_obj: Any, self_name: str, other_obj: Any, other_name: str, forward: Callable[[Any], Any]) -> None:
+def bind_to(self_obj: Any, self_name: str, other_obj: Any, other_name: str,
+            forward: Optional[Callable[[Any], Any]] = None) -> None:
     """Bind the property of one object to the property of another object.
 
     The binding works one way only, from the first object to the second.
@@ -133,7 +135,7 @@ def bind_to(self_obj: Any, self_name: str, other_obj: Any, other_name: str, forw
     :param self_name: The name of the property to bind from.
     :param other_obj: The object to bind to.
     :param other_name: The name of the property to bind to.
-    :param forward: A function to apply to the value before applying it.
+    :param forward: A function to apply to the value before applying it (default: identity).
     """
     bindings[(id(self_obj), self_name)].append((self_obj, other_obj, other_name, forward))
     if (id(self_obj), self_name) not in bindable_properties:
@@ -141,7 +143,8 @@ def bind_to(self_obj: Any, self_name: str, other_obj: Any, other_name: str, forw
     _propagate(self_obj, self_name)
 
 
-def bind_from(self_obj: Any, self_name: str, other_obj: Any, other_name: str, backward: Callable[[Any], Any]) -> None:
+def bind_from(self_obj: Any, self_name: str, other_obj: Any, other_name: str,
+              backward: Optional[Callable[[Any], Any]] = None) -> None:
     """Bind the property of one object from the property of another object.
 
     The binding works one way only, from the second object to the first.
@@ -151,7 +154,7 @@ def bind_from(self_obj: Any, self_name: str, other_obj: Any, other_name: str, ba
     :param self_name: The name of the property to bind to.
     :param other_obj: The object to bind from.
     :param other_name: The name of the property to bind from.
-    :param backward: A function to apply to the value before applying it.
+    :param backward: A function to apply to the value before applying it (default: identity).
     """
     bindings[(id(other_obj), other_name)].append((other_obj, self_obj, self_name, backward))
     if (id(other_obj), other_name) not in bindable_properties:
@@ -160,7 +163,8 @@ def bind_from(self_obj: Any, self_name: str, other_obj: Any, other_name: str, ba
 
 
 def bind(self_obj: Any, self_name: str, other_obj: Any, other_name: str, *,
-         forward: Callable[[Any], Any] = lambda x: x, backward: Callable[[Any], Any] = lambda x: x) -> None:
+         forward: Optional[Callable[[Any], Any]] = None,
+         backward: Optional[Callable[[Any], Any]] = None) -> None:
     """Bind the property of one object to the property of another object.
 
     The binding works both ways, from the first object to the second and from the second to the first.
@@ -171,8 +175,8 @@ def bind(self_obj: Any, self_name: str, other_obj: Any, other_name: str, *,
     :param self_name: The name of the first property to bind.
     :param other_obj: The second object to bind.
     :param other_name: The name of the second property to bind.
-    :param forward: A function to apply to the value before applying it to the second object.
-    :param backward: A function to apply to the value before applying it to the first object.
+    :param forward: A function to apply to the value before applying it to the second object (default: identity).
+    :param backward: A function to apply to the value before applying it to the first object (default: identity).
     """
     bind_from(self_obj, self_name, other_obj, other_name, backward=backward)
     bind_to(self_obj, self_name, other_obj, other_name, forward=forward)
diff --git a/nicegui/client.py b/nicegui/client.py
index 7b11d4c6..0600b341 100644
--- a/nicegui/client.py
+++ b/nicegui/client.py
@@ -155,7 +155,8 @@ class Client:
                 'imports': json.dumps(imports),
                 'js_imports': '\n'.join(js_imports),
                 'js_imports_urls': js_imports_urls,
-                'quasar_config': json.dumps(core.app.config.quasar_config),
+                'vue_config': json.dumps(core.app.config.quasar_config),
+                'vue_config_script': core.app.config.vue_config_script,
                 'title': self.resolve_title(),
                 'viewport': self.page.resolve_viewport(),
                 'favicon_url': get_favicon_url(self.page, prefix),
diff --git a/nicegui/elements/aggrid.js b/nicegui/elements/aggrid.js
index ce444184..141f4704 100644
--- a/nicegui/elements/aggrid.js
+++ b/nicegui/elements/aggrid.js
@@ -42,6 +42,7 @@ export default {
         checkboxRenderer: CheckboxRenderer,
       };
 
+      this.gridOptions.onGridReady = (params) => this.handle_event("gridReady", params);
       this.api = agGrid.createGrid(this.$el, this.gridOptions);
       this.api.addGlobalListener(this.handle_event);
     },
@@ -54,7 +55,7 @@ export default {
       return runMethod(this.api.getRowNode(row_id), name, args);
     },
     handle_event(type, args) {
-      if ((type === "gridReady" || type === "gridSizeChanged") && this.auto_size_columns) {
+      if (type === "gridSizeChanged" && this.auto_size_columns) {
         this.api.sizeColumnsToFit();
       }
       this.$emit(type, {
diff --git a/nicegui/elements/codemirror.py b/nicegui/elements/codemirror.py
index 368d871f..d64e168c 100644
--- a/nicegui/elements/codemirror.py
+++ b/nicegui/elements/codemirror.py
@@ -301,11 +301,11 @@ class CodeMirror(ValueElement, DisableableElement, component='codemirror.js', de
         return self._props['theme']
 
     @theme.setter
-    def theme(self, theme: str) -> None:
+    def theme(self, theme: SUPPORTED_THEMES) -> None:
         self._props['theme'] = theme
         self.update()
 
-    def set_theme(self, theme: str) -> None:
+    def set_theme(self, theme: SUPPORTED_THEMES) -> None:
         """Sets the theme of the editor."""
         self._props['theme'] = theme
         self.update()
@@ -321,11 +321,11 @@ class CodeMirror(ValueElement, DisableableElement, component='codemirror.js', de
         return self._props['language']
 
     @language.setter
-    def language(self, language: Optional[str]) -> None:
+    def language(self, language: Optional[SUPPORTED_LANGUAGES] = None) -> None:
         self._props['language'] = language
         self.update()
 
-    def set_language(self, language: Optional[str]) -> None:
+    def set_language(self, language: Optional[SUPPORTED_LANGUAGES] = None) -> None:
         """Sets the language of the editor (case-insensitive)."""
         self._props['language'] = language
         self.update()
diff --git a/nicegui/elements/mermaid.js b/nicegui/elements/mermaid.js
index 8634ab2b..c4e75a23 100644
--- a/nicegui/elements/mermaid.js
+++ b/nicegui/elements/mermaid.js
@@ -1,5 +1,8 @@
 import mermaid from "mermaid";
 
+let is_running = false;
+const queue = [];
+
 export default {
   template: `<div></div>`,
   data: () => ({
@@ -21,18 +24,25 @@ export default {
     async update(content) {
       if (this.last_content === content) return;
       this.last_content = content;
-      try {
-        const { svg, bindFunctions } = await mermaid.render(this.$el.id + "_mermaid", content);
-        this.$el.innerHTML = svg;
-        bindFunctions?.(this.$el);
-      } catch (error) {
-        const { svg, bindFunctions } = await mermaid.render(this.$el.id + "_mermaid", "error");
-        this.$el.innerHTML = svg;
-        bindFunctions?.(this.$el);
-        const mermaidErrorFormat = { str: error.message, message: error.message, hash: error.name, error };
-        console.error(mermaidErrorFormat);
-        this.$emit("error", mermaidErrorFormat);
+      queue.push({ element: this.$el, content: content });
+      if (is_running) return;
+      is_running = true;
+      while (queue.length) {
+        const { element, content } = queue.shift();
+        try {
+          const { svg, bindFunctions } = await mermaid.render(element.id + "_mermaid", content);
+          element.innerHTML = svg;
+          bindFunctions?.(element);
+        } catch (error) {
+          const { svg, bindFunctions } = await mermaid.render(element.id + "_mermaid", "error");
+          element.innerHTML = svg;
+          bindFunctions?.(element);
+          const mermaidErrorFormat = { str: error.message, message: error.message, hash: error.name, error };
+          console.error(mermaidErrorFormat);
+          this.$emit("error", mermaidErrorFormat);
+        }
       }
+      is_running = false;
     },
   },
   props: {
diff --git a/nicegui/elements/mixins/content_element.py b/nicegui/elements/mixins/content_element.py
index 10016051..419fdedd 100644
--- a/nicegui/elements/mixins/content_element.py
+++ b/nicegui/elements/mixins/content_element.py
@@ -1,4 +1,4 @@
-from typing import Any, Callable, cast
+from typing import Any, Callable, Optional, cast
 
 from typing_extensions import Self
 
@@ -19,7 +19,7 @@ class ContentElement(Element):
     def bind_content_to(self,
                         target_object: Any,
                         target_name: str = 'content',
-                        forward: Callable[..., Any] = lambda x: x,
+                        forward: Optional[Callable[[Any], Any]] = None,
                         ) -> Self:
         """Bind the content of this element to the target object's target_name property.
 
@@ -28,7 +28,7 @@ class ContentElement(Element):
 
         :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 forward: A function to apply to the value before applying it to the target (default: identity).
         """
         bind_to(self, 'content', target_object, target_name, forward)
         return self
@@ -36,7 +36,7 @@ class ContentElement(Element):
     def bind_content_from(self,
                           target_object: Any,
                           target_name: str = 'content',
-                          backward: Callable[..., Any] = lambda x: x,
+                          backward: Optional[Callable[[Any], Any]] = None,
                           ) -> Self:
         """Bind the content of this element from the target object's target_name property.
 
@@ -45,7 +45,7 @@ class ContentElement(Element):
 
         :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.
+        :param backward: A function to apply to the value before applying it to this element (default: identity).
         """
         bind_from(self, 'content', target_object, target_name, backward)
         return self
@@ -53,8 +53,8 @@ class ContentElement(Element):
     def bind_content(self,
                      target_object: Any,
                      target_name: str = 'content', *,
-                     forward: Callable[..., Any] = lambda x: x,
-                     backward: Callable[..., Any] = lambda x: x,
+                     forward: Optional[Callable[[Any], Any]] = None,
+                     backward: Optional[Callable[[Any], Any]] = None,
                      ) -> Self:
         """Bind the content of this element to the target object's target_name property.
 
@@ -64,8 +64,8 @@ class ContentElement(Element):
 
         :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.
+        :param forward: A function to apply to the value before applying it to the target (default: identity).
+        :param backward: A function to apply to the value before applying it to this element (default: identity).
         """
         bind(self, 'content', target_object, target_name, forward=forward, backward=backward)
         return self
diff --git a/nicegui/elements/mixins/disableable_element.py b/nicegui/elements/mixins/disableable_element.py
index 84b69258..1f936e76 100644
--- a/nicegui/elements/mixins/disableable_element.py
+++ b/nicegui/elements/mixins/disableable_element.py
@@ -1,4 +1,4 @@
-from typing import Any, Callable, cast
+from typing import Any, Callable, Optional, cast
 
 from typing_extensions import Self
 
@@ -33,7 +33,7 @@ class DisableableElement(Element):
     def bind_enabled_to(self,
                         target_object: Any,
                         target_name: str = 'enabled',
-                        forward: Callable[..., Any] = lambda x: x,
+                        forward: Optional[Callable[[Any], Any]] = None,
                         ) -> Self:
         """Bind the enabled state of this element to the target object's target_name property.
 
@@ -42,7 +42,7 @@ class DisableableElement(Element):
 
         :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 forward: A function to apply to the value before applying it to the target (default: identity).
         """
         bind_to(self, 'enabled', target_object, target_name, forward)
         return self
@@ -50,7 +50,7 @@ class DisableableElement(Element):
     def bind_enabled_from(self,
                           target_object: Any,
                           target_name: str = 'enabled',
-                          backward: Callable[..., Any] = lambda x: x,
+                          backward: Optional[Callable[[Any], Any]] = None,
                           ) -> Self:
         """Bind the enabled state of this element from the target object's target_name property.
 
@@ -59,7 +59,7 @@ class DisableableElement(Element):
 
         :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.
+        :param backward: A function to apply to the value before applying it to this element (default: identity).
         """
         bind_from(self, 'enabled', target_object, target_name, backward)
         return self
@@ -67,8 +67,8 @@ class DisableableElement(Element):
     def bind_enabled(self,
                      target_object: Any,
                      target_name: str = 'enabled', *,
-                     forward: Callable[..., Any] = lambda x: x,
-                     backward: Callable[..., Any] = lambda x: x,
+                     forward: Optional[Callable[[Any], Any]] = None,
+                     backward: Optional[Callable[[Any], Any]] = None,
                      ) -> Self:
         """Bind the enabled state of this element to the target object's target_name property.
 
@@ -78,8 +78,8 @@ class DisableableElement(Element):
 
         :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.
+        :param forward: A function to apply to the value before applying it to the target (default: identity).
+        :param backward: A function to apply to the value before applying it to this element (default: identity).
         """
         bind(self, 'enabled', target_object, target_name, forward=forward, backward=backward)
         return self
diff --git a/nicegui/elements/mixins/filter_element.py b/nicegui/elements/mixins/filter_element.py
index cb219c20..df94441d 100644
--- a/nicegui/elements/mixins/filter_element.py
+++ b/nicegui/elements/mixins/filter_element.py
@@ -19,7 +19,7 @@ class FilterElement(Element):
     def bind_filter_to(self,
                        target_object: Any,
                        target_name: str = 'filter',
-                       forward: Callable[..., Any] = lambda x: x,
+                       forward: Optional[Callable[[Any], Any]] = None,
                        ) -> Self:
         """Bind the filter of this element to the target object's target_name property.
 
@@ -28,7 +28,7 @@ class FilterElement(Element):
 
         :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 forward: A function to apply to the value before applying it to the target (default: identity).
         """
         bind_to(self, 'filter', target_object, target_name, forward)
         return self
@@ -36,7 +36,7 @@ class FilterElement(Element):
     def bind_filter_from(self,
                          target_object: Any,
                          target_name: str = 'filter',
-                         backward: Callable[..., Any] = lambda x: x,
+                         backward: Optional[Callable[[Any], Any]] = None,
                          ) -> Self:
         """Bind the filter of this element from the target object's target_name property.
 
@@ -45,7 +45,7 @@ class FilterElement(Element):
 
         :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.
+        :param backward: A function to apply to the value before applying it to this element (default: identity).
         """
         bind_from(self, 'filter', target_object, target_name, backward)
         return self
@@ -53,8 +53,8 @@ class FilterElement(Element):
     def bind_filter(self,
                     target_object: Any,
                     target_name: str = 'filter', *,
-                    forward: Callable[..., Any] = lambda x: x,
-                    backward: Callable[..., Any] = lambda x: x,
+                    forward: Optional[Callable[[Any], Any]] = None,
+                    backward: Optional[Callable[[Any], Any]] = None,
                     ) -> Self:
         """Bind the filter of this element to the target object's target_name property.
 
@@ -64,8 +64,8 @@ class FilterElement(Element):
 
         :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.
+        :param forward: A function to apply to the value before applying it to the target (default: identity).
+        :param backward: A function to apply to the value before applying it to this element (default: identity).
         """
         bind(self, 'filter', target_object, target_name, forward=forward, backward=backward)
         return self
diff --git a/nicegui/elements/mixins/icon_element.py b/nicegui/elements/mixins/icon_element.py
index 028742d6..80fe625b 100644
--- a/nicegui/elements/mixins/icon_element.py
+++ b/nicegui/elements/mixins/icon_element.py
@@ -19,7 +19,7 @@ class IconElement(Element):
     def bind_icon_to(self,
                      target_object: Any,
                      target_name: str = 'icon',
-                     forward: Callable[..., Any] = lambda x: x,
+                     forward: Optional[Callable[[Any], Any]] = None,
                      ) -> Self:
         """Bind the icon of this element to the target object's target_name property.
 
@@ -28,7 +28,7 @@ class IconElement(Element):
 
         :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 forward: A function to apply to the value before applying it to the target (default: identity).
         """
         bind_to(self, 'icon', target_object, target_name, forward)
         return self
@@ -36,7 +36,7 @@ class IconElement(Element):
     def bind_icon_from(self,
                        target_object: Any,
                        target_name: str = 'icon',
-                       backward: Callable[..., Any] = lambda x: x,
+                       backward: Optional[Callable[[Any], Any]] = None,
                        ) -> Self:
         """Bind the icon of this element from the target object's target_name property.
 
@@ -45,7 +45,7 @@ class IconElement(Element):
 
         :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.
+        :param backward: A function to apply to the value before applying it to this element (default: identity).
         """
         bind_from(self, 'icon', target_object, target_name, backward)
         return self
@@ -53,8 +53,8 @@ class IconElement(Element):
     def bind_icon(self,
                   target_object: Any,
                   target_name: str = 'icon', *,
-                  forward: Callable[..., Any] = lambda x: x,
-                  backward: Callable[..., Any] = lambda x: x,
+                  forward: Optional[Callable[[Any], Any]] = None,
+                  backward: Optional[Callable[[Any], Any]] = None,
                   ) -> Self:
         """Bind the icon of this element to the target object's target_name property.
 
@@ -64,8 +64,8 @@ class IconElement(Element):
 
         :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.
+        :param forward: A function to apply to the value before applying it to the target (default: identity).
+        :param backward: A function to apply to the value before applying it to this element (default: identity).
         """
         bind(self, 'icon', target_object, target_name, forward=forward, backward=backward)
         return self
diff --git a/nicegui/elements/mixins/label_element.py b/nicegui/elements/mixins/label_element.py
index 3758b197..0c2c8791 100644
--- a/nicegui/elements/mixins/label_element.py
+++ b/nicegui/elements/mixins/label_element.py
@@ -19,7 +19,7 @@ class LabelElement(Element):
     def bind_label_to(self,
                       target_object: Any,
                       target_name: str = 'label',
-                      forward: Callable[..., Any] = lambda x: x,
+                      forward: Optional[Callable[[Any], Any]] = None,
                       ) -> Self:
         """Bind the label of this element to the target object's target_name property.
 
@@ -28,7 +28,7 @@ class LabelElement(Element):
 
         :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 forward: A function to apply to the value before applying it to the target (default: identity).
         """
         bind_to(self, 'label', target_object, target_name, forward)
         return self
@@ -36,7 +36,7 @@ class LabelElement(Element):
     def bind_label_from(self,
                         target_object: Any,
                         target_name: str = 'label',
-                        backward: Callable[..., Any] = lambda x: x,
+                        backward: Optional[Callable[[Any], Any]] = None,
                         ) -> Self:
         """Bind the label of this element from the target object's target_name property.
 
@@ -45,7 +45,7 @@ class LabelElement(Element):
 
         :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.
+        :param backward: A function to apply to the value before applying it to this element (default: identity).
         """
         bind_from(self, 'label', target_object, target_name, backward)
         return self
@@ -53,8 +53,8 @@ class LabelElement(Element):
     def bind_label(self,
                    target_object: Any,
                    target_name: str = 'label', *,
-                   forward: Callable[..., Any] = lambda x: x,
-                   backward: Callable[..., Any] = lambda x: x,
+                   forward: Optional[Callable[[Any], Any]] = None,
+                   backward: Optional[Callable[[Any], Any]] = None,
                    ) -> Self:
         """Bind the label of this element to the target object's target_name property.
 
@@ -64,8 +64,8 @@ class LabelElement(Element):
 
         :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.
+        :param forward: A function to apply to the value before applying it to the target (default: identity).
+        :param backward: A function to apply to the value before applying it to this element (default: identity).
         """
         bind(self, 'label', target_object, target_name, forward=forward, backward=backward)
         return self
diff --git a/nicegui/elements/mixins/name_element.py b/nicegui/elements/mixins/name_element.py
index ef597c2f..8e0bcc12 100644
--- a/nicegui/elements/mixins/name_element.py
+++ b/nicegui/elements/mixins/name_element.py
@@ -1,4 +1,4 @@
-from typing import Any, Callable, cast
+from typing import Any, Callable, Optional, cast
 
 from typing_extensions import Self
 
@@ -18,7 +18,7 @@ class NameElement(Element):
     def bind_name_to(self,
                      target_object: Any,
                      target_name: str = 'name',
-                     forward: Callable[..., Any] = lambda x: x,
+                     forward: Optional[Callable[[Any], Any]] = None,
                      ) -> Self:
         """Bind the name of this element to the target object's target_name property.
 
@@ -27,7 +27,7 @@ class NameElement(Element):
 
         :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 forward: A function to apply to the value before applying it to the target (default: identity).
         """
         bind_to(self, 'name', target_object, target_name, forward)
         return self
@@ -35,7 +35,7 @@ class NameElement(Element):
     def bind_name_from(self,
                        target_object: Any,
                        target_name: str = 'name',
-                       backward: Callable[..., Any] = lambda x: x,
+                       backward: Optional[Callable[[Any], Any]] = None,
                        ) -> Self:
         """Bind the name of this element from the target object's target_name property.
 
@@ -44,7 +44,7 @@ class NameElement(Element):
 
         :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.
+        :param backward: A function to apply to the value before applying it to this element (default: identity).
         """
         bind_from(self, 'name', target_object, target_name, backward)
         return self
@@ -52,8 +52,8 @@ class NameElement(Element):
     def bind_name(self,
                   target_object: Any,
                   target_name: str = 'name', *,
-                  forward: Callable[..., Any] = lambda x: x,
-                  backward: Callable[..., Any] = lambda x: x,
+                  forward: Optional[Callable[[Any], Any]] = None,
+                  backward: Optional[Callable[[Any], Any]] = None,
                   ) -> Self:
         """Bind the name of this element to the target object's target_name property.
 
@@ -63,8 +63,8 @@ class NameElement(Element):
 
         :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.
+        :param forward: A function to apply to the value before applying it to the target (default: identity).
+        :param backward: A function to apply to the value before applying it to this element (default: identity).
         """
         bind(self, 'name', target_object, target_name, forward=forward, backward=backward)
         return self
diff --git a/nicegui/elements/mixins/selectable_element.py b/nicegui/elements/mixins/selectable_element.py
index e29abaec..9dc1b938 100644
--- a/nicegui/elements/mixins/selectable_element.py
+++ b/nicegui/elements/mixins/selectable_element.py
@@ -39,7 +39,7 @@ class SelectableElement(Element):
     def bind_selected_to(self,
                          target_object: Any,
                          target_name: str = 'selected',
-                         forward: Callable[..., Any] = lambda x: x,
+                         forward: Optional[Callable[[Any], Any]] = None,
                          ) -> Self:
         """Bind the selection state of this element to the target object's target_name property.
 
@@ -48,7 +48,7 @@ class SelectableElement(Element):
 
         :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 forward: A function to apply to the value before applying it to the target (default: identity).
         """
         bind_to(self, 'selected', target_object, target_name, forward)
         return self
@@ -56,7 +56,7 @@ class SelectableElement(Element):
     def bind_selected_from(self,
                            target_object: Any,
                            target_name: str = 'selected',
-                           backward: Callable[..., Any] = lambda x: x,
+                           backward: Optional[Callable[[Any], Any]] = None,
                            ) -> Self:
         """Bind the selection state of this element from the target object's target_name property.
 
@@ -65,7 +65,7 @@ class SelectableElement(Element):
 
         :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.
+        :param backward: A function to apply to the value before applying it to this element (default: identity).
         """
         bind_from(self, 'selected', target_object, target_name, backward)
         return self
@@ -73,8 +73,8 @@ class SelectableElement(Element):
     def bind_selected(self,
                       target_object: Any,
                       target_name: str = 'selected', *,
-                      forward: Callable[..., Any] = lambda x: x,
-                      backward: Callable[..., Any] = lambda x: x,
+                      forward: Optional[Callable[[Any], Any]] = None,
+                      backward: Optional[Callable[[Any], Any]] = None,
                       ) -> Self:
         """Bind the selection state of this element to the target object's target_name property.
 
@@ -84,8 +84,8 @@ class SelectableElement(Element):
 
         :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.
+        :param forward: A function to apply to the value before applying it to the target (default: identity).
+        :param backward: A function to apply to the value before applying it to this element (default: identity).
         """
         bind(self, 'selected', target_object, target_name, forward=forward, backward=backward)
         return self
diff --git a/nicegui/elements/mixins/source_element.py b/nicegui/elements/mixins/source_element.py
index 3db8c368..3da1c756 100644
--- a/nicegui/elements/mixins/source_element.py
+++ b/nicegui/elements/mixins/source_element.py
@@ -24,7 +24,7 @@ class SourceElement(Element):
     def bind_source_to(self,
                        target_object: Any,
                        target_name: str = 'source',
-                       forward: Callable[..., Any] = lambda x: x,
+                       forward: Optional[Callable[[Any], Any]] = None,
                        ) -> Self:
         """Bind the source of this element to the target object's target_name property.
 
@@ -33,7 +33,7 @@ class SourceElement(Element):
 
         :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 forward: A function to apply to the value before applying it to the target (default: identity).
         """
         bind_to(self, 'source', target_object, target_name, forward)
         return self
@@ -41,7 +41,7 @@ class SourceElement(Element):
     def bind_source_from(self,
                          target_object: Any,
                          target_name: str = 'source',
-                         backward: Callable[..., Any] = lambda x: x,
+                         backward: Optional[Callable[[Any], Any]] = None,
                          ) -> Self:
         """Bind the source of this element from the target object's target_name property.
 
@@ -50,7 +50,7 @@ class SourceElement(Element):
 
         :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.
+        :param backward: A function to apply to the value before applying it to this element (default: identity).
         """
         bind_from(self, 'source', target_object, target_name, backward)
         return self
@@ -58,8 +58,8 @@ class SourceElement(Element):
     def bind_source(self,
                     target_object: Any,
                     target_name: str = 'source', *,
-                    forward: Callable[..., Any] = lambda x: x,
-                    backward: Callable[..., Any] = lambda x: x,
+                    forward: Optional[Callable[[Any], Any]] = None,
+                    backward: Optional[Callable[[Any], Any]] = None,
                     ) -> Self:
         """Bind the source of this element to the target object's target_name property.
 
@@ -69,8 +69,8 @@ class SourceElement(Element):
 
         :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.
+        :param forward: A function to apply to the value before applying it to the target (default: identity).
+        :param backward: A function to apply to the value before applying it to this element (default: identity).
         """
         bind(self, 'source', target_object, target_name, forward=forward, backward=backward)
         return self
diff --git a/nicegui/elements/mixins/text_element.py b/nicegui/elements/mixins/text_element.py
index b666403d..e0a85f8f 100644
--- a/nicegui/elements/mixins/text_element.py
+++ b/nicegui/elements/mixins/text_element.py
@@ -1,4 +1,4 @@
-from typing import Any, Callable, cast
+from typing import Any, Callable, Optional, cast
 
 from typing_extensions import Self
 
@@ -18,7 +18,7 @@ class TextElement(Element):
     def bind_text_to(self,
                      target_object: Any,
                      target_name: str = 'text',
-                     forward: Callable[..., Any] = lambda x: x,
+                     forward: Optional[Callable[[Any], Any]] = None,
                      ) -> Self:
         """Bind the text of this element to the target object's target_name property.
 
@@ -27,7 +27,7 @@ class TextElement(Element):
 
         :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 forward: A function to apply to the value before applying it to the target (default: identity).
         """
         bind_to(self, 'text', target_object, target_name, forward)
         return self
@@ -35,7 +35,7 @@ class TextElement(Element):
     def bind_text_from(self,
                        target_object: Any,
                        target_name: str = 'text',
-                       backward: Callable[..., Any] = lambda x: x,
+                       backward: Optional[Callable[[Any], Any]] = None,
                        ) -> Self:
         """Bind the text of this element from the target object's target_name property.
 
@@ -44,7 +44,7 @@ class TextElement(Element):
 
         :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.
+        :param backward: A function to apply to the value before applying it to this element (default: identity).
         """
         bind_from(self, 'text', target_object, target_name, backward)
         return self
@@ -52,8 +52,8 @@ class TextElement(Element):
     def bind_text(self,
                   target_object: Any,
                   target_name: str = 'text', *,
-                  forward: Callable[..., Any] = lambda x: x,
-                  backward: Callable[..., Any] = lambda x: x,
+                  forward: Optional[Callable[[Any], Any]] = None,
+                  backward: Optional[Callable[[Any], Any]] = None,
                   ) -> Self:
         """Bind the text of this element to the target object's target_name property.
 
@@ -63,8 +63,8 @@ class TextElement(Element):
 
         :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.
+        :param forward: A function to apply to the value before applying it to the target (default: identity).
+        :param backward: A function to apply to the value before applying it to this element (default: identity).
         """
         bind(self, 'text', target_object, target_name, forward=forward, backward=backward)
         return self
diff --git a/nicegui/elements/mixins/value_element.py b/nicegui/elements/mixins/value_element.py
index 60c43496..915c69e2 100644
--- a/nicegui/elements/mixins/value_element.py
+++ b/nicegui/elements/mixins/value_element.py
@@ -49,7 +49,7 @@ class ValueElement(Element):
     def bind_value_to(self,
                       target_object: Any,
                       target_name: str = 'value',
-                      forward: Callable[..., Any] = lambda x: x,
+                      forward: Optional[Callable[[Any], Any]] = None,
                       ) -> Self:
         """Bind the value of this element to the target object's target_name property.
 
@@ -58,7 +58,7 @@ class ValueElement(Element):
 
         :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 forward: A function to apply to the value before applying it to the target (default: identity).
         """
         bind_to(self, 'value', target_object, target_name, forward)
         return self
@@ -66,7 +66,7 @@ class ValueElement(Element):
     def bind_value_from(self,
                         target_object: Any,
                         target_name: str = 'value',
-                        backward: Callable[..., Any] = lambda x: x,
+                        backward: Optional[Callable[[Any], Any]] = None,
                         ) -> Self:
         """Bind the value of this element from the target object's target_name property.
 
@@ -75,7 +75,7 @@ class ValueElement(Element):
 
         :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.
+        :param backward: A function to apply to the value before applying it to this element (default: identity).
         """
         bind_from(self, 'value', target_object, target_name, backward)
         return self
@@ -83,8 +83,8 @@ class ValueElement(Element):
     def bind_value(self,
                    target_object: Any,
                    target_name: str = 'value', *,
-                   forward: Callable[..., Any] = lambda x: x,
-                   backward: Callable[..., Any] = lambda x: x,
+                   forward: Optional[Callable[[Any], Any]] = None,
+                   backward: Optional[Callable[[Any], Any]] = None,
                    ) -> Self:
         """Bind the value of this element to the target object's target_name property.
 
@@ -94,8 +94,8 @@ class ValueElement(Element):
 
         :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.
+        :param forward: A function to apply to the value before applying it to the target (default: identity).
+        :param backward: A function to apply to the value before applying it to this element (default: identity).
         """
         bind(self, 'value', target_object, target_name, forward=forward, backward=backward)
         return self
diff --git a/nicegui/elements/mixins/visibility.py b/nicegui/elements/mixins/visibility.py
index c853d46b..f5099d37 100644
--- a/nicegui/elements/mixins/visibility.py
+++ b/nicegui/elements/mixins/visibility.py
@@ -1,6 +1,6 @@
 from __future__ import annotations
 
-from typing import TYPE_CHECKING, Any, Callable, cast
+from typing import TYPE_CHECKING, Any, Callable, Optional, cast
 
 from typing_extensions import Self
 
@@ -27,7 +27,7 @@ class Visibility:
     def bind_visibility_to(self,
                            target_object: Any,
                            target_name: str = 'visible',
-                           forward: Callable[..., Any] = lambda x: x,
+                           forward: Optional[Callable[[Any], Any]] = None,
                            ) -> Self:
         """Bind the visibility of this element to the target object's target_name property.
 
@@ -36,7 +36,7 @@ class Visibility:
 
         :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 forward: A function to apply to the value before applying it to the target (default: identity).
         """
         bind_to(self, 'visible', target_object, target_name, forward)
         return self
@@ -44,7 +44,7 @@ class Visibility:
     def bind_visibility_from(self,
                              target_object: Any,
                              target_name: str = 'visible',
-                             backward: Callable[..., Any] = lambda x: x, *,
+                             backward: Optional[Callable[[Any], Any]] = None, *,
                              value: Any = None) -> Self:
         """Bind the visibility of this element from the target object's target_name property.
 
@@ -53,7 +53,7 @@ class Visibility:
 
         :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.
+        :param backward: A function to apply to the value before applying it to this element (default: identity).
         :param value: If specified, the element will be visible only when the target value is equal to this value.
         """
         if value is not None:
@@ -65,8 +65,8 @@ class Visibility:
     def bind_visibility(self,
                         target_object: Any,
                         target_name: str = 'visible', *,
-                        forward: Callable[..., Any] = lambda x: x,
-                        backward: Callable[..., Any] = lambda x: x,
+                        forward: Optional[Callable[[Any], Any]] = None,
+                        backward: Optional[Callable[[Any], Any]] = None,
                         value: Any = None,
                         ) -> Self:
         """Bind the visibility of this element to the target object's target_name property.
@@ -77,8 +77,8 @@ class Visibility:
 
         :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.
+        :param forward: A function to apply to the value before applying it to the target (default: identity).
+        :param backward: A function to apply to the value before applying it to this element (default: identity).
         :param value: If specified, the element will be visible only when the target value is equal to this value.
         """
         if value is not None:
diff --git a/nicegui/elements/table.py b/nicegui/elements/table.py
index af4c9f51..38904464 100644
--- a/nicegui/elements/table.py
+++ b/nicegui/elements/table.py
@@ -82,7 +82,7 @@ class Table(FilterElement, component='table.js'):
                     self.selected.clear()
                 self.selected.extend(e.args['rows'])
             else:
-                self.selected = [row for row in self.selected if row[row_key] not in e.args['keys']]
+                self.selected = [row for row in self.selected if row[self.row_key] not in e.args['keys']]
             self.update()
             arguments = TableSelectionEventArguments(sender=self, client=self.client, selection=self.selected)
             for handler in self._selection_handlers:
diff --git a/nicegui/events.py b/nicegui/events.py
index 9c403224..8f10bae1 100644
--- a/nicegui/events.py
+++ b/nicegui/events.py
@@ -3,7 +3,6 @@ from __future__ import annotations
 import asyncio
 from contextlib import nullcontext
 from dataclasses import dataclass
-from inspect import Parameter, signature
 from typing import (
     TYPE_CHECKING,
     Any,
@@ -20,7 +19,7 @@ from typing import (
     cast,
 )
 
-from . import background_tasks, core
+from . import background_tasks, core, helpers
 from .awaitable_response import AwaitableResponse
 from .dataclasses import KWONLY_SLOTS
 from .slot import Slot
@@ -250,7 +249,7 @@ class KeyboardKey:
     @property
     def space(self) -> bool:
         """Whether the key is the space key."""
-        return self.name == 'Space'
+        return self.name == ' '
 
     @property
     def page_up(self) -> bool:
@@ -421,11 +420,6 @@ def handle_event(handler: Optional[Handler[EventT]], arguments: EventT) -> None:
     if handler is None:
         return
     try:
-        expects_arguments = any(p.default is Parameter.empty and
-                                p.kind is not Parameter.VAR_POSITIONAL and
-                                p.kind is not Parameter.VAR_KEYWORD
-                                for p in signature(handler).parameters.values())
-
         parent_slot: Union[Slot, nullcontext]
         if isinstance(arguments, UiEventArguments):
             parent_slot = arguments.sender.parent_slot or arguments.sender.client.layout.default_slot
@@ -433,7 +427,7 @@ def handle_event(handler: Optional[Handler[EventT]], arguments: EventT) -> None:
             parent_slot = nullcontext()
 
         with parent_slot:
-            if expects_arguments:
+            if helpers.expects_arguments(handler):
                 result = cast(Callable[[EventT], Any], handler)(arguments)
             else:
                 result = cast(Callable[[], Any], handler)()
diff --git a/nicegui/helpers.py b/nicegui/helpers.py
index 0acae8a5..d3da3d97 100644
--- a/nicegui/helpers.py
+++ b/nicegui/helpers.py
@@ -7,12 +7,11 @@ import struct
 import threading
 import time
 import webbrowser
-from collections.abc import Awaitable
+from collections.abc import Callable
+from inspect import Parameter, signature
 from pathlib import Path
 from typing import Any, Optional, Set, Tuple, Union
 
-import wait_for2
-
 from .logging import log
 
 _shown_warnings: Set[str] = set()
@@ -41,6 +40,14 @@ def is_coroutine_function(obj: Any) -> bool:
     return asyncio.iscoroutinefunction(obj)
 
 
+def expects_arguments(func: Callable) -> bool:
+    """Check if the function expects non-variable arguments without a default value."""
+    return any(p.default is Parameter.empty and
+               p.kind is not Parameter.VAR_POSITIONAL and
+               p.kind is not Parameter.VAR_KEYWORD
+               for p in signature(func).parameters.values())
+
+
 def is_file(path: Optional[Union[str, Path]]) -> bool:
     """Check if the path is a file that exists."""
     if not path:
@@ -113,12 +120,3 @@ def kebab_to_camel_case(string: str) -> str:
 def event_type_to_camel_case(string: str) -> str:
     """Convert an event type string to camelCase."""
     return '.'.join(kebab_to_camel_case(part) if part != '-' else part for part in string.split('.'))
-
-
-async def wait_for(fut: Awaitable, timeout: Optional[float] = None) -> None:
-    """Wait for a future to complete.
-
-    This function is a wrapper around ``wait_for2.wait_for`` which is a drop-in replacement for ``asyncio.wait_for``.
-    It can be removed once we drop support for older versions than Python 3.13 which fixes ``asyncio.wait_for``.
-    """
-    return await wait_for2.wait_for(fut, timeout)
diff --git a/nicegui/javascript_request.py b/nicegui/javascript_request.py
index 716ab918..8f31f7fb 100644
--- a/nicegui/javascript_request.py
+++ b/nicegui/javascript_request.py
@@ -3,8 +3,6 @@ from __future__ import annotations
 import asyncio
 from typing import Any, ClassVar, Dict
 
-from . import helpers
-
 
 class JavaScriptRequest:
     _instances: ClassVar[Dict[str, JavaScriptRequest]] = {}
@@ -27,7 +25,7 @@ class JavaScriptRequest:
 
     def __await__(self) -> Any:
         try:
-            yield from helpers.wait_for(self._event.wait(), self.timeout).__await__()
+            yield from asyncio.wait_for(self._event.wait(), self.timeout).__await__()
         except asyncio.TimeoutError as e:
             raise TimeoutError(f'JavaScript did not respond within {self.timeout:.1f} s') from e
         else:
diff --git a/nicegui/middlewares.py b/nicegui/middlewares.py
index 608695b3..4f0399cc 100644
--- a/nicegui/middlewares.py
+++ b/nicegui/middlewares.py
@@ -2,6 +2,9 @@ from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoin
 from starlette.requests import Request
 from starlette.responses import Response
 
+from . import core
+from .version import __version__
+
 
 class RedirectWithPrefixMiddleware(BaseHTTPMiddleware):
 
@@ -12,3 +15,13 @@ class RedirectWithPrefixMiddleware(BaseHTTPMiddleware):
             new_location = prefix + response.headers['Location']
             response.headers['Location'] = new_location
         return response
+
+
+class SetCacheControlMiddleware(BaseHTTPMiddleware):
+
+    async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
+        response = await call_next(request)
+        if request.url.path.startswith(f'/_nicegui/{__version__}/') \
+                and not request.url.path.startswith(f'/_nicegui/{__version__}/dynamic_resources/'):
+            response.headers['Cache-Control'] = core.app.config.cache_control_directives
+        return response
diff --git a/nicegui/nicegui.py b/nicegui/nicegui.py
index 87ab0f07..42f9582f 100644
--- a/nicegui/nicegui.py
+++ b/nicegui/nicegui.py
@@ -75,16 +75,14 @@ def _get_library(key: str) -> FileResponse:
         if is_map:
             path = path.with_name(path.name + '.map')
         if path.exists():
-            headers = {'Cache-Control': 'public, max-age=3600'}
-            return FileResponse(path, media_type='text/javascript', headers=headers)
+            return FileResponse(path, media_type='text/javascript')
     raise HTTPException(status_code=404, detail=f'library "{key}" not found')
 
 
 @app.get(f'/_nicegui/{__version__}' + '/components/{key:path}')
 def _get_component(key: str) -> FileResponse:
     if key in js_components and js_components[key].path.exists():
-        headers = {'Cache-Control': 'public, max-age=3600'}
-        return FileResponse(js_components[key].path, media_type='text/javascript', headers=headers)
+        return FileResponse(js_components[key].path, media_type='text/javascript')
     raise HTTPException(status_code=404, detail=f'component "{key}" not found')
 
 
@@ -97,9 +95,8 @@ def _get_resource(key: str, path: str) -> FileResponse:
         except ValueError as e:
             raise HTTPException(status_code=403, detail='forbidden') from e
         if filepath.exists():
-            headers = {'Cache-Control': 'public, max-age=3600'}
             media_type, _ = mimetypes.guess_type(filepath)
-            return FileResponse(filepath, media_type=media_type, headers=headers)
+            return FileResponse(filepath, media_type=media_type)
     raise HTTPException(status_code=404, detail=f'resource "{key}" not found')
 
 
diff --git a/nicegui/outbox.py b/nicegui/outbox.py
index c16d498e..70cff30b 100644
--- a/nicegui/outbox.py
+++ b/nicegui/outbox.py
@@ -5,7 +5,7 @@ import time
 from collections import deque
 from typing import TYPE_CHECKING, Any, Deque, Dict, Optional, Tuple
 
-from . import background_tasks, core, helpers
+from . import background_tasks, core
 
 if TYPE_CHECKING:
     from .client import Client
@@ -72,7 +72,7 @@ class Outbox:
             try:
                 if not self._enqueue_event.is_set():
                     try:
-                        await helpers.wait_for(self._enqueue_event.wait(), timeout=1.0)
+                        await asyncio.wait_for(self._enqueue_event.wait(), timeout=1.0)
                     except (TimeoutError, asyncio.TimeoutError):
                         continue
 
diff --git a/nicegui/page.py b/nicegui/page.py
index b7b9760a..c027449f 100644
--- a/nicegui/page.py
+++ b/nicegui/page.py
@@ -109,6 +109,31 @@ class page:
             except asyncio.CancelledError:
                 pass
 
+        def create_error_page(e: Exception, request: Request) -> Response:
+            page_exception_handler = core.app._page_exception_handler  # pylint: disable=protected-access
+            if page_exception_handler is None:
+                raise e
+            with Client(page(''), request=request) as error_client:
+                # page exception handler
+                if helpers.expects_arguments(page_exception_handler):
+                    page_exception_handler(e)
+                else:
+                    page_exception_handler()
+
+                # FastAPI exception handlers
+                for key, handler in core.app.exception_handlers.items():
+                    if key == 500 or (isinstance(key, type) and isinstance(e, key)):
+                        result = handler(request, e)
+                        if helpers.is_coroutine_function(handler):
+                            async def await_handler(result: Any) -> None:
+                                await result
+                            background_tasks.create(await_handler(result), name=f'exception handler {handler.__name__}')
+
+                # NiceGUI exception handlers
+                core.app.handle_exception(e)
+
+                return error_client.build_response(request, 500)
+
         @wraps(func)
         async def decorated(*dec_args, **dec_kwargs) -> Response:
             request = dec_kwargs['request']
@@ -117,11 +142,17 @@ class page:
             with Client(self, request=request) as client:
                 if any(p.name == 'client' for p in inspect.signature(func).parameters.values()):
                     dec_kwargs['client'] = client
-                result = func(*dec_args, **dec_kwargs)
+                try:
+                    result = func(*dec_args, **dec_kwargs)
+                except Exception as e:
+                    return create_error_page(e, request)
             if helpers.is_coroutine_function(func):
-                async def wait_for_result() -> None:
+                async def wait_for_result() -> Optional[Response]:
                     with client:
-                        return await result
+                        try:
+                            return await result
+                        except Exception as e:
+                            return create_error_page(e, request)
                 task = background_tasks.create(wait_for_result())
                 task_wait_for_connection = background_tasks.create(
                     client._waiting_for_connection.wait(),  # pylint: disable=protected-access
diff --git a/nicegui/static/nicegui.js b/nicegui/static/nicegui.js
index 6323ed64..7e81907d 100644
--- a/nicegui/static/nicegui.js
+++ b/nicegui/static/nicegui.js
@@ -436,8 +436,6 @@ function createApp(elements, options) {
         });
       }
     },
-  }).use(Quasar, {
-    config: options.quasarConfig,
   }));
 }
 
diff --git a/nicegui/templates/index.html b/nicegui/templates/index.html
index d206f1e1..a6a73a12 100644
--- a/nicegui/templates/index.html
+++ b/nicegui/templates/index.html
@@ -51,15 +51,15 @@
         query: {{ socket_io_js_query_params | safe }},
         extraHeaders: {{ socket_io_js_extra_headers | safe }},
         transports: {{ socket_io_js_transports | safe }},
-        quasarConfig: {{ quasar_config | safe }},
       });
+      const dark = {{ dark }};
+      const language = "{{ language }}";
+      const vue_config = {{ vue_config | safe }};
+      {{ vue_config_script | safe }}
 
       {{ js_imports | safe }}
       {{ vue_scripts | safe }}
 
-      const dark = {{ dark }};
-      Quasar.lang.set(Quasar.lang["{{ language }}".replace('-', '')]);
-      Quasar.Dark.set(dark === None ? "auto" : dark);
       {% if tailwind %}
       if (dark !== None) tailwind.config.darkMode = "class";
       if (dark === True) document.body.classList.add("dark");
diff --git a/nicegui/ui_run.py b/nicegui/ui_run.py
index 99407d39..24794420 100644
--- a/nicegui/ui_run.py
+++ b/nicegui/ui_run.py
@@ -17,7 +17,7 @@ from .air import Air
 from .client import Client
 from .language import Language
 from .logging import log
-from .middlewares import RedirectWithPrefixMiddleware
+from .middlewares import RedirectWithPrefixMiddleware, SetCacheControlMiddleware
 from .server import CustomServerConfig, Server
 
 APP_IMPORT_STRING = 'nicegui:app'
@@ -56,6 +56,7 @@ def run(*,
         binding_refresh_interval: float = 0.1,
         reconnect_timeout: float = 3.0,
         message_history_length: int = 1000,
+        cache_control_directives: str = 'public, max-age=31536000, immutable, stale-while-revalidate=31536000',
         fastapi_docs: Union[bool, DocsConfig] = False,
         show: bool = True,
         on_air: Optional[Union[str, Literal[True]]] = None,
@@ -90,6 +91,7 @@ def run(*,
     :param binding_refresh_interval: time between binding updates (default: `0.1` seconds, bigger is more CPU friendly)
     :param reconnect_timeout: maximum time the server waits for the browser to reconnect (default: 3.0 seconds)
     :param message_history_length: maximum number of messages that will be stored and resent after a connection interruption (default: 1000, use 0 to disable, *added in version 2.9.0*)
+    :param cache_control_directives: cache control directives for internal static files (default: `'public, max-age=31536000, immutable, stale-while-revalidate=31536000'`)
     :param fastapi_docs: enable FastAPI's automatic documentation with Swagger UI, ReDoc, and OpenAPI JSON (bool or dictionary as described `here <https://fastapi.tiangolo.com/tutorial/metadata/>`_, default: `False`, *updated in version 2.9.0*)
     :param show: automatically open the UI in a browser tab (default: `True`)
     :param on_air: tech preview: `allows temporary remote access <https://nicegui.io/documentation/section_configuration_deployment#nicegui_on_air>`_ if set to `True` (default: disabled)
@@ -119,6 +121,7 @@ def run(*,
         binding_refresh_interval=binding_refresh_interval,
         reconnect_timeout=reconnect_timeout,
         message_history_length=message_history_length,
+        cache_control_directives=cache_control_directives,
         tailwind=tailwind,
         prod_js=prod_js,
         show_welcome_message=show_welcome_message,
@@ -127,6 +130,7 @@ def run(*,
     if not helpers.is_pytest():
         core.app.add_middleware(GZipMiddleware)
     core.app.add_middleware(RedirectWithPrefixMiddleware)
+    core.app.add_middleware(SetCacheControlMiddleware)
 
     for route in core.app.routes:
         if not isinstance(route, Route):
diff --git a/nicegui/ui_run_with.py b/nicegui/ui_run_with.py
index 9c57b3be..f054c1a5 100644
--- a/nicegui/ui_run_with.py
+++ b/nicegui/ui_run_with.py
@@ -8,7 +8,7 @@ from fastapi.middleware.gzip import GZipMiddleware
 from . import core, storage
 from .air import Air
 from .language import Language
-from .middlewares import RedirectWithPrefixMiddleware
+from .middlewares import RedirectWithPrefixMiddleware, SetCacheControlMiddleware
 from .nicegui import _shutdown, _startup
 
 
@@ -22,6 +22,7 @@ def run_with(
     binding_refresh_interval: float = 0.1,
     reconnect_timeout: float = 3.0,
     message_history_length: int = 1000,
+    cache_control_directives: str = 'public, max-age=31536000, immutable, stale-while-revalidate=31536000',
     mount_path: str = '/',
     on_air: Optional[Union[str, Literal[True]]] = None,
     tailwind: bool = True,
@@ -40,6 +41,7 @@ def run_with(
     :param binding_refresh_interval: time between binding updates (default: `0.1` seconds, bigger is more CPU friendly)
     :param reconnect_timeout: maximum time the server waits for the browser to reconnect (default: 3.0 seconds)
     :param message_history_length: maximum number of messages that will be stored and resent after a connection interruption (default: 1000, use 0 to disable, *added in version 2.9.0*)
+    :param cache_control_directives: cache control directives for internal static files (default: `'public, max-age=31536000, immutable, stale-while-revalidate=31536000'`)
     :param mount_path: mount NiceGUI at this path (default: `'/'`)
     :param on_air: tech preview: `allows temporary remote access <https://nicegui.io/documentation/section_configuration_deployment#nicegui_on_air>`_ if set to `True` (default: disabled)
     :param tailwind: whether to use Tailwind CSS (experimental, default: `True`)
@@ -60,10 +62,12 @@ def run_with(
         tailwind=tailwind,
         prod_js=prod_js,
         show_welcome_message=show_welcome_message,
+        cache_control_directives=cache_control_directives,
     )
     storage.set_storage_secret(storage_secret)
     core.app.add_middleware(GZipMiddleware)
     core.app.add_middleware(RedirectWithPrefixMiddleware)
+    core.app.add_middleware(SetCacheControlMiddleware)
 
     if on_air:
         core.air = Air('' if on_air is True else on_air)
diff --git a/npm.json b/npm.json
index 8a954388..a9a70206 100644
--- a/npm.json
+++ b/npm.json
@@ -1,10 +1,13 @@
 {
   "vue": {
     "destination": "nicegui/static",
-    "keep": ["package/dist/vue\\.global(\\.prod)?\\.js"],
+    "keep": [
+      "package/dist/vue\\.global(\\.prod)?\\.js"
+    ],
     "rename": {
       "package/dist": ".."
-    }
+    },
+    "version": "3.4.38"
   },
   "quasar": {
     "destination": "nicegui/static",
@@ -15,49 +18,68 @@
     ],
     "rename": {
       "package/dist": ".."
-    }
+    },
+    "version": "2.16.9"
   },
   "tailwindcss": {
     "destination": "nicegui/static",
     "keep": [],
     "download": "https://cdn.tailwindcss.com",
-    "rename": "../tailwindcss.min.js"
+    "rename": "../tailwindcss.min.js",
+    "version": "3.4.10"
   },
   "socket.io": {
     "destination": "nicegui/static",
-    "keep": ["package/client-dist/socket\\.io\\.min\\.js(\\.map)?"],
+    "keep": [
+      "package/client-dist/socket\\.io\\.min\\.js(\\.map)?"
+    ],
     "rename": {
       "package/client-dist": ".."
-    }
+    },
+    "version": "4.7.5"
   },
   "es-module-shims": {
     "destination": "nicegui/static",
-    "keep": ["package/dist/es-module-shims\\.js"],
+    "keep": [
+      "package/dist/es-module-shims\\.js"
+    ],
     "rename": {
       "package/dist": ".."
-    }
+    },
+    "version": "1.10.0"
   },
   "aggrid": {
     "package": "ag-grid-community",
     "destination": "nicegui/elements/lib",
-    "keep": ["package/dist/ag-grid-community(\\.min)?\\.js"],
+    "keep": [
+      "package/dist/ag-grid-community(\\.min)?\\.js"
+    ],
     "rename": {
       "package/dist/": ""
-    }
+    },
+    "version": "32.1.0"
   },
   "echarts": {
     "destination": "nicegui/elements/lib",
-    "keep": ["package/dist/echarts\\.min\\.js", "package/dist/echarts\\.js\\.map"],
+    "keep": [
+      "package/dist/echarts\\.min\\.js",
+      "package/dist/echarts\\.js\\.map"
+    ],
     "rename": {
       "package/dist/": ""
-    }
+    },
+    "version": "5.5.1"
   },
   "echarts-gl": {
     "destination": "nicegui/elements/lib",
-    "keep": ["package/dist/echarts-gl\\.min\\.js", "package/dist/echarts-gl\\.js\\.map"],
+    "keep": [
+      "package/dist/echarts-gl\\.min\\.js",
+      "package/dist/echarts-gl\\.js\\.map"
+    ],
     "rename": {
       "package/dist/": ""
-    }
+    },
+    "version": "2.0.9"
   },
   "leaflet": {
     "destination": "nicegui/elements/lib/leaflet",
@@ -69,36 +91,52 @@
     ],
     "rename": {
       "package/dist/": ""
-    }
+    },
+    "version": "1.9.4"
   },
   "leaflet-draw": {
     "destination": "nicegui/elements/lib/leaflet",
-    "keep": ["package/dist/leaflet\\.draw\\.css", "package/dist/leaflet\\.draw\\.js", "package/dist/images/.*"],
+    "keep": [
+      "package/dist/leaflet\\.draw\\.css",
+      "package/dist/leaflet\\.draw\\.js",
+      "package/dist/images/.*"
+    ],
     "rename": {
       "package/dist/": ""
-    }
+    },
+    "version": "1.0.4"
   },
   "mermaid": {
     "destination": "nicegui/elements/lib",
-    "keep": ["package/dist/mermaid.esm.min\\.mjs", "package/dist/chunks/mermaid.esm.min/.*\\.mjs"],
+    "keep": [
+      "package/dist/mermaid.esm.min\\.mjs",
+      "package/dist/chunks/mermaid.esm.min/.*\\.mjs"
+    ],
     "rename": {
       "package/dist/": ""
-    }
+    },
+    "version": "11.0.2"
   },
   "nipplejs": {
     "destination": "nicegui/elements/lib",
-    "keep": ["package/dist/nipplejs(\\.min)?\\.js"],
+    "keep": [
+      "package/dist/nipplejs(\\.min)?\\.js"
+    ],
     "rename": {
       "package/dist/": ""
-    }
+    },
+    "version": "0.10.2"
   },
   "plotly": {
     "package": "plotly.js",
     "destination": "nicegui/elements/lib",
-    "keep": ["package/dist/plotly(\\.min)?\\.js"],
+    "keep": [
+      "package/dist/plotly(\\.min)?\\.js"
+    ],
     "rename": {
       "package/dist/": ""
-    }
+    },
+    "version": "2.35.0"
   },
   "three": {
     "destination": "nicegui/elements/lib",
@@ -120,21 +158,29 @@
       "package/examples/jsm/loaders/": "modules/",
       "package/examples/jsm/libs/": "modules/",
       "package/examples/jsm/utils/": "modules/"
-    }
+    },
+    "version": "0.168.0"
   },
   "tween": {
     "package": "@tweenjs/tween.js",
     "destination": "nicegui/elements/lib",
-    "keep": ["package/dist/tween\\.umd(\\.min)?\\.js"],
+    "keep": [
+      "package/dist/tween\\.umd(\\.min)?\\.js"
+    ],
     "rename": {
       "package/dist/": ""
-    }
+    },
+    "version": "25.0.0"
   },
   "vanilla-jsoneditor": {
     "destination": "nicegui/elements/lib",
-    "keep": ["package/standalone\\.js", "package/standalone\\.js\\.map"],
+    "keep": [
+      "package/standalone\\.js",
+      "package/standalone\\.js\\.map"
+    ],
     "rename": {
       "package/": ""
-    }
+    },
+    "version": "0.23.8"
   }
 }
diff --git a/package.json b/package.json
new file mode 100644
index 00000000..8238c8b2
--- /dev/null
+++ b/package.json
@@ -0,0 +1,20 @@
+{
+  "dependencies": {
+    "@tweenjs/tween.js": "^25.0.0",
+    "ag-grid-community": "^32.1.0",
+    "echarts": "^5.5.1",
+    "echarts-gl": "^2.0.9",
+    "es-module-shims": "^1.10.0",
+    "leaflet": "^1.9.4",
+    "leaflet-draw": "^1.0.4",
+    "mermaid": "^11.0.2",
+    "nipplejs": "^0.10.2",
+    "plotly.js": "^2.35.0",
+    "quasar": "^2.16.9",
+    "socket.io": "^4.7.5",
+    "tailwindcss": "^3.4.10",
+    "three": "^0.168.0",
+    "vanilla-jsoneditor": "^0.23.8",
+    "vue": "^3.4.38"
+  }
+}
diff --git a/pin_version.py b/pin_version.py
new file mode 100755
index 00000000..8f61850b
--- /dev/null
+++ b/pin_version.py
@@ -0,0 +1,35 @@
+#!/usr/bin/env python3
+"""Pins version of dependencies in npm.json according to package.json"""
+import json
+from pathlib import Path
+from typing import Dict, Set
+
+PATH = Path(__file__).parent
+NPM_FILE = PATH / 'npm.json'
+PACKAGE_FILE = PATH / 'package.json'
+
+npm: Dict[str, Dict] = json.loads(NPM_FILE.read_text(encoding='utf-8'))
+package: Dict[str, Dict] = json.loads(PACKAGE_FILE.read_text(encoding='utf-8'))
+npm_pinned_entries: Set[str] = set()
+
+for package_name, package_version in package['dependencies'].items():
+    stripped_version = package_version.strip('^~')
+    for key, value in npm.items():
+        if key == package_name or value.get('package') == package_name:
+            existing_version = value.get('version', 'N/A')
+            if existing_version != stripped_version:
+                print(f'Changing {package_name} from {existing_version} to {stripped_version}')
+                npm[key]['version'] = stripped_version.strip('^~')
+            else:
+                print(f'Keeping  {package_name} at {stripped_version}')
+            npm_pinned_entries.add(key)
+            break
+    else:
+        print(f'Warning: {package_name} not found in npm.json!')
+
+for key in npm:
+    if key not in npm_pinned_entries:
+        print(f'Warning: {key} not found in package.json!')
+
+
+NPM_FILE.write_text(json.dumps(npm, indent=2) + '\n', encoding='utf-8')
diff --git a/poetry.lock b/poetry.lock
index 451175cc..af8ab7e2 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -3381,19 +3381,19 @@ ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==23.2.1)", "requests (>=2.31.0)"
 
 [[package]]
 name = "requests"
-version = "2.32.3"
+version = "2.32.4"
 description = "Python HTTP for Humans."
 optional = false
 python-versions = ">=3.8"
 groups = ["main", "dev"]
 files = [
-    {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"},
-    {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"},
+    {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"},
+    {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"},
 ]
 
 [package.dependencies]
 certifi = ">=2017.4.17"
-charset-normalizer = ">=2,<4"
+charset_normalizer = ">=2,<4"
 idna = ">=2.5,<4"
 urllib3 = ">=1.21.1,<3"
 
@@ -3820,6 +3820,7 @@ description = "HTTP library with thread-safe connection pooling, file post, and
 optional = false
 python-versions = ">=3.8"
 groups = ["main", "dev"]
+markers = "python_version == \"3.8\""
 files = [
     {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"},
     {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"},
@@ -3834,6 +3835,28 @@ h2 = ["h2 (>=4,<5)"]
 socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
 zstd = ["zstandard (>=0.18.0)"]
 
+[[package]]
+name = "urllib3"
+version = "2.5.0"
+description = "HTTP library with thread-safe connection pooling, file post, and more."
+optional = false
+python-versions = ">=3.9"
+groups = ["main", "dev"]
+markers = "python_version >= \"3.9\""
+files = [
+    {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"},
+    {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"},
+]
+
+[package.dependencies]
+pysocks = {version = ">=1.5.6,<1.5.7 || >1.5.7,<2.0", optional = true, markers = "extra == \"socks\""}
+
+[package.extras]
+brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""]
+h2 = ["h2 (>=4,<5)"]
+socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
+zstd = ["zstandard (>=0.18.0)"]
+
 [[package]]
 name = "uvicorn"
 version = "0.33.0"
@@ -3951,17 +3974,6 @@ platformdirs = ">=3.9.1,<5"
 docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
 test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""]
 
-[[package]]
-name = "wait-for2"
-version = "0.3.2"
-description = "Asyncio wait_for that can handle simultaneous cancellation and future completion."
-optional = false
-python-versions = "*"
-groups = ["main"]
-files = [
-    {file = "wait_for2-0.3.2.tar.gz", hash = "sha256:93863026dc35f3471104ecf7de1f4a0b31f4c8b12a2241c0d6ee26dcc0c2092a"},
-]
-
 [[package]]
 name = "watchdog"
 version = "4.0.2"
@@ -4408,4 +4420,4 @@ sass = ["libsass"]
 [metadata]
 lock-version = "2.1"
 python-versions = "^3.8"
-content-hash = "fd49862eeebd95f8eb1fe6d09e3a98ee41c2d1003f54b616745e8a30df33ae71"
+content-hash = "ffe9ba78e0412d800856f36a6f97bfeb7930e768b01e3e87a1f3fabf4599cca0"
diff --git a/pyproject.toml b/pyproject.toml
index 2c1a723d..2da1348f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "nicegui"
-version = "2.18.0-dev"
+version = "2.20.0-dev"
 description = "Create web-based user interfaces with Python. The nice way."
 authors = ["Zauberzeug GmbH <info@zauberzeug.com>"]
 license = "MIT"
@@ -37,12 +37,11 @@ ifaddr = ">=0.2.0"
 aiohttp = ">=3.10.2" # https://github.com/zauberzeug/nicegui/security/dependabot/36
 libsass = { version = "^0.23.0", optional = true }
 docutils = ">=0.19.0"
-requests = ">=2.32.0" # https://github.com/zauberzeug/nicegui/security/dependabot/33
+requests = ">=2.32.4" # https://github.com/zauberzeug/nicegui/security/dependabot/46
 urllib3 = ">=1.26.18,!=2.0.0,!=2.0.1,!=2.0.2,!=2.0.3,!=2.0.4,!=2.0.5,!=2.0.6,!=2.0.7,!=2.1.0,!=2.2.0,!=2.2.1" # https://github.com/zauberzeug/nicegui/security/dependabot/34
 certifi = ">=2024.07.04" # https://github.com/zauberzeug/nicegui/security/dependabot/35
 redis = { version = ">=4.0.0", optional = true }
 h11 = ">=0.16.0" # https://github.com/zauberzeug/nicegui/security/dependabot/45
-wait_for2 = ">=0.3.2"
 
 [tool.poetry.extras]
 native = ["pywebview"]
@@ -124,7 +123,6 @@ module = [
     "sass",
     "socketio.*",
     "vbuild",
-    "wait_for2",
     "webview.*", # can be removed with next pywebview release
 ]
 ignore_missing_imports = true
diff --git a/website/documentation/content/overview.py b/website/documentation/content/overview.py
index 7437645d..a998d684 100644
--- a/website/documentation/content/overview.py
+++ b/website/documentation/content/overview.py
@@ -326,6 +326,7 @@ def map_of_nicegui():
             - `app.on_startup()`: called when the app starts
             - `app.on_shutdown()`: called when the app shuts down
             - `app.on_exception()`: called when an exception occurs
+            - `app.on_page_exception()`: called when an exception occurs while building a page
         - [`app.shutdown()`](/documentation/section_action_events#shut_down_nicegui): shut down the app
         - static files:
             - [`app.add_static_files()`](/documentation/section_pages_routing#add_a_directory_of_static_files),
diff --git a/website/documentation/content/page_documentation.py b/website/documentation/content/page_documentation.py
index 75425206..4194f990 100644
--- a/website/documentation/content/page_documentation.py
+++ b/website/documentation/content/page_documentation.py
@@ -107,4 +107,4 @@ def api_router_demo():
     #
     # app.include_router(router)
     # END OF DEMO
-    ui.label('Shows up on /sub-path')
+    ui.label('This is content on /sub-path')
diff --git a/website/documentation/content/section_action_events.py b/website/documentation/content/section_action_events.py
index 7f00e829..e7cb5fab 100644
--- a/website/documentation/content/section_action_events.py
+++ b/website/documentation/content/section_action_events.py
@@ -151,6 +151,46 @@ def lifecycle_demo():
     dt = datetime.now()
 
 
+@doc.auto_execute
+@doc.demo('Custom error page', '''
+    You can use `@app.on_page_exception` to define a custom error page.
+
+    The handler must be a synchronous function that creates a page like a normal page function.
+    It can take the exception as an argument, but it is not required.
+    It overrides the default "sad face" error page, except when the error is re-raised.
+
+    The following example shows how to create a custom error page handler that only handles a specific exception.
+    The default error page handler is still used for all other exceptions.
+
+    Note: Showing the traceback may not be a good idea in production, as it may leak sensitive information.
+
+    *Added in version 2.20.0*
+''')
+def error_page_demo():
+    from nicegui import app
+    import traceback
+
+    @app.on_page_exception
+    def timeout_error_page(exception: Exception) -> None:
+        if not isinstance(exception, TimeoutError):
+            raise exception
+        with ui.column().classes('absolute-center items-center gap-8'):
+            ui.icon('sym_o_timer', size='xl')
+            ui.label(f'{exception}').classes('text-2xl')
+            ui.code(traceback.format_exc(chain=False))
+
+    @ui.page('/raise_timeout_error')
+    def raise_timeout_error():
+        raise TimeoutError('This took too long')
+
+    @ui.page('/raise_runtime_error')
+    def raise_runtime_error():
+        raise RuntimeError('Something is wrong')
+
+    ui.link('Raise timeout error (custom error page)', '/raise_timeout_error')
+    ui.link('Raise runtime error (default error page)', '/raise_runtime_error')
+
+
 @doc.demo(app.shutdown)
 def shutdown_demo():
     from nicegui import app
diff --git a/website/documentation/content/section_configuration_deployment.py b/website/documentation/content/section_configuration_deployment.py
index 83e85a60..342c02be 100644
--- a/website/documentation/content/section_configuration_deployment.py
+++ b/website/documentation/content/section_configuration_deployment.py
@@ -29,14 +29,14 @@ doc.intro(run_documentation)
     You can enable native mode for NiceGUI by specifying `native=True` in the `ui.run` function.
     To customize the initial window size and display mode, use the `window_size` and `fullscreen` parameters respectively.
     Additionally, you can provide extra keyword arguments via `app.native.window_args` and `app.native.start_args`.
-    Pick any parameter as it is defined by the internally used [pywebview module](https://pywebview.flowrl.com/guide/api.html)
+    Pick any parameter as it is defined by the internally used [pywebview module](https://pywebview.flowrl.com/api)
     for the `webview.create_window` and `webview.start` functions.
     Note that these keyword arguments will take precedence over the parameters defined in `ui.run`.
 
     Additionally, you can change `webview.settings` via `app.native.settings`.
 
     In native mode the `app.native.main_window` object allows you to access the underlying window.
-    It is an async version of [`Window` from pywebview](https://pywebview.flowrl.com/guide/api.html#window-object).
+    It is an async version of [`Window` from pywebview](https://pywebview.flowrl.com/api/#webview-window).
 ''', tab=lambda: ui.label('NiceGUI'))
 def native_mode_demo():
     from nicegui import app
diff --git a/website/documentation/content/section_styling_appearance.py b/website/documentation/content/section_styling_appearance.py
index de9fb244..dc02dd1d 100644
--- a/website/documentation/content/section_styling_appearance.py
+++ b/website/documentation/content/section_styling_appearance.py
@@ -203,3 +203,67 @@ def overwrite_tailwind_style_demo():
 
 doc.intro(dark_mode_documentation)
 doc.intro(add_style_documentation)
+
+
+@doc.demo('Using other Vue UI frameworks', '''
+    **This is an experimental feature.**
+    **Many NiceGUI elements are likely to break, and the API is subject to change.**
+
+    NiceGUI uses the [Quasar Framework](https://quasar.dev/) by default.
+    However, you can also try to use other Vue UI frameworks
+    like [Element Plus](https://element-plus.org/en-US/) or [Vuetify](https://vuetifyjs.com/en/).
+    To do so, you need to add the framework's JavaScript and CSS file to the head of your HTML document
+    and configure NiceGUI accordingly by extending or replacing `app.config.vue_config_script`.
+
+    *Added in NiceGUI 2.21.0*
+''')
+def other_vue_ui_frameworks_demo():
+    from nicegui import app
+
+    # ui.add_body_html('''
+    #     <link rel="stylesheet" href="//unpkg.com/element-plus/dist/index.css" />
+    #     <script defer src="https://unpkg.com/element-plus"></script>
+    # ''')
+    # app.config.vue_config_script += '''
+    #     app.use(ElementPlus);
+    # '''
+
+    with ui.element('el-button').on('click', lambda: ui.notify('Hi!')):
+        ui.html('Element Plus button')
+
+    ui.button('Quasar button', on_click=lambda: ui.notify('Ho!'))
+
+    # END OF DEMO
+    ui.add_css('''
+        el-button {
+            border: 1px solid #dcdfe6;
+            border-radius: 4px;
+            color: #606266;
+            cursor: pointer;
+            font-weight: 500;
+            font-size: 14px;
+            height: 32px;
+            line-height: 1;
+            padding: 8px 15px;
+        }
+        el-button:hover {
+            background-color: rgb(235.9,245.3,255);
+            border-color: rgb(197.7,225.9,255);
+            color: rgb(64,158,255);
+        }
+        el-button:active {
+            border-color: rgb(64,158,255);
+        }
+        body.dark el-button {
+            border-color: #4c4d4f;
+            color: #cfd3dc;
+        }
+        body.dark el-button:hover {
+            background-color: rgb(24.4, 33.8, 43.5);
+            border-color: rgb(33.2, 61.4, 90.5);
+            color: rgb(64, 158, 255);
+        }
+        body.dark el-button:active {
+            border-color: rgb(64, 158, 255);
+        }
+    ''')
diff --git a/website/documentation/demo.py b/website/documentation/demo.py
index 54ba1329..454ae14e 100644
--- a/website/documentation/demo.py
+++ b/website/documentation/demo.py
@@ -20,7 +20,7 @@ def demo(f: Callable, *, lazy: bool = True, tab: Optional[Union[str, Callable]]
         with browser_window(title=tab,
                             classes='w-full max-w-[44rem] min-[1500px]:max-w-[20rem] min-h-[10rem] browser-window') as window:
             if lazy:
-                spinner = ui.spinner(size='lg').props('thickness=2')
+                spinner = ui.image('/static/loading.gif').classes('w-8 h-8').props('no-spinner no-transition')
 
                 async def handle_intersection():
                     window.remove(spinner)
diff --git a/website/sponsors.json b/website/sponsors.json
index dd552db6..64c029f5 100644
--- a/website/sponsors.json
+++ b/website/sponsors.json
@@ -1,5 +1,5 @@
 {
   "top": [],
-  "total": 20,
-  "contributors": 158
+  "total": 21,
+  "contributors": 162
 }
diff --git a/website/star.py b/website/star.py
index 874bc728..3f339bbe 100644
--- a/website/star.py
+++ b/website/star.py
@@ -7,19 +7,10 @@ STYLE = '''
           0% { transform: translateX(6em) rotate(432deg); }
         100% { transform: translateX(0)   rotate(0);      }
     }
-    @keyframes star-pulse {
-          0% { scale: 1.0; }
-         60% { scale: 1.0; }
-         70% { scale: 1.2; }
-         80% { scale: 1.0; }
-         90% { scale: 1.2; }
-        100% { scale: 1.0; }
-    }
     .star {
         height: 1.75em;
         fill: white;
-        animation: 1s ease-in-out 6s both star-tumble,
-                   3s ease-in-out 3s infinite star-pulse;
+        animation: 1s ease-in-out 6s both star-tumble;
     }
     .star:hover {
         fill: rgb(250, 204, 21);


Test patch:
diff --git a/nicegui/testing/user_interaction.py b/nicegui/testing/user_interaction.py
index e45e211d..0a0875a8 100644
--- a/nicegui/testing/user_interaction.py
+++ b/nicegui/testing/user_interaction.py
@@ -94,6 +94,13 @@ class UserInteraction(Generic[T]):
                     else:
                         element._is_showing_popup = True  # pylint: disable=protected-access
                     return self
+                elif isinstance(element, ui.radio):
+                    if isinstance(element.options, dict):
+                        target_value = next((k for k, v in element.options.items() if v == self.target), '')
+                    else:
+                        target_value = self.target
+                    element.value = target_value
+                    return self
 
                 for listener in element._event_listeners.values():  # pylint: disable=protected-access
                     if listener.element_id != element.id:
diff --git a/tests/test_alternate_ui_frameworks.py b/tests/test_alternate_ui_frameworks.py
new file mode 100644
index 00000000..9239031a
--- /dev/null
+++ b/tests/test_alternate_ui_frameworks.py
@@ -0,0 +1,19 @@
+from nicegui import app, ui
+from nicegui.testing import Screen
+
+
+def test_quasar(screen: Screen):
+    ui.element('q-btn')
+
+    screen.open('/')
+    assert screen.find_by_tag('button')
+
+
+def test_element_plus(screen: Screen):
+    ui.add_body_html('<script defer src="https://unpkg.com/element-plus"></script>')
+    app.config.vue_config_script = 'app.use(ElementPlus);'
+
+    ui.element('el-button')
+
+    screen.open('/')
+    assert screen.find_by_tag('button')
diff --git a/tests/test_mermaid.py b/tests/test_mermaid.py
index f243d2bc..c9fc44db 100644
--- a/tests/test_mermaid.py
+++ b/tests/test_mermaid.py
@@ -86,14 +86,28 @@ def test_error(screen: Screen):
 def test_click_mermaid_node(security_level: str, screen: Screen):
     ui.mermaid('''
         flowchart TD;
-            A;
-            click A call document.write("Success")
+            X;
+            click X call document.write("Clicked X")
+    ''', config={'securityLevel': security_level})
+
+    ui.mermaid('''
+        flowchart TD;
+            Y;
+            click Y call document.write("Clicked Y")
+    ''', config={'securityLevel': security_level})
+
+    ui.mermaid('''
+        flowchart TD;
+            Z;
+            click Z call document.write("Clicked Z")
     ''', config={'securityLevel': security_level})
 
     screen.open('/')
-    screen.click('A')
+    screen.click('Y')
     screen.wait(0.5)
+    screen.should_not_contain('Clicked X')
+    screen.should_not_contain('Clicked Z')
     if security_level == 'loose':
-        screen.should_contain('Success')
+        screen.should_contain('Clicked Y')
     else:
-        screen.should_not_contain('Success')
+        screen.should_not_contain('Clicked Y')
diff --git a/tests/test_radio.py b/tests/test_radio.py
index 054de2b0..0282476f 100644
--- a/tests/test_radio.py
+++ b/tests/test_radio.py
@@ -1,5 +1,5 @@
 from nicegui import ui
-from nicegui.testing import Screen
+from nicegui.testing import Screen, User
 
 
 def test_radio_click(screen: Screen):
@@ -56,3 +56,18 @@ def test_radio_set_options(screen: Screen):
 
     screen.click('clear')
     screen.should_contain('Value: None')
+
+
+async def test_radio_click_with_user(user: User):
+    radio = ui.radio(['A', 'B', 'C'])
+    ui.label().bind_text_from(radio, 'value', lambda x: f'Value: {x}')
+
+    await user.open('/')
+    user.find('A', kind=ui.radio).click()
+    await user.should_see('Value: A')
+
+    user.find('B', kind=ui.radio).click()
+    await user.should_see('Value: B')
+
+    user.find('B', kind=ui.radio).click()  # already selected, should not change
+    await user.should_see('Value: B')
diff --git a/tests/test_serving_files.py b/tests/test_serving_files.py
index 582e5329..0784f1aa 100644
--- a/tests/test_serving_files.py
+++ b/tests/test_serving_files.py
@@ -55,13 +55,13 @@ def test_adding_single_media_file(screen: Screen):
 
 @pytest.mark.parametrize('url_path', ['/static', '/static/'])
 def test_get_from_static_files_dir(url_path: str, screen: Screen):
-    app.add_static_files(url_path, Path(TEST_DIR).parent)
+    app.add_static_files(url_path, Path(TEST_DIR).parent, max_cache_age=3456)
 
     screen.open('/')
     with httpx.Client() as http_client:
         r = http_client.get(f'http://localhost:{Screen.PORT}/static/examples/slideshow/slides/slide1.jpg')
         assert r.status_code == 200
-        assert 'max-age=' in r.headers['Cache-Control']
+        assert 'max-age=3456' in r.headers['Cache-Control']
 
 
 def test_404_for_non_existing_static_file(screen: Screen):
@@ -76,13 +76,13 @@ def test_404_for_non_existing_static_file(screen: Screen):
 
 
 def test_adding_single_static_file(screen: Screen):
-    url_path = app.add_static_file(local_file=IMAGE_FILE)
+    url_path = app.add_static_file(local_file=IMAGE_FILE, max_cache_age=3456)
 
     screen.open('/')
     with httpx.Client() as http_client:
         r = http_client.get(f'http://localhost:{Screen.PORT}{url_path}')
         assert r.status_code == 200
-        assert 'max-age=' in r.headers['Cache-Control']
+        assert 'max-age=3456' in r.headers['Cache-Control']
 
 
 def test_auto_serving_file_from_image_source(screen: Screen):
@@ -118,3 +118,23 @@ def test_mimetypes_of_static_files(screen: Screen):
     response = requests.get(f'http://localhost:{Screen.PORT}/_nicegui/{__version__}/static/nicegui.css', timeout=5)
     assert response.status_code == 200
     assert response.headers['Content-Type'].startswith('text/css')
+
+
+def test_cache_control_header_of_static_files(screen: Screen):
+    ui.markdown()
+    app.add_static_files('/static', Path(TEST_DIR).parent)
+
+    screen.open('/')
+
+    # resources are served with cache-control headers from `ui.run`
+    response1 = requests.get(f'http://localhost:{Screen.PORT}/_nicegui/{__version__}/static/nicegui.css', timeout=5)
+    assert 'immutable' in response1.headers.get('Cache-Control', '')
+
+    # dynamic resources are _not_ served with cache-control headers from `ui.run`
+    response2 = requests.get(
+        f'http://localhost:{Screen.PORT}/_nicegui/{__version__}/dynamic_resources/codehilite.css', timeout=5)
+    assert 'immutable' not in response2.headers.get('Cache-Control', '')
+
+    # static resources are _not_ served with cache-control headers from `ui.run`
+    response3 = requests.get(f'http://localhost:{Screen.PORT}/static/examples/slideshow/slides/slide1.jpg', timeout=5)
+    assert 'immutable' not in response3.headers.get('Cache-Control', '')