Skip to content

Commit 4da03c9

Browse files
authored
useSyncExternalStore React Native version (#22367)
1 parent 48d475c commit 4da03c9

8 files changed

+397
-166
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow
8+
*/
9+
10+
'use strict';
11+
12+
export * from './src/useSyncExternalStoreClient';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
'use strict';
2+
3+
if (process.env.NODE_ENV === 'production') {
4+
module.exports = require('./cjs/use-sync-external-store.native.production.min.js');
5+
} else {
6+
module.exports = require('./cjs/use-sync-external-store.native.development.js');
7+
}

packages/use-sync-external-store/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"build-info.json",
1414
"index.js",
1515
"extra.js",
16+
"index.native.js",
1617
"cjs/"
1718
],
1819
"license": "MIT",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @emails react-core
8+
*
9+
* @jest-environment node
10+
*/
11+
12+
'use strict';
13+
14+
let React;
15+
let ReactNoop;
16+
let Scheduler;
17+
let useSyncExternalStore;
18+
let useSyncExternalStoreExtra;
19+
let act;
20+
21+
// This tests the userspace shim of `useSyncExternalStore` in a server-rendering
22+
// (Node) environment
23+
describe('useSyncExternalStore (userspace shim, server rendering)', () => {
24+
beforeEach(() => {
25+
jest.resetModules();
26+
27+
// Remove useSyncExternalStore from the React imports so that we use the
28+
// shim instead. Also removing startTransition, since we use that to detect
29+
// outdated 18 alphas that don't yet include useSyncExternalStore.
30+
//
31+
// Longer term, we'll probably test this branch using an actual build of
32+
// React 17.
33+
jest.mock('react', () => {
34+
const {
35+
// eslint-disable-next-line no-unused-vars
36+
startTransition: _,
37+
// eslint-disable-next-line no-unused-vars
38+
useSyncExternalStore: __,
39+
// eslint-disable-next-line no-unused-vars
40+
unstable_useSyncExternalStore: ___,
41+
...otherExports
42+
} = jest.requireActual('react');
43+
return otherExports;
44+
});
45+
46+
jest.mock('use-sync-external-store', () =>
47+
jest.requireActual('use-sync-external-store/index.native'),
48+
);
49+
50+
React = require('react');
51+
ReactNoop = require('react-noop-renderer');
52+
Scheduler = require('scheduler');
53+
act = require('jest-react').act;
54+
useSyncExternalStore = require('use-sync-external-store')
55+
.useSyncExternalStore;
56+
useSyncExternalStoreExtra = require('use-sync-external-store/extra')
57+
.useSyncExternalStoreExtra;
58+
});
59+
60+
function Text({text}) {
61+
Scheduler.unstable_yieldValue(text);
62+
return text;
63+
}
64+
65+
function createExternalStore(initialState) {
66+
const listeners = new Set();
67+
let currentState = initialState;
68+
return {
69+
set(text) {
70+
currentState = text;
71+
ReactNoop.batchedUpdates(() => {
72+
listeners.forEach(listener => listener());
73+
});
74+
},
75+
subscribe(listener) {
76+
listeners.add(listener);
77+
return () => listeners.delete(listener);
78+
},
79+
getState() {
80+
return currentState;
81+
},
82+
getSubscriberCount() {
83+
return listeners.size;
84+
},
85+
};
86+
}
87+
88+
test('native version', async () => {
89+
const store = createExternalStore('client');
90+
91+
function App() {
92+
const text = useSyncExternalStore(
93+
store.subscribe,
94+
store.getState,
95+
() => 'server',
96+
);
97+
return <Text text={text} />;
98+
}
99+
100+
const root = ReactNoop.createRoot();
101+
await act(() => {
102+
root.render(<App />);
103+
});
104+
expect(Scheduler).toHaveYielded(['client']);
105+
expect(root).toMatchRenderedOutput('client');
106+
});
107+
108+
test('native version', async () => {
109+
const store = createExternalStore('client');
110+
111+
function App() {
112+
const text = useSyncExternalStore(
113+
store.subscribe,
114+
store.getState,
115+
() => 'server',
116+
);
117+
return <Text text={text} />;
118+
}
119+
120+
const root = ReactNoop.createRoot();
121+
await act(() => {
122+
root.render(<App />);
123+
});
124+
expect(Scheduler).toHaveYielded(['client']);
125+
expect(root).toMatchRenderedOutput('client');
126+
});
127+
128+
// @gate !(enableUseRefAccessWarning && __DEV__)
129+
test('Using isEqual to bailout', async () => {
130+
const store = createExternalStore({a: 0, b: 0});
131+
132+
function A() {
133+
const {a} = useSyncExternalStoreExtra(
134+
store.subscribe,
135+
store.getState,
136+
null,
137+
state => ({a: state.a}),
138+
(state1, state2) => state1.a === state2.a,
139+
);
140+
return <Text text={'A' + a} />;
141+
}
142+
function B() {
143+
const {b} = useSyncExternalStoreExtra(
144+
store.subscribe,
145+
store.getState,
146+
null,
147+
state => {
148+
return {b: state.b};
149+
},
150+
(state1, state2) => state1.b === state2.b,
151+
);
152+
return <Text text={'B' + b} />;
153+
}
154+
155+
function App() {
156+
return (
157+
<>
158+
<A />
159+
<B />
160+
</>
161+
);
162+
}
163+
164+
const root = ReactNoop.createRoot();
165+
act(() => root.render(<App />));
166+
167+
expect(Scheduler).toHaveYielded(['A0', 'B0']);
168+
expect(root).toMatchRenderedOutput('A0B0');
169+
170+
// Update b but not a
171+
await act(() => {
172+
store.set({a: 0, b: 1});
173+
});
174+
// Only b re-renders
175+
expect(Scheduler).toHaveYielded(['B1']);
176+
expect(root).toMatchRenderedOutput('A0B1');
177+
178+
// Update a but not b
179+
await act(() => {
180+
store.set({a: 1, b: 1});
181+
});
182+
// Only a re-renders
183+
expect(Scheduler).toHaveYielded(['A1']);
184+
expect(root).toMatchRenderedOutput('A1B1');
185+
});
186+
});

packages/use-sync-external-store/src/useSyncExternalStore.js

+3-166
Original file line numberDiff line numberDiff line change
@@ -7,171 +7,8 @@
77
* @flow
88
*/
99

10-
import * as React from 'react';
11-
import is from 'shared/objectIs';
12-
import invariant from 'shared/invariant';
1310
import {canUseDOM} from 'shared/ExecutionEnvironment';
11+
import {useSyncExternalStore as client} from './useSyncExternalStoreClient';
12+
import {useSyncExternalStore as server} from './useSyncExternalStoreServer';
1413

15-
// Intentionally not using named imports because Rollup uses dynamic
16-
// dispatch for CommonJS interop named imports.
17-
const {
18-
useState,
19-
useEffect,
20-
useLayoutEffect,
21-
useDebugValue,
22-
// The built-in API is still prefixed.
23-
unstable_useSyncExternalStore: builtInAPI,
24-
} = React;
25-
26-
// TODO: This heuristic doesn't work in React Native. We'll need to provide a
27-
// special build, using the `.native` extension.
28-
const isServerEnvironment = !canUseDOM;
29-
30-
// Prefer the built-in API, if it exists. If it doesn't exist, then we assume
31-
// we're in version 16 or 17, so rendering is always synchronous. The shim
32-
// does not support concurrent rendering, only the built-in API.
33-
export const useSyncExternalStore =
34-
builtInAPI !== undefined
35-
? ((builtInAPI: any): typeof useSyncExternalStore_client)
36-
: isServerEnvironment
37-
? useSyncExternalStore_server
38-
: useSyncExternalStore_client;
39-
40-
let didWarnOld18Alpha = false;
41-
let didWarnUncachedGetSnapshot = false;
42-
43-
function useSyncExternalStore_server<T>(
44-
subscribe: (() => void) => () => void,
45-
getSnapshot: () => T,
46-
getServerSnapshot?: () => T,
47-
): T {
48-
if (getServerSnapshot === undefined) {
49-
invariant(
50-
false,
51-
'Missing getServerSnapshot, which is required for server-' +
52-
'rendered content.',
53-
);
54-
}
55-
return getServerSnapshot();
56-
}
57-
58-
// Disclaimer: This shim breaks many of the rules of React, and only works
59-
// because of a very particular set of implementation details and assumptions
60-
// -- change any one of them and it will break. The most important assumption
61-
// is that updates are always synchronous, because concurrent rendering is
62-
// only available in versions of React that also have a built-in
63-
// useSyncExternalStore API. And we only use this shim when the built-in API
64-
// does not exist.
65-
//
66-
// Do not assume that the clever hacks used by this hook also work in general.
67-
// The point of this shim is to replace the need for hacks by other libraries.
68-
function useSyncExternalStore_client<T>(
69-
subscribe: (() => void) => () => void,
70-
getSnapshot: () => T,
71-
// Note: The client shim does not use getServerSnapshot, because pre-18
72-
// versions of React do not expose a way to check if we're hydrating. So
73-
// users of the shim will need to track that themselves and return the
74-
// correct value from `getSnapshot`.
75-
getServerSnapshot?: () => T,
76-
): T {
77-
if (__DEV__) {
78-
if (!didWarnOld18Alpha) {
79-
if (React.startTransition !== undefined) {
80-
didWarnOld18Alpha = true;
81-
console.error(
82-
'You are using an outdated, pre-release alpha of React 18 that ' +
83-
'does not support useSyncExternalStore. The ' +
84-
'use-sync-external-store shim will not work correctly. Upgrade ' +
85-
'to a newer pre-release.',
86-
);
87-
}
88-
}
89-
}
90-
91-
// Read the current snapshot from the store on every render. Again, this
92-
// breaks the rules of React, and only works here because of specific
93-
// implementation details, most importantly that updates are
94-
// always synchronous.
95-
const value = getSnapshot();
96-
if (__DEV__) {
97-
if (!didWarnUncachedGetSnapshot) {
98-
if (value !== getSnapshot()) {
99-
console.error(
100-
'The result of getSnapshot should be cached to avoid an infinite loop',
101-
);
102-
didWarnUncachedGetSnapshot = true;
103-
}
104-
}
105-
}
106-
107-
// Because updates are synchronous, we don't queue them. Instead we force a
108-
// re-render whenever the subscribed state changes by updating an some
109-
// arbitrary useState hook. Then, during render, we call getSnapshot to read
110-
// the current value.
111-
//
112-
// Because we don't actually use the state returned by the useState hook, we
113-
// can save a bit of memory by storing other stuff in that slot.
114-
//
115-
// To implement the early bailout, we need to track some things on a mutable
116-
// object. Usually, we would put that in a useRef hook, but we can stash it in
117-
// our useState hook instead.
118-
//
119-
// To force a re-render, we call forceUpdate({inst}). That works because the
120-
// new object always fails an equality check.
121-
const [{inst}, forceUpdate] = useState({inst: {value, getSnapshot}});
122-
123-
// Track the latest getSnapshot function with a ref. This needs to be updated
124-
// in the layout phase so we can access it during the tearing check that
125-
// happens on subscribe.
126-
useLayoutEffect(() => {
127-
inst.value = value;
128-
inst.getSnapshot = getSnapshot;
129-
130-
// Whenever getSnapshot or subscribe changes, we need to check in the
131-
// commit phase if there was an interleaved mutation. In concurrent mode
132-
// this can happen all the time, but even in synchronous mode, an earlier
133-
// effect may have mutated the store.
134-
if (checkIfSnapshotChanged(inst)) {
135-
// Force a re-render.
136-
forceUpdate({inst});
137-
}
138-
}, [subscribe, value, getSnapshot]);
139-
140-
useEffect(() => {
141-
// Check for changes right before subscribing. Subsequent changes will be
142-
// detected in the subscription handler.
143-
if (checkIfSnapshotChanged(inst)) {
144-
// Force a re-render.
145-
forceUpdate({inst});
146-
}
147-
const handleStoreChange = () => {
148-
// TODO: Because there is no cross-renderer API for batching updates, it's
149-
// up to the consumer of this library to wrap their subscription event
150-
// with unstable_batchedUpdates. Should we try to detect when this isn't
151-
// the case and print a warning in development?
152-
153-
// The store changed. Check if the snapshot changed since the last time we
154-
// read from the store.
155-
if (checkIfSnapshotChanged(inst)) {
156-
// Force a re-render.
157-
forceUpdate({inst});
158-
}
159-
};
160-
// Subscribe to the store and return a clean-up function.
161-
return subscribe(handleStoreChange);
162-
}, [subscribe]);
163-
164-
useDebugValue(value);
165-
return value;
166-
}
167-
168-
function checkIfSnapshotChanged(inst) {
169-
const latestGetSnapshot = inst.getSnapshot;
170-
const prevValue = inst.value;
171-
try {
172-
const nextValue = latestGetSnapshot();
173-
return !is(prevValue, nextValue);
174-
} catch (error) {
175-
return true;
176-
}
177-
}
14+
export const useSyncExternalStore = canUseDOM ? client : server;

0 commit comments

Comments
 (0)