From 11e741c470a66f43a6813e7ac4298f5f698f54cf Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Sun, 4 Mar 2018 21:06:44 -0500 Subject: [PATCH] POC for create-component-with-subscriptions --- .../README.md | 81 ++++++++ .../index.js | 12 ++ .../npm/index.js | 7 + .../package.json | 13 ++ .../createComponentWithSubscriptions-test.js | 161 +++++++++++++++ .../src/createComponentWithSubscriptions.js | 188 ++++++++++++++++++ 6 files changed, 462 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..c0b981307f375 --- /dev/null +++ b/packages/create-component-with-subscriptions/src/__tests__/createComponentWithSubscriptions-test.js @@ -0,0 +1,161 @@ +/** + * 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 = []; + let data = null; + let changeCallback = null; + + const Component = createComponent( + { + subscribablePropertiesMap: {observable: 'value'}, + getDataFor: (subscribable, propertyName) => { + expect(propertyName).toBe('observable'); + return data; + }, + subscribeTo: (valueChangedCallback, subscribable, propertyName) => { + expect(propertyName).toBe('observable'); + expect(changeCallback).toBe(null); + changeCallback = valueChangedCallback; + }, + unsubscribeFrom: (subscribable, propertyName, subscription) => { + expect(propertyName).toBe('observable'); + expect(typeof changeCallback).toBe('function'); + changeCallback = null; + }, + }, + ({value}) => { + renderedValues.push(value); + return null; + }, + ); + + const render = ReactTestRenderer.create(); + + expect(renderedValues).toEqual([null]); + expect(typeof changeCallback).toBe('function'); + changeCallback(123); + expect(renderedValues).toEqual([null, 123]); + changeCallback('abc'); + expect(renderedValues).toEqual([null, 123, 'abc']); + + render.update(); + expect(changeCallback).toBe(null); + expect(renderedValues).toEqual([null, 123, 'abc', undefined]); + }); + + 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(); + + 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}]); + + renderedValues.length = 0; + render.update(); + expect(renderedValues).toEqual([{bar: undefined, foo: undefined}]); + + 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; +}