UNPKG

react-relay

Version:

A framework for building GraphQL-driven React applications.

609 lines (569 loc) • 19.7 kB
/** * 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 */ 'use strict'; import type {LoaderFn} from './useQueryLoader'; import type { ConcreteRequest, Disposable, FetchPolicy, IEnvironment, OperationDescriptor, OperationType, ReaderFragment, RefetchableIdentifierInfo, RenderPolicy, Variables, VariablesOf, } from 'relay-runtime'; const ProfilerContext = require('./ProfilerContext'); const {getQueryResourceForEnvironment} = require('./QueryResource'); const readFragmentInternal = require('./readFragmentInternal'); const useFragmentInternal = require('./useFragmentInternal'); const useIsMountedRef = require('./useIsMountedRef'); const useQueryLoader = require('./useQueryLoader'); const useRelayEnvironment = require('./useRelayEnvironment'); const invariant = require('invariant'); const {useCallback, useContext, useReducer} = require('react'); const { __internal: {fetchQuery}, createOperationDescriptor, getFragmentIdentifier, getRefetchMetadata, getSelector, getValueAtPath, } = require('relay-runtime'); const warning = require('warning'); export type RefetchFn< TQuery: OperationType, TOptions = Options, > = RefetchFnExact<TQuery, TOptions>; // NOTE: RefetchFnDynamic returns a refetch function that: // - Expects the /exact/ set of query variables if the provided key type is // /nullable/. // - Or, expects /a subset/ of the query variables if the provided key type is // /non-null/. export type RefetchFnDynamic< TQuery: OperationType, TKey: ?{+$data?: mixed, ...}, TOptions = Options, > = [TKey] extends [{+$data?: mixed, ...}] ? RefetchFnInexact<TQuery, TOptions> : RefetchFnExact<TQuery, TOptions>; export type ReturnType< TQuery: OperationType, TKey: ?{+$data?: mixed, ...}, TOptions = Options, > = { fragmentData: mixed, fragmentRef: mixed, refetch: RefetchFnDynamic<TQuery, TKey, TOptions>, }; export type Options = { fetchPolicy?: FetchPolicy, onComplete?: (Error | null) => void, UNSTABLE_renderPolicy?: RenderPolicy, }; type InternalOptions = { ...Options, __environment?: IEnvironment, }; type RefetchFnBase<TVars, TOptions> = ( vars: TVars, options?: TOptions, ) => Disposable; type RefetchFnExact<TQuery: OperationType, TOptions = Options> = RefetchFnBase< VariablesOf<TQuery>, TOptions, >; type RefetchFnInexact< TQuery: OperationType, TOptions = Options, > = RefetchFnBase<$ReadOnly<Partial<VariablesOf<TQuery>>>, TOptions>; type Action = | { type: 'reset', environment: IEnvironment, fragmentIdentifier: string, } | { type: 'refetch', refetchQuery: OperationDescriptor, fetchPolicy?: FetchPolicy, renderPolicy?: RenderPolicy, onComplete?: (Error | null) => void, refetchEnvironment: ?IEnvironment, }; type RefetchState = { fetchPolicy: FetchPolicy | void, mirroredEnvironment: IEnvironment, mirroredFragmentIdentifier: string, onComplete: ((Error | null) => void) | void, refetchEnvironment?: ?IEnvironment, refetchQuery: OperationDescriptor | null, renderPolicy: RenderPolicy | void, }; type DebugIDandTypename = { id: string, typename: string, ... }; function reducer(state: RefetchState, action: Action): RefetchState { switch (action.type) { case 'refetch': { return { ...state, fetchPolicy: action.fetchPolicy, mirroredEnvironment: action.refetchEnvironment ?? state.mirroredEnvironment, onComplete: action.onComplete, refetchEnvironment: action.refetchEnvironment, refetchQuery: action.refetchQuery, renderPolicy: action.renderPolicy, }; } case 'reset': { return { fetchPolicy: undefined, mirroredEnvironment: action.environment, mirroredFragmentIdentifier: action.fragmentIdentifier, onComplete: undefined, refetchQuery: null, renderPolicy: undefined, }; } default: { (action.type: empty); throw new Error('useRefetchableFragmentNode: Unexpected action type'); } } } hook useRefetchableFragmentNode< TQuery: OperationType, TKey: ?{+$data?: mixed, ...}, >( fragmentNode: ReaderFragment, parentFragmentRef: mixed, componentDisplayName: string, ): ReturnType<TQuery, TKey, InternalOptions> { const parentEnvironment = useRelayEnvironment(); const {refetchableRequest, fragmentRefPathInResponse, identifierInfo} = getRefetchMetadata(fragmentNode, componentDisplayName); const fragmentIdentifier = getFragmentIdentifier( fragmentNode, parentFragmentRef, ); const [refetchState, dispatch] = useReducer(reducer, { fetchPolicy: undefined, mirroredEnvironment: parentEnvironment, mirroredFragmentIdentifier: fragmentIdentifier, onComplete: undefined, refetchEnvironment: null, refetchQuery: null, renderPolicy: undefined, }); const { fetchPolicy, mirroredEnvironment, mirroredFragmentIdentifier, onComplete, refetchEnvironment, refetchQuery, renderPolicy, } = refetchState; const environment = refetchEnvironment ?? parentEnvironment; const QueryResource = getQueryResourceForEnvironment(environment); const profilerContext = useContext(ProfilerContext); const shouldReset = environment !== mirroredEnvironment || fragmentIdentifier !== mirroredFragmentIdentifier; const [queryRef, loadQuery, disposeQuery] = useQueryLoader< TQuery['variables'], TQuery['response'], TQuery['rawResponse'], >((refetchableRequest: $FlowFixMe)); let fragmentRef = parentFragmentRef; if (shouldReset) { dispatch({ type: 'reset', environment, fragmentIdentifier, }); disposeQuery(); } else if (refetchQuery != null && queryRef != null) { // If refetch was called, we expect to have a refetchQuery and queryRef // in state, since both state updates to set the refetchQuery and the // queryRef occur simultaneously. // In this case, we need to read the refetched query data (potentially // suspending if it's in flight), and extract the new fragment ref // from the query in order read the current @refetchable fragment // with the updated fragment owner as the new refetchQuery. // Before observing the refetch, record the current ID and typename // so that, if we are refetching existing data on // a field that implements Node, after refetching we // can validate that the received data is consistent let debugPreviousIDAndTypename: ?DebugIDandTypename; if (__DEV__) { debugPreviousIDAndTypename = debugFunctions.getInitialIDAndType( refetchQuery.request.variables, fragmentRefPathInResponse, identifierInfo?.identifierQueryVariableName, environment, ); } const handleQueryCompleted = (maybeError: void | Error) => { onComplete && onComplete(maybeError ?? null); }; // The queryRef.source obtained from useQueryLoader will be // an observable we can consume /if/ a network request was // started. Otherwise, given that QueryResource.prepare // always expects an observable we fall back to a new network // observable. Note however that if loadQuery did not make a network // request, we don't expect to make one here, unless the state of // the cache has changed between the call to refetch and this // render. const fetchObservable = queryRef.source != null ? queryRef.source : fetchQuery(environment, refetchQuery); // Now wwe can we read the refetch query here using the // queryRef provided from useQueryLoader. Note that the // network request is started during the call to refetch, // but if the refetch query is still in flight, we will suspend // at this point: const queryResult = profilerContext.wrapPrepareQueryResource(() => { return QueryResource.prepare( refetchQuery, fetchObservable, fetchPolicy, renderPolicy, { error: handleQueryCompleted, complete: () => { // Validate that the type of the object we got back matches the type // of the object already in the store if (__DEV__) { debugFunctions.checkSameTypeAfterRefetch( debugPreviousIDAndTypename, environment, fragmentNode, componentDisplayName, ); } handleQueryCompleted(); }, }, queryRef.fetchKey, profilerContext, ); }); const queryData = readFragmentInternal( environment, queryResult.fragmentNode, queryResult.fragmentRef, componentDisplayName, ).data; invariant( queryData != null, 'Relay: Expected to be able to read refetch query response. ' + "If you're seeing this, this is likely a bug in Relay.", ); // After reading/fetching the refetch query, we extract from the // refetch query response the new fragment ref we need to use to read // the fragment. The new fragment ref will point to the refetch query // as its fragment owner. const refetchedFragmentRef = getValueAtPath( queryData, fragmentRefPathInResponse, ); fragmentRef = refetchedFragmentRef; if (__DEV__) { // Validate that the id of the object we got back matches the id // we queried for in the variables. // We do this during render instead of onComplete to make sure we are // only validating the most recent refetch. debugFunctions.checkSameIDAfterRefetch( debugPreviousIDAndTypename, fragmentRef, fragmentNode, componentDisplayName, ); } } // We read and subscribe to the fragment using useFragmentNode. // If refetch was called, we read the fragment using the new computed // fragment ref from the refetch query response; otherwise, we use the // fragment ref passed by the caller as normal. const fragmentData = useFragmentInternal( fragmentNode, fragmentRef, componentDisplayName, ); const refetch = useRefetchFunction<TQuery>( componentDisplayName, dispatch, disposeQuery, fragmentData, fragmentIdentifier, fragmentNode, fragmentRefPathInResponse, identifierInfo, loadQuery, parentFragmentRef, refetchableRequest, ); return { fragmentData, fragmentRef, // $FlowFixMe[incompatible-return] RefetchFn not compatible with RefetchFnDynamic refetch, }; } hook useRefetchFunction<TQuery: OperationType>( componentDisplayName: string, dispatch: ( | { environment: IEnvironment, fragmentIdentifier: string, type: 'reset', } | { fetchPolicy?: FetchPolicy, onComplete?: (Error | null) => void, refetchEnvironment: ?IEnvironment, refetchQuery: OperationDescriptor, renderPolicy?: RenderPolicy, type: 'refetch', }, ) => void, disposeQuery: () => void, fragmentData: mixed, fragmentIdentifier: string, fragmentNode: ReaderFragment, fragmentRefPathInResponse: $ReadOnlyArray<string | number>, identifierInfo: ?RefetchableIdentifierInfo, loadQuery: LoaderFn<TQuery>, parentFragmentRef: mixed, refetchableRequest: ConcreteRequest, ): RefetchFn<TQuery, InternalOptions> { const isMountedRef = useIsMountedRef(); const identifierValue = identifierInfo?.identifierField != null && fragmentData != null && typeof fragmentData === 'object' ? fragmentData[identifierInfo.identifierField] : null; return useCallback( ( providedRefetchVariables: VariablesOf<TQuery>, options: void | InternalOptions, ) => { // Bail out and warn if we're trying to refetch after the component // has unmounted if (isMountedRef.current !== true) { warning( false, 'Relay: Unexpected call to `refetch` on unmounted component for fragment ' + '`%s` in `%s`. It looks like some instances of your component are ' + 'still trying to fetch data but they already unmounted. ' + 'Please make sure you clear all timers, intervals, ' + 'async calls, etc that may trigger a fetch.', fragmentNode.name, componentDisplayName, ); return {dispose: () => {}}; } if (parentFragmentRef == null) { warning( false, 'Relay: Unexpected call to `refetch` while using a null fragment ref ' + 'for fragment `%s` in `%s`. When calling `refetch`, we expect ' + "initial fragment data to be non-null. Please make sure you're " + 'passing a valid fragment ref to `%s` before calling ' + '`refetch`, or make sure you pass all required variables to `refetch`.', fragmentNode.name, componentDisplayName, componentDisplayName, ); } const refetchEnvironment = options?.__environment; const fetchPolicy = options?.fetchPolicy; const renderPolicy = options?.UNSTABLE_renderPolicy; const onComplete = options?.onComplete; const fragmentSelector = getSelector(fragmentNode, parentFragmentRef); let parentVariables: Variables; let fragmentVariables: Variables; if (fragmentSelector == null) { parentVariables = {}; fragmentVariables = {}; } else if (fragmentSelector.kind === 'PluralReaderSelector') { parentVariables = fragmentSelector.selectors[0]?.owner.variables ?? {}; fragmentVariables = fragmentSelector.selectors[0]?.variables ?? {}; } else { parentVariables = fragmentSelector.owner.variables; fragmentVariables = fragmentSelector.variables; } // A user of `useRefetchableFragment()` may pass a subset of // all variables required by the fragment when calling `refetch()`. // We fill in any variables not passed by the call to `refetch()` with the // variables from the original parent fragment owner. const refetchVariables: VariablesOf<TQuery> = { ...(parentVariables: $FlowFixMe), ...fragmentVariables, ...providedRefetchVariables, }; // If the query needs an identifier value ('id' or similar) and one // was not explicitly provided, read it from the fragment data. if ( identifierInfo != null && !providedRefetchVariables.hasOwnProperty( identifierInfo.identifierQueryVariableName, ) ) { // @refetchable fragments are guaranteed to have an `id` selection // if the type is Node, implements Node, or is @fetchable. Double-check // that there actually is a value at runtime. if (typeof identifierValue !== 'string') { warning( false, 'Relay: Expected result to have a string ' + '`%s` in order to refetch, got `%s`.', identifierInfo.identifierField, identifierValue, ); } (refetchVariables: $FlowFixMe)[ identifierInfo.identifierQueryVariableName ] = identifierValue; } const refetchQuery = createOperationDescriptor( refetchableRequest, refetchVariables, { force: true, }, ); // We call loadQuery which will start a network request if necessary // and update the queryRef from useQueryLoader. // Note the following: // - loadQuery will dispose of any previously refetched queries. // - We use the variables extracted off the OperationDescriptor // so that they have been filtered out to include only the // variables actually declared in the query. loadQuery(refetchQuery.request.variables, { fetchPolicy, __environment: refetchEnvironment, __nameForWarning: 'refetch', }); dispatch({ type: 'refetch', fetchPolicy, onComplete, refetchEnvironment, refetchQuery, renderPolicy, }); return {dispose: disposeQuery}; }, // NOTE: We disable react-hooks-deps warning because: // - We know fragmentRefPathInResponse is static, so it can be omitted from // deps // - We know fragmentNode is static, so it can be omitted from deps. // - fragmentNode and parentFragmentRef are also captured by including // fragmentIdentifier // eslint-disable-next-line react-hooks/exhaustive-deps [fragmentIdentifier, dispatch, disposeQuery, identifierValue, loadQuery], ); } let debugFunctions; if (__DEV__) { debugFunctions = { getInitialIDAndType( memoRefetchVariables: ?Variables, fragmentRefPathInResponse: $ReadOnlyArray<string | number>, identifierQueryVariableName: ?string, environment: IEnvironment, ): ?DebugIDandTypename { const {Record} = require('relay-runtime'); const id = memoRefetchVariables?.[identifierQueryVariableName ?? 'id']; if ( fragmentRefPathInResponse.length !== 1 || fragmentRefPathInResponse[0] !== 'node' || id == null ) { return null; } const recordSource = environment.getStore().getSource(); const record = recordSource.get(id); const typename = record == null ? null : Record.getType(record); if (typename == null) { return null; } return { id, typename, }; }, checkSameTypeAfterRefetch( previousIDAndType: ?DebugIDandTypename, environment: IEnvironment, fragmentNode: ReaderFragment, componentDisplayName: string, ): void { const {Record} = require('relay-runtime'); if (!previousIDAndType) { return; } const recordSource = environment.getStore().getSource(); const record = recordSource.get(previousIDAndType.id); const typename = record && Record.getType(record); if (typename !== previousIDAndType.typename) { warning( false, 'Relay: Call to `refetch` returned data with a different ' + '__typename: was `%s`, now `%s`, on `%s` in `%s`. ' + 'Please make sure the server correctly implements' + 'unique id requirement.', previousIDAndType.typename, typename, fragmentNode.name, componentDisplayName, ); } }, checkSameIDAfterRefetch( previousIDAndTypename: ?DebugIDandTypename, refetchedFragmentRef: mixed, fragmentNode: ReaderFragment, componentDisplayName: string, ): void { if (previousIDAndTypename == null) { return; } const {ID_KEY} = require('relay-runtime'); // $FlowExpectedError[incompatible-use] const resultID = refetchedFragmentRef[ID_KEY]; if (resultID != null && resultID !== previousIDAndTypename.id) { warning( false, 'Relay: Call to `refetch` returned a different id, expected ' + '`%s`, got `%s`, on `%s` in `%s`. ' + 'Please make sure the server correctly implements ' + 'unique id requirement.', resultID, previousIDAndTypename.id, fragmentNode.name, componentDisplayName, ); } }, }; } module.exports = useRefetchableFragmentNode;