diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index db9495a97dd..555f7046270 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -14,6 +14,7 @@ import type { Usable, Thenable, ReactDebugInfo, + ReactExternalDataSource, } from 'shared/ReactTypes'; import type { ContextDependency, @@ -481,6 +482,13 @@ function useSyncExternalStore( return value; } +function useStore( + store: ReactExternalDataSource, + selector?: (state: S) => T, +): T { + throw new Error('useStore is not yet supported in React Debug Tools.'); +} + function useTransition(): [ boolean, (callback: () => void, options?: StartTransitionOptions) => void, @@ -777,6 +785,7 @@ const Dispatcher: DispatcherType = { useDeferredValue, useTransition, useSyncExternalStore, + useStore, useId, useHostTransitionStatus, useFormState, diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 66a390ebb81..49d68177560 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -14,6 +14,7 @@ import type { Thenable, RejectedThenable, Awaited, + ReactExternalDataSource, } from 'shared/ReactTypes'; import type { Fiber, @@ -39,6 +40,7 @@ import { enableSchedulingProfiler, enableTransitionTracing, enableUseEffectEventHook, + enableStore, enableLegacyCache, disableLegacyMode, enableNoCloningMemoCache, @@ -74,6 +76,8 @@ import { isGestureRender, GestureLane, UpdateLanes, + includesTransitionLane, + SomeTransitionLane, } from './ReactFiberLane'; import { ContinuousEventPriority, @@ -161,6 +165,8 @@ import {requestCurrentTransition} from './ReactFiberTransition'; import {callComponentInDEV} from './ReactFiberCallUserSpace'; import {scheduleGesture} from './ReactFiberGestureScheduler'; +import type {StoreWrapper} from './ReactFiberStoreTracking'; // Ensure StoreTracking is loaded} from './ReactFiberStoreTracking'; // Ensure StoreTracking is loaded +import {StoreTracker} from './ReactFiberStoreTracking'; // Ensure StoreTracking is loaded export type Update = { lane: Lane, @@ -1808,6 +1814,265 @@ function updateSyncExternalStore( return nextSnapshot; } +function identity(x: T): T { + return x; +} + +function storeReducerPlaceholder(state: S, action: A): S { + // This reducer is never called because we handle updates in the subscription. + throw new Error( + 'storeReducer should never be called. This is a bug in React. Please file an issue.', + ); +} + +type UseStoreArgs = { + wrapper: StoreWrapper, + selector: S => T, +}; + +function mountStore( + store: ReactExternalDataSource, + selector?: S => T, +): T { + const actualSelector: S => T = + selector === undefined ? (identity: any) : selector; + const root = ((getWorkInProgressRoot(): any): FiberRoot); + if (root.storeTracker === null) { + root.storeTracker = new StoreTracker(); + } + + const wrapper = root.storeTracker.getWrapper(store); + const fiber = currentlyRenderingFiber; + const storeState = wrapper.getStateForLanes(renderLanes); + + const initialState = actualSelector(storeState); + + const hook = mountWorkInProgressHook(); + + const hookArgs: UseStoreArgs = { + wrapper, + selector: actualSelector, + }; + + hook.memoizedState = hook.baseState = initialState; + const queue: UpdateQueue = { + pending: null, + lanes: NoLanes, + // Hack: We don't use dispatch for anything, so we can repurpose + // it to store the args for access inside updateStore. + dispatch: hookArgs as any, + lastRenderedReducer: storeReducerPlaceholder, + lastRenderedState: (initialState: any), + }; + hook.queue = queue; + + mountEffect( + createSubscription.bind( + null, + wrapper, + fiber, + actualSelector, + queue, + storeState, + ), + [actualSelector, wrapper], + ); + + return initialState; +} + +function updateStore( + store: ReactExternalDataSource, + selector?: S => T, +): T { + const actualSelector: S => T = + selector === undefined ? (identity: any) : selector; + const root = ((getWorkInProgressRoot(): any): FiberRoot); + if (root.storeTracker === null) { + root.storeTracker = new StoreTracker(); + } + + const wrapper = root.storeTracker.getWrapper(store); + const hook = updateWorkInProgressHook(); + const storeState = wrapper.getStateForLanes(renderLanes); + const [state, _previousArgs] = updateReducerImpl( + hook, + ((currentHook: any): Hook), + storeReducerPlaceholder, + ); + const previousArgs: UseStoreArgs = (_previousArgs: any); + + const fiber = currentlyRenderingFiber; + const queue = hook.queue; + + updateEffect( + createSubscription.bind( + null, + wrapper, + fiber, + actualSelector, + queue, + storeState, + ), + [actualSelector, wrapper], + ); + + // If the arguments have changed since last render, our hook/queue state is + // invalid. + if ( + previousArgs.selector !== actualSelector || + previousArgs.wrapper !== wrapper + ) { + queue.dispatch = {wrapper, selector: actualSelector}; + return (hook.memoizedState = queue.lastRenderedState = + actualSelector(storeState)); + } + + return state; +} + +// Subscribes to the store and ensures updates are scheduled for any pending +// transitions. +function createSubscription( + storeWrapper: StoreWrapper, + fiber: Fiber, + selector: S => T, + queue: UpdateQueue, + storeState: S, +): () => void { + // If we are mounting mid-transition, we need to schedule an update to + // bring the selected state up to date with the transition state. + const mountTransitionState = + storeWrapper.getStateForLanes(SomeTransitionLane); + if (!is(storeState, mountTransitionState)) { + const newState = selector(mountTransitionState); + // TODO: It's possible this is the same as the existing mount state. In that + // case we could avoid triggering a redundant update. + const lane = SomeTransitionLane; + const update: Update = { + lane, + revertLane: NoLane, + gesture: null, + action: null, + hasEagerState: true, + eagerState: newState, + next: (null: any), + }; + + const updateRoot = enqueueConcurrentHookUpdate(fiber, queue, update, lane); + if (updateRoot !== null) { + startUpdateTimerByLane( + lane, + 'useStore mount mid transition fixup', + fiber, + ); + scheduleUpdateOnFiber(updateRoot, fiber, lane); + entangleTransitionUpdate(updateRoot, queue, lane); + } + } + return storeWrapper.subscribe( + handleStoreSubscriptionChange.bind( + null, + fiber, + queue, + storeWrapper, + selector, + ), + ); +} + +function handleStoreSubscriptionChange( + fiber: Fiber, + queue: UpdateQueue, + storeWrapper: StoreWrapper, + selector: (state: S) => T, +): void { + const lane = requestUpdateLane(fiber); + // Eagerly compute the new selected state + const newState = selector(storeWrapper.getStateForLanes(lane)); + + if ( + queue.lanes === NoLanes && + queue.pending === null && + is(newState, queue.lastRenderedState) + ) { + // Our last render is current and there are no other updates pending. If the + // state is unchanged, we don't need to rerender. + + // In other similar places we call `enqueueConcurrentHookUpdateAndEagerlyBailout` + // but we don't need to here because we don't use update ordering to + // manage rebasing, we do it ourselves eagerly. + return; + } + + const update: Update = { + lane, + revertLane: NoLane, + gesture: null, + action: null, + hasEagerState: true, + eagerState: newState, + next: (null: any), + }; + + const hasQueuedTransitionUpdate = includesTransitionLane(queue.lanes); + + const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane); + if (root !== null) { + startUpdateTimerByLane(lane, 'store.dispatch()', fiber); + scheduleUpdateOnFiber(root, fiber, lane); + entangleTransitionUpdate(root, queue, lane); + } + + // The way React's update ordering works is that for each render we apply + // the updates for that render's lane, and skip over any updates that don't + // have sufficient priority. For normal reducer updates this means that we + // will: + // 1. Apply a sync update on top of the currently committed state. + // 2. Reattempt the pending transition update, this time with the sync + // update applied on top. + // + // However, we don't want each individual component's update to have to + // re-rerun the store's reducer in order to achieve this update reordering. + // Instead, if we know there is a pending transition update, we simply + // enqueue yet another transition update on top. + // The sync render will ignore this update but the subsequent transition render + // will apply it, giving us the desired final state. + // + // Ideally we could define a custom approach for store selector states, but + // for now this lets us reuse all of the very complex updateReducerImpl logic + // without changes. + if (hasQueuedTransitionUpdate && !isTransitionLane(lane)) { + // Current entanglement semantics mean we can pick an arbitrary transition + // lane here and be sure the update will get entangled with any/all other + // transitions. + const transitionLane = SomeTransitionLane; + const transitionState = selector( + storeWrapper.getStateForLanes(transitionLane), + ); + const transitionUpdate: Update = { + lane: transitionLane, + revertLane: NoLane, + gesture: null, + action: null, + hasEagerState: true, + eagerState: transitionState, + next: (null: any), + }; + const transitionRoot = enqueueConcurrentHookUpdate( + fiber, + queue, + transitionUpdate, + transitionLane, + ); + if (transitionRoot !== null) { + startUpdateTimerByLane(transitionLane, 'store.dispatch()', fiber); + scheduleUpdateOnFiber(transitionRoot, fiber, transitionLane); + entangleTransitionUpdate(transitionRoot, queue, transitionLane); + } + } +} + function pushStoreConsistencyCheck( fiber: Fiber, getSnapshot: () => T, @@ -1847,7 +2112,7 @@ function updateStoreInstance( // Something may have been mutated in between render and commit. This could // have been in an event that fired before the passive effects, or it could // have been in a layout effect. In that case, we would have used the old - // snapsho and getSnapshot values to bail out. We need to check one more time. + // snapshot and getSnapshot values to bail out. We need to check one more time. if (checkIfSnapshotChanged(inst)) { // Force a re-render. // We intentionally don't log update times and stacks here because this @@ -3890,6 +4155,9 @@ export const ContextOnlyDispatcher: Dispatcher = { if (enableUseEffectEventHook) { (ContextOnlyDispatcher: Dispatcher).useEffectEvent = throwInvalidHookError; } +if (enableStore) { + (ContextOnlyDispatcher: Dispatcher).useStore = throwInvalidHookError; +} const HooksDispatcherOnMount: Dispatcher = { readContext, @@ -3920,6 +4188,9 @@ const HooksDispatcherOnMount: Dispatcher = { if (enableUseEffectEventHook) { (HooksDispatcherOnMount: Dispatcher).useEffectEvent = mountEvent; } +if (enableStore) { + (HooksDispatcherOnMount: Dispatcher).useStore = mountStore; +} const HooksDispatcherOnUpdate: Dispatcher = { readContext, @@ -3950,6 +4221,9 @@ const HooksDispatcherOnUpdate: Dispatcher = { if (enableUseEffectEventHook) { (HooksDispatcherOnUpdate: Dispatcher).useEffectEvent = updateEvent; } +if (enableStore) { + (HooksDispatcherOnUpdate: Dispatcher).useStore = updateStore; +} const HooksDispatcherOnRerender: Dispatcher = { readContext, @@ -3980,6 +4254,9 @@ const HooksDispatcherOnRerender: Dispatcher = { if (enableUseEffectEventHook) { (HooksDispatcherOnRerender: Dispatcher).useEffectEvent = updateEvent; } +if (enableStore) { + (HooksDispatcherOnRerender: Dispatcher).useStore = updateStore; +} let HooksDispatcherOnMountInDEV: Dispatcher | null = null; let HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher | null = null; @@ -4130,6 +4407,14 @@ if (__DEV__) { mountHookTypesDev(); return mountSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, + useStore( + store: ReactExternalDataSource, + selector?: (state: S) => T, + ): T { + currentHookNameInDev = 'useStore'; + mountHookTypesDev(); + return mountStore(store, selector); + }, useId(): string { currentHookNameInDev = 'useId'; mountHookTypesDev(); @@ -4180,6 +4465,16 @@ if (__DEV__) { return mountEvent(callback); }; } + if (enableStore) { + (HooksDispatcherOnMountInDEV: Dispatcher).useStore = function useStore< + S, + T, + >(store: ReactExternalDataSource, selector?: (state: S) => T): T { + currentHookNameInDev = 'useStore'; + mountHookTypesDev(); + return mountStore(store, selector); + }; + } HooksDispatcherOnMountWithHookTypesInDEV = { readContext(context: ReactContext): T { @@ -4297,6 +4592,14 @@ if (__DEV__) { updateHookTypesDev(); return mountSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, + useStore( + store: ReactExternalDataSource, + selector?: (state: S) => T, + ): T { + currentHookNameInDev = 'useStore'; + updateHookTypesDev(); + return mountStore(store, selector); + }, useId(): string { currentHookNameInDev = 'useId'; updateHookTypesDev(); @@ -4347,6 +4650,17 @@ if (__DEV__) { return mountEvent(callback); }; } + if (enableStore) { + (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useStore = + function useStore( + store: ReactExternalDataSource, + selector?: (state: S) => T, + ): T { + currentHookNameInDev = 'useStore'; + updateHookTypesDev(); + return mountStore(store, selector); + }; + } HooksDispatcherOnUpdateInDEV = { readContext(context: ReactContext): T { @@ -4464,6 +4778,14 @@ if (__DEV__) { updateHookTypesDev(); return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, + useStore( + store: ReactExternalDataSource, + selector?: (state: S) => T, + ): T { + currentHookNameInDev = 'useStore'; + updateHookTypesDev(); + return updateStore(store, selector); + }, useId(): string { currentHookNameInDev = 'useId'; updateHookTypesDev(); @@ -4514,6 +4836,16 @@ if (__DEV__) { return updateEvent(callback); }; } + if (enableStore) { + (HooksDispatcherOnUpdateInDEV: Dispatcher).useStore = function useStore< + S, + T, + >(store: ReactExternalDataSource, selector?: (state: S) => T): T { + currentHookNameInDev = 'useStore'; + updateHookTypesDev(); + return updateStore(store, selector); + }; + } HooksDispatcherOnRerenderInDEV = { readContext(context: ReactContext): T { @@ -4631,6 +4963,14 @@ if (__DEV__) { updateHookTypesDev(); return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, + useStore( + store: ReactExternalDataSource, + selector?: (state: S) => T, + ): T { + currentHookNameInDev = 'useStore'; + updateHookTypesDev(); + return updateStore(store, selector); + }, useId(): string { currentHookNameInDev = 'useId'; updateHookTypesDev(); @@ -4681,6 +5021,16 @@ if (__DEV__) { return updateEvent(callback); }; } + if (enableStore) { + (HooksDispatcherOnRerenderInDEV: Dispatcher).useStore = function useStore< + S, + T, + >(store: ReactExternalDataSource, selector?: (state: S) => T): T { + currentHookNameInDev = 'useStore'; + updateHookTypesDev(); + return updateStore(store, selector); + }; + } InvalidNestedHooksDispatcherOnMountInDEV = { readContext(context: ReactContext): T { @@ -4816,6 +5166,15 @@ if (__DEV__) { mountHookTypesDev(); return mountSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, + useStore( + store: ReactExternalDataSource, + selector?: (state: S) => T, + ): T { + currentHookNameInDev = 'useStore'; + warnInvalidHookAccess(); + mountHookTypesDev(); + return mountStore(store, selector); + }, useId(): string { currentHookNameInDev = 'useId'; warnInvalidHookAccess(); @@ -4873,6 +5232,18 @@ if (__DEV__) { return mountEvent(callback); }; } + if (enableStore) { + (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useStore = + function useStore( + store: ReactExternalDataSource, + selector?: (state: S) => T, + ): T { + currentHookNameInDev = 'useStore'; + warnInvalidHookAccess(); + mountHookTypesDev(); + return mountStore(store, selector); + }; + } InvalidNestedHooksDispatcherOnUpdateInDEV = { readContext(context: ReactContext): T { @@ -5008,6 +5379,15 @@ if (__DEV__) { updateHookTypesDev(); return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, + useStore( + store: ReactExternalDataSource, + selector?: (state: S) => T, + ): T { + currentHookNameInDev = 'useStore'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateStore(store, selector); + }, useId(): string { currentHookNameInDev = 'useId'; warnInvalidHookAccess(); @@ -5065,6 +5445,18 @@ if (__DEV__) { return updateEvent(callback); }; } + if (enableStore) { + (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useStore = + function useStore( + store: ReactExternalDataSource, + selector?: (state: S) => T, + ): T { + currentHookNameInDev = 'useStore'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateStore(store, selector); + }; + } InvalidNestedHooksDispatcherOnRerenderInDEV = { readContext(context: ReactContext): T { @@ -5200,6 +5592,15 @@ if (__DEV__) { updateHookTypesDev(); return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, + useStore( + store: ReactExternalDataSource, + selector?: (state: S) => T, + ): T { + currentHookNameInDev = 'useStore'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateStore(store, selector); + }, useId(): string { currentHookNameInDev = 'useId'; warnInvalidHookAccess(); @@ -5257,4 +5658,16 @@ if (__DEV__) { return updateEvent(callback); }; } + if (enableStore) { + (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useStore = + function useStore( + store: ReactExternalDataSource, + selector?: (state: S) => T, + ): T { + currentHookNameInDev = 'useStore'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateStore(store, selector); + }; + } } diff --git a/packages/react-reconciler/src/ReactFiberRoot.js b/packages/react-reconciler/src/ReactFiberRoot.js index 908893db948..448bd80e9af 100644 --- a/packages/react-reconciler/src/ReactFiberRoot.js +++ b/packages/react-reconciler/src/ReactFiberRoot.js @@ -68,6 +68,7 @@ function FiberRootNode( this.timeoutHandle = noTimeout; this.cancelPendingCommit = null; this.context = null; + this.storeTracker = null; this.pendingContext = null; this.next = null; this.callbackNode = null; diff --git a/packages/react-reconciler/src/ReactFiberStoreTracking.js b/packages/react-reconciler/src/ReactFiberStoreTracking.js new file mode 100644 index 00000000000..22bddebb383 --- /dev/null +++ b/packages/react-reconciler/src/ReactFiberStoreTracking.js @@ -0,0 +1,108 @@ +/** + * 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 {Lanes} from './ReactFiberLane'; +import type {ReactExternalDataSource} from 'shared/ReactTypes'; + +import {includesTransitionLane} from './ReactFiberLane'; +import ReactSharedInternals from 'shared/ReactSharedInternals'; +import is from 'shared/objectIs'; + +// Wraps/subscribes to a store and tracks its state(s) for a given React root. +export class StoreWrapper { + _committedState: S; + _headState: S; + _unsubscribe: () => void; + _listeners: Set<() => void>; + store: ReactExternalDataSource; + constructor(store: ReactExternalDataSource) { + this._headState = this._committedState = store.getState(); + this._listeners = new Set(); + this._unsubscribe = store.subscribe(action => { + this.handleUpdate(action); + }); + this.store = store; + } + handleUpdate(action: A) { + const transitionState = this._headState; + const currentState = this._committedState; + this._headState = this.store.getState(); + + if (ReactSharedInternals.T !== null) { + // We are in a transition, update the transition state only + } else if (is(transitionState, currentState)) { + // We are updating sync and no transition is in progress, update both + this._committedState = this._headState; + } else { + // We are updating sync, but a transition is in progress. Implement + // React's update reordering semantics. + this._committedState = this.store.reducer(this._committedState, action); + } + // Notify all subscribed fibers + this._listeners.forEach(listener => listener()); + } + getStateForLanes(lanes: Lanes): S { + const isTransition = includesTransitionLane(lanes); + return isTransition ? this._headState : this._committedState; + } + subscribe(callback: () => void): () => void { + this._listeners.add(callback); + return () => { + this._listeners.delete(callback); + }; + } + commitFinished(lanes: Lanes) { + this._committedState = this.getStateForLanes(lanes); + } + dispose() { + this._unsubscribe(); + } +} + +type StoreWrapperInfo = { + wrapper: StoreWrapper, + references: number, +}; + +// Used by a React root to track the stores referenced by its fibers. +export class StoreTracker { + stores: Map, StoreWrapperInfo>; + + constructor() { + this.stores = new Map(); + } + + commitFinished(lanes: Lanes) { + this.stores.forEach(({wrapper}) => { + wrapper.commitFinished(lanes); + }); + } + + getWrapper(store: ReactExternalDataSource): StoreWrapper { + const info = this.stores.get(store); + if (info !== undefined) { + info.references++; + return info.wrapper; + } + const wrapper = new StoreWrapper(store); + this.stores.set(store, {references: 1, wrapper}); + return wrapper; + } + + remove(store: ReactExternalDataSource): void { + const info = this.stores.get(store); + if (info !== undefined) { + info.references--; + if (info.references === 0) { + info.wrapper.dispose(); + this.stores.delete(store); + } + } + } +} diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index f7200458be1..a834d15fe78 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -3431,6 +3431,11 @@ function commitRoot( ): void { root.cancelPendingCommit = null; + // TODO: Where exactly should this live? + if (root.storeTracker !== null) { + root.storeTracker.commitFinished(lanes); + } + do { // `flushPassiveEffects` will call `flushSyncUpdateQueue` at the end, which // means `flushPassiveEffects` will sometimes result in additional diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index 775b69d211f..cd7d11645d3 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -18,6 +18,7 @@ import type { ReactComponentInfo, ReactDebugInfo, ReactKey, + ReactExternalDataSource, } from 'shared/ReactTypes'; import type {TransitionTypes} from 'react/src/ReactTransitionType'; import type {WorkTag} from './ReactWorkTags'; @@ -41,6 +42,7 @@ import type {ConcurrentUpdate} from './ReactFiberConcurrentUpdates'; import type {ComponentStackNode} from 'react-server/src/ReactFizzComponentStack'; import type {ThenableState} from './ReactFiberThenable'; import type {ScheduledGesture} from './ReactFiberGestureScheduler'; +import type {StoreTracker} from './ReactFiberStoreTracking'; // Unwind Circular: moved from ReactFiberHooks.old export type HookType = @@ -59,6 +61,7 @@ export type HookType = | 'useDeferredValue' | 'useTransition' | 'useSyncExternalStore' + | 'useStore' | 'useId' | 'useCacheRefresh' | 'useOptimistic' @@ -231,6 +234,8 @@ type BaseFiberRootProperties = { cancelPendingCommit: null | (() => void), // Top context object, used by renderSubtreeIntoContainer context: Object | null, + + storeTracker: StoreTracker | null, pendingContext: Object | null, // Used to create a linked list that represent all the roots that have @@ -438,6 +443,11 @@ export type Dispatcher = { getSnapshot: () => T, getServerSnapshot?: () => T, ): T, + // TODO: Non-nullable once `enableStore` is on everywhere. + useStore?: ( + store: ReactExternalDataSource, + selector?: (state: S) => T, + ) => S | T, useId(): string, useCacheRefresh: () => (?() => T, ?T) => void, useMemoCache: (size: number) => Array, diff --git a/packages/react-reconciler/src/ReactStore.js b/packages/react-reconciler/src/ReactStore.js new file mode 100644 index 00000000000..6fc08da5d81 --- /dev/null +++ b/packages/react-reconciler/src/ReactStore.js @@ -0,0 +1,56 @@ +/** + * 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 {ReactStore} from 'shared/ReactTypes'; +import {enableStore} from 'shared/ReactFeatureFlags'; + +function defaultReducer(state: S, action: S | ((prev: S) => S)): S { + if (typeof action === 'function') { + // State value itself is not allowed to be a function, so we can safely + // assume we are in the `(prev: S) => S` case here. + // $FlowFixMe[incompatible-use] + return action(state); + } + return action; +} + +declare function createStore(initialValue: S): ReactStore S>; + +export function createStore( + initialValue: S, + reducer?: (S, A) => S, +): ReactStore { + if (!enableStore) { + throw new Error( + 'createStore is not available because the enableStore feature flag is not enabled.', + ); + } + const actualReducer = reducer ?? (defaultReducer: any); + + const subscriptions = new Set<(action: A) => void>(); + + let state = initialValue; + + return { + getState(): S { + return state; + }, + reducer: actualReducer, + dispatch(action: A) { + state = actualReducer(state, action); + subscriptions.forEach(callback => callback(action)); + }, + subscribe(callback: (action: A) => void): () => void { + subscriptions.add(callback); + return () => { + subscriptions.delete(callback); + }; + }, + }; +} diff --git a/packages/react-reconciler/src/__tests__/useStore-test.js b/packages/react-reconciler/src/__tests__/useStore-test.js new file mode 100644 index 00000000000..24282876b2d --- /dev/null +++ b/packages/react-reconciler/src/__tests__/useStore-test.js @@ -0,0 +1,1542 @@ +/** + * 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. + * + * @emails react-core + * @gate enableStore + */ + +'use strict'; + +let useStore; +let React; +let ReactNoop; +let Scheduler; +let act; +let createStore; +let startTransition; +let waitFor; +let assertLog; +let useLayoutEffect; +let useEffect; +let use; +let Suspense; + +describe('useStore', () => { + beforeEach(() => { + jest.resetModules(); + + act = require('internal-test-utils').act; + React = require('react'); + ReactNoop = require('react-noop-renderer'); + Scheduler = require('scheduler'); + createStore = React.createStore; + useStore = React.useStore; + useLayoutEffect = React.useLayoutEffect; + useEffect = React.useEffect; + use = React.use; + Suspense = React.Suspense; + startTransition = React.startTransition; + const InternalTestUtils = require('internal-test-utils'); + waitFor = InternalTestUtils.waitFor; + assertLog = InternalTestUtils.assertLog; + }); + + it('simplest use', async () => { + const store = createStore(2); + + function App() { + const value = useStore(store); + Scheduler.log({kind: 'render', value}); + return <>{value}; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + startTransition(() => { + root.render(); + }); + await waitFor([{kind: 'render', value: 2}]); + }); + expect(root).toMatchRenderedOutput('2'); + + await act(async () => { + startTransition(() => { + store.dispatch(3); + }); + }); + assertLog([{kind: 'render', value: 3}]); + expect(root).toMatchRenderedOutput('3'); + + await act(async () => { + startTransition(() => { + store.dispatch(4); + }); + }); + assertLog([{kind: 'render', value: 4}]); + expect(root).toMatchRenderedOutput('4'); + }); + + it('simplest use (updater function)', async () => { + const store = createStore(2); + + function App() { + const value = useStore(store); + Scheduler.log({kind: 'render', value}); + return <>{value}; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + startTransition(() => { + root.render(); + }); + await waitFor([{kind: 'render', value: 2}]); + }); + expect(root).toMatchRenderedOutput('2'); + + await act(async () => { + startTransition(() => { + store.dispatch(n => n + 1); + }); + }); + assertLog([{kind: 'render', value: 3}]); + expect(root).toMatchRenderedOutput('3'); + + await act(async () => { + startTransition(() => { + store.dispatch(n => n + 1); + }); + }); + assertLog([{kind: 'render', value: 4}]); + expect(root).toMatchRenderedOutput('4'); + }); + it('useStore', async () => { + function counterReducer( + count: number, + action: {type: 'increment'}, + ): number { + Scheduler.log({kind: 'reducer', state: count, action: action.type}); + switch (action.type) { + case 'increment': + return count + 1; + default: + return count; + } + } + const store = createStore(2, counterReducer); + + function identity(x) { + Scheduler.log({kind: 'selector', state: x}); + return x; + } + + function App() { + const value = useStore(store, identity); + Scheduler.log({kind: 'render', value}); + return <>{value}; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + startTransition(() => { + root.render(); + }); + await waitFor([ + {kind: 'selector', state: 2}, + {kind: 'render', value: 2}, + ]); + }); + expect(root).toMatchRenderedOutput('2'); + + await act(async () => { + startTransition(() => { + store.dispatch({type: 'increment'}); + }); + }); + assertLog([ + {kind: 'reducer', state: 2, action: 'increment'}, + {kind: 'selector', state: 3}, + {kind: 'render', value: 3}, + ]); + expect(root).toMatchRenderedOutput('3'); + + await act(async () => { + startTransition(() => { + store.dispatch({type: 'increment'}); + }); + }); + assertLog([ + {kind: 'reducer', state: 3, action: 'increment'}, + {kind: 'selector', state: 4}, + {kind: 'render', value: 4}, + ]); + expect(root).toMatchRenderedOutput('4'); + }); + + it('useStore (no selector)', async () => { + function counterReducer( + count: number, + action: {type: 'increment'}, + ): number { + Scheduler.log({kind: 'reducer', state: count, action: action.type}); + switch (action.type) { + case 'increment': + return count + 1; + default: + return count; + } + } + const store = createStore(2, counterReducer); + + function App() { + const value = useStore(store); + Scheduler.log({kind: 'render', value}); + return <>{value}; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + startTransition(() => { + root.render(); + }); + await waitFor([{kind: 'render', value: 2}]); + }); + expect(root).toMatchRenderedOutput('2'); + + await act(async () => { + startTransition(() => { + store.dispatch({type: 'increment'}); + }); + }); + assertLog([ + {kind: 'reducer', state: 2, action: 'increment'}, + {kind: 'render', value: 3}, + ]); + expect(root).toMatchRenderedOutput('3'); + + await act(async () => { + startTransition(() => { + store.dispatch({type: 'increment'}); + }); + }); + assertLog([ + {kind: 'reducer', state: 3, action: 'increment'}, + {kind: 'render', value: 4}, + ]); + expect(root).toMatchRenderedOutput('4'); + }); + it('sync update interrupts transition update', async () => { + function counterReducer( + count: number, + action: {type: 'increment' | 'double'}, + ): number { + Scheduler.log({kind: 'reducer', state: count, action: action.type}); + switch (action.type) { + case 'increment': + return count + 1; + case 'double': + return count * 2; + default: + return count; + } + } + const store = createStore(2, counterReducer); + + function identity(x) { + Scheduler.log({kind: 'selector', state: x}); + return x; + } + + function App() { + const value = useStore(store, identity); + Scheduler.log({kind: 'render', value}); + return <>{value}; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + startTransition(() => { + root.render(); + }); + await waitFor([ + {kind: 'selector', state: 2}, + {kind: 'render', value: 2}, + ]); + }); + expect(root).toMatchRenderedOutput('2'); + + let resolve; + + await act(async () => { + await startTransition(async () => { + store.dispatch({type: 'increment'}); + await new Promise(r => (resolve = r)); + }); + }); + + assertLog([ + {kind: 'reducer', state: 2, action: 'increment'}, + {kind: 'selector', state: 3}, + ]); + expect(root).toMatchRenderedOutput('2'); + + await act(async () => { + store.dispatch({type: 'double'}); + }); + + assertLog([ + {kind: 'reducer', state: 3, action: 'double'}, + {kind: 'reducer', state: 2, action: 'double'}, + {kind: 'selector', state: 4}, + {kind: 'selector', state: 6}, + {kind: 'render', value: 4}, + ]); + expect(root).toMatchRenderedOutput('4'); + + await act(async () => { + resolve(); + }); + + assertLog([{kind: 'render', value: 6}]); + expect(root).toMatchRenderedOutput('6'); + }); + it('store reader mounts mid transition', async () => { + function counterReducer( + count: number, + action: {type: 'increment'}, + ): number { + Scheduler.log({kind: 'reducer', state: count, action: action.type}); + switch (action.type) { + case 'increment': + return count + 1; + default: + return count; + } + } + const store = createStore(2, counterReducer); + function identity(x) { + Scheduler.log({kind: 'selector', state: x}); + return x; + } + + function StoreReader({componentName}) { + const value = useStore(store, identity); + Scheduler.log({kind: 'render', value, componentName}); + return <>{value}; + } + + let setShowReader; + + function App() { + const [showReader, _setShowReader] = React.useState(false); + setShowReader = _setShowReader; + return ( + <> + + {showReader ? : null} + + ); + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(); + await waitFor([ + {kind: 'selector', state: 2}, + {kind: 'render', value: 2, componentName: 'stable'}, + ]); + }); + + expect(root).toMatchRenderedOutput('2'); + + let resolve; + + await act(async () => { + await startTransition(async () => { + store.dispatch({type: 'increment'}); + await new Promise(r => (resolve = r)); + }); + }); + + assertLog([ + {kind: 'reducer', state: 2, action: 'increment'}, + {kind: 'selector', state: 3}, + ]); + expect(root).toMatchRenderedOutput('2'); + + await act(async () => { + setShowReader(true); + }); + + assertLog([ + {kind: 'render', componentName: 'stable', value: 2}, + {kind: 'selector', state: 2}, + {kind: 'render', componentName: 'conditional', value: 2}, + {kind: 'selector', state: 3}, + ]); + + expect(root).toMatchRenderedOutput('22'); + + await act(async () => { + resolve(); + }); + + assertLog([ + {kind: 'render', componentName: 'stable', value: 3}, + {kind: 'render', componentName: 'conditional', value: 3}, + ]); + expect(root).toMatchRenderedOutput('33'); + }); + + it('store reader mounts as a result of store update in transition', async () => { + function counterReducer( + count: number, + action: {type: 'increment'}, + ): number { + Scheduler.log({kind: 'reducer', state: count, action: action.type}); + switch (action.type) { + case 'increment': + return count + 1; + default: + return count; + } + } + const store = createStore(2, counterReducer); + + function identity(x) { + Scheduler.log({kind: 'selector', state: x}); + return x; + } + + function StoreReader({componentName}) { + const value = useStore(store, identity); + Scheduler.log({kind: 'render', value, componentName}); + return <>{value}; + } + + function App() { + const value = useStore(store, identity); + Scheduler.log({kind: 'render', value, componentName: 'App'}); + return ( + <> + {value} + {value > 2 ? : null} + + ); + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(); + await waitFor([ + {kind: 'selector', state: 2}, + {kind: 'render', value: 2, componentName: 'App'}, + ]); + }); + + expect(root).toMatchRenderedOutput('2'); + + let resolve; + + await act(async () => { + await startTransition(async () => { + store.dispatch({type: 'increment'}); + await new Promise(r => (resolve = r)); + }); + }); + + assertLog([ + {kind: 'reducer', state: 2, action: 'increment'}, + {kind: 'selector', state: 3}, + ]); + expect(root).toMatchRenderedOutput('2'); + + await act(async () => { + resolve(); + }); + + assertLog([ + {kind: 'render', componentName: 'App', value: 3}, + {kind: 'selector', state: 3}, + {kind: 'render', componentName: 'conditional', value: 3}, + ]); + expect(root).toMatchRenderedOutput('33'); + }); + it('After transition update commits, new mounters mount with up-to-date state', async () => { + function counterReducer( + count: number, + action: {type: 'increment' | 'double'}, + ): number { + Scheduler.log({kind: 'reducer', state: count, action: action.type}); + switch (action.type) { + case 'increment': + return count + 1; + case 'double': + return count * 2; + default: + return count; + } + } + const store = createStore(2, counterReducer); + + function identity(x) { + Scheduler.log({kind: 'selector', state: x}); + return x; + } + + function StoreReader({componentName}) { + const value = useStore(store, identity); + Scheduler.log({kind: 'render', value, componentName}); + return <>{value}; + } + + let setShowReader; + + function App() { + const [showReader, _setShowReader] = React.useState(false); + setShowReader = _setShowReader; + return ( + <> + + {showReader ? : null} + + ); + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(); + await waitFor([ + {kind: 'selector', state: 2}, + {kind: 'render', value: 2, componentName: 'stable'}, + ]); + }); + + expect(root).toMatchRenderedOutput('2'); + + let resolve; + + await act(async () => { + await startTransition(async () => { + store.dispatch({type: 'increment'}); + await new Promise(r => (resolve = r)); + }); + }); + + assertLog([ + {kind: 'reducer', state: 2, action: 'increment'}, + {kind: 'selector', state: 3}, + ]); + + expect(root).toMatchRenderedOutput('2'); + + await act(async () => { + store.dispatch({type: 'double'}); + }); + assertLog([ + {kind: 'reducer', state: 3, action: 'double'}, + {kind: 'reducer', state: 2, action: 'double'}, + {kind: 'selector', state: 4}, + {kind: 'selector', state: 6}, + {kind: 'render', value: 4, componentName: 'stable'}, + ]); + + expect(root).toMatchRenderedOutput('4'); + + await act(async () => { + setShowReader(true); + }); + + assertLog([ + {kind: 'render', value: 4, componentName: 'stable'}, + {kind: 'selector', state: 4}, + {kind: 'render', componentName: 'conditional', value: 4}, + {kind: 'selector', state: 6}, + ]); + + expect(root).toMatchRenderedOutput('44'); + + await act(async () => { + resolve(); + }); + + assertLog([ + {kind: 'render', value: 6, componentName: 'stable'}, + {kind: 'render', componentName: 'conditional', value: 6}, + ]); + }); + it('After mid-transition sync update commits, new mounters mount with up-to-date sync state (but not transition state)', async () => { + function counterReducer( + count: number, + action: {type: 'increment'}, + ): number { + Scheduler.log({kind: 'reducer', state: count, action: action.type}); + switch (action.type) { + case 'increment': + return count + 1; + default: + return count; + } + } + const store = createStore(2, counterReducer); + + function identity(x) { + Scheduler.log({kind: 'selector', state: x}); + return x; + } + + function StoreReader({componentName}) { + const value = useStore(store, identity); + Scheduler.log({kind: 'render', value, componentName}); + return <>{value}; + } + + let setShowReader; + + function App() { + const [showReader, _setShowReader] = React.useState(false); + setShowReader = _setShowReader; + return ( + <> + + {showReader ? : null} + + ); + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(); + await waitFor([ + {kind: 'selector', state: 2}, + {kind: 'render', value: 2, componentName: 'stable'}, + ]); + }); + + expect(root).toMatchRenderedOutput('2'); + + await act(async () => { + startTransition(() => { + store.dispatch({type: 'increment'}); + }); + assertLog([ + {kind: 'reducer', state: 2, action: 'increment'}, + {kind: 'selector', state: 3}, + ]); + await waitFor([{kind: 'render', value: 3, componentName: 'stable'}]); + }); + + expect(root).toMatchRenderedOutput('3'); + + await act(async () => { + setShowReader(true); + }); + + assertLog([ + {kind: 'render', value: 3, componentName: 'stable'}, + {kind: 'selector', state: 3}, + {kind: 'render', componentName: 'conditional', value: 3}, + ]); + expect(root).toMatchRenderedOutput('33'); + }); + it('Component does not rerender if selected value is unchanged', async () => { + function counterReducer(count: number, action: {type: 'double'}): number { + Scheduler.log({kind: 'reducer', state: count, action: action.type}); + switch (action.type) { + case 'double': + return count * 2; + default: + return count; + } + } + const store = createStore(2, counterReducer); + + function isEven(x) { + Scheduler.log({kind: 'selector', state: x}); + return x % 2 === 0; + } + + function App() { + const value = useStore(store, isEven); + Scheduler.log({kind: 'render', value, componentName: 'App'}); + return <>{value ? 'true' : 'false'}; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(); + await waitFor([ + {kind: 'selector', state: 2}, + {kind: 'render', value: true, componentName: 'App'}, + ]); + }); + + expect(root).toMatchRenderedOutput('true'); + + await act(async () => { + store.dispatch({type: 'double'}); + }); + + assertLog([ + {kind: 'reducer', state: 2, action: 'double'}, + {kind: 'selector', state: 4}, + // No rerender since selected value did not change + ]); + + expect(root).toMatchRenderedOutput('true'); + }); + + it('selector changes sync mid transition while store is updating', async () => { + function counterReducer( + count: number, + action: {type: 'increment'}, + ): number { + Scheduler.log({kind: 'reducer', state: count, action: action.type}); + switch (action.type) { + case 'increment': + return count + 1; + default: + return count; + } + } + const store = createStore(2, counterReducer); + + function identity(x) { + Scheduler.log({kind: 'selector:identity', state: x}); + return x; + } + + function doubled(x) { + Scheduler.log({kind: 'selector:doubled', state: x}); + return x * 2; + } + + let setSelector; + + function App() { + const [selector, _setSelector] = React.useState(() => identity); + setSelector = _setSelector; + const value = useStore(store, selector); + Scheduler.log({kind: 'render', value}); + return <>{value}; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(); + await waitFor([ + {kind: 'selector:identity', state: 2}, + {kind: 'render', value: 2}, + ]); + }); + + expect(root).toMatchRenderedOutput('2'); + + let resolve; + + // Start a transition that updates the store + await act(async () => { + await startTransition(async () => { + store.dispatch({type: 'increment'}); + await new Promise(r => (resolve = r)); + }); + }); + + assertLog([ + {kind: 'reducer', state: 2, action: 'increment'}, + {kind: 'selector:identity', state: 3}, + ]); + // Still showing pre-transition state + expect(root).toMatchRenderedOutput('2'); + + // Now change the selector synchronously while transition is pending + await act(async () => { + setSelector(() => doubled); + }); + + // Should sync render with the new selector applied to the pre-transition state (2) + // and also compute the new selector for the transition state (3) + assertLog([ + {kind: 'selector:doubled', state: 2}, + {kind: 'render', value: 4}, + {kind: 'selector:doubled', state: 3}, + ]); + // Rendered with new selector applied to pre-transition state: doubled(2) = 4 + expect(root).toMatchRenderedOutput('4'); + + // Complete the transition + await act(async () => { + resolve(); + }); + + // Should render with new selector applied to transition state: doubled(3) = 6 + assertLog([{kind: 'render', value: 6}]); + expect(root).toMatchRenderedOutput('6'); + }); + + it('store reader mounts after sibling updates state in useLayoutEffect', async () => { + function counterReducer( + count: number, + action: {type: 'increment'}, + ): number { + Scheduler.log({kind: 'reducer', state: count, action: action.type}); + switch (action.type) { + case 'increment': + return count + 1; + default: + return count; + } + } + const store = createStore(2, counterReducer); + + function identity(x) { + Scheduler.log({kind: 'selector', state: x}); + return x; + } + + function StoreUpdater() { + useLayoutEffect(() => { + Scheduler.log({kind: 'layout effect'}); + store.dispatch({type: 'increment'}); + }, []); + Scheduler.log({kind: 'render', componentName: 'StoreUpdater'}); + return null; + } + + function StoreReader() { + const value = useStore(store, identity); + Scheduler.log({kind: 'render', value, componentName: 'StoreReader'}); + return <>{value}; + } + + function App() { + return ( + <> + + + + ); + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(); + }); + + // First render: StoreUpdater renders, StoreReader renders with initial state (2) + // Then useLayoutEffect fires, dispatches increment + // StoreReader re-renders with updated state (3) before mount completes + assertLog([ + {kind: 'render', componentName: 'StoreUpdater'}, + {kind: 'selector', state: 2}, + {kind: 'render', value: 2, componentName: 'StoreReader'}, + {kind: 'layout effect'}, + {kind: 'reducer', state: 2, action: 'increment'}, + {kind: 'selector', state: 3}, + {kind: 'render', value: 3, componentName: 'StoreReader'}, + ]); + expect(root).toMatchRenderedOutput('3'); + }); + + it('store reader mounts after sibling updates state in useEffect', async () => { + function counterReducer( + count: number, + action: {type: 'increment'}, + ): number { + Scheduler.log({kind: 'reducer', state: count, action: action.type}); + switch (action.type) { + case 'increment': + return count + 1; + default: + return count; + } + } + const store = createStore(2, counterReducer); + + function identity(x) { + Scheduler.log({kind: 'selector', state: x}); + return x; + } + + function StoreUpdater() { + useEffect(() => { + Scheduler.log({kind: 'effect'}); + store.dispatch({type: 'increment'}); + }, []); + Scheduler.log({kind: 'render', componentName: 'StoreUpdater'}); + return null; + } + + function StoreReader() { + const value = useStore(store, identity); + Scheduler.log({kind: 'render', value, componentName: 'StoreReader'}); + return <>{value}; + } + + function App() { + return ( + <> + + + + ); + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(); + }); + + // First render: StoreUpdater renders, StoreReader renders with initial state (2) + // Then useLayoutEffect fires, dispatches increment + // StoreReader re-renders with updated state (3) before mount completes + assertLog([ + {kind: 'render', componentName: 'StoreUpdater'}, + {kind: 'selector', state: 2}, + {kind: 'render', value: 2, componentName: 'StoreReader'}, + {kind: 'effect'}, + {kind: 'reducer', state: 2, action: 'increment'}, + {kind: 'selector', state: 3}, + {kind: 'render', value: 3, componentName: 'StoreReader'}, + ]); + expect(root).toMatchRenderedOutput('3'); + }); + + // This test checks an edge case in update short circuiting to ensure we don't incorrectly skip + // the second transition update based on the fact that it matches the "current" state. + it('second transition update reverts state to pre-transition state', async () => { + function counterReducer( + count: number, + action: {type: 'increment' | 'decrement'}, + ): number { + Scheduler.log({kind: 'reducer', state: count, action: action.type}); + switch (action.type) { + case 'increment': + return count + 1; + case 'decrement': + return count - 1; + default: + return count; + } + } + const store = createStore(2, counterReducer); + + function identity(x) { + Scheduler.log({kind: 'selector', state: x}); + return x; + } + + function App() { + const value = useStore(store, identity); + Scheduler.log({kind: 'render', value}); + return <>{value}; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(); + }); + + assertLog([ + {kind: 'selector', state: 2}, + {kind: 'render', value: 2}, + ]); + expect(root).toMatchRenderedOutput('2'); + + let resolve; + + // Start a transition that increments the store + await act(async () => { + await startTransition(async () => { + store.dispatch({type: 'increment'}); + await new Promise(r => (resolve = r)); + }); + }); + + assertLog([ + {kind: 'reducer', state: 2, action: 'increment'}, + {kind: 'selector', state: 3}, + ]); + // Still showing pre-transition state + expect(root).toMatchRenderedOutput('2'); + + // Apply a second transition update that reverts back to the original state + await act(async () => { + startTransition(() => { + store.dispatch({type: 'decrement'}); + }); + }); + + // The second transition decrements the transition state (3 -> 2) + // Since the transition state now equals the pre-transition state, + // we still render (could be optimized in the future) + assertLog([ + {kind: 'reducer', state: 3, action: 'decrement'}, + {kind: 'selector', state: 2}, + {kind: 'render', value: 2}, + ]); + expect(root).toMatchRenderedOutput('2'); + + // Complete the first transition + await act(async () => { + resolve(); + }); + + // The transition completes but state is still 2, so no re-render needed + assertLog([]); + expect(root).toMatchRenderedOutput('2'); + }); + + // This test checks an edge case in update short circuiting to ensure we don't incorrectly skip + // the sync update based on the fact that it matches the most recently rendered state. + it('sync update interrupts transition with identical state change', async () => { + function counterReducer( + count: number, + action: {type: 'increment'}, + ): number { + Scheduler.log({kind: 'reducer', state: count, action: action.type}); + switch (action.type) { + case 'increment': + return count + 1; + default: + return count; + } + } + const store = createStore(2, counterReducer); + + function identity(x) { + Scheduler.log({kind: 'selector', state: x}); + return x; + } + + function App() { + const value = useStore(store, identity); + Scheduler.log({kind: 'render', value}); + return <>{value}; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(); + }); + + assertLog([ + {kind: 'selector', state: 2}, + {kind: 'render', value: 2}, + ]); + expect(root).toMatchRenderedOutput('2'); + + let resolve; + + // Start a transition that increments the store + await act(async () => { + await startTransition(async () => { + store.dispatch({type: 'increment'}); + await new Promise(r => (resolve = r)); + }); + }); + + assertLog([ + {kind: 'reducer', state: 2, action: 'increment'}, + {kind: 'selector', state: 3}, + ]); + // Still showing pre-transition state + expect(root).toMatchRenderedOutput('2'); + + // Interrupt with a sync update that results in the same state as the transition + await act(async () => { + store.dispatch({type: 'increment'}); + }); + + // The sync update increments the sync state (2 -> 3) + // This matches what the transition state already was + assertLog([ + {kind: 'reducer', state: 3, action: 'increment'}, + {kind: 'reducer', state: 2, action: 'increment'}, + {kind: 'selector', state: 3}, + {kind: 'selector', state: 4}, + {kind: 'render', value: 3}, + ]); + expect(root).toMatchRenderedOutput('3'); + + // Complete the transition + await act(async () => { + resolve(); + }); + + // The transition completes with state 4 (original transition 3 + sync increment) + assertLog([{kind: 'render', value: 4}]); + expect(root).toMatchRenderedOutput('4'); + }); + + it('selector is not called after component unmounts', async () => { + function counterReducer( + count: number, + action: {type: 'increment'}, + ): number { + Scheduler.log({kind: 'reducer', state: count, action: action.type}); + switch (action.type) { + case 'increment': + return count + 1; + default: + return count; + } + } + const store = createStore(2, counterReducer); + + function identity(x) { + Scheduler.log({kind: 'selector', state: x}); + return x; + } + + function StoreReader() { + const value = useStore(store, identity); + Scheduler.log({kind: 'render', value}); + return <>{value}; + } + + let setShowReader; + + function App() { + const [showReader, _setShowReader] = React.useState(true); + setShowReader = _setShowReader; + return showReader ? : null; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(); + }); + + assertLog([ + {kind: 'selector', state: 2}, + {kind: 'render', value: 2}, + ]); + expect(root).toMatchRenderedOutput('2'); + + // Unmount the component that uses the store + await act(async () => { + setShowReader(false); + }); + + assertLog([]); + expect(root).toMatchRenderedOutput(null); + + // Dispatch an action to the store after unmount + // The selector should NOT be called since the component is unmounted + await act(async () => { + store.dispatch({type: 'increment'}); + }); + + // Only the reducer should run, not the selector + assertLog([{kind: 'reducer', state: 2, action: 'increment'}]); + expect(root).toMatchRenderedOutput(null); + + // Dispatch another action to confirm selector is still not called + await act(async () => { + store.dispatch({type: 'increment'}); + }); + + assertLog([{kind: 'reducer', state: 3, action: 'increment'}]); + expect(root).toMatchRenderedOutput(null); + }); + + it('batched sync updates in an event handler', async () => { + function counterReducer( + count: number, + action: {type: 'increment'}, + ): number { + Scheduler.log({kind: 'reducer', state: count, action: action.type}); + switch (action.type) { + case 'increment': + return count + 1; + default: + return count; + } + } + const store = createStore(0, counterReducer); + + function identity(x) { + Scheduler.log({kind: 'selector', state: x}); + return x; + } + + function App() { + const value = useStore(store, identity); + Scheduler.log({kind: 'render', value}); + return <>{value}; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(); + }); + + assertLog([ + {kind: 'selector', state: 0}, + {kind: 'render', value: 0}, + ]); + expect(root).toMatchRenderedOutput('0'); + + // Dispatch multiple actions synchronously, simulating an event handler + // They should be batched into a single render + await act(async () => { + store.dispatch({type: 'increment'}); + store.dispatch({type: 'increment'}); + store.dispatch({type: 'increment'}); + }); + + // All three reducer calls happen, but only one render + // Note: A future optimization could allow us to avoid calling the selector + // multiple times here + assertLog([ + {kind: 'reducer', state: 0, action: 'increment'}, + {kind: 'selector', state: 1}, + {kind: 'reducer', state: 1, action: 'increment'}, + {kind: 'selector', state: 2}, + {kind: 'reducer', state: 2, action: 'increment'}, + {kind: 'selector', state: 3}, + {kind: 'render', value: 3}, + ]); + expect(root).toMatchRenderedOutput('3'); + }); + + it('changing the store prop triggers a re-render with the new store state', async () => { + function counterReducer( + count: number, + action: {type: 'increment'}, + ): number { + Scheduler.log({kind: 'reducer', state: count, action: action.type}); + switch (action.type) { + case 'increment': + return count + 1; + default: + return count; + } + } + + const storeA = createStore(10, counterReducer); + const storeB = createStore(20, counterReducer); + + function identity(x) { + Scheduler.log({kind: 'selector', state: x}); + return x; + } + + let setStore; + + function App() { + const [store, _setStore] = React.useState(storeA); + setStore = _setStore; + const value = useStore(store, identity); + Scheduler.log({kind: 'render', value}); + return <>{value}; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(); + }); + + assertLog([ + {kind: 'selector', state: 10}, + {kind: 'render', value: 10}, + ]); + expect(root).toMatchRenderedOutput('10'); + + // Change the store prop from storeA to storeB + await act(async () => { + setStore(storeB); + }); + + // Should re-render with storeB's state (20) + assertLog([ + {kind: 'selector', state: 20}, + {kind: 'render', value: 20}, + ]); + expect(root).toMatchRenderedOutput('20'); + }); + + it('first store reader is in a tree that suspends on mount', async () => { + function counterReducer( + count: number, + action: {type: 'increment'}, + ): number { + Scheduler.log({kind: 'reducer', state: count, action: action.type}); + switch (action.type) { + case 'increment': + return count + 1; + default: + return count; + } + } + const store = createStore(2, counterReducer); + + function identity(x) { + Scheduler.log({kind: 'selector', state: x}); + return x; + } + + let resolve; + const promise = new Promise(r => { + resolve = r; + }); + + function SuspendingStoreReader() { + const value = useStore(store, identity); + Scheduler.log({ + kind: 'render', + value, + componentName: 'SuspendingStoreReader', + }); + use(promise); + Scheduler.log({ + kind: 'after suspend', + componentName: 'SuspendingStoreReader', + }); + return <>{value}; + } + + function Fallback() { + Scheduler.log({kind: 'render', componentName: 'Fallback'}); + return 'Loading...'; + } + + function App() { + return ( + }> + + + ); + } + + const root = ReactNoop.createRoot(); + + // Initial render - the store reader suspends + await act(async () => { + root.render(); + }); + + assertLog([ + {kind: 'selector', state: 2}, + {kind: 'render', value: 2, componentName: 'SuspendingStoreReader'}, + // Component suspends, fallback is shown + {kind: 'render', componentName: 'Fallback'}, + // TODO: React tries to render the suspended tree again? + {kind: 'selector', state: 2}, + {kind: 'render', value: 2, componentName: 'SuspendingStoreReader'}, + // {kind: 'render', componentName: 'Fallback'}, + ]); + expect(root).toMatchRenderedOutput('Loading...'); + + // Dispatch while suspended - only the reducer runs since the + // suspended component is not being re-rendered + + let resolveTransition; + await act(async () => { + startTransition(async () => { + store.dispatch({type: 'increment'}); + await new Promise(r => (resolveTransition = r)); + }); + }); + + assertLog([{kind: 'reducer', state: 2, action: 'increment'}]); + // Still showing fallback + expect(root).toMatchRenderedOutput('Loading...'); + + // Resolve the suspense + await act(async () => { + resolve(); + }); + + // Now the component should render with the pre-transition state + assertLog([ + {kind: 'selector', state: 2}, + {kind: 'render', value: 2, componentName: 'SuspendingStoreReader'}, + {kind: 'after suspend', componentName: 'SuspendingStoreReader'}, + // React tries to re-render the transition state eagerly + {kind: 'selector', state: 3}, + // WAT? + {kind: 'render', componentName: 'Fallback'}, + ]); + expect(root).toMatchRenderedOutput('2'); + + // Verify updates continue to work after unsuspending + await act(async () => { + resolveTransition(); + }); + + assertLog([ + {kind: 'render', value: 3, componentName: 'SuspendingStoreReader'}, + {kind: 'after suspend', componentName: 'SuspendingStoreReader'}, + ]); + expect(root).toMatchRenderedOutput('3'); + }); + + it('store is already updating in transition on initial mount', async () => { + function counterReducer( + count: number, + action: {type: 'increment'}, + ): number { + Scheduler.log({kind: 'reducer', state: count, action: action.type}); + switch (action.type) { + case 'increment': + return count + 1; + default: + return count; + } + } + + const store = createStore(2, counterReducer); + + let resolve; + + startTransition(async () => { + store.dispatch({type: 'increment'}); + await new Promise(r => { + resolve = r; + }); + }); + + function identity(x) { + Scheduler.log({kind: 'selector', state: x}); + return x; + } + + function App() { + const value = useStore(store, identity); + Scheduler.log({kind: 'render', value}); + return <>{value}; + } + + const root = ReactNoop.createRoot(); + assertLog([{kind: 'reducer', state: 2, action: 'increment'}]); + await act(async () => { + root.render(); + }); + + // Technically we the transition is not complete, so we + // SHOULD be showing 2 here. + assertLog([ + {kind: 'selector', state: 3}, + {kind: 'render', value: 3}, + ]); + expect(root).toMatchRenderedOutput('3'); + + await act(async () => { + resolve(); + }); + + assertLog([ + // This is where we should be updating to 3. + ]); + expect(root).toMatchRenderedOutput('3'); + }); + + it('store is previously updated in transition before initial mount', async () => { + function counterReducer( + count: number, + action: {type: 'increment'}, + ): number { + Scheduler.log({kind: 'reducer', state: count, action: action.type}); + switch (action.type) { + case 'increment': + return count + 1; + default: + return count; + } + } + + const store = createStore(2, counterReducer); + + startTransition(async () => { + store.dispatch({type: 'increment'}); + }); + // Transition completed immediately, so if we are tracking committed state + // we would need to mark this transtion as complete. + + function identity(x) { + Scheduler.log({kind: 'selector', state: x}); + return x; + } + + function App() { + const value = useStore(store, identity); + Scheduler.log({kind: 'render', value}); + return <>{value}; + } + + const root = ReactNoop.createRoot(); + assertLog([{kind: 'reducer', action: 'increment', state: 2}]); + await act(async () => { + root.render(); + }); + + assertLog([ + {kind: 'selector', state: 3}, + {kind: 'render', value: 3}, + ]); + expect(root).toMatchRenderedOutput('3'); + }); + + it('store is created in a transition that is still ongoing during initial mount', async () => { + function counterReducer( + count: number, + action: {type: 'increment'}, + ): number { + Scheduler.log({kind: 'reducer', state: count, action: action.type}); + switch (action.type) { + case 'increment': + return count + 1; + default: + return count; + } + } + + let store; + let resolve; + + startTransition(async () => { + store = createStore(2, counterReducer); + await new Promise(r => { + resolve = r; + }); + }); + + function identity(x) { + Scheduler.log({kind: 'selector', state: x}); + return x; + } + + function App() { + const value = useStore(store, identity); + Scheduler.log({kind: 'render', value}); + return <>{value}; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(); + }); + + // Technically there is no valid state since the store should not really + // exist until the transition completes? + assertLog([ + {kind: 'selector', state: 2}, + {kind: 'render', value: 2}, + ]); + expect(root).toMatchRenderedOutput('2'); + + await act(async () => { + resolve(); + }); + + assertLog([ + // Not clear what should be happening here. + ]); + expect(root).toMatchRenderedOutput('2'); + }); +}); diff --git a/packages/react-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js index 119c2a0778f..c592c23b5da 100644 --- a/packages/react-server/src/ReactFizzHooks.js +++ b/packages/react-server/src/ReactFizzHooks.js @@ -16,6 +16,7 @@ import type { Usable, ReactCustomFormAction, Awaited, + ReactExternalDataSource, } from 'shared/ReactTypes'; import type {ResumableState} from './ReactFizzConfig'; @@ -38,7 +39,7 @@ import { } from './ReactFizzConfig'; import {createFastHash} from './ReactServerStreamConfig'; -import {enableUseEffectEventHook} from 'shared/ReactFeatureFlags'; +import {enableUseEffectEventHook, enableStore} from 'shared/ReactFeatureFlags'; import is from 'shared/objectIs'; import { REACT_CONTEXT_TYPE, @@ -564,6 +565,13 @@ function useSyncExternalStore( return getServerSnapshot(); } +function useStore( + store: ReactExternalDataSource, + selector?: (state: S) => T, +): T { + throw new Error('useStore is not yet supported during server rendering.'); +} + function useDeferredValue(value: T, initialValue?: T): T { resolveCurrentlyRenderingComponent(); return initialValue !== undefined ? initialValue : value; @@ -863,6 +871,11 @@ export const HooksDispatcher: Dispatcher = supportsClientAPIs if (enableUseEffectEventHook) { HooksDispatcher.useEffectEvent = useEffectEvent; } +if (enableStore) { + HooksDispatcher.useStore = supportsClientAPIs + ? useStore + : clientHookNotSupported; +} export let currentResumableState: null | ResumableState = (null: any); export function setCurrentResumableState( diff --git a/packages/react-server/src/ReactFlightHooks.js b/packages/react-server/src/ReactFlightHooks.js index ed369be0e9b..512167bc230 100644 --- a/packages/react-server/src/ReactFlightHooks.js +++ b/packages/react-server/src/ReactFlightHooks.js @@ -86,6 +86,7 @@ export const HooksDispatcher: Dispatcher = { useDeferredValue: (unsupportedHook: any), useTransition: (unsupportedHook: any), useSyncExternalStore: (unsupportedHook: any), + useStore: (unsupportedHook: any), useId, useHostTransitionStatus: (unsupportedHook: any), useFormState: (unsupportedHook: any), diff --git a/packages/react/index.experimental.development.js b/packages/react/index.experimental.development.js index 8ff2e1d257f..a8ca25fa04b 100644 --- a/packages/react/index.experimental.development.js +++ b/packages/react/index.experimental.development.js @@ -53,11 +53,13 @@ export { useRef, useState, useSyncExternalStore, + useStore, useTransition, useActionState, version, act, // DEV-only captureOwnerStack, // DEV-only + createStore, } from './src/ReactClient'; import {useOptimistic} from './src/ReactClient'; diff --git a/packages/react/index.experimental.js b/packages/react/index.experimental.js index 881a71b2501..7915796efc0 100644 --- a/packages/react/index.experimental.js +++ b/packages/react/index.experimental.js @@ -54,9 +54,11 @@ export { useRef, useState, useSyncExternalStore, + useStore, useTransition, useActionState, version, + createStore, } from './src/ReactClient'; import {useOptimistic} from './src/ReactClient'; diff --git a/packages/react/index.js b/packages/react/index.js index 78b11b809e7..a32c6f1206c 100644 --- a/packages/react/index.js +++ b/packages/react/index.js @@ -65,10 +65,12 @@ export { useMemo, useOptimistic, useSyncExternalStore, + useStore, useReducer, useRef, useState, useTransition, useActionState, version, + createStore, } from './src/ReactClient'; diff --git a/packages/react/src/ReactClient.js b/packages/react/src/ReactClient.js index 5d1c7f3ac05..6152ab20959 100644 --- a/packages/react/src/ReactClient.js +++ b/packages/react/src/ReactClient.js @@ -47,6 +47,7 @@ import { useLayoutEffect, useMemo, useSyncExternalStore, + useStore, useReducer, useRef, useState, @@ -65,6 +66,8 @@ import {act} from './ReactAct'; import {captureOwnerStack} from './ReactOwnerStack'; import * as ReactCompilerRuntime from './ReactCompilerRuntime'; +import {createStore} from 'react-reconciler/src/ReactStore'; + const Children = { map, forEach, @@ -96,6 +99,7 @@ export { useOptimistic, useActionState, useSyncExternalStore, + useStore, useReducer, useRef, useState, @@ -134,4 +138,5 @@ export { useId, act, captureOwnerStack, + createStore, }; diff --git a/packages/react/src/ReactHooks.js b/packages/react/src/ReactHooks.js index ff86130baa0..dbcc867e2c3 100644 --- a/packages/react/src/ReactHooks.js +++ b/packages/react/src/ReactHooks.js @@ -13,6 +13,7 @@ import type { StartTransitionOptions, Usable, Awaited, + ReactExternalDataSource, } from 'shared/ReactTypes'; import {REACT_CONSUMER_TYPE} from 'shared/ReactSymbols'; @@ -198,6 +199,15 @@ export function useSyncExternalStore( ); } +export function useStore( + store: ReactExternalDataSource, + selector?: (state: S) => T, +): S | T { + const dispatcher = resolveDispatcher(); + // $FlowFixMe[not-a-function] This is unstable, thus optional + return dispatcher.useStore(store, selector); +} + export function useCacheRefresh(): (?() => T, ?T) => void { const dispatcher = resolveDispatcher(); // $FlowFixMe[not-a-function] This is unstable, thus optional diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index ebb287568af..a2d84d58350 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -120,6 +120,8 @@ export const enableNoCloningMemoCache: boolean = false; export const enableUseEffectEventHook: boolean = true; +export const enableStore: boolean = true; + // Test in www before enabling in open source. // Enables DOM-server to stream its instruction set as data-attributes // (handled with an MutationObserver) instead of inline-scripts diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 65ed43c063c..846082964f9 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -387,3 +387,19 @@ export type ProfilerProps = { ) => void, children?: ReactNodeList, }; + +export type ReactExternalDataSource = { + // Get the current state of the store. + getState(): S, + // The stable reducer function used by the store to produce new states. + reducer: (S, A) => S, + // Subscribe to the store. The callback will be called after the state has + // updated and includes the action that was dispatched. + subscribe: (callback: (action: A) => void) => () => void, +}; + +export type ReactStore = { + ...ReactExternalDataSource, + // Dispatch an action to the store. + dispatch: (action: A) => void, +}; diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index d9a91f8a808..d84e25ce0be 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -65,6 +65,7 @@ export const enableTransitionTracing: boolean = false; export const enableTrustedTypesIntegration: boolean = false; export const enableUpdaterTracking: boolean = __PROFILE__; export const enableUseEffectEventHook: boolean = true; +export const enableStore: boolean = true; export const retryLaneExpirationMs = 5000; export const syncLaneExpirationMs = 250; export const transitionLaneExpirationMs = 5000; diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index fa8f336c03f..5353e30ad77 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -52,6 +52,7 @@ export const enableTaint: boolean = true; export const enableTransitionTracing: boolean = false; export const enableTrustedTypesIntegration: boolean = false; export const enableUseEffectEventHook: boolean = true; +export const enableStore: boolean = true; export const passChildrenWhenCloningPersistedNodes: boolean = false; export const renameElementSymbol: boolean = true; export const retryLaneExpirationMs = 5000; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index acf3847bd06..daa43aa3893 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -33,6 +33,7 @@ export const enableSuspenseAvoidThisFallback: boolean = false; export const enableCPUSuspense: boolean = false; export const enableNoCloningMemoCache: boolean = false; export const enableUseEffectEventHook: boolean = true; +export const enableStore: boolean = true; export const enableLegacyFBSupport: boolean = false; export const enableMoveBefore: boolean = false; export const enableHiddenSubtreeInsertionEffectCleanup: boolean = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js index 5d3a5513018..8c8fb141b8b 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js @@ -50,6 +50,7 @@ export const enableTransitionTracing = false; export const enableTrustedTypesIntegration = false; export const enableUpdaterTracking = false; export const enableUseEffectEventHook = true; +export const enableStore = true; export const passChildrenWhenCloningPersistedNodes = false; export const renameElementSymbol = false; export const retryLaneExpirationMs = 5000; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index 553be202c45..881663f2764 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -35,6 +35,7 @@ export const enableSuspenseAvoidThisFallback: boolean = true; export const enableCPUSuspense: boolean = false; export const enableNoCloningMemoCache: boolean = false; export const enableUseEffectEventHook: boolean = true; +export const enableStore: boolean = true; export const enableLegacyFBSupport: boolean = false; export const enableMoveBefore: boolean = false; export const enableHiddenSubtreeInsertionEffectCleanup: boolean = true; diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 87801a9658f..21e6a68f180 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -50,6 +50,7 @@ export const enableSuspenseAvoidThisFallback: boolean = true; export const enableCPUSuspense: boolean = true; export const enableUseEffectEventHook: boolean = true; +export const enableStore: boolean = true; export const enableMoveBefore: boolean = false; export const disableInputAttributeSyncing: boolean = false; export const enableLegacyFBSupport: boolean = true; diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index e87d750ecaf..0c5dec3cf97 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -551,5 +551,9 @@ "563": "This render completed successfully. All cacheSignals are now aborted to allow clean up of any unused resources.", "564": "Unknown command. The debugChannel was not wired up properly.", "565": "resolveDebugMessage/closeDebugChannel should not be called for a Request that wasn't kept alive. This is a bug in React.", - "566": "FragmentInstance.scrollIntoView() does not support scrollIntoViewOptions. Use the alignToTop boolean instead." + "566": "FragmentInstance.scrollIntoView() does not support scrollIntoViewOptions. Use the alignToTop boolean instead.", + "567": "createStore is not available because the enableStore feature flag is not enabled.", + "568": "useStore is not yet supported during server rendering.", + "569": "useStore is not yet supported in React Debug Tools.", + "570": "storeReducer should never be called. This is a bug in React. Please file an issue." }