From 0ec06e1960d6ed59bf3f46dc3305253ff5eaf429 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sat, 29 Jan 2022 22:10:46 -0800 Subject: [PATCH] make scripts behave more like normal elements --- .../idom-client-react/src/components.js | 31 ++++++++-- src/idom/html.py | 52 +++++++++++++---- tests/test_html.py | 58 +++++++++++++++++-- 3 files changed, 122 insertions(+), 19 deletions(-) diff --git a/src/client/packages/idom-client-react/src/components.js b/src/client/packages/idom-client-react/src/components.js index 5caed180e..465f94801 100644 --- a/src/client/packages/idom-client-react/src/components.js +++ b/src/client/packages/idom-client-react/src/components.js @@ -35,7 +35,7 @@ export function Element({ model }) { return null; } } else if (model.tagName == "script") { - return html`<${ScriptElement} script=${model.children[0]} />`; + return html`<${ScriptElement} model=${model} />`; } else if (model.importSource) { return html`<${ImportedElement} model=${model} />`; } else { @@ -58,10 +58,31 @@ function StandardElement({ model }) { ); } -function ScriptElement({ script }) { - const el = React.useRef(); - React.useEffect(eval(script), [script]); - return null; +function ScriptElement({ model }) { + const ref = React.useRef(); + React.useEffect(() => { + if (model?.children?.length > 1) { + console.error("Too many children for 'script' element."); + } + + let scriptContent = model?.children?.[0]; + + let scriptElement; + if (model.attributes) { + scriptElement = document.createElement("script"); + for (const [k, v] of Object.entries(model.attributes)) { + scriptElement.setAttribute(k, v); + } + scriptElement.appendChild(document.createTextNode(scriptContent)); + ref.current.appendChild(scriptElement); + } else { + let scriptResult = eval(scriptContent); + if (typeof scriptResult == "function") { + return scriptResult(); + } + } + }, [model.key]); + return html`
`; } function ImportedElement({ model }) { diff --git a/src/idom/html.py b/src/idom/html.py index 64536595e..dd7ac24af 100644 --- a/src/idom/html.py +++ b/src/idom/html.py @@ -150,8 +150,12 @@ - :func:`template` """ +from __future__ import annotations + +from typing import Any, Mapping + from .core.proto import VdomDict -from .core.vdom import make_vdom_constructor +from .core.vdom import coalesce_attributes_and_children, make_vdom_constructor # Dcument metadata @@ -250,18 +254,46 @@ noscript = make_vdom_constructor("noscript") -def script(content: str) -> VdomDict: +def script( + *attributes_and_children: Mapping[str, Any] | str, + key: str | int | None = None, +) -> VdomDict: """Create a new `<{script}> `__ element. - Parameters: - content: - The text of the script should 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 optionally return a teardown function that - is called when the script element is removed from the tree, or when the - script content changes. + This behaves slightly differently than a normal script element in that it may be run + multiple times if its key changes (depending on specific browser behaviors). If no + 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. """ - return {"tagName": "script", "children": [content]} + model: VdomDict = {"tagName": "script"} + + attributes, children = coalesce_attributes_and_children(attributes_and_children) + + if children: + if len(children) > 1: + raise ValueError("'script' nodes may have, at most, one child.") + elif not isinstance(children[0], str): + raise ValueError("The child of a 'script' must be a string.") + else: + model["children"] = children + if key is None: + key = children[0] + + if attributes: + model["attributes"] = attributes + if key is None and not children and "src" in attributes: + key = attributes["src"] + + if key is not None: + model["key"] = key + + return model # Demarcating edits diff --git a/tests/test_html.py b/tests/test_html.py index d62e7a954..fbc888627 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -1,4 +1,6 @@ -from idom import component, html, use_state +import pytest + +from idom import component, config, html, use_state from idom.utils import Ref @@ -7,8 +9,8 @@ def use_toggle(): return state, lambda: set_state(not state) -def use_counter(): - state, set_state = use_state(1) +def use_counter(initial_value): + state, set_state = use_state(initial_value) return state, lambda: set_state(state + 1) @@ -61,7 +63,7 @@ def test_script_re_run_on_content_change(driver, driver_wait, display): @component def HasScript(): - count, incr_count.current = use_counter() + 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}), @@ -92,3 +94,51 @@ def HasScript(): driver_wait.until(lambda d: mount_count.get_attribute("data-value") == "3") driver_wait.until(lambda d: unmount_count.get_attribute("data-value") == "2") + + +def test_script_from_src(driver, driver_wait, display): + incr_src_id = Ref() + file_name_template = "__some_js_script_{src_id}__.js" + + @component + def HasScript(): + src_id, incr_src_id.current = use_counter(0) + if src_id == 0: + # on initial display we haven't added the file yet. + return html.div() + else: + return html.div( + html.div({"id": "run-count", "data-value": 0}), + html.script( + {"src": f"/modules/{file_name_template.format(src_id=src_id)}"} + ), + ) + + display(HasScript) + + for i in range(1, 4): + script_file = config.IDOM_WED_MODULES_DIR.current / file_name_template.format( + src_id=i + ) + script_file.write_text( + f""" + let runCountEl = document.getElementById("run-count"); + runCountEl.setAttribute("data-value", {i}); + """ + ) + + incr_src_id.current() + + run_count = driver.find_element("id", "run-count") + + driver_wait.until(lambda d: (run_count.get_attribute("data-value") == "1")) + + +def test_script_may_only_have_one_child(): + with pytest.raises(ValueError, match="'script' nodes may have, at most, one child"): + html.script("one child", "two child") + + +def test_child_of_script_must_be_string(): + with pytest.raises(ValueError, match="The child of a 'script' must be a string"): + html.script(1)