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"