diff --git a/docs/plugin-registry-architecture.html b/docs/plugin-registry-architecture.html
new file mode 100644
index 0000000..bd8ffb2
--- /dev/null
+++ b/docs/plugin-registry-architecture.html
@@ -0,0 +1,928 @@
+
+
+
+
+
+
streamlit-folium vs streamlit-folium-vnext
+
A complete comparison of how the two packages work — from Python API to frontend rendering
+
+
+
1. The Core Difference in One Sentence
+
+
+ The old package lets folium render the map — Python hands the frontend a blob of Leaflet JavaScript and the frontend executes it.
+ The new package lets the frontend render the map — Python extracts structured data and the frontend reconstructs Leaflet objects from that data.
+
+
+
+ Old: Python → folium Jinja2 templates → raw HTML + raw JS strings → eval() in browser
+ New: Python → CompilerRegistry → typed MapSpec data tree → frontend registry dispatch → Leaflet API calls
+
+
+
+
2. Components v1 vs Components v2
+
+
+ The packages use entirely different Streamlit component APIs. This is not just an implementation detail —
+ the move from CCv1 to CCv2 is what makes most of the other improvements possible.
+
+
+
+
+
Old — streamlit.components.v1
+
+# Python: register by URL or build path
+import streamlit.components.v1 as components
+
+_component_func = components.declare_component(
+ "st_folium",
+ url="http://localhost:3001", # dev
+ # path=build_dir # prod
+)
+
+# Call site: pass args as flat kwargs
+_component_func(
+ script=leaflet_js_string,
+ html=html_string,
+ js_links=cdn_url_list,
+ plugins=plugin_dicts,
+ returned_objects=["last_clicked"],
+ ...
+)
+
+
+
+
New — streamlit.components.v2
+
+# Python: JS bundle inlined — no server needed
+import streamlit.components.v2 as components_v2
+
+_component = components_v2.component(
+ "st_folium_vnext",
+ js=_JS_CODE, # bundle read from disk
+ html=" ",
+ isolate_styles=False,
+)
+
+# Call site: data + per-key callbacks
+_component(
+ key=key,
+ data={"spec": spec_dict, "height": height},
+ default={"center": None, "zoom": None, ...},
+ on_center_change=_noop,
+ on_zoom_change=_noop,
+ on_bounds_change=_noop,
+ on_event_change=_noop,
+)
+
+
+
+
+
How the frontend is structured
+
+
+
+
Old — event listener in an iframe
+
+ The frontend is a React app served from a separate dev server or build directory.
+ Streamlit embeds it in an <iframe>. The app registers a listener
+ for RENDER_EVENT and calls Streamlit.setComponentValue()
+ to send data back. Frame height is managed manually.
+
+
+// Must call these explicitly
+Streamlit.events.addEventListener(
+ Streamlit.RENDER_EVENT, onRender
+)
+Streamlit.setComponentReady()
+Streamlit.setFrameHeight()
+
+// To send state back to Python
+Streamlit.setComponentValue({ last_clicked: ..., zoom: ... })
+
+
+
+
New — renderer function, no iframe
+
+ The frontend is a single exported function. Streamlit calls it directly and passes a
+ parentElement DOM node (inside the Streamlit page itself — no iframe).
+ State is sent back per-key via setStateValue / setTriggerValue.
+ No manual setup or frame sizing required.
+
+
+// The entire frontend is this one function
+const StFoliumVnext: FrontendRenderer<ComponentState, ComponentData>
+ = ({ parentElement, data, setStateValue, setTriggerValue }) => {
+
+ // parentElement is a real DOM node in the page
+ // Build the Leaflet map inside it...
+
+ // Send state back per key
+ setStateValue("center", [lat, lng])
+ setStateValue("zoom", zoom)
+ setTriggerValue("event", { type: "click", ... })
+
+ return () => {} // optional cleanup on unmount
+}
+
+export default StFoliumVnext
+
+
+
+
+
setStateValue vs setTriggerValue vs setComponentValue
+
+
+ | Concept | CCv1 | CCv2 |
+
+ | How state is sent to Python | One call: Streamlit.setComponentValue({...}) — a single flat dict | Per-key: setStateValue(key, val) / setTriggerValue(key, val) |
+ | State persistence across reruns | No — rebuilt from scratch each time | setStateValue values persist; setTriggerValue resets to null after one rerun |
+ | Python callbacks | Single default= dict; no per-key callbacks | on_<key>_change= callback per state key |
+ | Component isolation | Full iframe — styles and globals cannot leak either way | No iframe — shares the page DOM; isolate_styles=False allows Leaflet CSS to work correctly |
+ | JS delivery | Served from a URL (dev server or path=) | Bundle read from disk and inlined via js=_JS_CODE |
+
+
+
+
Why no iframe is the key enabler
+
+
+ The CCv1 iframe persists across reruns as long as key= doesn't change, which is
+ how the old package achieves partial incremental updates at all — window.map stays
+ alive. But because all layer state is tracked through window globals written by
+ eval'd strings, only feature_group and layer_control can be swapped.
+ In CCv2, parentElement is equally stable, but the instances Map tracks
+ every layer by a stable typed ID — so syncLayers() can diff any layer type cleanly,
+ without globals, eval, or re-hooking listeners.
+
+
+
+ Chain of enablement: CCv2 persistent parentElement
+ → instances Map survives reruns
+ → syncLayers() can diff against live Leaflet objects
+ → incremental layer updates without rebuilding the map
+
+
+
+
3. What Python Sends to the Frontend
+
+
The most visible difference is the shape of the payload that crosses the Python/JS boundary.
+
+
+
+
Old — streamlit_folium
+
Raw strings from folium's Jinja2 render pipeline:
+
+MapSpec(
+ html = "<div class='folium-map' ...>",
+ header = "<link rel=stylesheet ...>...",
+ script = "var map_div = L.map('map_div', {...}); ...",
+ map_id = "map_5f9d46...",
+ assets = AssetSpec(
+ js = ["https://cdnjs.../leaflet.js", ...],
+ css = ["https://cdnjs.../leaflet.css", ...],
+ ),
+ plugins = [
+ PluginSpec(
+ kind = "feature_group",
+ script = "var feature_group_feature_group_0 = ...",
+ ),
+ ],
+)
+
+
+
+
New — streamlit_folium_vnext
+
Structured data tree — no JavaScript anywhere:
+
+MapSpec(
+ version = 1,
+ map = {"id": "map-0", "center": [45.5, -122.6],
+ "zoom": 12, "options": {...}},
+ layers = [
+ MapNode(kind="tile_layer", id="tile-layer-0",
+ props={"url": "https://tile.openstreetmap..."}),
+ MapNode(kind="marker", id="marker-1",
+ props={"location": [45.5, -122.6], ...}),
+ ],
+ controls = [...],
+ plugins = [
+ MapNode(kind="marker_cluster", id="marker-cluster-2",
+ props={"markers": [...]}),
+ ],
+ subscriptions = ["last_clicked"],
+)
+
+
+
+
+
+ The old PluginSpec.script field contains a raw JavaScript string — it is the literal output of calling folium_object.render(),
+ which runs folium's internal Jinja2 templates. The new MapNode.props is a plain Python dict
+ of typed values. No JavaScript is ever generated in Python.
+
+
+
+
4. How Python Builds the Payload
+
+
+
+
Old — render pipeline
+
+# 1. Let folium render itself to JS strings
+html = fig.get_root().html.render()
+header = fig.get_root().header.render()
+script = generate_leaflet_string(fig)
+
+# 2. Scrape CDN URLs by walking the element tree
+for elem in walk(folium_map):
+ css_links.extend(elem.default_css)
+ js_links.extend(elem.default_js)
+
+# 3. Render plugin JS strings via Jinja2
+feature_group.render()
+script_str = generate_leaflet_string(feature_group)
+plugins.append(PluginSpec(kind="feature_group",
+ script=script_str))
+
+
+
+
New — compiler pipeline
+
+# 1. Walk the folium object tree
+for child in obj._children.values():
+
+ # 2. Look up a compile function by Python type
+ handler = registry.resolve(child)
+
+ # 3. Extract structured data — no JS generated
+ node = handler(child, context)
+
+ # 4. Route by kind into the right bucket
+ match node.kind:
+ case "marker" | "geojson" | ...:
+ spec.layers.append(node)
+ case "layer_control":
+ spec.controls.append(node)
+ case _:
+ spec.plugins.append(node)
+
+
+
+
+
+ In the old version Python is a pass-through — it calls folium's render methods and relays the
+ resulting JS strings. In the new version Python is a compiler — it reads folium object attributes
+ and emits a data representation.
+
+
+
+
5. How the Frontend Initializes the Map
+
+
+
+
Old — script injection + eval
+
+// 1. Inject CDN <script> tags sequentially
+for (const link of js_links) {
+ await new Promise((resolve, reject) => {
+ const s = document.createElement("script")
+ s.src = link
+ s.onload = resolve; s.onerror = reject
+ document.body.appendChild(s)
+ })
+}
+
+// 2. Inject folium HTML into DOM
+html_div.innerHTML = html
+document.body.appendChild(html_div)
+
+// 3. Eval the entire rendered Leaflet script
+render_script.innerHTML =
+ script +
+ `window.map = map_div;
+ window.initComponent(map_div, ...);`
+document.body.appendChild(render_script)
+
+
+
+
New — registry dispatch
+
+// 1. Load all needed CDN deps in parallel
+await collectDeps(spec) // Promise.all
+
+// 2. Create the Leaflet map from spec data
+const map = L.map(container, camelizeKeys(spec.map.options))
+map.setView(spec.map.center, spec.map.zoom)
+
+// 3. Render each layer via registry lookup
+for (const node of spec.layers) {
+ const fn = layerRenderers.get(node.kind)
+ if (fn) fn(map, node, setTrigger)
+}
+
+// 4. Mount controls and plugins via registries
+for (const node of spec.controls) {
+ controlMounters.get(node.kind)?.(map, node)
+}
+for (const node of spec.plugins) {
+ pluginMounters.get(node.kind)?.(map, node, setTrigger)
+}
+
+
+
+
+
+ eval() in the old package — on plugin re-renders (e.g. when a new feature_group is passed) the old frontend
+ literally calls eval(feature_group + layer_control) with the raw JavaScript strings sent from Python.
+ The new package never evaluates strings — it calls Leaflet's API directly.
+
+
+
+
6. CDN Loading
+
+
+ The old and new packages solve the "load extra CDN libraries for plugins" problem in completely different places.
+
+
+
+
+
Old — Python scrapes, frontend loads sequentially
+
+# Python: walk element tree, collect URLs
+for elem in walk(folium_map):
+ css_links.extend(
+ href for _, href in elem.default_css
+ )
+ js_links.extend(
+ src for _, src in elem.default_js
+ )
+assets = AssetSpec(js=js_links, css=css_links)
+
+
+// Frontend: inject each <script> sequentially
+for (const link of js_links) {
+ await new Promise(...) // one at a time
+}
+
+
+
+
New — TypeScript owns all CDN knowledge
+
+// TypeScript: each plugin kind declares its CDN deps
+pluginDeps.set("marker_cluster", () =>
+ ensureDep("leaflet-markercluster", async () => {
+ await loadStylesheet("sl-mc-css",
+ "https://unpkg.com/leaflet.markercluster@1.5.3/...")
+ if (!L?.MarkerClusterGroup)
+ await loadScript("https://unpkg.com/.../leaflet.markercluster.js")
+ })
+)
+
+// All plugins' deps fanned out in parallel
+function collectDeps(spec): Promise<void> {
+ const loads = spec.plugins
+ .map(p => pluginDeps.get(p.kind)?.())
+ .filter(Boolean)
+ return Promise.all(loads)
+}
+
+
+
+
+
+ | Concern | Old | New |
+
+ | Where CDN URLs live | Inside folium/branca Python objects (default_js, default_css) | In TypeScript pluginDeps Map |
+ | Who controls loading order | Python (sends ordered list); frontend loads serially | Frontend; all needed deps loaded in parallel via Promise.all |
+ | Deduplication when two maps use same plugin | Not handled — same URL injected twice | ensureDep(key, ...) memoises by string key |
+ | Adding a CDN dep for a new plugin | Must add to folium object's default_js / default_css (inside folium library, not this package) | One pluginDeps.set("kind", ...) call in index.ts |
+
+
+
+
+
7. Re-renders and Layer Updates
+
+
+ When Streamlit sends a new render event (e.g. a Python slider changed the data), the two packages behave very differently.
+
+
+
+
+
Old — partial incremental updates, hackily
+
+ The base map (tile layer, markers, GeoJSON baked into the initial script) is rendered once
+ at init and can never be updated. However, feature_group and layer_control
+ plugins can be swapped between reruns — via a fragile mechanism using window
+ globals, JSON.stringify diffing, and eval().
+
+
+// On every RENDER_EVENT, finalizeOnRender() runs:
+
+// 1. Detect change via full JSON.stringify diff
+if (JSON.stringify(plugins) !==
+ JSON.stringify(window.__GLOBAL_DATA__.last_feature_group)) {
+
+ // 2. Remove old layers via window.feature_group global
+ window.feature_group.forEach((layer: Layer) => {
+ window.map.removeLayer(layer)
+ })
+ window.map.removeControl(window.layer_control)
+
+ // 3. Re-add by eval()ing the new raw JS strings
+ // eslint-disable-next-line
+ eval(feature_group + layer_control)
+
+ // 4. Re-hook all click listeners on every layer
+ for (let key in window.map._layers) {
+ window.map._layers[key].on("click", onLayerClick)
+ }
+}
+
+
+ This only works because the CCv1 iframe persists across Python reruns as long as the
+ key= parameter doesn't change — keeping window.map alive.
+ But it relies on three side-channel globals (window.map, window.feature_group,
+ window.layer_control) written by the eval'd JS strings, and re-hooks
+ click listeners on every layer on every update.
+
+
+
+
New — incremental diffing via syncLayers()
+
+ Every MapInstance holds a layerRefs: Map<string, any> keyed by stable node IDs.
+ On every render event, syncLayers() computes the diff: IDs no longer in the spec are
+ removed from the Leaflet map; new IDs are created and added. Only the changed layers are touched.
+ Works for all layer types, not just feature groups.
+
+
+
+
+
+// New — syncLayers() incremental diff
+function syncLayers(instance: MapInstance, spec: MapSpec, setTrigger: SetTrigger) {
+ const { map, layerRefs } = instance
+ const nextIds = new Set(spec.layers.map(n => n.id))
+
+ // Remove layers that are no longer in the spec
+ for (const [id, layer] of layerRefs) {
+ if (!nextIds.has(id)) { map.removeLayer(layer); layerRefs.delete(id) }
+ }
+
+ // Add layers that are new
+ for (const node of spec.layers) {
+ if (layerRefs.has(node.id)) continue // already present
+ const fn = layerRenderers.get(node.kind)
+ if (fn) layerRefs.set(node.id, fn(map, node, setTrigger))
+ }
+}
+
+
+
+
8. Events and Return Values
+
+
+
+
Old — window globals + string keys
+
+ All event state lives in window.__GLOBAL_DATA__. Click/move/draw handlers write directly
+ into named fields. The user specifies which fields to return via the returned_objects
+ argument (a list of strings). The frontend filters __GLOBAL_DATA__ to only those keys
+ before calling Streamlit.setComponentValue().
+
+
+// 27 fields in window.__GLOBAL_DATA__
+global_data.lat_lng_clicked = e.latlng
+global_data.last_object_clicked = e.latlng
+global_data.last_object_clicked_tooltip = ...
+global_data.all_drawings = ...
+// ...etc
+
+
+
+
New — typed subscriptions
+
+ The Python compiler specifies which events to subscribe to via MapSpec.subscriptions.
+ The frontend only tracks and returns the subscribed fields. Event state is held in a typed
+ ComponentState interface rather than a catch-all global object.
+
+
+// Python side — explicit subscription
+spec = compile_folium_map(m,
+ subscribe=["last_clicked", "bounds"])
+
+// Frontend — typed state
+interface ComponentState {
+ center: [number, number] | null
+ zoom: number | null
+ bounds: [[number, number], [number, number]] | null
+ event: unknown
+}
+
+
+
+
+
+
9. Case Study: Marker Cluster
+
+
+ Marker cluster is a good end-to-end example because it requires a CDN library, has nested children
+ (individual markers), and fires click events.
+
+
+
+
+
Old — folium renders everything
+
+# Python does nothing special for MarkerCluster.
+# folium's own Jinja2 template renders it to JS:
+#
+# var marker_cluster_abc123 =
+# L.markerClusterGroup({...});
+# var marker_xyz = L.marker([45.5, -122.6])
+# .addTo(marker_cluster_abc123);
+# marker_cluster_abc123.addTo(map_div);
+#
+# That JS string is included in the main
+# 'script' payload field and eval()d at init.
+#
+# CDN URL scraped from:
+# MarkerCluster.default_js (inside folium)
+
+
+
+
New — two explicit registration points
+
+# Python: extract data from folium object
+def compile_marker_cluster(obj, context):
+ markers = []
+ for child in obj._children.values():
+ markers.append({
+ "location": list(child.location),
+ "tooltip": tooltip_text,
+ "popup": popup_html,
+ })
+ return make_node("marker_cluster",
+ context.allocate_id("marker-cluster"),
+ markers=markers,
+ options=dict(obj.options),
+ )
+
+registry.register(
+ folium.plugins.MarkerCluster,
+ compile_marker_cluster,
+)
+
+
+// TypeScript: CDN dep registration
+pluginDeps.set("marker_cluster", () =>
+ ensureDep("leaflet-markercluster", async () => {
+ await loadStylesheet("sl-mc-css", "...")
+ if (!L?.MarkerClusterGroup)
+ await loadScript("...leaflet.markercluster.js")
+ })
+)
+
+// TypeScript: render registration
+layerRenderers.set("marker_cluster", renderMarkerCluster)
+
+function renderMarkerCluster(map, node, setTrigger) {
+ const group = L.markerClusterGroup()
+ for (const m of node.props.markers) {
+ const marker = L.marker(m.location)
+ // attach tooltip, popup, click handler...
+ group.addLayer(marker)
+ }
+ return group.addTo(map)
+}
+
+
+
+
+
+
10. Adding Support for a New Folium Object
+
+
+
+
+ | Step |
+ Old |
+ New |
+
+
+
+
+ | Write Python data extractor |
+ Not needed — folium renders it to JS itself via Jinja2. No Python code to write. |
+ Write a compile_*(obj, context) → MapNode function in compiler/plugins/ |
+
+
+ | Register the Python type |
+ Not applicable |
+ registry.register(folium.plugins.MyType, compile_my_type) in compile_map.py |
+
+
+ | CDN dependency |
+ Must add default_js / default_css to the folium object class (inside the folium library — not this package) |
+ pluginDeps.set("my_kind", () => ensureDep(...)) in index.ts |
+
+
+ | Write frontend renderer |
+ Not needed — folium's rendered JS is executed directly |
+ Write a renderMyKind(map, node, setTrigger) function in index.ts |
+
+
+ | Register the frontend renderer |
+ Not applicable |
+ layerRenderers.set("my_kind", renderMyKind) or pluginMounters.set(...) |
+
+
+ | Files touched total |
+ 0 files in this package (folium does everything) |
+ 2 files: compile_map.py + compiler/plugins/*.py (Python) and index.ts (TypeScript) |
+
+
+
+
+
+ The old package's "zero files" advantage is actually a constraint: you can only support what folium
+ already knows how to render via Jinja2. The new package requires more explicit work per type but gives
+ full control over the rendering, event handling, and CDN loading of each object.
+
+
+
+
11. Full Architecture Comparison
+
+
Old package data flow
+
+ Python (st_folium)
+ │
+ ├─ folium.Map._children ──► folium Jinja2 templates ──► raw JS strings
+ ├─ branca element tree ──► scrape default_js/css ──► CDN URL lists
+ │
+ └─ MapSpec { html, header, script, assets{js,css}, plugins[{kind, script}] }
+ │
+ │ (Streamlit component iframe)
+ ▼
+ Frontend (index.tsx)
+ │
+ ├─ inject <script src=cdnUrl> for each URL in js_links (sequentially)
+ ├─ inject HTML string into DOM
+ ├─ eval(script) ──► Leaflet map created, all layers/markers/plugins added
+ ├─ window.map = map_div
+ ├─ initComponent() ──► walk map._layers, attach click/draw/move handlers
+ │
+ └─ On re-render: JSON.stringify(plugins) diff → if changed: removeLayer(window.feature_group),
+ removeControl(window.layer_control), eval(new_feature_group_script + new_layer_control_script),
+ re-hook click listeners on all layers. Base map layers unchanged.
+
+
+
New package data flow
+
+ Python (st_folium_vnext)
+ │
+ ├─ folium.Map._children ──► CompilerRegistry.resolve(type) ──► compile_fn
+ │ │
+ │ ▼
+ │ MapNode{kind,id,props}
+ │
+ └─ MapSpec { map{center,zoom}, layers[], controls[], plugins[], subscriptions[] }
+ │
+ │ (Streamlit component iframe — pure JSON, no JS)
+ ▼
+ Frontend (index.ts)
+ │
+ ├─ collectDeps(spec)
+ │ └─ for each plugin kind: pluginDeps.get(kind)?.()
+ │ └─ ensureDep(key, loader) ──► Promise.all (parallel CDN loading)
+ │
+ ├─ L.map(container, options).setView(center, zoom)
+ │
+ ├─ for each layer node: layerRenderers.get(kind)(map, node, setTrigger)
+ ├─ for each control node: controlMounters.get(kind)(map, node)
+ ├─ for each plugin node: pluginMounters.get(kind)(map, node, setTrigger)
+ │
+ └─ On re-render: syncLayers() ──► diff layerRefs Map, add/remove only changed nodes
+
+
+
+
+
12. Summary
+
+
+ | Property | streamlit-folium (old) | streamlit-folium-vnext (new) |
+
+ | Streamlit component API | components.v1.declare_component() — iframe-based, event listener, setComponentValue() | components.v2.component() — no iframe, inline JS, setStateValue() / setTriggerValue() |
+ | What Python sends | Raw HTML + JS strings from folium's Jinja2 render | Structured MapSpec data tree — no JavaScript |
+ | Who renders to Leaflet API | folium (in Python, via Jinja2) | The frontend TypeScript (via registry dispatch) |
+ | How scripts execute | eval() of the raw JS string | Direct Leaflet API calls from TypeScript |
+ | CDN loading | Python scrapes; frontend injects sequentially | TypeScript pluginDeps; loaded in parallel |
+ | Layer updates on re-render | feature_group and layer_control only — via JSON.stringify diff, eval(), and window globals. Base map layers are frozen. | syncLayers() stable-ID diff — any layer type, no globals, no eval |
+ | Event state | window.__GLOBAL_DATA__ with 27 string-keyed fields | Typed ComponentState; explicit subscriptions list |
+ | Support for a new type | Free — if folium supports it, it renders automatically | Requires a Python compiler + TypeScript renderer (2 files) |
+ | External extensibility | Not possible without forking | layerRenderers.set() / registry.register() from any module |
+
+
+
+
+
+
diff --git a/streamlit_folium_vnext/__init__.py b/streamlit_folium_vnext/__init__.py
new file mode 100644
index 0000000..fbe4126
--- /dev/null
+++ b/streamlit_folium_vnext/__init__.py
@@ -0,0 +1,3 @@
+from .api import FoliumResult, compile_folium, st_folium_vnext, st_leaflet
+
+__all__ = ["FoliumResult", "compile_folium", "st_folium_vnext", "st_leaflet"]
diff --git a/streamlit_folium_vnext/api.py b/streamlit_folium_vnext/api.py
new file mode 100644
index 0000000..8f80dd4
--- /dev/null
+++ b/streamlit_folium_vnext/api.py
@@ -0,0 +1,133 @@
+from __future__ import annotations
+
+from copy import deepcopy
+from typing import Any
+
+import folium
+import streamlit as st
+
+from streamlit_folium_vnext.compiler import compile_folium_map
+from streamlit_folium_vnext.component import mount_leaflet_component
+from streamlit_folium_vnext.models.spec import MapSpec
+
+_DEFAULT_ACCUMULATORS: dict[str, dict[str, Any]] = {
+ "click": {
+ "key": "clicks",
+ "initial": [],
+ "mode": "append",
+ "extract": lambda e: e["payload"],
+ },
+ "draw.created": {
+ "key": "drawn_features",
+ "initial": [],
+ "mode": "append",
+ "extract": lambda e: e["payload"].get("geojson"),
+ },
+ "draw.edited": {
+ "key": "drawn_features",
+ "initial": [],
+ "mode": "replace",
+ "extract": lambda e: e["payload"].get("features", []),
+ },
+ "draw.deleted": {
+ "key": "drawn_features",
+ "initial": [],
+ "mode": "replace",
+ "extract": lambda e: e["payload"].get("features", []),
+ },
+}
+
+
+def _session_key(component_key: str | None) -> str:
+ return f"_stf_vnext_state_{component_key or 'default'}"
+
+
+def _accumulate(
+ component_key: str | None, event: dict[str, Any] | None, state_spec: dict[str, Any]
+) -> dict[str, Any]:
+ sk = _session_key(component_key)
+ if sk not in st.session_state:
+ st.session_state[sk] = deepcopy(state_spec)
+
+ acc: dict[str, Any] = st.session_state[sk]
+
+ if event is None or not isinstance(event, dict):
+ return acc
+
+ event_type = event.get("type", "")
+ cfg = _DEFAULT_ACCUMULATORS.get(event_type)
+ if cfg is None:
+ return acc
+
+ target_key = cfg["key"]
+ if target_key not in acc:
+ return acc
+
+ value = cfg["extract"](event)
+ if cfg["mode"] == "append":
+ acc[target_key] = acc[target_key] + [value]
+ elif cfg["mode"] == "replace":
+ acc[target_key] = list(value) if isinstance(value, list) else [value]
+
+ st.session_state[sk] = acc
+ return acc
+
+
+class FoliumResult:
+ def __init__(self, raw_result: Any, accumulated: dict[str, Any] | None = None):
+ self._raw = raw_result
+ self._accumulated = accumulated or {}
+
+ def __getattr__(self, name: str) -> Any:
+ if name.startswith("_"):
+ raise AttributeError(name)
+ if name == "state":
+ return self._accumulated
+ return getattr(self._raw, name)
+
+ def __bool__(self) -> bool:
+ return bool(self._raw)
+
+ def __repr__(self) -> str:
+ return f"FoliumResult(raw={self._raw!r}, state={self._accumulated!r})"
+
+
+def compile_folium(obj: folium.Map, *, subscribe: list[str] | None = None) -> MapSpec:
+ return compile_folium_map(obj, subscribe=subscribe)
+
+
+def st_leaflet(
+ spec: MapSpec,
+ *,
+ key: str | None = None,
+ height: int = 500,
+ width: str = "stretch",
+ state: dict[str, Any] | None = None,
+):
+ d = spec.to_dict()
+ if key is not None:
+ d["map"]["id"] = key
+ raw = mount_leaflet_component(
+ spec=d,
+ key=key,
+ height=height,
+ width=width,
+ )
+ if state is not None:
+ event = getattr(raw, "event", None)
+ acc = _accumulate(key, event, state)
+ return FoliumResult(raw, acc)
+ return raw
+
+
+def st_folium_vnext(
+ obj: folium.Map,
+ *,
+ key: str | None = None,
+ height: int = 500,
+ width: str = "stretch",
+ subscribe: list[str] | None = None,
+ state: dict[str, Any] | None = None,
+):
+ spec = compile_folium(obj, subscribe=subscribe)
+ return st_leaflet(spec, key=key, height=height, width=width, state=state)
diff --git a/streamlit_folium_vnext/compiler/__init__.py b/streamlit_folium_vnext/compiler/__init__.py
new file mode 100644
index 0000000..0f8c95b
--- /dev/null
+++ b/streamlit_folium_vnext/compiler/__init__.py
@@ -0,0 +1,3 @@
+from .compile_map import compile_folium_map
+
+__all__ = ["compile_folium_map"]
diff --git a/streamlit_folium_vnext/compiler/compile_map.py b/streamlit_folium_vnext/compiler/compile_map.py
new file mode 100644
index 0000000..9aa675b
--- /dev/null
+++ b/streamlit_folium_vnext/compiler/compile_map.py
@@ -0,0 +1,79 @@
+from __future__ import annotations
+
+import folium
+import folium.plugins
+
+from streamlit_folium_vnext.compiler.context import CompileContext
+from streamlit_folium_vnext.compiler.plugins import (
+ compile_draw,
+ compile_feature_group,
+ compile_layer_control,
+)
+from streamlit_folium_vnext.compiler.plugins.map_objects import (
+ compile_circle,
+ compile_circle_marker,
+ compile_geojson,
+ compile_heat,
+ compile_marker,
+ compile_marker_cluster,
+ compile_polygon,
+ compile_polyline,
+ compile_tile_layer,
+)
+from streamlit_folium_vnext.compiler.registry import CompilerRegistry
+from streamlit_folium_vnext.models.spec import MapSpec
+
+registry = CompilerRegistry()
+registry.register(folium.raster_layers.TileLayer, compile_tile_layer)
+registry.register(folium.map.Marker, compile_marker)
+registry.register(folium.vector_layers.CircleMarker, compile_circle_marker)
+registry.register(folium.vector_layers.Circle, compile_circle)
+registry.register(folium.vector_layers.PolyLine, compile_polyline)
+registry.register(folium.vector_layers.Polygon, compile_polygon)
+registry.register(folium.features.GeoJson, compile_geojson)
+registry.register(folium.FeatureGroup, compile_feature_group)
+registry.register(folium.LayerControl, compile_layer_control)
+registry.register(folium.plugins.Draw, compile_draw)
+registry.register(folium.plugins.MarkerCluster, compile_marker_cluster)
+registry.register(folium.plugins.HeatMap, compile_heat)
+
+
+def compile_folium_map(
+ obj: folium.Map, *, subscribe: list[str] | None = None
+) -> MapSpec:
+ context = CompileContext()
+ spec = MapSpec(
+ map={
+ "id": context.allocate_id("map"),
+ "center": list(obj.location),
+ "zoom": obj.options.get("zoom") or obj.options.get("zoomStart"),
+ "options": dict(obj.options),
+ },
+ subscriptions=subscribe or [],
+ )
+
+ for child in obj._children.values():
+ handler = registry.resolve(child)
+ if handler is None:
+ continue
+ node = handler(child, context)
+ match node.kind:
+ case (
+ "tile_layer"
+ | "marker"
+ | "circle_marker"
+ | "circle"
+ | "polyline"
+ | "polygon"
+ | "geojson"
+ | "feature_group"
+ | "marker_cluster"
+ | "heat"
+ ):
+ spec.layers.append(node)
+ case "layer_control":
+ spec.controls.append(node)
+ case _:
+ spec.plugins.append(node)
+
+ return spec
diff --git a/streamlit_folium_vnext/compiler/context.py b/streamlit_folium_vnext/compiler/context.py
new file mode 100644
index 0000000..e368c11
--- /dev/null
+++ b/streamlit_folium_vnext/compiler/context.py
@@ -0,0 +1,12 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+
+@dataclass(slots=True)
+class CompileContext:
+ next_id: int = 0
+
+ def allocate_id(self, prefix: str) -> str:
+ self.next_id += 1
+ return f"{prefix}-{self.next_id}"
diff --git a/streamlit_folium_vnext/compiler/nodes.py b/streamlit_folium_vnext/compiler/nodes.py
new file mode 100644
index 0000000..c2e3631
--- /dev/null
+++ b/streamlit_folium_vnext/compiler/nodes.py
@@ -0,0 +1,7 @@
+from __future__ import annotations
+
+from streamlit_folium_vnext.models.spec import MapNode
+
+
+def make_node(kind: str, node_id: str, **props) -> MapNode:
+ return MapNode(kind=kind, id=node_id, props=props)
diff --git a/streamlit_folium_vnext/compiler/plugins/__init__.py b/streamlit_folium_vnext/compiler/plugins/__init__.py
new file mode 100644
index 0000000..ad0666d
--- /dev/null
+++ b/streamlit_folium_vnext/compiler/plugins/__init__.py
@@ -0,0 +1,19 @@
+from .draw import compile_draw
+from .feature_group import compile_feature_group
+from .layer_control import compile_layer_control
+from .map_objects import (
+ compile_circle_marker,
+ compile_geojson,
+ compile_marker,
+ compile_tile_layer,
+)
+
+__all__ = [
+ "compile_circle_marker",
+ "compile_draw",
+ "compile_feature_group",
+ "compile_geojson",
+ "compile_layer_control",
+ "compile_marker",
+ "compile_tile_layer",
+]
diff --git a/streamlit_folium_vnext/compiler/plugins/draw.py b/streamlit_folium_vnext/compiler/plugins/draw.py
new file mode 100644
index 0000000..35d3ba9
--- /dev/null
+++ b/streamlit_folium_vnext/compiler/plugins/draw.py
@@ -0,0 +1,13 @@
+from __future__ import annotations
+
+import folium.plugins
+
+from streamlit_folium_vnext.compiler.context import CompileContext
+from streamlit_folium_vnext.compiler.nodes import make_node
+from streamlit_folium_vnext.models.spec import MapNode
+
+
+def compile_draw(obj: folium.plugins.Draw, context: CompileContext) -> MapNode:
+ return make_node(
+ "draw", context.allocate_id("draw"), options=getattr(obj, "options", {})
+ )
diff --git a/streamlit_folium_vnext/compiler/plugins/feature_group.py b/streamlit_folium_vnext/compiler/plugins/feature_group.py
new file mode 100644
index 0000000..e310346
--- /dev/null
+++ b/streamlit_folium_vnext/compiler/plugins/feature_group.py
@@ -0,0 +1,15 @@
+from __future__ import annotations
+
+import folium
+
+from streamlit_folium_vnext.compiler.context import CompileContext
+from streamlit_folium_vnext.compiler.nodes import make_node
+from streamlit_folium_vnext.models.spec import MapNode
+
+
+def compile_feature_group(obj: folium.FeatureGroup, context: CompileContext) -> MapNode:
+ return make_node(
+ "feature_group",
+ context.allocate_id("feature-group"),
+ name=getattr(obj, "layer_name", None),
+ )
diff --git a/streamlit_folium_vnext/compiler/plugins/layer_control.py b/streamlit_folium_vnext/compiler/plugins/layer_control.py
new file mode 100644
index 0000000..772ba86
--- /dev/null
+++ b/streamlit_folium_vnext/compiler/plugins/layer_control.py
@@ -0,0 +1,17 @@
+from __future__ import annotations
+
+import folium
+
+from streamlit_folium_vnext.compiler.context import CompileContext
+from streamlit_folium_vnext.compiler.nodes import make_node
+from streamlit_folium_vnext.models.spec import MapNode
+
+
+def compile_layer_control(obj: folium.LayerControl, context: CompileContext) -> MapNode:
+ options = dict(obj.options) if hasattr(obj, "options") else {}
+ return make_node(
+ "layer_control",
+ context.allocate_id("layer-control"),
+ position=options.get("position", "topright"),
+ options=options,
+ )
diff --git a/streamlit_folium_vnext/compiler/plugins/map_objects.py b/streamlit_folium_vnext/compiler/plugins/map_objects.py
new file mode 100644
index 0000000..db4dd58
--- /dev/null
+++ b/streamlit_folium_vnext/compiler/plugins/map_objects.py
@@ -0,0 +1,166 @@
+from __future__ import annotations
+
+import folium
+import folium.plugins
+
+from streamlit_folium_vnext.compiler.context import CompileContext
+from streamlit_folium_vnext.compiler.nodes import make_node
+from streamlit_folium_vnext.models.spec import MapNode
+
+
+def compile_tile_layer(
+ obj: folium.raster_layers.TileLayer, context: CompileContext
+) -> MapNode:
+ return make_node(
+ "tile_layer",
+ context.allocate_id("tile-layer"),
+ url=obj.tiles,
+ attribution=obj.options.get("attribution", ""),
+ name=obj.layer_name,
+ options=dict(obj.options),
+ )
+
+
+def compile_marker(obj: folium.map.Marker, context: CompileContext) -> MapNode:
+ location = list(obj.location) if obj.location is not None else None
+ popup = None
+ tooltip = None
+ for child in obj._children.values():
+ if isinstance(child, folium.map.Popup):
+ popup = {
+ "html": child.html.render()
+ if hasattr(child, "html")
+ else getattr(child, "text", None)
+ }
+ elif isinstance(child, folium.map.Tooltip):
+ tooltip = {"text": child.text}
+
+ return make_node(
+ "marker",
+ context.allocate_id("marker"),
+ location=location,
+ popup=popup,
+ tooltip=tooltip,
+ options=dict(obj.options),
+ )
+
+
+def compile_circle_marker(
+ obj: folium.vector_layers.CircleMarker, context: CompileContext
+) -> MapNode:
+ location = list(obj.location) if obj.location is not None else None
+ return make_node(
+ "circle_marker",
+ context.allocate_id("circle-marker"),
+ location=location,
+ radius=obj.options.get("radius"),
+ options=dict(obj.options),
+ )
+
+
+def compile_geojson(obj: folium.features.GeoJson, context: CompileContext) -> MapNode:
+ return make_node(
+ "geojson",
+ context.allocate_id("geojson"),
+ data=obj.data,
+ options=dict(obj.options),
+ )
+
+
+def _extract_tooltip_popup(obj: object) -> tuple[dict | None, dict | None]:
+ tooltip = None
+ popup = None
+ for child in getattr(obj, "_children", {}).values():
+ if isinstance(child, folium.map.Popup):
+ popup = {
+ "html": child.html.render()
+ if hasattr(child, "html")
+ else getattr(child, "text", None)
+ }
+ elif isinstance(child, folium.map.Tooltip):
+ tooltip = {"text": child.text}
+ return tooltip, popup
+
+
+def compile_circle(
+ obj: folium.vector_layers.Circle, context: CompileContext
+) -> MapNode:
+ location = list(obj.location) if obj.location is not None else None
+ tooltip, popup = _extract_tooltip_popup(obj)
+ return make_node(
+ "circle",
+ context.allocate_id("circle"),
+ location=location,
+ tooltip=tooltip,
+ popup=popup,
+ options=dict(obj.options),
+ )
+
+
+def compile_polyline(
+ obj: folium.vector_layers.PolyLine, context: CompileContext
+) -> MapNode:
+ locations = [list(loc) for loc in obj.locations] if obj.locations else []
+ tooltip, popup = _extract_tooltip_popup(obj)
+ return make_node(
+ "polyline",
+ context.allocate_id("polyline"),
+ locations=locations,
+ tooltip=tooltip,
+ popup=popup,
+ options=dict(obj.options),
+ )
+
+
+def compile_polygon(
+ obj: folium.vector_layers.Polygon, context: CompileContext
+) -> MapNode:
+ locations = [list(loc) for loc in obj.locations] if obj.locations else []
+ tooltip, popup = _extract_tooltip_popup(obj)
+ return make_node(
+ "polygon",
+ context.allocate_id("polygon"),
+ locations=locations,
+ tooltip=tooltip,
+ popup=popup,
+ options=dict(obj.options),
+ )
+
+
+def compile_marker_cluster(
+ obj: folium.plugins.MarkerCluster, context: CompileContext
+) -> MapNode:
+ markers = []
+ for child in obj._children.values():
+ if not isinstance(child, folium.map.Marker):
+ continue
+ location = list(child.location) if child.location is not None else None
+ tooltip_text = None
+ popup_html = None
+ for grandchild in child._children.values():
+ if isinstance(grandchild, folium.map.Popup):
+ popup_html = (
+ grandchild.html.render()
+ if hasattr(grandchild, "html")
+ else getattr(grandchild, "text", None)
+ )
+ elif isinstance(grandchild, folium.map.Tooltip):
+ tooltip_text = grandchild.text
+ markers.append(
+ {"location": location, "tooltip": tooltip_text, "popup": popup_html}
+ )
+ return make_node(
+ "marker_cluster",
+ context.allocate_id("marker-cluster"),
+ markers=markers,
+ options=dict(obj.options),
+ )
+
+
+def compile_heat(obj: folium.plugins.HeatMap, context: CompileContext) -> MapNode:
+ return make_node(
+ "heat",
+ context.allocate_id("heat"),
+ data=obj.data,
+ options=dict(obj.options),
+ )
diff --git a/streamlit_folium_vnext/compiler/registry.py b/streamlit_folium_vnext/compiler/registry.py
new file mode 100644
index 0000000..8e51f39
--- /dev/null
+++ b/streamlit_folium_vnext/compiler/registry.py
@@ -0,0 +1,19 @@
+from __future__ import annotations
+
+from collections.abc import Callable
+from typing import Any
+
+
+class CompilerRegistry:
+ def __init__(self) -> None:
+ self._handlers: dict[type[Any], Callable[..., Any]] = {}
+
+ def register(self, cls: type[Any], handler: Callable[..., Any]) -> None:
+ self._handlers[cls] = handler
+
+ def resolve(self, obj: Any) -> Callable[..., Any] | None:
+ for cls in type(obj).__mro__:
+ handler = self._handlers.get(cls)
+ if handler is not None:
+ return handler
+ return None
diff --git a/streamlit_folium_vnext/component/__init__.py b/streamlit_folium_vnext/component/__init__.py
new file mode 100644
index 0000000..659ce4a
--- /dev/null
+++ b/streamlit_folium_vnext/component/__init__.py
@@ -0,0 +1,3 @@
+from .mount import mount_leaflet_component
+
+__all__ = ["mount_leaflet_component"]
diff --git a/streamlit_folium_vnext/component/mount.py b/streamlit_folium_vnext/component/mount.py
new file mode 100644
index 0000000..c572a7a
--- /dev/null
+++ b/streamlit_folium_vnext/component/mount.py
@@ -0,0 +1,42 @@
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any
+
+import streamlit.components.v2 as components_v2
+
+_JS_BUNDLE = list(
+ (Path(__file__).resolve().parents[1] / "frontend" / "build").glob("index-*.js")
+)
+_JS_FILE = _JS_BUNDLE[0] if _JS_BUNDLE else None
+_JS_CODE = _JS_FILE.read_text() if _JS_FILE is not None else ""
+
+_component = components_v2.component(
+ "st_folium_vnext",
+ js=_JS_CODE,
+ html=" ",
+ isolate_styles=False,
+)
+
+
+def _noop():
+ pass
+
+
+def mount_leaflet_component(
+ *,
+ spec: dict[str, Any],
+ key: str | None,
+ height: int = 500,
+ width: str = "stretch",
+):
+ return _component(
+ key=key,
+ data={"spec": spec, "height": height, "width": width},
+ default={"center": None, "zoom": None, "bounds": None},
+ height=height,
+ on_center_change=_noop,
+ on_zoom_change=_noop,
+ on_bounds_change=_noop,
+ on_event_change=_noop,
+ )
diff --git a/streamlit_folium_vnext/frontend/package.json b/streamlit_folium_vnext/frontend/package.json
new file mode 100644
index 0000000..6e11e8a
--- /dev/null
+++ b/streamlit_folium_vnext/frontend/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "streamlit_folium_vnext_frontend",
+ "private": true,
+ "version": "0.0.1",
+ "type": "module",
+ "scripts": {
+ "build": "npm run clean && npm run typecheck && npm run build:frontend:production",
+ "build:frontend:production": "cross-env NODE_ENV=production vite build",
+ "clean": "rimraf build",
+ "dev": "cross-env NODE_ENV=development vite build --watch",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@streamlit/component-v2-lib": "^0.2.0"
+ },
+ "devDependencies": {
+ "cross-env": "^7.0.3",
+ "rimraf": "^6.0.0",
+ "typescript": "^5.7.0",
+ "vite": "^6.0.0"
+ }
+}
diff --git a/streamlit_folium_vnext/frontend/src/index.ts b/streamlit_folium_vnext/frontend/src/index.ts
new file mode 100644
index 0000000..ba0e5dd
--- /dev/null
+++ b/streamlit_folium_vnext/frontend/src/index.ts
@@ -0,0 +1,555 @@
+import type { FrontendRenderer, FrontendState } from "@streamlit/component-v2-lib"
+
+type MapNode = {
+ kind: string
+ id: string
+ props: Record