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;