diff --git a/packages/use-subscription/README.md b/packages/use-subscription/README.md new file mode 100644 index 0000000000000..b56023d1c6e3c --- /dev/null +++ b/packages/use-subscription/README.md @@ -0,0 +1,132 @@ +# use-subscription + +React hook that safely manages subscriptions in concurrent mode. + +## When should you NOT use this? + +This utility should be used for subscriptions to a single value that are typically only read in one place and may update frequently (e.g. a component that subscribes to a geolocation API to show a dot on a map). + +Other cases have **better long-term solutions**: +* Redux/Flux stores should use the [context API](https://reactjs.org/docs/context.html) instead. +* I/O subscriptions (e.g. notifications) that update infrequently should use a mechanism like [`react-cache`](https://github.com/facebook/react/blob/master/packages/react-cache/README.md) instead. +* Complex libraries like Relay/Apollo should manage subscriptions manually with the same techniques which this library uses under the hood (as referenced [here](https://gist.github.com/bvaughn/d569177d70b50b58bff69c3c4a5353f3)) in a way that is most optimized for their library usage. + +## Limitations in concurrent mode + +`use-subscription` is safe to use in concurrent mode. However, [it achieves correctness by sometimes de-opting to synchronous mode](https://github.com/facebook/react/issues/13186#issuecomment-403959161), obviating the benefits of concurrent rendering. This is an inherent limitation of storing state outside of React's managed state queue and rendering in response to a change event. + +The effect of de-opting to sync mode is that the main thread may periodically be blocked (in the case of CPU-bound work), and placeholders may appear earlier than desired (in the case of IO-bound work). + +For **full compatibility** with concurrent rendering, including both **time-slicing** and **React Suspense**, the suggested longer-term solution is to move to one of the patterns described in the previous section. + +## What types of subscriptions can this support? + +This abstraction can handle a variety of subscription types, including: +* Event dispatchers like `HTMLInputElement`. +* Custom pub/sub components like Relay's `FragmentSpecResolver`. +* Observable types like RxJS `BehaviorSubject` and `ReplaySubject`. (Types like RxJS `Subject` or `Observable` are not supported, because they provide no way to read the "current" value after it has been emitted.) + +Note that JavaScript promises are also **not supported** because they provide no way to synchronously read the "current" value. + +# Installation + +```sh +# Yarn +yarn add use-subscription + +# NPM +npm install use-subscription +``` + +# Usage + +To configure a subscription, you must provide two methods: `getCurrentValue` and `subscribe`. + +In order to avoid removing and re-adding subscriptions each time this hook is called, the parameters passed to this hook should be memoized. This can be done by wrapping the entire subscription with `useMemo()`, or by wrapping the individual callbacks with `useCallback()`. + +## Subscribing to event dispatchers + +Below is an example showing how `use-subscription` can be used to subscribe to event dispatchers such as DOM elements. + +```js +import React, { useMemo } from "react"; +import { useSubscription } from "use-subscription"; + +// In this example, "input" is an event dispatcher (e.g. an HTMLInputElement) +// but it could be anything that emits an event and has a readable current value. +function Example({ input }) { + + // Memoize to avoid removing and re-adding subscriptions each time this hook is called. + const subscription = useMemo( + () => ({ + getCurrentValue: () => input.value, + subscribe: callback => { + input.addEventListener("change", callback); + return () => input.removeEventListener("change", callback); + } + }), + + // Re-subscribe any time our input changes + // (e.g. we get a new HTMLInputElement prop to subscribe to) + [input] + ); + + // The value returned by this hook reflects the input's current value. + // Our component will automatically be re-rendered when that value changes. + const value = useSubscription(subscription); + + // Your rendered output goes here ... +} +``` + +## Subscribing to observables + +Below are examples showing how `use-subscription` can be used to subscribe to certain types of observables (e.g. RxJS `BehaviorSubject` and `ReplaySubject`). + +**Note** that it is not possible to support all observable types (e.g. RxJS `Subject` or `Observable`) because some provide no way to read the "current" value after it has been emitted. + +### `BehaviorSubject` +```js +const subscription = useMemo( + () => ({ + getCurrentValue: () => behaviorSubject.getValue(), + subscribe: callback => { + const subscription = behaviorSubject.subscribe(callback); + return () => subscription.unsubscribe(); + } + }), + + // Re-subscribe any time the behaviorSubject changes + [behaviorSubject] +); + +const value = useSubscription(subscription); +``` + +### `ReplaySubject` +```js +const subscription = useMemo( + () => ({ + getCurrentValue: () => { + let currentValue; + // ReplaySubject does not have a sync data getter, + // So we need to temporarily subscribe to retrieve the most recent value. + replaySubject + .subscribe(value => { + currentValue = value; + }) + .unsubscribe(); + return currentValue; + }, + subscribe: callback => { + const subscription = replaySubject.subscribe(callback); + return () => subscription.unsubscribe(); + } + }), + + // Re-subscribe any time the replaySubject changes + [replaySubject] +); + +const value = useSubscription(subscription); +``` diff --git a/packages/use-subscription/index.js b/packages/use-subscription/index.js new file mode 100644 index 0000000000000..f5030786a9639 --- /dev/null +++ b/packages/use-subscription/index.js @@ -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/useSubscription'; diff --git a/packages/use-subscription/npm/index.js b/packages/use-subscription/npm/index.js new file mode 100644 index 0000000000000..b91e9c4a14237 --- /dev/null +++ b/packages/use-subscription/npm/index.js @@ -0,0 +1,7 @@ +'use strict'; + +if (process.env.NODE_ENV === 'production') { + module.exports = require('./cjs/use-subscription.production.min.js'); +} else { + module.exports = require('./cjs/use-subscription.development.js'); +} diff --git a/packages/use-subscription/package.json b/packages/use-subscription/package.json new file mode 100644 index 0000000000000..2643fbd161d1f --- /dev/null +++ b/packages/use-subscription/package.json @@ -0,0 +1,24 @@ +{ + "private": true, + "name": "use-subscription", + "description": "Reusable hooks", + "version": "0.0.0", + "repository": { + "type": "git", + "url": "https://github.com/facebook/react.git", + "directory": "packages/use-subscription" + }, + "files": [ + "LICENSE", + "README.md", + "build-info.json", + "index.js", + "cjs/" + ], + "peerDependencies": { + "react": "^16.8.0" + }, + "devDependencies": { + "rxjs": "^5.5.6" + } +} diff --git a/packages/use-subscription/src/__tests__/useSubscription-test.internal.js b/packages/use-subscription/src/__tests__/useSubscription-test.internal.js new file mode 100644 index 0000000000000..daacfb5cfe575 --- /dev/null +++ b/packages/use-subscription/src/__tests__/useSubscription-test.internal.js @@ -0,0 +1,563 @@ +/** + * 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 + */ + +'use strict'; + +let act; +let useSubscription; +let BehaviorSubject; +let React; +let ReactTestRenderer; +let Scheduler; +let ReplaySubject; + +describe('useSubscription', () => { + beforeEach(() => { + jest.resetModules(); + jest.mock('scheduler', () => require('scheduler/unstable_mock')); + + useSubscription = require('use-subscription').useSubscription; + React = require('react'); + ReactTestRenderer = require('react-test-renderer'); + Scheduler = require('scheduler'); + + act = ReactTestRenderer.act; + + BehaviorSubject = require('rxjs').BehaviorSubject; + ReplaySubject = require('rxjs').ReplaySubject; + }); + + function createBehaviorSubject(initialValue) { + const behaviorSubject = new BehaviorSubject(); + if (initialValue) { + behaviorSubject.next(initialValue); + } + return behaviorSubject; + } + + function createReplaySubject(initialValue) { + const replaySubject = new ReplaySubject(); + if (initialValue) { + replaySubject.next(initialValue); + } + return replaySubject; + } + + it('supports basic subscription pattern', () => { + function Child({value = 'default'}) { + Scheduler.unstable_yieldValue(value); + return null; + } + + function Subscription({source}) { + const value = useSubscription( + React.useMemo( + () => ({ + getCurrentValue: () => source.getValue(), + subscribe: callback => { + const subscription = source.subscribe(callback); + return () => subscription.unsubscribe(); + }, + }), + [source], + ), + ); + return ; + } + + const observable = createBehaviorSubject(); + let renderer; + act(() => { + renderer = ReactTestRenderer.create( + , + {unstable_isConcurrent: true}, + ); + }); + expect(Scheduler).toHaveYielded(['default']); + + // Updates while subscribed should re-render the child component + act(() => observable.next(123)); + expect(Scheduler).toHaveYielded([123]); + act(() => observable.next('abc')); + expect(Scheduler).toHaveYielded(['abc']); + + // Unmounting the subscriber should remove listeners + act(() => renderer.update(
)); + act(() => observable.next(456)); + expect(Scheduler).toFlushAndYield([]); + }); + + it('should support observable types like RxJS ReplaySubject', () => { + function Child({value = 'default'}) { + Scheduler.unstable_yieldValue(value); + return null; + } + + function Subscription({source}) { + const value = useSubscription( + React.useMemo( + () => ({ + getCurrentValue: () => { + let currentValue; + source + .subscribe(tempValue => { + currentValue = tempValue; + }) + .unsubscribe(); + return currentValue; + }, + subscribe: callback => { + const subscription = source.subscribe(callback); + return () => subscription.unsubscribe(); + }, + }), + [source], + ), + ); + return ; + } + + let observable = createReplaySubject('initial'); + let renderer; + act(() => { + renderer = ReactTestRenderer.create( + , + {unstable_isConcurrent: true}, + ); + }); + expect(Scheduler).toHaveYielded(['initial']); + act(() => observable.next('updated')); + expect(Scheduler).toHaveYielded(['updated']); + + Scheduler.unstable_flushAll(); + + // Unsetting the subscriber prop should reset subscribed values + observable = createReplaySubject(undefined); + act(() => renderer.update()); + expect(Scheduler).toHaveYielded(['default']); + }); + + it('should unsubscribe from old sources and subscribe to new sources when memoized props change', () => { + function Child({value = 'default'}) { + Scheduler.unstable_yieldValue(value); + return null; + } + + let subscriptions = []; + + function Subscription({source}) { + const value = useSubscription( + React.useMemo( + () => ({ + getCurrentValue: () => source.getValue(), + subscribe: callback => { + subscriptions.push(source); + const subscription = source.subscribe(callback); + return () => subscription.unsubscribe(); + }, + }), + [source], + ), + ); + return ; + } + + const observableA = createBehaviorSubject('a-0'); + const observableB = createBehaviorSubject('b-0'); + + expect(subscriptions).toHaveLength(0); + + let renderer; + act(() => { + renderer = ReactTestRenderer.create( + , + {unstable_isConcurrent: true}, + ); + }); + + // Updates while subscribed should re-render the child component + expect(Scheduler).toHaveYielded(['a-0']); + expect(subscriptions).toHaveLength(1); + expect(subscriptions[0]).toBe(observableA); + + // Unsetting the subscriber prop should reset subscribed values + act(() => renderer.update()); + + expect(Scheduler).toHaveYielded(['b-0']); + expect(subscriptions).toHaveLength(2); + expect(subscriptions[1]).toBe(observableB); + + // Updates to the old subscribable should not re-render the child component + act(() => observableA.next('a-1')); + expect(Scheduler).toFlushAndYield([]); + + // Updates to the bew subscribable should re-render the child component + act(() => observableB.next('b-1')); + expect(Scheduler).toHaveYielded(['b-1']); + + expect(subscriptions).toHaveLength(2); + }); + + it('should unsubscribe from old sources and subscribe to new sources when useCallback functions change', () => { + function Child({value = 'default'}) { + Scheduler.unstable_yieldValue(value); + return null; + } + + let subscriptions = []; + + function Subscription({source}) { + const value = useSubscription({ + getCurrentValue: React.useCallback(() => source.getValue(), [source]), + subscribe: React.useCallback( + callback => { + subscriptions.push(source); + const subscription = source.subscribe(callback); + return () => subscription.unsubscribe(); + }, + [source], + ), + }); + return ; + } + + const observableA = createBehaviorSubject('a-0'); + const observableB = createBehaviorSubject('b-0'); + + expect(subscriptions).toHaveLength(0); + + let renderer; + act(() => { + renderer = ReactTestRenderer.create( + , + {unstable_isConcurrent: true}, + ); + }); + + // Updates while subscribed should re-render the child component + expect(Scheduler).toHaveYielded(['a-0']); + expect(subscriptions).toHaveLength(1); + expect(subscriptions[0]).toBe(observableA); + + // Unsetting the subscriber prop should reset subscribed values + act(() => renderer.update()); + expect(Scheduler).toHaveYielded(['b-0']); + expect(subscriptions).toHaveLength(2); + expect(subscriptions[1]).toBe(observableB); + + // Updates to the old subscribable should not re-render the child component + act(() => observableA.next('a-1')); + expect(Scheduler).toFlushAndYield([]); + + // Updates to the bew subscribable should re-render the child component + act(() => observableB.next('b-1')); + expect(Scheduler).toHaveYielded(['b-1']); + + expect(subscriptions).toHaveLength(2); + }); + + it('should ignore values emitted by a new subscribable until the commit phase', () => { + const log = []; + + function Grandchild({value}) { + Scheduler.unstable_yieldValue('Grandchild: ' + value); + return null; + } + + function Child({value = 'default'}) { + Scheduler.unstable_yieldValue('Child: ' + value); + return ; + } + + function Subscription({source}) { + const value = useSubscription( + React.useMemo( + () => ({ + getCurrentValue: () => source.getValue(), + subscribe: callback => { + const subscription = source.subscribe(callback); + return () => subscription.unsubscribe(); + }, + }), + [source], + ), + ); + return ; + } + + class Parent extends React.Component { + state = {}; + + static getDerivedStateFromProps(nextProps, prevState) { + if (nextProps.observed !== prevState.observed) { + return { + observed: nextProps.observed, + }; + } + + return null; + } + + componentDidMount() { + log.push('Parent.componentDidMount'); + } + + componentDidUpdate() { + log.push('Parent.componentDidUpdate'); + } + + render() { + return ; + } + } + + const observableA = createBehaviorSubject('a-0'); + const observableB = createBehaviorSubject('b-0'); + + let renderer; + act(() => { + renderer = ReactTestRenderer.create(, { + unstable_isConcurrent: true, + }); + }); + expect(Scheduler).toHaveYielded(['Child: a-0', 'Grandchild: a-0']); + expect(log).toEqual(['Parent.componentDidMount']); + + // Start React update, but don't finish + act(() => { + renderer.update(); + expect(Scheduler).toFlushAndYieldThrough(['Child: b-0']); + expect(log).toEqual(['Parent.componentDidMount']); + + // Emit some updates from the uncommitted subscribable + observableB.next('b-1'); + observableB.next('b-2'); + observableB.next('b-3'); + }); + + // Update again + act(() => renderer.update()); + + // Flush everything and ensure that the correct subscribable is used + // We expect the last emitted update to be rendered (because of the commit phase value check) + // But the intermediate ones should be ignored, + // And the final rendered output should be the higher-priority observable. + expect(Scheduler).toHaveYielded([ + 'Grandchild: b-0', + 'Child: b-3', + 'Grandchild: b-3', + 'Child: a-0', + 'Grandchild: a-0', + ]); + expect(log).toEqual([ + 'Parent.componentDidMount', + 'Parent.componentDidUpdate', + 'Parent.componentDidUpdate', + ]); + }); + + it('should not drop values emitted between updates', () => { + const log = []; + + function Grandchild({value}) { + Scheduler.unstable_yieldValue('Grandchild: ' + value); + return null; + } + + function Child({value = 'default'}) { + Scheduler.unstable_yieldValue('Child: ' + value); + return ; + } + + function Subscription({source}) { + const value = useSubscription( + React.useMemo( + () => ({ + getCurrentValue: () => source.getValue(), + subscribe: callback => { + const subscription = source.subscribe(callback); + return () => subscription.unsubscribe(); + }, + }), + [source], + ), + ); + return ; + } + + class Parent extends React.Component { + state = {}; + + static getDerivedStateFromProps(nextProps, prevState) { + if (nextProps.observed !== prevState.observed) { + return { + observed: nextProps.observed, + }; + } + + return null; + } + + componentDidMount() { + log.push('Parent.componentDidMount:' + this.props.observed.value); + } + + componentDidUpdate() { + log.push('Parent.componentDidUpdate:' + this.props.observed.value); + } + + render() { + return ; + } + } + + const observableA = createBehaviorSubject('a-0'); + const observableB = createBehaviorSubject('b-0'); + + let renderer; + act(() => { + renderer = ReactTestRenderer.create(, { + unstable_isConcurrent: true, + }); + }); + expect(Scheduler).toHaveYielded(['Child: a-0', 'Grandchild: a-0']); + expect(log).toEqual(['Parent.componentDidMount:a-0']); + log.splice(0); + + // Start React update, but don't finish + act(() => { + renderer.update(); + expect(Scheduler).toFlushAndYieldThrough(['Child: b-0']); + expect(log).toEqual([]); + + // Emit some updates from the old subscribable + observableA.next('a-1'); + observableA.next('a-2'); + + // Update again + renderer.update(); + + // Flush everything and ensure that the correct subscribable is used + // We expect the new subscribable to finish rendering, + // But then the updated values from the old subscribable should be used. + expect(Scheduler).toFlushAndYield([ + 'Grandchild: b-0', + 'Child: a-2', + 'Grandchild: a-2', + ]); + expect(log).toEqual([ + 'Parent.componentDidUpdate:b-0', + 'Parent.componentDidUpdate:a-2', + ]); + }); + + // Updates from the new subscribable should be ignored. + log.splice(0); + act(() => observableB.next('b-1')); + expect(Scheduler).toFlushAndYield([]); + expect(log).toEqual([]); + }); + + it('should guard against updates that happen after unmounting', () => { + function Child({value = 'default'}) { + Scheduler.unstable_yieldValue(value); + return null; + } + + function Subscription({source}) { + const value = useSubscription( + React.useMemo( + () => ({ + getCurrentValue: () => source.getValue(), + subscribe: callback => { + return source.subscribe(callback); + }, + }), + [source], + ), + ); + return ; + } + + const eventHandler = { + _callbacks: [], + _value: true, + change(value) { + eventHandler._value = value; + const _callbacks = eventHandler._callbacks.slice(0); + _callbacks.forEach(callback => callback(value)); + }, + getValue() { + return eventHandler._value; + }, + subscribe(callback) { + eventHandler._callbacks.push(callback); + return () => { + eventHandler._callbacks.splice( + eventHandler._callbacks.indexOf(callback), + 1, + ); + }; + }, + }; + + eventHandler.subscribe(value => { + if (value === false) { + renderer.unmount(); + expect(Scheduler).toFlushAndYield([]); + } + }); + + let renderer; + act(() => { + renderer = ReactTestRenderer.create( + , + {unstable_isConcurrent: true}, + ); + }); + expect(Scheduler).toHaveYielded([true]); + + // This event should unmount + eventHandler.change(false); + }); + + it('does not return a value from the previous subscription if the source is updated', () => { + const subscription1 = { + getCurrentValue: () => 'one', + subscribe: () => () => {}, + }; + + const subscription2 = { + getCurrentValue: () => 'two', + subscribe: () => () => {}, + }; + + function Subscription({subscription}) { + const value = useSubscription(subscription); + if (value !== subscription.getCurrentValue()) { + throw Error( + `expected value "${subscription.getCurrentValue()}" but got value "${value}"`, + ); + } + return null; + } + + let renderer; + act(() => { + renderer = ReactTestRenderer.create( + , + {unstable_isConcurrent: true}, + ); + }); + Scheduler.unstable_flushAll(); + + act(() => renderer.update()); + Scheduler.unstable_flushAll(); + }); +}); diff --git a/packages/use-subscription/src/useSubscription.js b/packages/use-subscription/src/useSubscription.js new file mode 100644 index 0000000000000..1bb80e2d7b131 --- /dev/null +++ b/packages/use-subscription/src/useSubscription.js @@ -0,0 +1,123 @@ +/** + * 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 + */ + +import {useDebugValue, useEffect, useState} from 'react'; + +// Hook used for safely managing subscriptions in concurrent mode. +// +// In order to avoid removing and re-adding subscriptions each time this hook is called, +// the parameters passed to this hook should be memoized in some way– +// either by wrapping the entire params object with useMemo() +// or by wrapping the individual callbacks with useCallback(). +export function useSubscription({ + // (Synchronously) returns the current value of our subscription. + getCurrentValue, + + // This function is passed an event handler to attach to the subscription. + // It should return an unsubscribe function that removes the handler. + subscribe, +}: {| + getCurrentValue: () => Value, + subscribe: (callback: Function) => () => void, +|}): Value { + // Read the current value from our subscription. + // When this value changes, we'll schedule an update with React. + // It's important to also store the hook params so that we can check for staleness. + // (See the comment in checkForUpdates() below for more info.) + const [state, setState] = useState(() => ({ + getCurrentValue, + subscribe, + value: getCurrentValue(), + })); + + let valueToReturn = state.value; + + // If parameters have changed since our last render, schedule an update with its current value. + if ( + state.getCurrentValue !== getCurrentValue || + state.subscribe !== subscribe + ) { + // If the subscription has been updated, we'll schedule another update with React. + // React will process this update immediately, so the old subscription value won't be committed. + // It is still nice to avoid returning a mismatched value though, so let's override the return value. + valueToReturn = getCurrentValue(); + + setState({ + getCurrentValue, + subscribe, + value: valueToReturn, + }); + } + + // Display the current value for this hook in React DevTools. + useDebugValue(valueToReturn); + + // It is important not to subscribe while rendering because this can lead to memory leaks. + // (Learn more at reactjs.org/docs/strict-mode.html#detecting-unexpected-side-effects) + // Instead, we wait until the commit phase to attach our handler. + // + // We intentionally use a passive effect (useEffect) rather than a synchronous one (useLayoutEffect) + // so that we don't stretch the commit phase. + // This also has an added benefit when multiple components are subscribed to the same source: + // It allows each of the event handlers to safely schedule work without potentially removing an another handler. + // (Learn more at https://codesandbox.io/s/k0yvr5970o) + useEffect( + () => { + let didUnsubscribe = false; + + const checkForUpdates = () => { + // It's possible that this callback will be invoked even after being unsubscribed, + // if it's removed as a result of a subscription event/update. + // In this case, React will log a DEV warning about an update from an unmounted component. + // We can avoid triggering that warning with this check. + if (didUnsubscribe) { + return; + } + + setState(prevState => { + // Ignore values from stale sources! + // Since we subscribe an unsubscribe in a passive effect, + // it's possible that this callback will be invoked for a stale (previous) subscription. + // This check avoids scheduling an update for that stale subscription. + if ( + prevState.getCurrentValue !== getCurrentValue || + prevState.subscribe !== subscribe + ) { + return prevState; + } + + // Some subscriptions will auto-invoke the handler, even if the value hasn't changed. + // If the value hasn't changed, no update is needed. + // Return state as-is so React can bail out and avoid an unnecessary render. + const value = getCurrentValue(); + if (prevState.value === value) { + return prevState; + } + + return {...prevState, value}; + }); + }; + const unsubscribe = subscribe(checkForUpdates); + + // Because we're subscribing in a passive effect, + // it's possible that an update has occurred between render and our effect handler. + // Check for this and schedule an update if work has occurred. + checkForUpdates(); + + return () => { + didUnsubscribe = true; + unsubscribe(); + }; + }, + [getCurrentValue, subscribe], + ); + + // Return the current value for our caller to use while rendering. + return valueToReturn; +}