UNPKG

react-relay

Version:

A framework for building GraphQL-driven React applications.

919 lines (848 loc) • 29.1 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 {Cache} from '../LRUCache'; import type {QueryResource, QueryResult} from '../QueryResource'; import type { ConcreteRequest, DataID, Disposable, IEnvironment, ReaderFragment, RequestDescriptor, Snapshot, } from 'relay-runtime'; const LRUCache = require('../LRUCache'); const {getQueryResourceForEnvironment} = require('../QueryResource'); const SuspenseResource = require('../SuspenseResource'); const invariant = require('invariant'); const { __internal: {fetchQuery, getPromiseForActiveRequest}, RelayFeatureFlags, createOperationDescriptor, getFragmentIdentifier, getPendingOperationsForFragment, getSelector, getVariablesFromFragment, handlePotentialSnapshotErrors, isPromise, recycleNodesInto, } = require('relay-runtime'); export type FragmentResource = FragmentResourceImpl; type FragmentResourceCache = Cache< | { kind: 'pending', pendingOperations: $ReadOnlyArray<RequestDescriptor>, promise: Promise<mixed>, result: FragmentResult, } | {kind: 'done', result: FragmentResult} | { kind: 'missing', result: FragmentResult, snapshot: SingularOrPluralSnapshot, }, >; const WEAKMAP_SUPPORTED = typeof WeakMap === 'function'; interface IMap<K, V> { get(key: K): V | void; set(key: K, value: V): IMap<K, V>; } type SingularOrPluralSnapshot = Snapshot | $ReadOnlyArray<Snapshot>; opaque type FragmentResult: {data: mixed, ...} = { cacheKey: string, data: mixed, isMissingData: boolean, snapshot: SingularOrPluralSnapshot | null, storeEpoch: number, }; // TODO: Fix to not rely on LRU. If the number of active fragments exceeds this // capacity, readSpec() will fail to find cached entries and break object // identity even if data hasn't changed. const CACHE_CAPACITY = 1000000; // this is frozen so that users don't accidentally push data into the array const CONSTANT_READONLY_EMPTY_ARRAY: Array<$FlowFixMe> = Object.freeze([]); function isMissingData(snapshot: SingularOrPluralSnapshot): boolean { if (Array.isArray(snapshot)) { return snapshot.some(s => s.isMissingData); } return snapshot.isMissingData; } function hasMissingClientEdges(snapshot: SingularOrPluralSnapshot): boolean { if (Array.isArray(snapshot)) { return snapshot.some(s => (s.missingClientEdges?.length ?? 0) > 0); } return (snapshot.missingClientEdges?.length ?? 0) > 0; } function missingLiveResolverFields( snapshot: SingularOrPluralSnapshot, ): ?$ReadOnlyArray<DataID> { if (Array.isArray(snapshot)) { return snapshot .map(s => s.missingLiveResolverFields) .filter(Boolean) .flat(); } return snapshot.missingLiveResolverFields; } function singularOrPluralForEach( snapshot: SingularOrPluralSnapshot, f: Snapshot => void, ): void { if (Array.isArray(snapshot)) { snapshot.forEach(f); } else { f(snapshot); } } function getFragmentResult( cacheKey: string, snapshot: SingularOrPluralSnapshot, storeEpoch: number, ): FragmentResult { if (Array.isArray(snapshot)) { return { cacheKey, snapshot, data: snapshot.map(s => s.data), isMissingData: isMissingData(snapshot), storeEpoch, }; } return { cacheKey, snapshot, data: snapshot.data, isMissingData: isMissingData(snapshot), storeEpoch, }; } /** * The purpose of this cache is to allow information to be passed from an * initial read which suspends through to the commit that follows a subsequent * successful read. Specifically, the QueryResource result for the data fetch * is passed through so that that query can be retained on commit. */ class ClientEdgeQueryResultsCache { _cache: Map<string, [Array<QueryResult>, SuspenseResource]> = new Map(); _retainCounts: Map<string, number> = new Map(); _environment: IEnvironment; constructor(environment: IEnvironment) { this._environment = environment; } get(fragmentIdentifier: string): void | Array<QueryResult> { return this._cache.get(fragmentIdentifier)?.[0] ?? undefined; } recordQueryResults( fragmentIdentifier: string, value: Array<QueryResult>, // may be mutated after being passed here ): void { const existing = this._cache.get(fragmentIdentifier); if (!existing) { const suspenseResource = new SuspenseResource(() => this._retain(fragmentIdentifier), ); this._cache.set(fragmentIdentifier, [value, suspenseResource]); suspenseResource.temporaryRetain(this._environment); } else { const [existingResults, suspenseResource] = existing; value.forEach(queryResult => { existingResults.push(queryResult); }); suspenseResource.temporaryRetain(this._environment); } } _retain(id: string): {dispose: () => void} { const retainCount = (this._retainCounts.get(id) ?? 0) + 1; this._retainCounts.set(id, retainCount); return { dispose: () => { const newRetainCount = (this._retainCounts.get(id) ?? 0) - 1; if (newRetainCount > 0) { this._retainCounts.set(id, newRetainCount); } else { this._retainCounts.delete(id); this._cache.delete(id); } }, }; } } class FragmentResourceImpl { _environment: IEnvironment; _cache: FragmentResourceCache; _clientEdgeQueryResultsCache: void | ClientEdgeQueryResultsCache; constructor(environment: IEnvironment) { this._environment = environment; this._cache = LRUCache.create(CACHE_CAPACITY); this._clientEdgeQueryResultsCache = new ClientEdgeQueryResultsCache( environment, ); } /** * This function should be called during a Component's render function, * to read the data for a fragment, or suspend if the fragment is being * fetched. */ read( fragmentNode: ReaderFragment, fragmentRef: mixed, componentDisplayName: string, fragmentKey?: string, ): FragmentResult { return this.readWithIdentifier( fragmentNode, fragmentRef, getFragmentIdentifier(fragmentNode, fragmentRef), componentDisplayName, fragmentKey, ); } /** * Like `read`, but with a pre-computed fragmentIdentifier that should be * equal to `getFragmentIdentifier(fragmentNode, fragmentRef)` from the * arguments. */ readWithIdentifier( fragmentNode: ReaderFragment, fragmentRef: mixed, fragmentIdentifier: string, componentDisplayName: string, fragmentKey?: ?string, ): FragmentResult { const environment = this._environment; // If fragmentRef is null or undefined, pass it directly through. // This is a convenience when consuming fragments via a HOC API, when the // prop corresponding to the fragment ref might be passed as null. if (fragmentRef == null) { return { cacheKey: fragmentIdentifier, data: null, isMissingData: false, snapshot: null, storeEpoch: 0, }; } const storeEpoch = environment.getStore().getEpoch(); // If fragmentRef is plural, ensure that it is an array. // If it's empty, return the empty array directly before doing any more work. if (fragmentNode?.metadata?.plural === true) { invariant( Array.isArray(fragmentRef), 'Relay: Expected fragment pointer%s for fragment `%s` to be ' + 'an array, instead got `%s`. Remove `@relay(plural: true)` ' + 'from fragment `%s` to allow the prop to be an object.', fragmentKey != null ? ` for key \`${fragmentKey}\`` : '', fragmentNode.name, typeof fragmentRef, fragmentNode.name, ); if (fragmentRef.length === 0) { return { cacheKey: fragmentIdentifier, data: CONSTANT_READONLY_EMPTY_ARRAY, isMissingData: false, snapshot: CONSTANT_READONLY_EMPTY_ARRAY, storeEpoch, }; } } // Now we actually attempt to read the fragment: // 1. Check if there's a cached value for this fragment const cachedValue = this._cache.get(fragmentIdentifier); if (cachedValue != null) { if (cachedValue.kind === 'pending' && isPromise(cachedValue.promise)) { environment.__log({ name: 'suspense.fragment', data: cachedValue.result.data, fragment: fragmentNode, isRelayHooks: true, isMissingData: cachedValue.result.isMissingData, isPromiseCached: true, pendingOperations: cachedValue.pendingOperations, }); throw cachedValue.promise; } if ( cachedValue.kind === 'done' && cachedValue.result.snapshot && !missingLiveResolverFields(cachedValue.result.snapshot)?.length ) { this._throwOrLogErrorsInSnapshot( // $FlowFixMe[incompatible-call] cachedValue.result.snapshot, ); // This cache gets populated directly whenever the store notifies us of // an update. That mechanism does not check for missing data, or // in-flight requests. if (cachedValue.result.isMissingData) { environment.__log({ name: 'fragmentresource.missing_data', data: cachedValue.result.data, fragment: fragmentNode, isRelayHooks: true, cached: true, }); } return cachedValue.result; } } // 2. If not, try reading the fragment from the Relay store. // If the snapshot has data, return it and save it in cache const fragmentSelector = getSelector(fragmentNode, fragmentRef); invariant( fragmentSelector != null, 'Relay: Expected to receive an object where `...%s` was spread, ' + 'but the fragment reference was not found`. This is most ' + 'likely the result of:\n' + "- Forgetting to spread `%s` in `%s`'s parent's fragment.\n" + '- Conditionally fetching `%s` but unconditionally passing %s prop ' + 'to `%s`. If the parent fragment only fetches the fragment conditionally ' + '- with e.g. `@include`, `@skip`, or inside a `... on SomeType { }` ' + 'spread - then the fragment reference will not exist. ' + 'In this case, pass `null` if the conditions for evaluating the ' + 'fragment are not met (e.g. if the `@include(if)` value is false.)', fragmentNode.name, fragmentNode.name, componentDisplayName, fragmentNode.name, fragmentKey == null ? 'a fragment reference' : `the \`${fragmentKey}\``, componentDisplayName, ); let fragmentResult = null; let snapshot = null; // Fall through to existing logic if it's 'missing' state so it would check and save promise into cache. if ( RelayFeatureFlags.ENABLE_RELAY_OPERATION_TRACKER_SUSPENSE && cachedValue != null && cachedValue.kind === 'missing' ) { fragmentResult = cachedValue.result; snapshot = cachedValue.snapshot; } else { snapshot = fragmentSelector.kind === 'PluralReaderSelector' ? fragmentSelector.selectors.map(s => environment.lookup(s)) : environment.lookup(fragmentSelector); fragmentResult = getFragmentResult( fragmentIdentifier, snapshot, storeEpoch, ); } if (!fragmentResult.isMissingData) { this._throwOrLogErrorsInSnapshot(snapshot); this._cache.set(fragmentIdentifier, { kind: 'done', result: fragmentResult, }); return fragmentResult; } // 3. If we don't have data in the store, there's two cases where we should // suspend to await the data: First if any client edges were traversed where // the destination record was missing data; in that case we initiate a query // here to fetch the missing data. Second, there may already be a request // in flight for the fragment's parent query, or for another operation that // may affect the parent's query data, such as a mutation or subscription. // For any of these cases we can get a promise, which we will cache and // suspend on. // First, initiate a query for any client edges that were missing data: let clientEdgeRequests: ?Array<RequestDescriptor> = null; if ( fragmentNode.metadata?.hasClientEdges === true && hasMissingClientEdges(snapshot) ) { clientEdgeRequests = []; const queryResource = getQueryResourceForEnvironment(this._environment); const queryResults = []; singularOrPluralForEach(snapshot, snap => { snap.missingClientEdges?.forEach( ({request, clientEdgeDestinationID}) => { const {queryResult, requestDescriptor} = this._performClientEdgeQuery( queryResource, fragmentNode, fragmentRef, request, clientEdgeDestinationID, ); queryResults.push(queryResult); clientEdgeRequests?.push(requestDescriptor); }, ); }); // Store the query so that it can be retained when our own fragment is // subscribed to. This merges with any existing query results: invariant( this._clientEdgeQueryResultsCache != null, 'Client edge query result cache should exist when ENABLE_CLIENT_EDGES is on.', ); this._clientEdgeQueryResultsCache.recordQueryResults( fragmentIdentifier, queryResults, ); } let clientEdgePromises: Array<Promise<void>> = []; if (clientEdgeRequests) { clientEdgePromises = clientEdgeRequests .map(request => getPromiseForActiveRequest(this._environment, request)) .filter(Boolean); } // Finally look for operations in flight for our parent query: const fragmentOwner = fragmentSelector.kind === 'PluralReaderSelector' ? fragmentSelector.selectors[0].owner : fragmentSelector.owner; const parentQueryPromiseResult = this._getAndSavePromiseForFragmentRequestInFlight( fragmentIdentifier, fragmentNode, fragmentOwner, fragmentResult, ); const parentQueryPromiseResultPromise = parentQueryPromiseResult?.promise; // for refinement const missingResolverFieldPromises = missingLiveResolverFields(snapshot)?.map(liveStateID => { const store = environment.getStore(); // $FlowFixMe[prop-missing] This is expected to be a RelayModernStore return store.getLiveResolverPromise(liveStateID); }) ?? []; if ( clientEdgePromises.length || missingResolverFieldPromises.length || isPromise(parentQueryPromiseResultPromise) ) { environment.__log({ name: 'suspense.fragment', data: fragmentResult.data, fragment: fragmentNode, isRelayHooks: true, isPromiseCached: false, isMissingData: fragmentResult.isMissingData, // TODO! Attach information here about missing live resolver fields pendingOperations: [ ...(parentQueryPromiseResult?.pendingOperations ?? []), ...(clientEdgeRequests ?? []), ], }); let promises = []; if (clientEdgePromises.length > 0) { promises = promises.concat(clientEdgePromises); } if (missingResolverFieldPromises.length > 0) { promises = promises.concat(missingResolverFieldPromises); } if (promises.length > 0) { if (parentQueryPromiseResultPromise) { promises.push(parentQueryPromiseResultPromise); } throw Promise.all(promises); } // Note: we are re-throwing the `parentQueryPromiseResultPromise` here, // because some of our suspense-related code is relying on the instance equality // of thrown promises. See FragmentResource-test.js if (parentQueryPromiseResultPromise) { throw parentQueryPromiseResultPromise; } } // set it as done if has missing data and no pending operations if ( RelayFeatureFlags.ENABLE_RELAY_OPERATION_TRACKER_SUSPENSE && fragmentResult.isMissingData ) { this._cache.set(fragmentIdentifier, { kind: 'done', result: fragmentResult, }); } this._throwOrLogErrorsInSnapshot(snapshot); // At this point, there's nothing we can do. We don't have all the expected // data, but there's no indication the missing data will be fulfilled. So we // choose to return potentially non-typesafe data. The data returned here // might not match the generated types for this fragment/operation. environment.__log({ name: 'fragmentresource.missing_data', data: fragmentResult.data, fragment: fragmentNode, isRelayHooks: true, cached: false, }); return getFragmentResult(fragmentIdentifier, snapshot, storeEpoch); } _performClientEdgeQuery( queryResource: QueryResource, fragmentNode: ReaderFragment, fragmentRef: mixed, request: ConcreteRequest, clientEdgeDestinationID: DataID, ): {queryResult: QueryResult, requestDescriptor: RequestDescriptor} { const originalVariables = getVariablesFromFragment( fragmentNode, fragmentRef, ); const variables = { ...originalVariables, id: clientEdgeDestinationID, // TODO should be a reserved name }; const operation = createOperationDescriptor( request, variables, {}, // TODO cacheConfig should probably inherent from parent operation ); const fetchObservable = fetchQuery(this._environment, operation); const queryResult = queryResource.prepare( operation, fetchObservable, // TODO should inherent render policy etc. from parent operation ); return { requestDescriptor: operation.request, queryResult, }; } _throwOrLogErrorsInSnapshot(snapshot: SingularOrPluralSnapshot) { if (Array.isArray(snapshot)) { snapshot.forEach(s => { handlePotentialSnapshotErrors(this._environment, s.errorResponseFields); }); } else { handlePotentialSnapshotErrors( this._environment, snapshot.errorResponseFields, ); } } readSpec( fragmentNodes: {[string]: ReaderFragment, ...}, fragmentRefs: {[string]: mixed, ...}, componentDisplayName: string, ): {[string]: FragmentResult, ...} { const result: {[string]: FragmentResult} = {}; for (const key in fragmentNodes) { result[key] = this.read( fragmentNodes[key], fragmentRefs[key], componentDisplayName, key, ); } return result; } subscribe(fragmentResult: FragmentResult, callback: () => void): Disposable { const environment = this._environment; const {cacheKey} = fragmentResult; const renderedSnapshot = fragmentResult.snapshot; if (!renderedSnapshot) { return {dispose: () => {}}; } // 1. Check for any updates missed during render phase // TODO(T44066760): More efficiently detect if we missed an update const [didMissUpdates, currentSnapshot] = this.checkMissedUpdates(fragmentResult); // 2. If an update was missed, notify the component so it updates with // the latest data. if (didMissUpdates) { callback(); } // 3. Establish subscriptions on the snapshot(s) const disposables = []; if (Array.isArray(renderedSnapshot)) { invariant( Array.isArray(currentSnapshot), 'Relay: Expected snapshots to be plural. ' + "If you're seeing this, this is likely a bug in Relay.", ); currentSnapshot.forEach((snapshot, idx) => { disposables.push( environment.subscribe(snapshot, latestSnapshot => { const storeEpoch = environment.getStore().getEpoch(); this._updatePluralSnapshot( cacheKey, currentSnapshot, latestSnapshot, idx, storeEpoch, ); callback(); }), ); }); } else { invariant( currentSnapshot != null && !Array.isArray(currentSnapshot), 'Relay: Expected snapshot to be singular. ' + "If you're seeing this, this is likely a bug in Relay.", ); disposables.push( environment.subscribe(currentSnapshot, latestSnapshot => { const storeEpoch = environment.getStore().getEpoch(); const result = getFragmentResult( cacheKey, latestSnapshot, storeEpoch, ); if ( RelayFeatureFlags.ENABLE_RELAY_OPERATION_TRACKER_SUSPENSE && result.isMissingData ) { this._cache.set(cacheKey, { kind: 'missing', result: result, snapshot: latestSnapshot, }); } else { this._cache.set(cacheKey, { kind: 'done', result: getFragmentResult(cacheKey, latestSnapshot, storeEpoch), }); } callback(); }), ); } const clientEdgeQueryResults = this._clientEdgeQueryResultsCache?.get(cacheKey) ?? undefined; if (clientEdgeQueryResults?.length) { const queryResource = getQueryResourceForEnvironment(this._environment); clientEdgeQueryResults.forEach(queryResult => { disposables.push(queryResource.retain(queryResult)); }); } return { dispose: () => { disposables.forEach(s => s.dispose()); this._cache.delete(cacheKey); }, }; } subscribeSpec( fragmentResults: {[string]: FragmentResult, ...}, callback: () => void, ): Disposable { const disposables = Object.keys(fragmentResults).map(key => this.subscribe(fragmentResults[key], callback), ); return { dispose: () => { disposables.forEach(disposable => { disposable.dispose(); }); }, }; } checkMissedUpdates( fragmentResult: FragmentResult, ): [boolean /* were updates missed? */, SingularOrPluralSnapshot | null] { const environment = this._environment; const renderedSnapshot = fragmentResult.snapshot; if (!renderedSnapshot) { return [false, null]; } let storeEpoch = null; // Bail out if the store hasn't been written since last read storeEpoch = environment.getStore().getEpoch(); if (fragmentResult.storeEpoch === storeEpoch) { return [false, fragmentResult.snapshot]; } const {cacheKey} = fragmentResult; if (Array.isArray(renderedSnapshot)) { let didMissUpdates = false; const currentSnapshots = []; renderedSnapshot.forEach((snapshot, idx) => { let currentSnapshot: Snapshot = environment.lookup(snapshot.selector); const renderData = snapshot.data; const currentData = currentSnapshot.data; const updatedData = recycleNodesInto(renderData, currentData); if (updatedData !== renderData) { currentSnapshot = {...currentSnapshot, data: updatedData}; didMissUpdates = true; } currentSnapshots[idx] = currentSnapshot; }); // Only update the cache when the data is changed to avoid // returning different `data` instances if (didMissUpdates) { const result = getFragmentResult( cacheKey, currentSnapshots, storeEpoch, ); if ( RelayFeatureFlags.ENABLE_RELAY_OPERATION_TRACKER_SUSPENSE && result.isMissingData ) { this._cache.set(cacheKey, { kind: 'missing', result, snapshot: currentSnapshots, }); } else { this._cache.set(cacheKey, { kind: 'done', result, }); } } return [didMissUpdates, currentSnapshots]; } const currentSnapshot = environment.lookup(renderedSnapshot.selector); const renderData = renderedSnapshot.data; const currentData = currentSnapshot.data; const updatedData = recycleNodesInto(renderData, currentData); const updatedCurrentSnapshot: Snapshot = { data: updatedData, isMissingData: currentSnapshot.isMissingData, missingClientEdges: currentSnapshot.missingClientEdges, missingLiveResolverFields: currentSnapshot.missingLiveResolverFields, seenRecords: currentSnapshot.seenRecords, selector: currentSnapshot.selector, errorResponseFields: currentSnapshot.errorResponseFields, }; if (updatedData !== renderData) { const result = getFragmentResult( cacheKey, updatedCurrentSnapshot, storeEpoch, ); if ( RelayFeatureFlags.ENABLE_RELAY_OPERATION_TRACKER_SUSPENSE && result.isMissingData ) { this._cache.set(cacheKey, { kind: 'missing', result: result, snapshot: updatedCurrentSnapshot, }); } else { this._cache.set(cacheKey, { kind: 'done', result, }); } } return [updatedData !== renderData, updatedCurrentSnapshot]; } checkMissedUpdatesSpec(fragmentResults: { [string]: FragmentResult, ... }): boolean { return Object.keys(fragmentResults).some( key => this.checkMissedUpdates(fragmentResults[key])[0], ); } _getAndSavePromiseForFragmentRequestInFlight( cacheKey: string, fragmentNode: ReaderFragment, fragmentOwner: RequestDescriptor, fragmentResult: FragmentResult, ): { promise: Promise<void>, pendingOperations: $ReadOnlyArray<RequestDescriptor>, } | null { const pendingOperationsResult = getPendingOperationsForFragment( this._environment, fragmentNode, fragmentOwner, ); if (pendingOperationsResult == null) { return null; } // When the Promise for the request resolves, we need to make sure to // update the cache with the latest data available in the store before // resolving the Promise const networkPromise = pendingOperationsResult.promise; const pendingOperations = pendingOperationsResult.pendingOperations; const promise = networkPromise .then(() => { this._cache.delete(cacheKey); }) .catch<void>((error: Error) => { this._cache.delete(cacheKey); }); // $FlowExpectedError[prop-missing] Expando to annotate Promises. promise.displayName = networkPromise.displayName; this._cache.set(cacheKey, { kind: 'pending', pendingOperations, promise, result: fragmentResult, }); return {promise, pendingOperations}; } _updatePluralSnapshot( cacheKey: string, baseSnapshots: $ReadOnlyArray<Snapshot>, latestSnapshot: Snapshot, idx: number, storeEpoch: number, ): void { const currentFragmentResult = this._cache.get(cacheKey); if (isPromise(currentFragmentResult)) { reportInvalidCachedData(latestSnapshot.selector.node.name); return; } const currentSnapshot = currentFragmentResult?.result?.snapshot; if (currentSnapshot && !Array.isArray(currentSnapshot)) { reportInvalidCachedData(latestSnapshot.selector.node.name); return; } const nextSnapshots = currentSnapshot ? [...currentSnapshot] : [...baseSnapshots]; nextSnapshots[idx] = latestSnapshot; const result = getFragmentResult(cacheKey, nextSnapshots, storeEpoch); if ( RelayFeatureFlags.ENABLE_RELAY_OPERATION_TRACKER_SUSPENSE && result.isMissingData ) { this._cache.set(cacheKey, { kind: 'missing', result, snapshot: nextSnapshots, }); } else { this._cache.set(cacheKey, { kind: 'done', result, }); } } } function reportInvalidCachedData(nodeName: string): void { invariant( false, 'Relay: Expected to find cached data for plural fragment `%s` when ' + 'receiving a subscription. ' + "If you're seeing this, this is likely a bug in Relay.", nodeName, ); } function createFragmentResource(environment: IEnvironment): FragmentResource { return new FragmentResourceImpl(environment); } const dataResources: IMap<IEnvironment, FragmentResource> = WEAKMAP_SUPPORTED ? new WeakMap() : new Map(); function getFragmentResourceForEnvironment( environment: IEnvironment, ): FragmentResourceImpl { const cached = dataResources.get(environment); if (cached) { return cached; } const newDataResource = createFragmentResource(environment); dataResources.set(environment, newDataResource); return newDataResource; } module.exports = { createFragmentResource, getFragmentResourceForEnvironment, };