ReviewOS

also looking at this

stacks/stx

fix(components): Button reads disabled and loading as live props

#2007
Merged glennmichael123 wants to merge fix/button-reactive-disabled-loading into main
3 files +255 -25
9 days ago

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-busy follows loading
  • the spinner is present but hidden on first paint, and revealed once loading turns 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.

3 changed files on the files tab, with 0 review threads.

Sign in to comment on this pull request.