Skip to content

Commit

Permalink
useSyncExternalStore React Native version (#22367)
Browse files Browse the repository at this point in the history
  • Loading branch information
salazarm committed Sep 21, 2021
1 parent 48d475c commit 4da03c9
Show file tree
Hide file tree
Showing 8 changed files with 397 additions and 166 deletions.
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

0 comments on commit 4da03c9

Please sign in to comment.