Summary
FormDisabler.disable() sets input.disabled = true on every control in a form during a server action's in-flight window. That disabled state is stripped by any morph that lands mid-submit, silently re-enabling the controls — so a user (or a rapid double-click) can submit the same form twice while the first request is still outstanding.
This is the same bug family as #147 / #148: client-created DOM state the server doesn't emit, wiped by morphdom. It was surfaced while fixing #147 but is a distinct, unprotected path — the lvt-el overlay fix does not cover it.
Mechanism
dom/form-disabler.ts:12 disables controls as a property, not an attribute:
inputs.forEach((input) => {
(input as HTMLInputElement).disabled = true;
});
morphdom's built-in INPUT special handler (node_modules/morphdom/dist/morphdom.js) then syncs disabled from the server's toEl:
INPUT: function(fromEl, toEl) {
syncBooleanAttrProp(fromEl, toEl, 'checked');
syncBooleanAttrProp(fromEl, toEl, 'disabled'); // <-- server's value wins
...
}
The server template does not emit disabled (the control isn't disabled in server state — it's a transient client-side submit lock), so toEl.disabled is false and morphdom re-enables the live control.
Why existing guards don't cover it
- The
onBeforeElUpdated checkbox/radio block (livetemplate-client.ts ~2016-2066) preserves checked and indeterminate from fromEl, but not disabled.
focusManager.shouldSkipUpdate(fromEl) returns false for every control except the one currently focused. During a submit, at most one control (often the submit button) is focused; every other input/select/textarea in the form is fair game for the morph, so they re-enable.
<fieldset disabled> / aria-busy on the <form> are plain attributes and get diffed away for the same reason.
Impact
Any app that renders while a form submit is in flight — a background poll, a broadcast, an --agent-mode status render, or simply a fast server that re-renders the same subtree — can re-enable a form the framework had locked. The user's second click, or a double-submit, then goes through. The whole point of FormDisabler is to prevent exactly that.
Suggested fix
Mirror the #148 approach — register disabled (and <fieldset disabled> / form aria-busy) as client-owned state that survives the morph, so the framework's own submit lock isn't destroyed by an unrelated render. The dom/client-owned-state.ts mechanism from #148 is the natural home; the wrinkle here is that disabled on <input> goes through morphdom's INPUT special handler (property sync) rather than the generic attribute diff, so preserving it means copying the property onto toEl (as the checkbox checked block already does) rather than only setting an attribute.
A test should drive FormDisabler.disable() → a real updateDOM morph → assert the controls stay disabled, modelled on tests/client-owned-state.test.ts.
Refs
Summary
FormDisabler.disable()setsinput.disabled = trueon every control in a form during a server action's in-flight window. That disabled state is stripped by any morph that lands mid-submit, silently re-enabling the controls — so a user (or a rapid double-click) can submit the same form twice while the first request is still outstanding.This is the same bug family as #147 / #148: client-created DOM state the server doesn't emit, wiped by morphdom. It was surfaced while fixing #147 but is a distinct, unprotected path — the
lvt-eloverlay fix does not cover it.Mechanism
dom/form-disabler.ts:12disables controls as a property, not an attribute:morphdom's built-in
INPUTspecial handler (node_modules/morphdom/dist/morphdom.js) then syncsdisabledfrom the server'stoEl:The server template does not emit
disabled(the control isn't disabled in server state — it's a transient client-side submit lock), sotoEl.disabledisfalseand morphdom re-enables the live control.Why existing guards don't cover it
onBeforeElUpdatedcheckbox/radio block (livetemplate-client.ts~2016-2066) preservescheckedandindeterminatefromfromEl, but notdisabled.focusManager.shouldSkipUpdate(fromEl)returnsfalsefor every control except the one currently focused. During a submit, at most one control (often the submit button) is focused; every other input/select/textarea in the form is fair game for the morph, so they re-enable.<fieldset disabled>/aria-busyon the<form>are plain attributes and get diffed away for the same reason.Impact
Any app that renders while a form submit is in flight — a background poll, a broadcast, an
--agent-mode status render, or simply a fast server that re-renders the same subtree — can re-enable a form the framework had locked. The user's second click, or a double-submit, then goes through. The whole point ofFormDisableris to prevent exactly that.Suggested fix
Mirror the #148 approach — register
disabled(and<fieldset disabled>/ formaria-busy) as client-owned state that survives the morph, so the framework's own submit lock isn't destroyed by an unrelated render. Thedom/client-owned-state.tsmechanism from #148 is the natural home; the wrinkle here is thatdisabledon<input>goes through morphdom'sINPUTspecial handler (property sync) rather than the generic attribute diff, so preserving it means copying the property ontotoEl(as the checkboxcheckedblock already does) rather than only setting an attribute.A test should drive
FormDisabler.disable()→ a realupdateDOMmorph → assert the controls stay disabled, modelled ontests/client-owned-state.test.ts.Refs
dom/form-disabler.ts:12,24livetemplate-client.tscheckbox block (~2016-2066) — the precedent for preserving a property fromfromEl.