Closes #1997.
disabled and loading were evaluated once on the server and frozen:
const disabledNow = {{ isDisabled }}
<button @if(isDisabled)disabled@endif>so the most common piece of button state in an application could not be expressed:
<Button :disabled="saving()" @click="save">Save</Button>saving() is a signal. On the server it is not yet meaningful, isDisabled resolved false, and the button shipped enabled and stayed enabled for the life of the page — through the exact async action it was there to guard.
What changed
useReactiveProp for both props. This is the package's existing answer rather than a new one — Dialog uses it for open (#1704), Switch uses useModel for checked:
const isLoading = useReactiveProp('loading', {{ loading }})
const disabledProp = useReactiveProp('disabled', {{ disabled }})
const isDisabled = derived(() => disabledProp() || isLoading())isDisabled stays disabled || loading, the same combination the server computes, so the two agree either side of hydration. onClick reads through the signal, so the guard uses the live value rather than what was true at build time.
Server-rendered attributes are kept alongside the bindings, not replaced by them. and the static aria-busy stay, so a button rendered disabled is disabled before hydration rather than briefly clickable. The bindings take over afterwards.
The spinner moves out of its @if and lives in the DOM permanently, shown by :show — it was unreachable otherwise, since the only way to see it was to have been loading when the page was built. It carries x-cloak only when the server says we are not loading, so a button rendered loading still paints its spinner on first paint instead of waiting for hydration. Left and right icons hide the same way.
Tests
packages/components/test/button-reactive-state.test.ts drives the real runtime through the SPA harness, with a parent component owning the signal — the shape the bug is about, where the value is not known until after render:
- ships enabled while the signal is false
- disables when the signal flips after render (the bug: this stayed false forever)
- re-enables when it flips back, so the state is not one-way
aria-busyfollowsloading- the spinner is present but hidden on first paint, and revealed once
loadingturns on
Full package suite: 1218 pass, 0 fail. The button-template visual snapshot is regenerated, per the procedure its helper documents.
Two notes
The harness renders @click but does not wire it as a DOM listener, so the test flips the parent signal by calling the handler on the scope — the same reason dialog-modal-behaviour.test.ts invokes its handler directly. Worth knowing before anyone writes a similar test.
The issue also guessed this freeze affects other server-evaluated state props a caller would naturally bind to a signal — Progress's value, Skeleton's animate, Notification's show, Tooltip's show. This PR changes only Button; I have not verified the others.