From 50299afa63e15ea0a95adbd0a97fe7b74bf40ab7 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Mon, 17 Nov 2025 17:27:51 -0800 Subject: [PATCH 01/26] Checkpoint --- .../react-debug-tools/src/ReactDebugHooks.js | 18 ++++ .../react-reconciler/src/ReactFiberHooks.js | 78 ++++++++++++++++ .../src/ReactInternalTypes.js | 6 ++ .../__tests__/useStoreWithSelector-test.js | 88 +++++++++++++++++++ packages/react-server/src/ReactFizzHooks.js | 11 +++ packages/react-server/src/ReactFlightHooks.js | 1 + .../react/index.experimental.development.js | 2 + packages/react/index.experimental.js | 2 + packages/react/index.js | 2 + packages/react/src/ReactClient.js | 4 + packages/react/src/ReactHooks.js | 9 ++ packages/react/src/ReactStore.js | 20 +++++ packages/shared/ReactTypes.js | 5 ++ packages/shared/ReactVersion.js | 16 +--- react.code-workspace | 1 + 15 files changed, 248 insertions(+), 15 deletions(-) create mode 100644 packages/react-reconciler/src/__tests__/useStoreWithSelector-test.js create mode 100644 packages/react/src/ReactStore.js diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index db9495a97dd..298379140a7 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, + ReactStore, } from 'shared/ReactTypes'; import type { ContextDependency, @@ -481,6 +482,22 @@ function useSyncExternalStore( return value; } +function useStoreWithSelector( + store: ReactStore, + selector: (state: S) => T, +): T { + const value = selector(store._current); + hookLog.push({ + displayName: null, + primitive: 'StoreWithSelector', + stackError: new Error(), + value, + debugInfo: null, + dispatcherHookName: 'StoreWithSelector', + }); + return value; +} + function useTransition(): [ boolean, (callback: () => void, options?: StartTransitionOptions) => void, @@ -777,6 +794,7 @@ const Dispatcher: DispatcherType = { useDeferredValue, useTransition, useSyncExternalStore, + useStoreWithSelector, useId, useHostTransitionStatus, useFormState, diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 66a390ebb81..33c941e8eed 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -14,6 +14,7 @@ import type { Thenable, RejectedThenable, Awaited, + ReactStore, } from 'shared/ReactTypes'; import type { Fiber, @@ -1808,6 +1809,20 @@ function updateSyncExternalStore( return nextSnapshot; } +function mountStoreWithSelector( + store: ReactStore, + selector: S => T, +): T { + return selector(store._current); +} + +function updateStoreWithSelector( + store: ReactStore, + selector: S => T, +): T { + return selector(store._current); +} + function pushStoreConsistencyCheck( fiber: Fiber, getSnapshot: () => T, @@ -3879,6 +3894,7 @@ export const ContextOnlyDispatcher: Dispatcher = { useDeferredValue: throwInvalidHookError, useTransition: throwInvalidHookError, useSyncExternalStore: throwInvalidHookError, + useStoreWithSelector: throwInvalidHookError, useId: throwInvalidHookError, useHostTransitionStatus: throwInvalidHookError, useFormState: throwInvalidHookError, @@ -3909,6 +3925,7 @@ const HooksDispatcherOnMount: Dispatcher = { useDeferredValue: mountDeferredValue, useTransition: mountTransition, useSyncExternalStore: mountSyncExternalStore, + useStoreWithSelector: mountStoreWithSelector, useId: mountId, useHostTransitionStatus: useHostTransitionStatus, useFormState: mountActionState, @@ -3939,6 +3956,7 @@ const HooksDispatcherOnUpdate: Dispatcher = { useDeferredValue: updateDeferredValue, useTransition: updateTransition, useSyncExternalStore: updateSyncExternalStore, + useStoreWithSelector: updateStoreWithSelector, useId: updateId, useHostTransitionStatus: useHostTransitionStatus, useFormState: updateActionState, @@ -3969,6 +3987,7 @@ const HooksDispatcherOnRerender: Dispatcher = { useDeferredValue: rerenderDeferredValue, useTransition: rerenderTransition, useSyncExternalStore: updateSyncExternalStore, + useStoreWithSelector: updateStoreWithSelector, useId: updateId, useHostTransitionStatus: useHostTransitionStatus, useFormState: rerenderActionState, @@ -4130,6 +4149,14 @@ if (__DEV__) { mountHookTypesDev(); return mountSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, + useStoreWithSelector( + store: ReactStore, + selector: (state: S) => T, + ): T { + currentHookNameInDev = 'useStoreWithSelector'; + mountHookTypesDev(); + return mountStoreWithSelector(store, selector); + }, useId(): string { currentHookNameInDev = 'useId'; mountHookTypesDev(); @@ -4297,6 +4324,14 @@ if (__DEV__) { updateHookTypesDev(); return mountSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, + useStoreWithSelector( + store: ReactStore, + selector: (state: S) => T, + ): T { + currentHookNameInDev = 'useStoreWithSelector'; + updateHookTypesDev(); + return mountStoreWithSelector(store, selector); + }, useId(): string { currentHookNameInDev = 'useId'; updateHookTypesDev(); @@ -4464,6 +4499,14 @@ if (__DEV__) { updateHookTypesDev(); return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, + useStoreWithSelector( + store: ReactStore, + selector: (state: S) => T, + ): T { + currentHookNameInDev = 'useStoreWithSelector'; + updateHookTypesDev(); + return updateStoreWithSelector(store, selector); + }, useId(): string { currentHookNameInDev = 'useId'; updateHookTypesDev(); @@ -4631,6 +4674,14 @@ if (__DEV__) { updateHookTypesDev(); return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, + useStoreWithSelector( + store: ReactStore, + selector: (state: S) => T, + ): T { + currentHookNameInDev = 'useStoreWithSelector'; + updateHookTypesDev(); + return updateStoreWithSelector(store, selector); + }, useId(): string { currentHookNameInDev = 'useId'; updateHookTypesDev(); @@ -4816,6 +4867,15 @@ if (__DEV__) { mountHookTypesDev(); return mountSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, + useStoreWithSelector( + store: ReactStore, + selector: (state: S) => T, + ): T { + currentHookNameInDev = 'useStoreWithSelector'; + warnInvalidHookAccess(); + mountHookTypesDev(); + return mountStoreWithSelector(store, selector); + }, useId(): string { currentHookNameInDev = 'useId'; warnInvalidHookAccess(); @@ -5008,6 +5068,15 @@ if (__DEV__) { updateHookTypesDev(); return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, + useStoreWithSelector( + store: ReactStore, + selector: (state: S) => T, + ): T { + currentHookNameInDev = 'useStoreWithSelector'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateStoreWithSelector(store, selector); + }, useId(): string { currentHookNameInDev = 'useId'; warnInvalidHookAccess(); @@ -5200,6 +5269,15 @@ if (__DEV__) { updateHookTypesDev(); return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, + useStoreWithSelector( + store: ReactStore, + selector: (state: S) => T, + ): T { + currentHookNameInDev = 'useStoreWithSelector'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateStoreWithSelector(store, selector); + }, useId(): string { currentHookNameInDev = 'useId'; warnInvalidHookAccess(); diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index 775b69d211f..eea60c34938 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -18,6 +18,7 @@ import type { ReactComponentInfo, ReactDebugInfo, ReactKey, + ReactStore, } from 'shared/ReactTypes'; import type {TransitionTypes} from 'react/src/ReactTransitionType'; import type {WorkTag} from './ReactWorkTags'; @@ -59,6 +60,7 @@ export type HookType = | 'useDeferredValue' | 'useTransition' | 'useSyncExternalStore' + | 'useStoreWithSelector' | 'useId' | 'useCacheRefresh' | 'useOptimistic' @@ -438,6 +440,10 @@ export type Dispatcher = { getSnapshot: () => T, getServerSnapshot?: () => T, ): T, + useStoreWithSelector( + store: ReactStore, + selector: (state: S) => T, + ): T, useId(): string, useCacheRefresh: () => (?() => T, ?T) => void, useMemoCache: (size: number) => Array, diff --git a/packages/react-reconciler/src/__tests__/useStoreWithSelector-test.js b/packages/react-reconciler/src/__tests__/useStoreWithSelector-test.js new file mode 100644 index 00000000000..b63cde5269e --- /dev/null +++ b/packages/react-reconciler/src/__tests__/useStoreWithSelector-test.js @@ -0,0 +1,88 @@ +/** + * 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 + */ + +'use strict'; + +let useStoreWithSelector; +let React; +let ReactNoop; +let Scheduler; +let act; +let useLayoutEffect; +let forwardRef; +let useImperativeHandle; +let useRef; +let useState; +let use; +let createStore; +let startTransition; +let waitFor; +let waitForAll; +let assertLog; +let Suspense; +let useMemo; + +describe('useStoreWithSelector', () => { + beforeEach(() => { + jest.resetModules(); + + React = require('react'); + ReactNoop = require('react-noop-renderer'); + Scheduler = require('scheduler'); + useLayoutEffect = React.useLayoutEffect; + useImperativeHandle = React.useImperativeHandle; + forwardRef = React.forwardRef; + useRef = React.useRef; + useState = React.useState; + use = React.use; + createStore = React.createStore; + useStoreWithSelector = React.useStoreWithSelector; + startTransition = React.startTransition; + Suspense = React.Suspense; + useMemo = React.useMemo; + const InternalTestUtils = require('internal-test-utils'); + waitFor = InternalTestUtils.waitFor; + waitForAll = InternalTestUtils.waitForAll; + assertLog = InternalTestUtils.assertLog; + + act = require('internal-test-utils').act; + }); + + it('useStoreWithSelector', async () => { + function counterReducer( + count: number, + action: {type: 'increment' | 'decrement'}, + ): number { + switch (action.type) { + case 'increment': + return count + 1; + case 'decrement': + return count - 1; + default: + return count; + } + } + const store = createStore(counterReducer, 2); + function identify(x) { + return x; + } + + function App() { + const value = useStoreWithSelector(store, identify); + return <>{value}; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + startTransition(() => { + root.render(); + }); + }); + }); +}); diff --git a/packages/react-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js index 119c2a0778f..963539c30a7 100644 --- a/packages/react-server/src/ReactFizzHooks.js +++ b/packages/react-server/src/ReactFizzHooks.js @@ -16,6 +16,7 @@ import type { Usable, ReactCustomFormAction, Awaited, + ReactStore, } from 'shared/ReactTypes'; import type {ResumableState} from './ReactFizzConfig'; @@ -564,6 +565,14 @@ function useSyncExternalStore( return getServerSnapshot(); } +function useStoreWithSelector( + store: ReactStore, + selector: (state: S) => T, +): T { + resolveCurrentlyRenderingComponent(); + return selector(store._current); +} + function useDeferredValue(value: T, initialValue?: T): T { resolveCurrentlyRenderingComponent(); return initialValue !== undefined ? initialValue : value; @@ -827,6 +836,7 @@ export const HooksDispatcher: Dispatcher = supportsClientAPIs useId, // Subscriptions are not setup in a server environment. useSyncExternalStore, + useStoreWithSelector, useOptimistic, useActionState, useFormState: useActionState, @@ -851,6 +861,7 @@ export const HooksDispatcher: Dispatcher = supportsClientAPIs useDeferredValue: clientHookNotSupported, useTransition: clientHookNotSupported, useSyncExternalStore: clientHookNotSupported, + useStoreWithSelector: clientHookNotSupported, useId, useHostTransitionStatus, useFormState: useActionState, diff --git a/packages/react-server/src/ReactFlightHooks.js b/packages/react-server/src/ReactFlightHooks.js index ed369be0e9b..0ac82deb4f3 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), + useStoreWithSelector: (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..bc22640a1e0 100644 --- a/packages/react/index.experimental.development.js +++ b/packages/react/index.experimental.development.js @@ -21,6 +21,7 @@ export { createContext, createElement, createRef, + createStore, use, forwardRef, isValidElement, @@ -53,6 +54,7 @@ export { useRef, useState, useSyncExternalStore, + useStoreWithSelector, useTransition, useActionState, version, diff --git a/packages/react/index.experimental.js b/packages/react/index.experimental.js index 881a71b2501..be27fcd8d89 100644 --- a/packages/react/index.experimental.js +++ b/packages/react/index.experimental.js @@ -21,6 +21,7 @@ export { createContext, createElement, createRef, + createStore, use, forwardRef, isValidElement, @@ -54,6 +55,7 @@ export { useRef, useState, useSyncExternalStore, + useStoreWithSelector, useTransition, useActionState, version, diff --git a/packages/react/index.js b/packages/react/index.js index 78b11b809e7..23c1ac42e18 100644 --- a/packages/react/index.js +++ b/packages/react/index.js @@ -35,6 +35,7 @@ export { createContext, createElement, createRef, + createStore, use, forwardRef, isValidElement, @@ -65,6 +66,7 @@ export { useMemo, useOptimistic, useSyncExternalStore, + useStoreWithSelector, useReducer, useRef, useState, diff --git a/packages/react/src/ReactClient.js b/packages/react/src/ReactClient.js index 5d1c7f3ac05..ee269252f66 100644 --- a/packages/react/src/ReactClient.js +++ b/packages/react/src/ReactClient.js @@ -35,6 +35,7 @@ import {lazy} from './ReactLazy'; import {forwardRef} from './ReactForwardRef'; import {memo} from './ReactMemo'; import {cache, cacheSignal} from './ReactCacheClient'; +import {createStore} from './ReactStore'; import { getCacheForType, useCallback, @@ -47,6 +48,7 @@ import { useLayoutEffect, useMemo, useSyncExternalStore, + useStoreWithSelector, useReducer, useRef, useState, @@ -84,6 +86,7 @@ export { memo, cache, cacheSignal, + createStore, useCallback, useContext, useEffect, @@ -96,6 +99,7 @@ export { useOptimistic, useActionState, useSyncExternalStore, + useStoreWithSelector, useReducer, useRef, useState, diff --git a/packages/react/src/ReactHooks.js b/packages/react/src/ReactHooks.js index ff86130baa0..53afe878396 100644 --- a/packages/react/src/ReactHooks.js +++ b/packages/react/src/ReactHooks.js @@ -13,6 +13,7 @@ import type { StartTransitionOptions, Usable, Awaited, + ReactStore, } from 'shared/ReactTypes'; import {REACT_CONSUMER_TYPE} from 'shared/ReactSymbols'; @@ -198,6 +199,14 @@ export function useSyncExternalStore( ); } +export function useStoreWithSelector( + store: ReactStore, + selector: S => T, +): T { + const dispatcher = resolveDispatcher(); + return dispatcher.useStoreWithSelector(store, selector); +} + export function useCacheRefresh(): (?() => T, ?T) => void { const dispatcher = resolveDispatcher(); // $FlowFixMe[not-a-function] This is unstable, thus optional diff --git a/packages/react/src/ReactStore.js b/packages/react/src/ReactStore.js new file mode 100644 index 00000000000..303fe295c01 --- /dev/null +++ b/packages/react/src/ReactStore.js @@ -0,0 +1,20 @@ +/** + * 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'; + +export function createStore( + reducer: (S, A) => S, + initialValue: S, +): ReactStore { + return { + _current: initialValue, + _reducer: reducer, + }; +} diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 65ed43c063c..5f5981277ec 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -387,3 +387,8 @@ export type ProfilerProps = { ) => void, children?: ReactNodeList, }; + +export type ReactStore = { + _current: S, + _reducer: (S, A) => S, +}; diff --git a/packages/shared/ReactVersion.js b/packages/shared/ReactVersion.js index bd5fa23ca26..3c63937cede 100644 --- a/packages/shared/ReactVersion.js +++ b/packages/shared/ReactVersion.js @@ -1,15 +1 @@ -/** - * 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. - */ - -// TODO: this is special because it gets imported during build. -// -// It exists as a placeholder so that DevTools can support work tag changes between releases. -// When we next publish a release, update the matching TODO in backend/renderer.js -// TODO: This module is used both by the release scripts and to expose a version -// at runtime. We should instead inject the version number as part of the build -// process, and use the ReactVersions.js module as the single source of truth. -export default '19.3.0'; +export default '19.3.0-canary-ea4899e1-20251117'; diff --git a/react.code-workspace b/react.code-workspace index 16ff05dce9e..b91b697e3c5 100644 --- a/react.code-workspace +++ b/react.code-workspace @@ -24,6 +24,7 @@ "editor.formatOnSave": true, "editor.defaultFormatter": "esbenp.prettier-vscode", "flow.pathToFlow": "${workspaceFolder}/node_modules/.bin/flow", + "flow.showUncovered": false, "prettier.configPath": "", "prettier.ignorePath": "" } From aae051b54b04a227ebb3ce80ecc180a82e74fe59 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Mon, 17 Nov 2025 22:00:34 -0800 Subject: [PATCH 02/26] Get subscriptions working --- .../react-reconciler/src/ReactFiberHooks.js | 36 +++++++++++++++++-- .../react-reconciler/src/__tests__/todo.md | 2 ++ .../__tests__/useStoreWithSelector-test.js | 26 ++++++++++++-- packages/react/src/ReactStore.js | 18 +++++++++- packages/shared/ReactTypes.js | 2 ++ 5 files changed, 77 insertions(+), 7 deletions(-) create mode 100644 packages/react-reconciler/src/__tests__/todo.md diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 33c941e8eed..fedd56b2bf2 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -1813,14 +1813,44 @@ function mountStoreWithSelector( store: ReactStore, selector: S => T, ): T { - return selector(store._current); + const fiber = currentlyRenderingFiber; + const hook = mountWorkInProgressHook(); + const selectedState = selector(store._current); + hook.memoizedState = selectedState; + + mountEffect(createSubscription.bind(null, store, fiber), []); + return selectedState; } function updateStoreWithSelector( store: ReactStore, selector: S => T, ): T { - return selector(store._current); + const fiber = currentlyRenderingFiber; + const hook = updateWorkInProgressHook(); + + updateEffect(createSubscription.bind(null, store, fiber), []); + + // TODO: Calling selector during render is not our end state. + // We need to find something we can eagerly write the state to which + // will be stable during subsequent renders. + // The hook object is not stable since it gets recreated during each render. + const nextState = selector(store._current); + if (!is(hook.memoizedState, nextState)) { + hook.memoizedState = nextState; + markWorkInProgressReceivedUpdate(); + } + return nextState; +} + +function createSubscription(store: ReactStore, fiber: Fiber) { + return store.subscribe(() => { + const lane = requestUpdateLane(fiber); + const root = enqueueConcurrentRenderForLane(fiber, lane); + if (root !== null) { + scheduleUpdateOnFiber(root, fiber, lane); + } + }); } function pushStoreConsistencyCheck( @@ -1862,7 +1892,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 diff --git a/packages/react-reconciler/src/__tests__/todo.md b/packages/react-reconciler/src/__tests__/todo.md new file mode 100644 index 00000000000..06caed04503 --- /dev/null +++ b/packages/react-reconciler/src/__tests__/todo.md @@ -0,0 +1,2 @@ +baseQueue contains a mixture of updates at different priorities +`hook.queue` is stable. We could create that on mount, and use that to stash eager states. \ No newline at end of file diff --git a/packages/react-reconciler/src/__tests__/useStoreWithSelector-test.js b/packages/react-reconciler/src/__tests__/useStoreWithSelector-test.js index b63cde5269e..4328499f890 100644 --- a/packages/react-reconciler/src/__tests__/useStoreWithSelector-test.js +++ b/packages/react-reconciler/src/__tests__/useStoreWithSelector-test.js @@ -69,12 +69,10 @@ describe('useStoreWithSelector', () => { } } const store = createStore(counterReducer, 2); - function identify(x) { - return x; - } function App() { const value = useStoreWithSelector(store, identify); + Scheduler.log(value); return <>{value}; } @@ -83,6 +81,28 @@ describe('useStoreWithSelector', () => { startTransition(() => { root.render(); }); + await waitFor([2]); + }); + expect(root).toMatchRenderedOutput('2'); + + await act(async () => { + startTransition(() => { + store.dispatch({type: 'increment'}); + }); }); + assertLog([3]); + expect(root).toMatchRenderedOutput('3'); + + await act(async () => { + startTransition(() => { + store.dispatch({type: 'increment'}); + }); + }); + assertLog([4]); + expect(root).toMatchRenderedOutput('4'); }); }); + +function identify(x: T): T { + return x; +} diff --git a/packages/react/src/ReactStore.js b/packages/react/src/ReactStore.js index 303fe295c01..bb4d8cfbe14 100644 --- a/packages/react/src/ReactStore.js +++ b/packages/react/src/ReactStore.js @@ -13,8 +13,24 @@ export function createStore( reducer: (S, A) => S, initialValue: S, ): ReactStore { - return { + const subscriptions = new Set<() => void>(); + + const self = { _current: initialValue, _reducer: reducer, + dispatch(action: A) { + const nextValue = reducer(self._current, action); + if (nextValue !== self._current) { + self._current = nextValue; + subscriptions.forEach(callback => callback()); + } + }, + subscribe(callback: () => void): () => void { + subscriptions.add(callback); + return () => { + subscriptions.delete(callback); + }; + }, }; + return self; } diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 5f5981277ec..6e8e8c0a71b 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -391,4 +391,6 @@ export type ProfilerProps = { export type ReactStore = { _current: S, _reducer: (S, A) => S, + subscribe: (callback: () => void) => () => void, + dispatch: (action: A) => void, }; From a7aa799e27103bed4700e8d953ed9ca3c1f5e2e5 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Tue, 18 Nov 2025 19:06:58 -0800 Subject: [PATCH 03/26] Get some transition stuff working --- .../react-reconciler/src/ReactFiberHooks.js | 93 ++++++++++++++++--- .../react-reconciler/src/__tests__/todo.md | 14 ++- .../__tests__/useStoreWithSelector-test.js | 76 +++++++++++---- packages/react/src/ReactStore.js | 19 +++- packages/shared/ReactTypes.js | 1 + 5 files changed, 167 insertions(+), 36 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index fedd56b2bf2..19964928885 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -75,6 +75,7 @@ import { isGestureRender, GestureLane, UpdateLanes, + includesOnlyTransitions, } from './ReactFiberLane'; import { ContinuousEventPriority, @@ -237,6 +238,13 @@ type StoreConsistencyCheck = { getSnapshot: () => T, }; +// TODO: Use something other than null +type StoreWithSelectorQueue = { + syncEagerState: T | null, + transitionEagerState: T | null, + lanes: Lanes, +}; + type EventFunctionPayload) => Return> = { ref: { eventFn: F, @@ -1815,10 +1823,24 @@ function mountStoreWithSelector( ): T { const fiber = currentlyRenderingFiber; const hook = mountWorkInProgressHook(); - const selectedState = selector(store._current); + const isTransition = includesOnlyTransitions(renderLanes); + const selectedState = selector( + isTransition ? store._transition : store._current, + ); + + // TODO: If store._transition !== store._current, we need to + // enqueue an entangled fixup update to ensure consistency. hook.memoizedState = selectedState; - mountEffect(createSubscription.bind(null, store, fiber), []); + // Create a queue object to hold eager states for sync and transition updates + const queue: StoreWithSelectorQueue = { + syncEagerState: null, + transitionEagerState: null, + lanes: NoLanes, + }; + hook.queue = queue; + + mountEffect(createSubscription.bind(null, store, fiber, selector, queue), []); return selectedState; } @@ -1828,27 +1850,72 @@ function updateStoreWithSelector( ): T { const fiber = currentlyRenderingFiber; const hook = updateWorkInProgressHook(); + const queue: StoreWithSelectorQueue = (hook.queue: any); + + updateEffect( + createSubscription.bind(null, store, fiber, selector, queue), + [], + ); - updateEffect(createSubscription.bind(null, store, fiber), []); + // TODO: What's the correct check here? + const isTransition = includesOnlyTransitions(renderLanes); + let eagerState; + if (isTransition) { + eagerState = queue.transitionEagerState; + queue.transitionEagerState = null; + } else { + eagerState = queue.syncEagerState; + queue.syncEagerState = null; + } + + // If we are rendering as part of an async transition update that still hasn't + // resolved, we want to stop rendering and wait for the promise to resolve. + if (queue.lanes === peekEntangledActionLane()) { + const entangledActionThenable = peekEntangledActionThenable(); + if (entangledActionThenable !== null) { + // TODO: Instead of the throwing the thenable directly, throw a + // special object like `use` does so we can detect if it's captured + // by userspace. + throw entangledActionThenable; + } + } - // TODO: Calling selector during render is not our end state. - // We need to find something we can eagerly write the state to which - // will be stable during subsequent renders. - // The hook object is not stable since it gets recreated during each render. - const nextState = selector(store._current); - if (!is(hook.memoizedState, nextState)) { - hook.memoizedState = nextState; + // Check if the eager state is different from the previous memoized state + if (eagerState !== null && !is(hook.memoizedState, eagerState)) { + hook.memoizedState = eagerState; markWorkInProgressReceivedUpdate(); } - return nextState; + + return hook.memoizedState; } -function createSubscription(store: ReactStore, fiber: Fiber) { +function createSubscription( + store: ReactStore, + fiber: Fiber, + selector: S => T, + queue: StoreWithSelectorQueue, +): () => void { return store.subscribe(() => { const lane = requestUpdateLane(fiber); + const isTransition = isTransitionLane(lane); + // Eagerly compute the new selected state + const newState = selector( + isTransition ? store._transition : store._current, + ); + + // Write to the appropriate queue property based on whether this is a transition + if (isTransition) { + queue.transitionEagerState = newState; + } else { + queue.syncEagerState = newState; + } + + queue.lanes = mergeLanes(queue.lanes, lane); + const root = enqueueConcurrentRenderForLane(fiber, lane); if (root !== null) { scheduleUpdateOnFiber(root, fiber, lane); + entangleTransitionUpdate(root, queue, lane); } }); } @@ -3877,7 +3944,7 @@ function enqueueRenderPhaseUpdate( // TODO: Move to ReactFiberConcurrentUpdates? function entangleTransitionUpdate( root: FiberRoot, - queue: UpdateQueue, + queue: UpdateQueue | StoreWithSelectorQueue, lane: Lane, ): void { if (isTransitionLane(lane)) { diff --git a/packages/react-reconciler/src/__tests__/todo.md b/packages/react-reconciler/src/__tests__/todo.md index 06caed04503..32b272535e6 100644 --- a/packages/react-reconciler/src/__tests__/todo.md +++ b/packages/react-reconciler/src/__tests__/todo.md @@ -1,2 +1,14 @@ baseQueue contains a mixture of updates at different priorities -`hook.queue` is stable. We could create that on mount, and use that to stash eager states. \ No newline at end of file +`hook.queue` is stable. We could create that on mount, and use that to stash eager states. +`hook.memoizedState` is updated during update to be the newly computed state, and +is compared with the previous state to determine if an update should be reported. + +## Plan + +1. Create an object with both a sync update and transition update. +2. Use that as the hook queue +3. Close over that object in the subscription +4. On update, eagerly compute the new state and write it to the correct queue property. +5. During the render phase, check the current lane, if it's a transition, read from the transition eager state, otherwise read from the sync eager state. +6. Check if this is different than the previous memoized state, and if so, mark a pending update and update the memoized state. +7. Return the memoized state. diff --git a/packages/react-reconciler/src/__tests__/useStoreWithSelector-test.js b/packages/react-reconciler/src/__tests__/useStoreWithSelector-test.js index 4328499f890..56403bc6a71 100644 --- a/packages/react-reconciler/src/__tests__/useStoreWithSelector-test.js +++ b/packages/react-reconciler/src/__tests__/useStoreWithSelector-test.js @@ -14,19 +14,10 @@ let React; let ReactNoop; let Scheduler; let act; -let useLayoutEffect; -let forwardRef; -let useImperativeHandle; -let useRef; -let useState; -let use; let createStore; let startTransition; let waitFor; -let waitForAll; let assertLog; -let Suspense; -let useMemo; describe('useStoreWithSelector', () => { beforeEach(() => { @@ -35,20 +26,11 @@ describe('useStoreWithSelector', () => { React = require('react'); ReactNoop = require('react-noop-renderer'); Scheduler = require('scheduler'); - useLayoutEffect = React.useLayoutEffect; - useImperativeHandle = React.useImperativeHandle; - forwardRef = React.forwardRef; - useRef = React.useRef; - useState = React.useState; - use = React.use; createStore = React.createStore; useStoreWithSelector = React.useStoreWithSelector; startTransition = React.startTransition; - Suspense = React.Suspense; - useMemo = React.useMemo; const InternalTestUtils = require('internal-test-utils'); waitFor = InternalTestUtils.waitFor; - waitForAll = InternalTestUtils.waitForAll; assertLog = InternalTestUtils.assertLog; act = require('internal-test-utils').act; @@ -101,6 +83,64 @@ describe('useStoreWithSelector', () => { assertLog([4]); expect(root).toMatchRenderedOutput('4'); }); + it('rebasing', async () => { + function counterReducer( + count: number, + action: {type: 'increment' | 'decrement'}, + ): number { + switch (action.type) { + case 'increment': + return count + 1; + case 'double': + return count * 2; + default: + return count; + } + } + const store = createStore(counterReducer, 2); + + function App() { + const value = useStoreWithSelector(store, identify); + Scheduler.log(value); + return <>{value}; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + startTransition(() => { + root.render(); + }); + await waitFor([2]); + }); + expect(root).toMatchRenderedOutput('2'); + + let resolve; + + await act(async () => { + await startTransition(async () => { + store.dispatch({type: 'increment'}); + await new Promise(r => (resolve = r)); + }); + }); + + assertLog([]); + expect(root).toMatchRenderedOutput('2'); + + await act(async () => { + store.dispatch({type: 'double'}); + }); + + assertLog([4]); + expect(root).toMatchRenderedOutput('4'); + + await act(async () => { + resolve(); + }); + + // TODO: Get this working + // assertLog([6]); + // expect(root).toMatchRenderedOutput('6'); + }); }); function identify(x: T): T { diff --git a/packages/react/src/ReactStore.js b/packages/react/src/ReactStore.js index bb4d8cfbe14..4405d5a2ed9 100644 --- a/packages/react/src/ReactStore.js +++ b/packages/react/src/ReactStore.js @@ -8,6 +8,8 @@ */ import type {ReactStore} from 'shared/ReactTypes'; +import ReactSharedInternals from 'shared/ReactSharedInternals'; +import is from 'shared/objectIs'; export function createStore( reducer: (S, A) => S, @@ -17,13 +19,22 @@ export function createStore( const self = { _current: initialValue, + _transition: initialValue, _reducer: reducer, dispatch(action: A) { - const nextValue = reducer(self._current, action); - if (nextValue !== self._current) { - self._current = nextValue; - subscriptions.forEach(callback => callback()); + if (ReactSharedInternals.T !== null) { + // We are in a transition, update the transition state + self._transition = reducer(self._transition, action); + } else if (is(self._current, self._transition)) { + // We are updating sync and no transition is in progress, update both + self._current = self._transition = reducer(self._transition, action); + } else { + // We are updating sync, but a transition is in progress. Implement + // React's update reordering semantics. + self._transition = reducer(self._transition, action); + self._current = reducer(self._current, action); } + subscriptions.forEach(callback => callback()); }, subscribe(callback: () => void): () => void { subscriptions.add(callback); diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 6e8e8c0a71b..3e07e9023c7 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -390,6 +390,7 @@ export type ProfilerProps = { export type ReactStore = { _current: S, + _transition: S, _reducer: (S, A) => S, subscribe: (callback: () => void) => () => void, dispatch: (action: A) => void, From b189e03a67d16b62d4a3488367d08ad5c3d553c2 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Wed, 19 Nov 2025 13:32:30 -0800 Subject: [PATCH 04/26] Try reusing useReducer code --- .../react-reconciler/src/ReactFiberHooks.js | 138 +++++++++++------- .../__tests__/useStoreWithSelector-test.js | 5 +- 2 files changed, 88 insertions(+), 55 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 19964928885..183b0735e23 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -76,6 +76,8 @@ import { GestureLane, UpdateLanes, includesOnlyTransitions, + includesTransitionLane, + SomeTransitionLane, } from './ReactFiberLane'; import { ContinuousEventPriority, @@ -1817,83 +1819,67 @@ function updateSyncExternalStore( return nextSnapshot; } +// Used as a placeholder to let us reuse updateReducerImpl for useStoreWithSelector. +function storeReducer(state: S, action: A): S { + throw new Error( + 'Should never be called. This is a bug in React. Please file an issue.', + ); +} + function mountStoreWithSelector( store: ReactStore, selector: S => T, ): T { const fiber = currentlyRenderingFiber; - const hook = mountWorkInProgressHook(); const isTransition = includesOnlyTransitions(renderLanes); - const selectedState = selector( + // TODO: If store._transition !== store._current, we need to + // enqueue an entangled fixup update to ensure consistency. + const initialState = selector( isTransition ? store._transition : store._current, ); - // TODO: If store._transition !== store._current, we need to - // enqueue an entangled fixup update to ensure consistency. - hook.memoizedState = selectedState; + const hook = mountWorkInProgressHook(); - // Create a queue object to hold eager states for sync and transition updates - const queue: StoreWithSelectorQueue = { - syncEagerState: null, - transitionEagerState: null, + hook.memoizedState = hook.baseState = initialState; + const queue: UpdateQueue = { + pending: null, lanes: NoLanes, + dispatch: null, + lastRenderedReducer: storeReducer, + lastRenderedState: (initialState: any), }; hook.queue = queue; mountEffect(createSubscription.bind(null, store, fiber, selector, queue), []); - return selectedState; + return initialState; } function updateStoreWithSelector( store: ReactStore, selector: S => T, ): T { - const fiber = currentlyRenderingFiber; const hook = updateWorkInProgressHook(); - const queue: StoreWithSelectorQueue = (hook.queue: any); + const [state /* _dispatch */] = updateReducerImpl( + hook, + ((currentHook: any): Hook), + storeReducer, + ); + + const fiber = currentlyRenderingFiber; + const queue = hook.queue; updateEffect( createSubscription.bind(null, store, fiber, selector, queue), [], ); - - // TODO: What's the correct check here? - const isTransition = includesOnlyTransitions(renderLanes); - let eagerState; - if (isTransition) { - eagerState = queue.transitionEagerState; - queue.transitionEagerState = null; - } else { - eagerState = queue.syncEagerState; - queue.syncEagerState = null; - } - - // If we are rendering as part of an async transition update that still hasn't - // resolved, we want to stop rendering and wait for the promise to resolve. - if (queue.lanes === peekEntangledActionLane()) { - const entangledActionThenable = peekEntangledActionThenable(); - if (entangledActionThenable !== null) { - // TODO: Instead of the throwing the thenable directly, throw a - // special object like `use` does so we can detect if it's captured - // by userspace. - throw entangledActionThenable; - } - } - - // Check if the eager state is different from the previous memoized state - if (eagerState !== null && !is(hook.memoizedState, eagerState)) { - hook.memoizedState = eagerState; - markWorkInProgressReceivedUpdate(); - } - - return hook.memoizedState; + return state; } function createSubscription( store: ReactStore, fiber: Fiber, selector: S => T, - queue: StoreWithSelectorQueue, + queue: UpdateQueue, ): () => void { return store.subscribe(() => { const lane = requestUpdateLane(fiber); @@ -1903,20 +1889,68 @@ function createSubscription( isTransition ? store._transition : store._current, ); - // Write to the appropriate queue property based on whether this is a transition - if (isTransition) { - queue.transitionEagerState = newState; - } else { - queue.syncEagerState = newState; - } + const update: Update = { + lane, + revertLane: NoLane, + gesture: null, + action: null, + hasEagerState: true, + eagerState: newState, + next: (null: any), + }; - queue.lanes = mergeLanes(queue.lanes, lane); + const hasQueuedTransitionUpdate = includesTransitionLane(queue.lanes); - const root = enqueueConcurrentRenderForLane(fiber, lane); + 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 && !isTransition) { + // TODO: We should determine the actual lane (lanes?) we need to use here. + const transitionLane = SomeTransitionLane; + const transitionState = selector(store._transition); + 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); + } + } }); } diff --git a/packages/react-reconciler/src/__tests__/useStoreWithSelector-test.js b/packages/react-reconciler/src/__tests__/useStoreWithSelector-test.js index 56403bc6a71..97375511430 100644 --- a/packages/react-reconciler/src/__tests__/useStoreWithSelector-test.js +++ b/packages/react-reconciler/src/__tests__/useStoreWithSelector-test.js @@ -137,9 +137,8 @@ describe('useStoreWithSelector', () => { resolve(); }); - // TODO: Get this working - // assertLog([6]); - // expect(root).toMatchRenderedOutput('6'); + assertLog([6]); + expect(root).toMatchRenderedOutput('6'); }); }); From 4360f29b727ae46ce034db7272bd0204b17f3637 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Wed, 19 Nov 2025 15:51:32 -0800 Subject: [PATCH 05/26] Apply fixup when mounting during transition --- .../react-reconciler/src/ReactFiberHooks.js | 37 +++- .../__tests__/useStoreWithSelector-test.js | 158 +++++++++++++++++- 2 files changed, 186 insertions(+), 9 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 183b0735e23..a82be810994 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -1832,16 +1832,14 @@ function mountStoreWithSelector( ): T { const fiber = currentlyRenderingFiber; const isTransition = includesOnlyTransitions(renderLanes); - // TODO: If store._transition !== store._current, we need to - // enqueue an entangled fixup update to ensure consistency. - const initialState = selector( - isTransition ? store._transition : store._current, - ); + const storeState = isTransition ? store._transition : store._current; + + const initialState = selector(storeState); const hook = mountWorkInProgressHook(); hook.memoizedState = hook.baseState = initialState; - const queue: UpdateQueue = { + const queue: UpdateQueue = { pending: null, lanes: NoLanes, dispatch: null, @@ -1851,6 +1849,33 @@ function mountStoreWithSelector( hook.queue = queue; mountEffect(createSubscription.bind(null, store, fiber, selector, queue), []); + + // If we are mounting mid-transition, we need to schedule an update to + // bring the selected state up to date with the transition state. + if (!is(storeState, store._transition)) { + const newState = selector(store._transition); + const lane = SomeTransitionLane; + const update: Update = { + lane, + revertLane: NoLane, + gesture: null, + action: null, + hasEagerState: true, + eagerState: newState, + next: (null: any), + }; + + const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane); + if (root !== null) { + startUpdateTimerByLane( + lane, + 'useStoreWithSelector mount mid transition fixup', + fiber, + ); + scheduleUpdateOnFiber(root, fiber, lane); + entangleTransitionUpdate(root, queue, lane); + } + } return initialState; } diff --git a/packages/react-reconciler/src/__tests__/useStoreWithSelector-test.js b/packages/react-reconciler/src/__tests__/useStoreWithSelector-test.js index 97375511430..4553c92bc11 100644 --- a/packages/react-reconciler/src/__tests__/useStoreWithSelector-test.js +++ b/packages/react-reconciler/src/__tests__/useStoreWithSelector-test.js @@ -18,11 +18,18 @@ let createStore; let startTransition; let waitFor; let assertLog; +let assertConsoleErrorDev; describe('useStoreWithSelector', () => { beforeEach(() => { jest.resetModules(); + ({ + act, + assertConsoleErrorDev, + // assertConsoleWarnDev, + } = require('internal-test-utils')); + React = require('react'); ReactNoop = require('react-noop-renderer'); Scheduler = require('scheduler'); @@ -32,8 +39,6 @@ describe('useStoreWithSelector', () => { const InternalTestUtils = require('internal-test-utils'); waitFor = InternalTestUtils.waitFor; assertLog = InternalTestUtils.assertLog; - - act = require('internal-test-utils').act; }); it('useStoreWithSelector', async () => { @@ -83,7 +88,7 @@ describe('useStoreWithSelector', () => { assertLog([4]); expect(root).toMatchRenderedOutput('4'); }); - it('rebasing', async () => { + it('sync update interrupts transition update', async () => { function counterReducer( count: number, action: {type: 'increment' | 'decrement'}, @@ -140,6 +145,153 @@ describe('useStoreWithSelector', () => { assertLog([6]); expect(root).toMatchRenderedOutput('6'); }); + it('store reader mounts mid transition', async () => { + function counterReducer( + count: number, + action: {type: 'increment' | 'decrement'}, + ): number { + switch (action.type) { + case 'increment': + return count + 1; + case 'double': + return count * 2; + default: + return count; + } + } + const store = createStore(counterReducer, 2); + + function StoreReader({componentName}) { + const value = useStoreWithSelector(store, identify); + Scheduler.log({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([{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([]); + expect(root).toMatchRenderedOutput('2'); + + await act(async () => { + setShowReader(true); + }); + + assertLog([ + {componentName: 'stable', value: 2}, + {componentName: 'conditional', value: 2}, + ]); + + // TODO: Avoid triggering this error. + assertConsoleErrorDev([ + 'Cannot update a component (`StoreReader`) while rendering a different component (`StoreReader`). ' + + 'To locate the bad setState() call inside `StoreReader`, ' + + 'follow the stack trace as described in https://react.dev/link/setstate-in-render\n' + + ' in App (at **)', + ]); + expect(root).toMatchRenderedOutput('22'); + + await act(async () => { + resolve(); + }); + + assertLog([ + {componentName: 'stable', value: 3}, + {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' | 'decrement'}, + ): number { + switch (action.type) { + case 'increment': + return count + 1; + case 'double': + return count * 2; + default: + return count; + } + } + const store = createStore(counterReducer, 2); + + function StoreReader({componentName}) { + const value = useStoreWithSelector(store, identify); + Scheduler.log({value, componentName}); + return <>{value}; + } + + function App() { + const value = useStoreWithSelector(store, identify); + Scheduler.log({value, componentName: 'App'}); + return ( + <> + {value} + {value > 2 ? : null} + + ); + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(); + await waitFor([{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([]); + expect(root).toMatchRenderedOutput('2'); + + await act(async () => { + resolve(); + }); + + assertLog([ + {componentName: 'App', value: 3}, + {componentName: 'conditional', value: 3}, + ]); + expect(root).toMatchRenderedOutput('33'); + }); }); function identify(x: T): T { From 871d1414f873585ec0e9825a7eb36ce36fec82d9 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Wed, 19 Nov 2025 16:20:34 -0800 Subject: [PATCH 06/26] Commit store state on root commit --- .../react-reconciler/src/ReactFiberHooks.js | 7 +++ .../src/ReactFiberWorkLoop.js | 6 ++ .../__tests__/useStoreWithSelector-test.js | 62 +++++++++++++++++++ 3 files changed, 75 insertions(+) diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index a82be810994..15efad765c8 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -1850,6 +1850,13 @@ function mountStoreWithSelector( mountEffect(createSubscription.bind(null, store, fiber, selector, queue), []); + const root = ((getWorkInProgressRoot(): any): FiberRoot); + if (root.stores == null) { + root.stores = [store]; + } else { + root.stores.push(store); + } + // If we are mounting mid-transition, we need to schedule an update to // bring the selected state up to date with the transition state. if (!is(storeState, store._transition)) { diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index f7200458be1..92f41cd4b87 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -3903,6 +3903,12 @@ function flushLayoutEffects(): void { const finishedWork = pendingFinishedWork; const lanes = pendingEffectsLanes; + if (root.stores != null) { + root.stores.forEach(store => { + store._current = store._transition; + }); + } + if (enableDefaultTransitionIndicator) { const cleanUpIndicator = root.pendingIndicator; if (cleanUpIndicator !== null && root.indicatorLanes === NoLanes) { diff --git a/packages/react-reconciler/src/__tests__/useStoreWithSelector-test.js b/packages/react-reconciler/src/__tests__/useStoreWithSelector-test.js index 4553c92bc11..f39150971f6 100644 --- a/packages/react-reconciler/src/__tests__/useStoreWithSelector-test.js +++ b/packages/react-reconciler/src/__tests__/useStoreWithSelector-test.js @@ -292,6 +292,68 @@ describe('useStoreWithSelector', () => { ]); 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' | 'decrement'}, + ): number { + switch (action.type) { + case 'increment': + return count + 1; + case 'double': + return count * 2; + default: + return count; + } + } + const store = createStore(counterReducer, 2); + + function StoreReader({componentName}) { + const value = useStoreWithSelector(store, identify); + Scheduler.log({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([{value: 2, componentName: 'stable'}]); + }); + + expect(root).toMatchRenderedOutput('2'); + + await act(async () => { + startTransition(() => { + store.dispatch({type: 'increment'}); + }); + await waitFor([{value: 3, componentName: 'stable'}]); + }); + + expect(root).toMatchRenderedOutput('3'); + + await act(async () => { + setShowReader(true); + }); + + assertLog([ + {value: 3, componentName: 'stable'}, + {componentName: 'conditional', value: 3}, + ]); + expect(root).toMatchRenderedOutput('33'); + }); }); function identify(x: T): T { From fa1ffe4e2511d04b2f237e80d9076eb3a9b6d103 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Wed, 19 Nov 2025 20:54:56 -0800 Subject: [PATCH 07/26] Extend test logging --- .../__tests__/useStoreWithSelector-test.js | 41 ++++++++++++++----- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/packages/react-reconciler/src/__tests__/useStoreWithSelector-test.js b/packages/react-reconciler/src/__tests__/useStoreWithSelector-test.js index f39150971f6..328cf562d84 100644 --- a/packages/react-reconciler/src/__tests__/useStoreWithSelector-test.js +++ b/packages/react-reconciler/src/__tests__/useStoreWithSelector-test.js @@ -46,6 +46,7 @@ describe('useStoreWithSelector', () => { count: number, action: {type: 'increment' | 'decrement'}, ): number { + Scheduler.log('reducer'); switch (action.type) { case 'increment': return count + 1; @@ -57,8 +58,13 @@ describe('useStoreWithSelector', () => { } const store = createStore(counterReducer, 2); + function identity(x) { + Scheduler.log('selector'); + return x; + } + function App() { - const value = useStoreWithSelector(store, identify); + const value = useStoreWithSelector(store, identity); Scheduler.log(value); return <>{value}; } @@ -68,7 +74,7 @@ describe('useStoreWithSelector', () => { startTransition(() => { root.render(); }); - await waitFor([2]); + await waitFor(['selector', 2]); }); expect(root).toMatchRenderedOutput('2'); @@ -77,7 +83,7 @@ describe('useStoreWithSelector', () => { store.dispatch({type: 'increment'}); }); }); - assertLog([3]); + assertLog(['reducer', 'selector', 3]); expect(root).toMatchRenderedOutput('3'); await act(async () => { @@ -85,7 +91,7 @@ describe('useStoreWithSelector', () => { store.dispatch({type: 'increment'}); }); }); - assertLog([4]); + assertLog(['reducer', 'selector', 4]); expect(root).toMatchRenderedOutput('4'); }); it('sync update interrupts transition update', async () => { @@ -93,6 +99,7 @@ describe('useStoreWithSelector', () => { count: number, action: {type: 'increment' | 'decrement'}, ): number { + Scheduler.log('reducer'); switch (action.type) { case 'increment': return count + 1; @@ -104,8 +111,13 @@ describe('useStoreWithSelector', () => { } const store = createStore(counterReducer, 2); + function identity(x) { + Scheduler.log('selector'); + return x; + } + function App() { - const value = useStoreWithSelector(store, identify); + const value = useStoreWithSelector(store, identity); Scheduler.log(value); return <>{value}; } @@ -115,7 +127,7 @@ describe('useStoreWithSelector', () => { startTransition(() => { root.render(); }); - await waitFor([2]); + await waitFor(['selector', 2]); }); expect(root).toMatchRenderedOutput('2'); @@ -128,14 +140,14 @@ describe('useStoreWithSelector', () => { }); }); - assertLog([]); + assertLog(['reducer', 'selector']); expect(root).toMatchRenderedOutput('2'); await act(async () => { store.dispatch({type: 'double'}); }); - assertLog([4]); + assertLog(['reducer', 'reducer', 'selector', 'selector', 4]); expect(root).toMatchRenderedOutput('4'); await act(async () => { @@ -150,6 +162,7 @@ describe('useStoreWithSelector', () => { count: number, action: {type: 'increment' | 'decrement'}, ): number { + Scheduler.log('reducer'); switch (action.type) { case 'increment': return count + 1; @@ -160,9 +173,13 @@ describe('useStoreWithSelector', () => { } } const store = createStore(counterReducer, 2); + function identity(x) { + Scheduler.log('selector'); + return x; + } function StoreReader({componentName}) { - const value = useStoreWithSelector(store, identify); + const value = useStoreWithSelector(store, identity); Scheduler.log({value, componentName}); return <>{value}; } @@ -183,7 +200,7 @@ describe('useStoreWithSelector', () => { const root = ReactNoop.createRoot(); await act(async () => { root.render(); - await waitFor([{value: 2, componentName: 'stable'}]); + await waitFor(['selector', {value: 2, componentName: 'stable'}]); }); expect(root).toMatchRenderedOutput('2'); @@ -197,7 +214,7 @@ describe('useStoreWithSelector', () => { }); }); - assertLog([]); + assertLog(['reducer', 'selector']); expect(root).toMatchRenderedOutput('2'); await act(async () => { @@ -206,6 +223,8 @@ describe('useStoreWithSelector', () => { assertLog([ {componentName: 'stable', value: 2}, + 'selector', + 'selector', {componentName: 'conditional', value: 2}, ]); From 9b80f05212c392c322d26f485e117750896959f3 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Wed, 19 Nov 2025 21:05:51 -0800 Subject: [PATCH 08/26] Make logging more verbose --- .../__tests__/useStoreWithSelector-test.js | 136 ++++++++++++------ 1 file changed, 94 insertions(+), 42 deletions(-) diff --git a/packages/react-reconciler/src/__tests__/useStoreWithSelector-test.js b/packages/react-reconciler/src/__tests__/useStoreWithSelector-test.js index 328cf562d84..54931752a5d 100644 --- a/packages/react-reconciler/src/__tests__/useStoreWithSelector-test.js +++ b/packages/react-reconciler/src/__tests__/useStoreWithSelector-test.js @@ -46,7 +46,7 @@ describe('useStoreWithSelector', () => { count: number, action: {type: 'increment' | 'decrement'}, ): number { - Scheduler.log('reducer'); + Scheduler.log({kind: 'reducer', state: count, action: action.type}); switch (action.type) { case 'increment': return count + 1; @@ -59,13 +59,13 @@ describe('useStoreWithSelector', () => { const store = createStore(counterReducer, 2); function identity(x) { - Scheduler.log('selector'); + Scheduler.log({kind: 'selector', state: x}); return x; } function App() { const value = useStoreWithSelector(store, identity); - Scheduler.log(value); + Scheduler.log({kind: 'render', value}); return <>{value}; } @@ -74,7 +74,10 @@ describe('useStoreWithSelector', () => { startTransition(() => { root.render(); }); - await waitFor(['selector', 2]); + await waitFor([ + {kind: 'selector', state: 2}, + {kind: 'render', value: 2}, + ]); }); expect(root).toMatchRenderedOutput('2'); @@ -83,7 +86,11 @@ describe('useStoreWithSelector', () => { store.dispatch({type: 'increment'}); }); }); - assertLog(['reducer', 'selector', 3]); + assertLog([ + {kind: 'reducer', state: 2, action: 'increment'}, + {kind: 'selector', state: 3}, + {kind: 'render', value: 3}, + ]); expect(root).toMatchRenderedOutput('3'); await act(async () => { @@ -91,7 +98,11 @@ describe('useStoreWithSelector', () => { store.dispatch({type: 'increment'}); }); }); - assertLog(['reducer', 'selector', 4]); + assertLog([ + {kind: 'reducer', state: 3, action: 'increment'}, + {kind: 'selector', state: 4}, + {kind: 'render', value: 4}, + ]); expect(root).toMatchRenderedOutput('4'); }); it('sync update interrupts transition update', async () => { @@ -99,7 +110,7 @@ describe('useStoreWithSelector', () => { count: number, action: {type: 'increment' | 'decrement'}, ): number { - Scheduler.log('reducer'); + Scheduler.log({kind: 'reducer', state: count, action: action.type}); switch (action.type) { case 'increment': return count + 1; @@ -112,13 +123,13 @@ describe('useStoreWithSelector', () => { const store = createStore(counterReducer, 2); function identity(x) { - Scheduler.log('selector'); + Scheduler.log({kind: 'selector', state: x}); return x; } function App() { const value = useStoreWithSelector(store, identity); - Scheduler.log(value); + Scheduler.log({kind: 'render', value}); return <>{value}; } @@ -127,7 +138,10 @@ describe('useStoreWithSelector', () => { startTransition(() => { root.render(); }); - await waitFor(['selector', 2]); + await waitFor([ + {kind: 'selector', state: 2}, + {kind: 'render', value: 2}, + ]); }); expect(root).toMatchRenderedOutput('2'); @@ -140,21 +154,30 @@ describe('useStoreWithSelector', () => { }); }); - assertLog(['reducer', 'selector']); + assertLog([ + {kind: 'reducer', state: 2, action: 'increment'}, + {kind: 'selector', state: 3}, + ]); expect(root).toMatchRenderedOutput('2'); await act(async () => { store.dispatch({type: 'double'}); }); - assertLog(['reducer', 'reducer', 'selector', 'selector', 4]); + 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([6]); + assertLog([{kind: 'render', value: 6}]); expect(root).toMatchRenderedOutput('6'); }); it('store reader mounts mid transition', async () => { @@ -162,7 +185,7 @@ describe('useStoreWithSelector', () => { count: number, action: {type: 'increment' | 'decrement'}, ): number { - Scheduler.log('reducer'); + Scheduler.log({kind: 'reducer', state: count, action: action.type}); switch (action.type) { case 'increment': return count + 1; @@ -174,13 +197,13 @@ describe('useStoreWithSelector', () => { } const store = createStore(counterReducer, 2); function identity(x) { - Scheduler.log('selector'); + Scheduler.log({kind: 'selector', state: x}); return x; } function StoreReader({componentName}) { const value = useStoreWithSelector(store, identity); - Scheduler.log({value, componentName}); + Scheduler.log({kind: 'render', value, componentName}); return <>{value}; } @@ -200,7 +223,10 @@ describe('useStoreWithSelector', () => { const root = ReactNoop.createRoot(); await act(async () => { root.render(); - await waitFor(['selector', {value: 2, componentName: 'stable'}]); + await waitFor([ + {kind: 'selector', state: 2}, + {kind: 'render', value: 2, componentName: 'stable'}, + ]); }); expect(root).toMatchRenderedOutput('2'); @@ -214,7 +240,10 @@ describe('useStoreWithSelector', () => { }); }); - assertLog(['reducer', 'selector']); + assertLog([ + {kind: 'reducer', state: 2, action: 'increment'}, + {kind: 'selector', state: 3}, + ]); expect(root).toMatchRenderedOutput('2'); await act(async () => { @@ -222,10 +251,10 @@ describe('useStoreWithSelector', () => { }); assertLog([ - {componentName: 'stable', value: 2}, - 'selector', - 'selector', - {componentName: 'conditional', value: 2}, + {kind: 'render', componentName: 'stable', value: 2}, + {kind: 'selector', state: 2}, + {kind: 'selector', state: 3}, + {kind: 'render', componentName: 'conditional', value: 2}, ]); // TODO: Avoid triggering this error. @@ -242,8 +271,8 @@ describe('useStoreWithSelector', () => { }); assertLog([ - {componentName: 'stable', value: 3}, - {componentName: 'conditional', value: 3}, + {kind: 'render', componentName: 'stable', value: 3}, + {kind: 'render', componentName: 'conditional', value: 3}, ]); expect(root).toMatchRenderedOutput('33'); }); @@ -253,6 +282,7 @@ describe('useStoreWithSelector', () => { count: number, action: {type: 'increment' | 'decrement'}, ): number { + Scheduler.log({kind: 'reducer', state: count, action: action.type}); switch (action.type) { case 'increment': return count + 1; @@ -264,15 +294,20 @@ describe('useStoreWithSelector', () => { } const store = createStore(counterReducer, 2); + function identity(x) { + Scheduler.log({kind: 'selector', state: x}); + return x; + } + function StoreReader({componentName}) { - const value = useStoreWithSelector(store, identify); - Scheduler.log({value, componentName}); + const value = useStoreWithSelector(store, identity); + Scheduler.log({kind: 'render', value, componentName}); return <>{value}; } function App() { - const value = useStoreWithSelector(store, identify); - Scheduler.log({value, componentName: 'App'}); + const value = useStoreWithSelector(store, identity); + Scheduler.log({kind: 'render', value, componentName: 'App'}); return ( <> {value} @@ -284,7 +319,10 @@ describe('useStoreWithSelector', () => { const root = ReactNoop.createRoot(); await act(async () => { root.render(); - await waitFor([{value: 2, componentName: 'App'}]); + await waitFor([ + {kind: 'selector', state: 2}, + {kind: 'render', value: 2, componentName: 'App'}, + ]); }); expect(root).toMatchRenderedOutput('2'); @@ -298,7 +336,10 @@ describe('useStoreWithSelector', () => { }); }); - assertLog([]); + assertLog([ + {kind: 'reducer', state: 2, action: 'increment'}, + {kind: 'selector', state: 3}, + ]); expect(root).toMatchRenderedOutput('2'); await act(async () => { @@ -306,8 +347,9 @@ describe('useStoreWithSelector', () => { }); assertLog([ - {componentName: 'App', value: 3}, - {componentName: 'conditional', value: 3}, + {kind: 'render', componentName: 'App', value: 3}, + {kind: 'selector', state: 3}, + {kind: 'render', componentName: 'conditional', value: 3}, ]); expect(root).toMatchRenderedOutput('33'); }); @@ -316,6 +358,7 @@ describe('useStoreWithSelector', () => { count: number, action: {type: 'increment' | 'decrement'}, ): number { + Scheduler.log({kind: 'reducer', state: count, action: action.type}); switch (action.type) { case 'increment': return count + 1; @@ -327,9 +370,14 @@ describe('useStoreWithSelector', () => { } const store = createStore(counterReducer, 2); + function identity(x) { + Scheduler.log({kind: 'selector', state: x}); + return x; + } + function StoreReader({componentName}) { - const value = useStoreWithSelector(store, identify); - Scheduler.log({value, componentName}); + const value = useStoreWithSelector(store, identity); + Scheduler.log({kind: 'render', value, componentName}); return <>{value}; } @@ -349,7 +397,10 @@ describe('useStoreWithSelector', () => { const root = ReactNoop.createRoot(); await act(async () => { root.render(); - await waitFor([{value: 2, componentName: 'stable'}]); + await waitFor([ + {kind: 'selector', state: 2}, + {kind: 'render', value: 2, componentName: 'stable'}, + ]); }); expect(root).toMatchRenderedOutput('2'); @@ -358,7 +409,11 @@ describe('useStoreWithSelector', () => { startTransition(() => { store.dispatch({type: 'increment'}); }); - await waitFor([{value: 3, componentName: 'stable'}]); + assertLog([ + {kind: 'reducer', state: 2, action: 'increment'}, + {kind: 'selector', state: 3}, + ]); + await waitFor([{kind: 'render', value: 3, componentName: 'stable'}]); }); expect(root).toMatchRenderedOutput('3'); @@ -368,13 +423,10 @@ describe('useStoreWithSelector', () => { }); assertLog([ - {value: 3, componentName: 'stable'}, - {componentName: 'conditional', value: 3}, + {kind: 'render', value: 3, componentName: 'stable'}, + {kind: 'selector', state: 3}, + {kind: 'render', componentName: 'conditional', value: 3}, ]); expect(root).toMatchRenderedOutput('33'); }); }); - -function identify(x: T): T { - return x; -} From b20181cb5f01b538c5377611676d12e0035db088 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Wed, 19 Nov 2025 21:46:26 -0800 Subject: [PATCH 09/26] Avoid rendering in steady state if selected state is unchanged --- .../react-reconciler/src/ReactFiberHooks.js | 19 +++++-- .../__tests__/useStoreWithSelector-test.js | 51 +++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 15efad765c8..257cab11e8d 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -1872,15 +1872,15 @@ function mountStoreWithSelector( next: (null: any), }; - const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane); + const updateRoot = enqueueConcurrentHookUpdate(fiber, queue, update, lane); if (root !== null) { startUpdateTimerByLane( lane, 'useStoreWithSelector mount mid transition fixup', fiber, ); - scheduleUpdateOnFiber(root, fiber, lane); - entangleTransitionUpdate(root, queue, lane); + scheduleUpdateOnFiber(updateRoot, fiber, lane); + entangleTransitionUpdate(updateRoot, queue, lane); } } return initialState; @@ -1921,6 +1921,19 @@ function createSubscription( isTransition ? store._transition : store._current, ); + if ( + queue.lanes === NoLanes && + queue.pending === null && + is(newState, queue.lastRenderedState) + ) { + // Selected state hasn't changed. Bail out. + + // 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, diff --git a/packages/react-reconciler/src/__tests__/useStoreWithSelector-test.js b/packages/react-reconciler/src/__tests__/useStoreWithSelector-test.js index 54931752a5d..47408dd8faa 100644 --- a/packages/react-reconciler/src/__tests__/useStoreWithSelector-test.js +++ b/packages/react-reconciler/src/__tests__/useStoreWithSelector-test.js @@ -429,4 +429,55 @@ describe('useStoreWithSelector', () => { ]); expect(root).toMatchRenderedOutput('33'); }); + it('Component does not rerender if selected value is unchanged', 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 'double': + return count * 2; + default: + return count; + } + } + const store = createStore(counterReducer, 2); + + function isEven(x) { + Scheduler.log({kind: 'selector', state: x}); + return x % 2 === 0; + } + + function App() { + const value = useStoreWithSelector(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'); + }); }); From ce58f2ae4418a9608cd759489b9897fd8d07cf9b Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Thu, 20 Nov 2025 10:13:47 -0800 Subject: [PATCH 10/26] Move ReactStore to react-reconciler package --- packages/{react => react-reconciler}/src/ReactStore.js | 0 packages/react-reconciler/src/__tests__/todo.md | 7 +++++++ packages/react/src/ReactClient.js | 2 +- 3 files changed, 8 insertions(+), 1 deletion(-) rename packages/{react => react-reconciler}/src/ReactStore.js (100%) diff --git a/packages/react/src/ReactStore.js b/packages/react-reconciler/src/ReactStore.js similarity index 100% rename from packages/react/src/ReactStore.js rename to packages/react-reconciler/src/ReactStore.js diff --git a/packages/react-reconciler/src/__tests__/todo.md b/packages/react-reconciler/src/__tests__/todo.md index 32b272535e6..a661edafe23 100644 --- a/packages/react-reconciler/src/__tests__/todo.md +++ b/packages/react-reconciler/src/__tests__/todo.md @@ -12,3 +12,10 @@ is compared with the previous state to determine if an update should be reported 5. During the render phase, check the current lane, if it's a transition, read from the transition eager state, otherwise read from the sync eager state. 6. Check if this is different than the previous memoized state, and if so, mark a pending update and update the memoized state. 7. Return the memoized state. + +## Next + +1. Enqueue updates tagged by lane +2. Method to compute state for lane +3. On commit, update base state and filter out processed updates +4. Root subscribes to store and schedules updates \ No newline at end of file diff --git a/packages/react/src/ReactClient.js b/packages/react/src/ReactClient.js index ee269252f66..9b993bbf08d 100644 --- a/packages/react/src/ReactClient.js +++ b/packages/react/src/ReactClient.js @@ -35,7 +35,7 @@ import {lazy} from './ReactLazy'; import {forwardRef} from './ReactForwardRef'; import {memo} from './ReactMemo'; import {cache, cacheSignal} from './ReactCacheClient'; -import {createStore} from './ReactStore'; +import {createStore} from 'react-reconciler/src/ReactStore'; import { getCacheForType, useCallback, From 3ce0aa51bf31dac96c37156f0644959035309957 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Thu, 20 Nov 2025 16:29:52 -0800 Subject: [PATCH 11/26] Refactor to store wrapper/tracker --- .../react-reconciler/src/ReactFiberHooks.js | 55 ++++++---- .../react-reconciler/src/ReactFiberRoot.js | 1 + .../src/ReactFiberStoreTracking.js | 101 ++++++++++++++++++ .../src/ReactFiberWorkLoop.js | 11 +- .../src/ReactInternalTypes.js | 3 + packages/react-reconciler/src/ReactStore.js | 30 ++---- .../react-reconciler/src/__tests__/todo.md | 6 +- packages/shared/ReactTypes.js | 7 +- 8 files changed, 161 insertions(+), 53 deletions(-) create mode 100644 packages/react-reconciler/src/ReactFiberStoreTracking.js diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 257cab11e8d..31c51d29a8d 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -165,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, @@ -1830,9 +1832,14 @@ function mountStoreWithSelector( store: ReactStore, selector: S => T, ): T { + const root = ((getWorkInProgressRoot(): any): FiberRoot); + if (root.storeTracker === null) { + root.storeTracker = new StoreTracker(); + } + + const wrapper = root.storeTracker.getWrapper(store); const fiber = currentlyRenderingFiber; - const isTransition = includesOnlyTransitions(renderLanes); - const storeState = isTransition ? store._transition : store._current; + const storeState = wrapper.getStateForLanes(renderLanes); const initialState = selector(storeState); @@ -1848,19 +1855,16 @@ function mountStoreWithSelector( }; hook.queue = queue; - mountEffect(createSubscription.bind(null, store, fiber, selector, queue), []); - - const root = ((getWorkInProgressRoot(): any): FiberRoot); - if (root.stores == null) { - root.stores = [store]; - } else { - root.stores.push(store); - } + mountEffect( + createSubscription.bind(null, wrapper, fiber, selector, queue), + [], + ); // If we are mounting mid-transition, we need to schedule an update to // bring the selected state up to date with the transition state. - if (!is(storeState, store._transition)) { - const newState = selector(store._transition); + const transitionState = wrapper.getStateForLanes(SomeTransitionLane); + if (!is(storeState, transitionState)) { + const newState = selector(transitionState); const lane = SomeTransitionLane; const update: Update = { lane, @@ -1887,9 +1891,17 @@ function mountStoreWithSelector( } function updateStoreWithSelector( - store: ReactStore, + store: StoreWrapper, selector: S => T, ): T { + const root = ((getWorkInProgressRoot(): any): FiberRoot); + if (root.storeTracker === null) { + // TODO: This could be an invariant violation if the store was not + // mounted previously. + root.storeTracker = new StoreTracker(); + } + + const wrapper = root.storeTracker.getWrapper(store); const hook = updateWorkInProgressHook(); const [state /* _dispatch */] = updateReducerImpl( hook, @@ -1901,25 +1913,22 @@ function updateStoreWithSelector( const queue = hook.queue; updateEffect( - createSubscription.bind(null, store, fiber, selector, queue), + createSubscription.bind(null, wrapper, fiber, selector, queue), [], ); return state; } function createSubscription( - store: ReactStore, + storeWrapper: ReactStore, fiber: Fiber, selector: S => T, queue: UpdateQueue, ): () => void { - return store.subscribe(() => { + return storeWrapper.subscribe(() => { const lane = requestUpdateLane(fiber); - const isTransition = isTransitionLane(lane); // Eagerly compute the new selected state - const newState = selector( - isTransition ? store._transition : store._current, - ); + const newState = selector(storeWrapper.getStateForLanes(lane)); if ( queue.lanes === NoLanes && @@ -1971,10 +1980,12 @@ function createSubscription( // 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 && !isTransition) { + if (hasQueuedTransitionUpdate && !isTransitionLane(lane)) { // TODO: We should determine the actual lane (lanes?) we need to use here. const transitionLane = SomeTransitionLane; - const transitionState = selector(store._transition); + const transitionState = selector( + storeWrapper.getStateForLanes(transitionLane), + ); const transitionUpdate: Update = { lane: transitionLane, revertLane: NoLane, 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..07f2b977afe --- /dev/null +++ b/packages/react-reconciler/src/ReactFiberStoreTracking.js @@ -0,0 +1,101 @@ +/** + * 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 {ReactStore} from 'shared/ReactTypes'; + +import {includesOnlyTransitions} 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; + store: ReactStore; + constructor(store: ReactStore) { + this._headState = this._committedState = store.getState(); + 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); + } + } + getStateForLanes(lanes: Lanes): S { + const isTransition = includesOnlyTransitions(lanes); + return isTransition ? this._headState : this._committedState; + } + subscribe(callback: () => void): () => void { + return this.store.subscribe(callback); + } + commitFinished(lanes: Lanes) { + this._committedState = this._headState; + } + 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: ReactStore): 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: ReactStore): 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 92f41cd4b87..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 @@ -3903,12 +3908,6 @@ function flushLayoutEffects(): void { const finishedWork = pendingFinishedWork; const lanes = pendingEffectsLanes; - if (root.stores != null) { - root.stores.forEach(store => { - store._current = store._transition; - }); - } - if (enableDefaultTransitionIndicator) { const cleanUpIndicator = root.pendingIndicator; if (cleanUpIndicator !== null && root.indicatorLanes === NoLanes) { diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index eea60c34938..9d5e685ac79 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -42,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 = @@ -233,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 diff --git a/packages/react-reconciler/src/ReactStore.js b/packages/react-reconciler/src/ReactStore.js index 4405d5a2ed9..dee1543116d 100644 --- a/packages/react-reconciler/src/ReactStore.js +++ b/packages/react-reconciler/src/ReactStore.js @@ -8,35 +8,25 @@ */ import type {ReactStore} from 'shared/ReactTypes'; -import ReactSharedInternals from 'shared/ReactSharedInternals'; -import is from 'shared/objectIs'; export function createStore( reducer: (S, A) => S, initialValue: S, ): ReactStore { - const subscriptions = new Set<() => void>(); + const subscriptions = new Set<(action: A) => void>(); + + let state = initialValue; const self = { - _current: initialValue, - _transition: initialValue, - _reducer: reducer, + getState(): S { + return state; + }, + reducer: reducer, dispatch(action: A) { - if (ReactSharedInternals.T !== null) { - // We are in a transition, update the transition state - self._transition = reducer(self._transition, action); - } else if (is(self._current, self._transition)) { - // We are updating sync and no transition is in progress, update both - self._current = self._transition = reducer(self._transition, action); - } else { - // We are updating sync, but a transition is in progress. Implement - // React's update reordering semantics. - self._transition = reducer(self._transition, action); - self._current = reducer(self._current, action); - } - subscriptions.forEach(callback => callback()); + state = reducer(state, action); + subscriptions.forEach(callback => callback(action)); }, - subscribe(callback: () => void): () => void { + subscribe(callback: (action: A) => void): () => void { subscriptions.add(callback); return () => { subscriptions.delete(callback); diff --git a/packages/react-reconciler/src/__tests__/todo.md b/packages/react-reconciler/src/__tests__/todo.md index a661edafe23..0ae5a81b63d 100644 --- a/packages/react-reconciler/src/__tests__/todo.md +++ b/packages/react-reconciler/src/__tests__/todo.md @@ -18,4 +18,8 @@ is compared with the previous state to determine if an update should be reported 1. Enqueue updates tagged by lane 2. Method to compute state for lane 3. On commit, update base state and filter out processed updates -4. Root subscribes to store and schedules updates \ No newline at end of file +4. Root subscribes to store and schedules updates + +- [ ] Look at dispatch to see how an update is assigned a lane. Can we do the same for store updates? +- [ ] We should include an update if isSubsetOfLanes(renderLanes, update.lane) + - [ ] Small caveat relating to offscreen (see isHiddenUpdate in updateReducerImpl) \ No newline at end of file diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 3e07e9023c7..b53fea19545 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -389,9 +389,8 @@ export type ProfilerProps = { }; export type ReactStore = { - _current: S, - _transition: S, - _reducer: (S, A) => S, - subscribe: (callback: () => void) => () => void, + getState(): S, + reducer: (S, A) => S, + subscribe: (callback: (action: A) => void) => () => void, dispatch: (action: A) => void, }; From b6f0c7ea22ee4ae1122df833a3dd7ab930972008 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Mon, 24 Nov 2025 12:58:53 -0800 Subject: [PATCH 12/26] Small tweaks --- .../react-reconciler/src/ReactFiberStoreTracking.js | 8 +++++--- packages/react-reconciler/src/ReactStore.js | 3 +-- packages/shared/ReactTypes.js | 11 ++++++++++- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberStoreTracking.js b/packages/react-reconciler/src/ReactFiberStoreTracking.js index 07f2b977afe..5aa789c2b78 100644 --- a/packages/react-reconciler/src/ReactFiberStoreTracking.js +++ b/packages/react-reconciler/src/ReactFiberStoreTracking.js @@ -10,7 +10,7 @@ import type {Lanes} from './ReactFiberLane'; import type {ReactStore} from 'shared/ReactTypes'; -import {includesOnlyTransitions} from './ReactFiberLane'; +import {includesTransitionLane} from './ReactFiberLane'; import ReactSharedInternals from 'shared/ReactSharedInternals'; import is from 'shared/objectIs'; @@ -44,14 +44,16 @@ export class StoreWrapper { } } getStateForLanes(lanes: Lanes): S { - const isTransition = includesOnlyTransitions(lanes); + const isTransition = includesTransitionLane(lanes); return isTransition ? this._headState : this._committedState; } subscribe(callback: () => void): () => void { + // TODO: Have our own subscription mechanism such that fibers subscribe to the wrapper + // and the wrapper can subscribe to the store. return this.store.subscribe(callback); } commitFinished(lanes: Lanes) { - this._committedState = this._headState; + this._committedState = this.getStateForLanes(lanes); } dispose() { this._unsubscribe(); diff --git a/packages/react-reconciler/src/ReactStore.js b/packages/react-reconciler/src/ReactStore.js index dee1543116d..a7f3ead892f 100644 --- a/packages/react-reconciler/src/ReactStore.js +++ b/packages/react-reconciler/src/ReactStore.js @@ -17,7 +17,7 @@ export function createStore( let state = initialValue; - const self = { + return { getState(): S { return state; }, @@ -33,5 +33,4 @@ export function createStore( }; }, }; - return self; } diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index b53fea19545..846082964f9 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -388,9 +388,18 @@ export type ProfilerProps = { children?: ReactNodeList, }; -export type ReactStore = { +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, }; From bbc914fd8e44f7acef09a62e3dd7ff1e20f22101 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Mon, 24 Nov 2025 13:05:23 -0800 Subject: [PATCH 13/26] Fix flow errors --- packages/react-debug-tools/src/ReactDebugHooks.js | 13 +++---------- packages/react-reconciler/src/ReactFiberHooks.js | 8 ++++---- packages/react-server/src/ReactFizzHooks.js | 5 +++-- 3 files changed, 10 insertions(+), 16 deletions(-) diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index 298379140a7..32103ce7a9f 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -486,16 +486,9 @@ function useStoreWithSelector( store: ReactStore, selector: (state: S) => T, ): T { - const value = selector(store._current); - hookLog.push({ - displayName: null, - primitive: 'StoreWithSelector', - stackError: new Error(), - value, - debugInfo: null, - dispatcherHookName: 'StoreWithSelector', - }); - return value; + throw new Error( + 'useStoreWithSelector is not yet supported in React Debug Tools.', + ); } function useTransition(): [ diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 31c51d29a8d..3cbd4f56e10 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -1877,7 +1877,7 @@ function mountStoreWithSelector( }; const updateRoot = enqueueConcurrentHookUpdate(fiber, queue, update, lane); - if (root !== null) { + if (updateRoot !== null) { startUpdateTimerByLane( lane, 'useStoreWithSelector mount mid transition fixup', @@ -1891,7 +1891,7 @@ function mountStoreWithSelector( } function updateStoreWithSelector( - store: StoreWrapper, + store: ReactStore, selector: S => T, ): T { const root = ((getWorkInProgressRoot(): any): FiberRoot); @@ -1920,7 +1920,7 @@ function updateStoreWithSelector( } function createSubscription( - storeWrapper: ReactStore, + storeWrapper: StoreWrapper, fiber: Fiber, selector: S => T, queue: UpdateQueue, @@ -4034,7 +4034,7 @@ function enqueueRenderPhaseUpdate( // TODO: Move to ReactFiberConcurrentUpdates? function entangleTransitionUpdate( root: FiberRoot, - queue: UpdateQueue | StoreWithSelectorQueue, + queue: UpdateQueue, lane: Lane, ): void { if (isTransitionLane(lane)) { diff --git a/packages/react-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js index 963539c30a7..463b93ea990 100644 --- a/packages/react-server/src/ReactFizzHooks.js +++ b/packages/react-server/src/ReactFizzHooks.js @@ -569,8 +569,9 @@ function useStoreWithSelector( store: ReactStore, selector: (state: S) => T, ): T { - resolveCurrentlyRenderingComponent(); - return selector(store._current); + throw new Error( + 'useStoreWithSelector is not yet supported during server rendering.', + ); } function useDeferredValue(value: T, initialValue?: T): T { From 444fb578bedcffea07bae73881ed0ed1d144e295 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Mon, 24 Nov 2025 14:06:37 -0800 Subject: [PATCH 14/26] Add feature flag and rename API --- .../react-debug-tools/src/ReactDebugHooks.js | 10 +- .../react-reconciler/src/ReactFiberHooks.js | 191 ++++++++++++------ .../src/ReactInternalTypes.js | 9 +- packages/react-reconciler/src/ReactStore.js | 7 + ...eWithSelector-test.js => useStore-test.js} | 23 ++- packages/react-server/src/ReactFizzHooks.js | 17 +- packages/react-server/src/ReactFlightHooks.js | 2 +- .../react/index.experimental.development.js | 4 +- packages/react/index.experimental.js | 4 +- packages/react/index.js | 4 +- packages/react/src/ReactClient.js | 9 +- packages/react/src/ReactHooks.js | 9 +- packages/shared/ReactFeatureFlags.js | 2 + .../forks/ReactFeatureFlags.native-fb.js | 1 + .../forks/ReactFeatureFlags.native-oss.js | 1 + .../forks/ReactFeatureFlags.test-renderer.js | 1 + ...actFeatureFlags.test-renderer.native-fb.js | 1 + .../ReactFeatureFlags.test-renderer.www.js | 1 + .../shared/forks/ReactFeatureFlags.www.js | 1 + scripts/error-codes/codes.json | 6 +- 20 files changed, 196 insertions(+), 107 deletions(-) rename packages/react-reconciler/src/__tests__/{useStoreWithSelector-test.js => useStore-test.js} (95%) diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index 32103ce7a9f..b6728cf9e45 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -482,13 +482,11 @@ function useSyncExternalStore( return value; } -function useStoreWithSelector( +function useStore( store: ReactStore, - selector: (state: S) => T, + selector?: (state: S) => T, ): T { - throw new Error( - 'useStoreWithSelector is not yet supported in React Debug Tools.', - ); + throw new Error('useStore is not yet supported in React Debug Tools.'); } function useTransition(): [ @@ -787,7 +785,7 @@ const Dispatcher: DispatcherType = { useDeferredValue, useTransition, useSyncExternalStore, - useStoreWithSelector, + useStore, useId, useHostTransitionStatus, useFormState, diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 3cbd4f56e10..d47af53e777 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -40,6 +40,7 @@ import { enableSchedulingProfiler, enableTransitionTracing, enableUseEffectEventHook, + enableStore, enableLegacyCache, disableLegacyMode, enableNoCloningMemoCache, @@ -243,7 +244,7 @@ type StoreConsistencyCheck = { }; // TODO: Use something other than null -type StoreWithSelectorQueue = { +type StoreQueue = { syncEagerState: T | null, transitionEagerState: T | null, lanes: Lanes, @@ -1821,17 +1822,20 @@ function updateSyncExternalStore( return nextSnapshot; } -// Used as a placeholder to let us reuse updateReducerImpl for useStoreWithSelector. +// Used as a placeholder to let us reuse updateReducerImpl for useStore. function storeReducer(state: S, action: A): S { throw new Error( - 'Should never be called. This is a bug in React. Please file an issue.', + 'storeReducer should never be called. This is a bug in React. Please file an issue.', ); } -function mountStoreWithSelector( - store: ReactStore, - selector: S => T, -): T { +function identity(x: T): T { + return x; +} + +function mountStore(store: ReactStore, 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(); @@ -1841,7 +1845,7 @@ function mountStoreWithSelector( const fiber = currentlyRenderingFiber; const storeState = wrapper.getStateForLanes(renderLanes); - const initialState = selector(storeState); + const initialState = actualSelector(storeState); const hook = mountWorkInProgressHook(); @@ -1856,7 +1860,7 @@ function mountStoreWithSelector( hook.queue = queue; mountEffect( - createSubscription.bind(null, wrapper, fiber, selector, queue), + createSubscription.bind(null, wrapper, fiber, actualSelector, queue), [], ); @@ -1864,7 +1868,7 @@ function mountStoreWithSelector( // bring the selected state up to date with the transition state. const transitionState = wrapper.getStateForLanes(SomeTransitionLane); if (!is(storeState, transitionState)) { - const newState = selector(transitionState); + const newState = actualSelector(transitionState); const lane = SomeTransitionLane; const update: Update = { lane, @@ -1880,7 +1884,7 @@ function mountStoreWithSelector( if (updateRoot !== null) { startUpdateTimerByLane( lane, - 'useStoreWithSelector mount mid transition fixup', + 'useStore mount mid transition fixup', fiber, ); scheduleUpdateOnFiber(updateRoot, fiber, lane); @@ -1890,10 +1894,9 @@ function mountStoreWithSelector( return initialState; } -function updateStoreWithSelector( - store: ReactStore, - selector: S => T, -): T { +function updateStore(store: ReactStore, selector?: S => T): T { + const actualSelector: S => T = + selector === undefined ? (identity: any) : selector; const root = ((getWorkInProgressRoot(): any): FiberRoot); if (root.storeTracker === null) { // TODO: This could be an invariant violation if the store was not @@ -1913,7 +1916,7 @@ function updateStoreWithSelector( const queue = hook.queue; updateEffect( - createSubscription.bind(null, wrapper, fiber, selector, queue), + createSubscription.bind(null, wrapper, fiber, actualSelector, queue), [], ); return state; @@ -4081,7 +4084,6 @@ export const ContextOnlyDispatcher: Dispatcher = { useDeferredValue: throwInvalidHookError, useTransition: throwInvalidHookError, useSyncExternalStore: throwInvalidHookError, - useStoreWithSelector: throwInvalidHookError, useId: throwInvalidHookError, useHostTransitionStatus: throwInvalidHookError, useFormState: throwInvalidHookError, @@ -4093,6 +4095,9 @@ export const ContextOnlyDispatcher: Dispatcher = { if (enableUseEffectEventHook) { (ContextOnlyDispatcher: Dispatcher).useEffectEvent = throwInvalidHookError; } +if (enableStore) { + (ContextOnlyDispatcher: Dispatcher).useStore = throwInvalidHookError; +} const HooksDispatcherOnMount: Dispatcher = { readContext, @@ -4112,7 +4117,6 @@ const HooksDispatcherOnMount: Dispatcher = { useDeferredValue: mountDeferredValue, useTransition: mountTransition, useSyncExternalStore: mountSyncExternalStore, - useStoreWithSelector: mountStoreWithSelector, useId: mountId, useHostTransitionStatus: useHostTransitionStatus, useFormState: mountActionState, @@ -4124,6 +4128,9 @@ const HooksDispatcherOnMount: Dispatcher = { if (enableUseEffectEventHook) { (HooksDispatcherOnMount: Dispatcher).useEffectEvent = mountEvent; } +if (enableStore) { + (HooksDispatcherOnMount: Dispatcher).useStore = mountStore; +} const HooksDispatcherOnUpdate: Dispatcher = { readContext, @@ -4143,7 +4150,6 @@ const HooksDispatcherOnUpdate: Dispatcher = { useDeferredValue: updateDeferredValue, useTransition: updateTransition, useSyncExternalStore: updateSyncExternalStore, - useStoreWithSelector: updateStoreWithSelector, useId: updateId, useHostTransitionStatus: useHostTransitionStatus, useFormState: updateActionState, @@ -4155,6 +4161,9 @@ const HooksDispatcherOnUpdate: Dispatcher = { if (enableUseEffectEventHook) { (HooksDispatcherOnUpdate: Dispatcher).useEffectEvent = updateEvent; } +if (enableStore) { + (HooksDispatcherOnUpdate: Dispatcher).useStore = updateStore; +} const HooksDispatcherOnRerender: Dispatcher = { readContext, @@ -4174,7 +4183,6 @@ const HooksDispatcherOnRerender: Dispatcher = { useDeferredValue: rerenderDeferredValue, useTransition: rerenderTransition, useSyncExternalStore: updateSyncExternalStore, - useStoreWithSelector: updateStoreWithSelector, useId: updateId, useHostTransitionStatus: useHostTransitionStatus, useFormState: rerenderActionState, @@ -4186,6 +4194,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; @@ -4336,13 +4347,10 @@ if (__DEV__) { mountHookTypesDev(); return mountSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, - useStoreWithSelector( - store: ReactStore, - selector: (state: S) => T, - ): T { - currentHookNameInDev = 'useStoreWithSelector'; + useStore(store: ReactStore, selector?: (state: S) => T): T { + currentHookNameInDev = 'useStore'; mountHookTypesDev(); - return mountStoreWithSelector(store, selector); + return mountStore(store, selector); }, useId(): string { currentHookNameInDev = 'useId'; @@ -4394,6 +4402,16 @@ if (__DEV__) { return mountEvent(callback); }; } + if (enableStore) { + (HooksDispatcherOnMountInDEV: Dispatcher).useStore = function useStore< + S, + T, + >(store: ReactStore, selector?: (state: S) => T): T { + currentHookNameInDev = 'useStore'; + mountHookTypesDev(); + return mountStore(store, selector); + }; + } HooksDispatcherOnMountWithHookTypesInDEV = { readContext(context: ReactContext): T { @@ -4511,13 +4529,10 @@ if (__DEV__) { updateHookTypesDev(); return mountSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, - useStoreWithSelector( - store: ReactStore, - selector: (state: S) => T, - ): T { - currentHookNameInDev = 'useStoreWithSelector'; + useStore(store: ReactStore, selector?: (state: S) => T): T { + currentHookNameInDev = 'useStore'; updateHookTypesDev(); - return mountStoreWithSelector(store, selector); + return mountStore(store, selector); }, useId(): string { currentHookNameInDev = 'useId'; @@ -4569,6 +4584,17 @@ if (__DEV__) { return mountEvent(callback); }; } + if (enableStore) { + (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useStore = + function useStore( + store: ReactStore, + selector?: (state: S) => T, + ): T { + currentHookNameInDev = 'useStore'; + updateHookTypesDev(); + return mountStore(store, selector); + }; + } HooksDispatcherOnUpdateInDEV = { readContext(context: ReactContext): T { @@ -4686,13 +4712,10 @@ if (__DEV__) { updateHookTypesDev(); return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, - useStoreWithSelector( - store: ReactStore, - selector: (state: S) => T, - ): T { - currentHookNameInDev = 'useStoreWithSelector'; + useStore(store: ReactStore, selector?: (state: S) => T): T { + currentHookNameInDev = 'useStore'; updateHookTypesDev(); - return updateStoreWithSelector(store, selector); + return updateStore(store, selector); }, useId(): string { currentHookNameInDev = 'useId'; @@ -4744,6 +4767,16 @@ if (__DEV__) { return updateEvent(callback); }; } + if (enableStore) { + (HooksDispatcherOnUpdateInDEV: Dispatcher).useStore = function useStore< + S, + T, + >(store: ReactStore, selector?: (state: S) => T): T { + currentHookNameInDev = 'useStore'; + updateHookTypesDev(); + return updateStore(store, selector); + }; + } HooksDispatcherOnRerenderInDEV = { readContext(context: ReactContext): T { @@ -4861,13 +4894,10 @@ if (__DEV__) { updateHookTypesDev(); return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, - useStoreWithSelector( - store: ReactStore, - selector: (state: S) => T, - ): T { - currentHookNameInDev = 'useStoreWithSelector'; + useStore(store: ReactStore, selector?: (state: S) => T): T { + currentHookNameInDev = 'useStore'; updateHookTypesDev(); - return updateStoreWithSelector(store, selector); + return updateStore(store, selector); }, useId(): string { currentHookNameInDev = 'useId'; @@ -4919,6 +4949,16 @@ if (__DEV__) { return updateEvent(callback); }; } + if (enableStore) { + (HooksDispatcherOnRerenderInDEV: Dispatcher).useStore = function useStore< + S, + T, + >(store: ReactStore, selector?: (state: S) => T): T { + currentHookNameInDev = 'useStore'; + updateHookTypesDev(); + return updateStore(store, selector); + }; + } InvalidNestedHooksDispatcherOnMountInDEV = { readContext(context: ReactContext): T { @@ -5054,14 +5094,11 @@ if (__DEV__) { mountHookTypesDev(); return mountSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, - useStoreWithSelector( - store: ReactStore, - selector: (state: S) => T, - ): T { - currentHookNameInDev = 'useStoreWithSelector'; + useStore(store: ReactStore, selector?: (state: S) => T): T { + currentHookNameInDev = 'useStore'; warnInvalidHookAccess(); mountHookTypesDev(); - return mountStoreWithSelector(store, selector); + return mountStore(store, selector); }, useId(): string { currentHookNameInDev = 'useId'; @@ -5120,6 +5157,18 @@ if (__DEV__) { return mountEvent(callback); }; } + if (enableStore) { + (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useStore = + function useStore( + store: ReactStore, + selector?: (state: S) => T, + ): T { + currentHookNameInDev = 'useStore'; + warnInvalidHookAccess(); + mountHookTypesDev(); + return mountStore(store, selector); + }; + } InvalidNestedHooksDispatcherOnUpdateInDEV = { readContext(context: ReactContext): T { @@ -5255,14 +5304,11 @@ if (__DEV__) { updateHookTypesDev(); return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, - useStoreWithSelector( - store: ReactStore, - selector: (state: S) => T, - ): T { - currentHookNameInDev = 'useStoreWithSelector'; + useStore(store: ReactStore, selector?: (state: S) => T): T { + currentHookNameInDev = 'useStore'; warnInvalidHookAccess(); updateHookTypesDev(); - return updateStoreWithSelector(store, selector); + return updateStore(store, selector); }, useId(): string { currentHookNameInDev = 'useId'; @@ -5321,6 +5367,18 @@ if (__DEV__) { return updateEvent(callback); }; } + if (enableStore) { + (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useStore = + function useStore( + store: ReactStore, + selector?: (state: S) => T, + ): T { + currentHookNameInDev = 'useStore'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateStore(store, selector); + }; + } InvalidNestedHooksDispatcherOnRerenderInDEV = { readContext(context: ReactContext): T { @@ -5456,14 +5514,11 @@ if (__DEV__) { updateHookTypesDev(); return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, - useStoreWithSelector( - store: ReactStore, - selector: (state: S) => T, - ): T { - currentHookNameInDev = 'useStoreWithSelector'; + useStore(store: ReactStore, selector?: (state: S) => T): T { + currentHookNameInDev = 'useStore'; warnInvalidHookAccess(); updateHookTypesDev(); - return updateStoreWithSelector(store, selector); + return updateStore(store, selector); }, useId(): string { currentHookNameInDev = 'useId'; @@ -5522,4 +5577,16 @@ if (__DEV__) { return updateEvent(callback); }; } + if (enableStore) { + (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useStore = + function useStore( + store: ReactStore, + selector?: (state: S) => T, + ): T { + currentHookNameInDev = 'useStore'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateStore(store, selector); + }; + } } diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index 9d5e685ac79..8be8e3a1787 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -61,7 +61,7 @@ export type HookType = | 'useDeferredValue' | 'useTransition' | 'useSyncExternalStore' - | 'useStoreWithSelector' + | 'useStore' | 'useId' | 'useCacheRefresh' | 'useOptimistic' @@ -443,10 +443,11 @@ export type Dispatcher = { getSnapshot: () => T, getServerSnapshot?: () => T, ): T, - useStoreWithSelector( + // TODO: Non-nullable once `enableStore` is on everywhere. + useStore?: ( store: ReactStore, - selector: (state: S) => T, - ): T, + 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 index a7f3ead892f..c2974c50591 100644 --- a/packages/react-reconciler/src/ReactStore.js +++ b/packages/react-reconciler/src/ReactStore.js @@ -8,11 +8,18 @@ */ import type {ReactStore} from 'shared/ReactTypes'; +import {enableStore} from 'shared/ReactFeatureFlags'; export function createStore( reducer: (S, A) => S, initialValue: S, ): ReactStore { + if (!enableStore) { + throw new Error( + 'createStore is not available because the enableStore feature flag is not enabled.', + ); + } + const subscriptions = new Set<(action: A) => void>(); let state = initialValue; diff --git a/packages/react-reconciler/src/__tests__/useStoreWithSelector-test.js b/packages/react-reconciler/src/__tests__/useStore-test.js similarity index 95% rename from packages/react-reconciler/src/__tests__/useStoreWithSelector-test.js rename to packages/react-reconciler/src/__tests__/useStore-test.js index 47408dd8faa..525cafbb169 100644 --- a/packages/react-reconciler/src/__tests__/useStoreWithSelector-test.js +++ b/packages/react-reconciler/src/__tests__/useStore-test.js @@ -5,11 +5,12 @@ * LICENSE file in the root directory of this source tree. * * @emails react-core + * @gate enableStore */ 'use strict'; -let useStoreWithSelector; +let useStore; let React; let ReactNoop; let Scheduler; @@ -20,7 +21,7 @@ let waitFor; let assertLog; let assertConsoleErrorDev; -describe('useStoreWithSelector', () => { +describe('useStore', () => { beforeEach(() => { jest.resetModules(); @@ -34,14 +35,14 @@ describe('useStoreWithSelector', () => { ReactNoop = require('react-noop-renderer'); Scheduler = require('scheduler'); createStore = React.createStore; - useStoreWithSelector = React.useStoreWithSelector; + useStore = React.useStore; startTransition = React.startTransition; const InternalTestUtils = require('internal-test-utils'); waitFor = InternalTestUtils.waitFor; assertLog = InternalTestUtils.assertLog; }); - it('useStoreWithSelector', async () => { + it('useStore', async () => { function counterReducer( count: number, action: {type: 'increment' | 'decrement'}, @@ -64,7 +65,7 @@ describe('useStoreWithSelector', () => { } function App() { - const value = useStoreWithSelector(store, identity); + const value = useStore(store, identity); Scheduler.log({kind: 'render', value}); return <>{value}; } @@ -128,7 +129,7 @@ describe('useStoreWithSelector', () => { } function App() { - const value = useStoreWithSelector(store, identity); + const value = useStore(store, identity); Scheduler.log({kind: 'render', value}); return <>{value}; } @@ -202,7 +203,7 @@ describe('useStoreWithSelector', () => { } function StoreReader({componentName}) { - const value = useStoreWithSelector(store, identity); + const value = useStore(store, identity); Scheduler.log({kind: 'render', value, componentName}); return <>{value}; } @@ -300,13 +301,13 @@ describe('useStoreWithSelector', () => { } function StoreReader({componentName}) { - const value = useStoreWithSelector(store, identity); + const value = useStore(store, identity); Scheduler.log({kind: 'render', value, componentName}); return <>{value}; } function App() { - const value = useStoreWithSelector(store, identity); + const value = useStore(store, identity); Scheduler.log({kind: 'render', value, componentName: 'App'}); return ( <> @@ -376,7 +377,7 @@ describe('useStoreWithSelector', () => { } function StoreReader({componentName}) { - const value = useStoreWithSelector(store, identity); + const value = useStore(store, identity); Scheduler.log({kind: 'render', value, componentName}); return <>{value}; } @@ -452,7 +453,7 @@ describe('useStoreWithSelector', () => { } function App() { - const value = useStoreWithSelector(store, isEven); + const value = useStore(store, isEven); Scheduler.log({kind: 'render', value, componentName: 'App'}); return <>{value ? 'true' : 'false'}; } diff --git a/packages/react-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js index 463b93ea990..ba2c2afe465 100644 --- a/packages/react-server/src/ReactFizzHooks.js +++ b/packages/react-server/src/ReactFizzHooks.js @@ -39,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, @@ -565,13 +565,11 @@ function useSyncExternalStore( return getServerSnapshot(); } -function useStoreWithSelector( +function useStore( store: ReactStore, - selector: (state: S) => T, + selector?: (state: S) => T, ): T { - throw new Error( - 'useStoreWithSelector is not yet supported during server rendering.', - ); + throw new Error('useStore is not yet supported during server rendering.'); } function useDeferredValue(value: T, initialValue?: T): T { @@ -837,7 +835,6 @@ export const HooksDispatcher: Dispatcher = supportsClientAPIs useId, // Subscriptions are not setup in a server environment. useSyncExternalStore, - useStoreWithSelector, useOptimistic, useActionState, useFormState: useActionState, @@ -862,7 +859,6 @@ export const HooksDispatcher: Dispatcher = supportsClientAPIs useDeferredValue: clientHookNotSupported, useTransition: clientHookNotSupported, useSyncExternalStore: clientHookNotSupported, - useStoreWithSelector: clientHookNotSupported, useId, useHostTransitionStatus, useFormState: useActionState, @@ -875,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 0ac82deb4f3..512167bc230 100644 --- a/packages/react-server/src/ReactFlightHooks.js +++ b/packages/react-server/src/ReactFlightHooks.js @@ -86,7 +86,7 @@ export const HooksDispatcher: Dispatcher = { useDeferredValue: (unsupportedHook: any), useTransition: (unsupportedHook: any), useSyncExternalStore: (unsupportedHook: any), - useStoreWithSelector: (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 bc22640a1e0..a8ca25fa04b 100644 --- a/packages/react/index.experimental.development.js +++ b/packages/react/index.experimental.development.js @@ -21,7 +21,6 @@ export { createContext, createElement, createRef, - createStore, use, forwardRef, isValidElement, @@ -54,12 +53,13 @@ export { useRef, useState, useSyncExternalStore, - useStoreWithSelector, + 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 be27fcd8d89..7915796efc0 100644 --- a/packages/react/index.experimental.js +++ b/packages/react/index.experimental.js @@ -21,7 +21,6 @@ export { createContext, createElement, createRef, - createStore, use, forwardRef, isValidElement, @@ -55,10 +54,11 @@ export { useRef, useState, useSyncExternalStore, - useStoreWithSelector, + 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 23c1ac42e18..a32c6f1206c 100644 --- a/packages/react/index.js +++ b/packages/react/index.js @@ -35,7 +35,6 @@ export { createContext, createElement, createRef, - createStore, use, forwardRef, isValidElement, @@ -66,11 +65,12 @@ export { useMemo, useOptimistic, useSyncExternalStore, - useStoreWithSelector, + 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 9b993bbf08d..6152ab20959 100644 --- a/packages/react/src/ReactClient.js +++ b/packages/react/src/ReactClient.js @@ -35,7 +35,6 @@ import {lazy} from './ReactLazy'; import {forwardRef} from './ReactForwardRef'; import {memo} from './ReactMemo'; import {cache, cacheSignal} from './ReactCacheClient'; -import {createStore} from 'react-reconciler/src/ReactStore'; import { getCacheForType, useCallback, @@ -48,7 +47,7 @@ import { useLayoutEffect, useMemo, useSyncExternalStore, - useStoreWithSelector, + useStore, useReducer, useRef, useState, @@ -67,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, @@ -86,7 +87,6 @@ export { memo, cache, cacheSignal, - createStore, useCallback, useContext, useEffect, @@ -99,7 +99,7 @@ export { useOptimistic, useActionState, useSyncExternalStore, - useStoreWithSelector, + useStore, useReducer, useRef, useState, @@ -138,4 +138,5 @@ export { useId, act, captureOwnerStack, + createStore, }; diff --git a/packages/react/src/ReactHooks.js b/packages/react/src/ReactHooks.js index 53afe878396..643051f739c 100644 --- a/packages/react/src/ReactHooks.js +++ b/packages/react/src/ReactHooks.js @@ -199,12 +199,13 @@ export function useSyncExternalStore( ); } -export function useStoreWithSelector( +export function useStore( store: ReactStore, - selector: S => T, -): T { + selector?: (state: S) => T, +): S | T { const dispatcher = resolveDispatcher(); - return dispatcher.useStoreWithSelector(store, selector); + // $FlowFixMe[not-a-function] This is unstable, thus optional + return dispatcher.useStore(store, selector); } export function useCacheRefresh(): (?() => T, ?T) => void { 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/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." } From a0a7221ed6ea62010e793630e5aacbd6f16086f4 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Mon, 24 Nov 2025 14:22:20 -0800 Subject: [PATCH 15/26] Add tests --- .../src/__tests__/useStore-test.js | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/packages/react-reconciler/src/__tests__/useStore-test.js b/packages/react-reconciler/src/__tests__/useStore-test.js index 525cafbb169..49f410f954b 100644 --- a/packages/react-reconciler/src/__tests__/useStore-test.js +++ b/packages/react-reconciler/src/__tests__/useStore-test.js @@ -106,6 +106,61 @@ describe('useStore', () => { ]); expect(root).toMatchRenderedOutput('4'); }); + + it('useStore (no selector)', 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(counterReducer, 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({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, @@ -406,6 +461,115 @@ describe('useStore', () => { 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: 'selector', state: 6}, + {kind: 'render', componentName: 'conditional', value: 4}, + ]); + // TODO: Avoid triggering this error. + assertConsoleErrorDev([ + 'Cannot update a component (`StoreReader`) while rendering a different component (`StoreReader`). ' + + 'To locate the bad setState() call inside `StoreReader`, ' + + 'follow the stack trace as described in https://react.dev/link/setstate-in-render\n' + + ' in App (at **)', + ]); + 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' | 'decrement'}, + ): 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(counterReducer, 2); + + 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'}); From 144e89b16bdb465140c06122e1fefc885cb45073 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Mon, 24 Nov 2025 16:04:45 -0800 Subject: [PATCH 16/26] Handle selector changes --- .../react-reconciler/src/ReactFiberHooks.js | 127 +++--- .../react-reconciler/src/__tests__/todo.md | 27 +- .../src/__tests__/useStore-test.js | 420 +++++++++++++++++- 3 files changed, 477 insertions(+), 97 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index d47af53e777..c03d8027ba4 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -76,7 +76,6 @@ import { isGestureRender, GestureLane, UpdateLanes, - includesOnlyTransitions, includesTransitionLane, SomeTransitionLane, } from './ReactFiberLane'; @@ -243,13 +242,6 @@ type StoreConsistencyCheck = { getSnapshot: () => T, }; -// TODO: Use something other than null -type StoreQueue = { - syncEagerState: T | null, - transitionEagerState: T | null, - lanes: Lanes, -}; - type EventFunctionPayload) => Return> = { ref: { eventFn: F, @@ -1822,17 +1814,17 @@ function updateSyncExternalStore( return nextSnapshot; } -// Used as a placeholder to let us reuse updateReducerImpl for useStore. -function storeReducer(state: S, action: A): S { +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.', ); } -function identity(x: T): T { - return x; -} - function mountStore(store: ReactStore, selector?: S => T): T { const actualSelector: S => T = selector === undefined ? (identity: any) : selector; @@ -1853,44 +1845,26 @@ function mountStore(store: ReactStore, selector?: S => T): T { const queue: UpdateQueue = { pending: null, lanes: NoLanes, - dispatch: null, - lastRenderedReducer: storeReducer, + // Hack: We don't use dispatch for anything, so we can repurpose + // it to store the selector. + dispatch: actualSelector as any, + lastRenderedReducer: storeReducerPlaceholder, lastRenderedState: (initialState: any), }; hook.queue = queue; mountEffect( - createSubscription.bind(null, wrapper, fiber, actualSelector, queue), - [], + createSubscription.bind( + null, + wrapper, + fiber, + actualSelector, + queue, + storeState, + ), + [actualSelector], ); - // 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 transitionState = wrapper.getStateForLanes(SomeTransitionLane); - if (!is(storeState, transitionState)) { - const newState = actualSelector(transitionState); - 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 initialState; } @@ -1906,28 +1880,78 @@ function updateStore(store: ReactStore, selector?: S => T): T { const wrapper = root.storeTracker.getWrapper(store); const hook = updateWorkInProgressHook(); - const [state /* _dispatch */] = updateReducerImpl( + const storeState = wrapper.getStateForLanes(renderLanes); + const [state, previousSelector] = updateReducerImpl( hook, ((currentHook: any): Hook), - storeReducer, + storeReducerPlaceholder, ); const fiber = currentlyRenderingFiber; const queue = hook.queue; + let nextState = state; + + if (previousSelector !== actualSelector) { + queue.dispatch = actualSelector; + nextState = actualSelector(storeState); + queue.lastRenderedState = nextState; + } + updateEffect( - createSubscription.bind(null, wrapper, fiber, actualSelector, queue), - [], + createSubscription.bind( + null, + wrapper, + fiber, + actualSelector, + queue, + storeState, + ), + [actualSelector], ); - return state; + + return nextState; } +// 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 should 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(() => { const lane = requestUpdateLane(fiber); // Eagerly compute the new selected state @@ -1936,6 +1960,8 @@ function createSubscription( if ( queue.lanes === NoLanes && queue.pending === null && + // TODO: `queue.lastRenderedState` may not be the correct thing to check here since + // this may be at a different priority than the last render. is(newState, queue.lastRenderedState) ) { // Selected state hasn't changed. Bail out. @@ -1943,6 +1969,7 @@ function createSubscription( // 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; } diff --git a/packages/react-reconciler/src/__tests__/todo.md b/packages/react-reconciler/src/__tests__/todo.md index 0ae5a81b63d..6e6019f17b9 100644 --- a/packages/react-reconciler/src/__tests__/todo.md +++ b/packages/react-reconciler/src/__tests__/todo.md @@ -1,25 +1,4 @@ -baseQueue contains a mixture of updates at different priorities -`hook.queue` is stable. We could create that on mount, and use that to stash eager states. -`hook.memoizedState` is updated during update to be the newly computed state, and -is compared with the previous state to determine if an update should be reported. +# useStore TODO -## Plan - -1. Create an object with both a sync update and transition update. -2. Use that as the hook queue -3. Close over that object in the subscription -4. On update, eagerly compute the new state and write it to the correct queue property. -5. During the render phase, check the current lane, if it's a transition, read from the transition eager state, otherwise read from the sync eager state. -6. Check if this is different than the previous memoized state, and if so, mark a pending update and update the memoized state. -7. Return the memoized state. - -## Next - -1. Enqueue updates tagged by lane -2. Method to compute state for lane -3. On commit, update base state and filter out processed updates -4. Root subscribes to store and schedules updates - -- [ ] Look at dispatch to see how an update is assigned a lane. Can we do the same for store updates? -- [ ] We should include an update if isSubsetOfLanes(renderLanes, update.lane) - - [ ] Small caveat relating to offscreen (see isHiddenUpdate in updateReducerImpl) \ No newline at end of file +- [ ] Do we need to handle rerenders specially in useStore? +- [ ] Handle case where the store itself changes (similar to selector changing). \ No newline at end of file diff --git a/packages/react-reconciler/src/__tests__/useStore-test.js b/packages/react-reconciler/src/__tests__/useStore-test.js index 49f410f954b..b6aaae3e8c5 100644 --- a/packages/react-reconciler/src/__tests__/useStore-test.js +++ b/packages/react-reconciler/src/__tests__/useStore-test.js @@ -19,23 +19,21 @@ let createStore; let startTransition; let waitFor; let assertLog; -let assertConsoleErrorDev; +let useLayoutEffect; +let useEffect; describe('useStore', () => { beforeEach(() => { jest.resetModules(); - ({ - act, - assertConsoleErrorDev, - // assertConsoleWarnDev, - } = require('internal-test-utils')); - + 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.useLayoutEffect; startTransition = React.startTransition; const InternalTestUtils = require('internal-test-utils'); waitFor = InternalTestUtils.waitFor; @@ -309,17 +307,10 @@ describe('useStore', () => { assertLog([ {kind: 'render', componentName: 'stable', value: 2}, {kind: 'selector', state: 2}, - {kind: 'selector', state: 3}, {kind: 'render', componentName: 'conditional', value: 2}, + {kind: 'selector', state: 3}, ]); - // TODO: Avoid triggering this error. - assertConsoleErrorDev([ - 'Cannot update a component (`StoreReader`) while rendering a different component (`StoreReader`). ' + - 'To locate the bad setState() call inside `StoreReader`, ' + - 'follow the stack trace as described in https://react.dev/link/setstate-in-render\n' + - ' in App (at **)', - ]); expect(root).toMatchRenderedOutput('22'); await act(async () => { @@ -497,16 +488,10 @@ describe('useStore', () => { assertLog([ {kind: 'render', value: 4, componentName: 'stable'}, {kind: 'selector', state: 4}, - {kind: 'selector', state: 6}, {kind: 'render', componentName: 'conditional', value: 4}, + {kind: 'selector', state: 6}, ]); - // TODO: Avoid triggering this error. - assertConsoleErrorDev([ - 'Cannot update a component (`StoreReader`) while rendering a different component (`StoreReader`). ' + - 'To locate the bad setState() call inside `StoreReader`, ' + - 'follow the stack trace as described in https://react.dev/link/setstate-in-render\n' + - ' in App (at **)', - ]); + expect(root).toMatchRenderedOutput('44'); await act(async () => { @@ -645,4 +630,393 @@ describe('useStore', () => { expect(root).toMatchRenderedOutput('true'); }); + + it('selector changes sync mid transition while store is updating', 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 'double': + return count * 2; + default: + return count; + } + } + const store = createStore(counterReducer, 2); + + 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' | 'decrement'}, + ): number { + Scheduler.log({kind: 'reducer', state: count, action: action.type}); + switch (action.type) { + case 'increment': + return count + 1; + default: + return count; + } + } + const store = createStore(counterReducer, 2); + + 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' | 'decrement'}, + ): number { + Scheduler.log({kind: 'reducer', state: count, action: action.type}); + switch (action.type) { + case 'increment': + return count + 1; + default: + return count; + } + } + const store = createStore(counterReducer, 2); + + 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(counterReducer, 2); + + 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' | 'decrement' | 'set', value?: number}, + ): number { + Scheduler.log({kind: 'reducer', state: count, action: action.type}); + switch (action.type) { + case 'increment': + return count + 1; + case 'decrement': + return count - 1; + case 'set': + return action.value; + default: + return count; + } + } + const store = createStore(counterReducer, 2); + + 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'); + }); }); From 54bf746c8f218c477244860c2abd24cf6d3d5fdc Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Mon, 24 Nov 2025 16:20:34 -0800 Subject: [PATCH 17/26] Clarify (with types) that the store hook can read other external stores. --- .../react-debug-tools/src/ReactDebugHooks.js | 4 +- .../react-reconciler/src/ReactFiberHooks.js | 61 +++++++++++++------ .../src/ReactFiberStoreTracking.js | 12 ++-- .../src/ReactInternalTypes.js | 4 +- packages/react-server/src/ReactFizzHooks.js | 4 +- packages/react/src/ReactHooks.js | 4 +- 6 files changed, 58 insertions(+), 31 deletions(-) diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index b6728cf9e45..555f7046270 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -14,7 +14,7 @@ import type { Usable, Thenable, ReactDebugInfo, - ReactStore, + ReactExternalDataSource, } from 'shared/ReactTypes'; import type { ContextDependency, @@ -483,7 +483,7 @@ function useSyncExternalStore( } function useStore( - store: ReactStore, + store: ReactExternalDataSource, selector?: (state: S) => T, ): T { throw new Error('useStore is not yet supported in React Debug Tools.'); diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index c03d8027ba4..07fd99effdb 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -14,7 +14,7 @@ import type { Thenable, RejectedThenable, Awaited, - ReactStore, + ReactExternalDataSource, } from 'shared/ReactTypes'; import type { Fiber, @@ -1825,7 +1825,10 @@ function storeReducerPlaceholder(state: S, action: A): S { ); } -function mountStore(store: ReactStore, selector?: S => T): T { +function mountStore( + store: ReactExternalDataSource, + selector?: S => T, +): T { const actualSelector: S => T = selector === undefined ? (identity: any) : selector; const root = ((getWorkInProgressRoot(): any): FiberRoot); @@ -1868,7 +1871,10 @@ function mountStore(store: ReactStore, selector?: S => T): T { return initialState; } -function updateStore(store: ReactStore, selector?: S => T): T { +function updateStore( + store: ReactExternalDataSource, + selector?: S => T, +): T { const actualSelector: S => T = selector === undefined ? (identity: any) : selector; const root = ((getWorkInProgressRoot(): any): FiberRoot); @@ -4374,7 +4380,10 @@ if (__DEV__) { mountHookTypesDev(); return mountSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, - useStore(store: ReactStore, selector?: (state: S) => T): T { + useStore( + store: ReactExternalDataSource, + selector?: (state: S) => T, + ): T { currentHookNameInDev = 'useStore'; mountHookTypesDev(); return mountStore(store, selector); @@ -4433,7 +4442,7 @@ if (__DEV__) { (HooksDispatcherOnMountInDEV: Dispatcher).useStore = function useStore< S, T, - >(store: ReactStore, selector?: (state: S) => T): T { + >(store: ReactExternalDataSource, selector?: (state: S) => T): T { currentHookNameInDev = 'useStore'; mountHookTypesDev(); return mountStore(store, selector); @@ -4556,7 +4565,10 @@ if (__DEV__) { updateHookTypesDev(); return mountSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, - useStore(store: ReactStore, selector?: (state: S) => T): T { + useStore( + store: ReactExternalDataSource, + selector?: (state: S) => T, + ): T { currentHookNameInDev = 'useStore'; updateHookTypesDev(); return mountStore(store, selector); @@ -4614,7 +4626,7 @@ if (__DEV__) { if (enableStore) { (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useStore = function useStore( - store: ReactStore, + store: ReactExternalDataSource, selector?: (state: S) => T, ): T { currentHookNameInDev = 'useStore'; @@ -4739,7 +4751,10 @@ if (__DEV__) { updateHookTypesDev(); return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, - useStore(store: ReactStore, selector?: (state: S) => T): T { + useStore( + store: ReactExternalDataSource, + selector?: (state: S) => T, + ): T { currentHookNameInDev = 'useStore'; updateHookTypesDev(); return updateStore(store, selector); @@ -4798,7 +4813,7 @@ if (__DEV__) { (HooksDispatcherOnUpdateInDEV: Dispatcher).useStore = function useStore< S, T, - >(store: ReactStore, selector?: (state: S) => T): T { + >(store: ReactExternalDataSource, selector?: (state: S) => T): T { currentHookNameInDev = 'useStore'; updateHookTypesDev(); return updateStore(store, selector); @@ -4921,7 +4936,10 @@ if (__DEV__) { updateHookTypesDev(); return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, - useStore(store: ReactStore, selector?: (state: S) => T): T { + useStore( + store: ReactExternalDataSource, + selector?: (state: S) => T, + ): T { currentHookNameInDev = 'useStore'; updateHookTypesDev(); return updateStore(store, selector); @@ -4980,7 +4998,7 @@ if (__DEV__) { (HooksDispatcherOnRerenderInDEV: Dispatcher).useStore = function useStore< S, T, - >(store: ReactStore, selector?: (state: S) => T): T { + >(store: ReactExternalDataSource, selector?: (state: S) => T): T { currentHookNameInDev = 'useStore'; updateHookTypesDev(); return updateStore(store, selector); @@ -5121,7 +5139,10 @@ if (__DEV__) { mountHookTypesDev(); return mountSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, - useStore(store: ReactStore, selector?: (state: S) => T): T { + useStore( + store: ReactExternalDataSource, + selector?: (state: S) => T, + ): T { currentHookNameInDev = 'useStore'; warnInvalidHookAccess(); mountHookTypesDev(); @@ -5187,7 +5208,7 @@ if (__DEV__) { if (enableStore) { (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useStore = function useStore( - store: ReactStore, + store: ReactExternalDataSource, selector?: (state: S) => T, ): T { currentHookNameInDev = 'useStore'; @@ -5331,7 +5352,10 @@ if (__DEV__) { updateHookTypesDev(); return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, - useStore(store: ReactStore, selector?: (state: S) => T): T { + useStore( + store: ReactExternalDataSource, + selector?: (state: S) => T, + ): T { currentHookNameInDev = 'useStore'; warnInvalidHookAccess(); updateHookTypesDev(); @@ -5397,7 +5421,7 @@ if (__DEV__) { if (enableStore) { (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useStore = function useStore( - store: ReactStore, + store: ReactExternalDataSource, selector?: (state: S) => T, ): T { currentHookNameInDev = 'useStore'; @@ -5541,7 +5565,10 @@ if (__DEV__) { updateHookTypesDev(); return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, - useStore(store: ReactStore, selector?: (state: S) => T): T { + useStore( + store: ReactExternalDataSource, + selector?: (state: S) => T, + ): T { currentHookNameInDev = 'useStore'; warnInvalidHookAccess(); updateHookTypesDev(); @@ -5607,7 +5634,7 @@ if (__DEV__) { if (enableStore) { (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useStore = function useStore( - store: ReactStore, + store: ReactExternalDataSource, selector?: (state: S) => T, ): T { currentHookNameInDev = 'useStore'; diff --git a/packages/react-reconciler/src/ReactFiberStoreTracking.js b/packages/react-reconciler/src/ReactFiberStoreTracking.js index 5aa789c2b78..7a99788863c 100644 --- a/packages/react-reconciler/src/ReactFiberStoreTracking.js +++ b/packages/react-reconciler/src/ReactFiberStoreTracking.js @@ -8,7 +8,7 @@ */ import type {Lanes} from './ReactFiberLane'; -import type {ReactStore} from 'shared/ReactTypes'; +import type {ReactExternalDataSource} from 'shared/ReactTypes'; import {includesTransitionLane} from './ReactFiberLane'; import ReactSharedInternals from 'shared/ReactSharedInternals'; @@ -19,8 +19,8 @@ export class StoreWrapper { _committedState: S; _headState: S; _unsubscribe: () => void; - store: ReactStore; - constructor(store: ReactStore) { + store: ReactExternalDataSource; + constructor(store: ReactExternalDataSource) { this._headState = this._committedState = store.getState(); this._unsubscribe = store.subscribe(action => { this.handleUpdate(action); @@ -67,7 +67,7 @@ type StoreWrapperInfo = { // Used by a React root to track the stores referenced by its fibers. export class StoreTracker { - stores: Map, StoreWrapperInfo>; + stores: Map, StoreWrapperInfo>; constructor() { this.stores = new Map(); @@ -79,7 +79,7 @@ export class StoreTracker { }); } - getWrapper(store: ReactStore): StoreWrapper { + getWrapper(store: ReactExternalDataSource): StoreWrapper { const info = this.stores.get(store); if (info !== undefined) { info.references++; @@ -90,7 +90,7 @@ export class StoreTracker { return wrapper; } - remove(store: ReactStore): void { + remove(store: ReactExternalDataSource): void { const info = this.stores.get(store); if (info !== undefined) { info.references--; diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index 8be8e3a1787..cd7d11645d3 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -18,7 +18,7 @@ import type { ReactComponentInfo, ReactDebugInfo, ReactKey, - ReactStore, + ReactExternalDataSource, } from 'shared/ReactTypes'; import type {TransitionTypes} from 'react/src/ReactTransitionType'; import type {WorkTag} from './ReactWorkTags'; @@ -445,7 +445,7 @@ export type Dispatcher = { ): T, // TODO: Non-nullable once `enableStore` is on everywhere. useStore?: ( - store: ReactStore, + store: ReactExternalDataSource, selector?: (state: S) => T, ) => S | T, useId(): string, diff --git a/packages/react-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js index ba2c2afe465..c592c23b5da 100644 --- a/packages/react-server/src/ReactFizzHooks.js +++ b/packages/react-server/src/ReactFizzHooks.js @@ -16,7 +16,7 @@ import type { Usable, ReactCustomFormAction, Awaited, - ReactStore, + ReactExternalDataSource, } from 'shared/ReactTypes'; import type {ResumableState} from './ReactFizzConfig'; @@ -566,7 +566,7 @@ function useSyncExternalStore( } function useStore( - store: ReactStore, + store: ReactExternalDataSource, selector?: (state: S) => T, ): T { throw new Error('useStore is not yet supported during server rendering.'); diff --git a/packages/react/src/ReactHooks.js b/packages/react/src/ReactHooks.js index 643051f739c..dbcc867e2c3 100644 --- a/packages/react/src/ReactHooks.js +++ b/packages/react/src/ReactHooks.js @@ -13,7 +13,7 @@ import type { StartTransitionOptions, Usable, Awaited, - ReactStore, + ReactExternalDataSource, } from 'shared/ReactTypes'; import {REACT_CONSUMER_TYPE} from 'shared/ReactSymbols'; @@ -200,7 +200,7 @@ export function useSyncExternalStore( } export function useStore( - store: ReactStore, + store: ReactExternalDataSource, selector?: (state: S) => T, ): S | T { const dispatcher = resolveDispatcher(); From fe978b3593f297a7d1342d4db70b0bde94efdd0b Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Mon, 24 Nov 2025 16:21:42 -0800 Subject: [PATCH 18/26] Revert changes to dev files --- packages/shared/ReactVersion.js | 16 +++++++++++++++- react.code-workspace | 1 - 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/shared/ReactVersion.js b/packages/shared/ReactVersion.js index 3c63937cede..bd5fa23ca26 100644 --- a/packages/shared/ReactVersion.js +++ b/packages/shared/ReactVersion.js @@ -1 +1,15 @@ -export default '19.3.0-canary-ea4899e1-20251117'; +/** + * 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. + */ + +// TODO: this is special because it gets imported during build. +// +// It exists as a placeholder so that DevTools can support work tag changes between releases. +// When we next publish a release, update the matching TODO in backend/renderer.js +// TODO: This module is used both by the release scripts and to expose a version +// at runtime. We should instead inject the version number as part of the build +// process, and use the ReactVersions.js module as the single source of truth. +export default '19.3.0'; diff --git a/react.code-workspace b/react.code-workspace index b91b697e3c5..16ff05dce9e 100644 --- a/react.code-workspace +++ b/react.code-workspace @@ -24,7 +24,6 @@ "editor.formatOnSave": true, "editor.defaultFormatter": "esbenp.prettier-vscode", "flow.pathToFlow": "${workspaceFolder}/node_modules/.bin/flow", - "flow.showUncovered": false, "prettier.configPath": "", "prettier.ignorePath": "" } From 242c5c5f6ca8a882e64be2a3b0c967e65afb7d12 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Mon, 24 Nov 2025 16:31:11 -0800 Subject: [PATCH 19/26] Make store wrapper its own event emitter --- .../react-reconciler/src/ReactFiberStoreTracking.js | 11 ++++++++--- packages/react-reconciler/src/__tests__/todo.md | 9 ++++++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberStoreTracking.js b/packages/react-reconciler/src/ReactFiberStoreTracking.js index 7a99788863c..22bddebb383 100644 --- a/packages/react-reconciler/src/ReactFiberStoreTracking.js +++ b/packages/react-reconciler/src/ReactFiberStoreTracking.js @@ -19,9 +19,11 @@ 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); }); @@ -42,15 +44,18 @@ export class StoreWrapper { // 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 { - // TODO: Have our own subscription mechanism such that fibers subscribe to the wrapper - // and the wrapper can subscribe to the store. - return this.store.subscribe(callback); + this._listeners.add(callback); + return () => { + this._listeners.delete(callback); + }; } commitFinished(lanes: Lanes) { this._committedState = this.getStateForLanes(lanes); diff --git a/packages/react-reconciler/src/__tests__/todo.md b/packages/react-reconciler/src/__tests__/todo.md index 6e6019f17b9..eb1fb483836 100644 --- a/packages/react-reconciler/src/__tests__/todo.md +++ b/packages/react-reconciler/src/__tests__/todo.md @@ -1,4 +1,11 @@ -# useStore TODO +# useStore Implementation Notes + +Each React root maintains a StoreTracker which is a reference-counted registry of all stores used within that root. For each store, a StoreWrapper is created which tracks the committed and transition state(s) for that store within that root. + +The wrapper is also responsible for tracking fibers which are subscribed to the store, and scheduling updates to those fibers when the store changes. + + +## Todo List - [ ] Do we need to handle rerenders specially in useStore? - [ ] Handle case where the store itself changes (similar to selector changing). \ No newline at end of file From afe25779489cae7373984f959caf61d3564ada01 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Mon, 24 Nov 2025 19:20:45 -0800 Subject: [PATCH 20/26] Confirm we unsubscribe from components --- .../src/__tests__/useStore-test.js | 119 +++++++++++++----- 1 file changed, 85 insertions(+), 34 deletions(-) diff --git a/packages/react-reconciler/src/__tests__/useStore-test.js b/packages/react-reconciler/src/__tests__/useStore-test.js index b6aaae3e8c5..93c33832161 100644 --- a/packages/react-reconciler/src/__tests__/useStore-test.js +++ b/packages/react-reconciler/src/__tests__/useStore-test.js @@ -33,7 +33,7 @@ describe('useStore', () => { createStore = React.createStore; useStore = React.useStore; useLayoutEffect = React.useLayoutEffect; - useEffect = React.useLayoutEffect; + useEffect = React.useEffect; startTransition = React.startTransition; const InternalTestUtils = require('internal-test-utils'); waitFor = InternalTestUtils.waitFor; @@ -43,14 +43,12 @@ describe('useStore', () => { it('useStore', async () => { function counterReducer( count: number, - action: {type: 'increment' | 'decrement'}, + action: {type: 'increment'}, ): 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; } @@ -108,14 +106,12 @@ describe('useStore', () => { it('useStore (no selector)', async () => { function counterReducer( count: number, - action: {type: 'increment' | 'decrement'}, + action: {type: 'increment'}, ): 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; } @@ -162,7 +158,7 @@ describe('useStore', () => { it('sync update interrupts transition update', async () => { function counterReducer( count: number, - action: {type: 'increment' | 'decrement'}, + action: {type: 'increment' | 'double'}, ): number { Scheduler.log({kind: 'reducer', state: count, action: action.type}); switch (action.type) { @@ -237,14 +233,12 @@ describe('useStore', () => { it('store reader mounts mid transition', async () => { function counterReducer( count: number, - action: {type: 'increment' | 'decrement'}, + action: {type: 'increment'}, ): 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; } @@ -327,14 +321,12 @@ describe('useStore', () => { it('store reader mounts as a result of store update in transition', async () => { function counterReducer( count: number, - action: {type: 'increment' | 'decrement'}, + action: {type: 'increment'}, ): 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; } @@ -403,7 +395,7 @@ describe('useStore', () => { it('After transition update commits, new mounters mount with up-to-date state', async () => { function counterReducer( count: number, - action: {type: 'increment' | 'decrement'}, + action: {type: 'increment' | 'double'}, ): number { Scheduler.log({kind: 'reducer', state: count, action: action.type}); switch (action.type) { @@ -506,14 +498,12 @@ describe('useStore', () => { 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' | 'decrement'}, + action: {type: 'increment'}, ): 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; } @@ -580,14 +570,9 @@ describe('useStore', () => { expect(root).toMatchRenderedOutput('33'); }); it('Component does not rerender if selected value is unchanged', async () => { - function counterReducer( - count: number, - action: {type: 'increment' | 'decrement'}, - ): number { + function counterReducer(count: number, action: {type: '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: @@ -634,14 +619,12 @@ describe('useStore', () => { it('selector changes sync mid transition while store is updating', async () => { function counterReducer( count: number, - action: {type: 'increment' | 'decrement'}, + action: {type: 'increment'}, ): 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; } @@ -724,7 +707,7 @@ describe('useStore', () => { it('store reader mounts after sibling updates state in useLayoutEffect', async () => { function counterReducer( count: number, - action: {type: 'increment' | 'decrement'}, + action: {type: 'increment'}, ): number { Scheduler.log({kind: 'reducer', state: count, action: action.type}); switch (action.type) { @@ -788,7 +771,7 @@ describe('useStore', () => { it('store reader mounts after sibling updates state in useEffect', async () => { function counterReducer( count: number, - action: {type: 'increment' | 'decrement'}, + action: {type: 'increment'}, ): number { Scheduler.log({kind: 'reducer', state: count, action: action.type}); switch (action.type) { @@ -939,16 +922,12 @@ describe('useStore', () => { it('sync update interrupts transition with identical state change', async () => { function counterReducer( count: number, - action: {type: 'increment' | 'decrement' | 'set', value?: number}, + action: {type: 'increment'}, ): number { Scheduler.log({kind: 'reducer', state: count, action: action.type}); switch (action.type) { case 'increment': return count + 1; - case 'decrement': - return count - 1; - case 'set': - return action.value; default: return count; } @@ -1019,4 +998,76 @@ describe('useStore', () => { 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(counterReducer, 2); + + 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); + }); }); From f0c2e15d18aaa4eac6870764f7c6d1b84bccdc4c Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Mon, 24 Nov 2025 20:16:40 -0800 Subject: [PATCH 21/26] Test batched updates --- .../react-reconciler/src/ReactFiberHooks.js | 211 ++++++++++-------- .../src/__tests__/useStore-test.js | 116 ++++++++++ 2 files changed, 235 insertions(+), 92 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 07fd99effdb..49d68177560 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -1825,6 +1825,11 @@ function storeReducerPlaceholder(state: S, action: A): S { ); } +type UseStoreArgs = { + wrapper: StoreWrapper, + selector: S => T, +}; + function mountStore( store: ReactExternalDataSource, selector?: S => T, @@ -1844,13 +1849,18 @@ function mountStore( 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 selector. - dispatch: actualSelector as any, + // it to store the args for access inside updateStore. + dispatch: hookArgs as any, lastRenderedReducer: storeReducerPlaceholder, lastRenderedState: (initialState: any), }; @@ -1865,7 +1875,7 @@ function mountStore( queue, storeState, ), - [actualSelector], + [actualSelector, wrapper], ); return initialState; @@ -1879,31 +1889,22 @@ function updateStore( selector === undefined ? (identity: any) : selector; const root = ((getWorkInProgressRoot(): any): FiberRoot); if (root.storeTracker === null) { - // TODO: This could be an invariant violation if the store was not - // mounted previously. root.storeTracker = new StoreTracker(); } const wrapper = root.storeTracker.getWrapper(store); const hook = updateWorkInProgressHook(); const storeState = wrapper.getStateForLanes(renderLanes); - const [state, previousSelector] = updateReducerImpl( + const [state, _previousArgs] = updateReducerImpl( hook, ((currentHook: any): Hook), storeReducerPlaceholder, ); + const previousArgs: UseStoreArgs = (_previousArgs: any); const fiber = currentlyRenderingFiber; const queue = hook.queue; - let nextState = state; - - if (previousSelector !== actualSelector) { - queue.dispatch = actualSelector; - nextState = actualSelector(storeState); - queue.lastRenderedState = nextState; - } - updateEffect( createSubscription.bind( null, @@ -1913,10 +1914,21 @@ function updateStore( queue, storeState, ), - [actualSelector], + [actualSelector, wrapper], ); - return nextState; + // 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 @@ -1935,7 +1947,7 @@ function createSubscription( 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 should avoid triggering a redundant update. + // case we could avoid triggering a redundant update. const lane = SomeTransitionLane; const update: Update = { lane, @@ -1958,92 +1970,107 @@ function createSubscription( entangleTransitionUpdate(updateRoot, queue, lane); } } - return storeWrapper.subscribe(() => { - const lane = requestUpdateLane(fiber); - // Eagerly compute the new selected state - const newState = selector(storeWrapper.getStateForLanes(lane)); + return storeWrapper.subscribe( + handleStoreSubscriptionChange.bind( + null, + fiber, + queue, + storeWrapper, + selector, + ), + ); +} - if ( - queue.lanes === NoLanes && - queue.pending === null && - // TODO: `queue.lastRenderedState` may not be the correct thing to check here since - // this may be at a different priority than the last render. - is(newState, queue.lastRenderedState) - ) { - // Selected state hasn't changed. Bail out. +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)); - // 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. + 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. - return; - } + // 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, + 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: newState, + eagerState: transitionState, 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)) { - // TODO: We should determine the actual lane (lanes?) we need to use here. - 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); - } + 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( diff --git a/packages/react-reconciler/src/__tests__/useStore-test.js b/packages/react-reconciler/src/__tests__/useStore-test.js index 93c33832161..0065288aedc 100644 --- a/packages/react-reconciler/src/__tests__/useStore-test.js +++ b/packages/react-reconciler/src/__tests__/useStore-test.js @@ -1070,4 +1070,120 @@ describe('useStore', () => { 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(counterReducer, 0); + + 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(counterReducer, 10); + const storeB = createStore(counterReducer, 20); + + 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'); + }); }); From a9068ff18b2bcea7995ae30b7726d7080207e247 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Mon, 24 Nov 2025 20:43:32 -0800 Subject: [PATCH 22/26] Simplify API by adding defaults --- packages/react-reconciler/src/ReactStore.js | 13 +++- .../react-reconciler/src/__tests__/todo.md | 11 --- .../src/__tests__/useStore-test.js | 68 ++++++++++++++----- 3 files changed, 61 insertions(+), 31 deletions(-) delete mode 100644 packages/react-reconciler/src/__tests__/todo.md diff --git a/packages/react-reconciler/src/ReactStore.js b/packages/react-reconciler/src/ReactStore.js index c2974c50591..eaa7ba9804b 100644 --- a/packages/react-reconciler/src/ReactStore.js +++ b/packages/react-reconciler/src/ReactStore.js @@ -10,15 +10,22 @@ import type {ReactStore} from 'shared/ReactTypes'; import {enableStore} from 'shared/ReactFeatureFlags'; +function defaultReducer(state: S, action: (prev: S) => S): S { + return action(state); +} + +declare function createStore(initialValue: S): ReactStore S>; + export function createStore( - reducer: (S, A) => S, 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>(); @@ -28,9 +35,9 @@ export function createStore( getState(): S { return state; }, - reducer: reducer, + reducer: actualReducer, dispatch(action: A) { - state = reducer(state, action); + state = actualReducer(state, action); subscriptions.forEach(callback => callback(action)); }, subscribe(callback: (action: A) => void): () => void { diff --git a/packages/react-reconciler/src/__tests__/todo.md b/packages/react-reconciler/src/__tests__/todo.md deleted file mode 100644 index eb1fb483836..00000000000 --- a/packages/react-reconciler/src/__tests__/todo.md +++ /dev/null @@ -1,11 +0,0 @@ -# useStore Implementation Notes - -Each React root maintains a StoreTracker which is a reference-counted registry of all stores used within that root. For each store, a StoreWrapper is created which tracks the committed and transition state(s) for that store within that root. - -The wrapper is also responsible for tracking fibers which are subscribed to the store, and scheduling updates to those fibers when the store changes. - - -## Todo List - -- [ ] Do we need to handle rerenders specially in useStore? -- [ ] Handle case where the store itself changes (similar to selector changing). \ No newline at end of file diff --git a/packages/react-reconciler/src/__tests__/useStore-test.js b/packages/react-reconciler/src/__tests__/useStore-test.js index 0065288aedc..04da64728e2 100644 --- a/packages/react-reconciler/src/__tests__/useStore-test.js +++ b/packages/react-reconciler/src/__tests__/useStore-test.js @@ -40,6 +40,40 @@ describe('useStore', () => { 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(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, @@ -53,7 +87,7 @@ describe('useStore', () => { return count; } } - const store = createStore(counterReducer, 2); + const store = createStore(2, counterReducer); function identity(x) { Scheduler.log({kind: 'selector', state: x}); @@ -116,7 +150,7 @@ describe('useStore', () => { return count; } } - const store = createStore(counterReducer, 2); + const store = createStore(2, counterReducer); function App() { const value = useStore(store); @@ -170,7 +204,7 @@ describe('useStore', () => { return count; } } - const store = createStore(counterReducer, 2); + const store = createStore(2, counterReducer); function identity(x) { Scheduler.log({kind: 'selector', state: x}); @@ -243,7 +277,7 @@ describe('useStore', () => { return count; } } - const store = createStore(counterReducer, 2); + const store = createStore(2, counterReducer); function identity(x) { Scheduler.log({kind: 'selector', state: x}); return x; @@ -331,7 +365,7 @@ describe('useStore', () => { return count; } } - const store = createStore(counterReducer, 2); + const store = createStore(2, counterReducer); function identity(x) { Scheduler.log({kind: 'selector', state: x}); @@ -407,7 +441,7 @@ describe('useStore', () => { return count; } } - const store = createStore(counterReducer, 2); + const store = createStore(2, counterReducer); function identity(x) { Scheduler.log({kind: 'selector', state: x}); @@ -508,7 +542,7 @@ describe('useStore', () => { return count; } } - const store = createStore(counterReducer, 2); + const store = createStore(2, counterReducer); function identity(x) { Scheduler.log({kind: 'selector', state: x}); @@ -579,7 +613,7 @@ describe('useStore', () => { return count; } } - const store = createStore(counterReducer, 2); + const store = createStore(2, counterReducer); function isEven(x) { Scheduler.log({kind: 'selector', state: x}); @@ -629,7 +663,7 @@ describe('useStore', () => { return count; } } - const store = createStore(counterReducer, 2); + const store = createStore(2, counterReducer); function identity(x) { Scheduler.log({kind: 'selector:identity', state: x}); @@ -717,7 +751,7 @@ describe('useStore', () => { return count; } } - const store = createStore(counterReducer, 2); + const store = createStore(2, counterReducer); function identity(x) { Scheduler.log({kind: 'selector', state: x}); @@ -781,7 +815,7 @@ describe('useStore', () => { return count; } } - const store = createStore(counterReducer, 2); + const store = createStore(2, counterReducer); function identity(x) { Scheduler.log({kind: 'selector', state: x}); @@ -849,7 +883,7 @@ describe('useStore', () => { return count; } } - const store = createStore(counterReducer, 2); + const store = createStore(2, counterReducer); function identity(x) { Scheduler.log({kind: 'selector', state: x}); @@ -932,7 +966,7 @@ describe('useStore', () => { return count; } } - const store = createStore(counterReducer, 2); + const store = createStore(2, counterReducer); function identity(x) { Scheduler.log({kind: 'selector', state: x}); @@ -1012,7 +1046,7 @@ describe('useStore', () => { return count; } } - const store = createStore(counterReducer, 2); + const store = createStore(2, counterReducer); function identity(x) { Scheduler.log({kind: 'selector', state: x}); @@ -1084,7 +1118,7 @@ describe('useStore', () => { return count; } } - const store = createStore(counterReducer, 0); + const store = createStore(0, counterReducer); function identity(x) { Scheduler.log({kind: 'selector', state: x}); @@ -1145,8 +1179,8 @@ describe('useStore', () => { } } - const storeA = createStore(counterReducer, 10); - const storeB = createStore(counterReducer, 20); + const storeA = createStore(10, counterReducer); + const storeB = createStore(20, counterReducer); function identity(x) { Scheduler.log({kind: 'selector', state: x}); From 95cff4da9c1838282a01d86dded64ff489459d2b Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Tue, 2 Dec 2025 12:02:47 -0800 Subject: [PATCH 23/26] Demonstrate two cases where we may need to eagerly track stores --- .../src/__tests__/useStore-test.js | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/packages/react-reconciler/src/__tests__/useStore-test.js b/packages/react-reconciler/src/__tests__/useStore-test.js index 04da64728e2..73fe16bea8e 100644 --- a/packages/react-reconciler/src/__tests__/useStore-test.js +++ b/packages/react-reconciler/src/__tests__/useStore-test.js @@ -1220,4 +1220,109 @@ describe('useStore', () => { ]); expect(root).toMatchRenderedOutput('20'); }); + + 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(); + 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'); + + // Change the store prop from storeA to storeB + 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(); + await act(async () => { + root.render(); + }); + + assertLog([ + {kind: 'selector', state: 3}, + {kind: 'render', value: 3}, + ]); + expect(root).toMatchRenderedOutput('3'); + }); }); From 8b40db2a70f9bc168fdc207528b6e056203aeaff Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Tue, 2 Dec 2025 16:33:49 -0800 Subject: [PATCH 24/26] Add test for creating store in transition --- .../src/__tests__/useStore-test.js | 59 ++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/packages/react-reconciler/src/__tests__/useStore-test.js b/packages/react-reconciler/src/__tests__/useStore-test.js index 73fe16bea8e..6be2cdd0df6 100644 --- a/packages/react-reconciler/src/__tests__/useStore-test.js +++ b/packages/react-reconciler/src/__tests__/useStore-test.js @@ -1270,7 +1270,6 @@ describe('useStore', () => { ]); expect(root).toMatchRenderedOutput('3'); - // Change the store prop from storeA to storeB await act(async () => { resolve(); }); @@ -1325,4 +1324,62 @@ describe('useStore', () => { ]); 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'); + }); }); From f515995022b19f81b8e20a01b3504cfd81fc3680 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Wed, 26 Nov 2025 14:02:22 -0800 Subject: [PATCH 25/26] Add test for suspense --- .../src/__tests__/useStore-test.js | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/packages/react-reconciler/src/__tests__/useStore-test.js b/packages/react-reconciler/src/__tests__/useStore-test.js index 6be2cdd0df6..6dad48b5c1d 100644 --- a/packages/react-reconciler/src/__tests__/useStore-test.js +++ b/packages/react-reconciler/src/__tests__/useStore-test.js @@ -21,6 +21,8 @@ let waitFor; let assertLog; let useLayoutEffect; let useEffect; +let use; +let Suspense; describe('useStore', () => { beforeEach(() => { @@ -34,6 +36,8 @@ describe('useStore', () => { 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; @@ -1221,6 +1225,122 @@ describe('useStore', () => { 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, @@ -1258,6 +1378,7 @@ describe('useStore', () => { } const root = ReactNoop.createRoot(); + assertLog([{kind: 'reducer', state: 2, action: 'increment'}]); await act(async () => { root.render(); }); @@ -1314,6 +1435,7 @@ describe('useStore', () => { } const root = ReactNoop.createRoot(); + assertLog([{kind: 'reducer', action: 'increment', state: 2}]); await act(async () => { root.render(); }); From e1c705378e8318a2b4780777781d8c0aabdb6f2f Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Fri, 2 Jan 2026 13:58:53 -0800 Subject: [PATCH 26/26] Make default reducer work like setState and accept a state value directly, or an updater function --- packages/react-reconciler/src/ReactStore.js | 10 ++++-- .../src/__tests__/useStore-test.js | 35 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/packages/react-reconciler/src/ReactStore.js b/packages/react-reconciler/src/ReactStore.js index eaa7ba9804b..6fc08da5d81 100644 --- a/packages/react-reconciler/src/ReactStore.js +++ b/packages/react-reconciler/src/ReactStore.js @@ -10,8 +10,14 @@ import type {ReactStore} from 'shared/ReactTypes'; import {enableStore} from 'shared/ReactFeatureFlags'; -function defaultReducer(state: S, action: (prev: S) => S): S { - return action(state); +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>; diff --git a/packages/react-reconciler/src/__tests__/useStore-test.js b/packages/react-reconciler/src/__tests__/useStore-test.js index 6dad48b5c1d..24282876b2d 100644 --- a/packages/react-reconciler/src/__tests__/useStore-test.js +++ b/packages/react-reconciler/src/__tests__/useStore-test.js @@ -62,6 +62,41 @@ describe('useStore', () => { }); 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);