ReviewOS

stacks/stx

Input family: `error` is a server snapshot, so a field never becomes invalid after the submit that found the problem

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

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

callemitted 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).

9 days ago

Fixed in 2713139362, on all five controls, implemented as suggested.

One correction to the diagnosis

It was worse than frozen. You spotted the mechanism — "with error false the whole binding resolves to the client-categorised __stx_aria-invalid="ZmFsc2U="" — and the consequence is that the server-side binding pass then removes it as a falsy attribute. So a field with a reactive error shipped with no aria-invalid at all, not a stale one:

callbefore
<TextInput name="email" />no aria-invalid
<TextInput name="email" error helperText="…" />aria-invalid="true" (static)
<TextInput name="email" :error="hasError()" />no aria-invalid

Both halves are there now, exactly the pairing you proposed: a static aria-invalid so a server-rendered error is announced before hydration and with JavaScript off, and a reactive :aria-invalid so it flips when the signal does — the same shape Button uses for aria-busy.

The colour needed a different mechanism than the obvious one

Binding the whole class string does not work, and this is worth recording because it is invisible until you look at the live DOM. A reactive class string merges with the static one. Measured:

className after hydration: "base ring-ok text-ok ring-bad text-bad"

Both rings present, and which one wins is CSS source order rather than anything the component controls. So the state is toggled as an object binding instead, which removes one set as it adds the other:

:class="{ '': isError(), '': !isError() }"

The test asserts the valid ring is gone after the flip, not merely that the invalid one arrived — the merge bug would have passed a weaker assertion.

Checkbox and Radio take only the invalid half, since their valid state has no classes to remove.

Verified

Through the real runtime, in the shape the report describes: the field renders valid, a click sets the signal with no re-render, and the field is then announced invalid with the ring swapped. Plus a static-emission case per control. Full suite 14716 pass.

Your framing was the useful part — pointing at disabled and loading as the precedent made the right fix obvious, and noting that server-rendered forms were already fine kept the scope honest.

Sign in to comment on this issue.