From d5d8bf6419e8ddfe40d15e91ac0ce25fe02c5bdd Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Sun, 4 Mar 2018 21:12:42 -0500 Subject: [PATCH 01/53] POC for create-component-with-subscriptions --- .../README.md | 81 ++++++++ .../index.js | 12 ++ .../npm/index.js | 7 + .../package.json | 13 ++ .../createComponentWithSubscriptions-test.js | 171 ++++++++++++++++ .../src/createComponentWithSubscriptions.js | 188 ++++++++++++++++++ 6 files changed, 472 insertions(+) create mode 100644 packages/create-component-with-subscriptions/README.md create mode 100644 packages/create-component-with-subscriptions/index.js create mode 100644 packages/create-component-with-subscriptions/npm/index.js create mode 100644 packages/create-component-with-subscriptions/package.json create mode 100644 packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.js create mode 100644 packages/create-component-with-subscriptions/src/createComponentWithSubscriptions.js diff --git a/packages/create-component-with-subscriptions/README.md b/packages/create-component-with-subscriptions/README.md new file mode 100644 index 0000000000000..baaf70a916665 --- /dev/null +++ b/packages/create-component-with-subscriptions/README.md @@ -0,0 +1,81 @@ +# create-component-with-subscriptions + +Better docs coming soon... + +```js +// Here is an example of using the subscribable HOC. +// It shows a couple of potentially common subscription types. +function ExampleComponent(props: Props) { + const { + observedValue, + relayData, + scrollTop, + } = props; + + // The rendered output is not interesting. + // The interesting thing is the incoming props/values. +} + +function getDataFor(subscribable, propertyName) { + switch (propertyName) { + case 'fragmentResolver': + return subscribable.resolve(); + case 'observableStream': + // This only works for some observable types (e.g. BehaviorSubject) + // It's okay to just return null/undefined here for other types. + return subscribable.getValue(); + case 'scrollTarget': + return subscribable.scrollTop; + default: + throw Error(`Invalid subscribable, "${propertyName}", specified.`); + } +} + +function subscribeTo(valueChangedCallback, subscribable, propertyName) { + switch (propertyName) { + case 'fragmentResolver': + subscribable.setCallback( + () => valueChangedCallback(subscribable.resolve() + ); + break; + case 'observableStream': + // Return the subscription; it's necessary to unsubscribe. + return subscribable.subscribe(valueChangedCallback); + case 'scrollTarget': + const onScroll = () => valueChangedCallback(subscribable.scrollTop); + subscribable.addEventListener(onScroll); + return onScroll; + default: + throw Error(`Invalid subscribable, "${propertyName}", specified.`); + } +} + +function unsubscribeFrom(subscribable, propertyName, subscription) { + switch (propertyName) { + case 'fragmentResolver': + subscribable.dispose(); + break; + case 'observableStream': + // Unsubscribe using the subscription rather than the subscribable. + subscription.unsubscribe(); + case 'scrollTarget': + // In this case, 'subscription', is the event handler/function. + subscribable.removeEventListener(subscription); + break; + default: + throw Error(`Invalid subscribable, "${propertyName}", specified.`); + } +} + +// 3: This is the component you would export. +createSubscribable({ + subscribablePropertiesMap: { + fragmentResolver: 'relayData', + observableStream: 'observedValue', + scrollTarget: 'scrollTop', + }, + getDataFor, + subscribeTo, + unsubscribeFrom, +}, ExampleComponent); +``` \ No newline at end of file diff --git a/packages/create-component-with-subscriptions/index.js b/packages/create-component-with-subscriptions/index.js new file mode 100644 index 0000000000000..e0ee91920b554 --- /dev/null +++ b/packages/create-component-with-subscriptions/index.js @@ -0,0 +1,12 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * + * 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/createComponentWithSubscriptions'; diff --git a/packages/create-component-with-subscriptions/npm/index.js b/packages/create-component-with-subscriptions/npm/index.js new file mode 100644 index 0000000000000..7262038596185 --- /dev/null +++ b/packages/create-component-with-subscriptions/npm/index.js @@ -0,0 +1,7 @@ +'use strict'; + +if (process.env.NODE_ENV === 'production') { + module.exports = require('./cjs/create-component-with-subscriptions.production.min.js'); +} else { + module.exports = require('./cjs/create-component-with-subscriptions.development.js'); +} diff --git a/packages/create-component-with-subscriptions/package.json b/packages/create-component-with-subscriptions/package.json new file mode 100644 index 0000000000000..eeda6f59c5f8f --- /dev/null +++ b/packages/create-component-with-subscriptions/package.json @@ -0,0 +1,13 @@ +{ + "name": "create-component-with-subscriptions", + "description": "HOC for creating async-safe React components with subscriptions", + "version": "0.0.1", + "repository": "facebook/react", + "files": ["LICENSE", "README.md", "index.js", "cjs/"], + "dependencies": { + "fbjs": "^0.8.16" + }, + "peerDependencies": { + "react": "16.3.0-alpha.1" + } +} \ No newline at end of file diff --git a/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.js b/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.js new file mode 100644 index 0000000000000..aeb8511fcd55e --- /dev/null +++ b/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.js @@ -0,0 +1,171 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * + * 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 createComponent; +let React; +let ReactTestRenderer; + +describe('CreateComponentWithSubscriptions', () => { + beforeEach(() => { + jest.resetModules(); + createComponent = require('create-component-with-subscriptions') + .createComponent; + React = require('react'); + ReactTestRenderer = require('react-test-renderer'); + }); + + function createFauxObservable() { + let currentValue; + let subscribedCallback = null; + return { + getValue: () => currentValue, + subscribe: callback => { + expect(subscribedCallback).toBe(null); + subscribedCallback = callback; + return { + unsubscribe: () => { + expect(subscribedCallback).not.toBe(null); + subscribedCallback = null; + }, + }; + }, + update: value => { + currentValue = value; + if (typeof subscribedCallback === 'function') { + subscribedCallback(value); + } + }, + }; + } + + it('supports basic subscription pattern', () => { + const renderedValues = []; + + const Component = createComponent( + { + subscribablePropertiesMap: {observable: 'value'}, + getDataFor: (subscribable, propertyName) => { + expect(propertyName).toBe('observable'); + return observable.getValue(); + }, + subscribeTo: (valueChangedCallback, subscribable, propertyName) => { + expect(propertyName).toBe('observable'); + return subscribable.subscribe(valueChangedCallback); + }, + unsubscribeFrom: (subscribable, propertyName, subscription) => { + expect(propertyName).toBe('observable'); + subscription.unsubscribe(); + }, + }, + ({value}) => { + renderedValues.push(value); + return null; + }, + ); + + const observable = createFauxObservable(); + const render = ReactTestRenderer.create( + , + ); + + // Updates while subscribed should re-render the child component + expect(renderedValues).toEqual([undefined]); + renderedValues.length = 0; + observable.update(123); + expect(renderedValues).toEqual([123]); + renderedValues.length = 0; + observable.update('abc'); + expect(renderedValues).toEqual(['abc']); + + // Unsetting the subscriber prop should reset subscribed values + renderedValues.length = 0; + render.update(); + expect(renderedValues).toEqual([undefined]); + + // Updates while unsubscribed should not re-render the child component + renderedValues.length = 0; + observable.update(789); + expect(renderedValues).toEqual([]); + }); + + it('supports multiple subscriptions', () => { + const renderedValues = []; + + const Component = createComponent( + { + subscribablePropertiesMap: { + foo: 'foo', + bar: 'bar', + }, + getDataFor: (subscribable, propertyName) => { + switch (propertyName) { + case 'foo': + return foo.getValue(); + case 'bar': + return bar.getValue(); + default: + throw Error('Unexpected propertyName ' + propertyName); + } + }, + subscribeTo: (valueChangedCallback, subscribable, propertyName) => { + switch (propertyName) { + case 'foo': + return foo.subscribe(valueChangedCallback); + case 'bar': + return bar.subscribe(valueChangedCallback); + default: + throw Error('Unexpected propertyName ' + propertyName); + } + }, + unsubscribeFrom: (subscribable, propertyName, subscription) => { + switch (propertyName) { + case 'foo': + case 'bar': + subscription.unsubscribe(); + break; + default: + throw Error('Unexpected propertyName ' + propertyName); + } + }, + }, + ({foo, bar}) => { + renderedValues.push({foo, bar}); + return null; + }, + ); + + const foo = createFauxObservable(); + const bar = createFauxObservable(); + const render = ReactTestRenderer.create(); + + // Updates while subscribed should re-render the child component + expect(renderedValues).toEqual([{bar: undefined, foo: undefined}]); + renderedValues.length = 0; + foo.update(123); + expect(renderedValues).toEqual([{bar: undefined, foo: 123}]); + renderedValues.length = 0; + bar.update('abc'); + expect(renderedValues).toEqual([{bar: 'abc', foo: 123}]); + renderedValues.length = 0; + foo.update(456); + expect(renderedValues).toEqual([{bar: 'abc', foo: 456}]); + + // Unsetting the subscriber prop should reset subscribed values + renderedValues.length = 0; + render.update(); + expect(renderedValues).toEqual([{bar: undefined, foo: undefined}]); + + // Updates while unsubscribed should not re-render the child component + renderedValues.length = 0; + foo.update(789); + expect(renderedValues).toEqual([]); + }); +}); diff --git a/packages/create-component-with-subscriptions/src/createComponentWithSubscriptions.js b/packages/create-component-with-subscriptions/src/createComponentWithSubscriptions.js new file mode 100644 index 0000000000000..8a47d2731b51a --- /dev/null +++ b/packages/create-component-with-subscriptions/src/createComponentWithSubscriptions.js @@ -0,0 +1,188 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import React from 'react'; + +type SubscribableConfig = { + // Maps property names of subscribable data sources (e.g. 'someObservable'), + // To state names for subscribed values (e.g. 'someValue'). + subscribablePropertiesMap: {[subscribableProperty: string]: string}, + + // Synchronously get data for a given subscribable property. + // It is okay to return null if the subscribable does not support sync value reading. + getDataFor: (subscribable: any, propertyName: string) => any, + + // Subscribe to a given subscribable. + // Due to the variety of change event types, subscribers should provide their own handlers. + // Those handlers should NOT update state though; they should call the valueChangedCallback() instead. + subscribeTo: ( + valueChangedCallback: (value: any) => void, + subscribable: any, + propertyName: string, + ) => any, + + // Unsubscribe from a given subscribable. + // The optional subscription object returned by subscribeTo() is passed as a third parameter. + unsubscribeFrom: ( + subscribable: any, + propertyName: string, + subscription: any, + ) => void, +}; + +// TODO Decide how to handle missing subscribables. + +export function createComponent( + config: SubscribableConfig, + Component: React$ComponentType<*>, +): React$ComponentType<*> { + const { + getDataFor, + subscribablePropertiesMap, + subscribeTo, + unsubscribeFrom, + } = config; + + class SubscribableContainer extends React.Component { + state = {}; + + static getDerivedStateFromProps(nextProps, prevState) { + const nextState = {}; + + let hasUpdates = false; + + // Read value (if sync read is possible) for upcoming render + for (let propertyName in subscribablePropertiesMap) { + const prevSubscribable = prevState[propertyName]; + const nextSubscribable = nextProps[propertyName]; + + if (prevSubscribable !== nextSubscribable) { + nextState[propertyName] = { + ...prevState[propertyName], + subscribable: nextSubscribable, + value: + nextSubscribable != null + ? getDataFor(nextSubscribable, propertyName) + : undefined, + }; + + hasUpdates = true; + } + } + + return hasUpdates ? nextState : null; + } + + componentDidMount() { + for (let propertyName in subscribablePropertiesMap) { + const subscribable = this.props[propertyName]; + this.subscribeTo(subscribable, propertyName); + } + } + + componentDidUpdate(prevProps, prevState) { + for (let propertyName in subscribablePropertiesMap) { + const prevSubscribable = prevProps[propertyName]; + const nextSubscribable = this.props[propertyName]; + if (prevSubscribable !== nextSubscribable) { + this.unsubscribeFrom(prevSubscribable, propertyName); + this.subscribeTo(nextSubscribable, propertyName); + } + } + } + + componentWillUnmount() { + for (let propertyName in subscribablePropertiesMap) { + const subscribable = this.props[propertyName]; + this.unsubscribeFrom(subscribable, propertyName); + } + } + + // Event listeners are only safe to add during the commit phase, + // So they won't leak if render is interrupted or errors. + subscribeTo(subscribable, propertyName) { + if (subscribable != null) { + const wrapper = this.state[propertyName]; + + const valueChangedCallback = value => { + this.setState(state => { + const currentWrapper = state[propertyName]; + + // If this event belongs to the current data source, update state. + // Otherwise we should ignore it. + if (subscribable === currentWrapper.subscribable) { + return { + [propertyName]: { + ...currentWrapper, + value, + }, + }; + } + + return null; + }); + }; + + // Store subscription for later (in case it's needed to unsubscribe). + // This is safe to do via mutation since: + // 1) It does not impact render. + // 2) This method will only be called during the "commit" phase. + wrapper.subscription = subscribeTo( + valueChangedCallback, + subscribable, + propertyName, + ); + + // External values could change between render and mount, + // In some cases it may be important to handle this case. + const value = getDataFor(subscribable, propertyName); + if (value !== wrapper.value) { + this.setState({ + [propertyName]: { + ...wrapper, + value, + }, + }); + } + } + } + + unsubscribeFrom(subscribable, propertyName) { + if (subscribable != null) { + const wrapper = this.state[propertyName]; + + unsubscribeFrom(subscribable, propertyName, wrapper.subscription); + + wrapper.subscription = null; + } + } + + render() { + const filteredProps = {}; + const subscribedValues = {}; + + for (let key in this.props) { + if (!subscribablePropertiesMap.hasOwnProperty(key)) { + filteredProps[key] = this.props[key]; + } + } + + for (let fromProperty in subscribablePropertiesMap) { + const toProperty = subscribablePropertiesMap[fromProperty]; + const wrapper = this.state[fromProperty]; + subscribedValues[toProperty] = + wrapper != null ? wrapper.value : undefined; + } + + return ; + } + } + + return SubscribableContainer; +} From 7b1e8c28175019612844d48b048357111e6154d2 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Sun, 4 Mar 2018 21:39:21 -0500 Subject: [PATCH 02/53] Updated README --- .../README.md | 90 +++++++++---------- 1 file changed, 45 insertions(+), 45 deletions(-) diff --git a/packages/create-component-with-subscriptions/README.md b/packages/create-component-with-subscriptions/README.md index baaf70a916665..63065db9e5a4a 100644 --- a/packages/create-component-with-subscriptions/README.md +++ b/packages/create-component-with-subscriptions/README.md @@ -1,31 +1,28 @@ # create-component-with-subscriptions -Better docs coming soon... +Below is an example showing how the container can be used: ```js -// Here is an example of using the subscribable HOC. -// It shows a couple of potentially common subscription types. -function ExampleComponent(props: Props) { - const { - observedValue, - relayData, - scrollTop, - } = props; - - // The rendered output is not interesting. - // The interesting thing is the incoming props/values. +// This is an example functional component that subscribes to some values. +function ExampleComponent({ + examplePassThroughProperty, + friendsList, + userProfile +}) { + // The rendered output of this component is not very important. + // It just exists to show how the observed values are provided. + // Properties not related to subscriptions are passed through as-is, + // (e.g. examplePassThroughProperty). } +// In the below example, "friendsList" mimics an RxJS BehaviorSubject, +// and "userProfile" mimics an event dispatcher (like a DOM element). function getDataFor(subscribable, propertyName) { switch (propertyName) { - case 'fragmentResolver': - return subscribable.resolve(); - case 'observableStream': - // This only works for some observable types (e.g. BehaviorSubject) - // It's okay to just return null/undefined here for other types. + case "friendsListSubject": return subscribable.getValue(); - case 'scrollTarget': - return subscribable.scrollTop; + case "userProfile": + return subscribable.value; default: throw Error(`Invalid subscribable, "${propertyName}", specified.`); } @@ -33,18 +30,14 @@ function getDataFor(subscribable, propertyName) { function subscribeTo(valueChangedCallback, subscribable, propertyName) { switch (propertyName) { - case 'fragmentResolver': - subscribable.setCallback( - () => valueChangedCallback(subscribable.resolve() - ); - break; - case 'observableStream': - // Return the subscription; it's necessary to unsubscribe. + case "friendsListSubject": + // Return the subscription in this case; it's necessary to unsubscribe. return subscribable.subscribe(valueChangedCallback); - case 'scrollTarget': - const onScroll = () => valueChangedCallback(subscribable.scrollTop); - subscribable.addEventListener(onScroll); - return onScroll; + case "userProfile": + const onChange = () => valueChangedCallback(subscribable.value); + subscribable.addEventListener(onChange); + // Return the event handling callback, since it's required to unsubscribe. + return onChange; default: throw Error(`Invalid subscribable, "${propertyName}", specified.`); } @@ -52,13 +45,10 @@ function subscribeTo(valueChangedCallback, subscribable, propertyName) { function unsubscribeFrom(subscribable, propertyName, subscription) { switch (propertyName) { - case 'fragmentResolver': - subscribable.dispose(); - break; - case 'observableStream': + case "friendsListSubject": // Unsubscribe using the subscription rather than the subscribable. subscription.unsubscribe(); - case 'scrollTarget': + case "userProfile": // In this case, 'subscription', is the event handler/function. subscribable.removeEventListener(subscription); break; @@ -67,15 +57,25 @@ function unsubscribeFrom(subscribable, propertyName, subscription) { } } -// 3: This is the component you would export. -createSubscribable({ - subscribablePropertiesMap: { - fragmentResolver: 'relayData', - observableStream: 'observedValue', - scrollTarget: 'scrollTop', +// Map incoming subscriptions property names (e.g. friendsListSubject) +// to property names expected by our functional component (e.g. friendsList). +const subscribablePropertiesMap = { + friendsListSubject: "friendsList", + userProfile: "userProfile" +}; + +// Decorate our functional component with a subscriber component. +// This HOC will automatically manage subscriptions to the incoming props, +// and map them to subscribed values to be passed to the inner component. +// All other props will be passed through as-is. +export default createSubscribable( + { + getDataFor, + subscribablePropertiesMap, + subscribeTo, + unsubscribeFrom }, - getDataFor, - subscribeTo, - unsubscribeFrom, -}, ExampleComponent); + ExampleComponent +); + ``` \ No newline at end of file From f8743b3bd89a258b77caa35bdeaaf377f0c64ad5 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Sun, 4 Mar 2018 21:39:34 -0500 Subject: [PATCH 03/53] Added Rollup bundle --- scripts/rollup/bundles.js | 10 ++++++++++ scripts/rollup/results.json | 14 ++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index 0be2375a58b9a..02e103f433d7b 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -254,6 +254,16 @@ const bundles = [ global: 'SimpleCacheProvider', externals: ['react'], }, + + /******* Simple Cache Provider (experimental) *******/ + { + label: 'create-component-with-subscriptions', + bundleTypes: [NODE_DEV, NODE_PROD], + moduleType: ISOMORPHIC, + entry: 'create-component-with-subscriptions', + global: 'createComponentWithSubscriptions', + externals: ['react'], + }, ]; // Based on deep-freeze by substack (public domain) diff --git a/scripts/rollup/results.json b/scripts/rollup/results.json index 77a21c1b12d68..0d21980b3e391 100644 --- a/scripts/rollup/results.json +++ b/scripts/rollup/results.json @@ -440,6 +440,20 @@ "packageName": "simple-cache-provider", "size": 1313, "gzip": 665 + }, + { + "filename": "create-component-with-subscriptions.development.js", + "bundleType": "NODE_DEV", + "packageName": "create-component-with-subscriptions", + "size": 9931, + "gzip": 3067 + }, + { + "filename": "create-component-with-subscriptions.production.min.js", + "bundleType": "NODE_PROD", + "packageName": "create-component-with-subscriptions", + "size": 3783, + "gzip": 1637 } ] } \ No newline at end of file From 4304b55a15310e46dcdc413faacafbf01fda0ca6 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Mon, 5 Mar 2018 11:12:57 -0500 Subject: [PATCH 04/53] Expanded tests --- .../createComponentWithSubscriptions-test.js | 287 +++++++++++++++--- .../src/createComponentWithSubscriptions.js | 2 - 2 files changed, 237 insertions(+), 52 deletions(-) diff --git a/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.js b/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.js index aeb8511fcd55e..209b065142f67 100644 --- a/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.js +++ b/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.js @@ -11,7 +11,7 @@ let createComponent; let React; -let ReactTestRenderer; +let ReactNoop; describe('CreateComponentWithSubscriptions', () => { beforeEach(() => { @@ -19,11 +19,12 @@ describe('CreateComponentWithSubscriptions', () => { createComponent = require('create-component-with-subscriptions') .createComponent; React = require('react'); - ReactTestRenderer = require('react-test-renderer'); + ReactNoop = require('react-noop-renderer'); }); - function createFauxObservable() { - let currentValue; + // Mimics the interface of RxJS `BehaviorSubject` + function createFauxObservable(initialValue) { + let currentValue = initialValue; let subscribedCallback = null; return { getValue: () => currentValue, @@ -47,14 +48,12 @@ describe('CreateComponentWithSubscriptions', () => { } it('supports basic subscription pattern', () => { - const renderedValues = []; - - const Component = createComponent( + const Subscriber = createComponent( { subscribablePropertiesMap: {observable: 'value'}, getDataFor: (subscribable, propertyName) => { expect(propertyName).toBe('observable'); - return observable.getValue(); + return subscribable.getValue(); }, subscribeTo: (valueChangedCallback, subscribable, propertyName) => { expect(propertyName).toBe('observable'); @@ -66,40 +65,28 @@ describe('CreateComponentWithSubscriptions', () => { }, }, ({value}) => { - renderedValues.push(value); + ReactNoop.yield(value); return null; }, ); const observable = createFauxObservable(); - const render = ReactTestRenderer.create( - , - ); + ReactNoop.render(); // Updates while subscribed should re-render the child component - expect(renderedValues).toEqual([undefined]); - renderedValues.length = 0; + expect(ReactNoop.flush()).toEqual([undefined]); observable.update(123); - expect(renderedValues).toEqual([123]); - renderedValues.length = 0; + expect(ReactNoop.flush()).toEqual([123]); observable.update('abc'); - expect(renderedValues).toEqual(['abc']); + expect(ReactNoop.flush()).toEqual(['abc']); // Unsetting the subscriber prop should reset subscribed values - renderedValues.length = 0; - render.update(); - expect(renderedValues).toEqual([undefined]); - - // Updates while unsubscribed should not re-render the child component - renderedValues.length = 0; - observable.update(789); - expect(renderedValues).toEqual([]); + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([undefined]); }); it('supports multiple subscriptions', () => { - const renderedValues = []; - - const Component = createComponent( + const Subscriber = createComponent( { subscribablePropertiesMap: { foo: 'foo', @@ -108,9 +95,9 @@ describe('CreateComponentWithSubscriptions', () => { getDataFor: (subscribable, propertyName) => { switch (propertyName) { case 'foo': - return foo.getValue(); + return subscribable.getValue(); case 'bar': - return bar.getValue(); + return subscribable.getValue(); default: throw Error('Unexpected propertyName ' + propertyName); } @@ -118,9 +105,9 @@ describe('CreateComponentWithSubscriptions', () => { subscribeTo: (valueChangedCallback, subscribable, propertyName) => { switch (propertyName) { case 'foo': - return foo.subscribe(valueChangedCallback); + return subscribable.subscribe(valueChangedCallback); case 'bar': - return bar.subscribe(valueChangedCallback); + return subscribable.subscribe(valueChangedCallback); default: throw Error('Unexpected propertyName ' + propertyName); } @@ -136,36 +123,236 @@ describe('CreateComponentWithSubscriptions', () => { } }, }, - ({foo, bar}) => { - renderedValues.push({foo, bar}); + ({bar, foo}) => { + ReactNoop.yield(`bar:${bar}, foo:${foo}`); return null; }, ); const foo = createFauxObservable(); const bar = createFauxObservable(); - const render = ReactTestRenderer.create(); + + ReactNoop.render(); // Updates while subscribed should re-render the child component - expect(renderedValues).toEqual([{bar: undefined, foo: undefined}]); - renderedValues.length = 0; + expect(ReactNoop.flush()).toEqual([`bar:undefined, foo:undefined`]); foo.update(123); - expect(renderedValues).toEqual([{bar: undefined, foo: 123}]); - renderedValues.length = 0; + expect(ReactNoop.flush()).toEqual([`bar:undefined, foo:123`]); bar.update('abc'); - expect(renderedValues).toEqual([{bar: 'abc', foo: 123}]); - renderedValues.length = 0; + expect(ReactNoop.flush()).toEqual([`bar:abc, foo:123`]); foo.update(456); - expect(renderedValues).toEqual([{bar: 'abc', foo: 456}]); + expect(ReactNoop.flush()).toEqual([`bar:abc, foo:456`]); + + // Unsetting the subscriber prop should reset subscribed values + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([`bar:undefined, foo:undefined`]); + }); + + it('should unsubscribe from old subscribables and subscribe to new subscribables when props change', () => { + const Subscriber = createComponent( + { + subscribablePropertiesMap: {observable: 'value'}, + getDataFor: (subscribable, propertyName) => subscribable.getValue(), + subscribeTo: (valueChangedCallback, subscribable, propertyName) => + subscribable.subscribe(valueChangedCallback), + unsubscribeFrom: (subscribable, propertyName, subscription) => + subscription.unsubscribe(), + }, + ({value}) => { + ReactNoop.yield(value); + return null; + }, + ); + + const observableA = createFauxObservable('a-0'); + const observableB = createFauxObservable('b-0'); + + ReactNoop.render(); + + // Updates while subscribed should re-render the child component + expect(ReactNoop.flush()).toEqual(['a-0']); // Unsetting the subscriber prop should reset subscribed values - renderedValues.length = 0; - render.update(); - expect(renderedValues).toEqual([{bar: undefined, foo: undefined}]); - - // Updates while unsubscribed should not re-render the child component - renderedValues.length = 0; - foo.update(789); - expect(renderedValues).toEqual([]); + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['b-0']); + + // Updates to the old subscribable should not re-render the child component + observableA.update('a-1'); + expect(ReactNoop.flush()).toEqual([]); + + // Updates to the bew subscribable should re-render the child component + observableB.update('b-1'); + expect(ReactNoop.flush()).toEqual(['b-1']); + }); + + it('should ignore values emitted by a new subscribable until the commit phase', () => { + let parentInstance; + + function Child({value}) { + ReactNoop.yield('Child: ' + value); + return null; + } + + const Subscriber = createComponent( + { + subscribablePropertiesMap: {observable: 'value'}, + getDataFor: (subscribable, propertyName) => subscribable.getValue(), + subscribeTo: (valueChangedCallback, subscribable, propertyName) => + subscribable.subscribe(valueChangedCallback), + unsubscribeFrom: (subscribable, propertyName, subscription) => + subscription.unsubscribe(), + }, + ({value}) => { + ReactNoop.yield('Subscriber: ' + value); + return ; + }, + ); + + class Parent extends React.Component { + state = {}; + + static getDerivedStateFromProps(nextProps, prevState) { + if (nextProps.observable !== prevState.observable) { + return { + observable: nextProps.observable, + }; + } + + return null; + } + + render() { + parentInstance = this; + + return ; + } + } + + const observableA = createFauxObservable('a-0'); + const observableB = createFauxObservable('b-0'); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Subscriber: a-0', 'Child: a-0']); + + // Start React update, but don't finish + ReactNoop.render(); + ReactNoop.flushThrough(['Subscriber: b-0']); + + // Emit some updates from the uncommitted subscribable + observableB.update('b-1'); + observableB.update('b-2'); + observableB.update('b-3'); + + // Mimic a higher-priority interruption + parentInstance.setState({observable: observableA}); + + // 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(ReactNoop.flush()).toEqual([ + 'Child: b-0', + 'Subscriber: b-3', + 'Child: b-3', + 'Subscriber: a-0', + 'Child: a-0', + ]); + }); + + it('should not drop values emitted between updates', () => { + let parentInstance; + + function Child({value}) { + ReactNoop.yield('Child: ' + value); + return null; + } + + const Subscriber = createComponent( + { + subscribablePropertiesMap: {observable: 'value'}, + getDataFor: (subscribable, propertyName) => subscribable.getValue(), + subscribeTo: (valueChangedCallback, subscribable, propertyName) => + subscribable.subscribe(valueChangedCallback), + unsubscribeFrom: (subscribable, propertyName, subscription) => + subscription.unsubscribe(), + }, + ({value}) => { + ReactNoop.yield('Subscriber: ' + value); + return ; + }, + ); + + class Parent extends React.Component { + state = {}; + + static getDerivedStateFromProps(nextProps, prevState) { + if (nextProps.observable !== prevState.observable) { + return { + observable: nextProps.observable, + }; + } + + return null; + } + + render() { + parentInstance = this; + + return ; + } + } + + const observableA = createFauxObservable('a-0'); + const observableB = createFauxObservable('b-0'); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Subscriber: a-0', 'Child: a-0']); + + // Start React update, but don't finish + ReactNoop.render(); + ReactNoop.flushThrough(['Subscriber: b-0']); + + // Emit some updates from the old subscribable + observableA.update('a-1'); + observableA.update('a-2'); + + // Mimic a higher-priority interruption + parentInstance.setState({observable: observableA}); + + // 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(ReactNoop.flush()).toEqual([ + 'Child: b-0', + 'Subscriber: a-2', + 'Child: a-2', + ]); + + // Updates from the new subsribable should be ignored. + observableB.update('b-1'); + expect(ReactNoop.flush()).toEqual([]); + }); + + it('should pass all non-subscribable props through to the child component', () => { + const Subscriber = createComponent( + { + subscribablePropertiesMap: {observable: 'value'}, + getDataFor: (subscribable, propertyName) => subscribable.getValue(), + subscribeTo: (valueChangedCallback, subscribable, propertyName) => + subscribable.subscribe(valueChangedCallback), + unsubscribeFrom: (subscribable, propertyName, subscription) => + subscription.unsubscribe(), + }, + ({bar, foo, value}) => { + ReactNoop.yield(`bar:${bar}, foo:${foo}, value:${value}`); + return null; + }, + ); + + const observable = createFauxObservable(true); + ReactNoop.render( + , + ); + expect(ReactNoop.flush()).toEqual(['bar:abc, foo:123, value:true']); }); }); diff --git a/packages/create-component-with-subscriptions/src/createComponentWithSubscriptions.js b/packages/create-component-with-subscriptions/src/createComponentWithSubscriptions.js index 8a47d2731b51a..3fad28fc5263c 100644 --- a/packages/create-component-with-subscriptions/src/createComponentWithSubscriptions.js +++ b/packages/create-component-with-subscriptions/src/createComponentWithSubscriptions.js @@ -36,8 +36,6 @@ type SubscribableConfig = { ) => void, }; -// TODO Decide how to handle missing subscribables. - export function createComponent( config: SubscribableConfig, Component: React$ComponentType<*>, From 30eb16a837bde2ac8c3158241b6c68476e6b6e3e Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Mon, 5 Mar 2018 11:18:36 -0500 Subject: [PATCH 05/53] Updated bundle comment --- scripts/rollup/bundles.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index 02e103f433d7b..0547baf782954 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -255,7 +255,7 @@ const bundles = [ externals: ['react'], }, - /******* Simple Cache Provider (experimental) *******/ + /******* createComponentWithSubscriptions (experimental) *******/ { label: 'create-component-with-subscriptions', bundleTypes: [NODE_DEV, NODE_PROD], From eb1372c73dfd6b185226aaca5658084835e7021b Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Mon, 5 Mar 2018 12:11:19 -0500 Subject: [PATCH 06/53] Added a test for "cold" observable --- .../createComponentWithSubscriptions-test.js | 84 ++++++++++++++----- .../src/createComponentWithSubscriptions.js | 24 +++--- 2 files changed, 78 insertions(+), 30 deletions(-) diff --git a/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.js b/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.js index 209b065142f67..3e725ecda442e 100644 --- a/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.js +++ b/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.js @@ -22,31 +22,44 @@ describe('CreateComponentWithSubscriptions', () => { ReactNoop = require('react-noop-renderer'); }); - // Mimics the interface of RxJS `BehaviorSubject` - function createFauxObservable(initialValue) { + // Mimics a partial interface of RxJS `BehaviorSubject` + function createFauxBehaviorSubject(initialValue) { let currentValue = initialValue; - let subscribedCallback = null; + let subscribedCallbacks = []; return { getValue: () => currentValue, subscribe: callback => { - expect(subscribedCallback).toBe(null); - subscribedCallback = callback; + subscribedCallbacks.push(callback); return { unsubscribe: () => { - expect(subscribedCallback).not.toBe(null); - subscribedCallback = null; + subscribedCallbacks.splice( + subscribedCallbacks.indexOf(callback), + 1, + ); }, }; }, update: value => { currentValue = value; - if (typeof subscribedCallback === 'function') { - subscribedCallback(value); - } + subscribedCallbacks.forEach(subscribedCallback => + subscribedCallback(value), + ); }, }; } + // Mimics a partial interface of RxJS `ReplaySubject` + function createFauxReplaySubject(initialValue) { + const observable = createFauxBehaviorSubject(initialValue); + const {getValue, subscribe} = observable; + observable.getValue = undefined; + observable.subscribe = callback => { + callback(getValue()); + return subscribe(callback); + }; + return observable; + } + it('supports basic subscription pattern', () => { const Subscriber = createComponent( { @@ -70,7 +83,7 @@ describe('CreateComponentWithSubscriptions', () => { }, ); - const observable = createFauxObservable(); + const observable = createFauxBehaviorSubject(); ReactNoop.render(); // Updates while subscribed should re-render the child component @@ -129,8 +142,8 @@ describe('CreateComponentWithSubscriptions', () => { }, ); - const foo = createFauxObservable(); - const bar = createFauxObservable(); + const foo = createFauxBehaviorSubject(); + const bar = createFauxBehaviorSubject(); ReactNoop.render(); @@ -148,6 +161,37 @@ describe('CreateComponentWithSubscriptions', () => { expect(ReactNoop.flush()).toEqual([`bar:undefined, foo:undefined`]); }); + it('should support "cold" observable types like RxJS ReplaySubject', () => { + const Subscriber = createComponent( + { + subscribablePropertiesMap: {observable: 'value'}, + getDataFor: (subscribable, propertyName, subscription) => { + let currentValue; + const temporarySubscription = subscribable.subscribe(value => { + currentValue = value; + }); + temporarySubscription.unsubscribe(); + return currentValue; + }, + subscribeTo: (valueChangedCallback, subscribable, propertyName) => + subscribable.subscribe(valueChangedCallback), + unsubscribeFrom: (subscribable, propertyName, subscription) => + subscription.unsubscribe(), + }, + ({value}) => { + ReactNoop.yield(value); + return null; + }, + ); + + const observable = createFauxReplaySubject('initial'); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['initial']); + observable.update('updated'); + expect(ReactNoop.flush()).toEqual(['updated']); + }); + it('should unsubscribe from old subscribables and subscribe to new subscribables when props change', () => { const Subscriber = createComponent( { @@ -164,8 +208,8 @@ describe('CreateComponentWithSubscriptions', () => { }, ); - const observableA = createFauxObservable('a-0'); - const observableB = createFauxObservable('b-0'); + const observableA = createFauxBehaviorSubject('a-0'); + const observableB = createFauxBehaviorSubject('b-0'); ReactNoop.render(); @@ -228,8 +272,8 @@ describe('CreateComponentWithSubscriptions', () => { } } - const observableA = createFauxObservable('a-0'); - const observableB = createFauxObservable('b-0'); + const observableA = createFauxBehaviorSubject('a-0'); + const observableB = createFauxBehaviorSubject('b-0'); ReactNoop.render(); expect(ReactNoop.flush()).toEqual(['Subscriber: a-0', 'Child: a-0']); @@ -302,8 +346,8 @@ describe('CreateComponentWithSubscriptions', () => { } } - const observableA = createFauxObservable('a-0'); - const observableB = createFauxObservable('b-0'); + const observableA = createFauxBehaviorSubject('a-0'); + const observableB = createFauxBehaviorSubject('b-0'); ReactNoop.render(); expect(ReactNoop.flush()).toEqual(['Subscriber: a-0', 'Child: a-0']); @@ -349,7 +393,7 @@ describe('CreateComponentWithSubscriptions', () => { }, ); - const observable = createFauxObservable(true); + const observable = createFauxBehaviorSubject(true); ReactNoop.render( , ); diff --git a/packages/create-component-with-subscriptions/src/createComponentWithSubscriptions.js b/packages/create-component-with-subscriptions/src/createComponentWithSubscriptions.js index 3fad28fc5263c..8f506cfef60da 100644 --- a/packages/create-component-with-subscriptions/src/createComponentWithSubscriptions.js +++ b/packages/create-component-with-subscriptions/src/createComponentWithSubscriptions.js @@ -112,18 +112,22 @@ export function createComponent( this.setState(state => { const currentWrapper = state[propertyName]; - // If this event belongs to the current data source, update state. - // Otherwise we should ignore it. - if (subscribable === currentWrapper.subscribable) { - return { - [propertyName]: { - ...currentWrapper, - value, - }, - }; + // If the value is the same, skip the unnecessary state update. + if (currentWrapper.value === value) { + return null; } - return null; + // If this event belongs to an old or uncommitted data source, ignore it. + if (subscribable !== currentWrapper.subscribable) { + return null; + } + + return { + [propertyName]: { + ...currentWrapper, + value, + }, + }; }); }; From 88e7e226da2d229733b7c6f0f38cb115f2ece149 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Mon, 5 Mar 2018 17:20:17 -0500 Subject: [PATCH 07/53] Updated inline comments --- .../src/createComponentWithSubscriptions.js | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/create-component-with-subscriptions/src/createComponentWithSubscriptions.js b/packages/create-component-with-subscriptions/src/createComponentWithSubscriptions.js index 8f506cfef60da..07fee665fcec5 100644 --- a/packages/create-component-with-subscriptions/src/createComponentWithSubscriptions.js +++ b/packages/create-component-with-subscriptions/src/createComponentWithSubscriptions.js @@ -9,18 +9,22 @@ import React from 'react'; -type SubscribableConfig = { - // Maps property names of subscribable data sources (e.g. 'someObservable'), - // To state names for subscribed values (e.g. 'someValue'). +type SubscrptionConfig = { + // Maps property names of subscribable sources (e.g. 'eventDispatcher'), + // To property names for subscribed values (e.g. 'value'). subscribablePropertiesMap: {[subscribableProperty: string]: string}, // Synchronously get data for a given subscribable property. - // It is okay to return null if the subscribable does not support sync value reading. + // If your component has multiple subscriptions, + // The second 'propertyName' parameter can be used to distinguish between them. getDataFor: (subscribable: any, propertyName: string) => any, - // Subscribe to a given subscribable. + // Subscribe to a subscribable. // Due to the variety of change event types, subscribers should provide their own handlers. - // Those handlers should NOT update state though; they should call the valueChangedCallback() instead. + // Those handlers should NOT update state though; + // They should call the valueChangedCallback() instead when a subscription changes. + // If your component has multiple subscriptions, + // The third 'propertyName' parameter can be used to distinguish between them. subscribeTo: ( valueChangedCallback: (value: any) => void, subscribable: any, @@ -28,7 +32,9 @@ type SubscribableConfig = { ) => any, // Unsubscribe from a given subscribable. - // The optional subscription object returned by subscribeTo() is passed as a third parameter. + // If your component has multiple subscriptions, + // The second 'propertyName' parameter can be used to distinguish between them. + // The value returned by subscribeTo() is the third 'subscription' parameter. unsubscribeFrom: ( subscribable: any, propertyName: string, @@ -37,7 +43,7 @@ type SubscribableConfig = { }; export function createComponent( - config: SubscribableConfig, + config: SubscrptionConfig, Component: React$ComponentType<*>, ): React$ComponentType<*> { const { From c3950516b44854db9b4f15250bfebcfc2bc1e136 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Mon, 5 Mar 2018 18:12:23 -0500 Subject: [PATCH 08/53] Updated README examples --- .../README.md | 173 +++++++++++------- 1 file changed, 109 insertions(+), 64 deletions(-) diff --git a/packages/create-component-with-subscriptions/README.md b/packages/create-component-with-subscriptions/README.md index 63065db9e5a4a..7ecd1b5cc6c0a 100644 --- a/packages/create-component-with-subscriptions/README.md +++ b/packages/create-component-with-subscriptions/README.md @@ -1,81 +1,126 @@ # create-component-with-subscriptions -Below is an example showing how the container can be used: +[Async-safe subscriptions are hard to get right.](https://gist.github.com/bvaughn/d569177d70b50b58bff69c3c4a5353f3) + +This complexity is acceptible for libraries like Redux/Relay/MobX, but it's not ideal to have mixed in with application code. `create-component-with-subscriptions` provides an interface to easily manage subscriptions in an async-safe way. + +## Installation + +```sh +# Yarn +yarn add create-component-with-subscriptions + +# NPM +npm install create-component-with-subscriptions --save +``` + +# API + +Creating a subscription component requires a configuration object and a React component. The configuration object must have four properties: +* **subscribablePropertiesMap** `{[subscribableProperty: string]: string}` - Maps property names of incoming subscribable sources (e.g. "eventDispatcher") to property names for their values (e.g. "value"). +* **getDataFor** `(subscribable: any, propertyName: string) => any` - Synchronously returns the value of the specified subscribable property. If your component has multiple subscriptions,the second 'propertyName' parameter can be used to distinguish between them. +* **subscribeTo** `( + valueChangedCallback: (value: any) => void, + subscribable: any, + propertyName: string, + ) => any` - Subscribes to the specified subscribable and call the `valueChangedCallback` parameter whenever a subscription changes. If your component has multiple subscriptions, the third 'propertyName' parameter can be used to distinguish between them. +* **unsubscribeFrom** `( + subscribable: any, + propertyName: string, + subscription: any, + ) => void` - Unsubscribes from the specified subscribable. If your component has multiple subscriptions, the second `propertyName` parameter can be used to distinguish between them. The value returned by `subscribeTo()` is the third `subscription` parameter. + +# Examples + +## Subscribing to event dispatchers + +Below is an example showing how `create-component-with-subscriptions` can be used to subscribe to event dispatchers such as DOM elements or Flux stores. ```js -// This is an example functional component that subscribes to some values. -function ExampleComponent({ - examplePassThroughProperty, - friendsList, - userProfile -}) { - // The rendered output of this component is not very important. - // It just exists to show how the observed values are provided. - // Properties not related to subscriptions are passed through as-is, - // (e.g. examplePassThroughProperty). -} +import React from "react"; +import createComponent from "create-component-with-subscriptions"; -// In the below example, "friendsList" mimics an RxJS BehaviorSubject, -// and "userProfile" mimics an event dispatcher (like a DOM element). -function getDataFor(subscribable, propertyName) { - switch (propertyName) { - case "friendsListSubject": - return subscribable.getValue(); - case "userProfile": - return subscribable.value; - default: - throw Error(`Invalid subscribable, "${propertyName}", specified.`); - } +// Start with a simple functional (or class-based) component. +function InnerComponent({ followerCount, username }) { + return ( +
+ {username} has {followerCount} follower +
+ ); } -function subscribeTo(valueChangedCallback, subscribable, propertyName) { - switch (propertyName) { - case "friendsListSubject": - // Return the subscription in this case; it's necessary to unsubscribe. - return subscribable.subscribe(valueChangedCallback); - case "userProfile": - const onChange = () => valueChangedCallback(subscribable.value); +// Wrap the functional component with a subscriber HOC. +// This HOC will manage subscriptions and pass values to the decorated component. +// It will add and remove subscriptions in an async-safe way when props change. +const FollowerCountComponent = createComponent( + { + subscribablePropertiesMap: { followerStore: "followerCount" }, + getDataFor: (subscribable, propertyName) => subscribable.value, + subscribeTo: (valueChangedCallback, subscribable, propertyName) => { + const onChange = event => valueChangedCallback(subscribable.value); subscribable.addEventListener(onChange); - // Return the event handling callback, since it's required to unsubscribe. return onChange; - default: - throw Error(`Invalid subscribable, "${propertyName}", specified.`); - } -} - -function unsubscribeFrom(subscribable, propertyName, subscription) { - switch (propertyName) { - case "friendsListSubject": - // Unsubscribe using the subscription rather than the subscribable. - subscription.unsubscribe(); - case "userProfile": - // In this case, 'subscription', is the event handler/function. + }, + unsubscribeFrom: (subscribable, propertyName, subscription) => { + // `subscription` is the value returned from subscribeTo, our event handler. subscribable.removeEventListener(subscription); - break; - default: - throw Error(`Invalid subscribable, "${propertyName}", specified.`); - } + } + }, + InnerComponent +); + +// Your component can now be used as shown below. +// (In this example, `followerStore` represents a generic event dispatcher.) +; +``` + +## Subscribing to observables + +Below is an example showing how `create-component-with-subscriptions` 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. + +```js +import React from "react"; +import createComponent from "create-component-with-subscriptions"; + +function InnerComponent({ behaviorValue, replayValue }) { + // Render ... } -// Map incoming subscriptions property names (e.g. friendsListSubject) -// to property names expected by our functional component (e.g. friendsList). -const subscribablePropertiesMap = { - friendsListSubject: "friendsList", - userProfile: "userProfile" -}; - -// Decorate our functional component with a subscriber component. -// This HOC will automatically manage subscriptions to the incoming props, -// and map them to subscribed values to be passed to the inner component. -// All other props will be passed through as-is. -export default createSubscribable( +const SubscribedComponent = createComponent( { - getDataFor, - subscribablePropertiesMap, - subscribeTo, - unsubscribeFrom + subscribablePropertiesMap: { + behaviorSubject: "behaviorValue", + replaySubject: "replayValue" + }, + getDataFor: (subscribable, propertyName) => { + switch (propertyName) { + case "behaviorSubject": + return subscribable.getValue(); + case "replaySubject": + let currentValue; + // ReplaySubject does not have a sync data getter, + // So we need to temporarily subscribe to retrieve the most recent value. + const temporarySubscription = subscribable.subscribe(value => { + currentValue = value; + }); + temporarySubscription.unsubscribe(); + return currentValue; + } + }, + subscribeTo: (valueChangedCallback, subscribable, propertyName) => + subscribable.subscribe(valueChangedCallback), + unsubscribeFrom: (subscribable, propertyName, subscription) => + subscription.unsubscribe() }, - ExampleComponent + InnerComponent ); +// Your component can now be used as shown below. +// In this example, both properties below represent RxJS types with the same name. +; ``` \ No newline at end of file From 78c9a4c774ab13d2a9db27d831aa88940929bdc9 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Mon, 5 Mar 2018 18:30:22 -0500 Subject: [PATCH 09/53] Added a test (and README docs) for Promises --- .../README.md | 52 +++++++++++++++++- .../createComponentWithSubscriptions-test.js | 54 ++++++++++++++++++- 2 files changed, 104 insertions(+), 2 deletions(-) diff --git a/packages/create-component-with-subscriptions/README.md b/packages/create-component-with-subscriptions/README.md index 7ecd1b5cc6c0a..414c5bc7e22ef 100644 --- a/packages/create-component-with-subscriptions/README.md +++ b/packages/create-component-with-subscriptions/README.md @@ -70,7 +70,7 @@ const FollowerCountComponent = createComponent( ); // Your component can now be used as shown below. -// (In this example, `followerStore` represents a generic event dispatcher.) +// In this example, `followerStore` represents a generic event dispatcher. ; ``` @@ -123,4 +123,54 @@ const SubscribedComponent = createComponent( behaviorSubject={behaviorSubject} replaySubject={replaySubject} />; +``` + +## Subscribing to a Promise + +Below is an example showing how `create-component-with-subscriptions` can be used with native Promises. + +**Note** that it an initial render value of `undefined` is unavoidable due to the fact that Promises provide no way to synchronously read their current value. + +```js +import React from "react"; +import createComponent from "create-component-with-subscriptions"; + +// Start with a simple functional (or class-based) component. +function InnerComponent({ followerCount, username }) { + return ( +
+ {username} has {followerCount} follower +
+ ); +} + +// Wrap the functional component with a subscriber HOC. +// This HOC will manage subscriptions and pass values to the decorated component. +// It will add and remove subscriptions in an async-safe way when props change. +const FollowerCountComponent = createComponent( + { + subscribablePropertiesMap: { followerPromise: "followerCount" }, + getDataFor: (subscribable, propertyName, subscription) => undefined, + subscribeTo: (valueChangedCallback, subscribable, propertyName) => { + let subscribed = true; + subscribable.then(value => { + if (subscribed) { + valueChangedCallback(value); + } + }); + return { + unsubscribe() { + subscribed = false; + } + }; + }, + unsubscribeFrom: (subscribable, propertyName, subscription) => + subscription.unsubscribe() + }, + InnerComponent +); + +// Your component can now be used as shown below. +// In this example, `followerPromise` represents a native JavaScript Promise. +; ``` \ No newline at end of file diff --git a/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.js b/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.js index 3e725ecda442e..679854799489b 100644 --- a/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.js +++ b/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.js @@ -161,7 +161,7 @@ describe('CreateComponentWithSubscriptions', () => { expect(ReactNoop.flush()).toEqual([`bar:undefined, foo:undefined`]); }); - it('should support "cold" observable types like RxJS ReplaySubject', () => { + it('should support observable types like RxJS ReplaySubject', () => { const Subscriber = createComponent( { subscribablePropertiesMap: {observable: 'value'}, @@ -190,6 +190,58 @@ describe('CreateComponentWithSubscriptions', () => { expect(ReactNoop.flush()).toEqual(['initial']); observable.update('updated'); expect(ReactNoop.flush()).toEqual(['updated']); + + // Unsetting the subscriber prop should reset subscribed values + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([undefined]); + }); + + it('should support Promises', async () => { + const Subscriber = createComponent( + { + subscribablePropertiesMap: {promise: 'value'}, + getDataFor: (subscribable, propertyName, subscription) => undefined, + subscribeTo: (valueChangedCallback, subscribable, propertyName) => { + let subscribed = true; + subscribable.then(value => { + if (subscribed) { + valueChangedCallback(value); + } + }); + return { + unsubscribe() { + subscribed = false; + }, + }; + }, + unsubscribeFrom: (subscribable, propertyName, subscription) => + subscription.unsubscribe(), + }, + ({value}) => { + ReactNoop.yield(value); + return null; + }, + ); + + let resolveA, resolveB; + const promiseA = new Promise(r => (resolveA = r)); + const promiseB = new Promise(r => (resolveB = r)); + + // Test a promise that resolves after render + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([undefined]); + resolveA('abc'); + await promiseA; + expect(ReactNoop.flush()).toEqual(['abc']); + + // Test a promise that resolves before render + // Note that this will require an extra render anyway, + // Because there is no way to syncrhonously get a Promise's value + resolveB(123); + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([undefined]); + await promiseB; + expect(ReactNoop.flush()).toEqual([123]); }); it('should unsubscribe from old subscribables and subscribe to new subscribables when props change', () => { From d1fc6e896986b58da93f9d54f94c22cad481e341 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Mon, 5 Mar 2018 18:39:46 -0500 Subject: [PATCH 10/53] Added a caveat about Promises to README --- packages/create-component-with-subscriptions/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/create-component-with-subscriptions/README.md b/packages/create-component-with-subscriptions/README.md index 414c5bc7e22ef..2e035349fd03c 100644 --- a/packages/create-component-with-subscriptions/README.md +++ b/packages/create-component-with-subscriptions/README.md @@ -32,6 +32,8 @@ Creating a subscription component requires a configuration object and a React co # Examples +This API can be used to subscribe to a variety of "subscribable" sources, from Flux stores to RxJS observables. Below are a few examples of how to subscribe to common types. + ## Subscribing to event dispatchers Below is an example showing how `create-component-with-subscriptions` can be used to subscribe to event dispatchers such as DOM elements or Flux stores. @@ -131,6 +133,8 @@ Below is an example showing how `create-component-with-subscriptions` can be use **Note** that it an initial render value of `undefined` is unavoidable due to the fact that Promises provide no way to synchronously read their current value. +**Note** the lack of a way to "unsubscribe" from a Promise can result in memory leaks as long as something has a reference to the Promise. This should be taken into considerationg when determining whether Promises are appropriate to use in this way within your application. + ```js import React from "react"; import createComponent from "create-component-with-subscriptions"; From 2e98ca965d45bb56703551ab3ef1653f46731cbb Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Mon, 5 Mar 2018 21:31:57 -0500 Subject: [PATCH 11/53] Use a HOC for functional components and a mixin for ES6 components --- ...mponentWithSubscriptions-test.internal.js} | 210 +++++++++++++- .../src/createComponentWithSubscriptions.js | 258 +++++++++++------- 2 files changed, 366 insertions(+), 102 deletions(-) rename packages/create-component-with-subscriptions/src/__tests__/{createComponentWithSubscriptions-test.js => createComponentWithSubscriptions-test.internal.js} (68%) diff --git a/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.js b/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.internal.js similarity index 68% rename from packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.js rename to packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.internal.js index 679854799489b..cbb64994ce149 100644 --- a/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.js +++ b/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.internal.js @@ -11,6 +11,7 @@ let createComponent; let React; +let ReactFeatureFlags; let ReactNoop; describe('CreateComponentWithSubscriptions', () => { @@ -18,6 +19,8 @@ describe('CreateComponentWithSubscriptions', () => { jest.resetModules(); createComponent = require('create-component-with-subscriptions') .createComponent; + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false; React = require('react'); ReactNoop = require('react-noop-renderer'); }); @@ -93,9 +96,10 @@ describe('CreateComponentWithSubscriptions', () => { observable.update('abc'); expect(ReactNoop.flush()).toEqual(['abc']); - // Unsetting the subscriber prop should reset subscribed values - ReactNoop.render(); - expect(ReactNoop.flush()).toEqual([undefined]); + // Unmounting the subscriber should remove listeners + ReactNoop.render(
); + observable.update(456); + expect(ReactNoop.flush()).toEqual([]); }); it('supports multiple subscriptions', () => { @@ -451,4 +455,204 @@ describe('CreateComponentWithSubscriptions', () => { ); expect(ReactNoop.flush()).toEqual(['bar:abc, foo:123, value:true']); }); + + describe('class component', () => { + it('should support class components', () => { + const Subscriber = createComponent( + { + subscribablePropertiesMap: {observable: 'value'}, + getDataFor: (subscribable, propertyName) => subscribable.getValue(), + subscribeTo: (valueChangedCallback, subscribable, propertyName) => + subscribable.subscribe(valueChangedCallback), + unsubscribeFrom: (subscribable, propertyName, subscription) => + subscription.unsubscribe(), + }, + class extends React.Component { + state = {}; + render() { + ReactNoop.yield(this.state.value); + return null; + } + }, + ); + + const observable = createFauxBehaviorSubject('initial'); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['initial']); + observable.update('updated'); + expect(ReactNoop.flush()).toEqual(['updated']); + + // Unsetting the subscriber prop should reset subscribed values + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([undefined]); + }); + + it('should class mixed-in class component lifecycles', () => { + const lifecycles = []; + const Subscriber = createComponent( + { + subscribablePropertiesMap: {observable: 'value'}, + getDataFor: (subscribable, propertyName) => subscribable.getValue(), + subscribeTo: (valueChangedCallback, subscribable, propertyName) => + subscribable.subscribe(valueChangedCallback), + unsubscribeFrom: (subscribable, propertyName, subscription) => + subscription.unsubscribe(), + }, + class extends React.Component { + state = { + foo: 1, + }; + constructor(props) { + super(props); + lifecycles.push('constructor'); + } + static getDerivedStateFromProps(nextProps, prevState) { + lifecycles.push('getDerivedStateFromProps'); + return { + foo: prevState.foo + 1, + }; + } + componentDidMount() { + lifecycles.push('componentDidMount'); + } + componentDidUpdate(prevProps, prevState) { + lifecycles.push('componentDidUpdate'); + } + componentWillUnmount() { + lifecycles.push('componentWillUnmount'); + } + render() { + ReactNoop.yield({foo: this.state.foo, value: this.state.value}); + return null; + } + }, + ); + + const observable = createFauxBehaviorSubject('initial'); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([{foo: 2, value: 'initial'}]); + expect(lifecycles).toEqual([ + 'constructor', + 'getDerivedStateFromProps', + 'componentDidMount', + ]); + lifecycles.length = 0; + observable.update('updated'); + expect(ReactNoop.flush()).toEqual([{foo: 2, value: 'updated'}]); + expect(lifecycles).toEqual(['componentDidUpdate']); + + // Unsetting the subscriber prop should reset subscribed values + lifecycles.length = 0; + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([{foo: 3, value: undefined}]); + expect(lifecycles).toEqual([ + 'getDerivedStateFromProps', + 'componentDidUpdate', + ]); + + // Test unmounting lifecycle as well + lifecycles.length = 0; + ReactNoop.render(
); + expect(ReactNoop.flush()).toEqual([]); + expect(lifecycles).toEqual(['componentWillUnmount']); + }); + + it('should not mask the displayName used for errors and DevTools', () => { + const Subscriber = createComponent( + { + subscribablePropertiesMap: {observable: 'value'}, + getDataFor: (subscribable, propertyName) => subscribable.getValue(), + subscribeTo: (valueChangedCallback, subscribable, propertyName) => + subscribable.subscribe(valueChangedCallback), + unsubscribeFrom: (subscribable, propertyName, subscription) => + subscription.unsubscribe(), + }, + class MyExampleComponent extends React.Component { + static displayName = 'MyExampleComponent'; + state = {}; + render() { + return null; + } + }, + ); + + expect(Subscriber.displayName).toBe('MyExampleComponent'); + }); + }); + + // TODO Test create-class component (for FluxSubscriberMixin use case) + + // TODO Test with react-lifecycle-polyfill to make sure gDSFP isn't broken by mixin approach + + describe('invariants', () => { + it('should error for invalid Component', () => { + expect(() => { + createComponent( + { + subscribablePropertiesMap: {}, + getDataFor: () => {}, + subscribeTo: () => {}, + unsubscribeFrom: () => {}, + }, + null, + ); + }).toThrow('Invalid subscribable Component specified'); + }); + + it('should error for invalid missing subscribablePropertiesMap', () => { + expect(() => { + createComponent( + { + getDataFor: () => {}, + subscribeTo: () => {}, + unsubscribeFrom: () => {}, + }, + () => null, + ); + }).toThrow( + 'Subscribable config must specify a subscribablePropertiesMap map', + ); + }); + + it('should error for invalid missing getDataFor', () => { + expect(() => { + createComponent( + { + subscribablePropertiesMap: {}, + subscribeTo: () => {}, + unsubscribeFrom: () => {}, + }, + () => null, + ); + }).toThrow('Subscribable config must specify a getDataFor function'); + }); + + it('should error for invalid missing subscribeTo', () => { + expect(() => { + createComponent( + { + subscribablePropertiesMap: {}, + getDataFor: () => {}, + unsubscribeFrom: () => {}, + }, + () => null, + ); + }).toThrow('Subscribable config must specify a subscribeTo function'); + }); + + it('should error for invalid missing unsubscribeFrom', () => { + expect(() => { + createComponent( + { + subscribablePropertiesMap: {}, + getDataFor: () => {}, + subscribeTo: () => {}, + }, + () => null, + ); + }).toThrow('Subscribable config must specify a unsubscribeFrom function'); + }); + }); }); diff --git a/packages/create-component-with-subscriptions/src/createComponentWithSubscriptions.js b/packages/create-component-with-subscriptions/src/createComponentWithSubscriptions.js index 07fee665fcec5..26889d3cfd264 100644 --- a/packages/create-component-with-subscriptions/src/createComponentWithSubscriptions.js +++ b/packages/create-component-with-subscriptions/src/createComponentWithSubscriptions.js @@ -8,10 +8,11 @@ */ import React from 'react'; +import invariant from 'fbjs/lib/invariant'; type SubscrptionConfig = { // Maps property names of subscribable sources (e.g. 'eventDispatcher'), - // To property names for subscribed values (e.g. 'value'). + // To state names for subscribed values (e.g. 'value'). subscribablePropertiesMap: {[subscribableProperty: string]: string}, // Synchronously get data for a given subscribable property. @@ -46,6 +47,25 @@ export function createComponent( config: SubscrptionConfig, Component: React$ComponentType<*>, ): React$ComponentType<*> { + invariant(Component != null, 'Invalid subscribable Component specified'); + invariant( + config.subscribablePropertiesMap !== null && + typeof config.subscribablePropertiesMap === 'object', + 'Subscribable config must specify a subscribablePropertiesMap map', + ); + invariant( + typeof config.getDataFor === 'function', + 'Subscribable config must specify a getDataFor function', + ); + invariant( + typeof config.subscribeTo === 'function', + 'Subscribable config must specify a subscribeTo function', + ); + invariant( + typeof config.unsubscribeFrom === 'function', + 'Subscribable config must specify a unsubscribeFrom function', + ); + const { getDataFor, subscribablePropertiesMap, @@ -53,8 +73,106 @@ export function createComponent( unsubscribeFrom, } = config; - class SubscribableContainer extends React.Component { - state = {}; + // Unique state key name to avoid conflicts. + const getStateWrapperKey = propertyName => `____${propertyName}`; + + // If possible, extend the specified component to add subscriptions. + // This preserves ref compatibility and avoids the overhead of an extra fiber. + let BaseClass = Component; + + // If this is a functional component, use a HOC. + // Since functional components can't have refs, that isn't a problem. + // Class component lifecycles are required, so a class component is needed anyway. + if (typeof Component.prototype.render !== 'function') { + BaseClass = class extends React.Component { + render() { + const subscribedValues = {}; + for (let propertyName in subscribablePropertiesMap) { + const stateValueKey = subscribablePropertiesMap[propertyName]; + subscribedValues[stateValueKey] = this.state[stateValueKey]; + } + + return ; + } + }; + } + + // Event listeners are only safe to add during the commit phase, + // So they won't leak if render is interrupted or errors. + const subscribeToHelper = (subscribable, propertyName, instance) => { + if (subscribable != null) { + const stateWrapperKey = getStateWrapperKey(propertyName); + const stateValueKey = subscribablePropertiesMap[propertyName]; + + const wrapper = instance.state[stateWrapperKey]; + + const valueChangedCallback = value => { + instance.setState(state => { + // If the value is the same, skip the unnecessary state update. + if (state[stateValueKey] === value) { + return null; + } + + const currentSubscribable = + instance.state[stateWrapperKey] !== undefined + ? instance.state[stateWrapperKey].subscribable + : null; + + // If this event belongs to an old or uncommitted data source, ignore it. + if (wrapper.subscribable !== currentSubscribable) { + return null; + } + + return { + [stateValueKey]: value, + }; + }); + }; + + // Store subscription for later (in case it's needed to unsubscribe). + // This is safe to do via mutation since: + // 1) It does not impact render. + // 2) This method will only be called during the "commit" phase. + wrapper.subscription = subscribeTo( + valueChangedCallback, + subscribable, + propertyName, + ); + + // External values could change between render and mount, + // In some cases it may be important to handle this case. + const value = getDataFor(subscribable, propertyName); + if (value !== instance.state[stateValueKey]) { + instance.setState({ + [stateValueKey]: value, + }); + } + } + }; + + const unsubscribeFromHelper = (subscribable, propertyName, instance) => { + if (subscribable != null) { + const stateWrapperKey = getStateWrapperKey(propertyName); + const wrapper = instance.state[stateWrapperKey]; + + unsubscribeFrom(subscribable, propertyName, wrapper.subscription); + + wrapper.subscription = null; + } + }; + + // Extend specified component class to hook into subscriptions. + class Subscribable extends BaseClass { + constructor(props) { + super(props); + + // Ensure state is initialized, so getDerivedStateFromProps doesn't warn. + // Parent class components might not use state outside of this helper, + // So it might be confusing for them to have to initialize it. + if (this.state == null) { + this.state = {}; + } + } static getDerivedStateFromProps(nextProps, prevState) { const nextState = {}; @@ -63,134 +181,76 @@ export function createComponent( // Read value (if sync read is possible) for upcoming render for (let propertyName in subscribablePropertiesMap) { - const prevSubscribable = prevState[propertyName]; + const stateWrapperKey = getStateWrapperKey(propertyName); + const stateValueKey = subscribablePropertiesMap[propertyName]; + + const prevSubscribable = + prevState[stateWrapperKey] !== undefined + ? prevState[stateWrapperKey].subscribable + : null; const nextSubscribable = nextProps[propertyName]; if (prevSubscribable !== nextSubscribable) { - nextState[propertyName] = { - ...prevState[propertyName], + nextState[stateWrapperKey] = { + ...prevState[stateWrapperKey], subscribable: nextSubscribable, - value: - nextSubscribable != null - ? getDataFor(nextSubscribable, propertyName) - : undefined, }; + nextState[stateValueKey] = + nextSubscribable != null + ? getDataFor(nextSubscribable, propertyName) + : undefined; hasUpdates = true; } } - return hasUpdates ? nextState : null; + const nextSuperState = + typeof Component.getDerivedStateFromProps === 'function' + ? Component.getDerivedStateFromProps(nextProps, prevState) + : null; + + return hasUpdates || nextSuperState !== null + ? {...nextSuperState, ...nextState} + : null; } componentDidMount() { + if (typeof super.componentDidMount === 'function') { + super.componentDidMount(); + } + for (let propertyName in subscribablePropertiesMap) { const subscribable = this.props[propertyName]; - this.subscribeTo(subscribable, propertyName); + subscribeToHelper(subscribable, propertyName, this); } } componentDidUpdate(prevProps, prevState) { + if (typeof super.componentDidUpdate === 'function') { + super.componentDidUpdate(prevProps, prevState); + } + for (let propertyName in subscribablePropertiesMap) { const prevSubscribable = prevProps[propertyName]; const nextSubscribable = this.props[propertyName]; if (prevSubscribable !== nextSubscribable) { - this.unsubscribeFrom(prevSubscribable, propertyName); - this.subscribeTo(nextSubscribable, propertyName); + unsubscribeFromHelper(prevSubscribable, propertyName, this); + subscribeToHelper(nextSubscribable, propertyName, this); } } } componentWillUnmount() { - for (let propertyName in subscribablePropertiesMap) { - const subscribable = this.props[propertyName]; - this.unsubscribeFrom(subscribable, propertyName); - } - } - - // Event listeners are only safe to add during the commit phase, - // So they won't leak if render is interrupted or errors. - subscribeTo(subscribable, propertyName) { - if (subscribable != null) { - const wrapper = this.state[propertyName]; - - const valueChangedCallback = value => { - this.setState(state => { - const currentWrapper = state[propertyName]; - - // If the value is the same, skip the unnecessary state update. - if (currentWrapper.value === value) { - return null; - } - - // If this event belongs to an old or uncommitted data source, ignore it. - if (subscribable !== currentWrapper.subscribable) { - return null; - } - - return { - [propertyName]: { - ...currentWrapper, - value, - }, - }; - }); - }; - - // Store subscription for later (in case it's needed to unsubscribe). - // This is safe to do via mutation since: - // 1) It does not impact render. - // 2) This method will only be called during the "commit" phase. - wrapper.subscription = subscribeTo( - valueChangedCallback, - subscribable, - propertyName, - ); - - // External values could change between render and mount, - // In some cases it may be important to handle this case. - const value = getDataFor(subscribable, propertyName); - if (value !== wrapper.value) { - this.setState({ - [propertyName]: { - ...wrapper, - value, - }, - }); - } + if (typeof super.componentWillUnmount === 'function') { + super.componentWillUnmount(); } - } - unsubscribeFrom(subscribable, propertyName) { - if (subscribable != null) { - const wrapper = this.state[propertyName]; - - unsubscribeFrom(subscribable, propertyName, wrapper.subscription); - - wrapper.subscription = null; - } - } - - render() { - const filteredProps = {}; - const subscribedValues = {}; - - for (let key in this.props) { - if (!subscribablePropertiesMap.hasOwnProperty(key)) { - filteredProps[key] = this.props[key]; - } - } - - for (let fromProperty in subscribablePropertiesMap) { - const toProperty = subscribablePropertiesMap[fromProperty]; - const wrapper = this.state[fromProperty]; - subscribedValues[toProperty] = - wrapper != null ? wrapper.value : undefined; + for (let propertyName in subscribablePropertiesMap) { + const subscribable = this.props[propertyName]; + unsubscribeFromHelper(subscribable, propertyName, this); } - - return ; } } - return SubscribableContainer; + return Subscribable; } From 5093ab45a6e717dbe127729c364a438e19033a3b Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Mon, 5 Mar 2018 21:42:54 -0500 Subject: [PATCH 12/53] Added tests for create-react-class --- .../package.json | 12 +- ...omponentWithSubscriptions-test.internal.js | 127 +++++++++++++++--- 2 files changed, 120 insertions(+), 19 deletions(-) diff --git a/packages/create-component-with-subscriptions/package.json b/packages/create-component-with-subscriptions/package.json index eeda6f59c5f8f..6b855b3f20e43 100644 --- a/packages/create-component-with-subscriptions/package.json +++ b/packages/create-component-with-subscriptions/package.json @@ -3,11 +3,19 @@ "description": "HOC for creating async-safe React components with subscriptions", "version": "0.0.1", "repository": "facebook/react", - "files": ["LICENSE", "README.md", "index.js", "cjs/"], + "files": [ + "LICENSE", + "README.md", + "index.js", + "cjs/" + ], "dependencies": { "fbjs": "^0.8.16" }, "peerDependencies": { "react": "16.3.0-alpha.1" + }, + "devDependencies": { + "create-react-class": "^15.6.3" } -} \ No newline at end of file +} diff --git a/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.internal.js b/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.internal.js index cbb64994ce149..a93970b52d7f9 100644 --- a/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.internal.js +++ b/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.internal.js @@ -489,7 +489,7 @@ describe('CreateComponentWithSubscriptions', () => { }); it('should class mixed-in class component lifecycles', () => { - const lifecycles = []; + const log = []; const Subscriber = createComponent( { subscribablePropertiesMap: {observable: 'value'}, @@ -505,22 +505,22 @@ describe('CreateComponentWithSubscriptions', () => { }; constructor(props) { super(props); - lifecycles.push('constructor'); + log.push('constructor'); } static getDerivedStateFromProps(nextProps, prevState) { - lifecycles.push('getDerivedStateFromProps'); + log.push('getDerivedStateFromProps'); return { foo: prevState.foo + 1, }; } componentDidMount() { - lifecycles.push('componentDidMount'); + log.push('componentDidMount'); } componentDidUpdate(prevProps, prevState) { - lifecycles.push('componentDidUpdate'); + log.push('componentDidUpdate'); } componentWillUnmount() { - lifecycles.push('componentWillUnmount'); + log.push('componentWillUnmount'); } render() { ReactNoop.yield({foo: this.state.foo, value: this.state.value}); @@ -533,30 +533,27 @@ describe('CreateComponentWithSubscriptions', () => { ReactNoop.render(); expect(ReactNoop.flush()).toEqual([{foo: 2, value: 'initial'}]); - expect(lifecycles).toEqual([ + expect(log).toEqual([ 'constructor', 'getDerivedStateFromProps', 'componentDidMount', ]); - lifecycles.length = 0; + log.length = 0; observable.update('updated'); expect(ReactNoop.flush()).toEqual([{foo: 2, value: 'updated'}]); - expect(lifecycles).toEqual(['componentDidUpdate']); + expect(log).toEqual(['componentDidUpdate']); // Unsetting the subscriber prop should reset subscribed values - lifecycles.length = 0; + log.length = 0; ReactNoop.render(); expect(ReactNoop.flush()).toEqual([{foo: 3, value: undefined}]); - expect(lifecycles).toEqual([ - 'getDerivedStateFromProps', - 'componentDidUpdate', - ]); + expect(log).toEqual(['getDerivedStateFromProps', 'componentDidUpdate']); // Test unmounting lifecycle as well - lifecycles.length = 0; + log.length = 0; ReactNoop.render(
); expect(ReactNoop.flush()).toEqual([]); - expect(lifecycles).toEqual(['componentWillUnmount']); + expect(log).toEqual(['componentWillUnmount']); }); it('should not mask the displayName used for errors and DevTools', () => { @@ -582,7 +579,103 @@ describe('CreateComponentWithSubscriptions', () => { }); }); - // TODO Test create-class component (for FluxSubscriberMixin use case) + it('should support create-react-class components', () => { + const createReactClass = require('create-react-class/factory')( + React.Component, + React.isValidElement, + new React.Component().updater, + ); + + const log = []; + + const Component = createReactClass({ + mixins: [ + { + componentDidMount() { + log.push('mixin componentDidMount'); + }, + componentDidUpdate() { + log.push('mixin componentDidUpdate'); + }, + componentWillUnmount() { + log.push('mixin componentWillUnmount'); + }, + statics: { + getDerivedStateFromProps() { + log.push('mixin getDerivedStateFromProps'); + return null; + }, + }, + }, + ], + getInitialState() { + return {}; + }, + componentDidMount() { + log.push('componentDidMount'); + }, + componentDidUpdate() { + log.push('componentDidUpdate'); + }, + componentWillUnmount() { + log.push('componentWillUnmount'); + }, + render() { + ReactNoop.yield(this.state.value); + return null; + }, + statics: { + getDerivedStateFromProps() { + log.push('getDerivedStateFromProps'); + return null; + }, + }, + }); + + const Subscriber = createComponent( + { + subscribablePropertiesMap: {observable: 'value'}, + getDataFor: (subscribable, propertyName) => subscribable.getValue(), + subscribeTo: (valueChangedCallback, subscribable, propertyName) => + subscribable.subscribe(valueChangedCallback), + unsubscribeFrom: (subscribable, propertyName, subscription) => + subscription.unsubscribe(), + }, + Component, + ); + + const observable = createFauxBehaviorSubject('initial'); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['initial']); + expect(log).toEqual([ + 'mixin getDerivedStateFromProps', + 'getDerivedStateFromProps', + 'mixin componentDidMount', + 'componentDidMount', + ]); + log.length = 0; + observable.update('updated'); + expect(ReactNoop.flush()).toEqual(['updated']); + expect(log).toEqual(['mixin componentDidUpdate', 'componentDidUpdate']); + + // Unsetting the subscriber prop should reset subscribed values + log.length = 0; + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([undefined]); + expect(log).toEqual([ + 'mixin getDerivedStateFromProps', + 'getDerivedStateFromProps', + 'mixin componentDidUpdate', + 'componentDidUpdate', + ]); + + // Test unmounting lifecycle as well + log.length = 0; + ReactNoop.render(
); + expect(ReactNoop.flush()).toEqual([]); + expect(log).toEqual(['mixin componentWillUnmount', 'componentWillUnmount']); + }); // TODO Test with react-lifecycle-polyfill to make sure gDSFP isn't broken by mixin approach From 54468e86fe950dcb9d242b7afa90fe3cf5b7dcd0 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Mon, 5 Mar 2018 21:46:18 -0500 Subject: [PATCH 13/53] Flow fix --- .../src/createComponentWithSubscriptions.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/create-component-with-subscriptions/src/createComponentWithSubscriptions.js b/packages/create-component-with-subscriptions/src/createComponentWithSubscriptions.js index 26889d3cfd264..8cc4f74e20754 100644 --- a/packages/create-component-with-subscriptions/src/createComponentWithSubscriptions.js +++ b/packages/create-component-with-subscriptions/src/createComponentWithSubscriptions.js @@ -78,12 +78,13 @@ export function createComponent( // If possible, extend the specified component to add subscriptions. // This preserves ref compatibility and avoids the overhead of an extra fiber. - let BaseClass = Component; + let BaseClass = (Component: any); + const prototype = (Component: any).prototype; // If this is a functional component, use a HOC. // Since functional components can't have refs, that isn't a problem. // Class component lifecycles are required, so a class component is needed anyway. - if (typeof Component.prototype.render !== 'function') { + if (typeof prototype === 'object' && typeof prototype.render !== 'function') { BaseClass = class extends React.Component { render() { const subscribedValues = {}; From 6b16b7c883532fd44efcb38e3f764341986dea5c Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Mon, 5 Mar 2018 20:42:04 -0800 Subject: [PATCH 14/53] Added a ref test --- ...omponentWithSubscriptions-test.internal.js | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.internal.js b/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.internal.js index a93970b52d7f9..b462d1b412145 100644 --- a/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.internal.js +++ b/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.internal.js @@ -577,6 +577,37 @@ describe('CreateComponentWithSubscriptions', () => { expect(Subscriber.displayName).toBe('MyExampleComponent'); }); + + it('should preserve refs attached to class components', () => { + class MyExampleComponent extends React.Component { + state = {}; + customMethod() {} + render() { + ReactNoop.yield(this.state.value); + return null; + } + } + const Subscriber = createComponent( + { + subscribablePropertiesMap: {observable: 'value'}, + getDataFor: (subscribable, propertyName) => subscribable.getValue(), + subscribeTo: (valueChangedCallback, subscribable, propertyName) => + subscribable.subscribe(valueChangedCallback), + unsubscribeFrom: (subscribable, propertyName, subscription) => + subscription.unsubscribe(), + }, + MyExampleComponent, + ); + + const observable = createFauxBehaviorSubject('initial'); + const ref = React.createRef(); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['initial']); + + expect(ref.value instanceof MyExampleComponent).toBe(true); + expect(typeof ref.value.customMethod).toBe('function'); + }); }); it('should support create-react-class components', () => { From d05ffa34e597764015835f02c32d29c40aa12530 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Mon, 5 Mar 2018 20:47:30 -0800 Subject: [PATCH 15/53] Added a test for react-lifecycles-compat --- .../package.json | 3 +- ...omponentWithSubscriptions-test.internal.js | 45 ++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/create-component-with-subscriptions/package.json b/packages/create-component-with-subscriptions/package.json index 6b855b3f20e43..3c70c903dcb9f 100644 --- a/packages/create-component-with-subscriptions/package.json +++ b/packages/create-component-with-subscriptions/package.json @@ -16,6 +16,7 @@ "react": "16.3.0-alpha.1" }, "devDependencies": { - "create-react-class": "^15.6.3" + "create-react-class": "^15.6.3", + "react-lifecycles-compat": "^1.0.2" } } diff --git a/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.internal.js b/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.internal.js index b462d1b412145..a48afa2459000 100644 --- a/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.internal.js +++ b/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.internal.js @@ -708,7 +708,50 @@ describe('CreateComponentWithSubscriptions', () => { expect(log).toEqual(['mixin componentWillUnmount', 'componentWillUnmount']); }); - // TODO Test with react-lifecycle-polyfill to make sure gDSFP isn't broken by mixin approach + it('should be compatible with react-lifecycles-compat', () => { + const polyfill = require('react-lifecycles-compat'); + + class MyExampleComponent extends React.Component { + state = { + foo: 1, + }; + static getDerivedStateFromProps(nextProps, prevState) { + return { + foo: prevState.foo + 1, + }; + } + render() { + ReactNoop.yield({foo: this.state.foo, value: this.state.value}); + return null; + } + } + const Subscriber = createComponent( + { + subscribablePropertiesMap: {observable: 'value'}, + getDataFor: (subscribable, propertyName) => subscribable.getValue(), + subscribeTo: (valueChangedCallback, subscribable, propertyName) => + subscribable.subscribe(valueChangedCallback), + unsubscribeFrom: (subscribable, propertyName, subscription) => + subscription.unsubscribe(), + }, + polyfill(MyExampleComponent), + ); + + const observable = createFauxBehaviorSubject('initial'); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([{foo: 2, value: 'initial'}]); + observable.update('updated'); + expect(ReactNoop.flush()).toEqual([{foo: 2, value: 'updated'}]); + + // Unsetting the subscriber prop should reset subscribed values + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([{foo: 3, value: undefined}]); + + // Test unmounting lifecycle as well + ReactNoop.render(
); + expect(ReactNoop.flush()).toEqual([]); + }); describe('invariants', () => { it('should error for invalid Component', () => { From bd36fb49575c72995d4f4eaa0ef8141e04173e37 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Mon, 5 Mar 2018 20:59:27 -0800 Subject: [PATCH 16/53] Updated README to show class component --- .../README.md | 35 +++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/packages/create-component-with-subscriptions/README.md b/packages/create-component-with-subscriptions/README.md index 2e035349fd03c..3671f2b074799 100644 --- a/packages/create-component-with-subscriptions/README.md +++ b/packages/create-component-with-subscriptions/README.md @@ -30,6 +30,14 @@ Creating a subscription component requires a configuration object and a React co subscription: any, ) => void` - Unsubscribes from the specified subscribable. If your component has multiple subscriptions, the second `propertyName` parameter can be used to distinguish between them. The value returned by `subscribeTo()` is the third `subscription` parameter. +Depending on the type of React component specified, `create-component-with-subscriptions` will either create a wrapper component or use a mixin technique. + +If a stateless functional component is specified, a high-order component will be wrapped around it. The wrapper will pass through all `props` (including "subscribables"). In addition, it will also pass the values of each subscribable as `props` (using the name specified by `subscribablePropertiesMap`). + +If a class (or `create-react-class`) component is specified, the library uses an ["ES6 mixin"](https://gist.github.com/sebmarkbage/fac0830dbb13ccbff596) technique in order to preserve compatibility with refs and to avoid the overhead of an additional fiber. Subscription values will be stored in `state` (using the name specified by `subscribablePropertiesMap`) to be accessed from within the `render` method. + +Examples of both [functional](#subscribing-to-event-dispatchers) and [class](#subscribing-to-a-promise) components are provided below. + # Examples This API can be used to subscribe to a variety of "subscribable" sources, from Flux stores to RxJS observables. Below are a few examples of how to subscribe to common types. @@ -42,7 +50,8 @@ Below is an example showing how `create-component-with-subscriptions` can be use import React from "react"; import createComponent from "create-component-with-subscriptions"; -// Start with a simple functional (or class-based) component. +// Start with a simple component. +// In this case, it's a functional component, but it could have been a class. function InnerComponent({ followerCount, username }) { return (
@@ -139,17 +148,23 @@ Below is an example showing how `create-component-with-subscriptions` can be use import React from "react"; import createComponent from "create-component-with-subscriptions"; -// Start with a simple functional (or class-based) component. -function InnerComponent({ followerCount, username }) { - return ( -
- {username} has {followerCount} follower -
- ); +// Start with a simple component. +// In this case, it's a class component, but it could have been functional. +class InnerComponent extends React.Component { + // Subscribed values will be stored in state. + state = {}; + + render() { + return ( +
+ {this.state.username} has {this.state.followerCount} follower +
+ ); + } } -// Wrap the functional component with a subscriber HOC. -// This HOC will manage subscriptions and pass values to the decorated component. +// Add subscription logic mixin to the class component. +// The mixin will manage subscriptions and store the values in state. // It will add and remove subscriptions in an async-safe way when props change. const FollowerCountComponent = createComponent( { From a11164e7a93a097651c1eb026be0b7a44e685d76 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Tue, 6 Mar 2018 06:53:35 -0800 Subject: [PATCH 17/53] Added docs for default values --- .../README.md | 24 +++++++++++++++++++ ...omponentWithSubscriptions-test.internal.js | 4 ++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/create-component-with-subscriptions/README.md b/packages/create-component-with-subscriptions/README.md index 3671f2b074799..120b21b9e888c 100644 --- a/packages/create-component-with-subscriptions/README.md +++ b/packages/create-component-with-subscriptions/README.md @@ -192,4 +192,28 @@ const FollowerCountComponent = createComponent( // Your component can now be used as shown below. // In this example, `followerPromise` represents a native JavaScript Promise. ; +``` + +## Optional parameters and default values + +Subscribable properties are treated as optional by `create-component-with-subscriptions`. In the event that a subscribable `prop` is missing, a value of `undefined` will be passed to the decorated component (using `props` for a functional component or `state` for a class component). + +If you would like to set default values for missing subscribables, you can do this as shown below. + +For functional components, declare a default value while destructuring the `props` parameter: +```js +function InnerComponent({ followerCount = 0 }) { + return
You have {followerCount} followers.
; +} +``` + +For class components, declare a default value while destructuring `state`: +```js +class InnerComponent extends React.Component { + state = {}; + render() { + const { followerCount = 0 } = this.state; + return
You have {followerCount} followers.
; + } +} ``` \ No newline at end of file diff --git a/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.internal.js b/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.internal.js index a48afa2459000..e52ae28309f96 100644 --- a/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.internal.js +++ b/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.internal.js @@ -80,7 +80,7 @@ describe('CreateComponentWithSubscriptions', () => { subscription.unsubscribe(); }, }, - ({value}) => { + ({value = 'default'}) => { ReactNoop.yield(value); return null; }, @@ -90,7 +90,7 @@ describe('CreateComponentWithSubscriptions', () => { ReactNoop.render(); // Updates while subscribed should re-render the child component - expect(ReactNoop.flush()).toEqual([undefined]); + expect(ReactNoop.flush()).toEqual(['default']); observable.update(123); expect(ReactNoop.flush()).toEqual([123]); observable.update('abc'); From 31edf5984c46e12ae634ded81a5a56cd3383cdd1 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Tue, 6 Mar 2018 07:30:57 -0800 Subject: [PATCH 18/53] Improved README examples --- .../README.md | 141 ++++++++++++++---- ...omponentWithSubscriptions-test.internal.js | 67 ++++++--- 2 files changed, 157 insertions(+), 51 deletions(-) diff --git a/packages/create-component-with-subscriptions/README.md b/packages/create-component-with-subscriptions/README.md index 120b21b9e888c..1e1c49ccd1c72 100644 --- a/packages/create-component-with-subscriptions/README.md +++ b/packages/create-component-with-subscriptions/README.md @@ -17,25 +17,94 @@ npm install create-component-with-subscriptions --save # API Creating a subscription component requires a configuration object and a React component. The configuration object must have four properties: -* **subscribablePropertiesMap** `{[subscribableProperty: string]: string}` - Maps property names of incoming subscribable sources (e.g. "eventDispatcher") to property names for their values (e.g. "value"). -* **getDataFor** `(subscribable: any, propertyName: string) => any` - Synchronously returns the value of the specified subscribable property. If your component has multiple subscriptions,the second 'propertyName' parameter can be used to distinguish between them. -* **subscribeTo** `( - valueChangedCallback: (value: any) => void, - subscribable: any, - propertyName: string, - ) => any` - Subscribes to the specified subscribable and call the `valueChangedCallback` parameter whenever a subscription changes. If your component has multiple subscriptions, the third 'propertyName' parameter can be used to distinguish between them. -* **unsubscribeFrom** `( - subscribable: any, - propertyName: string, - subscription: any, - ) => void` - Unsubscribes from the specified subscribable. If your component has multiple subscriptions, the second `propertyName` parameter can be used to distinguish between them. The value returned by `subscribeTo()` is the third `subscription` parameter. + +#### `subscribablePropertiesMap: {[subscribableProperty: string]: string}` + +Maps property names of incoming subscribable sources (e.g. "eventDispatcher") to property names for their values (e.g. "value"). + +For example: +```js +{ + followerStore: 'followerCount', + scrollContainer: 'scrollTop', +} +``` + +#### `getDataFor(subscribable: any, propertyName: string) => any` + +Synchronously returns the value of the specified subscribable property. If your component has multiple subscriptions, the second `propertyName` parameter can be used to distinguish between them. + +For example: +```js +function getDataFor(subscribable, propertyName) { + switch (propertyName) { + case "followerStore": + return subscribable.getValue(); + case "scrollContainer": + return subscriber.scrollTop; + } +} +``` + +#### `subscribeTo(valueChangedCallback: (value: any) => void, subscribable: any, propertyName: string) => any` + +Subscribes to the specified subscribable and call the `valueChangedCallback` parameter whenever a subscription changes. If your component has multiple subscriptions, the third 'propertyName' parameter can be used to distinguish between them. + +For example: +```js +function subscribeTo(valueChangedCallback, subscribable, propertyName) { + switch (propertyName) { + case "followerStore": + return subscribable.subscribe(valueChangedCallback); + case "scrollContainer": + const onScroll = event => valueChangedCallback(subscribable.scrollTop); + subscribable.addEventListener("scroll", onScroll); + return onChange; + } +} +``` + +#### `unsubscribeFrom(subscribable: any, propertyName: string, subscription: any) => void` + +Unsubscribes from the specified subscribable. If your component has multiple subscriptions, the second `propertyName` parameter can be used to distinguish between them. The value returned by `subscribeTo()` is the third `subscription` parameter. + +For example: +```js +function unsubscribeFrom(subscribable, propertyName, subscription) { + switch (propertyName) { + case "followerStore": + return subscription.unsubscribe(); + case "scrollContainer": + subscribable.removeEventListener("change", subscription); + } +} +``` + +# How it works Depending on the type of React component specified, `create-component-with-subscriptions` will either create a wrapper component or use a mixin technique. If a stateless functional component is specified, a high-order component will be wrapped around it. The wrapper will pass through all `props` (including "subscribables"). In addition, it will also pass the values of each subscribable as `props` (using the name specified by `subscribablePropertiesMap`). +Given the above example, a stateless functional component would look something like this: +```js +function ExampleComponent({ followerCount, scrollTop, ...rest }) { + // Render ... +} +``` + If a class (or `create-react-class`) component is specified, the library uses an ["ES6 mixin"](https://gist.github.com/sebmarkbage/fac0830dbb13ccbff596) technique in order to preserve compatibility with refs and to avoid the overhead of an additional fiber. Subscription values will be stored in `state` (using the name specified by `subscribablePropertiesMap`) to be accessed from within the `render` method. +Given the above example, a class component would look something like this: +```js +class ExampleComponent extends React.Component { + render() { + const { followerCount, scrollTop, ...rest } = this.props; + // Render ... + } +} +``` + Examples of both [functional](#subscribing-to-event-dispatchers) and [class](#subscribing-to-a-promise) components are provided below. # Examples @@ -69,12 +138,12 @@ const FollowerCountComponent = createComponent( getDataFor: (subscribable, propertyName) => subscribable.value, subscribeTo: (valueChangedCallback, subscribable, propertyName) => { const onChange = event => valueChangedCallback(subscribable.value); - subscribable.addEventListener(onChange); + subscribable.addEventListener("change", onChange); return onChange; }, unsubscribeFrom: (subscribable, propertyName, subscription) => { // `subscription` is the value returned from subscribeTo, our event handler. - subscribable.removeEventListener(subscription); + subscribable.removeEventListener("change", subscription); } }, InnerComponent @@ -153,30 +222,45 @@ import createComponent from "create-component-with-subscriptions"; class InnerComponent extends React.Component { // Subscribed values will be stored in state. state = {}; - render() { - return ( -
- {this.state.username} has {this.state.followerCount} follower -
- ); + const { loadingStatus } = this.state; + if (loadingStatus === undefined) { + // Loading + } else if (loadingStatus) { + // Success + } else { + // Error + } } } // Add subscription logic mixin to the class component. // The mixin will manage subscriptions and store the values in state. // It will add and remove subscriptions in an async-safe way when props change. -const FollowerCountComponent = createComponent( +const LoadingComponent = createComponent( { - subscribablePropertiesMap: { followerPromise: "followerCount" }, - getDataFor: (subscribable, propertyName, subscription) => undefined, + subscribablePropertiesMap: { loadingPromise: "loadingStatus" }, + getDataFor: (subscribable, propertyName, subscription) => { + // There is no way to synchronously read a Promise's value, + // So this method should return undefined. + return undefined; + }, subscribeTo: (valueChangedCallback, subscribable, propertyName) => { let subscribed = true; - subscribable.then(value => { - if (subscribed) { - valueChangedCallback(value); + subscribable.then( + // Success + () => { + if (subscribed) { + valueChangedCallback(true); + } + }, + // Failure + () => { + if (subscribed) { + valueChangedCallback(false); + } } - }); + ); return { unsubscribe() { subscribed = false; @@ -190,8 +274,7 @@ const FollowerCountComponent = createComponent( ); // Your component can now be used as shown below. -// In this example, `followerPromise` represents a native JavaScript Promise. -; +; ``` ## Optional parameters and default values diff --git a/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.internal.js b/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.internal.js index e52ae28309f96..2aed7c580311c 100644 --- a/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.internal.js +++ b/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.internal.js @@ -201,17 +201,39 @@ describe('CreateComponentWithSubscriptions', () => { }); it('should support Promises', async () => { + class InnerComponent extends React.Component { + state = {}; + render() { + const {hasLoaded} = this.state; + if (hasLoaded === undefined) { + ReactNoop.yield('loading'); + } else { + ReactNoop.yield(hasLoaded ? 'finished' : 'failed'); + } + return null; + } + } + const Subscriber = createComponent( { - subscribablePropertiesMap: {promise: 'value'}, + subscribablePropertiesMap: {loadingPromise: 'hasLoaded'}, getDataFor: (subscribable, propertyName, subscription) => undefined, subscribeTo: (valueChangedCallback, subscribable, propertyName) => { let subscribed = true; - subscribable.then(value => { - if (subscribed) { - valueChangedCallback(value); - } - }); + subscribable.then( + // Success + () => { + if (subscribed) { + valueChangedCallback(true); + } + }, + // Failure + () => { + if (subscribed) { + valueChangedCallback(false); + } + }, + ); return { unsubscribe() { subscribed = false; @@ -221,31 +243,32 @@ describe('CreateComponentWithSubscriptions', () => { unsubscribeFrom: (subscribable, propertyName, subscription) => subscription.unsubscribe(), }, - ({value}) => { - ReactNoop.yield(value); - return null; - }, + InnerComponent, ); - let resolveA, resolveB; - const promiseA = new Promise(r => (resolveA = r)); - const promiseB = new Promise(r => (resolveB = r)); + let resolveA, rejectB; + const promiseA = new Promise((resolve, reject) => { + resolveA = resolve; + }); + const promiseB = new Promise((resolve, reject) => { + rejectB = reject; + }); // Test a promise that resolves after render - ReactNoop.render(); - expect(ReactNoop.flush()).toEqual([undefined]); - resolveA('abc'); + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['loading']); + resolveA(true); await promiseA; - expect(ReactNoop.flush()).toEqual(['abc']); + expect(ReactNoop.flush()).toEqual(['finished']); // Test a promise that resolves before render // Note that this will require an extra render anyway, // Because there is no way to syncrhonously get a Promise's value - resolveB(123); - ReactNoop.render(); - expect(ReactNoop.flush()).toEqual([undefined]); - await promiseB; - expect(ReactNoop.flush()).toEqual([123]); + rejectB(); + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['loading']); + await promiseB.catch(() => true); + expect(ReactNoop.flush()).toEqual(['failed']); }); it('should unsubscribe from old subscribables and subscribe to new subscribables when props change', () => { From 256c5e592007379de98bb93b22ff088415100db9 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Tue, 6 Mar 2018 07:39:42 -0800 Subject: [PATCH 19/53] Simplified Promise docs and added additional test --- .../README.md | 23 +-- ...omponentWithSubscriptions-test.internal.js | 143 ++++++++++-------- 2 files changed, 84 insertions(+), 82 deletions(-) diff --git a/packages/create-component-with-subscriptions/README.md b/packages/create-component-with-subscriptions/README.md index 1e1c49ccd1c72..becf88335ccaa 100644 --- a/packages/create-component-with-subscriptions/README.md +++ b/packages/create-component-with-subscriptions/README.md @@ -249,26 +249,15 @@ const LoadingComponent = createComponent( let subscribed = true; subscribable.then( // Success - () => { - if (subscribed) { - valueChangedCallback(true); - } - }, + () => valueChangedCallback(true), // Failure - () => { - if (subscribed) { - valueChangedCallback(false); - } - } + () => valueChangedCallback(false), ); - return { - unsubscribe() { - subscribed = false; - } - }; }, - unsubscribeFrom: (subscribable, propertyName, subscription) => - subscription.unsubscribe() + unsubscribeFrom: (subscribable, propertyName, subscription) => { + // There is no way to "unsubscribe" from a Promise. + // In this case, create-component-with-subscriptions will block stale values from rendering. + } }, InnerComponent ); diff --git a/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.internal.js b/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.internal.js index 2aed7c580311c..bca1abec83afb 100644 --- a/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.internal.js +++ b/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.internal.js @@ -200,75 +200,88 @@ describe('CreateComponentWithSubscriptions', () => { expect(ReactNoop.flush()).toEqual([undefined]); }); - it('should support Promises', async () => { - class InnerComponent extends React.Component { - state = {}; - render() { - const {hasLoaded} = this.state; - if (hasLoaded === undefined) { - ReactNoop.yield('loading'); - } else { - ReactNoop.yield(hasLoaded ? 'finished' : 'failed'); - } - return null; - } - } - - const Subscriber = createComponent( - { - subscribablePropertiesMap: {loadingPromise: 'hasLoaded'}, - getDataFor: (subscribable, propertyName, subscription) => undefined, - subscribeTo: (valueChangedCallback, subscribable, propertyName) => { - let subscribed = true; - subscribable.then( - // Success - () => { - if (subscribed) { - valueChangedCallback(true); - } - }, - // Failure - () => { - if (subscribed) { - valueChangedCallback(false); - } - }, - ); - return { - unsubscribe() { - subscribed = false; - }, - }; + describe('Promises', () => { + it('should support Promises', async () => { + const Subscriber = createComponent( + { + subscribablePropertiesMap: {loadingPromise: 'hasLoaded'}, + getDataFor: (subscribable, propertyName, subscription) => undefined, + subscribeTo: (valueChangedCallback, subscribable, propertyName) => { + subscribable.then( + () => valueChangedCallback(true), + () => valueChangedCallback(false), + ); + }, + unsubscribeFrom: (subscribable, propertyName, subscription) => {}, }, - unsubscribeFrom: (subscribable, propertyName, subscription) => - subscription.unsubscribe(), - }, - InnerComponent, - ); + ({hasLoaded}) => { + if (hasLoaded === undefined) { + ReactNoop.yield('loading'); + } else { + ReactNoop.yield(hasLoaded ? 'finished' : 'failed'); + } + return null; + }, + ); - let resolveA, rejectB; - const promiseA = new Promise((resolve, reject) => { - resolveA = resolve; - }); - const promiseB = new Promise((resolve, reject) => { - rejectB = reject; + let resolveA, rejectB; + const promiseA = new Promise((resolve, reject) => { + resolveA = resolve; + }); + const promiseB = new Promise((resolve, reject) => { + rejectB = reject; + }); + + // Test a promise that resolves after render + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['loading']); + resolveA(); + await promiseA; + expect(ReactNoop.flush()).toEqual(['finished']); + + // Test a promise that resolves before render + // Note that this will require an extra render anyway, + // Because there is no way to syncrhonously get a Promise's value + rejectB(); + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['loading']); + await promiseB.catch(() => true); + expect(ReactNoop.flush()).toEqual(['failed']); }); - // Test a promise that resolves after render - ReactNoop.render(); - expect(ReactNoop.flush()).toEqual(['loading']); - resolveA(true); - await promiseA; - expect(ReactNoop.flush()).toEqual(['finished']); - - // Test a promise that resolves before render - // Note that this will require an extra render anyway, - // Because there is no way to syncrhonously get a Promise's value - rejectB(); - ReactNoop.render(); - expect(ReactNoop.flush()).toEqual(['loading']); - await promiseB.catch(() => true); - expect(ReactNoop.flush()).toEqual(['failed']); + it('should still work if unsubscription is managed incorrectly', async () => { + const Subscriber = createComponent( + { + subscribablePropertiesMap: {promise: 'value'}, + getDataFor: (subscribable, propertyName, subscription) => undefined, + subscribeTo: (valueChangedCallback, subscribable, propertyName) => + subscribable.then(valueChangedCallback), + unsubscribeFrom: (subscribable, propertyName, subscription) => {}, + }, + ({value}) => { + ReactNoop.yield(value); + return null; + }, + ); + + let resolveA, resolveB; + const promiseA = new Promise(resolve => (resolveA = resolve)); + const promiseB = new Promise(resolve => (resolveB = resolve)); + + // Subscribe first to Promise A then Promsie B + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([undefined]); + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([undefined]); + + // Resolve both Promises + resolveB(123); + resolveA('abc'); + await Promise.all([promiseA, promiseB]); + + // Ensure that only Promise B causes an update + expect(ReactNoop.flush()).toEqual([123]); + }); }); it('should unsubscribe from old subscribables and subscribe to new subscribables when props change', () => { From 6dcac15ba00c12df22cd044168038d3c223cc23a Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Tue, 6 Mar 2018 07:45:13 -0800 Subject: [PATCH 20/53] Swapped functional/class component usage in examples --- .../README.md | 42 ++++++++++--------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/packages/create-component-with-subscriptions/README.md b/packages/create-component-with-subscriptions/README.md index becf88335ccaa..b9efe9902945b 100644 --- a/packages/create-component-with-subscriptions/README.md +++ b/packages/create-component-with-subscriptions/README.md @@ -164,10 +164,21 @@ Below is an example showing how `create-component-with-subscriptions` can be use import React from "react"; import createComponent from "create-component-with-subscriptions"; -function InnerComponent({ behaviorValue, replayValue }) { - // Render ... +// This example uses a class component, but it could have been functional. +class InnerComponent extends React.Component { + // Subscribed values are stored in state for class components. + state = {}; + + render() { + const { behaviorValue, replayValue } = this.state; + + // Render ... + } } +// Add subscription logic mixin to the class component. +// The mixin will manage subscriptions and store the values in state. +// It will add and remove subscriptions in an async-safe way when props change. const SubscribedComponent = createComponent( { subscribablePropertiesMap: { @@ -218,24 +229,18 @@ import React from "react"; import createComponent from "create-component-with-subscriptions"; // Start with a simple component. -// In this case, it's a class component, but it could have been functional. -class InnerComponent extends React.Component { - // Subscribed values will be stored in state. - state = {}; - render() { - const { loadingStatus } = this.state; - if (loadingStatus === undefined) { - // Loading - } else if (loadingStatus) { - // Success - } else { - // Error - } +function InnerComponent({ loadingStatus }) { + if (loadingStatus === undefined) { + // Loading + } else if (loadingStatus) { + // Success + } else { + // Error } } -// Add subscription logic mixin to the class component. -// The mixin will manage subscriptions and store the values in state. +// Wrap the functional component with a subscriber HOC. +// This HOC will manage subscriptions and pass values to the decorated component. // It will add and remove subscriptions in an async-safe way when props change. const LoadingComponent = createComponent( { @@ -246,12 +251,11 @@ const LoadingComponent = createComponent( return undefined; }, subscribeTo: (valueChangedCallback, subscribable, propertyName) => { - let subscribed = true; subscribable.then( // Success () => valueChangedCallback(true), // Failure - () => valueChangedCallback(false), + () => valueChangedCallback(false) ); }, unsubscribeFrom: (subscribable, propertyName, subscription) => { From 39d7ba883c8310b434f668bc017504cdfab72dee Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Tue, 6 Mar 2018 07:49:06 -0800 Subject: [PATCH 21/53] Split internal and public API tests --- ...omponentWithSubscriptions-test.internal.js | 511 ---------------- .../createComponentWithSubscriptions-test.js | 561 ++++++++++++++++++ 2 files changed, 561 insertions(+), 511 deletions(-) create mode 100644 packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.js diff --git a/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.internal.js b/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.internal.js index bca1abec83afb..e46e0879973bc 100644 --- a/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.internal.js +++ b/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.internal.js @@ -51,447 +51,6 @@ describe('CreateComponentWithSubscriptions', () => { }; } - // Mimics a partial interface of RxJS `ReplaySubject` - function createFauxReplaySubject(initialValue) { - const observable = createFauxBehaviorSubject(initialValue); - const {getValue, subscribe} = observable; - observable.getValue = undefined; - observable.subscribe = callback => { - callback(getValue()); - return subscribe(callback); - }; - return observable; - } - - it('supports basic subscription pattern', () => { - const Subscriber = createComponent( - { - subscribablePropertiesMap: {observable: 'value'}, - getDataFor: (subscribable, propertyName) => { - expect(propertyName).toBe('observable'); - return subscribable.getValue(); - }, - subscribeTo: (valueChangedCallback, subscribable, propertyName) => { - expect(propertyName).toBe('observable'); - return subscribable.subscribe(valueChangedCallback); - }, - unsubscribeFrom: (subscribable, propertyName, subscription) => { - expect(propertyName).toBe('observable'); - subscription.unsubscribe(); - }, - }, - ({value = 'default'}) => { - ReactNoop.yield(value); - return null; - }, - ); - - const observable = createFauxBehaviorSubject(); - ReactNoop.render(); - - // Updates while subscribed should re-render the child component - expect(ReactNoop.flush()).toEqual(['default']); - observable.update(123); - expect(ReactNoop.flush()).toEqual([123]); - observable.update('abc'); - expect(ReactNoop.flush()).toEqual(['abc']); - - // Unmounting the subscriber should remove listeners - ReactNoop.render(
); - observable.update(456); - expect(ReactNoop.flush()).toEqual([]); - }); - - it('supports multiple subscriptions', () => { - const Subscriber = createComponent( - { - subscribablePropertiesMap: { - foo: 'foo', - bar: 'bar', - }, - getDataFor: (subscribable, propertyName) => { - switch (propertyName) { - case 'foo': - return subscribable.getValue(); - case 'bar': - return subscribable.getValue(); - default: - throw Error('Unexpected propertyName ' + propertyName); - } - }, - subscribeTo: (valueChangedCallback, subscribable, propertyName) => { - switch (propertyName) { - case 'foo': - return subscribable.subscribe(valueChangedCallback); - case 'bar': - return subscribable.subscribe(valueChangedCallback); - default: - throw Error('Unexpected propertyName ' + propertyName); - } - }, - unsubscribeFrom: (subscribable, propertyName, subscription) => { - switch (propertyName) { - case 'foo': - case 'bar': - subscription.unsubscribe(); - break; - default: - throw Error('Unexpected propertyName ' + propertyName); - } - }, - }, - ({bar, foo}) => { - ReactNoop.yield(`bar:${bar}, foo:${foo}`); - return null; - }, - ); - - const foo = createFauxBehaviorSubject(); - const bar = createFauxBehaviorSubject(); - - ReactNoop.render(); - - // Updates while subscribed should re-render the child component - expect(ReactNoop.flush()).toEqual([`bar:undefined, foo:undefined`]); - foo.update(123); - expect(ReactNoop.flush()).toEqual([`bar:undefined, foo:123`]); - bar.update('abc'); - expect(ReactNoop.flush()).toEqual([`bar:abc, foo:123`]); - foo.update(456); - expect(ReactNoop.flush()).toEqual([`bar:abc, foo:456`]); - - // Unsetting the subscriber prop should reset subscribed values - ReactNoop.render(); - expect(ReactNoop.flush()).toEqual([`bar:undefined, foo:undefined`]); - }); - - it('should support observable types like RxJS ReplaySubject', () => { - const Subscriber = createComponent( - { - subscribablePropertiesMap: {observable: 'value'}, - getDataFor: (subscribable, propertyName, subscription) => { - let currentValue; - const temporarySubscription = subscribable.subscribe(value => { - currentValue = value; - }); - temporarySubscription.unsubscribe(); - return currentValue; - }, - subscribeTo: (valueChangedCallback, subscribable, propertyName) => - subscribable.subscribe(valueChangedCallback), - unsubscribeFrom: (subscribable, propertyName, subscription) => - subscription.unsubscribe(), - }, - ({value}) => { - ReactNoop.yield(value); - return null; - }, - ); - - const observable = createFauxReplaySubject('initial'); - - ReactNoop.render(); - expect(ReactNoop.flush()).toEqual(['initial']); - observable.update('updated'); - expect(ReactNoop.flush()).toEqual(['updated']); - - // Unsetting the subscriber prop should reset subscribed values - ReactNoop.render(); - expect(ReactNoop.flush()).toEqual([undefined]); - }); - - describe('Promises', () => { - it('should support Promises', async () => { - const Subscriber = createComponent( - { - subscribablePropertiesMap: {loadingPromise: 'hasLoaded'}, - getDataFor: (subscribable, propertyName, subscription) => undefined, - subscribeTo: (valueChangedCallback, subscribable, propertyName) => { - subscribable.then( - () => valueChangedCallback(true), - () => valueChangedCallback(false), - ); - }, - unsubscribeFrom: (subscribable, propertyName, subscription) => {}, - }, - ({hasLoaded}) => { - if (hasLoaded === undefined) { - ReactNoop.yield('loading'); - } else { - ReactNoop.yield(hasLoaded ? 'finished' : 'failed'); - } - return null; - }, - ); - - let resolveA, rejectB; - const promiseA = new Promise((resolve, reject) => { - resolveA = resolve; - }); - const promiseB = new Promise((resolve, reject) => { - rejectB = reject; - }); - - // Test a promise that resolves after render - ReactNoop.render(); - expect(ReactNoop.flush()).toEqual(['loading']); - resolveA(); - await promiseA; - expect(ReactNoop.flush()).toEqual(['finished']); - - // Test a promise that resolves before render - // Note that this will require an extra render anyway, - // Because there is no way to syncrhonously get a Promise's value - rejectB(); - ReactNoop.render(); - expect(ReactNoop.flush()).toEqual(['loading']); - await promiseB.catch(() => true); - expect(ReactNoop.flush()).toEqual(['failed']); - }); - - it('should still work if unsubscription is managed incorrectly', async () => { - const Subscriber = createComponent( - { - subscribablePropertiesMap: {promise: 'value'}, - getDataFor: (subscribable, propertyName, subscription) => undefined, - subscribeTo: (valueChangedCallback, subscribable, propertyName) => - subscribable.then(valueChangedCallback), - unsubscribeFrom: (subscribable, propertyName, subscription) => {}, - }, - ({value}) => { - ReactNoop.yield(value); - return null; - }, - ); - - let resolveA, resolveB; - const promiseA = new Promise(resolve => (resolveA = resolve)); - const promiseB = new Promise(resolve => (resolveB = resolve)); - - // Subscribe first to Promise A then Promsie B - ReactNoop.render(); - expect(ReactNoop.flush()).toEqual([undefined]); - ReactNoop.render(); - expect(ReactNoop.flush()).toEqual([undefined]); - - // Resolve both Promises - resolveB(123); - resolveA('abc'); - await Promise.all([promiseA, promiseB]); - - // Ensure that only Promise B causes an update - expect(ReactNoop.flush()).toEqual([123]); - }); - }); - - it('should unsubscribe from old subscribables and subscribe to new subscribables when props change', () => { - const Subscriber = createComponent( - { - subscribablePropertiesMap: {observable: 'value'}, - getDataFor: (subscribable, propertyName) => subscribable.getValue(), - subscribeTo: (valueChangedCallback, subscribable, propertyName) => - subscribable.subscribe(valueChangedCallback), - unsubscribeFrom: (subscribable, propertyName, subscription) => - subscription.unsubscribe(), - }, - ({value}) => { - ReactNoop.yield(value); - return null; - }, - ); - - const observableA = createFauxBehaviorSubject('a-0'); - const observableB = createFauxBehaviorSubject('b-0'); - - ReactNoop.render(); - - // Updates while subscribed should re-render the child component - expect(ReactNoop.flush()).toEqual(['a-0']); - - // Unsetting the subscriber prop should reset subscribed values - ReactNoop.render(); - expect(ReactNoop.flush()).toEqual(['b-0']); - - // Updates to the old subscribable should not re-render the child component - observableA.update('a-1'); - expect(ReactNoop.flush()).toEqual([]); - - // Updates to the bew subscribable should re-render the child component - observableB.update('b-1'); - expect(ReactNoop.flush()).toEqual(['b-1']); - }); - - it('should ignore values emitted by a new subscribable until the commit phase', () => { - let parentInstance; - - function Child({value}) { - ReactNoop.yield('Child: ' + value); - return null; - } - - const Subscriber = createComponent( - { - subscribablePropertiesMap: {observable: 'value'}, - getDataFor: (subscribable, propertyName) => subscribable.getValue(), - subscribeTo: (valueChangedCallback, subscribable, propertyName) => - subscribable.subscribe(valueChangedCallback), - unsubscribeFrom: (subscribable, propertyName, subscription) => - subscription.unsubscribe(), - }, - ({value}) => { - ReactNoop.yield('Subscriber: ' + value); - return ; - }, - ); - - class Parent extends React.Component { - state = {}; - - static getDerivedStateFromProps(nextProps, prevState) { - if (nextProps.observable !== prevState.observable) { - return { - observable: nextProps.observable, - }; - } - - return null; - } - - render() { - parentInstance = this; - - return ; - } - } - - const observableA = createFauxBehaviorSubject('a-0'); - const observableB = createFauxBehaviorSubject('b-0'); - - ReactNoop.render(); - expect(ReactNoop.flush()).toEqual(['Subscriber: a-0', 'Child: a-0']); - - // Start React update, but don't finish - ReactNoop.render(); - ReactNoop.flushThrough(['Subscriber: b-0']); - - // Emit some updates from the uncommitted subscribable - observableB.update('b-1'); - observableB.update('b-2'); - observableB.update('b-3'); - - // Mimic a higher-priority interruption - parentInstance.setState({observable: observableA}); - - // 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(ReactNoop.flush()).toEqual([ - 'Child: b-0', - 'Subscriber: b-3', - 'Child: b-3', - 'Subscriber: a-0', - 'Child: a-0', - ]); - }); - - it('should not drop values emitted between updates', () => { - let parentInstance; - - function Child({value}) { - ReactNoop.yield('Child: ' + value); - return null; - } - - const Subscriber = createComponent( - { - subscribablePropertiesMap: {observable: 'value'}, - getDataFor: (subscribable, propertyName) => subscribable.getValue(), - subscribeTo: (valueChangedCallback, subscribable, propertyName) => - subscribable.subscribe(valueChangedCallback), - unsubscribeFrom: (subscribable, propertyName, subscription) => - subscription.unsubscribe(), - }, - ({value}) => { - ReactNoop.yield('Subscriber: ' + value); - return ; - }, - ); - - class Parent extends React.Component { - state = {}; - - static getDerivedStateFromProps(nextProps, prevState) { - if (nextProps.observable !== prevState.observable) { - return { - observable: nextProps.observable, - }; - } - - return null; - } - - render() { - parentInstance = this; - - return ; - } - } - - const observableA = createFauxBehaviorSubject('a-0'); - const observableB = createFauxBehaviorSubject('b-0'); - - ReactNoop.render(); - expect(ReactNoop.flush()).toEqual(['Subscriber: a-0', 'Child: a-0']); - - // Start React update, but don't finish - ReactNoop.render(); - ReactNoop.flushThrough(['Subscriber: b-0']); - - // Emit some updates from the old subscribable - observableA.update('a-1'); - observableA.update('a-2'); - - // Mimic a higher-priority interruption - parentInstance.setState({observable: observableA}); - - // 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(ReactNoop.flush()).toEqual([ - 'Child: b-0', - 'Subscriber: a-2', - 'Child: a-2', - ]); - - // Updates from the new subsribable should be ignored. - observableB.update('b-1'); - expect(ReactNoop.flush()).toEqual([]); - }); - - it('should pass all non-subscribable props through to the child component', () => { - const Subscriber = createComponent( - { - subscribablePropertiesMap: {observable: 'value'}, - getDataFor: (subscribable, propertyName) => subscribable.getValue(), - subscribeTo: (valueChangedCallback, subscribable, propertyName) => - subscribable.subscribe(valueChangedCallback), - unsubscribeFrom: (subscribable, propertyName, subscription) => - subscription.unsubscribe(), - }, - ({bar, foo, value}) => { - ReactNoop.yield(`bar:${bar}, foo:${foo}, value:${value}`); - return null; - }, - ); - - const observable = createFauxBehaviorSubject(true); - ReactNoop.render( - , - ); - expect(ReactNoop.flush()).toEqual(['bar:abc, foo:123, value:true']); - }); - describe('class component', () => { it('should support class components', () => { const Subscriber = createComponent( @@ -788,74 +347,4 @@ describe('CreateComponentWithSubscriptions', () => { ReactNoop.render(
); expect(ReactNoop.flush()).toEqual([]); }); - - describe('invariants', () => { - it('should error for invalid Component', () => { - expect(() => { - createComponent( - { - subscribablePropertiesMap: {}, - getDataFor: () => {}, - subscribeTo: () => {}, - unsubscribeFrom: () => {}, - }, - null, - ); - }).toThrow('Invalid subscribable Component specified'); - }); - - it('should error for invalid missing subscribablePropertiesMap', () => { - expect(() => { - createComponent( - { - getDataFor: () => {}, - subscribeTo: () => {}, - unsubscribeFrom: () => {}, - }, - () => null, - ); - }).toThrow( - 'Subscribable config must specify a subscribablePropertiesMap map', - ); - }); - - it('should error for invalid missing getDataFor', () => { - expect(() => { - createComponent( - { - subscribablePropertiesMap: {}, - subscribeTo: () => {}, - unsubscribeFrom: () => {}, - }, - () => null, - ); - }).toThrow('Subscribable config must specify a getDataFor function'); - }); - - it('should error for invalid missing subscribeTo', () => { - expect(() => { - createComponent( - { - subscribablePropertiesMap: {}, - getDataFor: () => {}, - unsubscribeFrom: () => {}, - }, - () => null, - ); - }).toThrow('Subscribable config must specify a subscribeTo function'); - }); - - it('should error for invalid missing unsubscribeFrom', () => { - expect(() => { - createComponent( - { - subscribablePropertiesMap: {}, - getDataFor: () => {}, - subscribeTo: () => {}, - }, - () => null, - ); - }).toThrow('Subscribable config must specify a unsubscribeFrom function'); - }); - }); }); diff --git a/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.js b/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.js new file mode 100644 index 0000000000000..cc87b4ab45711 --- /dev/null +++ b/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.js @@ -0,0 +1,561 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * + * 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 createComponent; +let React; +let ReactNoop; + +describe('CreateComponentWithSubscriptions', () => { + beforeEach(() => { + jest.resetModules(); + createComponent = require('create-component-with-subscriptions') + .createComponent; + React = require('react'); + ReactNoop = require('react-noop-renderer'); + }); + + // Mimics a partial interface of RxJS `BehaviorSubject` + function createFauxBehaviorSubject(initialValue) { + let currentValue = initialValue; + let subscribedCallbacks = []; + return { + getValue: () => currentValue, + subscribe: callback => { + subscribedCallbacks.push(callback); + return { + unsubscribe: () => { + subscribedCallbacks.splice( + subscribedCallbacks.indexOf(callback), + 1, + ); + }, + }; + }, + update: value => { + currentValue = value; + subscribedCallbacks.forEach(subscribedCallback => + subscribedCallback(value), + ); + }, + }; + } + + // Mimics a partial interface of RxJS `ReplaySubject` + function createFauxReplaySubject(initialValue) { + const observable = createFauxBehaviorSubject(initialValue); + const {getValue, subscribe} = observable; + observable.getValue = undefined; + observable.subscribe = callback => { + callback(getValue()); + return subscribe(callback); + }; + return observable; + } + + it('supports basic subscription pattern', () => { + const Subscriber = createComponent( + { + subscribablePropertiesMap: {observable: 'value'}, + getDataFor: (subscribable, propertyName) => { + expect(propertyName).toBe('observable'); + return subscribable.getValue(); + }, + subscribeTo: (valueChangedCallback, subscribable, propertyName) => { + expect(propertyName).toBe('observable'); + return subscribable.subscribe(valueChangedCallback); + }, + unsubscribeFrom: (subscribable, propertyName, subscription) => { + expect(propertyName).toBe('observable'); + subscription.unsubscribe(); + }, + }, + ({value = 'default'}) => { + ReactNoop.yield(value); + return null; + }, + ); + + const observable = createFauxBehaviorSubject(); + ReactNoop.render(); + + // Updates while subscribed should re-render the child component + expect(ReactNoop.flush()).toEqual(['default']); + observable.update(123); + expect(ReactNoop.flush()).toEqual([123]); + observable.update('abc'); + expect(ReactNoop.flush()).toEqual(['abc']); + + // Unmounting the subscriber should remove listeners + ReactNoop.render(
); + observable.update(456); + expect(ReactNoop.flush()).toEqual([]); + }); + + it('supports multiple subscriptions', () => { + const Subscriber = createComponent( + { + subscribablePropertiesMap: { + foo: 'foo', + bar: 'bar', + }, + getDataFor: (subscribable, propertyName) => { + switch (propertyName) { + case 'foo': + return subscribable.getValue(); + case 'bar': + return subscribable.getValue(); + default: + throw Error('Unexpected propertyName ' + propertyName); + } + }, + subscribeTo: (valueChangedCallback, subscribable, propertyName) => { + switch (propertyName) { + case 'foo': + return subscribable.subscribe(valueChangedCallback); + case 'bar': + return subscribable.subscribe(valueChangedCallback); + default: + throw Error('Unexpected propertyName ' + propertyName); + } + }, + unsubscribeFrom: (subscribable, propertyName, subscription) => { + switch (propertyName) { + case 'foo': + case 'bar': + subscription.unsubscribe(); + break; + default: + throw Error('Unexpected propertyName ' + propertyName); + } + }, + }, + ({bar, foo}) => { + ReactNoop.yield(`bar:${bar}, foo:${foo}`); + return null; + }, + ); + + const foo = createFauxBehaviorSubject(); + const bar = createFauxBehaviorSubject(); + + ReactNoop.render(); + + // Updates while subscribed should re-render the child component + expect(ReactNoop.flush()).toEqual([`bar:undefined, foo:undefined`]); + foo.update(123); + expect(ReactNoop.flush()).toEqual([`bar:undefined, foo:123`]); + bar.update('abc'); + expect(ReactNoop.flush()).toEqual([`bar:abc, foo:123`]); + foo.update(456); + expect(ReactNoop.flush()).toEqual([`bar:abc, foo:456`]); + + // Unsetting the subscriber prop should reset subscribed values + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([`bar:undefined, foo:undefined`]); + }); + + it('should support observable types like RxJS ReplaySubject', () => { + const Subscriber = createComponent( + { + subscribablePropertiesMap: {observable: 'value'}, + getDataFor: (subscribable, propertyName, subscription) => { + let currentValue; + const temporarySubscription = subscribable.subscribe(value => { + currentValue = value; + }); + temporarySubscription.unsubscribe(); + return currentValue; + }, + subscribeTo: (valueChangedCallback, subscribable, propertyName) => + subscribable.subscribe(valueChangedCallback), + unsubscribeFrom: (subscribable, propertyName, subscription) => + subscription.unsubscribe(), + }, + ({value}) => { + ReactNoop.yield(value); + return null; + }, + ); + + const observable = createFauxReplaySubject('initial'); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['initial']); + observable.update('updated'); + expect(ReactNoop.flush()).toEqual(['updated']); + + // Unsetting the subscriber prop should reset subscribed values + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([undefined]); + }); + + describe('Promises', () => { + it('should support Promises', async () => { + const Subscriber = createComponent( + { + subscribablePropertiesMap: {loadingPromise: 'hasLoaded'}, + getDataFor: (subscribable, propertyName, subscription) => undefined, + subscribeTo: (valueChangedCallback, subscribable, propertyName) => { + subscribable.then( + () => valueChangedCallback(true), + () => valueChangedCallback(false), + ); + }, + unsubscribeFrom: (subscribable, propertyName, subscription) => {}, + }, + ({hasLoaded}) => { + if (hasLoaded === undefined) { + ReactNoop.yield('loading'); + } else { + ReactNoop.yield(hasLoaded ? 'finished' : 'failed'); + } + return null; + }, + ); + + let resolveA, rejectB; + const promiseA = new Promise((resolve, reject) => { + resolveA = resolve; + }); + const promiseB = new Promise((resolve, reject) => { + rejectB = reject; + }); + + // Test a promise that resolves after render + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['loading']); + resolveA(); + await promiseA; + expect(ReactNoop.flush()).toEqual(['finished']); + + // Test a promise that resolves before render + // Note that this will require an extra render anyway, + // Because there is no way to syncrhonously get a Promise's value + rejectB(); + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['loading']); + await promiseB.catch(() => true); + expect(ReactNoop.flush()).toEqual(['failed']); + }); + + it('should still work if unsubscription is managed incorrectly', async () => { + const Subscriber = createComponent( + { + subscribablePropertiesMap: {promise: 'value'}, + getDataFor: (subscribable, propertyName, subscription) => undefined, + subscribeTo: (valueChangedCallback, subscribable, propertyName) => + subscribable.then(valueChangedCallback), + unsubscribeFrom: (subscribable, propertyName, subscription) => {}, + }, + ({value}) => { + ReactNoop.yield(value); + return null; + }, + ); + + let resolveA, resolveB; + const promiseA = new Promise(resolve => (resolveA = resolve)); + const promiseB = new Promise(resolve => (resolveB = resolve)); + + // Subscribe first to Promise A then Promsie B + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([undefined]); + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([undefined]); + + // Resolve both Promises + resolveB(123); + resolveA('abc'); + await Promise.all([promiseA, promiseB]); + + // Ensure that only Promise B causes an update + expect(ReactNoop.flush()).toEqual([123]); + }); + }); + + it('should unsubscribe from old subscribables and subscribe to new subscribables when props change', () => { + const Subscriber = createComponent( + { + subscribablePropertiesMap: {observable: 'value'}, + getDataFor: (subscribable, propertyName) => subscribable.getValue(), + subscribeTo: (valueChangedCallback, subscribable, propertyName) => + subscribable.subscribe(valueChangedCallback), + unsubscribeFrom: (subscribable, propertyName, subscription) => + subscription.unsubscribe(), + }, + ({value}) => { + ReactNoop.yield(value); + return null; + }, + ); + + const observableA = createFauxBehaviorSubject('a-0'); + const observableB = createFauxBehaviorSubject('b-0'); + + ReactNoop.render(); + + // Updates while subscribed should re-render the child component + expect(ReactNoop.flush()).toEqual(['a-0']); + + // Unsetting the subscriber prop should reset subscribed values + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['b-0']); + + // Updates to the old subscribable should not re-render the child component + observableA.update('a-1'); + expect(ReactNoop.flush()).toEqual([]); + + // Updates to the bew subscribable should re-render the child component + observableB.update('b-1'); + expect(ReactNoop.flush()).toEqual(['b-1']); + }); + + it('should ignore values emitted by a new subscribable until the commit phase', () => { + let parentInstance; + + function Child({value}) { + ReactNoop.yield('Child: ' + value); + return null; + } + + const Subscriber = createComponent( + { + subscribablePropertiesMap: {observable: 'value'}, + getDataFor: (subscribable, propertyName) => subscribable.getValue(), + subscribeTo: (valueChangedCallback, subscribable, propertyName) => + subscribable.subscribe(valueChangedCallback), + unsubscribeFrom: (subscribable, propertyName, subscription) => + subscription.unsubscribe(), + }, + ({value}) => { + ReactNoop.yield('Subscriber: ' + value); + return ; + }, + ); + + class Parent extends React.Component { + state = {}; + + static getDerivedStateFromProps(nextProps, prevState) { + if (nextProps.observable !== prevState.observable) { + return { + observable: nextProps.observable, + }; + } + + return null; + } + + render() { + parentInstance = this; + + return ; + } + } + + const observableA = createFauxBehaviorSubject('a-0'); + const observableB = createFauxBehaviorSubject('b-0'); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Subscriber: a-0', 'Child: a-0']); + + // Start React update, but don't finish + ReactNoop.render(); + ReactNoop.flushThrough(['Subscriber: b-0']); + + // Emit some updates from the uncommitted subscribable + observableB.update('b-1'); + observableB.update('b-2'); + observableB.update('b-3'); + + // Mimic a higher-priority interruption + parentInstance.setState({observable: observableA}); + + // 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(ReactNoop.flush()).toEqual([ + 'Child: b-0', + 'Subscriber: b-3', + 'Child: b-3', + 'Subscriber: a-0', + 'Child: a-0', + ]); + }); + + it('should not drop values emitted between updates', () => { + let parentInstance; + + function Child({value}) { + ReactNoop.yield('Child: ' + value); + return null; + } + + const Subscriber = createComponent( + { + subscribablePropertiesMap: {observable: 'value'}, + getDataFor: (subscribable, propertyName) => subscribable.getValue(), + subscribeTo: (valueChangedCallback, subscribable, propertyName) => + subscribable.subscribe(valueChangedCallback), + unsubscribeFrom: (subscribable, propertyName, subscription) => + subscription.unsubscribe(), + }, + ({value}) => { + ReactNoop.yield('Subscriber: ' + value); + return ; + }, + ); + + class Parent extends React.Component { + state = {}; + + static getDerivedStateFromProps(nextProps, prevState) { + if (nextProps.observable !== prevState.observable) { + return { + observable: nextProps.observable, + }; + } + + return null; + } + + render() { + parentInstance = this; + + return ; + } + } + + const observableA = createFauxBehaviorSubject('a-0'); + const observableB = createFauxBehaviorSubject('b-0'); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Subscriber: a-0', 'Child: a-0']); + + // Start React update, but don't finish + ReactNoop.render(); + ReactNoop.flushThrough(['Subscriber: b-0']); + + // Emit some updates from the old subscribable + observableA.update('a-1'); + observableA.update('a-2'); + + // Mimic a higher-priority interruption + parentInstance.setState({observable: observableA}); + + // 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(ReactNoop.flush()).toEqual([ + 'Child: b-0', + 'Subscriber: a-2', + 'Child: a-2', + ]); + + // Updates from the new subsribable should be ignored. + observableB.update('b-1'); + expect(ReactNoop.flush()).toEqual([]); + }); + + it('should pass all non-subscribable props through to the child component', () => { + const Subscriber = createComponent( + { + subscribablePropertiesMap: {observable: 'value'}, + getDataFor: (subscribable, propertyName) => subscribable.getValue(), + subscribeTo: (valueChangedCallback, subscribable, propertyName) => + subscribable.subscribe(valueChangedCallback), + unsubscribeFrom: (subscribable, propertyName, subscription) => + subscription.unsubscribe(), + }, + ({bar, foo, value}) => { + ReactNoop.yield(`bar:${bar}, foo:${foo}, value:${value}`); + return null; + }, + ); + + const observable = createFauxBehaviorSubject(true); + ReactNoop.render( + , + ); + expect(ReactNoop.flush()).toEqual(['bar:abc, foo:123, value:true']); + }); + + describe('invariants', () => { + it('should error for invalid Component', () => { + expect(() => { + createComponent( + { + subscribablePropertiesMap: {}, + getDataFor: () => {}, + subscribeTo: () => {}, + unsubscribeFrom: () => {}, + }, + null, + ); + }).toThrow('Invalid subscribable Component specified'); + }); + + it('should error for invalid missing subscribablePropertiesMap', () => { + expect(() => { + createComponent( + { + getDataFor: () => {}, + subscribeTo: () => {}, + unsubscribeFrom: () => {}, + }, + () => null, + ); + }).toThrow( + 'Subscribable config must specify a subscribablePropertiesMap map', + ); + }); + + it('should error for invalid missing getDataFor', () => { + expect(() => { + createComponent( + { + subscribablePropertiesMap: {}, + subscribeTo: () => {}, + unsubscribeFrom: () => {}, + }, + () => null, + ); + }).toThrow('Subscribable config must specify a getDataFor function'); + }); + + it('should error for invalid missing subscribeTo', () => { + expect(() => { + createComponent( + { + subscribablePropertiesMap: {}, + getDataFor: () => {}, + unsubscribeFrom: () => {}, + }, + () => null, + ); + }).toThrow('Subscribable config must specify a subscribeTo function'); + }); + + it('should error for invalid missing unsubscribeFrom', () => { + expect(() => { + createComponent( + { + subscribablePropertiesMap: {}, + getDataFor: () => {}, + subscribeTo: () => {}, + }, + () => null, + ); + }).toThrow('Subscribable config must specify a unsubscribeFrom function'); + }); + }); +}); From b5571c1976929474263669d043e0ba38dce69023 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Tue, 6 Mar 2018 07:55:06 -0800 Subject: [PATCH 22/53] Tweaks --- .../src/createComponentWithSubscriptions.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/create-component-with-subscriptions/src/createComponentWithSubscriptions.js b/packages/create-component-with-subscriptions/src/createComponentWithSubscriptions.js index 8cc4f74e20754..56e6e0810cef6 100644 --- a/packages/create-component-with-subscriptions/src/createComponentWithSubscriptions.js +++ b/packages/create-component-with-subscriptions/src/createComponentWithSubscriptions.js @@ -84,7 +84,7 @@ export function createComponent( // If this is a functional component, use a HOC. // Since functional components can't have refs, that isn't a problem. // Class component lifecycles are required, so a class component is needed anyway. - if (typeof prototype === 'object' && typeof prototype.render !== 'function') { + if (typeof prototype !== 'object' || typeof prototype.render !== 'function') { BaseClass = class extends React.Component { render() { const subscribedValues = {}; @@ -115,8 +115,8 @@ export function createComponent( } const currentSubscribable = - instance.state[stateWrapperKey] !== undefined - ? instance.state[stateWrapperKey].subscribable + state[stateWrapperKey] !== undefined + ? state[stateWrapperKey].subscribable : null; // If this event belongs to an old or uncommitted data source, ignore it. From 2192fd57bb9b0757548f6bb7f9aef392e006bc2a Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Tue, 6 Mar 2018 15:00:55 -0800 Subject: [PATCH 23/53] Changed impl to only support one subscription per component --- .../README.md | 226 ++++++++------ ...omponentWithSubscriptions-test.internal.js | 101 +++---- .../createComponentWithSubscriptions-test.js | 281 ++++++++---------- .../src/createComponentWithSubscriptions.js | 195 +++++------- 4 files changed, 380 insertions(+), 423 deletions(-) diff --git a/packages/create-component-with-subscriptions/README.md b/packages/create-component-with-subscriptions/README.md index b9efe9902945b..9feb5d31fb9d1 100644 --- a/packages/create-component-with-subscriptions/README.md +++ b/packages/create-component-with-subscriptions/README.md @@ -18,65 +18,45 @@ npm install create-component-with-subscriptions --save Creating a subscription component requires a configuration object and a React component. The configuration object must have four properties: -#### `subscribablePropertiesMap: {[subscribableProperty: string]: string}` +#### `property: string` -Maps property names of incoming subscribable sources (e.g. "eventDispatcher") to property names for their values (e.g. "value"). +Property name of the subscribable sources (e.g. "hasLoaded"). -For example: -```js -{ - followerStore: 'followerCount', - scrollContainer: 'scrollTop', -} -``` +#### `getValue: (props: Props) => Value` -#### `getDataFor(subscribable: any, propertyName: string) => any` +Synchronously returns the value of the subscribable property. -Synchronously returns the value of the specified subscribable property. If your component has multiple subscriptions, the second `propertyName` parameter can be used to distinguish between them. +You should return `undefined` if the subscribable type does not support this operation (e.g. native Promises). For example: ```js -function getDataFor(subscribable, propertyName) { - switch (propertyName) { - case "followerStore": - return subscribable.getValue(); - case "scrollContainer": - return subscriber.scrollTop; - } +function getValue(props: Props) { + return props.scrollContainer.scrollTop; } ``` -#### `subscribeTo(valueChangedCallback: (value: any) => void, subscribable: any, propertyName: string) => any` +#### `subscribe(props: Props, valueChangedCallback: (value: any) => void) => Subscription` -Subscribes to the specified subscribable and call the `valueChangedCallback` parameter whenever a subscription changes. If your component has multiple subscriptions, the third 'propertyName' parameter can be used to distinguish between them. +Setup a subscription for the subscribable value in `props`. This subscription should call the `valueChangedCallback` parameter whenever a subscription changes. For example: ```js -function subscribeTo(valueChangedCallback, subscribable, propertyName) { - switch (propertyName) { - case "followerStore": - return subscribable.subscribe(valueChangedCallback); - case "scrollContainer": - const onScroll = event => valueChangedCallback(subscribable.scrollTop); - subscribable.addEventListener("scroll", onScroll); - return onChange; - } +function subscribe(props: Props, valueChangedCallback: (value: any) => void) { + const {scrollContainer} = props; + const onScroll = event => valueChangedCallback(scrollContainer.scrollTop); + scrollContainer.addEventListener("scroll", onScroll); + return onScroll; } ``` -#### `unsubscribeFrom(subscribable: any, propertyName: string, subscription: any) => void` +#### `unsubscribe: (props: Props, subscription: Subscription) => void` -Unsubscribes from the specified subscribable. If your component has multiple subscriptions, the second `propertyName` parameter can be used to distinguish between them. The value returned by `subscribeTo()` is the third `subscription` parameter. +Unsubsribe from the subscribable value in `props`. The value returned by `subscribe()` is the second, `subscription` parameter. For example: ```js -function unsubscribeFrom(subscribable, propertyName, subscription) { - switch (propertyName) { - case "followerStore": - return subscription.unsubscribe(); - case "scrollContainer": - subscribable.removeEventListener("change", subscription); - } +function unsubscribe(props, subscription) { + props.scrollContainer.removeEventListener("scroll", subscription); } ``` @@ -84,22 +64,22 @@ function unsubscribeFrom(subscribable, propertyName, subscription) { Depending on the type of React component specified, `create-component-with-subscriptions` will either create a wrapper component or use a mixin technique. -If a stateless functional component is specified, a high-order component will be wrapped around it. The wrapper will pass through all `props` (including "subscribables"). In addition, it will also pass the values of each subscribable as `props` (using the name specified by `subscribablePropertiesMap`). +If a stateless functional component is specified, a high-order component will be wrapped around it. The wrapper will pass through all `props`. The subscribed value will be passed in place of the "subscribable" prop though. Given the above example, a stateless functional component would look something like this: ```js -function ExampleComponent({ followerCount, scrollTop, ...rest }) { +function ExampleComponent({ scrollTop, ...rest }) { // Render ... } ``` -If a class (or `create-react-class`) component is specified, the library uses an ["ES6 mixin"](https://gist.github.com/sebmarkbage/fac0830dbb13ccbff596) technique in order to preserve compatibility with refs and to avoid the overhead of an additional fiber. Subscription values will be stored in `state` (using the name specified by `subscribablePropertiesMap`) to be accessed from within the `render` method. +If a class (or `create-react-class`) component is specified, the library uses an ["ES6 mixin"](https://gist.github.com/sebmarkbage/fac0830dbb13ccbff596) technique in order to preserve compatibility with refs and to avoid the overhead of an additional fiber. In this case, the subscription value will be stored in `state` (using the same `property` name) and be accessed from within the `render` method. Given the above example, a class component would look something like this: ```js class ExampleComponent extends React.Component { render() { - const { followerCount, scrollTop, ...rest } = this.props; + const { scrollTop } = this.state; // Render ... } } @@ -121,10 +101,10 @@ import createComponent from "create-component-with-subscriptions"; // Start with a simple component. // In this case, it's a functional component, but it could have been a class. -function InnerComponent({ followerCount, username }) { +function InnerComponent({ followers, username }) { return (
- {username} has {followerCount} follower + {username} has {followers} follower
); } @@ -134,16 +114,17 @@ function InnerComponent({ followerCount, username }) { // It will add and remove subscriptions in an async-safe way when props change. const FollowerCountComponent = createComponent( { - subscribablePropertiesMap: { followerStore: "followerCount" }, - getDataFor: (subscribable, propertyName) => subscribable.value, - subscribeTo: (valueChangedCallback, subscribable, propertyName) => { - const onChange = event => valueChangedCallback(subscribable.value); - subscribable.addEventListener("change", onChange); + property: "followers", + getValue: props => subscribable.value, + subscribe: (props, valueChangedCallback) => { + const { followers } = props; + const onChange = event => valueChangedCallback(followers.value); + followers.addEventListener("change", onChange); return onChange; }, - unsubscribeFrom: (subscribable, propertyName, subscription) => { - // `subscription` is the value returned from subscribeTo, our event handler. - subscribable.removeEventListener("change", subscription); + unsubscribe: (props, subscription) => { + // `subscription` is the value returned from subscribe, our event handler. + props.followers.removeEventListener("change", subscription); } }, InnerComponent @@ -151,29 +132,57 @@ const FollowerCountComponent = createComponent( // Your component can now be used as shown below. // In this example, `followerStore` represents a generic event dispatcher. -; +; ``` ## Subscribing to observables +TODO Break up + Below is an example showing how `create-component-with-subscriptions` 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 import React from "react"; import createComponent from "create-component-with-subscriptions"; -// This example uses a class component, but it could have been functional. -class InnerComponent extends React.Component { - // Subscribed values are stored in state for class components. - state = {}; +// Start with a simple component. +// In this case, it's a functional component, but it could have been a class. +function InnerComponent({ behaviorSubject }) { + // Render ... +} - render() { - const { behaviorValue, replayValue } = this.state; +// Add subscription logic mixin to the class component. +// The mixin will manage subscriptions and store the values in state. +// It will add and remove subscriptions in an async-safe way when props change. +const SubscribedComponent = createComponent( + { + property: "behaviorSubject", + getValue: (props) => props.behaviorSubject.getValue(), + subscribe: (props, valueChangedCallback) => + props.behaviorSubject.subscribe(valueChangedCallback), + unsubscribe: (props, subscription) => + subscription.unsubscribe() + }, + InnerComponent +); - // Render ... - } +// Your component can now be used as shown below. +// In this example, both properties below represent RxJS types with the same name. +; +``` + +### `ReplaySubject` +```js +import React from "react"; +import createComponent from "create-component-with-subscriptions"; + +// Start with a simple component. +// In this case, it's a functional component, but it could have been a class. +function InnerComponent({ replaySubject }) { + // Render ... } // Add subscription logic mixin to the class component. @@ -181,39 +190,27 @@ class InnerComponent extends React.Component { // It will add and remove subscriptions in an async-safe way when props change. const SubscribedComponent = createComponent( { - subscribablePropertiesMap: { - behaviorSubject: "behaviorValue", - replaySubject: "replayValue" + property: "replaySubject", + getValue: props => { + let currentValue; + // ReplaySubject does not have a sync data getter, + // So we need to temporarily subscribe to retrieve the most recent value. + const temporarySubscription = props.replaySubject.subscribe(value => { + currentValue = value; + }); + temporarySubscription.unsubscribe(); + return currentValue; }, - getDataFor: (subscribable, propertyName) => { - switch (propertyName) { - case "behaviorSubject": - return subscribable.getValue(); - case "replaySubject": - let currentValue; - // ReplaySubject does not have a sync data getter, - // So we need to temporarily subscribe to retrieve the most recent value. - const temporarySubscription = subscribable.subscribe(value => { - currentValue = value; - }); - temporarySubscription.unsubscribe(); - return currentValue; - } - }, - subscribeTo: (valueChangedCallback, subscribable, propertyName) => - subscribable.subscribe(valueChangedCallback), - unsubscribeFrom: (subscribable, propertyName, subscription) => - subscription.unsubscribe() + subscribe: (props, valueChangedCallback) => + props.replaySubject.subscribe(valueChangedCallback), + unsubscribe: (props, subscription) => subscription.unsubscribe() }, InnerComponent ); // Your component can now be used as shown below. // In this example, both properties below represent RxJS types with the same name. -; +; ``` ## Subscribing to a Promise @@ -244,21 +241,21 @@ function InnerComponent({ loadingStatus }) { // It will add and remove subscriptions in an async-safe way when props change. const LoadingComponent = createComponent( { - subscribablePropertiesMap: { loadingPromise: "loadingStatus" }, - getDataFor: (subscribable, propertyName, subscription) => { + property: "loadingStatus", + getValue: (props, subscription) => { // There is no way to synchronously read a Promise's value, // So this method should return undefined. return undefined; }, - subscribeTo: (valueChangedCallback, subscribable, propertyName) => { - subscribable.then( + subscribe: (props, valueChangedCallback) => { + props.loadingStatus.then( // Success () => valueChangedCallback(true), // Failure () => valueChangedCallback(false) ); }, - unsubscribeFrom: (subscribable, propertyName, subscription) => { + unsubscribe: (props, subscription) => { // There is no way to "unsubscribe" from a Promise. // In this case, create-component-with-subscriptions will block stale values from rendering. } @@ -267,19 +264,19 @@ const LoadingComponent = createComponent( ); // Your component can now be used as shown below. -; +; ``` ## Optional parameters and default values Subscribable properties are treated as optional by `create-component-with-subscriptions`. In the event that a subscribable `prop` is missing, a value of `undefined` will be passed to the decorated component (using `props` for a functional component or `state` for a class component). -If you would like to set default values for missing subscribables, you can do this as shown below. +If you would like to set default values for missing subscriptions, you can do this as shown below. For functional components, declare a default value while destructuring the `props` parameter: ```js -function InnerComponent({ followerCount = 0 }) { - return
You have {followerCount} followers.
; +function InnerComponent({ followers = 0 }) { + return
You have {followers} followers.
; } ``` @@ -288,8 +285,41 @@ For class components, declare a default value while destructuring `state`: class InnerComponent extends React.Component { state = {}; render() { - const { followerCount = 0 } = this.state; - return
You have {followerCount} followers.
; + const { followers = 0 } = this.state; + return
You have {followers} followers.
; } } +``` + +## Subscribing to multiple sources + +It is possible for a single component to subscribe to multiple data sources. To do this, compose the return value of `create-component-with-subscriptions` as shown below: + +```js +function InnerComponent({ bar, foo }) { + // Render ... +} + +const MultiSubscriptionComponent = createComponent( + { + property: "promiseTwo", + getValue: props => props.promiseTwo.getValue(), + subscribe: (props, valueChangedCallback) => + props.promiseTwo.subscribe(valueChangedCallback), + unsubscribe: (props, subscription) => subscription.unsubscribe() + }, + createComponent( + { + property: "promiseTwo", + getValue: props => props.promiseTwo.getValue(), + subscribe: (props, valueChangedCallback) => + props.promiseTwo.subscribe(valueChangedCallback), + unsubscribe: (props, subscription) => subscription.unsubscribe() + }, + InnerComponent + ) +); + +// Your component can now be used as shown below. +; ``` \ No newline at end of file diff --git a/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.internal.js b/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.internal.js index e46e0879973bc..bf131ea84bf8a 100644 --- a/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.internal.js +++ b/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.internal.js @@ -55,17 +55,16 @@ describe('CreateComponentWithSubscriptions', () => { it('should support class components', () => { const Subscriber = createComponent( { - subscribablePropertiesMap: {observable: 'value'}, - getDataFor: (subscribable, propertyName) => subscribable.getValue(), - subscribeTo: (valueChangedCallback, subscribable, propertyName) => - subscribable.subscribe(valueChangedCallback), - unsubscribeFrom: (subscribable, propertyName, subscription) => - subscription.unsubscribe(), + property: 'observed', + getValue: props => props.observed.getValue(), + subscribe: (props, valueChangedCallback) => + props.observed.subscribe(valueChangedCallback), + unsubscribe: (props, subscription) => subscription.unsubscribe(), }, class extends React.Component { state = {}; render() { - ReactNoop.yield(this.state.value); + ReactNoop.yield(this.state.observed); return null; } }, @@ -73,7 +72,7 @@ describe('CreateComponentWithSubscriptions', () => { const observable = createFauxBehaviorSubject('initial'); - ReactNoop.render(); + ReactNoop.render(); expect(ReactNoop.flush()).toEqual(['initial']); observable.update('updated'); expect(ReactNoop.flush()).toEqual(['updated']); @@ -87,12 +86,11 @@ describe('CreateComponentWithSubscriptions', () => { const log = []; const Subscriber = createComponent( { - subscribablePropertiesMap: {observable: 'value'}, - getDataFor: (subscribable, propertyName) => subscribable.getValue(), - subscribeTo: (valueChangedCallback, subscribable, propertyName) => - subscribable.subscribe(valueChangedCallback), - unsubscribeFrom: (subscribable, propertyName, subscription) => - subscription.unsubscribe(), + property: 'observed', + getValue: props => props.observed.getValue(), + subscribe: (props, valueChangedCallback) => + props.observed.subscribe(valueChangedCallback), + unsubscribe: (props, subscription) => subscription.unsubscribe(), }, class extends React.Component { state = { @@ -118,7 +116,10 @@ describe('CreateComponentWithSubscriptions', () => { log.push('componentWillUnmount'); } render() { - ReactNoop.yield({foo: this.state.foo, value: this.state.value}); + ReactNoop.yield({ + foo: this.state.foo, + observed: this.state.observed, + }); return null; } }, @@ -126,8 +127,8 @@ describe('CreateComponentWithSubscriptions', () => { const observable = createFauxBehaviorSubject('initial'); - ReactNoop.render(); - expect(ReactNoop.flush()).toEqual([{foo: 2, value: 'initial'}]); + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([{foo: 2, observed: 'initial'}]); expect(log).toEqual([ 'constructor', 'getDerivedStateFromProps', @@ -135,13 +136,13 @@ describe('CreateComponentWithSubscriptions', () => { ]); log.length = 0; observable.update('updated'); - expect(ReactNoop.flush()).toEqual([{foo: 2, value: 'updated'}]); + expect(ReactNoop.flush()).toEqual([{foo: 2, observed: 'updated'}]); expect(log).toEqual(['componentDidUpdate']); // Unsetting the subscriber prop should reset subscribed values log.length = 0; ReactNoop.render(); - expect(ReactNoop.flush()).toEqual([{foo: 3, value: undefined}]); + expect(ReactNoop.flush()).toEqual([{foo: 3, observed: undefined}]); expect(log).toEqual(['getDerivedStateFromProps', 'componentDidUpdate']); // Test unmounting lifecycle as well @@ -154,12 +155,11 @@ describe('CreateComponentWithSubscriptions', () => { it('should not mask the displayName used for errors and DevTools', () => { const Subscriber = createComponent( { - subscribablePropertiesMap: {observable: 'value'}, - getDataFor: (subscribable, propertyName) => subscribable.getValue(), - subscribeTo: (valueChangedCallback, subscribable, propertyName) => - subscribable.subscribe(valueChangedCallback), - unsubscribeFrom: (subscribable, propertyName, subscription) => - subscription.unsubscribe(), + property: 'observed', + getValue: props => props.observed.getValue(), + subscribe: (props, valueChangedCallback) => + props.observed.subscribe(valueChangedCallback), + unsubscribe: (props, subscription) => subscription.unsubscribe(), }, class MyExampleComponent extends React.Component { static displayName = 'MyExampleComponent'; @@ -178,18 +178,17 @@ describe('CreateComponentWithSubscriptions', () => { state = {}; customMethod() {} render() { - ReactNoop.yield(this.state.value); + ReactNoop.yield(this.state.observed); return null; } } const Subscriber = createComponent( { - subscribablePropertiesMap: {observable: 'value'}, - getDataFor: (subscribable, propertyName) => subscribable.getValue(), - subscribeTo: (valueChangedCallback, subscribable, propertyName) => - subscribable.subscribe(valueChangedCallback), - unsubscribeFrom: (subscribable, propertyName, subscription) => - subscription.unsubscribe(), + property: 'observed', + getValue: props => props.observed.getValue(), + subscribe: (props, valueChangedCallback) => + props.observed.subscribe(valueChangedCallback), + unsubscribe: (props, subscription) => subscription.unsubscribe(), }, MyExampleComponent, ); @@ -197,7 +196,7 @@ describe('CreateComponentWithSubscriptions', () => { const observable = createFauxBehaviorSubject('initial'); const ref = React.createRef(); - ReactNoop.render(); + ReactNoop.render(); expect(ReactNoop.flush()).toEqual(['initial']); expect(ref.value instanceof MyExampleComponent).toBe(true); @@ -247,7 +246,7 @@ describe('CreateComponentWithSubscriptions', () => { log.push('componentWillUnmount'); }, render() { - ReactNoop.yield(this.state.value); + ReactNoop.yield(this.state.observed); return null; }, statics: { @@ -260,19 +259,18 @@ describe('CreateComponentWithSubscriptions', () => { const Subscriber = createComponent( { - subscribablePropertiesMap: {observable: 'value'}, - getDataFor: (subscribable, propertyName) => subscribable.getValue(), - subscribeTo: (valueChangedCallback, subscribable, propertyName) => - subscribable.subscribe(valueChangedCallback), - unsubscribeFrom: (subscribable, propertyName, subscription) => - subscription.unsubscribe(), + property: 'observed', + getValue: props => props.observed.getValue(), + subscribe: (props, valueChangedCallback) => + props.observed.subscribe(valueChangedCallback), + unsubscribe: (props, subscription) => subscription.unsubscribe(), }, Component, ); const observable = createFauxBehaviorSubject('initial'); - ReactNoop.render(); + ReactNoop.render(); expect(ReactNoop.flush()).toEqual(['initial']); expect(log).toEqual([ 'mixin getDerivedStateFromProps', @@ -316,32 +314,31 @@ describe('CreateComponentWithSubscriptions', () => { }; } render() { - ReactNoop.yield({foo: this.state.foo, value: this.state.value}); + ReactNoop.yield({foo: this.state.foo, observed: this.state.observed}); return null; } } const Subscriber = createComponent( { - subscribablePropertiesMap: {observable: 'value'}, - getDataFor: (subscribable, propertyName) => subscribable.getValue(), - subscribeTo: (valueChangedCallback, subscribable, propertyName) => - subscribable.subscribe(valueChangedCallback), - unsubscribeFrom: (subscribable, propertyName, subscription) => - subscription.unsubscribe(), + property: 'observed', + getValue: props => props.observed.getValue(), + subscribe: (props, valueChangedCallback) => + props.observed.subscribe(valueChangedCallback), + unsubscribe: (props, subscription) => subscription.unsubscribe(), }, polyfill(MyExampleComponent), ); const observable = createFauxBehaviorSubject('initial'); - ReactNoop.render(); - expect(ReactNoop.flush()).toEqual([{foo: 2, value: 'initial'}]); + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([{foo: 2, observed: 'initial'}]); observable.update('updated'); - expect(ReactNoop.flush()).toEqual([{foo: 2, value: 'updated'}]); + expect(ReactNoop.flush()).toEqual([{foo: 2, observed: 'updated'}]); // Unsetting the subscriber prop should reset subscribed values ReactNoop.render(); - expect(ReactNoop.flush()).toEqual([{foo: 3, value: undefined}]); + expect(ReactNoop.flush()).toEqual([{foo: 3, observed: undefined}]); // Test unmounting lifecycle as well ReactNoop.render(
); diff --git a/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.js b/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.js index cc87b4ab45711..42afc30860f01 100644 --- a/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.js +++ b/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.js @@ -63,28 +63,20 @@ describe('CreateComponentWithSubscriptions', () => { it('supports basic subscription pattern', () => { const Subscriber = createComponent( { - subscribablePropertiesMap: {observable: 'value'}, - getDataFor: (subscribable, propertyName) => { - expect(propertyName).toBe('observable'); - return subscribable.getValue(); - }, - subscribeTo: (valueChangedCallback, subscribable, propertyName) => { - expect(propertyName).toBe('observable'); - return subscribable.subscribe(valueChangedCallback); - }, - unsubscribeFrom: (subscribable, propertyName, subscription) => { - expect(propertyName).toBe('observable'); - subscription.unsubscribe(); - }, + property: 'observed', + getValue: props => props.observed.getValue(), + subscribe: (props, valueChangedCallback) => + props.observed.subscribe(valueChangedCallback), + unsubscribe: (props, subscription) => subscription.unsubscribe(), }, - ({value = 'default'}) => { - ReactNoop.yield(value); + ({observed = 'default'}) => { + ReactNoop.yield(observed); return null; }, ); const observable = createFauxBehaviorSubject(); - ReactNoop.render(); + ReactNoop.render(); // Updates while subscribed should re-render the child component expect(ReactNoop.flush()).toEqual(['default']); @@ -100,47 +92,29 @@ describe('CreateComponentWithSubscriptions', () => { }); it('supports multiple subscriptions', () => { + const InnerComponent = ({bar, foo}) => { + ReactNoop.yield(`bar:${bar}, foo:${foo}`); + return null; + }; + const Subscriber = createComponent( { - subscribablePropertiesMap: { - foo: 'foo', - bar: 'bar', - }, - getDataFor: (subscribable, propertyName) => { - switch (propertyName) { - case 'foo': - return subscribable.getValue(); - case 'bar': - return subscribable.getValue(); - default: - throw Error('Unexpected propertyName ' + propertyName); - } - }, - subscribeTo: (valueChangedCallback, subscribable, propertyName) => { - switch (propertyName) { - case 'foo': - return subscribable.subscribe(valueChangedCallback); - case 'bar': - return subscribable.subscribe(valueChangedCallback); - default: - throw Error('Unexpected propertyName ' + propertyName); - } - }, - unsubscribeFrom: (subscribable, propertyName, subscription) => { - switch (propertyName) { - case 'foo': - case 'bar': - subscription.unsubscribe(); - break; - default: - throw Error('Unexpected propertyName ' + propertyName); - } - }, - }, - ({bar, foo}) => { - ReactNoop.yield(`bar:${bar}, foo:${foo}`); - return null; + property: 'foo', + getValue: props => props.foo.getValue(), + subscribe: (props, valueChangedCallback) => + props.foo.subscribe(valueChangedCallback), + unsubscribe: (props, subscription) => subscription.unsubscribe(), }, + createComponent( + { + property: 'bar', + getValue: props => props.bar.getValue(), + subscribe: (props, valueChangedCallback) => + props.bar.subscribe(valueChangedCallback), + unsubscribe: (props, subscription) => subscription.unsubscribe(), + }, + InnerComponent, + ), ); const foo = createFauxBehaviorSubject(); @@ -165,29 +139,28 @@ describe('CreateComponentWithSubscriptions', () => { it('should support observable types like RxJS ReplaySubject', () => { const Subscriber = createComponent( { - subscribablePropertiesMap: {observable: 'value'}, - getDataFor: (subscribable, propertyName, subscription) => { + property: 'observed', + getValue: props => { let currentValue; - const temporarySubscription = subscribable.subscribe(value => { + const temporarySubscription = props.observed.subscribe(value => { currentValue = value; }); temporarySubscription.unsubscribe(); return currentValue; }, - subscribeTo: (valueChangedCallback, subscribable, propertyName) => - subscribable.subscribe(valueChangedCallback), - unsubscribeFrom: (subscribable, propertyName, subscription) => - subscription.unsubscribe(), + subscribe: (props, valueChangedCallback) => + props.observed.subscribe(valueChangedCallback), + unsubscribe: (props, subscription) => subscription.unsubscribe(), }, - ({value}) => { - ReactNoop.yield(value); + ({observed}) => { + ReactNoop.yield(observed); return null; }, ); const observable = createFauxReplaySubject('initial'); - ReactNoop.render(); + ReactNoop.render(); expect(ReactNoop.flush()).toEqual(['initial']); observable.update('updated'); expect(ReactNoop.flush()).toEqual(['updated']); @@ -201,15 +174,15 @@ describe('CreateComponentWithSubscriptions', () => { it('should support Promises', async () => { const Subscriber = createComponent( { - subscribablePropertiesMap: {loadingPromise: 'hasLoaded'}, - getDataFor: (subscribable, propertyName, subscription) => undefined, - subscribeTo: (valueChangedCallback, subscribable, propertyName) => { - subscribable.then( + property: 'hasLoaded', + getValue: props => undefined, + subscribe: (props, valueChangedCallback) => { + props.hasLoaded.then( () => valueChangedCallback(true), () => valueChangedCallback(false), ); }, - unsubscribeFrom: (subscribable, propertyName, subscription) => {}, + unsubscribe: (props, subscription) => {}, }, ({hasLoaded}) => { if (hasLoaded === undefined) { @@ -230,7 +203,7 @@ describe('CreateComponentWithSubscriptions', () => { }); // Test a promise that resolves after render - ReactNoop.render(); + ReactNoop.render(); expect(ReactNoop.flush()).toEqual(['loading']); resolveA(); await promiseA; @@ -240,7 +213,7 @@ describe('CreateComponentWithSubscriptions', () => { // Note that this will require an extra render anyway, // Because there is no way to syncrhonously get a Promise's value rejectB(); - ReactNoop.render(); + ReactNoop.render(); expect(ReactNoop.flush()).toEqual(['loading']); await promiseB.catch(() => true); expect(ReactNoop.flush()).toEqual(['failed']); @@ -249,14 +222,14 @@ describe('CreateComponentWithSubscriptions', () => { it('should still work if unsubscription is managed incorrectly', async () => { const Subscriber = createComponent( { - subscribablePropertiesMap: {promise: 'value'}, - getDataFor: (subscribable, propertyName, subscription) => undefined, - subscribeTo: (valueChangedCallback, subscribable, propertyName) => - subscribable.then(valueChangedCallback), - unsubscribeFrom: (subscribable, propertyName, subscription) => {}, + property: 'promised', + getValue: props => undefined, + subscribe: (props, valueChangedCallback) => + props.promised.then(valueChangedCallback), + unsubscribe: (props, subscription) => {}, }, - ({value}) => { - ReactNoop.yield(value); + ({promised}) => { + ReactNoop.yield(promised); return null; }, ); @@ -266,9 +239,9 @@ describe('CreateComponentWithSubscriptions', () => { const promiseB = new Promise(resolve => (resolveB = resolve)); // Subscribe first to Promise A then Promsie B - ReactNoop.render(); + ReactNoop.render(); expect(ReactNoop.flush()).toEqual([undefined]); - ReactNoop.render(); + ReactNoop.render(); expect(ReactNoop.flush()).toEqual([undefined]); // Resolve both Promises @@ -284,15 +257,14 @@ describe('CreateComponentWithSubscriptions', () => { it('should unsubscribe from old subscribables and subscribe to new subscribables when props change', () => { const Subscriber = createComponent( { - subscribablePropertiesMap: {observable: 'value'}, - getDataFor: (subscribable, propertyName) => subscribable.getValue(), - subscribeTo: (valueChangedCallback, subscribable, propertyName) => - subscribable.subscribe(valueChangedCallback), - unsubscribeFrom: (subscribable, propertyName, subscription) => - subscription.unsubscribe(), + property: 'observed', + getValue: props => props.observed.getValue(), + subscribe: (props, valueChangedCallback) => + props.observed.subscribe(valueChangedCallback), + unsubscribe: (props, subscription) => subscription.unsubscribe(), }, - ({value}) => { - ReactNoop.yield(value); + ({observed}) => { + ReactNoop.yield(observed); return null; }, ); @@ -300,13 +272,13 @@ describe('CreateComponentWithSubscriptions', () => { const observableA = createFauxBehaviorSubject('a-0'); const observableB = createFauxBehaviorSubject('b-0'); - ReactNoop.render(); + ReactNoop.render(); // Updates while subscribed should re-render the child component expect(ReactNoop.flush()).toEqual(['a-0']); // Unsetting the subscriber prop should reset subscribed values - ReactNoop.render(); + ReactNoop.render(); expect(ReactNoop.flush()).toEqual(['b-0']); // Updates to the old subscribable should not re-render the child component @@ -328,16 +300,15 @@ describe('CreateComponentWithSubscriptions', () => { const Subscriber = createComponent( { - subscribablePropertiesMap: {observable: 'value'}, - getDataFor: (subscribable, propertyName) => subscribable.getValue(), - subscribeTo: (valueChangedCallback, subscribable, propertyName) => - subscribable.subscribe(valueChangedCallback), - unsubscribeFrom: (subscribable, propertyName, subscription) => - subscription.unsubscribe(), + property: 'observed', + getValue: props => props.observed.getValue(), + subscribe: (props, valueChangedCallback) => + props.observed.subscribe(valueChangedCallback), + unsubscribe: (props, subscription) => subscription.unsubscribe(), }, - ({value}) => { - ReactNoop.yield('Subscriber: ' + value); - return ; + ({observed}) => { + ReactNoop.yield('Subscriber: ' + observed); + return ; }, ); @@ -345,9 +316,9 @@ describe('CreateComponentWithSubscriptions', () => { state = {}; static getDerivedStateFromProps(nextProps, prevState) { - if (nextProps.observable !== prevState.observable) { + if (nextProps.observed !== prevState.observed) { return { - observable: nextProps.observable, + observed: nextProps.observed, }; } @@ -357,18 +328,18 @@ describe('CreateComponentWithSubscriptions', () => { render() { parentInstance = this; - return ; + return ; } } const observableA = createFauxBehaviorSubject('a-0'); const observableB = createFauxBehaviorSubject('b-0'); - ReactNoop.render(); + ReactNoop.render(); expect(ReactNoop.flush()).toEqual(['Subscriber: a-0', 'Child: a-0']); // Start React update, but don't finish - ReactNoop.render(); + ReactNoop.render(); ReactNoop.flushThrough(['Subscriber: b-0']); // Emit some updates from the uncommitted subscribable @@ -377,7 +348,7 @@ describe('CreateComponentWithSubscriptions', () => { observableB.update('b-3'); // Mimic a higher-priority interruption - parentInstance.setState({observable: observableA}); + parentInstance.setState({observed: observableA}); // 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) @@ -402,16 +373,15 @@ describe('CreateComponentWithSubscriptions', () => { const Subscriber = createComponent( { - subscribablePropertiesMap: {observable: 'value'}, - getDataFor: (subscribable, propertyName) => subscribable.getValue(), - subscribeTo: (valueChangedCallback, subscribable, propertyName) => - subscribable.subscribe(valueChangedCallback), - unsubscribeFrom: (subscribable, propertyName, subscription) => - subscription.unsubscribe(), + property: 'observed', + getValue: props => props.observed.getValue(), + subscribe: (props, valueChangedCallback) => + props.observed.subscribe(valueChangedCallback), + unsubscribe: (props, subscription) => subscription.unsubscribe(), }, - ({value}) => { - ReactNoop.yield('Subscriber: ' + value); - return ; + ({observed}) => { + ReactNoop.yield('Subscriber: ' + observed); + return ; }, ); @@ -419,9 +389,9 @@ describe('CreateComponentWithSubscriptions', () => { state = {}; static getDerivedStateFromProps(nextProps, prevState) { - if (nextProps.observable !== prevState.observable) { + if (nextProps.observed !== prevState.observed) { return { - observable: nextProps.observable, + observed: nextProps.observed, }; } @@ -431,18 +401,18 @@ describe('CreateComponentWithSubscriptions', () => { render() { parentInstance = this; - return ; + return ; } } const observableA = createFauxBehaviorSubject('a-0'); const observableB = createFauxBehaviorSubject('b-0'); - ReactNoop.render(); + ReactNoop.render(); expect(ReactNoop.flush()).toEqual(['Subscriber: a-0', 'Child: a-0']); // Start React update, but don't finish - ReactNoop.render(); + ReactNoop.render(); ReactNoop.flushThrough(['Subscriber: b-0']); // Emit some updates from the old subscribable @@ -450,7 +420,7 @@ describe('CreateComponentWithSubscriptions', () => { observableA.update('a-2'); // Mimic a higher-priority interruption - parentInstance.setState({observable: observableA}); + parentInstance.setState({observed: observableA}); // Flush everything and ensure that the correct subscribable is used // We expect the new subscribable to finish rendering, @@ -469,24 +439,21 @@ describe('CreateComponentWithSubscriptions', () => { it('should pass all non-subscribable props through to the child component', () => { const Subscriber = createComponent( { - subscribablePropertiesMap: {observable: 'value'}, - getDataFor: (subscribable, propertyName) => subscribable.getValue(), - subscribeTo: (valueChangedCallback, subscribable, propertyName) => - subscribable.subscribe(valueChangedCallback), - unsubscribeFrom: (subscribable, propertyName, subscription) => - subscription.unsubscribe(), + property: 'observed', + getValue: props => props.observed.getValue(), + subscribe: (props, valueChangedCallback) => + props.observed.subscribe(valueChangedCallback), + unsubscribe: (props, subscription) => subscription.unsubscribe(), }, - ({bar, foo, value}) => { - ReactNoop.yield(`bar:${bar}, foo:${foo}, value:${value}`); + ({bar, foo, observed}) => { + ReactNoop.yield(`bar:${bar}, foo:${foo}, observed:${observed}`); return null; }, ); const observable = createFauxBehaviorSubject(true); - ReactNoop.render( - , - ); - expect(ReactNoop.flush()).toEqual(['bar:abc, foo:123, value:true']); + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['bar:abc, foo:123, observed:true']); }); describe('invariants', () => { @@ -494,68 +461,66 @@ describe('CreateComponentWithSubscriptions', () => { expect(() => { createComponent( { - subscribablePropertiesMap: {}, - getDataFor: () => {}, - subscribeTo: () => {}, - unsubscribeFrom: () => {}, + property: 'somePropertyName', + getValue: () => {}, + subscribe: () => {}, + unsubscribe: () => {}, }, null, ); }).toThrow('Invalid subscribable Component specified'); }); - it('should error for invalid missing subscribablePropertiesMap', () => { + it('should error for invalid missing property', () => { expect(() => { createComponent( { - getDataFor: () => {}, - subscribeTo: () => {}, - unsubscribeFrom: () => {}, + getValue: () => {}, + subscribe: () => {}, + unsubscribe: () => {}, }, () => null, ); - }).toThrow( - 'Subscribable config must specify a subscribablePropertiesMap map', - ); + }).toThrow('Subscribable config must specify a subscribable property'); }); - it('should error for invalid missing getDataFor', () => { + it('should error for invalid missing getValue', () => { expect(() => { createComponent( { - subscribablePropertiesMap: {}, - subscribeTo: () => {}, - unsubscribeFrom: () => {}, + property: 'somePropertyName', + subscribe: () => {}, + unsubscribe: () => {}, }, () => null, ); - }).toThrow('Subscribable config must specify a getDataFor function'); + }).toThrow('Subscribable config must specify a getValue function'); }); - it('should error for invalid missing subscribeTo', () => { + it('should error for invalid missing subscribe', () => { expect(() => { createComponent( { - subscribablePropertiesMap: {}, - getDataFor: () => {}, - unsubscribeFrom: () => {}, + property: 'somePropertyName', + getValue: () => {}, + unsubscribe: () => {}, }, () => null, ); - }).toThrow('Subscribable config must specify a subscribeTo function'); + }).toThrow('Subscribable config must specify a subscribe function'); }); - it('should error for invalid missing unsubscribeFrom', () => { + it('should error for invalid missing unsubscribe', () => { expect(() => { createComponent( { - subscribablePropertiesMap: {}, - getDataFor: () => {}, - subscribeTo: () => {}, + property: 'somePropertyName', + getValue: () => {}, + subscribe: () => {}, }, () => null, ); - }).toThrow('Subscribable config must specify a unsubscribeFrom function'); + }).toThrow('Subscribable config must specify a unsubscribe function'); }); }); }); diff --git a/packages/create-component-with-subscriptions/src/createComponentWithSubscriptions.js b/packages/create-component-with-subscriptions/src/createComponentWithSubscriptions.js index 56e6e0810cef6..71db4b3ed1849 100644 --- a/packages/create-component-with-subscriptions/src/createComponentWithSubscriptions.js +++ b/packages/create-component-with-subscriptions/src/createComponentWithSubscriptions.js @@ -10,71 +10,58 @@ import React from 'react'; import invariant from 'fbjs/lib/invariant'; -type SubscrptionConfig = { - // Maps property names of subscribable sources (e.g. 'eventDispatcher'), - // To state names for subscribed values (e.g. 'value'). - subscribablePropertiesMap: {[subscribableProperty: string]: string}, - - // Synchronously get data for a given subscribable property. - // If your component has multiple subscriptions, - // The second 'propertyName' parameter can be used to distinguish between them. - getDataFor: (subscribable: any, propertyName: string) => any, - - // Subscribe to a subscribable. - // Due to the variety of change event types, subscribers should provide their own handlers. - // Those handlers should NOT update state though; - // They should call the valueChangedCallback() instead when a subscription changes. - // If your component has multiple subscriptions, - // The third 'propertyName' parameter can be used to distinguish between them. - subscribeTo: ( - valueChangedCallback: (value: any) => void, - subscribable: any, - propertyName: string, - ) => any, - - // Unsubscribe from a given subscribable. - // If your component has multiple subscriptions, - // The second 'propertyName' parameter can be used to distinguish between them. - // The value returned by subscribeTo() is the third 'subscription' parameter. - unsubscribeFrom: ( - subscribable: any, - propertyName: string, - subscription: any, - ) => void, -}; - -export function createComponent( - config: SubscrptionConfig, +// TODO The below Flow types don't work for `Props` +export function createComponent( + config: {| + // Specifies the name of the subscribable property. + // The subscription value will be passed along using this same name. + // In the case of a functional component, it will be passed as a `prop` with this name. + // For a class component, it will be set in `state` with this name. + property: string, + + // Synchronously get the value for the subscribed property. + // Return undefined if the subscribable value is undefined, + // Or does not support synchronous reading (e.g. native Promise). + getValue: (props: Props) => Value, + + // Setup a subscription for the subscribable value in props. + // Due to the variety of change event types, subscribers should provide their own handlers. + // Those handlers should not attempt to update state though; + // They should call the valueChangedCallback() instead when a subscription changes. + // You may optionally return a subscription value to later unsubscribe (e.g. event handler). + subscribe: ( + props: Props, + valueChangedCallback: (value: Value) => void, + ) => Subscription, + + // Unsubsribe from the subscribable value in props. + // The subscription value returned from subscribe() is passed as the second parameter. + unsubscribe: (props: Props, subscription: Subscription) => void, + |}, Component: React$ComponentType<*>, -): React$ComponentType<*> { +): React$ComponentType { invariant(Component != null, 'Invalid subscribable Component specified'); invariant( - config.subscribablePropertiesMap !== null && - typeof config.subscribablePropertiesMap === 'object', - 'Subscribable config must specify a subscribablePropertiesMap map', + typeof config.property === 'string' && config.property !== '', + 'Subscribable config must specify a subscribable property', ); invariant( - typeof config.getDataFor === 'function', - 'Subscribable config must specify a getDataFor function', + typeof config.getValue === 'function', + 'Subscribable config must specify a getValue function', ); invariant( - typeof config.subscribeTo === 'function', - 'Subscribable config must specify a subscribeTo function', + typeof config.subscribe === 'function', + 'Subscribable config must specify a subscribe function', ); invariant( - typeof config.unsubscribeFrom === 'function', - 'Subscribable config must specify a unsubscribeFrom function', + typeof config.unsubscribe === 'function', + 'Subscribable config must specify a unsubscribe function', ); - const { - getDataFor, - subscribablePropertiesMap, - subscribeTo, - unsubscribeFrom, - } = config; + const {getValue, property, subscribe, unsubscribe} = config; // Unique state key name to avoid conflicts. - const getStateWrapperKey = propertyName => `____${propertyName}`; + const stateWrapperKey = `____${property}`; // If possible, extend the specified component to add subscriptions. // This preserves ref compatibility and avoids the overhead of an extra fiber. @@ -84,33 +71,33 @@ export function createComponent( // If this is a functional component, use a HOC. // Since functional components can't have refs, that isn't a problem. // Class component lifecycles are required, so a class component is needed anyway. - if (typeof prototype !== 'object' || typeof prototype.render !== 'function') { + if ( + typeof prototype !== 'object' || + typeof prototype.render !== 'function' || + Component.____isSubscriptionHOC === true + ) { BaseClass = class extends React.Component { + static ____isSubscriptionHOC = true; render() { - const subscribedValues = {}; - for (let propertyName in subscribablePropertiesMap) { - const stateValueKey = subscribablePropertiesMap[propertyName]; - subscribedValues[stateValueKey] = this.state[stateValueKey]; - } - - return ; + const props = { + ...this.props, + [property]: this.state[property], + }; + return React.createElement(Component, props); } }; } // Event listeners are only safe to add during the commit phase, // So they won't leak if render is interrupted or errors. - const subscribeToHelper = (subscribable, propertyName, instance) => { - if (subscribable != null) { - const stateWrapperKey = getStateWrapperKey(propertyName); - const stateValueKey = subscribablePropertiesMap[propertyName]; - + const subscribeHelper = (props, instance) => { + if (props[property] != null) { const wrapper = instance.state[stateWrapperKey]; const valueChangedCallback = value => { instance.setState(state => { // If the value is the same, skip the unnecessary state update. - if (state[stateValueKey] === value) { + if (state[property] === value) { return null; } @@ -125,7 +112,7 @@ export function createComponent( } return { - [stateValueKey]: value, + [property]: value, }; }); }; @@ -134,29 +121,24 @@ export function createComponent( // This is safe to do via mutation since: // 1) It does not impact render. // 2) This method will only be called during the "commit" phase. - wrapper.subscription = subscribeTo( - valueChangedCallback, - subscribable, - propertyName, - ); + wrapper.subscription = subscribe(props, valueChangedCallback); // External values could change between render and mount, // In some cases it may be important to handle this case. - const value = getDataFor(subscribable, propertyName); - if (value !== instance.state[stateValueKey]) { + const value = getValue(props); + if (value !== instance.state[property]) { instance.setState({ - [stateValueKey]: value, + [property]: value, }); } } }; - const unsubscribeFromHelper = (subscribable, propertyName, instance) => { - if (subscribable != null) { - const stateWrapperKey = getStateWrapperKey(propertyName); + const unsubscribeHelper = (props, instance) => { + if (props[property] != null) { const wrapper = instance.state[stateWrapperKey]; - unsubscribeFrom(subscribable, propertyName, wrapper.subscription); + unsubscribe(props, wrapper.subscription); wrapper.subscription = null; } @@ -181,28 +163,21 @@ export function createComponent( let hasUpdates = false; // Read value (if sync read is possible) for upcoming render - for (let propertyName in subscribablePropertiesMap) { - const stateWrapperKey = getStateWrapperKey(propertyName); - const stateValueKey = subscribablePropertiesMap[propertyName]; - - const prevSubscribable = - prevState[stateWrapperKey] !== undefined - ? prevState[stateWrapperKey].subscribable - : null; - const nextSubscribable = nextProps[propertyName]; - - if (prevSubscribable !== nextSubscribable) { - nextState[stateWrapperKey] = { - ...prevState[stateWrapperKey], - subscribable: nextSubscribable, - }; - nextState[stateValueKey] = - nextSubscribable != null - ? getDataFor(nextSubscribable, propertyName) - : undefined; + const prevSubscribable = + prevState[stateWrapperKey] !== undefined + ? prevState[stateWrapperKey].subscribable + : null; + const nextSubscribable = nextProps[property]; - hasUpdates = true; - } + if (prevSubscribable !== nextSubscribable) { + nextState[stateWrapperKey] = { + ...prevState[stateWrapperKey], + subscribable: nextSubscribable, + }; + nextState[property] = + nextSubscribable != null ? getValue(nextProps) : undefined; + + hasUpdates = true; } const nextSuperState = @@ -220,10 +195,7 @@ export function createComponent( super.componentDidMount(); } - for (let propertyName in subscribablePropertiesMap) { - const subscribable = this.props[propertyName]; - subscribeToHelper(subscribable, propertyName, this); - } + subscribeHelper(this.props, this); } componentDidUpdate(prevProps, prevState) { @@ -231,13 +203,9 @@ export function createComponent( super.componentDidUpdate(prevProps, prevState); } - for (let propertyName in subscribablePropertiesMap) { - const prevSubscribable = prevProps[propertyName]; - const nextSubscribable = this.props[propertyName]; - if (prevSubscribable !== nextSubscribable) { - unsubscribeFromHelper(prevSubscribable, propertyName, this); - subscribeToHelper(nextSubscribable, propertyName, this); - } + if (prevProps[property] !== this.props[property]) { + unsubscribeHelper(prevProps, this); + subscribeHelper(this.props, this); } } @@ -246,10 +214,7 @@ export function createComponent( super.componentWillUnmount(); } - for (let propertyName in subscribablePropertiesMap) { - const subscribable = this.props[propertyName]; - unsubscribeFromHelper(subscribable, propertyName, this); - } + unsubscribeHelper(this.props, this); } } From fdfa22b413a4e551198084376385d3f57c015653 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Tue, 6 Mar 2018 15:03:57 -0800 Subject: [PATCH 24/53] Docs tweak --- packages/create-component-with-subscriptions/README.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/create-component-with-subscriptions/README.md b/packages/create-component-with-subscriptions/README.md index 9feb5d31fb9d1..cca86f62b41fc 100644 --- a/packages/create-component-with-subscriptions/README.md +++ b/packages/create-component-with-subscriptions/README.md @@ -115,7 +115,7 @@ function InnerComponent({ followers, username }) { const FollowerCountComponent = createComponent( { property: "followers", - getValue: props => subscribable.value, + getValue: props => props.followers.value, subscribe: (props, valueChangedCallback) => { const { followers } = props; const onChange = event => valueChangedCallback(followers.value); @@ -137,9 +137,7 @@ const FollowerCountComponent = createComponent( ## Subscribing to observables -TODO Break up - -Below is an example showing how `create-component-with-subscriptions` can be used to subscribe to certain types of observables (e.g. RxJS `BehaviorSubject` and `ReplaySubject`). +Below are examples showing how `create-component-with-subscriptions` 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. From afeb6cd4c09d5dd574bd5e031679b78edbe32b03 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Tue, 6 Mar 2018 15:05:28 -0800 Subject: [PATCH 25/53] Docs tweaks --- .../README.md | 45 ++++--------------- 1 file changed, 8 insertions(+), 37 deletions(-) diff --git a/packages/create-component-with-subscriptions/README.md b/packages/create-component-with-subscriptions/README.md index cca86f62b41fc..b0a266ccc1710 100644 --- a/packages/create-component-with-subscriptions/README.md +++ b/packages/create-component-with-subscriptions/README.md @@ -143,49 +143,22 @@ Below are examples showing how `create-component-with-subscriptions` can be used ### `BehaviorSubject` ```js -import React from "react"; -import createComponent from "create-component-with-subscriptions"; - -// Start with a simple component. -// In this case, it's a functional component, but it could have been a class. -function InnerComponent({ behaviorSubject }) { - // Render ... -} - -// Add subscription logic mixin to the class component. -// The mixin will manage subscriptions and store the values in state. -// It will add and remove subscriptions in an async-safe way when props change. const SubscribedComponent = createComponent( { property: "behaviorSubject", - getValue: (props) => props.behaviorSubject.getValue(), + getValue: props => props.behaviorSubject.getValue(), subscribe: (props, valueChangedCallback) => props.behaviorSubject.subscribe(valueChangedCallback), - unsubscribe: (props, subscription) => - subscription.unsubscribe() + unsubscribe: (props, subscription) => subscription.unsubscribe() }, - InnerComponent + ({ behaviorSubject }) => { + // Render ... + } ); - -// Your component can now be used as shown below. -// In this example, both properties below represent RxJS types with the same name. -; ``` ### `ReplaySubject` ```js -import React from "react"; -import createComponent from "create-component-with-subscriptions"; - -// Start with a simple component. -// In this case, it's a functional component, but it could have been a class. -function InnerComponent({ replaySubject }) { - // Render ... -} - -// Add subscription logic mixin to the class component. -// The mixin will manage subscriptions and store the values in state. -// It will add and remove subscriptions in an async-safe way when props change. const SubscribedComponent = createComponent( { property: "replaySubject", @@ -203,12 +176,10 @@ const SubscribedComponent = createComponent( props.replaySubject.subscribe(valueChangedCallback), unsubscribe: (props, subscription) => subscription.unsubscribe() }, - InnerComponent + ({ replaySubject }) => { + // Render ... + } ); - -// Your component can now be used as shown below. -// In this example, both properties below represent RxJS types with the same name. -; ``` ## Subscribing to a Promise From 753218433e0b8faa49fbb7b5288b546be2ddc2f1 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Wed, 7 Mar 2018 08:49:11 -0800 Subject: [PATCH 26/53] Refactored create-subscription to more closely mimic context API --- ...omponentWithSubscriptions-test.internal.js | 347 --------------- .../createComponentWithSubscriptions-test.js | 414 ++++++++---------- .../src/createComponentWithSubscriptions.js | 305 +++++-------- 3 files changed, 297 insertions(+), 769 deletions(-) delete mode 100644 packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.internal.js diff --git a/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.internal.js b/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.internal.js deleted file mode 100644 index bf131ea84bf8a..0000000000000 --- a/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.internal.js +++ /dev/null @@ -1,347 +0,0 @@ -/** - * Copyright (c) 2013-present, Facebook, Inc. - * - * 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 createComponent; -let React; -let ReactFeatureFlags; -let ReactNoop; - -describe('CreateComponentWithSubscriptions', () => { - beforeEach(() => { - jest.resetModules(); - createComponent = require('create-component-with-subscriptions') - .createComponent; - ReactFeatureFlags = require('shared/ReactFeatureFlags'); - ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false; - React = require('react'); - ReactNoop = require('react-noop-renderer'); - }); - - // Mimics a partial interface of RxJS `BehaviorSubject` - function createFauxBehaviorSubject(initialValue) { - let currentValue = initialValue; - let subscribedCallbacks = []; - return { - getValue: () => currentValue, - subscribe: callback => { - subscribedCallbacks.push(callback); - return { - unsubscribe: () => { - subscribedCallbacks.splice( - subscribedCallbacks.indexOf(callback), - 1, - ); - }, - }; - }, - update: value => { - currentValue = value; - subscribedCallbacks.forEach(subscribedCallback => - subscribedCallback(value), - ); - }, - }; - } - - describe('class component', () => { - it('should support class components', () => { - const Subscriber = createComponent( - { - property: 'observed', - getValue: props => props.observed.getValue(), - subscribe: (props, valueChangedCallback) => - props.observed.subscribe(valueChangedCallback), - unsubscribe: (props, subscription) => subscription.unsubscribe(), - }, - class extends React.Component { - state = {}; - render() { - ReactNoop.yield(this.state.observed); - return null; - } - }, - ); - - const observable = createFauxBehaviorSubject('initial'); - - ReactNoop.render(); - expect(ReactNoop.flush()).toEqual(['initial']); - observable.update('updated'); - expect(ReactNoop.flush()).toEqual(['updated']); - - // Unsetting the subscriber prop should reset subscribed values - ReactNoop.render(); - expect(ReactNoop.flush()).toEqual([undefined]); - }); - - it('should class mixed-in class component lifecycles', () => { - const log = []; - const Subscriber = createComponent( - { - property: 'observed', - getValue: props => props.observed.getValue(), - subscribe: (props, valueChangedCallback) => - props.observed.subscribe(valueChangedCallback), - unsubscribe: (props, subscription) => subscription.unsubscribe(), - }, - class extends React.Component { - state = { - foo: 1, - }; - constructor(props) { - super(props); - log.push('constructor'); - } - static getDerivedStateFromProps(nextProps, prevState) { - log.push('getDerivedStateFromProps'); - return { - foo: prevState.foo + 1, - }; - } - componentDidMount() { - log.push('componentDidMount'); - } - componentDidUpdate(prevProps, prevState) { - log.push('componentDidUpdate'); - } - componentWillUnmount() { - log.push('componentWillUnmount'); - } - render() { - ReactNoop.yield({ - foo: this.state.foo, - observed: this.state.observed, - }); - return null; - } - }, - ); - - const observable = createFauxBehaviorSubject('initial'); - - ReactNoop.render(); - expect(ReactNoop.flush()).toEqual([{foo: 2, observed: 'initial'}]); - expect(log).toEqual([ - 'constructor', - 'getDerivedStateFromProps', - 'componentDidMount', - ]); - log.length = 0; - observable.update('updated'); - expect(ReactNoop.flush()).toEqual([{foo: 2, observed: 'updated'}]); - expect(log).toEqual(['componentDidUpdate']); - - // Unsetting the subscriber prop should reset subscribed values - log.length = 0; - ReactNoop.render(); - expect(ReactNoop.flush()).toEqual([{foo: 3, observed: undefined}]); - expect(log).toEqual(['getDerivedStateFromProps', 'componentDidUpdate']); - - // Test unmounting lifecycle as well - log.length = 0; - ReactNoop.render(
); - expect(ReactNoop.flush()).toEqual([]); - expect(log).toEqual(['componentWillUnmount']); - }); - - it('should not mask the displayName used for errors and DevTools', () => { - const Subscriber = createComponent( - { - property: 'observed', - getValue: props => props.observed.getValue(), - subscribe: (props, valueChangedCallback) => - props.observed.subscribe(valueChangedCallback), - unsubscribe: (props, subscription) => subscription.unsubscribe(), - }, - class MyExampleComponent extends React.Component { - static displayName = 'MyExampleComponent'; - state = {}; - render() { - return null; - } - }, - ); - - expect(Subscriber.displayName).toBe('MyExampleComponent'); - }); - - it('should preserve refs attached to class components', () => { - class MyExampleComponent extends React.Component { - state = {}; - customMethod() {} - render() { - ReactNoop.yield(this.state.observed); - return null; - } - } - const Subscriber = createComponent( - { - property: 'observed', - getValue: props => props.observed.getValue(), - subscribe: (props, valueChangedCallback) => - props.observed.subscribe(valueChangedCallback), - unsubscribe: (props, subscription) => subscription.unsubscribe(), - }, - MyExampleComponent, - ); - - const observable = createFauxBehaviorSubject('initial'); - const ref = React.createRef(); - - ReactNoop.render(); - expect(ReactNoop.flush()).toEqual(['initial']); - - expect(ref.value instanceof MyExampleComponent).toBe(true); - expect(typeof ref.value.customMethod).toBe('function'); - }); - }); - - it('should support create-react-class components', () => { - const createReactClass = require('create-react-class/factory')( - React.Component, - React.isValidElement, - new React.Component().updater, - ); - - const log = []; - - const Component = createReactClass({ - mixins: [ - { - componentDidMount() { - log.push('mixin componentDidMount'); - }, - componentDidUpdate() { - log.push('mixin componentDidUpdate'); - }, - componentWillUnmount() { - log.push('mixin componentWillUnmount'); - }, - statics: { - getDerivedStateFromProps() { - log.push('mixin getDerivedStateFromProps'); - return null; - }, - }, - }, - ], - getInitialState() { - return {}; - }, - componentDidMount() { - log.push('componentDidMount'); - }, - componentDidUpdate() { - log.push('componentDidUpdate'); - }, - componentWillUnmount() { - log.push('componentWillUnmount'); - }, - render() { - ReactNoop.yield(this.state.observed); - return null; - }, - statics: { - getDerivedStateFromProps() { - log.push('getDerivedStateFromProps'); - return null; - }, - }, - }); - - const Subscriber = createComponent( - { - property: 'observed', - getValue: props => props.observed.getValue(), - subscribe: (props, valueChangedCallback) => - props.observed.subscribe(valueChangedCallback), - unsubscribe: (props, subscription) => subscription.unsubscribe(), - }, - Component, - ); - - const observable = createFauxBehaviorSubject('initial'); - - ReactNoop.render(); - expect(ReactNoop.flush()).toEqual(['initial']); - expect(log).toEqual([ - 'mixin getDerivedStateFromProps', - 'getDerivedStateFromProps', - 'mixin componentDidMount', - 'componentDidMount', - ]); - log.length = 0; - observable.update('updated'); - expect(ReactNoop.flush()).toEqual(['updated']); - expect(log).toEqual(['mixin componentDidUpdate', 'componentDidUpdate']); - - // Unsetting the subscriber prop should reset subscribed values - log.length = 0; - ReactNoop.render(); - expect(ReactNoop.flush()).toEqual([undefined]); - expect(log).toEqual([ - 'mixin getDerivedStateFromProps', - 'getDerivedStateFromProps', - 'mixin componentDidUpdate', - 'componentDidUpdate', - ]); - - // Test unmounting lifecycle as well - log.length = 0; - ReactNoop.render(
); - expect(ReactNoop.flush()).toEqual([]); - expect(log).toEqual(['mixin componentWillUnmount', 'componentWillUnmount']); - }); - - it('should be compatible with react-lifecycles-compat', () => { - const polyfill = require('react-lifecycles-compat'); - - class MyExampleComponent extends React.Component { - state = { - foo: 1, - }; - static getDerivedStateFromProps(nextProps, prevState) { - return { - foo: prevState.foo + 1, - }; - } - render() { - ReactNoop.yield({foo: this.state.foo, observed: this.state.observed}); - return null; - } - } - const Subscriber = createComponent( - { - property: 'observed', - getValue: props => props.observed.getValue(), - subscribe: (props, valueChangedCallback) => - props.observed.subscribe(valueChangedCallback), - unsubscribe: (props, subscription) => subscription.unsubscribe(), - }, - polyfill(MyExampleComponent), - ); - - const observable = createFauxBehaviorSubject('initial'); - - ReactNoop.render(); - expect(ReactNoop.flush()).toEqual([{foo: 2, observed: 'initial'}]); - observable.update('updated'); - expect(ReactNoop.flush()).toEqual([{foo: 2, observed: 'updated'}]); - - // Unsetting the subscriber prop should reset subscribed values - ReactNoop.render(); - expect(ReactNoop.flush()).toEqual([{foo: 3, observed: undefined}]); - - // Test unmounting lifecycle as well - ReactNoop.render(
); - expect(ReactNoop.flush()).toEqual([]); - }); -}); diff --git a/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.js b/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.js index 42afc30860f01..693cbd9372fa9 100644 --- a/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.js +++ b/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.js @@ -9,15 +9,15 @@ 'use strict'; -let createComponent; +let createSubscription; let React; let ReactNoop; -describe('CreateComponentWithSubscriptions', () => { +describe('createSubscription', () => { beforeEach(() => { jest.resetModules(); - createComponent = require('create-component-with-subscriptions') - .createComponent; + createSubscription = require('create-component-with-subscriptions') + .createSubscription; React = require('react'); ReactNoop = require('react-noop-renderer'); }); @@ -61,29 +61,30 @@ describe('CreateComponentWithSubscriptions', () => { } it('supports basic subscription pattern', () => { - const Subscriber = createComponent( - { - property: 'observed', - getValue: props => props.observed.getValue(), - subscribe: (props, valueChangedCallback) => - props.observed.subscribe(valueChangedCallback), - unsubscribe: (props, subscription) => subscription.unsubscribe(), - }, - ({observed = 'default'}) => { - ReactNoop.yield(observed); - return null; - }, - ); + const Subscription = createSubscription({ + getValue: source => source.getValue(), + subscribe: (source, valueChangedCallback) => + source.subscribe(valueChangedCallback), + unsubscribe: (source, subscription) => subscription.unsubscribe(), + }); const observable = createFauxBehaviorSubject(); - ReactNoop.render(); + ReactNoop.render( + + {(value = 'default') => { + ReactNoop.yield(value); + return null; + }} + , + ); // Updates while subscribed should re-render the child component - expect(ReactNoop.flush()).toEqual(['default']); + // NOTE: Redundant yields are expected due to 'debugRenderPhaseSideEffectsForStrictMode' + expect(ReactNoop.flush()).toEqual(['default', 'default']); observable.update(123); - expect(ReactNoop.flush()).toEqual([123]); + expect(ReactNoop.flush()).toEqual([123, 123]); observable.update('abc'); - expect(ReactNoop.flush()).toEqual(['abc']); + expect(ReactNoop.flush()).toEqual(['abc', 'abc']); // Unmounting the subscriber should remove listeners ReactNoop.render(
); @@ -91,108 +92,68 @@ describe('CreateComponentWithSubscriptions', () => { expect(ReactNoop.flush()).toEqual([]); }); - it('supports multiple subscriptions', () => { - const InnerComponent = ({bar, foo}) => { - ReactNoop.yield(`bar:${bar}, foo:${foo}`); - return null; - }; - - const Subscriber = createComponent( - { - property: 'foo', - getValue: props => props.foo.getValue(), - subscribe: (props, valueChangedCallback) => - props.foo.subscribe(valueChangedCallback), - unsubscribe: (props, subscription) => subscription.unsubscribe(), - }, - createComponent( - { - property: 'bar', - getValue: props => props.bar.getValue(), - subscribe: (props, valueChangedCallback) => - props.bar.subscribe(valueChangedCallback), - unsubscribe: (props, subscription) => subscription.unsubscribe(), - }, - InnerComponent, - ), - ); - - const foo = createFauxBehaviorSubject(); - const bar = createFauxBehaviorSubject(); - - ReactNoop.render(); - - // Updates while subscribed should re-render the child component - expect(ReactNoop.flush()).toEqual([`bar:undefined, foo:undefined`]); - foo.update(123); - expect(ReactNoop.flush()).toEqual([`bar:undefined, foo:123`]); - bar.update('abc'); - expect(ReactNoop.flush()).toEqual([`bar:abc, foo:123`]); - foo.update(456); - expect(ReactNoop.flush()).toEqual([`bar:abc, foo:456`]); - - // Unsetting the subscriber prop should reset subscribed values - ReactNoop.render(); - expect(ReactNoop.flush()).toEqual([`bar:undefined, foo:undefined`]); - }); - it('should support observable types like RxJS ReplaySubject', () => { - const Subscriber = createComponent( - { - property: 'observed', - getValue: props => { - let currentValue; - const temporarySubscription = props.observed.subscribe(value => { - currentValue = value; - }); - temporarySubscription.unsubscribe(); - return currentValue; - }, - subscribe: (props, valueChangedCallback) => - props.observed.subscribe(valueChangedCallback), - unsubscribe: (props, subscription) => subscription.unsubscribe(), + const Subscription = createSubscription({ + getValue: source => { + let currentValue; + const temporarySubscription = source.subscribe(value => { + currentValue = value; + }); + temporarySubscription.unsubscribe(); + return currentValue; }, - ({observed}) => { - ReactNoop.yield(observed); - return null; - }, - ); + subscribe: (source, valueChangedCallback) => + source.subscribe(valueChangedCallback), + unsubscribe: (source, subscription) => subscription.unsubscribe(), + }); const observable = createFauxReplaySubject('initial'); - ReactNoop.render(); - expect(ReactNoop.flush()).toEqual(['initial']); + // NOTE: Redundant yields are expected due to 'debugRenderPhaseSideEffectsForStrictMode' + ReactNoop.render( + + {(value = 'default') => { + ReactNoop.yield(value); + return null; + }} + , + ); + expect(ReactNoop.flush()).toEqual(['initial', 'initial']); observable.update('updated'); - expect(ReactNoop.flush()).toEqual(['updated']); + expect(ReactNoop.flush()).toEqual(['updated', 'updated']); // Unsetting the subscriber prop should reset subscribed values - ReactNoop.render(); - expect(ReactNoop.flush()).toEqual([undefined]); + ReactNoop.render( + + {(value = 'default') => { + ReactNoop.yield(value); + return null; + }} + , + ); + expect(ReactNoop.flush()).toEqual(['default', 'default']); }); describe('Promises', () => { it('should support Promises', async () => { - const Subscriber = createComponent( - { - property: 'hasLoaded', - getValue: props => undefined, - subscribe: (props, valueChangedCallback) => { - props.hasLoaded.then( - () => valueChangedCallback(true), - () => valueChangedCallback(false), - ); - }, - unsubscribe: (props, subscription) => {}, - }, - ({hasLoaded}) => { - if (hasLoaded === undefined) { - ReactNoop.yield('loading'); - } else { - ReactNoop.yield(hasLoaded ? 'finished' : 'failed'); - } - return null; - }, - ); + const Subscription = createSubscription({ + getValue: source => undefined, + subscribe: (source, valueChangedCallback) => + source.then( + () => valueChangedCallback(true), + () => valueChangedCallback(false), + ), + unsubscribe: (source, subscription) => {}, + }); + + function childrenFunction(hasLoaded) { + if (hasLoaded === undefined) { + ReactNoop.yield('loading'); + } else { + ReactNoop.yield(hasLoaded ? 'finished' : 'failed'); + } + return null; + } let resolveA, rejectB; const promiseA = new Promise((resolve, reject) => { @@ -203,46 +164,54 @@ describe('CreateComponentWithSubscriptions', () => { }); // Test a promise that resolves after render - ReactNoop.render(); - expect(ReactNoop.flush()).toEqual(['loading']); + // NOTE: Redundant yields are expected due to 'debugRenderPhaseSideEffectsForStrictMode' + ReactNoop.render( + {childrenFunction}, + ); + expect(ReactNoop.flush()).toEqual(['loading', 'loading']); resolveA(); await promiseA; - expect(ReactNoop.flush()).toEqual(['finished']); + expect(ReactNoop.flush()).toEqual(['finished', 'finished']); // Test a promise that resolves before render // Note that this will require an extra render anyway, // Because there is no way to syncrhonously get a Promise's value rejectB(); - ReactNoop.render(); - expect(ReactNoop.flush()).toEqual(['loading']); + ReactNoop.render( + {childrenFunction}, + ); + expect(ReactNoop.flush()).toEqual(['loading', 'loading']); await promiseB.catch(() => true); - expect(ReactNoop.flush()).toEqual(['failed']); + expect(ReactNoop.flush()).toEqual(['failed', 'failed']); }); it('should still work if unsubscription is managed incorrectly', async () => { - const Subscriber = createComponent( - { - property: 'promised', - getValue: props => undefined, - subscribe: (props, valueChangedCallback) => - props.promised.then(valueChangedCallback), - unsubscribe: (props, subscription) => {}, - }, - ({promised}) => { - ReactNoop.yield(promised); - return null; - }, - ); + const Subscription = createSubscription({ + getValue: source => undefined, + subscribe: (source, valueChangedCallback) => + source.then(valueChangedCallback), + unsubscribe: (source, subscription) => {}, + }); + + function childrenFunction(value = 'default') { + ReactNoop.yield(value); + return null; + } let resolveA, resolveB; const promiseA = new Promise(resolve => (resolveA = resolve)); const promiseB = new Promise(resolve => (resolveB = resolve)); // Subscribe first to Promise A then Promsie B - ReactNoop.render(); - expect(ReactNoop.flush()).toEqual([undefined]); - ReactNoop.render(); - expect(ReactNoop.flush()).toEqual([undefined]); + // NOTE: Redundant yields are expected due to 'debugRenderPhaseSideEffectsForStrictMode' + ReactNoop.render( + {childrenFunction}, + ); + expect(ReactNoop.flush()).toEqual(['default', 'default']); + ReactNoop.render( + {childrenFunction}, + ); + expect(ReactNoop.flush()).toEqual(['default', 'default']); // Resolve both Promises resolveB(123); @@ -250,36 +219,39 @@ describe('CreateComponentWithSubscriptions', () => { await Promise.all([promiseA, promiseB]); // Ensure that only Promise B causes an update - expect(ReactNoop.flush()).toEqual([123]); + expect(ReactNoop.flush()).toEqual([123, 123]); }); }); it('should unsubscribe from old subscribables and subscribe to new subscribables when props change', () => { - const Subscriber = createComponent( - { - property: 'observed', - getValue: props => props.observed.getValue(), - subscribe: (props, valueChangedCallback) => - props.observed.subscribe(valueChangedCallback), - unsubscribe: (props, subscription) => subscription.unsubscribe(), - }, - ({observed}) => { - ReactNoop.yield(observed); - return null; - }, - ); + const Subscription = createSubscription({ + getValue: source => source.getValue(), + subscribe: (source, valueChangedCallback) => + source.subscribe(valueChangedCallback), + unsubscribe: (source, subscription) => subscription.unsubscribe(), + }); + + function childrenFunction(value = 'default') { + ReactNoop.yield(value); + return null; + } const observableA = createFauxBehaviorSubject('a-0'); const observableB = createFauxBehaviorSubject('b-0'); - ReactNoop.render(); + // NOTE: Redundant yields are expected due to 'debugRenderPhaseSideEffectsForStrictMode' + ReactNoop.render( + {childrenFunction}, + ); // Updates while subscribed should re-render the child component - expect(ReactNoop.flush()).toEqual(['a-0']); + expect(ReactNoop.flush()).toEqual(['a-0', 'a-0']); // Unsetting the subscriber prop should reset subscribed values - ReactNoop.render(); - expect(ReactNoop.flush()).toEqual(['b-0']); + ReactNoop.render( + {childrenFunction}, + ); + expect(ReactNoop.flush()).toEqual(['b-0', 'b-0']); // Updates to the old subscribable should not re-render the child component observableA.update('a-1'); @@ -287,7 +259,7 @@ describe('CreateComponentWithSubscriptions', () => { // Updates to the bew subscribable should re-render the child component observableB.update('b-1'); - expect(ReactNoop.flush()).toEqual(['b-1']); + expect(ReactNoop.flush()).toEqual(['b-1', 'b-1']); }); it('should ignore values emitted by a new subscribable until the commit phase', () => { @@ -298,19 +270,12 @@ describe('CreateComponentWithSubscriptions', () => { return null; } - const Subscriber = createComponent( - { - property: 'observed', - getValue: props => props.observed.getValue(), - subscribe: (props, valueChangedCallback) => - props.observed.subscribe(valueChangedCallback), - unsubscribe: (props, subscription) => subscription.unsubscribe(), - }, - ({observed}) => { - ReactNoop.yield('Subscriber: ' + observed); - return ; - }, - ); + const Subscription = createSubscription({ + getValue: source => source.getValue(), + subscribe: (source, valueChangedCallback) => + source.subscribe(valueChangedCallback), + unsubscribe: (source, subscription) => subscription.unsubscribe(), + }); class Parent extends React.Component { state = {}; @@ -328,19 +293,31 @@ describe('CreateComponentWithSubscriptions', () => { render() { parentInstance = this; - return ; + return ( + + {(value = 'default') => { + ReactNoop.yield('Subscriber: ' + value); + return ; + }} + + ); } } const observableA = createFauxBehaviorSubject('a-0'); const observableB = createFauxBehaviorSubject('b-0'); + // NOTE: Redundant yields are expected due to 'debugRenderPhaseSideEffectsForStrictMode' ReactNoop.render(); - expect(ReactNoop.flush()).toEqual(['Subscriber: a-0', 'Child: a-0']); + expect(ReactNoop.flush()).toEqual([ + 'Subscriber: a-0', + 'Subscriber: a-0', + 'Child: a-0', + ]); // Start React update, but don't finish ReactNoop.render(); - ReactNoop.flushThrough(['Subscriber: b-0']); + ReactNoop.flushThrough(['Subscriber: b-0', 'Subscriber: b-0']); // Emit some updates from the uncommitted subscribable observableB.update('b-1'); @@ -357,8 +334,10 @@ describe('CreateComponentWithSubscriptions', () => { expect(ReactNoop.flush()).toEqual([ 'Child: b-0', 'Subscriber: b-3', + 'Subscriber: b-3', 'Child: b-3', 'Subscriber: a-0', + 'Subscriber: a-0', 'Child: a-0', ]); }); @@ -371,19 +350,12 @@ describe('CreateComponentWithSubscriptions', () => { return null; } - const Subscriber = createComponent( - { - property: 'observed', - getValue: props => props.observed.getValue(), - subscribe: (props, valueChangedCallback) => - props.observed.subscribe(valueChangedCallback), - unsubscribe: (props, subscription) => subscription.unsubscribe(), - }, - ({observed}) => { - ReactNoop.yield('Subscriber: ' + observed); - return ; - }, - ); + const Subscription = createSubscription({ + getValue: source => source.getValue(), + subscribe: (source, valueChangedCallback) => + source.subscribe(valueChangedCallback), + unsubscribe: (source, subscription) => subscription.unsubscribe(), + }); class Parent extends React.Component { state = {}; @@ -401,19 +373,31 @@ describe('CreateComponentWithSubscriptions', () => { render() { parentInstance = this; - return ; + return ( + + {(value = 'default') => { + ReactNoop.yield('Subscriber: ' + value); + return ; + }} + + ); } } const observableA = createFauxBehaviorSubject('a-0'); const observableB = createFauxBehaviorSubject('b-0'); + // NOTE: Redundant yields are expected due to 'debugRenderPhaseSideEffectsForStrictMode' ReactNoop.render(); - expect(ReactNoop.flush()).toEqual(['Subscriber: a-0', 'Child: a-0']); + expect(ReactNoop.flush()).toEqual([ + 'Subscriber: a-0', + 'Subscriber: a-0', + 'Child: a-0', + ]); // Start React update, but don't finish ReactNoop.render(); - ReactNoop.flushThrough(['Subscriber: b-0']); + ReactNoop.flushThrough(['Subscriber: b-0', 'Subscriber: b-0']); // Emit some updates from the old subscribable observableA.update('a-1'); @@ -428,6 +412,7 @@ describe('CreateComponentWithSubscriptions', () => { expect(ReactNoop.flush()).toEqual([ 'Child: b-0', 'Subscriber: a-2', + 'Subscriber: a-2', 'Child: a-2', ]); @@ -436,57 +421,10 @@ describe('CreateComponentWithSubscriptions', () => { expect(ReactNoop.flush()).toEqual([]); }); - it('should pass all non-subscribable props through to the child component', () => { - const Subscriber = createComponent( - { - property: 'observed', - getValue: props => props.observed.getValue(), - subscribe: (props, valueChangedCallback) => - props.observed.subscribe(valueChangedCallback), - unsubscribe: (props, subscription) => subscription.unsubscribe(), - }, - ({bar, foo, observed}) => { - ReactNoop.yield(`bar:${bar}, foo:${foo}, observed:${observed}`); - return null; - }, - ); - - const observable = createFauxBehaviorSubject(true); - ReactNoop.render(); - expect(ReactNoop.flush()).toEqual(['bar:abc, foo:123, observed:true']); - }); - describe('invariants', () => { - it('should error for invalid Component', () => { - expect(() => { - createComponent( - { - property: 'somePropertyName', - getValue: () => {}, - subscribe: () => {}, - unsubscribe: () => {}, - }, - null, - ); - }).toThrow('Invalid subscribable Component specified'); - }); - - it('should error for invalid missing property', () => { - expect(() => { - createComponent( - { - getValue: () => {}, - subscribe: () => {}, - unsubscribe: () => {}, - }, - () => null, - ); - }).toThrow('Subscribable config must specify a subscribable property'); - }); - it('should error for invalid missing getValue', () => { expect(() => { - createComponent( + createSubscription( { property: 'somePropertyName', subscribe: () => {}, @@ -494,12 +432,12 @@ describe('CreateComponentWithSubscriptions', () => { }, () => null, ); - }).toThrow('Subscribable config must specify a getValue function'); + }).toThrow('Subscription must specify a getValue function'); }); it('should error for invalid missing subscribe', () => { expect(() => { - createComponent( + createSubscription( { property: 'somePropertyName', getValue: () => {}, @@ -507,12 +445,12 @@ describe('CreateComponentWithSubscriptions', () => { }, () => null, ); - }).toThrow('Subscribable config must specify a subscribe function'); + }).toThrow('Subscription must specify a subscribe function'); }); it('should error for invalid missing unsubscribe', () => { expect(() => { - createComponent( + createSubscription( { property: 'somePropertyName', getValue: () => {}, @@ -520,7 +458,7 @@ describe('CreateComponentWithSubscriptions', () => { }, () => null, ); - }).toThrow('Subscribable config must specify a unsubscribe function'); + }).toThrow('Subscription must specify a unsubscribe function'); }); }); }); diff --git a/packages/create-component-with-subscriptions/src/createComponentWithSubscriptions.js b/packages/create-component-with-subscriptions/src/createComponentWithSubscriptions.js index 71db4b3ed1849..565a8899a715c 100644 --- a/packages/create-component-with-subscriptions/src/createComponentWithSubscriptions.js +++ b/packages/create-component-with-subscriptions/src/createComponentWithSubscriptions.js @@ -8,215 +8,152 @@ */ import React from 'react'; -import invariant from 'fbjs/lib/invariant'; - -// TODO The below Flow types don't work for `Props` -export function createComponent( - config: {| - // Specifies the name of the subscribable property. - // The subscription value will be passed along using this same name. - // In the case of a functional component, it will be passed as a `prop` with this name. - // For a class component, it will be set in `state` with this name. - property: string, - - // Synchronously get the value for the subscribed property. - // Return undefined if the subscribable value is undefined, - // Or does not support synchronous reading (e.g. native Promise). - getValue: (props: Props) => Value, - - // Setup a subscription for the subscribable value in props. - // Due to the variety of change event types, subscribers should provide their own handlers. - // Those handlers should not attempt to update state though; - // They should call the valueChangedCallback() instead when a subscription changes. - // You may optionally return a subscription value to later unsubscribe (e.g. event handler). - subscribe: ( - props: Props, - valueChangedCallback: (value: Value) => void, - ) => Subscription, - - // Unsubsribe from the subscribable value in props. - // The subscription value returned from subscribe() is passed as the second parameter. - unsubscribe: (props: Props, subscription: Subscription) => void, - |}, - Component: React$ComponentType<*>, -): React$ComponentType { - invariant(Component != null, 'Invalid subscribable Component specified'); - invariant( - typeof config.property === 'string' && config.property !== '', - 'Subscribable config must specify a subscribable property', +import warning from 'fbjs/lib/invariant'; + +export function createSubscription< + Property, + CreatedSubscription, + Value, +>(config: {| + // Synchronously gets the value for the subscribed property. + // Return undefined if the subscribable value is undefined, + // Or does not support synchronous reading (e.g. native Promise). + +getValue: (source: Property) => Value, + + // Setup a subscription for the subscribable value in props. + // Due to the variety of change event types, subscribers should provide their own handlers. + // Those handlers should not attempt to update state though; + // They should call the valueChangedCallback() instead when a subscription changes. + // You may optionally return a subscription value to later unsubscribe (e.g. event handler). + +subscribe: ( + source: Property, + valueChangedCallback: (value: Value) => void, + ) => CreatedSubscription, + + // Unsubsribe from the subscribable value in props. + // The subscription value returned from subscribe() is passed as the second parameter. + +unsubscribe: (source: Property, subscription: CreatedSubscription) => void, +|}): React$ComponentType<{ + children: (value: Value) => React$Element, + source: any, +}> { + const {getValue, subscribe, unsubscribe} = config; + + warning( + typeof getValue === 'function', + 'Subscription must specify a getValue function', ); - invariant( - typeof config.getValue === 'function', - 'Subscribable config must specify a getValue function', + warning( + typeof subscribe === 'function', + 'Subscription must specify a subscribe function', ); - invariant( - typeof config.subscribe === 'function', - 'Subscribable config must specify a subscribe function', - ); - invariant( - typeof config.unsubscribe === 'function', - 'Subscribable config must specify a unsubscribe function', + warning( + typeof unsubscribe === 'function', + 'Subscription must specify a unsubscribe function', ); - const {getValue, property, subscribe, unsubscribe} = config; - - // Unique state key name to avoid conflicts. - const stateWrapperKey = `____${property}`; - - // If possible, extend the specified component to add subscriptions. - // This preserves ref compatibility and avoids the overhead of an extra fiber. - let BaseClass = (Component: any); - const prototype = (Component: any).prototype; - - // If this is a functional component, use a HOC. - // Since functional components can't have refs, that isn't a problem. - // Class component lifecycles are required, so a class component is needed anyway. - if ( - typeof prototype !== 'object' || - typeof prototype.render !== 'function' || - Component.____isSubscriptionHOC === true - ) { - BaseClass = class extends React.Component { - static ____isSubscriptionHOC = true; - render() { - const props = { - ...this.props, - [property]: this.state[property], - }; - return React.createElement(Component, props); - } - }; - } - - // Event listeners are only safe to add during the commit phase, - // So they won't leak if render is interrupted or errors. - const subscribeHelper = (props, instance) => { - if (props[property] != null) { - const wrapper = instance.state[stateWrapperKey]; - - const valueChangedCallback = value => { - instance.setState(state => { - // If the value is the same, skip the unnecessary state update. - if (state[property] === value) { - return null; - } - - const currentSubscribable = - state[stateWrapperKey] !== undefined - ? state[stateWrapperKey].subscribable - : null; - - // If this event belongs to an old or uncommitted data source, ignore it. - if (wrapper.subscribable !== currentSubscribable) { - return null; - } - - return { - [property]: value, - }; - }); - }; - - // Store subscription for later (in case it's needed to unsubscribe). - // This is safe to do via mutation since: - // 1) It does not impact render. - // 2) This method will only be called during the "commit" phase. - wrapper.subscription = subscribe(props, valueChangedCallback); - - // External values could change between render and mount, - // In some cases it may be important to handle this case. - const value = getValue(props); - if (value !== instance.state[property]) { - instance.setState({ - [property]: value, - }); - } - } + type Props = { + children: (value: Value) => React$Element, + source: any, }; - - const unsubscribeHelper = (props, instance) => { - if (props[property] != null) { - const wrapper = instance.state[stateWrapperKey]; - - unsubscribe(props, wrapper.subscription); - - wrapper.subscription = null; - } + type State = { + source: Property, + subscriptionWrapper: { + subscription?: CreatedSubscription, + }, + value?: Value, }; - // Extend specified component class to hook into subscriptions. - class Subscribable extends BaseClass { - constructor(props) { - super(props); - - // Ensure state is initialized, so getDerivedStateFromProps doesn't warn. - // Parent class components might not use state outside of this helper, - // So it might be confusing for them to have to initialize it. - if (this.state == null) { - this.state = {}; - } - } + // Reference: https://gist.github.com/bvaughn/d569177d70b50b58bff69c3c4a5353f3 + class Subscription extends React.Component { + state: State = { + source: this.props.source, + subscriptionWrapper: {}, + value: + this.props.source != null ? getValue(this.props.source) : undefined, + }; static getDerivedStateFromProps(nextProps, prevState) { - const nextState = {}; - - let hasUpdates = false; - - // Read value (if sync read is possible) for upcoming render - const prevSubscribable = - prevState[stateWrapperKey] !== undefined - ? prevState[stateWrapperKey].subscribable - : null; - const nextSubscribable = nextProps[property]; - - if (prevSubscribable !== nextSubscribable) { - nextState[stateWrapperKey] = { - ...prevState[stateWrapperKey], - subscribable: nextSubscribable, + if (nextProps.source !== prevState.source) { + return { + source: nextProps.source, + subscriptionWrapper: {}, + value: + nextProps.source != null ? getValue(nextProps.source) : undefined, }; - nextState[property] = - nextSubscribable != null ? getValue(nextProps) : undefined; - - hasUpdates = true; } - const nextSuperState = - typeof Component.getDerivedStateFromProps === 'function' - ? Component.getDerivedStateFromProps(nextProps, prevState) - : null; - - return hasUpdates || nextSuperState !== null - ? {...nextSuperState, ...nextState} - : null; + return null; } componentDidMount() { - if (typeof super.componentDidMount === 'function') { - super.componentDidMount(); - } - - subscribeHelper(this.props, this); + this.subscribe(); } componentDidUpdate(prevProps, prevState) { - if (typeof super.componentDidUpdate === 'function') { - super.componentDidUpdate(prevProps, prevState); - } - - if (prevProps[property] !== this.props[property]) { - unsubscribeHelper(prevProps, this); - subscribeHelper(this.props, this); + if (this.state.source !== prevState.source) { + // Similar to adding subscriptions, + // It's only safe to unsubscribe during the commit phase. + this.unsubscribe(prevState); + this.subscribe(); } } componentWillUnmount() { - if (typeof super.componentWillUnmount === 'function') { - super.componentWillUnmount(); + this.unsubscribe(this.state); + } + + render() { + return this.props.children(this.state.value); + } + + subscribe() { + const {source} = this.state; + if (source != null) { + const valueChangedCallback = (value: Value) => { + this.setState(state => { + // If the value is the same, skip the unnecessary state update. + if (value === state.value) { + return null; + } + + // If this event belongs to an old or uncommitted data source, ignore it. + if (source !== state.source) { + return null; + } + + return {value}; + }); + }; + + // Event listeners are only safe to add during the commit phase, + // So they won't leak if render is interrupted or errors. + const subscription = subscribe(source, valueChangedCallback); + + // Store subscription for later (in case it's needed to unsubscribe). + // This is safe to do via mutation since: + // 1) It does not impact render. + // 2) This method will only be called during the "commit" phase. + this.state.subscriptionWrapper.subscription = subscription; + + // External values could change between render and mount, + // In some cases it may be important to handle this case. + const value = getValue(this.props.source); + if (value !== this.state.value) { + this.setState({value}); + } } + } - unsubscribeHelper(this.props, this); + unsubscribe(state: State) { + if (state.source != null) { + unsubscribe( + state.source, + ((state.subscriptionWrapper.subscription: any): CreatedSubscription), + ); + } } } - return Subscribable; + return Subscription; } From 0f936ba266a9149aeb3f289890cb42ce8f11e46f Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Wed, 7 Mar 2018 08:51:02 -0800 Subject: [PATCH 27/53] Renamed create-component-with-subscriptions => create-subscription --- .../README.md | 0 .../index.js | 0 .../npm/index.js | 0 .../package.json | 0 .../src/__tests__/createSubscription-test.js} | 0 .../src/createSubscription.js} | 0 6 files changed, 0 insertions(+), 0 deletions(-) rename packages/{create-component-with-subscriptions => create-subscription}/README.md (100%) rename packages/{create-component-with-subscriptions => create-subscription}/index.js (100%) rename packages/{create-component-with-subscriptions => create-subscription}/npm/index.js (100%) rename packages/{create-component-with-subscriptions => create-subscription}/package.json (100%) rename packages/{create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.js => create-subscription/src/__tests__/createSubscription-test.js} (100%) rename packages/{create-component-with-subscriptions/src/createComponentWithSubscriptions.js => create-subscription/src/createSubscription.js} (100%) diff --git a/packages/create-component-with-subscriptions/README.md b/packages/create-subscription/README.md similarity index 100% rename from packages/create-component-with-subscriptions/README.md rename to packages/create-subscription/README.md diff --git a/packages/create-component-with-subscriptions/index.js b/packages/create-subscription/index.js similarity index 100% rename from packages/create-component-with-subscriptions/index.js rename to packages/create-subscription/index.js diff --git a/packages/create-component-with-subscriptions/npm/index.js b/packages/create-subscription/npm/index.js similarity index 100% rename from packages/create-component-with-subscriptions/npm/index.js rename to packages/create-subscription/npm/index.js diff --git a/packages/create-component-with-subscriptions/package.json b/packages/create-subscription/package.json similarity index 100% rename from packages/create-component-with-subscriptions/package.json rename to packages/create-subscription/package.json diff --git a/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.js b/packages/create-subscription/src/__tests__/createSubscription-test.js similarity index 100% rename from packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.js rename to packages/create-subscription/src/__tests__/createSubscription-test.js diff --git a/packages/create-component-with-subscriptions/src/createComponentWithSubscriptions.js b/packages/create-subscription/src/createSubscription.js similarity index 100% rename from packages/create-component-with-subscriptions/src/createComponentWithSubscriptions.js rename to packages/create-subscription/src/createSubscription.js From 2d824c2960aee18a6a901fd2176525441029ee5c Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Wed, 7 Mar 2018 08:58:24 -0800 Subject: [PATCH 28/53] Renamed references to create-subscription --- packages/create-subscription/README.md | 26 +-- packages/create-subscription/index.js | 2 +- packages/create-subscription/npm/index.js | 4 +- packages/create-subscription/package.json | 6 +- .../src/__tests__/createSubscription-test.js | 2 +- scripts/rollup/bundles.js | 6 +- scripts/rollup/results.json | 190 ++++++++++-------- 7 files changed, 123 insertions(+), 113 deletions(-) diff --git a/packages/create-subscription/README.md b/packages/create-subscription/README.md index b0a266ccc1710..9553bd9153ef6 100644 --- a/packages/create-subscription/README.md +++ b/packages/create-subscription/README.md @@ -1,17 +1,17 @@ -# create-component-with-subscriptions +# create-subscription [Async-safe subscriptions are hard to get right.](https://gist.github.com/bvaughn/d569177d70b50b58bff69c3c4a5353f3) -This complexity is acceptible for libraries like Redux/Relay/MobX, but it's not ideal to have mixed in with application code. `create-component-with-subscriptions` provides an interface to easily manage subscriptions in an async-safe way. +This complexity is acceptible for libraries like Redux/Relay/MobX, but it's not ideal to have mixed in with application code. `create-subscription` provides an interface to easily manage subscriptions in an async-safe way. ## Installation ```sh # Yarn -yarn add create-component-with-subscriptions +yarn add create-subscription # NPM -npm install create-component-with-subscriptions --save +npm install create-subscription --save ``` # API @@ -62,7 +62,7 @@ function unsubscribe(props, subscription) { # How it works -Depending on the type of React component specified, `create-component-with-subscriptions` will either create a wrapper component or use a mixin technique. +Depending on the type of React component specified, `create-subscription` will either create a wrapper component or use a mixin technique. If a stateless functional component is specified, a high-order component will be wrapped around it. The wrapper will pass through all `props`. The subscribed value will be passed in place of the "subscribable" prop though. @@ -93,11 +93,11 @@ This API can be used to subscribe to a variety of "subscribable" sources, from F ## Subscribing to event dispatchers -Below is an example showing how `create-component-with-subscriptions` can be used to subscribe to event dispatchers such as DOM elements or Flux stores. +Below is an example showing how `create-subscription` can be used to subscribe to event dispatchers such as DOM elements or Flux stores. ```js import React from "react"; -import createComponent from "create-component-with-subscriptions"; +import createComponent from "create-subscription"; // Start with a simple component. // In this case, it's a functional component, but it could have been a class. @@ -137,7 +137,7 @@ const FollowerCountComponent = createComponent( ## Subscribing to observables -Below are examples showing how `create-component-with-subscriptions` can be used to subscribe to certain types of observables (e.g. RxJS `BehaviorSubject` and `ReplaySubject`). +Below are examples showing how `create-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. @@ -184,7 +184,7 @@ const SubscribedComponent = createComponent( ## Subscribing to a Promise -Below is an example showing how `create-component-with-subscriptions` can be used with native Promises. +Below is an example showing how `create-subscription` can be used with native Promises. **Note** that it an initial render value of `undefined` is unavoidable due to the fact that Promises provide no way to synchronously read their current value. @@ -192,7 +192,7 @@ Below is an example showing how `create-component-with-subscriptions` can be use ```js import React from "react"; -import createComponent from "create-component-with-subscriptions"; +import createComponent from "create-subscription"; // Start with a simple component. function InnerComponent({ loadingStatus }) { @@ -226,7 +226,7 @@ const LoadingComponent = createComponent( }, unsubscribe: (props, subscription) => { // There is no way to "unsubscribe" from a Promise. - // In this case, create-component-with-subscriptions will block stale values from rendering. + // In this case, create-subscription will block stale values from rendering. } }, InnerComponent @@ -238,7 +238,7 @@ const LoadingComponent = createComponent( ## Optional parameters and default values -Subscribable properties are treated as optional by `create-component-with-subscriptions`. In the event that a subscribable `prop` is missing, a value of `undefined` will be passed to the decorated component (using `props` for a functional component or `state` for a class component). +Subscribable properties are treated as optional by `create-subscription`. In the event that a subscribable `prop` is missing, a value of `undefined` will be passed to the decorated component (using `props` for a functional component or `state` for a class component). If you would like to set default values for missing subscriptions, you can do this as shown below. @@ -262,7 +262,7 @@ class InnerComponent extends React.Component { ## Subscribing to multiple sources -It is possible for a single component to subscribe to multiple data sources. To do this, compose the return value of `create-component-with-subscriptions` as shown below: +It is possible for a single component to subscribe to multiple data sources. To do this, compose the return value of `create-subscription` as shown below: ```js function InnerComponent({ bar, foo }) { diff --git a/packages/create-subscription/index.js b/packages/create-subscription/index.js index e0ee91920b554..8e84321cc3186 100644 --- a/packages/create-subscription/index.js +++ b/packages/create-subscription/index.js @@ -9,4 +9,4 @@ 'use strict'; -export * from './src/createComponentWithSubscriptions'; +export * from './src/createSubscription'; diff --git a/packages/create-subscription/npm/index.js b/packages/create-subscription/npm/index.js index 7262038596185..6b7a5b017457d 100644 --- a/packages/create-subscription/npm/index.js +++ b/packages/create-subscription/npm/index.js @@ -1,7 +1,7 @@ 'use strict'; if (process.env.NODE_ENV === 'production') { - module.exports = require('./cjs/create-component-with-subscriptions.production.min.js'); + module.exports = require('./cjs/create-subscription.production.min.js'); } else { - module.exports = require('./cjs/create-component-with-subscriptions.development.js'); + module.exports = require('./cjs/create-subscription.development.js'); } diff --git a/packages/create-subscription/package.json b/packages/create-subscription/package.json index 3c70c903dcb9f..e83632169d199 100644 --- a/packages/create-subscription/package.json +++ b/packages/create-subscription/package.json @@ -1,5 +1,5 @@ { - "name": "create-component-with-subscriptions", + "name": "create-subscription", "description": "HOC for creating async-safe React components with subscriptions", "version": "0.0.1", "repository": "facebook/react", @@ -14,9 +14,5 @@ }, "peerDependencies": { "react": "16.3.0-alpha.1" - }, - "devDependencies": { - "create-react-class": "^15.6.3", - "react-lifecycles-compat": "^1.0.2" } } diff --git a/packages/create-subscription/src/__tests__/createSubscription-test.js b/packages/create-subscription/src/__tests__/createSubscription-test.js index 693cbd9372fa9..8d8fd2f7ac43c 100644 --- a/packages/create-subscription/src/__tests__/createSubscription-test.js +++ b/packages/create-subscription/src/__tests__/createSubscription-test.js @@ -16,7 +16,7 @@ let ReactNoop; describe('createSubscription', () => { beforeEach(() => { jest.resetModules(); - createSubscription = require('create-component-with-subscriptions') + createSubscription = require('create-subscription') .createSubscription; React = require('react'); ReactNoop = require('react-noop-renderer'); diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index 0547baf782954..b9c5f57558a56 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -257,11 +257,11 @@ const bundles = [ /******* createComponentWithSubscriptions (experimental) *******/ { - label: 'create-component-with-subscriptions', + label: 'create-subscription', bundleTypes: [NODE_DEV, NODE_PROD], moduleType: ISOMORPHIC, - entry: 'create-component-with-subscriptions', - global: 'createComponentWithSubscriptions', + entry: 'create-subscription', + global: 'createSubscription', externals: ['react'], }, ]; diff --git a/scripts/rollup/results.json b/scripts/rollup/results.json index 0d21980b3e391..cd88e85620eae 100644 --- a/scripts/rollup/results.json +++ b/scripts/rollup/results.json @@ -4,8 +4,8 @@ "filename": "react.development.js", "bundleType": "UMD_DEV", "packageName": "react", - "size": 55674, - "gzip": 15255 + "size": 55675, + "gzip": 15253 }, { "filename": "react.production.min.js", @@ -18,8 +18,8 @@ "filename": "react.development.js", "bundleType": "NODE_DEV", "packageName": "react", - "size": 46095, - "gzip": 12925 + "size": 46096, + "gzip": 12924 }, { "filename": "react.production.min.js", @@ -32,8 +32,8 @@ "filename": "React-dev.js", "bundleType": "FB_DEV", "packageName": "react", - "size": 45476, - "gzip": 12448 + "size": 45477, + "gzip": 12446 }, { "filename": "React-prod.js", @@ -46,50 +46,50 @@ "filename": "react-dom.development.js", "bundleType": "UMD_DEV", "packageName": "react-dom", - "size": 591513, - "gzip": 138743 + "size": 600642, + "gzip": 139543 }, { "filename": "react-dom.production.min.js", "bundleType": "UMD_PROD", "packageName": "react-dom", - "size": 96778, - "gzip": 31445 + "size": 100738, + "gzip": 32495 }, { "filename": "react-dom.development.js", "bundleType": "NODE_DEV", "packageName": "react-dom", - "size": 575526, - "gzip": 134516 + "size": 584651, + "gzip": 135289 }, { "filename": "react-dom.production.min.js", "bundleType": "NODE_PROD", "packageName": "react-dom", - "size": 95503, - "gzip": 30619 + "size": 99167, + "gzip": 31568 }, { "filename": "ReactDOM-dev.js", "bundleType": "FB_DEV", "packageName": "react-dom", - "size": 594783, - "gzip": 136782 + "size": 604987, + "gzip": 137591 }, { "filename": "ReactDOM-prod.js", "bundleType": "FB_PROD", "packageName": "react-dom", - "size": 279046, - "gzip": 53062 + "size": 290412, + "gzip": 54502 }, { "filename": "react-dom-test-utils.development.js", "bundleType": "UMD_DEV", "packageName": "react-dom", - "size": 41697, - "gzip": 11964 + "size": 41803, + "gzip": 12011 }, { "filename": "react-dom-test-utils.production.min.js", @@ -102,8 +102,8 @@ "filename": "react-dom-test-utils.development.js", "bundleType": "NODE_DEV", "packageName": "react-dom", - "size": 36434, - "gzip": 10505 + "size": 36540, + "gzip": 10554 }, { "filename": "react-dom-test-utils.production.min.js", @@ -116,8 +116,8 @@ "filename": "ReactTestUtils-dev.js", "bundleType": "FB_DEV", "packageName": "react-dom", - "size": 37155, - "gzip": 10582 + "size": 37255, + "gzip": 10630 }, { "filename": "react-dom-unstable-native-dependencies.development.js", @@ -165,141 +165,141 @@ "filename": "react-dom-server.browser.development.js", "bundleType": "UMD_DEV", "packageName": "react-dom", - "size": 102991, - "gzip": 26927 + "size": 103067, + "gzip": 27041 }, { "filename": "react-dom-server.browser.production.min.js", "bundleType": "UMD_PROD", "packageName": "react-dom", - "size": 15184, - "gzip": 5856 + "size": 15133, + "gzip": 5835 }, { "filename": "react-dom-server.browser.development.js", "bundleType": "NODE_DEV", "packageName": "react-dom", - "size": 92035, - "gzip": 24618 + "size": 92111, + "gzip": 24739 }, { "filename": "react-dom-server.browser.production.min.js", "bundleType": "NODE_PROD", "packageName": "react-dom", - "size": 14818, - "gzip": 5705 + "size": 14771, + "gzip": 5680 }, { "filename": "ReactDOMServer-dev.js", "bundleType": "FB_DEV", "packageName": "react-dom", - "size": 95165, - "gzip": 24327 + "size": 95191, + "gzip": 24410 }, { "filename": "ReactDOMServer-prod.js", "bundleType": "FB_PROD", "packageName": "react-dom", - "size": 33262, - "gzip": 8299 + "size": 33064, + "gzip": 8279 }, { "filename": "react-dom-server.node.development.js", "bundleType": "NODE_DEV", "packageName": "react-dom", - "size": 94003, - "gzip": 25175 + "size": 94079, + "gzip": 25295 }, { "filename": "react-dom-server.node.production.min.js", "bundleType": "NODE_PROD", "packageName": "react-dom", - "size": 15642, - "gzip": 6010 + "size": 15595, + "gzip": 5990 }, { "filename": "react-art.development.js", "bundleType": "UMD_DEV", "packageName": "react-art", - "size": 389869, - "gzip": 86413 + "size": 399001, + "gzip": 87190 }, { "filename": "react-art.production.min.js", "bundleType": "UMD_PROD", "packageName": "react-art", - "size": 86808, - "gzip": 26944 + "size": 90690, + "gzip": 27874 }, { "filename": "react-art.development.js", "bundleType": "NODE_DEV", "packageName": "react-art", - "size": 313942, - "gzip": 67385 + "size": 323070, + "gzip": 68147 }, { "filename": "react-art.production.min.js", "bundleType": "NODE_PROD", "packageName": "react-art", - "size": 50754, - "gzip": 16005 + "size": 54355, + "gzip": 16860 }, { "filename": "ReactART-dev.js", "bundleType": "FB_DEV", "packageName": "react-art", - "size": 318024, - "gzip": 66603 + "size": 328230, + "gzip": 67375 }, { "filename": "ReactART-prod.js", "bundleType": "FB_PROD", "packageName": "react-art", - "size": 157473, - "gzip": 27225 + "size": 168749, + "gzip": 28640 }, { "filename": "ReactNativeRenderer-dev.js", "bundleType": "RN_DEV", "packageName": "react-native-renderer", - "size": 443941, - "gzip": 97414 + "size": 454044, + "gzip": 98203 }, { "filename": "ReactNativeRenderer-prod.js", "bundleType": "RN_PROD", "packageName": "react-native-renderer", - "size": 209855, - "gzip": 36492 + "size": 220436, + "gzip": 37780 }, { "filename": "react-test-renderer.development.js", "bundleType": "NODE_DEV", "packageName": "react-test-renderer", - "size": 310910, - "gzip": 66329 + "size": 320190, + "gzip": 67115 }, { "filename": "react-test-renderer.production.min.js", "bundleType": "NODE_PROD", "packageName": "react-test-renderer", - "size": 49219, - "gzip": 15315 + "size": 52870, + "gzip": 16241 }, { "filename": "ReactTestRenderer-dev.js", "bundleType": "FB_DEV", "packageName": "react-test-renderer", - "size": 315000, - "gzip": 65520 + "size": 325364, + "gzip": 66316 }, { "filename": "react-test-renderer-shallow.development.js", "bundleType": "NODE_DEV", "packageName": "react-test-renderer", - "size": 21221, - "gzip": 5193 + "size": 21475, + "gzip": 5309 }, { "filename": "react-test-renderer-shallow.production.min.js", @@ -312,43 +312,43 @@ "filename": "ReactShallowRenderer-dev.js", "bundleType": "FB_DEV", "packageName": "react-test-renderer", - "size": 20928, - "gzip": 4566 + "size": 21120, + "gzip": 4625 }, { "filename": "react-noop-renderer.development.js", "bundleType": "NODE_DEV", "packageName": "react-noop-renderer", - "size": 18777, - "gzip": 5303 + "size": 19408, + "gzip": 5482 }, { "filename": "react-noop-renderer.production.min.js", "bundleType": "NODE_PROD", "packageName": "react-noop-renderer", - "size": 6429, - "gzip": 2573 + "size": 6643, + "gzip": 2618 }, { "filename": "react-reconciler.development.js", "bundleType": "NODE_DEV", "packageName": "react-reconciler", - "size": 292377, - "gzip": 61765 + "size": 301505, + "gzip": 62567 }, { "filename": "react-reconciler.production.min.js", "bundleType": "NODE_PROD", "packageName": "react-reconciler", - "size": 42443, - "gzip": 13358 + "size": 46055, + "gzip": 14278 }, { "filename": "react-reconciler-reflection.development.js", "bundleType": "NODE_DEV", "packageName": "react-reconciler", - "size": 10934, - "gzip": 3388 + "size": 11040, + "gzip": 3435 }, { "filename": "react-reconciler-reflection.production.min.js", @@ -375,29 +375,29 @@ "filename": "ReactFabric-dev.js", "bundleType": "RN_DEV", "packageName": "react-native-renderer", - "size": 438218, - "gzip": 96267 + "size": 438891, + "gzip": 94687 }, { "filename": "ReactFabric-prod.js", "bundleType": "RN_PROD", "packageName": "react-native-renderer", - "size": 201883, - "gzip": 35448 + "size": 204481, + "gzip": 35139 }, { "filename": "react-reconciler-persistent.development.js", "bundleType": "NODE_DEV", "packageName": "react-reconciler", - "size": 291949, - "gzip": 61587 + "size": 300825, + "gzip": 62279 }, { "filename": "react-reconciler-persistent.production.min.js", "bundleType": "NODE_PROD", "packageName": "react-reconciler", - "size": 41327, - "gzip": 13133 + "size": 44927, + "gzip": 14054 }, { "filename": "react-is.development.js", @@ -431,15 +431,15 @@ "filename": "simple-cache-provider.development.js", "bundleType": "NODE_DEV", "packageName": "simple-cache-provider", - "size": 5830, - "gzip": 1904 + "size": 5759, + "gzip": 1870 }, { "filename": "simple-cache-provider.production.min.js", "bundleType": "NODE_PROD", "packageName": "simple-cache-provider", - "size": 1313, - "gzip": 665 + "size": 1295, + "gzip": 656 }, { "filename": "create-component-with-subscriptions.development.js", @@ -454,6 +454,20 @@ "packageName": "create-component-with-subscriptions", "size": 3783, "gzip": 1637 + }, + { + "filename": "create-subscription.development.js", + "bundleType": "NODE_DEV", + "packageName": "create-subscription", + "size": 5491, + "gzip": 1896 + }, + { + "filename": "create-subscription.production.min.js", + "bundleType": "NODE_PROD", + "packageName": "create-subscription", + "size": 2190, + "gzip": 1007 } ] } \ No newline at end of file From 3edff4942491c0bf093d1a9be44e522f798fae44 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Wed, 7 Mar 2018 08:58:44 -0800 Subject: [PATCH 29/53] Replaced .toThrow with .toWarnDev --- .../src/__tests__/createSubscription-test.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/create-subscription/src/__tests__/createSubscription-test.js b/packages/create-subscription/src/__tests__/createSubscription-test.js index 8d8fd2f7ac43c..a249d0811360d 100644 --- a/packages/create-subscription/src/__tests__/createSubscription-test.js +++ b/packages/create-subscription/src/__tests__/createSubscription-test.js @@ -432,7 +432,7 @@ describe('createSubscription', () => { }, () => null, ); - }).toThrow('Subscription must specify a getValue function'); + }).toWarnDev('Subscription must specify a getValue function'); }); it('should error for invalid missing subscribe', () => { @@ -445,7 +445,7 @@ describe('createSubscription', () => { }, () => null, ); - }).toThrow('Subscription must specify a subscribe function'); + }).toWarnDev('Subscription must specify a subscribe function'); }); it('should error for invalid missing unsubscribe', () => { @@ -458,7 +458,7 @@ describe('createSubscription', () => { }, () => null, ); - }).toThrow('Subscription must specify a unsubscribe function'); + }).toWarnDev('Subscription must specify a unsubscribe function'); }); }); }); From e05617290c75eb05e89373170f43d3fe59955e0b Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Wed, 7 Mar 2018 09:03:13 -0800 Subject: [PATCH 30/53] Disable render-phase side effects --- ...js => createSubscription-test.internal.js} | 72 ++++++++----------- 1 file changed, 29 insertions(+), 43 deletions(-) rename packages/create-subscription/src/__tests__/{createSubscription-test.js => createSubscription-test.internal.js} (83%) diff --git a/packages/create-subscription/src/__tests__/createSubscription-test.js b/packages/create-subscription/src/__tests__/createSubscription-test.internal.js similarity index 83% rename from packages/create-subscription/src/__tests__/createSubscription-test.js rename to packages/create-subscription/src/__tests__/createSubscription-test.internal.js index a249d0811360d..699c96ecaa77d 100644 --- a/packages/create-subscription/src/__tests__/createSubscription-test.js +++ b/packages/create-subscription/src/__tests__/createSubscription-test.internal.js @@ -10,14 +10,16 @@ 'use strict'; let createSubscription; +let ReactFeatureFlags; let React; let ReactNoop; describe('createSubscription', () => { beforeEach(() => { jest.resetModules(); - createSubscription = require('create-subscription') - .createSubscription; + createSubscription = require('create-subscription').createSubscription; + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false; React = require('react'); ReactNoop = require('react-noop-renderer'); }); @@ -79,12 +81,11 @@ describe('createSubscription', () => { ); // Updates while subscribed should re-render the child component - // NOTE: Redundant yields are expected due to 'debugRenderPhaseSideEffectsForStrictMode' - expect(ReactNoop.flush()).toEqual(['default', 'default']); + expect(ReactNoop.flush()).toEqual(['default']); observable.update(123); - expect(ReactNoop.flush()).toEqual([123, 123]); + expect(ReactNoop.flush()).toEqual([123]); observable.update('abc'); - expect(ReactNoop.flush()).toEqual(['abc', 'abc']); + expect(ReactNoop.flush()).toEqual(['abc']); // Unmounting the subscriber should remove listeners ReactNoop.render(
); @@ -109,7 +110,6 @@ describe('createSubscription', () => { const observable = createFauxReplaySubject('initial'); - // NOTE: Redundant yields are expected due to 'debugRenderPhaseSideEffectsForStrictMode' ReactNoop.render( {(value = 'default') => { @@ -118,9 +118,9 @@ describe('createSubscription', () => { }} , ); - expect(ReactNoop.flush()).toEqual(['initial', 'initial']); + expect(ReactNoop.flush()).toEqual(['initial']); observable.update('updated'); - expect(ReactNoop.flush()).toEqual(['updated', 'updated']); + expect(ReactNoop.flush()).toEqual(['updated']); // Unsetting the subscriber prop should reset subscribed values ReactNoop.render( @@ -131,7 +131,7 @@ describe('createSubscription', () => { }} , ); - expect(ReactNoop.flush()).toEqual(['default', 'default']); + expect(ReactNoop.flush()).toEqual(['default']); }); describe('Promises', () => { @@ -164,14 +164,14 @@ describe('createSubscription', () => { }); // Test a promise that resolves after render - // NOTE: Redundant yields are expected due to 'debugRenderPhaseSideEffectsForStrictMode' + ReactNoop.render( {childrenFunction}, ); - expect(ReactNoop.flush()).toEqual(['loading', 'loading']); + expect(ReactNoop.flush()).toEqual(['loading']); resolveA(); await promiseA; - expect(ReactNoop.flush()).toEqual(['finished', 'finished']); + expect(ReactNoop.flush()).toEqual(['finished']); // Test a promise that resolves before render // Note that this will require an extra render anyway, @@ -180,9 +180,9 @@ describe('createSubscription', () => { ReactNoop.render( {childrenFunction}, ); - expect(ReactNoop.flush()).toEqual(['loading', 'loading']); + expect(ReactNoop.flush()).toEqual(['loading']); await promiseB.catch(() => true); - expect(ReactNoop.flush()).toEqual(['failed', 'failed']); + expect(ReactNoop.flush()).toEqual(['failed']); }); it('should still work if unsubscription is managed incorrectly', async () => { @@ -203,15 +203,15 @@ describe('createSubscription', () => { const promiseB = new Promise(resolve => (resolveB = resolve)); // Subscribe first to Promise A then Promsie B - // NOTE: Redundant yields are expected due to 'debugRenderPhaseSideEffectsForStrictMode' + ReactNoop.render( {childrenFunction}, ); - expect(ReactNoop.flush()).toEqual(['default', 'default']); + expect(ReactNoop.flush()).toEqual(['default']); ReactNoop.render( {childrenFunction}, ); - expect(ReactNoop.flush()).toEqual(['default', 'default']); + expect(ReactNoop.flush()).toEqual(['default']); // Resolve both Promises resolveB(123); @@ -219,7 +219,7 @@ describe('createSubscription', () => { await Promise.all([promiseA, promiseB]); // Ensure that only Promise B causes an update - expect(ReactNoop.flush()).toEqual([123, 123]); + expect(ReactNoop.flush()).toEqual([123]); }); }); @@ -239,19 +239,18 @@ describe('createSubscription', () => { const observableA = createFauxBehaviorSubject('a-0'); const observableB = createFauxBehaviorSubject('b-0'); - // NOTE: Redundant yields are expected due to 'debugRenderPhaseSideEffectsForStrictMode' ReactNoop.render( {childrenFunction}, ); // Updates while subscribed should re-render the child component - expect(ReactNoop.flush()).toEqual(['a-0', 'a-0']); + expect(ReactNoop.flush()).toEqual(['a-0']); // Unsetting the subscriber prop should reset subscribed values ReactNoop.render( {childrenFunction}, ); - expect(ReactNoop.flush()).toEqual(['b-0', 'b-0']); + expect(ReactNoop.flush()).toEqual(['b-0']); // Updates to the old subscribable should not re-render the child component observableA.update('a-1'); @@ -259,7 +258,7 @@ describe('createSubscription', () => { // Updates to the bew subscribable should re-render the child component observableB.update('b-1'); - expect(ReactNoop.flush()).toEqual(['b-1', 'b-1']); + expect(ReactNoop.flush()).toEqual(['b-1']); }); it('should ignore values emitted by a new subscribable until the commit phase', () => { @@ -307,17 +306,12 @@ describe('createSubscription', () => { const observableA = createFauxBehaviorSubject('a-0'); const observableB = createFauxBehaviorSubject('b-0'); - // NOTE: Redundant yields are expected due to 'debugRenderPhaseSideEffectsForStrictMode' ReactNoop.render(); - expect(ReactNoop.flush()).toEqual([ - 'Subscriber: a-0', - 'Subscriber: a-0', - 'Child: a-0', - ]); + expect(ReactNoop.flush()).toEqual(['Subscriber: a-0', 'Child: a-0']); // Start React update, but don't finish ReactNoop.render(); - ReactNoop.flushThrough(['Subscriber: b-0', 'Subscriber: b-0']); + ReactNoop.flushThrough(['Subscriber: b-0']); // Emit some updates from the uncommitted subscribable observableB.update('b-1'); @@ -334,10 +328,8 @@ describe('createSubscription', () => { expect(ReactNoop.flush()).toEqual([ 'Child: b-0', 'Subscriber: b-3', - 'Subscriber: b-3', 'Child: b-3', 'Subscriber: a-0', - 'Subscriber: a-0', 'Child: a-0', ]); }); @@ -387,17 +379,12 @@ describe('createSubscription', () => { const observableA = createFauxBehaviorSubject('a-0'); const observableB = createFauxBehaviorSubject('b-0'); - // NOTE: Redundant yields are expected due to 'debugRenderPhaseSideEffectsForStrictMode' ReactNoop.render(); - expect(ReactNoop.flush()).toEqual([ - 'Subscriber: a-0', - 'Subscriber: a-0', - 'Child: a-0', - ]); + expect(ReactNoop.flush()).toEqual(['Subscriber: a-0', 'Child: a-0']); // Start React update, but don't finish ReactNoop.render(); - ReactNoop.flushThrough(['Subscriber: b-0', 'Subscriber: b-0']); + ReactNoop.flushThrough(['Subscriber: b-0']); // Emit some updates from the old subscribable observableA.update('a-1'); @@ -412,7 +399,6 @@ describe('createSubscription', () => { expect(ReactNoop.flush()).toEqual([ 'Child: b-0', 'Subscriber: a-2', - 'Subscriber: a-2', 'Child: a-2', ]); @@ -432,7 +418,7 @@ describe('createSubscription', () => { }, () => null, ); - }).toWarnDev('Subscription must specify a getValue function'); + }).toThrow('Subscription must specify a getValue function'); }); it('should error for invalid missing subscribe', () => { @@ -445,7 +431,7 @@ describe('createSubscription', () => { }, () => null, ); - }).toWarnDev('Subscription must specify a subscribe function'); + }).toThrow('Subscription must specify a subscribe function'); }); it('should error for invalid missing unsubscribe', () => { @@ -458,7 +444,7 @@ describe('createSubscription', () => { }, () => null, ); - }).toWarnDev('Subscription must specify a unsubscribe function'); + }).toThrow('Subscription must specify a unsubscribe function'); }); }); }); From 9bdc6d637f8c45cd13a1d3b53005a6bdcd24aecd Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Wed, 7 Mar 2018 09:23:00 -0800 Subject: [PATCH 31/53] Updated docs --- packages/create-subscription/README.md | 250 +++++------------- .../createSubscription-test.internal.js | 9 +- 2 files changed, 71 insertions(+), 188 deletions(-) diff --git a/packages/create-subscription/README.md b/packages/create-subscription/README.md index 9553bd9153ef6..11cb1c9c689d8 100644 --- a/packages/create-subscription/README.md +++ b/packages/create-subscription/README.md @@ -14,79 +14,38 @@ yarn add create-subscription npm install create-subscription --save ``` -# API +# Usage -Creating a subscription component requires a configuration object and a React component. The configuration object must have four properties: +To configure a subscription, you must specify three properties: `getValue`, `subscribe`, and `unsubscribe`. -#### `property: string` - -Property name of the subscribable sources (e.g. "hasLoaded"). - -#### `getValue: (props: Props) => Value` - -Synchronously returns the value of the subscribable property. - -You should return `undefined` if the subscribable type does not support this operation (e.g. native Promises). - -For example: ```js -function getValue(props: Props) { - return props.scrollContainer.scrollTop; -} -``` - -#### `subscribe(props: Props, valueChangedCallback: (value: any) => void) => Subscription` - -Setup a subscription for the subscribable value in `props`. This subscription should call the `valueChangedCallback` parameter whenever a subscription changes. - -For example: -```js -function subscribe(props: Props, valueChangedCallback: (value: any) => void) { - const {scrollContainer} = props; - const onScroll = event => valueChangedCallback(scrollContainer.scrollTop); - scrollContainer.addEventListener("scroll", onScroll); - return onScroll; -} -``` - -#### `unsubscribe: (props: Props, subscription: Subscription) => void` - -Unsubsribe from the subscribable value in `props`. The value returned by `subscribe()` is the second, `subscription` parameter. - -For example: -```js -function unsubscribe(props, subscription) { - props.scrollContainer.removeEventListener("scroll", subscription); -} -``` - -# How it works - -Depending on the type of React component specified, `create-subscription` will either create a wrapper component or use a mixin technique. - -If a stateless functional component is specified, a high-order component will be wrapped around it. The wrapper will pass through all `props`. The subscribed value will be passed in place of the "subscribable" prop though. +import createComponent from "create-subscription"; -Given the above example, a stateless functional component would look something like this: -```js -function ExampleComponent({ scrollTop, ...rest }) { - // Render ... -} +const Subscription = createComponent({ + getValue(source) { + // Return the current value of the subscription (source), + // or `undefined` if the value can't be read synchronously (e.g. native Promises). + }, + subscribe(source, valueChangedCallback) { + // Subscribe (e.g. add an event listener) to the subscription (source). + // Call valueChangedCallback() whenever a subscription changes. + // Return any value that will later be needed to unsubscribe (e.g. an event handler). + }, + unsubscribe(source, subscription) { + // Remove your subscription from source. + // The value returned by subscribe() is the second, 'subscription' parameter. + } +}); ``` -If a class (or `create-react-class`) component is specified, the library uses an ["ES6 mixin"](https://gist.github.com/sebmarkbage/fac0830dbb13ccbff596) technique in order to preserve compatibility with refs and to avoid the overhead of an additional fiber. In this case, the subscription value will be stored in `state` (using the same `property` name) and be accessed from within the `render` method. +To use the `Subscription` component, pass the subscribable property (e.g. an event dispatcher, Flux store, observable) as the `source` property and use a [`children` render prop](https://reactjs.org/docs/render-props.html) to handle the subscribed value when it changes: -Given the above example, a class component would look something like this: ```js -class ExampleComponent extends React.Component { - render() { - const { scrollTop } = this.state; - // Render ... - } -} + + {value => } + ``` -Examples of both [functional](#subscribing-to-event-dispatchers) and [class](#subscribing-to-a-promise) components are provided below. - # Examples This API can be used to subscribe to a variety of "subscribable" sources, from Flux stores to RxJS observables. Below are a few examples of how to subscribe to common types. @@ -101,38 +60,28 @@ import createComponent from "create-subscription"; // Start with a simple component. // In this case, it's a functional component, but it could have been a class. -function InnerComponent({ followers, username }) { - return ( -
- {username} has {followers} follower -
- ); +function FollowerComponent({ followersCount }) { + return
You have {followersCount} followers!
; } -// Wrap the functional component with a subscriber HOC. -// This HOC will manage subscriptions and pass values to the decorated component. -// It will add and remove subscriptions in an async-safe way when props change. -const FollowerCountComponent = createComponent( - { - property: "followers", - getValue: props => props.followers.value, - subscribe: (props, valueChangedCallback) => { - const { followers } = props; - const onChange = event => valueChangedCallback(followers.value); - followers.addEventListener("change", onChange); - return onChange; - }, - unsubscribe: (props, subscription) => { - // `subscription` is the value returned from subscribe, our event handler. - props.followers.removeEventListener("change", subscription); - } +// Create a wrapper component to manage the subscription. +const EventHandlerSubscription = createComponent({ + getValue: followers => followers.value, + subscribe: (followers, valueChangedCallback) => { + const onChange = event => valueChangedCallback(followers.value); + followers.addEventListener("change", onChange); + return onChange; }, - InnerComponent -); + unsubscribe: (followers, subscription) => { + followers.removeEventListener("change", subscription); + } +}); // Your component can now be used as shown below. // In this example, `followerStore` represents a generic event dispatcher. -; + + {followersCount => } + ``` ## Subscribing to observables @@ -143,43 +92,32 @@ Below are examples showing how `create-subscription` can be used to subscribe to ### `BehaviorSubject` ```js -const SubscribedComponent = createComponent( - { - property: "behaviorSubject", - getValue: props => props.behaviorSubject.getValue(), - subscribe: (props, valueChangedCallback) => - props.behaviorSubject.subscribe(valueChangedCallback), - unsubscribe: (props, subscription) => subscription.unsubscribe() - }, - ({ behaviorSubject }) => { - // Render ... - } -); +const BehaviorSubscription = createComponent({ + getValue: behaviorSubject => behaviorSubject.getValue(), + subscribe: (behaviorSubject, valueChangedCallback) => + behaviorSubject.subscribe(valueChangedCallback), + unsubscribe: (behaviorSubject, subscription) => behaviorSubject.unsubscribe() +}); ``` ### `ReplaySubject` ```js -const SubscribedComponent = createComponent( - { - property: "replaySubject", - getValue: props => { - let currentValue; - // ReplaySubject does not have a sync data getter, - // So we need to temporarily subscribe to retrieve the most recent value. - const temporarySubscription = props.replaySubject.subscribe(value => { +const ReplaySubscription = createComponent({ + getValue: replaySubject => { + 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; - }); - temporarySubscription.unsubscribe(); - return currentValue; - }, - subscribe: (props, valueChangedCallback) => - props.replaySubject.subscribe(valueChangedCallback), - unsubscribe: (props, subscription) => subscription.unsubscribe() + }) + .unsubscribe(); + return currentValue; }, - ({ replaySubject }) => { - // Render ... - } -); + subscribe: (replaySubject, valueChangedCallback) => + replaySubject.subscribe(valueChangedCallback), + unsubscribe: (replaySubject, subscription) => replaySubject.unsubscribe() +}); ``` ## Subscribing to a Promise @@ -208,23 +146,22 @@ function InnerComponent({ loadingStatus }) { // Wrap the functional component with a subscriber HOC. // This HOC will manage subscriptions and pass values to the decorated component. // It will add and remove subscriptions in an async-safe way when props change. -const LoadingComponent = createComponent( +const PromiseSubscription = createComponent( { - property: "loadingStatus", - getValue: (props, subscription) => { + getValue: promise => { // There is no way to synchronously read a Promise's value, // So this method should return undefined. return undefined; }, - subscribe: (props, valueChangedCallback) => { - props.loadingStatus.then( + subscribe: (promise, valueChangedCallback) => { + promise.then( // Success () => valueChangedCallback(true), // Failure () => valueChangedCallback(false) ); }, - unsubscribe: (props, subscription) => { + unsubscribe: (promise, subscription) => { // There is no way to "unsubscribe" from a Promise. // In this case, create-subscription will block stale values from rendering. } @@ -233,62 +170,7 @@ const LoadingComponent = createComponent( ); // Your component can now be used as shown below. -; -``` - -## Optional parameters and default values - -Subscribable properties are treated as optional by `create-subscription`. In the event that a subscribable `prop` is missing, a value of `undefined` will be passed to the decorated component (using `props` for a functional component or `state` for a class component). - -If you would like to set default values for missing subscriptions, you can do this as shown below. - -For functional components, declare a default value while destructuring the `props` parameter: -```js -function InnerComponent({ followers = 0 }) { - return
You have {followers} followers.
; -} -``` - -For class components, declare a default value while destructuring `state`: -```js -class InnerComponent extends React.Component { - state = {}; - render() { - const { followers = 0 } = this.state; - return
You have {followers} followers.
; - } -} -``` - -## Subscribing to multiple sources - -It is possible for a single component to subscribe to multiple data sources. To do this, compose the return value of `create-subscription` as shown below: - -```js -function InnerComponent({ bar, foo }) { - // Render ... -} - -const MultiSubscriptionComponent = createComponent( - { - property: "promiseTwo", - getValue: props => props.promiseTwo.getValue(), - subscribe: (props, valueChangedCallback) => - props.promiseTwo.subscribe(valueChangedCallback), - unsubscribe: (props, subscription) => subscription.unsubscribe() - }, - createComponent( - { - property: "promiseTwo", - getValue: props => props.promiseTwo.getValue(), - subscribe: (props, valueChangedCallback) => - props.promiseTwo.subscribe(valueChangedCallback), - unsubscribe: (props, subscription) => subscription.unsubscribe() - }, - InnerComponent - ) -); - -// Your component can now be used as shown below. -; + + {loadingStatus => } +; ``` \ No newline at end of file diff --git a/packages/create-subscription/src/__tests__/createSubscription-test.internal.js b/packages/create-subscription/src/__tests__/createSubscription-test.internal.js index 699c96ecaa77d..991c1935608b5 100644 --- a/packages/create-subscription/src/__tests__/createSubscription-test.internal.js +++ b/packages/create-subscription/src/__tests__/createSubscription-test.internal.js @@ -97,10 +97,11 @@ describe('createSubscription', () => { const Subscription = createSubscription({ getValue: source => { let currentValue; - const temporarySubscription = source.subscribe(value => { - currentValue = value; - }); - temporarySubscription.unsubscribe(); + source + .subscribe(value => { + currentValue = value; + }) + .unsubscribe(); return currentValue; }, subscribe: (source, valueChangedCallback) => From 9ffe079fe24158fc4b13a4debd8e48fb62c9db2b Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Wed, 7 Mar 2018 09:36:43 -0800 Subject: [PATCH 32/53] README and naming tweaks --- packages/create-subscription/README.md | 75 +++++++++---------- .../createSubscription-test.internal.js | 25 ++----- .../src/createSubscription.js | 8 +- 3 files changed, 48 insertions(+), 60 deletions(-) diff --git a/packages/create-subscription/README.md b/packages/create-subscription/README.md index 11cb1c9c689d8..76eee957512bd 100644 --- a/packages/create-subscription/README.md +++ b/packages/create-subscription/README.md @@ -26,9 +26,9 @@ const Subscription = createComponent({ // Return the current value of the subscription (source), // or `undefined` if the value can't be read synchronously (e.g. native Promises). }, - subscribe(source, valueChangedCallback) { + subscribe(source, callback) { // Subscribe (e.g. add an event listener) to the subscription (source). - // Call valueChangedCallback() whenever a subscription changes. + // Call callback() whenever a subscription changes. // Return any value that will later be needed to unsubscribe (e.g. an event handler). }, unsubscribe(source, subscription) { @@ -66,21 +66,21 @@ function FollowerComponent({ followersCount }) { // Create a wrapper component to manage the subscription. const EventHandlerSubscription = createComponent({ - getValue: followers => followers.value, - subscribe: (followers, valueChangedCallback) => { - const onChange = event => valueChangedCallback(followers.value); - followers.addEventListener("change", onChange); + getValue: eventDispatcher => eventDispatcher.value, + subscribe: (eventDispatcher, callback) => { + const onChange = event => callback(eventDispatcher.value); + eventDispatcher.addEventListener("change", onChange); return onChange; }, - unsubscribe: (followers, subscription) => { - followers.removeEventListener("change", subscription); + unsubscribe: (eventDispatcher, subscription) => { + eventDispatcher.removeEventListener("change", subscription); } }); // Your component can now be used as shown below. -// In this example, `followerStore` represents a generic event dispatcher. - - {followersCount => } +// In this example, 'eventDispatcher' represents a generic event dispatcher. + + {value => } ``` @@ -94,8 +94,8 @@ Below are examples showing how `create-subscription` can be used to subscribe to ```js const BehaviorSubscription = createComponent({ getValue: behaviorSubject => behaviorSubject.getValue(), - subscribe: (behaviorSubject, valueChangedCallback) => - behaviorSubject.subscribe(valueChangedCallback), + subscribe: (behaviorSubject, callback) => + behaviorSubject.subscribe(callback), unsubscribe: (behaviorSubject, subscription) => behaviorSubject.unsubscribe() }); ``` @@ -114,8 +114,8 @@ const ReplaySubscription = createComponent({ .unsubscribe(); return currentValue; }, - subscribe: (replaySubject, valueChangedCallback) => - replaySubject.subscribe(valueChangedCallback), + subscribe: (replaySubject, callback) => + replaySubject.subscribe(callback), unsubscribe: (replaySubject, subscription) => replaySubject.unsubscribe() }); ``` @@ -133,7 +133,7 @@ import React from "react"; import createComponent from "create-subscription"; // Start with a simple component. -function InnerComponent({ loadingStatus }) { +function LoadingComponent({ loadingStatus }) { if (loadingStatus === undefined) { // Loading } else if (loadingStatus) { @@ -146,31 +146,28 @@ function InnerComponent({ loadingStatus }) { // Wrap the functional component with a subscriber HOC. // This HOC will manage subscriptions and pass values to the decorated component. // It will add and remove subscriptions in an async-safe way when props change. -const PromiseSubscription = createComponent( - { - getValue: promise => { - // There is no way to synchronously read a Promise's value, - // So this method should return undefined. - return undefined; - }, - subscribe: (promise, valueChangedCallback) => { - promise.then( - // Success - () => valueChangedCallback(true), - // Failure - () => valueChangedCallback(false) - ); - }, - unsubscribe: (promise, subscription) => { - // There is no way to "unsubscribe" from a Promise. - // In this case, create-subscription will block stale values from rendering. - } +const PromiseSubscription = createComponent({ + getValue: promise => { + // There is no way to synchronously read a Promise's value, + // So this method should return undefined. + return undefined; }, - InnerComponent -); + subscribe: (promise, callback) => { + promise.then( + // Success + () => callback(true), + // Failure + () => callback(false) + ); + }, + unsubscribe: (promise, subscription) => { + // There is no way to "unsubscribe" from a Promise. + // In this case, create-subscription will block stale values from rendering. + } +}); // Your component can now be used as shown below. - {loadingStatus => } -; + {loadingStatus => } + ``` \ No newline at end of file diff --git a/packages/create-subscription/src/__tests__/createSubscription-test.internal.js b/packages/create-subscription/src/__tests__/createSubscription-test.internal.js index 991c1935608b5..8fefc17f124a6 100644 --- a/packages/create-subscription/src/__tests__/createSubscription-test.internal.js +++ b/packages/create-subscription/src/__tests__/createSubscription-test.internal.js @@ -65,8 +65,7 @@ describe('createSubscription', () => { it('supports basic subscription pattern', () => { const Subscription = createSubscription({ getValue: source => source.getValue(), - subscribe: (source, valueChangedCallback) => - source.subscribe(valueChangedCallback), + subscribe: (source, callback) => source.subscribe(callback), unsubscribe: (source, subscription) => subscription.unsubscribe(), }); @@ -104,8 +103,7 @@ describe('createSubscription', () => { .unsubscribe(); return currentValue; }, - subscribe: (source, valueChangedCallback) => - source.subscribe(valueChangedCallback), + subscribe: (source, callback) => source.subscribe(callback), unsubscribe: (source, subscription) => subscription.unsubscribe(), }); @@ -139,11 +137,8 @@ describe('createSubscription', () => { it('should support Promises', async () => { const Subscription = createSubscription({ getValue: source => undefined, - subscribe: (source, valueChangedCallback) => - source.then( - () => valueChangedCallback(true), - () => valueChangedCallback(false), - ), + subscribe: (source, callback) => + source.then(() => callback(true), () => callback(false)), unsubscribe: (source, subscription) => {}, }); @@ -189,8 +184,7 @@ describe('createSubscription', () => { it('should still work if unsubscription is managed incorrectly', async () => { const Subscription = createSubscription({ getValue: source => undefined, - subscribe: (source, valueChangedCallback) => - source.then(valueChangedCallback), + subscribe: (source, callback) => source.then(callback), unsubscribe: (source, subscription) => {}, }); @@ -227,8 +221,7 @@ describe('createSubscription', () => { it('should unsubscribe from old subscribables and subscribe to new subscribables when props change', () => { const Subscription = createSubscription({ getValue: source => source.getValue(), - subscribe: (source, valueChangedCallback) => - source.subscribe(valueChangedCallback), + subscribe: (source, callback) => source.subscribe(callback), unsubscribe: (source, subscription) => subscription.unsubscribe(), }); @@ -272,8 +265,7 @@ describe('createSubscription', () => { const Subscription = createSubscription({ getValue: source => source.getValue(), - subscribe: (source, valueChangedCallback) => - source.subscribe(valueChangedCallback), + subscribe: (source, callback) => source.subscribe(callback), unsubscribe: (source, subscription) => subscription.unsubscribe(), }); @@ -345,8 +337,7 @@ describe('createSubscription', () => { const Subscription = createSubscription({ getValue: source => source.getValue(), - subscribe: (source, valueChangedCallback) => - source.subscribe(valueChangedCallback), + subscribe: (source, callback) => source.subscribe(callback), unsubscribe: (source, subscription) => subscription.unsubscribe(), }); diff --git a/packages/create-subscription/src/createSubscription.js b/packages/create-subscription/src/createSubscription.js index 565a8899a715c..3a871fe0a739f 100644 --- a/packages/create-subscription/src/createSubscription.js +++ b/packages/create-subscription/src/createSubscription.js @@ -23,11 +23,11 @@ export function createSubscription< // Setup a subscription for the subscribable value in props. // Due to the variety of change event types, subscribers should provide their own handlers. // Those handlers should not attempt to update state though; - // They should call the valueChangedCallback() instead when a subscription changes. + // They should call the callback() instead when a subscription changes. // You may optionally return a subscription value to later unsubscribe (e.g. event handler). +subscribe: ( source: Property, - valueChangedCallback: (value: Value) => void, + callback: (value: Value) => void, ) => CreatedSubscription, // Unsubsribe from the subscribable value in props. @@ -110,7 +110,7 @@ export function createSubscription< subscribe() { const {source} = this.state; if (source != null) { - const valueChangedCallback = (value: Value) => { + const callback = (value: Value) => { this.setState(state => { // If the value is the same, skip the unnecessary state update. if (value === state.value) { @@ -128,7 +128,7 @@ export function createSubscription< // Event listeners are only safe to add during the commit phase, // So they won't leak if render is interrupted or errors. - const subscription = subscribe(source, valueChangedCallback); + const subscription = subscribe(source, callback); // Store subscription for later (in case it's needed to unsubscribe). // This is safe to do via mutation since: From 629f145ccb448e8603574613bd572d8507dbeea6 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Wed, 7 Mar 2018 09:38:15 -0800 Subject: [PATCH 33/53] README tweaks --- packages/create-subscription/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/create-subscription/README.md b/packages/create-subscription/README.md index 76eee957512bd..f75e4306615ef 100644 --- a/packages/create-subscription/README.md +++ b/packages/create-subscription/README.md @@ -28,7 +28,7 @@ const Subscription = createComponent({ }, subscribe(source, callback) { // Subscribe (e.g. add an event listener) to the subscription (source). - // Call callback() whenever a subscription changes. + // Call callback(newValue) whenever a subscription changes. // Return any value that will later be needed to unsubscribe (e.g. an event handler). }, unsubscribe(source, subscription) { @@ -38,7 +38,7 @@ const Subscription = createComponent({ }); ``` -To use the `Subscription` component, pass the subscribable property (e.g. an event dispatcher, Flux store, observable) as the `source` property and use a [`children` render prop](https://reactjs.org/docs/render-props.html) to handle the subscribed value when it changes: +To use the `Subscription` component, pass the subscribable property (e.g. an event dispatcher, Flux store, observable) as the `source` property and use a [render prop](https://reactjs.org/docs/render-props.html), `children`, to handle the subscribed value when it changes: ```js From 48b4a1bbe994c2622362d2d2b7ec0138c8718e05 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Wed, 7 Mar 2018 09:47:15 -0800 Subject: [PATCH 34/53] Wording tweak --- .../src/__tests__/createSubscription-test.internal.js | 4 ++-- packages/create-subscription/src/createSubscription.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/create-subscription/src/__tests__/createSubscription-test.internal.js b/packages/create-subscription/src/__tests__/createSubscription-test.internal.js index 8fefc17f124a6..7a7091aea8e5a 100644 --- a/packages/create-subscription/src/__tests__/createSubscription-test.internal.js +++ b/packages/create-subscription/src/__tests__/createSubscription-test.internal.js @@ -399,7 +399,7 @@ describe('createSubscription', () => { expect(ReactNoop.flush()).toEqual([]); }); - describe('invariants', () => { + describe('warnings', () => { it('should error for invalid missing getValue', () => { expect(() => { createSubscription( @@ -436,7 +436,7 @@ describe('createSubscription', () => { }, () => null, ); - }).toThrow('Subscription must specify a unsubscribe function'); + }).toThrow('Subscription must specify an unsubscribe function'); }); }); }); diff --git a/packages/create-subscription/src/createSubscription.js b/packages/create-subscription/src/createSubscription.js index 3a871fe0a739f..44d2ea52a0c74 100644 --- a/packages/create-subscription/src/createSubscription.js +++ b/packages/create-subscription/src/createSubscription.js @@ -49,7 +49,7 @@ export function createSubscription< ); warning( typeof unsubscribe === 'function', - 'Subscription must specify a unsubscribe function', + 'Subscription must specify an unsubscribe function', ); type Props = { From ee2ae93312c404c17bc1c5f932829cc65efa5641 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Wed, 7 Mar 2018 09:49:42 -0800 Subject: [PATCH 35/53] Inline comments tweak --- .../create-subscription/src/createSubscription.js | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/create-subscription/src/createSubscription.js b/packages/create-subscription/src/createSubscription.js index 44d2ea52a0c74..3ce33f899942c 100644 --- a/packages/create-subscription/src/createSubscription.js +++ b/packages/create-subscription/src/createSubscription.js @@ -92,8 +92,6 @@ export function createSubscription< componentDidUpdate(prevProps, prevState) { if (this.state.source !== prevState.source) { - // Similar to adding subscriptions, - // It's only safe to unsubscribe during the commit phase. this.unsubscribe(prevState); this.subscribe(); } @@ -126,15 +124,14 @@ export function createSubscription< }); }; - // Event listeners are only safe to add during the commit phase, - // So they won't leak if render is interrupted or errors. - const subscription = subscribe(source, callback); - // Store subscription for later (in case it's needed to unsubscribe). // This is safe to do via mutation since: // 1) It does not impact render. // 2) This method will only be called during the "commit" phase. - this.state.subscriptionWrapper.subscription = subscription; + this.state.subscriptionWrapper.subscription = subscribe( + source, + callback, + ); // External values could change between render and mount, // In some cases it may be important to handle this case. From 64d80b8c86c6224a6bb057a5cffa8380387f426b Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Wed, 7 Mar 2018 09:53:23 -0800 Subject: [PATCH 36/53] Minor test tidying up --- .../createSubscription-test.internal.js | 51 ++++++------------- 1 file changed, 16 insertions(+), 35 deletions(-) diff --git a/packages/create-subscription/src/__tests__/createSubscription-test.internal.js b/packages/create-subscription/src/__tests__/createSubscription-test.internal.js index 7a7091aea8e5a..e98463e9d9a5e 100644 --- a/packages/create-subscription/src/__tests__/createSubscription-test.internal.js +++ b/packages/create-subscription/src/__tests__/createSubscription-test.internal.js @@ -107,29 +107,20 @@ describe('createSubscription', () => { unsubscribe: (source, subscription) => subscription.unsubscribe(), }); + function render(value = 'default') { + ReactNoop.yield(value); + return null; + } + const observable = createFauxReplaySubject('initial'); - ReactNoop.render( - - {(value = 'default') => { - ReactNoop.yield(value); - return null; - }} - , - ); + ReactNoop.render({render}); expect(ReactNoop.flush()).toEqual(['initial']); observable.update('updated'); expect(ReactNoop.flush()).toEqual(['updated']); // Unsetting the subscriber prop should reset subscribed values - ReactNoop.render( - - {(value = 'default') => { - ReactNoop.yield(value); - return null; - }} - , - ); + ReactNoop.render({render}); expect(ReactNoop.flush()).toEqual(['default']); }); @@ -142,7 +133,7 @@ describe('createSubscription', () => { unsubscribe: (source, subscription) => {}, }); - function childrenFunction(hasLoaded) { + function render(hasLoaded) { if (hasLoaded === undefined) { ReactNoop.yield('loading'); } else { @@ -160,10 +151,7 @@ describe('createSubscription', () => { }); // Test a promise that resolves after render - - ReactNoop.render( - {childrenFunction}, - ); + ReactNoop.render({render}); expect(ReactNoop.flush()).toEqual(['loading']); resolveA(); await promiseA; @@ -173,9 +161,7 @@ describe('createSubscription', () => { // Note that this will require an extra render anyway, // Because there is no way to syncrhonously get a Promise's value rejectB(); - ReactNoop.render( - {childrenFunction}, - ); + ReactNoop.render({render}); expect(ReactNoop.flush()).toEqual(['loading']); await promiseB.catch(() => true); expect(ReactNoop.flush()).toEqual(['failed']); @@ -188,7 +174,7 @@ describe('createSubscription', () => { unsubscribe: (source, subscription) => {}, }); - function childrenFunction(value = 'default') { + function render(value = 'default') { ReactNoop.yield(value); return null; } @@ -198,14 +184,9 @@ describe('createSubscription', () => { const promiseB = new Promise(resolve => (resolveB = resolve)); // Subscribe first to Promise A then Promsie B - - ReactNoop.render( - {childrenFunction}, - ); + ReactNoop.render({render}); expect(ReactNoop.flush()).toEqual(['default']); - ReactNoop.render( - {childrenFunction}, - ); + ReactNoop.render({render}); expect(ReactNoop.flush()).toEqual(['default']); // Resolve both Promises @@ -225,7 +206,7 @@ describe('createSubscription', () => { unsubscribe: (source, subscription) => subscription.unsubscribe(), }); - function childrenFunction(value = 'default') { + function render(value = 'default') { ReactNoop.yield(value); return null; } @@ -234,7 +215,7 @@ describe('createSubscription', () => { const observableB = createFauxBehaviorSubject('b-0'); ReactNoop.render( - {childrenFunction}, + {render}, ); // Updates while subscribed should re-render the child component @@ -242,7 +223,7 @@ describe('createSubscription', () => { // Unsetting the subscriber prop should reset subscribed values ReactNoop.render( - {childrenFunction}, + {render}, ); expect(ReactNoop.flush()).toEqual(['b-0']); From ad190fbe45aaa9cb7094f9a71b23e9b7b094fb9d Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Wed, 7 Mar 2018 10:10:36 -0800 Subject: [PATCH 37/53] Added more context to README intro --- packages/create-subscription/README.md | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/create-subscription/README.md b/packages/create-subscription/README.md index f75e4306615ef..291c8e33ed8a8 100644 --- a/packages/create-subscription/README.md +++ b/packages/create-subscription/README.md @@ -2,9 +2,29 @@ [Async-safe subscriptions are hard to get right.](https://gist.github.com/bvaughn/d569177d70b50b58bff69c3c4a5353f3) -This complexity is acceptible for libraries like Redux/Relay/MobX, but it's not ideal to have mixed in with application code. `create-subscription` provides an interface to easily manage subscriptions in an async-safe way. +`create-subscription` provides an simple, async-safe interface to manage a subscription. -## Installation +## Who should use this? + +This utility is 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 the 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 [`simple-cache-provider`](https://github.com/facebook/react/blob/master/packages/simple-cache-provider/README.md) instead. +* Complex things like Relay/Apollo should use this same technique (as referenced [here](https://gist.github.com/bvaughn/d569177d70b50b58bff69c3c4a5353f3)), in a way that is most optimized for their library usage. + +## What types of subscriptions can this support? + +This abstraction can handle a variety of "subscribable" types. For example: + * Event dispatchers like `HTMLInputElement` with `addEventListener()`, `removeEventListener()`, and `value` attributes. + * Custom pub/sub components like Relay's `FragmentSpecResolver` with `subscribe()`, `unsubscribe()`, and `resolve()` methods. + * Observable types like RxJS `BehaviorSubject` with `subscribe()`, `subscription.unsubscribe()`, and `getValue()` methods. + * Observable types like RxJS `ReplaySubject`. (**Note** that these types require a temporary subscription inside of `getValue` to retrieve the latest current/value. See tests for an example.) +* Native Promises. (**Note** that it an initial render value of `undefined` is unavoidable due to the fact that Promises provide no way to synchronously read their current value.) + +Observable types like RxJS `Subject` or `Observable` are not supported, because they provide no way to read the "current" value after it has been emitted. + +# Installation ```sh # Yarn From 32887265bdfff0540a06939a7962f8cdfa1c2e2b Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Wed, 7 Mar 2018 10:24:40 -0800 Subject: [PATCH 38/53] Wordsmith nit picking --- packages/create-subscription/README.md | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/create-subscription/README.md b/packages/create-subscription/README.md index 291c8e33ed8a8..6db79495b27bb 100644 --- a/packages/create-subscription/README.md +++ b/packages/create-subscription/README.md @@ -6,21 +6,20 @@ ## Who should use this? -This utility is 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 the geolocation API to show a dot on a map). +This utility is 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 [`simple-cache-provider`](https://github.com/facebook/react/blob/master/packages/simple-cache-provider/README.md) instead. -* Complex things like Relay/Apollo should use this same technique (as referenced [here](https://gist.github.com/bvaughn/d569177d70b50b58bff69c3c4a5353f3)), in a way that is most optimized for their library usage. +* Complex libraries like Relay/Apollo should use this same technique (as referenced [here](https://gist.github.com/bvaughn/d569177d70b50b58bff69c3c4a5353f3)) in a way that is most optimized for their library usage. ## What types of subscriptions can this support? -This abstraction can handle a variety of "subscribable" types. For example: - * Event dispatchers like `HTMLInputElement` with `addEventListener()`, `removeEventListener()`, and `value` attributes. - * Custom pub/sub components like Relay's `FragmentSpecResolver` with `subscribe()`, `unsubscribe()`, and `resolve()` methods. - * Observable types like RxJS `BehaviorSubject` with `subscribe()`, `subscription.unsubscribe()`, and `getValue()` methods. - * Observable types like RxJS `ReplaySubject`. (**Note** that these types require a temporary subscription inside of `getValue` to retrieve the latest current/value. See tests for an example.) -* Native Promises. (**Note** that it an initial render value of `undefined` is unavoidable due to the fact that Promises provide no way to synchronously read their current value.) +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`. +* Native Promises. Observable types like RxJS `Subject` or `Observable` are not supported, because they provide no way to read the "current" value after it has been emitted. From 81f2695b7a5e8e4a7a3995775cc6926d51434a89 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Wed, 7 Mar 2018 10:25:20 -0800 Subject: [PATCH 39/53] Wordsmith nit picking --- packages/create-subscription/README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/create-subscription/README.md b/packages/create-subscription/README.md index 6db79495b27bb..4bbba66866e96 100644 --- a/packages/create-subscription/README.md +++ b/packages/create-subscription/README.md @@ -18,11 +18,9 @@ Other cases have better long-term solutions: 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`. +* 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.) * Native Promises. -Observable types like RxJS `Subject` or `Observable` are not supported, because they provide no way to read the "current" value after it has been emitted. - # Installation ```sh From 267a76b7fbe3376c1d9d5d0a294f6fcb96d0255d Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Wed, 7 Mar 2018 10:43:41 -0800 Subject: [PATCH 40/53] Replaced Value with Value | void type --- packages/create-subscription/src/createSubscription.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/create-subscription/src/createSubscription.js b/packages/create-subscription/src/createSubscription.js index 3ce33f899942c..eb22282b3f80b 100644 --- a/packages/create-subscription/src/createSubscription.js +++ b/packages/create-subscription/src/createSubscription.js @@ -18,7 +18,7 @@ export function createSubscription< // Synchronously gets the value for the subscribed property. // Return undefined if the subscribable value is undefined, // Or does not support synchronous reading (e.g. native Promise). - +getValue: (source: Property) => Value, + +getValue: (source: Property) => Value | void, // Setup a subscription for the subscribable value in props. // Due to the variety of change event types, subscribers should provide their own handlers. @@ -27,7 +27,7 @@ export function createSubscription< // You may optionally return a subscription value to later unsubscribe (e.g. event handler). +subscribe: ( source: Property, - callback: (value: Value) => void, + callback: (value: Value | void) => void, ) => CreatedSubscription, // Unsubsribe from the subscribable value in props. @@ -61,7 +61,7 @@ export function createSubscription< subscriptionWrapper: { subscription?: CreatedSubscription, }, - value?: Value, + value: Value | void, }; // Reference: https://gist.github.com/bvaughn/d569177d70b50b58bff69c3c4a5353f3 @@ -108,7 +108,7 @@ export function createSubscription< subscribe() { const {source} = this.state; if (source != null) { - const callback = (value: Value) => { + const callback = (value: Value | void) => { this.setState(state => { // If the value is the same, skip the unnecessary state update. if (value === state.value) { From db7b84fc0b48e916a555a56f9d402dc96c144c51 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Wed, 7 Mar 2018 10:52:19 -0800 Subject: [PATCH 41/53] Tweaks in response to Flarnie's feedback --- packages/create-subscription/README.md | 10 +++++----- .../src/__tests__/createSubscription-test.internal.js | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/create-subscription/README.md b/packages/create-subscription/README.md index 4bbba66866e96..f82c45804bd2e 100644 --- a/packages/create-subscription/README.md +++ b/packages/create-subscription/README.md @@ -153,10 +153,10 @@ import createComponent from "create-subscription"; function LoadingComponent({ loadingStatus }) { if (loadingStatus === undefined) { // Loading - } else if (loadingStatus) { - // Success - } else { + } else if (loadingStatus === null) { // Error + } else { + // Success } } @@ -172,9 +172,9 @@ const PromiseSubscription = createComponent({ subscribe: (promise, callback) => { promise.then( // Success - () => callback(true), + value => callback(value), // Failure - () => callback(false) + () => callback(null) ); }, unsubscribe: (promise, subscription) => { diff --git a/packages/create-subscription/src/__tests__/createSubscription-test.internal.js b/packages/create-subscription/src/__tests__/createSubscription-test.internal.js index e98463e9d9a5e..2a5494df46d26 100644 --- a/packages/create-subscription/src/__tests__/createSubscription-test.internal.js +++ b/packages/create-subscription/src/__tests__/createSubscription-test.internal.js @@ -129,7 +129,7 @@ describe('createSubscription', () => { const Subscription = createSubscription({ getValue: source => undefined, subscribe: (source, callback) => - source.then(() => callback(true), () => callback(false)), + source.then(value => callback(value), value => callback(value)), unsubscribe: (source, subscription) => {}, }); @@ -153,14 +153,14 @@ describe('createSubscription', () => { // Test a promise that resolves after render ReactNoop.render({render}); expect(ReactNoop.flush()).toEqual(['loading']); - resolveA(); + resolveA(true); await promiseA; expect(ReactNoop.flush()).toEqual(['finished']); // Test a promise that resolves before render // Note that this will require an extra render anyway, // Because there is no way to syncrhonously get a Promise's value - rejectB(); + rejectB(false); ReactNoop.render({render}); expect(ReactNoop.flush()).toEqual(['loading']); await promiseB.catch(() => true); From 32d6d405882be51f677ad523507bb6cf0c32e712 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Wed, 7 Mar 2018 10:57:48 -0800 Subject: [PATCH 42/53] Added RxJS for tests instead of fake impls --- packages/create-subscription/package.json | 3 + .../createSubscription-test.internal.js | 91 ++++++++----------- yarn.lock | 10 ++ 3 files changed, 50 insertions(+), 54 deletions(-) diff --git a/packages/create-subscription/package.json b/packages/create-subscription/package.json index e83632169d199..bd8ae749f46bc 100644 --- a/packages/create-subscription/package.json +++ b/packages/create-subscription/package.json @@ -14,5 +14,8 @@ }, "peerDependencies": { "react": "16.3.0-alpha.1" + }, + "devDependencies": { + "rxjs": "^5.5.6" } } diff --git a/packages/create-subscription/src/__tests__/createSubscription-test.internal.js b/packages/create-subscription/src/__tests__/createSubscription-test.internal.js index 2a5494df46d26..dbf62b871da37 100644 --- a/packages/create-subscription/src/__tests__/createSubscription-test.internal.js +++ b/packages/create-subscription/src/__tests__/createSubscription-test.internal.js @@ -10,9 +10,11 @@ 'use strict'; let createSubscription; +let BehaviorSubject; let ReactFeatureFlags; let React; let ReactNoop; +let ReplaySubject; describe('createSubscription', () => { beforeEach(() => { @@ -22,44 +24,25 @@ describe('createSubscription', () => { ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false; React = require('react'); ReactNoop = require('react-noop-renderer'); + + BehaviorSubject = require('rxjs/BehaviorSubject').BehaviorSubject; + ReplaySubject = require('rxjs/ReplaySubject').ReplaySubject; }); - // Mimics a partial interface of RxJS `BehaviorSubject` - function createFauxBehaviorSubject(initialValue) { - let currentValue = initialValue; - let subscribedCallbacks = []; - return { - getValue: () => currentValue, - subscribe: callback => { - subscribedCallbacks.push(callback); - return { - unsubscribe: () => { - subscribedCallbacks.splice( - subscribedCallbacks.indexOf(callback), - 1, - ); - }, - }; - }, - update: value => { - currentValue = value; - subscribedCallbacks.forEach(subscribedCallback => - subscribedCallback(value), - ); - }, - }; + function createBehaviorSubject(initialValue) { + const behaviorSubject = new BehaviorSubject(); + if (initialValue) { + behaviorSubject.next(initialValue); + } + return behaviorSubject; } - // Mimics a partial interface of RxJS `ReplaySubject` - function createFauxReplaySubject(initialValue) { - const observable = createFauxBehaviorSubject(initialValue); - const {getValue, subscribe} = observable; - observable.getValue = undefined; - observable.subscribe = callback => { - callback(getValue()); - return subscribe(callback); - }; - return observable; + function createReplaySubject(initialValue) { + const replaySubject = new ReplaySubject(); + if (initialValue) { + replaySubject.next(initialValue); + } + return replaySubject; } it('supports basic subscription pattern', () => { @@ -69,7 +52,7 @@ describe('createSubscription', () => { unsubscribe: (source, subscription) => subscription.unsubscribe(), }); - const observable = createFauxBehaviorSubject(); + const observable = createBehaviorSubject(); ReactNoop.render( {(value = 'default') => { @@ -81,14 +64,14 @@ describe('createSubscription', () => { // Updates while subscribed should re-render the child component expect(ReactNoop.flush()).toEqual(['default']); - observable.update(123); + observable.next(123); expect(ReactNoop.flush()).toEqual([123]); - observable.update('abc'); + observable.next('abc'); expect(ReactNoop.flush()).toEqual(['abc']); // Unmounting the subscriber should remove listeners ReactNoop.render(
); - observable.update(456); + observable.next(456); expect(ReactNoop.flush()).toEqual([]); }); @@ -112,11 +95,11 @@ describe('createSubscription', () => { return null; } - const observable = createFauxReplaySubject('initial'); + const observable = createReplaySubject('initial'); ReactNoop.render({render}); expect(ReactNoop.flush()).toEqual(['initial']); - observable.update('updated'); + observable.next('updated'); expect(ReactNoop.flush()).toEqual(['updated']); // Unsetting the subscriber prop should reset subscribed values @@ -211,8 +194,8 @@ describe('createSubscription', () => { return null; } - const observableA = createFauxBehaviorSubject('a-0'); - const observableB = createFauxBehaviorSubject('b-0'); + const observableA = createBehaviorSubject('a-0'); + const observableB = createBehaviorSubject('b-0'); ReactNoop.render( {render}, @@ -228,11 +211,11 @@ describe('createSubscription', () => { expect(ReactNoop.flush()).toEqual(['b-0']); // Updates to the old subscribable should not re-render the child component - observableA.update('a-1'); + observableA.next('a-1'); expect(ReactNoop.flush()).toEqual([]); // Updates to the bew subscribable should re-render the child component - observableB.update('b-1'); + observableB.next('b-1'); expect(ReactNoop.flush()).toEqual(['b-1']); }); @@ -277,8 +260,8 @@ describe('createSubscription', () => { } } - const observableA = createFauxBehaviorSubject('a-0'); - const observableB = createFauxBehaviorSubject('b-0'); + const observableA = createBehaviorSubject('a-0'); + const observableB = createBehaviorSubject('b-0'); ReactNoop.render(); expect(ReactNoop.flush()).toEqual(['Subscriber: a-0', 'Child: a-0']); @@ -288,9 +271,9 @@ describe('createSubscription', () => { ReactNoop.flushThrough(['Subscriber: b-0']); // Emit some updates from the uncommitted subscribable - observableB.update('b-1'); - observableB.update('b-2'); - observableB.update('b-3'); + observableB.next('b-1'); + observableB.next('b-2'); + observableB.next('b-3'); // Mimic a higher-priority interruption parentInstance.setState({observed: observableA}); @@ -349,8 +332,8 @@ describe('createSubscription', () => { } } - const observableA = createFauxBehaviorSubject('a-0'); - const observableB = createFauxBehaviorSubject('b-0'); + const observableA = createBehaviorSubject('a-0'); + const observableB = createBehaviorSubject('b-0'); ReactNoop.render(); expect(ReactNoop.flush()).toEqual(['Subscriber: a-0', 'Child: a-0']); @@ -360,8 +343,8 @@ describe('createSubscription', () => { ReactNoop.flushThrough(['Subscriber: b-0']); // Emit some updates from the old subscribable - observableA.update('a-1'); - observableA.update('a-2'); + observableA.next('a-1'); + observableA.next('a-2'); // Mimic a higher-priority interruption parentInstance.setState({observed: observableA}); @@ -376,7 +359,7 @@ describe('createSubscription', () => { ]); // Updates from the new subsribable should be ignored. - observableB.update('b-1'); + observableB.next('b-1'); expect(ReactNoop.flush()).toEqual([]); }); diff --git a/yarn.lock b/yarn.lock index 5f1e2ce632188..2846df0a248db 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4897,6 +4897,12 @@ rx-lite@*, rx-lite@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/rx-lite/-/rx-lite-4.0.8.tgz#0b1e11af8bc44836f04a6407e92da42467b79444" +rxjs@^5.5.6: + version "5.5.6" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-5.5.6.tgz#e31fb96d6fd2ff1fd84bcea8ae9c02d007179c02" + dependencies: + symbol-observable "1.0.1" + safe-buffer@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.0.1.tgz#d263ca54696cd8a306b5ca6551e92de57918fbe7" @@ -5217,6 +5223,10 @@ supports-hyperlinks@^1.0.1: has-flag "^2.0.0" supports-color "^5.0.0" +symbol-observable@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.0.1.tgz#8340fc4702c3122df5d22288f88283f513d3fdd4" + symbol-tree@^3.2.1: version "3.2.2" resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.2.tgz#ae27db38f660a7ae2e1c3b7d1bc290819b8519e6" From 55571205ba630cb0ba079ba5306f794676dd93b9 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Wed, 7 Mar 2018 11:04:20 -0800 Subject: [PATCH 43/53] Improved children Flow type slightly --- packages/create-subscription/src/createSubscription.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/create-subscription/src/createSubscription.js b/packages/create-subscription/src/createSubscription.js index eb22282b3f80b..2d8443d6f71cf 100644 --- a/packages/create-subscription/src/createSubscription.js +++ b/packages/create-subscription/src/createSubscription.js @@ -34,7 +34,7 @@ export function createSubscription< // The subscription value returned from subscribe() is passed as the second parameter. +unsubscribe: (source: Property, subscription: CreatedSubscription) => void, |}): React$ComponentType<{ - children: (value: Value) => React$Element, + children: (value: Value) => React$Node, source: any, }> { const {getValue, subscribe, unsubscribe} = config; From ee3dfcc97ee74c0ffaf731331e3b7d4886469c1a Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Wed, 7 Mar 2018 11:11:13 -0800 Subject: [PATCH 44/53] Added Flow <> around config --- .../src/createSubscription.js | 46 +++++++++---------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/packages/create-subscription/src/createSubscription.js b/packages/create-subscription/src/createSubscription.js index 2d8443d6f71cf..6ea36ed395c42 100644 --- a/packages/create-subscription/src/createSubscription.js +++ b/packages/create-subscription/src/createSubscription.js @@ -10,30 +10,28 @@ import React from 'react'; import warning from 'fbjs/lib/invariant'; -export function createSubscription< - Property, - CreatedSubscription, - Value, ->(config: {| - // Synchronously gets the value for the subscribed property. - // Return undefined if the subscribable value is undefined, - // Or does not support synchronous reading (e.g. native Promise). - +getValue: (source: Property) => Value | void, - - // Setup a subscription for the subscribable value in props. - // Due to the variety of change event types, subscribers should provide their own handlers. - // Those handlers should not attempt to update state though; - // They should call the callback() instead when a subscription changes. - // You may optionally return a subscription value to later unsubscribe (e.g. event handler). - +subscribe: ( - source: Property, - callback: (value: Value | void) => void, - ) => CreatedSubscription, - - // Unsubsribe from the subscribable value in props. - // The subscription value returned from subscribe() is passed as the second parameter. - +unsubscribe: (source: Property, subscription: CreatedSubscription) => void, -|}): React$ComponentType<{ +export function createSubscription( + config: $ReadOnly<{| + // Synchronously gets the value for the subscribed property. + // Return undefined if the subscribable value is undefined, + // Or does not support synchronous reading (e.g. native Promise). + getValue: (source: Property) => Value | void, + + // Setup a subscription for the subscribable value in props. + // Due to the variety of change event types, subscribers should provide their own handlers. + // Those handlers should not attempt to update state though; + // They should call the callback() instead when a subscription changes. + // You may optionally return a subscription value to later unsubscribe (e.g. event handler). + subscribe: ( + source: Property, + callback: (value: Value | void) => void, + ) => CreatedSubscription, + + // Unsubsribe from the subscribable value in props. + // The subscription value returned from subscribe() is passed as the second parameter. + unsubscribe: (source: Property, subscription: CreatedSubscription) => void, + |}>, +): React$ComponentType<{ children: (value: Value) => React$Node, source: any, }> { From a2f43a57714557b7c712cdcc3bbb4230025ae232 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Thu, 8 Mar 2018 08:13:13 -0800 Subject: [PATCH 45/53] Fixed example imports in README --- packages/create-subscription/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/create-subscription/README.md b/packages/create-subscription/README.md index f82c45804bd2e..372c09d4688cd 100644 --- a/packages/create-subscription/README.md +++ b/packages/create-subscription/README.md @@ -36,7 +36,7 @@ npm install create-subscription --save To configure a subscription, you must specify three properties: `getValue`, `subscribe`, and `unsubscribe`. ```js -import createComponent from "create-subscription"; +import { createSubscription } from "create-subscription"; const Subscription = createComponent({ getValue(source) { @@ -73,7 +73,7 @@ Below is an example showing how `create-subscription` can be used to subscribe t ```js import React from "react"; -import createComponent from "create-subscription"; +import { createSubscription } from "create-subscription"; // Start with a simple component. // In this case, it's a functional component, but it could have been a class. @@ -147,7 +147,7 @@ Below is an example showing how `create-subscription` can be used with native Pr ```js import React from "react"; -import createComponent from "create-subscription"; +import { createSubscription } from "create-subscription"; // Start with a simple component. function LoadingComponent({ loadingStatus }) { @@ -187,4 +187,4 @@ const PromiseSubscription = createComponent({ {loadingStatus => } -``` \ No newline at end of file +``` From 4e57ed78b9daf1bab0f6c8717741bda3ccd8172f Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Thu, 8 Mar 2018 08:51:08 -0800 Subject: [PATCH 46/53] Replaced createComponent() references with createSubscription() in README --- packages/create-subscription/README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/create-subscription/README.md b/packages/create-subscription/README.md index 372c09d4688cd..8a5fe5ccdc192 100644 --- a/packages/create-subscription/README.md +++ b/packages/create-subscription/README.md @@ -38,7 +38,7 @@ To configure a subscription, you must specify three properties: `getValue`, `sub ```js import { createSubscription } from "create-subscription"; -const Subscription = createComponent({ +const Subscription = createSubscription({ getValue(source) { // Return the current value of the subscription (source), // or `undefined` if the value can't be read synchronously (e.g. native Promises). @@ -82,7 +82,7 @@ function FollowerComponent({ followersCount }) { } // Create a wrapper component to manage the subscription. -const EventHandlerSubscription = createComponent({ +const EventHandlerSubscription = createSubscription({ getValue: eventDispatcher => eventDispatcher.value, subscribe: (eventDispatcher, callback) => { const onChange = event => callback(eventDispatcher.value); @@ -109,7 +109,7 @@ Below are examples showing how `create-subscription` can be used to subscribe to ### `BehaviorSubject` ```js -const BehaviorSubscription = createComponent({ +const BehaviorSubscription = createSubscription({ getValue: behaviorSubject => behaviorSubject.getValue(), subscribe: (behaviorSubject, callback) => behaviorSubject.subscribe(callback), @@ -119,7 +119,7 @@ const BehaviorSubscription = createComponent({ ### `ReplaySubject` ```js -const ReplaySubscription = createComponent({ +const ReplaySubscription = createSubscription({ getValue: replaySubject => { let currentValue; // ReplaySubject does not have a sync data getter, @@ -163,7 +163,7 @@ function LoadingComponent({ loadingStatus }) { // Wrap the functional component with a subscriber HOC. // This HOC will manage subscriptions and pass values to the decorated component. // It will add and remove subscriptions in an async-safe way when props change. -const PromiseSubscription = createComponent({ +const PromiseSubscription = createSubscription({ getValue: promise => { // There is no way to synchronously read a Promise's value, // So this method should return undefined. From f0c68b804febaa25745354cf1f6e087deae6ca28 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Thu, 8 Mar 2018 10:01:00 -0800 Subject: [PATCH 47/53] Changed subscribe() to return an unsubscribe method (or false) --- packages/create-subscription/README.md | 36 ++++---- .../createSubscription-test.internal.js | 82 +++++++++++-------- .../src/createSubscription.js | 51 ++++++------ 3 files changed, 89 insertions(+), 80 deletions(-) diff --git a/packages/create-subscription/README.md b/packages/create-subscription/README.md index 8a5fe5ccdc192..0ca20391ac09c 100644 --- a/packages/create-subscription/README.md +++ b/packages/create-subscription/README.md @@ -33,7 +33,7 @@ npm install create-subscription --save # Usage -To configure a subscription, you must specify three properties: `getValue`, `subscribe`, and `unsubscribe`. +To configure a subscription, you must provide two methods: `getValue` and `subscribe`. ```js import { createSubscription } from "create-subscription"; @@ -46,11 +46,8 @@ const Subscription = createSubscription({ subscribe(source, callback) { // Subscribe (e.g. add an event listener) to the subscription (source). // Call callback(newValue) whenever a subscription changes. - // Return any value that will later be needed to unsubscribe (e.g. an event handler). - }, - unsubscribe(source, subscription) { - // Remove your subscription from source. - // The value returned by subscribe() is the second, 'subscription' parameter. + // Return an unsubscribe method, + // Or false if unsubscribe is not supported (e.g. native Promises). } }); ``` @@ -87,10 +84,7 @@ const EventHandlerSubscription = createSubscription({ subscribe: (eventDispatcher, callback) => { const onChange = event => callback(eventDispatcher.value); eventDispatcher.addEventListener("change", onChange); - return onChange; - }, - unsubscribe: (eventDispatcher, subscription) => { - eventDispatcher.removeEventListener("change", subscription); + return () => eventDispatcher.removeEventListener("change", onChange); } }); @@ -98,7 +92,7 @@ const EventHandlerSubscription = createSubscription({ // In this example, 'eventDispatcher' represents a generic event dispatcher. {value => } - +; ``` ## Subscribing to observables @@ -111,9 +105,10 @@ Below are examples showing how `create-subscription` can be used to subscribe to ```js const BehaviorSubscription = createSubscription({ getValue: behaviorSubject => behaviorSubject.getValue(), - subscribe: (behaviorSubject, callback) => - behaviorSubject.subscribe(callback), - unsubscribe: (behaviorSubject, subscription) => behaviorSubject.unsubscribe() + subscribe: (behaviorSubject, callback) => { + const subscription = behaviorSubject.subscribe(callback); + return () => subscription.unsubscribe(); + } }); ``` @@ -131,9 +126,10 @@ const ReplaySubscription = createSubscription({ .unsubscribe(); return currentValue; }, - subscribe: (replaySubject, callback) => - replaySubject.subscribe(callback), - unsubscribe: (replaySubject, subscription) => replaySubject.unsubscribe() + subscribe: (replaySubject, callback) => { + const subscription = replaySubject.subscribe(callback); + return () => subscription.unsubscribe(); + } }); ``` @@ -176,10 +172,10 @@ const PromiseSubscription = createSubscription({ // Failure () => callback(null) ); - }, - unsubscribe: (promise, subscription) => { + // There is no way to "unsubscribe" from a Promise. - // In this case, create-subscription will block stale values from rendering. + // create-subscription will still prevent stale values from rendering. + return false; } }); diff --git a/packages/create-subscription/src/__tests__/createSubscription-test.internal.js b/packages/create-subscription/src/__tests__/createSubscription-test.internal.js index dbf62b871da37..b5092fe2d78b7 100644 --- a/packages/create-subscription/src/__tests__/createSubscription-test.internal.js +++ b/packages/create-subscription/src/__tests__/createSubscription-test.internal.js @@ -48,8 +48,10 @@ describe('createSubscription', () => { it('supports basic subscription pattern', () => { const Subscription = createSubscription({ getValue: source => source.getValue(), - subscribe: (source, callback) => source.subscribe(callback), - unsubscribe: (source, subscription) => subscription.unsubscribe(), + subscribe: (source, callback) => { + const subscription = source.subscribe(callback); + return () => subscription.unsubscribe; + }, }); const observable = createBehaviorSubject(); @@ -86,8 +88,10 @@ describe('createSubscription', () => { .unsubscribe(); return currentValue; }, - subscribe: (source, callback) => source.subscribe(callback), - unsubscribe: (source, subscription) => subscription.unsubscribe(), + subscribe: (source, callback) => { + const subscription = source.subscribe(callback); + return () => subscription.unsubscribe; + }, }); function render(value = 'default') { @@ -111,9 +115,11 @@ describe('createSubscription', () => { it('should support Promises', async () => { const Subscription = createSubscription({ getValue: source => undefined, - subscribe: (source, callback) => - source.then(value => callback(value), value => callback(value)), - unsubscribe: (source, subscription) => {}, + subscribe: (source, callback) => { + source.then(value => callback(value), value => callback(value)); + // (Can't unsubscribe from a Promise) + return false; + }, }); function render(hasLoaded) { @@ -153,8 +159,11 @@ describe('createSubscription', () => { it('should still work if unsubscription is managed incorrectly', async () => { const Subscription = createSubscription({ getValue: source => undefined, - subscribe: (source, callback) => source.then(callback), - unsubscribe: (source, subscription) => {}, + subscribe: (source, callback) => { + source.then(callback); + // (Can't unsubscribe from a Promise) + return false; + }, }); function render(value = 'default') { @@ -185,8 +194,10 @@ describe('createSubscription', () => { it('should unsubscribe from old subscribables and subscribe to new subscribables when props change', () => { const Subscription = createSubscription({ getValue: source => source.getValue(), - subscribe: (source, callback) => source.subscribe(callback), - unsubscribe: (source, subscription) => subscription.unsubscribe(), + subscribe: (source, callback) => { + const subscription = source.subscribe(callback); + return () => subscription.unsubscribe(); + }, }); function render(value = 'default') { @@ -229,8 +240,10 @@ describe('createSubscription', () => { const Subscription = createSubscription({ getValue: source => source.getValue(), - subscribe: (source, callback) => source.subscribe(callback), - unsubscribe: (source, subscription) => subscription.unsubscribe(), + subscribe: (source, callback) => { + const subscription = source.subscribe(callback); + return () => subscription.unsubscribe(); + }, }); class Parent extends React.Component { @@ -301,8 +314,10 @@ describe('createSubscription', () => { const Subscription = createSubscription({ getValue: source => source.getValue(), - subscribe: (source, callback) => source.subscribe(callback), - unsubscribe: (source, subscription) => subscription.unsubscribe(), + subscribe: (source, callback) => { + const subscription = source.subscribe(callback); + return () => subscription.unsubscribe(); + }, }); class Parent extends React.Component { @@ -368,39 +383,38 @@ describe('createSubscription', () => { expect(() => { createSubscription( { - property: 'somePropertyName', - subscribe: () => {}, - unsubscribe: () => {}, + subscribe: () => () => {}, }, () => null, ); - }).toThrow('Subscription must specify a getValue function'); + }).toWarnDev('Subscription must specify a getValue function'); }); it('should error for invalid missing subscribe', () => { expect(() => { createSubscription( { - property: 'somePropertyName', - getValue: () => {}, - unsubscribe: () => {}, + getValue: () => () => {}, }, () => null, ); - }).toThrow('Subscription must specify a subscribe function'); + }).toWarnDev('Subscription must specify a subscribe function'); }); - it('should error for invalid missing unsubscribe', () => { - expect(() => { - createSubscription( - { - property: 'somePropertyName', - getValue: () => {}, - subscribe: () => {}, - }, - () => null, - ); - }).toThrow('Subscription must specify an unsubscribe function'); + it('should error if subscribe does not return an unsubscribe method', () => { + const Subscription = createSubscription({ + getValue: source => undefined, + subscribe: (source, callback) => {}, + }); + + const observable = createBehaviorSubject(); + ReactNoop.render( + {value => null}, + ); + + expect(ReactNoop.flush).toThrow( + 'A subscription should return either an unsubscribe function or false.', + ); }); }); }); diff --git a/packages/create-subscription/src/createSubscription.js b/packages/create-subscription/src/createSubscription.js index 6ea36ed395c42..c7233135fd676 100644 --- a/packages/create-subscription/src/createSubscription.js +++ b/packages/create-subscription/src/createSubscription.js @@ -8,34 +8,34 @@ */ import React from 'react'; -import warning from 'fbjs/lib/invariant'; +import invariant from 'fbjs/lib/invariant'; +import warning from 'fbjs/lib/warning'; -export function createSubscription( +type Unsubscribe = () => void; +type CannotUnsubscribe = false; + +export function createSubscription( config: $ReadOnly<{| // Synchronously gets the value for the subscribed property. // Return undefined if the subscribable value is undefined, // Or does not support synchronous reading (e.g. native Promise). getValue: (source: Property) => Value | void, - // Setup a subscription for the subscribable value in props. + // Setup a subscription for the subscribable value in props, and return an unsubscribe function. + // Return false to indicate the property cannot be unsubscribed from (e.g. native Promises). // Due to the variety of change event types, subscribers should provide their own handlers. // Those handlers should not attempt to update state though; // They should call the callback() instead when a subscription changes. - // You may optionally return a subscription value to later unsubscribe (e.g. event handler). subscribe: ( source: Property, callback: (value: Value | void) => void, - ) => CreatedSubscription, - - // Unsubsribe from the subscribable value in props. - // The subscription value returned from subscribe() is passed as the second parameter. - unsubscribe: (source: Property, subscription: CreatedSubscription) => void, + ) => Unsubscribe | CannotUnsubscribe, |}>, ): React$ComponentType<{ children: (value: Value) => React$Node, source: any, }> { - const {getValue, subscribe, unsubscribe} = config; + const {getValue, subscribe} = config; warning( typeof getValue === 'function', @@ -45,10 +45,6 @@ export function createSubscription( typeof subscribe === 'function', 'Subscription must specify a subscribe function', ); - warning( - typeof unsubscribe === 'function', - 'Subscription must specify an unsubscribe function', - ); type Props = { children: (value: Value) => React$Element, @@ -56,8 +52,8 @@ export function createSubscription( }; type State = { source: Property, - subscriptionWrapper: { - subscription?: CreatedSubscription, + unsubscribeContainer: { + unsubscribe?: Unsubscribe | CannotUnsubscribe, }, value: Value | void, }; @@ -66,7 +62,7 @@ export function createSubscription( class Subscription extends React.Component { state: State = { source: this.props.source, - subscriptionWrapper: {}, + unsubscribeContainer: {}, value: this.props.source != null ? getValue(this.props.source) : undefined, }; @@ -75,7 +71,7 @@ export function createSubscription( if (nextProps.source !== prevState.source) { return { source: nextProps.source, - subscriptionWrapper: {}, + unsubscribeContainer: {}, value: nextProps.source != null ? getValue(nextProps.source) : undefined, }; @@ -126,11 +122,16 @@ export function createSubscription( // This is safe to do via mutation since: // 1) It does not impact render. // 2) This method will only be called during the "commit" phase. - this.state.subscriptionWrapper.subscription = subscribe( - source, - callback, + const unsubscribeOrBoolean = subscribe(source, callback); + + invariant( + unsubscribeOrBoolean === false || + typeof unsubscribeOrBoolean === 'function', + 'A subscription should return either an unsubscribe function or false.', ); + this.state.unsubscribeContainer.unsubscribe = unsubscribeOrBoolean; + // External values could change between render and mount, // In some cases it may be important to handle this case. const value = getValue(this.props.source); @@ -141,11 +142,9 @@ export function createSubscription( } unsubscribe(state: State) { - if (state.source != null) { - unsubscribe( - state.source, - ((state.subscriptionWrapper.subscription: any): CreatedSubscription), - ); + const {unsubscribe} = state.unsubscribeContainer; + if (typeof unsubscribe === 'function') { + unsubscribe(); } } } From 63a65e61526665360db7dc2b227c498bedb100cd Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Mon, 12 Mar 2018 15:20:40 -0700 Subject: [PATCH 48/53] Flow type tweak --- packages/create-subscription/src/createSubscription.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/create-subscription/src/createSubscription.js b/packages/create-subscription/src/createSubscription.js index c7233135fd676..f672866a6cb4f 100644 --- a/packages/create-subscription/src/createSubscription.js +++ b/packages/create-subscription/src/createSubscription.js @@ -32,7 +32,7 @@ export function createSubscription( ) => Unsubscribe | CannotUnsubscribe, |}>, ): React$ComponentType<{ - children: (value: Value) => React$Node, + children: (value: Value | void) => React$Node, source: any, }> { const {getValue, subscribe} = config; From c116528a03bdf4c4207bd5c1c6dc22d99de46494 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Tue, 13 Mar 2018 11:19:20 -0700 Subject: [PATCH 49/53] Responded to Andrew's PR feedback --- packages/create-subscription/README.md | 8 ++- .../createSubscription-test.internal.js | 49 ++++++++++++++++--- .../src/createSubscription.js | 22 +++++---- 3 files changed, 58 insertions(+), 21 deletions(-) diff --git a/packages/create-subscription/README.md b/packages/create-subscription/README.md index 0ca20391ac09c..2233224a06ce9 100644 --- a/packages/create-subscription/README.md +++ b/packages/create-subscription/README.md @@ -1,14 +1,12 @@ # create-subscription -[Async-safe subscriptions are hard to get right.](https://gist.github.com/bvaughn/d569177d70b50b58bff69c3c4a5353f3) +`create-subscription` provides an async-safe interface to manage a subscription. -`create-subscription` provides an simple, async-safe interface to manage a subscription. - -## Who should use this? +## When should you NOT use this? This utility is 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: +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 [`simple-cache-provider`](https://github.com/facebook/react/blob/master/packages/simple-cache-provider/README.md) instead. * Complex libraries like Relay/Apollo should use this same technique (as referenced [here](https://gist.github.com/bvaughn/d569177d70b50b58bff69c3c4a5353f3)) in a way that is most optimized for their library usage. diff --git a/packages/create-subscription/src/__tests__/createSubscription-test.internal.js b/packages/create-subscription/src/__tests__/createSubscription-test.internal.js index b5092fe2d78b7..19f7440655f9e 100644 --- a/packages/create-subscription/src/__tests__/createSubscription-test.internal.js +++ b/packages/create-subscription/src/__tests__/createSubscription-test.internal.js @@ -118,7 +118,7 @@ describe('createSubscription', () => { subscribe: (source, callback) => { source.then(value => callback(value), value => callback(value)); // (Can't unsubscribe from a Promise) - return false; + return () => {}; }, }); @@ -162,7 +162,7 @@ describe('createSubscription', () => { subscribe: (source, callback) => { source.then(callback); // (Can't unsubscribe from a Promise) - return false; + return () => {}; }, }); @@ -175,7 +175,7 @@ describe('createSubscription', () => { const promiseA = new Promise(resolve => (resolveA = resolve)); const promiseB = new Promise(resolve => (resolveB = resolve)); - // Subscribe first to Promise A then Promsie B + // Subscribe first to Promise A then Promise B ReactNoop.render({render}); expect(ReactNoop.flush()).toEqual(['default']); ReactNoop.render({render}); @@ -231,6 +231,7 @@ describe('createSubscription', () => { }); it('should ignore values emitted by a new subscribable until the commit phase', () => { + const log = []; let parentInstance; function Child({value}) { @@ -259,6 +260,14 @@ describe('createSubscription', () => { return null; } + componentDidMount() { + log.push('Parent.componentDidMount'); + } + + componentDidUpdate() { + log.push('Parent.componentDidUpdate'); + } + render() { parentInstance = this; @@ -278,10 +287,12 @@ describe('createSubscription', () => { ReactNoop.render(); expect(ReactNoop.flush()).toEqual(['Subscriber: a-0', 'Child: a-0']); + expect(log).toEqual(['Parent.componentDidMount']); // Start React update, but don't finish ReactNoop.render(); ReactNoop.flushThrough(['Subscriber: b-0']); + expect(log).toEqual(['Parent.componentDidMount']); // Emit some updates from the uncommitted subscribable observableB.next('b-1'); @@ -302,9 +313,15 @@ describe('createSubscription', () => { '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 = []; let parentInstance; function Child({value}) { @@ -333,6 +350,14 @@ describe('createSubscription', () => { return null; } + componentDidMount() { + log.push('Parent.componentDidMount'); + } + + componentDidUpdate() { + log.push('Parent.componentDidUpdate'); + } + render() { parentInstance = this; @@ -352,10 +377,12 @@ describe('createSubscription', () => { ReactNoop.render(); expect(ReactNoop.flush()).toEqual(['Subscriber: a-0', 'Child: a-0']); + expect(log).toEqual(['Parent.componentDidMount']); // Start React update, but don't finish ReactNoop.render(); ReactNoop.flushThrough(['Subscriber: b-0']); + expect(log).toEqual(['Parent.componentDidMount']); // Emit some updates from the old subscribable observableA.next('a-1'); @@ -372,14 +399,24 @@ describe('createSubscription', () => { 'Subscriber: a-2', 'Child: a-2', ]); + expect(log).toEqual([ + 'Parent.componentDidMount', + 'Parent.componentDidUpdate', + 'Parent.componentDidUpdate', + ]); // Updates from the new subsribable should be ignored. observableB.next('b-1'); expect(ReactNoop.flush()).toEqual([]); + expect(log).toEqual([ + 'Parent.componentDidMount', + 'Parent.componentDidUpdate', + 'Parent.componentDidUpdate', + ]); }); describe('warnings', () => { - it('should error for invalid missing getValue', () => { + it('should warn for invalid missing getValue', () => { expect(() => { createSubscription( { @@ -390,7 +427,7 @@ describe('createSubscription', () => { }).toWarnDev('Subscription must specify a getValue function'); }); - it('should error for invalid missing subscribe', () => { + it('should warn for invalid missing subscribe', () => { expect(() => { createSubscription( { @@ -401,7 +438,7 @@ describe('createSubscription', () => { }).toWarnDev('Subscription must specify a subscribe function'); }); - it('should error if subscribe does not return an unsubscribe method', () => { + it('should warn if subscribe does not return an unsubscribe method', () => { const Subscription = createSubscription({ getValue: source => undefined, subscribe: (source, callback) => {}, diff --git a/packages/create-subscription/src/createSubscription.js b/packages/create-subscription/src/createSubscription.js index f672866a6cb4f..317962d3e9ddc 100644 --- a/packages/create-subscription/src/createSubscription.js +++ b/packages/create-subscription/src/createSubscription.js @@ -12,7 +12,6 @@ import invariant from 'fbjs/lib/invariant'; import warning from 'fbjs/lib/warning'; type Unsubscribe = () => void; -type CannotUnsubscribe = false; export function createSubscription( config: $ReadOnly<{| @@ -29,11 +28,11 @@ export function createSubscription( subscribe: ( source: Property, callback: (value: Value | void) => void, - ) => Unsubscribe | CannotUnsubscribe, + ) => Unsubscribe, |}>, ): React$ComponentType<{ children: (value: Value | void) => React$Node, - source: any, + source: Property, }> { const {getValue, subscribe} = config; @@ -53,7 +52,7 @@ export function createSubscription( type State = { source: Property, unsubscribeContainer: { - unsubscribe?: Unsubscribe | CannotUnsubscribe, + unsubscribe: Unsubscribe | null, }, value: Value | void, }; @@ -62,7 +61,9 @@ export function createSubscription( class Subscription extends React.Component { state: State = { source: this.props.source, - unsubscribeContainer: {}, + unsubscribeContainer: { + unsubscribe: null, + }, value: this.props.source != null ? getValue(this.props.source) : undefined, }; @@ -71,7 +72,9 @@ export function createSubscription( if (nextProps.source !== prevState.source) { return { source: nextProps.source, - unsubscribeContainer: {}, + unsubscribeContainer: { + unsubscribe: null, + }, value: nextProps.source != null ? getValue(nextProps.source) : undefined, }; @@ -122,15 +125,14 @@ export function createSubscription( // This is safe to do via mutation since: // 1) It does not impact render. // 2) This method will only be called during the "commit" phase. - const unsubscribeOrBoolean = subscribe(source, callback); + const unsubscribe = subscribe(source, callback); invariant( - unsubscribeOrBoolean === false || - typeof unsubscribeOrBoolean === 'function', + typeof unsubscribe === 'function', 'A subscription should return either an unsubscribe function or false.', ); - this.state.unsubscribeContainer.unsubscribe = unsubscribeOrBoolean; + this.state.unsubscribeContainer.unsubscribe = unsubscribe; // External values could change between render and mount, // In some cases it may be important to handle this case. From c1dd9a778e9ce914bc886f99f7eab5a3f8a48f26 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Tue, 13 Mar 2018 11:34:13 -0700 Subject: [PATCH 50/53] Docs updatE --- packages/create-subscription/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/create-subscription/README.md b/packages/create-subscription/README.md index 2233224a06ce9..c99a912273d90 100644 --- a/packages/create-subscription/README.md +++ b/packages/create-subscription/README.md @@ -45,7 +45,7 @@ const Subscription = createSubscription({ // Subscribe (e.g. add an event listener) to the subscription (source). // Call callback(newValue) whenever a subscription changes. // Return an unsubscribe method, - // Or false if unsubscribe is not supported (e.g. native Promises). + // Or a no-op if unsubscribe is not supported (e.g. native Promises). } }); ``` @@ -173,7 +173,7 @@ const PromiseSubscription = createSubscription({ // There is no way to "unsubscribe" from a Promise. // create-subscription will still prevent stale values from rendering. - return false; + return () => {}; } }); From e10e2fc2de578af5d0a9b59dacc7642ff1763cdb Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Tue, 13 Mar 2018 11:36:11 -0700 Subject: [PATCH 51/53] Flow tweak --- packages/create-subscription/src/createSubscription.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/create-subscription/src/createSubscription.js b/packages/create-subscription/src/createSubscription.js index 317962d3e9ddc..b2f52b6548238 100644 --- a/packages/create-subscription/src/createSubscription.js +++ b/packages/create-subscription/src/createSubscription.js @@ -47,7 +47,7 @@ export function createSubscription( type Props = { children: (value: Value) => React$Element, - source: any, + source: Property, }; type State = { source: Property, From f03dfa99f637db61bef6aeb94bbaff104af56347 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Tue, 13 Mar 2018 13:35:03 -0700 Subject: [PATCH 52/53] Addressed PR feedback from Flarnie --- packages/create-subscription/README.md | 16 ++++++------- .../createSubscription-test.internal.js | 24 +++++++++---------- .../src/createSubscription.js | 20 +++++++++------- 3 files changed, 32 insertions(+), 28 deletions(-) diff --git a/packages/create-subscription/README.md b/packages/create-subscription/README.md index c99a912273d90..5d738ed359c31 100644 --- a/packages/create-subscription/README.md +++ b/packages/create-subscription/README.md @@ -4,12 +4,12 @@ ## When should you NOT use this? -This utility is 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). +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 [`simple-cache-provider`](https://github.com/facebook/react/blob/master/packages/simple-cache-provider/README.md) instead. -* Complex libraries like Relay/Apollo should use this same technique (as referenced [here](https://gist.github.com/bvaughn/d569177d70b50b58bff69c3c4a5353f3)) in a way that is most optimized for their library usage. +* 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. ## What types of subscriptions can this support? @@ -31,13 +31,13 @@ npm install create-subscription --save # Usage -To configure a subscription, you must provide two methods: `getValue` and `subscribe`. +To configure a subscription, you must provide two methods: `getCurrentValue` and `subscribe`. ```js import { createSubscription } from "create-subscription"; const Subscription = createSubscription({ - getValue(source) { + getCurrentValue(source) { // Return the current value of the subscription (source), // or `undefined` if the value can't be read synchronously (e.g. native Promises). }, @@ -78,7 +78,7 @@ function FollowerComponent({ followersCount }) { // Create a wrapper component to manage the subscription. const EventHandlerSubscription = createSubscription({ - getValue: eventDispatcher => eventDispatcher.value, + getCurrentValue: eventDispatcher => eventDispatcher.value, subscribe: (eventDispatcher, callback) => { const onChange = event => callback(eventDispatcher.value); eventDispatcher.addEventListener("change", onChange); @@ -102,7 +102,7 @@ Below are examples showing how `create-subscription` can be used to subscribe to ### `BehaviorSubject` ```js const BehaviorSubscription = createSubscription({ - getValue: behaviorSubject => behaviorSubject.getValue(), + getCurrentValue: behaviorSubject => behaviorSubject.getValue(), subscribe: (behaviorSubject, callback) => { const subscription = behaviorSubject.subscribe(callback); return () => subscription.unsubscribe(); @@ -113,7 +113,7 @@ const BehaviorSubscription = createSubscription({ ### `ReplaySubject` ```js const ReplaySubscription = createSubscription({ - getValue: replaySubject => { + getCurrentValue: replaySubject => { let currentValue; // ReplaySubject does not have a sync data getter, // So we need to temporarily subscribe to retrieve the most recent value. @@ -158,7 +158,7 @@ function LoadingComponent({ loadingStatus }) { // This HOC will manage subscriptions and pass values to the decorated component. // It will add and remove subscriptions in an async-safe way when props change. const PromiseSubscription = createSubscription({ - getValue: promise => { + getCurrentValue: promise => { // There is no way to synchronously read a Promise's value, // So this method should return undefined. return undefined; diff --git a/packages/create-subscription/src/__tests__/createSubscription-test.internal.js b/packages/create-subscription/src/__tests__/createSubscription-test.internal.js index 19f7440655f9e..8dee9bfa5c9de 100644 --- a/packages/create-subscription/src/__tests__/createSubscription-test.internal.js +++ b/packages/create-subscription/src/__tests__/createSubscription-test.internal.js @@ -47,7 +47,7 @@ describe('createSubscription', () => { it('supports basic subscription pattern', () => { const Subscription = createSubscription({ - getValue: source => source.getValue(), + getCurrentValue: source => source.getValue(), subscribe: (source, callback) => { const subscription = source.subscribe(callback); return () => subscription.unsubscribe; @@ -79,7 +79,7 @@ describe('createSubscription', () => { it('should support observable types like RxJS ReplaySubject', () => { const Subscription = createSubscription({ - getValue: source => { + getCurrentValue: source => { let currentValue; source .subscribe(value => { @@ -114,7 +114,7 @@ describe('createSubscription', () => { describe('Promises', () => { it('should support Promises', async () => { const Subscription = createSubscription({ - getValue: source => undefined, + getCurrentValue: source => undefined, subscribe: (source, callback) => { source.then(value => callback(value), value => callback(value)); // (Can't unsubscribe from a Promise) @@ -158,7 +158,7 @@ describe('createSubscription', () => { it('should still work if unsubscription is managed incorrectly', async () => { const Subscription = createSubscription({ - getValue: source => undefined, + getCurrentValue: source => undefined, subscribe: (source, callback) => { source.then(callback); // (Can't unsubscribe from a Promise) @@ -193,7 +193,7 @@ describe('createSubscription', () => { it('should unsubscribe from old subscribables and subscribe to new subscribables when props change', () => { const Subscription = createSubscription({ - getValue: source => source.getValue(), + getCurrentValue: source => source.getValue(), subscribe: (source, callback) => { const subscription = source.subscribe(callback); return () => subscription.unsubscribe(); @@ -240,7 +240,7 @@ describe('createSubscription', () => { } const Subscription = createSubscription({ - getValue: source => source.getValue(), + getCurrentValue: source => source.getValue(), subscribe: (source, callback) => { const subscription = source.subscribe(callback); return () => subscription.unsubscribe(); @@ -330,7 +330,7 @@ describe('createSubscription', () => { } const Subscription = createSubscription({ - getValue: source => source.getValue(), + getCurrentValue: source => source.getValue(), subscribe: (source, callback) => { const subscription = source.subscribe(callback); return () => subscription.unsubscribe(); @@ -416,7 +416,7 @@ describe('createSubscription', () => { }); describe('warnings', () => { - it('should warn for invalid missing getValue', () => { + it('should warn for invalid missing getCurrentValue', () => { expect(() => { createSubscription( { @@ -424,14 +424,14 @@ describe('createSubscription', () => { }, () => null, ); - }).toWarnDev('Subscription must specify a getValue function'); + }).toWarnDev('Subscription must specify a getCurrentValue function'); }); it('should warn for invalid missing subscribe', () => { expect(() => { createSubscription( { - getValue: () => () => {}, + getCurrentValue: () => () => {}, }, () => null, ); @@ -440,7 +440,7 @@ describe('createSubscription', () => { it('should warn if subscribe does not return an unsubscribe method', () => { const Subscription = createSubscription({ - getValue: source => undefined, + getCurrentValue: source => undefined, subscribe: (source, callback) => {}, }); @@ -450,7 +450,7 @@ describe('createSubscription', () => { ); expect(ReactNoop.flush).toThrow( - 'A subscription should return either an unsubscribe function or false.', + 'A subscription must return an unsubscribe function.', ); }); }); diff --git a/packages/create-subscription/src/createSubscription.js b/packages/create-subscription/src/createSubscription.js index b2f52b6548238..748090d6cc961 100644 --- a/packages/create-subscription/src/createSubscription.js +++ b/packages/create-subscription/src/createSubscription.js @@ -18,7 +18,7 @@ export function createSubscription( // Synchronously gets the value for the subscribed property. // Return undefined if the subscribable value is undefined, // Or does not support synchronous reading (e.g. native Promise). - getValue: (source: Property) => Value | void, + getCurrentValue: (source: Property) => Value | void, // Setup a subscription for the subscribable value in props, and return an unsubscribe function. // Return false to indicate the property cannot be unsubscribed from (e.g. native Promises). @@ -34,11 +34,11 @@ export function createSubscription( children: (value: Value | void) => React$Node, source: Property, }> { - const {getValue, subscribe} = config; + const {getCurrentValue, subscribe} = config; warning( - typeof getValue === 'function', - 'Subscription must specify a getValue function', + typeof getCurrentValue === 'function', + 'Subscription must specify a getCurrentValue function', ); warning( typeof subscribe === 'function', @@ -65,7 +65,9 @@ export function createSubscription( unsubscribe: null, }, value: - this.props.source != null ? getValue(this.props.source) : undefined, + this.props.source != null + ? getCurrentValue(this.props.source) + : undefined, }; static getDerivedStateFromProps(nextProps, prevState) { @@ -76,7 +78,9 @@ export function createSubscription( unsubscribe: null, }, value: - nextProps.source != null ? getValue(nextProps.source) : undefined, + nextProps.source != null + ? getCurrentValue(nextProps.source) + : undefined, }; } @@ -129,14 +133,14 @@ export function createSubscription( invariant( typeof unsubscribe === 'function', - 'A subscription should return either an unsubscribe function or false.', + 'A subscription must return an unsubscribe function.', ); this.state.unsubscribeContainer.unsubscribe = unsubscribe; // External values could change between render and mount, // In some cases it may be important to handle this case. - const value = getValue(this.props.source); + const value = getCurrentValue(this.props.source); if (value !== this.state.value) { this.setState({value}); } From 6f740d9481ec0e57f8c2ee8c927fec0a4b838585 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Tue, 13 Mar 2018 13:42:55 -0700 Subject: [PATCH 53/53] Removed contradictory references to Flux stores in README --- packages/create-subscription/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/create-subscription/README.md b/packages/create-subscription/README.md index 5d738ed359c31..a55210d1d4b89 100644 --- a/packages/create-subscription/README.md +++ b/packages/create-subscription/README.md @@ -60,11 +60,11 @@ To use the `Subscription` component, pass the subscribable property (e.g. an eve # Examples -This API can be used to subscribe to a variety of "subscribable" sources, from Flux stores to RxJS observables. Below are a few examples of how to subscribe to common types. +This API can be used to subscribe to a variety of "subscribable" sources, from event dispatchers to RxJS observables. Below are a few examples of how to subscribe to common types. ## Subscribing to event dispatchers -Below is an example showing how `create-subscription` can be used to subscribe to event dispatchers such as DOM elements or Flux stores. +Below is an example showing how `create-subscription` can be used to subscribe to event dispatchers such as DOM elements. ```js import React from "react";