Skip to content

form submit disabling is stripped by a mid-submit morph — inputs re-enable under the user (double-submit risk) #149

Description

@adnaan

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions