Skip to content

Commit 128404f

Browse files
committed
Rethrow errors from form actions
This is the next step toward full support for async form actions. Errors thrown inside form actions should cause the form to re-render and throw the error so it can be captured by an error boundary. The behavior is the same if the <form /> had an internal useTransition hook, which is pretty much exactly how we implement it, too. The first time an action is called, the form's HostComponent is "upgraded" to become stateful, by lazily mounting a list of hooks. The rest of the implementation for function components can be shared. Because the error handling behavior added in this commit is just using useTransition under-the-hood, it also handles pending states, too. However, this pending state can't be observed until we add a new hook for that purpose. I'll add this next.
1 parent 2432857 commit 128404f

File tree

6 files changed

+309
-22
lines changed

6 files changed

+309
-22
lines changed

packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {SyntheticEvent} from '../SyntheticEvent';
2525
function extractEvents(
2626
dispatchQueue: DispatchQueue,
2727
domEventName: DOMEventName,
28-
targetInst: null | Fiber,
28+
maybeTargetInst: null | Fiber,
2929
nativeEvent: AnyNativeEvent,
3030
nativeEventTarget: null | EventTarget,
3131
eventSystemFlags: EventSystemFlags,
@@ -34,11 +34,12 @@ function extractEvents(
3434
if (domEventName !== 'submit') {
3535
return;
3636
}
37-
if (!targetInst || targetInst.stateNode !== nativeEventTarget) {
37+
if (!maybeTargetInst || maybeTargetInst.stateNode !== nativeEventTarget) {
3838
// If we're inside a parent root that itself is a parent of this root, then
3939
// its deepest target won't be the actual form that's being submitted.
4040
return;
4141
}
42+
const formInst = maybeTargetInst;
4243
const form: HTMLFormElement = (nativeEventTarget: any);
4344
let action = (getFiberCurrentPropsFromNode(form): any).action;
4445
const submitter: null | HTMLInputElement | HTMLButtonElement =
@@ -96,7 +97,7 @@ function extractEvents(
9697
formData = new FormData(form);
9798
}
9899

99-
startFormAction(action, formData);
100+
startFormAction(formInst, action, formData);
100101
}
101102

102103
dispatchQueue.push({

packages/react-dom/src/__tests__/ReactDOMForm-test.js

Lines changed: 170 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ describe('ReactDOMForm', () => {
3737
let assertLog;
3838
let useState;
3939
let Suspense;
40+
let startTransition;
4041
let textCache;
4142

4243
beforeEach(() => {
@@ -49,6 +50,7 @@ describe('ReactDOMForm', () => {
4950
assertLog = require('internal-test-utils').assertLog;
5051
useState = React.useState;
5152
Suspense = React.Suspense;
53+
startTransition = React.startTransition;
5254
container = document.createElement('div');
5355
document.body.appendChild(container);
5456

@@ -121,6 +123,37 @@ describe('ReactDOMForm', () => {
121123
}
122124
}
123125

126+
function getText(text) {
127+
const record = textCache.get(text);
128+
if (record === undefined) {
129+
const thenable = {
130+
pings: [],
131+
then(resolve) {
132+
if (newRecord.status === 'pending') {
133+
thenable.pings.push(resolve);
134+
} else {
135+
Promise.resolve().then(() => resolve(newRecord.value));
136+
}
137+
},
138+
};
139+
const newRecord = {
140+
status: 'pending',
141+
value: thenable,
142+
};
143+
textCache.set(text, newRecord);
144+
return thenable;
145+
} else {
146+
switch (record.status) {
147+
case 'pending':
148+
return record.value;
149+
case 'rejected':
150+
return Promise.reject(record.value);
151+
case 'resolved':
152+
return Promise.resolve(record.value);
153+
}
154+
}
155+
}
156+
124157
function Text({text}) {
125158
Scheduler.log(text);
126159
return text;
@@ -138,24 +171,24 @@ describe('ReactDOMForm', () => {
138171

139172
async function submit(submitter) {
140173
await act(() => {
141-
const form = submitter.form || submitter;
142-
if (!submitter.form) {
143-
submitter = undefined;
144-
}
174+
const form = submitter.form || submitter;
175+
if (!submitter.form) {
176+
submitter = undefined;
177+
}
145178
const submitEvent = new Event('submit', {
146179
bubbles: true,
147180
cancelable: true,
148181
});
149-
submitEvent.submitter = submitter;
150-
const returnValue = form.dispatchEvent(submitEvent);
151-
if (!returnValue) {
152-
return;
153-
}
154-
const action =
155-
(submitter && submitter.getAttribute('formaction')) || form.action;
156-
if (!/\s*javascript:/i.test(action)) {
157-
throw new Error('Navigate to: ' + action);
158-
}
182+
submitEvent.submitter = submitter;
183+
const returnValue = form.dispatchEvent(submitEvent);
184+
if (!returnValue) {
185+
return;
186+
}
187+
const action =
188+
(submitter && submitter.getAttribute('formaction')) || form.action;
189+
if (!/\s*javascript:/i.test(action)) {
190+
throw new Error('Navigate to: ' + action);
191+
}
159192
});
160193
}
161194

@@ -577,4 +610,127 @@ describe('ReactDOMForm', () => {
577610
assertLog(['Updated']);
578611
expect(container.textContent).toBe('Updated');
579612
});
613+
614+
// @gate enableFormActions
615+
it('form actions can be asynchronous', async () => {
616+
const formRef = React.createRef();
617+
618+
function App() {
619+
const [state, setState] = useState('Initial');
620+
return (
621+
<form
622+
action={async () => {
623+
Scheduler.log('Async action started');
624+
await getText('Wait');
625+
startTransition(() => setState('Updated'));
626+
}}
627+
ref={formRef}>
628+
<Suspense fallback={<Text text="Loading..." />}>
629+
<AsyncText text={state} />
630+
</Suspense>
631+
</form>
632+
);
633+
}
634+
635+
const root = ReactDOMClient.createRoot(container);
636+
await resolveText('Initial');
637+
await act(() => root.render(<App />));
638+
assertLog(['Initial']);
639+
expect(container.textContent).toBe('Initial');
640+
641+
await submit(formRef.current);
642+
assertLog(['Async action started']);
643+
644+
await act(() => resolveText('Wait'));
645+
assertLog(['Suspend! [Updated]', 'Loading...']);
646+
expect(container.textContent).toBe('Initial');
647+
});
648+
649+
// @gate enableFormActions
650+
it('sync errors in form actions can be captured by an error boundary', async () => {
651+
class ErrorBoundary extends React.Component {
652+
state = {error: null};
653+
static getDerivedStateFromError(error) {
654+
return {error};
655+
}
656+
render() {
657+
if (this.state.error !== null) {
658+
return <Text text={this.state.error.message} />;
659+
}
660+
return this.props.children;
661+
}
662+
}
663+
664+
const formRef = React.createRef();
665+
666+
function App() {
667+
return (
668+
<ErrorBoundary>
669+
<form
670+
action={() => {
671+
throw new Error('Oh no!');
672+
}}
673+
ref={formRef}>
674+
<Text text="Everything is fine" />
675+
</form>
676+
</ErrorBoundary>
677+
);
678+
}
679+
680+
const root = ReactDOMClient.createRoot(container);
681+
await act(() => root.render(<App />));
682+
assertLog(['Everything is fine']);
683+
expect(container.textContent).toBe('Everything is fine');
684+
685+
await submit(formRef.current);
686+
assertLog(['Oh no!', 'Oh no!']);
687+
expect(container.textContent).toBe('Oh no!');
688+
});
689+
690+
// @gate enableFormActions
691+
it('async errors in form actions can be captured by an error boundary', async () => {
692+
class ErrorBoundary extends React.Component {
693+
state = {error: null};
694+
static getDerivedStateFromError(error) {
695+
return {error};
696+
}
697+
render() {
698+
if (this.state.error !== null) {
699+
return <Text text={this.state.error.message} />;
700+
}
701+
return this.props.children;
702+
}
703+
}
704+
705+
const formRef = React.createRef();
706+
707+
function App() {
708+
return (
709+
<ErrorBoundary>
710+
<form
711+
action={async () => {
712+
Scheduler.log('Async action started');
713+
await getText('Wait');
714+
throw new Error('Oh no!');
715+
}}
716+
ref={formRef}>
717+
<Text text="Everything is fine" />
718+
</form>
719+
</ErrorBoundary>
720+
);
721+
}
722+
723+
const root = ReactDOMClient.createRoot(container);
724+
await act(() => root.render(<App />));
725+
assertLog(['Everything is fine']);
726+
expect(container.textContent).toBe('Everything is fine');
727+
728+
await submit(formRef.current);
729+
assertLog(['Async action started']);
730+
expect(container.textContent).toBe('Everything is fine');
731+
732+
await act(() => resolveText('Wait'));
733+
assertLog(['Oh no!', 'Oh no!']);
734+
expect(container.textContent).toBe('Oh no!');
735+
});
580736
});

packages/react-reconciler/src/ReactFiberBeginWork.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ import {
107107
enableUseMutableSource,
108108
enableFloat,
109109
enableHostSingletons,
110+
enableFormActions,
110111
} from 'shared/ReactFeatureFlags';
111112
import isArray from 'shared/isArray';
112113
import shallowEqual from 'shared/shallowEqual';
@@ -208,6 +209,7 @@ import {
208209
checkDidRenderIdHook,
209210
bailoutHooks,
210211
replaySuspendedComponentWithHooks,
212+
renderStatefulHostComponentWithHooks,
211213
} from './ReactFiberHooks';
212214
import {stopProfilerTimerIfRunning} from './ReactProfilerTimer';
213215
import {
@@ -1620,6 +1622,23 @@ function updateHostComponent(
16201622
workInProgress.flags |= ContentReset;
16211623
}
16221624

1625+
if (!enableFormActions) {
1626+
const memoizedState = workInProgress.memoizedState;
1627+
if (memoizedState !== null) {
1628+
// This fiber has been upgraded to a stateful component. The only way
1629+
// happens currently is for form actions. We use hooks to track the pending
1630+
// and error state of the form.
1631+
//
1632+
// Once a fiber is upgraded to be stateful, it remains stateful for the rest
1633+
// of its lifetime.
1634+
renderStatefulHostComponentWithHooks(
1635+
current,
1636+
workInProgress,
1637+
renderLanes,
1638+
);
1639+
}
1640+
}
1641+
16231642
markRef(current, workInProgress);
16241643
reconcileChildren(current, workInProgress, nextChildren, renderLanes);
16251644
return workInProgress.child;

0 commit comments

Comments
 (0)