react-relay
Version:
A framework for building GraphQL-driven React applications.
254 lines (232 loc) • 9.36 kB
Flow
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
* @oncall relay
*/
;
import type {
PreloadableConcreteRequest,
PreloadedQuery,
} from './EntryPointTypes.flow';
import type {
NullQueryReference,
UseQueryLoaderHookReturnType,
UseQueryLoaderLoadQueryOptions,
} from './useQueryLoader';
import type {OperationType, Query, Variables} from 'relay-runtime';
const {loadQuery} = require('./loadQuery');
const useIsMountedRef = require('./useIsMountedRef');
const useRelayEnvironment = require('./useRelayEnvironment');
const {
useCallback,
useEffect,
// $FlowFixMe[prop-missing] Remove once callers are migrated to official API
useInsertionEffect,
useRef,
useState,
} = require('react');
const {getRequest} = require('relay-runtime');
const initialNullQueryReferenceState = {kind: 'NullQueryReference'};
function requestIsLiveQuery<
TVariables: Variables,
TData,
TRawResponse: ?{...} = void,
TQuery: OperationType = {
response: TData,
variables: TVariables,
rawResponse?: $NonMaybeType<TRawResponse>,
},
>(
preloadableRequest:
| Query<TVariables, TData, TRawResponse>
| PreloadableConcreteRequest<TQuery>,
): boolean {
if (preloadableRequest.kind === 'PreloadableConcreteRequest') {
return preloadableRequest.params.metadata.live !== undefined;
}
const request = getRequest(preloadableRequest);
return request.params.metadata.live !== undefined;
}
const CLEANUP_TIMEOUT = 1000 * 60 * 5; // 5 minutes;
hook useQueryLoader_EXPERIMENTAL<
TVariables: Variables,
TData,
TRawResponse: ?{...} = void,
>(
preloadableRequest: Query<TVariables, TData, TRawResponse>,
initialQueryReference?: ?PreloadedQuery<{
response: TData,
variables: TVariables,
rawResponse?: $NonMaybeType<TRawResponse>,
}>,
): UseQueryLoaderHookReturnType<TVariables, TData> {
type QueryType = {
response: TData,
variables: TVariables,
rawResponse?: $NonMaybeType<TRawResponse>,
};
/**
* 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 initialQueryReferenceInternal =
initialQueryReference ?? initialNullQueryReferenceState;
const environment = useRelayEnvironment();
const isMountedRef = useIsMountedRef();
const undisposedQueryReferencesRef = useRef<Set<
PreloadedQuery<QueryType> | NullQueryReference,
> | null>(null);
if (undisposedQueryReferencesRef.current == null) {
undisposedQueryReferencesRef.current = new Set([
initialQueryReferenceInternal,
]);
}
const [queryReference, setQueryReference] = useState<
PreloadedQuery<QueryType> | NullQueryReference,
>(() => initialQueryReferenceInternal);
const [previousInitialQueryReference, setPreviousInitialQueryReference] =
useState<PreloadedQuery<QueryType> | NullQueryReference>(
() => initialQueryReferenceInternal,
);
if (initialQueryReferenceInternal !== previousInitialQueryReference) {
// Rendering the query reference makes it "managed" by this hook, so
// we start keeping track of it so we can dispose it when it is no longer
// necessary here
// TODO(T78446637): Handle disposal of managed query references in
// components that were never mounted after rendering
// $FlowFixMe[react-rule-unsafe-ref]
undisposedQueryReferencesRef.current?.add(initialQueryReferenceInternal);
setPreviousInitialQueryReference(initialQueryReferenceInternal);
setQueryReference(initialQueryReferenceInternal);
}
const disposeQuery = useCallback(() => {
if (isMountedRef.current) {
undisposedQueryReferencesRef.current?.add(initialNullQueryReferenceState);
setQueryReference(initialNullQueryReferenceState);
}
}, [isMountedRef]);
const queryLoaderCallback = useCallback(
(variables: TVariables, options?: ?UseQueryLoaderLoadQueryOptions) => {
if (!isMountedRef.current) {
return;
}
const mergedOptions: ?UseQueryLoaderLoadQueryOptions =
options != null && options.hasOwnProperty('__environment')
? {
fetchPolicy: options.fetchPolicy,
networkCacheConfig: options.networkCacheConfig,
__nameForWarning: options.__nameForWarning,
}
: options;
const updatedQueryReference = loadQuery(
options?.__environment ?? environment,
preloadableRequest,
variables,
(mergedOptions: $FlowFixMe),
);
undisposedQueryReferencesRef.current?.add(updatedQueryReference);
setQueryReference(updatedQueryReference);
},
[environment, preloadableRequest, setQueryReference, isMountedRef],
);
const disposeAllRemainingQueryReferences = useCallback(
function disposeAllRemainingQueryReferences(
preloadableRequest: Query<TVariables, TData, TRawResponse>,
currentQueryReference:
| PreloadedQuery<QueryType>
| NullQueryReference
| null,
) {
const undisposedQueryReferences =
undisposedQueryReferencesRef.current ?? new Set();
// undisposedQueryReferences.current is never reassigned
// eslint-disable-next-line react-hooks/exhaustive-deps
for (const undisposedQueryReference of undisposedQueryReferences) {
if (undisposedQueryReference === currentQueryReference) {
continue;
}
if (undisposedQueryReference.kind !== 'NullQueryReference') {
if (requestIsLiveQuery(preloadableRequest)) {
undisposedQueryReference.dispose &&
undisposedQueryReference.dispose();
} else {
undisposedQueryReference.releaseQuery &&
undisposedQueryReference.releaseQuery();
}
}
}
},
[],
);
const cleanupTimerRef = useRef<?TimeoutID>(null);
useEffect(() => {
// When a new queryReference is committed, we iterate over all
// query references in undisposedQueryReferences and dispose all of
// the refs that aren't the currently committed one. This ensures
// that we don't leave any dangling query references for the
// case that loadQuery is called multiple times before commit; when
// this happens, multiple state updates will be scheduled, but only one
// will commit, meaning that we need to keep track of and dispose any
// query references that don't end up committing.
// - 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 update results in a commit, no state updates initiated prior to that
// one will be committed, and we are disposing and removing references
// associated with updates that were scheduled prior to the currently
// committing state change. (A useEffect callback is called during the commit
// phase.)
disposeAllRemainingQueryReferences(preloadableRequest, queryReference);
if (cleanupTimerRef.current != null) {
clearTimeout(cleanupTimerRef.current);
cleanupTimerRef.current = null;
}
return () => {
cleanupTimerRef.current = setTimeout(() => {
disposeAllRemainingQueryReferences(preloadableRequest, null);
}, CLEANUP_TIMEOUT);
};
}, [preloadableRequest, queryReference]);
// $FlowFixMe[not-a-function]
useInsertionEffect(() => {
// We use an insertion effect to ensure that we cleanup the final query
// reference when the component truly unmounts. Note that a regular
// useEffect may detach/reattach due to <Activity> multiple times, and
// we don't want to free queries that may be used when the component reveals
// again.
return () => {
cleanupTimerRef.current && clearTimeout(cleanupTimerRef.current);
cleanupTimerRef.current = null;
disposeAllRemainingQueryReferences(preloadableRequest, null);
};
}, [preloadableRequest]);
return [
queryReference.kind === 'NullQueryReference' ? null : queryReference,
queryLoaderCallback,
disposeQuery,
];
}
module.exports = useQueryLoader_EXPERIMENTAL;