Preserve native interaction semantics
This commit is contained in:
+39
-34
@@ -1,9 +1,9 @@
|
||||
(() => {
|
||||
const ROOT = "data-hemx-root";
|
||||
const HID = "data-hid";
|
||||
const SID = "data-sid";
|
||||
const ACTION = "data-hemx-action";
|
||||
const RESOURCE = "data-hemx-resource";
|
||||
const runtimeAbiVersion = 1;
|
||||
const FINGERPRINT = "data-hemx-fp";
|
||||
const BUILD = "data-hemx-build";
|
||||
const STATE = "data-hemx-st";
|
||||
const pending = new WeakMap();
|
||||
const queues = new WeakMap();
|
||||
@@ -54,8 +54,8 @@
|
||||
|
||||
function formHandleId(form) {
|
||||
if (!form) return null;
|
||||
const holder = form.hasAttribute(HID) ? form : firstElement(form, (el) => el.hasAttribute(HID));
|
||||
const raw = holder && holder.getAttribute(HID);
|
||||
const holder = form.hasAttribute(ACTION) ? form : firstElement(form, (el) => el.hasAttribute(ACTION));
|
||||
const raw = holder && holder.getAttribute(ACTION);
|
||||
return raw && /^\d+$/.test(raw) ? raw : null;
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
}
|
||||
|
||||
function handleId(el) {
|
||||
const raw = el && el.getAttribute(HID);
|
||||
const raw = el && el.getAttribute(ACTION);
|
||||
return raw && /^\d+$/.test(raw) ? raw : null;
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@
|
||||
const dragKey = eventName === "drop" && dragKeys.get(rootOf(el));
|
||||
if (dragKey && !data.has("work_id")) data.set("work_id", dragKey);
|
||||
for (const { name, value } of Array.from(el.attributes || [])) {
|
||||
if (name.startsWith("data-") && !name.startsWith("data-hemx-") && name !== HID && name !== SID) {
|
||||
if (name.startsWith("data-") && !name.startsWith("data-hemx-") && name !== ACTION && name !== RESOURCE) {
|
||||
data.set(name.slice(5).replace(/-/g, "_"), value);
|
||||
}
|
||||
}
|
||||
@@ -356,17 +356,25 @@
|
||||
|
||||
async function navigateUrl(href, root, mode = "replace") {
|
||||
if (mode === "push") savePage(root);
|
||||
const response = await fetch(href, {
|
||||
headers: { "X-HEMX-Partial": "1", "Accept": "text/html" },
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!await applyResponse(response, root)) {
|
||||
if (mode === "none") location.reload();
|
||||
else location.href = href;
|
||||
return;
|
||||
try {
|
||||
const response = await fetch(href, {
|
||||
headers: { "X-HEMX-Partial": "1", "Accept": "text/html" },
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!response.ok || !await applyResponse(response, root)) {
|
||||
fallbackNavigation(href, mode);
|
||||
return;
|
||||
}
|
||||
if (mode === "push") history.pushState({ hemx: true }, "", href);
|
||||
else if (mode === "replace") history.replaceState({ hemx: true }, "", href);
|
||||
} catch (_) {
|
||||
fallbackNavigation(href, mode);
|
||||
}
|
||||
if (mode === "push") history.pushState({ hemx: true }, "", href);
|
||||
else if (mode === "replace") history.replaceState({ hemx: true }, "", href);
|
||||
}
|
||||
|
||||
function fallbackNavigation(href, mode) {
|
||||
if (mode === "none") location.reload();
|
||||
else location.href = href;
|
||||
}
|
||||
|
||||
async function applyResponse(response, root) {
|
||||
@@ -389,7 +397,7 @@
|
||||
|
||||
function compatibleFingerprint(response, root) {
|
||||
const received = response.headers.get("x-hemx-fingerprint");
|
||||
const expected = root && root.getAttribute(FINGERPRINT);
|
||||
const expected = root && root.getAttribute(BUILD);
|
||||
return !received || !expected || received === expected;
|
||||
}
|
||||
|
||||
@@ -404,7 +412,7 @@
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const expected = root && root.getAttribute(FINGERPRINT);
|
||||
const expected = root && root.getAttribute(BUILD);
|
||||
if (expected && String(batch.fingerprint) !== expected) {
|
||||
location.reload();
|
||||
return;
|
||||
@@ -495,11 +503,11 @@
|
||||
}
|
||||
|
||||
function generatedResource(el, id) {
|
||||
return attrEquals(el, "data-sid", id) || attrEquals(el, "data-slot-id", id);
|
||||
return attrEquals(el, RESOURCE, id);
|
||||
}
|
||||
|
||||
function generatedForm(el, id) {
|
||||
return attrEquals(el, "data-fid", id) || attrEquals(el, "data-form-id", id);
|
||||
return generatedResource(el, id);
|
||||
}
|
||||
|
||||
function generatedTarget(scope, id) {
|
||||
@@ -550,7 +558,7 @@
|
||||
}
|
||||
|
||||
function keyedTarget(scope, id, key) {
|
||||
return firstElement(scope, (el) => attrEquals(el, "data-key", key) && withinGeneratedResource(el, scope, id));
|
||||
return firstElement(scope, (el) => attrEquals(el, "data-hemx-key", key) && withinGeneratedResource(el, scope, id));
|
||||
}
|
||||
|
||||
function fieldTarget(scope, id, field) {
|
||||
@@ -585,8 +593,8 @@
|
||||
else template.textContent = payload.value;
|
||||
const nodes = Array.from(template.content.childNodes);
|
||||
const firstElement = nodes.find((node) => node.nodeType === 1);
|
||||
if (firstElement && key != null && !firstElement.hasAttribute("data-key")) firstElement.setAttribute("data-key", key);
|
||||
if (firstElement && resourceId != null && !firstElement.hasAttribute("data-sid")) firstElement.setAttribute("data-sid", resourceId);
|
||||
if (firstElement && key != null && !firstElement.hasAttribute("data-hemx-key")) firstElement.setAttribute("data-hemx-key", key);
|
||||
if (firstElement && resourceId != null && !firstElement.hasAttribute("data-hemx-resource")) firstElement.setAttribute("data-hemx-resource", resourceId);
|
||||
return nodes;
|
||||
}
|
||||
|
||||
@@ -619,7 +627,7 @@
|
||||
function replaceLoweredSlots(scope, doc) {
|
||||
let changed = false;
|
||||
forEachElement(doc.body || doc, (source) => {
|
||||
const id = source.getAttribute("data-sid") || source.getAttribute("data-slot-id");
|
||||
const id = source.getAttribute(RESOURCE);
|
||||
if (!id) return;
|
||||
const target = generatedTarget(scope, id);
|
||||
if (target) {
|
||||
@@ -674,15 +682,15 @@
|
||||
["click", "submit", "input", "change", "keydown", "dragstart", "dragover", "drop"].forEach((name) => {
|
||||
root.addEventListener(name, (event) => {
|
||||
if (name === "dragstart") {
|
||||
const item = closestInRoot(event.target, root, (el) => el.hasAttribute("data-key"));
|
||||
const item = closestInRoot(event.target, root, (el) => el.hasAttribute("data-hemx-key"));
|
||||
if (item) {
|
||||
dragKeys.set(root, item.getAttribute("data-key"));
|
||||
if (event.dataTransfer) event.dataTransfer.setData("text/plain", item.getAttribute("data-key"));
|
||||
dragKeys.set(root, item.getAttribute("data-hemx-key"));
|
||||
if (event.dataTransfer) event.dataTransfer.setData("text/plain", item.getAttribute("data-hemx-key"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (name === "dragover") {
|
||||
const drop = closestInRoot(event.target, root, (el) => el.hasAttribute(HID) && el.getAttribute("data-hemx-on") === "drop");
|
||||
const drop = closestInRoot(event.target, root, (el) => el.hasAttribute(ACTION) && el.getAttribute("data-hemx-on") === "drop");
|
||||
if (drop) event.preventDefault();
|
||||
return;
|
||||
}
|
||||
@@ -696,7 +704,7 @@
|
||||
return;
|
||||
}
|
||||
if (name === "click") {
|
||||
const direct = closestInRoot(event.target, root, (el) => el.hasAttribute(HID));
|
||||
const direct = closestInRoot(event.target, root, (el) => el.hasAttribute(ACTION));
|
||||
if (direct && handlesEvent(direct, "click")) {
|
||||
event.preventDefault();
|
||||
schedule(direct, name);
|
||||
@@ -717,7 +725,7 @@
|
||||
}
|
||||
}
|
||||
let el = closestInRoot(event.target, root, (node) =>
|
||||
node.hasAttribute(HID) ||
|
||||
node.hasAttribute(ACTION) ||
|
||||
(node.tagName === "FORM" && (pageFormHistoryMode(event.submitter || node, node) || boostRoot(node)))
|
||||
);
|
||||
if (name === "submit" && !el && event.target && event.target.tagName === "FORM" && (formHandleId(event.target) || pageFormHistoryMode(event.submitter || event.target, event.target))) el = event.target;
|
||||
@@ -732,13 +740,10 @@
|
||||
|
||||
function schedule(el, eventName, source = el) {
|
||||
const debounce = duration(el.getAttribute("data-hemx-debounce"));
|
||||
const delay = duration(el.getAttribute("data-hemx-delay"));
|
||||
const throttle = duration(el.getAttribute("data-hemx-throttle"));
|
||||
if (debounce) {
|
||||
clearTimeout(timers.get(el));
|
||||
timers.set(el, setTimeout(() => send(el, eventName, source), debounce));
|
||||
} else if (delay) {
|
||||
setTimeout(() => send(el, eventName, source), delay);
|
||||
} else if (throttle) {
|
||||
if (timers.get(el)) return;
|
||||
send(el, eventName, source).finally(() => setTimeout(() => timers.delete(el), throttle));
|
||||
|
||||
@@ -75,6 +75,20 @@ fn runtime_preserves_multipart_file_upload_fallback_shape() {
|
||||
assert!(source.contains("if (body instanceof URLSearchParams) headers[\"Content-Type\"] = \"application/x-www-form-urlencoded;charset=UTF-8\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_preserves_native_form_controls_and_validation() {
|
||||
let source = hemx_js::RUNTIME_JS;
|
||||
|
||||
assert!(source.contains("if (form.reportValidity && !form.reportValidity()) return;"));
|
||||
assert!(source.contains(
|
||||
"return source && source !== form ? new FormData(form, source) : new FormData(form);"
|
||||
));
|
||||
assert!(source.contains("event.submitter || node"));
|
||||
assert!(source.contains("multipart/form-data"));
|
||||
assert!(!source.contains("compositionstart"));
|
||||
assert!(!source.contains("compositionend"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_uses_root_scoped_walks_not_dom_selector_apis() {
|
||||
let source = hemx_js::RUNTIME_JS;
|
||||
@@ -98,7 +112,7 @@ fn runtime_targets_generated_resources_not_response_selectors() {
|
||||
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("return firstElement(scope, (el) => attrEquals(el, \"data-hemx-key\", key) && withinGeneratedResource(el, scope, id))"));
|
||||
assert!(source.contains("if (node.hasAttribute && node.hasAttribute(ROOT)) continue"));
|
||||
assert!(source.contains("if (!applyOp(scope, op)) return"));
|
||||
assert!(!source.contains("canApplyOp"));
|
||||
@@ -129,11 +143,10 @@ fn runtime_interval_dispatch_avoids_duplicate_timers() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_supports_tiny_delay_and_revealed_scheduling() {
|
||||
fn runtime_supports_revealed_scheduling() {
|
||||
let source = hemx_js::RUNTIME_JS;
|
||||
|
||||
assert!(source.contains("data-hemx-delay"));
|
||||
assert!(source.contains("setTimeout(() => send(el, eventName, source), delay)"));
|
||||
assert!(!source.contains("data-hemx-delay"));
|
||||
assert!(source.contains("const revealObservers = new WeakMap()"));
|
||||
assert!(source.contains("const revealed = new WeakSet()"));
|
||||
assert!(source.contains("data-hemx-revealed"));
|
||||
@@ -238,7 +251,7 @@ fn runtime_clicking_submitter_schedules_form_submit() {
|
||||
);
|
||||
assert!(!source.contains("document.getElementById(el.getAttribute(\"form\"))"));
|
||||
assert!(source.contains(
|
||||
"const direct = closestInRoot(event.target, root, (el) => el.hasAttribute(HID))"
|
||||
"const direct = closestInRoot(event.target, root, (el) => el.hasAttribute(ACTION))"
|
||||
));
|
||||
assert!(source.contains("const submitter = closestInRoot(event.target, root, (el) =>"));
|
||||
assert!(source.contains("el.tagName === \"BUTTON\" && (!el.hasAttribute(\"type\") || el.getAttribute(\"type\") === \"submit\")"));
|
||||
@@ -309,6 +322,7 @@ fn runtime_exposes_page_swap_hooks() {
|
||||
assert!(source.contains("x-hemx-fingerprint"));
|
||||
assert!(source.contains("hemx:missing-content-slot"));
|
||||
assert!(source.contains("else location.href = href"));
|
||||
assert!(source.contains("if (!response.ok || !await applyResponse(response, root))"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -317,6 +331,7 @@ fn runtime_popstate_failed_partials_reload_instead_of_stale_ui() {
|
||||
|
||||
assert!(source.contains("if (mode === \"none\") location.reload();"));
|
||||
assert!(source.contains("if (root) navigateUrl(location.href, root, \"none\")"));
|
||||
assert!(source.contains("catch (_) {\n fallbackNavigation(href, mode);"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -338,7 +353,7 @@ fn runtime_refuses_partial_updates_on_fingerprint_mismatch() {
|
||||
|
||||
assert!(source.contains("function compatibleFingerprint(response, root)"));
|
||||
assert!(source.contains("const received = response.headers.get(\"x-hemx-fingerprint\")"));
|
||||
assert!(source.contains("const expected = root && root.getAttribute(FINGERPRINT)"));
|
||||
assert!(source.contains("const expected = root && root.getAttribute(BUILD)"));
|
||||
assert!(source.contains("if (!compatibleFingerprint(response, root))"));
|
||||
assert!(source.contains("location.reload()"));
|
||||
assert!(source.contains("if (expected && String(batch.fingerprint) !== expected)"));
|
||||
|
||||
Reference in New Issue
Block a user