diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 45aefe401..ccbd3d728 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -8,18 +8,21 @@ Changelog scheme for the project adheres to `Semantic Versioning `__. -.. INSTRUCTIONS FOR CHANGELOG CONTRIBUTORS -.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -.. If you're adding a changelog entry, be sure to read the "Creating a Changelog Entry" -.. section of the documentation before doing so for instructions on how to adhere to the -.. "Keep a Changelog" style guide (https://keepachangelog.com). +.. Using the following categories, list your changes in this order: +.. [Added, Changed, Deprecated, Removed, Fixed, Security] +.. Don't forget to remove deprecated code on each major release! Unreleased ---------- **Changed** -- :pull:`1251` Substitute client-side usage of ``react`` with ``preact``. +- :pull:`1251` - Substitute client-side usage of ``react`` with ``preact``. +- :pull:`1239` - Script elements no longer support behaving like effects. They now strictly behave like plain HTML script elements. + +**Fixed** + +- :pull:`1239` - Fixed a bug where script elements would not render to the DOM as plain text. v1.1.0 ------ diff --git a/pyproject.toml b/pyproject.toml index 371bed107..868e884ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,7 @@ artifacts = ["/src/reactpy/static/"] artifacts = ["/src/reactpy/static/"] [tool.hatch.metadata] -license-files = { paths = ["LICENSE.md"] } +license-files = { paths = ["LICENSE"] } [tool.hatch.envs.default] installer = "uv" diff --git a/src/js/packages/@reactpy/client/src/components.tsx b/src/js/packages/@reactpy/client/src/components.tsx index 27ec64fc6..efaa7a759 100644 --- a/src/js/packages/@reactpy/client/src/components.tsx +++ b/src/js/packages/@reactpy/client/src/components.tsx @@ -120,30 +120,33 @@ function ScriptElement({ model }: { model: ReactPyVdom }) { const ref = useRef(null); React.useEffect(() => { + // Don't run if the parent element is missing if (!ref.current) { return; } + + // Create the script element + const scriptElement: HTMLScriptElement = document.createElement("script"); + for (const [k, v] of Object.entries(model.attributes || {})) { + scriptElement.setAttribute(k, v); + } + + // Add the script content as text const scriptContent = model?.children?.filter( (value): value is string => typeof value == "string", )[0]; - - let scriptElement: HTMLScriptElement; - if (model.attributes) { - scriptElement = document.createElement("script"); - for (const [k, v] of Object.entries(model.attributes)) { - scriptElement.setAttribute(k, v); - } - if (scriptContent) { - scriptElement.appendChild(document.createTextNode(scriptContent)); - } - ref.current.appendChild(scriptElement); - } else if (scriptContent) { - const scriptResult = eval(scriptContent); - if (typeof scriptResult == "function") { - return scriptResult(); - } + if (scriptContent) { + scriptElement.appendChild(document.createTextNode(scriptContent)); } - }, [model.key, ref.current]); + + // Append the script element to the parent element + ref.current.appendChild(scriptElement); + + // Remove the script element when the component is unmounted + return () => { + ref.current?.removeChild(scriptElement); + }; + }, [model.key]); return
; } diff --git a/src/reactpy/html.py b/src/reactpy/html.py index 941af949f..91d73d240 100644 --- a/src/reactpy/html.py +++ b/src/reactpy/html.py @@ -422,54 +422,10 @@ def _script( key is given, the key is inferred to be the content of the script or, lastly its 'src' attribute if that is given. - If no attributes are given, the content of the script may evaluate to a function. - This function will be called when the script is initially created or when the - content of the script changes. The function may itself optionally return a teardown - function that is called when the script element is removed from the tree, or when - the script content changes. - Notes: Do not use unsanitized data from untrusted sources anywhere in your script. - Doing so may allow for malicious code injection. Consider this **insecure** - code: - - .. code-block:: - - my_script = html.script(f"console.log('{user_bio}');") - - A clever attacker could construct ``user_bio`` such that they could escape the - string and execute arbitrary code to perform cross-site scripting - (`XSS `__`). For example, - what if ``user_bio`` were of the form: - - .. code-block:: text - - '); attackerCodeHere(); (' - - This would allow the following Javascript code to be executed client-side: - - .. code-block:: js - - console.log(''); attackerCodeHere(); (''); - - One way to avoid this could be to escape ``user_bio`` so as to prevent the - injection of Javascript code. For example: - - .. code-block:: python - - import json - - my_script = html.script(f"console.log({json.dumps(user_bio)});") - - This would prevent the injection of Javascript code by escaping the ``user_bio`` - string. In this case, the following client-side code would be executed instead: - - .. code-block:: js - - console.log("'); attackerCodeHere(); ('"); - - This is a very simple example, but it illustrates the point that you should - always be careful when using unsanitized data from untrusted sources. + Doing so may allow for malicious code injection + (`XSS `__`). """ model: VdomDict = {"tagName": "script"} @@ -481,13 +437,12 @@ def _script( if len(children) > 1: msg = "'script' nodes may have, at most, one child." raise ValueError(msg) - elif not isinstance(children[0], str): + if not isinstance(children[0], str): msg = "The child of a 'script' must be a string." raise ValueError(msg) - else: - model["children"] = children - if key is None: - key = children[0] + model["children"] = children + if key is None: + key = children[0] if attributes: model["attributes"] = attributes diff --git a/tests/conftest.py b/tests/conftest.py index eaeb37f64..17231a2ac 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -36,6 +36,11 @@ def install_playwright(): subprocess.run(["playwright", "install", "chromium"], check=True) # noqa: S607, S603 +@pytest.fixture(autouse=True, scope="session") +def rebuild_javascript(): + subprocess.run(["hatch", "run", "javascript:build"], check=True) # noqa: S607, S603 + + @pytest.fixture async def display(server, page): async with DisplayFixture(server, page) as display: diff --git a/tests/test_html.py b/tests/test_html.py index 334fcab03..30b02ce99 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -3,47 +3,7 @@ from reactpy import component, config, html from reactpy.testing import DisplayFixture, poll from reactpy.utils import Ref -from tests.tooling.hooks import use_counter, use_toggle - - -async def test_script_mount_unmount(display: DisplayFixture): - toggle_is_mounted = Ref() - - @component - def Root(): - is_mounted, toggle_is_mounted.current = use_toggle(True) - return html.div( - html.div({"id": "mount-state", "data_value": False}), - HasScript() if is_mounted else html.div(), - ) - - @component - def HasScript(): - return html.script( - """() => { - const mapping = {"false": false, "true": true}; - const mountStateEl = document.getElementById("mount-state"); - mountStateEl.setAttribute( - "data-value", !mapping[mountStateEl.getAttribute("data-value")]); - return () => mountStateEl.setAttribute( - "data-value", !mapping[mountStateEl.getAttribute("data-value")]); - }""" - ) - - await display.show(Root) - - mount_state = await display.page.wait_for_selector("#mount-state", state="attached") - poll_mount_state = poll(mount_state.get_attribute, "data-value") - - await poll_mount_state.until_equals("true") - - toggle_is_mounted.current() - - await poll_mount_state.until_equals("false") - - toggle_is_mounted.current() - - await poll_mount_state.until_equals("true") +from tests.tooling.hooks import use_counter async def test_script_re_run_on_content_change(display: DisplayFixture): @@ -54,14 +14,8 @@ def HasScript(): count, incr_count.current = use_counter(1) return html.div( html.div({"id": "mount-count", "data_value": 0}), - html.div({"id": "unmount-count", "data_value": 0}), html.script( - f"""() => {{ - const mountCountEl = document.getElementById("mount-count"); - const unmountCountEl = document.getElementById("unmount-count"); - mountCountEl.setAttribute("data-value", {count}); - return () => unmountCountEl.setAttribute("data-value", {count});; - }}""" + f'document.getElementById("mount-count").setAttribute("data-value", {count});' ), ) @@ -70,23 +24,11 @@ def HasScript(): mount_count = await display.page.wait_for_selector("#mount-count", state="attached") poll_mount_count = poll(mount_count.get_attribute, "data-value") - unmount_count = await display.page.wait_for_selector( - "#unmount-count", state="attached" - ) - poll_unmount_count = poll(unmount_count.get_attribute, "data-value") - await poll_mount_count.until_equals("1") - await poll_unmount_count.until_equals("0") - incr_count.current() - await poll_mount_count.until_equals("2") - await poll_unmount_count.until_equals("1") - incr_count.current() - await poll_mount_count.until_equals("3") - await poll_unmount_count.until_equals("2") async def test_script_from_src(display: DisplayFixture): diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py index 75c819a32..388794741 100644 --- a/tests/test_web/test_module.py +++ b/tests/test_web/test_module.py @@ -50,6 +50,7 @@ def ShowCurrentComponent(): await display.page.wait_for_selector("#unmount-flag", state="attached") +@pytest.mark.flaky(reruns=3) async def test_module_from_url(browser): app = Sanic("test_module_from_url") diff --git a/tests/tooling/common.py b/tests/tooling/common.py index c0191bd4e..1803b8aed 100644 --- a/tests/tooling/common.py +++ b/tests/tooling/common.py @@ -1,9 +1,12 @@ +import os from typing import Any from reactpy.core.types import LayoutEventMessage, LayoutUpdateMessage -# see: https://github.com/microsoft/playwright-python/issues/1614 -DEFAULT_TYPE_DELAY = 100 # milliseconds +GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "False") +DEFAULT_TYPE_DELAY = ( + 250 if GITHUB_ACTIONS.lower() in {"y", "yes", "t", "true", "on", "1"} else 25 +) def event_message(target: str, *data: Any) -> LayoutEventMessage: