Repository: zauberzeug/nicegui
Pull request #4368 (2025-02-18 16:58:05)

Add ruff quote-style
A super small PR that adds simple quotes settings for ruff to make sure we stay in the correct coding style.

codingpaula: Hello @EmberLightVFX,

thanks for bringing this up here! We currently only use the linting function of ruff since the ruff formatter in our opinion doesn't work well with the way NiceGUI elements are written.
We usually write elements like this button for example:
```python
ui.button('Click me!', icon='left_click', on_click=self.on_click) \
    .bind_visibility_from(self.property, 'active') \
    .props('flat rounded dense').classes('m-0 p-2') \
    .tooltip('This is just a test')
```
If we now format this with ruff instead of autopep8, it becomes this:
```python
ui.button("Click me!", icon="left_click", on_click=self.on_click).bind_visibility_from(
    self.property, "active"
).props("flat rounded dense").classes("m-0 p-2").tooltip("This is just a test")
```
which is a lot harder to read and identify in one glance. Because of this, for the time being we want to stick to autopep8 as our formatter.

As far as I could find out from the ruff documentation the setting you suggest doesn't influence the `ruff check` command. So, I'd argue against having a `[tool.ruff.format]` section in our `pyproject.tom` since it would wrongly indicate that we use ruff for formatting.
But while at it, I also noticed that we don't have an autopep8 config section in our `pyproject.toml`, just some configuration in the vs code settings. Would you be fine with "rerouting" this PR to moving the autopep8 config into the `pyproject.toml` instead of adding the ruff format config?
EmberLightVFX: I see. Yeah Ruff doesn't seem to be able to format the code as you described.
I don't know exactly how the autopep8 settings would look like in pyproject.toml so I think it's better for you to add it correctly. If you do it in this PR or if you make a new one is ok with me.

Patch:
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/.vscode/settings.json b/.vscode/settings.json
index b72710b4..280468d1 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,5 +1,4 @@
 {
-  "autopep8.args": ["--max-line-length=120"],
   "editor.defaultFormatter": "esbenp.prettier-vscode",
   "editor.formatOnSave": true,
   "prettier.printWidth": 120,
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
index 537259e7..b64f8b94 100755
--- a/fetch_languages.py
+++ b/fetch_languages.py
@@ -3,7 +3,7 @@ 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') as f:
+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')
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/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 f54f7f41..935815a7 100644
--- a/nicegui/elements/leaflet.js
+++ b/nicegui/elements/leaflet.js
@@ -17,8 +17,8 @@ export default {
     await Promise.all([
       loadResource(window.path_prefix + `${this.resource_path}/leaflet/leaflet.css`),
       loadResource(window.path_prefix + `${this.resource_path}/leaflet/leaflet.js`),
-      ...this.additional_resources.map((resource) => loadResource(resource)),
     ]);
+    await Promise.all(this.additional_resources.map((resource) => loadResource(resource)));
     if (this.draw_control) {
       await Promise.all([
         loadResource(window.path_prefix + `${this.resource_path}/leaflet-draw/leaflet.draw.css`),
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/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/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/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/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/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/pyproject.toml b/pyproject.toml
index f15a3e21..4171a9d5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -116,6 +116,9 @@ module = [
 ]
 ignore_missing_imports = true
 
+[tool.autopep8]
+max-line-length = 120
+
 [tool.ruff]
 target-version = "py38"
 indent-width = 4
@@ -172,5 +175,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/refreshable_documentation.py b/website/documentation/content/refreshable_documentation.py
index 23459cc2..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)', '''
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_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):