Skip to content

Commit

Permalink
useFormState: Emit comment to mark whether state matches (#27307)
Browse files Browse the repository at this point in the history
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.

DiffTrain build for [8b26f07](8b26f07)
  • Loading branch information
acdlite committed Sep 7, 2023
1 parent 089c36c commit 88778ab
Show file tree
Hide file tree
Showing 13 changed files with 545 additions and 162 deletions.
2 changes: 1 addition & 1 deletion compiled/facebook-www/REVISION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3566de59e2046e7e8478462375aaa71716f1095b
8b26f07a883bb341c20283c0099bf5ee6f87bd1f
6 changes: 4 additions & 2 deletions compiled/facebook-www/ReactDOM-dev.classic.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ var enableHostSingletons = true;
var enableClientRenderFallbackOnTextMismatch = false;

var enableSchedulingProfiler = dynamicFeatureFlags.enableSchedulingProfiler; // Note: we'll want to remove this when we to userland implementation.
var enableFormActions = false;
var enableSuspenseCallback = true;

var FunctionComponent = 0;
Expand Down Expand Up @@ -34008,7 +34009,7 @@ function createFiberRoot(
return root;
}

var ReactVersion = "18.3.0-www-classic-4045cb9c";
var ReactVersion = "18.3.0-www-classic-5b9b66e9";

function createPortal$1(
children,
Expand Down Expand Up @@ -42938,7 +42939,8 @@ function getNextHydratable(node) {
if (
nodeData === SUSPENSE_START_DATA ||
nodeData === SUSPENSE_FALLBACK_START_DATA ||
nodeData === SUSPENSE_PENDING_START_DATA
nodeData === SUSPENSE_PENDING_START_DATA ||
enableFormActions
) {
break;
}
Expand Down
6 changes: 4 additions & 2 deletions compiled/facebook-www/ReactDOM-dev.modern.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ var enableHostSingletons = true;
var enableClientRenderFallbackOnTextMismatch = false;

var enableSchedulingProfiler = dynamicFeatureFlags.enableSchedulingProfiler; // Note: we'll want to remove this when we to userland implementation.
var enableFormActions = false;
var enableSuspenseCallback = true;

var ReactSharedInternals =
Expand Down Expand Up @@ -33853,7 +33854,7 @@ function createFiberRoot(
return root;
}

var ReactVersion = "18.3.0-www-modern-73585f43";
var ReactVersion = "18.3.0-www-modern-dd5c91c5";

function createPortal$1(
children,
Expand Down Expand Up @@ -43448,7 +43449,8 @@ function getNextHydratable(node) {
if (
nodeData === SUSPENSE_START_DATA ||
nodeData === SUSPENSE_FALLBACK_START_DATA ||
nodeData === SUSPENSE_PENDING_START_DATA
nodeData === SUSPENSE_PENDING_START_DATA ||
enableFormActions
) {
break;
}
Expand Down
155 changes: 118 additions & 37 deletions compiled/facebook-www/ReactDOMServer-dev.classic.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ if (__DEV__) {
var React = require("react");
var ReactDOM = require("react-dom");

var ReactVersion = "18.3.0-www-classic-605a9ce6";
var ReactVersion = "18.3.0-www-classic-de28fc21";

// This refers to a WWW module.
var warningWWW = require("warning");
Expand Down Expand Up @@ -3153,6 +3153,15 @@ function pushStartOption(target, props, formatContext) {
return children;
}

var formStateMarkerIsMatching = stringToPrecomputedChunk("<!--F!-->");
var formStateMarkerIsNotMatching = stringToPrecomputedChunk("<!--F-->");
function pushFormStateMarkerIsMatching(target) {
target.push(formStateMarkerIsMatching);
}
function pushFormStateMarkerIsNotMatching(target) {
target.push(formStateMarkerIsNotMatching);
}

function pushStartForm(target, props, resumableState, renderState) {
target.push(startChunkForTag("form"));
var children = null;
Expand Down Expand Up @@ -9152,7 +9161,14 @@ var isReRender = false; // Whether an update was scheduled during the currently

var didScheduleRenderPhaseUpdate = false; // Counts the number of useId hooks in this component

var localIdCounter = 0; // Counts the number of use(thenable) calls in this component
var localIdCounter = 0; // Chunks that should be pushed to the stream once the component
// finishes rendering.
// Counts the number of useFormState calls in this component

var formStateCounter = 0; // The index of the useFormState hook that matches the one passed in at the
// root during an MPA navigation, if any.

var formStateMatchingIndex = -1; // Counts the number of use(thenable) calls in this component

var thenableIndexCounter = 0;
var thenableState = null; // Lazily created map of render-phase updates
Expand Down Expand Up @@ -9285,6 +9301,8 @@ function prepareToUseHooks(task, componentIdentity, prevThenableState) {
// workInProgressHook = null;

localIdCounter = 0;
formStateCounter = 0;
formStateMatchingIndex = -1;
thenableIndexCounter = 0;
thenableState = prevThenableState;
}
Expand All @@ -9298,6 +9316,8 @@ function finishHooks(Component, props, children, refOrContext) {
// restarting until no more updates are scheduled.
didScheduleRenderPhaseUpdate = false;
localIdCounter = 0;
formStateCounter = 0;
formStateMatchingIndex = -1;
thenableIndexCounter = 0;
numberOfReRenders += 1; // Start over from the beginning of the list

Expand All @@ -9319,6 +9339,18 @@ function checkDidRenderIdHook() {
// separate function to avoid using an array tuple.
var didRenderIdHook = localIdCounter !== 0;
return didRenderIdHook;
}
function getFormStateCount() {
// This should be called immediately after every finishHooks call.
// Conceptually, it's part of the return value of finishHooks; it's only a
// separate function to avoid using an array tuple.
return formStateCounter;
}
function getFormStateMatchingIndex() {
// This should be called immediately after every finishHooks call.
// Conceptually, it's part of the return value of finishHooks; it's only a
// separate function to avoid using an array tuple.
return formStateMatchingIndex;
} // Reset the internal hooks state if an error occurs while rendering a component

function resetHooksState() {
Expand Down Expand Up @@ -9608,7 +9640,11 @@ function useOptimistic(passthrough, reducer) {
}

function useFormState(action, initialState, permalink) {
resolveCurrentlyRenderingComponent(); // Bind the initial state to the first argument of the action.
resolveCurrentlyRenderingComponent(); // Count the number of useFormState hooks per component.
// TODO: We should also track which hook matches the form state passed at
// the root, if any. Matching is not yet implemented.

formStateCounter++; // Bind the initial state to the first argument of the action.
// TODO: Use the keypath (or permalink) to check if there's matching state
// from the previous page.

Expand Down Expand Up @@ -10400,6 +10436,8 @@ function renderIndeterminateComponent(
legacyContext
);
var hasId = checkDidRenderIdHook();
var formStateCount = getFormStateCount();
var formStateMatchingIndex = getFormStateMatchingIndex();

{
// Support for module components is deprecated and is removed behind a flag.
Expand Down Expand Up @@ -10432,30 +10470,79 @@ function renderIndeterminateComponent(
{
{
validateFunctionComponentInDev(Component);
} // We're now successfully past this task, and we don't have to pop back to
// the previous task every again, so we can use the destructive recursive form.

if (hasId) {
// This component materialized an id. We treat this as its own level, with
// a single "child" slot.
var prevTreeContext = task.treeContext;
var totalChildren = 1;
var index = 0; // Modify the id context. Because we'll need to reset this if something
// suspends or errors, we'll use the non-destructive render path.

task.treeContext = pushTreeContext(prevTreeContext, totalChildren, index);
renderNode(request, task, value, 0); // Like the other contexts, this does not need to be in a finally block
// because renderNode takes care of unwinding the stack.

task.treeContext = prevTreeContext;
} else {
renderNodeDestructive(request, task, null, value, 0);
}

finishFunctionComponent(
request,
task,
value,
hasId,
formStateCount,
formStateMatchingIndex
);
}

popComponentStackInDEV(task);
}

function finishFunctionComponent(
request,
task,
children,
hasId,
formStateCount,
formStateMatchingIndex
) {
var didEmitFormStateMarkers = false;

if (formStateCount !== 0) {
// For each useFormState hook, emit a marker that indicates whether we
// rendered using the form state passed at the root.
// TODO: As an optimization, Fizz should only emit these markers if form
// state is passed at the root.
var segment = task.blockedSegment;

if (segment === null);
else {
didEmitFormStateMarkers = true;
var target = segment.chunks;

for (var i = 0; i < formStateCount; i++) {
if (i === formStateMatchingIndex) {
pushFormStateMarkerIsMatching(target);
} else {
pushFormStateMarkerIsNotMatching(target);
}
}
}
}

if (hasId) {
// This component materialized an id. We treat this as its own level, with
// a single "child" slot.
var prevTreeContext = task.treeContext;
var totalChildren = 1;
var index = 0; // Modify the id context. Because we'll need to reset this if something
// suspends or errors, we'll use the non-destructive render path.

task.treeContext = pushTreeContext(prevTreeContext, totalChildren, index);
renderNode(request, task, children, 0); // Like the other contexts, this does not need to be in a finally block
// because renderNode takes care of unwinding the stack.

task.treeContext = prevTreeContext;
} else if (didEmitFormStateMarkers) {
// If there were formState hooks, we must use the non-destructive path
// because this component is not a pure indirection; we emitted markers
// to the stream.
renderNode(request, task, children, 0);
} else {
// We're now successfully past this task, and we haven't modified the
// context stack. We don't have to pop back to the previous task every
// again, so we can use the destructive recursive form.
renderNodeDestructive(request, task, null, children, 0);
}
}

function validateFunctionComponentInDev(Component) {
{
if (Component) {
Expand Down Expand Up @@ -10541,22 +10628,16 @@ function renderForwardRef(request, task, prevThenableState, type, props, ref) {
ref
);
var hasId = checkDidRenderIdHook();

if (hasId) {
// This component materialized an id. We treat this as its own level, with
// a single "child" slot.
var prevTreeContext = task.treeContext;
var totalChildren = 1;
var index = 0; // Modify the id context. Because we'll need to reset this if something
// suspends or errors, we'll use the non-destructive render path.

task.treeContext = pushTreeContext(prevTreeContext, totalChildren, index);
renderNode(request, task, children, 0); // Like the other contexts, this does not need to be in a finally block
// because renderNode takes care of unwinding the stack.
} else {
renderNodeDestructive(request, task, null, children, 0);
}

var formStateCount = getFormStateCount();
var formStateMatchingIndex = getFormStateMatchingIndex();
finishFunctionComponent(
request,
task,
children,
hasId,
formStateCount,
formStateMatchingIndex
);
popComponentStackInDEV(task);
}

Expand Down
Loading

0 comments on commit 88778ab

Please sign in to comment.