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:
slhx agent
2026-05-26 01:18:21 +02:00
parent a8bd7dfdf7
commit d3fc1a745a
2 changed files with 137 additions and 46 deletions
+119 -35
View File
@@ -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
View File
@@ -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]