@samitouri / QOS-React-2 / commits / 4312b82932

[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