Skip to content

Commit

Permalink
ReactDOM.requestFormReset
Browse files Browse the repository at this point in the history
This adds a React DOM method called requestFormReset that schedules a
form reset to occur when the current transition completes.

Internally, it's the same method that's called automatically whenever
a form action is submitted. It only affects uncontrolled form inputs.
See facebook#28804 for details.

The reason for the public API is so UI libraries can implement their
own action-based APIs and maintain the form-resetting behavior,
something like this:

```js
function onSubmit(event) {
  // Disable default form submission behavior
  event.preventDefault();
  const form = event.target;
  startTransition(async () => {
    // Request the form to reset once the action
    // has completed
    requestFormReset(form);

    // Call the user-provided action prop
    await action(new FormData(form));
  })
}
```
  • Loading branch information
acdlite committed Apr 10, 2024
1 parent f96f89f commit ede5c49
Show file tree
Hide file tree
Showing 2 changed files with 416 additions and 72 deletions.
342 changes: 342 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMForm-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ describe('ReactDOMForm', () => {
let textCache;
let useFormStatus;
let useActionState;
let requestFormReset;

beforeEach(() => {
jest.resetModules();
Expand All @@ -58,6 +59,7 @@ describe('ReactDOMForm', () => {
startTransition = React.startTransition;
use = React.use;
useFormStatus = ReactDOM.useFormStatus;
requestFormReset = ReactDOM.requestFormReset;
container = document.createElement('div');
document.body.appendChild(container);

Expand Down Expand Up @@ -1414,4 +1416,344 @@ describe('ReactDOMForm', () => {
expect(inputRef.current.value).toBe('acdlite');
expect(divRef.current.textContent).toEqual('Current username: acdlite');
});

test('requestFormReset schedules a form reset after transition completes', async () => {
// This is the same as the previous test, except the form is updated with
// a userspace action instead of a built-in form action.

const formRef = React.createRef();
const inputRef = React.createRef();
const divRef = React.createRef();

function App({promiseForUsername}) {
// Make this suspensey to simulate RSC streaming.
const username = use(promiseForUsername);

return (
<form ref={formRef}>
<input
ref={inputRef}
text="text"
name="username"
defaultValue={username}
/>
<div ref={divRef}>
<Text text={'Current username: ' + username} />
</div>
</form>
);
}

// Initial render
const root = ReactDOMClient.createRoot(container);
const promiseForInitialUsername = getText('(empty)');
await resolveText('(empty)');
await act(() =>
root.render(<App promiseForUsername={promiseForInitialUsername} />),
);
assertLog(['Current username: (empty)']);
expect(divRef.current.textContent).toEqual('Current username: (empty)');

// Dirty the uncontrolled input
inputRef.current.value = ' AcdLite ';

// This is a userspace action. It does not trigger a real form submission.
// The practical use case is implementing a custom action prop using
// onSubmit without losing the built-in form resetting behavior.
await act(() => {
startTransition(async () => {
const form = formRef.current;
const formData = new FormData(form);
requestFormReset(form);

const rawUsername = formData.get('username');
const normalizedUsername = rawUsername.trim().toLowerCase();

Scheduler.log(`Async action started`);
await getText('Wait');

// Update the app with new data. This is analagous to re-rendering
// from the root with a new RSC payload.
startTransition(() => {
root.render(<App promiseForUsername={getText(normalizedUsername)} />);
});
});
});
assertLog(['Async action started']);
expect(inputRef.current.value).toBe(' AcdLite ');

// Finish the async action. This will trigger a re-render from the root with
// new data from the "server", which suspends.
//
// The form should not reset yet because we need to update `defaultValue`
// first. So we wait for the render to complete.
await act(() => resolveText('Wait'));
assertLog([]);
// The DOM input is still dirty.
expect(inputRef.current.value).toBe(' AcdLite ');
// The React tree is suspended.
expect(divRef.current.textContent).toEqual('Current username: (empty)');

// Unsuspend and finish rendering. Now the form should be reset.
await act(() => resolveText('acdlite'));
assertLog(['Current username: acdlite']);
// The form was reset to the new value from the server.
expect(inputRef.current.value).toBe('acdlite');
expect(divRef.current.textContent).toEqual('Current username: acdlite');
});

test(
'requestFormReset works with inputs that are not descendants ' +
'of the form element',
async () => {
// This is the same as the previous test, except the input is not a child
// of the form; it's linked with <input form="myform" />

const formRef = React.createRef();
const inputRef = React.createRef();
const divRef = React.createRef();

function App({promiseForUsername}) {
// Make this suspensey to simulate RSC streaming.
const username = use(promiseForUsername);

return (
<>
<form id="myform" ref={formRef} />
<input
form="myform"
ref={inputRef}
text="text"
name="username"
defaultValue={username}
/>
<div ref={divRef}>
<Text text={'Current username: ' + username} />
</div>
</>
);
}

// Initial render
const root = ReactDOMClient.createRoot(container);
const promiseForInitialUsername = getText('(empty)');
await resolveText('(empty)');
await act(() =>
root.render(<App promiseForUsername={promiseForInitialUsername} />),
);
assertLog(['Current username: (empty)']);
expect(divRef.current.textContent).toEqual('Current username: (empty)');

// Dirty the uncontrolled input
inputRef.current.value = ' AcdLite ';

// This is a userspace action. It does not trigger a real form submission.
// The practical use case is implementing a custom action prop using
// onSubmit without losing the built-in form resetting behavior.
await act(() => {
startTransition(async () => {
const form = formRef.current;
const formData = new FormData(form);
requestFormReset(form);

const rawUsername = formData.get('username');
const normalizedUsername = rawUsername.trim().toLowerCase();

Scheduler.log(`Async action started`);
await getText('Wait');

// Update the app with new data. This is analagous to re-rendering
// from the root with a new RSC payload.
startTransition(() => {
root.render(
<App promiseForUsername={getText(normalizedUsername)} />,
);
});
});
});
assertLog(['Async action started']);
expect(inputRef.current.value).toBe(' AcdLite ');

// Finish the async action. This will trigger a re-render from the root with
// new data from the "server", which suspends.
//
// The form should not reset yet because we need to update `defaultValue`
// first. So we wait for the render to complete.
await act(() => resolveText('Wait'));
assertLog([]);
// The DOM input is still dirty.
expect(inputRef.current.value).toBe(' AcdLite ');
// The React tree is suspended.
expect(divRef.current.textContent).toEqual('Current username: (empty)');

// Unsuspend and finish rendering. Now the form should be reset.
await act(() => resolveText('acdlite'));
assertLog(['Current username: acdlite']);
// The form was reset to the new value from the server.
expect(inputRef.current.value).toBe('acdlite');
expect(divRef.current.textContent).toEqual('Current username: acdlite');
},
);

test('reset multiple forms in the same transition', async () => {
const formRefA = React.createRef();
const formRefB = React.createRef();

function App({promiseForA, promiseForB}) {
// Make these suspensey to simulate RSC streaming.
const a = use(promiseForA);
const b = use(promiseForB);
return (
<>
<form ref={formRefA}>
<input type="text" name="inputName" defaultValue={a} />
</form>
<form ref={formRefB}>
<input type="text" name="inputName" defaultValue={b} />
</form>
</>
);
}

const root = ReactDOMClient.createRoot(container);
const initialPromiseForA = getText('A1');
const initialPromiseForB = getText('B1');
await resolveText('A1');
await resolveText('B1');
await act(() =>
root.render(
<App
promiseForA={initialPromiseForA}
promiseForB={initialPromiseForB}
/>,
),
);

// Dirty the uncontrolled inputs
formRefA.current.elements.inputName.value = ' A2 ';
formRefB.current.elements.inputName.value = ' B2 ';

// Trigger an async action that updates and reset both forms.
await act(() => {
startTransition(async () => {
const currentA = formRefA.current.elements.inputName.value;
const currentB = formRefB.current.elements.inputName.value;

requestFormReset(formRefA.current);
requestFormReset(formRefB.current);

Scheduler.log('Async action started');
await getText('Wait');

// Pretend the server did something with the data.
const normalizedA = currentA.trim();
const normalizedB = currentB.trim();

// Update the app with new data. This is analagous to re-rendering
// from the root with a new RSC payload.
startTransition(() => {
root.render(
<App
promiseForA={getText(normalizedA)}
promiseForB={getText(normalizedB)}
/>,
);
});
});
});
assertLog(['Async action started']);

// Finish the async action. This will trigger a re-render from the root with
// new data from the "server", which suspends.
//
// The forms should not reset yet because we need to update `defaultValue`
// first. So we wait for the render to complete.
await act(() => resolveText('Wait'));

// The DOM inputs are still dirty.
expect(formRefA.current.elements.inputName.value).toBe(' A2 ');
expect(formRefB.current.elements.inputName.value).toBe(' B2 ');

// Unsuspend and finish rendering. Now the forms should be reset.
await act(() => {
resolveText('A2');
resolveText('B2');
});
// The forms were reset to the new value from the server.
expect(formRefA.current.elements.inputName.value).toBe('A2');
expect(formRefB.current.elements.inputName.value).toBe('B2');
});

test('requestFormReset does nothing if the form is not managed by React', async () => {
// TODO: Is this the desired behavior? Should it warn? Should it call
// form.reset() immediately?

container.innerHTML = `
<form id="myform">
<input id="input" type="text" name="greeting" />
</form>
`;

const form = document.getElementById('myform');
const input = document.getElementById('input');

input.value = 'Hi!!!!!!!!!!!!!';

// This shouldn't do anything.
requestFormReset(form);
expect(input.value).toBe('Hi!!!!!!!!!!!!!');

// Whereas the regular DOM form.reset() method does
form.reset();
expect(input.value).toBe('');
});

test('warns if requestFormReset is called outside of a transition', async () => {
const formRef = React.createRef();
const inputRef = React.createRef();

function App() {
return (
<form ref={formRef}>
<input ref={inputRef} type="text" defaultValue="Initial" />
</form>
);
}

const root = ReactDOMClient.createRoot(container);
await act(() => root.render(<App />));

// Dirty the uncontrolled input
inputRef.current.value = ' Updated ';

// Trigger an async action that updates and reset both forms.
await act(() => {
startTransition(async () => {
Scheduler.log('Action started');
await getText('Wait 1');
Scheduler.log('Request form reset');

// This happens after an `await`, and is not wrapped in startTransition,
// so it will be scheduled synchronously instead of with the transition.
// This is almost certainly a mistake, so we log a warning in dev.
requestFormReset(formRef.current);

await getText('Wait 2');
Scheduler.log('Action finished');
});
});
assertLog(['Action started']);
expect(inputRef.current.value).toBe(' Updated ');

// This triggers a synchronous requestFormReset, and a warning
await expect(async () => {
await act(() => resolveText('Wait 1'));
}).toErrorDev(['requestFormReset was called outside a transition'], {
withoutStack: true,
});
assertLog(['Request form reset']);

// The form was reset even though the action didn't finish.
expect(inputRef.current.value).toBe('Initial');
});
});
Loading

0 comments on commit ede5c49

Please sign in to comment.