diff --git a/packages/react-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js new file mode 100644 index 0000000000000..1650b5bf64c04 --- /dev/null +++ b/packages/react-server/src/ReactFizzHooks.js @@ -0,0 +1,515 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {Dispatcher as DispatcherType} from 'react-reconciler/src/ReactInternalTypes'; + +import type { + MutableSource, + MutableSourceGetSnapshotFn, + MutableSourceSubscribeFn, + ReactContext, +} from 'shared/ReactTypes'; + +import {readContext as readContextImpl} from './ReactFizzNewContext'; + +import invariant from 'shared/invariant'; +import {enableCache} from 'shared/ReactFeatureFlags'; +import is from 'shared/objectIs'; + +type BasicStateAction = (S => S) | S; +type Dispatch = A => void; + +type Update = {| + action: A, + next: Update | null, +|}; + +type UpdateQueue = {| + last: Update | null, + dispatch: any, +|}; + +type Hook = {| + memoizedState: any, + queue: UpdateQueue | null, + next: Hook | null, +|}; + +type OpaqueIDType = string; + +let currentlyRenderingComponent: Object | null = null; +let firstWorkInProgressHook: Hook | null = null; +let workInProgressHook: Hook | null = null; +// Whether the work-in-progress hook is a re-rendered hook +let isReRender: boolean = false; +// Whether an update was scheduled during the currently executing render pass. +let didScheduleRenderPhaseUpdate: boolean = false; +// Lazily created map of render-phase updates +let renderPhaseUpdates: Map, Update> | null = null; +// Counter to prevent infinite loops. +let numberOfReRenders: number = 0; +const RE_RENDER_LIMIT = 25; + +let isInHookUserCodeInDev = false; + +// In DEV, this is the name of the currently executing primitive hook +let currentHookNameInDev: ?string; + +function resolveCurrentlyRenderingComponent(): Object { + invariant( + currentlyRenderingComponent !== null, + 'Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for' + + ' one of the following reasons:\n' + + '1. You might have mismatching versions of React and the renderer (such as React DOM)\n' + + '2. You might be breaking the Rules of Hooks\n' + + '3. You might have more than one copy of React in the same app\n' + + 'See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem.', + ); + if (__DEV__) { + if (isInHookUserCodeInDev) { + console.error( + 'Do not call Hooks inside useEffect(...), useMemo(...), or other built-in Hooks. ' + + 'You can only call Hooks at the top level of your React function. ' + + 'For more information, see ' + + 'https://reactjs.org/link/rules-of-hooks', + ); + } + } + return currentlyRenderingComponent; +} + +function areHookInputsEqual( + nextDeps: Array, + prevDeps: Array | null, +) { + if (prevDeps === null) { + if (__DEV__) { + console.error( + '%s received a final argument during this render, but not during ' + + 'the previous render. Even though the final argument is optional, ' + + 'its type cannot change between renders.', + currentHookNameInDev, + ); + } + return false; + } + + if (__DEV__) { + // Don't bother comparing lengths in prod because these arrays should be + // passed inline. + if (nextDeps.length !== prevDeps.length) { + console.error( + 'The final argument passed to %s changed size between renders. The ' + + 'order and size of this array must remain constant.\n\n' + + 'Previous: %s\n' + + 'Incoming: %s', + currentHookNameInDev, + `[${nextDeps.join(', ')}]`, + `[${prevDeps.join(', ')}]`, + ); + } + } + for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) { + if (is(nextDeps[i], prevDeps[i])) { + continue; + } + return false; + } + return true; +} + +function createHook(): Hook { + if (numberOfReRenders > 0) { + invariant(false, 'Rendered more hooks than during the previous render'); + } + return { + memoizedState: null, + queue: null, + next: null, + }; +} + +function createWorkInProgressHook(): Hook { + if (workInProgressHook === null) { + // This is the first hook in the list + if (firstWorkInProgressHook === null) { + isReRender = false; + firstWorkInProgressHook = workInProgressHook = createHook(); + } else { + // There's already a work-in-progress. Reuse it. + isReRender = true; + workInProgressHook = firstWorkInProgressHook; + } + } else { + if (workInProgressHook.next === null) { + isReRender = false; + // Append to the end of the list + workInProgressHook = workInProgressHook.next = createHook(); + } else { + // There's already a work-in-progress. Reuse it. + isReRender = true; + workInProgressHook = workInProgressHook.next; + } + } + return workInProgressHook; +} + +export function prepareToUseHooks(componentIdentity: Object): void { + currentlyRenderingComponent = componentIdentity; + if (__DEV__) { + isInHookUserCodeInDev = false; + } + + // The following should have already been reset + // didScheduleRenderPhaseUpdate = false; + // firstWorkInProgressHook = null; + // numberOfReRenders = 0; + // renderPhaseUpdates = null; + // workInProgressHook = null; +} + +export function finishHooks( + Component: any, + props: any, + children: any, + refOrContext: any, +): any { + // This must be called after every function component to prevent hooks from + // being used in classes. + + while (didScheduleRenderPhaseUpdate) { + // Updates were scheduled during the render phase. They are stored in + // the `renderPhaseUpdates` map. Call the component again, reusing the + // work-in-progress hooks and applying the additional updates on top. Keep + // restarting until no more updates are scheduled. + didScheduleRenderPhaseUpdate = false; + numberOfReRenders += 1; + + // Start over from the beginning of the list + workInProgressHook = null; + + children = Component(props, refOrContext); + } + resetHooksState(); + return children; +} + +// Reset the internal hooks state if an error occurs while rendering a component +export function resetHooksState(): void { + if (__DEV__) { + isInHookUserCodeInDev = false; + } + + currentlyRenderingComponent = null; + didScheduleRenderPhaseUpdate = false; + firstWorkInProgressHook = null; + numberOfReRenders = 0; + renderPhaseUpdates = null; + workInProgressHook = null; +} + +function getCacheForType(resourceType: () => T): T { + // TODO: This should silently mark this as client rendered since it's not necesssarily + // considered an error. It needs to work for things like Flight though. + invariant(false, 'Not implemented.'); +} + +function readContext(context: ReactContext): T { + if (__DEV__) { + if (isInHookUserCodeInDev) { + console.error( + 'Context can only be read while React is rendering. ' + + 'In classes, you can read it in the render method or getDerivedStateFromProps. ' + + 'In function components, you can read it directly in the function body, but not ' + + 'inside Hooks like useReducer() or useMemo().', + ); + } + } + return readContextImpl(context); +} + +function useContext(context: ReactContext): T { + if (__DEV__) { + currentHookNameInDev = 'useContext'; + } + resolveCurrentlyRenderingComponent(); + return readContextImpl(context); +} + +function basicStateReducer(state: S, action: BasicStateAction): S { + // $FlowFixMe: Flow doesn't like mixed types + return typeof action === 'function' ? action(state) : action; +} + +export function useState( + initialState: (() => S) | S, +): [S, Dispatch>] { + if (__DEV__) { + currentHookNameInDev = 'useState'; + } + return useReducer( + basicStateReducer, + // useReducer has a special case to support lazy useState initializers + (initialState: any), + ); +} + +export function useReducer( + reducer: (S, A) => S, + initialArg: I, + init?: I => S, +): [S, Dispatch] { + if (__DEV__) { + if (reducer !== basicStateReducer) { + currentHookNameInDev = 'useReducer'; + } + } + currentlyRenderingComponent = resolveCurrentlyRenderingComponent(); + workInProgressHook = createWorkInProgressHook(); + if (isReRender) { + // This is a re-render. Apply the new render phase updates to the previous + // current hook. + const queue: UpdateQueue = (workInProgressHook.queue: any); + const dispatch: Dispatch = (queue.dispatch: any); + if (renderPhaseUpdates !== null) { + // Render phase updates are stored in a map of queue -> linked list + const firstRenderPhaseUpdate = renderPhaseUpdates.get(queue); + if (firstRenderPhaseUpdate !== undefined) { + renderPhaseUpdates.delete(queue); + let newState = workInProgressHook.memoizedState; + let update = firstRenderPhaseUpdate; + do { + // Process this render phase update. We don't have to check the + // priority because it will always be the same as the current + // render's. + const action = update.action; + if (__DEV__) { + isInHookUserCodeInDev = true; + } + newState = reducer(newState, action); + if (__DEV__) { + isInHookUserCodeInDev = false; + } + update = update.next; + } while (update !== null); + + workInProgressHook.memoizedState = newState; + + return [newState, dispatch]; + } + } + return [workInProgressHook.memoizedState, dispatch]; + } else { + if (__DEV__) { + isInHookUserCodeInDev = true; + } + let initialState; + if (reducer === basicStateReducer) { + // Special case for `useState`. + initialState = + typeof initialArg === 'function' + ? ((initialArg: any): () => S)() + : ((initialArg: any): S); + } else { + initialState = + init !== undefined ? init(initialArg) : ((initialArg: any): S); + } + if (__DEV__) { + isInHookUserCodeInDev = false; + } + workInProgressHook.memoizedState = initialState; + const queue: UpdateQueue = (workInProgressHook.queue = { + last: null, + dispatch: null, + }); + const dispatch: Dispatch = (queue.dispatch = (dispatchAction.bind( + null, + currentlyRenderingComponent, + queue, + ): any)); + return [workInProgressHook.memoizedState, dispatch]; + } +} + +function useMemo(nextCreate: () => T, deps: Array | void | null): T { + currentlyRenderingComponent = resolveCurrentlyRenderingComponent(); + workInProgressHook = createWorkInProgressHook(); + + const nextDeps = deps === undefined ? null : deps; + + if (workInProgressHook !== null) { + const prevState = workInProgressHook.memoizedState; + if (prevState !== null) { + if (nextDeps !== null) { + const prevDeps = prevState[1]; + if (areHookInputsEqual(nextDeps, prevDeps)) { + return prevState[0]; + } + } + } + } + + if (__DEV__) { + isInHookUserCodeInDev = true; + } + const nextValue = nextCreate(); + if (__DEV__) { + isInHookUserCodeInDev = false; + } + workInProgressHook.memoizedState = [nextValue, nextDeps]; + return nextValue; +} + +function useRef(initialValue: T): {|current: T|} { + currentlyRenderingComponent = resolveCurrentlyRenderingComponent(); + workInProgressHook = createWorkInProgressHook(); + const previousRef = workInProgressHook.memoizedState; + if (previousRef === null) { + const ref = {current: initialValue}; + if (__DEV__) { + Object.seal(ref); + } + workInProgressHook.memoizedState = ref; + return ref; + } else { + return previousRef; + } +} + +export function useLayoutEffect( + create: () => (() => void) | void, + inputs: Array | void | null, +) { + if (__DEV__) { + currentHookNameInDev = 'useLayoutEffect'; + console.error( + 'useLayoutEffect does nothing on the server, because its effect cannot ' + + "be encoded into the server renderer's output format. This will lead " + + 'to a mismatch between the initial, non-hydrated UI and the intended ' + + 'UI. To avoid this, useLayoutEffect should only be used in ' + + 'components that render exclusively on the client. ' + + 'See https://reactjs.org/link/uselayouteffect-ssr for common fixes.', + ); + } +} + +function dispatchAction( + componentIdentity: Object, + queue: UpdateQueue, + action: A, +) { + invariant( + numberOfReRenders < RE_RENDER_LIMIT, + 'Too many re-renders. React limits the number of renders to prevent ' + + 'an infinite loop.', + ); + + if (componentIdentity === currentlyRenderingComponent) { + // This is a render phase update. Stash it in a lazily-created map of + // queue -> linked list of updates. After this render pass, we'll restart + // and apply the stashed updates on top of the work-in-progress hook. + didScheduleRenderPhaseUpdate = true; + const update: Update = { + action, + next: null, + }; + if (renderPhaseUpdates === null) { + renderPhaseUpdates = new Map(); + } + const firstRenderPhaseUpdate = renderPhaseUpdates.get(queue); + if (firstRenderPhaseUpdate === undefined) { + renderPhaseUpdates.set(queue, update); + } else { + // Append the update to the end of the list. + let lastRenderPhaseUpdate = firstRenderPhaseUpdate; + while (lastRenderPhaseUpdate.next !== null) { + lastRenderPhaseUpdate = lastRenderPhaseUpdate.next; + } + lastRenderPhaseUpdate.next = update; + } + } else { + // This means an update has happened after the function component has + // returned. On the server this is a no-op. In React Fiber, the update + // would be scheduled for a future render. + } +} + +export function useCallback( + callback: T, + deps: Array | void | null, +): T { + return useMemo(() => callback, deps); +} + +// TODO Decide on how to implement this hook for server rendering. +// If a mutation occurs during render, consider triggering a Suspense boundary +// and falling back to client rendering. +function useMutableSource( + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn, + subscribe: MutableSourceSubscribeFn, +): Snapshot { + resolveCurrentlyRenderingComponent(); + return getSnapshot(source._source); +} + +function useDeferredValue(value: T): T { + resolveCurrentlyRenderingComponent(); + return value; +} + +function unsupportedStartTransition() { + invariant(false, 'startTransition cannot be called during server rendering.'); +} + +function useTransition(): [(callback: () => void) => void, boolean] { + resolveCurrentlyRenderingComponent(); + return [unsupportedStartTransition, false]; +} + +function useOpaqueIdentifier(): OpaqueIDType { + throw new Error('Not yet implemented.'); +} + +function unsupportedRefresh() { + invariant(false, 'Cache cannot be refreshed during server rendering.'); +} + +function useCacheRefresh(): (?() => T, ?T) => void { + return unsupportedRefresh; +} + +function noop(): void {} + +export const Dispatcher: DispatcherType = { + readContext, + useContext, + useMemo, + useReducer, + useRef, + useState, + useLayoutEffect, + useCallback, + // useImperativeHandle is not run in the server environment + useImperativeHandle: noop, + // Effects are not run in the server environment. + useEffect: noop, + // Debugging effect + useDebugValue: noop, + useDeferredValue, + useTransition, + useOpaqueIdentifier, + // Subscriptions are not setup in a server environment. + useMutableSource, +}; + +if (enableCache) { + Dispatcher.getCacheForType = getCacheForType; + Dispatcher.useCacheRefresh = useCacheRefresh; +} diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 4febbbe98163e..edeaebbe56693 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -7,7 +7,6 @@ * @flow */ -import type {Dispatcher as DispatcherType} from 'react-reconciler/src/ReactInternalTypes'; import type { Destination, Chunk, @@ -70,6 +69,12 @@ import { pushProvider, popProvider, } from './ReactFizzNewContext'; +import { + prepareToUseHooks, + finishHooks, + resetHooksState, + Dispatcher, +} from './ReactFizzHooks'; import { getIteratorFn, @@ -199,6 +204,8 @@ function defaultErrorHandler(error: mixed) { console['error'](error); // Don't transform to our wrapper } +function noop(): void {} + export function createRequest( children: ReactNodeList, destination: Destination, @@ -474,8 +481,10 @@ function renderWithHooks( props: Props, secondArg: SecondArg, ): any { - // TODO: Set up Hooks etc. - const children = Component(props, secondArg); + const componentIdentity = {}; + prepareToUseHooks(componentIdentity); + const result = Component(props, secondArg); + const children = finishHooks(Component, props, result, secondArg); if (children === undefined) { invalidRenderResult(Component); } @@ -1102,6 +1111,7 @@ function renderNode(request: Request, task: Task, node: ReactNodeList): void { try { return renderNodeDestructive(request, task, node); } catch (x) { + resetHooksState(); if (typeof x === 'object' && x !== null && typeof x.then === 'function') { spawnNewSuspendedTask(request, task, x); // Restore the context. We assume that this will be restored by the inner @@ -1290,6 +1300,7 @@ function retryTask(request: Request, task: Task): void { segment.status = COMPLETED; finishedTask(request, task.blockedBoundary, segment); } catch (x) { + resetHooksState(); if (typeof x === 'object' && x !== null && typeof x.then === 'function') { // Something suspended again, let's pick it back up later. const ping = task.ping; @@ -1704,51 +1715,3 @@ export function abort(request: Request): void { fatalError(request, error); } } - -function notYetImplemented(): void { - throw new Error('Not yet implemented.'); -} - -function unsupportedRefresh() { - invariant(false, 'Cache cannot be refreshed during server rendering.'); -} - -function unsupportedStartTransition() { - invariant(false, 'startTransition cannot be called during server rendering.'); -} - -function noop(): void {} - -const Dispatcher: DispatcherType = { - useMemo(nextCreate: () => T): T { - return nextCreate(); - }, - useCallback(callback: T): T { - return callback; - }, - useDebugValue(): void {}, - useDeferredValue(value: T): T { - return value; - }, - useTransition(): [(callback: () => void) => void, boolean] { - return [unsupportedStartTransition, false]; - }, - getCacheForType(resourceType: () => T): T { - throw new Error('Not yet implemented. Should mark as client rendered.'); - }, - readContext: (notYetImplemented: any), - useContext: (notYetImplemented: any), - useReducer: (notYetImplemented: any), - useRef: (notYetImplemented: any), - useState: (notYetImplemented: any), - useLayoutEffect: noop, - // useImperativeHandle is not run in the server environment - useImperativeHandle: noop, - // Effects are not run in the server environment. - useEffect: noop, - useOpaqueIdentifier: (notYetImplemented: any), - useMutableSource: (notYetImplemented: any), - useCacheRefresh(): (?() => T, ?T) => void { - return unsupportedRefresh; - }, -};