UNPKG

react-relay

Version:

A framework for building GraphQL-driven React applications.

253 lines (233 loc) • 9.38 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 { EntryPoint, EntryPointComponent, EnvironmentProviderOptions, IEnvironmentProvider, PreloadedEntryPoint, PreloadedQuery, } from './EntryPointTypes.flow'; const loadEntryPoint = require('./loadEntryPoint'); const useIsMountedRef = require('./useIsMountedRef'); const {useCallback, useEffect, useRef, useState} = require('react'); type UseEntryPointLoaderHookReturnType< TEntryPointParams: {...}, TPreloadedQueries: {...}, TPreloadedEntryPoints: {...}, TRuntimeProps: {...}, TExtraProps, TEntryPointComponent: EntryPointComponent< TPreloadedQueries, TPreloadedEntryPoints, TRuntimeProps, TExtraProps, >, > = [ ?PreloadedEntryPoint<TEntryPointComponent>, (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'}; hook useLoadEntryPoint< TEntryPointParams: {...}, // $FlowExpectedError[unclear-type] Need any to make it supertype of all PreloadedQuery TPreloadedQueries: {+[string]: PreloadedQuery<any>}, TPreloadedEntryPoints: {...}, TRuntimeProps: {...}, TExtraProps, TEntryPointComponent: EntryPointComponent< TPreloadedQueries, TPreloadedEntryPoints, TRuntimeProps, TExtraProps, >, TEntryPoint: EntryPoint<TEntryPointParams, TEntryPointComponent>, >( environmentProvider: IEnvironmentProvider<EnvironmentProviderOptions>, entryPoint: TEntryPoint, options?: ?{ // TODO(T83890478): Remove once Offscreen API lands in xplat // and we can use it in tests TEST_ONLY__initialEntryPointData?: ?{ entryPointReference: ?PreloadedEntryPoint<TEntryPointComponent>, entryPointParams: ?TEntryPointParams, }, }, ): UseEntryPointLoaderHookReturnType< 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. */ const initialEntryPointReferenceInternal = options?.TEST_ONLY__initialEntryPointData?.entryPointReference ?? initialNullEntryPointReferenceState; const initialEntryPointParamsInternal = options?.TEST_ONLY__initialEntryPointData?.entryPointParams ?? null; const isMountedRef = useIsMountedRef(); const undisposedEntryPointReferencesRef = useRef< Set<PreloadedEntryPoint<TEntryPointComponent> | NullEntryPointReference>, >(new Set([initialEntryPointReferenceInternal])); const [entryPointReference, setEntryPointReference] = useState< PreloadedEntryPoint<TEntryPointComponent> | NullEntryPointReference, >(initialEntryPointReferenceInternal); const [entryPointParams, setEntryPointParams] = useState<TEntryPointParams | null>(initialEntryPointParamsInternal); const disposeEntryPoint = useCallback(() => { if (isMountedRef.current) { const nullEntryPointReference = { kind: 'NullEntryPointReference', }; undisposedEntryPointReferencesRef.current.add(nullEntryPointReference); setEntryPointReference(nullEntryPointReference); } }, [setEntryPointReference, isMountedRef]); const entryPointLoaderCallback = useCallback( (params: TEntryPointParams) => { if (isMountedRef.current) { const updatedEntryPointReference = loadEntryPoint< TEntryPointParams, TPreloadedQueries, TPreloadedEntryPoints, TRuntimeProps, TExtraProps, TEntryPointComponent, TEntryPoint, >(environmentProvider, entryPoint, params); undisposedEntryPointReferencesRef.current.add( updatedEntryPointReference, ); setEntryPointReference(updatedEntryPointReference); setEntryPointParams(params); } }, [environmentProvider, entryPoint, setEntryPointReference, isMountedRef], ); const maybeHiddenOrFastRefresh = useRef(false); useEffect(() => { return () => { // Attempt to detect if the component was // hidden (by Offscreen API), or fast refresh occured; // Only in these situations would the effect cleanup // for "unmounting" run multiple times, so if // we are ever able to read this ref with a value // of true, it means that one of these cases // has happened. maybeHiddenOrFastRefresh.current = true; }; }, []); useEffect(() => { if (maybeHiddenOrFastRefresh.current === true) { // This block only runs if the component has previously "unmounted" // due to it being hidden by the Offscreen API, or during fast refresh. // At this point, the current entryPointReference will have been disposed // by the previous cleanup, so instead of attempting to // do our regular commit setup, which would incorrectly leave our // current entryPointReference disposed, we need to load the entryPoint again // and force a re-render by calling entryPointLoaderCallback again, // so that the entryPointReference's queries are correctly re-retained, and // potentially refetched if necessary. maybeHiddenOrFastRefresh.current = false; if ( entryPointReference.kind !== 'NullEntryPointReference' && entryPointParams != null ) { entryPointLoaderCallback(entryPointParams); } return; } // When a new entryPointReference is committed, we iterate over all // entrypoint refs in undisposedEntryPointReferences and dispose all of // the refs that aren't the currently committed one. This ensures // that we don't leave any dangling entrypoint references for the // case that loadEntryPoint 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 // 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 (isMountedRef.current) { for (const undisposedEntryPointReference of undisposedEntryPointReferences) { if (undisposedEntryPointReference === entryPointReference) { break; } undisposedEntryPointReferences.delete(undisposedEntryPointReference); if (undisposedEntryPointReference.kind !== 'NullEntryPointReference') { undisposedEntryPointReference.dispose(); } } } }, [ entryPointReference, entryPointParams, entryPointLoaderCallback, isMountedRef, ]); useEffect(() => { return function disposeAllRemainingEntryPointReferences() { // undisposedEntryPointReferences.current is never reassigned // eslint-disable-next-line react-hooks/exhaustive-deps for (const unhandledStateChange of undisposedEntryPointReferencesRef.current) { if (unhandledStateChange.kind !== 'NullEntryPointReference') { unhandledStateChange.dispose(); } } }; }, []); return [ entryPointReference.kind === 'NullEntryPointReference' ? null : entryPointReference, entryPointLoaderCallback, disposeEntryPoint, ]; } module.exports = useLoadEntryPoint;