@samitouri / QOS-React-2 / commits / 00a0e3c14f

create-subscription (#12325)

create-subscription provides an simple, async-safe interface to manage a subscription.

Brian Vaughn committed Mar 13, 2018 at 13:59 UTC 00a0e3c14fd76557112316bbe4a72e6bde2d2f63
9 files changed +976 -88
packages/create-subscription/README.md new
+184
@@ -0,0 +1,184 @@
1 +# create-subscription
2 +
3 +`create-subscription` provides an async-safe interface to manage a subscription.
4 +
5 +## When should you NOT use this?
6 +
7 +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).
8 +
9 +Other cases have **better long-term solutions**:
10 +* Redux/Flux stores should use the [context API](https://reactjs.org/docs/context.html) instead.
11 +* 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.
12 +* 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.
13 +
14 +## What types of subscriptions can this support?
15 +
16 +This abstraction can handle a variety of subscription types, including:
17 +* Event dispatchers like `HTMLInputElement`.
18 +* Custom pub/sub components like Relay's `FragmentSpecResolver`.
19 +* 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.)
20 +* Native Promises.
21 +
22 +# Installation
23 +
24 +```sh
25 +# Yarn
26 +yarn add create-subscription
27 +
28 +# NPM
29 +npm install create-subscription --save
30 +```
31 +
32 +# Usage
33 +
34 +To configure a subscription, you must provide two methods: `getCurrentValue` and `subscribe`.
35 +
36 +```js
37 +import { createSubscription } from "create-subscription";
38 +
39 +const Subscription = createSubscription({
40 + getCurrentValue(source) {
41 + // Return the current value of the subscription (source),
42 + // or `undefined` if the value can't be read synchronously (e.g. native Promises).
43 + },
44 + subscribe(source, callback) {
45 + // Subscribe (e.g. add an event listener) to the subscription (source).
46 + // Call callback(newValue) whenever a subscription changes.
47 + // Return an unsubscribe method,
48 + // Or a no-op if unsubscribe is not supported (e.g. native Promises).
49 + }
50 +});
51 +```
52 +
53 +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:
54 +
55 +```js
56 +<Subscription source={eventDispatcher}>
57 + {value => <AnotherComponent value={value} />}
58 +</Subscription>
59 +```
60 +
61 +# Examples
62 +
63 +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.
64 +
65 +## Subscribing to event dispatchers
66 +
67 +Below is an example showing how `create-subscription` can be used to subscribe to event dispatchers such as DOM elements.
68 +
69 +```js
70 +import React from "react";
71 +import { createSubscription } from "create-subscription";
72 +
73 +// Start with a simple component.
74 +// In this case, it's a functional component, but it could have been a class.
75 +function FollowerComponent({ followersCount }) {
76 + return <div>You have {followersCount} followers!</div>;
77 +}
78 +
79 +// Create a wrapper component to manage the subscription.
80 +const EventHandlerSubscription = createSubscription({
81 + getCurrentValue: eventDispatcher => eventDispatcher.value,
82 + subscribe: (eventDispatcher, callback) => {
83 + const onChange = event => callback(eventDispatcher.value);
84 + eventDispatcher.addEventListener("change", onChange);
85 + return () => eventDispatcher.removeEventListener("change", onChange);
86 + }
87 +});
88 +
89 +// Your component can now be used as shown below.
90 +// In this example, 'eventDispatcher' represents a generic event dispatcher.
91 +<EventHandlerSubscription source={eventDispatcher}>
92 + {value => <FollowerComponent followersCount={value} />}
93 +</EventHandlerSubscription>;
94 +```
95 +
96 +## Subscribing to observables
97 +
98 +Below are examples showing how `create-subscription` can be used to subscribe to certain types of observables (e.g. RxJS `BehaviorSubject` and `ReplaySubject`).
99 +
100 +**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.
101 +
102 +### `BehaviorSubject`
103 +```js
104 +const BehaviorSubscription = createSubscription({
105 + getCurrentValue: behaviorSubject => behaviorSubject.getValue(),
106 + subscribe: (behaviorSubject, callback) => {
107 + const subscription = behaviorSubject.subscribe(callback);
108 + return () => subscription.unsubscribe();
109 + }
110 +});
111 +```
112 +
113 +### `ReplaySubject`
114 +```js
115 +const ReplaySubscription = createSubscription({
116 + getCurrentValue: replaySubject => {
117 + let currentValue;
118 + // ReplaySubject does not have a sync data getter,
119 + // So we need to temporarily subscribe to retrieve the most recent value.
120 + replaySubject
121 + .subscribe(value => {
122 + currentValue = value;
123 + })
124 + .unsubscribe();
125 + return currentValue;
126 + },
127 + subscribe: (replaySubject, callback) => {
128 + const subscription = replaySubject.subscribe(callback);
129 + return () => subscription.unsubscribe();
130 + }
131 +});
132 +```
133 +
134 +## Subscribing to a Promise
135 +
136 +Below is an example showing how `create-subscription` can be used with native Promises.
137 +
138 +**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.
139 +
140 +**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.
141 +
142 +```js
143 +import React from "react";
144 +import { createSubscription } from "create-subscription";
145 +
146 +// Start with a simple component.
147 +function LoadingComponent({ loadingStatus }) {
148 + if (loadingStatus === undefined) {
149 + // Loading
150 + } else if (loadingStatus === null) {
151 + // Error
152 + } else {
153 + // Success
154 + }
155 +}
156 +
157 +// Wrap the functional component with a subscriber HOC.
158 +// This HOC will manage subscriptions and pass values to the decorated component.
159 +// It will add and remove subscriptions in an async-safe way when props change.
160 +const PromiseSubscription = createSubscription({
161 + getCurrentValue: promise => {
162 + // There is no way to synchronously read a Promise's value,
163 + // So this method should return undefined.
164 + return undefined;
165 + },
166 + subscribe: (promise, callback) => {
167 + promise.then(
168 + // Success
169 + value => callback(value),
170 + // Failure
171 + () => callback(null)
172 + );
173 +
174 + // There is no way to "unsubscribe" from a Promise.
175 + // create-subscription will still prevent stale values from rendering.
176 + return () => {};
177 + }
178 +});
179 +
180 +// Your component can now be used as shown below.
181 +<PromiseSubscription source={loadingPromise}>
182 + {loadingStatus => <LoadingComponent loadingStatus={loadingStatus} />}
183 +</PromiseSubscription>
184 +```
packages/create-subscription/index.js new
+12
@@ -0,0 +1,12 @@
1 +/**
2 + * Copyright (c) 2013-present, Facebook, Inc.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +'use strict';
11 +
12 +export * from './src/createSubscription';
packages/create-subscription/npm/index.js new
+7
@@ -0,0 +1,7 @@
1 +'use strict';
2 +
3 +if (process.env.NODE_ENV === 'production') {
4 + module.exports = require('./cjs/create-subscription.production.min.js');
5 +} else {
6 + module.exports = require('./cjs/create-subscription.development.js');
7 +}
packages/create-subscription/package.json new
+21
@@ -0,0 +1,21 @@
1 +{
2 + "name": "create-subscription",
3 + "description": "HOC for creating async-safe React components with subscriptions",
4 + "version": "0.0.1",
5 + "repository": "facebook/react",
6 + "files": [
7 + "LICENSE",
8 + "README.md",
9 + "index.js",
10 + "cjs/"
11 + ],
12 + "dependencies": {
13 + "fbjs": "^0.8.16"
14 + },
15 + "peerDependencies": {
16 + "react": "16.3.0-alpha.1"
17 + },
18 + "devDependencies": {
19 + "rxjs": "^5.5.6"
20 + }
21 +}
packages/create-subscription/src/__tests__/createSubscription-test.internal.js new
+457
@@ -0,0 +1,457 @@
1 +/**
2 + * Copyright (c) 2013-present, Facebook, Inc.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @emails react-core
8 + */
9 +
10 +'use strict';
11 +
12 +let createSubscription;
13 +let BehaviorSubject;
14 +let ReactFeatureFlags;
15 +let React;
16 +let ReactNoop;
17 +let ReplaySubject;
18 +
19 +describe('createSubscription', () => {
20 + beforeEach(() => {
21 + jest.resetModules();
22 + createSubscription = require('create-subscription').createSubscription;
23 + ReactFeatureFlags = require('shared/ReactFeatureFlags');
24 + ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false;
25 + React = require('react');
26 + ReactNoop = require('react-noop-renderer');
27 +
28 + BehaviorSubject = require('rxjs/BehaviorSubject').BehaviorSubject;
29 + ReplaySubject = require('rxjs/ReplaySubject').ReplaySubject;
30 + });
31 +
32 + function createBehaviorSubject(initialValue) {
33 + const behaviorSubject = new BehaviorSubject();
34 + if (initialValue) {
35 + behaviorSubject.next(initialValue);
36 + }
37 + return behaviorSubject;
38 + }
39 +
40 + function createReplaySubject(initialValue) {
41 + const replaySubject = new ReplaySubject();
42 + if (initialValue) {
43 + replaySubject.next(initialValue);
44 + }
45 + return replaySubject;
46 + }
47 +
48 + it('supports basic subscription pattern', () => {
49 + const Subscription = createSubscription({
50 + getCurrentValue: source => source.getValue(),
51 + subscribe: (source, callback) => {
52 + const subscription = source.subscribe(callback);
53 + return () => subscription.unsubscribe;
54 + },
55 + });
56 +
57 + const observable = createBehaviorSubject();
58 + ReactNoop.render(
59 + <Subscription source={observable}>
60 + {(value = 'default') => {
61 + ReactNoop.yield(value);
62 + return null;
63 + }}
64 + </Subscription>,
65 + );
66 +
67 + // Updates while subscribed should re-render the child component
68 + expect(ReactNoop.flush()).toEqual(['default']);
69 + observable.next(123);
70 + expect(ReactNoop.flush()).toEqual([123]);
71 + observable.next('abc');
72 + expect(ReactNoop.flush()).toEqual(['abc']);
73 +
74 + // Unmounting the subscriber should remove listeners
75 + ReactNoop.render(<div />);
76 + observable.next(456);
77 + expect(ReactNoop.flush()).toEqual([]);
78 + });
79 +
80 + it('should support observable types like RxJS ReplaySubject', () => {
81 + const Subscription = createSubscription({
82 + getCurrentValue: source => {
83 + let currentValue;
84 + source
85 + .subscribe(value => {
86 + currentValue = value;
87 + })
88 + .unsubscribe();
89 + return currentValue;
90 + },
91 + subscribe: (source, callback) => {
92 + const subscription = source.subscribe(callback);
93 + return () => subscription.unsubscribe;
94 + },
95 + });
96 +
97 + function render(value = 'default') {
98 + ReactNoop.yield(value);
99 + return null;
100 + }
101 +
102 + const observable = createReplaySubject('initial');
103 +
104 + ReactNoop.render(<Subscription source={observable}>{render}</Subscription>);
105 + expect(ReactNoop.flush()).toEqual(['initial']);
106 + observable.next('updated');
107 + expect(ReactNoop.flush()).toEqual(['updated']);
108 +
109 + // Unsetting the subscriber prop should reset subscribed values
110 + ReactNoop.render(<Subscription>{render}</Subscription>);
111 + expect(ReactNoop.flush()).toEqual(['default']);
112 + });
113 +
114 + describe('Promises', () => {
115 + it('should support Promises', async () => {
116 + const Subscription = createSubscription({
117 + getCurrentValue: source => undefined,
118 + subscribe: (source, callback) => {
119 + source.then(value => callback(value), value => callback(value));
120 + // (Can't unsubscribe from a Promise)
121 + return () => {};
122 + },
123 + });
124 +
125 + function render(hasLoaded) {
126 + if (hasLoaded === undefined) {
127 + ReactNoop.yield('loading');
128 + } else {
129 + ReactNoop.yield(hasLoaded ? 'finished' : 'failed');
130 + }
131 + return null;
132 + }
133 +
134 + let resolveA, rejectB;
135 + const promiseA = new Promise((resolve, reject) => {
136 + resolveA = resolve;
137 + });
138 + const promiseB = new Promise((resolve, reject) => {
139 + rejectB = reject;
140 + });
141 +
142 + // Test a promise that resolves after render
143 + ReactNoop.render(<Subscription source={promiseA}>{render}</Subscription>);
144 + expect(ReactNoop.flush()).toEqual(['loading']);
145 + resolveA(true);
146 + await promiseA;
147 + expect(ReactNoop.flush()).toEqual(['finished']);
148 +
149 + // Test a promise that resolves before render
150 + // Note that this will require an extra render anyway,
151 + // Because there is no way to syncrhonously get a Promise's value
152 + rejectB(false);
153 + ReactNoop.render(<Subscription source={promiseB}>{render}</Subscription>);
154 + expect(ReactNoop.flush()).toEqual(['loading']);
155 + await promiseB.catch(() => true);
156 + expect(ReactNoop.flush()).toEqual(['failed']);
157 + });
158 +
159 + it('should still work if unsubscription is managed incorrectly', async () => {
160 + const Subscription = createSubscription({
161 + getCurrentValue: source => undefined,
162 + subscribe: (source, callback) => {
163 + source.then(callback);
164 + // (Can't unsubscribe from a Promise)
165 + return () => {};
166 + },
167 + });
168 +
169 + function render(value = 'default') {
170 + ReactNoop.yield(value);
171 + return null;
172 + }
173 +
174 + let resolveA, resolveB;
175 + const promiseA = new Promise(resolve => (resolveA = resolve));
176 + const promiseB = new Promise(resolve => (resolveB = resolve));
177 +
178 + // Subscribe first to Promise A then Promise B
179 + ReactNoop.render(<Subscription source={promiseA}>{render}</Subscription>);
180 + expect(ReactNoop.flush()).toEqual(['default']);
181 + ReactNoop.render(<Subscription source={promiseB}>{render}</Subscription>);
182 + expect(ReactNoop.flush()).toEqual(['default']);
183 +
184 + // Resolve both Promises
185 + resolveB(123);
186 + resolveA('abc');
187 + await Promise.all([promiseA, promiseB]);
188 +
189 + // Ensure that only Promise B causes an update
190 + expect(ReactNoop.flush()).toEqual([123]);
191 + });
192 + });
193 +
194 + it('should unsubscribe from old subscribables and subscribe to new subscribables when props change', () => {
195 + const Subscription = createSubscription({
196 + getCurrentValue: source => source.getValue(),
197 + subscribe: (source, callback) => {
198 + const subscription = source.subscribe(callback);
199 + return () => subscription.unsubscribe();
200 + },
201 + });
202 +
203 + function render(value = 'default') {
204 + ReactNoop.yield(value);
205 + return null;
206 + }
207 +
208 + const observableA = createBehaviorSubject('a-0');
209 + const observableB = createBehaviorSubject('b-0');
210 +
211 + ReactNoop.render(
212 + <Subscription source={observableA}>{render}</Subscription>,
213 + );
214 +
215 + // Updates while subscribed should re-render the child component
216 + expect(ReactNoop.flush()).toEqual(['a-0']);
217 +
218 + // Unsetting the subscriber prop should reset subscribed values
219 + ReactNoop.render(
220 + <Subscription source={observableB}>{render}</Subscription>,
221 + );
222 + expect(ReactNoop.flush()).toEqual(['b-0']);
223 +
224 + // Updates to the old subscribable should not re-render the child component
225 + observableA.next('a-1');
226 + expect(ReactNoop.flush()).toEqual([]);
227 +
228 + // Updates to the bew subscribable should re-render the child component
229 + observableB.next('b-1');
230 + expect(ReactNoop.flush()).toEqual(['b-1']);
231 + });
232 +
233 + it('should ignore values emitted by a new subscribable until the commit phase', () => {
234 + const log = [];
235 + let parentInstance;
236 +
237 + function Child({value}) {
238 + ReactNoop.yield('Child: ' + value);
239 + return null;
240 + }
241 +
242 + const Subscription = createSubscription({
243 + getCurrentValue: source => source.getValue(),
244 + subscribe: (source, callback) => {
245 + const subscription = source.subscribe(callback);
246 + return () => subscription.unsubscribe();
247 + },
248 + });
249 +
250 + class Parent extends React.Component {
251 + state = {};
252 +
253 + static getDerivedStateFromProps(nextProps, prevState) {
254 + if (nextProps.observed !== prevState.observed) {
255 + return {
256 + observed: nextProps.observed,
257 + };
258 + }
259 +
260 + return null;
261 + }
262 +
263 + componentDidMount() {
264 + log.push('Parent.componentDidMount');
265 + }
266 +
267 + componentDidUpdate() {
268 + log.push('Parent.componentDidUpdate');
269 + }
270 +
271 + render() {
272 + parentInstance = this;
273 +
274 + return (
275 + <Subscription source={this.state.observed}>
276 + {(value = 'default') => {
277 + ReactNoop.yield('Subscriber: ' + value);
278 + return <Child value={value} />;
279 + }}
280 + </Subscription>
281 + );
282 + }
283 + }
284 +
285 + const observableA = createBehaviorSubject('a-0');
286 + const observableB = createBehaviorSubject('b-0');
287 +
288 + ReactNoop.render(<Parent observed={observableA} />);
289 + expect(ReactNoop.flush()).toEqual(['Subscriber: a-0', 'Child: a-0']);
290 + expect(log).toEqual(['Parent.componentDidMount']);
291 +
292 + // Start React update, but don't finish
293 + ReactNoop.render(<Parent observed={observableB} />);
294 + ReactNoop.flushThrough(['Subscriber: b-0']);
295 + expect(log).toEqual(['Parent.componentDidMount']);
296 +
297 + // Emit some updates from the uncommitted subscribable
298 + observableB.next('b-1');
299 + observableB.next('b-2');
300 + observableB.next('b-3');
301 +
302 + // Mimic a higher-priority interruption
303 + parentInstance.setState({observed: observableA});
304 +
305 + // Flush everything and ensure that the correct subscribable is used
306 + // We expect the last emitted update to be rendered (because of the commit phase value check)
307 + // But the intermediate ones should be ignored,
308 + // And the final rendered output should be the higher-priority observable.
309 + expect(ReactNoop.flush()).toEqual([
310 + 'Child: b-0',
311 + 'Subscriber: b-3',
312 + 'Child: b-3',
313 + 'Subscriber: a-0',
314 + 'Child: a-0',
315 + ]);
316 + expect(log).toEqual([
317 + 'Parent.componentDidMount',
318 + 'Parent.componentDidUpdate',
319 + 'Parent.componentDidUpdate',
320 + ]);
321 + });
322 +
323 + it('should not drop values emitted between updates', () => {
324 + const log = [];
325 + let parentInstance;
326 +
327 + function Child({value}) {
328 + ReactNoop.yield('Child: ' + value);
329 + return null;
330 + }
331 +
332 + const Subscription = createSubscription({
333 + getCurrentValue: source => source.getValue(),
334 + subscribe: (source, callback) => {
335 + const subscription = source.subscribe(callback);
336 + return () => subscription.unsubscribe();
337 + },
338 + });
339 +
340 + class Parent extends React.Component {
341 + state = {};
342 +
343 + static getDerivedStateFromProps(nextProps, prevState) {
344 + if (nextProps.observed !== prevState.observed) {
345 + return {
346 + observed: nextProps.observed,
347 + };
348 + }
349 +
350 + return null;
351 + }
352 +
353 + componentDidMount() {
354 + log.push('Parent.componentDidMount');
355 + }
356 +
357 + componentDidUpdate() {
358 + log.push('Parent.componentDidUpdate');
359 + }
360 +
361 + render() {
362 + parentInstance = this;
363 +
364 + return (
365 + <Subscription source={this.state.observed}>
366 + {(value = 'default') => {
367 + ReactNoop.yield('Subscriber: ' + value);
368 + return <Child value={value} />;
369 + }}
370 + </Subscription>
371 + );
372 + }
373 + }
374 +
375 + const observableA = createBehaviorSubject('a-0');
376 + const observableB = createBehaviorSubject('b-0');
377 +
378 + ReactNoop.render(<Parent observed={observableA} />);
379 + expect(ReactNoop.flush()).toEqual(['Subscriber: a-0', 'Child: a-0']);
380 + expect(log).toEqual(['Parent.componentDidMount']);
381 +
382 + // Start React update, but don't finish
383 + ReactNoop.render(<Parent observed={observableB} />);
384 + ReactNoop.flushThrough(['Subscriber: b-0']);
385 + expect(log).toEqual(['Parent.componentDidMount']);
386 +
387 + // Emit some updates from the old subscribable
388 + observableA.next('a-1');
389 + observableA.next('a-2');
390 +
391 + // Mimic a higher-priority interruption
392 + parentInstance.setState({observed: observableA});
393 +
394 + // Flush everything and ensure that the correct subscribable is used
395 + // We expect the new subscribable to finish rendering,
396 + // But then the updated values from the old subscribable should be used.
397 + expect(ReactNoop.flush()).toEqual([
398 + 'Child: b-0',
399 + 'Subscriber: a-2',
400 + 'Child: a-2',
401 + ]);
402 + expect(log).toEqual([
403 + 'Parent.componentDidMount',
404 + 'Parent.componentDidUpdate',
405 + 'Parent.componentDidUpdate',
406 + ]);
407 +
408 + // Updates from the new subsribable should be ignored.
409 + observableB.next('b-1');
410 + expect(ReactNoop.flush()).toEqual([]);
411 + expect(log).toEqual([
412 + 'Parent.componentDidMount',
413 + 'Parent.componentDidUpdate',
414 + 'Parent.componentDidUpdate',
415 + ]);
416 + });
417 +
418 + describe('warnings', () => {
419 + it('should warn for invalid missing getCurrentValue', () => {
420 + expect(() => {
421 + createSubscription(
422 + {
423 + subscribe: () => () => {},
424 + },
425 + () => null,
426 + );
427 + }).toWarnDev('Subscription must specify a getCurrentValue function');
428 + });
429 +
430 + it('should warn for invalid missing subscribe', () => {
431 + expect(() => {
432 + createSubscription(
433 + {
434 + getCurrentValue: () => () => {},
435 + },
436 + () => null,
437 + );
438 + }).toWarnDev('Subscription must specify a subscribe function');
439 + });
440 +
441 + it('should warn if subscribe does not return an unsubscribe method', () => {
442 + const Subscription = createSubscription({
443 + getCurrentValue: source => undefined,
444 + subscribe: (source, callback) => {},
445 + });
446 +
447 + const observable = createBehaviorSubject();
448 + ReactNoop.render(
449 + <Subscription source={observable}>{value => null}</Subscription>,
450 + );
451 +
452 + expect(ReactNoop.flush).toThrow(
453 + 'A subscription must return an unsubscribe function.',
454 + );
455 + });
456 + });
457 +});
packages/create-subscription/src/createSubscription.js new
+159
@@ -0,0 +1,159 @@
1 +/**
2 + * Copyright (c) 2014-present, Facebook, Inc.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import React from 'react';
11 +import invariant from 'fbjs/lib/invariant';
12 +import warning from 'fbjs/lib/warning';
13 +
14 +type Unsubscribe = () => void;
15 +
16 +export function createSubscription<Property, Value>(
17 + config: $ReadOnly<{|
18 + // Synchronously gets the value for the subscribed property.
19 + // Return undefined if the subscribable value is undefined,
20 + // Or does not support synchronous reading (e.g. native Promise).
21 + getCurrentValue: (source: Property) => Value | void,
22 +
23 + // Setup a subscription for the subscribable value in props, and return an unsubscribe function.
24 + // Return false to indicate the property cannot be unsubscribed from (e.g. native Promises).
25 + // Due to the variety of change event types, subscribers should provide their own handlers.
26 + // Those handlers should not attempt to update state though;
27 + // They should call the callback() instead when a subscription changes.
28 + subscribe: (
29 + source: Property,
30 + callback: (value: Value | void) => void,
31 + ) => Unsubscribe,
32 + |}>,
33 +): React$ComponentType<{
34 + children: (value: Value | void) => React$Node,
35 + source: Property,
36 +}> {
37 + const {getCurrentValue, subscribe} = config;
38 +
39 + warning(
40 + typeof getCurrentValue === 'function',
41 + 'Subscription must specify a getCurrentValue function',
42 + );
43 + warning(
44 + typeof subscribe === 'function',
45 + 'Subscription must specify a subscribe function',
46 + );
47 +
48 + type Props = {
49 + children: (value: Value) => React$Element<any>,
50 + source: Property,
51 + };
52 + type State = {
53 + source: Property,
54 + unsubscribeContainer: {
55 + unsubscribe: Unsubscribe | null,
56 + },
57 + value: Value | void,
58 + };
59 +
60 + // Reference: https://gist.github.com/bvaughn/d569177d70b50b58bff69c3c4a5353f3
61 + class Subscription extends React.Component<Props, State> {
62 + state: State = {
63 + source: this.props.source,
64 + unsubscribeContainer: {
65 + unsubscribe: null,
66 + },
67 + value:
68 + this.props.source != null
69 + ? getCurrentValue(this.props.source)
70 + : undefined,
71 + };
72 +
73 + static getDerivedStateFromProps(nextProps, prevState) {
74 + if (nextProps.source !== prevState.source) {
75 + return {
76 + source: nextProps.source,
77 + unsubscribeContainer: {
78 + unsubscribe: null,
79 + },
80 + value:
81 + nextProps.source != null
82 + ? getCurrentValue(nextProps.source)
83 + : undefined,
84 + };
85 + }
86 +
87 + return null;
88 + }
89 +
90 + componentDidMount() {
91 + this.subscribe();
92 + }
93 +
94 + componentDidUpdate(prevProps, prevState) {
95 + if (this.state.source !== prevState.source) {
96 + this.unsubscribe(prevState);
97 + this.subscribe();
98 + }
99 + }
100 +
101 + componentWillUnmount() {
102 + this.unsubscribe(this.state);
103 + }
104 +
105 + render() {
106 + return this.props.children(this.state.value);
107 + }
108 +
109 + subscribe() {
110 + const {source} = this.state;
111 + if (source != null) {
112 + const callback = (value: Value | void) => {
113 + this.setState(state => {
114 + // If the value is the same, skip the unnecessary state update.
115 + if (value === state.value) {
116 + return null;
117 + }
118 +
119 + // If this event belongs to an old or uncommitted data source, ignore it.
120 + if (source !== state.source) {
121 + return null;
122 + }
123 +
124 + return {value};
125 + });
126 + };
127 +
128 + // Store subscription for later (in case it's needed to unsubscribe).
129 + // This is safe to do via mutation since:
130 + // 1) It does not impact render.
131 + // 2) This method will only be called during the "commit" phase.
132 + const unsubscribe = subscribe(source, callback);
133 +
134 + invariant(
135 + typeof unsubscribe === 'function',
136 + 'A subscription must return an unsubscribe function.',
137 + );
138 +
139 + this.state.unsubscribeContainer.unsubscribe = unsubscribe;
140 +
141 + // External values could change between render and mount,
142 + // In some cases it may be important to handle this case.
143 + const value = getCurrentValue(this.props.source);
144 + if (value !== this.state.value) {
145 + this.setState({value});
146 + }
147 + }
148 + }
149 +
150 + unsubscribe(state: State) {
151 + const {unsubscribe} = state.unsubscribeContainer;
152 + if (typeof unsubscribe === 'function') {
153 + unsubscribe();
154 + }
155 + }
156 + }
157 +
158 + return Subscription;
159 +}
scripts/rollup/bundles.js
+10
@@ -254,6 +254,16 @@ const bundles = [
254 global: 'SimpleCacheProvider',
255 externals: ['react'],
256 },
257 +
258 + /******* createComponentWithSubscriptions (experimental) *******/
259 + {
260 + label: 'create-subscription',
261 + bundleTypes: [NODE_DEV, NODE_PROD],
262 + moduleType: ISOMORPHIC,
263 + entry: 'create-subscription',
264 + global: 'createSubscription',
265 + externals: ['react'],
266 + },
267 ];
268
269 // Based on deep-freeze by substack (public domain)
scripts/rollup/results.json
+116 -88
@@ -4,8 +4,8 @@
4 "filename": "react.development.js",
5 "bundleType": "UMD_DEV",
6 "packageName": "react",
7 - "size": 55674,
8 - "gzip": 15255
7 + "size": 55675,
8 + "gzip": 15253
9 },
10 {
11 "filename": "react.production.min.js",
@@ -18,8 +18,8 @@
18 "filename": "react.development.js",
19 "bundleType": "NODE_DEV",
20 "packageName": "react",
21 - "size": 46095,
22 - "gzip": 12925
21 + "size": 46096,
22 + "gzip": 12924
23 },
24 {
25 "filename": "react.production.min.js",
@@ -32,8 +32,8 @@
32 "filename": "React-dev.js",
33 "bundleType": "FB_DEV",
34 "packageName": "react",
35 - "size": 45476,
36 - "gzip": 12448
35 + "size": 45477,
36 + "gzip": 12446
37 },
38 {
39 "filename": "React-prod.js",
@@ -46,50 +46,50 @@
46 "filename": "react-dom.development.js",
47 "bundleType": "UMD_DEV",
48 "packageName": "react-dom",
49 - "size": 591513,
50 - "gzip": 138743
49 + "size": 600642,
50 + "gzip": 139543
51 },
52 {
53 "filename": "react-dom.production.min.js",
54 "bundleType": "UMD_PROD",
55 "packageName": "react-dom",
56 - "size": 96778,
57 - "gzip": 31445
56 + "size": 100738,
57 + "gzip": 32495
58 },
59 {
60 "filename": "react-dom.development.js",
61 "bundleType": "NODE_DEV",
62 "packageName": "react-dom",
63 - "size": 575526,
64 - "gzip": 134516
63 + "size": 584651,
64 + "gzip": 135289
65 },
66 {
67 "filename": "react-dom.production.min.js",
68 "bundleType": "NODE_PROD",
69 "packageName": "react-dom",
70 - "size": 95503,
71 - "gzip": 30619
70 + "size": 99167,
71 + "gzip": 31568
72 },
73 {
74 "filename": "ReactDOM-dev.js",
75 "bundleType": "FB_DEV",
76 "packageName": "react-dom",
77 - "size": 594783,
78 - "gzip": 136782
77 + "size": 604987,
78 + "gzip": 137591
79 },
80 {
81 "filename": "ReactDOM-prod.js",
82 "bundleType": "FB_PROD",
83 "packageName": "react-dom",
84 - "size": 279046,
85 - "gzip": 53062
84 + "size": 290412,
85 + "gzip": 54502
86 },
87 {
88 "filename": "react-dom-test-utils.development.js",
89 "bundleType": "UMD_DEV",
90 "packageName": "react-dom",
91 - "size": 41697,
92 - "gzip": 11964
91 + "size": 41803,
92 + "gzip": 12011
93 },
94 {
95 "filename": "react-dom-test-utils.production.min.js",
@@ -102,8 +102,8 @@
102 "filename": "react-dom-test-utils.development.js",
103 "bundleType": "NODE_DEV",
104 "packageName": "react-dom",
105 - "size": 36434,
106 - "gzip": 10505
105 + "size": 36540,
106 + "gzip": 10554
107 },
108 {
109 "filename": "react-dom-test-utils.production.min.js",
@@ -116,8 +116,8 @@
116 "filename": "ReactTestUtils-dev.js",
117 "bundleType": "FB_DEV",
118 "packageName": "react-dom",
119 - "size": 37155,
120 - "gzip": 10582
119 + "size": 37255,
120 + "gzip": 10630
121 },
122 {
123 "filename": "react-dom-unstable-native-dependencies.development.js",
@@ -165,141 +165,141 @@
165 "filename": "react-dom-server.browser.development.js",
166 "bundleType": "UMD_DEV",
167 "packageName": "react-dom",
168 - "size": 102991,
169 - "gzip": 26927
168 + "size": 103067,
169 + "gzip": 27041
170 },
171 {
172 "filename": "react-dom-server.browser.production.min.js",
173 "bundleType": "UMD_PROD",
174 "packageName": "react-dom",
175 - "size": 15184,
176 - "gzip": 5856
175 + "size": 15133,
176 + "gzip": 5835
177 },
178 {
179 "filename": "react-dom-server.browser.development.js",
180 "bundleType": "NODE_DEV",
181 "packageName": "react-dom",
182 - "size": 92035,
183 - "gzip": 24618
182 + "size": 92111,
183 + "gzip": 24739
184 },
185 {
186 "filename": "react-dom-server.browser.production.min.js",
187 "bundleType": "NODE_PROD",
188 "packageName": "react-dom",
189 - "size": 14818,
190 - "gzip": 5705
189 + "size": 14771,
190 + "gzip": 5680
191 },
192 {
193 "filename": "ReactDOMServer-dev.js",
194 "bundleType": "FB_DEV",
195 "packageName": "react-dom",
196 - "size": 95165,
197 - "gzip": 24327
196 + "size": 95191,
197 + "gzip": 24410
198 },
199 {
200 "filename": "ReactDOMServer-prod.js",
201 "bundleType": "FB_PROD",
202 "packageName": "react-dom",
203 - "size": 33262,
204 - "gzip": 8299
203 + "size": 33064,
204 + "gzip": 8279
205 },
206 {
207 "filename": "react-dom-server.node.development.js",
208 "bundleType": "NODE_DEV",
209 "packageName": "react-dom",
210 - "size": 94003,
211 - "gzip": 25175
210 + "size": 94079,
211 + "gzip": 25295
212 },
213 {
214 "filename": "react-dom-server.node.production.min.js",
215 "bundleType": "NODE_PROD",
216 "packageName": "react-dom",
217 - "size": 15642,
218 - "gzip": 6010
217 + "size": 15595,
218 + "gzip": 5990
219 },
220 {
221 "filename": "react-art.development.js",
222 "bundleType": "UMD_DEV",
223 "packageName": "react-art",
224 - "size": 389869,
225 - "gzip": 86413
224 + "size": 399001,
225 + "gzip": 87190
226 },
227 {
228 "filename": "react-art.production.min.js",
229 "bundleType": "UMD_PROD",
230 "packageName": "react-art",
231 - "size": 86808,
232 - "gzip": 26944
231 + "size": 90690,
232 + "gzip": 27874
233 },
234 {
235 "filename": "react-art.development.js",
236 "bundleType": "NODE_DEV",
237 "packageName": "react-art",
238 - "size": 313942,
239 - "gzip": 67385
238 + "size": 323070,
239 + "gzip": 68147
240 },
241 {
242 "filename": "react-art.production.min.js",
243 "bundleType": "NODE_PROD",
244 "packageName": "react-art",
245 - "size": 50754,
246 - "gzip": 16005
245 + "size": 54355,
246 + "gzip": 16860
247 },
248 {
249 "filename": "ReactART-dev.js",
250 "bundleType": "FB_DEV",
251 "packageName": "react-art",
252 - "size": 318024,
253 - "gzip": 66603
252 + "size": 328230,
253 + "gzip": 67375
254 },
255 {
256 "filename": "ReactART-prod.js",
257 "bundleType": "FB_PROD",
258 "packageName": "react-art",
259 - "size": 157473,
260 - "gzip": 27225
259 + "size": 168749,
260 + "gzip": 28640
261 },
262 {
263 "filename": "ReactNativeRenderer-dev.js",
264 "bundleType": "RN_DEV",
265 "packageName": "react-native-renderer",
266 - "size": 443941,
267 - "gzip": 97414
266 + "size": 454044,
267 + "gzip": 98203
268 },
269 {
270 "filename": "ReactNativeRenderer-prod.js",
271 "bundleType": "RN_PROD",
272 "packageName": "react-native-renderer",
273 - "size": 209855,
274 - "gzip": 36492
273 + "size": 220436,
274 + "gzip": 37780
275 },
276 {
277 "filename": "react-test-renderer.development.js",
278 "bundleType": "NODE_DEV",
279 "packageName": "react-test-renderer",
280 - "size": 310910,
281 - "gzip": 66329
280 + "size": 320190,
281 + "gzip": 67115
282 },
283 {
284 "filename": "react-test-renderer.production.min.js",
285 "bundleType": "NODE_PROD",
286 "packageName": "react-test-renderer",
287 - "size": 49219,
288 - "gzip": 15315
287 + "size": 52870,
288 + "gzip": 16241
289 },
290 {
291 "filename": "ReactTestRenderer-dev.js",
292 "bundleType": "FB_DEV",
293 "packageName": "react-test-renderer",
294 - "size": 315000,
295 - "gzip": 65520
294 + "size": 325364,
295 + "gzip": 66316
296 },
297 {
298 "filename": "react-test-renderer-shallow.development.js",
299 "bundleType": "NODE_DEV",
300 "packageName": "react-test-renderer",
301 - "size": 21221,
302 - "gzip": 5193
301 + "size": 21475,
302 + "gzip": 5309
303 },
304 {
305 "filename": "react-test-renderer-shallow.production.min.js",
@@ -312,43 +312,43 @@
312 "filename": "ReactShallowRenderer-dev.js",
313 "bundleType": "FB_DEV",
314 "packageName": "react-test-renderer",
315 - "size": 20928,
316 - "gzip": 4566
315 + "size": 21120,
316 + "gzip": 4625
317 },
318 {
319 "filename": "react-noop-renderer.development.js",
320 "bundleType": "NODE_DEV",
321 "packageName": "react-noop-renderer",
322 - "size": 18777,
323 - "gzip": 5303
322 + "size": 19408,
323 + "gzip": 5482
324 },
325 {
326 "filename": "react-noop-renderer.production.min.js",
327 "bundleType": "NODE_PROD",
328 "packageName": "react-noop-renderer",
329 - "size": 6429,
330 - "gzip": 2573
329 + "size": 6643,
330 + "gzip": 2618
331 },
332 {
333 "filename": "react-reconciler.development.js",
334 "bundleType": "NODE_DEV",
335 "packageName": "react-reconciler",
336 - "size": 292377,
337 - "gzip": 61765
336 + "size": 301505,
337 + "gzip": 62567
338 },
339 {
340 "filename": "react-reconciler.production.min.js",
341 "bundleType": "NODE_PROD",
342 "packageName": "react-reconciler",
343 - "size": 42443,
344 - "gzip": 13358
343 + "size": 46055,
344 + "gzip": 14278
345 },
346 {
347 "filename": "react-reconciler-reflection.development.js",
348 "bundleType": "NODE_DEV",
349 "packageName": "react-reconciler",
350 - "size": 10934,
351 - "gzip": 3388
350 + "size": 11040,
351 + "gzip": 3435
352 },
353 {
354 "filename": "react-reconciler-reflection.production.min.js",
@@ -375,29 +375,29 @@
375 "filename": "ReactFabric-dev.js",
376 "bundleType": "RN_DEV",
377 "packageName": "react-native-renderer",
378 - "size": 438218,
379 - "gzip": 96267
378 + "size": 438891,
379 + "gzip": 94687
380 },
381 {
382 "filename": "ReactFabric-prod.js",
383 "bundleType": "RN_PROD",
384 "packageName": "react-native-renderer",
385 - "size": 201883,
386 - "gzip": 35448
385 + "size": 204481,
386 + "gzip": 35139
387 },
388 {
389 "filename": "react-reconciler-persistent.development.js",
390 "bundleType": "NODE_DEV",
391 "packageName": "react-reconciler",
392 - "size": 291949,
393 - "gzip": 61587
392 + "size": 300825,
393 + "gzip": 62279
394 },
395 {
396 "filename": "react-reconciler-persistent.production.min.js",
397 "bundleType": "NODE_PROD",
398 "packageName": "react-reconciler",
399 - "size": 41327,
400 - "gzip": 13133
399 + "size": 44927,
400 + "gzip": 14054
401 },
402 {
403 "filename": "react-is.development.js",
@@ -431,15 +431,43 @@
431 "filename": "simple-cache-provider.development.js",
432 "bundleType": "NODE_DEV",
433 "packageName": "simple-cache-provider",
434 - "size": 5830,
435 - "gzip": 1904
434 + "size": 5759,
435 + "gzip": 1870
436 },
437 {
438 "filename": "simple-cache-provider.production.min.js",
439 "bundleType": "NODE_PROD",
440 "packageName": "simple-cache-provider",
441 - "size": 1313,
442 - "gzip": 665
441 + "size": 1295,
442 + "gzip": 656
443 + },
444 + {
445 + "filename": "create-component-with-subscriptions.development.js",
446 + "bundleType": "NODE_DEV",
447 + "packageName": "create-component-with-subscriptions",
448 + "size": 9931,
449 + "gzip": 3067
450 + },
451 + {
452 + "filename": "create-component-with-subscriptions.production.min.js",
453 + "bundleType": "NODE_PROD",
454 + "packageName": "create-component-with-subscriptions",
455 + "size": 3783,
456 + "gzip": 1637
457 + },
458 + {
459 + "filename": "create-subscription.development.js",
460 + "bundleType": "NODE_DEV",
461 + "packageName": "create-subscription",
462 + "size": 5491,
463 + "gzip": 1896
464 + },
465 + {
466 + "filename": "create-subscription.production.min.js",
467 + "bundleType": "NODE_PROD",
468 + "packageName": "create-subscription",
469 + "size": 2190,
470 + "gzip": 1007
471 }
472 ]
473 }
\ No newline at end of file
yarn.lock
+10
@@ -4897,6 +4897,12 @@ rx-lite@*, rx-lite@^4.0.8:
4897 version "4.0.8"
4898 resolved "https://registry.yarnpkg.com/rx-lite/-/rx-lite-4.0.8.tgz#0b1e11af8bc44836f04a6407e92da42467b79444"
4899
4900 +rxjs@^5.5.6:
4901 + version "5.5.6"
4902 + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-5.5.6.tgz#e31fb96d6fd2ff1fd84bcea8ae9c02d007179c02"
4903 + dependencies:
4904 + symbol-observable "1.0.1"
4905 +
4906 safe-buffer@^5.0.1:
4907 version "5.0.1"
4908 resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.0.1.tgz#d263ca54696cd8a306b5ca6551e92de57918fbe7"
@@ -5217,6 +5223,10 @@ supports-hyperlinks@^1.0.1:
5223 has-flag "^2.0.0"
5224 supports-color "^5.0.0"
5225
5226 +symbol-observable@1.0.1:
5227 + version "1.0.1"
5228 + resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.0.1.tgz#8340fc4702c3122df5d22288f88283f513d3fdd4"
5229 +
5230 symbol-tree@^3.2.1:
5231 version "3.2.2"
5232 resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.2.tgz#ae27db38f660a7ae2e1c3b7d1bc290819b8519e6"