UNPKG

relay-runtime

Version:

A core runtime for building GraphQL-driven applications.

482 lines (449 loc) • 16 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 * @format * @oncall relay */ 'use strict'; import type {ConcreteRequest} from '../util/RelayConcreteNode'; import type {Disposable, Variables} from '../util/RelayRuntimeTypes'; import type { FieldErrors, FragmentMap, FragmentSpecResolver, FragmentSpecResults, IEnvironment, PluralReaderSelector, RelayContext, SelectorData, SingularReaderSelector, Snapshot, } from './RelayStoreTypes'; const getPendingOperationsForFragment = require('../util/getPendingOperationsForFragment'); const { handlePotentialSnapshotErrors, } = require('../util/handlePotentialSnapshotErrors'); const isScalarAndEqual = require('../util/isScalarAndEqual'); const recycleNodesInto = require('../util/recycleNodesInto'); const RelayFeatureFlags = require('../util/RelayFeatureFlags'); const {createRequestDescriptor} = require('./RelayModernOperationDescriptor'); const { areEqualSelectors, createReaderSelector, getSelectorsFromObject, } = require('./RelayModernSelector'); const areEqual = require('areEqual'); const invariant = require('invariant'); const warning = require('warning'); type Props = {[key: string]: mixed, ...}; type Resolvers = { [key: string]: ?(SelectorListResolver | SelectorResolver), ... }; /** * A utility for resolving and subscribing to the results of a fragment spec * (key -> fragment mapping) given some "props" that determine the root ID * and variables to use when reading each fragment. When props are changed via * `setProps()`, the resolver will update its results and subscriptions * accordingly. Internally, the resolver: * - Converts the fragment map & props map into a map of `Selector`s. * - Removes any resolvers for any props that became null. * - Creates resolvers for any props that became non-null. * - Updates resolvers with the latest props. * * This utility is implemented as an imperative, stateful API for performance * reasons: reusing previous resolvers, callback functions, and subscriptions * all helps to reduce object allocation and thereby decrease GC time. * * The `resolve()` function is also lazy and memoized: changes in the store mark * the resolver as stale and notify the caller, and the actual results are * recomputed the first time `resolve()` is called. */ class RelayModernFragmentSpecResolver implements FragmentSpecResolver { _callback: ?() => void; _context: RelayContext; _rootIsQueryRenderer: boolean; _data: Object; _fragments: FragmentMap; _props: Props; _resolvers: Resolvers; _stale: boolean; constructor( context: RelayContext, fragments: FragmentMap, props: Props, callback?: () => void, rootIsQueryRenderer: boolean, ) { this._callback = callback; this._context = context; this._data = {}; this._fragments = fragments; this._props = {}; this._resolvers = {}; this._stale = false; this._rootIsQueryRenderer = rootIsQueryRenderer; this.setProps(props); } dispose(): void { for (const key in this._resolvers) { if (this._resolvers.hasOwnProperty(key)) { disposeCallback(this._resolvers[key]); } } } resolve(): FragmentSpecResults { if (this._stale) { // Avoid mapping the object multiple times, which could occur if data for // multiple keys changes in the same event loop. const prevData = this._data; let nextData: any; for (const key in this._resolvers) { if (this._resolvers.hasOwnProperty(key)) { const resolver = this._resolvers[key]; const prevItem = prevData[key]; if (resolver) { const nextItem = resolver.resolve(); if (nextData || nextItem !== prevItem) { nextData = nextData || {...prevData}; nextData[key] = nextItem; } } else { const prop = this._props[key]; const nextItem = prop !== undefined ? prop : null; if (nextData || !isScalarAndEqual(nextItem, prevItem)) { nextData = nextData || {...prevData}; nextData[key] = nextItem; } } } } this._data = nextData || prevData; this._stale = false; } return this._data; } setCallback(props: Props, callback: () => void): void { this._callback = callback; if (RelayFeatureFlags.ENABLE_CONTAINERS_SUBSCRIBE_ON_COMMIT === true) { this.setProps(props); } } setProps(props: Props): void { this._props = {}; const ownedSelectors = getSelectorsFromObject(this._fragments, props); for (const key in ownedSelectors) { if (ownedSelectors.hasOwnProperty(key)) { const ownedSelector = ownedSelectors[key]; let resolver = this._resolvers[key]; if (ownedSelector == null) { if (resolver != null) { resolver.dispose(); } resolver = null; } else if (ownedSelector.kind === 'PluralReaderSelector') { if (resolver == null) { resolver = new SelectorListResolver( this._context.environment, this._rootIsQueryRenderer, ownedSelector, this._callback != null, this._onChange, ); } else { invariant( resolver instanceof SelectorListResolver, 'RelayModernFragmentSpecResolver: Expected prop `%s` to always be an array.', key, ); resolver.setSelector(ownedSelector); } } else { if (resolver == null) { resolver = new SelectorResolver( this._context.environment, this._rootIsQueryRenderer, ownedSelector, this._callback != null, this._onChange, ); } else { invariant( resolver instanceof SelectorResolver, 'RelayModernFragmentSpecResolver: Expected prop `%s` to always be an object.', key, ); resolver.setSelector(ownedSelector); } } this._props[key] = props[key]; this._resolvers[key] = resolver; } } this._stale = true; } setVariables(variables: Variables, request: ConcreteRequest): void { for (const key in this._resolvers) { if (this._resolvers.hasOwnProperty(key)) { const resolver = this._resolvers[key]; if (resolver) { resolver.setVariables(variables, request); } } } this._stale = true; } _onChange = (): void => { this._stale = true; if (typeof this._callback === 'function') { this._callback(); } }; } /** * A resolver for a single Selector. */ class SelectorResolver { _callback: () => void; _data: ?SelectorData; _environment: IEnvironment; _isMissingData: boolean; _fieldErrors: ?FieldErrors; _rootIsQueryRenderer: boolean; _selector: SingularReaderSelector; _subscription: ?Disposable; constructor( environment: IEnvironment, rootIsQueryRenderer: boolean, selector: SingularReaderSelector, subscribeOnConstruction: boolean, callback: () => void, ) { const snapshot = environment.lookup(selector); this._callback = callback; this._data = snapshot.data; this._isMissingData = snapshot.isMissingData; this._fieldErrors = snapshot.fieldErrors; this._environment = environment; this._rootIsQueryRenderer = rootIsQueryRenderer; this._selector = selector; if (RelayFeatureFlags.ENABLE_CONTAINERS_SUBSCRIBE_ON_COMMIT === true) { if (subscribeOnConstruction) { this._subscription = environment.subscribe(snapshot, this._onChange); } } else { this._subscription = environment.subscribe(snapshot, this._onChange); } } dispose(): void { if (this._subscription) { this._subscription.dispose(); this._subscription = null; } } resolve(): ?Object { if (this._isMissingData === true) { // NOTE: This branch exists to handle the case in which: // - A RelayModern container is rendered as a descendant of a Relay Hook // root using a "partial" renderPolicy (this means that eargerly // reading any cached data that is available instead of blocking // at the root until the whole query is fetched). // - A parent Relay Hook didnt' suspend earlier on data being fetched, // either because the fragment data for the parent was available, or // the parent fragment didn't have any data dependencies. // Even though our Flow types reflect the possiblity of null data, there // might still be cases where it's not handled at runtime becuase the // Flow types are being ignored, or simply not being used (for example, // the case reported here: https://fburl.com/srnbucf8, was due to // misuse of Flow types here: https://fburl.com/g3m0mqqh). // Additionally, even though the null data might be handled without a // runtime error, we might not suspend when we intended to if a parent // Relay Hook (e.g. that is using @defer) decided not to suspend becuase // it's immediate data was already available (even if it was deferred), // or it didn't actually need any data (was just spreading other fragments). // This should eventually go away with something like @optional, where we only // suspend at specific boundaries depending on whether the boundary // can be fulfilled or not. const pendingOperationsResult = getPendingOperationsForFragment( this._environment, this._selector.node, this._selector.owner, ); const promise: void | Promise<void> = pendingOperationsResult?.promise; if (promise != null) { if (this._rootIsQueryRenderer) { warning( false, 'Relay: Relay Container for fragment `%s` has missing data and ' + 'would suspend. When using features such as @defer or @module, ' + 'use `useFragment` instead of a Relay Container.', this._selector.node.name, ); } else { const pendingOperations = pendingOperationsResult?.pendingOperations ?? []; warning( false, 'Relay: Relay Container for fragment `%s` suspended. When using ' + 'features such as @defer or @module, use `useFragment` instead ' + 'of a Relay Container.', this._selector.node.name, ); this._environment.__log({ name: 'suspense.fragment', data: this._data, fragment: this._selector.node, isRelayHooks: false, isMissingData: this._isMissingData, isPromiseCached: false, pendingOperations, }); throw promise; } } } handlePotentialSnapshotErrors(this._environment, this._fieldErrors); return this._data; } setSelector(selector: SingularReaderSelector): void { if ( this._subscription != null && areEqualSelectors(selector, this._selector) ) { return; } this.dispose(); const snapshot = this._environment.lookup(selector); this._data = recycleNodesInto(this._data, snapshot.data); this._isMissingData = snapshot.isMissingData; this._fieldErrors = snapshot.fieldErrors; this._selector = selector; this._subscription = this._environment.subscribe(snapshot, this._onChange); } setVariables(variables: Variables, request: ConcreteRequest): void { if (areEqual(variables, this._selector.variables)) { // If we're not actually setting new variables, we don't actually want // to create a new fragment owner, since areEqualSelectors relies on // owner identity. // In fact, we don't even need to try to attempt to set a new selector. // When fragment ownership is not enabled, setSelector will also bail // out since the selector doesn't really change, so we're doing it here // earlier. return; } // NOTE: We manually create the request descriptor here instead of // calling createOperationDescriptor() because we want to set a // descriptor with *unaltered* variables as the fragment owner. // This is a hack that allows us to preserve existing (broken) // behavior of RelayModern containers while using fragment ownership // to propagate variables instead of Context. // For more details, see the summary of D13999308 const requestDescriptor = createRequestDescriptor(request, variables); const selector = createReaderSelector( this._selector.node, this._selector.dataID, variables, requestDescriptor, ); this.setSelector(selector); } _onChange = (snapshot: Snapshot): void => { this._data = snapshot.data; this._isMissingData = snapshot.isMissingData; this._fieldErrors = snapshot.fieldErrors; this._callback(); }; } /** * A resolver for an array of Selectors. */ class SelectorListResolver { _callback: () => void; _data: Array<?SelectorData>; _environment: IEnvironment; _resolvers: Array<SelectorResolver>; _rootIsQueryRenderer: boolean; _stale: boolean; _subscribeOnConstruction: boolean; constructor( environment: IEnvironment, rootIsQueryRenderer: boolean, selector: PluralReaderSelector, subscribeOnConstruction: boolean, callback: () => void, ) { this._callback = callback; this._data = []; this._environment = environment; this._resolvers = []; this._stale = true; this._rootIsQueryRenderer = rootIsQueryRenderer; this._subscribeOnConstruction = subscribeOnConstruction; this.setSelector(selector); } dispose(): void { this._resolvers.forEach(disposeCallback); } resolve(): Array<?Object> { if (this._stale) { // Avoid mapping the array multiple times, which could occur if data for // multiple indices changes in the same event loop. const prevData = this._data; let nextData: Array<?SelectorData>; for (let ii = 0; ii < this._resolvers.length; ii++) { const prevItem = prevData[ii]; const nextItem = this._resolvers[ii].resolve(); if (nextData || nextItem !== prevItem) { nextData = nextData || prevData.slice(0, ii); nextData.push(nextItem); } } if (!nextData && this._resolvers.length !== prevData.length) { nextData = prevData.slice(0, this._resolvers.length); } this._data = nextData || prevData; this._stale = false; } return this._data; } setSelector(selector: PluralReaderSelector): void { const {selectors} = selector; while (this._resolvers.length > selectors.length) { const resolver = this._resolvers.pop(); // $FlowFixMe[incompatible-use] resolver.dispose(); } for (let ii = 0; ii < selectors.length; ii++) { if (ii < this._resolvers.length) { this._resolvers[ii].setSelector(selectors[ii]); } else { this._resolvers[ii] = new SelectorResolver( this._environment, this._rootIsQueryRenderer, selectors[ii], this._subscribeOnConstruction, this._onChange, ); } } this._stale = true; } setVariables(variables: Variables, request: ConcreteRequest): void { this._resolvers.forEach(resolver => resolver.setVariables(variables, request), ); this._stale = true; } _onChange = (data: ?Object): void => { this._stale = true; this._callback(); }; } function disposeCallback(disposable: ?Disposable): void { disposable && disposable.dispose(); } module.exports = RelayModernFragmentSpecResolver;