From 2b7290a5f81397364fa89783cb01169e50735237 Mon Sep 17 00:00:00 2001 From: vraj2131 Date: Sat, 13 Jun 2026 00:28:34 -0400 Subject: [PATCH 1/2] Fix duplicate HTML when map.save() is called repeatedly Repeated save() calls appended duplicate JavaScript because SetIcon and ElementAddToElement created new render children with unique IDs on each pass. Use stable script names so repeated renders replace instead of append. Do not clear figure.html in render(); geopandas adds legend HTML directly there. Fixes #2237 --- folium/__init__.py | 2 +- folium/elements.py | 12 ++++++++++++ folium/figure.py | 10 ++++++++++ folium/folium.py | 3 ++- folium/map.py | 14 +++++++++++++- folium/plugins/dual_map.py | 3 ++- tests/test_map.py | 24 ++++++++++++++++++++++++ 7 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 folium/figure.py diff --git a/folium/__init__.py b/folium/__init__.py index 249fafa397..3e76aec5f1 100644 --- a/folium/__init__.py +++ b/folium/__init__.py @@ -4,7 +4,6 @@ CssLink, Div, Element, - Figure, Html, IFrame, JavascriptLink, @@ -29,6 +28,7 @@ Vega, VegaLite, ) +from folium.figure import Figure from folium.folium import Map from folium.map import ( FeatureGroup, diff --git a/folium/elements.py b/folium/elements.py index 753a55ad06..9faa2623d7 100644 --- a/folium/elements.py +++ b/folium/elements.py @@ -149,6 +149,18 @@ def __init__(self, element_name: str, element_parent_name: str): self.element_name = element_name self.element_parent_name = element_parent_name + def render(self, **kwargs): + figure = self.get_root() + assert isinstance( + figure, Figure + ), "You cannot render this Element if it is not in a Figure." + script = self._template.module.__dict__.get("script", None) + if script is not None: + figure.script.add_child( + Element(script(self, kwargs)), + name=f"{self.element_name}_add_to_{self.element_parent_name}", + ) + class IncludeStatement(MacroElement): """Generate an include statement on a class.""" diff --git a/folium/figure.py b/folium/figure.py new file mode 100644 index 0000000000..2baed5cd63 --- /dev/null +++ b/folium/figure.py @@ -0,0 +1,10 @@ +"""Folium-specific Figure subclass.""" + +from branca.element import Figure as BrancaFigure + +# Re-export branca Figure for folium maps. Rendering must not clear +# figure.html children that were added directly (e.g. geopandas legends). + + +class Figure(BrancaFigure): + """Figure used as the root container for folium maps.""" diff --git a/folium/folium.py b/folium/folium.py index 8983ce2721..f861cb71e7 100644 --- a/folium/folium.py +++ b/folium/folium.py @@ -8,9 +8,10 @@ from collections.abc import Sequence from typing import Any, Optional, Union -from branca.element import Element, Figure +from branca.element import Element from folium.elements import JSCSSMixin +from folium.figure import Figure from folium.map import Evented, FitBounds, Layer from folium.raster_layers import TileLayer from folium.template import Template diff --git a/folium/map.py b/folium/map.py index ce2e561a5b..c744195a9d 100644 --- a/folium/map.py +++ b/folium/map.py @@ -517,6 +517,18 @@ def __init__( self.marker = marker self.icon = icon + def render(self, **kwargs): + figure = self.get_root() + assert isinstance( + figure, Figure + ), "You cannot render this Element if it is not in a Figure." + script = self._template.module.__dict__.get("script", None) + if script is not None: + figure.script.add_child( + Element(script(self, kwargs)), + name=f"{self.marker.get_name()}_set_icon", + ) + def __init__( self, location: Optional[Sequence[float]] = None, @@ -557,7 +569,7 @@ def render(self): f"{self._name} location must be assigned when added directly to map." ) if self.icon: - self.add_child(self.SetIcon(marker=self, icon=self.icon)) + self.add_child(self.SetIcon(marker=self, icon=self.icon), name="set_icon") super().render() def set_icon(self, icon): diff --git a/folium/plugins/dual_map.py b/folium/plugins/dual_map.py index c410aaf994..24880487f1 100644 --- a/folium/plugins/dual_map.py +++ b/folium/plugins/dual_map.py @@ -1,6 +1,7 @@ -from branca.element import Figure, MacroElement +from branca.element import MacroElement from folium.elements import EventHandler, JSCSSMixin +from folium.figure import Figure from folium.folium import Map from folium.map import LayerControl from folium.template import Template diff --git a/tests/test_map.py b/tests/test_map.py index cf6635a0b9..581e603ff7 100644 --- a/tests/test_map.py +++ b/tests/test_map.py @@ -290,3 +290,27 @@ def test_icon_invalid_marker_colors(): pytest.warns(UserWarning, Icon, color="lila") pytest.warns(UserWarning, Icon, color=42) pytest.warns(UserWarning, Icon, color=None) + + +def test_repeated_save_produces_identical_html(tmp_path): + """Regression test for https://github.com/python-visualization/folium/issues/2237""" + m = Map(location=[40.75, -73.98], zoom_start=13) + locations = [ + [40.7829, -73.9654], + [40.7484, -73.9857], + [40.7580, -73.9855], + ] + for lat, lon in locations: + Marker([lat, lon], icon=Icon(color="blue", icon="info-sign")).add_to(m) + + path1 = tmp_path / "1.html" + path2 = tmp_path / "2.html" + path3 = tmp_path / "3.html" + m.save(path1) + m.save(path2) + m.save(path3) + + html1 = path1.read_text() + html2 = path2.read_text() + html3 = path3.read_text() + assert html1 == html2 == html3 From c55c2863fab64d7277b5b47ce065ae99744e78e7 Mon Sep 17 00:00:00 2001 From: vraj2131 Date: Sat, 13 Jun 2026 11:54:28 -0400 Subject: [PATCH 2/2] Remove unrelated changes from repeated save fix Keep only ElementAddToElement.render() and Marker.SetIcon.render() per review feedback. --- folium/__init__.py | 2 +- folium/figure.py | 10 ---------- folium/folium.py | 3 +-- folium/map.py | 2 +- folium/plugins/dual_map.py | 3 +-- 5 files changed, 4 insertions(+), 16 deletions(-) delete mode 100644 folium/figure.py diff --git a/folium/__init__.py b/folium/__init__.py index 3e76aec5f1..249fafa397 100644 --- a/folium/__init__.py +++ b/folium/__init__.py @@ -4,6 +4,7 @@ CssLink, Div, Element, + Figure, Html, IFrame, JavascriptLink, @@ -28,7 +29,6 @@ Vega, VegaLite, ) -from folium.figure import Figure from folium.folium import Map from folium.map import ( FeatureGroup, diff --git a/folium/figure.py b/folium/figure.py deleted file mode 100644 index 2baed5cd63..0000000000 --- a/folium/figure.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Folium-specific Figure subclass.""" - -from branca.element import Figure as BrancaFigure - -# Re-export branca Figure for folium maps. Rendering must not clear -# figure.html children that were added directly (e.g. geopandas legends). - - -class Figure(BrancaFigure): - """Figure used as the root container for folium maps.""" diff --git a/folium/folium.py b/folium/folium.py index f861cb71e7..8983ce2721 100644 --- a/folium/folium.py +++ b/folium/folium.py @@ -8,10 +8,9 @@ from collections.abc import Sequence from typing import Any, Optional, Union -from branca.element import Element +from branca.element import Element, Figure from folium.elements import JSCSSMixin -from folium.figure import Figure from folium.map import Evented, FitBounds, Layer from folium.raster_layers import TileLayer from folium.template import Template diff --git a/folium/map.py b/folium/map.py index c744195a9d..8b99e1bbfa 100644 --- a/folium/map.py +++ b/folium/map.py @@ -569,7 +569,7 @@ def render(self): f"{self._name} location must be assigned when added directly to map." ) if self.icon: - self.add_child(self.SetIcon(marker=self, icon=self.icon), name="set_icon") + self.add_child(self.SetIcon(marker=self, icon=self.icon)) super().render() def set_icon(self, icon): diff --git a/folium/plugins/dual_map.py b/folium/plugins/dual_map.py index 24880487f1..c410aaf994 100644 --- a/folium/plugins/dual_map.py +++ b/folium/plugins/dual_map.py @@ -1,7 +1,6 @@ -from branca.element import MacroElement +from branca.element import Figure, MacroElement from folium.elements import EventHandler, JSCSSMixin -from folium.figure import Figure from folium.folium import Map from folium.map import LayerControl from folium.template import Template