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
packages/components/test/button-reactive-state.test.tsadded+144-0
Changes to packages/components/test/button-reactive-state.test.ts
@@ -0,0 +1,144 @@
1/**
2 * Button's `disabled` and `loading` follow the parent's signal after render.
3 *
4 * Both props used to be evaluated once on the server and frozen:
5 *
6 * const disabledNow =
7 * <button >
8 *
9 * so the single most common piece of button state in an application could not
10 * be expressed at all:
11 *
12 * <Button :disabled="saving()" @click="save">Save</Button>
13 *
14 * `saving()` is a signal. On the server it is not yet meaningful, `isDisabled`
15 * resolved false, and the button shipped enabled and stayed enabled for the
16 * life of the page — through the exact async action it was there to guard.
17 * Double-submit protection, destructive deletes and rotate-key confirmations
18 * all land on this. The same freeze hid the built-in spinner: `loading` could
19 * only ever be true if it had been true when the page was built, which is
20 * never the case for an action that starts on a click.
21 *
22 * The reporting app measured 22 of its 50 otherwise-migratable buttons as
23 * depending on a reactive `:disabled`, and kept a plain `<button>` for them.
24 *
25 * stacksjs/stx#1997.
26 */
27import { readFileSync } from 'node:fs'
28import path from 'node:path'
29import { afterEach, describe, expect, it } from 'bun:test'
30import { boot, closeBrowser, find, layout, page, renderApp, settle } from '../../stx/test/router/spa-harness'
31
32const UI = path.join(import.meta.dir, '..', 'src', 'ui')
33
34/** The shipped component, not a copy: the point is to test what apps install. */
35function component(rel: string): string {
36 return readFileSync(path.join(UI, rel), 'utf-8')
37}
38
39/**
40 * A parent that owns the signal, which is the shape the bug is about: the value
41 * is not known until something happens on the client, long after render.
42 */
43const HOST = `<script client>
44 const saving = state(false)
45 function flip() { saving.set(!saving()) }
46</script>
47
48<div id="host">
49 <Button className="probe" :disabled="saving()" :loading="saving()">Save</Button>
50</div>`
51
52const FILES = {
53 'layouts/app.stx': layout(''),
54 'components/Button.stx': component('button/Button.stx'),
55 'components/Host.stx': HOST,
56 'pages/index.stx': page('app', '<Host />'),
57}
58
59const ROUTES = { '/': 'pages/index.stx' }
60
61interface Harness {
62 /** The component's own <button>, found by the className the parent passed. */
63 button: () => any
64 /** The spinner, which stays in the DOM and is shown by `:show`. */
65 spinner: () => any
66 /** Flip the parent's signal. */
67 flip: () => void
68 dispose: () => Promise<void>
69}
70
71async function mount(): Promise<Harness> {
72 const app = await renderApp(FILES, ROUTES)
73 const browser = await boot(app, '/')
74 await settle()
75 const hostEl = find(browser, '#host').closest('[data-stx-scope]')
76 const hostScope = browser.window.stx._scopes[hostEl.getAttribute('data-stx-scope')]
77 return {
78 button: () => find(browser, 'button.probe'),
79 spinner: () => find(browser, 'button.probe svg'),
80 // Called on the scope rather than dispatched: this harness renders the
81 // @click attribute but does not wire it as a DOM listener, the same reason
82 // dialog-modal-behaviour.test.ts invokes its handler directly.
83 flip: () => hostScope.flip(),
84 dispose: () => app.dispose(),
85 }
86}
87
88/** `:show` toggles display rather than detaching, so ask the style. */
89function visible(el: any): boolean {
90 return !!el && el.style.display !== 'none'
91}
92
93afterEach(() => {
94 closeBrowser()
95})
96
97describe('#1997 — Button reads disabled and loading as live props', () => {
98 it('ships enabled while the parent signal is false', async () => {
99 const h = await mount()
100 expect(h.button().hasAttribute('disabled')).toBe(false)
101 expect(h.button().getAttribute('aria-busy')).toBe('false')
102 await h.dispose()
103 })
104
105 it('disables when the parent signal flips after render', async () => {
106 const h = await mount()
107 h.flip()
108 await settle()
109 // The whole bug: this stayed false forever, because the attribute came from
110 // a value computed before the page was sent.
111 expect(h.button().hasAttribute('disabled')).toBe(true)
112 await h.dispose()
113 })
114
115 it('re-enables when the signal flips back, so the state is not one-way', async () => {
116 const h = await mount()
117 h.flip()
118 await settle()
119 h.flip()
120 await settle()
121 expect(h.button().hasAttribute('disabled')).toBe(false)
122 await h.dispose()
123 })
124
125 it('marks itself busy for assistive tech while loading', async () => {
126 const h = await mount()
127 h.flip()
128 await settle()
129 expect(h.button().getAttribute('aria-busy')).toBe('true')
130 await h.dispose()
131 })
132
133 it('reveals the spinner when loading turns on after render', async () => {
134 const h = await mount()
135 // Present from the start but hidden. It used to live inside an @if, so the
136 // only way to see it was to have been loading when the page was built.
137 expect(h.spinner()).toBeTruthy()
138 expect(visible(h.spinner())).toBe(false)
139 h.flip()
140 await settle()
141 expect(visible(h.spinner())).toBe(true)
142 await h.dispose()
143 })
144})