@samitouri / QOS-React / commits / 8b26f07a88

useFormState: Emit comment to mark whether state matches (#27307)

A planned feature of useFormState is that if the page load is the result of an MPA-style form submission — i.e. a form was submitted before it was hydrated, using Server Actions — the state of the hook should transfer to the next page. I haven't implemented that part yet, but as a prerequisite, we need some way for Fizz to indicate whether a useFormState hook was rendered using the "postback" state. That way we can do all state matching logic on the server without having to replicate it on the client, too. The approach here is to emit a comment node for each useFormState hook. We use one of two comment types: `<!--F-->` for a normal useFormState hook, and `<!--F!-->` for a hook that was rendered using the postback state. React will read these markers during hydration. This is similar to how we encode Suspense boundaries. Again, the actual matching algorithm is not yet implemented — for now, the "not matching" marker is always emitted. We can optimize this further by not emitting any markers for a render that is not the result of a form postback, which I'll do in subsequent PRs.

Andrew Clark committed Sep 7, 2023 at 16:05 UTC 8b26f07a883bb341c20283c0099bf5ee6f87bd1f
12 files changed +343 -39
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+45 -2
@@ -98,6 +98,7 @@ import {
98 enableTrustedTypesIntegration,
99 diffInCommitPhase,
100 enableFormActions,
101 + enableAsyncActions,
102 } from 'shared/ReactFeatureFlags';
103 import {
104 HostComponent,
@@ -160,7 +161,12 @@ export type TextInstance = Text;
161 export interface SuspenseInstance extends Comment {
162 _reactRetry?: () => void;
163 }
163 -export type HydratableInstance = Instance | TextInstance | SuspenseInstance;
164 +type FormStateMarkerInstance = Comment;
165 +export type HydratableInstance =
166 + | Instance
167 + | TextInstance
168 + | SuspenseInstance
169 + | FormStateMarkerInstance;
170 export type PublicInstance = Element | Text;
171 export type HostContextDev = {
172 context: HostContextProd,
@@ -187,6 +193,8 @@ const SUSPENSE_START_DATA = '$';
193 const SUSPENSE_END_DATA = '/$';
194 const SUSPENSE_PENDING_START_DATA = '$?';
195 const SUSPENSE_FALLBACK_START_DATA = '$!';
196 +const FORM_STATE_IS_MATCHING = 'F!';
197 +const FORM_STATE_IS_NOT_MATCHING = 'F';
198
199 const STYLE = 'style';
200
@@ -1283,6 +1291,37 @@ export function registerSuspenseInstanceRetry(
1291 instance._reactRetry = callback;
1292 }
1293
1294 +export function canHydrateFormStateMarker(
1295 + instance: HydratableInstance,
1296 + inRootOrSingleton: boolean,
1297 +): null | FormStateMarkerInstance {
1298 + while (instance.nodeType !== COMMENT_NODE) {
1299 + if (!inRootOrSingleton || !enableHostSingletons) {
1300 + return null;
1301 + }
1302 + const nextInstance = getNextHydratableSibling(instance);
1303 + if (nextInstance === null) {
1304 + return null;
1305 + }
1306 + instance = nextInstance;
1307 + }
1308 + const nodeData = (instance: any).data;
1309 + if (
1310 + nodeData === FORM_STATE_IS_MATCHING ||
1311 + nodeData === FORM_STATE_IS_NOT_MATCHING
1312 + ) {
1313 + const markerInstance: FormStateMarkerInstance = (instance: any);
1314 + return markerInstance;
1315 + }
1316 + return null;
1317 +}
1318 +
1319 +export function isFormStateMarkerMatching(
1320 + markerInstance: FormStateMarkerInstance,
1321 +): boolean {
1322 + return markerInstance.data === FORM_STATE_IS_MATCHING;
1323 +}
1324 +
1325 function getNextHydratable(node: ?Node) {
1326 // Skip non-hydratable nodes.
1327 for (; node != null; node = ((node: any): Node).nextSibling) {
@@ -1295,7 +1334,11 @@ function getNextHydratable(node: ?Node) {
1334 if (
1335 nodeData === SUSPENSE_START_DATA ||
1336 nodeData === SUSPENSE_FALLBACK_START_DATA ||
1298 - nodeData === SUSPENSE_PENDING_START_DATA
1337 + nodeData === SUSPENSE_PENDING_START_DATA ||
1338 + (enableFormActions &&
1339 + enableAsyncActions &&
1340 + (nodeData === FORM_STATE_IS_MATCHING ||
1341 + nodeData === FORM_STATE_IS_NOT_MATCHING))
1342 ) {
1343 break;
1344 }
packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
+15
@@ -1519,6 +1519,21 @@ function injectFormReplayingRuntime(
1519 }
1520 }
1521
1522 +const formStateMarkerIsMatching = stringToPrecomputedChunk('<!--F!-->');
1523 +const formStateMarkerIsNotMatching = stringToPrecomputedChunk('<!--F-->');
1524 +
1525 +export function pushFormStateMarkerIsMatching(
1526 + target: Array<Chunk | PrecomputedChunk>,
1527 +) {
1528 + target.push(formStateMarkerIsMatching);
1529 +}
1530 +
1531 +export function pushFormStateMarkerIsNotMatching(
1532 + target: Array<Chunk | PrecomputedChunk>,
1533 +) {
1534 + target.push(formStateMarkerIsNotMatching);
1535 +}
1536 +
1537 function pushStartForm(
1538 target: Array<Chunk | PrecomputedChunk>,
1539 props: Object,
packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js
+2
@@ -101,6 +101,8 @@ export {
101 pushEndInstance,
102 pushStartCompletedSuspenseBoundary,
103 pushEndCompletedSuspenseBoundary,
104 + pushFormStateMarkerIsMatching,
105 + pushFormStateMarkerIsNotMatching,
106 writeStartSegment,
107 writeEndSegment,
108 writeCompletedSegmentInstruction,
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+119
@@ -30,6 +30,7 @@ let SuspenseList;
30 let useSyncExternalStore;
31 let useSyncExternalStoreWithSelector;
32 let use;
33 +let useFormState;
34 let PropTypes;
35 let textCache;
36 let writable;
@@ -88,6 +89,7 @@ describe('ReactDOMFizzServer', () => {
89 if (gate(flags => flags.enableSuspenseList)) {
90 SuspenseList = React.unstable_SuspenseList;
91 }
92 + useFormState = ReactDOM.experimental_useFormState;
93
94 PropTypes = require('prop-types');
95
@@ -5876,6 +5878,123 @@ describe('ReactDOMFizzServer', () => {
5878 expect(getVisibleChildren(container)).toEqual('Hi');
5879 });
5880
5881 + // @gate enableFormActions
5882 + // @gate enableAsyncActions
5883 + it('useFormState hydrates without a mismatch', async () => {
5884 + // This is testing an implementation detail: useFormState emits comment
5885 + // nodes into the SSR stream, so this checks that they are handled correctly
5886 + // during hydration.
5887 +
5888 + async function action(state) {
5889 + return state;
5890 + }
5891 +
5892 + const childRef = React.createRef(null);
5893 + function Form() {
5894 + const [state] = useFormState(action, 0);
5895 + const text = `Child: ${state}`;
5896 + return (
5897 + <div id="child" ref={childRef}>
5898 + {text}
5899 + </div>
5900 + );
5901 + }
5902 +
5903 + function App() {
5904 + return (
5905 + <div>
5906 + <div>
5907 + <Form />
5908 + </div>
5909 + <span>Sibling</span>
5910 + </div>
5911 + );
5912 + }
5913 +
5914 + await act(() => {
5915 + const {pipe} = renderToPipeableStream(<App />);
5916 + pipe(writable);
5917 + });
5918 + expect(getVisibleChildren(container)).toEqual(
5919 + <div>
5920 + <div>
5921 + <div id="child">Child: 0</div>
5922 + </div>
5923 + <span>Sibling</span>
5924 + </div>,
5925 + );
5926 + const child = document.getElementById('child');
5927 +
5928 + // Confirm that it hydrates correctly
5929 + await clientAct(() => {
5930 + ReactDOMClient.hydrateRoot(container, <App />);
5931 + });
5932 + expect(childRef.current).toBe(child);
5933 + });
5934 +
5935 + // @gate enableFormActions
5936 + // @gate enableAsyncActions
5937 + it("useFormState hydrates without a mismatch if there's a render phase update", async () => {
5938 + async function action(state) {
5939 + return state;
5940 + }
5941 +
5942 + const childRef = React.createRef(null);
5943 + function Form() {
5944 + const [localState, setLocalState] = React.useState(0);
5945 + if (localState < 3) {
5946 + setLocalState(localState + 1);
5947 + }
5948 +
5949 + // Because of the render phase update above, this component is evaluated
5950 + // multiple times (even during SSR), but it should only emit a single
5951 + // marker per useFormState instance.
5952 + const [formState] = useFormState(action, 0);
5953 + const text = `${readText('Child')}:${formState}:${localState}`;
5954 + return (
5955 + <div id="child" ref={childRef}>
5956 + {text}
5957 + </div>
5958 + );
5959 + }
5960 +
5961 + function App() {
5962 + return (
5963 + <div>
5964 + <Suspense fallback="Loading...">
5965 + <Form />
5966 + </Suspense>
5967 + <span>Sibling</span>
5968 + </div>
5969 + );
5970 + }
5971 +
5972 + await act(() => {
5973 + const {pipe} = renderToPipeableStream(<App />);
5974 + pipe(writable);
5975 + });
5976 + expect(getVisibleChildren(container)).toEqual(
5977 + <div>
5978 + Loading...<span>Sibling</span>
5979 + </div>,
5980 + );
5981 +
5982 + await act(() => resolveText('Child'));
5983 + expect(getVisibleChildren(container)).toEqual(
5984 + <div>
5985 + <div id="child">Child:0:3</div>
5986 + <span>Sibling</span>
5987 + </div>,
5988 + );
5989 + const child = document.getElementById('child');
5990 +
5991 + // Confirm that it hydrates correctly
5992 + await clientAct(() => {
5993 + ReactDOMClient.hydrateRoot(container, <App />);
5994 + });
5995 + expect(childRef.current).toBe(child);
5996 + });
5997 +
5998 describe('useEffectEvent', () => {
5999 // @gate enableUseEffectEventHook
6000 it('can server render a component with useEffectEvent', async () => {
packages/react-reconciler/src/ReactFiberConfigWithNoHydration.js
+2
@@ -26,6 +26,8 @@ export const isSuspenseInstancePending = shim;
26 export const isSuspenseInstanceFallback = shim;
27 export const getSuspenseInstanceFallbackErrorDetails = shim;
28 export const registerSuspenseInstanceRetry = shim;
29 +export const canHydrateFormStateMarker = shim;
30 +export const isFormStateMarkerMatching = shim;
31 export const getNextHydratableSibling = shim;
32 export const getFirstHydratableChild = shim;
33 export const getFirstHydratableChildWithinContainer = shim;
packages/react-reconciler/src/ReactFiberHooks.js
+12 -2
@@ -111,7 +111,10 @@ import {
111 markWorkInProgressReceivedUpdate,
112 checkIfWorkInProgressReceivedUpdate,
113 } from './ReactFiberBeginWork';
114 -import {getIsHydrating} from './ReactFiberHydrationContext';
114 +import {
115 + getIsHydrating,
116 + tryToClaimNextHydratableFormMarkerInstance,
117 +} from './ReactFiberHydrationContext';
118 import {logStateUpdateScheduled} from './DebugTracing';
119 import {
120 markStateUpdateScheduled,
@@ -2010,6 +2013,12 @@ function mountFormState<S, P>(
2013 initialState: S,
2014 permalink?: string,
2015 ): [S, (P) => void] {
2016 + if (getIsHydrating()) {
2017 + // TODO: If this function returns true, it means we should use the form
2018 + // state passed to hydrateRoot instead of initialState.
2019 + tryToClaimNextHydratableFormMarkerInstance(currentlyRenderingFiber);
2020 + }
2021 +
2022 // State hook. The state is stored in a thenable which is then unwrapped by
2023 // the `use` algorithm during render.
2024 const stateHook = mountWorkInProgressHook();
@@ -2145,7 +2154,8 @@ function rerenderFormState<S, P>(
2154 }
2155
2156 // This is a mount. No updates to process.
2148 - const state = stateHook.memoizedState;
2157 + const thenable: Thenable<S> = stateHook.memoizedState;
2158 + const state = useThenable(thenable);
2159
2160 const actionQueueHook = updateWorkInProgressHook();
2161 const actionQueue = actionQueueHook.queue;
packages/react-reconciler/src/ReactFiberHydrationContext.js
+30
@@ -76,6 +76,8 @@ import {
76 canHydrateInstance,
77 canHydrateTextInstance,
78 canHydrateSuspenseInstance,
79 + canHydrateFormStateMarker,
80 + isFormStateMarkerMatching,
81 isHydratableText,
82 } from './ReactFiberConfig';
83 import {OffscreenLane} from './ReactFiberLane';
@@ -595,6 +597,34 @@ function tryToClaimNextHydratableSuspenseInstance(fiber: Fiber): void {
597 }
598 }
599
600 +export function tryToClaimNextHydratableFormMarkerInstance(
601 + fiber: Fiber,
602 +): boolean {
603 + if (!isHydrating) {
604 + return false;
605 + }
606 + if (nextHydratableInstance) {
607 + const markerInstance = canHydrateFormStateMarker(
608 + nextHydratableInstance,
609 + rootOrSingletonContext,
610 + );
611 + if (markerInstance) {
612 + // Found the marker instance.
613 + nextHydratableInstance = getNextHydratableSibling(markerInstance);
614 + // Return true if this marker instance should use the state passed
615 + // to hydrateRoot.
616 + // TODO: As an optimization, Fizz should only emit these markers if form
617 + // state is passed at the root.
618 + return isFormStateMarkerMatching(markerInstance);
619 + }
620 + }
621 + // Should have found a marker instance. Throw an error to trigger client
622 + // rendering. We don't bother to check if we're in a concurrent root because
623 + // useFormState is a new API, so backwards compat is not an issue.
624 + throwOnHydrationMismatch(fiber);
625 + return false;
626 +}
627 +
628 function prepareToHydrateHostInstance(
629 fiber: Fiber,
630 hostContext: HostContext,
packages/react-reconciler/src/forks/ReactFiberConfig.custom.js
+2
@@ -142,6 +142,8 @@ export const getSuspenseInstanceFallbackErrorDetails =
142 $$$config.getSuspenseInstanceFallbackErrorDetails;
143 export const registerSuspenseInstanceRetry =
144 $$$config.registerSuspenseInstanceRetry;
145 +export const canHydrateFormStateMarker = $$$config.canHydrateFormStateMarker;
146 +export const isFormStateMarkerMatching = $$$config.isFormStateMarkerMatching;
147 export const getNextHydratableSibling = $$$config.getNextHydratableSibling;
148 export const getFirstHydratableChild = $$$config.getFirstHydratableChild;
149 export const getFirstHydratableChildWithinContainer =
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js
+3 -3
@@ -344,7 +344,7 @@ describe('ReactFlightDOMForm', () => {
344 const ssrStream = await ReactDOMServer.renderToReadableStream(response);
345 await readIntoContainer(ssrStream);
346
347 - const form = container.firstChild;
347 + const form = container.getElementsByTagName('form')[0];
348 const span = container.getElementsByTagName('span')[0];
349 expect(span.textContent).toBe('Count: 1');
350
@@ -382,7 +382,7 @@ describe('ReactFlightDOMForm', () => {
382 const ssrStream = await ReactDOMServer.renderToReadableStream(response);
383 await readIntoContainer(ssrStream);
384
385 - const form = container.firstChild;
385 + const form = container.getElementsByTagName('form')[0];
386 const span = container.getElementsByTagName('span')[0];
387 expect(span.textContent).toBe('Count: 1');
388
@@ -423,7 +423,7 @@ describe('ReactFlightDOMForm', () => {
423 const ssrStream = await ReactDOMServer.renderToReadableStream(response);
424 await readIntoContainer(ssrStream);
425
426 - const form = container.firstChild;
426 + const form = container.getElementsByTagName('form')[0];
427 const span = container.getElementsByTagName('span')[0];
428 expect(span.textContent).toBe('Count: 1');
429
packages/react-server/src/ReactFizzHooks.js
+30
@@ -72,6 +72,13 @@ let isReRender: boolean = false;
72 let didScheduleRenderPhaseUpdate: boolean = false;
73 // Counts the number of useId hooks in this component
74 let localIdCounter: number = 0;
75 +// Chunks that should be pushed to the stream once the component
76 +// finishes rendering.
77 +// Counts the number of useFormState calls in this component
78 +let formStateCounter: number = 0;
79 +// The index of the useFormState hook that matches the one passed in at the
80 +// root during an MPA navigation, if any.
81 +let formStateMatchingIndex: number = -1;
82 // Counts the number of use(thenable) calls in this component
83 let thenableIndexCounter: number = 0;
84 let thenableState: ThenableState | null = null;
@@ -208,6 +215,8 @@ export function prepareToUseHooks(
215 // workInProgressHook = null;
216
217 localIdCounter = 0;
218 + formStateCounter = 0;
219 + formStateMatchingIndex = -1;
220 thenableIndexCounter = 0;
221 thenableState = prevThenableState;
222 }
@@ -228,6 +237,8 @@ export function finishHooks(
237 // restarting until no more updates are scheduled.
238 didScheduleRenderPhaseUpdate = false;
239 localIdCounter = 0;
240 + formStateCounter = 0;
241 + formStateMatchingIndex = -1;
242 thenableIndexCounter = 0;
243 numberOfReRenders += 1;
244
@@ -236,6 +247,7 @@ export function finishHooks(
247
248 children = Component(props, refOrContext);
249 }
250 +
251 resetHooksState();
252 return children;
253 }
@@ -254,6 +266,19 @@ export function checkDidRenderIdHook(): boolean {
266 return didRenderIdHook;
267 }
268
269 +export function getFormStateCount(): number {
270 + // This should be called immediately after every finishHooks call.
271 + // Conceptually, it's part of the return value of finishHooks; it's only a
272 + // separate function to avoid using an array tuple.
273 + return formStateCounter;
274 +}
275 +export function getFormStateMatchingIndex(): number {
276 + // This should be called immediately after every finishHooks call.
277 + // Conceptually, it's part of the return value of finishHooks; it's only a
278 + // separate function to avoid using an array tuple.
279 + return formStateMatchingIndex;
280 +}
281 +
282 // Reset the internal hooks state if an error occurs while rendering a component
283 export function resetHooksState(): void {
284 if (__DEV__) {
@@ -559,6 +584,11 @@ function useFormState<S, P>(
584 ): [S, (P) => void] {
585 resolveCurrentlyRenderingComponent();
586
587 + // Count the number of useFormState hooks per component.
588 + // TODO: We should also track which hook matches the form state passed at
589 + // the root, if any. Matching is not yet implemented.
590 + formStateCounter++;
591 +
592 // Bind the initial state to the first argument of the action.
593 // TODO: Use the keypath (or permalink) to check if there's matching state
594 // from the previous page.
packages/react-server/src/ReactFizzServer.js
+79 -32
@@ -76,6 +76,8 @@ import {
76 prepareHostDispatcher,
77 supportsRequestStorage,
78 requestStorage,
79 + pushFormStateMarkerIsMatching,
80 + pushFormStateMarkerIsNotMatching,
81 } from './ReactFizzConfig';
82 import {
83 constructClassInstance,
@@ -104,6 +106,8 @@ import {
106 setCurrentResumableState,
107 getThenableStateAfterSuspending,
108 unwrapThenable,
109 + getFormStateCount,
110 + getFormStateMatchingIndex,
111 } from './ReactFizzHooks';
112 import {DefaultCacheDispatcher} from './ReactFizzCache';
113 import {getStackByComponentStackNode} from './ReactFizzComponentStack';
@@ -1044,6 +1048,8 @@ function renderIndeterminateComponent(
1048 legacyContext,
1049 );
1050 const hasId = checkDidRenderIdHook();
1051 + const formStateCount = getFormStateCount();
1052 + const formStateMatchingIndex = getFormStateMatchingIndex();
1053
1054 if (__DEV__) {
1055 // Support for module components is deprecated and is removed behind a flag.
@@ -1113,26 +1119,72 @@ function renderIndeterminateComponent(
1119 if (__DEV__) {
1120 validateFunctionComponentInDev(Component);
1121 }
1116 - // We're now successfully past this task, and we don't have to pop back to
1117 - // the previous task every again, so we can use the destructive recursive form.
1118 - if (hasId) {
1119 - // This component materialized an id. We treat this as its own level, with
1120 - // a single "child" slot.
1121 - const prevTreeContext = task.treeContext;
1122 - const totalChildren = 1;
1123 - const index = 0;
1124 - // Modify the id context. Because we'll need to reset this if something
1125 - // suspends or errors, we'll use the non-destructive render path.
1126 - task.treeContext = pushTreeContext(prevTreeContext, totalChildren, index);
1127 - renderNode(request, task, value, 0);
1128 - // Like the other contexts, this does not need to be in a finally block
1129 - // because renderNode takes care of unwinding the stack.
1130 - task.treeContext = prevTreeContext;
1122 + finishFunctionComponent(
1123 + request,
1124 + task,
1125 + value,
1126 + hasId,
1127 + formStateCount,
1128 + formStateMatchingIndex,
1129 + );
1130 + }
1131 + popComponentStackInDEV(task);
1132 +}
1133 +
1134 +function finishFunctionComponent(
1135 + request: Request,
1136 + task: Task,
1137 + children: ReactNodeList,
1138 + hasId: boolean,
1139 + formStateCount: number,
1140 + formStateMatchingIndex: number,
1141 +) {
1142 + let didEmitFormStateMarkers = false;
1143 + if (formStateCount !== 0) {
1144 + // For each useFormState hook, emit a marker that indicates whether we
1145 + // rendered using the form state passed at the root.
1146 + // TODO: As an optimization, Fizz should only emit these markers if form
1147 + // state is passed at the root.
1148 + const segment = task.blockedSegment;
1149 + if (segment === null) {
1150 + // Implies we're in reumable mode.
1151 } else {
1132 - renderNodeDestructive(request, task, null, value, 0);
1152 + didEmitFormStateMarkers = true;
1153 + const target = segment.chunks;
1154 + for (let i = 0; i < formStateCount; i++) {
1155 + if (i === formStateMatchingIndex) {
1156 + pushFormStateMarkerIsMatching(target);
1157 + } else {
1158 + pushFormStateMarkerIsNotMatching(target);
1159 + }
1160 + }
1161 }
1162 }
1135 - popComponentStackInDEV(task);
1163 +
1164 + if (hasId) {
1165 + // This component materialized an id. We treat this as its own level, with
1166 + // a single "child" slot.
1167 + const prevTreeContext = task.treeContext;
1168 + const totalChildren = 1;
1169 + const index = 0;
1170 + // Modify the id context. Because we'll need to reset this if something
1171 + // suspends or errors, we'll use the non-destructive render path.
1172 + task.treeContext = pushTreeContext(prevTreeContext, totalChildren, index);
1173 + renderNode(request, task, children, 0);
1174 + // Like the other contexts, this does not need to be in a finally block
1175 + // because renderNode takes care of unwinding the stack.
1176 + task.treeContext = prevTreeContext;
1177 + } else if (didEmitFormStateMarkers) {
1178 + // If there were formState hooks, we must use the non-destructive path
1179 + // because this component is not a pure indirection; we emitted markers
1180 + // to the stream.
1181 + renderNode(request, task, children, 0);
1182 + } else {
1183 + // We're now successfully past this task, and we haven't modified the
1184 + // context stack. We don't have to pop back to the previous task every
1185 + // again, so we can use the destructive recursive form.
1186 + renderNodeDestructive(request, task, null, children, 0);
1187 + }
1188 }
1189
1190 function validateFunctionComponentInDev(Component: any): void {
@@ -1221,21 +1273,16 @@ function renderForwardRef(
1273 ref,
1274 );
1275 const hasId = checkDidRenderIdHook();
1224 - if (hasId) {
1225 - // This component materialized an id. We treat this as its own level, with
1226 - // a single "child" slot.
1227 - const prevTreeContext = task.treeContext;
1228 - const totalChildren = 1;
1229 - const index = 0;
1230 - // Modify the id context. Because we'll need to reset this if something
1231 - // suspends or errors, we'll use the non-destructive render path.
1232 - task.treeContext = pushTreeContext(prevTreeContext, totalChildren, index);
1233 - renderNode(request, task, children, 0);
1234 - // Like the other contexts, this does not need to be in a finally block
1235 - // because renderNode takes care of unwinding the stack.
1236 - } else {
1237 - renderNodeDestructive(request, task, null, children, 0);
1238 - }
1276 + const formStateCount = getFormStateCount();
1277 + const formStateMatchingIndex = getFormStateMatchingIndex();
1278 + finishFunctionComponent(
1279 + request,
1280 + task,
1281 + children,
1282 + hasId,
1283 + formStateCount,
1284 + formStateMatchingIndex,
1285 + );
1286 popComponentStackInDEV(task);
1287 }
1288
packages/react-server/src/forks/ReactFizzConfig.custom.js
+4
@@ -53,6 +53,10 @@ export const pushStartCompletedSuspenseBoundary =
53 export const pushEndCompletedSuspenseBoundary =
54 $$$config.pushEndCompletedSuspenseBoundary;
55 export const pushSegmentFinale = $$$config.pushSegmentFinale;
56 +export const pushFormStateMarkerIsMatching =
57 + $$$config.pushFormStateMarkerIsMatching;
58 +export const pushFormStateMarkerIsNotMatching =
59 + $$$config.pushFormStateMarkerIsNotMatching;
60 export const writeCompletedRoot = $$$config.writeCompletedRoot;
61 export const writePlaceholder = $$$config.writePlaceholder;
62 export const writeStartCompletedSuspenseBoundary =