From 16e54a5cfe19a4aafa4cd49240982c21cc7f0646 Mon Sep 17 00:00:00 2001 From: Adnaan Date: Sat, 4 Apr 2026 12:01:32 +0530 Subject: [PATCH 1/2] fix: lvt-form:action routing, lvt-nav:no-intercept, action field as data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add lvt-form:action attribute for explicit form routing (highest priority) - Stop excluding 'action' from form data — it's no longer reserved - Rename lvt-no-intercept → lvt-nav:no-intercept on links (semantic separation) - Keep lvt-form:no-intercept for forms (already correct) - Update change-auto-wirer: lvt-no-intercept → lvt-form:no-intercept - Add tests for lvt-form:action routing and action field preservation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dom/event-delegation.ts | 34 ++++++++------ dom/link-interceptor.ts | 6 +-- state/change-auto-wirer.ts | 4 +- tests/change-auto-wirer.test.ts | 8 ++-- tests/event-delegation.test.ts | 81 +++++++++++++++++++++++++++++++++ 5 files changed, 111 insertions(+), 22 deletions(-) diff --git a/dom/event-delegation.ts b/dom/event-delegation.ts index a1d145e..5c8c1d1 100644 --- a/dom/event-delegation.ts +++ b/dom/event-delegation.ts @@ -151,19 +151,26 @@ export class EventDelegator { } // Auto-intercept forms (progressive complexity). - // Button name IS the action. Resolution order: - // 1. submitter.name (button name = action) - // 2. form.name (form name = action) - // 3. "" (server defaults to Submit()) + // Action resolution order: + // 1. lvt-form:action attribute (explicit routing) + // 2. submitter.name (button name = action) + // 3. form.name (form name = action) + // 4. "" (server defaults to Submit()) if (!action && eventType === "submit" && element instanceof HTMLFormElement) { if (!element.hasAttribute("lvt-form:no-intercept")) { + // Check for explicit routing attribute first + const explicitAction = element.getAttribute("lvt-form:action"); const submitter = (e as SubmitEvent).submitter; - if (submitter instanceof HTMLButtonElement && submitter.name) { - action = submitter.name; - } else if (element.name) { - action = element.name; + if (explicitAction) { + action = explicitAction; } else { - action = "submit"; + if (submitter instanceof HTMLButtonElement && submitter.name) { + action = submitter.name; + } else if (element.name) { + action = element.name; + } else { + action = "submit"; + } } actionElement = element; @@ -233,13 +240,14 @@ export class EventDelegator { ).map((el) => (el as HTMLInputElement).name) ); - // Determine which form field key is the action routing key - // (the submitter button's name, or "action" field) + // Determine the action routing key to exclude from form data. + // Only the submitter button's name is excluded (it's the routing key, not data). + // "action" is NOT excluded — it's a normal data field. const submitterForData = (targetElement as any).__lvtSubmitter as HTMLButtonElement | undefined; - const actionFieldName = submitterForData?.name || "action"; + const actionFieldName = submitterForData?.name; formData.forEach((value, key) => { - if (key === actionFieldName || key === "action") return; + if (actionFieldName && key === actionFieldName) return; if (checkboxNames.has(key)) { message.data[key] = true; this.logger.debug("Converted checkbox", key, "to true"); diff --git a/dom/link-interceptor.ts b/dom/link-interceptor.ts index 1573fc8..5a90a74 100644 --- a/dom/link-interceptor.ts +++ b/dom/link-interceptor.ts @@ -8,7 +8,7 @@ export interface LinkInterceptorContext { /** * Intercepts clicks within the LiveTemplate wrapper for SPA navigation. * Same-origin links are fetched via fetch() and the wrapper content is replaced. - * External links, target="_blank", download, and lvt-no-intercept are skipped. + * External links, target="_blank", download, and lvt-nav:no-intercept are skipped. */ export class LinkInterceptor { private popstateListener: (() => void) | null = null; @@ -58,8 +58,8 @@ export class LinkInterceptor { if (link.target && link.target !== "_self") return true; // Download links if (link.hasAttribute("download")) return true; - // Opt-out (lvt-no-intercept is the generic opt-out for both forms and links) - if (link.hasAttribute("lvt-no-intercept")) return true; + // Opt-out attribute for link interception + if (link.hasAttribute("lvt-nav:no-intercept")) return true; // Hash-only links (scroll anchors) if (link.pathname === window.location.pathname && link.hash) return true; // mailto/tel/javascript diff --git a/state/change-auto-wirer.ts b/state/change-auto-wirer.ts index d766c04..00e18b2 100644 --- a/state/change-auto-wirer.ts +++ b/state/change-auto-wirer.ts @@ -77,7 +77,7 @@ export class ChangeAutoWirer { const parentForm = el.closest("form"); if (parentForm) { if (parentForm.hasAttribute("lvt-change")) continue; - if (parentForm.hasAttribute("lvt-no-intercept")) continue; + if (parentForm.hasAttribute("lvt-form:no-intercept")) continue; } if (el instanceof HTMLInputElement) { @@ -107,7 +107,7 @@ export class ChangeAutoWirer { const parentForm = el.closest("form"); if (parentForm) { if (parentForm.hasAttribute("lvt-change")) continue; - if (parentForm.hasAttribute("lvt-no-intercept")) continue; + if (parentForm.hasAttribute("lvt-form:no-intercept")) continue; } const name = el.getAttribute("name"); diff --git a/tests/change-auto-wirer.test.ts b/tests/change-auto-wirer.test.ts index 4aeaede..0a5ebc8 100644 --- a/tests/change-auto-wirer.test.ts +++ b/tests/change-auto-wirer.test.ts @@ -348,9 +348,9 @@ describe("ChangeAutoWirer", () => { expect(sendSpy).not.toHaveBeenCalled(); }); - it("skips elements inside form with lvt-no-intercept", () => { + it("skips elements inside form with lvt-form:no-intercept", () => { const form = document.createElement("form"); - form.setAttribute("lvt-no-intercept", ""); + form.setAttribute("lvt-form:no-intercept", ""); const input = document.createElement("input"); input.setAttribute("name", "Title"); form.appendChild(input); @@ -625,9 +625,9 @@ describe("ChangeAutoWirer", () => { expect(sendSpy).not.toHaveBeenCalled(); }); - it("skips select inside form with lvt-no-intercept", () => { + it("skips select inside form with lvt-form:no-intercept", () => { const form = document.createElement("form"); - form.setAttribute("lvt-no-intercept", ""); + form.setAttribute("lvt-form:no-intercept", ""); const select = document.createElement("select"); select.setAttribute("name", "sort_by"); form.appendChild(select); diff --git a/tests/event-delegation.test.ts b/tests/event-delegation.test.ts index ba3515d..83764ce 100644 --- a/tests/event-delegation.test.ts +++ b/tests/event-delegation.test.ts @@ -197,6 +197,87 @@ describe("EventDelegator", () => { ); }); + it("lvt-form:action attribute takes priority over button name", () => { + const wrapper = document.createElement("div"); + wrapper.setAttribute("data-lvt-id", "wrapper-form-action"); + wrapper.innerHTML = ` +
+ + +
+ `; + document.body.appendChild(wrapper); + + const form = document.getElementById("action-form") as HTMLFormElement; + const submitButton = document.getElementById("submit") as HTMLButtonElement; + + const context = createContext(wrapper); + const delegator = new EventDelegator( + context, + createLogger({ scope: "EventDelegatorTest", level: "silent" }) + ); + delegator.setupEventDelegation(); + + const submitEvent = new Event("submit", { + bubbles: true, + cancelable: true, + }) as SubmitEvent & { submitter?: HTMLButtonElement }; + submitEvent.submitter = submitButton; + + form.dispatchEvent(submitEvent); + + expect(context.send).toHaveBeenCalledTimes(1); + // lvt-form:action="checkout" takes priority over button name="save" + expect(context.send).toHaveBeenCalledWith( + expect.objectContaining({ + action: "checkout", + data: expect.objectContaining({ item: "widget" }), + }) + ); + }); + + it("action field in form data is preserved as normal data", () => { + const wrapper = document.createElement("div"); + wrapper.setAttribute("data-lvt-id", "wrapper-action-data"); + wrapper.innerHTML = ` +
+ + + +
+ `; + document.body.appendChild(wrapper); + + const form = document.getElementById("workflow-form") as HTMLFormElement; + const submitButton = document.getElementById("submit") as HTMLButtonElement; + + const context = createContext(wrapper); + const delegator = new EventDelegator( + context, + createLogger({ scope: "EventDelegatorTest", level: "silent" }) + ); + delegator.setupEventDelegation(); + + const submitEvent = new Event("submit", { + bubbles: true, + cancelable: true, + }) as SubmitEvent & { submitter?: HTMLButtonElement }; + submitEvent.submitter = submitButton; + + form.dispatchEvent(submitEvent); + + expect(context.send).toHaveBeenCalledTimes(1); + expect(context.send).toHaveBeenCalledWith( + expect.objectContaining({ + action: "processStep", + data: expect.objectContaining({ + action: "approve", // "action" is NOT reserved — it flows through as data + reason: "looks good", + }), + }) + ); + }); + describe("focus trap", () => { it("sets up keydown listener for focus trap", () => { const wrapper = document.createElement("div"); From f5af4bea0c57780e1570934a474ab8ad9a3296ba Mon Sep 17 00:00:00 2001 From: Adnaan Date: Sat, 4 Apr 2026 12:31:10 +0530 Subject: [PATCH 2/2] fix: address bot review comments - Fix action resolution comment: fallback is 'submit' not '' - Add note: lvt-action is server-side only, client doesn't read it - Add comment: empty lvt-form:action='' falls through to submitter - Clarify submitter exclusion: always excluded to avoid noisy payloads Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dom/event-delegation.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/dom/event-delegation.ts b/dom/event-delegation.ts index 5c8c1d1..a7347d1 100644 --- a/dom/event-delegation.ts +++ b/dom/event-delegation.ts @@ -155,10 +155,15 @@ export class EventDelegator { // 1. lvt-form:action attribute (explicit routing) // 2. submitter.name (button name = action) // 3. form.name (form name = action) - // 4. "" (server defaults to Submit()) + // 4. "submit" (server defaults to Submit()) + // + // Note: lvt-action hidden field is a server-side progressive + // enhancement fallback (no-JS POST). The client does not read it; + // the server extracts it from form data directly. if (!action && eventType === "submit" && element instanceof HTMLFormElement) { if (!element.hasAttribute("lvt-form:no-intercept")) { - // Check for explicit routing attribute first + // Check for explicit routing attribute first. + // Empty string ("") falls through to submitter/form name resolution. const explicitAction = element.getAttribute("lvt-form:action"); const submitter = (e as SubmitEvent).submitter; if (explicitAction) { @@ -240,8 +245,12 @@ export class EventDelegator { ).map((el) => (el as HTMLInputElement).name) ); - // Determine the action routing key to exclude from form data. - // Only the submitter button's name is excluded (it's the routing key, not data). + // Exclude the submitter button's name from form data. + // The submitter's name is used as the action routing key in the + // button-name path — including it in data would be redundant. + // When lvt-form:action overrides routing, the button name is still + // excluded to avoid noisy payloads (the button is a UI control, + // not domain data). Button value and data-* attrs are collected below. // "action" is NOT excluded — it's a normal data field. const submitterForData = (targetElement as any).__lvtSubmitter as HTMLButtonElement | undefined; const actionFieldName = submitterForData?.name;