[experimental] simple-cache-provider (#12224)
* [experimental] simple-cache-provider Pushing an early version of this for testing and demonstration purposes. * Change invariant to DEV-only warning * Use function overloading for createResource type Expresses that primitive keys do not require a hash function, but non-primitive keys do. * More tests * Use export * * Make Record type a disjoint union * Pass miss argument separate from key to avoid a closure
Andrew Clark committed
Feb 15, 2018 at 16:38 UTC
4312b8293244e1fd1d7031fb474b156b0e43001c
8 files changed
+563
packages/simple-cache-provider/README.md
new
+16
@@ -0,0 +1,16 @@
1
+# simple-cache-provider
2
+
3
+A basic cache for React applications. It also serves as a reference for more
4
+advanced caching implementations.
5
+
6
+This package is meant to be used alongside yet-to-be-released, experimental
7
+React features. It's unlikely to be useful in any other context.
8
+
9
+**Do not use in a real application.** We're publishing this early for
10
+demonstration purposes.
11
+
12
+**Use it at your own risk.**
13
+
14
+# No, Really, It Is Unstable
15
+
16
+The API ~~may~~ will change wildly between versions.
packages/simple-cache-provider/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/SimpleCacheProvider';
packages/simple-cache-provider/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/simple-cache-provider.production.min.js');
5
+} else {
6
+ module.exports = require('./cjs/simple-cache-provider.development.js');
7
+}
packages/simple-cache-provider/package.json
new
+13
@@ -0,0 +1,13 @@
1
+{
2
+ "name": "simple-cache-provider",
3
+ "description": "A basic cache for React applications",
4
+ "version": "0.1.1",
5
+ "repository": "facebook/react",
6
+ "files": ["LICENSE", "README.md", "index.js", "cjs/"],
7
+ "dependencies": {
8
+ "fbjs": "^0.8.16"
9
+ },
10
+ "peerDependencies": {
11
+ "react": "16.3.0-alpha.1"
12
+ }
13
+}
packages/simple-cache-provider/src/SimpleCacheProvider.js
new
+299
@@ -0,0 +1,299 @@
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 warning from 'fbjs/lib/warning';
12
+
13
+function noop() {}
14
+
15
+const Empty = 0;
16
+const Pending = 1;
17
+const Resolved = 2;
18
+const Rejected = 3;
19
+
20
+type EmptyRecord = {|
21
+ status: 0,
22
+ suspender: null,
23
+ value: null,
24
+ error: null,
25
+|};
26
+
27
+type PendingRecord<V> = {|
28
+ status: 1,
29
+ suspender: Promise<V>,
30
+ value: null,
31
+ error: null,
32
+|};
33
+
34
+type ResolvedRecord<V> = {|
35
+ status: 2,
36
+ suspender: null,
37
+ value: V,
38
+ error: null,
39
+|};
40
+
41
+type RejectedRecord = {|
42
+ status: 3,
43
+ suspender: null,
44
+ value: null,
45
+ error: Error,
46
+|};
47
+
48
+type Record<V> =
49
+ | EmptyRecord
50
+ | PendingRecord<V>
51
+ | ResolvedRecord<V>
52
+ | RejectedRecord;
53
+
54
+type RecordCache<K, V> = Map<K, Record<V>>;
55
+// TODO: How do you express this type with Flow?
56
+type ResourceCache = Map<any, RecordCache<any, any>>;
57
+type Cache = {
58
+ invalidate(): void,
59
+ read<K, V, A>(
60
+ resourceType: mixed,
61
+ key: K,
62
+ miss: (A) => Promise<V>,
63
+ missArg: A,
64
+ ): V,
65
+ preload<K, V, A>(
66
+ resourceType: mixed,
67
+ key: K,
68
+ miss: (A) => Promise<V>,
69
+ missArg: A,
70
+ ): void,
71
+
72
+ // DEV-only
73
+ $$typeof?: Symbol | number,
74
+};
75
+
76
+let CACHE_TYPE;
77
+if (__DEV__) {
78
+ CACHE_TYPE = 0xcac4e;
79
+}
80
+
81
+let isCache;
82
+if (__DEV__) {
83
+ isCache = value =>
84
+ value !== null &&
85
+ typeof value === 'object' &&
86
+ value.$$typeof === CACHE_TYPE;
87
+}
88
+
89
+export function createCache(invalidator: () => mixed): Cache {
90
+ const resourceCache: ResourceCache = new Map();
91
+
92
+ function getRecord<K, V>(resourceType: any, key: K): Record<V> {
93
+ if (__DEV__) {
94
+ warning(
95
+ typeof resourceType !== 'string' && typeof resourceType !== 'number',
96
+ 'Invalid resourceType: Expected a symbol, object, or function, but ' +
97
+ 'instead received: %s. Strings and numbers are not permitted as ' +
98
+ 'resource types.',
99
+ resourceType,
100
+ );
101
+ }
102
+
103
+ let recordCache = resourceCache.get(resourceType);
104
+ if (recordCache !== undefined) {
105
+ const record = recordCache.get(key);
106
+ if (record !== undefined) {
107
+ return record;
108
+ }
109
+ } else {
110
+ recordCache = new Map();
111
+ resourceCache.set(resourceType, recordCache);
112
+ }
113
+
114
+ const record = {
115
+ status: Empty,
116
+ suspender: null,
117
+ value: null,
118
+ error: null,
119
+ };
120
+ recordCache.set(key, record);
121
+ return record;
122
+ }
123
+
124
+ function load<V>(emptyRecord: EmptyRecord, suspender: Promise<V>) {
125
+ const pendingRecord: PendingRecord<V> = (emptyRecord: any);
126
+ pendingRecord.status = Pending;
127
+ pendingRecord.suspender = suspender;
128
+ suspender.then(
129
+ value => {
130
+ // Resource loaded successfully.
131
+ const resolvedRecord: ResolvedRecord<V> = (pendingRecord: any);
132
+ resolvedRecord.status = Resolved;
133
+ resolvedRecord.suspender = null;
134
+ resolvedRecord.value = value;
135
+ },
136
+ error => {
137
+ // Resource failed to load. Stash the error for later so we can throw it
138
+ // the next time it's requested.
139
+ const rejectedRecord: RejectedRecord = (pendingRecord: any);
140
+ rejectedRecord.status = Rejected;
141
+ rejectedRecord.suspender = null;
142
+ rejectedRecord.error = error;
143
+ },
144
+ );
145
+ }
146
+
147
+ const cache: Cache = {
148
+ invalidate() {
149
+ invalidator();
150
+ },
151
+ preload<K, V, A>(
152
+ resourceType: any,
153
+ key: K,
154
+ miss: A => Promise<V>,
155
+ missArg: A,
156
+ ): void {
157
+ const record: Record<V> = getRecord(resourceType, key);
158
+ switch (record.status) {
159
+ case Empty:
160
+ // Warm the cache.
161
+ const suspender = miss(missArg);
162
+ load(record, suspender);
163
+ return;
164
+ case Pending:
165
+ // There's already a pending request.
166
+ return;
167
+ case Resolved:
168
+ // The resource is already in the cache.
169
+ return;
170
+ case Rejected:
171
+ // The request failed.
172
+ return;
173
+ }
174
+ },
175
+ read<K, V, A>(
176
+ resourceType: any,
177
+ key: K,
178
+ miss: A => Promise<V>,
179
+ missArg: A,
180
+ ): V {
181
+ const record: Record<V> = getRecord(resourceType, key);
182
+ switch (record.status) {
183
+ case Empty:
184
+ // Load the requested resource.
185
+ const suspender = miss(missArg);
186
+ load(record, suspender);
187
+ throw suspender;
188
+ case Pending:
189
+ // There's already a pending request.
190
+ throw record.suspender;
191
+ case Resolved:
192
+ return record.value;
193
+ case Rejected:
194
+ default:
195
+ // The requested resource previously failed loading.
196
+ const error = record.error;
197
+ const emptyRecord: EmptyRecord = (record: any);
198
+ emptyRecord.status = 0;
199
+ emptyRecord.error = null;
200
+ throw error;
201
+ }
202
+ },
203
+ };
204
+
205
+ if (__DEV__) {
206
+ cache.$$typeof = CACHE_TYPE;
207
+ }
208
+ return cache;
209
+}
210
+
211
+let warnIfNonPrimitiveKey;
212
+if (__DEV__) {
213
+ warnIfNonPrimitiveKey = (key, methodName) => {
214
+ warning(
215
+ typeof key === 'string' ||
216
+ typeof key === 'number' ||
217
+ typeof key === 'boolean' ||
218
+ key === undefined ||
219
+ key === null,
220
+ '%s: Invalid key type. Expected a string, number, symbol, or boolean, ' +
221
+ 'but instead received: %s' +
222
+ '\n\nTo use non-primitive values as keys, you must pass a hash ' +
223
+ 'function as the second argument to createResource().',
224
+ methodName,
225
+ key,
226
+ );
227
+ };
228
+}
229
+
230
+type primitive = string | number | boolean | void | null;
231
+type ResourceReader<K, V> = (Cache, K) => V;
232
+
233
+type Resource<K, V> = ResourceReader<K, V> & {
234
+ preload(cache: Cache, key: K): void,
235
+};
236
+
237
+// These declarations are used to express function overloading. I wish there
238
+// were a more elegant way to do this in the function definition itself.
239
+
240
+// Primitive keys do not request a hash function.
241
+declare function createResource<V, K: primitive, H: primitive>(
242
+ loadResource: (K) => Promise<V>,
243
+ hash?: (K) => H,
244
+): Resource<K, V>;
245
+
246
+// Non-primitive keys *do* require a hash function.
247
+// eslint-disable-next-line no-redeclare
248
+declare function createResource<V, K: mixed, H: primitive>(
249
+ loadResource: (K) => Promise<V>,
250
+ hash: (K) => H,
251
+): Resource<K, V>;
252
+
253
+// eslint-disable-next-line no-redeclare
254
+export function createResource<V, K, H: primitive>(
255
+ loadResource: K => Promise<V>,
256
+ hash: K => H,
257
+): Resource<K, V> {
258
+ // The read function itself serves as the resource type.
259
+ function read(cache, key) {
260
+ if (__DEV__) {
261
+ warning(
262
+ isCache(cache),
263
+ 'read(): The first argument must be a cache. Instead received: %s',
264
+ cache,
265
+ );
266
+ }
267
+ if (hash === undefined) {
268
+ if (__DEV__) {
269
+ warnIfNonPrimitiveKey(key, 'read');
270
+ }
271
+ return cache.read(read, key, loadResource, key);
272
+ }
273
+ const hashedKey = hash(key);
274
+ return cache.read(read, hashedKey, loadResource, key);
275
+ }
276
+ read.preload = function(cache, key) {
277
+ if (__DEV__) {
278
+ warning(
279
+ isCache(cache),
280
+ 'preload(): The first argument must be a cache. Instead received: %s',
281
+ cache,
282
+ );
283
+ }
284
+ if (hash === undefined) {
285
+ if (__DEV__) {
286
+ warnIfNonPrimitiveKey(key, 'preload');
287
+ }
288
+ cache.preload(read, key, loadResource, key);
289
+ return;
290
+ }
291
+ const hashedKey = hash(key);
292
+ cache.preload(read, hashedKey, loadResource, key);
293
+ };
294
+ return read;
295
+}
296
+
297
+// Global cache has no eviction policy (except for, ya know, a browser refresh).
298
+const globalCache = createCache(noop);
299
+export const SimpleCache = React.createContext(globalCache);
packages/simple-cache-provider/src/__tests__/SimpleCacheProvider-test.js
new
+192
@@ -0,0 +1,192 @@
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 SimpleCacheProvider;
13
+
14
+describe('SimpleCacheProvider', () => {
15
+ beforeEach(() => {
16
+ jest.resetModules();
17
+ SimpleCacheProvider = require('simple-cache-provider');
18
+ });
19
+
20
+ it('throws a promise if the requested value is not in the cache', async () => {
21
+ const {createCache, createResource} = SimpleCacheProvider;
22
+
23
+ function loadUpperCase(text) {
24
+ return Promise.resolve(text.toUpperCase());
25
+ }
26
+ const readUpperCase = createResource(loadUpperCase);
27
+ const cache = createCache();
28
+
29
+ let suspender;
30
+ try {
31
+ readUpperCase(cache, 'hello');
32
+ } catch (v) {
33
+ suspender = v;
34
+ }
35
+
36
+ await suspender;
37
+ const result = readUpperCase(cache, 'hello');
38
+ expect(result).toBe('HELLO');
39
+ });
40
+
41
+ it('throws an error on the subsequent read if the promise is rejected', async () => {
42
+ const {createCache, createResource} = SimpleCacheProvider;
43
+
44
+ let shouldFail = true;
45
+ function loadUpperCase(text) {
46
+ if (shouldFail) {
47
+ // Rejects on the first try
48
+ shouldFail = false;
49
+ return Promise.reject(new Error('oh no'));
50
+ } else {
51
+ // Succeeds the second time
52
+ return Promise.resolve(text.toUpperCase());
53
+ }
54
+ }
55
+ const readUpperCase = createResource(loadUpperCase);
56
+ const cache = createCache();
57
+
58
+ let suspender;
59
+ try {
60
+ readUpperCase(cache, 'hello');
61
+ } catch (v) {
62
+ suspender = v;
63
+ }
64
+
65
+ let error;
66
+ try {
67
+ await suspender;
68
+ } catch (e) {
69
+ error = e;
70
+ }
71
+ expect(() => readUpperCase(cache, 'hello')).toThrow(error);
72
+ expect(error.message).toBe('oh no');
73
+
74
+ // On a subsequent read, it should complete successfully.
75
+ try {
76
+ readUpperCase(cache, 'hello');
77
+ } catch (v) {
78
+ suspender = v;
79
+ }
80
+ await suspender;
81
+ const result = readUpperCase(cache, 'hello');
82
+ expect(result).toBe('HELLO');
83
+ });
84
+
85
+ it('can preload data ahead of time', async () => {
86
+ const {createCache, createResource} = SimpleCacheProvider;
87
+
88
+ function loadUpperCase(text) {
89
+ return Promise.resolve(text.toUpperCase());
90
+ }
91
+ const readUpperCase = createResource(loadUpperCase);
92
+ const cache = createCache();
93
+
94
+ readUpperCase.preload(cache, 'hello');
95
+ // Wait for next tick
96
+ await Promise.resolve();
97
+ const result = readUpperCase(cache, 'hello');
98
+ expect(result).toBe('HELLO');
99
+ });
100
+
101
+ it('does not throw if preloaded promise rejects', async () => {
102
+ const {createCache, createResource} = SimpleCacheProvider;
103
+
104
+ function loadUpperCase(text) {
105
+ return Promise.reject(new Error('uh oh'));
106
+ }
107
+ const readUpperCase = createResource(loadUpperCase);
108
+ const cache = createCache();
109
+
110
+ readUpperCase.preload(cache, 'hello');
111
+ // Wait for next tick
112
+ await Promise.resolve();
113
+
114
+ expect(() => readUpperCase(cache, 'hello')).toThrow('uh oh');
115
+ });
116
+
117
+ it('accepts custom hash function', async () => {
118
+ const {createCache, createResource} = SimpleCacheProvider;
119
+
120
+ function loadSum([a, b]) {
121
+ return Promise.resolve(a + b);
122
+ }
123
+ function hash([a, b]) {
124
+ return `${a + b}`;
125
+ }
126
+ const readSum = createResource(loadSum, hash);
127
+ const cache = createCache();
128
+
129
+ readSum.preload(cache, [5, 5]);
130
+ readSum.preload(cache, [1, 2]);
131
+ await Promise.resolve();
132
+
133
+ expect(readSum(cache, [5, 5])).toEqual(10);
134
+ expect(readSum(cache, [1, 2])).toEqual(3);
135
+ // The fact that the next line returns synchronously and doesn't throw, even
136
+ // though [3, 7] was not preloaded, proves that the hashing function works.
137
+ expect(readSum(cache, [3, 7])).toEqual(10);
138
+ });
139
+
140
+ it('warns if resourceType is a string or number', () => {
141
+ const {createCache} = SimpleCacheProvider;
142
+
143
+ spyOnDev(console, 'error');
144
+ const cache = createCache();
145
+
146
+ function fn() {
147
+ cache.preload('foo', 'uppercaseA', () => Promise.resolve('A'));
148
+ cache.preload(123, 'productOf9And2', () => Promise.resolve(18));
149
+ }
150
+
151
+ if (__DEV__) {
152
+ expect(fn).toWarnDev([
153
+ 'Invalid resourceType: Expected a symbol, object, or function, but ' +
154
+ 'instead received: foo. Strings and numbers are not permitted as ' +
155
+ 'resource types.',
156
+ 'Invalid resourceType: Expected a symbol, object, or function, but ' +
157
+ 'instead received: 123. Strings and numbers are not permitted as ' +
158
+ 'resource types.',
159
+ ]);
160
+ } else {
161
+ fn();
162
+ }
163
+ });
164
+
165
+ it('warns if non-primitive key is passed to a resource without a hash function', () => {
166
+ const {createCache, createResource} = SimpleCacheProvider;
167
+
168
+ spyOnDev(console, 'error');
169
+
170
+ function loadSum([a, b]) {
171
+ return Promise.resolve(a + b);
172
+ }
173
+
174
+ const readSum = createResource(loadSum);
175
+ const cache = createCache();
176
+
177
+ function fn() {
178
+ readSum.preload(cache, [5, 5]);
179
+ }
180
+
181
+ if (__DEV__) {
182
+ expect(fn).toWarnDev([
183
+ 'preload: Invalid key type. Expected a string, number, symbol, or ' +
184
+ 'boolean, but instead received: 5,5\n\n' +
185
+ 'To use non-primitive values as keys, you must pass a hash ' +
186
+ 'function as the second argument to createResource().',
187
+ ]);
188
+ } else {
189
+ fn();
190
+ }
191
+ });
192
+});
scripts/rollup/bundles.js
+10
@@ -244,6 +244,16 @@ const bundles = [
244
global: 'ReactIs',
245
externals: [],
246
},
247
+
248
+ /******* Simple Cache Provider (experimental) *******/
249
+ {
250
+ label: 'simple-cache-provider',
251
+ bundleTypes: [NODE_DEV, NODE_PROD],
252
+ moduleType: ISOMORPHIC,
253
+ entry: 'simple-cache-provider',
254
+ global: 'SimpleCacheProvider',
255
+ externals: ['react'],
256
+ },
257
];
258
259
// Based on deep-freeze by substack (public domain)
scripts/rollup/results.json
+14
@@ -426,6 +426,20 @@
426
"packageName": "react-is",
427
"size": 1515,
428
"gzip": 670
429
+ },
430
+ {
431
+ "filename": "simple-cache-provider.development.js",
432
+ "bundleType": "NODE_DEV",
433
+ "packageName": "simple-cache-provider",
434
+ "size": 4800,
435
+ "gzip": 1647
436
+ },
437
+ {
438
+ "filename": "simple-cache-provider.production.min.js",
439
+ "bundleType": "NODE_PROD",
440
+ "packageName": "simple-cache-provider",
441
+ "size": 1343,
442
+ "gzip": 684
443
}
444
]
445
}
\ No newline at end of file