Skip to content

Commit e25a06c

Browse files
committed
Fix duplicate HTML when map.save() is called repeatedly
Repeated render() calls accumulated script/header/html content because render helpers created new child elements with unique IDs on each pass. Reset figure output sections before rendering and use stable script names for SetIcon and ElementAddToElement. Fixes #2237
1 parent 6b75c07 commit e25a06c

7 files changed

Lines changed: 77 additions & 4 deletions

File tree

folium/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
CssLink,
55
Div,
66
Element,
7-
Figure,
87
Html,
98
IFrame,
109
JavascriptLink,
@@ -29,6 +28,7 @@
2928
Vega,
3029
VegaLite,
3130
)
31+
from folium.figure import Figure
3232
from folium.folium import Map
3333
from folium.map import (
3434
FeatureGroup,

folium/elements.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,18 @@ def __init__(self, element_name: str, element_parent_name: str):
149149
self.element_name = element_name
150150
self.element_parent_name = element_parent_name
151151

152+
def render(self, **kwargs):
153+
figure = self.get_root()
154+
assert isinstance(
155+
figure, Figure
156+
), "You cannot render this Element if it is not in a Figure."
157+
script = self._template.module.__dict__.get("script", None)
158+
if script is not None:
159+
figure.script.add_child(
160+
Element(script(self, kwargs)),
161+
name=f"{self.element_name}_add_to_{self.element_parent_name}",
162+
)
163+
152164

153165
class IncludeStatement(MacroElement):
154166
"""Generate an include statement on a class."""

folium/figure.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""Folium-specific Figure subclass."""
2+
3+
from branca.element import Figure as BrancaFigure
4+
5+
6+
class Figure(BrancaFigure):
7+
"""Figure that supports repeated rendering without duplicating output.
8+
9+
Branca elements populate ``header``, ``html``, and ``script`` during
10+
``render()``. Some folium elements create new child nodes on every render
11+
call, which causes repeated ``save()`` calls to accumulate duplicate HTML
12+
and JavaScript. Clearing the rendered sections before each render makes
13+
output idempotent while preserving static header content from ``__init__``.
14+
"""
15+
16+
def render(self, **kwargs):
17+
meta = self.header._children.pop("meta_http", None)
18+
self.header._children.clear()
19+
if meta is not None:
20+
self.header._children["meta_http"] = meta
21+
self.html._children.clear()
22+
self.script._children.clear()
23+
return super().render(**kwargs)

folium/folium.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@
88
from collections.abc import Sequence
99
from typing import Any, Optional, Union
1010

11-
from branca.element import Element, Figure
11+
from branca.element import Element
1212

1313
from folium.elements import JSCSSMixin
14+
from folium.figure import Figure
1415
from folium.map import Evented, FitBounds, Layer
1516
from folium.raster_layers import TileLayer
1617
from folium.template import Template

folium/map.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,18 @@ def __init__(
517517
self.marker = marker
518518
self.icon = icon
519519

520+
def render(self, **kwargs):
521+
figure = self.get_root()
522+
assert isinstance(
523+
figure, Figure
524+
), "You cannot render this Element if it is not in a Figure."
525+
script = self._template.module.__dict__.get("script", None)
526+
if script is not None:
527+
figure.script.add_child(
528+
Element(script(self, kwargs)),
529+
name=f"{self.marker.get_name()}_set_icon",
530+
)
531+
520532
def __init__(
521533
self,
522534
location: Optional[Sequence[float]] = None,
@@ -557,7 +569,7 @@ def render(self):
557569
f"{self._name} location must be assigned when added directly to map."
558570
)
559571
if self.icon:
560-
self.add_child(self.SetIcon(marker=self, icon=self.icon))
572+
self.add_child(self.SetIcon(marker=self, icon=self.icon), name="set_icon")
561573
super().render()
562574

563575
def set_icon(self, icon):

folium/plugins/dual_map.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
from branca.element import Figure, MacroElement
1+
from branca.element import MacroElement
22

33
from folium.elements import EventHandler, JSCSSMixin
4+
from folium.figure import Figure
45
from folium.folium import Map
56
from folium.map import LayerControl
67
from folium.template import Template

tests/test_map.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,3 +290,27 @@ def test_icon_invalid_marker_colors():
290290
pytest.warns(UserWarning, Icon, color="lila")
291291
pytest.warns(UserWarning, Icon, color=42)
292292
pytest.warns(UserWarning, Icon, color=None)
293+
294+
295+
def test_repeated_save_produces_identical_html(tmp_path):
296+
"""Regression test for https://github.com/python-visualization/folium/issues/2237"""
297+
m = Map(location=[40.75, -73.98], zoom_start=13)
298+
locations = [
299+
[40.7829, -73.9654],
300+
[40.7484, -73.9857],
301+
[40.7580, -73.9855],
302+
]
303+
for lat, lon in locations:
304+
Marker([lat, lon], icon=Icon(color="blue", icon="info-sign")).add_to(m)
305+
306+
path1 = tmp_path / "1.html"
307+
path2 = tmp_path / "2.html"
308+
path3 = tmp_path / "3.html"
309+
m.save(path1)
310+
m.save(path2)
311+
m.save(path3)
312+
313+
html1 = path1.read_text()
314+
html2 = path2.read_text()
315+
html3 = path3.read_text()
316+
assert html1 == html2 == html3

0 commit comments

Comments
 (0)