Skip to content

Commit 25f55b3

Browse files
authored
feat(react): modernize useSyncExternalStore usage for React 18+ (#1021)
- Drop use-sync-external-store shim; use native useSyncExternalStore from react and inline the with-selector logic (mirrors React's reference implementation). - Remove initializeUseStoreState indirection — no longer needed since React 18+ is the floor (per peerDependencies). - Migrate useLocalStore from raw useState/subscribe to useSyncExternalStore to prevent tearing in concurrent mode. - Add 'use client' banner to ESM and CJS bundles via Rollup so the package works correctly in React Server Components environments (Next.js App Router etc). - Remove use-sync-external-store from runtime dependencies. Refs #1004. Deferred React 18/19 primitive work tracked in #1020.
1 parent 19d17ba commit 25f55b3

5 files changed

Lines changed: 112 additions & 45 deletions

File tree

package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,7 @@
9797
"immer": "^9.0.21",
9898
"redux": "^5.0.1",
9999
"redux-thunk": "^3.1.0",
100-
"ts-toolbelt": "^9.6.0",
101-
"use-sync-external-store": "^1.4.0"
100+
"ts-toolbelt": "^9.6.0"
102101
},
103102
"devDependencies": {
104103
"@babel/core": "^7.22.9",

rollup.config.js

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,17 @@ const external = (id) => !id.startsWith('.') && !id.startsWith(root);
88

99
const extensions = ['.js'];
1010

11+
const useClientBanner = `'use client';`;
12+
1113
function createESMConfig(input, output) {
1214
return {
1315
input,
14-
output: { sourcemap: true, file: output, format: 'esm' },
16+
output: {
17+
sourcemap: true,
18+
file: output,
19+
format: 'esm',
20+
banner: useClientBanner,
21+
},
1522
external,
1623
plugins: [
1724
resolve({ extensions }),
@@ -28,7 +35,13 @@ function createESMConfig(input, output) {
2835
function createCommonJSConfig(input, output) {
2936
return {
3037
input,
31-
output: { sourcemap: true, file: output, format: 'cjs', exports: 'named' },
38+
output: {
39+
sourcemap: true,
40+
file: output,
41+
format: 'cjs',
42+
exports: 'named',
43+
banner: useClientBanner,
44+
},
3245
external,
3346
plugins: [
3447
resolve({ extensions }),

src/hooks.js

Lines changed: 90 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,96 @@
1-
import { useContext, useDebugValue, useEffect, useState } from 'react';
1+
import {
2+
useContext,
3+
useDebugValue,
4+
useEffect,
5+
useMemo,
6+
useRef,
7+
useState,
8+
useSyncExternalStore,
9+
} from 'react';
210
import EasyPeasyContext from './context';
311

4-
const useNotInitialized = () => {
5-
throw new Error('uSES not initialized!');
6-
};
7-
let useSyncExternalStoreWithSelector = useNotInitialized;
8-
export const initializeUseStoreState = (fn) => {
9-
useSyncExternalStoreWithSelector = fn;
10-
};
11-
1212
const refEquality = (a, b) => a === b;
1313

14+
function useSyncExternalStoreWithSelector(
15+
subscribe,
16+
getSnapshot,
17+
getServerSnapshot,
18+
selector,
19+
isEqual,
20+
) {
21+
const instRef = useRef(null);
22+
let inst;
23+
if (instRef.current === null) {
24+
inst = { hasValue: false, value: null };
25+
instRef.current = inst;
26+
} else {
27+
inst = instRef.current;
28+
}
29+
30+
const [getSelection, getServerSelection] = useMemo(() => {
31+
let hasMemo = false;
32+
let memoizedSnapshot;
33+
let memoizedSelection;
34+
const memoizedSelector = (nextSnapshot) => {
35+
if (!hasMemo) {
36+
hasMemo = true;
37+
memoizedSnapshot = nextSnapshot;
38+
const nextSelection = selector(nextSnapshot);
39+
if (isEqual !== undefined && inst.hasValue) {
40+
const currentSelection = inst.value;
41+
if (isEqual(currentSelection, nextSelection)) {
42+
memoizedSelection = currentSelection;
43+
return currentSelection;
44+
}
45+
}
46+
memoizedSelection = nextSelection;
47+
return nextSelection;
48+
}
49+
50+
const prevSnapshot = memoizedSnapshot;
51+
const prevSelection = memoizedSelection;
52+
53+
if (Object.is(prevSnapshot, nextSnapshot)) {
54+
return prevSelection;
55+
}
56+
57+
const nextSelection = selector(nextSnapshot);
58+
59+
if (isEqual !== undefined && isEqual(prevSelection, nextSelection)) {
60+
memoizedSnapshot = nextSnapshot;
61+
return prevSelection;
62+
}
63+
64+
memoizedSnapshot = nextSnapshot;
65+
memoizedSelection = nextSelection;
66+
return nextSelection;
67+
};
68+
69+
const getSnapshotWithSelector = () => memoizedSelector(getSnapshot());
70+
const getServerSnapshotWithSelector =
71+
getServerSnapshot == null
72+
? undefined
73+
: () => memoizedSelector(getServerSnapshot());
74+
75+
return [getSnapshotWithSelector, getServerSnapshotWithSelector];
76+
// eslint-disable-next-line react-hooks/exhaustive-deps
77+
}, [getSnapshot, getServerSnapshot, selector, isEqual]);
78+
79+
const value = useSyncExternalStore(
80+
subscribe,
81+
getSelection,
82+
getServerSelection,
83+
);
84+
85+
useEffect(() => {
86+
inst.hasValue = true;
87+
inst.value = value;
88+
// eslint-disable-next-line react-hooks/exhaustive-deps
89+
}, [value]);
90+
91+
return value;
92+
}
93+
1494
export function createStoreStateHook(Context) {
1595
return function useStoreState(mapState, equalityFn = refEquality) {
1696
if (process.env.NODE_ENV !== 'production') {
@@ -31,19 +111,10 @@ export function createStoreStateHook(Context) {
31111

32112
const store = useContext(Context);
33113

34-
/*
35-
function useSyncExternalStoreWithSelector<Snapshot, Selection>(
36-
subscribe: (onStoreChange: () => void) => () => void,
37-
getSnapshot: () => Snapshot,
38-
getServerSnapshot: undefined | null | (() => Snapshot),
39-
selector: (snapshot: Snapshot) => Selection,
40-
isEqual?: (a: Selection, b: Selection) => boolean,
41-
): Selection;
42-
*/
43114
const selectedState = useSyncExternalStoreWithSelector(
44115
store.subscribe,
45116
store.getState,
46-
store.getState, // getServerSnapshot = getState
117+
store.getState,
47118
mapState,
48119
equalityFn,
49120
);

src/index.js

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,3 @@
1-
// React 18 requires the use of the useSyncExternalStore hook for external
2-
// stores to hook into its concurrent features. We want to continue supporting
3-
// older versions of React (16/17), so we are utilsing a shim provided by the
4-
// React team which will ensure backwards compatibility;
5-
import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim/with-selector';
6-
7-
import { initializeUseStoreState } from './hooks';
8-
9-
initializeUseStoreState(useSyncExternalStoreWithSelector);
10-
111
export * from './hooks';
122
export * from './create-store';
133
export * from './create-context-store';

src/use-local-store.js

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useRef, useState } from 'react';
1+
import { useRef, useSyncExternalStore } from 'react';
22
import { useMemoOne } from './lib';
33
import { createStore } from './create-store';
44

@@ -24,17 +24,11 @@ export function useLocalStore(
2424
return _store;
2525
}, dependencies);
2626

27-
const [currentState, setCurrentState] = useState(() => store.getState());
28-
29-
useEffect(() => {
30-
setCurrentState(store.getState());
31-
return store.subscribe(() => {
32-
const nextState = store.getState();
33-
if (currentState !== nextState) {
34-
setCurrentState(nextState);
35-
}
36-
});
37-
}, [store]);
27+
const currentState = useSyncExternalStore(
28+
store.subscribe,
29+
store.getState,
30+
store.getState,
31+
);
3832

3933
return [currentState, store.getActions(), store];
4034
}

0 commit comments

Comments
 (0)