ReviewOS

stacks/stx

Form controls render no `name` and no `autocomplete`: 8 of 10 cannot be submitted, read back, or autofilled

#2043
Closed glennmichael123 opened this 9 days ago ·
9 days ago

TextInput, EmailInput, PasswordInput, NumberInput, SearchInput, Textarea, Select and Switch render no name and no autocomplete, so they cannot be submitted, read back, or autofilled.

Eight of the ten form-control components emit an <input>/<select>/<textarea> with no name attribute, and none of the ten accepts autocomplete. Only Checkbox and Radio pass name through.

A control without a name is not a form control in the HTML sense: it is excluded from FormData, from form.elements.namedItem(), and from an ordinary non-JS form POST. Any form built from these components silently submits nothing for those fields.

Reproduction

import { processDirectives } from '@stacksjs/stx'

const markup = `<TextInput label="Email" name="email" autocomplete="email" placeholder="you@example.com" helperText="We never share it." />`
const html = await processDirectives(markup, {}, 'probe.stx', {
  autoShell: false,
  componentsDir: 'node_modules/@stacksjs/components/src/ui',
}, new Set())
console.log(html.match(/<input[^>]*>/)[0])

Rendered on @stacksjs/components 0.2.382:

<input
  type="text"
  id="input-stx_text_input_1_s6690i"
  class="block w-full border-0 rounded-control …"
  :value="inputValue()"
  placeholder="you@example.com"




  @input="onInput($event)"
  @change="onChange($event)"

  aria-describedby="input-stx_text_input_1_s6690i-helper"
/ />

name="email" and autocomplete="email" were both passed and neither appears. There is no $attrs-style fallthrough, so a caller has no way to add them.

Why autocomplete matters separately

autocomplete is how a password manager and the browser's own autofill identify a field. PasswordInput with no autocomplete="current-password" is not offered for autofill and is not recognised as a sign-in field. For PasswordInput specifically this is the difference between a usable and an unusable login form.

Two smaller things visible in the same output

  • The tag closes as / />. A stray slash is emitted alongside the real self-closing one.
  • -style conditional attributes leave blank lines in the output when false. Cosmetic, but it is four empty lines per control.

Suggested fix

Accept and emit name, autocomplete, and ideally form, inputmode and enterkeyhint, on every control that renders a native form element — or add a general attribute fallthrough so callers can supply anything the native element takes. Checkbox/Radio already do the former and can serve as the pattern.

Impact here

This blocks adopting the form-control family across six apps with 175 <input> sites. The components are otherwise in good shape for it — fully role-token based (0 hard-coded palette values, 0 dark:), and they already wire aria-invalid and aria-describedby, which is the gap we have in all 175. The missing name is the only thing standing in the way.

Found on @stacksjs/components 0.2.382 / @stacksjs/stx 0.2.382.

9 days ago

Fixed in 901119f — all three findings, including the / />, which turned out to be an engine bug rather than a component one.

name / autocomplete

Every control now takes name, autocomplete and form; the text-like ones also inputmode and enterkeyhint. EmailInput and SearchInput forward them to the TextInput they render — a wrapper that drops them is as unsubmittable as a control that never accepted them, and that was easy to miss since their own source looked complete. All emitted only when given, so a control passed none renders exactly the markup it did before.

Switch needed more than an attribute. The control the user operates is a <button role="switch">, and a button is never submitted by name — no attribute would have fixed it. It now renders a hidden input carrying the state, bound to the same signal the button toggles, and only when a name is given. It submits one of two values rather than a checkbox's present-or-absent convention, so a reader gets an explicit "false" instead of a missing key — the difference between "switched off" and "the field was never in the form". checkedValue/uncheckedValue override them.

The / />

Worth flagging separately, because it was not in the components — it was in server-bindings.ts, and it affected every page, not just forms:

const tagRegex = /<([a-zA-Z][a-zA-Z0-9-]*)\b((?:[^>"']|"[^"]*"|'[^']*')*)\s*\/?>/g
const selfClose = fullTag.endsWith('/>') ? ' /' : ''

The attribute class matches / like anything else and the group was greedy, so for <input … /> the trailing slash was already inside the captured attributes and \s*\/?> matched only the >. Rebuilding the tag then appended a second one. It fires for any element with a server-resolved :attr, which is all ten of these through :aria-invalid — and the blank lines you noticed are the same pass removing @if-guarded attributes. The attribute group is lazy now with the slash as its own capture, the same shape as the matcher fixed in #2039.

Verification

Through FormData, not by matching markup, since FormData is the thing that was empty — plus a case per control asserting the tag closes once.

One gap this does not close

Found while testing, and worth knowing before the 175-site migration: value is bound client-side with no static attribute, so a server-rendered <TextInput value="x"> still posts an empty string without JavaScript. The field now arrives under its name; the value does not survive a non-JS POST. Textarea is unaffected, since its value is its element content. Same for checked on Checkbox/Radio — bound client-side, so a server-rendered checked box is unchecked until hydration.

That is a different defect from the missing name and I did not fold it in. Say the word if it blocks the migration and I will take it next.

The measurement in the report was the useful part here — "I found this by rendering, not by reading" is exactly why this was catchable before 175 sites rather than after.

9 days ago

Follow-up: the gap I flagged when closing this is now fixed too, in 1da5a4d.

Giving the controls a name got the fields into FormData. It did not put anything in them.

value and checked were bound only reactively — :value, :checked — with no static attribute. An HTML value attribute is the default value: it is what the field shows on first paint and what an ordinary form POST submits, while the runtime drives the live property afterwards. The two are different things and do not fight, so binding one was never a reason to omit the other.

Measured before the fix, every text-like control posted an empty string for a value it had been given:

controlposted
<TextInput name="email" value="a@b.c" />""
<PasswordInput name="password" value="hunter2" />""
<NumberInput name="qty" value="3" />""
<SearchInput name="q" value="widgets" />""
<EmailInput name="email" value="a@b.c" />""

Same shape as the missing name: a form that looks right and submits emptiness.

checked was worse than empty. An unchecked box is not submitted at all, so a server-rendered <Checkbox checked> was absent from the POST rather than present-and-wrong — indistinguishable from a user who left it alone.

And selected, which the select never emitted. selected is defaultSelected, so a <Select value="b"> posted whatever option happened to come first regardless of what value said. That one is a wrong answer rather than a missing one, which is the harder kind to notice.

Textarea was already correct — its value is its element content — and Switch's hidden input got a static value when it was added.

Nothing is invented for a control given nothing: an empty text field still posts "", and an unchecked box is still absent. Verified through FormData, including the negative cases.

With this, the form-control family should be ready for the 175-site migration — a form built from these components now submits correctly with JavaScript disabled, which is the property that makes it safe to convert the remaining ~160 inputs.

Sign in to comment on this issue.