Repository: zauberzeug/nicegui
Pull request #4355 (2025-02-16 05:38:14)

Run init of app.storage.general as early as possible
When introducing Redis storage in #4074, the initialization of persistent storage was made async to not block the main thread when loading data. For that to work the init must be done async -- which introduced #4352. This PR fixes the issue by moving the init of `app.storage.general` from app startup to import time and using `asyncio.run` to create a temporary event loop for the init.


Patch:
diff --git a/.gitattributes b/.gitattributes
index df6e86d8..45f6cf44 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -11,6 +11,7 @@ nicegui/static/quasar.*            linguist-vendored
 nicegui/static/socket.*            linguist-vendored
 nicegui/static/tailwindcss.*       linguist-vendored
 nicegui/static/vue.*               linguist-vendored
+nicegui/language.py                linguist-generated
 website/**                         linguist-documentation
 website/static/fuse.js@*           linguist-vendored
 examples/**                        linguist-documentation
diff --git a/.github/workflows/citation.yml b/.github/workflows/citation.yml
index 8e5bb9f8..35071396 100644
--- a/.github/workflows/citation.yml
+++ b/.github/workflows/citation.yml
@@ -17,6 +17,6 @@ jobs:
           github_token: ${{ secrets.GITHUB_TOKEN }}
           zenodo_token: ${{ secrets.ZENODO_TOKEN }}
           zenodo_deposition_id: 13623775
-          zenodo_publish: true
+          zenodo_publish: false
           commit_message: "Update CITATION.cff"
           citation_cff: true
diff --git a/CITATION.cff b/CITATION.cff
index 34c1491a..a5103ccb 100644
--- a/CITATION.cff
+++ b/CITATION.cff
@@ -11,7 +11,7 @@ title: 'NiceGUI: Web-based user interfaces with Python. The nice way.'
 version: 2.11.1
 date-released: '2025-02-11'
 url: https://github.com/zauberzeug/nicegui
-doi: 10.5281/zenodo.14850111
+doi: 10.5281/zenodo.7785516
 identifiers:
   - description: DOI for this application's record on Zenodo
     type: doi
diff --git a/README.md b/README.md
index facf84ea..8906b77a 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,8 @@
 <a href="https://nicegui.io/#about">
   <img src="https://raw.githubusercontent.com/zauberzeug/nicegui/main/screenshot.png"
-    width="200" align="right" alt="Try online!" />
+    width="200" align="right" alt="Try online!"
+    style="background: white; position: relative; z-index: 1;"
+  />
 </a>
 
 # NiceGUI
@@ -23,6 +25,7 @@ NiceGUI is available as [PyPI package](https://pypi.org/project/nicegui/), [Dock
 [![GitHub issues](https://img.shields.io/github/issues/zauberzeug/nicegui?color=blue)](https://github.com/zauberzeug/nicegui/issues)
 [![GitHub forks](https://img.shields.io/github/forks/zauberzeug/nicegui)](https://github.com/zauberzeug/nicegui/network)
 [![GitHub stars](https://img.shields.io/github/stars/zauberzeug/nicegui)](https://github.com/zauberzeug/nicegui/stargazers)
+[![DOI](https://zenodo.org/badge/365250183.svg)](https://doi.org/10.5281/zenodo.7785516)
 
 ## Features
 
diff --git a/examples/webserial/main.py b/examples/webserial/main.py
index cbae50a2..3ccf0b39 100755
--- a/examples/webserial/main.py
+++ b/examples/webserial/main.py
@@ -27,7 +27,7 @@ def page():
         'button': False,
     }
 
-    ui.add_body_html(f'<script>{(Path(__file__).parent / "script.js").read_text()}</script>')
+    ui.add_body_html(f'<script>{(Path(__file__).parent / "script.js").read_text(encoding="utf-8")}</script>')
 
     ui.button('Connect', on_click=connect).bind_visibility_from(state, 'connected', value=False)
     ui.button('Disconnect', on_click=disconnect).bind_visibility_from(state, 'connected')
diff --git a/fetch_google_fonts.py b/fetch_google_fonts.py
index a42ffea4..0216066e 100755
--- a/fetch_google_fonts.py
+++ b/fetch_google_fonts.py
@@ -39,4 +39,4 @@ for font_url in re.findall(r'url\((.*?)\)', css):
 css = css.replace('https://fonts.gstatic.com/s/materialicons/v140', 'fonts')
 css = css.replace('https://fonts.gstatic.com/s/roboto/v30', 'fonts')
 css = css.replace("'", '"')
-Path('nicegui/static/fonts.css').write_text(css)
+Path('nicegui/static/fonts.css').write_text(css, encoding='utf-8')
diff --git a/fetch_languages.py b/fetch_languages.py
new file mode 100755
index 00000000..b64f8b94
--- /dev/null
+++ b/fetch_languages.py
@@ -0,0 +1,12 @@
+#!/usr/bin/env python3
+from pathlib import Path
+
+codes = sorted(path.name.split('.')[0] for path in Path('nicegui/static/lang').glob('*.umd.prod.js'))
+
+with (Path(__file__).parent / 'nicegui' / 'language.py').open('w', encoding='utf-8') as f:
+    f.write('from typing import Literal\n')
+    f.write('\n')
+    f.write('Language = Literal[\n')
+    for code in codes:
+        f.write(f"    '{code}',\n")
+    f.write(']\n')
diff --git a/fetch_sponsors.py b/fetch_sponsors.py
index 40147b1b..26b7d291 100755
--- a/fetch_sponsors.py
+++ b/fetch_sponsors.py
@@ -98,7 +98,7 @@ Path('website/sponsors.json').write_text(json.dumps({
     'top': [s['login'] for s in sponsors if s['tier_amount'] >= 100 and not s['tier_is_one_time']],
     'total': len(sponsors),
     'contributors': len(contributors),
-}, indent=2) + '\n')
+}, indent=2) + '\n', encoding='utf-8')
 
 sponsor_html = '<p align="center">\n'
 for sponsor in sponsors:
@@ -106,13 +106,13 @@ for sponsor in sponsors:
         sponsor_html += f'  <a href="{sponsor["url"]}"><img src="{sponsor["url"]}.png" width="50px" alt="{sponsor["name"]}" /></a>\n'
 sponsor_html += '</p>'
 readme_path = Path('README.md')
-readme_content = readme_path.read_text()
+readme_content = readme_path.read_text(encoding='utf-8')
 updated_content = re.sub(
     r'<!-- SPONSORS -->.*?<!-- SPONSORS -->',
     f'<!-- SPONSORS -->\n{sponsor_html}\n<!-- SPONSORS -->',
     readme_content,
     flags=re.DOTALL,
 )
-readme_path.write_text(updated_content)
+readme_path.write_text(updated_content, encoding='utf-8')
 
 print('README.md and sponsors.json updated successfully.')
diff --git a/fetch_tailwind.py b/fetch_tailwind.py
index 616dd5b2..db514421 100755
--- a/fetch_tailwind.py
+++ b/fetch_tailwind.py
@@ -53,11 +53,11 @@ def get_soup(url: str) -> BeautifulSoup:
     path = Path('/tmp/nicegui_tailwind') / url.split('/')[-1]
     path.parent.mkdir(parents=True, exist_ok=True)
     if path.exists():
-        html = path.read_text()
+        html = path.read_text(encoding='utf-8')
     else:
         req = requests.get(url, timeout=5)
         html = req.text
-        path.write_text(html)
+        path.write_text(html, encoding='utf-8')
     return BeautifulSoup(html, 'html.parser')
 
 
@@ -89,7 +89,8 @@ def generate_type_files(properties: List[Property]) -> None:
     for property_ in properties:
         if not property_.members:
             continue
-        with (Path(__file__).parent / 'nicegui' / 'tailwind_types' / f'{property_.snake_title}.py').open('w') as f:
+        with (Path(__file__).parent / 'nicegui' / 'tailwind_types' / f'{property_.snake_title}.py') \
+                .open('w', encoding='utf-8') as f:
             f.write('from typing import Literal\n')
             f.write('\n')
             f.write(f'{property_.pascal_title} = Literal[\n')
@@ -100,7 +101,7 @@ def generate_type_files(properties: List[Property]) -> None:
 
 def generate_tailwind_file(properties: List[Property]) -> None:
     """Generate the tailwind.py file."""
-    with (Path(__file__).parent / 'nicegui' / 'tailwind.py').open('w') as f:
+    with (Path(__file__).parent / 'nicegui' / 'tailwind.py').open('w', encoding='utf-8') as f:
         f.write('# pylint: disable=too-many-lines\n')
         f.write('from __future__ import annotations\n')
         f.write('\n')
diff --git a/nicegui/app/app.py b/nicegui/app/app.py
index 02e1d33e..cc7fc7d9 100644
--- a/nicegui/app/app.py
+++ b/nicegui/app/app.py
@@ -46,7 +46,6 @@ class App(FastAPI):
         self._disconnect_handlers: List[Union[Callable[..., Any], Awaitable]] = []
         self._exception_handlers: List[Callable[..., Any]] = [log.exception]
 
-        self.on_startup(self.storage.general.initialize)
         self.on_shutdown(self.storage.on_shutdown)
 
     @property
diff --git a/nicegui/binding.py b/nicegui/binding.py
index ee857420..2c2bf8fe 100644
--- a/nicegui/binding.py
+++ b/nicegui/binding.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import asyncio
+import copyreg
 import dataclasses
 import time
 import weakref
@@ -36,7 +37,8 @@ bindings: DefaultDict[Tuple[int, str], List] = defaultdict(list)
 bindable_properties: Dict[Tuple[int, str], weakref.finalize] = {}
 active_links: List[Tuple[Any, str, Any, str, Callable[[Any], Any]]] = []
 
-T = TypeVar('T', bound=type)
+TC = TypeVar('TC', bound=type)
+T = TypeVar('T')
 
 
 def _has_attribute(obj: Union[object, Mapping], name: str) -> Any:
@@ -170,6 +172,8 @@ class BindableProperty:
 
     def __set__(self, owner: Any, value: Any) -> None:
         has_attr = hasattr(owner, '___' + self.name)
+        if not has_attr:
+            _make_copyable(type(owner))
         value_changed = has_attr and getattr(owner, '___' + self.name) != value
         if has_attr and not value_changed:
             return
@@ -217,7 +221,7 @@ def reset() -> None:
 
 
 @dataclass_transform()
-def bindable_dataclass(cls: Optional[T] = None, /, *,
+def bindable_dataclass(cls: Optional[TC] = None, /, *,
                        bindable_fields: Optional[Iterable[str]] = None,
                        **kwargs: Any) -> Union[Type[DataclassInstance], IdentityFunction]:
     """A decorator that transforms a class into a dataclass with bindable fields.
@@ -255,3 +259,23 @@ def bindable_dataclass(cls: Optional[T] = None, /, *,
         bindable_property.__set_name__(dataclass, field_name)
         setattr(dataclass, field_name, bindable_property)
     return dataclass
+
+
+def _make_copyable(cls: Type[T]) -> None:
+    """Tell the copy module to update the ``bindable_properties`` dictionary when an object is copied."""
+    if cls in copyreg.dispatch_table:
+        return
+
+    def _pickle_function(obj: T) -> Tuple[Callable[..., T], Tuple[Any, ...]]:
+        reduced = obj.__reduce__()
+        assert isinstance(reduced, tuple)
+        creator = reduced[0]
+
+        def creator_with_hook(*args, **kwargs) -> T:
+            copy = creator(*args, **kwargs)
+            for attr_name in dir(obj):
+                if (id(obj), attr_name) in bindable_properties:
+                    bindable_properties[(id(copy), attr_name)] = copy
+            return copy
+        return (creator_with_hook, *reduced[1:])
+    copyreg.pickle(cls, _pickle_function)
diff --git a/nicegui/client.py b/nicegui/client.py
index c18deb94..f5b4fc9a 100644
--- a/nicegui/client.py
+++ b/nicegui/client.py
@@ -23,6 +23,7 @@ from .javascript_request import JavaScriptRequest
 from .logging import log
 from .observables import ObservableDict
 from .outbox import Outbox
+from .translations import translations
 from .version import __version__
 
 if TYPE_CHECKING:
@@ -157,6 +158,7 @@ class Client:
                 'favicon_url': get_favicon_url(self.page, prefix),
                 'dark': str(self.page.resolve_dark()),
                 'language': self.page.resolve_language(),
+                'translations': translations[self.page.resolve_language()],
                 'prefix': prefix,
                 'tailwind': core.app.config.tailwind,
                 'prod_js': core.app.config.prod_js,
diff --git a/nicegui/dependencies.py b/nicegui/dependencies.py
index 0db08f48..3f6af730 100644
--- a/nicegui/dependencies.py
+++ b/nicegui/dependencies.py
@@ -71,7 +71,7 @@ def register_vue_component(path: Path) -> Component:
         if key in vue_components and vue_components[key].path == path:
             return vue_components[key]
         assert key not in vue_components, f'Duplicate VUE component {key}'
-        v = vbuild.VBuild(path.name, path.read_text())
+        v = vbuild.VBuild(path.name, path.read_text(encoding='utf-8'))
         vue_components[key] = VueComponent(key=key, name=name, path=path, html=v.html, script=v.script, style=v.style)
         return vue_components[key]
     if path.suffix == '.js':
diff --git a/nicegui/elements/leaflet.js b/nicegui/elements/leaflet.js
index cb1f3cb1..f54f7f41 100644
--- a/nicegui/elements/leaflet.js
+++ b/nicegui/elements/leaflet.js
@@ -127,7 +127,11 @@ export default {
   },
   methods: {
     add_layer(layer, id) {
-      const l = L[layer.type](...layer.args);
+      let obj = L;
+      for (const part of layer.type.split(".")) {
+        obj = obj[part];
+      }
+      const l = obj(...layer.args);
       l.id = id;
       l.addTo(this.map);
     },
diff --git a/nicegui/elements/leaflet.py b/nicegui/elements/leaflet.py
index 2538a1b0..2a5bbfb9 100644
--- a/nicegui/elements/leaflet.py
+++ b/nicegui/elements/leaflet.py
@@ -16,6 +16,7 @@ class Leaflet(Element, component='leaflet.js', default_classes='nicegui-leaflet'
     from .leaflet_layers import GenericLayer as generic_layer
     from .leaflet_layers import Marker as marker
     from .leaflet_layers import TileLayer as tile_layer
+    from .leaflet_layers import WmsLayer as wms_layer
 
     center = binding.BindableProperty(lambda sender, value: cast(Leaflet, sender).set_center(value))
     zoom = binding.BindableProperty(lambda sender, value: cast(Leaflet, sender).set_zoom(value))
diff --git a/nicegui/elements/leaflet_layers.py b/nicegui/elements/leaflet_layers.py
index 56da8c55..651450e5 100644
--- a/nicegui/elements/leaflet_layers.py
+++ b/nicegui/elements/leaflet_layers.py
@@ -31,6 +31,18 @@ class TileLayer(Layer):
         }
 
 
+@dataclass(**KWONLY_SLOTS)
+class WmsLayer(Layer):
+    url_template: str
+    options: Dict = field(default_factory=dict)
+
+    def to_dict(self) -> Dict:
+        return {
+            'type': 'tileLayer.wms',
+            'args': [self.url_template, self.options],
+        }
+
+
 @dataclass(**KWONLY_SLOTS)
 class Marker(Layer):
     latlng: Tuple[float, float]
diff --git a/nicegui/elements/notification.js b/nicegui/elements/notification.js
index 73c62030..e5acf945 100644
--- a/nicegui/elements/notification.js
+++ b/nicegui/elements/notification.js
@@ -4,10 +4,10 @@ export default {
   mounted() {
     this.notification = Quasar.Notify.create(this.convertedOptions);
   },
-  updated() {
-    this.notification(this.convertedOptions);
-  },
   methods: {
+    update_notification() {
+      this.notification(this.convertedOptions);
+    },
     dismiss() {
       this.notification();
     },
diff --git a/nicegui/elements/notification.py b/nicegui/elements/notification.py
index 61203890..aa1e0eb4 100644
--- a/nicegui/elements/notification.py
+++ b/nicegui/elements/notification.py
@@ -77,7 +77,6 @@ class Notification(Element, component='notification.js'):
                 'closeBtn': close_button,
                 'timeout': (timeout or 0) * 1000,
                 'group': False,
-                'attrs': {'data-id': f'nicegui-dialog-{self.id}'},
             }
             if type is not None:
                 self._props['options']['type'] = type
@@ -199,3 +198,7 @@ class Notification(Element, component='notification.js'):
 
     def set_visibility(self, visible: bool) -> None:
         raise NotImplementedError('Use `dismiss()` to remove the notification. See #3670 for more information.')
+
+    def update(self) -> None:
+        super().update()
+        self.run_method('update_notification')
diff --git a/nicegui/elements/rating.py b/nicegui/elements/rating.py
new file mode 100644
index 00000000..3a502f6e
--- /dev/null
+++ b/nicegui/elements/rating.py
@@ -0,0 +1,52 @@
+from typing import List, Optional, Union
+
+from ..events import Handler, ValueChangeEventArguments
+from .mixins.disableable_element import DisableableElement
+from .mixins.value_element import ValueElement
+
+
+class Rating(ValueElement, DisableableElement):
+
+    def __init__(self,
+                 value: Optional[float] = None,
+                 max: int = 5,  # pylint: disable=redefined-builtin
+                 icon: Optional[str] = None,
+                 icon_selected: Optional[str] = None,
+                 icon_half: Optional[str] = None,
+                 color: Optional[Union[str, List[str]]] = 'primary',
+                 size: Optional[str] = None,
+                 on_change: Optional[Handler[ValueChangeEventArguments]] = None,
+                 ) -> None:
+        """Rating
+
+        This element is based on Quasar's `QRating <https://quasar.dev/vue-components/rating>`_ component.
+
+        *Added in version 2.12.0*
+
+        :param value: initial value (default: ``None``)
+        :param max: maximum rating, number of icons (default: 5)
+        :param icon: name of icons to be displayed (default: star)
+        :param icon_selected: name of an icon to be displayed when selected (default: same as ``icon``)
+        :param icon_half: name of an icon to be displayed when half-selected (default: same as ``icon``)
+        :param color: color(s) of the icons (Quasar, Tailwind, or CSS colors or ``None``, default: "primary")
+        :param size: size in CSS units, including unit name or standard size name (xs|sm|md|lg|xl), examples: 16px, 2rem
+        :param on_change: callback to execute when selection changes
+        """
+        super().__init__(tag='q-rating', value=value, on_value_change=on_change)
+
+        self._props['max'] = max
+
+        if icon:
+            self._props['icon'] = icon
+
+        if color:
+            self._props['color'] = color
+
+        if icon_selected:
+            self._props['icon-selected'] = icon_selected
+
+        if icon_half:
+            self._props['icon-half'] = icon_half
+
+        if size:
+            self._props['size'] = size
diff --git a/nicegui/elements/scene.py b/nicegui/elements/scene.py
index 58b4486c..bc6b9e9c 100644
--- a/nicegui/elements/scene.py
+++ b/nicegui/elements/scene.py
@@ -112,7 +112,7 @@ class Scene(Element,
         self.objects: Dict[str, Object3D] = {}
         self.stack: List[Union[Object3D, SceneObject]] = [SceneObject()]
         self._click_handlers = [on_click] if on_click else []
-        self._props['click_events'] = click_events
+        self._props['click_events'] = click_events[:]
         self._drag_start_handlers = [on_drag_start] if on_drag_start else []
         self._drag_end_handlers = [on_drag_end] if on_drag_end else []
         self.on('init', self._handle_init)
diff --git a/nicegui/error.py b/nicegui/error.py
index 93a2cbdb..192dd365 100644
--- a/nicegui/error.py
+++ b/nicegui/error.py
@@ -5,7 +5,7 @@ from .elements.column import Column as column
 from .elements.html import Html as html
 from .elements.label import Label as label
 
-SAD_FACE_SVG = (Path(__file__).parent / 'static' / 'sad_face.svg').read_text()
+SAD_FACE_SVG = (Path(__file__).parent / 'static' / 'sad_face.svg').read_text(encoding='utf-8')
 
 
 def error_content(status_code: int, exception: Union[str, Exception] = '') -> None:
diff --git a/nicegui/functions/refreshable.py b/nicegui/functions/refreshable.py
index b46b3f8d..7ef44d9a 100644
--- a/nicegui/functions/refreshable.py
+++ b/nicegui/functions/refreshable.py
@@ -60,8 +60,11 @@ class refreshable(Generic[_P, _T]):
     def __init__(self, func: Callable[_P, Union[_T, Awaitable[_T]]]) -> None:
         """Refreshable UI functions
 
-        The `@ui.refreshable` decorator allows you to create functions that have a `refresh` method.
+        The ``@ui.refreshable`` decorator allows you to create functions that have a ``refresh`` method.
         This method will automatically delete all elements created by the function and recreate them.
+
+        For decorating refreshable methods in classes, there is a ``@ui.refreshable_method`` decorator,
+        which is equivalent but prevents static type checking errors.
         """
         self.func = func
         self.instance = None
diff --git a/nicegui/functions/style.py b/nicegui/functions/style.py
index 7ea88437..dde3cbd3 100644
--- a/nicegui/functions/style.py
+++ b/nicegui/functions/style.py
@@ -23,7 +23,7 @@ def add_css(content: Union[str, Path]) -> None:
     :param content: CSS content (string or file path)
     """
     if helpers.is_file(content):
-        content = Path(content).read_text()
+        content = Path(content).read_text(encoding='utf-8')
     add_head_html(f'<style>{content}</style>')
 
 
@@ -41,7 +41,7 @@ def add_scss(content: Union[str, Path], *, indented: bool = False) -> None:
         raise ImportError('Please run "pip install libsass" to use SASS or SCSS.')
 
     if helpers.is_file(content):
-        content = Path(content).read_text()
+        content = Path(content).read_text(encoding='utf-8')
     add_css(sass.compile(string=str(content).strip(), indented=indented))
 
 
diff --git a/nicegui/language.py b/nicegui/language.py
index 76b9778b..f4bb1863 100644
--- a/nicegui/language.py
+++ b/nicegui/language.py
@@ -6,10 +6,13 @@ Language = Literal[
     'az-Latn',
     'bg',
     'bn',
+    'bs-BA',
     'ca',
     'cs',
     'da',
     'de',
+    'de-CH',
+    'de-DE',
     'el',
     'en-GB',
     'en-US',
@@ -23,6 +26,7 @@ Language = Literal[
     'fr',
     'gn',
     'he',
+    'hi',
     'hr',
     'hu',
     'id',
@@ -36,9 +40,11 @@ Language = Literal[
     'lt',
     'lu',
     'lv',
+    'mk',
     'ml',
     'mm',
     'ms',
+    'ms-MY',
     'my',
     'nb-NO',
     'nl',
@@ -55,6 +61,7 @@ Language = Literal[
     'sv',
     'ta',
     'th',
+    'tl',
     'tr',
     'ug',
     'uk',
diff --git a/nicegui/nicegui.py b/nicegui/nicegui.py
index 1d9d2bc2..f03df8df 100644
--- a/nicegui/nicegui.py
+++ b/nicegui/nicegui.py
@@ -43,8 +43,8 @@ class SocketIoApp(socketio.ASGIApp):
 
 
 core.app = app = App(default_response_class=NiceGUIJSONResponse, lifespan=_lifespan)
-# NOTE we use custom json module which wraps orjson
-core.sio = sio = socketio.AsyncServer(async_mode='asgi', cors_allowed_origins='*', json=json)
+core.app.storage.general.initialize_sync()
+core.sio = sio = socketio.AsyncServer(async_mode='asgi', cors_allowed_origins='*', json=json)  # custom orjson wrapper
 sio_app = SocketIoApp(socketio_server=sio, socketio_path='/socket.io')
 app.mount('/_nicegui_ws/', sio_app)
 
diff --git a/nicegui/page.py b/nicegui/page.py
index c4f46d24..45dc690a 100644
--- a/nicegui/page.py
+++ b/nicegui/page.py
@@ -90,7 +90,7 @@ class page:
         """Return whether the page should use dark mode."""
         return self.dark if self.dark is not ... else core.app.config.dark
 
-    def resolve_language(self) -> Optional[str]:
+    def resolve_language(self) -> Language:
         """Return the language of the page."""
         return self.language if self.language is not ... else core.app.config.language
 
diff --git a/nicegui/persistence/file_persistent_dict.py b/nicegui/persistence/file_persistent_dict.py
index 5bf6973d..8b5a60f5 100644
--- a/nicegui/persistence/file_persistent_dict.py
+++ b/nicegui/persistence/file_persistent_dict.py
@@ -28,6 +28,16 @@ class FilePersistentDict(PersistentDict):
         except Exception:
             log.warning(f'Could not load storage file {self.filepath}')
 
+    def initialize_sync(self) -> None:
+        try:
+            if self.filepath.exists():
+                data = json.loads(self.filepath.read_text(encoding=self.encoding))
+            else:
+                data = {}
+            self.update(data)
+        except Exception:
+            log.warning(f'Could not load storage file {self.filepath}')
+
     def backup(self) -> None:
         """Back up the data to the given file path."""
         if not self.filepath.exists():
diff --git a/nicegui/persistence/persistent_dict.py b/nicegui/persistence/persistent_dict.py
index c22817cc..c5b1f412 100644
--- a/nicegui/persistence/persistent_dict.py
+++ b/nicegui/persistence/persistent_dict.py
@@ -9,5 +9,9 @@ class PersistentDict(observables.ObservableDict, abc.ABC):
     async def initialize(self) -> None:
         """Load initial data from the persistence layer."""
 
+    @abc.abstractmethod
+    def initialize_sync(self) -> None:
+        """Load initial data from the persistence layer in a synchronous context."""
+
     async def close(self) -> None:
         """Clean up the persistence layer."""
diff --git a/nicegui/persistence/redis_persistent_dict.py b/nicegui/persistence/redis_persistent_dict.py
index 1725df80..b5955eab 100644
--- a/nicegui/persistence/redis_persistent_dict.py
+++ b/nicegui/persistence/redis_persistent_dict.py
@@ -3,6 +3,7 @@ from ..logging import log
 from .persistent_dict import PersistentDict
 
 try:
+    import redis as redis_sync
     import redis.asyncio as redis
     optional_features.register('redis')
 except ImportError:
@@ -14,6 +15,7 @@ class RedisPersistentDict(PersistentDict):
     def __init__(self, *, url: str, id: str, key_prefix: str = 'nicegui:') -> None:  # pylint: disable=redefined-builtin
         if not optional_features.has('redis'):
             raise ImportError('Redis is not installed. Please run "pip install nicegui[redis]".')
+        self.url = url
         self.redis_client = redis.from_url(
             url,
             health_check_interval=10,
@@ -30,18 +32,39 @@ class RedisPersistentDict(PersistentDict):
         try:
             data = await self.redis_client.get(self.key)
             self.update(json.loads(data) if data else {})
+            self._start_listening()
         except Exception:
             log.warning(f'Could not load data from Redis with key {self.key}')
-        await self.pubsub.subscribe(self.key + 'changes')
 
+    def initialize_sync(self) -> None:
+        """Load initial data from Redis and start listening for changes in a synchronous context."""
+        with redis_sync.from_url(
+            self.url,
+            health_check_interval=10,
+            socket_connect_timeout=5,
+            retry_on_timeout=True,
+            socket_keepalive=True,
+        ) as redis_client_sync:
+            try:
+                data = redis_client_sync.get(self.key)
+                self.update(json.loads(data) if data else {})
+                self._start_listening()
+            except Exception:
+                log.warning(f'Could not load data from Redis with key {self.key}')
+
+    def _start_listening(self) -> None:
         async def listen():
+            await self.pubsub.subscribe(self.key + 'changes')
             async for message in self.pubsub.listen():
                 if message['type'] == 'message':
                     new_data = json.loads(message['data'])
                     if new_data != self:
                         self.update(new_data)
 
-        background_tasks.create(listen(), name=f'redis-listen-{self.key}')
+        if core.loop and core.loop.is_running():
+            background_tasks.create(listen(), name=f'redis-listen-{self.key}')
+        else:
+            core.app.on_startup(listen())
 
     def publish(self) -> None:
         """Publish the data to Redis and notify other instances."""
diff --git a/nicegui/templates/index.html b/nicegui/templates/index.html
index 8b47bc1b..4a6a9130 100644
--- a/nicegui/templates/index.html
+++ b/nicegui/templates/index.html
@@ -38,8 +38,8 @@
 
     <div id="app"></div>
     <div id="popup" aria-hidden="true">
-      <span>Connection lost.</span>
-      <span>Trying to reconnect...</span>
+      <span>{{ translations.connection_lost }}</span>
+      <span>{{ translations.trying_to_reconnect }}</span>
     </div>
     <script type="module">
       const app = createApp(parseElements(String.raw`{{ elements | safe }}`), {
diff --git a/nicegui/translations.py b/nicegui/translations.py
new file mode 100644
index 00000000..801ed722
--- /dev/null
+++ b/nicegui/translations.py
@@ -0,0 +1,42 @@
+translations = {
+    'en-US': {
+        'connection_lost': 'Connection lost.',
+        'trying_to_reconnect': 'Trying to reconnect...'
+    },
+    'zh-CN': {
+        'connection_lost': '连接丢失',
+        'trying_to_reconnect': '正在尝试重新连接...'
+    },
+    'zh-TW': {
+        'connection_lost': '連線丟失',
+        'trying_to_reconnect': '正在嘗試重新連線...'
+    },
+    'ja-JP': {
+        'connection_lost': '接続が切断されました',
+        'trying_to_reconnect': '再接続を試みています...'
+    },
+    'ko-KR': {
+        'connection_lost': '연결이 끊어졌습니다',
+        'trying_to_reconnect': '다시 연결하는 중...'
+    },
+    'fr-FR': {
+        'connection_lost': 'Connexion perdue',
+        'trying_to_reconnect': 'Tentative de reconnexion...'
+    },
+    'de-DE': {
+        'connection_lost': 'Verbindung verloren',
+        'trying_to_reconnect': 'Versuche erneut zu verbinden...'
+    },
+    'it-IT': {
+        'connection_lost': 'Connessione persa',
+        'trying_to_reconnect': 'Tentativo di riconnessione...'
+    },
+    'es-ES': {
+        'connection_lost': 'Conexión perdida',
+        'trying_to_reconnect': 'Intentando reconectar...'
+    },
+    'ru-RU': {
+        'connection_lost': 'Соединение потеряно',
+        'trying_to_reconnect': 'Попытка переподключения...'
+    }
+}
diff --git a/nicegui/ui.py b/nicegui/ui.py
index 452db9d5..ad2713c3 100644
--- a/nicegui/ui.py
+++ b/nicegui/ui.py
@@ -83,6 +83,7 @@ __all__ = [
     'query',
     'radio',
     'range',
+    'rating',
     'refreshable',
     'refreshable_method',
     'restructured_text',
@@ -192,6 +193,7 @@ from .elements.pyplot import Pyplot as pyplot
 from .elements.query import Query as query
 from .elements.radio import Radio as radio
 from .elements.range import Range as range  # pylint: disable=redefined-builtin
+from .elements.rating import Rating as rating
 from .elements.restructured_text import ReStructuredText as restructured_text
 from .elements.row import Row as row
 from .elements.scene import Scene as scene
diff --git a/npm.py b/npm.py
index d998ce1c..3344eb90 100755
--- a/npm.py
+++ b/npm.py
@@ -53,7 +53,7 @@ def download_buffered(url: str) -> Path:
     return filepath
 
 
-DEPENDENCIES = (root_path / 'DEPENDENCIES.md').open('w')
+DEPENDENCIES = (root_path / 'DEPENDENCIES.md').open('w', encoding='utf-8')
 DEPENDENCIES.write('# Included Web Dependencies\n\n')
 KNOWN_LICENSES = {
     'UNKNOWN': 'UNKNOWN',
@@ -67,7 +67,7 @@ KNOWN_LICENSES = {
 # Create a hidden folder to work in.
 tmp = cleanup(root_path / '.npm')
 
-dependencies: Dict[str, dict] = json.loads((root_path / 'npm.json').read_text())
+dependencies: Dict[str, dict] = json.loads((root_path / 'npm.json').read_text(encoding='utf-8'))
 for key, dependency in dependencies.items():
     if names is not None and key not in names:
         continue
@@ -77,7 +77,7 @@ for key, dependency in dependencies.items():
 
     # Get package info from NPM.
     package_name = dependency.get('package', key)
-    npm_data = json.loads(download_buffered(f'https://registry.npmjs.org/{package_name}').read_text())
+    npm_data = json.loads(download_buffered(f'https://registry.npmjs.org/{package_name}').read_text(encoding='utf-8'))
     npm_version = dependency.get('version') or dependency.get('version', npm_data['dist-tags']['latest'])
     npm_tarball = npm_data['versions'][npm_version]['dist']['tarball']
     license_ = 'UNKNOWN'
@@ -91,7 +91,7 @@ for key, dependency in dependencies.items():
     # Handle the special case of tailwind. Hopefully remove this soon.
     if 'download' in dependency:
         download_path = download_buffered(dependency['download'])
-        content = download_path.read_text()
+        content = download_path.read_text(encoding='utf-8')
         MSG = (
             'console.warn("cdn.tailwindcss.com should not be used in production. '
             'To use Tailwind CSS in production, install it as a PostCSS plugin or use the Tailwind CLI: '
@@ -100,7 +100,7 @@ for key, dependency in dependencies.items():
         if MSG not in content:
             raise ValueError(f'Expected to find "{MSG}" in {download_path}')
         content = content.replace(MSG, '')
-        prepare(destination / dependency['rename']).write_text(content)
+        prepare(destination / dependency['rename']).write_text(content, encoding='utf-8')
 
     # Download and extract.
     tgz_file = prepare(Path(tmp, key, f'{key}.tgz'))
@@ -124,25 +124,25 @@ for key, dependency in dependencies.items():
             Path(tmp, key, extracted.name).rename(newfile)
 
             if 'GLTFLoader' in filename:
-                content = newfile.read_text()
+                content = newfile.read_text(encoding='utf-8')
                 MSG = '../utils/BufferGeometryUtils.js'
                 if MSG not in content:
                     raise ValueError(f'Expected to find "{MSG}" in {filename}')
                 content = content.replace(MSG, 'BufferGeometryUtils')
-                newfile.write_text(content)
+                newfile.write_text(content, encoding='utf-8')
 
             if 'DragControls.js' in filename:
-                content = newfile.read_text()
+                content = newfile.read_text(encoding='utf-8')
                 MSG = '_selected = findGroup( _intersections[ 0 ].object )'
                 if MSG not in content:
                     raise ValueError(f'Expected to find "{MSG}" in {filename}')
                 content = content.replace(MSG, MSG + ' || _intersections[ 0 ].object')
-                newfile.write_text(content)
+                newfile.write_text(content, encoding='utf-8')
 
             if 'mermaid.esm.min.mjs' in filename:
-                content = newfile.read_text()
+                content = newfile.read_text(encoding='utf-8')
                 content = re.sub(r'"\./chunks/mermaid.esm.min/(.*?)\.mjs"', r'"\1"', content)
-                newfile.write_text(content)
+                newfile.write_text(content, encoding='utf-8')
 
     # Delete destination folder if empty.
     if not any(destination.iterdir()):
diff --git a/poetry.lock b/poetry.lock
index 6ea44956..adae54e6 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1710,13 +1710,13 @@ pyspark = ["pyspark (>=3.5.0)"]
 
 [[package]]
 name = "nicegui-highcharts"
-version = "2.0.2"
+version = "2.1.0"
 description = "Add Highcharts elements to your NiceGUI app."
 optional = true
 python-versions = "<4.0,>=3.8"
 files = [
-    {file = "nicegui_highcharts-2.0.2-py3-none-any.whl", hash = "sha256:9004ce220bf24e37064f40fe1da38cfbd905cb92c650f31efa7abdc838c0a805"},
-    {file = "nicegui_highcharts-2.0.2.tar.gz", hash = "sha256:3240790086ce7c87171e8ab1e4e59bb2496ad0028b2b6df8da9961f5f428b5af"},
+    {file = "nicegui_highcharts-2.1.0-py3-none-any.whl", hash = "sha256:09e437caa31a434497b6b82cd0d9c1100982fca87549182a4b853f0e98c9c95b"},
+    {file = "nicegui_highcharts-2.1.0.tar.gz", hash = "sha256:343833f508304cd5df938cffc35fc6f2340d4250b50f2a7be5161469f5429a58"},
 ]
 
 [package.dependencies]
diff --git a/pyproject.toml b/pyproject.toml
index f15a3e21..97f2add1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -172,5 +172,4 @@ disable = [
     "W0102", # Dangerous default value as argument
     "W0718", # Catching too general exception
     "W1203", # Use % formatting in logging functions
-    "W1514" # Using open without explicitly specifying an encoding
 ]
diff --git a/website/documentation/content/leaflet_documentation.py b/website/documentation/content/leaflet_documentation.py
index 37a7434e..46e27e60 100644
--- a/website/documentation/content/leaflet_documentation.py
+++ b/website/documentation/content/leaflet_documentation.py
@@ -21,11 +21,14 @@ def main_demo() -> None:
     You can find more map styles at <https://leaflet-extras.github.io/leaflet-providers/preview/>.
     Each call to `tile_layer` stacks upon the previous ones.
     So if you want to change the map style, you have to remove the default one first.
+
+    *Updated in version 2.12.0: Both WMTS and WMS map services are supported.*
 ''')
 def map_style() -> None:
-    m = ui.leaflet(center=(51.505, -0.090), zoom=3)
-    m.clear_layers()
-    m.tile_layer(
+    ui.label('Web Map Tile Service')
+    map1 = ui.leaflet(center=(51.505, -0.090), zoom=3)
+    map1.clear_layers()
+    map1.tile_layer(
         url_template=r'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png',
         options={
             'maxZoom': 17,
@@ -35,6 +38,16 @@ def map_style() -> None:
         },
     )
 
+    ui.label('Web Map Service')
+    map2 = ui.leaflet(center=(51.505, -0.090), zoom=3)
+    map2.clear_layers()
+    map2.wms_layer(
+        url_template='http://ows.mundialis.de/services/service?',
+        options={
+            'layers': 'TOPO-WMS,OSM-Overlay-WMS'
+        },
+    )
+
 
 @doc.demo('Add Markers on Click', '''
     You can add markers to the map with `marker`.
diff --git a/website/documentation/content/rating_documentation.py b/website/documentation/content/rating_documentation.py
new file mode 100644
index 00000000..20f390fb
--- /dev/null
+++ b/website/documentation/content/rating_documentation.py
@@ -0,0 +1,49 @@
+from nicegui import ui
+
+from . import doc
+
+
+@doc.demo(ui.rating)
+def main_demo() -> None:
+    ui.rating(value=4)
+
+
+@doc.demo('Customize icons', '''
+    You can customize name and size of the icons.
+    Optionally, unselected, selected or half-selected values can have different icons.
+''')
+def customize_icons():
+    ui.rating(
+        value=3.5,
+        size='lg',
+        icon='sentiment_dissatisfied',
+        icon_selected='sentiment_satisfied',
+        icon_half='sentiment_neutral',
+    )
+    ui.rating(
+        value=3.5,
+        size='lg',
+        icon='star',
+        icon_selected='star',
+        icon_half='star_half',
+    )
+
+
+@doc.demo('Customize color', '''
+    You can customize the color of the rating either by providing a single color or a range of different colors.
+''')
+def rating_color():
+    ui.rating(value=3, color='red-10')
+    ui.rating(value=5, color=['green-2', 'green-4', 'green-6', 'green-8', 'green-10'])
+
+
+@doc.demo('Maximum rating', '''
+    This demo shows how to change the maximum possible rating
+    as well as binding the value to a slider.
+''')
+def rating_scale():
+    slider = ui.slider(value=5, min=0, max=10)
+    ui.rating(max=10, icon='circle').bind_value(slider)
+
+
+doc.reference(ui.range)
diff --git a/website/documentation/content/refreshable_documentation.py b/website/documentation/content/refreshable_documentation.py
index 67f45cce..fb7fc696 100644
--- a/website/documentation/content/refreshable_documentation.py
+++ b/website/documentation/content/refreshable_documentation.py
@@ -112,6 +112,9 @@ def global_scope():
         ui.button('Refresh', on_click=time.refresh)
 
     ui.link('Open demo', demo)
+    # END OF DEMO
+    ui.label('Note: This demo may not work as expected on nicegui.io due to load balancing across multiple servers. '
+             'For accurate results, please run this example locally on your machine.').classes('text-gray-600')
 
 
 @doc.demo('Local scope (variant A)', '''
@@ -145,7 +148,7 @@ def local_scope_b():
     from datetime import datetime
 
     class Clock:
-        @ui.refreshable
+        @ui.refreshable_method
         def time(self):
             ui.label(f'Time: {datetime.now()}')
 
diff --git a/website/documentation/content/section_controls.py b/website/documentation/content/section_controls.py
index e57509ba..a9737795 100644
--- a/website/documentation/content/section_controls.py
+++ b/website/documentation/content/section_controls.py
@@ -16,6 +16,7 @@ from . import (
     number_documentation,
     radio_documentation,
     range_documentation,
+    rating_documentation,
     select_documentation,
     slider_documentation,
     switch_documentation,
@@ -39,6 +40,7 @@ doc.intro(checkbox_documentation)
 doc.intro(switch_documentation)
 doc.intro(slider_documentation)
 doc.intro(range_documentation)
+doc.intro(rating_documentation)
 doc.intro(joystick_documentation)
 doc.intro(input_documentation)
 doc.intro(textarea_documentation)
diff --git a/website/header.py b/website/header.py
index 33109e68..bfdfc658 100644
--- a/website/header.py
+++ b/website/header.py
@@ -7,8 +7,8 @@ from . import svg
 from .search import Search
 from .star import add_star
 
-HEADER_HTML = (Path(__file__).parent / 'static' / 'header.html').read_text()
-STYLE_CSS = (Path(__file__).parent / 'static' / 'style.css').read_text()
+HEADER_HTML = (Path(__file__).parent / 'static' / 'header.html').read_text(encoding='utf-8')
+STYLE_CSS = (Path(__file__).parent / 'static' / 'style.css').read_text(encoding='utf-8')
 
 
 def add_head_html() -> None:
diff --git a/website/main_page.py b/website/main_page.py
index 64d29321..73c82823 100644
--- a/website/main_page.py
+++ b/website/main_page.py
@@ -8,7 +8,7 @@ from .examples import examples
 from .header import add_head_html, add_header
 from .style import example_link, features, heading, link_target, section_heading, subtitle, title
 
-SPONSORS = json.loads((Path(__file__).parent / 'sponsors.json').read_text())
+SPONSORS = json.loads((Path(__file__).parent / 'sponsors.json').read_text(encoding='utf-8'))
 
 
 def create() -> None:
diff --git a/website/svg.py b/website/svg.py
index f16a7240..88cab543 100644
--- a/website/svg.py
+++ b/website/svg.py
@@ -3,11 +3,11 @@ from pathlib import Path
 from nicegui import ui
 
 PATH = Path(__file__).parent / 'static'
-HAPPY_FACE_SVG = (PATH / 'happy_face.svg').read_text()
-NICEGUI_WORD_SVG = (PATH / 'nicegui_word.svg').read_text()
-GITHUB_SVG = (PATH / 'github.svg').read_text()
-DISCORD_SVG = (PATH / 'discord.svg').read_text()
-REDDIT_SVG = (PATH / 'reddit.svg').read_text()
+HAPPY_FACE_SVG = (PATH / 'happy_face.svg').read_text(encoding='utf-8')
+NICEGUI_WORD_SVG = (PATH / 'nicegui_word.svg').read_text(encoding='utf-8')
+GITHUB_SVG = (PATH / 'github.svg').read_text(encoding='utf-8')
+DISCORD_SVG = (PATH / 'discord.svg').read_text(encoding='utf-8')
+REDDIT_SVG = (PATH / 'reddit.svg').read_text(encoding='utf-8')
 
 
 def face(half: bool = False) -> ui.html:


Test patch:
diff --git a/nicegui/testing/user_plugin.py b/nicegui/testing/user_plugin.py
index 5bed990e..0ca74207 100644
--- a/nicegui/testing/user_plugin.py
+++ b/nicegui/testing/user_plugin.py
@@ -5,6 +5,7 @@ import httpx
 import pytest
 
 from nicegui import Client, core, ui
+from nicegui.functions.download import download
 from nicegui.functions.navigate import Navigate
 from nicegui.functions.notify import notify
 
@@ -46,6 +47,7 @@ async def user(nicegui_reset_globals,  # noqa: F811, pylint: disable=unused-argu
             yield User(client)
     ui.navigate = Navigate()
     ui.notify = notify
+    ui.download = download
 
 
 @pytest.fixture
@@ -59,3 +61,4 @@ async def create_user(nicegui_reset_globals,  # noqa: F811, pylint: disable=unus
         yield lambda: User(httpx.AsyncClient(transport=httpx.ASGITransport(core.app), base_url='http://test'))
     ui.navigate = Navigate()
     ui.notify = notify
+    ui.download = download
diff --git a/tests/test_binding.py b/tests/test_binding.py
index 19d539dd..3c469337 100644
--- a/tests/test_binding.py
+++ b/tests/test_binding.py
@@ -1,10 +1,11 @@
+import copy
 import weakref
 from typing import Dict, Optional, Tuple
 
 from selenium.webdriver.common.keys import Keys
 
 from nicegui import binding, ui
-from nicegui.testing import Screen
+from nicegui.testing import Screen, User
 
 
 def test_ui_select_with_tuple_as_key(screen: Screen):
@@ -128,6 +129,31 @@ def test_bindable_dataclass(screen: Screen):
     assert binding.active_links[0][1] == 'not_bindable'
 
 
+async def test_copy_instance_with_bindable_property(user: User):
+    @binding.bindable_dataclass
+    class Number:
+        value: int = 1
+
+    x = Number()
+    y = copy.copy(x)
+
+    ui.label().bind_text_from(x, 'value', lambda v: f'x={v}')
+    assert len(binding.bindings) == 1
+    assert len(binding.active_links) == 0
+
+    ui.label().bind_text_from(y, 'value', lambda v: f'y={v}')
+    assert len(binding.bindings) == 2
+    assert len(binding.active_links) == 0
+
+    await user.open('/')
+    await user.should_see('x=1')
+    await user.should_see('y=1')
+
+    y.value = 2
+    await user.should_see('x=1')
+    await user.should_see('y=2')
+
+
 def test_automatic_cleanup(screen: Screen):
     class Model:
         value = binding.BindableProperty()
diff --git a/tests/test_download.py b/tests/test_download.py
index b821a631..4e584118 100644
--- a/tests/test_download.py
+++ b/tests/test_download.py
@@ -25,7 +25,7 @@ def test_download_text_file(screen: Screen, test_route: str):  # pylint: disable
     screen.open('/')
     screen.click('Download')
     screen.wait(0.5)
-    assert (screen_plugin.DOWNLOAD_DIR / 'test.txt').read_text() == 'test'
+    assert (screen_plugin.DOWNLOAD_DIR / 'test.txt').read_text(encoding='utf-8') == 'test'
 
 
 def test_downloading_local_file_as_src(screen: Screen):
@@ -46,4 +46,4 @@ def test_download_raw_data(screen: Screen):
     screen.open('/')
     screen.click('download')
     screen.wait(0.5)
-    assert (screen_plugin.DOWNLOAD_DIR / 'test.txt').read_text() == 'test'
+    assert (screen_plugin.DOWNLOAD_DIR / 'test.txt').read_text(encoding='utf-8') == 'test'
diff --git a/tests/test_notification.py b/tests/test_notification.py
index eb62c8e1..5aceba35 100644
--- a/tests/test_notification.py
+++ b/tests/test_notification.py
@@ -37,3 +37,22 @@ def test_dismiss(screen: Screen):
     screen.wait(1.5)
     screen.should_not_contain('Hi!')
     assert len(b.client.layout.default_slot.children) == 1
+
+
+def test_no_reset_by_other_notifications(screen: Screen):
+    # see #4373
+    ui.button('Button A', on_click=lambda: ui.notification('Notification A', timeout=1.0))
+    ui.button('Button B', on_click=lambda: ui.notification('Notification B', timeout=1.0))
+    ui.button('Button C', on_click=lambda: ui.notification('Notification C', timeout=1.0))
+    ui.button('Button D', on_click=lambda: ui.notification('Notification D', timeout=1.0))
+
+    screen.open('/')
+    screen.click('Button A')
+    screen.wait(1)
+    screen.click('Button B')
+    screen.wait(1)
+    screen.click('Button C')
+    screen.wait(1)
+    screen.click('Button D')
+    screen.should_contain('Notification D')
+    screen.should_not_contain('Notification A')
diff --git a/tests/test_rating.py b/tests/test_rating.py
new file mode 100644
index 00000000..ac71d620
--- /dev/null
+++ b/tests/test_rating.py
@@ -0,0 +1,18 @@
+from nicegui import ui
+from nicegui.testing import Screen
+
+
+def test_rating_click(screen: Screen):
+    rating = ui.rating(value=2)
+    ui.label().bind_text_from(rating, 'value', lambda x: f'Value: {x}')
+
+    screen.open('/')
+    rating_icons = screen.find_all_by_class('q-rating__icon-container')
+    rating_icons[0].click()
+    screen.should_contain('Value: 1')
+
+    rating_icons[3].click()
+    screen.should_contain('Value: 4')
+
+    rating_icons[3].click()  # already selected, should unselect
+    screen.should_contain('Value: 0')
diff --git a/tests/test_storage.py b/tests/test_storage.py
index a1a2a988..a92c6098 100644
--- a/tests/test_storage.py
+++ b/tests/test_storage.py
@@ -92,7 +92,7 @@ async def test_access_user_storage_from_fastapi(screen: Screen):
         assert response.status_code == 200
         assert response.text == '"OK"'
         await asyncio.sleep(0.5)  # wait for storage to be written
-        assert next(Path('.nicegui').glob('storage-user-*.json')).read_text('utf-8') == '{"msg":"yes"}'
+        assert next(Path('.nicegui').glob('storage-user-*.json')).read_text(encoding='utf-8') == '{"msg":"yes"}'
 
 
 def test_access_user_storage_on_interaction(screen: Screen):
@@ -106,7 +106,7 @@ def test_access_user_storage_on_interaction(screen: Screen):
     screen.open('/')
     screen.click('switch')
     screen.wait(0.5)
-    assert next(Path('.nicegui').glob('storage-user-*.json')).read_text('utf-8') == '{"test_switch":true}'
+    assert next(Path('.nicegui').glob('storage-user-*.json')).read_text(encoding='utf-8') == '{"test_switch":true}'
 
 
 def test_access_user_storage_from_button_click_handler(screen: Screen):
@@ -118,7 +118,8 @@ def test_access_user_storage_from_button_click_handler(screen: Screen):
     screen.open('/')
     screen.click('test')
     screen.wait(1)
-    assert next(Path('.nicegui').glob('storage-user-*.json')).read_text('utf-8') == '{"inner_function":"works"}'
+    assert \
+        next(Path('.nicegui').glob('storage-user-*.json')).read_text(encoding='utf-8') == '{"inner_function":"works"}'
 
 
 async def test_access_user_storage_from_background_task(screen: Screen):
@@ -131,7 +132,7 @@ async def test_access_user_storage_from_background_task(screen: Screen):
 
     screen.ui_run_kwargs['storage_secret'] = 'just a test'
     screen.open('/')
-    assert next(Path('.nicegui').glob('storage-user-*.json')).read_text('utf-8') == '{"subtask":"works"}'
+    assert next(Path('.nicegui').glob('storage-user-*.json')).read_text(encoding='utf-8') == '{"subtask":"works"}'
 
 
 def test_user_and_general_storage_is_persisted(screen: Screen):
@@ -165,7 +166,7 @@ def test_rapid_storage(screen: Screen):
     screen.open('/')
     screen.click('test')
     screen.wait(0.5)
-    assert Path('.nicegui', 'storage-general.json').read_text('utf-8') == '{"one":1,"two":2,"three":3}'
+    assert Path('.nicegui', 'storage-general.json').read_text(encoding='utf-8') == '{"one":1,"two":2,"three":3}'
 
 
 def test_tab_storage_is_local(screen: Screen):
@@ -277,7 +278,7 @@ def test_deepcopy(screen: Screen):
     screen.open('/')
     screen.should_contain('Loaded')
     screen.wait(0.5)
-    assert Path('.nicegui', 'storage-general.json').read_text('utf-8') == '{"a":{"b":0}}'
+    assert Path('.nicegui', 'storage-general.json').read_text(encoding='utf-8') == '{"a":{"b":0}}'
 
 
 def test_missing_storage_secret(screen: Screen):