fix: MPS-24 Phase 2.8e — form.action DOM-clobbered by <input name=action>

With 2.8d live the operator's Network panel proved the proxy-proof
ajax=1 signal works (real fetch to /tags -> 200, 0.7kB JSON) but also
showed 4 requests to a URL literally named [object HTMLInputElement],
with a CORRECT payload (action=delete, tag_slug=..., ajax=1).

Cause: every tag form contains <input type=hidden name=action>. A
named form control clobbers the built-in HTMLFormElement.action
property (DOM clobbering), so fetch(form.action) fetched that <input>
element -> 'String([object HTMLInputElement])' -> resolved to the shop
page (200 HTML, 25.9kB) -> reportFailure, no DOM change ('closer but
nothing changes on screen').

Fix: read form.getAttribute('action') (content attribute, never
clobbered) in submitForm + doReorder; build the programmatic toggle
form with setAttribute('action', ...) instead of form.action =.
No bare form.action reads remain. product_tags.js unaffected (posts
to data-product-tags-url). node --check clean; JS-only defect fix,
no Python/template/test impact.

This closes the chain: 2.8c stale cache -> 2.8d proxy-stripped
X-Requested-With -> 2.8e clobbered form.action. Docs: mps-24.md
Phase 2.8e, CLAUDE.md DOM-clobbering note.
This commit is contained in:
russell@unturf.com 2026-05-16 10:32:50 -04:00
parent 8e31124ca8
commit 7836035d1a
No known key found for this signature in database
3 changed files with 41 additions and 4 deletions

View file

@ -521,7 +521,14 @@ NOT forwarding `X-Requested-With` to uWSGI, so every AJAX action took
the 302 path and the page full-reloaded — `ajax=1` rides in the
URL/body where no proxy strips it. All progressive-enhancement JS
(`tag_bulk.js`, `product_tags.js`, …) must send `ajax=1` in addition
to the header. Removable chip component:
to the header. **DOM-clobbering gotcha:** every tag form has
`<input name="action">`, and a named form control shadows the
built-in `HTMLFormElement.action` property — `form.action` returns
that `<input>` element, so `fetch(form.action)` requests
`"[object HTMLInputElement]"`. ALWAYS read
`form.getAttribute("action")` (and build programmatic forms with
`setAttribute("action", …)`), never the `.action` property, in any
JS that submits these forms. Removable chip component:
`.tag-chip-removable` family in `common.css` + `/styleguide#tagchips`.
Home rendering: `views/shop.py:_build_home_layout_context()` builds

View file

@ -363,6 +363,25 @@ Tests (`test_functional.py::TestProductTagsSpa`):
Deferred (occasional click, not the hot path): AJAX-ifying the
"Suggest categories" link — still a full navigation by design.
**Phase 2.8e — the LAST link: `form.action` DOM-clobbered** (shipped
2026-05-16): with 2.8d live, the operator's Network panel showed the
proxy-proof `ajax=1` working (a real `fetch` to `tags` → 200, 0.7 kB
JSON) — but also four requests to a URL literally named
`[object HTMLInputElement]`, and the Payload tab confirmed the body
was correct (`action=delete`, `tag_slug=…`, `ajax=1`). Cause: every
tag form contains `<input type="hidden" name="action">`. A named form
control **clobbers the built-in `HTMLFormElement.action` property**
(DOM clobbering / named-property override), so `fetch(form.action)`
fetched that `<input>` element — coerced to the string
`"[object HTMLInputElement]"` → resolved against the shop, returned
the 25.9 kB shop page (200, non-JSON) → `reportFailure` (no DOM
change: "closer but nothing changes"). Fix: read
`form.getAttribute("action")` (content attribute, never clobbered),
never `form.action`; build programmatic forms with
`setAttribute("action", …)`. tag_bulk.js: submitForm, doReorder,
swapToggleForm. (No browser test harness exists; guarded via
CLAUDE.md note + the `ajax=1` server tests from 2.8d.)
**Phase 2.8d — the OTHER root cause: proxy strips X-Requested-With**
(shipped 2026-05-16): even after 2.8c (fresh JS confirmed loading,
`tag_bulk.js?v=<hash>` 200 in the operator's Network panel),

View file

@ -108,7 +108,11 @@
const submit = form.querySelector("button[type=submit]");
if (submit) submit.disabled = true;
try {
const res = await fetch(form.action, {
// form.getAttribute("action") — NOT form.action: every tag form
// has <input name="action">, and a named control clobbers the
// built-in HTMLFormElement.action property, so form.action would
// return that <input> element → fetch("[object HTMLInputElement]").
const res = await fetch(form.getAttribute("action"), {
method: "POST",
body: fd,
headers: { "X-Requested-With": "XMLHttpRequest" },
@ -208,7 +212,11 @@
fd.append("ajax", "1"); // proxy-proof AJAX signal (see is_ajax)
btn.disabled = true; // guard against double-click during the round-trip
try {
const res = await fetch(form.action, {
// form.getAttribute("action") — NOT form.action: every tag form
// has <input name="action">, and a named control clobbers the
// built-in HTMLFormElement.action property, so form.action would
// return that <input> element → fetch("[object HTMLInputElement]").
const res = await fetch(form.getAttribute("action"), {
method: "POST",
body: fd,
headers: { "X-Requested-With": "XMLHttpRequest" },
@ -345,7 +353,10 @@
const buttonText = attached ? "✓ Applied" : "Apply";
const form = document.createElement("form");
form.method = "POST";
form.action = window.location.pathname;
// setAttribute, not form.action = … : innerHTML below adds
// <input name="action">, which clobbers the .action property.
// submitForm() reads getAttribute("action"), so keep it consistent.
form.setAttribute("action", window.location.pathname);
form.className = "tag-product-toggle";
form.setAttribute("data-tag-form", nextAction);
form.innerHTML =