Skip to content

Commit 17f2286

Browse files
Always render script element as plain HTML (#1239)
* Always render script element as plain HTML --------- Co-authored-by: James Hutchison <[email protected]>
1 parent 985472b commit 17f2286

File tree

8 files changed

+49
-137
lines changed

8 files changed

+49
-137
lines changed

docs/source/about/changelog.rst

+9-6
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,21 @@ Changelog
88
scheme for the project adheres to `Semantic Versioning <https://semver.org/>`__.
99

1010

11-
.. INSTRUCTIONS FOR CHANGELOG CONTRIBUTORS
12-
.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
13-
.. If you're adding a changelog entry, be sure to read the "Creating a Changelog Entry"
14-
.. section of the documentation before doing so for instructions on how to adhere to the
15-
.. "Keep a Changelog" style guide (https://keepachangelog.com).
11+
.. Using the following categories, list your changes in this order:
12+
.. [Added, Changed, Deprecated, Removed, Fixed, Security]
13+
.. Don't forget to remove deprecated code on each major release!
1614
1715
Unreleased
1816
----------
1917

2018
**Changed**
2119

22-
- :pull:`1251` Substitute client-side usage of ``react`` with ``preact``.
20+
- :pull:`1251` - Substitute client-side usage of ``react`` with ``preact``.
21+
- :pull:`1239` - Script elements no longer support behaving like effects. They now strictly behave like plain HTML script elements.
22+
23+
**Fixed**
24+
25+
- :pull:`1239` - Fixed a bug where script elements would not render to the DOM as plain text.
2326

2427
v1.1.0
2528
------

pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ artifacts = ["/src/reactpy/static/"]
5454
artifacts = ["/src/reactpy/static/"]
5555

5656
[tool.hatch.metadata]
57-
license-files = { paths = ["LICENSE.md"] }
57+
license-files = { paths = ["LICENSE"] }
5858

5959
[tool.hatch.envs.default]
6060
installer = "uv"

src/js/packages/@reactpy/client/src/components.tsx

+20-17
Original file line numberDiff line numberDiff line change
@@ -120,30 +120,33 @@ function ScriptElement({ model }: { model: ReactPyVdom }) {
120120
const ref = useRef<HTMLDivElement | null>(null);
121121

122122
React.useEffect(() => {
123+
// Don't run if the parent element is missing
123124
if (!ref.current) {
124125
return;
125126
}
127+
128+
// Create the script element
129+
const scriptElement: HTMLScriptElement = document.createElement("script");
130+
for (const [k, v] of Object.entries(model.attributes || {})) {
131+
scriptElement.setAttribute(k, v);
132+
}
133+
134+
// Add the script content as text
126135
const scriptContent = model?.children?.filter(
127136
(value): value is string => typeof value == "string",
128137
)[0];
129-
130-
let scriptElement: HTMLScriptElement;
131-
if (model.attributes) {
132-
scriptElement = document.createElement("script");
133-
for (const [k, v] of Object.entries(model.attributes)) {
134-
scriptElement.setAttribute(k, v);
135-
}
136-
if (scriptContent) {
137-
scriptElement.appendChild(document.createTextNode(scriptContent));
138-
}
139-
ref.current.appendChild(scriptElement);
140-
} else if (scriptContent) {
141-
const scriptResult = eval(scriptContent);
142-
if (typeof scriptResult == "function") {
143-
return scriptResult();
144-
}
138+
if (scriptContent) {
139+
scriptElement.appendChild(document.createTextNode(scriptContent));
145140
}
146-
}, [model.key, ref.current]);
141+
142+
// Append the script element to the parent element
143+
ref.current.appendChild(scriptElement);
144+
145+
// Remove the script element when the component is unmounted
146+
return () => {
147+
ref.current?.removeChild(scriptElement);
148+
};
149+
}, [model.key]);
147150

148151
return <div ref={ref} />;
149152
}

src/reactpy/html.py

+6-51
Original file line numberDiff line numberDiff line change
@@ -422,54 +422,10 @@ def _script(
422422
key is given, the key is inferred to be the content of the script or, lastly its
423423
'src' attribute if that is given.
424424
425-
If no attributes are given, the content of the script may evaluate to a function.
426-
This function will be called when the script is initially created or when the
427-
content of the script changes. The function may itself optionally return a teardown
428-
function that is called when the script element is removed from the tree, or when
429-
the script content changes.
430-
431425
Notes:
432426
Do not use unsanitized data from untrusted sources anywhere in your script.
433-
Doing so may allow for malicious code injection. Consider this **insecure**
434-
code:
435-
436-
.. code-block::
437-
438-
my_script = html.script(f"console.log('{user_bio}');")
439-
440-
A clever attacker could construct ``user_bio`` such that they could escape the
441-
string and execute arbitrary code to perform cross-site scripting
442-
(`XSS <https://en.wikipedia.org/wiki/Cross-site_scripting>`__`). For example,
443-
what if ``user_bio`` were of the form:
444-
445-
.. code-block:: text
446-
447-
'); attackerCodeHere(); ('
448-
449-
This would allow the following Javascript code to be executed client-side:
450-
451-
.. code-block:: js
452-
453-
console.log(''); attackerCodeHere(); ('');
454-
455-
One way to avoid this could be to escape ``user_bio`` so as to prevent the
456-
injection of Javascript code. For example:
457-
458-
.. code-block:: python
459-
460-
import json
461-
462-
my_script = html.script(f"console.log({json.dumps(user_bio)});")
463-
464-
This would prevent the injection of Javascript code by escaping the ``user_bio``
465-
string. In this case, the following client-side code would be executed instead:
466-
467-
.. code-block:: js
468-
469-
console.log("'); attackerCodeHere(); ('");
470-
471-
This is a very simple example, but it illustrates the point that you should
472-
always be careful when using unsanitized data from untrusted sources.
427+
Doing so may allow for malicious code injection
428+
(`XSS <https://en.wikipedia.org/wiki/Cross-site_scripting>`__`).
473429
"""
474430
model: VdomDict = {"tagName": "script"}
475431

@@ -481,13 +437,12 @@ def _script(
481437
if len(children) > 1:
482438
msg = "'script' nodes may have, at most, one child."
483439
raise ValueError(msg)
484-
elif not isinstance(children[0], str):
440+
if not isinstance(children[0], str):
485441
msg = "The child of a 'script' must be a string."
486442
raise ValueError(msg)
487-
else:
488-
model["children"] = children
489-
if key is None:
490-
key = children[0]
443+
model["children"] = children
444+
if key is None:
445+
key = children[0]
491446

492447
if attributes:
493448
model["attributes"] = attributes

tests/conftest.py

+5
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ def install_playwright():
3636
subprocess.run(["playwright", "install", "chromium"], check=True) # noqa: S607, S603
3737

3838

39+
@pytest.fixture(autouse=True, scope="session")
40+
def rebuild_javascript():
41+
subprocess.run(["hatch", "run", "javascript:build"], check=True) # noqa: S607, S603
42+
43+
3944
@pytest.fixture
4045
async def display(server, page):
4146
async with DisplayFixture(server, page) as display:

tests/test_html.py

+2-60
Original file line numberDiff line numberDiff line change
@@ -3,47 +3,7 @@
33
from reactpy import component, config, html
44
from reactpy.testing import DisplayFixture, poll
55
from reactpy.utils import Ref
6-
from tests.tooling.hooks import use_counter, use_toggle
7-
8-
9-
async def test_script_mount_unmount(display: DisplayFixture):
10-
toggle_is_mounted = Ref()
11-
12-
@component
13-
def Root():
14-
is_mounted, toggle_is_mounted.current = use_toggle(True)
15-
return html.div(
16-
html.div({"id": "mount-state", "data_value": False}),
17-
HasScript() if is_mounted else html.div(),
18-
)
19-
20-
@component
21-
def HasScript():
22-
return html.script(
23-
"""() => {
24-
const mapping = {"false": false, "true": true};
25-
const mountStateEl = document.getElementById("mount-state");
26-
mountStateEl.setAttribute(
27-
"data-value", !mapping[mountStateEl.getAttribute("data-value")]);
28-
return () => mountStateEl.setAttribute(
29-
"data-value", !mapping[mountStateEl.getAttribute("data-value")]);
30-
}"""
31-
)
32-
33-
await display.show(Root)
34-
35-
mount_state = await display.page.wait_for_selector("#mount-state", state="attached")
36-
poll_mount_state = poll(mount_state.get_attribute, "data-value")
37-
38-
await poll_mount_state.until_equals("true")
39-
40-
toggle_is_mounted.current()
41-
42-
await poll_mount_state.until_equals("false")
43-
44-
toggle_is_mounted.current()
45-
46-
await poll_mount_state.until_equals("true")
6+
from tests.tooling.hooks import use_counter
477

488

499
async def test_script_re_run_on_content_change(display: DisplayFixture):
@@ -54,14 +14,8 @@ def HasScript():
5414
count, incr_count.current = use_counter(1)
5515
return html.div(
5616
html.div({"id": "mount-count", "data_value": 0}),
57-
html.div({"id": "unmount-count", "data_value": 0}),
5817
html.script(
59-
f"""() => {{
60-
const mountCountEl = document.getElementById("mount-count");
61-
const unmountCountEl = document.getElementById("unmount-count");
62-
mountCountEl.setAttribute("data-value", {count});
63-
return () => unmountCountEl.setAttribute("data-value", {count});;
64-
}}"""
18+
f'document.getElementById("mount-count").setAttribute("data-value", {count});'
6519
),
6620
)
6721

@@ -70,23 +24,11 @@ def HasScript():
7024
mount_count = await display.page.wait_for_selector("#mount-count", state="attached")
7125
poll_mount_count = poll(mount_count.get_attribute, "data-value")
7226

73-
unmount_count = await display.page.wait_for_selector(
74-
"#unmount-count", state="attached"
75-
)
76-
poll_unmount_count = poll(unmount_count.get_attribute, "data-value")
77-
7827
await poll_mount_count.until_equals("1")
79-
await poll_unmount_count.until_equals("0")
80-
8128
incr_count.current()
82-
8329
await poll_mount_count.until_equals("2")
84-
await poll_unmount_count.until_equals("1")
85-
8630
incr_count.current()
87-
8831
await poll_mount_count.until_equals("3")
89-
await poll_unmount_count.until_equals("2")
9032

9133

9234
async def test_script_from_src(display: DisplayFixture):

tests/test_web/test_module.py

+1
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ def ShowCurrentComponent():
5050
await display.page.wait_for_selector("#unmount-flag", state="attached")
5151

5252

53+
@pytest.mark.flaky(reruns=3)
5354
async def test_module_from_url(browser):
5455
app = Sanic("test_module_from_url")
5556

tests/tooling/common.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
import os
12
from typing import Any
23

34
from reactpy.core.types import LayoutEventMessage, LayoutUpdateMessage
45

5-
# see: https://github.com/microsoft/playwright-python/issues/1614
6-
DEFAULT_TYPE_DELAY = 100 # milliseconds
6+
GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "False")
7+
DEFAULT_TYPE_DELAY = (
8+
250 if GITHUB_ACTIONS.lower() in {"y", "yes", "t", "true", "on", "1"} else 25
9+
)
710

811

912
def event_message(target: str, *data: Any) -> LayoutEventMessage:

0 commit comments

Comments
 (0)