diff --git a/packages/react-client/src/ReactFlightReplyClient.js b/packages/react-client/src/ReactFlightReplyClient.js index bedf5a90496f0..96ebd193b162c 100644 --- a/packages/react-client/src/ReactFlightReplyClient.js +++ b/packages/react-client/src/ReactFlightReplyClient.js @@ -7,7 +7,12 @@ * @flow */ -import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes'; +import type { + Thenable, + FulfilledThenable, + RejectedThenable, + ReactCustomFormAction, +} from 'shared/ReactTypes'; import { REACT_ELEMENT_TYPE, @@ -23,10 +28,6 @@ import { } from 'shared/ReactSerializationErrors'; import isArray from 'shared/isArray'; -import type { - FulfilledThenable, - RejectedThenable, -} from '../../shared/ReactTypes'; import {usedWithSSR} from './ReactFlightClientConfig'; diff --git a/packages/react-dom/src/client/ReactDOMLegacy.js b/packages/react-dom/src/client/ReactDOMLegacy.js index b8b1fc43239a9..ce0c9ff609ebd 100644 --- a/packages/react-dom/src/client/ReactDOMLegacy.js +++ b/packages/react-dom/src/client/ReactDOMLegacy.js @@ -142,6 +142,7 @@ function legacyCreateRootFromDOMContainer( noopOnRecoverableError, // TODO(luna) Support hydration later null, + null, ); container._reactRootContainer = root; markContainerAsRoot(root.current, container); diff --git a/packages/react-dom/src/client/ReactDOMRoot.js b/packages/react-dom/src/client/ReactDOMRoot.js index b55b09eb2124d..55bbc6627922c 100644 --- a/packages/react-dom/src/client/ReactDOMRoot.js +++ b/packages/react-dom/src/client/ReactDOMRoot.js @@ -7,7 +7,7 @@ * @flow */ -import type {ReactNodeList} from 'shared/ReactTypes'; +import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes'; import type { FiberRoot, TransitionTracingCallbacks, @@ -21,6 +21,8 @@ import { enableHostSingletons, allowConcurrentByDefault, disableCommentsAsDOMContainers, + enableAsyncActions, + enableFormActions, } from 'shared/ReactFeatureFlags'; import ReactDOMSharedInternals from '../ReactDOMSharedInternals'; @@ -55,6 +57,7 @@ export type HydrateRootOptions = { unstable_transitionCallbacks?: TransitionTracingCallbacks, identifierPrefix?: string, onRecoverableError?: (error: mixed) => void, + experimental_formState?: ReactFormState | null, ... }; @@ -302,6 +305,7 @@ export function hydrateRoot( let identifierPrefix = ''; let onRecoverableError = defaultOnRecoverableError; let transitionCallbacks = null; + let formState = null; if (options !== null && options !== undefined) { if (options.unstable_strictMode === true) { isStrictMode = true; @@ -321,6 +325,11 @@ export function hydrateRoot( if (options.unstable_transitionCallbacks !== undefined) { transitionCallbacks = options.unstable_transitionCallbacks; } + if (enableAsyncActions && enableFormActions) { + if (options.experimental_formState !== undefined) { + formState = options.experimental_formState; + } + } } const root = createHydrationContainer( @@ -334,6 +343,7 @@ export function hydrateRoot( identifierPrefix, onRecoverableError, transitionCallbacks, + formState, ); markContainerAsRoot(root.current, container); Dispatcher.current = ReactDOMClientDispatcher; diff --git a/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js b/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js index 71631f9573086..aea256e2c6243 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js @@ -8,7 +8,7 @@ */ import type {PostponedState} from 'react-server/src/ReactFizzServer'; -import type {ReactNodeList} from 'shared/ReactTypes'; +import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes'; import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; import type {ImportMap} from '../shared/ReactDOMTypes'; @@ -40,6 +40,7 @@ type Options = { onPostpone?: (reason: string) => void, unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor, importMap?: ImportMap, + experimental_formState?: ReactFormState | null, }; type ResumeOptions = { @@ -48,6 +49,7 @@ type ResumeOptions = { onError?: (error: mixed) => ?string, onPostpone?: (reason: string) => void, unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor, + experimental_formState?: ReactFormState | null, }; // TODO: Move to sub-classing ReadableStream. @@ -116,6 +118,7 @@ function renderToReadableStream( onShellError, onFatalError, options ? options.onPostpone : undefined, + options ? options.experimental_formState : undefined, ); if (options && options.signal) { const signal = options.signal; @@ -187,6 +190,7 @@ function resume( onShellError, onFatalError, options ? options.onPostpone : undefined, + options ? options.experimental_formState : undefined, ); if (options && options.signal) { const signal = options.signal; diff --git a/packages/react-dom/src/server/ReactDOMFizzServerBun.js b/packages/react-dom/src/server/ReactDOMFizzServerBun.js index 4464b95551271..21919ee742d54 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerBun.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerBun.js @@ -7,7 +7,7 @@ * @flow */ -import type {ReactNodeList} from 'shared/ReactTypes'; +import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes'; import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; import type {ImportMap} from '../shared/ReactDOMTypes'; @@ -39,6 +39,7 @@ type Options = { onPostpone?: (reason: string) => void, unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor, importMap?: ImportMap, + experimental_formState?: ReactFormState | null, }; // TODO: Move to sub-classing ReadableStream. @@ -108,6 +109,7 @@ function renderToReadableStream( onShellError, onFatalError, options ? options.onPostpone : undefined, + options ? options.experimental_formState : undefined, ); if (options && options.signal) { const signal = options.signal; diff --git a/packages/react-dom/src/server/ReactDOMFizzServerEdge.js b/packages/react-dom/src/server/ReactDOMFizzServerEdge.js index 71631f9573086..aea256e2c6243 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerEdge.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerEdge.js @@ -8,7 +8,7 @@ */ import type {PostponedState} from 'react-server/src/ReactFizzServer'; -import type {ReactNodeList} from 'shared/ReactTypes'; +import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes'; import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; import type {ImportMap} from '../shared/ReactDOMTypes'; @@ -40,6 +40,7 @@ type Options = { onPostpone?: (reason: string) => void, unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor, importMap?: ImportMap, + experimental_formState?: ReactFormState | null, }; type ResumeOptions = { @@ -48,6 +49,7 @@ type ResumeOptions = { onError?: (error: mixed) => ?string, onPostpone?: (reason: string) => void, unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor, + experimental_formState?: ReactFormState | null, }; // TODO: Move to sub-classing ReadableStream. @@ -116,6 +118,7 @@ function renderToReadableStream( onShellError, onFatalError, options ? options.onPostpone : undefined, + options ? options.experimental_formState : undefined, ); if (options && options.signal) { const signal = options.signal; @@ -187,6 +190,7 @@ function resume( onShellError, onFatalError, options ? options.onPostpone : undefined, + options ? options.experimental_formState : undefined, ); if (options && options.signal) { const signal = options.signal; diff --git a/packages/react-dom/src/server/ReactDOMFizzServerNode.js b/packages/react-dom/src/server/ReactDOMFizzServerNode.js index c948d4f3996d8..265d76dfc24d6 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerNode.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerNode.js @@ -8,7 +8,7 @@ */ import type {Request, PostponedState} from 'react-server/src/ReactFizzServer'; -import type {ReactNodeList} from 'shared/ReactTypes'; +import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes'; import type {Writable} from 'stream'; import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; import type {Destination} from 'react-server/src/ReactServerStreamConfigNode'; @@ -53,6 +53,7 @@ type Options = { onPostpone?: (reason: string) => void, unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor, importMap?: ImportMap, + experimental_formState?: ReactFormState | null, }; type ResumeOptions = { @@ -62,6 +63,7 @@ type ResumeOptions = { onAllReady?: () => void, onError?: (error: mixed) => ?string, onPostpone?: (reason: string) => void, + experimental_formState?: ReactFormState | null, }; type PipeableStream = { @@ -96,6 +98,7 @@ function createRequestImpl(children: ReactNodeList, options: void | Options) { options ? options.onShellError : undefined, undefined, options ? options.onPostpone : undefined, + options ? options.experimental_formState : undefined, ); } @@ -156,6 +159,7 @@ function resumeRequestImpl( options ? options.onShellError : undefined, undefined, options ? options.onPostpone : undefined, + options ? options.experimental_formState : undefined, ); } diff --git a/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js b/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js index 64c5cf4ae28fd..b9ce65830e910 100644 --- a/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js +++ b/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js @@ -7,7 +7,7 @@ * @flow */ -import type {ReactNodeList} from 'shared/ReactTypes'; +import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes'; import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; import type {PostponedState} from 'react-server/src/ReactFizzServer'; import type {ImportMap} from '../shared/ReactDOMTypes'; @@ -40,6 +40,7 @@ type Options = { onPostpone?: (reason: string) => void, unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor, importMap?: ImportMap, + experimental_formState?: ReactFormState | null, }; type StaticResult = { @@ -96,6 +97,7 @@ function prerender( undefined, onFatalError, options ? options.onPostpone : undefined, + options ? options.experimental_formState : undefined, ); if (options && options.signal) { const signal = options.signal; diff --git a/packages/react-dom/src/server/ReactDOMFizzStaticEdge.js b/packages/react-dom/src/server/ReactDOMFizzStaticEdge.js index 64c5cf4ae28fd..b9ce65830e910 100644 --- a/packages/react-dom/src/server/ReactDOMFizzStaticEdge.js +++ b/packages/react-dom/src/server/ReactDOMFizzStaticEdge.js @@ -7,7 +7,7 @@ * @flow */ -import type {ReactNodeList} from 'shared/ReactTypes'; +import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes'; import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; import type {PostponedState} from 'react-server/src/ReactFizzServer'; import type {ImportMap} from '../shared/ReactDOMTypes'; @@ -40,6 +40,7 @@ type Options = { onPostpone?: (reason: string) => void, unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor, importMap?: ImportMap, + experimental_formState?: ReactFormState | null, }; type StaticResult = { @@ -96,6 +97,7 @@ function prerender( undefined, onFatalError, options ? options.onPostpone : undefined, + options ? options.experimental_formState : undefined, ); if (options && options.signal) { const signal = options.signal; diff --git a/packages/react-dom/src/server/ReactDOMFizzStaticNode.js b/packages/react-dom/src/server/ReactDOMFizzStaticNode.js index ee138ef5a3f6b..b6a2a6616fd62 100644 --- a/packages/react-dom/src/server/ReactDOMFizzStaticNode.js +++ b/packages/react-dom/src/server/ReactDOMFizzStaticNode.js @@ -7,7 +7,7 @@ * @flow */ -import type {ReactNodeList} from 'shared/ReactTypes'; +import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes'; import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; import type {PostponedState} from 'react-server/src/ReactFizzServer'; import type {ImportMap} from '../shared/ReactDOMTypes'; @@ -42,6 +42,7 @@ type Options = { onPostpone?: (reason: string) => void, unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor, importMap?: ImportMap, + experimental_formState?: ReactFormState | null, }; type StaticResult = { @@ -110,6 +111,7 @@ function prerenderToNodeStream( undefined, onFatalError, options ? options.onPostpone : undefined, + options ? options.experimental_formState : undefined, ); if (options && options.signal) { const signal = options.signal; diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index f8f935846f86f..6a668333f93e0 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -2010,28 +2010,37 @@ function formStateReducer(oldState: S, newState: S): S { function mountFormState( action: (S, P) => Promise, - initialState: S, + initialStateProp: S, permalink?: string, ): [S, (P) => void] { + let initialState = initialStateProp; if (getIsHydrating()) { - // TODO: If this function returns true, it means we should use the form - // state passed to hydrateRoot instead of initialState. - tryToClaimNextHydratableFormMarkerInstance(currentlyRenderingFiber); + const isMatching = tryToClaimNextHydratableFormMarkerInstance( + currentlyRenderingFiber, + ); + const root: FiberRoot = (getWorkInProgressRoot(): any); + const ssrFormState = root.formState; + if (ssrFormState !== null && isMatching) { + const promiseForState = ssrFormState[0]; + initialState = useThenable(promiseForState); + } } + const initialStateThenable: Thenable = { + status: 'fulfilled', + value: initialState, + then() {}, + }; // State hook. The state is stored in a thenable which is then unwrapped by // the `use` algorithm during render. const stateHook = mountWorkInProgressHook(); - stateHook.memoizedState = stateHook.baseState = { - status: 'fulfilled', - value: initialState, - }; + stateHook.memoizedState = stateHook.baseState = initialStateThenable; const stateQueue: UpdateQueue, Thenable> = { pending: null, lanes: NoLanes, dispatch: null, lastRenderedReducer: formStateReducer, - lastRenderedState: (initialState: any), + lastRenderedState: initialStateThenable, }; stateHook.queue = stateQueue; const setState: Dispatch> = (dispatchSetState.bind( diff --git a/packages/react-reconciler/src/ReactFiberReconciler.js b/packages/react-reconciler/src/ReactFiberReconciler.js index 4b5193585a0b0..599fa854c0a79 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.js @@ -21,7 +21,7 @@ import type { PublicInstance, RendererInspectionConfig, } from './ReactFiberConfig'; -import type {ReactNodeList} from 'shared/ReactTypes'; +import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes'; import type {Lane} from './ReactFiberLane'; import type {SuspenseState} from './ReactFiberSuspenseComponent'; @@ -265,6 +265,7 @@ export function createContainer( identifierPrefix, onRecoverableError, transitionCallbacks, + null, ); } @@ -280,6 +281,7 @@ export function createHydrationContainer( identifierPrefix: string, onRecoverableError: (error: mixed) => void, transitionCallbacks: null | TransitionTracingCallbacks, + formState: ReactFormState | null, ): OpaqueRoot { const hydrate = true; const root = createFiberRoot( @@ -293,6 +295,7 @@ export function createHydrationContainer( identifierPrefix, onRecoverableError, transitionCallbacks, + formState, ); // TODO: Move this to FiberRoot constructor diff --git a/packages/react-reconciler/src/ReactFiberRoot.js b/packages/react-reconciler/src/ReactFiberRoot.js index e65e25b97df6b..c686dba7c7e87 100644 --- a/packages/react-reconciler/src/ReactFiberRoot.js +++ b/packages/react-reconciler/src/ReactFiberRoot.js @@ -7,7 +7,7 @@ * @flow */ -import type {ReactNodeList} from 'shared/ReactTypes'; +import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes'; import type { FiberRoot, SuspenseHydrationCallbacks, @@ -52,6 +52,7 @@ function FiberRootNode( hydrate: any, identifierPrefix: any, onRecoverableError: any, + formState: ReactFormState | null, ) { this.tag = tag; this.containerInfo = containerInfo; @@ -93,6 +94,8 @@ function FiberRootNode( this.hydrationCallbacks = null; } + this.formState = formState; + this.incompleteTransitions = new Map(); if (enableTransitionTracing) { this.transitionCallbacks = null; @@ -142,6 +145,7 @@ export function createFiberRoot( identifierPrefix: string, onRecoverableError: null | ((error: mixed) => void), transitionCallbacks: null | TransitionTracingCallbacks, + formState: ReactFormState | null, ): FiberRoot { // $FlowFixMe[invalid-constructor] Flow no longer supports calling new on functions const root: FiberRoot = (new FiberRootNode( @@ -150,6 +154,7 @@ export function createFiberRoot( hydrate, identifierPrefix, onRecoverableError, + formState, ): any); if (enableSuspenseCallback) { root.hydrationCallbacks = hydrationCallbacks; diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index 8dae4fa10e07f..e6b002d0e357b 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -14,6 +14,7 @@ import type { StartTransitionOptions, Wakeable, Usable, + ReactFormState, } from 'shared/ReactTypes'; import type {WorkTag} from './ReactWorkTags'; import type {TypeOfMode} from './ReactTypeOfMode'; @@ -270,6 +271,8 @@ type BaseFiberRootProperties = { error: mixed, errorInfo: {digest?: ?string, componentStack?: ?string}, ) => void, + + formState: ReactFormState | null, }; // The following attributes are only used by DevTools and are only present in DEV builds. diff --git a/packages/react-server-dom-esm/src/ReactFlightDOMServerNode.js b/packages/react-server-dom-esm/src/ReactFlightDOMServerNode.js index 4d44fa01a3821..808033bb7605d 100644 --- a/packages/react-server-dom-esm/src/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-esm/src/ReactFlightDOMServerNode.js @@ -36,7 +36,10 @@ import { getRoot, } from 'react-server/src/ReactFlightReplyServer'; -import {decodeAction} from 'react-server/src/ReactFlightActionServer'; +import { + decodeAction, + decodeFormState, +} from 'react-server/src/ReactFlightActionServer'; export { registerServerReference, @@ -166,4 +169,5 @@ export { decodeReplyFromBusboy, decodeReply, decodeAction, + decodeFormState, }; diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js b/packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js index b445a4be15323..993c9585a9636 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js @@ -25,7 +25,10 @@ import { getRoot, } from 'react-server/src/ReactFlightReplyServer'; -import {decodeAction} from 'react-server/src/ReactFlightActionServer'; +import { + decodeAction, + decodeFormState, +} from 'react-server/src/ReactFlightActionServer'; export { registerServerReference, @@ -97,4 +100,4 @@ function decodeReply( return getRoot(response); } -export {renderToReadableStream, decodeReply, decodeAction}; +export {renderToReadableStream, decodeReply, decodeAction, decodeFormState}; diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js b/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js index b445a4be15323..993c9585a9636 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js @@ -25,7 +25,10 @@ import { getRoot, } from 'react-server/src/ReactFlightReplyServer'; -import {decodeAction} from 'react-server/src/ReactFlightActionServer'; +import { + decodeAction, + decodeFormState, +} from 'react-server/src/ReactFlightActionServer'; export { registerServerReference, @@ -97,4 +100,4 @@ function decodeReply( return getRoot(response); } -export {renderToReadableStream, decodeReply, decodeAction}; +export {renderToReadableStream, decodeReply, decodeAction, decodeFormState}; diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js b/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js index 17dd769cdecb8..0c6423f28316f 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js @@ -36,7 +36,10 @@ import { getRoot, } from 'react-server/src/ReactFlightReplyServer'; -import {decodeAction} from 'react-server/src/ReactFlightActionServer'; +import { + decodeAction, + decodeFormState, +} from 'react-server/src/ReactFlightActionServer'; export { registerServerReference, @@ -167,4 +170,5 @@ export { decodeReplyFromBusboy, decodeReply, decodeAction, + decodeFormState, }; diff --git a/packages/react-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js index 4f08d664a06fa..47e99fe567736 100644 --- a/packages/react-server/src/ReactFizzHooks.js +++ b/packages/react-server/src/ReactFizzHooks.js @@ -15,10 +15,11 @@ import type { Thenable, Usable, ReactCustomFormAction, + KeyNode, } from 'shared/ReactTypes'; import type {ResumableState} from './ReactFizzConfig'; -import type {Task} from './ReactFizzServer'; +import type {Request, Task} from './ReactFizzServer'; import type {ThenableState} from './ReactFizzThenable'; import type {TransitionStatus} from './ReactFizzConfig'; import type { @@ -33,6 +34,7 @@ import {createThenableState, trackUsedThenable} from './ReactFizzThenable'; import { makeId, NotPendingTransition, + pushFormStateMarkerIsMatching, pushFormStateMarkerIsNotMatching, } from './ReactFizzConfig'; @@ -50,6 +52,7 @@ import { REACT_MEMO_CACHE_SENTINEL, } from 'shared/ReactSymbols'; import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion'; +import {getFormState} from './ReactFizzServer'; type BasicStateAction = (S => S) | S; type Dispatch = A => void; @@ -72,6 +75,7 @@ type Hook = { let currentlyRenderingComponent: Object | null = null; let currentlyRenderingTask: Task | null = null; +let currentlyRenderingRequest: Request | null = null; let firstWorkInProgressHook: Hook | null = null; let workInProgressHook: Hook | null = null; // Whether the work-in-progress hook is a re-rendered hook @@ -83,6 +87,8 @@ let localIdCounter: number = 0; // Chunks that should be pushed to the stream once the component // finishes rendering. let bufferedChunks: Array | null = null; +// Counts the number of useFormState calls in this component +let formStateCounter: number = 0; // Counts the number of use(thenable) calls in this component let thenableIndexCounter: number = 0; let thenableState: ThenableState | null = null; @@ -201,12 +207,14 @@ function createWorkInProgressHook(): Hook { } export function prepareToUseHooks( + request: Request, task: Task, componentIdentity: Object, prevThenableState: ThenableState | null, ): void { currentlyRenderingComponent = componentIdentity; currentlyRenderingTask = task; + currentlyRenderingRequest = request; if (__DEV__) { isInHookUserCodeInDev = false; } @@ -220,6 +228,7 @@ export function prepareToUseHooks( localIdCounter = 0; bufferedChunks = null; + formStateCounter = 0; thenableIndexCounter = 0; thenableState = prevThenableState; } @@ -241,6 +250,7 @@ export function finishHooks( didScheduleRenderPhaseUpdate = false; localIdCounter = 0; bufferedChunks = null; + formStateCounter = 0; thenableIndexCounter = 0; numberOfReRenders += 1; @@ -287,6 +297,7 @@ export function resetHooksState(): void { currentlyRenderingComponent = null; currentlyRenderingTask = null; + currentlyRenderingRequest = null; didScheduleRenderPhaseUpdate = false; firstWorkInProgressHook = null; numberOfReRenders = 0; @@ -577,6 +588,33 @@ function useOptimistic( return [passthrough, unsupportedSetOptimisticState]; } +function isSameFormStateInstance(k1: KeyNode, k2: KeyNode): boolean { + let key1: KeyNode | null = k1; + let key2: KeyNode | null = k2; + while (key1 !== null) { + if (key2 === null) { + // Key 1 is longer than key 2 + return false; + } + if (key1[1] !== key2[1]) { + // Component name doesn't match + return false; + } + if (key1[2] !== key2[2]) { + // Key/index doesn't match + return false; + } + key1 = key1[0]; + key2 = key2[0]; + } + if (key2 !== null) { + // Key 2 is longer than key 1 + return false; + } + // Everything matches + return true; +} + function useFormState( action: (S, P) => Promise, initialState: S, @@ -584,22 +622,51 @@ function useFormState( ): [S, (P) => void] { resolveCurrentlyRenderingComponent(); - // Emit a marker that indicates whether we rendered using the form state - // passed at the root. We buffer these until the component has finished - // rendering, because a later hook might trigger a local re-render. - // TODO: Matching not yet implemented. For now, we always emit a "not - // matching" marker. - // TODO: As an optimization, Fizz should only emit these markers if form - // state is passed at the root. + const request: Request = (currentlyRenderingRequest: any); + const task: Task = (currentlyRenderingTask: any); + + // Track the position of this useFormState hook relative to the other ones in + // this component, so we can generate a unique key for each one. + const formStateHookIndex = formStateCounter++; + + // Append a node to the key path that represents the form state hook. + const componentKey = task.keyPath; + const key: KeyNode = [componentKey, null, formStateHookIndex]; + + // A chunk will be emitted below. We buffer these until the component has + // finished rendering, because a later hook might trigger a local re-render. if (bufferedChunks === null) { bufferedChunks = []; } - pushFormStateMarkerIsNotMatching(bufferedChunks); + const chunks = bufferedChunks; + + // Get the form state. If we received form state from a previous page, then + // we should reuse that, if the action identity matches. Otherwise we'll use + // the initial state argument. We emit a comment marker into the stream + // that indicates whether the state was reused. + let state; + const postbackFormState = getFormState(request); + if (postbackFormState !== null) { + const postbackKey = postbackFormState[1]; + // TODO: Compare the action identity, too + if (isSameFormStateInstance(postbackKey, key)) { + // This was a match. Reuse the state from the previous page. + const promiseForState = postbackFormState[0]; + state = unwrapThenable(promiseForState); + pushFormStateMarkerIsMatching(chunks); + } else { + state = initialState; + pushFormStateMarkerIsNotMatching(chunks); + } + } else { + // TODO: As an optimization, Fizz should only emit these markers if form + // state is passed at the root. + state = initialState; + pushFormStateMarkerIsNotMatching(chunks); + } - // 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. - const boundAction = action.bind(null, initialState); + // Bind the state to the first argument of the action. + const boundAction = action.bind(null, state); // Wrap the action so the return value is void. const dispatch = (payload: P): void => { @@ -612,6 +679,13 @@ function useFormState( dispatch.$$FORM_ACTION = (prefix: string) => { // $FlowIgnore[prop-missing] const metadata: ReactCustomFormAction = boundAction.$$FORM_ACTION(prefix); + + const keyJSON = JSON.stringify(key); + const formData = metadata.data; + if (formData) { + formData.append('$ACTION_KEY', keyJSON); + } + // Override the action URL if (permalink !== undefined) { if (__DEV__) { @@ -626,7 +700,7 @@ function useFormState( // no effect. The form will have to be hydrated before it's submitted. } - return [initialState, dispatch]; + return [state, dispatch]; } function useId(): string { diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 914fb152a4f48..923b7dbff75f5 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -19,6 +19,8 @@ import type { OffscreenMode, Wakeable, Thenable, + KeyNode, + ReactFormState, } from 'shared/ReactTypes'; import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy'; import type { @@ -151,15 +153,6 @@ const ReactCurrentDispatcher = ReactSharedInternals.ReactCurrentDispatcher; const ReactCurrentCache = ReactSharedInternals.ReactCurrentCache; const ReactDebugCurrentFrame = ReactSharedInternals.ReactDebugCurrentFrame; -// Linked list representing the identity of a component given the component/tag name and key. -// The name might be minified but we assume that it's going to be the same generated name. Typically -// because it's just the same compiled output in practice. -type KeyNode = [ - Root | KeyNode /* parent */, - string | null /* name */, - string | number /* key */, -]; - const REPLAY_NODE = 0; const REPLAY_SUSPENSE_BOUNDARY = 1; const RESUME_SEGMENT = 2; @@ -295,6 +288,10 @@ export opaque type Request = { // onPostpone is called when postpone() is called anywhere in the tree, which will defer // rendering - e.g. to the client. This is considered intentional and not an error. onPostpone: (reason: string) => void, + // The form state that was submitted by the previous page, if it was provided. + // This happens when a useFormState action is submitted before it + // has hydrated. + formState: null | ReactFormState, }; // This is a default heuristic for how to split up the HTML content into progressive @@ -333,6 +330,7 @@ export function createRequest( onShellError: void | ((error: mixed) => void), onFatalError: void | ((error: mixed) => void), onPostpone: void | ((reason: string) => void), + formState: void | null | ReactFormState, ): Request { prepareHostDispatcher(); const pingedTasks: Array = []; @@ -365,6 +363,7 @@ export function createRequest( onShellReady: onShellReady === undefined ? noop : onShellReady, onShellError: onShellError === undefined ? noop : onShellError, onFatalError: onFatalError === undefined ? noop : onFatalError, + formState: formState === undefined ? null : formState, }; // This segment represents the root fallback. const rootSegment = createPendingSegment( @@ -823,7 +822,7 @@ function renderWithHooks( secondArg: SecondArg, ): any { const componentIdentity = {}; - prepareToUseHooks(task, componentIdentity, prevThenableState); + prepareToUseHooks(request, task, componentIdentity, prevThenableState); const result = Component(props, secondArg); return finishHooks(Component, props, result, secondArg); } @@ -2816,6 +2815,10 @@ export function flushResources(request: Request): void { enqueueFlush(request); } +export function getFormState(request: Request): ReactFormState | null { + return request.formState; +} + export function getResumableState(request: Request): ResumableState { return request.resumableState; } diff --git a/packages/react-server/src/ReactFlightActionServer.js b/packages/react-server/src/ReactFlightActionServer.js index ee735c2e8f9a2..a41131ea0a75f 100644 --- a/packages/react-server/src/ReactFlightActionServer.js +++ b/packages/react-server/src/ReactFlightActionServer.js @@ -7,7 +7,7 @@ * @flow */ -import type {Thenable} from 'shared/ReactTypes'; +import type {Thenable, KeyNode, ReactFormState} from 'shared/ReactTypes'; import type { ServerManifest, @@ -108,3 +108,16 @@ export function decodeAction( // Return the action with the remaining FormData bound to the first argument. return action.then(fn => fn.bind(null, formData)); } + +export function decodeFormState( + actionResult: Promise, + body: FormData, + serverManifest: ServerManifest, +): ReactFormState | null { + const keyPathJSON = body.get('$ACTION_KEY'); + if (typeof keyPathJSON !== 'string') { + return null; + } + const keyPath: KeyNode = (JSON.parse(keyPathJSON): any); + return [actionResult, keyPath]; +} diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 625d687e8829f..b3d08eb56b4c1 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -174,3 +174,18 @@ export type ReactCustomFormAction = { target?: string, data?: null | FormData, }; + +// Linked list representing the identity of a component given the component/tag +// name and key. The name might be minified but we assume that it's going to be +// the same generated name. Typically because it's just the same compiled output +// in practice. +export type KeyNode = [ + null | KeyNode /* parent */, + string | null /* name */, + string | number /* key */, +]; + +export type ReactFormState = [ + Promise /* actual state value */, + KeyNode /* key path */, +];