Skip to content

Commit feb5892

Browse files
committed
useFormState: Emit comment to mark whether state matches
A planned feature of useFormState is that if 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 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 d0d6924 commit feb5892

File tree

12 files changed

+329
-39
lines changed

12 files changed

+329
-39
lines changed

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

Lines changed: 34 additions & 2 deletions
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,26 @@ export function registerSuspenseInstanceRetry(
12831291
instance._reactRetry = callback;
12841292
}
12851293

1294+
export function canHydrateFormStateMarker(
1295+
instance: HydratableInstance,
1296+
): null | FormStateMarkerInstance {
1297+
const nodeData = (instance: any).data;
1298+
if (
1299+
nodeData === FORM_STATE_IS_MATCHING ||
1300+
nodeData === FORM_STATE_IS_NOT_MATCHING
1301+
) {
1302+
const markerInstance: FormStateMarkerInstance = (instance: any);
1303+
return markerInstance;
1304+
}
1305+
return null;
1306+
}
1307+
1308+
export function isFormStateMarkerMatching(
1309+
markerInstance: FormStateMarkerInstance,
1310+
): boolean {
1311+
return markerInstance.data === FORM_STATE_IS_MATCHING;
1312+
}
1313+
12861314
function getNextHydratable(node: ?Node) {
12871315
// Skip non-hydratable nodes.
12881316
for (; node != null; node = ((node: any): Node).nextSibling) {
@@ -1295,7 +1323,11 @@ function getNextHydratable(node: ?Node) {
12951323
if (
12961324
nodeData === SUSPENSE_START_DATA ||
12971325
nodeData === SUSPENSE_FALLBACK_START_DATA ||
1298-
nodeData === SUSPENSE_PENDING_START_DATA
1326+
nodeData === SUSPENSE_PENDING_START_DATA ||
1327+
(enableFormActions &&
1328+
enableAsyncActions &&
1329+
(nodeData === FORM_STATE_IS_MATCHING ||
1330+
nodeData === FORM_STATE_IS_NOT_MATCHING))
12991331
) {
13001332
break;
13011333
}

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1525,6 +1525,21 @@ function injectFormReplayingRuntime(
15251525
}
15261526
}
15271527

1528+
const formStateMarkerIsMatching = stringToPrecomputedChunk('<!--F!-->');
1529+
const formStateMarkerIsNotMatching = stringToPrecomputedChunk('<!--F-->');
1530+
1531+
export function pushFormStateMarkerIsMatching(
1532+
target: Array<Chunk | PrecomputedChunk>,
1533+
) {
1534+
target.push(formStateMarkerIsMatching);
1535+
}
1536+
1537+
export function pushFormStateMarkerIsNotMatching(
1538+
target: Array<Chunk | PrecomputedChunk>,
1539+
) {
1540+
target.push(formStateMarkerIsNotMatching);
1541+
}
1542+
15281543
function pushStartForm(
15291544
target: Array<Chunk | PrecomputedChunk>,
15301545
props: Object,

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

Lines changed: 2 additions & 0 deletions
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

Lines changed: 119 additions & 0 deletions
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

Lines changed: 2 additions & 0 deletions
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

Lines changed: 12 additions & 2 deletions
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

Lines changed: 27 additions & 0 deletions
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,31 @@ 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(nextHydratableInstance);
608+
if (markerInstance) {
609+
// Found the marker instance.
610+
nextHydratableInstance = getNextHydratableSibling(markerInstance);
611+
// Return true if this marker instance should use the state passed
612+
// to hydrateRoot.
613+
// TODO: As an optimization, Fizz should only emit these markers if form
614+
// state is passed at the root.
615+
return isFormStateMarkerMatching(markerInstance);
616+
}
617+
}
618+
// Should have found a marker instance. Throw an error to trigger client
619+
// rendering. We don't bother to check if we're in a concurrent root because
620+
// useFormState is a new API, so backwards compat is not an issue.
621+
throwOnHydrationMismatch(fiber);
622+
return false;
623+
}
624+
598625
function prepareToHydrateHostInstance(
599626
fiber: Fiber,
600627
hostContext: HostContext,

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

Lines changed: 2 additions & 0 deletions
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

Lines changed: 3 additions & 3 deletions
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)