Compare commits

...

4 Commits

Author SHA1 Message Date
Jordan Eldredge
26b5ecc4f7 Try reusing useReducer code 2025-11-19 13:32:30 -08:00
Jordan Eldredge
e2dbca194b Get some transition stuff working 2025-11-18 19:06:58 -08:00
Jordan Eldredge
4786a1a43e Get subscriptions working 2025-11-17 22:00:34 -08:00
Jordan Eldredge
f213a1f6c5 Checkpoint 2025-11-17 17:33:22 -08:00
16 changed files with 484 additions and 17 deletions

View File

@@ -14,6 +14,7 @@ import type {
Usable,
Thenable,
ReactDebugInfo,
ReactStore,
} from 'shared/ReactTypes';
import type {
ContextDependency,
@@ -481,6 +482,22 @@ function useSyncExternalStore<T>(
return value;
}
function useStoreWithSelector<S, T>(
store: ReactStore<S, mixed>,
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,

View File

@@ -14,6 +14,7 @@ import type {
Thenable,
RejectedThenable,
Awaited,
ReactStore,
} from 'shared/ReactTypes';
import type {
Fiber,
@@ -74,6 +75,9 @@ import {
isGestureRender,
GestureLane,
UpdateLanes,
includesOnlyTransitions,
includesTransitionLane,
SomeTransitionLane,
} from './ReactFiberLane';
import {
ContinuousEventPriority,
@@ -236,6 +240,13 @@ type StoreConsistencyCheck<T> = {
getSnapshot: () => T,
};
// TODO: Use something other than null
type StoreWithSelectorQueue<T> = {
syncEagerState: T | null,
transitionEagerState: T | null,
lanes: Lanes,
};
type EventFunctionPayload<Args, Return, F: (...Array<Args>) => Return> = {
ref: {
eventFn: F,
@@ -1808,6 +1819,141 @@ function updateSyncExternalStore<T>(
return nextSnapshot;
}
// Used as a placeholder to let us reuse updateReducerImpl for useStoreWithSelector.
function storeReducer<S, A>(state: S, action: A): S {
throw new Error(
'Should never be called. This is a bug in React. Please file an issue.',
);
}
function mountStoreWithSelector<S, T>(
store: ReactStore<S, mixed>,
selector: S => T,
): 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 hook = mountWorkInProgressHook();
hook.memoizedState = hook.baseState = initialState;
const queue: UpdateQueue<S, mixed> = {
pending: null,
lanes: NoLanes,
dispatch: null,
lastRenderedReducer: storeReducer,
lastRenderedState: (initialState: any),
};
hook.queue = queue;
mountEffect(createSubscription.bind(null, store, fiber, selector, queue), []);
return initialState;
}
function updateStoreWithSelector<S, T>(
store: ReactStore<S, mixed>,
selector: S => T,
): T {
const hook = updateWorkInProgressHook();
const [state /* _dispatch */] = updateReducerImpl<T, mixed>(
hook,
((currentHook: any): Hook),
storeReducer,
);
const fiber = currentlyRenderingFiber;
const queue = hook.queue;
updateEffect(
createSubscription.bind(null, store, fiber, selector, queue),
[],
);
return state;
}
function createSubscription<S, T>(
store: ReactStore<S, mixed>,
fiber: Fiber,
selector: S => T,
queue: UpdateQueue<T, mixed>,
): () => 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,
);
const update: Update<T, mixed> = {
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 && !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<T, mixed> = {
lane: transitionLane,
revertLane: NoLane,
gesture: null,
action: null,
hasEagerState: true,
eagerState: transitionState,
next: (null: any),
};
const transitionRoot = enqueueConcurrentHookUpdate(
fiber,
queue,
transitionUpdate,
transitionLane,
);
if (transitionRoot !== null) {
startUpdateTimerByLane(transitionLane, 'store.dispatch()', fiber);
scheduleUpdateOnFiber(transitionRoot, fiber, transitionLane);
entangleTransitionUpdate(transitionRoot, queue, transitionLane);
}
}
});
}
function pushStoreConsistencyCheck<T>(
fiber: Fiber,
getSnapshot: () => T,
@@ -1847,7 +1993,7 @@ function updateStoreInstance<T>(
// 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
@@ -3832,7 +3978,7 @@ function enqueueRenderPhaseUpdate<S, A>(
// TODO: Move to ReactFiberConcurrentUpdates?
function entangleTransitionUpdate<S, A>(
root: FiberRoot,
queue: UpdateQueue<S, A>,
queue: UpdateQueue<S, A> | StoreWithSelectorQueue<T>,
lane: Lane,
): void {
if (isTransitionLane(lane)) {
@@ -3879,6 +4025,7 @@ export const ContextOnlyDispatcher: Dispatcher = {
useDeferredValue: throwInvalidHookError,
useTransition: throwInvalidHookError,
useSyncExternalStore: throwInvalidHookError,
useStoreWithSelector: throwInvalidHookError,
useId: throwInvalidHookError,
useHostTransitionStatus: throwInvalidHookError,
useFormState: throwInvalidHookError,
@@ -3909,6 +4056,7 @@ const HooksDispatcherOnMount: Dispatcher = {
useDeferredValue: mountDeferredValue,
useTransition: mountTransition,
useSyncExternalStore: mountSyncExternalStore,
useStoreWithSelector: mountStoreWithSelector,
useId: mountId,
useHostTransitionStatus: useHostTransitionStatus,
useFormState: mountActionState,
@@ -3939,6 +4087,7 @@ const HooksDispatcherOnUpdate: Dispatcher = {
useDeferredValue: updateDeferredValue,
useTransition: updateTransition,
useSyncExternalStore: updateSyncExternalStore,
useStoreWithSelector: updateStoreWithSelector,
useId: updateId,
useHostTransitionStatus: useHostTransitionStatus,
useFormState: updateActionState,
@@ -3969,6 +4118,7 @@ const HooksDispatcherOnRerender: Dispatcher = {
useDeferredValue: rerenderDeferredValue,
useTransition: rerenderTransition,
useSyncExternalStore: updateSyncExternalStore,
useStoreWithSelector: updateStoreWithSelector,
useId: updateId,
useHostTransitionStatus: useHostTransitionStatus,
useFormState: rerenderActionState,
@@ -4130,6 +4280,14 @@ if (__DEV__) {
mountHookTypesDev();
return mountSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
},
useStoreWithSelector<S, T>(
store: ReactStore<S, mixed>,
selector: (state: S) => T,
): T {
currentHookNameInDev = 'useStoreWithSelector';
mountHookTypesDev();
return mountStoreWithSelector(store, selector);
},
useId(): string {
currentHookNameInDev = 'useId';
mountHookTypesDev();
@@ -4297,6 +4455,14 @@ if (__DEV__) {
updateHookTypesDev();
return mountSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
},
useStoreWithSelector<S, T>(
store: ReactStore<S, mixed>,
selector: (state: S) => T,
): T {
currentHookNameInDev = 'useStoreWithSelector';
updateHookTypesDev();
return mountStoreWithSelector(store, selector);
},
useId(): string {
currentHookNameInDev = 'useId';
updateHookTypesDev();
@@ -4464,6 +4630,14 @@ if (__DEV__) {
updateHookTypesDev();
return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
},
useStoreWithSelector<S, T>(
store: ReactStore<S, mixed>,
selector: (state: S) => T,
): T {
currentHookNameInDev = 'useStoreWithSelector';
updateHookTypesDev();
return updateStoreWithSelector(store, selector);
},
useId(): string {
currentHookNameInDev = 'useId';
updateHookTypesDev();
@@ -4631,6 +4805,14 @@ if (__DEV__) {
updateHookTypesDev();
return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
},
useStoreWithSelector<S, T>(
store: ReactStore<S, mixed>,
selector: (state: S) => T,
): T {
currentHookNameInDev = 'useStoreWithSelector';
updateHookTypesDev();
return updateStoreWithSelector(store, selector);
},
useId(): string {
currentHookNameInDev = 'useId';
updateHookTypesDev();
@@ -4816,6 +4998,15 @@ if (__DEV__) {
mountHookTypesDev();
return mountSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
},
useStoreWithSelector<S, T>(
store: ReactStore<S, mixed>,
selector: (state: S) => T,
): T {
currentHookNameInDev = 'useStoreWithSelector';
warnInvalidHookAccess();
mountHookTypesDev();
return mountStoreWithSelector(store, selector);
},
useId(): string {
currentHookNameInDev = 'useId';
warnInvalidHookAccess();
@@ -5008,6 +5199,15 @@ if (__DEV__) {
updateHookTypesDev();
return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
},
useStoreWithSelector<S, T>(
store: ReactStore<S, mixed>,
selector: (state: S) => T,
): T {
currentHookNameInDev = 'useStoreWithSelector';
warnInvalidHookAccess();
updateHookTypesDev();
return updateStoreWithSelector(store, selector);
},
useId(): string {
currentHookNameInDev = 'useId';
warnInvalidHookAccess();
@@ -5200,6 +5400,15 @@ if (__DEV__) {
updateHookTypesDev();
return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
},
useStoreWithSelector<S, T>(
store: ReactStore<S, mixed>,
selector: (state: S) => T,
): T {
currentHookNameInDev = 'useStoreWithSelector';
warnInvalidHookAccess();
updateHookTypesDev();
return updateStoreWithSelector(store, selector);
},
useId(): string {
currentHookNameInDev = 'useId';
warnInvalidHookAccess();

View File

@@ -17,6 +17,7 @@ import type {
Awaited,
ReactComponentInfo,
ReactDebugInfo,
ReactStore,
} from 'shared/ReactTypes';
import type {TransitionTypes} from 'react/src/ReactTransitionType';
import type {WorkTag} from './ReactWorkTags';
@@ -58,6 +59,7 @@ export type HookType =
| 'useDeferredValue'
| 'useTransition'
| 'useSyncExternalStore'
| 'useStoreWithSelector'
| 'useId'
| 'useCacheRefresh'
| 'useOptimistic'
@@ -437,6 +439,10 @@ export type Dispatcher = {
getSnapshot: () => T,
getServerSnapshot?: () => T,
): T,
useStoreWithSelector<S, T>(
store: ReactStore<S, mixed>,
selector: (state: S) => T,
): T,
useId(): string,
useCacheRefresh: () => <T>(?() => T, ?T) => void,
useMemoCache: (size: number) => Array<any>,

View File

@@ -0,0 +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.
`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.

View File

@@ -0,0 +1,147 @@
/**
* 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 createStore;
let startTransition;
let waitFor;
let assertLog;
describe('useStoreWithSelector', () => {
beforeEach(() => {
jest.resetModules();
React = require('react');
ReactNoop = require('react-noop-renderer');
Scheduler = require('scheduler');
createStore = React.createStore;
useStoreWithSelector = React.useStoreWithSelector;
startTransition = React.startTransition;
const InternalTestUtils = require('internal-test-utils');
waitFor = InternalTestUtils.waitFor;
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 App() {
const value = useStoreWithSelector(store, identify);
Scheduler.log(value);
return <>{value}</>;
}
const root = ReactNoop.createRoot();
await act(async () => {
startTransition(() => {
root.render(<App />);
});
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');
});
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(<App />);
});
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();
});
assertLog([6]);
expect(root).toMatchRenderedOutput('6');
});
});
function identify<T>(x: T): T {
return x;
}

View File

@@ -16,6 +16,7 @@ import type {
Usable,
ReactCustomFormAction,
Awaited,
ReactStore,
} from 'shared/ReactTypes';
import type {ResumableState} from './ReactFizzConfig';
@@ -564,6 +565,14 @@ function useSyncExternalStore<T>(
return getServerSnapshot();
}
function useStoreWithSelector<S, T>(
store: ReactStore<S, mixed>,
selector: (state: S) => T,
): T {
resolveCurrentlyRenderingComponent();
return selector(store._current);
}
function useDeferredValue<T>(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,

View File

@@ -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),

View File

@@ -21,6 +21,7 @@ export {
createContext,
createElement,
createRef,
createStore,
use,
forwardRef,
isValidElement,
@@ -52,6 +53,7 @@ export {
useRef,
useState,
useSyncExternalStore,
useStoreWithSelector,
useTransition,
useActionState,
version,

View File

@@ -21,6 +21,7 @@ export {
createContext,
createElement,
createRef,
createStore,
use,
forwardRef,
isValidElement,
@@ -53,6 +54,7 @@ export {
useRef,
useState,
useSyncExternalStore,
useStoreWithSelector,
useTransition,
useActionState,
version,

View File

@@ -35,6 +35,7 @@ export {
createContext,
createElement,
createRef,
createStore,
use,
forwardRef,
isValidElement,
@@ -65,6 +66,7 @@ export {
useMemo,
useOptimistic,
useSyncExternalStore,
useStoreWithSelector,
useReducer,
useRef,
useState,

View File

@@ -34,6 +34,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,
@@ -46,6 +47,7 @@ import {
useLayoutEffect,
useMemo,
useSyncExternalStore,
useStoreWithSelector,
useReducer,
useRef,
useState,
@@ -83,6 +85,7 @@ export {
memo,
cache,
cacheSignal,
createStore,
useCallback,
useContext,
useEffect,
@@ -95,6 +98,7 @@ export {
useOptimistic,
useActionState,
useSyncExternalStore,
useStoreWithSelector,
useReducer,
useRef,
useState,

View File

@@ -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<T>(
);
}
export function useStoreWithSelector<T, S>(
store: ReactStore<S, mixed>,
selector: S => T,
): T {
const dispatcher = resolveDispatcher();
return dispatcher.useStoreWithSelector(store, selector);
}
export function useCacheRefresh(): <T>(?() => T, ?T) => void {
const dispatcher = resolveDispatcher();
// $FlowFixMe[not-a-function] This is unstable, thus optional

View File

@@ -0,0 +1,47 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import type {ReactStore} from 'shared/ReactTypes';
import ReactSharedInternals from 'shared/ReactSharedInternals';
import is from 'shared/objectIs';
export function createStore<S, A>(
reducer: (S, A) => S,
initialValue: S,
): ReactStore<S, A> {
const subscriptions = new Set<() => void>();
const self = {
_current: initialValue,
_transition: initialValue,
_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());
},
subscribe(callback: () => void): () => void {
subscriptions.add(callback);
return () => {
subscriptions.delete(callback);
};
},
};
return self;
}

View File

@@ -381,3 +381,11 @@ export type ProfilerProps = {
) => void,
children?: ReactNodeList,
};
export type ReactStore<S, A> = {
_current: S,
_transition: S,
_reducer: (S, A) => S,
subscribe: (callback: () => void) => () => void,
dispatch: (action: A) => void,
};

View File

@@ -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';

View File

@@ -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": ""
}