Skip to content

Commit 7fd3f6d

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 ddff504 commit 7fd3f6d

File tree

10 files changed

+252
-5
lines changed

10 files changed

+252
-5
lines changed

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

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,12 @@ export type TextInstance = Text;
160160
export interface SuspenseInstance extends Comment {
161161
_reactRetry?: () => void;
162162
}
163-
export type HydratableInstance = Instance | TextInstance | SuspenseInstance;
163+
type FormStateMarkerInstance = Comment;
164+
export type HydratableInstance =
165+
| Instance
166+
| TextInstance
167+
| SuspenseInstance
168+
| FormStateMarkerInstance;
164169
export type PublicInstance = Element | Text;
165170
export type HostContextDev = {
166171
context: HostContextProd,
@@ -187,6 +192,8 @@ const SUSPENSE_START_DATA = '$';
187192
const SUSPENSE_END_DATA = '/$';
188193
const SUSPENSE_PENDING_START_DATA = '$?';
189194
const SUSPENSE_FALLBACK_START_DATA = '$!';
195+
const FORM_STATE_IS_MATCHING = '!F';
196+
const FORM_STATE_IS_NOT_MATCHING = 'F';
190197

191198
const STYLE = 'style';
192199

@@ -1283,6 +1290,26 @@ export function registerSuspenseInstanceRetry(
12831290
instance._reactRetry = callback;
12841291
}
12851292

1293+
export function canHydrateFormStateMarker(
1294+
instance: HydratableInstance,
1295+
): null | FormStateMarkerInstance {
1296+
const nodeData = (instance: any).data;
1297+
if (
1298+
nodeData === FORM_STATE_IS_MATCHING ||
1299+
nodeData === FORM_STATE_IS_NOT_MATCHING
1300+
) {
1301+
const markerInstance: FormStateMarkerInstance = (instance: any);
1302+
return markerInstance;
1303+
}
1304+
return null;
1305+
}
1306+
1307+
export function isFormStateMarkerMatching(
1308+
markerInstance: FormStateMarkerInstance,
1309+
): boolean {
1310+
return markerInstance.data === FORM_STATE_IS_MATCHING;
1311+
}
1312+
12861313
function getNextHydratable(node: ?Node) {
12871314
// Skip non-hydratable nodes.
12881315
for (; node != null; node = ((node: any): Node).nextSibling) {
@@ -1295,7 +1322,9 @@ function getNextHydratable(node: ?Node) {
12951322
if (
12961323
nodeData === SUSPENSE_START_DATA ||
12971324
nodeData === SUSPENSE_FALLBACK_START_DATA ||
1298-
nodeData === SUSPENSE_PENDING_START_DATA
1325+
nodeData === SUSPENSE_PENDING_START_DATA ||
1326+
nodeData === FORM_STATE_IS_MATCHING ||
1327+
nodeData === FORM_STATE_IS_NOT_MATCHING
12991328
) {
13001329
break;
13011330
}

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
@@ -28,6 +28,7 @@ let SuspenseList;
2828
let useSyncExternalStore;
2929
let useSyncExternalStoreWithSelector;
3030
let use;
31+
let useFormState;
3132
let PropTypes;
3233
let textCache;
3334
let writable;
@@ -83,6 +84,7 @@ describe('ReactDOMFizzServer', () => {
8384
if (gate(flags => flags.enableSuspenseList)) {
8485
SuspenseList = React.unstable_SuspenseList;
8586
}
87+
useFormState = ReactDOM.experimental_useFormState;
8688

8789
PropTypes = require('prop-types');
8890

@@ -5911,6 +5913,123 @@ describe('ReactDOMFizzServer', () => {
59115913
expect(getVisibleChildren(container)).toEqual('Hi');
59125914
});
59135915

5916+
// @gate enableFormActions
5917+
// @gate enableAsyncActions
5918+
it('useFormState hydrates without a mismatch', async () => {
5919+
// This is testing an implementation detail: useFormState emits comment
5920+
// nodes into the SSR stream, so this checks that they are handled correctly
5921+
// during hydration.
5922+
5923+
async function action(state) {
5924+
return state;
5925+
}
5926+
5927+
const childRef = React.createRef(null);
5928+
function Form() {
5929+
const [state] = useFormState(action, 0);
5930+
const text = `Child: ${state}`;
5931+
return (
5932+
<div id="child" ref={childRef}>
5933+
{text}
5934+
</div>
5935+
);
5936+
}
5937+
5938+
function App() {
5939+
return (
5940+
<div>
5941+
<div>
5942+
<Form />
5943+
</div>
5944+
<span>Sibling</span>
5945+
</div>
5946+
);
5947+
}
5948+
5949+
await act(() => {
5950+
const {pipe} = renderToPipeableStream(<App />);
5951+
pipe(writable);
5952+
});
5953+
expect(getVisibleChildren(container)).toEqual(
5954+
<div>
5955+
<div>
5956+
<div id="child">Child: 0</div>
5957+
</div>
5958+
<span>Sibling</span>
5959+
</div>,
5960+
);
5961+
const child = document.getElementById('child');
5962+
5963+
// Confirm that it hydrates correctly
5964+
await clientAct(() => {
5965+
ReactDOMClient.hydrateRoot(container, <App />);
5966+
});
5967+
expect(childRef.current).toBe(child);
5968+
});
5969+
5970+
// @gate enableFormActions
5971+
// @gate enableAsyncActions
5972+
it("useFormState hydrates without a mismatch if there's a render phase update", async () => {
5973+
async function action(state) {
5974+
return state;
5975+
}
5976+
5977+
const childRef = React.createRef(null);
5978+
function Form() {
5979+
const [localState, setLocalState] = React.useState(0);
5980+
if (localState < 3) {
5981+
setLocalState(localState + 1);
5982+
}
5983+
5984+
// Because of the render phase update above, this component is evaluated
5985+
// multiple times (even during SSR), but it should only emit a single
5986+
// marker per useFormState instance.
5987+
const [formState] = useFormState(action, 0);
5988+
const text = `${readText('Child')}:${formState}:${localState}`;
5989+
return (
5990+
<div id="child" ref={childRef}>
5991+
{text}
5992+
</div>
5993+
);
5994+
}
5995+
5996+
function App() {
5997+
return (
5998+
<div>
5999+
<Suspense fallback="Loading...">
6000+
<Form />
6001+
</Suspense>
6002+
<span>Sibling</span>
6003+
</div>
6004+
);
6005+
}
6006+
6007+
await act(() => {
6008+
const {pipe} = renderToPipeableStream(<App />);
6009+
pipe(writable);
6010+
});
6011+
expect(getVisibleChildren(container)).toEqual(
6012+
<div>
6013+
Loading...<span>Sibling</span>
6014+
</div>,
6015+
);
6016+
6017+
await act(() => resolveText('Child'));
6018+
expect(getVisibleChildren(container)).toEqual(
6019+
<div>
6020+
<div id="child">Child:0:3</div>
6021+
<span>Sibling</span>
6022+
</div>,
6023+
);
6024+
const child = document.getElementById('child');
6025+
6026+
// Confirm that it hydrates correctly
6027+
await clientAct(() => {
6028+
ReactDOMClient.hydrateRoot(container, <App />);
6029+
});
6030+
expect(childRef.current).toBe(child);
6031+
});
6032+
59146033
describe('useEffectEvent', () => {
59156034
// @gate enableUseEffectEventHook
59166035
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 =

0 commit comments

Comments
 (0)