[react-cache] Remove cache as argument to read (#13865)

* [react-cache] Remove `cache` as argument to `read`

Updated is API is `Resource.read(key)` instead of
`Resource.read(cache, key)`.

The cache is read from context using `readContext`.

This also removes cache invalidation entirely (other than the default
LRU mechanism), as well as the ability to have multiple caches. We'll
add it back once `Context.write` lands and we can implement it the
right way.

Since there's now only a single cache (the global one), we don't
actually need to use context yet, but I've added a dummy context
anyway so the user gets an error if they attempt to read outside the
render phase.

* nits

* Add test for thenables that resolve multiple times
This commit is contained in:
Andrew Clark
2018-10-23 14:38:51 -07:00
committed by GitHub
parent fefa1269e2
commit d8e03de4aa
11 changed files with 758 additions and 718 deletions
+142
View File
@@ -0,0 +1,142 @@
/**
* Copyright (c) 2014-present, Facebook, Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import {unstable_scheduleCallback as scheduleCallback} from 'scheduler';
type Entry<T> = {|
value: T,
onDelete: () => mixed,
previous: Entry<T>,
next: Entry<T>,
|};
export function createLRU<T>(limit: number) {
let LIMIT = limit;
// Circular, doubly-linked list
let first: Entry<T> | null = null;
let size: number = 0;
let cleanUpIsScheduled: boolean = false;
function scheduleCleanUp() {
if (cleanUpIsScheduled === false && size > LIMIT) {
// The cache size exceeds the limit. Schedule a callback to delete the
// least recently used entries.
cleanUpIsScheduled = true;
scheduleCallback(cleanUp);
}
}
function cleanUp() {
cleanUpIsScheduled = false;
deleteLeastRecentlyUsedEntries(LIMIT);
}
function deleteLeastRecentlyUsedEntries(targetSize: number) {
// Delete entries from the cache, starting from the end of the list.
if (first !== null) {
const resolvedFirst: Entry<T> = (first: any);
let last = resolvedFirst.previous;
while (size > targetSize && last !== null) {
const onDelete = last.onDelete;
const previous = last.previous;
last.onDelete = (null: any);
// Remove from the list
last.previous = last.next = (null: any);
if (last === first) {
// Reached the head of the list.
first = last = null;
} else {
(first: any).previous = previous;
previous.next = (first: any);
last = previous;
}
size -= 1;
// Call the destroy method after removing the entry from the list. If it
// throws, the rest of cache will not be deleted, but it will be in a
// valid state.
onDelete();
}
}
}
function add(value: T, onDelete: () => mixed): Entry<T> {
const entry = {
value,
onDelete,
next: (null: any),
previous: (null: any),
};
if (first === null) {
entry.previous = entry.next = entry;
first = entry;
} else {
// Append to head
const last = first.previous;
last.next = entry;
entry.previous = last;
first.previous = entry;
entry.next = first;
first = entry;
}
size += 1;
return entry;
}
function update(entry: Entry<T>, newValue: T): void {
entry.value = newValue;
}
function access(entry: Entry<T>): T {
const next = entry.next;
if (next !== null) {
// Entry already cached
const resolvedFirst: Entry<T> = (first: any);
if (first !== entry) {
// Remove from current position
const previous = entry.previous;
previous.next = next;
next.previous = previous;
// Append to head
const last = resolvedFirst.previous;
last.next = entry;
entry.previous = last;
resolvedFirst.previous = entry;
entry.next = resolvedFirst;
first = entry;
}
} else {
// Cannot access a deleted entry
// TODO: Error? Warning?
}
scheduleCleanUp();
return entry.value;
}
function setLimit(newLimit: number) {
LIMIT = newLimit;
scheduleCleanUp();
}
return {
add,
update,
access,
setLimit,
};
}
+157 -373
View File
@@ -10,394 +10,178 @@
import React from 'react';
import warningWithoutStack from 'shared/warningWithoutStack';
function noop() {}
import {createLRU} from './LRU';
const Empty = 0;
const Pending = 1;
const Resolved = 2;
const Rejected = 3;
type EmptyRecord<K> = {|
status: 0,
suspender: null,
key: K,
value: null,
error: null,
next: any, // TODO: (issue #12941)
previous: any, // TODO: (issue #12941)
/**
* Proper types would be something like this:
* next: Record<K, V> | null,
* previous: Record<K, V> | null,
*/
|};
type PendingRecord<K, V> = {|
status: 1,
suspender: Promise<V>,
key: K,
value: null,
error: null,
next: any, // TODO: (issue #12941)
previous: any, // TODO: (issue #12941)
/**
* Proper types would be something like this:
* next: Record<K, V> | null,
* previous: Record<K, V> | null,
*/
|};
type ResolvedRecord<K, V> = {|
status: 2,
suspender: null,
key: K,
value: V,
error: null,
next: any, // TODO: (issue #12941)
previous: any, // TODO: (issue #12941)
/**
* Proper types would be something like this:
* next: Record<K, V> | null,
* previous: Record<K, V> | null,
*/
|};
type RejectedRecord<K> = {|
status: 3,
suspender: null,
key: K,
value: null,
error: Error,
next: any, // TODO: (issue #12941)
previous: any, // TODO: (issue #12941)
/**
* Proper types would be something like this:
* next: Record<K, V> | null,
* previous: Record<K, V> | null,
*/
|};
type Record<K, V> =
| EmptyRecord<K>
| PendingRecord<K, V>
| ResolvedRecord<K, V>
| RejectedRecord<K>;
type RecordCache<K, V> = {|
map: Map<K, Record<K, V>>,
head: Record<K, V> | null,
tail: Record<K, V> | null,
size: number,
|};
// TODO: How do you express this type with Flow?
type ResourceMap = Map<any, RecordCache<any, any>>;
type Cache = {
invalidate(): void,
read<K, V, A>(
resourceType: mixed,
key: K,
miss: (A) => Promise<V>,
missArg: A,
): V,
preload<K, V, A>(
resourceType: mixed,
key: K,
miss: (A) => Promise<V>,
missArg: A,
): void,
// DEV-only
$$typeof?: Symbol | number,
type Thenable<T> = {
then(resolve: (T) => mixed, reject: (mixed) => mixed): mixed,
};
let CACHE_TYPE;
if (__DEV__) {
CACHE_TYPE = 0xcac4e;
}
type Suspender = {
then(resolve: () => mixed, reject: () => mixed): mixed,
};
let isCache;
if (__DEV__) {
isCache = value =>
value !== null &&
typeof value === 'object' &&
value.$$typeof === CACHE_TYPE;
}
// TODO: Make this configurable per resource
const MAX_SIZE = 500;
const PAGE_SIZE = 50;
function createRecord<K>(key: K): EmptyRecord<K> {
return {
status: Empty,
suspender: null,
key,
value: null,
error: null,
next: null,
previous: null,
};
}
function createRecordCache<K, V>(): RecordCache<K, V> {
return {
map: new Map(),
head: null,
tail: null,
size: 0,
};
}
export function createCache(invalidator: () => mixed): Cache {
const resourceMap: ResourceMap = new Map();
function accessRecord<K, V>(resourceType: any, key: K): Record<K, V> {
if (__DEV__) {
warningWithoutStack(
typeof resourceType !== 'string' && typeof resourceType !== 'number',
'Invalid resourceType: Expected a symbol, object, or function, but ' +
'instead received: %s. Strings and numbers are not permitted as ' +
'resource types.',
resourceType,
);
}
let recordCache = resourceMap.get(resourceType);
if (recordCache === undefined) {
recordCache = createRecordCache();
resourceMap.set(resourceType, recordCache);
}
const map = recordCache.map;
let record = map.get(key);
if (record === undefined) {
// This record does not already exist. Create a new one.
record = createRecord(key);
map.set(key, record);
if (recordCache.size >= MAX_SIZE) {
// The cache is already at maximum capacity. Remove PAGE_SIZE least
// recently used records.
// TODO: We assume the max capcity is greater than zero. Otherwise warn.
const tail = recordCache.tail;
if (tail !== null) {
let newTail = tail;
for (let i = 0; i < PAGE_SIZE && newTail !== null; i++) {
recordCache.size -= 1;
map.delete(newTail.key);
newTail = newTail.previous;
}
recordCache.tail = newTail;
if (newTail !== null) {
newTail.next = null;
}
}
}
} else {
// This record is already cached. Remove it from its current position in
// the list. We'll add it to the front below.
const previous = record.previous;
const next = record.next;
if (previous !== null) {
previous.next = next;
} else {
recordCache.head = next;
}
if (next !== null) {
next.previous = previous;
} else {
recordCache.tail = previous;
}
recordCache.size -= 1;
}
// Add the record to the front of the list.
const head = recordCache.head;
const newHead = record;
recordCache.head = newHead;
newHead.previous = null;
newHead.next = head;
if (head !== null) {
head.previous = newHead;
} else {
recordCache.tail = newHead;
}
recordCache.size += 1;
return newHead;
}
function load<K, V>(emptyRecord: EmptyRecord<K>, suspender: Promise<V>) {
const pendingRecord: PendingRecord<K, V> = (emptyRecord: any);
pendingRecord.status = Pending;
pendingRecord.suspender = suspender;
suspender.then(
value => {
// Resource loaded successfully.
const resolvedRecord: ResolvedRecord<K, V> = (pendingRecord: any);
resolvedRecord.status = Resolved;
resolvedRecord.suspender = null;
resolvedRecord.value = value;
},
error => {
// Resource failed to load. Stash the error for later so we can throw it
// the next time it's requested.
const rejectedRecord: RejectedRecord<K> = (pendingRecord: any);
rejectedRecord.status = Rejected;
rejectedRecord.suspender = null;
rejectedRecord.error = error;
},
);
}
const cache: Cache = {
invalidate() {
invalidator();
},
preload<K, V, A>(
resourceType: any,
key: K,
miss: A => Promise<V>,
missArg: A,
): void {
const record: Record<K, V> = accessRecord(resourceType, key);
switch (record.status) {
case Empty:
// Warm the cache.
const suspender = miss(missArg);
load(record, suspender);
return;
case Pending:
// There's already a pending request.
return;
case Resolved:
// The resource is already in the cache.
return;
case Rejected:
// The request failed.
return;
}
},
read<K, V, A>(
resourceType: any,
key: K,
miss: A => Promise<V>,
missArg: A,
): V {
const record: Record<K, V> = accessRecord(resourceType, key);
switch (record.status) {
case Empty:
// Load the requested resource.
const suspender = miss(missArg);
load(record, suspender);
throw suspender;
case Pending:
// There's already a pending request.
throw record.suspender;
case Resolved:
return record.value;
case Rejected:
default:
// The requested resource previously failed loading.
const error = record.error;
throw error;
}
},
};
if (__DEV__) {
cache.$$typeof = CACHE_TYPE;
}
return cache;
}
let warnIfNonPrimitiveKey;
if (__DEV__) {
warnIfNonPrimitiveKey = (key, methodName) => {
warningWithoutStack(
typeof key === 'string' ||
typeof key === 'number' ||
typeof key === 'boolean' ||
key === undefined ||
key === null,
'%s: Invalid key type. Expected a string, number, symbol, or boolean, ' +
'but instead received: %s' +
'\n\nTo use non-primitive values as keys, you must pass a hash ' +
'function as the second argument to unstable_createResource().',
methodName,
key,
);
};
}
type primitive = string | number | boolean | void | null;
type Resource<K, V> = {|
read(Cache, K): V,
preload(cache: Cache, key: K): void,
type PendingResult = {|
status: 0,
value: Suspender,
|};
// These declarations are used to express function overloading. I wish there
// were a more elegant way to do this in the function definition itself.
type ResolvedResult<V> = {|
status: 1,
value: V,
|};
// Primitive keys do not request a hash function.
declare function unstable_createResource<V, K: primitive, H: primitive>(
loadResource: (K) => Promise<V>,
hash?: (K) => H,
): Resource<K, V>;
type RejectedResult = {|
status: 2,
value: mixed,
|};
// Non-primitive keys *do* require a hash function.
// eslint-disable-next-line no-redeclare
declare function unstable_createResource<V, K: mixed, H: primitive>(
loadResource: (K) => Promise<V>,
hash: (K) => H,
): Resource<K, V>;
type Result<V> = PendingResult | ResolvedResult<V> | RejectedResult;
type Resource<I, V> = {
read(I): V,
preload(I): void,
};
const Pending = 0;
const Resolved = 1;
const Rejected = 2;
const currentOwner =
React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner;
function readContext(Context, observedBits) {
const dispatcher = currentOwner.currentDispatcher;
if (dispatcher === null) {
throw new Error(
'react-cache: read and preload may only be called from within a ' +
"component's render. They are not supported in event handlers or " +
'lifecycle methods.',
);
}
return dispatcher.readContext(Context, observedBits);
}
function identityHashFn(input) {
if (__DEV__) {
warningWithoutStack(
typeof input === 'string' ||
typeof input === 'number' ||
typeof input === 'boolean' ||
input === undefined ||
input === null,
'Invalid key type. Expected a string, number, symbol, or boolean, ' +
'but instead received: %s' +
'\n\nTo use non-primitive values as keys, you must pass a hash ' +
'function as the second argument to createResource().',
input,
);
}
return input;
}
const CACHE_LIMIT = 500;
const lru = createLRU(CACHE_LIMIT);
const entries: Map<Resource<any, any>, Map<any, any>> = new Map();
const CacheContext = React.createContext(null);
function accessResult<I, K, V>(
resource: any,
fetch: I => Thenable<V>,
input: I,
key: K,
): Result<V> {
let entriesForResource = entries.get(resource);
if (entriesForResource === undefined) {
entriesForResource = new Map();
entries.set(resource, entriesForResource);
}
let entry = entriesForResource.get(key);
if (entry === undefined) {
const thenable = fetch(input);
thenable.then(
value => {
if (newResult.status === Pending) {
const resolvedResult: ResolvedResult<V> = (newResult: any);
resolvedResult.status = Resolved;
resolvedResult.value = value;
}
},
error => {
if (newResult.status === Pending) {
const rejectedResult: RejectedResult = (newResult: any);
rejectedResult.status = Rejected;
rejectedResult.value = error;
}
},
);
const newResult: PendingResult = {
status: Pending,
value: thenable,
};
const newEntry = lru.add(newResult, deleteEntry.bind(null, resource, key));
entriesForResource.set(key, newEntry);
return newResult;
} else {
return (lru.access(entry): any);
}
}
function deleteEntry(resource, key) {
const entriesForResource = entries.get(resource);
if (entriesForResource !== undefined) {
entriesForResource.delete(key);
if (entriesForResource.size === 0) {
entries.delete(resource);
}
}
}
export function unstable_createResource<I, K: string | number, V>(
fetch: I => Thenable<V>,
maybeHashInput?: I => K,
): Resource<I, V> {
const hashInput: I => K =
maybeHashInput !== undefined ? maybeHashInput : (identityHashFn: any);
// eslint-disable-next-line no-redeclare
export function unstable_createResource<V, K, H: primitive>(
loadResource: K => Promise<V>,
hash: K => H,
): Resource<K, V> {
const resource = {
read(cache, key) {
if (__DEV__) {
warningWithoutStack(
isCache(cache),
'read(): The first argument must be a cache. Instead received: %s',
cache,
);
}
if (hash === undefined) {
if (__DEV__) {
warnIfNonPrimitiveKey(key, 'read');
read(input: I): V {
// react-cache currently doesn't rely on context, but it may in the
// future, so we read anyway to prevent access outside of render.
readContext(CacheContext);
const key = hashInput(input);
const result: Result<V> = accessResult(resource, fetch, input, key);
switch (result.status) {
case Pending: {
const suspender = result.value;
throw suspender;
}
return cache.read(resource, key, loadResource, key);
case Resolved: {
const value = result.value;
return value;
}
case Rejected: {
const error = result.value;
throw error;
}
default:
// Should be unreachable
return (undefined: any);
}
const hashedKey = hash(key);
return cache.read(resource, hashedKey, loadResource, key);
},
preload(cache, key) {
if (__DEV__) {
warningWithoutStack(
isCache(cache),
'preload(): The first argument must be a cache. Instead received: %s',
cache,
);
}
if (hash === undefined) {
if (__DEV__) {
warnIfNonPrimitiveKey(key, 'preload');
}
cache.preload(resource, key, loadResource, key);
return;
}
const hashedKey = hash(key);
cache.preload(resource, hashedKey, loadResource, key);
preload(input: I): void {
// react-cache currently doesn't rely on context, but it may in the
// future, so we read anyway to prevent access outside of render.
readContext(CacheContext);
const key = hashInput(input);
accessResult(resource, fetch, input, key);
},
};
return resource;
}
// Global cache has no eviction policy (except for, ya know, a browser refresh).
const globalCache = createCache(noop);
export const ReactCache = React.createContext(globalCache);
export function unstable_setGlobalCacheLimit(limit: number) {
lru.setLimit(limit);
}
@@ -0,0 +1,402 @@
/**
* Copyright (c) 2013-present, Facebook, Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @emails react-core
*/
'use strict';
let ReactCache;
let createResource;
let React;
let ReactFeatureFlags;
let ReactTestRenderer;
let Suspense;
let TextResource;
let textResourceShouldFail;
let flushScheduledWork;
let evictLRU;
describe('ReactCache', () => {
beforeEach(() => {
jest.resetModules();
jest.mock('scheduler', () => {
let callbacks = [];
return {
unstable_scheduleCallback(callback) {
const callbackIndex = callbacks.length;
callbacks.push(callback);
return {callbackIndex};
},
flushScheduledWork() {
while (callbacks.length) {
const callback = callbacks.pop();
callback();
}
},
};
});
ReactFeatureFlags = require('shared/ReactFeatureFlags');
ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false;
ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false;
React = require('react');
Suspense = React.Suspense;
ReactCache = require('react-cache');
createResource = ReactCache.unstable_createResource;
ReactTestRenderer = require('react-test-renderer');
flushScheduledWork = require('scheduler').flushScheduledWork;
evictLRU = flushScheduledWork;
TextResource = createResource(([text, ms = 0]) => {
let listeners = null;
let status = 'pending';
let value = null;
return {
then(resolve, reject) {
switch (status) {
case 'pending': {
if (listeners === null) {
listeners = [{resolve, reject}];
setTimeout(() => {
if (textResourceShouldFail) {
ReactTestRenderer.unstable_yield(
`Promise rejected [${text}]`,
);
status = 'rejected';
value = new Error('Failed to load: ' + text);
listeners.forEach(listener => listener.reject(value));
} else {
ReactTestRenderer.unstable_yield(
`Promise resolved [${text}]`,
);
status = 'resolved';
value = text;
listeners.forEach(listener => listener.resolve(value));
}
}, ms);
} else {
listeners.push({resolve, reject});
}
break;
}
case 'resolved': {
resolve(value);
break;
}
case 'rejected': {
reject(value);
break;
}
}
},
};
}, ([text, ms]) => text);
textResourceShouldFail = false;
});
function Text(props) {
ReactTestRenderer.unstable_yield(props.text);
return props.text;
}
function AsyncText(props) {
const text = props.text;
try {
TextResource.read([props.text, props.ms]);
ReactTestRenderer.unstable_yield(text);
return text;
} catch (promise) {
if (typeof promise.then === 'function') {
ReactTestRenderer.unstable_yield(`Suspend! [${text}]`);
} else {
ReactTestRenderer.unstable_yield(`Error! [${text}]`);
}
throw promise;
}
}
it('throws a promise if the requested value is not in the cache', () => {
function App() {
return (
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText ms={100} text="Hi" />
</Suspense>
);
}
const root = ReactTestRenderer.create(<App />, {
unstable_isConcurrent: true,
});
expect(root).toFlushAndYield(['Suspend! [Hi]', 'Loading...']);
jest.advanceTimersByTime(100);
expect(ReactTestRenderer).toHaveYielded(['Promise resolved [Hi]']);
expect(root).toFlushAndYield(['Hi']);
});
it('throws an error on the subsequent read if the promise is rejected', async () => {
function App() {
return (
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText ms={100} text="Hi" />
</Suspense>
);
}
const root = ReactTestRenderer.create(<App />, {
unstable_isConcurrent: true,
});
expect(root).toFlushAndYield(['Suspend! [Hi]', 'Loading...']);
textResourceShouldFail = true;
jest.advanceTimersByTime(100);
expect(ReactTestRenderer).toHaveYielded(['Promise rejected [Hi]']);
expect(root).toFlushAndThrow('Failed to load: Hi');
expect(ReactTestRenderer).toHaveYielded(['Error! [Hi]', 'Error! [Hi]']);
// Should throw again on a subsequent read
root.update(<App />);
expect(root).toFlushAndThrow('Failed to load: Hi');
expect(ReactTestRenderer).toHaveYielded(['Error! [Hi]', 'Error! [Hi]']);
});
it('warns if non-primitive key is passed to a resource without a hash function', () => {
const BadTextResource = createResource(([text, ms = 0]) => {
return new Promise((resolve, reject) =>
setTimeout(() => {
resolve(text);
}, ms),
);
});
function App() {
ReactTestRenderer.unstable_yield('App');
return BadTextResource.read(['Hi', 100]);
}
const root = ReactTestRenderer.create(
<Suspense fallback={<Text text="Loading..." />}>
<App />
</Suspense>,
{
unstable_isConcurrent: true,
},
);
if (__DEV__) {
expect(() => {
expect(root).toFlushAndYield(['App', 'Loading...']);
}).toWarnDev(
[
'Invalid key type. Expected a string, number, symbol, or ' +
'boolean, but instead received: Hi,100\n\n' +
'To use non-primitive values as keys, you must pass a hash ' +
'function as the second argument to createResource().',
],
{withoutStack: true},
);
} else {
expect(root).toFlushAndYield(['App', 'Loading...']);
}
});
it('evicts least recently used values', async () => {
ReactCache.unstable_setGlobalCacheLimit(3);
// Render 1, 2, and 3
const root = ReactTestRenderer.create(
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText ms={100} text={1} />
<AsyncText ms={100} text={2} />
<AsyncText ms={100} text={3} />
</Suspense>,
{
unstable_isConcurrent: true,
},
);
expect(root).toFlushAndYield([
'Suspend! [1]',
'Suspend! [2]',
'Suspend! [3]',
'Loading...',
]);
jest.advanceTimersByTime(100);
expect(ReactTestRenderer).toHaveYielded([
'Promise resolved [1]',
'Promise resolved [2]',
'Promise resolved [3]',
]);
expect(root).toFlushAndYield([1, 2, 3]);
expect(root).toMatchRenderedOutput('123');
// Render 1, 4, 5
root.update(
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText ms={100} text={1} />
<AsyncText ms={100} text={4} />
<AsyncText ms={100} text={5} />
</Suspense>,
);
expect(root).toFlushAndYield([
1,
'Suspend! [4]',
'Suspend! [5]',
'Loading...',
]);
jest.advanceTimersByTime(100);
expect(ReactTestRenderer).toHaveYielded([
'Promise resolved [4]',
'Promise resolved [5]',
]);
expect(root).toFlushAndYield([1, 4, 5]);
expect(root).toMatchRenderedOutput('145');
// We've now rendered values 1, 2, 3, 4, 5, over our limit of 3. The least
// recently used values are 2 and 3. They will be evicted during the
// next sweep.
evictLRU();
root.update(
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText ms={100} text={1} />
<AsyncText ms={100} text={2} />
<AsyncText ms={100} text={3} />
</Suspense>,
);
expect(root).toFlushAndYield([
// 1 is still cached
1,
// 2 and 3 suspend because they were evicted from the cache
'Suspend! [2]',
'Suspend! [3]',
'Loading...',
]);
jest.advanceTimersByTime(100);
expect(ReactTestRenderer).toHaveYielded([
'Promise resolved [2]',
'Promise resolved [3]',
]);
expect(root).toFlushAndYield([1, 2, 3]);
expect(root).toMatchRenderedOutput('123');
});
it('preloads during the render phase', async () => {
function App() {
TextResource.preload(['B', 1000]);
TextResource.read(['A', 1000]);
TextResource.read(['B', 1000]);
return <Text text="Result" />;
}
const root = ReactTestRenderer.create(
<Suspense fallback={<Text text="Loading..." />}>
<App />
</Suspense>,
{
unstable_isConcurrent: true,
},
);
expect(root).toFlushAndYield(['Loading...']);
jest.advanceTimersByTime(1000);
expect(ReactTestRenderer).toHaveYielded([
'Promise resolved [B]',
'Promise resolved [A]',
]);
expect(root).toFlushAndYield(['Result']);
expect(root).toMatchRenderedOutput('Result');
});
it('if a thenable resolves multiple times, does not update the first cached value', () => {
let resolveThenable;
const BadTextResource = createResource(([text, ms = 0]) => {
let listeners = null;
let value = null;
return {
then(resolve, reject) {
if (value !== null) {
resolve(value);
} else {
if (listeners === null) {
listeners = [resolve];
resolveThenable = v => {
listeners.forEach(listener => listener(v));
};
} else {
listeners.push(resolve);
}
}
},
};
}, ([text, ms]) => text);
function BadAsyncText(props) {
const text = props.text;
try {
const actualText = BadTextResource.read([props.text, props.ms]);
ReactTestRenderer.unstable_yield(actualText);
return actualText;
} catch (promise) {
if (typeof promise.then === 'function') {
ReactTestRenderer.unstable_yield(`Suspend! [${text}]`);
} else {
ReactTestRenderer.unstable_yield(`Error! [${text}]`);
}
throw promise;
}
}
const root = ReactTestRenderer.create(
<Suspense fallback={<Text text="Loading..." />}>
<BadAsyncText text="Hi" />
</Suspense>,
{
unstable_isConcurrent: true,
},
);
expect(root).toFlushAndYield(['Suspend! [Hi]', 'Loading...']);
resolveThenable('Hi');
// This thenable improperly resolves twice. We should not update the
// cached value.
resolveThenable('Hi muahahaha I am different');
root.update(
<Suspense fallback={<Text text="Loading..." />}>
<BadAsyncText text="Hi" />
</Suspense>,
{
unstable_isConcurrent: true,
},
);
expect(ReactTestRenderer).toHaveYielded([]);
expect(root).toFlushAndYield(['Hi']);
expect(root).toMatchRenderedOutput('Hi');
});
it('throws if read is called outside render', () => {
expect(() => TextResource.read(['A', 1000])).toThrow(
"read and preload may only be called from within a component's render",
);
});
it('throws if preload is called outside render', () => {
expect(() => TextResource.preload(['A', 1000])).toThrow(
"read and preload may only be called from within a component's render",
);
});
});
-235
View File
@@ -1,235 +0,0 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @emails react-core
*/
'use strict';
let ReactCache;
describe('ReactCache', () => {
beforeEach(() => {
jest.resetModules();
ReactCache = require('react-cache');
});
it('throws a promise if the requested value is not in the cache', async () => {
const {createCache, unstable_createResource} = ReactCache;
function loadUpperCase(text) {
return Promise.resolve(text.toUpperCase());
}
const UpperCase = unstable_createResource(loadUpperCase);
const cache = createCache();
let suspender;
try {
UpperCase.read(cache, 'hello');
} catch (v) {
suspender = v;
}
await suspender;
const result = UpperCase.read(cache, 'hello');
expect(result).toBe('HELLO');
});
it('throws an error on the subsequent read if the promise is rejected', async () => {
const {createCache, unstable_createResource} = ReactCache;
let shouldFail = true;
function loadUpperCase(text) {
if (shouldFail) {
// Rejects on the first try
shouldFail = false;
return Promise.reject(new Error('oh no'));
} else {
// Succeeds the second time
return Promise.resolve(text.toUpperCase());
}
}
const UpperCase = unstable_createResource(loadUpperCase);
const cache = createCache();
let suspender;
try {
UpperCase.read(cache, 'hello');
} catch (v) {
suspender = v;
}
let error;
try {
await suspender;
} catch (e) {
error = e;
}
expect(() => UpperCase.read(cache, 'hello')).toThrow(error);
expect(error.message).toBe('oh no');
// On a subsequent read, it should still throw.
try {
UpperCase.read(cache, 'hello');
} catch (v) {
suspender = v;
}
await suspender;
expect(() => UpperCase.read(cache, 'hello')).toThrow(error);
expect(error.message).toBe('oh no');
});
it('can preload data ahead of time', async () => {
const {createCache, unstable_createResource} = ReactCache;
function loadUpperCase(text) {
return Promise.resolve(text.toUpperCase());
}
const UpperCase = unstable_createResource(loadUpperCase);
const cache = createCache();
UpperCase.preload(cache, 'hello');
// Wait for next tick
await Promise.resolve();
const result = UpperCase.read(cache, 'hello');
expect(result).toBe('HELLO');
});
it('does not throw if preloaded promise rejects', async () => {
const {createCache, unstable_createResource} = ReactCache;
function loadUpperCase(text) {
return Promise.reject(new Error('uh oh'));
}
const UpperCase = unstable_createResource(loadUpperCase);
const cache = createCache();
UpperCase.preload(cache, 'hello');
// Wait for next tick
await Promise.resolve();
expect(() => UpperCase.read(cache, 'hello')).toThrow('uh oh');
});
it('accepts custom hash function', async () => {
const {createCache, unstable_createResource} = ReactCache;
function loadSum([a, b]) {
return Promise.resolve(a + b);
}
function hash([a, b]) {
return `${a + b}`;
}
const Sum = unstable_createResource(loadSum, hash);
const cache = createCache();
Sum.preload(cache, [5, 5]);
Sum.preload(cache, [1, 2]);
await Promise.resolve();
expect(Sum.read(cache, [5, 5])).toEqual(10);
expect(Sum.read(cache, [1, 2])).toEqual(3);
// The fact that the next line returns synchronously and doesn't throw, even
// though [3, 7] was not preloaded, proves that the hashing function works.
expect(Sum.read(cache, [3, 7])).toEqual(10);
});
it('warns if resourceType is a string or number', () => {
const {createCache} = ReactCache;
spyOnDev(console, 'error');
const cache = createCache();
function fn() {
cache.preload('foo', 'uppercaseA', () => Promise.resolve('A'));
cache.preload(123, 'productOf9And2', () => Promise.resolve(18));
}
if (__DEV__) {
expect(fn).toWarnDev(
[
'Invalid resourceType: Expected a symbol, object, or function, but ' +
'instead received: foo. Strings and numbers are not permitted as ' +
'resource types.',
'Invalid resourceType: Expected a symbol, object, or function, but ' +
'instead received: 123. Strings and numbers are not permitted as ' +
'resource types.',
],
{withoutStack: true},
);
} else {
fn();
}
});
it('warns if non-primitive key is passed to a resource without a hash function', () => {
const {createCache, unstable_createResource} = ReactCache;
spyOnDev(console, 'error');
function loadSum([a, b]) {
return Promise.resolve(a + b);
}
const Sum = unstable_createResource(loadSum);
const cache = createCache();
function fn() {
Sum.preload(cache, [5, 5]);
}
if (__DEV__) {
expect(fn).toWarnDev(
[
'preload: Invalid key type. Expected a string, number, symbol, or ' +
'boolean, but instead received: 5,5\n\n' +
'To use non-primitive values as keys, you must pass a hash ' +
'function as the second argument to unstable_createResource().',
],
{withoutStack: true},
);
} else {
fn();
}
});
it('stays within maximum capacity by evicting the least recently used record', async () => {
const {createCache, unstable_createResource} = ReactCache;
function loadIntegerString(int) {
return Promise.resolve(int + '');
}
const IntegerStringResource = unstable_createResource(loadIntegerString);
const cache = createCache();
// TODO: This is hard-coded to a maximum size of 500. Make this configurable
// per resource.
for (let n = 1; n <= 500; n++) {
IntegerStringResource.preload(cache, n);
}
// Access 1, 2, and 3 again. The least recently used integer is now 4.
IntegerStringResource.preload(cache, 3);
IntegerStringResource.preload(cache, 2);
IntegerStringResource.preload(cache, 1);
// Evict older integers from the cache by adding new ones.
IntegerStringResource.preload(cache, 501);
IntegerStringResource.preload(cache, 502);
IntegerStringResource.preload(cache, 503);
await Promise.resolve();
// 1, 2, and 3 should be in the cache. 4, 5, and 6 should have been evicted.
expect(IntegerStringResource.read(cache, 1)).toEqual('1');
expect(IntegerStringResource.read(cache, 2)).toEqual('2');
expect(IntegerStringResource.read(cache, 3)).toEqual('3');
expect(() => IntegerStringResource.read(cache, 4)).toThrow(Promise);
expect(() => IntegerStringResource.read(cache, 5)).toThrow(Promise);
expect(() => IntegerStringResource.read(cache, 6)).toThrow(Promise);
});
});
@@ -13,7 +13,6 @@ let React;
let ReactDOM;
let Suspense;
let ReactCache;
let cache;
let TextResource;
describe('ReactDOMSuspensePlaceholder', () => {
@@ -27,10 +26,6 @@ describe('ReactDOMSuspensePlaceholder', () => {
Suspense = React.Suspense;
container = document.createElement('div');
function invalidateCache() {
cache = ReactCache.createCache(invalidateCache);
}
invalidateCache();
TextResource = ReactCache.unstable_createResource(([text, ms = 0]) => {
return new Promise((resolve, reject) =>
setTimeout(() => {
@@ -59,7 +54,7 @@ describe('ReactDOMSuspensePlaceholder', () => {
function AsyncText(props) {
const text = props.text;
TextResource.read(cache, [props.text, props.ms]);
TextResource.read([props.text, props.ms]);
return text;
}
@@ -6,7 +6,6 @@ let Suspense;
// let JestReact;
let cache;
let TextResource;
let textResourceShouldFail;
@@ -25,10 +24,6 @@ describe('ReactSuspense', () => {
Suspense = React.Suspense;
function invalidateCache() {
cache = ReactCache.createCache(invalidateCache);
}
invalidateCache();
TextResource = ReactCache.unstable_createResource(([text, ms = 0]) => {
let listeners = null;
let status = 'pending';
@@ -84,7 +79,7 @@ describe('ReactSuspense', () => {
function AsyncText(props) {
const text = props.text;
try {
TextResource.read(cache, [props.text, props.ms]);
TextResource.read([props.text, props.ms]);
ReactTestRenderer.unstable_yield(text);
return text;
} catch (promise) {
@@ -362,7 +357,7 @@ describe('ReactSuspense', () => {
const text = `${this.props.text}:${this.state.step}`;
const ms = this.props.ms;
try {
TextResource.read(cache, [text, ms]);
TextResource.read([text, ms]);
ReactTestRenderer.unstable_yield(text);
return text;
} catch (promise) {
@@ -501,7 +496,7 @@ describe('ReactSuspense', () => {
const text = this.props.text;
const ms = this.props.ms;
try {
TextResource.read(cache, [text, ms]);
TextResource.read([text, ms]);
ReactTestRenderer.unstable_yield(text);
return text;
} catch (promise) {
@@ -21,8 +21,6 @@ function runPlaceholderTests(suiteLabel, loadReactNoop) {
let ReactFeatureFlags;
let ReactCache;
let Suspense;
let cache;
let TextResource;
let textResourceShouldFail;
@@ -34,15 +32,10 @@ function runPlaceholderTests(suiteLabel, loadReactNoop) {
ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false;
React = require('react');
ReactTestRenderer = require('react-test-renderer');
// JestReact = require('jest-react');
ReactCache = require('react-cache');
Suspense = React.Suspense;
function invalidateCache() {
cache = ReactCache.createCache(invalidateCache);
}
invalidateCache();
TextResource = ReactCache.unstable_createResource(([text, ms = 0]) => {
let listeners = null;
let status = 'pending';
@@ -98,7 +91,7 @@ function runPlaceholderTests(suiteLabel, loadReactNoop) {
function AsyncText(props) {
const text = props.text;
try {
TextResource.read(cache, [props.text, props.ms]);
TextResource.read([props.text, props.ms]);
ReactTestRenderer.unstable_yield(text);
return text;
} catch (promise) {
@@ -7,7 +7,6 @@ let Suspense;
let StrictMode;
let ConcurrentMode;
let cache;
let TextResource;
let textResourceShouldFail;
@@ -28,10 +27,6 @@ describe('ReactSuspenseWithNoopRenderer', () => {
StrictMode = React.StrictMode;
ConcurrentMode = React.unstable_ConcurrentMode;
function invalidateCache() {
cache = ReactCache.createCache(invalidateCache);
}
invalidateCache();
TextResource = ReactCache.unstable_createResource(([text, ms = 0]) => {
return new Promise((resolve, reject) =>
setTimeout(() => {
@@ -80,7 +75,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
function AsyncText(props) {
const text = props.text;
try {
TextResource.read(cache, [props.text, props.ms]);
TextResource.read([props.text, props.ms]);
ReactNoop.yield(text);
return <span prop={text} />;
} catch (promise) {
@@ -278,16 +273,6 @@ describe('ReactSuspenseWithNoopRenderer', () => {
expect(ReactNoop.getChildren()).toEqual([
span('Caught error: Failed to load: Result'),
]);
// Reset the error boundary and cache, and try again.
errorBoundary.current.reset();
cache.invalidate();
expect(ReactNoop.flush()).toEqual(['Suspend! [Result]', 'Loading...']);
ReactNoop.expire(1000);
await advanceTimers(1000);
expect(ReactNoop.flush()).toEqual(['Promise resolved [Result]', 'Result']);
expect(ReactNoop.getChildren()).toEqual([span('Result')]);
});
it('retries on error after falling back to a placeholder', async () => {
@@ -346,16 +331,6 @@ describe('ReactSuspenseWithNoopRenderer', () => {
expect(ReactNoop.getChildren()).toEqual([
span('Caught error: Failed to load: Result'),
]);
// Reset the error boundary and cache, and try again.
errorBoundary.current.reset();
cache.invalidate();
expect(ReactNoop.flush()).toEqual(['Suspend! [Result]', 'Loading...']);
ReactNoop.expire(3000);
await advanceTimers(3000);
expect(ReactNoop.flush()).toEqual(['Promise resolved [Result]', 'Result']);
expect(ReactNoop.getChildren()).toEqual([span('Result')]);
});
it('can update at a higher priority while in a suspended state', async () => {
@@ -1339,7 +1314,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
const text = props.text;
ReactNoop.yield('constructor');
try {
TextResource.read(cache, [props.text, props.ms]);
TextResource.read([props.text, props.ms]);
this.state = {text};
} catch (promise) {
if (typeof promise.then === 'function') {
@@ -1444,7 +1419,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
const text = this.props.text;
const ms = this.props.ms;
try {
TextResource.read(cache, [text, ms]);
TextResource.read([text, ms]);
ReactNoop.yield(text);
return <span prop={text} />;
} catch (promise) {
@@ -13,11 +13,16 @@
let React;
let ReactFeatureFlags;
let ReactNoop;
let ReactCache;
let ReactTestRenderer;
let advanceTimeBy;
let SchedulerTracing;
let mockNow;
let AdvanceTime;
let AsyncText;
let Text;
let TextResource;
let resourcePromise;
function loadModules({
enableProfilerTimer = true,
@@ -40,6 +45,7 @@ function loadModules({
React = require('react');
SchedulerTracing = require('scheduler/tracing');
ReactCache = require('react-cache');
if (useNoopRenderer) {
ReactNoop = require('react-noop-renderer');
@@ -68,6 +74,46 @@ function loadModules({
return this.props.children || null;
}
};
resourcePromise = null;
function yieldForRenderer(value) {
if (ReactNoop) {
ReactNoop.yield(value);
} else {
ReactTestRenderer.unstable_yield(value);
}
}
TextResource = ReactCache.unstable_createResource(([text, ms = 0]) => {
resourcePromise = new Promise((resolve, reject) =>
setTimeout(() => {
yieldForRenderer(`Promise resolved [${text}]`);
resolve(text);
}, ms),
);
return resourcePromise;
}, ([text, ms]) => text);
AsyncText = ({ms, text}) => {
try {
TextResource.read([text, ms]);
yieldForRenderer(`AsyncText [${text}]`);
return text;
} catch (promise) {
if (typeof promise.then === 'function') {
yieldForRenderer(`Suspend [${text}]`);
} else {
yieldForRenderer(`Error [${text}]`);
}
throw promise;
}
};
Text = ({text}) => {
yieldForRenderer(`Text [${text}]`);
return text;
};
}
const mockDevToolsForTest = () => {
@@ -2145,12 +2191,6 @@ describe('Profiler', () => {
});
describe('suspense', () => {
let AsyncText;
let Text;
let TextResource;
let cache;
let resourcePromise;
function awaitableAdvanceTimers(ms) {
jest.advanceTimersByTime(ms);
// Wait until the end of the current tick
@@ -2159,54 +2199,6 @@ describe('Profiler', () => {
});
}
function yieldForRenderer(value) {
if (ReactNoop) {
ReactNoop.yield(value);
} else {
ReactTestRenderer.unstable_yield(value);
}
}
beforeEach(() => {
const ReactCache = require('react-cache');
function invalidateCache() {
cache = ReactCache.createCache(invalidateCache);
}
invalidateCache();
resourcePromise = null;
TextResource = ReactCache.unstable_createResource(([text, ms = 0]) => {
resourcePromise = new Promise((resolve, reject) =>
setTimeout(() => {
yieldForRenderer(`Promise resolved [${text}]`);
resolve(text);
}, ms),
);
return resourcePromise;
}, ([text, ms]) => text);
AsyncText = ({ms, text}) => {
try {
TextResource.read(cache, [text, ms]);
yieldForRenderer(`AsyncText [${text}]`);
return text;
} catch (promise) {
if (typeof promise.then === 'function') {
yieldForRenderer(`Suspend [${text}]`);
} else {
yieldForRenderer(`Error [${text}]`);
}
throw promise;
}
};
Text = ({text}) => {
yieldForRenderer(`Text [${text}]`);
return text;
};
});
it('traces both the temporary placeholder and the finished render for an interaction', async () => {
loadModulesForTracing({useNoopRenderer: true});
@@ -2352,7 +2344,7 @@ describe('Profiler', () => {
render() {
const {ms, text} = this.props;
TextResource.read(cache, [text, ms]);
TextResource.read([text, ms]);
return <span prop={text}>{this.state.hasMounted}</span>;
}
}
@@ -59,7 +59,6 @@ function loadModules() {
describe('ProfilerDOM', () => {
let TextResource;
let cache;
let resourcePromise;
let onInteractionScheduledWorkCompleted;
let onInteractionTraced;
@@ -81,8 +80,6 @@ describe('ProfilerDOM', () => {
onWorkStopped: () => {},
});
cache = ReactCache.createCache(() => {});
resourcePromise = null;
TextResource = ReactCache.unstable_createResource(([text, ms = 0]) => {
@@ -101,7 +98,7 @@ describe('ProfilerDOM', () => {
});
const AsyncText = ({ms, text}) => {
TextResource.read(cache, [text, ms]);
TextResource.read([text, ms]);
return text;
};
+1 -1
View File
@@ -377,7 +377,7 @@ const bundles = [
moduleType: ISOMORPHIC,
entry: 'react-cache',
global: 'ReactCache',
externals: ['react'],
externals: ['react', 'scheduler'],
},
/******* createComponentWithSubscriptions (experimental) *******/