fix(js): remove runtime selector traversal
Replace runtime querySelector/closest/matches traversal with explicit element walks and generated-id predicates, preserving existing runtime convention coverage. req: runtime/001 req: target_policy/001 req: target_policy/002
This commit is contained in:
+119
-35
@@ -13,28 +13,34 @@
|
||||
const dragKeys = new WeakMap();
|
||||
|
||||
function roots() {
|
||||
return Array.from(document.querySelectorAll(`[${ROOT}]`));
|
||||
const found = [];
|
||||
forEachElement(document, (el) => { if (el.hasAttribute(ROOT)) found.push(el); });
|
||||
return found;
|
||||
}
|
||||
|
||||
function rootOf(node) {
|
||||
return node && node.closest ? node.closest(`[${ROOT}]`) : null;
|
||||
for (let el = node; el && el !== document; el = el.parentElement) {
|
||||
if (el.hasAttribute && el.hasAttribute(ROOT)) return el;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formOwner(el) {
|
||||
if (!el || el.tagName === "FORM") return el;
|
||||
if (el.getAttribute && el.getAttribute("form")) return document.getElementById(el.getAttribute("form"));
|
||||
return el.closest ? el.closest("form") : null;
|
||||
return closestInRoot(el, rootOf(el) || document, (node) => node.tagName === "FORM");
|
||||
}
|
||||
|
||||
function formHandleId(form) {
|
||||
if (!form) return null;
|
||||
const raw = form.getAttribute(HID) || (form.querySelector(`[${HID}]`) && form.querySelector(`[${HID}]`).getAttribute(HID));
|
||||
const holder = form.hasAttribute(HID) ? form : firstElement(form, (el) => el.hasAttribute(HID));
|
||||
const raw = holder && holder.getAttribute(HID);
|
||||
return raw && /^\d+$/.test(raw) ? raw : null;
|
||||
}
|
||||
|
||||
function closestInRoot(start, root, selector) {
|
||||
for (let node = start; node && node !== root.parentNode; node = node.parentNode) {
|
||||
if (node.matches && node.matches(selector)) return node;
|
||||
function closestInRoot(start, root, predicate) {
|
||||
for (let node = start; node && node !== root.parentNode; node = node.parentElement) {
|
||||
if (predicate(node)) return node;
|
||||
if (node === root) break;
|
||||
}
|
||||
return null;
|
||||
@@ -61,9 +67,11 @@
|
||||
const klass = el.getAttribute("data-slhx-pending-class");
|
||||
if (klass) el.classList.toggle(klass, on);
|
||||
const root = rootOf(el) || document;
|
||||
root.querySelectorAll("[data-slhx-indicator]").forEach((i) => { i.hidden = !on; });
|
||||
forEachElement(root, (i) => { if (i.hasAttribute("data-slhx-indicator")) i.hidden = !on; });
|
||||
if (el.hasAttribute("data-slhx-disable-while-pending")) {
|
||||
const controls = el.matches("button,input,select,textarea") ? [el] : el.querySelectorAll("button,input,select,textarea");
|
||||
const controls = [];
|
||||
if (isDisableControl(el)) controls.push(el);
|
||||
forEachElement(el, (child) => { if (isDisableControl(child)) controls.push(child); });
|
||||
controls.forEach((c) => { c.disabled = on; });
|
||||
}
|
||||
}
|
||||
@@ -279,7 +287,59 @@
|
||||
if (isAtom(ref)) return null;
|
||||
if (ref.scope && ref.scope.kind === "key") return keyedTarget(scope, ref.resource.id, ref.scope.value);
|
||||
if (ref.scope && ref.scope.kind === "field") return fieldTarget(scope, ref.resource.id, ref.scope.value);
|
||||
return scope.querySelector(`[data-sid="${ref.resource.id}"], [data-slot-id="${ref.resource.id}"]`);
|
||||
return generatedTarget(scope, ref.resource.id);
|
||||
}
|
||||
|
||||
function firstElement(scope, predicate) {
|
||||
const stack = [];
|
||||
for (let node = scope && scope.firstElementChild; node; node = node.nextElementSibling) stack.push(node);
|
||||
while (stack.length) {
|
||||
const node = stack.shift();
|
||||
if (predicate(node)) return node;
|
||||
for (let child = node.firstElementChild; child; child = child.nextElementSibling) stack.push(child);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function attrEquals(el, name, value) {
|
||||
return el && el.getAttribute && el.getAttribute(name) === String(value);
|
||||
}
|
||||
|
||||
function generatedResource(el, id) {
|
||||
return attrEquals(el, "data-sid", id) || attrEquals(el, "data-slot-id", id);
|
||||
}
|
||||
|
||||
function generatedForm(el, id) {
|
||||
return attrEquals(el, "data-fid", id) || attrEquals(el, "data-form-id", id);
|
||||
}
|
||||
|
||||
function generatedTarget(scope, id) {
|
||||
if (scope && generatedResource(scope, id)) return scope;
|
||||
return firstElement(scope, (el) => generatedResource(el, id));
|
||||
}
|
||||
|
||||
function withinGeneratedResource(el, scope, id) {
|
||||
for (let node = el; node && node !== scope.parentNode; node = node.parentElement) {
|
||||
if (generatedResource(node, id)) return true;
|
||||
if (node === scope) break;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function withinGeneratedForm(el, scope, id) {
|
||||
for (let node = el; node && node !== scope.parentNode; node = node.parentElement) {
|
||||
if (generatedForm(node, id)) return true;
|
||||
if (node === scope) break;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isInputControl(el) {
|
||||
return el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT");
|
||||
}
|
||||
|
||||
function isDisableControl(el) {
|
||||
return isInputControl(el) || (el && el.tagName === "BUTTON");
|
||||
}
|
||||
|
||||
function isAtom(ref) {
|
||||
@@ -301,29 +361,26 @@
|
||||
}
|
||||
|
||||
function keyedTarget(scope, id, key) {
|
||||
const escapedKey = cssEscape(key);
|
||||
return scope.querySelector(`[data-sid="${id}"][data-key="${escapedKey}"], [data-sid="${id}"] [data-key="${escapedKey}"], [data-slot-id="${id}"][data-key="${escapedKey}"], [data-slot-id="${id}"] [data-key="${escapedKey}"]`);
|
||||
return firstElement(scope, (el) => attrEquals(el, "data-key", key) && withinGeneratedResource(el, scope, id));
|
||||
}
|
||||
|
||||
function fieldTarget(scope, id, field) {
|
||||
const escapedField = cssEscape(field);
|
||||
return scope.querySelector(`[data-fid="${id}"] [name="${escapedField}"], [data-form-id="${id}"] [name="${escapedField}"]`);
|
||||
return firstElement(scope, (el) => attrEquals(el, "name", field) && withinGeneratedForm(el, scope, id));
|
||||
}
|
||||
|
||||
function formErrorTarget(scope, id, field) {
|
||||
const escapedField = cssEscape(field);
|
||||
return scope.querySelector(`[data-fid="${id}"] [data-slhx-error-for="${escapedField}"], [data-form-id="${id}"] [data-slhx-error-for="${escapedField}"]`) ||
|
||||
scope.querySelector(`[data-fid="${id}"] [name="${escapedField}"], [data-form-id="${id}"] [name="${escapedField}"]`);
|
||||
return firstElement(scope, (el) => attrEquals(el, "data-slhx-error-for", field) && withinGeneratedForm(el, scope, id)) ||
|
||||
firstElement(scope, (el) => attrEquals(el, "name", field) && withinGeneratedForm(el, scope, id));
|
||||
}
|
||||
|
||||
function putFormError(target, message) {
|
||||
if (target.matches && target.matches("input,textarea,select")) target.setCustomValidity(message);
|
||||
if (isInputControl(target)) target.setCustomValidity(message);
|
||||
else target.textContent = message;
|
||||
}
|
||||
|
||||
function putPayload(target, payload) {
|
||||
if (payload.kind === "html") target.innerHTML = payload.value;
|
||||
else if (target.matches && target.matches("input,textarea,select")) target.value = payload.value;
|
||||
else if (isInputControl(target)) target.value = payload.value;
|
||||
else target.textContent = payload.value;
|
||||
}
|
||||
|
||||
@@ -339,14 +396,14 @@
|
||||
|
||||
function handleRuntimeEvent(scope, name, payload) {
|
||||
if (name === "slhx:form-reset") {
|
||||
const form = scope.querySelector(`[data-fid="${payload}"], [data-form-id="${payload}"]`);
|
||||
const form = generatedFormTarget(scope, payload);
|
||||
if (form && form.reset) form.reset();
|
||||
} else if (name === "slhx:form-error") {
|
||||
const [id, field, message] = String(payload).split("\u001f");
|
||||
const target = formErrorTarget(scope, id, field);
|
||||
if (target) putFormError(target, message || "");
|
||||
} else if (name === "slhx:form-disable-while-pending") {
|
||||
const form = scope.querySelector(`[data-fid="${payload}"], [data-form-id="${payload}"]`);
|
||||
const form = generatedFormTarget(scope, payload);
|
||||
if (form) form.setAttribute("data-slhx-disable-while-pending", "");
|
||||
}
|
||||
}
|
||||
@@ -363,7 +420,7 @@
|
||||
function applyHtml(html, root, title) {
|
||||
const scope = root || document;
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
const template = doc.querySelector("template[data-slhx]");
|
||||
const template = firstElement(doc, (el) => el.tagName === "TEMPLATE" && el.hasAttribute("data-slhx"));
|
||||
const lowered = replaceLoweredSlots(scope, doc);
|
||||
const named = replaceSlot(scope, doc, "content", lowered ? undefined : (template ? template.innerHTML : html));
|
||||
if (!lowered && !named) {
|
||||
@@ -371,16 +428,18 @@
|
||||
return false;
|
||||
}
|
||||
replaceSlot(scope, doc, "nav");
|
||||
const nextTitle = title || (doc.querySelector("title") && doc.querySelector("title").textContent);
|
||||
const titleEl = firstElement(doc, (el) => el.tagName === "TITLE");
|
||||
const nextTitle = title || (titleEl && titleEl.textContent);
|
||||
if (nextTitle) document.title = nextTitle;
|
||||
return true;
|
||||
}
|
||||
|
||||
function replaceLoweredSlots(scope, doc) {
|
||||
let changed = false;
|
||||
doc.querySelectorAll("[data-sid], [data-slot-id]").forEach((source) => {
|
||||
forEachElement(doc.body || doc, (source) => {
|
||||
const id = source.getAttribute("data-sid") || source.getAttribute("data-slot-id");
|
||||
const target = scope.querySelector(`[data-sid="${cssEscape(id)}"], [data-slot-id="${cssEscape(id)}"]`);
|
||||
if (!id) return;
|
||||
const target = generatedTarget(scope, id);
|
||||
if (target) {
|
||||
target.innerHTML = source.innerHTML;
|
||||
changed = true;
|
||||
@@ -389,10 +448,26 @@
|
||||
return changed;
|
||||
}
|
||||
|
||||
function forEachElement(scope, visit) {
|
||||
for (let node = scope && scope.firstElementChild; node; node = node.nextElementSibling) {
|
||||
visit(node);
|
||||
forEachElement(node, visit);
|
||||
}
|
||||
}
|
||||
|
||||
function generatedFormTarget(scope, id) {
|
||||
if (scope && generatedForm(scope, id)) return scope;
|
||||
return firstElement(scope, (el) => generatedForm(el, id));
|
||||
}
|
||||
|
||||
function namedSlot(el, name) {
|
||||
return attrEquals(el, "data-slhx-slot", name) || attrEquals(el, "data-slot", name);
|
||||
}
|
||||
|
||||
function replaceSlot(scope, doc, name, fallback) {
|
||||
const target = scope.querySelector(`[data-slhx-slot="${name}"], [data-slot="${name}"]`);
|
||||
const target = firstElement(scope, (el) => namedSlot(el, name));
|
||||
if (!target) return false;
|
||||
const source = doc.querySelector(`[data-slhx-slot="${name}"], [data-slot="${name}"]`);
|
||||
const source = firstElement(doc, (el) => namedSlot(el, name));
|
||||
if (!source && fallback === undefined) return false;
|
||||
target.innerHTML = source ? source.innerHTML : fallback;
|
||||
return true;
|
||||
@@ -412,7 +487,7 @@
|
||||
["click", "submit", "input", "change", "dragstart", "dragover", "drop"].forEach((name) => {
|
||||
root.addEventListener(name, (event) => {
|
||||
if (name === "dragstart") {
|
||||
const item = closestInRoot(event.target, root, "[data-key]");
|
||||
const item = closestInRoot(event.target, root, (el) => el.hasAttribute("data-key"));
|
||||
if (item) {
|
||||
dragKeys.set(root, item.getAttribute("data-key"));
|
||||
if (event.dataTransfer) event.dataTransfer.setData("text/plain", item.getAttribute("data-key"));
|
||||
@@ -420,18 +495,21 @@
|
||||
return;
|
||||
}
|
||||
if (name === "dragover") {
|
||||
const drop = closestInRoot(event.target, root, `[${HID}][data-slhx-on="drop"]`);
|
||||
const drop = closestInRoot(event.target, root, (el) => el.hasAttribute(HID) && el.getAttribute("data-slhx-on") === "drop");
|
||||
if (drop) event.preventDefault();
|
||||
return;
|
||||
}
|
||||
const nav = closestInRoot(event.target, root, "a[data-slhx-nav], [data-slhx-boost] a[href]");
|
||||
const nav = closestInRoot(event.target, root, (el) =>
|
||||
(el.tagName === "A" && el.hasAttribute("data-slhx-nav")) ||
|
||||
(el.tagName === "A" && el.hasAttribute("href") && closestInRoot(el.parentElement, root, (node) => node.hasAttribute("data-slhx-boost")))
|
||||
);
|
||||
if (name === "click" && nav && sameOriginNav(event, nav)) {
|
||||
event.preventDefault();
|
||||
navigate(nav);
|
||||
return;
|
||||
}
|
||||
if (name === "click") {
|
||||
const direct = closestInRoot(event.target, root, `[${HID}]`);
|
||||
const direct = closestInRoot(event.target, root, (el) => el.hasAttribute(HID));
|
||||
if (direct && defaultEvent(direct) === "click") {
|
||||
event.preventDefault();
|
||||
schedule(direct, name);
|
||||
@@ -439,7 +517,10 @@
|
||||
}
|
||||
}
|
||||
if (name === "click") {
|
||||
const submitter = closestInRoot(event.target, root, "button[type=submit], input[type=submit], button:not([type])");
|
||||
const submitter = closestInRoot(event.target, root, (el) =>
|
||||
(el.tagName === "BUTTON" && (!el.hasAttribute("type") || el.getAttribute("type") === "submit")) ||
|
||||
(el.tagName === "INPUT" && el.getAttribute("type") === "submit")
|
||||
);
|
||||
const form = formOwner(submitter);
|
||||
if (form && root.contains(form) && formHandleId(form)) {
|
||||
if (form.reportValidity && !form.reportValidity()) return;
|
||||
@@ -448,7 +529,10 @@
|
||||
return;
|
||||
}
|
||||
}
|
||||
let el = closestInRoot(event.target, root, `[${HID}], [data-slhx-boost] form`);
|
||||
let el = closestInRoot(event.target, root, (node) =>
|
||||
node.hasAttribute(HID) ||
|
||||
(node.tagName === "FORM" && closestInRoot(node.parentElement, root, (parent) => parent.hasAttribute("data-slhx-boost")))
|
||||
);
|
||||
if (name === "submit" && !el && event.target && event.target.tagName === "FORM" && formHandleId(event.target)) el = event.target;
|
||||
if (!el || defaultEvent(el) !== name) return;
|
||||
event.preventDefault();
|
||||
@@ -473,8 +557,8 @@
|
||||
}
|
||||
|
||||
function bindPolling(root) {
|
||||
root.querySelectorAll("[data-slhx-every]").forEach((el) => {
|
||||
if (timers.has(el)) return;
|
||||
forEachElement(root, (el) => {
|
||||
if (!el.hasAttribute("data-slhx-every") || timers.has(el)) return;
|
||||
const ms = duration(el.getAttribute("data-slhx-every"));
|
||||
if (!ms) return;
|
||||
timers.set(el, setInterval(() => document.contains(el) ? send(el, "every") : clearInterval(timers.get(el)), ms));
|
||||
|
||||
+18
-11
@@ -26,9 +26,11 @@ fn runtime_targets_generated_resources_not_response_selectors() {
|
||||
let source = slhx_js::RUNTIME_JS;
|
||||
|
||||
assert!(source.contains("function targetFor(scope, ref)"));
|
||||
assert!(source.contains("return scope.querySelector(`[data-sid=\"${ref.resource.id}\"], [data-slot-id=\"${ref.resource.id}\"]`)"));
|
||||
assert!(source.contains("return scope.querySelector(`[data-sid=\"${id}\"][data-key=\"${escapedKey}\"], [data-sid=\"${id}\"] [data-key=\"${escapedKey}\"], [data-slot-id=\"${id}\"][data-key=\"${escapedKey}\"], [data-slot-id=\"${id}\"] [data-key=\"${escapedKey}\"]`)"));
|
||||
assert!(source.contains("doc.querySelectorAll(\"[data-sid], [data-slot-id]\")"));
|
||||
assert!(source.contains("return generatedTarget(scope, ref.resource.id)"));
|
||||
assert!(source.contains("function firstElement(scope, predicate)"));
|
||||
assert!(source.contains("function generatedResource(el, id)"));
|
||||
assert!(source.contains("return firstElement(scope, (el) => attrEquals(el, \"data-key\", key) && withinGeneratedResource(el, scope, id))"));
|
||||
assert!(source.contains("const target = generatedTarget(scope, id)"));
|
||||
assert!(source.contains("if (!target) return missing(scope, op.target)"));
|
||||
assert!(!source.contains("data-slhx-target"));
|
||||
assert!(!source.contains("data-slhx-select"));
|
||||
@@ -39,8 +41,9 @@ fn runtime_interval_dispatch_avoids_duplicate_timers() {
|
||||
// req: convention/005
|
||||
let source = slhx_js::RUNTIME_JS;
|
||||
|
||||
assert!(source.contains("root.querySelectorAll(\"[data-slhx-every]\")"));
|
||||
assert!(source.contains("if (timers.has(el)) return"));
|
||||
assert!(source.contains("forEachElement(root, (el) =>"));
|
||||
assert!(source.contains("!el.hasAttribute(\"data-slhx-every\") || timers.has(el)"));
|
||||
assert!(source.contains("if (!el.hasAttribute(\"data-slhx-every\") || timers.has(el)) return"));
|
||||
assert!(source.contains("setInterval(() => document.contains(el) ? send(el, \"every\")"));
|
||||
assert!(source.contains("clearInterval(timers.get(el))"));
|
||||
}
|
||||
@@ -53,9 +56,11 @@ fn runtime_toggles_pending_conventions_around_requests() {
|
||||
assert!(source.contains("function showPending(el, on)"));
|
||||
assert!(source.contains("el.getAttribute(\"data-slhx-pending-class\")"));
|
||||
assert!(source.contains("el.classList.toggle(klass, on)"));
|
||||
assert!(source.contains("root.querySelectorAll(\"[data-slhx-indicator]\")"));
|
||||
assert!(source.contains("forEachElement(root, (i) => { if (i.hasAttribute(\"data-slhx-indicator\")) i.hidden = !on; })"));
|
||||
assert!(source.contains("i.hidden = !on"));
|
||||
assert!(source.contains("el.hasAttribute(\"data-slhx-disable-while-pending\")"));
|
||||
assert!(source.contains("if (isDisableControl(el)) controls.push(el)"));
|
||||
assert!(source.contains("forEachElement(el, (child) => { if (isDisableControl(child)) controls.push(child); })"));
|
||||
assert!(source.contains("c.disabled = on"));
|
||||
assert!(source.contains("showPending(target, true)"));
|
||||
assert!(source.contains("showPending(target, false)"));
|
||||
@@ -95,8 +100,9 @@ fn runtime_clicking_submitter_schedules_form_submit() {
|
||||
assert!(source.contains("function formOwner(el)"));
|
||||
assert!(source.contains("function formHandleId(form)"));
|
||||
assert!(source.contains("document.getElementById(el.getAttribute(\"form\"))"));
|
||||
assert!(source.contains("const direct = closestInRoot(event.target, root, `[${HID}]`)"));
|
||||
assert!(source.contains("button[type=submit], input[type=submit], button:not([type])"));
|
||||
assert!(source.contains("const direct = closestInRoot(event.target, root, (el) => el.hasAttribute(HID))"));
|
||||
assert!(source.contains("const submitter = closestInRoot(event.target, root, (el) =>"));
|
||||
assert!(source.contains("el.tagName === \"BUTTON\" && (!el.hasAttribute(\"type\") || el.getAttribute(\"type\") === \"submit\")"));
|
||||
assert!(source.contains("form.reportValidity && !form.reportValidity()"));
|
||||
assert!(source.contains("schedule(form, \"submit\")"));
|
||||
}
|
||||
@@ -187,7 +193,8 @@ fn runtime_page_swaps_lowered_slot_ids() {
|
||||
let source = slhx_js::RUNTIME_JS;
|
||||
|
||||
assert!(source.contains("function replaceLoweredSlots(scope, doc)"));
|
||||
assert!(source.contains("doc.querySelectorAll(\"[data-sid], [data-slot-id]\")"));
|
||||
assert!(source.contains("forEachElement(doc.body || doc, (source)"));
|
||||
assert!(source.contains("const target = generatedTarget(scope, id)"));
|
||||
assert!(source.contains("target.innerHTML = source.innerHTML"));
|
||||
}
|
||||
|
||||
@@ -196,9 +203,9 @@ fn runtime_keeps_form_field_targets_separate_from_error_targets() {
|
||||
let source = slhx_js::RUNTIME_JS;
|
||||
|
||||
assert!(source.contains("function fieldTarget(scope, id, field)"));
|
||||
assert!(source.contains("[data-fid=\"${id}\"] [name=\"${escapedField}\"]"));
|
||||
assert!(source.contains("attrEquals(el, \"name\", field) && withinGeneratedForm(el, scope, id)"));
|
||||
assert!(source.contains("function formErrorTarget(scope, id, field)"));
|
||||
assert!(source.contains("[data-slhx-error-for=\"${escapedField}\"]"));
|
||||
assert!(source.contains("attrEquals(el, \"data-slhx-error-for\", field) && withinGeneratedForm(el, scope, id)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user