diff --git a/native/templates/_copy_btn.py b/native/templates/_copy_btn.py
index bd120ff2..e913956d 100644
--- a/native/templates/_copy_btn.py
+++ b/native/templates/_copy_btn.py
@@ -21,11 +21,7 @@ def create_copy_button(content: str) -> tuple[Button, str]:
tick_icon_svg = ''
safe_content = json.dumps(content)
- btn_component = button(
- hi("Copy01Icon", id=icon_id, class_name="size-4"),
- id=btn_id,
- on_click=call_script(
- f"""
+ copy_script = f"""
const icon = document.getElementById('{icon_id}');
navigator.clipboard.writeText({safe_content});
@@ -37,7 +33,11 @@ def create_copy_button(content: str) -> tuple[Button, str]:
icon.innerHTML = `{copy_icon_svg}`;
}}, 1500);
"""
- ),
+
+ btn_component = button(
+ hi("Copy01Icon", id=icon_id, class_name="size-4"),
+ id=btn_id,
+ on_click=call_script(copy_script).stop_propagation,
)
- return btn_component, btn_id
+ return btn_component, copy_script
diff --git a/native/templates/_source.py b/native/templates/_source.py
index b1cab8f9..9f4f015a 100644
--- a/native/templates/_source.py
+++ b/native/templates/_source.py
@@ -15,7 +15,7 @@ def _parse_file_path(file_path: str) -> tuple[str, str]:
def file_codeblock(file_path: str, source: str) -> rx.Component:
dir_part, file_part = _parse_file_path(file_path)
- button, btn_id = create_copy_button(content=source)
+ button, copy_script = create_copy_button(content=source)
return div(
div(
@@ -29,7 +29,7 @@ def file_codeblock(file_path: str, source: str) -> rx.Component:
button,
class_name="flex items-center justify-between gap-2",
),
- on_click=rx.call_script(f"document.getElementById('{btn_id}').click();"),
+ on_click=rx.call_script(copy_script),
class_name="group flex flex-col gap-3 border border-border bg-background p-4 hover:bg-muted/30 focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 focus-visible:outline-none rounded-lg",
)
diff --git a/tests/test_source_codeblock.py b/tests/test_source_codeblock.py
new file mode 100644
index 00000000..e4bfcc46
--- /dev/null
+++ b/tests/test_source_codeblock.py
@@ -0,0 +1,196 @@
+"""Tests for the "Source Code & Dependencies" copy card.
+
+The card in ``native/templates/_source.py::file_codeblock`` wraps a copy button
+(from ``create_copy_button``) in an outer ``div``. The outer ``div`` previously
+delegated clicks to the inner button via
+``document.getElementById('').click()``. Because ``HTMLElement.click()``
+dispatches a real, bubbling ``MouseEvent``, a click on the copy button would
+bubble up to the card, which re-fired ``.click()`` on the button, leading to a
+double copy (and, in runtimes that process events synchronously, unbounded
+recursion).
+
+These tests pin the contract of the fix so the same footgun is not reintroduced:
+
+* the card ``div`` runs the clipboard copy script *directly* (no ``.click()``
+ delegation),
+* the inner copy button keeps its own ``on_click`` copy handler with
+ ``stopPropagation`` so a click on the button does not double-fire the card,
+* the other consumers of ``create_copy_button`` (``_demo``, ``_dashboard``)
+ stay intact.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import reflex as rx # pyrefly: ignore
+
+from native.templates._copy_btn import create_copy_button
+from native.templates._dashboard import dashboard_demo
+from native.templates._demo import demo
+from native.templates._source import file_codeblock
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+# ---------------------------------------------------------------------------
+# Introspection helpers
+# ---------------------------------------------------------------------------
+
+
+def _event_triggers(comp: object) -> dict[str, object]:
+ return getattr(comp, "event_triggers", {}) or {}
+
+
+def _on_click_chain(comp: object) -> object | None:
+ return _event_triggers(comp).get("on_click")
+
+
+def _first_spec(chain: object) -> object | None:
+ events = getattr(chain, "events", None)
+ if not events:
+ return None
+ return events[0]
+
+
+def _string_value(value: object) -> str | None:
+ """Coerce a Reflex ``Var``/literal into its raw Python string."""
+ if isinstance(value, str):
+ return value
+ raw = getattr(value, "_var_value", None)
+ return raw if isinstance(raw, str) else None
+
+
+def on_click_js(comp: object) -> str | None:
+ """JavaScript emitted for ``comp``'s ``on_click`` (``call_script`` handlers)."""
+ chain = _on_click_chain(comp)
+ if chain is None:
+ return None
+ for key, val in getattr(_first_spec(chain), "args", ()) or ():
+ if getattr(key, "_js_expr", None) == "javascript_code":
+ return _string_value(val)
+ return None
+
+
+def has_on_click(comp: object) -> bool:
+ return _on_click_chain(comp) is not None
+
+
+def stops_propagation(comp: object) -> bool:
+ spec = _first_spec(_on_click_chain(comp))
+ return bool(
+ spec is not None and getattr(spec, "event_actions", {}).get("stopPropagation"),
+ )
+
+
+def walk(comp: object) -> Iterator[object]:
+ yield comp
+ for child in getattr(comp, "children", None) or []:
+ yield from walk(child)
+
+
+def is_copy_handler(js: str | None) -> bool:
+ return js is not None and "navigator.clipboard.writeText" in js
+
+
+def is_click_delegation(js: str | None) -> bool:
+ """True when a handler delegates via ``element.click()`` (the bug)."""
+ return js is not None and ".click(" in js
+
+
+def find_copy_buttons(root: object) -> list[object]:
+ """Copy buttons: carry the clipboard copy script AND stop propagation."""
+ return [
+ c
+ for c in walk(root)
+ if has_on_click(c) and is_copy_handler(on_click_js(c)) and stops_propagation(c)
+ ]
+
+
+# ---------------------------------------------------------------------------
+# file_codeblock -- the bug fix
+# ---------------------------------------------------------------------------
+
+
+def test_file_codeblock_card_does_not_delegate_via_click() -> None:
+ """The recursion/double-fire root: the card's on_click must NOT call `.click()`."""
+ card = file_codeblock("components/ui/button.py", "print(1)")
+
+ js = on_click_js(card)
+ assert js is not None
+ assert not is_click_delegation(js), (
+ f"card on_click must not delegate via .click(); got: {js!r}"
+ )
+
+
+def test_file_codeblock_card_copies_directly() -> None:
+ """Clicking anywhere on the card must copy (click-anywhere preserved)."""
+ card = file_codeblock("components/ui/button.py", "print(1)")
+
+ assert is_copy_handler(on_click_js(card))
+
+
+def test_file_codeblock_nested_button_stops_propagation() -> None:
+ """A button click must not bubble up and double-fire the card handler."""
+ card = file_codeblock("components/ui/button.py", "print(1)")
+ button = card.children[0].children[1]
+
+ assert stops_propagation(button)
+
+
+# ---------------------------------------------------------------------------
+# create_copy_button
+# ---------------------------------------------------------------------------
+
+
+def test_copy_button_handler_stops_propagation() -> None:
+ button, _ = create_copy_button(content="x")
+
+ assert stops_propagation(button), (
+ "the copy button must stop propagation so a click on it does not "
+ "bubble up and re-trigger an ancestor card handler"
+ )
+
+
+def test_copy_script_safely_escapes_content() -> None:
+ """The copied content is embedded via ``json.dumps`` so quotes, newlines,
+ backticks and ``${...}`` are safely escaped — never injected as raw JS.
+ """
+ import json
+
+ payload = 'he said "hi"\nimport os # evil'
+
+ _, copy_script = create_copy_button(content=payload)
+
+ assert json.dumps(payload) in copy_script
+ assert payload not in copy_script # raw, unescaped payload must not appear
+
+
+# ---------------------------------------------------------------------------
+# Regression: the other create_copy_button consumers stay healthy
+# ---------------------------------------------------------------------------
+
+
+def test_demo_builds_and_copy_button_is_intact() -> None:
+ component = rx.el.div("preview")
+ root = demo(component, "print('demo')")
+
+ copy_buttons = find_copy_buttons(root)
+ assert len(copy_buttons) == 1
+ assert is_copy_handler(on_click_js(copy_buttons[0]))
+ assert stops_propagation(copy_buttons[0])
+
+ # No handler anywhere in the demo tree delegates via .click()
+ assert [c for c in walk(root) if is_click_delegation(on_click_js(c))] == []
+
+
+def test_dashboard_demo_builds_and_copy_button_is_intact() -> None:
+ component = rx.el.div("preview")
+ root = dashboard_demo(component, "print('dash')")
+
+ copy_buttons = find_copy_buttons(root)
+ assert len(copy_buttons) == 1
+ assert is_copy_handler(on_click_js(copy_buttons[0]))
+ assert stops_propagation(copy_buttons[0])
+
+ assert [c for c in walk(root) if is_click_delegation(on_click_js(c))] == []