@samitouri / QOS-React-2 / commits / fd3fb8e3c5

Rethrow errors from form actions (#26689)

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.

Andrew Clark committed Apr 21, 2023 at 13:29 UTC fd3fb8e3c5d1c977f4bfa73d715143804c69d4b0
7 files changed +521 -35
packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js
+6 -4
@@ -14,6 +14,7 @@ import type {EventSystemFlags} from '../EventSystemFlags';
14 import type {Fiber} from 'react-reconciler/src/ReactInternalTypes';
15
16 import {getFiberCurrentPropsFromNode} from '../../client/ReactDOMComponentTree';
17 +import {startHostTransition} from 'react-reconciler/src/ReactFiberReconciler';
18
19 import {SyntheticEvent} from '../SyntheticEvent';
20
@@ -24,7 +25,7 @@ import {SyntheticEvent} from '../SyntheticEvent';
25 function extractEvents(
26 dispatchQueue: DispatchQueue,
27 domEventName: DOMEventName,
27 - targetInst: null | Fiber,
28 + maybeTargetInst: null | Fiber,
29 nativeEvent: AnyNativeEvent,
30 nativeEventTarget: null | EventTarget,
31 eventSystemFlags: EventSystemFlags,
@@ -33,11 +34,12 @@ function extractEvents(
34 if (domEventName !== 'submit') {
35 return;
36 }
36 - if (!targetInst || targetInst.stateNode !== nativeEventTarget) {
37 + if (!maybeTargetInst || maybeTargetInst.stateNode !== nativeEventTarget) {
38 // If we're inside a parent root that itself is a parent of this root, then
39 // its deepest target won't be the actual form that's being submitted.
40 return;
41 }
42 + const formInst = maybeTargetInst;
43 const form: HTMLFormElement = (nativeEventTarget: any);
44 let action = (getFiberCurrentPropsFromNode(form): any).action;
45 const submitter: null | HTMLInputElement | HTMLButtonElement =
@@ -94,8 +96,8 @@ function extractEvents(
96 } else {
97 formData = new FormData(form);
98 }
97 - // TODO: Deal with errors and pending state.
98 - action(formData);
99 +
100 + startHostTransition(formInst, action, formData);
101 }
102
103 dispatchQueue.push({
packages/react-dom/src/__tests__/ReactDOMForm-test.js
+362 -29
@@ -33,39 +33,165 @@ describe('ReactDOMForm', () => {
33 let React;
34 let ReactDOM;
35 let ReactDOMClient;
36 + let Scheduler;
37 + let assertLog;
38 + let useState;
39 + let Suspense;
40 + let startTransition;
41 + let textCache;
42
43 beforeEach(() => {
44 jest.resetModules();
45 React = require('react');
46 ReactDOM = require('react-dom');
47 ReactDOMClient = require('react-dom/client');
48 + Scheduler = require('scheduler');
49 act = require('internal-test-utils').act;
50 + assertLog = require('internal-test-utils').assertLog;
51 + useState = React.useState;
52 + Suspense = React.Suspense;
53 + startTransition = React.startTransition;
54 container = document.createElement('div');
55 document.body.appendChild(container);
45 - });
56
47 - afterEach(() => {
48 - document.body.removeChild(container);
57 + textCache = new Map();
58 });
59
51 - function submit(submitter) {
52 - const form = submitter.form || submitter;
53 - if (!submitter.form) {
54 - submitter = undefined;
60 + function resolveText(text) {
61 + const record = textCache.get(text);
62 + if (record === undefined) {
63 + const newRecord = {
64 + status: 'resolved',
65 + value: text,
66 + };
67 + textCache.set(text, newRecord);
68 + } else if (record.status === 'pending') {
69 + const thenable = record.value;
70 + record.status = 'resolved';
71 + record.value = text;
72 + thenable.pings.forEach(t => t());
73 }
56 - const submitEvent = new Event('submit', {bubbles: true, cancelable: true});
57 - submitEvent.submitter = submitter;
58 - const returnValue = form.dispatchEvent(submitEvent);
59 - if (!returnValue) {
60 - return;
74 + }
75 + function resolveText(text) {
76 + const record = textCache.get(text);
77 + if (record === undefined) {
78 + const newRecord = {
79 + status: 'resolved',
80 + value: text,
81 + };
82 + textCache.set(text, newRecord);
83 + } else if (record.status === 'pending') {
84 + const thenable = record.value;
85 + record.status = 'resolved';
86 + record.value = text;
87 + thenable.pings.forEach(t => t());
88 }
62 - const action =
63 - (submitter && submitter.getAttribute('formaction')) || form.action;
64 - if (!/\s*javascript:/i.test(action)) {
65 - throw new Error('Navigate to: ' + action);
89 + }
90 +
91 + function readText(text) {
92 + const record = textCache.get(text);
93 + if (record !== undefined) {
94 + switch (record.status) {
95 + case 'pending':
96 + Scheduler.log(`Suspend! [${text}]`);
97 + throw record.value;
98 + case 'rejected':
99 + throw record.value;
100 + case 'resolved':
101 + return record.value;
102 + }
103 + } else {
104 + Scheduler.log(`Suspend! [${text}]`);
105 + const thenable = {
106 + pings: [],
107 + then(resolve) {
108 + if (newRecord.status === 'pending') {
109 + thenable.pings.push(resolve);
110 + } else {
111 + Promise.resolve().then(() => resolve(newRecord.value));
112 + }
113 + },
114 + };
115 +
116 + const newRecord = {
117 + status: 'pending',
118 + value: thenable,
119 + };
120 + textCache.set(text, newRecord);
121 +
122 + throw thenable;
123 }
124 }
125
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 +
157 + function Text({text}) {
158 + Scheduler.log(text);
159 + return text;
160 + }
161 +
162 + function AsyncText({text}) {
163 + readText(text);
164 + Scheduler.log(text);
165 + return text;
166 + }
167 +
168 + afterEach(() => {
169 + document.body.removeChild(container);
170 + });
171 +
172 + async function submit(submitter) {
173 + await act(() => {
174 + const form = submitter.form || submitter;
175 + if (!submitter.form) {
176 + submitter = undefined;
177 + }
178 + const submitEvent = new Event('submit', {
179 + bubbles: true,
180 + cancelable: true,
181 + });
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 + }
192 + });
193 + }
194 +
195 // @gate enableFormActions
196 it('should allow passing a function to form action', async () => {
197 const ref = React.createRef();
@@ -84,7 +210,7 @@ describe('ReactDOMForm', () => {
210 );
211 });
212
87 - submit(ref.current);
213 + await submit(ref.current);
214
215 expect(foo).toBe('bar');
216
@@ -102,7 +228,7 @@ describe('ReactDOMForm', () => {
228 );
229 });
230
105 - submit(ref.current);
231 + await submit(ref.current);
232
233 expect(foo).toBe('bar2');
234 });
@@ -148,12 +274,12 @@ describe('ReactDOMForm', () => {
274 expect(savedTitle).toBe(null);
275 expect(deletedTitle).toBe(null);
276
151 - submit(inputRef.current);
277 + await submit(inputRef.current);
278 expect(savedTitle).toBe('Hello');
279 expect(deletedTitle).toBe(null);
280 savedTitle = null;
281
156 - submit(buttonRef.current);
282 + await submit(buttonRef.current);
283 expect(savedTitle).toBe(null);
284 expect(deletedTitle).toBe('Hello');
285 deletedTitle = null;
@@ -188,12 +314,12 @@ describe('ReactDOMForm', () => {
314 expect(savedTitle).toBe(null);
315 expect(deletedTitle).toBe(null);
316
191 - submit(inputRef.current);
317 + await submit(inputRef.current);
318 expect(savedTitle).toBe('Hello2');
319 expect(deletedTitle).toBe(null);
320 savedTitle = null;
321
196 - submit(buttonRef.current);
322 + await submit(buttonRef.current);
323 expect(savedTitle).toBe(null);
324 expect(deletedTitle).toBe('Hello2');
325
@@ -218,7 +344,7 @@ describe('ReactDOMForm', () => {
344 );
345 });
346
221 - submit(ref.current);
347 + await submit(ref.current);
348
349 expect(actionCalled).toBe(false);
350 });
@@ -254,7 +380,7 @@ describe('ReactDOMForm', () => {
380 '\n in form (at **)',
381 ]);
382
257 - submit(ref.current);
383 + await submit(ref.current);
384
385 expect(data).toBe('innerinner');
386 });
@@ -296,7 +422,7 @@ describe('ReactDOMForm', () => {
422 );
423 });
424
299 - submit(ref.current);
425 + await submit(ref.current);
426
427 expect(bubbledSubmit).toBe(true);
428 expect(outerCalled).toBe(0);
@@ -340,7 +466,7 @@ describe('ReactDOMForm', () => {
466
467 innerContainerRef.current.appendChild(innerContainer);
468
343 - submit(ref.current);
469 + await submit(ref.current);
470
471 expect(bubbledSubmit).toBe(true);
472 expect(outerCalled).toBe(0);
@@ -377,7 +503,7 @@ describe('ReactDOMForm', () => {
503 }
504 });
505
380 - submit(ref.current);
506 + await submit(ref.current);
507
508 expect(button).toBe('delete');
509 expect(title).toBe(null);
@@ -407,7 +533,7 @@ describe('ReactDOMForm', () => {
533
534 let nav;
535 try {
410 - submit(ref.current);
536 + await submit(ref.current);
537 } catch (x) {
538 nav = x.message;
539 }
@@ -443,11 +569,218 @@ describe('ReactDOMForm', () => {
569 const node = container.getElementsByTagName('input')[0];
570 let nav;
571 try {
446 - submit(node);
572 + await submit(node);
573 } catch (x) {
574 nav = x.message;
575 }
576 expect(nav).toBe('Navigate to: http://example.com/submit');
577 expect(actionCalled).toBe(false);
578 });
579 +
580 + // @gate enableFormActions
581 + // @gate enableAsyncActions
582 + it('form actions are transitions', async () => {
583 + const formRef = React.createRef();
584 +
585 + function App() {
586 + const [state, setState] = useState('Initial');
587 + return (
588 + <form action={() => setState('Updated')} ref={formRef}>
589 + <Suspense fallback={<Text text="Loading..." />}>
590 + <AsyncText text={state} />
591 + </Suspense>
592 + </form>
593 + );
594 + }
595 +
596 + const root = ReactDOMClient.createRoot(container);
597 + await resolveText('Initial');
598 + await act(() => root.render(<App />));
599 + assertLog(['Initial']);
600 + expect(container.textContent).toBe('Initial');
601 +
602 + // This should suspend because form actions are implicitly wrapped
603 + // in startTransition.
604 + await submit(formRef.current);
605 + assertLog(['Suspend! [Updated]', 'Loading...']);
606 + expect(container.textContent).toBe('Initial');
607 +
608 + await act(() => resolveText('Updated'));
609 + assertLog(['Updated']);
610 + expect(container.textContent).toBe('Updated');
611 + });
612 +
613 + // @gate enableFormActions
614 + // @gate enableAsyncActions
615 + it('multiple form actions', async () => {
616 + const formRef = React.createRef();
617 +
618 + function App() {
619 + const [state, setState] = useState(0);
620 + return (
621 + <form action={() => setState(n => n + 1)} ref={formRef}>
622 + <Suspense fallback={<Text text="Loading..." />}>
623 + <AsyncText text={'Count: ' + state} />
624 + </Suspense>
625 + </form>
626 + );
627 + }
628 +
629 + const root = ReactDOMClient.createRoot(container);
630 + await resolveText('Count: 0');
631 + await act(() => root.render(<App />));
632 + assertLog(['Count: 0']);
633 + expect(container.textContent).toBe('Count: 0');
634 +
635 + // Update
636 + await submit(formRef.current);
637 + assertLog(['Suspend! [Count: 1]', 'Loading...']);
638 + expect(container.textContent).toBe('Count: 0');
639 +
640 + await act(() => resolveText('Count: 1'));
641 + assertLog(['Count: 1']);
642 + expect(container.textContent).toBe('Count: 1');
643 +
644 + // Update again
645 + await submit(formRef.current);
646 + assertLog(['Suspend! [Count: 2]', 'Loading...']);
647 + expect(container.textContent).toBe('Count: 1');
648 +
649 + await act(() => resolveText('Count: 2'));
650 + assertLog(['Count: 2']);
651 + expect(container.textContent).toBe('Count: 2');
652 + });
653 +
654 + // @gate enableFormActions
655 + it('form actions can be asynchronous', async () => {
656 + const formRef = React.createRef();
657 +
658 + function App() {
659 + const [state, setState] = useState('Initial');
660 + return (
661 + <form
662 + action={async () => {
663 + Scheduler.log('Async action started');
664 + await getText('Wait');
665 + startTransition(() => setState('Updated'));
666 + }}
667 + ref={formRef}>
668 + <Suspense fallback={<Text text="Loading..." />}>
669 + <AsyncText text={state} />
670 + </Suspense>
671 + </form>
672 + );
673 + }
674 +
675 + const root = ReactDOMClient.createRoot(container);
676 + await resolveText('Initial');
677 + await act(() => root.render(<App />));
678 + assertLog(['Initial']);
679 + expect(container.textContent).toBe('Initial');
680 +
681 + await submit(formRef.current);
682 + assertLog(['Async action started']);
683 +
684 + await act(() => resolveText('Wait'));
685 + assertLog(['Suspend! [Updated]', 'Loading...']);
686 + expect(container.textContent).toBe('Initial');
687 + });
688 +
689 + it('sync errors in form actions can be captured by an error boundary', async () => {
690 + if (gate(flags => !(flags.enableFormActions && flags.enableAsyncActions))) {
691 + // TODO: Uncaught JSDOM errors fail the test after the scope has finished
692 + // so don't work with the `gate` mechanism.
693 + return;
694 + }
695 +
696 + class ErrorBoundary extends React.Component {
697 + state = {error: null};
698 + static getDerivedStateFromError(error) {
699 + return {error};
700 + }
701 + render() {
702 + if (this.state.error !== null) {
703 + return <Text text={this.state.error.message} />;
704 + }
705 + return this.props.children;
706 + }
707 + }
708 +
709 + const formRef = React.createRef();
710 +
711 + function App() {
712 + return (
713 + <ErrorBoundary>
714 + <form
715 + action={() => {
716 + throw new Error('Oh no!');
717 + }}
718 + ref={formRef}>
719 + <Text text="Everything is fine" />
720 + </form>
721 + </ErrorBoundary>
722 + );
723 + }
724 +
725 + const root = ReactDOMClient.createRoot(container);
726 + await act(() => root.render(<App />));
727 + assertLog(['Everything is fine']);
728 + expect(container.textContent).toBe('Everything is fine');
729 +
730 + await submit(formRef.current);
731 + assertLog(['Oh no!', 'Oh no!']);
732 + expect(container.textContent).toBe('Oh no!');
733 + });
734 +
735 + it('async errors in form actions can be captured by an error boundary', async () => {
736 + if (gate(flags => !(flags.enableFormActions && flags.enableAsyncActions))) {
737 + // TODO: Uncaught JSDOM errors fail the test after the scope has finished
738 + // so don't work with the `gate` mechanism.
739 + return;
740 + }
741 +
742 + class ErrorBoundary extends React.Component {
743 + state = {error: null};
744 + static getDerivedStateFromError(error) {
745 + return {error};
746 + }
747 + render() {
748 + if (this.state.error !== null) {
749 + return <Text text={this.state.error.message} />;
750 + }
751 + return this.props.children;
752 + }
753 + }
754 +
755 + const formRef = React.createRef();
756 +
757 + function App() {
758 + return (
759 + <ErrorBoundary>
760 + <form
761 + action={async () => {
762 + Scheduler.log('Async action started');
763 + await getText('Wait');
764 + throw new Error('Oh no!');
765 + }}
766 + ref={formRef}>
767 + <Text text="Everything is fine" />
768 + </form>
769 + </ErrorBoundary>
770 + );
771 + }
772 +
773 + const root = ReactDOMClient.createRoot(container);
774 + await act(() => root.render(<App />));
775 + assertLog(['Everything is fine']);
776 + expect(container.textContent).toBe('Everything is fine');
777 +
778 + await submit(formRef.current);
779 + assertLog(['Async action started']);
780 + expect(container.textContent).toBe('Everything is fine');
781 +
782 + await act(() => resolveText('Wait'));
783 + assertLog(['Oh no!', 'Oh no!']);
784 + expect(container.textContent).toBe('Oh no!');
785 + });
786 });
packages/react-reconciler/src/ReactFiberBeginWork.js
+20
@@ -107,6 +107,8 @@ import {
107 enableUseMutableSource,
108 enableFloat,
109 enableHostSingletons,
110 + enableFormActions,
111 + enableAsyncActions,
112 } from 'shared/ReactFeatureFlags';
113 import isArray from 'shared/isArray';
114 import shallowEqual from 'shared/shallowEqual';
@@ -208,6 +210,7 @@ import {
210 checkDidRenderIdHook,
211 bailoutHooks,
212 replaySuspendedComponentWithHooks,
213 + renderTransitionAwareHostComponentWithHooks,
214 } from './ReactFiberHooks';
215 import {stopProfilerTimerIfRunning} from './ReactProfilerTimer';
216 import {
@@ -1620,6 +1623,23 @@ function updateHostComponent(
1623 workInProgress.flags |= ContentReset;
1624 }
1625
1626 + if (enableFormActions && enableAsyncActions) {
1627 + const memoizedState = workInProgress.memoizedState;
1628 + if (memoizedState !== null) {
1629 + // This fiber has been upgraded to a stateful component. The only way
1630 + // happens currently is for form actions. We use hooks to track the
1631 + // pending and error state of the form.
1632 + //
1633 + // Once a fiber is upgraded to be stateful, it remains stateful for the
1634 + // rest of its lifetime.
1635 + renderTransitionAwareHostComponentWithHooks(
1636 + current,
1637 + workInProgress,
1638 + renderLanes,
1639 + );
1640 + }
1641 + }
1642 +
1643 markRef(current, workInProgress);
1644 reconcileChildren(current, workInProgress, nextChildren, renderLanes);
1645 return workInProgress.child;
packages/react-reconciler/src/ReactFiberHooks.js
+120 -1
@@ -43,6 +43,7 @@ import {
43 enableLegacyCache,
44 debugRenderPhaseSideEffectsForStrictMode,
45 enableAsyncActions,
46 + enableFormActions,
47 } from 'shared/ReactFeatureFlags';
48 import {
49 REACT_CONTEXT_TYPE,
@@ -80,7 +81,7 @@ import {
81 higherEventPriority,
82 } from './ReactEventPriorities';
83 import {readContext, checkIfContextChanged} from './ReactFiberNewContext';
83 -import {HostRoot, CacheComponent} from './ReactWorkTags';
84 +import {HostRoot, CacheComponent, HostComponent} from './ReactWorkTags';
85 import {
86 LayoutStatic as LayoutStaticEffect,
87 Passive as PassiveEffect,
@@ -753,6 +754,33 @@ function renderWithHooksAgain<Props, SecondArg>(
754 return children;
755 }
756
757 +export function renderTransitionAwareHostComponentWithHooks(
758 + current: Fiber | null,
759 + workInProgress: Fiber,
760 + lanes: Lanes,
761 +): boolean {
762 + if (!(enableFormActions && enableAsyncActions)) {
763 + return false;
764 + }
765 + return renderWithHooks(
766 + current,
767 + workInProgress,
768 + TransitionAwareHostComponent,
769 + null,
770 + null,
771 + lanes,
772 + );
773 +}
774 +
775 +export function TransitionAwareHostComponent(): boolean {
776 + if (!(enableFormActions && enableAsyncActions)) {
777 + return false;
778 + }
779 + const dispatcher = ReactCurrentDispatcher.current;
780 + const [isPending] = dispatcher.useTransition();
781 + return isPending;
782 +}
783 +
784 export function checkDidRenderIdHook(): boolean {
785 // This should be called immediately after every renderWithHooks call.
786 // Conceptually, it's part of the return value of renderWithHooks; it's only a
@@ -2483,6 +2511,97 @@ function startTransition(
2511 }
2512 }
2513
2514 +export function startHostTransition<F>(
2515 + formFiber: Fiber,
2516 + callback: F => mixed,
2517 + formData: F,
2518 +): void {
2519 + if (!enableFormActions) {
2520 + // Not implemented.
2521 + return;
2522 + }
2523 +
2524 + if (!enableAsyncActions) {
2525 + // Form actions are enabled, but async actions are not. Call the function,
2526 + // but don't handle any pending or error states.
2527 + callback(formData);
2528 + return;
2529 + }
2530 +
2531 + if (formFiber.tag !== HostComponent) {
2532 + throw new Error(
2533 + 'Expected the form instance to be a HostComponent. This ' +
2534 + 'is a bug in React.',
2535 + );
2536 + }
2537 +
2538 + let setPending;
2539 + if (formFiber.memoizedState === null) {
2540 + // Upgrade this host component fiber to be stateful. We're going to pretend
2541 + // it was stateful all along so we can reuse most of the implementation
2542 + // for function components and useTransition.
2543 + //
2544 + // Create the initial hooks used by useTransition. This is essentially an
2545 + // inlined version of mountTransition.
2546 + const queue: UpdateQueue<
2547 + Thenable<boolean> | boolean,
2548 + Thenable<boolean> | boolean,
2549 + > = {
2550 + pending: null,
2551 + lanes: NoLanes,
2552 + dispatch: null,
2553 + lastRenderedReducer: basicStateReducer,
2554 + lastRenderedState: false,
2555 + };
2556 + const stateHook: Hook = {
2557 + memoizedState: false,
2558 + baseState: false,
2559 + baseQueue: null,
2560 + queue: queue,
2561 + next: null,
2562 + };
2563 +
2564 + const dispatch: (Thenable<boolean> | boolean) => void =
2565 + (dispatchSetState.bind(null, formFiber, queue): any);
2566 + setPending = queue.dispatch = dispatch;
2567 +
2568 + // TODO: The only reason this second hook exists is to save a reference to
2569 + // the `dispatch` function. But we already store this on the state hook. So
2570 + // we can cheat and read it from there. Need to make this change to the
2571 + // regular `useTransition` implementation, too.
2572 + const transitionHook: Hook = {
2573 + memoizedState: dispatch,
2574 + baseState: null,
2575 + baseQueue: null,
2576 + queue: null,
2577 + next: null,
2578 + };
2579 +
2580 + stateHook.next = transitionHook;
2581 +
2582 + // Add the initial list of hooks to both fiber alternates. The idea is that
2583 + // the fiber had these hooks all along.
2584 + formFiber.memoizedState = stateHook;
2585 + const alternate = formFiber.alternate;
2586 + if (alternate !== null) {
2587 + alternate.memoizedState = stateHook;
2588 + }
2589 + } else {
2590 + // This fiber was already upgraded to be stateful.
2591 + const transitionHook: Hook = formFiber.memoizedState.next;
2592 + const dispatch: (Thenable<boolean> | boolean) => void =
2593 + transitionHook.memoizedState;
2594 + setPending = dispatch;
2595 + }
2596 +
2597 + startTransition(
2598 + setPending,
2599 + // TODO: We can avoid this extra wrapper, somehow. Figure out layering
2600 + // once more of this function is implemented.
2601 + () => callback(formData),
2602 + );
2603 +}
2604 +
2605 function mountTransition(): [
2606 boolean,
2607 (callback: () => void, options?: StartTransitionOptions) => void,
packages/react-reconciler/src/ReactFiberReconciler.js
+1
@@ -111,6 +111,7 @@ export {
111 focusWithin,
112 observeVisibleRects,
113 } from './ReactTestSelectors';
114 +export {startHostTransition} from './ReactFiberHooks';
115
116 type OpaqueRoot = FiberRoot;
117
packages/react-reconciler/src/ReactFiberWorkLoop.js
+10
@@ -2330,6 +2330,16 @@ function replaySuspendedUnitOfWork(unitOfWork: Fiber): void {
2330 );
2331 break;
2332 }
2333 + case HostComponent: {
2334 + // Some host components are stateful (that's how we implement form
2335 + // actions) but we don't bother to reuse the memoized state because it's
2336 + // not worth the extra code. The main reason to reuse the previous hooks
2337 + // is to reuse uncached promises, but we happen to know that the only
2338 + // promises that a host component might suspend on are definitely cached
2339 + // because they are controlled by us. So don't bother.
2340 + resetHooksOnUnwind();
2341 + // Fallthrough to the next branch.
2342 + }
2343 default: {
2344 // Other types besides function components are reset completely before
2345 // being replayed. Currently this only happens when a Usable type is
scripts/error-codes/codes.json
+2 -1
@@ -460,5 +460,6 @@
460 "472": "Type %s is not supported as an argument to a Server Function.",
461 "473": "React doesn't accept base64 encoded file uploads because we don't except form data passed from a browser to ever encode data that way. If that's the wrong assumption, we can easily fix it.",
462 "474": "Suspense Exception: This is not a real error, and should not leak into userspace. If you're seeing this, it's likely a bug in React.",
463 - "475": "Internal React Error: suspendedState null when it was expected to exists. Please report this as a React bug."
463 + "475": "Internal React Error: suspendedState null when it was expected to exists. Please report this as a React bug.",
464 + "476": "Expected the form instance to be a HostComponent. This is a bug in React."
465 }