Input family: error is a server snapshot, so a field never becomes invalid after the submit that found the problem.
disabled and loading were made live props precisely because they change after render. error is the same kind of state — arguably the clearest case, since validation state is almost never known at render time — but it is still read once:
export const error = $bool($props.error)and the client block binds only the value:
const inputValue = useModel('value', { default: {{ value }}, … })There is no useReactiveProp('error', …), so :error="hasError()" is frozen at whatever the signal held when the page was built.
Rendered behaviour on 0.2.395
| call | emitted on the input |
|---|---|
<TextInput label="Email" name="email" /> | no aria-invalid at all |
<TextInput label="Email" name="email" error helperText="Bad address" /> | aria-invalid="true" (static), aria-describedby="input-…-helper" |
The template has :aria-invalid="false", so with error false the whole binding resolves to the client-categorised :aria-invalid="false" and nothing static is served.
Why that blocks adoption here
Six apps post their sign-in, sign-up and reset forms with fetch and drive the banner from a signal — no re-render. Converting those to <TextInput> would mean the field is marked invalid never, because at first render there is no error and the prop cannot change afterwards. Hand-written markup does this correctly today:
<input … :aria-invalid="error() ? 'true' : 'false'" :aria-describedby="error() ? 'login-error' : ''">which flips on a failed submit and points at the live region. So for a client-driven form the component is currently a step backwards from a plain <input>, which is an awkward place for it to be given it is otherwise ready — name, autocomplete and value all landed in 0.2.384 and after.
Server-rendered forms are fine, because the page re-renders with error already true.
Suggested fix
Read it as a live prop, matching disabled:
const isError = useReactiveProp('error', {{ error }})and bind aria-invalid, the label colour and the ring from it, with a static aria-invalid="false" alongside so the server-rendered case still carries the state before hydration — the same pairing Button uses for aria-busy.
Worth doing for Textarea, Select, Checkbox and Radio at the same time: they all expose error the same way.
Found on @stacksjs/components 0.2.395. Related: #2043 (name/autocomplete, fixed), #2046 (reactive slot).