Skip to content

Commit 50c7ada

Browse files
authored
fix: disable form submission for isPending (#7498)
* fix: disable form submission for isPending * fix lint * fix earlier react versions for tests * fix early react version tests * remove extra element * simplify further * make diff smaller * fix code comment * fix lint
1 parent 0ab792f commit 50c7ada

File tree

2 files changed

+151
-1
lines changed

2 files changed

+151
-1
lines changed

packages/react-aria-components/src/Button.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,11 +157,14 @@ export const Button = /*#__PURE__*/ createHideableComponent(function Button(prop
157157
wasPending.current = isPending;
158158
}, [isPending, isFocused, ariaLabelledby, buttonId]);
159159

160+
// When the button is in a pending state, we want to stop implicit form submission (ie. when the user presses enter on a text input).
161+
// We do this by changing the button's type to button.
160162
return (
161163
<button
162164
{...filterDOMProps(props, {propNames: additionalButtonHTMLAttributes})}
163165
{...mergeProps(buttonProps, focusProps, hoverProps)}
164166
{...renderProps}
167+
type={buttonProps.type === 'submit' && isPending ? 'button' : buttonProps.type}
165168
id={buttonId}
166169
ref={ref}
167170
aria-labelledby={ariaLabelledby}

packages/react-aria-components/test/Button.test.js

Lines changed: 148 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13+
import {act, fireEvent, pointerMap, render} from '@react-spectrum/test-utils-internal';
1314
import {Button, ButtonContext, ProgressBar, Text} from '../';
14-
import {fireEvent, pointerMap, render} from '@react-spectrum/test-utils-internal';
1515
import React, {useState} from 'react';
1616
import userEvent from '@testing-library/user-event';
1717

@@ -21,6 +21,10 @@ describe('Button', () => {
2121
user = userEvent.setup({delay: null, pointerMap});
2222
jest.useFakeTimers();
2323
});
24+
afterEach(() => {
25+
// clear any live announcers from pending buttons
26+
act(() => jest.runAllTimers());
27+
});
2428

2529
it('should render a button with default class', () => {
2630
let {getByRole} = render(<Button>Test</Button>);
@@ -197,4 +201,147 @@ describe('Button', () => {
197201
let button = getByRole('button');
198202
expect(button).not.toHaveAttribute('href');
199203
});
204+
205+
it('should prevent explicit mouse form submission when isPending', async function () {
206+
let onSubmitSpy = jest.fn(e => e.preventDefault());
207+
function TestComponent() {
208+
let [pending, setPending] = useState(false);
209+
return (
210+
<Button
211+
type="submit"
212+
onPress={() => {
213+
// immediately setting pending to true will remove the click handler before the form is submitted
214+
setTimeout(() => {
215+
setPending(true);
216+
}, 0);
217+
}}
218+
isPending={pending}>
219+
{({isPending}) => (
220+
<>
221+
<Text style={{opacity: isPending ? '0' : undefined}}>Test</Text>
222+
<ProgressBar
223+
aria-label="loading"
224+
style={{opacity: isPending ? undefined : '0'}}
225+
isIndeterminate>
226+
loading
227+
</ProgressBar>
228+
</>
229+
)}
230+
</Button>
231+
);
232+
}
233+
let {getByRole} = render(
234+
<form onSubmit={onSubmitSpy}>
235+
<TestComponent />
236+
</form>
237+
);
238+
let button = getByRole('button');
239+
expect(button).not.toHaveAttribute('aria-disabled');
240+
241+
await user.click(button);
242+
expect(onSubmitSpy).toHaveBeenCalled();
243+
onSubmitSpy.mockClear();
244+
245+
// run timer to set pending
246+
act(() => jest.runAllTimers());
247+
248+
await user.click(button);
249+
expect(onSubmitSpy).not.toHaveBeenCalled();
250+
});
251+
252+
it('should prevent explicit keyboard form submission when isPending', async function () {
253+
let onSubmitSpy = jest.fn(e => e.preventDefault());
254+
function TestComponent() {
255+
let [pending, setPending] = useState(false);
256+
return (
257+
<Button
258+
type="submit"
259+
onPress={() => {
260+
// immediately setting pending to true will remove the click handler before the form is submitted
261+
setTimeout(() => {
262+
setPending(true);
263+
}, 0);
264+
}}
265+
isPending={pending}>
266+
{({isPending}) => (
267+
<>
268+
<Text style={{opacity: isPending ? '0' : undefined}}>Test</Text>
269+
<ProgressBar
270+
aria-label="loading"
271+
style={{opacity: isPending ? undefined : '0'}}
272+
isIndeterminate>
273+
loading
274+
</ProgressBar>
275+
</>
276+
)}
277+
</Button>
278+
);
279+
}
280+
render(
281+
<form onSubmit={onSubmitSpy}>
282+
<TestComponent />
283+
</form>
284+
);
285+
await user.tab();
286+
await user.keyboard('{Enter}');
287+
expect(onSubmitSpy).toHaveBeenCalled();
288+
onSubmitSpy.mockClear();
289+
act(() => jest.runAllTimers());
290+
291+
await user.keyboard('{Enter}');
292+
expect(onSubmitSpy).not.toHaveBeenCalled();
293+
});
294+
295+
// Note: two inputs are needed, otherwise https://www.w3.org/TR/2011/WD-html5-20110525/association-of-controls-and-forms.html#implicit-submission
296+
// Implicit form submission can happen if there's only one.
297+
it('should prevent implicit form submission when isPending', async function () {
298+
let onSubmitSpy = jest.fn(e => e.preventDefault());
299+
function TestComponent(props) {
300+
let [pending, setPending] = useState(false);
301+
return (
302+
<form
303+
onSubmit={(e) => {
304+
// forms are submitted implicitly on keydown, so we need to wait to set pending until after to set pending
305+
props.onSubmit(e);
306+
}}
307+
onKeyDown={(e) => {
308+
if (e.key === 'Enter') {
309+
// keyup could theoretically happen elsewhere if focus is moved during submission
310+
document.body.addEventListener('keyup', () => {
311+
setPending(true);
312+
}, {capture: true, once: true});
313+
}
314+
}}>
315+
<label htmlFor="foo">Test</label>
316+
<input id="foo" type="text" />
317+
<input id="bar" type="text" />
318+
<Button
319+
type="submit"
320+
isPending={pending}>
321+
{({isPending}) => (
322+
<>
323+
<Text style={{opacity: isPending ? '0' : undefined}}>Test</Text>
324+
<ProgressBar
325+
aria-label="loading"
326+
style={{opacity: isPending ? undefined : '0'}}
327+
isIndeterminate>
328+
loading
329+
</ProgressBar>
330+
</>
331+
)}
332+
</Button>
333+
</form>
334+
);
335+
}
336+
render(
337+
<TestComponent onSubmit={onSubmitSpy} />
338+
);
339+
await user.tab();
340+
await user.keyboard('{Enter}');
341+
expect(onSubmitSpy).toHaveBeenCalled();
342+
onSubmitSpy.mockClear();
343+
344+
await user.keyboard('{Enter}');
345+
expect(onSubmitSpy).not.toHaveBeenCalled();
346+
});
200347
});

0 commit comments

Comments
 (0)