From c35b0544868cd6609f191497de355c586cd8c7a3 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Tue, 5 Mar 2019 13:03:18 -0800 Subject: [PATCH 01/18] Added useSubscription reference hook and tests --- packages/create-subscription/index.js | 1 + .../useSubscription-test.internal.js | 357 ++++++++++++++++++ .../src/useSubscription.js | 77 ++++ 3 files changed, 435 insertions(+) create mode 100644 packages/create-subscription/src/__tests__/useSubscription-test.internal.js create mode 100644 packages/create-subscription/src/useSubscription.js diff --git a/packages/create-subscription/index.js b/packages/create-subscription/index.js index 314587a1351b..b6e16aaee861 100644 --- a/packages/create-subscription/index.js +++ b/packages/create-subscription/index.js @@ -10,3 +10,4 @@ 'use strict'; export * from './src/createSubscription'; +export * from './src/useSubscription'; diff --git a/packages/create-subscription/src/__tests__/useSubscription-test.internal.js b/packages/create-subscription/src/__tests__/useSubscription-test.internal.js new file mode 100644 index 000000000000..c39202e61f4f --- /dev/null +++ b/packages/create-subscription/src/__tests__/useSubscription-test.internal.js @@ -0,0 +1,357 @@ +/** + * 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('create-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; + } + + // Mimic createSubscription API to simplify testing + function createSubscription({getCurrentValue, subscribe}) { + return function Subscription({children, source}) { + const value = useSubscription( + React.useMemo(() => ({source, getCurrentValue, subscribe}), [source]), + ); + + return React.createElement(children, {value}); + }; + } + + it('supports basic subscription pattern', () => { + const Subscription = createSubscription({ + getCurrentValue: source => source.getValue(), + subscribe: (source, callback) => { + const subscription = source.subscribe(callback); + return () => subscription.unsubscribe(); + }, + }); + + const observable = createBehaviorSubject(); + const renderer = ReactTestRenderer.create( + + {({value = 'default'}) => { + Scheduler.yieldValue(value); + return null; + }} + , + {unstable_isConcurrent: true}, + ); + + // Updates while subscribed should re-render the child component + expect(Scheduler).toFlushAndYield(['default']); + act(() => observable.next(123)); + expect(Scheduler).toFlushAndYield([123]); + act(() => observable.next('abc')); + expect(Scheduler).toFlushAndYield(['abc']); + + // Unmounting the subscriber should remove listeners + renderer.update(
); + act(() => observable.next(456)); + expect(Scheduler).toFlushAndYield([]); + }); + + it('should support observable types like RxJS ReplaySubject', () => { + const Subscription = createSubscription({ + getCurrentValue: source => { + let currentValue; + source + .subscribe(value => { + currentValue = value; + }) + .unsubscribe(); + return currentValue; + }, + subscribe: (source, callback) => { + const subscription = source.subscribe(callback); + return () => subscription.unsubscribe; + }, + }); + + function render({value = 'default'}) { + Scheduler.yieldValue(value); + return null; + } + + let observable = createReplaySubject('initial'); + const renderer = ReactTestRenderer.create( + {render}, + {unstable_isConcurrent: true}, + ); + expect(Scheduler).toFlushAndYield(['initial']); + act(() => observable.next('updated')); + expect(Scheduler).toFlushAndYield(['updated']); + + Scheduler.flushAll(); + + // Unsetting the subscriber prop should reset subscribed values + observable = createReplaySubject(undefined); + renderer.update({render}); + expect(Scheduler).toFlushAndYield(['default']); + }); + + it('should unsubscribe from old subscribables and subscribe to new subscribables when props change', () => { + const Subscription = createSubscription({ + getCurrentValue: source => source.getValue(), + subscribe: (source, callback) => { + const subscription = source.subscribe(callback); + return () => subscription.unsubscribe(); + }, + }); + + function render({value = 'default'}) { + Scheduler.yieldValue(value); + return null; + } + + const observableA = createBehaviorSubject('a-0'); + const observableB = createBehaviorSubject('b-0'); + + const renderer = ReactTestRenderer.create( + {render}, + {unstable_isConcurrent: true}, + ); + + // Updates while subscribed should re-render the child component + expect(Scheduler).toFlushAndYield(['a-0']); + + // Unsetting the subscriber prop should reset subscribed values + renderer.update({render}); + expect(Scheduler).toFlushAndYield(['b-0']); + + // 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).toFlushAndYield(['b-1']); + }); + + it('should ignore values emitted by a new subscribable until the commit phase', () => { + const log = []; + + function Child({value}) { + Scheduler.yieldValue('Child: ' + value); + return null; + } + + const Subscription = createSubscription({ + getCurrentValue: source => source.getValue(), + subscribe: (source, callback) => { + const subscription = source.subscribe(callback); + return () => subscription.unsubscribe(); + }, + }); + + 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 ( + + {({value = 'default'}) => { + Scheduler.yieldValue('Subscriber: ' + value); + return ; + }} + + ); + } + } + + const observableA = createBehaviorSubject('a-0'); + const observableB = createBehaviorSubject('b-0'); + + const renderer = ReactTestRenderer.create( + , + {unstable_isConcurrent: true}, + ); + expect(Scheduler).toFlushAndYield(['Subscriber: a-0', 'Child: a-0']); + expect(log).toEqual(['Parent.componentDidMount']); + + // Start React update, but don't finish + renderer.update(); + expect(Scheduler).toFlushAndYieldThrough(['Subscriber: 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 + 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).toFlushAndYield([ + 'Child: b-0', + 'Subscriber: b-3', + 'Child: b-3', + 'Subscriber: a-0', + 'Child: a-0', + ]); + expect(log).toEqual([ + 'Parent.componentDidMount', + 'Parent.componentDidUpdate', + 'Parent.componentDidUpdate', + ]); + }); + + it('should not drop values emitted between updates', () => { + const log = []; + + function Child({value}) { + Scheduler.yieldValue('Child: ' + value); + return null; + } + + const Subscription = createSubscription({ + getCurrentValue: source => source.getValue(), + subscribe: (source, callback) => { + const subscription = source.subscribe(callback); + return () => subscription.unsubscribe(); + }, + }); + + 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 ( + + {({value = 'default'}) => { + Scheduler.yieldValue('Subscriber: ' + value); + return ; + }} + + ); + } + } + + const observableA = createBehaviorSubject('a-0'); + const observableB = createBehaviorSubject('b-0'); + + const renderer = ReactTestRenderer.create( + , + {unstable_isConcurrent: true}, + ); + expect(Scheduler).toFlushAndYield(['Subscriber: a-0', 'Child: a-0']); + expect(log).toEqual(['Parent.componentDidMount']); + + // Start React update, but don't finish + renderer.update(); + expect(Scheduler).toFlushAndYieldThrough(['Subscriber: b-0']); + expect(log).toEqual(['Parent.componentDidMount']); + + // Emit some updates from the old subscribable + act(() => observableA.next('a-1')); + act(() => 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([ + 'Child: b-0', + 'Subscriber: a-2', + 'Child: a-2', + ]); + expect(log).toEqual([ + 'Parent.componentDidMount', + 'Parent.componentDidUpdate', + 'Parent.componentDidUpdate', + ]); + + // Updates from the new subscribable should be ignored. + act(() => observableB.next('b-1')); + expect(Scheduler).toFlushAndYield([]); + expect(log).toEqual([ + 'Parent.componentDidMount', + 'Parent.componentDidUpdate', + 'Parent.componentDidUpdate', + ]); + }); +}); diff --git a/packages/create-subscription/src/useSubscription.js b/packages/create-subscription/src/useSubscription.js new file mode 100644 index 000000000000..4cfcadb61913 --- /dev/null +++ b/packages/create-subscription/src/useSubscription.js @@ -0,0 +1,77 @@ +import {useEffect, useReducer, useState} from 'react'; + +export function useSubscription({ + // This is the thing being subscribed to (e.g. an observable, event dispatcher, etc). + source, + + // (Synchronously) returns the current value of our subscription source. + getCurrentValue, + + // This function is passed an event handler to attach to the subscription source. + // It should return an unsubscribe function that removes the handler. + subscribe, +}) { + // Read the current value from our subscription source. + // When this value changes, we'll schedule an update with React. + // It's important to also store the source itself so that we can check for staleness. + // (See the comment in checkForUpdates() below for more info.) + const [state, setState] = useState({ + source, + value: getCurrentValue(source), + }); + + // If the source has changed since our last render, schedule an update with its current value. + if (state.source !== source) { + setState({ + source, + value: getCurrentValue(source), + }); + } + + // 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( + () => { + const checkForUpdates = () => { + setState(state => { + // 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) source. + // This check avoids scheduling an update for htat stale source. + if (state.source !== source) { + return state; + } + + // Some subscription sources 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(source); + if (state.value === value) { + return state; + } + + return { ...state, value }; + }); + }; + const unsubscribe = subscribe(source, 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 () => unsubscribe(); + }, + [getCurrentValue, source, subscribe], + ); + + // Return the current value for our caller to use while rendering. + return state.value; +} From f768b57c7db32d617b7e56d84d8f1417a6d75a1b Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Tue, 5 Mar 2019 13:03:31 -0800 Subject: [PATCH 02/18] Fixed a race case in useSubscription. Converted tests from Noop to Test renderer. --- .../useSubscription-test.internal.js | 53 +++++++++++++++++++ .../src/useSubscription.js | 29 +++++++--- 2 files changed, 74 insertions(+), 8 deletions(-) diff --git a/packages/create-subscription/src/__tests__/useSubscription-test.internal.js b/packages/create-subscription/src/__tests__/useSubscription-test.internal.js index c39202e61f4f..0a5fbd0a317f 100644 --- a/packages/create-subscription/src/__tests__/useSubscription-test.internal.js +++ b/packages/create-subscription/src/__tests__/useSubscription-test.internal.js @@ -354,4 +354,57 @@ describe('useSubscription', () => { 'Parent.componentDidUpdate', ]); }); + + it('should guard against updates that happen after unmounting', () => { + const Subscription = createSubscription({ + getCurrentValue: source => source.getValue(), + subscribe: (source, callback) => { + return source.subscribe(callback); + }, + }); + + 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([]); + } + }); + + const renderer = ReactTestRenderer.create( + + {({value}) => { + Scheduler.yieldValue(value); + return null; + }} + , + {unstable_isConcurrent: true}, + ); + + expect(Scheduler).toFlushAndYield([true]); + + // This event should unmount + eventHandler.change(false); + }); }); diff --git a/packages/create-subscription/src/useSubscription.js b/packages/create-subscription/src/useSubscription.js index 4cfcadb61913..ee4078fe2c4c 100644 --- a/packages/create-subscription/src/useSubscription.js +++ b/packages/create-subscription/src/useSubscription.js @@ -1,4 +1,4 @@ -import {useEffect, useReducer, useState} from 'react'; +import {useEffect, useState} from 'react'; export function useSubscription({ // This is the thing being subscribed to (e.g. an observable, event dispatcher, etc). @@ -39,25 +39,35 @@ export function useSubscription({ // (Learn more at https://codesandbox.io/s/k0yvr5970o) useEffect( () => { + let didUnsubscribe = false; + const checkForUpdates = () => { - setState(state => { + // It's possible that this callback will be invoked even after being unsubscribed, + // if it's removed as a result of an event/update from the source. + // 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) source. // This check avoids scheduling an update for htat stale source. - if (state.source !== source) { - return state; + if (prevState.source !== source) { + return prevState; } // Some subscription sources 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(source); - if (state.value === value) { - return state; + if (prevState.value === value) { + return prevState; } - return { ...state, value }; + return {...prevState, value}; }); }; const unsubscribe = subscribe(source, checkForUpdates); @@ -67,7 +77,10 @@ export function useSubscription({ // Check for this and schedule an update if work has occurred. checkForUpdates(); - return () => unsubscribe(); + return () => { + didUnsubscribe = true; + unsubscribe(); + }; }, [getCurrentValue, source, subscribe], ); From 00c38ddab18de8c409e18e13d77e3eabf661be9b Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Tue, 5 Mar 2019 13:03:57 -0800 Subject: [PATCH 03/18] Fixed an additional unmount edge case. Moved hook into temporary package. --- packages/create-subscription/index.js | 1 - packages/react-hooks/README.md | 3 +++ packages/react-hooks/index.js | 12 ++++++++++ packages/react-hooks/npm/index.js | 7 ++++++ packages/react-hooks/package.json | 24 +++++++++++++++++++ .../useSubscription-test.internal.js | 2 +- .../src/useSubscription.js | 0 7 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 packages/react-hooks/README.md create mode 100644 packages/react-hooks/index.js create mode 100644 packages/react-hooks/npm/index.js create mode 100644 packages/react-hooks/package.json rename packages/{create-subscription => react-hooks}/src/__tests__/useSubscription-test.internal.js (99%) rename packages/{create-subscription => react-hooks}/src/useSubscription.js (100%) diff --git a/packages/create-subscription/index.js b/packages/create-subscription/index.js index b6e16aaee861..314587a1351b 100644 --- a/packages/create-subscription/index.js +++ b/packages/create-subscription/index.js @@ -10,4 +10,3 @@ 'use strict'; export * from './src/createSubscription'; -export * from './src/useSubscription'; diff --git a/packages/react-hooks/README.md b/packages/react-hooks/README.md new file mode 100644 index 000000000000..68bb2c4a262e --- /dev/null +++ b/packages/react-hooks/README.md @@ -0,0 +1,3 @@ +# react-hooks + +Placeholder package. \ No newline at end of file diff --git a/packages/react-hooks/index.js b/packages/react-hooks/index.js new file mode 100644 index 000000000000..f5030786a963 --- /dev/null +++ b/packages/react-hooks/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/react-hooks/npm/index.js b/packages/react-hooks/npm/index.js new file mode 100644 index 000000000000..a09b8cb3c55f --- /dev/null +++ b/packages/react-hooks/npm/index.js @@ -0,0 +1,7 @@ +'use strict'; + +if (process.env.NODE_ENV === 'production') { + module.exports = require('./cjs/react-hooks.production.min.js'); +} else { + module.exports = require('./cjs/react-hooks.development.js'); +} diff --git a/packages/react-hooks/package.json b/packages/react-hooks/package.json new file mode 100644 index 000000000000..e08e0eaf92b3 --- /dev/null +++ b/packages/react-hooks/package.json @@ -0,0 +1,24 @@ +{ + "private": true, + "name": "react-hooks", + "description": "Reusable hooks", + "version": "16.8.3", + "repository": { + "type": "git", + "url": "https://github.com/facebook/react.git", + "directory": "packages/react-hooks" + }, + "files": [ + "LICENSE", + "README.md", + "build-info.json", + "index.js", + "cjs/" + ], + "peerDependencies": { + "react": "^16.3.0" + }, + "devDependencies": { + "rxjs": "^5.5.6" + } +} diff --git a/packages/create-subscription/src/__tests__/useSubscription-test.internal.js b/packages/react-hooks/src/__tests__/useSubscription-test.internal.js similarity index 99% rename from packages/create-subscription/src/__tests__/useSubscription-test.internal.js rename to packages/react-hooks/src/__tests__/useSubscription-test.internal.js index 0a5fbd0a317f..3027a6c4c446 100644 --- a/packages/create-subscription/src/__tests__/useSubscription-test.internal.js +++ b/packages/react-hooks/src/__tests__/useSubscription-test.internal.js @@ -22,7 +22,7 @@ describe('useSubscription', () => { jest.resetModules(); jest.mock('scheduler', () => require('scheduler/unstable_mock')); - useSubscription = require('create-subscription').useSubscription; + useSubscription = require('react-hooks').useSubscription; React = require('react'); ReactTestRenderer = require('react-test-renderer'); Scheduler = require('scheduler'); diff --git a/packages/create-subscription/src/useSubscription.js b/packages/react-hooks/src/useSubscription.js similarity index 100% rename from packages/create-subscription/src/useSubscription.js rename to packages/react-hooks/src/useSubscription.js From 0ee0bab4387ebffbf0fe93f8d0c8f37dde0385c0 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Tue, 5 Mar 2019 22:10:59 -0800 Subject: [PATCH 04/18] Updated useSubscription API to require deps array --- .../useSubscription-test.internal.js | 266 +++++++++--------- packages/react-hooks/src/useSubscription.js | 72 +++-- 2 files changed, 178 insertions(+), 160 deletions(-) diff --git a/packages/react-hooks/src/__tests__/useSubscription-test.internal.js b/packages/react-hooks/src/__tests__/useSubscription-test.internal.js index 3027a6c4c446..6f3ed4225d1c 100644 --- a/packages/react-hooks/src/__tests__/useSubscription-test.internal.js +++ b/packages/react-hooks/src/__tests__/useSubscription-test.internal.js @@ -49,34 +49,29 @@ describe('useSubscription', () => { return replaySubject; } - // Mimic createSubscription API to simplify testing - function createSubscription({getCurrentValue, subscribe}) { - return function Subscription({children, source}) { + it('supports basic subscription pattern', () => { + function Subscription({source}) { const value = useSubscription( - React.useMemo(() => ({source, getCurrentValue, subscribe}), [source]), + () => ({ + getCurrentValue: () => source.getValue(), + subscribe: callback => { + const subscription = source.subscribe(callback); + return () => subscription.unsubscribe(); + }, + }), + [source], ); + return ; + } - return React.createElement(children, {value}); - }; - } - - it('supports basic subscription pattern', () => { - const Subscription = createSubscription({ - getCurrentValue: source => source.getValue(), - subscribe: (source, callback) => { - const subscription = source.subscribe(callback); - return () => subscription.unsubscribe(); - }, - }); + function Child({value = 'default'}) { + Scheduler.yieldValue(value); + return null; + } const observable = createBehaviorSubject(); const renderer = ReactTestRenderer.create( - - {({value = 'default'}) => { - Scheduler.yieldValue(value); - return null; - }} - , + , {unstable_isConcurrent: true}, ); @@ -94,30 +89,36 @@ describe('useSubscription', () => { }); it('should support observable types like RxJS ReplaySubject', () => { - const Subscription = createSubscription({ - getCurrentValue: source => { - let currentValue; - source - .subscribe(value => { - currentValue = value; - }) - .unsubscribe(); - return currentValue; - }, - subscribe: (source, callback) => { - const subscription = source.subscribe(callback); - return () => subscription.unsubscribe; - }, - }); + function Subscription({source}) { + const value = useSubscription( + () => ({ + getCurrentValue: () => { + let currentValue; + source + .subscribe(tempValue => { + currentValue = tempValue; + }) + .unsubscribe(); + return currentValue; + }, + subscribe: callback => { + const subscription = source.subscribe(callback); + return () => subscription.unsubscribe(); + }, + }), + [source], + ); + return ; + } - function render({value = 'default'}) { + function Child({value = 'default'}) { Scheduler.yieldValue(value); return null; } let observable = createReplaySubject('initial'); const renderer = ReactTestRenderer.create( - {render}, + , {unstable_isConcurrent: true}, ); expect(Scheduler).toFlushAndYield(['initial']); @@ -128,20 +129,26 @@ describe('useSubscription', () => { // Unsetting the subscriber prop should reset subscribed values observable = createReplaySubject(undefined); - renderer.update({render}); + renderer.update(); expect(Scheduler).toFlushAndYield(['default']); }); it('should unsubscribe from old subscribables and subscribe to new subscribables when props change', () => { - const Subscription = createSubscription({ - getCurrentValue: source => source.getValue(), - subscribe: (source, callback) => { - const subscription = source.subscribe(callback); - return () => subscription.unsubscribe(); - }, - }); + function Subscription({source}) { + const value = useSubscription( + () => ({ + getCurrentValue: () => source.getValue(), + subscribe: callback => { + const subscription = source.subscribe(callback); + return () => subscription.unsubscribe(); + }, + }), + [source], + ); + return ; + } - function render({value = 'default'}) { + function Child({value = 'default'}) { Scheduler.yieldValue(value); return null; } @@ -150,7 +157,7 @@ describe('useSubscription', () => { const observableB = createBehaviorSubject('b-0'); const renderer = ReactTestRenderer.create( - {render}, + , {unstable_isConcurrent: true}, ); @@ -158,7 +165,7 @@ describe('useSubscription', () => { expect(Scheduler).toFlushAndYield(['a-0']); // Unsetting the subscriber prop should reset subscribed values - renderer.update({render}); + renderer.update(); expect(Scheduler).toFlushAndYield(['b-0']); // Updates to the old subscribable should not re-render the child component @@ -173,32 +180,31 @@ describe('useSubscription', () => { it('should ignore values emitted by a new subscribable until the commit phase', () => { const log = []; - function Child({value}) { - Scheduler.yieldValue('Child: ' + value); - return null; + function Subscription({source}) { + const value = useSubscription( + () => ({ + getCurrentValue: () => source.getValue(), + subscribe: callback => { + const subscription = source.subscribe(callback); + return () => subscription.unsubscribe(); + }, + }), + [source], + ); + return ; } - const Subscription = createSubscription({ - getCurrentValue: source => source.getValue(), - subscribe: (source, callback) => { - const subscription = source.subscribe(callback); - return () => subscription.unsubscribe(); - }, - }); - - class Parent extends React.Component { - state = {}; - - static getDerivedStateFromProps(nextProps, prevState) { - if (nextProps.observed !== prevState.observed) { - return { - observed: nextProps.observed, - }; - } + function Outer({value}) { + Scheduler.yieldValue('Outer: ' + value); + return ; + } - return null; - } + function Inner({value}) { + Scheduler.yieldValue('Inner: ' + value); + return null; + } + class Parent extends React.Component { componentDidMount() { log.push('Parent.componentDidMount'); } @@ -208,14 +214,7 @@ describe('useSubscription', () => { } render() { - return ( - - {({value = 'default'}) => { - Scheduler.yieldValue('Subscriber: ' + value); - return ; - }} - - ); + return ; } } @@ -226,12 +225,12 @@ describe('useSubscription', () => { , {unstable_isConcurrent: true}, ); - expect(Scheduler).toFlushAndYield(['Subscriber: a-0', 'Child: a-0']); + expect(Scheduler).toFlushAndYield(['Outer: a-0', 'Inner: a-0']); expect(log).toEqual(['Parent.componentDidMount']); // Start React update, but don't finish renderer.update(); - expect(Scheduler).toFlushAndYieldThrough(['Subscriber: b-0']); + expect(Scheduler).toFlushAndYieldThrough(['Outer: b-0']); expect(log).toEqual(['Parent.componentDidMount']); // Emit some updates from the uncommitted subscribable @@ -247,11 +246,11 @@ describe('useSubscription', () => { // But the intermediate ones should be ignored, // And the final rendered output should be the higher-priority observable. expect(Scheduler).toFlushAndYield([ - 'Child: b-0', - 'Subscriber: b-3', - 'Child: b-3', - 'Subscriber: a-0', - 'Child: a-0', + 'Inner: b-0', + 'Outer: b-3', + 'Inner: b-3', + 'Outer: a-0', + 'Inner: a-0', ]); expect(log).toEqual([ 'Parent.componentDidMount', @@ -263,32 +262,31 @@ describe('useSubscription', () => { it('should not drop values emitted between updates', () => { const log = []; - function Child({value}) { - Scheduler.yieldValue('Child: ' + value); - return null; + function Subscription({source}) { + const value = useSubscription( + () => ({ + getCurrentValue: () => source.getValue(), + subscribe: callback => { + const subscription = source.subscribe(callback); + return () => subscription.unsubscribe(); + }, + }), + [source], + ); + return ; } - const Subscription = createSubscription({ - getCurrentValue: source => source.getValue(), - subscribe: (source, callback) => { - const subscription = source.subscribe(callback); - return () => subscription.unsubscribe(); - }, - }); - - class Parent extends React.Component { - state = {}; - - static getDerivedStateFromProps(nextProps, prevState) { - if (nextProps.observed !== prevState.observed) { - return { - observed: nextProps.observed, - }; - } + function Outer({value}) { + Scheduler.yieldValue('Outer: ' + value); + return ; + } - return null; - } + function Inner({value}) { + Scheduler.yieldValue('Inner: ' + value); + return null; + } + class Parent extends React.Component { componentDidMount() { log.push('Parent.componentDidMount'); } @@ -298,14 +296,7 @@ describe('useSubscription', () => { } render() { - return ( - - {({value = 'default'}) => { - Scheduler.yieldValue('Subscriber: ' + value); - return ; - }} - - ); + return ; } } @@ -316,12 +307,12 @@ describe('useSubscription', () => { , {unstable_isConcurrent: true}, ); - expect(Scheduler).toFlushAndYield(['Subscriber: a-0', 'Child: a-0']); + expect(Scheduler).toFlushAndYield(['Outer: a-0', 'Inner: a-0']); expect(log).toEqual(['Parent.componentDidMount']); // Start React update, but don't finish renderer.update(); - expect(Scheduler).toFlushAndYieldThrough(['Subscriber: b-0']); + expect(Scheduler).toFlushAndYieldThrough(['Outer: b-0']); expect(log).toEqual(['Parent.componentDidMount']); // Emit some updates from the old subscribable @@ -335,9 +326,9 @@ describe('useSubscription', () => { // We expect the new subscribable to finish rendering, // But then the updated values from the old subscribable should be used. expect(Scheduler).toFlushAndYield([ - 'Child: b-0', - 'Subscriber: a-2', - 'Child: a-2', + 'Inner: b-0', + 'Outer: a-2', + 'Inner: a-2', ]); expect(log).toEqual([ 'Parent.componentDidMount', @@ -356,12 +347,24 @@ describe('useSubscription', () => { }); it('should guard against updates that happen after unmounting', () => { - const Subscription = createSubscription({ - getCurrentValue: source => source.getValue(), - subscribe: (source, callback) => { - return source.subscribe(callback); - }, - }); + function Subscription({source}) { + const value = useSubscription( + () => ({ + getCurrentValue: () => source.getValue(), + subscribe: callback => { + const unsubscribe = source.subscribe(callback); + return () => unsubscribe(); + }, + }), + [source], + ); + return ; + } + + function Child({value}) { + Scheduler.yieldValue(value); + return null; + } const eventHandler = { _callbacks: [], @@ -393,12 +396,7 @@ describe('useSubscription', () => { }); const renderer = ReactTestRenderer.create( - - {({value}) => { - Scheduler.yieldValue(value); - return null; - }} - , + , {unstable_isConcurrent: true}, ); diff --git a/packages/react-hooks/src/useSubscription.js b/packages/react-hooks/src/useSubscription.js index ee4078fe2c4c..c749203cbaa8 100644 --- a/packages/react-hooks/src/useSubscription.js +++ b/packages/react-hooks/src/useSubscription.js @@ -1,30 +1,49 @@ -import {useEffect, useState} from 'react'; +/** + * 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 + */ -export function useSubscription({ - // This is the thing being subscribed to (e.g. an observable, event dispatcher, etc). - source, +import {useEffect, useMemo, useState} from 'react'; - // (Synchronously) returns the current value of our subscription source. - getCurrentValue, +// Hook used for safely managing subscriptions in concurrent mode. +// It requires two parameters: a factory function and a dependencies array. +export function useSubscription( + // This function is called whenever the specified dependencies change. + // It should return an object with two keys (functions) documented below. + nextCreate: () => {| + // Get the current subscription value. + getCurrentValue: () => T, - // This function is passed an event handler to attach to the subscription source. - // It should return an unsubscribe function that removes the handler. - subscribe, -}) { - // Read the current value from our subscription source. + // This function is passed a callback to be called any time the subscription changes. + // It should return an unsubscribe function. + subscribe: (() => void) => () => void, + |}, + // Dependencies array. + // Any time one of the inputs change, a new subscription will be setup, + // and the previous listener will be unsubscribed. + deps: Array, +) { + const current = useMemo(nextCreate, deps); + + // Read the current subscription value. // When this value changes, we'll schedule an update with React. - // It's important to also store the source itself so that we can check for staleness. + // It's important to also store the current inputs as well so we can check for staleness. // (See the comment in checkForUpdates() below for more info.) const [state, setState] = useState({ - source, - value: getCurrentValue(source), + current, + value: current.getCurrentValue(), }); - // If the source has changed since our last render, schedule an update with its current value. - if (state.source !== source) { + // If the inputs have changed since our last render, schedule an update with the current value. + // We could do this in our effect handler below but there's no need to wait in this case. + if (state.current !== current) { setState({ - source, - value: getCurrentValue(source), + current, + value: current.getCurrentValue(), }); } @@ -51,18 +70,18 @@ export function useSubscription({ } setState(prevState => { - // Ignore values from stale sources! + // Ignore values from stale subscriptions! // Since we subscribe an unsubscribe in a passive effect, - // it's possible that this callback will be invoked for a stale (previous) source. - // This check avoids scheduling an update for htat stale source. - if (prevState.source !== source) { + // it's possible that this callback will be invoked for a stale (previous) subscription. + // This check avoids scheduling an update for the stale subscription. + if (prevState.current !== current) { return prevState; } - // Some subscription sources will auto-invoke the handler, even if the value hasn't changed. + // Some subscriptions will auto-invoke the handler when it's attached. // 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(source); + const value = current.getCurrentValue(); if (prevState.value === value) { return prevState; } @@ -70,7 +89,8 @@ export function useSubscription({ return {...prevState, value}; }); }; - const unsubscribe = subscribe(source, checkForUpdates); + + const unsubscribe = current.subscribe(checkForUpdates); // Because we're subscribing in a passive effect, // it's possible that an update has occurred between render and our effect handler. @@ -82,7 +102,7 @@ export function useSubscription({ unsubscribe(); }; }, - [getCurrentValue, source, subscribe], + [current], ); // Return the current value for our caller to use while rendering. From da060ab2e897c4680b62b32250a9f84585d15083 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Thu, 7 Mar 2019 10:24:50 -0800 Subject: [PATCH 05/18] Revert "Updated useSubscription API to require deps array" This reverts commit 87f7c31c166a444900f616b617de73cb351881b4. --- .../useSubscription-test.internal.js | 266 +++++++++--------- packages/react-hooks/src/useSubscription.js | 72 ++--- 2 files changed, 160 insertions(+), 178 deletions(-) diff --git a/packages/react-hooks/src/__tests__/useSubscription-test.internal.js b/packages/react-hooks/src/__tests__/useSubscription-test.internal.js index 6f3ed4225d1c..3027a6c4c446 100644 --- a/packages/react-hooks/src/__tests__/useSubscription-test.internal.js +++ b/packages/react-hooks/src/__tests__/useSubscription-test.internal.js @@ -49,29 +49,34 @@ describe('useSubscription', () => { return replaySubject; } - it('supports basic subscription pattern', () => { - function Subscription({source}) { + // Mimic createSubscription API to simplify testing + function createSubscription({getCurrentValue, subscribe}) { + return function Subscription({children, source}) { const value = useSubscription( - () => ({ - getCurrentValue: () => source.getValue(), - subscribe: callback => { - const subscription = source.subscribe(callback); - return () => subscription.unsubscribe(); - }, - }), - [source], + React.useMemo(() => ({source, getCurrentValue, subscribe}), [source]), ); - return ; - } - function Child({value = 'default'}) { - Scheduler.yieldValue(value); - return null; - } + return React.createElement(children, {value}); + }; + } + + it('supports basic subscription pattern', () => { + const Subscription = createSubscription({ + getCurrentValue: source => source.getValue(), + subscribe: (source, callback) => { + const subscription = source.subscribe(callback); + return () => subscription.unsubscribe(); + }, + }); const observable = createBehaviorSubject(); const renderer = ReactTestRenderer.create( - , + + {({value = 'default'}) => { + Scheduler.yieldValue(value); + return null; + }} + , {unstable_isConcurrent: true}, ); @@ -89,36 +94,30 @@ describe('useSubscription', () => { }); it('should support observable types like RxJS ReplaySubject', () => { - function Subscription({source}) { - const value = useSubscription( - () => ({ - getCurrentValue: () => { - let currentValue; - source - .subscribe(tempValue => { - currentValue = tempValue; - }) - .unsubscribe(); - return currentValue; - }, - subscribe: callback => { - const subscription = source.subscribe(callback); - return () => subscription.unsubscribe(); - }, - }), - [source], - ); - return ; - } + const Subscription = createSubscription({ + getCurrentValue: source => { + let currentValue; + source + .subscribe(value => { + currentValue = value; + }) + .unsubscribe(); + return currentValue; + }, + subscribe: (source, callback) => { + const subscription = source.subscribe(callback); + return () => subscription.unsubscribe; + }, + }); - function Child({value = 'default'}) { + function render({value = 'default'}) { Scheduler.yieldValue(value); return null; } let observable = createReplaySubject('initial'); const renderer = ReactTestRenderer.create( - , + {render}, {unstable_isConcurrent: true}, ); expect(Scheduler).toFlushAndYield(['initial']); @@ -129,26 +128,20 @@ describe('useSubscription', () => { // Unsetting the subscriber prop should reset subscribed values observable = createReplaySubject(undefined); - renderer.update(); + renderer.update({render}); expect(Scheduler).toFlushAndYield(['default']); }); it('should unsubscribe from old subscribables and subscribe to new subscribables when props change', () => { - function Subscription({source}) { - const value = useSubscription( - () => ({ - getCurrentValue: () => source.getValue(), - subscribe: callback => { - const subscription = source.subscribe(callback); - return () => subscription.unsubscribe(); - }, - }), - [source], - ); - return ; - } + const Subscription = createSubscription({ + getCurrentValue: source => source.getValue(), + subscribe: (source, callback) => { + const subscription = source.subscribe(callback); + return () => subscription.unsubscribe(); + }, + }); - function Child({value = 'default'}) { + function render({value = 'default'}) { Scheduler.yieldValue(value); return null; } @@ -157,7 +150,7 @@ describe('useSubscription', () => { const observableB = createBehaviorSubject('b-0'); const renderer = ReactTestRenderer.create( - , + {render}, {unstable_isConcurrent: true}, ); @@ -165,7 +158,7 @@ describe('useSubscription', () => { expect(Scheduler).toFlushAndYield(['a-0']); // Unsetting the subscriber prop should reset subscribed values - renderer.update(); + renderer.update({render}); expect(Scheduler).toFlushAndYield(['b-0']); // Updates to the old subscribable should not re-render the child component @@ -180,31 +173,32 @@ describe('useSubscription', () => { it('should ignore values emitted by a new subscribable until the commit phase', () => { const log = []; - function Subscription({source}) { - const value = useSubscription( - () => ({ - getCurrentValue: () => source.getValue(), - subscribe: callback => { - const subscription = source.subscribe(callback); - return () => subscription.unsubscribe(); - }, - }), - [source], - ); - return ; - } - - function Outer({value}) { - Scheduler.yieldValue('Outer: ' + value); - return ; - } - - function Inner({value}) { - Scheduler.yieldValue('Inner: ' + value); + function Child({value}) { + Scheduler.yieldValue('Child: ' + value); return null; } + const Subscription = createSubscription({ + getCurrentValue: source => source.getValue(), + subscribe: (source, callback) => { + const subscription = source.subscribe(callback); + return () => subscription.unsubscribe(); + }, + }); + 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'); } @@ -214,7 +208,14 @@ describe('useSubscription', () => { } render() { - return ; + return ( + + {({value = 'default'}) => { + Scheduler.yieldValue('Subscriber: ' + value); + return ; + }} + + ); } } @@ -225,12 +226,12 @@ describe('useSubscription', () => { , {unstable_isConcurrent: true}, ); - expect(Scheduler).toFlushAndYield(['Outer: a-0', 'Inner: a-0']); + expect(Scheduler).toFlushAndYield(['Subscriber: a-0', 'Child: a-0']); expect(log).toEqual(['Parent.componentDidMount']); // Start React update, but don't finish renderer.update(); - expect(Scheduler).toFlushAndYieldThrough(['Outer: b-0']); + expect(Scheduler).toFlushAndYieldThrough(['Subscriber: b-0']); expect(log).toEqual(['Parent.componentDidMount']); // Emit some updates from the uncommitted subscribable @@ -246,11 +247,11 @@ describe('useSubscription', () => { // But the intermediate ones should be ignored, // And the final rendered output should be the higher-priority observable. expect(Scheduler).toFlushAndYield([ - 'Inner: b-0', - 'Outer: b-3', - 'Inner: b-3', - 'Outer: a-0', - 'Inner: a-0', + 'Child: b-0', + 'Subscriber: b-3', + 'Child: b-3', + 'Subscriber: a-0', + 'Child: a-0', ]); expect(log).toEqual([ 'Parent.componentDidMount', @@ -262,31 +263,32 @@ describe('useSubscription', () => { it('should not drop values emitted between updates', () => { const log = []; - function Subscription({source}) { - const value = useSubscription( - () => ({ - getCurrentValue: () => source.getValue(), - subscribe: callback => { - const subscription = source.subscribe(callback); - return () => subscription.unsubscribe(); - }, - }), - [source], - ); - return ; - } - - function Outer({value}) { - Scheduler.yieldValue('Outer: ' + value); - return ; - } - - function Inner({value}) { - Scheduler.yieldValue('Inner: ' + value); + function Child({value}) { + Scheduler.yieldValue('Child: ' + value); return null; } + const Subscription = createSubscription({ + getCurrentValue: source => source.getValue(), + subscribe: (source, callback) => { + const subscription = source.subscribe(callback); + return () => subscription.unsubscribe(); + }, + }); + 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'); } @@ -296,7 +298,14 @@ describe('useSubscription', () => { } render() { - return ; + return ( + + {({value = 'default'}) => { + Scheduler.yieldValue('Subscriber: ' + value); + return ; + }} + + ); } } @@ -307,12 +316,12 @@ describe('useSubscription', () => { , {unstable_isConcurrent: true}, ); - expect(Scheduler).toFlushAndYield(['Outer: a-0', 'Inner: a-0']); + expect(Scheduler).toFlushAndYield(['Subscriber: a-0', 'Child: a-0']); expect(log).toEqual(['Parent.componentDidMount']); // Start React update, but don't finish renderer.update(); - expect(Scheduler).toFlushAndYieldThrough(['Outer: b-0']); + expect(Scheduler).toFlushAndYieldThrough(['Subscriber: b-0']); expect(log).toEqual(['Parent.componentDidMount']); // Emit some updates from the old subscribable @@ -326,9 +335,9 @@ describe('useSubscription', () => { // We expect the new subscribable to finish rendering, // But then the updated values from the old subscribable should be used. expect(Scheduler).toFlushAndYield([ - 'Inner: b-0', - 'Outer: a-2', - 'Inner: a-2', + 'Child: b-0', + 'Subscriber: a-2', + 'Child: a-2', ]); expect(log).toEqual([ 'Parent.componentDidMount', @@ -347,24 +356,12 @@ describe('useSubscription', () => { }); it('should guard against updates that happen after unmounting', () => { - function Subscription({source}) { - const value = useSubscription( - () => ({ - getCurrentValue: () => source.getValue(), - subscribe: callback => { - const unsubscribe = source.subscribe(callback); - return () => unsubscribe(); - }, - }), - [source], - ); - return ; - } - - function Child({value}) { - Scheduler.yieldValue(value); - return null; - } + const Subscription = createSubscription({ + getCurrentValue: source => source.getValue(), + subscribe: (source, callback) => { + return source.subscribe(callback); + }, + }); const eventHandler = { _callbacks: [], @@ -396,7 +393,12 @@ describe('useSubscription', () => { }); const renderer = ReactTestRenderer.create( - , + + {({value}) => { + Scheduler.yieldValue(value); + return null; + }} + , {unstable_isConcurrent: true}, ); diff --git a/packages/react-hooks/src/useSubscription.js b/packages/react-hooks/src/useSubscription.js index c749203cbaa8..ee4078fe2c4c 100644 --- a/packages/react-hooks/src/useSubscription.js +++ b/packages/react-hooks/src/useSubscription.js @@ -1,49 +1,30 @@ -/** - * 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 {useEffect, useState} from 'react'; -import {useEffect, useMemo, useState} from 'react'; +export function useSubscription({ + // This is the thing being subscribed to (e.g. an observable, event dispatcher, etc). + source, -// Hook used for safely managing subscriptions in concurrent mode. -// It requires two parameters: a factory function and a dependencies array. -export function useSubscription( - // This function is called whenever the specified dependencies change. - // It should return an object with two keys (functions) documented below. - nextCreate: () => {| - // Get the current subscription value. - getCurrentValue: () => T, + // (Synchronously) returns the current value of our subscription source. + getCurrentValue, - // This function is passed a callback to be called any time the subscription changes. - // It should return an unsubscribe function. - subscribe: (() => void) => () => void, - |}, - // Dependencies array. - // Any time one of the inputs change, a new subscription will be setup, - // and the previous listener will be unsubscribed. - deps: Array, -) { - const current = useMemo(nextCreate, deps); - - // Read the current subscription value. + // This function is passed an event handler to attach to the subscription source. + // It should return an unsubscribe function that removes the handler. + subscribe, +}) { + // Read the current value from our subscription source. // When this value changes, we'll schedule an update with React. - // It's important to also store the current inputs as well so we can check for staleness. + // It's important to also store the source itself so that we can check for staleness. // (See the comment in checkForUpdates() below for more info.) const [state, setState] = useState({ - current, - value: current.getCurrentValue(), + source, + value: getCurrentValue(source), }); - // If the inputs have changed since our last render, schedule an update with the current value. - // We could do this in our effect handler below but there's no need to wait in this case. - if (state.current !== current) { + // If the source has changed since our last render, schedule an update with its current value. + if (state.source !== source) { setState({ - current, - value: current.getCurrentValue(), + source, + value: getCurrentValue(source), }); } @@ -70,18 +51,18 @@ export function useSubscription( } setState(prevState => { - // Ignore values from stale subscriptions! + // 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 the stale subscription. - if (prevState.current !== current) { + // it's possible that this callback will be invoked for a stale (previous) source. + // This check avoids scheduling an update for htat stale source. + if (prevState.source !== source) { return prevState; } - // Some subscriptions will auto-invoke the handler when it's attached. + // Some subscription sources 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 = current.getCurrentValue(); + const value = getCurrentValue(source); if (prevState.value === value) { return prevState; } @@ -89,8 +70,7 @@ export function useSubscription( return {...prevState, value}; }); }; - - const unsubscribe = current.subscribe(checkForUpdates); + const unsubscribe = subscribe(source, checkForUpdates); // Because we're subscribing in a passive effect, // it's possible that an update has occurred between render and our effect handler. @@ -102,7 +82,7 @@ export function useSubscription( unsubscribe(); }; }, - [current], + [getCurrentValue, source, subscribe], ); // Return the current value for our caller to use while rendering. From 245b7070b7d375f5166515c4bd6de98a10418bf9 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Thu, 7 Mar 2019 10:38:41 -0800 Subject: [PATCH 06/18] Tidied things up, added Flow types, etc --- .../useSubscription-test.internal.js | 33 +++++++------------ packages/react-hooks/src/useSubscription.js | 23 +++++++++++-- 2 files changed, 32 insertions(+), 24 deletions(-) diff --git a/packages/react-hooks/src/__tests__/useSubscription-test.internal.js b/packages/react-hooks/src/__tests__/useSubscription-test.internal.js index 3027a6c4c446..fe9eaafe2607 100644 --- a/packages/react-hooks/src/__tests__/useSubscription-test.internal.js +++ b/packages/react-hooks/src/__tests__/useSubscription-test.internal.js @@ -51,12 +51,16 @@ describe('useSubscription', () => { // Mimic createSubscription API to simplify testing function createSubscription({getCurrentValue, subscribe}) { + function DefaultChild({value = 'default'}) { + Scheduler.yieldValue(value); + return null; + } + return function Subscription({children, source}) { const value = useSubscription( React.useMemo(() => ({source, getCurrentValue, subscribe}), [source]), ); - - return React.createElement(children, {value}); + return React.createElement(children || DefaultChild, {value}); }; } @@ -71,12 +75,7 @@ describe('useSubscription', () => { const observable = createBehaviorSubject(); const renderer = ReactTestRenderer.create( - - {({value = 'default'}) => { - Scheduler.yieldValue(value); - return null; - }} - , + , {unstable_isConcurrent: true}, ); @@ -110,14 +109,9 @@ describe('useSubscription', () => { }, }); - function render({value = 'default'}) { - Scheduler.yieldValue(value); - return null; - } - let observable = createReplaySubject('initial'); const renderer = ReactTestRenderer.create( - {render}, + , {unstable_isConcurrent: true}, ); expect(Scheduler).toFlushAndYield(['initial']); @@ -128,7 +122,7 @@ describe('useSubscription', () => { // Unsetting the subscriber prop should reset subscribed values observable = createReplaySubject(undefined); - renderer.update({render}); + renderer.update(); expect(Scheduler).toFlushAndYield(['default']); }); @@ -141,16 +135,11 @@ describe('useSubscription', () => { }, }); - function render({value = 'default'}) { - Scheduler.yieldValue(value); - return null; - } - const observableA = createBehaviorSubject('a-0'); const observableB = createBehaviorSubject('b-0'); const renderer = ReactTestRenderer.create( - {render}, + , {unstable_isConcurrent: true}, ); @@ -158,7 +147,7 @@ describe('useSubscription', () => { expect(Scheduler).toFlushAndYield(['a-0']); // Unsetting the subscriber prop should reset subscribed values - renderer.update({render}); + renderer.update(); expect(Scheduler).toFlushAndYield(['b-0']); // Updates to the old subscribable should not re-render the child component diff --git a/packages/react-hooks/src/useSubscription.js b/packages/react-hooks/src/useSubscription.js index ee4078fe2c4c..0a5a2da8a6a0 100644 --- a/packages/react-hooks/src/useSubscription.js +++ b/packages/react-hooks/src/useSubscription.js @@ -1,6 +1,21 @@ +/** + * 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 {useEffect, useState} from 'react'; -export function useSubscription({ +// 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({ // This is the thing being subscribed to (e.g. an observable, event dispatcher, etc). source, @@ -10,7 +25,11 @@ export function useSubscription({ // This function is passed an event handler to attach to the subscription source. // It should return an unsubscribe function that removes the handler. subscribe, -}) { +}: {| + source: Source, + getCurrentValue: (source: Source) => Value, + subscribe: (source: Source, callback: Function) => () => void, +|}) { // Read the current value from our subscription source. // When this value changes, we'll schedule an update with React. // It's important to also store the source itself so that we can check for staleness. From 97e9d07b8fdf7450cc49214f3a2612ea80885159 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Mon, 11 Mar 2019 14:51:23 -0700 Subject: [PATCH 07/18] Add return type to useSubscription --- packages/react-hooks/src/useSubscription.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-hooks/src/useSubscription.js b/packages/react-hooks/src/useSubscription.js index 0a5a2da8a6a0..8a564c8a180e 100644 --- a/packages/react-hooks/src/useSubscription.js +++ b/packages/react-hooks/src/useSubscription.js @@ -29,7 +29,7 @@ export function useSubscription({ source: Source, getCurrentValue: (source: Source) => Value, subscribe: (source: Source, callback: Function) => () => void, -|}) { +|}): Value { // Read the current value from our subscription source. // When this value changes, we'll schedule an update with React. // It's important to also store the source itself so that we can check for staleness. From 345a67a51cbfb725114024520ef16a926da1ddcf Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Thu, 14 Mar 2019 09:08:27 -0700 Subject: [PATCH 08/18] Refactored useSubscription to remove explicit source param and rely on useMemo --- .../useSubscription-test.internal.js | 232 +++++++++++------- packages/react-hooks/src/useSubscription.js | 54 ++-- 2 files changed, 167 insertions(+), 119 deletions(-) diff --git a/packages/react-hooks/src/__tests__/useSubscription-test.internal.js b/packages/react-hooks/src/__tests__/useSubscription-test.internal.js index fe9eaafe2607..bc1cc8811bee 100644 --- a/packages/react-hooks/src/__tests__/useSubscription-test.internal.js +++ b/packages/react-hooks/src/__tests__/useSubscription-test.internal.js @@ -49,29 +49,27 @@ describe('useSubscription', () => { return replaySubject; } - // Mimic createSubscription API to simplify testing - function createSubscription({getCurrentValue, subscribe}) { - function DefaultChild({value = 'default'}) { + it('supports basic subscription pattern', () => { + function Child({value = 'default'}) { Scheduler.yieldValue(value); return null; } - return function Subscription({children, source}) { + function Subscription({source}) { const value = useSubscription( - React.useMemo(() => ({source, getCurrentValue, subscribe}), [source]), + React.useMemo( + () => ({ + getCurrentValue: () => source.getValue(), + subscribe: callback => { + const subscription = source.subscribe(callback); + return () => subscription.unsubscribe(); + }, + }), + [source], + ), ); - return React.createElement(children || DefaultChild, {value}); - }; - } - - it('supports basic subscription pattern', () => { - const Subscription = createSubscription({ - getCurrentValue: source => source.getValue(), - subscribe: (source, callback) => { - const subscription = source.subscribe(callback); - return () => subscription.unsubscribe(); - }, - }); + return ; + } const observable = createBehaviorSubject(); const renderer = ReactTestRenderer.create( @@ -93,21 +91,34 @@ describe('useSubscription', () => { }); it('should support observable types like RxJS ReplaySubject', () => { - const Subscription = createSubscription({ - getCurrentValue: source => { - let currentValue; - source - .subscribe(value => { - currentValue = value; - }) - .unsubscribe(); - return currentValue; - }, - subscribe: (source, callback) => { - const subscription = source.subscribe(callback); - return () => subscription.unsubscribe; - }, - }); + function Child({value = 'default'}) { + Scheduler.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'); const renderer = ReactTestRenderer.create( @@ -127,13 +138,26 @@ describe('useSubscription', () => { }); it('should unsubscribe from old subscribables and subscribe to new subscribables when props change', () => { - const Subscription = createSubscription({ - getCurrentValue: source => source.getValue(), - subscribe: (source, callback) => { - const subscription = source.subscribe(callback); - return () => subscription.unsubscribe(); - }, - }); + function Child({value = 'default'}) { + Scheduler.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 observableA = createBehaviorSubject('a-0'); const observableB = createBehaviorSubject('b-0'); @@ -162,18 +186,31 @@ describe('useSubscription', () => { it('should ignore values emitted by a new subscribable until the commit phase', () => { const log = []; - function Child({value}) { - Scheduler.yieldValue('Child: ' + value); + function Grandchild({value}) { + Scheduler.yieldValue('Grandchild: ' + value); return null; } - const Subscription = createSubscription({ - getCurrentValue: source => source.getValue(), - subscribe: (source, callback) => { - const subscription = source.subscribe(callback); - return () => subscription.unsubscribe(); - }, - }); + function Child({value = 'default'}) { + Scheduler.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 = {}; @@ -197,14 +234,7 @@ describe('useSubscription', () => { } render() { - return ( - - {({value = 'default'}) => { - Scheduler.yieldValue('Subscriber: ' + value); - return ; - }} - - ); + return ; } } @@ -215,12 +245,12 @@ describe('useSubscription', () => { , {unstable_isConcurrent: true}, ); - expect(Scheduler).toFlushAndYield(['Subscriber: a-0', 'Child: a-0']); + expect(Scheduler).toFlushAndYield(['Child: a-0', 'Grandchild: a-0']); expect(log).toEqual(['Parent.componentDidMount']); // Start React update, but don't finish renderer.update(); - expect(Scheduler).toFlushAndYieldThrough(['Subscriber: b-0']); + expect(Scheduler).toFlushAndYieldThrough(['Child: b-0']); expect(log).toEqual(['Parent.componentDidMount']); // Emit some updates from the uncommitted subscribable @@ -236,11 +266,11 @@ describe('useSubscription', () => { // But the intermediate ones should be ignored, // And the final rendered output should be the higher-priority observable. expect(Scheduler).toFlushAndYield([ - 'Child: b-0', - 'Subscriber: b-3', + 'Grandchild: b-0', 'Child: b-3', - 'Subscriber: a-0', + 'Grandchild: b-3', 'Child: a-0', + 'Grandchild: a-0', ]); expect(log).toEqual([ 'Parent.componentDidMount', @@ -252,18 +282,31 @@ describe('useSubscription', () => { it('should not drop values emitted between updates', () => { const log = []; - function Child({value}) { - Scheduler.yieldValue('Child: ' + value); + function Grandchild({value}) { + Scheduler.yieldValue('Grandchild: ' + value); return null; } - const Subscription = createSubscription({ - getCurrentValue: source => source.getValue(), - subscribe: (source, callback) => { - const subscription = source.subscribe(callback); - return () => subscription.unsubscribe(); - }, - }); + function Child({value = 'default'}) { + Scheduler.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 = {}; @@ -287,14 +330,7 @@ describe('useSubscription', () => { } render() { - return ( - - {({value = 'default'}) => { - Scheduler.yieldValue('Subscriber: ' + value); - return ; - }} - - ); + return ; } } @@ -305,12 +341,12 @@ describe('useSubscription', () => { , {unstable_isConcurrent: true}, ); - expect(Scheduler).toFlushAndYield(['Subscriber: a-0', 'Child: a-0']); + expect(Scheduler).toFlushAndYield(['Child: a-0', 'Grandchild: a-0']); expect(log).toEqual(['Parent.componentDidMount']); // Start React update, but don't finish renderer.update(); - expect(Scheduler).toFlushAndYieldThrough(['Subscriber: b-0']); + expect(Scheduler).toFlushAndYieldThrough(['Child: b-0']); expect(log).toEqual(['Parent.componentDidMount']); // Emit some updates from the old subscribable @@ -324,9 +360,9 @@ describe('useSubscription', () => { // We expect the new subscribable to finish rendering, // But then the updated values from the old subscribable should be used. expect(Scheduler).toFlushAndYield([ - 'Child: b-0', - 'Subscriber: a-2', + 'Grandchild: b-0', 'Child: a-2', + 'Grandchild: a-2', ]); expect(log).toEqual([ 'Parent.componentDidMount', @@ -345,12 +381,25 @@ describe('useSubscription', () => { }); it('should guard against updates that happen after unmounting', () => { - const Subscription = createSubscription({ - getCurrentValue: source => source.getValue(), - subscribe: (source, callback) => { - return source.subscribe(callback); - }, - }); + function Child({value = 'default'}) { + Scheduler.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: [], @@ -382,12 +431,7 @@ describe('useSubscription', () => { }); const renderer = ReactTestRenderer.create( - - {({value}) => { - Scheduler.yieldValue(value); - return null; - }} - , + , {unstable_isConcurrent: true}, ); diff --git a/packages/react-hooks/src/useSubscription.js b/packages/react-hooks/src/useSubscription.js index 8a564c8a180e..75c2a8abdea7 100644 --- a/packages/react-hooks/src/useSubscription.js +++ b/packages/react-hooks/src/useSubscription.js @@ -15,35 +15,36 @@ import {useEffect, useState} from 'react'; // 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({ - // This is the thing being subscribed to (e.g. an observable, event dispatcher, etc). - source, - - // (Synchronously) returns the current value of our subscription source. +export function useSubscription({ + // (Synchronously) returns the current value of our subscription. getCurrentValue, - // This function is passed an event handler to attach to the subscription source. + // This function is passed an event handler to attach to the subscription. // It should return an unsubscribe function that removes the handler. subscribe, }: {| - source: Source, - getCurrentValue: (source: Source) => Value, - subscribe: (source: Source, callback: Function) => () => void, + getCurrentValue: () => Value, + subscribe: (callback: Function) => () => void, |}): Value { - // Read the current value from our subscription source. + // 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 source itself so that we can check for staleness. + // 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({ - source, - value: getCurrentValue(source), + getCurrentValue, + subscribe, + value: getCurrentValue(), }); - // If the source has changed since our last render, schedule an update with its current value. - if (state.source !== source) { + // If parameters have changed since our last render, schedule an update with its current value. + if ( + state.getCurrentValue !== getCurrentValue || + state.subscribe !== subscribe + ) { setState({ - source, - value: getCurrentValue(source), + getCurrentValue, + subscribe, + value: getCurrentValue(), }); } @@ -62,7 +63,7 @@ export function useSubscription({ const checkForUpdates = () => { // It's possible that this callback will be invoked even after being unsubscribed, - // if it's removed as a result of an event/update from the source. + // 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) { @@ -72,16 +73,19 @@ export function useSubscription({ 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) source. - // This check avoids scheduling an update for htat stale source. - if (prevState.source !== source) { + // 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 subscription sources will auto-invoke the handler, even if the value hasn't changed. + // 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(source); + const value = getCurrentValue(); if (prevState.value === value) { return prevState; } @@ -89,7 +93,7 @@ export function useSubscription({ return {...prevState, value}; }); }; - const unsubscribe = subscribe(source, checkForUpdates); + 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. @@ -101,7 +105,7 @@ export function useSubscription({ unsubscribe(); }; }, - [getCurrentValue, source, subscribe], + [getCurrentValue, subscribe], ); // Return the current value for our caller to use while rendering. From 1ad3743a9e39df87b7b0125f7bc74b63fa396a98 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Thu, 14 Mar 2019 09:20:58 -0700 Subject: [PATCH 09/18] Added explicit test for useMemo and useCallback and num unsubscriptions --- .../useSubscription-test.internal.js | 72 ++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/packages/react-hooks/src/__tests__/useSubscription-test.internal.js b/packages/react-hooks/src/__tests__/useSubscription-test.internal.js index bc1cc8811bee..51bb9ad7f095 100644 --- a/packages/react-hooks/src/__tests__/useSubscription-test.internal.js +++ b/packages/react-hooks/src/__tests__/useSubscription-test.internal.js @@ -137,18 +137,21 @@ describe('useSubscription', () => { expect(Scheduler).toFlushAndYield(['default']); }); - it('should unsubscribe from old subscribables and subscribe to new subscribables when props change', () => { + it('should unsubscribe from old sources and subscribe to new sources when memoized props change', () => { function Child({value = 'default'}) { Scheduler.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(); }, @@ -162,6 +165,8 @@ describe('useSubscription', () => { const observableA = createBehaviorSubject('a-0'); const observableB = createBehaviorSubject('b-0'); + expect(subscriptions).toHaveLength(0); + const renderer = ReactTestRenderer.create( , {unstable_isConcurrent: true}, @@ -170,10 +175,16 @@ describe('useSubscription', () => { // Updates while subscribed should re-render the child component expect(Scheduler).toFlushAndYield(['a-0']); + expect(subscriptions).toHaveLength(1); + expect(subscriptions[0]).toBe(observableA); + // Unsetting the subscriber prop should reset subscribed values renderer.update(); expect(Scheduler).toFlushAndYield(['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([]); @@ -181,6 +192,65 @@ describe('useSubscription', () => { // Updates to the bew subscribable should re-render the child component act(() => observableB.next('b-1')); expect(Scheduler).toFlushAndYield(['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.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); + + const renderer = ReactTestRenderer.create( + , + {unstable_isConcurrent: true}, + ); + + // Updates while subscribed should re-render the child component + expect(Scheduler).toFlushAndYield(['a-0']); + + expect(subscriptions).toHaveLength(1); + expect(subscriptions[0]).toBe(observableA); + + // Unsetting the subscriber prop should reset subscribed values + renderer.update(); + expect(Scheduler).toFlushAndYield(['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).toFlushAndYield(['b-1']); + + expect(subscriptions).toHaveLength(2); }); it('should ignore values emitted by a new subscribable until the commit phase', () => { From 6d1de395da2d2bba3cd203286d1c173e537892f1 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Wed, 29 May 2019 10:18:48 -0700 Subject: [PATCH 10/18] Renamed react-hooks package to use-subscription --- packages/react-hooks/npm/index.js | 7 ------- packages/{react-hooks => use-subscription}/README.md | 2 +- packages/{react-hooks => use-subscription}/index.js | 0 packages/use-subscription/npm/index.js | 7 +++++++ packages/{react-hooks => use-subscription}/package.json | 8 ++++---- .../src/__tests__/useSubscription-test.internal.js | 2 +- .../src/useSubscription.js | 0 7 files changed, 13 insertions(+), 13 deletions(-) delete mode 100644 packages/react-hooks/npm/index.js rename packages/{react-hooks => use-subscription}/README.md (52%) rename packages/{react-hooks => use-subscription}/index.js (100%) create mode 100644 packages/use-subscription/npm/index.js rename packages/{react-hooks => use-subscription}/package.json (73%) rename packages/{react-hooks => use-subscription}/src/__tests__/useSubscription-test.internal.js (99%) rename packages/{react-hooks => use-subscription}/src/useSubscription.js (100%) diff --git a/packages/react-hooks/npm/index.js b/packages/react-hooks/npm/index.js deleted file mode 100644 index a09b8cb3c55f..000000000000 --- a/packages/react-hooks/npm/index.js +++ /dev/null @@ -1,7 +0,0 @@ -'use strict'; - -if (process.env.NODE_ENV === 'production') { - module.exports = require('./cjs/react-hooks.production.min.js'); -} else { - module.exports = require('./cjs/react-hooks.development.js'); -} diff --git a/packages/react-hooks/README.md b/packages/use-subscription/README.md similarity index 52% rename from packages/react-hooks/README.md rename to packages/use-subscription/README.md index 68bb2c4a262e..11a90418847a 100644 --- a/packages/react-hooks/README.md +++ b/packages/use-subscription/README.md @@ -1,3 +1,3 @@ -# react-hooks +# use-subscription Placeholder package. \ No newline at end of file diff --git a/packages/react-hooks/index.js b/packages/use-subscription/index.js similarity index 100% rename from packages/react-hooks/index.js rename to packages/use-subscription/index.js diff --git a/packages/use-subscription/npm/index.js b/packages/use-subscription/npm/index.js new file mode 100644 index 000000000000..b91e9c4a1423 --- /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/react-hooks/package.json b/packages/use-subscription/package.json similarity index 73% rename from packages/react-hooks/package.json rename to packages/use-subscription/package.json index e08e0eaf92b3..713d93decb35 100644 --- a/packages/react-hooks/package.json +++ b/packages/use-subscription/package.json @@ -1,12 +1,12 @@ { "private": true, - "name": "react-hooks", + "name": "use-subscription", "description": "Reusable hooks", - "version": "16.8.3", + "version": "16.8.6", "repository": { "type": "git", "url": "https://github.com/facebook/react.git", - "directory": "packages/react-hooks" + "directory": "packages/use-subscription" }, "files": [ "LICENSE", @@ -16,7 +16,7 @@ "cjs/" ], "peerDependencies": { - "react": "^16.3.0" + "react": "^16.8.0" }, "devDependencies": { "rxjs": "^5.5.6" diff --git a/packages/react-hooks/src/__tests__/useSubscription-test.internal.js b/packages/use-subscription/src/__tests__/useSubscription-test.internal.js similarity index 99% rename from packages/react-hooks/src/__tests__/useSubscription-test.internal.js rename to packages/use-subscription/src/__tests__/useSubscription-test.internal.js index 51bb9ad7f095..3247196467ea 100644 --- a/packages/react-hooks/src/__tests__/useSubscription-test.internal.js +++ b/packages/use-subscription/src/__tests__/useSubscription-test.internal.js @@ -22,7 +22,7 @@ describe('useSubscription', () => { jest.resetModules(); jest.mock('scheduler', () => require('scheduler/unstable_mock')); - useSubscription = require('react-hooks').useSubscription; + useSubscription = require('use-subscription').useSubscription; React = require('react'); ReactTestRenderer = require('react-test-renderer'); Scheduler = require('scheduler'); diff --git a/packages/react-hooks/src/useSubscription.js b/packages/use-subscription/src/useSubscription.js similarity index 100% rename from packages/react-hooks/src/useSubscription.js rename to packages/use-subscription/src/useSubscription.js From b8270151813bbfe6b4f41ea0266946026df59725 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Wed, 29 May 2019 10:47:43 -0700 Subject: [PATCH 11/18] Updated tests to account for changes to scheduler / flushing behavior --- .../__tests__/useSubscription-test.internal.js | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/use-subscription/src/__tests__/useSubscription-test.internal.js b/packages/use-subscription/src/__tests__/useSubscription-test.internal.js index 3247196467ea..6132eda136a9 100644 --- a/packages/use-subscription/src/__tests__/useSubscription-test.internal.js +++ b/packages/use-subscription/src/__tests__/useSubscription-test.internal.js @@ -80,9 +80,9 @@ describe('useSubscription', () => { // Updates while subscribed should re-render the child component expect(Scheduler).toFlushAndYield(['default']); act(() => observable.next(123)); - expect(Scheduler).toFlushAndYield([123]); + expect(Scheduler).toHaveYielded([123]); act(() => observable.next('abc')); - expect(Scheduler).toFlushAndYield(['abc']); + expect(Scheduler).toHaveYielded(['abc']); // Unmounting the subscriber should remove listeners renderer.update(
); @@ -127,7 +127,7 @@ describe('useSubscription', () => { ); expect(Scheduler).toFlushAndYield(['initial']); act(() => observable.next('updated')); - expect(Scheduler).toFlushAndYield(['updated']); + expect(Scheduler).toHaveYielded(['updated']); Scheduler.flushAll(); @@ -191,7 +191,7 @@ describe('useSubscription', () => { // Updates to the bew subscribable should re-render the child component act(() => observableB.next('b-1')); - expect(Scheduler).toFlushAndYield(['b-1']); + expect(Scheduler).toHaveYielded(['b-1']); expect(subscriptions).toHaveLength(2); }); @@ -248,7 +248,7 @@ describe('useSubscription', () => { // Updates to the bew subscribable should re-render the child component act(() => observableB.next('b-1')); - expect(Scheduler).toFlushAndYield(['b-1']); + expect(Scheduler).toHaveYielded(['b-1']); expect(subscriptions).toHaveLength(2); }); @@ -429,8 +429,12 @@ describe('useSubscription', () => { // 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).toHaveYielded(['Grandchild: b-0',]); expect(Scheduler).toFlushAndYield([ - 'Grandchild: b-0', + 'Child: a-2', + 'Grandchild: a-2', + 'Child: a-2', + 'Grandchild: a-2', 'Child: a-2', 'Grandchild: a-2', ]); From 78f425e9320df9bf0c2d8ac53de11c55508d824a Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Wed, 29 May 2019 10:56:10 -0700 Subject: [PATCH 12/18] Avoid returning a stale/torn value when the subscription changes --- .../useSubscription-test.internal.js | 33 ++++++++++++++++++- .../use-subscription/src/useSubscription.js | 11 +++++-- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/packages/use-subscription/src/__tests__/useSubscription-test.internal.js b/packages/use-subscription/src/__tests__/useSubscription-test.internal.js index 6132eda136a9..6ae23eead017 100644 --- a/packages/use-subscription/src/__tests__/useSubscription-test.internal.js +++ b/packages/use-subscription/src/__tests__/useSubscription-test.internal.js @@ -429,7 +429,7 @@ describe('useSubscription', () => { // 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).toHaveYielded(['Grandchild: b-0',]); + expect(Scheduler).toHaveYielded(['Grandchild: b-0']); expect(Scheduler).toFlushAndYield([ 'Child: a-2', 'Grandchild: a-2', @@ -514,4 +514,35 @@ describe('useSubscription', () => { // 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; + } + + const renderer = ReactTestRenderer.create( + , + {unstable_isConcurrent: true}, + ); + + Scheduler.flushAll(); + renderer.update(); + Scheduler.flushAll(); + }); }); diff --git a/packages/use-subscription/src/useSubscription.js b/packages/use-subscription/src/useSubscription.js index 75c2a8abdea7..190ded77ffeb 100644 --- a/packages/use-subscription/src/useSubscription.js +++ b/packages/use-subscription/src/useSubscription.js @@ -36,15 +36,22 @@ export function useSubscription({ 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: getCurrentValue(), + value: valueToReturn, }); } @@ -109,5 +116,5 @@ export function useSubscription({ ); // Return the current value for our caller to use while rendering. - return state.value; + return valueToReturn; } From a6307a848a98e1715cd0e550ef964a920aba6fb0 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Wed, 29 May 2019 13:05:44 -0700 Subject: [PATCH 13/18] Use lazy initializer function for useState to avoid calling getCurrentValue() unnecessarily --- packages/use-subscription/src/useSubscription.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/use-subscription/src/useSubscription.js b/packages/use-subscription/src/useSubscription.js index 190ded77ffeb..8fbf3ef4290c 100644 --- a/packages/use-subscription/src/useSubscription.js +++ b/packages/use-subscription/src/useSubscription.js @@ -30,11 +30,11 @@ export function useSubscription({ // 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({ + const [state, setState] = useState(() => ({ getCurrentValue, subscribe, value: getCurrentValue(), - }); + })); let valueToReturn = state.value; From c161880973861ad2d63a9018e9ec74a432c0f525 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Tue, 9 Jul 2019 07:23:31 -0700 Subject: [PATCH 14/18] Added useDebugValue() to useSubscription() hook --- packages/use-subscription/src/useSubscription.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/use-subscription/src/useSubscription.js b/packages/use-subscription/src/useSubscription.js index 8fbf3ef4290c..1bb80e2d7b13 100644 --- a/packages/use-subscription/src/useSubscription.js +++ b/packages/use-subscription/src/useSubscription.js @@ -7,7 +7,7 @@ * @flow */ -import {useEffect, useState} from 'react'; +import {useDebugValue, useEffect, useState} from 'react'; // Hook used for safely managing subscriptions in concurrent mode. // @@ -55,6 +55,9 @@ export function useSubscription({ }); } + // 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. From 0f1258f1c015a1dc5abf2b6b6329cb8f2ebf1c8d Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Tue, 9 Jul 2019 07:36:34 -0700 Subject: [PATCH 15/18] Updated Scheduler method names --- .../useSubscription-test.internal.js | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/use-subscription/src/__tests__/useSubscription-test.internal.js b/packages/use-subscription/src/__tests__/useSubscription-test.internal.js index 6ae23eead017..6019ed33e101 100644 --- a/packages/use-subscription/src/__tests__/useSubscription-test.internal.js +++ b/packages/use-subscription/src/__tests__/useSubscription-test.internal.js @@ -51,7 +51,7 @@ describe('useSubscription', () => { it('supports basic subscription pattern', () => { function Child({value = 'default'}) { - Scheduler.yieldValue(value); + Scheduler.unstable_yieldValue(value); return null; } @@ -92,7 +92,7 @@ describe('useSubscription', () => { it('should support observable types like RxJS ReplaySubject', () => { function Child({value = 'default'}) { - Scheduler.yieldValue(value); + Scheduler.unstable_yieldValue(value); return null; } @@ -129,7 +129,7 @@ describe('useSubscription', () => { act(() => observable.next('updated')); expect(Scheduler).toHaveYielded(['updated']); - Scheduler.flushAll(); + Scheduler.unstable_flushAll(); // Unsetting the subscriber prop should reset subscribed values observable = createReplaySubject(undefined); @@ -139,7 +139,7 @@ describe('useSubscription', () => { it('should unsubscribe from old sources and subscribe to new sources when memoized props change', () => { function Child({value = 'default'}) { - Scheduler.yieldValue(value); + Scheduler.unstable_yieldValue(value); return null; } @@ -198,7 +198,7 @@ describe('useSubscription', () => { it('should unsubscribe from old sources and subscribe to new sources when useCallback functions change', () => { function Child({value = 'default'}) { - Scheduler.yieldValue(value); + Scheduler.unstable_yieldValue(value); return null; } @@ -257,12 +257,12 @@ describe('useSubscription', () => { const log = []; function Grandchild({value}) { - Scheduler.yieldValue('Grandchild: ' + value); + Scheduler.unstable_yieldValue('Grandchild: ' + value); return null; } function Child({value = 'default'}) { - Scheduler.yieldValue('Child: ' + value); + Scheduler.unstable_yieldValue('Child: ' + value); return ; } @@ -353,12 +353,12 @@ describe('useSubscription', () => { const log = []; function Grandchild({value}) { - Scheduler.yieldValue('Grandchild: ' + value); + Scheduler.unstable_yieldValue('Grandchild: ' + value); return null; } function Child({value = 'default'}) { - Scheduler.yieldValue('Child: ' + value); + Scheduler.unstable_yieldValue('Child: ' + value); return ; } @@ -456,7 +456,7 @@ describe('useSubscription', () => { it('should guard against updates that happen after unmounting', () => { function Child({value = 'default'}) { - Scheduler.yieldValue(value); + Scheduler.unstable_yieldValue(value); return null; } @@ -541,8 +541,8 @@ describe('useSubscription', () => { {unstable_isConcurrent: true}, ); - Scheduler.flushAll(); + Scheduler.unstable_flushAll(); renderer.update(); - Scheduler.flushAll(); + Scheduler.unstable_flushAll(); }); }); From b6471bc178223b19d6f5d2ef4fc4f8bd2aadf397 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Tue, 9 Jul 2019 07:56:27 -0700 Subject: [PATCH 16/18] Updated tests to account for changes in Act --- .../useSubscription-test.internal.js | 211 ++++++++++-------- 1 file changed, 113 insertions(+), 98 deletions(-) diff --git a/packages/use-subscription/src/__tests__/useSubscription-test.internal.js b/packages/use-subscription/src/__tests__/useSubscription-test.internal.js index 6019ed33e101..daacfb5cfe57 100644 --- a/packages/use-subscription/src/__tests__/useSubscription-test.internal.js +++ b/packages/use-subscription/src/__tests__/useSubscription-test.internal.js @@ -72,20 +72,23 @@ describe('useSubscription', () => { } const observable = createBehaviorSubject(); - const renderer = ReactTestRenderer.create( - , - {unstable_isConcurrent: true}, - ); + let renderer; + act(() => { + renderer = ReactTestRenderer.create( + , + {unstable_isConcurrent: true}, + ); + }); + expect(Scheduler).toHaveYielded(['default']); // Updates while subscribed should re-render the child component - expect(Scheduler).toFlushAndYield(['default']); act(() => observable.next(123)); expect(Scheduler).toHaveYielded([123]); act(() => observable.next('abc')); expect(Scheduler).toHaveYielded(['abc']); // Unmounting the subscriber should remove listeners - renderer.update(
); + act(() => renderer.update(
)); act(() => observable.next(456)); expect(Scheduler).toFlushAndYield([]); }); @@ -121,11 +124,14 @@ describe('useSubscription', () => { } let observable = createReplaySubject('initial'); - const renderer = ReactTestRenderer.create( - , - {unstable_isConcurrent: true}, - ); - expect(Scheduler).toFlushAndYield(['initial']); + let renderer; + act(() => { + renderer = ReactTestRenderer.create( + , + {unstable_isConcurrent: true}, + ); + }); + expect(Scheduler).toHaveYielded(['initial']); act(() => observable.next('updated')); expect(Scheduler).toHaveYielded(['updated']); @@ -133,8 +139,8 @@ describe('useSubscription', () => { // Unsetting the subscriber prop should reset subscribed values observable = createReplaySubject(undefined); - renderer.update(); - expect(Scheduler).toFlushAndYield(['default']); + act(() => renderer.update()); + expect(Scheduler).toHaveYielded(['default']); }); it('should unsubscribe from old sources and subscribe to new sources when memoized props change', () => { @@ -167,21 +173,23 @@ describe('useSubscription', () => { expect(subscriptions).toHaveLength(0); - const renderer = ReactTestRenderer.create( - , - {unstable_isConcurrent: true}, - ); + let renderer; + act(() => { + renderer = ReactTestRenderer.create( + , + {unstable_isConcurrent: true}, + ); + }); // Updates while subscribed should re-render the child component - expect(Scheduler).toFlushAndYield(['a-0']); - + expect(Scheduler).toHaveYielded(['a-0']); expect(subscriptions).toHaveLength(1); expect(subscriptions[0]).toBe(observableA); // Unsetting the subscriber prop should reset subscribed values - renderer.update(); - expect(Scheduler).toFlushAndYield(['b-0']); + act(() => renderer.update()); + expect(Scheduler).toHaveYielded(['b-0']); expect(subscriptions).toHaveLength(2); expect(subscriptions[1]).toBe(observableB); @@ -224,21 +232,22 @@ describe('useSubscription', () => { expect(subscriptions).toHaveLength(0); - const renderer = ReactTestRenderer.create( - , - {unstable_isConcurrent: true}, - ); + let renderer; + act(() => { + renderer = ReactTestRenderer.create( + , + {unstable_isConcurrent: true}, + ); + }); // Updates while subscribed should re-render the child component - expect(Scheduler).toFlushAndYield(['a-0']); - + expect(Scheduler).toHaveYielded(['a-0']); expect(subscriptions).toHaveLength(1); expect(subscriptions[0]).toBe(observableA); // Unsetting the subscriber prop should reset subscribed values - renderer.update(); - expect(Scheduler).toFlushAndYield(['b-0']); - + act(() => renderer.update()); + expect(Scheduler).toHaveYielded(['b-0']); expect(subscriptions).toHaveLength(2); expect(subscriptions[1]).toBe(observableB); @@ -311,31 +320,35 @@ describe('useSubscription', () => { const observableA = createBehaviorSubject('a-0'); const observableB = createBehaviorSubject('b-0'); - const renderer = ReactTestRenderer.create( - , - {unstable_isConcurrent: true}, - ); - expect(Scheduler).toFlushAndYield(['Child: a-0', 'Grandchild: a-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 - 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'); + 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 - renderer.update(); + 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).toFlushAndYield([ + expect(Scheduler).toHaveYielded([ 'Grandchild: b-0', 'Child: b-3', 'Grandchild: b-3', @@ -392,11 +405,11 @@ describe('useSubscription', () => { } componentDidMount() { - log.push('Parent.componentDidMount'); + log.push('Parent.componentDidMount:' + this.props.observed.value); } componentDidUpdate() { - log.push('Parent.componentDidUpdate'); + log.push('Parent.componentDidUpdate:' + this.props.observed.value); } render() { @@ -407,51 +420,48 @@ describe('useSubscription', () => { const observableA = createBehaviorSubject('a-0'); const observableB = createBehaviorSubject('b-0'); - const renderer = ReactTestRenderer.create( - , - {unstable_isConcurrent: true}, - ); - expect(Scheduler).toFlushAndYield(['Child: a-0', 'Grandchild: a-0']); - expect(log).toEqual(['Parent.componentDidMount']); + 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 - renderer.update(); - expect(Scheduler).toFlushAndYieldThrough(['Child: b-0']); - expect(log).toEqual(['Parent.componentDidMount']); - - // Emit some updates from the old subscribable - act(() => observableA.next('a-1')); - act(() => 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).toHaveYielded(['Grandchild: b-0']); - expect(Scheduler).toFlushAndYield([ - 'Child: a-2', - 'Grandchild: a-2', - 'Child: a-2', - 'Grandchild: a-2', - 'Child: a-2', - 'Grandchild: a-2', - ]); - expect(log).toEqual([ - 'Parent.componentDidMount', - 'Parent.componentDidUpdate', - 'Parent.componentDidUpdate', - ]); + 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([ - 'Parent.componentDidMount', - 'Parent.componentDidUpdate', - 'Parent.componentDidUpdate', - ]); + expect(log).toEqual([]); }); it('should guard against updates that happen after unmounting', () => { @@ -504,12 +514,14 @@ describe('useSubscription', () => { } }); - const renderer = ReactTestRenderer.create( - , - {unstable_isConcurrent: true}, - ); - - expect(Scheduler).toFlushAndYield([true]); + let renderer; + act(() => { + renderer = ReactTestRenderer.create( + , + {unstable_isConcurrent: true}, + ); + }); + expect(Scheduler).toHaveYielded([true]); // This event should unmount eventHandler.change(false); @@ -536,13 +548,16 @@ describe('useSubscription', () => { return null; } - const renderer = ReactTestRenderer.create( - , - {unstable_isConcurrent: true}, - ); - + let renderer; + act(() => { + renderer = ReactTestRenderer.create( + , + {unstable_isConcurrent: true}, + ); + }); Scheduler.unstable_flushAll(); - renderer.update(); + + act(() => renderer.update()); Scheduler.unstable_flushAll(); }); }); From 1d1ded9ce890fb6f09c654e9c1dc7bb5605ab6f3 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Mon, 15 Jul 2019 09:16:02 -0700 Subject: [PATCH 17/18] Reset useSubscription package version to 0.0.0 We will update it to 1.0.0 as part of the first release (with 16.9) --- packages/use-subscription/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/use-subscription/package.json b/packages/use-subscription/package.json index 713d93decb35..2643fbd161d1 100644 --- a/packages/use-subscription/package.json +++ b/packages/use-subscription/package.json @@ -2,7 +2,7 @@ "private": true, "name": "use-subscription", "description": "Reusable hooks", - "version": "16.8.6", + "version": "0.0.0", "repository": { "type": "git", "url": "https://github.com/facebook/react.git", From c88a93684caf6ce66135dfb5aa0cd9bc3dd35878 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Tue, 16 Jul 2019 15:01:26 -0700 Subject: [PATCH 18/18] Added README with examples --- packages/use-subscription/README.md | 131 +++++++++++++++++++++++++++- 1 file changed, 130 insertions(+), 1 deletion(-) diff --git a/packages/use-subscription/README.md b/packages/use-subscription/README.md index 11a90418847a..b56023d1c6e3 100644 --- a/packages/use-subscription/README.md +++ b/packages/use-subscription/README.md @@ -1,3 +1,132 @@ # use-subscription -Placeholder package. \ No newline at end of file +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); +```