Skip to content

Commit 8b26f07

Browse files
authored
useFormState: Emit comment to mark whether state matches (facebook#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.
1 parent 3566de5 commit 8b26f07

File tree

12 files changed

+343
-39
lines changed

12 files changed

+343
-39
lines changed

packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js

+45-2
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ import {
9898
enableTrustedTypesIntegration,
9999
diffInCommitPhase,
100100
enableFormActions,
101+
enableAsyncActions,
101102
} from 'shared/ReactFeatureFlags';
102103
import {
103104
HostComponent,
@@ -160,7 +161,12 @@ export type TextInstance = Text;
160161
export interface SuspenseInstance extends Comment {
161162
_reactRetry?: () => void;
162163
}
163-
export type HydratableInstance = Instance | TextInstance | SuspenseInstance;
164+
type FormStateMarkerInstance = Comment;
165+
export type HydratableInstance =
166+
| Instance
167+
| TextInstance
168+
| SuspenseInstance
169+
| FormStateMarkerInstance;
164170
export type PublicInstance = Element | Text;
165171
export type HostContextDev = {
166172
context: HostContextProd,
@@ -187,6 +193,8 @@ const SUSPENSE_START_DATA = '$';
187193
const SUSPENSE_END_DATA = '/$';
188194
const SUSPENSE_PENDING_START_DATA = '$?';
189195
const SUSPENSE_FALLBACK_START_DATA = '$!';
196+
const FORM_STATE_IS_MATCHING = 'F!';
197+
const FORM_STATE_IS_NOT_MATCHING = 'F';
190198

191199
const STYLE = 'style';
192200

@@ -1283,6 +1291,37 @@ export function registerSuspenseInstanceRetry(
12831291
instance._reactRetry = callback;
12841292
}
12851293

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+
12861325
function getNextHydratable(node: ?Node) {
12871326
// Skip non-hydratable nodes.
12881327
for (; node != null; node = ((node: any): Node).nextSibling) {
@@ -1295,7 +1334,11 @@ function getNextHydratable(node: ?Node) {
12951334
if (
12961335
nodeData === SUSPENSE_START_DATA ||
12971336
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))
12991342
) {
13001343
break;
13011344
}

packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js

+15
Original file line numberDiff line numberDiff line change
@@ -1519,6 +1519,21 @@ function injectFormReplayingRuntime(
15191519
}
15201520
}
15211521

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+
15221537
function pushStartForm(
15231538
target: Array<Chunk | PrecomputedChunk>,
15241539
props: Object,

packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js

+2
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,8 @@ export {
101101
pushEndInstance,
102102
pushStartCompletedSuspenseBoundary,
103103
pushEndCompletedSuspenseBoundary,
104+
pushFormStateMarkerIsMatching,
105+
pushFormStateMarkerIsNotMatching,
104106
writeStartSegment,
105107
writeEndSegment,
106108
writeCompletedSegmentInstruction,

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

+119
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ let SuspenseList;
3030
let useSyncExternalStore;
3131
let useSyncExternalStoreWithSelector;
3232
let use;
33+
let useFormState;
3334
let PropTypes;
3435
let textCache;
3536
let writable;
@@ -88,6 +89,7 @@ describe('ReactDOMFizzServer', () => {
8889
if (gate(flags => flags.enableSuspenseList)) {
8990
SuspenseList = React.unstable_SuspenseList;
9091
}
92+
useFormState = ReactDOM.experimental_useFormState;
9193

9294
PropTypes = require('prop-types');
9395

@@ -5876,6 +5878,123 @@ describe('ReactDOMFizzServer', () => {
58765878
expect(getVisibleChildren(container)).toEqual('Hi');
58775879
});
58785880

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+
58795998
describe('useEffectEvent', () => {
58805999
// @gate enableUseEffectEventHook
58816000
it('can server render a component with useEffectEvent', async () => {

packages/react-reconciler/src/ReactFiberConfigWithNoHydration.js

+2
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ export const isSuspenseInstancePending = shim;
2626
export const isSuspenseInstanceFallback = shim;
2727
export const getSuspenseInstanceFallbackErrorDetails = shim;
2828
export const registerSuspenseInstanceRetry = shim;
29+
export const canHydrateFormStateMarker = shim;
30+
export const isFormStateMarkerMatching = shim;
2931
export const getNextHydratableSibling = shim;
3032
export const getFirstHydratableChild = shim;
3133
export const getFirstHydratableChildWithinContainer = shim;

packages/react-reconciler/src/ReactFiberHooks.js

+12-2
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,10 @@ import {
111111
markWorkInProgressReceivedUpdate,
112112
checkIfWorkInProgressReceivedUpdate,
113113
} from './ReactFiberBeginWork';
114-
import {getIsHydrating} from './ReactFiberHydrationContext';
114+
import {
115+
getIsHydrating,
116+
tryToClaimNextHydratableFormMarkerInstance,
117+
} from './ReactFiberHydrationContext';
115118
import {logStateUpdateScheduled} from './DebugTracing';
116119
import {
117120
markStateUpdateScheduled,
@@ -2010,6 +2013,12 @@ function mountFormState<S, P>(
20102013
initialState: S,
20112014
permalink?: string,
20122015
): [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+
20132022
// State hook. The state is stored in a thenable which is then unwrapped by
20142023
// the `use` algorithm during render.
20152024
const stateHook = mountWorkInProgressHook();
@@ -2145,7 +2154,8 @@ function rerenderFormState<S, P>(
21452154
}
21462155

21472156
// 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);
21492159

21502160
const actionQueueHook = updateWorkInProgressHook();
21512161
const actionQueue = actionQueueHook.queue;

packages/react-reconciler/src/ReactFiberHydrationContext.js

+30
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ import {
7676
canHydrateInstance,
7777
canHydrateTextInstance,
7878
canHydrateSuspenseInstance,
79+
canHydrateFormStateMarker,
80+
isFormStateMarkerMatching,
7981
isHydratableText,
8082
} from './ReactFiberConfig';
8183
import {OffscreenLane} from './ReactFiberLane';
@@ -595,6 +597,34 @@ function tryToClaimNextHydratableSuspenseInstance(fiber: Fiber): void {
595597
}
596598
}
597599

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+
598628
function prepareToHydrateHostInstance(
599629
fiber: Fiber,
600630
hostContext: HostContext,

packages/react-reconciler/src/forks/ReactFiberConfig.custom.js

+2
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,8 @@ export const getSuspenseInstanceFallbackErrorDetails =
142142
$$$config.getSuspenseInstanceFallbackErrorDetails;
143143
export const registerSuspenseInstanceRetry =
144144
$$$config.registerSuspenseInstanceRetry;
145+
export const canHydrateFormStateMarker = $$$config.canHydrateFormStateMarker;
146+
export const isFormStateMarkerMatching = $$$config.isFormStateMarkerMatching;
145147
export const getNextHydratableSibling = $$$config.getNextHydratableSibling;
146148
export const getFirstHydratableChild = $$$config.getFirstHydratableChild;
147149
export const getFirstHydratableChildWithinContainer =

packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js

+3-3
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,7 @@ describe('ReactFlightDOMForm', () => {
344344
const ssrStream = await ReactDOMServer.renderToReadableStream(response);
345345
await readIntoContainer(ssrStream);
346346

347-
const form = container.firstChild;
347+
const form = container.getElementsByTagName('form')[0];
348348
const span = container.getElementsByTagName('span')[0];
349349
expect(span.textContent).toBe('Count: 1');
350350

@@ -382,7 +382,7 @@ describe('ReactFlightDOMForm', () => {
382382
const ssrStream = await ReactDOMServer.renderToReadableStream(response);
383383
await readIntoContainer(ssrStream);
384384

385-
const form = container.firstChild;
385+
const form = container.getElementsByTagName('form')[0];
386386
const span = container.getElementsByTagName('span')[0];
387387
expect(span.textContent).toBe('Count: 1');
388388

@@ -423,7 +423,7 @@ describe('ReactFlightDOMForm', () => {
423423
const ssrStream = await ReactDOMServer.renderToReadableStream(response);
424424
await readIntoContainer(ssrStream);
425425

426-
const form = container.firstChild;
426+
const form = container.getElementsByTagName('form')[0];
427427
const span = container.getElementsByTagName('span')[0];
428428
expect(span.textContent).toBe('Count: 1');
429429

0 commit comments

Comments
 (0)