diff --git a/packages/react-cache/src/LRU.js b/packages/react-cache/src/LRU.js new file mode 100644 index 00000000000..c555566159e --- /dev/null +++ b/packages/react-cache/src/LRU.js @@ -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 = {| + value: T, + onDelete: () => mixed, + previous: Entry, + next: Entry, +|}; + +export function createLRU(limit: number) { + let LIMIT = limit; + + // Circular, doubly-linked list + let first: Entry | 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 = (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 { + 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, newValue: T): void { + entry.value = newValue; + } + + function access(entry: Entry): T { + const next = entry.next; + if (next !== null) { + // Entry already cached + const resolvedFirst: Entry = (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, + }; +} diff --git a/packages/react-cache/src/ReactCache.js b/packages/react-cache/src/ReactCache.js index 98558ff9fba..64f6b151e92 100644 --- a/packages/react-cache/src/ReactCache.js +++ b/packages/react-cache/src/ReactCache.js @@ -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 Thenable = { + then(resolve: (T) => mixed, reject: (mixed) => mixed): mixed, +}; + +type Suspender = { + then(resolve: () => mixed, reject: () => mixed): mixed, +}; -type EmptyRecord = {| +type PendingResult = {| 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 | null, - * previous: Record | null, - */ + value: Suspender, |}; -type PendingRecord = {| +type ResolvedResult = {| status: 1, - suspender: Promise, - 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 | null, - * previous: Record | null, - */ -|}; - -type ResolvedRecord = {| - 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 | null, - * previous: Record | null, - */ -|}; - -type RejectedRecord = {| - 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 | null, - * previous: Record | null, - */ |}; -type Record = - | EmptyRecord - | PendingRecord - | ResolvedRecord - | RejectedRecord; - -type RecordCache = {| - map: Map>, - head: Record | null, - tail: Record | null, - size: number, +type RejectedResult = {| + status: 2, + value: mixed, |}; -// TODO: How do you express this type with Flow? -type ResourceMap = Map>; -type Cache = { - invalidate(): void, - read( - resourceType: mixed, - key: K, - miss: (A) => Promise, - missArg: A, - ): V, - preload( - resourceType: mixed, - key: K, - miss: (A) => Promise, - missArg: A, - ): void, +type Result = PendingResult | ResolvedResult | RejectedResult; - // DEV-only - $$typeof?: Symbol | number, +type Resource = { + read(I): V, + preload(I): void, }; -let CACHE_TYPE; -if (__DEV__) { - CACHE_TYPE = 0xcac4e; -} - -let isCache; -if (__DEV__) { - isCache = value => - value !== null && - typeof value === 'object' && - value.$$typeof === CACHE_TYPE; -} +const Pending = 0; +const Resolved = 1; +const Rejected = 2; -// TODO: Make this configurable per resource -const MAX_SIZE = 500; -const PAGE_SIZE = 50; +const currentOwner = + React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner; -function createRecord(key: K): EmptyRecord { - return { - status: Empty, - suspender: null, - key, - value: null, - error: null, - next: null, - previous: null, - }; +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 createRecordCache(): RecordCache { - return { - map: new Map(), - head: null, - tail: null, - size: 0, - }; +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; } -export function createCache(invalidator: () => mixed): Cache { - const resourceMap: ResourceMap = new Map(); +const CACHE_LIMIT = 500; +const lru = createLRU(CACHE_LIMIT); - function accessRecord(resourceType: any, key: K): Record { - 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, - ); - } +const entries: Map, Map> = new Map(); - let recordCache = resourceMap.get(resourceType); - if (recordCache === undefined) { - recordCache = createRecordCache(); - resourceMap.set(resourceType, recordCache); - } - const map = recordCache.map; +const CacheContext = React.createContext(null); - 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 accessResult( + resource: any, + fetch: I => Thenable, + input: I, + key: K, +): Result { + let entriesForResource = entries.get(resource); + if (entriesForResource === undefined) { + entriesForResource = new Map(); + entries.set(resource, entriesForResource); } - - function load(emptyRecord: EmptyRecord, suspender: Promise) { - const pendingRecord: PendingRecord = (emptyRecord: any); - pendingRecord.status = Pending; - pendingRecord.suspender = suspender; - suspender.then( + let entry = entriesForResource.get(key); + if (entry === undefined) { + const thenable = fetch(input); + thenable.then( value => { - // Resource loaded successfully. - const resolvedRecord: ResolvedRecord = (pendingRecord: any); - resolvedRecord.status = Resolved; - resolvedRecord.suspender = null; - resolvedRecord.value = value; + if (newResult.status === Pending) { + const resolvedResult: ResolvedResult = (newResult: any); + resolvedResult.status = Resolved; + resolvedResult.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 = (pendingRecord: any); - rejectedRecord.status = Rejected; - rejectedRecord.suspender = null; - rejectedRecord.error = 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); } - - const cache: Cache = { - invalidate() { - invalidator(); - }, - preload( - resourceType: any, - key: K, - miss: A => Promise, - missArg: A, - ): void { - const record: Record = 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( - resourceType: any, - key: K, - miss: A => Promise, - missArg: A, - ): V { - const record: Record = 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, - ); - }; +function deleteEntry(resource, key) { + const entriesForResource = entries.get(resource); + if (entriesForResource !== undefined) { + entriesForResource.delete(key); + if (entriesForResource.size === 0) { + entries.delete(resource); + } + } } -type primitive = string | number | boolean | void | null; - -type Resource = {| - read(Cache, K): V, - preload(cache: Cache, key: K): void, -|}; - -// These declarations are used to express function overloading. I wish there -// were a more elegant way to do this in the function definition itself. - -// Primitive keys do not request a hash function. -declare function unstable_createResource( - loadResource: (K) => Promise, - hash?: (K) => H, -): Resource; - -// Non-primitive keys *do* require a hash function. -// eslint-disable-next-line no-redeclare -declare function unstable_createResource( - loadResource: (K) => Promise, - hash: (K) => H, -): Resource; +export function unstable_createResource( + fetch: I => Thenable, + maybeHashInput?: I => K, +): Resource { + const hashInput: I => K = + maybeHashInput !== undefined ? maybeHashInput : (identityHashFn: any); -// eslint-disable-next-line no-redeclare -export function unstable_createResource( - loadResource: K => Promise, - hash: K => H, -): Resource { 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 = accessResult(resource, fetch, input, key); + switch (result.status) { + case Pending: { + const suspender = result.value; + throw suspender; } - return cache.read(resource, key, loadResource, key); - } - 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'); + case Resolved: { + const value = result.value; + return value; + } + case Rejected: { + const error = result.value; + throw error; } - cache.preload(resource, key, loadResource, key); - return; + default: + // Should be unreachable + return (undefined: any); } - 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); +} diff --git a/packages/react-cache/src/__tests__/ReactCache-test.internal.js b/packages/react-cache/src/__tests__/ReactCache-test.internal.js new file mode 100644 index 00000000000..e87d53e063c --- /dev/null +++ b/packages/react-cache/src/__tests__/ReactCache-test.internal.js @@ -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 ( + }> + + + ); + } + + const root = ReactTestRenderer.create(, { + 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 ( + }> + + + ); + } + + const root = ReactTestRenderer.create(, { + 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(); + 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( + }> + + , + { + 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( + }> + + + + , + { + 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( + }> + + + + , + ); + + 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( + }> + + + + , + ); + + 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 ; + } + + const root = ReactTestRenderer.create( + }> + + , + { + 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( + }> + + , + { + 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( + }> + + , + { + 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", + ); + }); +}); diff --git a/packages/react-cache/src/__tests__/ReactCache-test.js b/packages/react-cache/src/__tests__/ReactCache-test.js deleted file mode 100644 index 339ef6a2557..00000000000 --- a/packages/react-cache/src/__tests__/ReactCache-test.js +++ /dev/null @@ -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); - }); -}); diff --git a/packages/react-dom/src/__tests__/ReactDOMSuspensePlaceholder-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMSuspensePlaceholder-test.internal.js index 556a6202b16..3fc258e365b 100644 --- a/packages/react-dom/src/__tests__/ReactDOMSuspensePlaceholder-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMSuspensePlaceholder-test.internal.js @@ -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; } diff --git a/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js b/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js index bdbab58ac71..693c5d301e3 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js @@ -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) { diff --git a/packages/react-reconciler/src/__tests__/ReactSuspensePlaceholder-test.internal.js b/packages/react-reconciler/src/__tests__/ReactSuspensePlaceholder-test.internal.js index de8c76e9e19..971b73ad9e1 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspensePlaceholder-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspensePlaceholder-test.internal.js @@ -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) { diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js index 0acce64d23a..ad5c089c8c5 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js @@ -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 ; } 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 ; } catch (promise) { diff --git a/packages/react/src/__tests__/ReactProfiler-test.internal.js b/packages/react/src/__tests__/ReactProfiler-test.internal.js index 8a47868a6ac..127d47ea216 100644 --- a/packages/react/src/__tests__/ReactProfiler-test.internal.js +++ b/packages/react/src/__tests__/ReactProfiler-test.internal.js @@ -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 {this.state.hasMounted}; } } diff --git a/packages/react/src/__tests__/ReactProfilerDOM-test.internal.js b/packages/react/src/__tests__/ReactProfilerDOM-test.internal.js index 89604f22269..75253600066 100644 --- a/packages/react/src/__tests__/ReactProfilerDOM-test.internal.js +++ b/packages/react/src/__tests__/ReactProfilerDOM-test.internal.js @@ -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; }; diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index bd0608ed5aa..0e9bb851044 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -377,7 +377,7 @@ const bundles = [ moduleType: ISOMORPHIC, entry: 'react-cache', global: 'ReactCache', - externals: ['react'], + externals: ['react', 'scheduler'], }, /******* createComponentWithSubscriptions (experimental) *******/