From 676660dc86d498624d14dc50278563fc42c3fa7d Mon Sep 17 00:00:00 2001 From: Robert Balicki Date: Mon, 29 Jun 2020 08:01:03 -0700 Subject: [PATCH] Add `useQueryLoader` and `useEntryPointLoader` hooks Reviewed By: josephsavona Differential Revision: D21528129 fbshipit-source-id: daccaf3feb1025eaea91416309821d0a716d39e6 --- .../__tests__/useEntryPointLoader-test.js | 616 +++++++++++++++++ .../__tests__/useQueryLoader-test.js | 637 ++++++++++++++++++ packages/relay-experimental/index.js | 4 + .../relay-experimental/useEntryPointLoader.js | 196 ++++++ packages/relay-experimental/useQueryLoader.js | 163 +++++ 5 files changed, 1616 insertions(+) create mode 100644 packages/relay-experimental/__tests__/useEntryPointLoader-test.js create mode 100644 packages/relay-experimental/__tests__/useQueryLoader-test.js create mode 100644 packages/relay-experimental/useEntryPointLoader.js create mode 100644 packages/relay-experimental/useQueryLoader.js diff --git a/packages/relay-experimental/__tests__/useEntryPointLoader-test.js b/packages/relay-experimental/__tests__/useEntryPointLoader-test.js new file mode 100644 index 0000000000000..c5a8c4fd7f3ed --- /dev/null +++ b/packages/relay-experimental/__tests__/useEntryPointLoader-test.js @@ -0,0 +1,616 @@ +/** + * 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 oncall+relay + * @flow + * @format + */ + +// flowlint ambiguous-object-type:error + +'use strict'; + +const React = require('react'); +const ReactTestRenderer = require('react-test-renderer'); + +const useEntryPointLoader = require('../useEntryPointLoader'); + +const {createMockEnvironment} = require('relay-test-utils-internal'); + +let loadedEntryPoint; +let instance; +let entryPointLoaderCallback; +let dispose; +let loadEntryPointLastReturnValue; +let disposeEntryPoint; + +let renderCount; +let environment; +let defaultEnvironmentProvider; +let render; +let Container; +let defaultEntryPoint: any; + +const loadEntryPoint = jest.fn().mockImplementation(() => { + dispose = jest.fn(); + return (loadEntryPointLastReturnValue = { + dispose, + }); +}); +jest.mock('../loadEntryPoint', () => loadEntryPoint); + +beforeEach(() => { + renderCount = undefined; + dispose = undefined; + environment = createMockEnvironment(); + defaultEnvironmentProvider = { + getEnvironment: () => environment, + }; + // We don't care about the contents of entryPoints + defaultEntryPoint = {}; + + render = function() { + renderCount = 0; + ReactTestRenderer.act(() => { + instance = ReactTestRenderer.create( + , + ); + }); + }; + + Container = function({entryPoint, environmentProvider}) { + renderCount = (renderCount || 0) + 1; + [ + loadedEntryPoint, + entryPointLoaderCallback, + disposeEntryPoint, + ] = useEntryPointLoader(environmentProvider, entryPoint); + return null; + }; + loadEntryPoint.mockClear(); +}); + +afterAll(() => { + jest.clearAllMocks(); +}); + +it('calls loadEntryPoint with the appropriate parameters, if the callback is called', () => { + render(); + expect(loadEntryPoint).not.toHaveBeenCalled(); + const params = {}; + ReactTestRenderer.act(() => entryPointLoaderCallback(params)); + expect(loadEntryPoint).toHaveBeenCalledTimes(1); + expect(loadEntryPoint.mock.calls[0][0]).toBe(defaultEnvironmentProvider); + expect(loadEntryPoint.mock.calls[0][1]).toBe(defaultEntryPoint); + expect(loadEntryPoint.mock.calls[0][2]).toBe(params); +}); + +it('disposes the old preloaded entry point and calls loadEntryPoint anew if the callback is called again', () => { + render(); + + const params = {}; + ReactTestRenderer.act(() => entryPointLoaderCallback(params)); + expect(loadEntryPoint).toHaveBeenCalledTimes(1); + + const currentDispose = dispose; + expect(currentDispose).not.toHaveBeenCalled(); + + const params2 = {}; + ReactTestRenderer.act(() => entryPointLoaderCallback(params2)); + + expect(currentDispose).toHaveBeenCalled(); + expect(loadEntryPoint).toHaveBeenCalledTimes(2); + expect(loadEntryPoint.mock.calls[1][0]).toBe(defaultEnvironmentProvider); + expect(loadEntryPoint.mock.calls[1][1]).toBe(defaultEntryPoint); + expect(loadEntryPoint.mock.calls[1][2]).toBe(params2); +}); + +it('disposes the preloaded entry point if the component unmounts', () => { + render(); + const params = {}; + ReactTestRenderer.act(() => entryPointLoaderCallback(params)); + expect(dispose).toHaveBeenCalledTimes(0); + instance.unmount(); + expect(dispose).toHaveBeenCalledTimes(1); +}); + +it('returns the value of loadEntryPoint as the first item of the return value', () => { + render(); + const params = {}; + ReactTestRenderer.act(() => entryPointLoaderCallback(params)); + expect(loadedEntryPoint).toBe(loadEntryPointLastReturnValue); +}); + +it('disposes the entry point and nullifies the state when the disposeEntryPoint callback is called', () => { + render(); + const params = {}; + ReactTestRenderer.act(() => entryPointLoaderCallback(params)); + expect(disposeEntryPoint).toBeDefined(); + if (disposeEntryPoint) { + expect(loadedEntryPoint).not.toBe(null); + expect(dispose).not.toHaveBeenCalled(); + ReactTestRenderer.act(disposeEntryPoint); + expect(dispose).toHaveBeenCalledTimes(1); + expect(loadedEntryPoint).toBe(null); + } +}); + +beforeEach(() => { + jest.mock('scheduler', () => require('scheduler/unstable_mock')); +}); + +afterEach(() => { + jest.dontMock('scheduler'); +}); + +it('does not dispose the entry point before the new component tree unsuspends in concurrent mode', () => { + if (typeof React.useTransition === 'function') { + let resolve; + let resolved = false; + const suspensePromise = new Promise( + _resolve => + (resolve = () => { + resolved = true; + _resolve(); + }), + ); + + function ComponentThatSuspends() { + if (resolved) { + return null; + } + throw suspensePromise; + } + + function ComponentWithHook() { + [, entryPointLoaderCallback] = useEntryPointLoader( + defaultEnvironmentProvider, + defaultEntryPoint, + ); + return null; + } + + function concurrentRender() { + ReactTestRenderer.act(() => { + instance = ReactTestRenderer.create( + , + // $FlowFixMe - error revealed when flow-typing ReactTestRenderer + {unstable_isConcurrent: true}, + ); + }); + } + + let transitionToSecondRoute; + const suspenseTransitionConfig = { + timeoutMs: 3000, + }; + function ConcurrentWrapper() { + const [route, setRoute] = React.useState('FIRST'); + + const [startTransition] = React.useTransition(suspenseTransitionConfig); + transitionToSecondRoute = () => startTransition(() => setRoute('SECOND')); + + return ( + + + + ); + } + + function Router({route}) { + if (route === 'FIRST') { + return ; + } else { + return ; + } + } + + concurrentRender(); + + ReactTestRenderer.act(() => entryPointLoaderCallback({})); + const currentDispose = dispose; + + ReactTestRenderer.act(() => transitionToSecondRoute()); + + // currentDispose will have been called in non-concurrent mode + expect(currentDispose).not.toHaveBeenCalled(); + + ReactTestRenderer.act(() => { + resolve && resolve(); + jest.runAllImmediates(); + }); + + expect(currentDispose).toHaveBeenCalled(); + } +}); + +it('disposes entry point references associated with previous suspensions when multiple state changes trigger suspense and the final suspension concludes', () => { + // Three state changes and calls to loadEntryPoint: A, B, C, each causing suspense + // When C unsuspends, A and B's entry points are disposed. + + if (typeof React.useTransition === 'function') { + let resolve; + let resolved = false; + const resolvableSuspensePromise = new Promise( + _resolve => + (resolve = () => { + resolved = true; + _resolve(); + }), + ); + + const unresolvablePromise = new Promise(() => {}); + const unresolvablePromise2 = new Promise(() => {}); + + function concurrentRender() { + ReactTestRenderer.act(() => { + instance = ReactTestRenderer.create( + , + // $FlowFixMe - error revealed when flow-typing ReactTestRenderer + {unstable_isConcurrent: true}, + ); + }); + } + + let triggerStateChange: any; + const suspenseTransitionConfig = { + timeoutMs: 3000, + }; + function ConcurrentWrapper() { + const [promise, setPromise] = React.useState(null); + + const [startTransition] = React.useTransition(suspenseTransitionConfig); + triggerStateChange = (newPromise, newName) => + startTransition(() => { + entryPointLoaderCallback({}); + setPromise(newPromise); + }); + + return ( + + + + ); + } + + function Inner({promise}) { + [, entryPointLoaderCallback] = useEntryPointLoader( + defaultEnvironmentProvider, + defaultEntryPoint, + ); + if ( + promise == null || + (promise === resolvableSuspensePromise && resolved) + ) { + return null; + } + throw promise; + } + + concurrentRender(); + expect(instance.toJSON()).toEqual(null); + + const initialStateChange: any = triggerStateChange; + + ReactTestRenderer.act(() => { + initialStateChange(unresolvablePromise); + }); + expect(loadEntryPoint).toHaveBeenCalledTimes(1); + expect(instance.toJSON()).toEqual('fallback'); + const firstDispose = dispose; + + ReactTestRenderer.act(() => { + initialStateChange(unresolvablePromise2); + }); + expect(loadEntryPoint).toHaveBeenCalledTimes(2); + expect(instance.toJSON()).toEqual('fallback'); + const secondDispose = dispose; + + ReactTestRenderer.act(() => { + initialStateChange(resolvableSuspensePromise); + }); + expect(loadEntryPoint).toHaveBeenCalledTimes(3); + expect(instance.toJSON()).toEqual('fallback'); + const thirdDispose = dispose; + + expect(firstDispose).not.toHaveBeenCalled(); + expect(secondDispose).not.toHaveBeenCalled(); + + ReactTestRenderer.act(() => { + resolve(); + jest.runAllImmediates(); + }); + expect(firstDispose).toHaveBeenCalledTimes(1); + expect(secondDispose).toHaveBeenCalledTimes(1); + expect(thirdDispose).not.toHaveBeenCalled(); + } +}); + +it('disposes entry point references associated with subsequent suspensions when multiple state changes trigger suspense and the initial suspension concludes', () => { + // Three state changes and calls to loadEntryPoint: A, B, C, each causing suspense + // When A unsuspends, B and C's entry points do not get disposed. + + if (typeof React.useTransition === 'function') { + let resolve; + let resolved = false; + const resolvableSuspensePromise = new Promise( + _resolve => + (resolve = () => { + resolved = true; + _resolve(); + }), + ); + + const unresolvablePromise = new Promise(() => {}); + const unresolvablePromise2 = new Promise(() => {}); + + function concurrentRender() { + ReactTestRenderer.act(() => { + instance = ReactTestRenderer.create( + , + // $FlowFixMe - error revealed when flow-typing ReactTestRenderer + {unstable_isConcurrent: true}, + ); + }); + } + + let triggerStateChange: any; + const suspenseTransitionConfig = { + timeoutMs: 3000, + }; + function ConcurrentWrapper() { + const [promise, setPromise] = React.useState(null); + + const [startTransition] = React.useTransition(suspenseTransitionConfig); + triggerStateChange = (newPromise, newName) => + startTransition(() => { + entryPointLoaderCallback({}); + setPromise(newPromise); + }); + + return ( + + + + ); + } + + let innerUnsuspendedCorrectly = false; + function Inner({promise}) { + [, entryPointLoaderCallback] = useEntryPointLoader( + defaultEnvironmentProvider, + defaultEntryPoint, + ); + if ( + promise == null || + (promise === resolvableSuspensePromise && resolved) + ) { + innerUnsuspendedCorrectly = true; + return null; + } + throw promise; + } + + concurrentRender(); + expect(instance.toJSON()).toEqual(null); + + const initialStateChange: any = triggerStateChange; + + ReactTestRenderer.act(() => { + initialStateChange(resolvableSuspensePromise); + }); + expect(loadEntryPoint).toHaveBeenCalledTimes(1); + expect(instance.toJSON()).toEqual('fallback'); + const firstDispose = dispose; + + ReactTestRenderer.act(() => { + initialStateChange(unresolvablePromise); + }); + expect(loadEntryPoint).toHaveBeenCalledTimes(2); + expect(instance.toJSON()).toEqual('fallback'); + const secondDispose = dispose; + + ReactTestRenderer.act(() => { + initialStateChange(unresolvablePromise2); + }); + const thirdDispose = dispose; + + ReactTestRenderer.act(() => { + resolve(); + jest.runAllImmediates(); + }); + expect(innerUnsuspendedCorrectly).toEqual(true); + expect(firstDispose).not.toHaveBeenCalled(); + expect(secondDispose).not.toHaveBeenCalled(); + expect(thirdDispose).not.toHaveBeenCalled(); + } +}); + +it('should dispose of prior entry points if the callback is called multiple times in the same tick', () => { + render(); + let firstDispose; + ReactTestRenderer.act(() => { + entryPointLoaderCallback({}); + firstDispose = dispose; + entryPointLoaderCallback({}); + }); + expect(loadEntryPoint).toHaveBeenCalledTimes(2); + expect(firstDispose).toHaveBeenCalledTimes(1); +}); + +it('should dispose of entry points on unmount if the callback is called, the component suspends and then unmounts', () => { + let shouldSuspend; + let setShouldSuspend; + const suspensePromise = new Promise(() => {}); + function SuspendingComponent() { + [shouldSuspend, setShouldSuspend] = React.useState(false); + if (shouldSuspend) { + throw suspensePromise; + } + return null; + } + function Outer() { + return ( + + + + + ); + } + + const outerInstance = ReactTestRenderer.create(); + ReactTestRenderer.act(() => jest.runAllImmediates()); + expect(renderCount).toEqual(1); + ReactTestRenderer.act(() => { + entryPointLoaderCallback({}); + }); + expect(renderCount).toEqual(2); + ReactTestRenderer.act(() => { + setShouldSuspend(true); + }); + expect(renderCount).toEqual(2); + expect(outerInstance.toJSON()).toEqual('fallback'); + expect(dispose).not.toHaveBeenCalled(); + outerInstance.unmount(); + expect(dispose).toHaveBeenCalledTimes(1); +}); + +it('disposes all entry points if the callback is called, the component suspends, another entry point is loaded and then the component unmounts', () => { + let shouldSuspend; + let setShouldSuspend; + const suspensePromise = new Promise(() => {}); + function SuspendingComponent() { + [shouldSuspend, setShouldSuspend] = React.useState(false); + if (shouldSuspend) { + throw suspensePromise; + } + return null; + } + function Outer() { + return ( + + + + + ); + } + + const outerInstance = ReactTestRenderer.create(); + expect(renderCount).toEqual(1); + ReactTestRenderer.act(() => { + entryPointLoaderCallback({}); + }); + expect(renderCount).toEqual(2); + const firstDispose = dispose; + ReactTestRenderer.act(() => { + setShouldSuspend(true); + }); + expect(renderCount).toEqual(2); + expect(firstDispose).not.toHaveBeenCalled(); + expect(outerInstance.toJSON()).toEqual('fallback'); + + // For some reason, calling the entryPointLoaderCallback here causes a re-render, + // *even though the component is in a suspended state.* As such, it commits and + // the entry point is disposed. + // + // If we did not initially call `entryPointLoaderCallback`, there would not be a + // re-render. (See the following test.) + ReactTestRenderer.act(() => { + entryPointLoaderCallback({}); + }); + const secondDispose = dispose; + expect(renderCount).toEqual(3); + expect(outerInstance.toJSON()).toEqual('fallback'); + expect(firstDispose).toHaveBeenCalledTimes(1); + expect(secondDispose).not.toHaveBeenCalled(); + outerInstance.unmount(); + expect(secondDispose).toHaveBeenCalledTimes(1); +}); + +it('disposes all entry points if the component suspends, another entry point is loaded and then the component unmounts', () => { + let shouldSuspend; + let setShouldSuspend; + const suspensePromise = new Promise(() => {}); + function SuspendingComponent() { + [shouldSuspend, setShouldSuspend] = React.useState(false); + if (shouldSuspend) { + throw suspensePromise; + } + return null; + } + function Outer() { + return ( + + + + + ); + } + + const outerInstance = ReactTestRenderer.create(); + expect(renderCount).toEqual(1); + ReactTestRenderer.act(() => { + setShouldSuspend(true); + }); + expect(renderCount).toEqual(1); + expect(outerInstance.toJSON()).toEqual('fallback'); + ReactTestRenderer.act(() => { + entryPointLoaderCallback({}); + }); + + // Compare this to the previous test. Calling entryPointLoaderCallback here + // does not trigger a re-render + commit. + expect(renderCount).toEqual(1); + expect(outerInstance.toJSON()).toEqual('fallback'); + expect(dispose).not.toHaveBeenCalled(); + outerInstance.unmount(); + expect(dispose).toHaveBeenCalledTimes(1); +}); + +it('disposes the entry point on unmount if the callback is called and the component unmounts before rendering', () => { + // Case 1: unmount, then loadEntryPoint + render(); + expect(renderCount).toEqual(1); + ReactTestRenderer.act(() => { + instance.unmount(); + entryPointLoaderCallback({}); + expect(dispose).not.toHaveBeenCalled(); + }); + expect(dispose).toHaveBeenCalledTimes(1); + expect(renderCount).toEqual(1); // renderCount === 1 ensures that an extra commit hasn't occurred +}); + +it('disposes the entry point on unmount if the component unmounts and then the callback is called before rendering', () => { + // Case 2: loadEntryPoint, then unmount + render(); + expect(renderCount).toEqual(1); + ReactTestRenderer.act(() => { + entryPointLoaderCallback({}); + expect(dispose).not.toHaveBeenCalled(); + instance.unmount(); + }); + expect(dispose).toHaveBeenCalledTimes(1); + expect(renderCount).toEqual(1); // renderCount === 1 ensures that an extra commit hasn't occurred +}); + +it('does not call loadEntryPoint if the callback is called after the component unmounts', () => { + render(); + instance.unmount(); + entryPointLoaderCallback({}); + expect(loadEntryPoint).not.toHaveBeenCalled(); +}); diff --git a/packages/relay-experimental/__tests__/useQueryLoader-test.js b/packages/relay-experimental/__tests__/useQueryLoader-test.js new file mode 100644 index 0000000000000..069a67906e087 --- /dev/null +++ b/packages/relay-experimental/__tests__/useQueryLoader-test.js @@ -0,0 +1,637 @@ +/** + * 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 oncall+relay + * @flow + * @format + */ + +// flowlint ambiguous-object-type:error + +'use strict'; + +const React = require('react'); +const ReactTestRenderer = require('react-test-renderer'); +const RelayEnvironmentProvider = require('../RelayEnvironmentProvider'); + +const useQueryLoader = require('../useQueryLoader'); + +const { + createMockEnvironment, + generateAndCompile, +} = require('relay-test-utils-internal'); + +import type {ConcreteRequest} from 'relay-runtime'; + +const generatedQuery: ConcreteRequest = generateAndCompile(` + query TestQuery($id: ID!) { + node(id: $id) { + id + } + } +`).TestQuery; + +const defaultOptions = {}; + +let renderCount; +let loadedQuery; +let instance; +let queryLoaderCallback; +let dispose; +let lastLoadQueryReturnValue; +let disposeQuery; + +let render; +let Inner; +let Container; +let environment; + +const loadQuery = jest.fn().mockImplementation(() => { + dispose = jest.fn(); + return (lastLoadQueryReturnValue = { + dispose, + }); +}); + +jest.mock('../loadQuery', () => ({ + loadQuery, + useTrackLoadQueryInRender: () => {}, +})); + +beforeEach(() => { + renderCount = undefined; + dispose = undefined; + environment = createMockEnvironment(); + render = function(query = generatedQuery) { + renderCount = 0; + ReactTestRenderer.act(() => { + instance = ReactTestRenderer.create(); + }); + }; + + Inner = function({query}) { + renderCount = (renderCount || 0) + 1; + [loadedQuery, queryLoaderCallback, disposeQuery] = useQueryLoader(query); + return null; + }; + + Container = function({query}) { + return ( + + + + ); + }; + + loadQuery.mockClear(); +}); + +afterAll(() => { + jest.clearAllMocks(); +}); + +it('calls loadQuery with the appropriate parameters, if the callback is called', () => { + render(); + expect(loadQuery).not.toHaveBeenCalled(); + const variables = {id: '4'}; + ReactTestRenderer.act(() => queryLoaderCallback(variables, defaultOptions)); + expect(loadQuery).toHaveBeenCalledTimes(1); + expect(loadQuery.mock.calls[0][0]).toBe(environment); + expect(loadQuery.mock.calls[0][1]).toBe(generatedQuery); + expect(loadQuery.mock.calls[0][2]).toBe(variables); + expect(loadQuery.mock.calls[0][3]).toBe(defaultOptions); +}); + +it('disposes the old preloaded query and calls loadQuery anew if the callback is called again', () => { + render(); + + const variables = {id: '4'}; + ReactTestRenderer.act(() => queryLoaderCallback(variables)); + ReactTestRenderer.act(() => jest.runAllImmediates()); + expect(loadQuery).toHaveBeenCalledTimes(1); + + const currentDispose = dispose; + expect(currentDispose).not.toHaveBeenCalled(); + + ReactTestRenderer.act(() => queryLoaderCallback(variables, defaultOptions)); + expect(currentDispose).toHaveBeenCalled(); + + expect(loadQuery).toHaveBeenCalledTimes(2); + expect(loadQuery.mock.calls[1][0]).toBe(environment); + expect(loadQuery.mock.calls[1][1]).toBe(generatedQuery); + expect(loadQuery.mock.calls[1][2]).toBe(variables); + expect(loadQuery.mock.calls[1][3]).toBe(defaultOptions); +}); + +it('disposes the old preloaded query and calls loadQuery anew if the callback is called again with new variables', () => { + render(); + + const variables = {id: '4'}; + ReactTestRenderer.act(() => queryLoaderCallback(variables)); + expect(loadQuery).toHaveBeenCalledTimes(1); + + const currentDispose = dispose; + expect(currentDispose).not.toHaveBeenCalled(); + + const variables2 = {id: '5'}; + ReactTestRenderer.act(() => queryLoaderCallback(variables2, defaultOptions)); + + expect(currentDispose).toHaveBeenCalled(); + expect(loadQuery).toHaveBeenCalledTimes(2); + expect(loadQuery.mock.calls[1][0]).toBe(environment); + expect(loadQuery.mock.calls[1][1]).toBe(generatedQuery); + expect(loadQuery.mock.calls[1][2]).toBe(variables2); + expect(loadQuery.mock.calls[1][3]).toBe(defaultOptions); +}); + +it('disposes the preloaded query if the component unmounts', () => { + render(); + const variables = {id: '4'}; + ReactTestRenderer.act(() => queryLoaderCallback(variables)); + expect(dispose).toHaveBeenCalledTimes(0); + ReactTestRenderer.act(() => instance.unmount()); + expect(dispose).toHaveBeenCalledTimes(1); +}); + +it('returns the data that was returned from a call to loadQuery', () => { + render(); + const variables = {id: '4'}; + ReactTestRenderer.act(() => queryLoaderCallback(variables)); + expect(loadedQuery).toBe(lastLoadQueryReturnValue); +}); + +it('disposes the query and nullifies the state when the disposeQuery callback is called', () => { + render(); + const variables = {id: '4'}; + ReactTestRenderer.act(() => queryLoaderCallback(variables)); + expect(disposeQuery).toBeDefined(); + if (disposeQuery) { + expect(loadedQuery).not.toBe(null); + expect(dispose).not.toHaveBeenCalled(); + ReactTestRenderer.act(disposeQuery); + expect(dispose).toHaveBeenCalledTimes(1); + expect(loadedQuery).toBe(null); + } +}); + +beforeEach(() => { + jest.mock('scheduler', () => require('scheduler/unstable_mock')); +}); + +afterEach(() => { + jest.dontMock('scheduler'); +}); + +it('does not dispose the query before the new component tree unsuspends in concurrent mode', () => { + if (typeof React.useTransition === 'function') { + let resolve; + let resolved = false; + const suspensePromise = new Promise( + _resolve => + (resolve = () => { + resolved = true; + _resolve(); + }), + ); + + function ComponentThatSuspends() { + if (resolved) { + return null; + } + throw suspensePromise; + } + + function ComponentWithQuery() { + [, queryLoaderCallback] = useQueryLoader(generatedQuery); + return null; + } + + function concurrentRender() { + ReactTestRenderer.act(() => { + instance = ReactTestRenderer.create( + + + , + // $FlowFixMe - error revealed when flow-typing ReactTestRenderer + {unstable_isConcurrent: true}, + ); + }); + } + + let transitionToSecondRoute; + const suspenseTransitionConfig = { + timeoutMs: 3000, + }; + function ConcurrentWrapper() { + const [route, setRoute] = React.useState('FIRST'); + + const [startTransition] = React.useTransition(suspenseTransitionConfig); + transitionToSecondRoute = () => startTransition(() => setRoute('SECOND')); + + return ( + + + + ); + } + + function Router({route}) { + if (route === 'FIRST') { + return ; + } else { + return ; + } + } + + concurrentRender(); + + ReactTestRenderer.act(() => queryLoaderCallback({id: '4'})); + const currentDispose = dispose; + + ReactTestRenderer.act(() => transitionToSecondRoute()); + + // currentDispose will have been called in non-concurrent mode + expect(currentDispose).not.toHaveBeenCalled(); + + ReactTestRenderer.act(() => { + resolve && resolve(); + jest.runAllImmediates(); + }); + + expect(currentDispose).toHaveBeenCalled(); + } +}); + +it('disposes query references associated with previous suspensions when multiple state changes trigger suspense and the final suspension concludes', () => { + // Three state changes and calls to loadQuery: A, B, C, each causing suspense + // When C unsuspends, A and B's queries are disposed. + + if (typeof React.useTransition === 'function') { + let resolve; + let resolved = false; + const resolvableSuspensePromise = new Promise( + _resolve => + (resolve = () => { + resolved = true; + _resolve(); + }), + ); + + const unresolvablePromise = new Promise(() => {}); + const unresolvablePromise2 = new Promise(() => {}); + + function concurrentRender() { + ReactTestRenderer.act(() => { + instance = ReactTestRenderer.create( + + + , + // $FlowFixMe - error revealed when flow-typing ReactTestRenderer + {unstable_isConcurrent: true}, + ); + }); + } + + let triggerStateChange: any; + const suspenseTransitionConfig = { + timeoutMs: 3000, + }; + function ConcurrentWrapper() { + const [promise, setPromise] = React.useState(null); + + const [startTransition] = React.useTransition(suspenseTransitionConfig); + triggerStateChange = (newPromise, newName) => + startTransition(() => { + queryLoaderCallback({}); + setPromise(newPromise); + }); + + return ( + + + + ); + } + + function InnerConcurrent({promise}) { + [, queryLoaderCallback] = useQueryLoader(generatedQuery); + if ( + promise == null || + (promise === resolvableSuspensePromise && resolved) + ) { + return null; + } + throw promise; + } + + concurrentRender(); + expect(instance.toJSON()).toEqual(null); + + const initialStateChange: any = triggerStateChange; + + ReactTestRenderer.act(() => { + initialStateChange(unresolvablePromise); + }); + expect(loadQuery).toHaveBeenCalledTimes(1); + expect(instance.toJSON()).toEqual('fallback'); + const firstDispose = dispose; + + ReactTestRenderer.act(() => { + initialStateChange(unresolvablePromise2); + }); + expect(loadQuery).toHaveBeenCalledTimes(2); + expect(instance.toJSON()).toEqual('fallback'); + const secondDispose = dispose; + + ReactTestRenderer.act(() => { + initialStateChange(resolvableSuspensePromise); + }); + expect(loadQuery).toHaveBeenCalledTimes(3); + expect(instance.toJSON()).toEqual('fallback'); + const thirdDispose = dispose; + + expect(firstDispose).not.toHaveBeenCalled(); + expect(secondDispose).not.toHaveBeenCalled(); + + ReactTestRenderer.act(() => { + resolve(); + jest.runAllImmediates(); + }); + expect(firstDispose).toHaveBeenCalledTimes(1); + expect(secondDispose).toHaveBeenCalledTimes(1); + expect(thirdDispose).not.toHaveBeenCalled(); + } +}); + +it('disposes query references associated with subsequent suspensions when multiple state changes trigger suspense and the initial suspension concludes', () => { + // Three state changes and calls to loadQuery: A, B, C, each causing suspense + // When A unsuspends, B and C's queries do not get disposed. + + if (typeof React.useTransition === 'function') { + let resolve; + let resolved = false; + const resolvableSuspensePromise = new Promise( + _resolve => + (resolve = () => { + resolved = true; + _resolve(); + }), + ); + + const unresolvablePromise = new Promise(() => {}); + const unresolvablePromise2 = new Promise(() => {}); + + function concurrentRender() { + ReactTestRenderer.act(() => { + instance = ReactTestRenderer.create( + , + // $FlowFixMe - error revealed when flow-typing ReactTestRenderer + {unstable_isConcurrent: true}, + ); + }); + } + + let triggerStateChange: any; + const suspenseTransitionConfig = { + timeoutMs: 3000, + }; + function ConcurrentWrapper() { + const [promise, setPromise] = React.useState(null); + + const [startTransition] = React.useTransition(suspenseTransitionConfig); + triggerStateChange = (newPromise, newName) => + startTransition(() => { + queryLoaderCallback({}); + setPromise(newPromise); + }); + + return ( + + + + + + ); + } + + let innerUnsuspendedCorrectly = false; + function InnerConcurrent({promise}) { + [, queryLoaderCallback] = useQueryLoader(generatedQuery); + if ( + promise == null || + (promise === resolvableSuspensePromise && resolved) + ) { + innerUnsuspendedCorrectly = true; + return null; + } + throw promise; + } + + concurrentRender(); + expect(instance.toJSON()).toEqual(null); + + const initialStateChange: any = triggerStateChange; + + ReactTestRenderer.act(() => { + initialStateChange(resolvableSuspensePromise); + }); + expect(loadQuery).toHaveBeenCalledTimes(1); + expect(instance.toJSON()).toEqual('fallback'); + const firstDispose = dispose; + + ReactTestRenderer.act(() => { + initialStateChange(unresolvablePromise); + }); + expect(loadQuery).toHaveBeenCalledTimes(2); + expect(instance.toJSON()).toEqual('fallback'); + const secondDispose = dispose; + + ReactTestRenderer.act(() => { + initialStateChange(unresolvablePromise2); + }); + const thirdDispose = dispose; + + ReactTestRenderer.act(() => { + resolve(); + jest.runAllImmediates(); + }); + expect(innerUnsuspendedCorrectly).toEqual(true); + expect(firstDispose).not.toHaveBeenCalled(); + expect(secondDispose).not.toHaveBeenCalled(); + expect(thirdDispose).not.toHaveBeenCalled(); + } +}); + +it('should dispose of prior queries if the callback is called multiple times in the same tick', () => { + render(); + let firstDispose; + ReactTestRenderer.act(() => { + queryLoaderCallback({}); + firstDispose = dispose; + queryLoaderCallback({}); + }); + expect(loadQuery).toHaveBeenCalledTimes(2); + expect(firstDispose).toHaveBeenCalledTimes(1); +}); + +it('should dispose of queries on unmount if the callback is called, the component suspends and then unmounts', () => { + let shouldSuspend; + let setShouldSuspend; + const suspensePromise = new Promise(() => {}); + function SuspendingComponent() { + [shouldSuspend, setShouldSuspend] = React.useState(false); + if (shouldSuspend) { + throw suspensePromise; + } + return null; + } + function Outer() { + return ( + + + + + + + ); + } + + const outerInstance = ReactTestRenderer.create(); + expect(renderCount).toEqual(1); + ReactTestRenderer.act(() => { + queryLoaderCallback({}); + }); + expect(renderCount).toEqual(2); + ReactTestRenderer.act(() => { + setShouldSuspend(true); + }); + expect(renderCount).toEqual(2); + expect(outerInstance.toJSON()).toEqual('fallback'); + expect(dispose).not.toHaveBeenCalled(); + outerInstance.unmount(); + expect(dispose).toHaveBeenCalledTimes(1); +}); + +it('disposes all queries if a the callback is called, the component suspends, another query is called and then the component unmounts', () => { + let shouldSuspend; + let setShouldSuspend; + const suspensePromise = new Promise(() => {}); + function SuspendingComponent() { + [shouldSuspend, setShouldSuspend] = React.useState(false); + if (shouldSuspend) { + throw suspensePromise; + } + return null; + } + function Outer() { + return ( + + + + + + + ); + } + + const outerInstance = ReactTestRenderer.create(); + expect(renderCount).toEqual(1); + ReactTestRenderer.act(() => { + queryLoaderCallback({}); + }); + expect(renderCount).toEqual(2); + const firstDispose = dispose; + ReactTestRenderer.act(() => { + setShouldSuspend(true); + }); + expect(renderCount).toEqual(2); + expect(firstDispose).not.toHaveBeenCalled(); + expect(outerInstance.toJSON()).toEqual('fallback'); + + ReactTestRenderer.act(() => { + queryLoaderCallback({}); + }); + const secondDispose = dispose; + expect(renderCount).toEqual(3); + expect(outerInstance.toJSON()).toEqual('fallback'); + expect(firstDispose).toHaveBeenCalledTimes(1); + expect(secondDispose).not.toHaveBeenCalled(); + outerInstance.unmount(); + expect(secondDispose).toHaveBeenCalledTimes(1); +}); + +it('disposes all queries if the component suspends, another query is loaded and then the component unmounts', () => { + let shouldSuspend; + let setShouldSuspend; + const suspensePromise = new Promise(() => {}); + function SuspendingComponent() { + [shouldSuspend, setShouldSuspend] = React.useState(false); + if (shouldSuspend) { + throw suspensePromise; + } + return null; + } + function Outer() { + return ( + + + + + + + ); + } + + const outerInstance = ReactTestRenderer.create(); + expect(renderCount).toEqual(1); + ReactTestRenderer.act(() => { + setShouldSuspend(true); + }); + expect(renderCount).toEqual(1); + expect(outerInstance.toJSON()).toEqual('fallback'); + ReactTestRenderer.act(() => { + queryLoaderCallback({}); + }); + + expect(renderCount).toEqual(2); + expect(outerInstance.toJSON()).toEqual('fallback'); + expect(dispose).not.toHaveBeenCalled(); + outerInstance.unmount(); + expect(dispose).toHaveBeenCalledTimes(1); +}); + +it('disposes the query on unmount if the component unmounts and then the callback is called before rendering', () => { + // Case 1: unmount, then loadQuery + render(); + expect(renderCount).toEqual(1); + ReactTestRenderer.act(() => { + instance.unmount(); + queryLoaderCallback({}); + expect(dispose).not.toHaveBeenCalled(); + }); + expect(dispose).toHaveBeenCalledTimes(1); + expect(renderCount).toEqual(1); // renderCount === 1 ensures that an extra commit hasn't occurred +}); + +it('disposes the query on unmount if the callback is called and the component unmounts before rendering', () => { + // Case 2: loadQuery, then unmount + render(); + expect(renderCount).toEqual(1); + ReactTestRenderer.act(() => { + queryLoaderCallback({}); + expect(dispose).not.toHaveBeenCalled(); + instance.unmount(); + }); + expect(dispose).toHaveBeenCalledTimes(1); + expect(renderCount).toEqual(1); // renderCount === 1 ensures that an extra commit hasn't occurred +}); + +it('does not call loadQuery if the callback is called after the component unmounts', () => { + render(); + instance.unmount(); + queryLoaderCallback({}); + expect(loadQuery).not.toHaveBeenCalled(); +}); diff --git a/packages/relay-experimental/index.js b/packages/relay-experimental/index.js index 6f5f9cb62a95a..bf80b5244c993 100644 --- a/packages/relay-experimental/index.js +++ b/packages/relay-experimental/index.js @@ -23,11 +23,13 @@ const fetchQuery = require('./fetchQuery'); const loadEntryPoint = require('./loadEntryPoint'); const prepareEntryPoint_DEPRECATED = require('./prepareEntryPoint_DEPRECATED'); const useBlockingPaginationFragment = require('./useBlockingPaginationFragment'); +const useEntryPointLoader = require('./useEntryPointLoader'); const useFragment = require('./useFragment'); const useLazyLoadQuery = require('./useLazyLoadQuery'); const useMutation = require('./useMutation'); const usePaginationFragment = require('./usePaginationFragment'); const usePreloadedQuery = require('./usePreloadedQuery'); +const useQueryLoader = require('./useQueryLoader'); const useRefetchableFragment = require('./useRefetchableFragment'); const useRelayEnvironment = require('./useRelayEnvironment'); const useSubscribeToInvalidationState = require('./useSubscribeToInvalidationState'); @@ -63,6 +65,8 @@ module.exports = { useBlockingPaginationFragment: useBlockingPaginationFragment, useFragment: useFragment, useLazyLoadQuery: useLazyLoadQuery, + useEntryPointLoader: useEntryPointLoader, + useQueryLoader: useQueryLoader, useMutation: useMutation, usePaginationFragment: usePaginationFragment, usePreloadedQuery: usePreloadedQuery, diff --git a/packages/relay-experimental/useEntryPointLoader.js b/packages/relay-experimental/useEntryPointLoader.js new file mode 100644 index 0000000000000..c05148a447b46 --- /dev/null +++ b/packages/relay-experimental/useEntryPointLoader.js @@ -0,0 +1,196 @@ +/** + * 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 oncall+relay + * @flow strict-local + * @format + */ + +// flowlint ambiguous-object-type:error + +'use strict'; + +const loadEntryPoint = require('./loadEntryPoint'); + +const {useTrackLoadQueryInRender} = require('./loadQuery'); +const {useCallback, useEffect, useRef, useState} = require('react'); + +import type { + EntryPoint, + EntryPointComponent, + EnvironmentProviderOptions, + IEnvironmentProvider, + PreloadedEntryPoint, +} from './EntryPointTypes.flow'; + +type UseLoadEntryPointHookType< + TEntryPointParams: {...}, + TPreloadedQueries: {...}, + TPreloadedEntryPoints: {...}, + TRuntimeProps: {...}, + TExtraProps, + TEntryPointComponent: EntryPointComponent< + TPreloadedQueries, + TPreloadedEntryPoints, + TRuntimeProps, + TExtraProps, + >, +> = [ + ?PreloadedEntryPoint, + (params: TEntryPointParams) => void, + () => void, +]; + +// NullEntryPointReference needs to implement referential equality, +// so that multiple NullEntryPointReferences can be in the same set +// (corresponding to multiple calls to disposeEntryPoint). +type NullEntryPointReference = {| + kind: 'NullEntryPointReference', +|}; +const initialNullEntryPointReferenceState = {kind: 'NullEntryPointReference'}; + +function useLoadEntryPoint< + TEntryPointParams: {...}, + TPreloadedQueries: {...}, + TPreloadedEntryPoints: {...}, + TRuntimeProps: {...}, + TExtraProps, + TEntryPointComponent: EntryPointComponent< + TPreloadedQueries, + TPreloadedEntryPoints, + TRuntimeProps, + TExtraProps, + >, + TEntryPoint: EntryPoint, +>( + environmentProvider: IEnvironmentProvider, + entryPoint: TEntryPoint, +): UseLoadEntryPointHookType< + TEntryPointParams, + TPreloadedQueries, + TPreloadedEntryPoints, + TRuntimeProps, + TExtraProps, + TEntryPointComponent, +> { + /** + * We want to always call `entryPointReference.dispose()` for every call to + * `setEntryPointReference(loadEntryPoint(...))` so that no leaks of data in Relay + * stores will occur. + * + * However, a call to `setState(newState)` is not always followed by a commit where + * this value is reflected in the state. Thus, we cannot reliably clean up each ref + * with `useEffect(() => () => entryPointReference.dispose(), [entryPointReference])`. + * + * Instead, we keep track of each call to `loadEntryPoint` in a ref. + * Relying on the fact that if a state change commits, no state changes that were + * initiated prior to the currently committing state change will ever subsequently + * commit, we can safely dispose of all preloaded entry point references + * associated with state changes initiated prior to the currently committing state + * change. + * + * Finally, when the hook unmounts, we also dispose of all remaining uncommitted + * entry point references. + */ + + useTrackLoadQueryInRender(); + + const isUnmounted = useRef(false); + const undisposedEntryPointReferencesRef = useRef< + Set | NullEntryPointReference>, + >(new Set([initialNullEntryPointReferenceState])); + + const [entryPointReference, setEntryPointReference] = useState< + PreloadedEntryPoint | NullEntryPointReference, + >(initialNullEntryPointReferenceState); + + const disposeEntryPoint = useCallback(() => { + if (!isUnmounted.current) { + const nullEntryPointReference = { + kind: 'NullEntryPointReference', + }; + undisposedEntryPointReferencesRef.current.add(nullEntryPointReference); + setEntryPointReference(nullEntryPointReference); + } + }, [setEntryPointReference]); + + useEffect( + function disposePriorEntryPointReferences() { + // We are relying on the fact that sets iterate in insertion order, and we + // can remove items from a set as we iterate over it (i.e. no iterator + // invalidation issues.) Thus, it is safe to loop through + // undisposedEntryPointReferences until we find entryPointReference, and + // remove and dispose all previous references. + // + // We are guaranteed to find entryPointReference in the set, because if a + // state change results in a commit, no state changes initiated prior to that + // one will be committed, and we are disposing and removing references + // associated with commits that were initiated prior to the currently + // committing state change. (A useEffect callback is called during the commit + // phase.) + const undisposedEntryPointReferences = + undisposedEntryPointReferencesRef.current; + + if (!isUnmounted.current) { + for (const undisposedEntryPointReference of undisposedEntryPointReferences) { + if (undisposedEntryPointReference === entryPointReference) { + break; + } + + undisposedEntryPointReferences.delete(undisposedEntryPointReference); + if ( + undisposedEntryPointReference.kind !== 'NullEntryPointReference' + ) { + const dispose = undisposedEntryPointReference.dispose; + dispose && dispose(); + } + } + } + }, + [entryPointReference], + ); + + useEffect(() => { + return function disposeAllRemainingEntryPointReferences() { + isUnmounted.current = true; + // undisposedEntryPointReferences.current is never reassigned + // eslint-disable-next-line react-hooks/exhaustive-deps + for (const unhandledStateChange of undisposedEntryPointReferencesRef.current) { + if (unhandledStateChange.kind !== 'NullEntryPointReference') { + const dispose = unhandledStateChange.dispose; + dispose && dispose(); + } + } + }; + }, []); + + const entryPointLoaderCallback = useCallback( + (params: TEntryPointParams) => { + if (!isUnmounted.current) { + const updatedEntryPointReference = loadEntryPoint( + environmentProvider, + entryPoint, + params, + ); + undisposedEntryPointReferencesRef.current.add( + updatedEntryPointReference, + ); + setEntryPointReference(updatedEntryPointReference); + } + }, + [environmentProvider, entryPoint, setEntryPointReference], + ); + + return [ + entryPointReference.kind === 'NullEntryPointReference' + ? null + : entryPointReference, + entryPointLoaderCallback, + disposeEntryPoint, + ]; +} + +module.exports = useLoadEntryPoint; diff --git a/packages/relay-experimental/useQueryLoader.js b/packages/relay-experimental/useQueryLoader.js new file mode 100644 index 0000000000000..c91ddc1598bb4 --- /dev/null +++ b/packages/relay-experimental/useQueryLoader.js @@ -0,0 +1,163 @@ +/** + * 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 oncall+relay + * @flow strict-local + * @format + */ + +// flowlint ambiguous-object-type:error + +'use strict'; + +const useRelayEnvironment = require('./useRelayEnvironment'); + +const {loadQuery, useTrackLoadQueryInRender} = require('./loadQuery'); +const {useCallback, useEffect, useRef, useState} = require('react'); + +import type { + PreloadableConcreteRequest, + LoadQueryOptions, + PreloadedQuery, +} from './EntryPointTypes.flow'; +import type {GraphQLTaggedNode, OperationType} from 'relay-runtime'; + +type UseLoadQueryHookType = [ + ?PreloadedQuery, + ( + variables: $ElementType, + options?: LoadQueryOptions, + ) => void, + () => void, +]; + +// NullQueryReference needs to implement referential equality, +// so that multiple NullQueryReferences can be in the same set +// (corresponding to multiple calls to disposeQuery). +type NullQueryReference = {| + kind: 'NullQueryReference', +|}; +const initialNullQueryReferenceState = {kind: 'NullQueryReference'}; + +function useLoadQuery( + preloadableRequest: GraphQLTaggedNode | PreloadableConcreteRequest, +): UseLoadQueryHookType { + /** + * We want to always call `queryReference.dispose()` for every call to + * `setQueryReference(loadQuery(...))` so that no leaks of data in Relay stores + * will occur. + * + * However, a call to `setState(newState)` is not always followed by a commit where + * this value is reflected in the state. Thus, we cannot reliably clean up each + * ref with `useEffect(() => () => queryReference.dispose(), [queryReference])`. + * + * Instead, we keep track of each call to `loadQuery` in a ref. + * Relying on the fact that if a state change commits, no state changes that were + * initiated prior to the currently committing state change will ever subsequently + * commit, we can safely dispose of all preloaded query references + * associated with state changes initiated prior to the currently committing state + * change. + * + * Finally, when the hook unmounts, we also dispose of all remaining uncommitted + * query references. + */ + + const environment = useRelayEnvironment(); + useTrackLoadQueryInRender(); + + const isUnmounted = useRef(false); + const undisposedQueryReferencesRef = useRef( + new Set([initialNullQueryReferenceState]), + ); + + const [queryReference, setQueryReference] = useState< + PreloadedQuery | NullQueryReference, + >(initialNullQueryReferenceState); + + const disposeQuery = useCallback(() => { + if (!isUnmounted.current) { + const nullQueryReference = { + kind: 'NullQueryReference', + }; + undisposedQueryReferencesRef.current.add(nullQueryReference); + setQueryReference(nullQueryReference); + } + }, [setQueryReference]); + + useEffect( + function ensureQueryReferenceDisposal() { + // We are relying on the fact that sets iterate in insertion order, and we + // can remove items from a set as we iterate over it (i.e. no iterator + // invalidation issues.) Thus, it is safe to loop through + // undisposedQueryReferences until we find queryReference, and + // remove and dispose all previous references. + // + // We are guaranteed to find queryReference in the set, because if a + // state change results in a commit, no state changes initiated prior to that + // one will be committed, and we are disposing and removing references + // associated with commits that were initiated prior to the currently + // committing state change. (A useEffect callback is called during the commit + // phase.) + const undisposedQueryReferences = undisposedQueryReferencesRef.current; + + if (!isUnmounted.current) { + for (const undisposedQueryReference of undisposedQueryReferences) { + if (undisposedQueryReference === queryReference) { + break; + } + + undisposedQueryReferences.delete(undisposedQueryReference); + if (undisposedQueryReference.kind !== 'NullQueryReference') { + const dispose = undisposedQueryReference.dispose; + dispose && dispose(); + } + } + } + }, + [queryReference], + ); + + useEffect(() => { + return function disposeAllRemainingQueryReferences() { + isUnmounted.current = true; + // undisposedQueryReferences.current is never reassigned + // eslint-disable-next-line react-hooks/exhaustive-deps + for (const unhandledStateChange of undisposedQueryReferencesRef.current) { + if (unhandledStateChange.kind !== 'NullQueryReference') { + const dispose = unhandledStateChange.dispose; + dispose && dispose(); + } + } + }; + }, []); + + const queryLoaderCallback = useCallback( + ( + variables: $ElementType, + options?: LoadQueryOptions, + ) => { + if (!isUnmounted.current) { + const updatedQueryReference = loadQuery( + environment, + preloadableRequest, + variables, + options, + ); + undisposedQueryReferencesRef.current.add(updatedQueryReference); + setQueryReference(updatedQueryReference); + } + }, + [environment, preloadableRequest, setQueryReference], + ); + + return [ + queryReference.kind === 'NullQueryReference' ? null : queryReference, + queryLoaderCallback, + disposeQuery, + ]; +} + +module.exports = useLoadQuery;