Fix #1059 - Observe py-* attributes changes (#1435)

This commit is contained in:
Andrea Giammarchi
2023-05-03 09:50:21 +02:00
committed by GitHub
parent cd1aa948f9
commit e7aed7fcf0
3 changed files with 128 additions and 21 deletions

View File

@@ -11,6 +11,10 @@ Features
- The `py-mount` attribute on HTML elements has been deprecated, and will be removed in a future release.
### Runtime py- attributes
- Added logic to react to `py-*` attributes changes, removal, `py-*` attributes added to already live nodes but also `py-*` attributes added or defined via injected nodes (either appended or via `innerHTML` operations). ([#1435](https://github.com/pyscript/pyscript/pull/1435))
### <script type="py">
- Added the ability to optionally use `<script type="py">`, `<script type="pyscript">` or `<script type="py-script">` instead of a `<py-script>` custom element, in order to tackle cases where the content of the `<py-script>` tag, inevitably parsed by browsers, could accidentally contain *HTML* able to break the surrounding page layout. ([#1396](https://github.com/pyscript/pyscript/pull/1396))

View File

@@ -107,28 +107,55 @@ export function make_PyScript(interpreter: InterpreterClient, app: PyScriptApp)
);
};
// callback used to bootstrap already known <script> tags
const callback: MutationCallback = records => {
for (const { addedNodes } of records) {
for (const node of addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
if ((node as PyScriptElement).matches(pyScriptCSS)) {
bootstrap(node as PyScriptElement);
}
for (const child of $$(pyScriptCSS, node as PyScriptElement)) {
bootstrap(child as PyScriptElement);
}
}
}
// loop over all py scripts and botstrap these
const bootstrapScripts = (root: Document | Element) => {
for (const node of $$(pyScriptCSS, root)) {
bootstrap(node as PyScriptElement);
}
};
// globally shared MutationObserver for <script> special cases
const pyScriptMO = new MutationObserver(callback);
const pyScriptMO = new MutationObserver(records => {
for (const { type, target, attributeName, addedNodes } of records) {
if (type === 'attributes') {
// consider only py-* attributes
if (type.startsWith('py-')) {
// if the attribute is currently present
if ((target as Element).hasAttribute(attributeName)) {
// handle the element
addPyScriptEventListener(
getInterpreter(target as Element),
target as Element,
type.slice(3),
);
} else {
// remove the listener because the element should not answer
// to this specific event anymore
// Note: this is *NOT* a misused-promise, this is how async events work.
// eslint-disable-next-line @typescript-eslint/no-misused-promises
target.removeEventListener(type.slice(3), pyScriptListener);
}
}
// skip further loop on empty addedNodes
continue;
}
for (const node of addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
if ((node as PyScriptElement).matches(pyScriptCSS)) {
bootstrap(node as PyScriptElement);
} else {
addAllPyScriptEventListeners(node as Element);
bootstrapScripts(node as Element);
}
}
}
}
});
// simplifies observing any root node (document/shadowRoot)
const observe = (root: Document | ShadowRoot) => {
pyScriptMO.observe(root, { childList: true, subtree: true });
pyScriptMO.observe(root, { childList: true, subtree: true, attributes: true });
return root;
};
@@ -141,7 +168,7 @@ export function make_PyScript(interpreter: InterpreterClient, app: PyScriptApp)
});
// bootstrap all already live py <script> tags
callback([{ addedNodes: $$(pyScriptCSS, document) } as unknown] as MutationRecord[], null);
bootstrapScripts(document);
// once all tags have been initialized, observe new possible tags added later on
// this is to save a few ticks within the callback as each <script> already adds a companion node
@@ -154,18 +181,32 @@ export function make_PyScript(interpreter: InterpreterClient, app: PyScriptApp)
/** A weak relation between an element and current interpreter */
const elementInterpreter: WeakMap<Element, InterpreterClient> = new WeakMap();
/** Return the interpreter, if any, or vallback to the last known one */
const getInterpreter = (el: Element) => elementInterpreter.get(el) || lastInterpreter;
/** Retain last used interpreter to bootstrap PyScript to augment via MO runtime nodes */
let lastInterpreter: InterpreterClient;
/** Find all py-* attributes in a context node and its descendant + add listeners */
const addAllPyScriptEventListeners = (root: Document | Element) => {
// note the XPath needs to start with a `.` to reference the starting root element
const attributes = $x('.//@*[starts-with(name(), "py-")]', root) as Attr[];
for (const { name, ownerElement: el } of attributes) {
addPyScriptEventListener(getInterpreter(el), el, name.slice(3));
}
};
/** Initialize all elements with py-* handlers attributes */
export function initHandlers(interpreter: InterpreterClient) {
logger.debug('Initializing py-* event handlers...');
for (const { name, ownerElement: el } of $x('//@*[starts-with(name(), "py-")]') as Attr[]) {
createElementsWithEventListeners(interpreter, el, name.slice(3));
}
lastInterpreter = interpreter;
addAllPyScriptEventListeners(document);
}
/** An always same listeners to reduce RAM and enable future runtime changes via MO */
const pyScriptListener = async ({ type, currentTarget: el }) => {
try {
const interpreter = elementInterpreter.get(el);
const interpreter = getInterpreter(el);
await interpreter.run(el.getAttribute(`py-${type as string}`));
} catch (e) {
const err = e as Error;
@@ -174,7 +215,7 @@ const pyScriptListener = async ({ type, currentTarget: el }) => {
};
/** Weakly relate an element with an interpreter and then add the listener's type */
function createElementsWithEventListeners(interpreter: InterpreterClient, el: Element, type: string) {
function addPyScriptEventListener(interpreter: InterpreterClient, el: Element, type: string) {
// If the element doesn't have an id, let's add one automatically!
if (el.id.length === 0) {
ensureUniqueId(el as HTMLElement);

View File

@@ -0,0 +1,62 @@
from .support import PyScriptTest, skip_worker
class TestPyScriptRuntimeAttributes(PyScriptTest):
@skip_worker("FIXME: js.document")
def test_injected_html_with_py_event(self):
self.pyscript_run(
r"""
<div id="py-button-container"></div>
<py-script>
import js
py_button = Element("py-button-container")
py_button.element.innerHTML = '<button py-click="print_hello()"></button>'
def print_hello():
js.console.log("hello pyscript")
</py-script>
"""
)
self.page.locator("button").click()
assert self.console.log.lines == ["hello pyscript"]
@skip_worker("FIXME: js.document")
def test_added_py_event(self):
self.pyscript_run(
r"""
<button id="py-button"></button>
<py-script>
import js
py_button = Element("py-button")
py_button.element.setAttribute("py-click", "print_hello()")
def print_hello():
js.console.log("hello pyscript")
</py-script>
"""
)
self.page.locator("button").click()
assert self.console.log.lines == ["hello pyscript"]
@skip_worker("FIXME: js.document")
def test_added_then_removed_py_event(self):
self.pyscript_run(
r"""
<button id="py-button">live content</button>
<py-script>
import js
py_button = Element("py-button")
py_button.element.setAttribute("py-click", "print_hello()")
def print_hello():
js.console.log("hello pyscript")
py_button.element.removeAttribute("py-click")
</py-script>
"""
)
self.page.locator("button").click()
self.page.locator("button").click()
assert self.console.log.lines == ["hello pyscript"]