Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

useSyncExternalStore React Native version #22367

Merged
merged 19 commits into from
Sep 21, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/use-sync-external-store/index.native.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/

'use strict';

export * from './src/useSyncExternalStoreClient';
7 changes: 7 additions & 0 deletions packages/use-sync-external-store/npm/index.native.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
'use strict';

if (process.env.NODE_ENV === 'production') {
module.exports = require('./cjs/use-sync-external-store.native.production.min.js');
} else {
module.exports = require('./cjs/use-sync-external-store.native.development.js');
}
1 change: 1 addition & 0 deletions packages/use-sync-external-store/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"build-info.json",
"index.js",
"extra.js",
"index.native.js",
"cjs/"
],
"license": "MIT",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
/**
* Copyright (c) Facebook, Inc. and its 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
*
* @jest-environment node
*/

'use strict';

let React;
let ReactNoop;
let Scheduler;
let useSyncExternalStore;
let useSyncExternalStoreExtra;
let act;

// This tests the userspace shim of `useSyncExternalStore` in a server-rendering
// (Node) environment
describe('useSyncExternalStore (userspace shim, server rendering)', () => {
beforeEach(() => {
jest.resetModules();

// Remove useSyncExternalStore from the React imports so that we use the
// shim instead. Also removing startTransition, since we use that to detect
// outdated 18 alphas that don't yet include useSyncExternalStore.
//
// Longer term, we'll probably test this branch using an actual build of
// React 17.
jest.mock('react', () => {
const {
// eslint-disable-next-line no-unused-vars
startTransition: _,
// eslint-disable-next-line no-unused-vars
useSyncExternalStore: __,
// eslint-disable-next-line no-unused-vars
unstable_useSyncExternalStore: ___,
...otherExports
} = jest.requireActual('react');
return otherExports;
});

jest.mock('use-sync-external-store', () =>
jest.requireActual('use-sync-external-store/index.native'),
);

React = require('react');
ReactNoop = require('react-noop-renderer');
Scheduler = require('scheduler');
act = require('jest-react').act;
useSyncExternalStore = require('use-sync-external-store')
.useSyncExternalStore;
useSyncExternalStoreExtra = require('use-sync-external-store/extra')
.useSyncExternalStoreExtra;
});

function Text({text}) {
Scheduler.unstable_yieldValue(text);
return text;
}

function createExternalStore(initialState) {
const listeners = new Set();
let currentState = initialState;
return {
set(text) {
currentState = text;
ReactNoop.batchedUpdates(() => {
listeners.forEach(listener => listener());
});
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
getState() {
return currentState;
},
getSubscriberCount() {
return listeners.size;
},
};
}

test('native version', async () => {
const store = createExternalStore('client');

function App() {
const text = useSyncExternalStore(
store.subscribe,
store.getState,
() => 'server',
);
return <Text text={text} />;
}

const root = ReactNoop.createRoot();
await act(() => {
root.render(<App />);
});
expect(Scheduler).toHaveYielded(['client']);
expect(root).toMatchRenderedOutput('client');
});

test('native version', async () => {
const store = createExternalStore('client');

function App() {
const text = useSyncExternalStore(
store.subscribe,
store.getState,
() => 'server',
);
return <Text text={text} />;
}

const root = ReactNoop.createRoot();
await act(() => {
root.render(<App />);
});
expect(Scheduler).toHaveYielded(['client']);
expect(root).toMatchRenderedOutput('client');
});

// @gate !(enableUseRefAccessWarning && __DEV__)
test('Using isEqual to bailout', async () => {
const store = createExternalStore({a: 0, b: 0});

function A() {
const {a} = useSyncExternalStoreExtra(
store.subscribe,
store.getState,
null,
state => ({a: state.a}),
(state1, state2) => state1.a === state2.a,
);
return <Text text={'A' + a} />;
}
function B() {
const {b} = useSyncExternalStoreExtra(
store.subscribe,
store.getState,
null,
state => {
return {b: state.b};
},
(state1, state2) => state1.b === state2.b,
);
return <Text text={'B' + b} />;
}

function App() {
return (
<>
<A />
<B />
</>
);
}

const root = ReactNoop.createRoot();
act(() => root.render(<App />));

expect(Scheduler).toHaveYielded(['A0', 'B0']);
expect(root).toMatchRenderedOutput('A0B0');

// Update b but not a
await act(() => {
store.set({a: 0, b: 1});
});
// Only b re-renders
expect(Scheduler).toHaveYielded(['B1']);
expect(root).toMatchRenderedOutput('A0B1');

// Update a but not b
await act(() => {
store.set({a: 1, b: 1});
});
// Only a re-renders
expect(Scheduler).toHaveYielded(['A1']);
expect(root).toMatchRenderedOutput('A1B1');
});
});
169 changes: 3 additions & 166 deletions packages/use-sync-external-store/src/useSyncExternalStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,171 +7,8 @@
* @flow
*/

import * as React from 'react';
import is from 'shared/objectIs';
import invariant from 'shared/invariant';
import {canUseDOM} from 'shared/ExecutionEnvironment';
import {useSyncExternalStore as client} from './useSyncExternalStoreClient';
import {useSyncExternalStore as server} from './useSyncExternalStoreServer';

// Intentionally not using named imports because Rollup uses dynamic
// dispatch for CommonJS interop named imports.
const {
useState,
useEffect,
useLayoutEffect,
useDebugValue,
// The built-in API is still prefixed.
unstable_useSyncExternalStore: builtInAPI,
} = React;

// TODO: This heuristic doesn't work in React Native. We'll need to provide a
// special build, using the `.native` extension.
const isServerEnvironment = !canUseDOM;

// Prefer the built-in API, if it exists. If it doesn't exist, then we assume
// we're in version 16 or 17, so rendering is always synchronous. The shim
// does not support concurrent rendering, only the built-in API.
export const useSyncExternalStore =
builtInAPI !== undefined
? ((builtInAPI: any): typeof useSyncExternalStore_client)
: isServerEnvironment
? useSyncExternalStore_server
: useSyncExternalStore_client;

let didWarnOld18Alpha = false;
let didWarnUncachedGetSnapshot = false;

function useSyncExternalStore_server<T>(
subscribe: (() => void) => () => void,
getSnapshot: () => T,
getServerSnapshot?: () => T,
): T {
if (getServerSnapshot === undefined) {
invariant(
false,
'Missing getServerSnapshot, which is required for server-' +
'rendered content.',
);
}
return getServerSnapshot();
}

// Disclaimer: This shim breaks many of the rules of React, and only works
// because of a very particular set of implementation details and assumptions
// -- change any one of them and it will break. The most important assumption
// is that updates are always synchronous, because concurrent rendering is
// only available in versions of React that also have a built-in
// useSyncExternalStore API. And we only use this shim when the built-in API
// does not exist.
//
// Do not assume that the clever hacks used by this hook also work in general.
// The point of this shim is to replace the need for hacks by other libraries.
function useSyncExternalStore_client<T>(
subscribe: (() => void) => () => void,
getSnapshot: () => T,
// Note: The client shim does not use getServerSnapshot, because pre-18
// versions of React do not expose a way to check if we're hydrating. So
// users of the shim will need to track that themselves and return the
// correct value from `getSnapshot`.
getServerSnapshot?: () => T,
): T {
if (__DEV__) {
if (!didWarnOld18Alpha) {
if (React.startTransition !== undefined) {
didWarnOld18Alpha = true;
console.error(
'You are using an outdated, pre-release alpha of React 18 that ' +
'does not support useSyncExternalStore. The ' +
'use-sync-external-store shim will not work correctly. Upgrade ' +
'to a newer pre-release.',
);
}
}
}

// Read the current snapshot from the store on every render. Again, this
// breaks the rules of React, and only works here because of specific
// implementation details, most importantly that updates are
// always synchronous.
const value = getSnapshot();
if (__DEV__) {
if (!didWarnUncachedGetSnapshot) {
if (value !== getSnapshot()) {
console.error(
'The result of getSnapshot should be cached to avoid an infinite loop',
);
didWarnUncachedGetSnapshot = true;
}
}
}

// Because updates are synchronous, we don't queue them. Instead we force a
// re-render whenever the subscribed state changes by updating an some
// arbitrary useState hook. Then, during render, we call getSnapshot to read
// the current value.
//
// Because we don't actually use the state returned by the useState hook, we
// can save a bit of memory by storing other stuff in that slot.
//
// To implement the early bailout, we need to track some things on a mutable
// object. Usually, we would put that in a useRef hook, but we can stash it in
// our useState hook instead.
//
// To force a re-render, we call forceUpdate({inst}). That works because the
// new object always fails an equality check.
const [{inst}, forceUpdate] = useState({inst: {value, getSnapshot}});

// Track the latest getSnapshot function with a ref. This needs to be updated
// in the layout phase so we can access it during the tearing check that
// happens on subscribe.
useLayoutEffect(() => {
inst.value = value;
inst.getSnapshot = getSnapshot;

// Whenever getSnapshot or subscribe changes, we need to check in the
// commit phase if there was an interleaved mutation. In concurrent mode
// this can happen all the time, but even in synchronous mode, an earlier
// effect may have mutated the store.
if (checkIfSnapshotChanged(inst)) {
// Force a re-render.
forceUpdate({inst});
}
}, [subscribe, value, getSnapshot]);

useEffect(() => {
// Check for changes right before subscribing. Subsequent changes will be
// detected in the subscription handler.
if (checkIfSnapshotChanged(inst)) {
// Force a re-render.
forceUpdate({inst});
}
const handleStoreChange = () => {
// TODO: Because there is no cross-renderer API for batching updates, it's
// up to the consumer of this library to wrap their subscription event
// with unstable_batchedUpdates. Should we try to detect when this isn't
// the case and print a warning in development?

// The store changed. Check if the snapshot changed since the last time we
// read from the store.
if (checkIfSnapshotChanged(inst)) {
// Force a re-render.
forceUpdate({inst});
}
};
// Subscribe to the store and return a clean-up function.
return subscribe(handleStoreChange);
}, [subscribe]);

useDebugValue(value);
return value;
}

function checkIfSnapshotChanged(inst) {
const latestGetSnapshot = inst.getSnapshot;
const prevValue = inst.value;
try {
const nextValue = latestGetSnapshot();
return !is(prevValue, nextValue);
} catch (error) {
return true;
}
}
export const useSyncExternalStore = canUseDOM ? client : server;
Loading