Skip to content

fix: Don't reset form fields if reset event is cancelled. #7603

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from

Conversation

nanto
Copy link

@nanto nanto commented Jan 12, 2025

Native form fields don't get reset if a reset event is cancelled. I think it is good that React Aria components behave as the same as native form fields.

Closes #7602

✅ Pull Request Checklist:

  • Included link to corresponding React Spectrum GitHub Issue.
  • Added/updated unit tests and storybook for this change (for new code or code which already has tests).
  • Filled out test instructions.
  • Updated documentation (if it already exists for this component).
  • Looked at the Accessibility Practices for this feature - Aria Practices

📝 Test Instructions:

🧢 Your Project:

@nanto nanto closed this Jan 12, 2025
@nanto nanto reopened this Jan 12, 2025
@snowystinger
Copy link
Member

Thanks for the PR!
This doesn't quite match the browser, you must catch it in the capture phase. This might be ok, however, we could also handle the bubble phase, as well as stopPropagation with no preventDefault. I'm going to push those changes for team consideration.
I will be ok if we decide to revert my proposed change, this might be overkill.

@nanto
Copy link
Author

nanto commented May 21, 2025

Thank you for your follow-up! I agree with supporting bubble phase case and stopPropagation case.

However, creating a new event causes infinite recursion when there are more than one useFormReset call. We can avoid that problem by using queueMicrotask but it changes the timing onReset is called (tests need to be async).

diff --git a/packages/@react-aria/utils/src/useFormReset.ts b/packages/@react-aria/utils/src/useFormReset.ts
index b4bf47c3a..d0777c125 100644
--- a/packages/@react-aria/utils/src/useFormReset.ts
+++ b/packages/@react-aria/utils/src/useFormReset.ts
@@ -19,56 +19,24 @@ export function useFormReset<T>(
   initialValue: T,
   onReset: (value: T) => void
 ): void {
-  let currEvent = useRef<Event | null>(null);
   let resetValue = useRef(initialValue);
 
-  let handleReset = useEffectEvent((e: Event) => {
-    currEvent.current = null;
-    if (
-      e.target === ref?.current?.form
-      && onReset
-      && !e.defaultPrevented
-    ) {
-      onReset(resetValue.current);
-    }
-  });
-
-  /**
-   * Because event.stopPropagation() does not preventDefault and because we attach directly to the form element unlike React
-   * we need to create a new event which lets us monitor stopPropagation and preventDefault. This allows us to call onReset
-   * as the browser would natively.
-   */
   let formListener = useEffectEvent((e: Event) => {
-    if (currEvent.current !== null || e.target !== ref?.current?.form) {
-       // This is the re-dispatched event. Or it's for a different form.
+    if (e.target !== ref?.current?.form) {
       return;
     }
-    let alreadyPrevented = e.defaultPrevented;
-    e.stopPropagation();
-    e.preventDefault();
-    let event = new Event('reset', {bubbles: true, cancelable: true});
-    currEvent.current = event;
-    let originalStopPropagation = event.stopPropagation;
-    let stopPropagation = () => {
-      currEvent.current = null;
-      originalStopPropagation.call(event);
-      if (!alreadyPrevented && !event.defaultPrevented && onReset) {
+    queueMicrotask(() => {
+      if (!e.defaultPrevented && onReset) {
         onReset(resetValue.current);
       }
-    };
-    event.stopPropagation = stopPropagation;
-
-    e.target?.dispatchEvent(event);
+    });
   });
 
   useEffect(() => {
-    let form = ref?.current?.form;
-    let document = form?.ownerDocument;
+    let document = ref?.current?.form?.ownerDocument;
     document?.addEventListener('reset', formListener, true);
-    document?.addEventListener('reset', handleReset);
     return () => {
       document?.removeEventListener('reset', formListener, true);
-      document?.removeEventListener('reset', handleReset);
     };
-  }, [ref, handleReset, formListener]);
+  }, [ref, formListener]);
 }
diff --git a/packages/@react-aria/utils/test/useFormReset.test.tsx b/packages/@react-aria/utils/test/useFormReset.test.tsx
index 49c1151c4..91dd3ef3d 100644
--- a/packages/@react-aria/utils/test/useFormReset.test.tsx
+++ b/packages/@react-aria/utils/test/useFormReset.test.tsx
@@ -10,12 +10,12 @@
  * governing permissions and limitations under the License.
  */
 
-import {fireEvent, render} from '@react-spectrum/test-utils-internal';
+import {act, fireEvent, render} from '@react-spectrum/test-utils-internal';
 import React, {useRef} from 'react';
 import {useFormReset} from '../';
 
 describe('useFormReset', () => {
-  it('should call onReset on reset', () => {
+  it('should call onReset on reset', async () => {
     const onReset = jest.fn();
     const Form = () => {
       const ref = useRef<HTMLInputElement>(null);
@@ -29,11 +29,11 @@ describe('useFormReset', () => {
     };
     const {getByRole} = render(<Form />);
     const button = getByRole('button');
-    fireEvent.click(button);
+    await act(async () => fireEvent.click(button));
     expect(onReset).toHaveBeenCalled();
   });
 
-  it('should call onReset on reset even if event is stopped', () => {
+  it('should call onReset on reset even if event is stopped', async () => {
     const onReset = jest.fn();
     const Form = () => {
       const ref = useRef<HTMLInputElement>(null);
@@ -47,7 +47,7 @@ describe('useFormReset', () => {
     };
     const {getByRole} = render(<Form />);
     const button = getByRole('button');
-    fireEvent.click(button);
+    await act(async () => fireEvent.click(button));
     expect(onReset).toHaveBeenCalled();
   });
 
@@ -65,7 +65,7 @@ describe('useFormReset', () => {
     };
     const {getByRole} = render(<Form />);
     const button = getByRole('button');
-    fireEvent.click(button);
+    await act(async () => fireEvent.click(button));
     expect(onReset).not.toHaveBeenCalled();
   });
 
@@ -83,7 +83,30 @@ describe('useFormReset', () => {
     };
     const {getByRole} = render(<Form />);
     const button = getByRole('button');
-    fireEvent.click(button);
+    await act(async () => fireEvent.click(button));
     expect(onReset).not.toHaveBeenCalled();
   });
+
+  it('should not call any onReset if reset is cancelled', async () => {
+    const onReset1 = jest.fn();
+    const onReset2 = jest.fn();
+    const Form = () => {
+      const ref1 = useRef<HTMLInputElement>(null);
+      useFormReset(ref1, '', onReset1);
+      const ref2 = useRef<HTMLInputElement>(null);
+      useFormReset(ref2, '', onReset2);
+      return (
+        <form onReset={(e) => e.preventDefault()}>
+          <input ref={ref1} type="text" />
+          <input ref={ref2} type="text" />
+          <button type="reset">Reset</button>
+        </form>
+      );
+    };
+    const {getByRole} = render(<Form />);
+    const button = getByRole('button');
+    await act(async () => fireEvent.click(button));
+    expect(onReset1).not.toHaveBeenCalled();
+    expect(onReset2).not.toHaveBeenCalled();
+  });
 });

@snowystinger
Copy link
Member

Right, I didn't want to use queueMicrotask because it changes the timing.
Ah, multiple form elements with useFormReset, should've included a test for that. Maybe we could add a field to the event so we know if something has already handled the re-dispatch for us instead.

@nanto
Copy link
Author

nanto commented May 22, 2025

OK, I try to handle reset event with custom properties.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Form fields are reset even if reset event is cancelled
2 participants