Repository: zauberzeug/nicegui
Pull request #4324 (2025-02-08 07:26:04)

Add zh-CN & zh-TW support when server down.
When the client fails to connect to NiceGUI, a prompt will appear in the lower left corner of the client: `Connection Lost. Trying to reconnect...`. This is not difficult to understand for those who know English. However, for those who do not understand English, it undoubtedly reduces the user experience. 

This pull request adds support for Simplified Chinese and Traditional Chinese. After using `ui.run(language='zh-CN')` and the browser is fully loaded, if the server is closed, the correct display on the user's browser will be `连接丢失` instead of `Connection Lost`. If an undefined language is set， the default English prompt message will be displayed. I am still thinking of a better way for developers to customize this content.

> ui.run(language='zh-CN')
![zh-CN](https://github.com/user-attachments/assets/7c38efa1-4798-465d-b68a-484d5b2f302a)

> ui.run(language='zh-TW')
![zh-TW](https://github.com/user-attachments/assets/12ed2f86-8dc8-493f-89a6-8fe948fd430f)

> ui.run() # Specify another language or do not specify any language
![en-US](https://github.com/user-attachments/assets/0d1034ac-26c1-402d-a960-82196f951979)



Yuerchu: I have added languages from other countries/regions as well, such as Japanese `ja-JP`:
![image](https://github.com/user-attachments/assets/5d68b6d2-3b90-4ae0-b6e0-75bc85128a5f)


Patch:
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 9ec63fa1..03c0ff52 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -120,7 +120,7 @@ jobs:
           provider: dockerhub
           short_description: "Web Based User Interface for Python with Buttons, Dialogs, Markdown, 3D Scenes and Plots"
 
-  update_version_and_sponsors:
+  update_version:
     needs: docker
     runs-on: ubuntu-latest
     steps:
@@ -134,16 +134,12 @@ jobs:
         run: pip install httpx
       - name: Update version in pyproject.toml
         run: python .github/workflows/update_pyproject.py ${GITHUB_REF#refs/tags/}
-      - name: Update sponsors in README and on website
-        run: python .github/workflows/fetch_sponsors.py
-        env:
-          GITHUB_TOKEN: ${{ github.token }}
       - name: Commit and push changes
         run: |
           git config --global user.name "github-actions[bot]"
           git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
-          git add pyproject.toml README.md website/sponsors.json
-          git commit -m "Update version and sponsors"
+          git add pyproject.toml
+          git commit -m "Update version"
           git push origin HEAD:main
 
   verify:
diff --git a/CITATION.cff b/CITATION.cff
index 1c63c7d2..34c1491a 100644
--- a/CITATION.cff
+++ b/CITATION.cff
@@ -8,10 +8,10 @@ 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.11.0
-date-released: '2025-02-04'
+version: 2.11.1
+date-released: '2025-02-11'
 url: https://github.com/zauberzeug/nicegui
-doi: 10.5281/zenodo.14800625
+doi: 10.5281/zenodo.14850111
 identifiers:
   - description: DOI for this application's record on Zenodo
     type: doi
diff --git a/.github/workflows/fetch_sponsors.py b/fetch_sponsors.py
similarity index 100%
rename from .github/workflows/fetch_sponsors.py
rename to fetch_sponsors.py
diff --git a/nicegui/binding.py b/nicegui/binding.py
index d047d958..ee857420 100644
--- a/nicegui/binding.py
+++ b/nicegui/binding.py
@@ -3,6 +3,7 @@ from __future__ import annotations
 import asyncio
 import dataclasses
 import time
+import weakref
 from collections import defaultdict
 from typing import (
     TYPE_CHECKING,
@@ -32,7 +33,7 @@ if TYPE_CHECKING:
 MAX_PROPAGATION_TIME = 0.01
 
 bindings: DefaultDict[Tuple[int, str], List] = defaultdict(list)
-bindable_properties: Dict[Tuple[int, str], Any] = {}
+bindable_properties: Dict[Tuple[int, str], weakref.finalize] = {}
 active_links: List[Tuple[Any, str, Any, str, Callable[[Any], Any]]] = []
 
 T = TypeVar('T', bound=type)
@@ -173,7 +174,8 @@ class BindableProperty:
         if has_attr and not value_changed:
             return
         setattr(owner, '___' + self.name, value)
-        bindable_properties[(id(owner), self.name)] = owner
+        key = (id(owner), str(self.name))
+        bindable_properties.setdefault(key, weakref.finalize(owner, lambda: bindable_properties.pop(key, None)))
         _propagate(owner, self.name)
         if value_changed and self._change_handler is not None:
             self._change_handler(owner, value)
@@ -198,9 +200,10 @@ def remove(objects: Iterable[Any]) -> None:
         ]
         if not binding_list:
             del bindings[key]
-    for (obj_id, name), obj in list(bindable_properties.items()):
-        if id(obj) in object_ids:
+    for (obj_id, name), finalizer in list(bindable_properties.items()):
+        if obj_id in object_ids:
             del bindable_properties[(obj_id, name)]
+            finalizer.detach()
 
 
 def reset() -> None:
diff --git a/nicegui/client.py b/nicegui/client.py
index 6ca1119b..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,
@@ -257,6 +259,8 @@ class Client:
         In contrast to connect handlers, disconnect handlers are not called during a reconnect.
         This behavior should be fixed in version 3.0.
         """
+        if socket_id not in self._socket_to_document_id:
+            return
         document_id = self._socket_to_document_id.pop(socket_id)
         self._cancel_delete_task(document_id)
         self._num_connections[document_id] -= 1
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/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/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/persistence/redis_persistent_dict.py b/nicegui/persistence/redis_persistent_dict.py
index d8033762..1725df80 100644
--- a/nicegui/persistence/redis_persistent_dict.py
+++ b/nicegui/persistence/redis_persistent_dict.py
@@ -63,4 +63,7 @@ class RedisPersistentDict(PersistentDict):
 
     def clear(self) -> None:
         super().clear()
-        self.redis_client.delete(self.key)
+        if core.loop:
+            background_tasks.create_lazy(self.redis_client.delete(self.key), name=f'redis-delete-{self.key}')
+        else:
+            core.app.on_startup(self.redis_client.delete(self.key))
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/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 9b1c392b..f15a3e21 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "nicegui"
-version = "2.11.0-dev"
+version = "2.11.1-dev"
 description = "Create web-based user interfaces with Python. The nice way."
 authors = ["Zauberzeug GmbH <info@zauberzeug.com>"]
 license = "MIT"
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/refreshable_documentation.py b/website/documentation/content/refreshable_documentation.py
index 67f45cce..23459cc2 100644
--- a/website/documentation/content/refreshable_documentation.py
+++ b/website/documentation/content/refreshable_documentation.py
@@ -145,7 +145,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/sponsors.json b/website/sponsors.json
index 3bcec265..902f902c 100644
--- a/website/sponsors.json
+++ b/website/sponsors.json
@@ -1,5 +1,5 @@
 {
   "top": [],
   "total": 21,
-  "contributors": 141
+  "contributors": 142
 }


Test patch:
diff --git a/tests/test_binding.py b/tests/test_binding.py
index 2c0ad2e3..19d539dd 100644
--- a/tests/test_binding.py
+++ b/tests/test_binding.py
@@ -1,3 +1,4 @@
+import weakref
 from typing import Dict, Optional, Tuple
 
 from selenium.webdriver.common.keys import Keys
@@ -125,3 +126,35 @@ def test_bindable_dataclass(screen: Screen):
     assert len(binding.bindings) == 2
     assert len(binding.active_links) == 1
     assert binding.active_links[0][1] == 'not_bindable'
+
+
+def test_automatic_cleanup(screen: Screen):
+    class Model:
+        value = binding.BindableProperty()
+
+        def __init__(self, value: str) -> None:
+            self.value = value
+
+    def create_model_and_label(value: str) -> Tuple[Model, weakref.ref, ui.label]:
+        model = Model(value)
+        label = ui.label(value).bind_text(model, 'value')
+        return id(model), weakref.ref(model), label
+
+    model_id1, ref1, label1 = create_model_and_label('first label')
+    model_id2, ref2, _label2 = create_model_and_label('second label')
+
+    def is_alive(ref: weakref.ref) -> bool:
+        return ref() is not None
+
+    def has_bindable_property(model_id: int) -> bool:
+        return any(obj_id == model_id for obj_id, _ in binding.bindable_properties)
+
+    screen.open('/')
+    screen.should_contain('first label')
+    screen.should_contain('second label')
+    assert is_alive(ref1) and has_bindable_property(model_id1)
+    assert is_alive(ref2) and has_bindable_property(model_id2)
+
+    binding.remove([label1])
+    assert not is_alive(ref1) and not has_bindable_property(model_id1)
+    assert is_alive(ref2) and has_bindable_property(model_id2)