mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
9f2eebd807
Stacked on #28849, #28854, #28853. Behind a flag. If you're following along from the side-lines. This is probably not what you think it is. It's NOT a way to get updates to a component over time. The AsyncIterable works like an Iterable already works in React which is how an Array works. I.e. it's a list of children - not the value of a child over time. It also doesn't actually render one component at a time. The way it works is more like awaiting the entire list to become an array and then it shows up. Before that it suspends the parent. To actually get these to display one at a time, you have to opt-in with `<SuspenseList>` to describe how they should appear. That's really the interesting part and that not implemented yet. Additionally, since these are effectively Async Functions and uncached promises, they're not actually fully "supported" on the client yet for the same reason rendering plain Promises and Async Functions aren't. They warn. It's only really useful when paired with RSC that produces instrumented versions of these. Ideally we'd published instrumented helpers to help with map/filter style operations that yield new instrumented AsyncIterables. The way the implementation works basically just relies on unwrapThenable and otherwise works like a plain Iterator. There is one quirk with these that are different than just promises. We ask for a new iterator each time we rerender. This means that upon retry we kick off another iteration which itself might kick off new requests that block iterating further. To solve this and make it actually efficient enough to use on the client we'd need to stash something like a buffer of the previous iteration and maybe iterator on the iterable so that we can continue where we left off or synchronously iterate if we've seen it before. Similar to our `.value` convention on Promises. In Fizz, I had to do a special case because when we render an iterator child we don't actually rerender the parent again like we do in Fiber. However, it's more efficient to just continue on where we left off by reusing the entries from the thenable state from before in that case.
854 lines
26 KiB
JavaScript
854 lines
26 KiB
JavaScript
/**
|
|
* Copyright (c) Meta Platforms, Inc. and 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} from 'react-reconciler/src/ReactInternalTypes';
|
|
|
|
import type {
|
|
ReactContext,
|
|
StartTransitionOptions,
|
|
Thenable,
|
|
Usable,
|
|
ReactCustomFormAction,
|
|
Awaited,
|
|
} from 'shared/ReactTypes';
|
|
|
|
import type {ResumableState} from './ReactFizzConfig';
|
|
import type {Request, Task, KeyNode} from './ReactFizzServer';
|
|
import type {ThenableState} from './ReactFizzThenable';
|
|
import type {TransitionStatus} from './ReactFizzConfig';
|
|
|
|
import {readContext as readContextImpl} from './ReactFizzNewContext';
|
|
import {getTreeId} from './ReactFizzTreeContext';
|
|
import {
|
|
createThenableState,
|
|
trackUsedThenable,
|
|
readPreviousThenable,
|
|
} from './ReactFizzThenable';
|
|
|
|
import {makeId, NotPendingTransition} from './ReactFizzConfig';
|
|
import {createFastHash} from './ReactServerStreamConfig';
|
|
|
|
import {
|
|
enableCache,
|
|
enableUseEffectEventHook,
|
|
enableUseMemoCacheHook,
|
|
enableAsyncActions,
|
|
enableUseDeferredValueInitialArg,
|
|
} from 'shared/ReactFeatureFlags';
|
|
import is from 'shared/objectIs';
|
|
import {
|
|
REACT_CONTEXT_TYPE,
|
|
REACT_MEMO_CACHE_SENTINEL,
|
|
} from 'shared/ReactSymbols';
|
|
import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion';
|
|
import {getFormState} from './ReactFizzServer';
|
|
|
|
type BasicStateAction<S> = (S => S) | S;
|
|
type Dispatch<A> = A => void;
|
|
|
|
type Update<A> = {
|
|
action: A,
|
|
next: Update<A> | null,
|
|
};
|
|
|
|
type UpdateQueue<A> = {
|
|
last: Update<A> | null,
|
|
dispatch: any,
|
|
};
|
|
|
|
type Hook = {
|
|
memoizedState: any,
|
|
queue: UpdateQueue<any> | null,
|
|
next: Hook | null,
|
|
};
|
|
|
|
let currentlyRenderingComponent: Object | null = null;
|
|
let currentlyRenderingTask: Task | null = null;
|
|
let currentlyRenderingRequest: Request | null = null;
|
|
let currentlyRenderingKeyPath: KeyNode | 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;
|
|
// Counts the number of useId hooks in this component
|
|
let localIdCounter: number = 0;
|
|
// Chunks that should be pushed to the stream once the component
|
|
// finishes rendering.
|
|
// Counts the number of useActionState calls in this component
|
|
let actionStateCounter: number = 0;
|
|
// The index of the useActionState hook that matches the one passed in at the
|
|
// root during an MPA navigation, if any.
|
|
let actionStateMatchingIndex: number = -1;
|
|
// Counts the number of use(thenable) calls in this component
|
|
let thenableIndexCounter: number = 0;
|
|
let thenableState: ThenableState | null = null;
|
|
// Lazily created map of render-phase updates
|
|
let renderPhaseUpdates: Map<UpdateQueue<any>, Update<any>> | 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 {
|
|
if (currentlyRenderingComponent === null) {
|
|
throw new Error(
|
|
'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://react.dev/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://react.dev/link/rules-of-hooks',
|
|
);
|
|
}
|
|
}
|
|
return currentlyRenderingComponent;
|
|
}
|
|
|
|
function areHookInputsEqual(
|
|
nextDeps: Array<mixed>,
|
|
prevDeps: Array<mixed> | 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(', ')}]`,
|
|
);
|
|
}
|
|
}
|
|
// $FlowFixMe[incompatible-use] found when upgrading Flow
|
|
for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
|
|
// $FlowFixMe[incompatible-use] found when upgrading Flow
|
|
if (is(nextDeps[i], prevDeps[i])) {
|
|
continue;
|
|
}
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function createHook(): Hook {
|
|
if (numberOfReRenders > 0) {
|
|
throw new Error('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(
|
|
request: Request,
|
|
task: Task,
|
|
keyPath: KeyNode | null,
|
|
componentIdentity: Object,
|
|
prevThenableState: ThenableState | null,
|
|
): void {
|
|
currentlyRenderingComponent = componentIdentity;
|
|
currentlyRenderingTask = task;
|
|
currentlyRenderingRequest = request;
|
|
currentlyRenderingKeyPath = keyPath;
|
|
if (__DEV__) {
|
|
isInHookUserCodeInDev = false;
|
|
}
|
|
|
|
// The following should have already been reset
|
|
// didScheduleRenderPhaseUpdate = false;
|
|
// firstWorkInProgressHook = null;
|
|
// numberOfReRenders = 0;
|
|
// renderPhaseUpdates = null;
|
|
// workInProgressHook = null;
|
|
|
|
localIdCounter = 0;
|
|
actionStateCounter = 0;
|
|
actionStateMatchingIndex = -1;
|
|
thenableIndexCounter = 0;
|
|
thenableState = prevThenableState;
|
|
}
|
|
|
|
export function prepareToUseThenableState(
|
|
prevThenableState: ThenableState | null,
|
|
): void {
|
|
thenableIndexCounter = 0;
|
|
thenableState = prevThenableState;
|
|
}
|
|
|
|
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;
|
|
localIdCounter = 0;
|
|
actionStateCounter = 0;
|
|
actionStateMatchingIndex = -1;
|
|
thenableIndexCounter = 0;
|
|
numberOfReRenders += 1;
|
|
|
|
// Start over from the beginning of the list
|
|
workInProgressHook = null;
|
|
|
|
children = Component(props, refOrContext);
|
|
}
|
|
|
|
resetHooksState();
|
|
return children;
|
|
}
|
|
|
|
export function getThenableStateAfterSuspending(): null | ThenableState {
|
|
const state = thenableState;
|
|
thenableState = null;
|
|
return state;
|
|
}
|
|
|
|
export function checkDidRenderIdHook(): boolean {
|
|
// 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.
|
|
const didRenderIdHook = localIdCounter !== 0;
|
|
return didRenderIdHook;
|
|
}
|
|
|
|
export function getActionStateCount(): number {
|
|
// 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 actionStateCounter;
|
|
}
|
|
export function getActionStateMatchingIndex(): number {
|
|
// 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 actionStateMatchingIndex;
|
|
}
|
|
|
|
// Reset the internal hooks state if an error occurs while rendering a component
|
|
export function resetHooksState(): void {
|
|
if (__DEV__) {
|
|
isInHookUserCodeInDev = false;
|
|
}
|
|
|
|
currentlyRenderingComponent = null;
|
|
currentlyRenderingTask = null;
|
|
currentlyRenderingRequest = null;
|
|
currentlyRenderingKeyPath = null;
|
|
didScheduleRenderPhaseUpdate = false;
|
|
firstWorkInProgressHook = null;
|
|
numberOfReRenders = 0;
|
|
renderPhaseUpdates = null;
|
|
workInProgressHook = null;
|
|
}
|
|
|
|
function readContext<T>(context: ReactContext<T>): 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<T>(context: ReactContext<T>): T {
|
|
if (__DEV__) {
|
|
currentHookNameInDev = 'useContext';
|
|
}
|
|
resolveCurrentlyRenderingComponent();
|
|
return readContextImpl(context);
|
|
}
|
|
|
|
function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
|
|
// $FlowFixMe[incompatible-use]: Flow doesn't like mixed types
|
|
return typeof action === 'function' ? action(state) : action;
|
|
}
|
|
|
|
export function useState<S>(
|
|
initialState: (() => S) | S,
|
|
): [S, Dispatch<BasicStateAction<S>>] {
|
|
if (__DEV__) {
|
|
currentHookNameInDev = 'useState';
|
|
}
|
|
return useReducer(
|
|
basicStateReducer,
|
|
// useReducer has a special case to support lazy useState initializers
|
|
(initialState: any),
|
|
);
|
|
}
|
|
|
|
export function useReducer<S, I, A>(
|
|
reducer: (S, A) => S,
|
|
initialArg: I,
|
|
init?: I => S,
|
|
): [S, Dispatch<A>] {
|
|
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<A> = (workInProgressHook.queue: any);
|
|
const dispatch: Dispatch<A> = (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) {
|
|
// $FlowFixMe[incompatible-use] found when upgrading Flow
|
|
renderPhaseUpdates.delete(queue);
|
|
// $FlowFixMe[incompatible-use] found when upgrading Flow
|
|
let newState = workInProgressHook.memoizedState;
|
|
let update: Update<any> = 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;
|
|
}
|
|
// $FlowFixMe[incompatible-type] we bail out when we get a null
|
|
update = update.next;
|
|
} while (update !== null);
|
|
|
|
// $FlowFixMe[incompatible-use] found when upgrading Flow
|
|
workInProgressHook.memoizedState = newState;
|
|
|
|
return [newState, dispatch];
|
|
}
|
|
}
|
|
// $FlowFixMe[incompatible-use] found when upgrading Flow
|
|
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;
|
|
}
|
|
// $FlowFixMe[incompatible-use] found when upgrading Flow
|
|
workInProgressHook.memoizedState = initialState;
|
|
// $FlowFixMe[incompatible-use] found when upgrading Flow
|
|
const queue: UpdateQueue<A> = (workInProgressHook.queue = {
|
|
last: null,
|
|
dispatch: null,
|
|
});
|
|
const dispatch: Dispatch<A> = (queue.dispatch = (dispatchAction.bind(
|
|
null,
|
|
currentlyRenderingComponent,
|
|
queue,
|
|
): any));
|
|
// $FlowFixMe[incompatible-use] found when upgrading Flow
|
|
return [workInProgressHook.memoizedState, dispatch];
|
|
}
|
|
}
|
|
|
|
function useMemo<T>(nextCreate: () => T, deps: Array<mixed> | 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;
|
|
}
|
|
// $FlowFixMe[incompatible-use] found when upgrading Flow
|
|
workInProgressHook.memoizedState = [nextValue, nextDeps];
|
|
return nextValue;
|
|
}
|
|
|
|
function useRef<T>(initialValue: T): {current: T} {
|
|
currentlyRenderingComponent = resolveCurrentlyRenderingComponent();
|
|
workInProgressHook = createWorkInProgressHook();
|
|
const previousRef = workInProgressHook.memoizedState;
|
|
if (previousRef === null) {
|
|
const ref = {current: initialValue};
|
|
if (__DEV__) {
|
|
Object.seal(ref);
|
|
}
|
|
// $FlowFixMe[incompatible-use] found when upgrading Flow
|
|
workInProgressHook.memoizedState = ref;
|
|
return ref;
|
|
} else {
|
|
return previousRef;
|
|
}
|
|
}
|
|
|
|
function dispatchAction<A>(
|
|
componentIdentity: Object,
|
|
queue: UpdateQueue<A>,
|
|
action: A,
|
|
): void {
|
|
if (numberOfReRenders >= RE_RENDER_LIMIT) {
|
|
throw new Error(
|
|
'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<A> = {
|
|
action,
|
|
next: null,
|
|
};
|
|
if (renderPhaseUpdates === null) {
|
|
renderPhaseUpdates = new Map();
|
|
}
|
|
const firstRenderPhaseUpdate = renderPhaseUpdates.get(queue);
|
|
if (firstRenderPhaseUpdate === undefined) {
|
|
// $FlowFixMe[incompatible-use] found when upgrading Flow
|
|
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<T>(
|
|
callback: T,
|
|
deps: Array<mixed> | void | null,
|
|
): T {
|
|
return useMemo(() => callback, deps);
|
|
}
|
|
|
|
function throwOnUseEffectEventCall() {
|
|
throw new Error(
|
|
"A function wrapped in useEffectEvent can't be called during rendering.",
|
|
);
|
|
}
|
|
|
|
export function useEffectEvent<Args, Return, F: (...Array<Args>) => Return>(
|
|
callback: F,
|
|
): F {
|
|
// $FlowIgnore[incompatible-return]
|
|
return throwOnUseEffectEventCall;
|
|
}
|
|
|
|
function useSyncExternalStore<T>(
|
|
subscribe: (() => void) => () => void,
|
|
getSnapshot: () => T,
|
|
getServerSnapshot?: () => T,
|
|
): T {
|
|
if (getServerSnapshot === undefined) {
|
|
throw new Error(
|
|
'Missing getServerSnapshot, which is required for ' +
|
|
'server-rendered content. Will revert to client rendering.',
|
|
);
|
|
}
|
|
return getServerSnapshot();
|
|
}
|
|
|
|
function useDeferredValue<T>(value: T, initialValue?: T): T {
|
|
resolveCurrentlyRenderingComponent();
|
|
if (enableUseDeferredValueInitialArg) {
|
|
return initialValue !== undefined ? initialValue : value;
|
|
} else {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
function unsupportedStartTransition() {
|
|
throw new Error('startTransition cannot be called during server rendering.');
|
|
}
|
|
|
|
function useTransition(): [
|
|
boolean,
|
|
(callback: () => void, options?: StartTransitionOptions) => void,
|
|
] {
|
|
resolveCurrentlyRenderingComponent();
|
|
return [false, unsupportedStartTransition];
|
|
}
|
|
|
|
function useHostTransitionStatus(): TransitionStatus {
|
|
resolveCurrentlyRenderingComponent();
|
|
return NotPendingTransition;
|
|
}
|
|
|
|
function unsupportedSetOptimisticState() {
|
|
throw new Error('Cannot update optimistic state while rendering.');
|
|
}
|
|
|
|
function useOptimistic<S, A>(
|
|
passthrough: S,
|
|
reducer: ?(S, A) => S,
|
|
): [S, (A) => void] {
|
|
resolveCurrentlyRenderingComponent();
|
|
return [passthrough, unsupportedSetOptimisticState];
|
|
}
|
|
|
|
function createPostbackActionStateKey(
|
|
permalink: string | void,
|
|
componentKeyPath: KeyNode | null,
|
|
hookIndex: number,
|
|
): string {
|
|
if (permalink !== undefined) {
|
|
// Don't bother to hash a permalink-based key since it's already short.
|
|
return 'p' + permalink;
|
|
} else {
|
|
// Append a node to the key path that represents the form state hook.
|
|
const keyPath: KeyNode = [componentKeyPath, null, hookIndex];
|
|
// Key paths are hashed to reduce the size. It does not need to be secure,
|
|
// and it's more important that it's fast than that it's completely
|
|
// collision-free.
|
|
const keyPathHash = createFastHash(JSON.stringify(keyPath));
|
|
return 'k' + keyPathHash;
|
|
}
|
|
}
|
|
|
|
function useActionState<S, P>(
|
|
action: (Awaited<S>, P) => S,
|
|
initialState: Awaited<S>,
|
|
permalink?: string,
|
|
): [Awaited<S>, (P) => void, boolean] {
|
|
resolveCurrentlyRenderingComponent();
|
|
|
|
// Count the number of useActionState hooks per component. We also use this to
|
|
// track the position of this useActionState hook relative to the other ones in
|
|
// this component, so we can generate a unique key for each one.
|
|
const actionStateHookIndex = actionStateCounter++;
|
|
const request: Request = (currentlyRenderingRequest: any);
|
|
|
|
// $FlowIgnore[prop-missing]
|
|
const formAction = action.$$FORM_ACTION;
|
|
if (typeof formAction === 'function') {
|
|
// This is a server action. These have additional features to enable
|
|
// MPA-style form submissions with progressive enhancement.
|
|
|
|
// TODO: If the same permalink is passed to multiple useActionStates, and
|
|
// they all have the same action signature, Fizz will pass the postback
|
|
// state to all of them. We should probably only pass it to the first one,
|
|
// and/or warn.
|
|
|
|
// The key is lazily generated and deduped so the that the keypath doesn't
|
|
// get JSON.stringify-ed unnecessarily, and at most once.
|
|
let nextPostbackStateKey = null;
|
|
|
|
// Determine the current form state. If we received state during an MPA form
|
|
// submission, then we will reuse that, if the action identity matches.
|
|
// Otherwise, we'll use the initial state argument. We will emit a comment
|
|
// marker into the stream that indicates whether the state was reused.
|
|
let state = initialState;
|
|
const componentKeyPath = (currentlyRenderingKeyPath: any);
|
|
const postbackActionState = getFormState(request);
|
|
// $FlowIgnore[prop-missing]
|
|
const isSignatureEqual = action.$$IS_SIGNATURE_EQUAL;
|
|
if (
|
|
postbackActionState !== null &&
|
|
typeof isSignatureEqual === 'function'
|
|
) {
|
|
const postbackKey = postbackActionState[1];
|
|
const postbackReferenceId = postbackActionState[2];
|
|
const postbackBoundArity = postbackActionState[3];
|
|
if (
|
|
isSignatureEqual.call(action, postbackReferenceId, postbackBoundArity)
|
|
) {
|
|
nextPostbackStateKey = createPostbackActionStateKey(
|
|
permalink,
|
|
componentKeyPath,
|
|
actionStateHookIndex,
|
|
);
|
|
if (postbackKey === nextPostbackStateKey) {
|
|
// This was a match
|
|
actionStateMatchingIndex = actionStateHookIndex;
|
|
// Reuse the state that was submitted by the form.
|
|
state = postbackActionState[0];
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 => {
|
|
boundAction(payload);
|
|
};
|
|
|
|
// $FlowIgnore[prop-missing]
|
|
if (typeof boundAction.$$FORM_ACTION === 'function') {
|
|
// $FlowIgnore[prop-missing]
|
|
dispatch.$$FORM_ACTION = (prefix: string) => {
|
|
const metadata: ReactCustomFormAction =
|
|
boundAction.$$FORM_ACTION(prefix);
|
|
|
|
// Override the action URL
|
|
if (permalink !== undefined) {
|
|
if (__DEV__) {
|
|
checkAttributeStringCoercion(permalink, 'target');
|
|
}
|
|
permalink += '';
|
|
metadata.action = permalink;
|
|
}
|
|
|
|
const formData = metadata.data;
|
|
if (formData) {
|
|
if (nextPostbackStateKey === null) {
|
|
nextPostbackStateKey = createPostbackActionStateKey(
|
|
permalink,
|
|
componentKeyPath,
|
|
actionStateHookIndex,
|
|
);
|
|
}
|
|
formData.append('$ACTION_KEY', nextPostbackStateKey);
|
|
}
|
|
return metadata;
|
|
};
|
|
}
|
|
|
|
return [state, dispatch, false];
|
|
} else {
|
|
// This is not a server action, so the implementation is much simpler.
|
|
|
|
// Bind the state to the first argument of the action.
|
|
const boundAction = action.bind(null, initialState);
|
|
// Wrap the action so the return value is void.
|
|
const dispatch = (payload: P): void => {
|
|
boundAction(payload);
|
|
};
|
|
return [initialState, dispatch, false];
|
|
}
|
|
}
|
|
|
|
function useId(): string {
|
|
const task: Task = (currentlyRenderingTask: any);
|
|
const treeId = getTreeId(task.treeContext);
|
|
|
|
const resumableState = currentResumableState;
|
|
if (resumableState === null) {
|
|
throw new Error(
|
|
'Invalid hook call. Hooks can only be called inside of the body of a function component.',
|
|
);
|
|
}
|
|
|
|
const localId = localIdCounter++;
|
|
return makeId(resumableState, treeId, localId);
|
|
}
|
|
|
|
function use<T>(usable: Usable<T>): T {
|
|
if (usable !== null && typeof usable === 'object') {
|
|
// $FlowFixMe[method-unbinding]
|
|
if (typeof usable.then === 'function') {
|
|
// This is a thenable.
|
|
const thenable: Thenable<T> = (usable: any);
|
|
return unwrapThenable(thenable);
|
|
} else if (usable.$$typeof === REACT_CONTEXT_TYPE) {
|
|
const context: ReactContext<T> = (usable: any);
|
|
return readContext(context);
|
|
}
|
|
}
|
|
|
|
// eslint-disable-next-line react-internal/safe-string-coercion
|
|
throw new Error('An unsupported type was passed to use(): ' + String(usable));
|
|
}
|
|
|
|
export function unwrapThenable<T>(thenable: Thenable<T>): T {
|
|
const index = thenableIndexCounter;
|
|
thenableIndexCounter += 1;
|
|
if (thenableState === null) {
|
|
thenableState = createThenableState();
|
|
}
|
|
return trackUsedThenable(thenableState, thenable, index);
|
|
}
|
|
|
|
export function readPreviousThenableFromState<T>(): T | void {
|
|
const index = thenableIndexCounter;
|
|
thenableIndexCounter += 1;
|
|
if (thenableState === null) {
|
|
return undefined;
|
|
}
|
|
return readPreviousThenable(thenableState, index);
|
|
}
|
|
|
|
function unsupportedRefresh() {
|
|
throw new Error('Cache cannot be refreshed during server rendering.');
|
|
}
|
|
|
|
function useCacheRefresh(): <T>(?() => T, ?T) => void {
|
|
return unsupportedRefresh;
|
|
}
|
|
|
|
function useMemoCache(size: number): Array<any> {
|
|
const data = new Array<any>(size);
|
|
for (let i = 0; i < size; i++) {
|
|
data[i] = REACT_MEMO_CACHE_SENTINEL;
|
|
}
|
|
return data;
|
|
}
|
|
|
|
function noop(): void {}
|
|
|
|
export const HooksDispatcher: Dispatcher = {
|
|
readContext,
|
|
use,
|
|
useContext,
|
|
useMemo,
|
|
useReducer,
|
|
useRef,
|
|
useState,
|
|
useInsertionEffect: noop,
|
|
useLayoutEffect: noop,
|
|
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,
|
|
useId,
|
|
// Subscriptions are not setup in a server environment.
|
|
useSyncExternalStore,
|
|
};
|
|
|
|
if (enableCache) {
|
|
HooksDispatcher.useCacheRefresh = useCacheRefresh;
|
|
}
|
|
if (enableUseEffectEventHook) {
|
|
HooksDispatcher.useEffectEvent = useEffectEvent;
|
|
}
|
|
if (enableUseMemoCacheHook) {
|
|
HooksDispatcher.useMemoCache = useMemoCache;
|
|
}
|
|
if (enableAsyncActions) {
|
|
HooksDispatcher.useHostTransitionStatus = useHostTransitionStatus;
|
|
}
|
|
if (enableAsyncActions) {
|
|
HooksDispatcher.useOptimistic = useOptimistic;
|
|
HooksDispatcher.useFormState = useActionState;
|
|
HooksDispatcher.useActionState = useActionState;
|
|
}
|
|
|
|
export let currentResumableState: null | ResumableState = (null: any);
|
|
export function setCurrentResumableState(
|
|
resumableState: null | ResumableState,
|
|
): void {
|
|
currentResumableState = resumableState;
|
|
}
|