diff --git a/flow-typed/fbjs.js.flow b/flow-typed/fbjs.js.flow index 183dc592230c..eb2e235b01df 100644 --- a/flow-typed/fbjs.js.flow +++ b/flow-typed/fbjs.js.flow @@ -4,4 +4,22 @@ declare module 'mapObject' { declare module 'ErrorUtils' { declare module.exports: any; -} \ No newline at end of file +} + +declare module 'warning' { + declare module.exports: ( + condition: boolean, + format: string, + ...args: $ReadOnlyArray + ) => void; +} + +declare module 'fbjs/lib/ExecutionEnvironment' { + declare module.exports: { + canUseDOM: boolean, + }; +} + +declare module 'react-test-renderer' { + declare module.exports: any; +} diff --git a/packages/relay-experimental/FragmentResource.js b/packages/relay-experimental/FragmentResource.js new file mode 100644 index 000000000000..390da5702a39 --- /dev/null +++ b/packages/relay-experimental/FragmentResource.js @@ -0,0 +1,454 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 + * @emails oncall+relay + * @format + */ + +'use strict'; + +const LRUCache = require('./LRUCache'); + +const invariant = require('invariant'); +const mapObject = require('mapObject'); +const warning = require('warning'); + +const { + __internal: {getPromiseForRequestInFlight}, + getFragmentIdentifier, + getFragmentOwner, + getSelector, + isPromise, + recycleNodesInto, +} = require('relay-runtime'); + +import type {Cache} from './LRUCache'; +import type { + Disposable, + IEnvironment, + ReaderFragment, + RequestDescriptor, + Snapshot, +} from 'relay-runtime'; + +export type FragmentResource = FragmentResourceImpl; + +type FragmentResourceCache = Cache< + Error | Promise | SingularOrPluralSnapshot, +>; + +type SingularOrPluralSnapshot = Snapshot | $ReadOnlyArray; +opaque type FragmentResult: { + data: mixed, +} = {| + cacheKey: string, + data: mixed, + snapshot: SingularOrPluralSnapshot | null, +|}; + +// 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; + +function isMissingData(snapshot: SingularOrPluralSnapshot) { + if (Array.isArray(snapshot)) { + return snapshot.some(s => s.isMissingData); + } + return snapshot.isMissingData; +} + +function getFragmentResult( + cacheKey: string, + snapshot: SingularOrPluralSnapshot, +): FragmentResult { + if (Array.isArray(snapshot)) { + return {cacheKey, snapshot, data: snapshot.map(s => s.data)}; + } + return {cacheKey, snapshot, data: snapshot.data}; +} + +function lookupFragment( + environment, + fragmentNode, + fragmentRef, + fragmentOwnerOrOwners, + componentDisplayName, +) { + const selector = getSelector( + // We get the variables from the fragment owner in the fragment ref, so we + // don't pass them here. This API can change once fragment ownership + // stops being optional + // TODO(T39494051) + fragmentNode, + fragmentRef, + ); + invariant( + selector != null, + 'Relay: Expected to have received a valid ' + + 'fragment reference for fragment `%s` declared in `%s`. Make sure ' + + "that `%s`'s parent is passing the right fragment reference prop.", + fragmentNode.name, + componentDisplayName, + componentDisplayName, + ); + return selector.kind === 'PluralReaderSelector' + ? selector.selectors.map(s => environment.lookup(s)) + : environment.lookup(selector); +} + +function getPromiseForPendingOperationAffectingOwner( + environment: IEnvironment, + request: RequestDescriptor, +): Promise | null { + return environment + .getOperationTracker() + .getPromiseForPendingOperationsAffectingOwner(request); +} + +class FragmentResourceImpl { + _environment: IEnvironment; + _cache: FragmentResourceCache; + + constructor(environment: IEnvironment) { + this._environment = environment; + this._cache = LRUCache.create(CACHE_CAPACITY); + } + + /** + * 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 { + const environment = this._environment; + const cacheKey = getFragmentIdentifier(fragmentNode, fragmentRef); + + // 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, data: null, snapshot: null}; + } + + // If fragmentRef is plural, ensure that it is an array. + // If it's empty, return the empty array direclty 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, data: [], snapshot: []}; + } + } + + // Now we actually attempt to read the fragment: + + // 1. Check if there's a cached value for this fragment + const cachedValue = this._cache.get(cacheKey); + if (cachedValue != null) { + if (isPromise(cachedValue) || cachedValue instanceof Error) { + throw cachedValue; + } + return getFragmentResult(cacheKey, cachedValue); + } + + // 2. If not, try reading the fragment from the Relay store. + // If the snapshot has data, return it and save it in cache + // $FlowFixMe - TODO T39154660 Use FragmentPointer type instead of mixed + const fragmentOwnerOrOwners = getFragmentOwner(fragmentNode, fragmentRef); + const snapshot = lookupFragment( + environment, + fragmentNode, + fragmentRef, + fragmentOwnerOrOwners, + componentDisplayName, + ); + + const fragmentOwner = Array.isArray(fragmentOwnerOrOwners) + ? fragmentOwnerOrOwners[0] + : fragmentOwnerOrOwners; + const parentQueryName = + fragmentOwner?.node.params.name ?? 'Unknown Parent Query'; + + if (!isMissingData(snapshot)) { + this._cache.set(cacheKey, snapshot); + return getFragmentResult(cacheKey, snapshot); + } + + // 3. If we don't have data in the store, check if a request is 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. If a promise exists, cache the promise and use it + // to suspend. + invariant( + fragmentOwner != null, + 'Relay: Tried reading fragment %s declared in ' + + 'fragment container %s without a parent query. This usually means ' + + " you didn't render %s as a descendant of a QueryRenderer", + fragmentNode.name, + componentDisplayName, + componentDisplayName, + ); + const networkPromise = this._getAndSavePromiseForFragmentRequestInFlight( + cacheKey, + fragmentOwner, + ); + if (networkPromise != null) { + throw networkPromise; + } + + // 5. If a cached value still isn't available, raise a warning. + // This means that we're trying to read a fragment that isn't available + // and isn't being fetched at all. + warning( + false, + 'Relay: Tried reading fragment `%s` declared in ' + + '`%s`, but it has missing data and its parent query `%s` is not ' + + 'being fetched.\n' + + 'This might be fixed by by re-running the Relay Compiler. ' + + ' Otherwise, make sure of the following:\n' + + '* You are correctly fetching `%s` if you are using a ' + + '"store-only" `fetchPolicy`.\n' + + "* Other queries aren't accidentally fetching and overwriting " + + 'the data for this fragment.\n' + + '* Any related mutations or subscriptions are fetching all of ' + + 'the data for this fragment.\n' + + "* Any related store updaters aren't accidentally deleting " + + 'data for this fragment.', + fragmentNode.name, + componentDisplayName, + parentQueryName, + parentQueryName, + ); + return getFragmentResult(cacheKey, snapshot); + } + + readSpec( + fragmentNodes: {[string]: ReaderFragment}, + fragmentRefs: {[string]: mixed}, + componentDisplayName: string, + ): {[string]: FragmentResult} { + return mapObject(fragmentNodes, (fragmentNode, fragmentKey) => { + const fragmentRef = fragmentRefs[fragmentKey]; + return this.read( + fragmentNode, + fragmentRef, + componentDisplayName, + fragmentKey, + ); + }); + } + + 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 + // latest data. + if (didMissUpdates) { + callback(); + } + + // 3. Establish subscriptions on the snapshot(s) + const dataSubscriptions = []; + 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) => { + dataSubscriptions.push( + environment.subscribe(snapshot, latestSnapshot => { + this._updatePluralSnapshot(cacheKey, latestSnapshot, idx); + 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.", + ); + dataSubscriptions.push( + environment.subscribe(currentSnapshot, latestSnapshot => { + this._cache.set(cacheKey, latestSnapshot); + callback(); + }), + ); + } + + return { + dispose: () => { + dataSubscriptions.map(s => s.dispose()); + this._cache.delete(cacheKey); + }, + }; + } + + subscribeSpec( + fragmentResults: { + [string]: FragmentResult, + }, + callback: () => void, + ): Disposable { + const disposables = mapObject(fragmentResults, fragmentResult => { + return this.subscribe(fragmentResult, callback); + }); + return { + dispose: () => { + Object.keys(disposables).forEach(key => { + const disposable = disposables[key]; + disposable.dispose(); + }); + }, + }; + } + + checkMissedUpdates( + fragmentResult: FragmentResult, + ): [boolean, SingularOrPluralSnapshot | null] { + const environment = this._environment; + const {cacheKey} = fragmentResult; + const renderedSnapshot = fragmentResult.snapshot; + if (!renderedSnapshot) { + return [false, null]; + } + + let didMissUpdates = false; + + if (Array.isArray(renderedSnapshot)) { + const currentSnapshots = []; + renderedSnapshot.forEach((snapshot, idx) => { + let currentSnapshot = 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; + }); + if (didMissUpdates) { + this._cache.set(cacheKey, currentSnapshots); + } + return [didMissUpdates, currentSnapshots]; + } + let currentSnapshot = environment.lookup(renderedSnapshot.selector); + const renderData = renderedSnapshot.data; + const currentData = currentSnapshot.data; + const updatedData = recycleNodesInto(renderData, currentData); + if (updatedData !== renderData) { + currentSnapshot = {...currentSnapshot, data: updatedData}; + this._cache.set(cacheKey, currentSnapshot); + didMissUpdates = true; + } + return [didMissUpdates, currentSnapshot]; + } + + checkMissedUpdatesSpec(fragmentResults: {[string]: FragmentResult}): boolean { + let didMissUpdates: boolean = false; + Object.keys(fragmentResults).forEach(key => { + const fragmentResult = fragmentResults[key]; + didMissUpdates = this.checkMissedUpdates(fragmentResult)[0]; + }); + return didMissUpdates; + } + + _getAndSavePromiseForFragmentRequestInFlight( + cacheKey: string, + fragmentOwner: RequestDescriptor, + ): Promise | null { + const environment = this._environment; + const networkPromise = + getPromiseForRequestInFlight(environment, fragmentOwner) ?? + getPromiseForPendingOperationAffectingOwner(environment, fragmentOwner); + + if (!networkPromise) { + 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 promise = networkPromise + .then(() => { + this._cache.delete(cacheKey); + }) + .catch(error => { + this._cache.set(cacheKey, error); + }); + this._cache.set(cacheKey, promise); + + // $FlowExpectedError Expando to annotate Promises. + promise.displayName = 'Relay(' + fragmentOwner.node.params.name + ')'; + return promise; + } + + _updatePluralSnapshot( + cacheKey: string, + latestSnapshot: Snapshot, + idx: number, + ): void { + const currentSnapshots = this._cache.get(cacheKey); + invariant( + Array.isArray(currentSnapshots), + 'Relay: Expected to find cached data for plural fragment when ' + + 'recieving a subscription. ' + + "If you're seeing this, this is likely a bug in Relay.", + ); + const nextSnapshots = [...currentSnapshots]; + nextSnapshots[idx] = latestSnapshot; + this._cache.set(cacheKey, nextSnapshots); + } +} + +function createFragmentResource(environment: IEnvironment): FragmentResource { + return new FragmentResourceImpl(environment); +} + +const dataResources: Map = 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, +}; diff --git a/packages/relay-experimental/InternalLogger.js b/packages/relay-experimental/InternalLogger.js new file mode 100644 index 000000000000..b73e4f0b6004 --- /dev/null +++ b/packages/relay-experimental/InternalLogger.js @@ -0,0 +1,24 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 + */ + +'use strict'; + +type LogEvent = (eventData: string) => void; + +let loggerImpl = (eventData: string) => {}; + +module.exports = { + setLoggerImplementation(loggerFn: LogEvent): void { + loggerImpl = loggerFn; + }, + logEvent: (eventData: string): void => { + return loggerImpl(eventData); + }, +}; diff --git a/packages/relay-experimental/LRUCache.js b/packages/relay-experimental/LRUCache.js new file mode 100644 index 000000000000..3863ad04d7e4 --- /dev/null +++ b/packages/relay-experimental/LRUCache.js @@ -0,0 +1,98 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 + * @emails oncall+relay + * @format + */ + +'use strict'; + +const invariant = require('invariant'); + +export interface Cache { + get(key: string): ?T; + set(key: string, value: T): void; + has(key: string): boolean; + delete(key: string): void; + size(): number; + capacity(): number; + clear(): void; +} + +/** + * JS maps (both plain objects and Map) maintain key insertion + * order, which means there is an easy way to simulate LRU behavior + * that should also perform quite well: + * + * To insert a new value, first delete the key from the inner _map, + * then _map.set(k, v). By deleting and reinserting, you ensure that the + * map sees the key as the last inserted key. + * + * Get does the same: if the key is present, delete and reinsert it. + */ +class LRUCache implements Cache { + _capacity: number; + _map: Map; + + constructor(capacity: number) { + this._capacity = capacity; + invariant( + this._capacity > 0, + 'LRUCache: Unable to create instance of cache with zero or negative capacity.', + ); + + this._map = new Map(); + } + + set(key: string, value: T): void { + this._map.delete(key); + this._map.set(key, value); + if (this._map.size > this._capacity) { + const firstKey = this._map.keys().next(); + if (!firstKey.done) { + this._map.delete(firstKey.value); + } + } + } + + get(key: string): ?T { + const value = this._map.get(key); + if (value != null) { + this._map.delete(key); + this._map.set(key, value); + } + return value; + } + + has(key: string): boolean { + return this._map.has(key); + } + + delete(key: string): void { + this._map.delete(key); + } + + size(): number { + return this._map.size; + } + + capacity(): number { + return this._capacity - this._map.size; + } + + clear(): void { + this._map.clear(); + } +} + +function create(capacity: number): LRUCache { + return new LRUCache(capacity); +} + +module.exports = { + create, +}; diff --git a/packages/relay-experimental/MatchContainer.js b/packages/relay-experimental/MatchContainer.js new file mode 100644 index 000000000000..1449760d7b1a --- /dev/null +++ b/packages/relay-experimental/MatchContainer.js @@ -0,0 +1,156 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 + */ + +'use strict'; + +const React = require('react'); +const {useMemo} = React; + +/** + * Renders the results of a data-driven dependency fetched with the `@match` + * directive. The `@match` directive can be used to specify a mapping of + * result types to the containers used to render those types. The result + * value is an opaque object that described which component was selected + * and a reference to its data. Use to render these + * values. + * + * ## Example + * + * For example, consider a piece of media content that might be text or + * an image, where for clients that don't support images the application + * should fall back to rendering the image caption as text. @match can be + * used to dynamically select whether to render a given media item as + * an image or text (on the server) and then fetch the corresponding + * React component and its data dependencies (information about the + * image or about the text). + * + * ``` + * // Media.react.js + * + * // Define a React component that uses to render the + * // results of a @module selection + * function Media(props) { + * const {media, ...restPropsj} = props; + * + * const loader = moduleReference => { + * // given the data returned by your server for the @module directive, + * // return the React component (or throw a Suspense promise if + * // it is loading asynchronously). + * todo_returnModuleOrThrowPromise(moduleReference); + * }; + * return ; + * } + * + * module.exports = createSuspenseFragmentContainer( + * Media, + * { + * media: graphql` + * fragment Media_media on Media { + * # ... + * mediaAttachment @match { + * ...ImageContainer_image @module(name: "ImageContainer.react") + * ...TextContainer_text @module(name: "TextContainer.react") + * } + * } + * ` + * }, + * ); + * ``` + * + * ## API + * + * MatchContainer accepts the following props: + * - `match`: The results (an opaque object) of a `@match` field. + * - `props`: Props that should be passed through to the dynamically + * selected component. Note that any of the components listed in + * `@module()` could be selected, so all components should accept + * the value passed here. + * - `loader`: A function to load a module given a reference (whatever + * your server returns for the `js(moduleName: String)` field). + * + */ + +// Note: this type is intentionally non-exact, it is expected that the +// object may contain sibling fields. +export type MatchPointer = { + +__fragmentPropName?: ?string, + +__module_component?: mixed, + +$fragmentRefs: mixed, +}; + +export type MatchContainerProps = {| + +fallback?: ?TFallback, + +loader: (module: mixed) => React.AbstractComponent, + +match: ?MatchPointer, + +props?: TProps, +|}; + +function MatchContainer({ + fallback, + loader, + match, + props, +}: MatchContainerProps): + | React.Element> + | TFallback + | null { + if (match != null && typeof match !== 'object') { + throw new Error( + 'MatchContainer: Expected `match` value to be an object or null/undefined.', + ); + } + // NOTE: the MatchPointer type has a $fragmentRefs field to ensure that only + // an object that contains a FragmentSpread can be passed. If the fragment + // spread matches, then the metadata fields below (__id, __fragments, etc) + // will be present. But they can be missing if all the fragment spreads use + // @module and none of the types matched. The cast here is necessary because + // fragment Flow types don't describe metadata fields, only the actual schema + // fields the developer selected. + const { + __id, + __fragments, + __fragmentOwner, + __fragmentPropName, + __module_component, + } = (match: $FlowFixMe) ?? {}; + if ( + (__fragmentOwner != null && typeof __fragmentOwner !== 'object') || + (__fragmentPropName != null && typeof __fragmentPropName !== 'string') || + (__fragments != null && typeof __fragments !== 'object') || + (__id != null && typeof __id !== 'string') + ) { + throw new Error( + "MatchContainer: Invalid 'match' value, expected an object that has a " + + "'...SomeFragment' spread.", + ); + } + + const LoadedContainer = + __module_component != null ? loader(__module_component) : null; + + const fragmentProps = useMemo(() => { + // TODO: Perform this transformation in RelayReader so that unchanged + // output of subscriptions already has a stable identity. + if (__fragmentPropName != null && __id != null && __fragments != null) { + const fragProps = {}; + fragProps[__fragmentPropName] = {__id, __fragments, __fragmentOwner}; + return fragProps; + } + return null; + }, [__id, __fragments, __fragmentOwner, __fragmentPropName]); + + if (LoadedContainer != null && fragmentProps != null) { + return ; + } else { + return fallback ?? null; + } +} + +module.exports = MatchContainer; diff --git a/packages/relay-experimental/ProfilerContext.js b/packages/relay-experimental/ProfilerContext.js new file mode 100644 index 000000000000..8408ae1cbad7 --- /dev/null +++ b/packages/relay-experimental/ProfilerContext.js @@ -0,0 +1,32 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * This contextual profiler can be used to wrap a react sub-tree. It will bind + * the RelayProfiler during the render phase of these components. Allows + * collecting metrics for a specific part of your application. + * + * @emails oncall+relay + * @flow strict-local + * @format + */ + +'use strict'; + +const React = require('react'); + +export type ProfilerContextType = { + wrapPrepareQueryResource: (cb: () => T) => T, +}; + +const ProfilerContext: React$Context = React.createContext( + { + wrapPrepareQueryResource: (cb: () => T) => { + return cb(); + }, + }, +); + +module.exports = ProfilerContext; diff --git a/packages/relay-experimental/QueryResource.js b/packages/relay-experimental/QueryResource.js new file mode 100644 index 000000000000..e2528273c5e0 --- /dev/null +++ b/packages/relay-experimental/QueryResource.js @@ -0,0 +1,539 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 + * @emails oncall+relay + * @format + */ + +'use strict'; + +const ExecutionEnvironment = require('fbjs/lib/ExecutionEnvironment'); +const LRUCache = require('./LRUCache'); + +const invariant = require('invariant'); + +const {isPromise, RelayFeatureFlags} = require('relay-runtime'); + +const CACHE_CAPACITY = 1000; + +const DEFAULT_FETCH_POLICY = 'store-or-network'; +const DEFAULT_RENDER_POLICY = + RelayFeatureFlags.ENABLE_PARTIAL_RENDERING_DEFAULT === true + ? 'partial' + : 'full'; + +const DATA_RETENTION_TIMEOUT = 30 * 1000; + +import type { + Disposable, + FragmentPointer, + GraphQLResponse, + IEnvironment, + Observable, + Observer, + OperationDescriptor, + ReaderFragment, + Snapshot, +} from 'relay-runtime'; +import type {Cache} from './LRUCache'; + +export type QueryResource = QueryResourceImpl; +export type FetchPolicy = + | 'store-only' + | 'store-or-network' + | 'store-and-network' + | 'network-only'; +export type RenderPolicy = 'full' | 'partial'; + +type QueryResourceCache = Cache; +type QueryResourceCacheEntry = {| + +cacheKey: string, + getRetainCount(): number, + getValue(): Error | Promise | QueryResult, + setValue(Error | Promise | QueryResult): void, + temporaryRetain(environment: IEnvironment): void, + permanentRetain(environment: IEnvironment): Disposable, +|}; +opaque type QueryResult: { + fragmentNode: ReaderFragment, + fragmentRef: FragmentPointer, +} = {| + cacheKey: string, + fragmentNode: ReaderFragment, + fragmentRef: FragmentPointer, + operation: OperationDescriptor, +|}; + +function getQueryCacheKey( + operation: OperationDescriptor, + fetchPolicy: FetchPolicy, + renderPolicy: RenderPolicy, +): string { + return `${fetchPolicy}-${renderPolicy}-${operation.request.identifier}`; +} + +function getQueryResult( + operation: OperationDescriptor, + cacheKey: string, +): QueryResult { + const rootFragmentRef = { + __id: operation.fragment.dataID, + __fragments: { + [operation.fragment.node.name]: operation.request.variables, + }, + __fragmentOwner: operation.request, + }; + return { + cacheKey, + fragmentNode: operation.request.node.fragment, + fragmentRef: rootFragmentRef, + operation, + }; +} + +function createQueryResourceCacheEntry( + cacheKey: string, + operation: OperationDescriptor, + value: Error | Promise | QueryResult, + onDispose: QueryResourceCacheEntry => void, +): QueryResourceCacheEntry { + let currentValue: Error | Promise | QueryResult = value; + let retainCount = 0; + let permanentlyRetained = false; + let retainDisposable: ?Disposable = null; + let releaseTemporaryRetain: ?() => void = null; + + const retain = (environment: IEnvironment) => { + retainCount++; + if (retainCount === 1) { + retainDisposable = environment.retain(operation.root); + } + return { + dispose: () => { + retainCount = Math.max(0, retainCount - 1); + if (retainCount === 0) { + invariant( + retainDisposable != null, + 'Relay: Expected disposable to release query to be defined.' + + "If you're seeing this, this is likely a bug in Relay.", + ); + retainDisposable.dispose(); + retainDisposable = null; + } + onDispose(cacheEntry); + }, + }; + }; + + const cacheEntry = { + cacheKey, + getValue() { + return currentValue; + }, + setValue(val) { + currentValue = val; + }, + getRetainCount() { + return retainCount; + }, + temporaryRetain(environment: IEnvironment) { + // NOTE: If we're executing in a server environment, there's no need + // to create temporary retains, since the component will never commit. + if (!ExecutionEnvironment.canUseDOM) { + return; + } + + if (permanentlyRetained === true) { + return; + } + + // NOTE: temporaryRetain is called during the render phase. However, + // given that we can't tell if this render will eventually commit or not, + // we create a timer to autodispose of this retain in case the associated + // component never commits. + // If the component /does/ commit, permanentRetain will clear this timeout + // and permanently retain the data. + const disposable = retain(environment); + let releaseQueryTimeout = null; + const localReleaseTemporaryRetain = () => { + clearTimeout(releaseQueryTimeout); + releaseQueryTimeout = null; + releaseTemporaryRetain = null; + disposable.dispose(); + }; + releaseQueryTimeout = setTimeout( + localReleaseTemporaryRetain, + DATA_RETENTION_TIMEOUT, + ); + + // NOTE: Since temporaryRetain can be called multiple times, we release + // the previous temporary retain after we re-establish a new one, since + // we only ever need a single temporary retain until the permanent retain is + // established. + // temporaryRetain may be called multiple times by React during the render + // phase, as well multiple times by sibling query components that are + // rendering the same query/variables. + if (releaseTemporaryRetain != null) { + releaseTemporaryRetain(); + } + releaseTemporaryRetain = localReleaseTemporaryRetain; + }, + permanentRetain(environment: IEnvironment) { + const disposable = retain(environment); + if (releaseTemporaryRetain != null) { + releaseTemporaryRetain(); + releaseTemporaryRetain = null; + } + + permanentlyRetained = true; + return { + dispose: () => { + disposable.dispose(); + permanentlyRetained = false; + }, + }; + }, + }; + + return cacheEntry; +} + +class QueryResourceImpl { + _environment: IEnvironment; + _cache: QueryResourceCache; + _logQueryResource: ?( + operation: OperationDescriptor, + fetchPolicy: FetchPolicy, + renderPolicy: RenderPolicy, + hasFullQuery: boolean, + shouldFetch: boolean, + ) => void; + + constructor(environment: IEnvironment) { + this._environment = environment; + this._cache = LRUCache.create(CACHE_CAPACITY); + if (__DEV__) { + this._logQueryResource = ( + operation: OperationDescriptor, + fetchPolicy: FetchPolicy, + renderPolicy: RenderPolicy, + hasFullQuery: boolean, + shouldFetch: boolean, + ): void => { + if ( + // Disable relay network logging while performing Server-Side + // Rendering (SSR) + !ExecutionEnvironment.canUseDOM + ) { + return; + } + const logger = environment.getLogger({ + // $FlowFixMe + request: { + ...operation.request.node.params, + name: `${operation.request.node.params.name} (Store Cache)`, + }, + variables: operation.request.variables, + cacheConfig: {}, + }); + if (!logger) { + return; + } + logger.log('Fetch Policy', fetchPolicy); + logger.log('Render Policy', renderPolicy); + logger.log('Query', hasFullQuery ? 'Fully cached' : 'Has missing data'); + logger.log('Network Request', shouldFetch ? 'Required' : 'Skipped'); + logger.log('Variables', operation.request.variables); + logger.flushLogs(); + }; + } + } + + /** + * This function should be called during a Component's render function, + * to either read an existing cached value for the query, or fetch the query + * and suspend. + */ + prepare( + operation: OperationDescriptor, + fetchObservable: Observable, + maybeFetchPolicy: ?FetchPolicy, + maybeRenderPolicy: ?RenderPolicy, + observer?: Observer, + cacheKeyBuster: ?string | ?number, + ): QueryResult { + const environment = this._environment; + const fetchPolicy = maybeFetchPolicy ?? DEFAULT_FETCH_POLICY; + const renderPolicy = maybeRenderPolicy ?? DEFAULT_RENDER_POLICY; + let cacheKey = getQueryCacheKey(operation, fetchPolicy, renderPolicy); + if (cacheKeyBuster != null) { + cacheKey += `-${cacheKeyBuster}`; + } + + // 1. Check if there's a cached value for this operation, and reuse it if + // it's available + let cacheEntry = this._cache.get(cacheKey); + if (cacheEntry == null) { + // 2. If a cached value isn't available, try fetching the operation. + // fetchAndSaveQuery will update the cache with either a Promise or + // an Error to throw, or a FragmentResource to return. + cacheEntry = this._fetchAndSaveQuery( + cacheKey, + operation, + fetchObservable, + fetchPolicy, + renderPolicy, + observer, + ); + } + + // Retain here in render phase. When the Component reading the operation + // is committed, we will transfer ownership of data retention to the + // component. + // In case the component never mounts or updates from this render, + // this data retention hold will auto-release itself afer a timeout. + cacheEntry.temporaryRetain(environment); + + const cachedValue = cacheEntry.getValue(); + if (isPromise(cachedValue) || cachedValue instanceof Error) { + throw cachedValue; + } + return cachedValue; + } + + /** + * This function should be called during a Component's commit phase + * (e.g. inside useEffect), in order to retain the operation in the Relay store + * and transfer ownership of the operation to the component lifecycle. + */ + retain(queryResult: QueryResult): Disposable { + const environment = this._environment; + const {cacheKey, operation} = queryResult; + let cacheEntry = this._cache.get(cacheKey); + if (cacheEntry == null) { + cacheEntry = createQueryResourceCacheEntry( + cacheKey, + operation, + queryResult, + this._onDispose, + ); + this._cache.set(cacheKey, cacheEntry); + } + const disposable = cacheEntry.permanentRetain(environment); + + return { + dispose: () => { + disposable.dispose(); + invariant( + cacheEntry != null, + 'Relay: Expected to have cached a result when disposing query.' + + "If you're seeing this, this is likely a bug in Relay.", + ); + this._onDispose(cacheEntry); + }, + }; + } + + getCacheEntry( + operation: OperationDescriptor, + fetchPolicy: FetchPolicy, + maybeRenderPolicy?: RenderPolicy, + ): ?QueryResourceCacheEntry { + const renderPolicy = maybeRenderPolicy ?? DEFAULT_RENDER_POLICY; + const cacheKey = getQueryCacheKey(operation, fetchPolicy, renderPolicy); + return this._cache.get(cacheKey); + } + + _onDispose = (cacheEntry: QueryResourceCacheEntry): void => { + if (cacheEntry.getRetainCount() <= 0) { + this._cache.delete(cacheEntry.cacheKey); + } + }; + + _cacheResult(operation: OperationDescriptor, cacheKey: string): void { + const queryResult = getQueryResult(operation, cacheKey); + const cacheEntry = createQueryResourceCacheEntry( + cacheKey, + operation, + queryResult, + this._onDispose, + ); + this._cache.set(cacheKey, cacheEntry); + } + + _fetchAndSaveQuery( + cacheKey: string, + operation: OperationDescriptor, + fetchObservable: Observable, + fetchPolicy: FetchPolicy, + renderPolicy: RenderPolicy, + observer?: Observer, + ): QueryResourceCacheEntry { + const environment = this._environment; + + // NOTE: Running `check` will write missing data to the store using any + // missing data handlers specified on the environment; + // We run it here first to make the handlers get a chance to populate + // missing data. + const hasFullQuery = environment.check(operation.root); + const canPartialRender = hasFullQuery || renderPolicy === 'partial'; + + let shouldFetch; + let shouldAllowRender; + let resolveNetworkPromise = () => {}; + switch (fetchPolicy) { + case 'store-only': { + shouldFetch = false; + shouldAllowRender = true; + break; + } + case 'store-or-network': { + shouldFetch = !hasFullQuery; + shouldAllowRender = canPartialRender; + break; + } + case 'store-and-network': { + shouldFetch = true; + shouldAllowRender = canPartialRender; + break; + } + case 'network-only': + default: { + shouldFetch = true; + shouldAllowRender = false; + break; + } + } + + // NOTE: If this value is false, we will cache a promise for this + // query, which means we will suspend here at this query root. + // If it's true, we will cache the query resource and allow rendering to + // continue. + if (shouldAllowRender) { + this._cacheResult(operation, cacheKey); + } + + if (__DEV__) { + switch (fetchPolicy) { + case 'store-only': + case 'store-or-network': + case 'store-and-network': + this._logQueryResource && + this._logQueryResource( + operation, + fetchPolicy, + renderPolicy, + hasFullQuery, + shouldFetch, + ); + break; + default: + break; + } + } + + if (shouldFetch) { + const queryResult = getQueryResult(operation, cacheKey); + fetchObservable.subscribe({ + start: observer?.start, + next: () => { + const snapshot = environment.lookup(operation.fragment); + if (!snapshot.isMissingData) { + const cacheEntry = + this._cache.get(cacheKey) ?? + createQueryResourceCacheEntry( + cacheKey, + operation, + queryResult, + this._onDispose, + ); + cacheEntry.setValue(queryResult); + this._cache.set(cacheKey, cacheEntry); + resolveNetworkPromise(); + } + + const observerNext = observer?.next; + observerNext && observerNext(snapshot); + }, + error: error => { + const cacheEntry = + this._cache.get(cacheKey) ?? + createQueryResourceCacheEntry( + cacheKey, + operation, + error, + this._onDispose, + ); + cacheEntry.setValue(error); + this._cache.set(cacheKey, cacheEntry); + resolveNetworkPromise(); + + const observerError = observer?.error; + observerError && observerError(error); + }, + complete: () => { + resolveNetworkPromise(); + + const observerComplete = observer?.complete; + observerComplete && observerComplete(); + }, + unsubscribe: observer?.unsubscribe, + }); + + let cacheEntry = this._cache.get(cacheKey); + if (!cacheEntry) { + const networkPromise = new Promise(resolve => { + resolveNetworkPromise = resolve; + }); + + // $FlowExpectedError Expando to annotate Promises. + networkPromise.displayName = + 'Relay(' + operation.fragment.node.name + ')'; + + cacheEntry = createQueryResourceCacheEntry( + cacheKey, + operation, + networkPromise, + this._onDispose, + ); + this._cache.set(cacheKey, cacheEntry); + } + } else { + const observerComplete = observer?.complete; + observerComplete && observerComplete(); + } + const cacheEntry = this._cache.get(cacheKey); + invariant( + cacheEntry != null, + 'Relay: Expected to have cached a result when attempting to fetch query.' + + "If you're seeing this, this is likely a bug in Relay.", + ); + return cacheEntry; + } +} + +function createQueryResource(environment: IEnvironment): QueryResource { + return new QueryResourceImpl(environment); +} + +const dataResources: Map = new Map(); +function getQueryResourceForEnvironment( + environment: IEnvironment, +): QueryResourceImpl { + const cached = dataResources.get(environment); + if (cached) { + return cached; + } + const newDataResource = createQueryResource(environment); + dataResources.set(environment, newDataResource); + return newDataResource; +} + +module.exports = { + createQueryResource, + getQueryResourceForEnvironment, +}; diff --git a/packages/relay-experimental/RelayEnvironmentProvider.js b/packages/relay-experimental/RelayEnvironmentProvider.js new file mode 100644 index 000000000000..c3b53a234204 --- /dev/null +++ b/packages/relay-experimental/RelayEnvironmentProvider.js @@ -0,0 +1,39 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails oncall+relay + * @flow strict-local + * @format + */ + +'use strict'; + +const React = require('react'); +const ReactRelayContext = require('react-relay/ReactRelayContext'); + +import type {IEnvironment} from 'relay-runtime'; + +const {useMemo} = React; + +type Props = $ReadOnly<{| + children: React.Node, + environment: IEnvironment, +|}>; + +function RelayEnvironmentProvider(props: Props): React.Node { + const {children, environment} = props; + // TODO(T39494051) - We're setting empty variables here to make Flow happy + // and for backwards compatibility, while we remove variables from context + // in favor of fragment ownershipt + const context = useMemo(() => ({environment, variables: {}}), [environment]); + return ( + + {children} + + ); +} + +module.exports = RelayEnvironmentProvider; diff --git a/packages/relay-experimental/__flowtests__/useBlockingPaginationFragment-flowtest.js b/packages/relay-experimental/__flowtests__/useBlockingPaginationFragment-flowtest.js new file mode 100644 index 000000000000..3c6b862d7fc6 --- /dev/null +++ b/packages/relay-experimental/__flowtests__/useBlockingPaginationFragment-flowtest.js @@ -0,0 +1,88 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 + */ + +'use strict'; + +const useBlockingPaginationFragment = require('../useBlockingPaginationFragment'); + +import type {LoadMoreFn} from '../useLoadMoreFunction'; +import { + fragmentInput, + keyAnotherNonNullable, + keyAnotherNullable, + keyNonNullable, + keyNullable, +} from './utils'; +import type { + FetchFn, + NonNullableData, + NullableData, + QueryOperation, + QueryVariables, + QueryVariablesSubset, +} from './utils'; +import type {IEnvironment} from 'relay-runtime'; + +type ExpectedReturnType = {| + data: TFragmentData, + loadNext: LoadMoreFn, + loadPrevious: LoadMoreFn, + hasNext: boolean, + hasPrevious: boolean, + refetch: FetchFn, +|}; + +/* eslint-disable react-hooks/rules-of-hooks */ + +// Nullability of returned data type is correct +(useBlockingPaginationFragment( + fragmentInput, + keyNonNullable, +): ExpectedReturnType); + +(useBlockingPaginationFragment( + fragmentInput, + keyNullable, +): ExpectedReturnType); + +// $FlowExpectedError: can't cast nullable to non-nullable +(useBlockingPaginationFragment( + fragmentInput, + keyNullable, +): ExpectedReturnType); + +// $FlowExpectedError: actual type of returned data is correct +(useBlockingPaginationFragment( + fragmentInput, + keyAnotherNonNullable, +): ExpectedReturnType); +// $FlowExpectedError +(useBlockingPaginationFragment( + fragmentInput, + keyAnotherNullable, +): ExpectedReturnType); + +// Refetch function options: +declare var variables: QueryVariables; +declare var environment: IEnvironment; + +const {refetch} = useBlockingPaginationFragment( + fragmentInput, + keyNonNullable, +); +// $FlowExpectedError: internal option +refetch(variables, { + __environment: environment, +}); + +// $FlowExpectedError: doesn't exist +refetch(variables, { + NON_EXIST: 'NON_EXIST', +}); diff --git a/packages/relay-experimental/__flowtests__/useFragment-flowtest.js b/packages/relay-experimental/__flowtests__/useFragment-flowtest.js new file mode 100644 index 000000000000..4659c689d015 --- /dev/null +++ b/packages/relay-experimental/__flowtests__/useFragment-flowtest.js @@ -0,0 +1,47 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 + */ + +'use strict'; + +const useFragment = require('../useFragment'); + +import { + fragmentInput, + keyAnotherNonNullable, + keyAnotherNullable, + keyNonNullable, + keyNonNullablePlural, + keyNullable, + keyNullablePlural, +} from './utils'; +import type { + NonNullableData, + NonNullablePluralData, + NullableData, + NullablePluralData, +} from './utils'; + +/* eslint-disable react-hooks/rules-of-hooks */ + +// Nullability of returned data type is correct +(useFragment(fragmentInput, keyNonNullable): NonNullableData); +(useFragment(fragmentInput, keyNullable): NullableData); +(useFragment(fragmentInput, keyNonNullablePlural): NonNullablePluralData); +(useFragment(fragmentInput, keyNullablePlural): NullablePluralData); + +// $FlowExpectedError: can't cast nullable to non-nullable +(useFragment(fragmentInput, keyNullable): NonNullableData); +// $FlowExpectedError: can't cast nullable plural to non-nullable plural +(useFragment(fragmentInput, keyNullablePlural): NonNullablePluralData); + +// $FlowExpectedError: actual type of returned data is correct +(useFragment(fragmentInput, keyAnotherNonNullable): NonNullableData); +// $FlowExpectedError +(useFragment(fragmentInput, keyAnotherNullable): NullableData); diff --git a/packages/relay-experimental/__flowtests__/useLegacyPaginationFragment-flowtest.js b/packages/relay-experimental/__flowtests__/useLegacyPaginationFragment-flowtest.js new file mode 100644 index 000000000000..1ab9f356e249 --- /dev/null +++ b/packages/relay-experimental/__flowtests__/useLegacyPaginationFragment-flowtest.js @@ -0,0 +1,91 @@ +/** +/** + * Copyright (c) Facebook, Inc. and its 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 + */ + +'use strict'; + +const useLegacyPaginationFragment = require('../useLegacyPaginationFragment'); + +import type {LoadMoreFn} from '../useLoadMoreFunction'; +import { + fragmentInput, + keyAnotherNonNullable, + keyAnotherNullable, + keyNonNullable, + keyNullable, +} from './utils'; +import type { + FetchFn, + NonNullableData, + NullableData, + QueryOperation, + QueryVariables, + QueryVariablesSubset, +} from './utils'; +import type {IEnvironment} from 'relay-runtime'; + +type ExpectedReturnType = {| + data: TFragmentData, + loadNext: LoadMoreFn, + loadPrevious: LoadMoreFn, + hasNext: boolean, + hasPrevious: boolean, + isLoadingNext: boolean, + isLoadingPrevious: boolean, + refetch: FetchFn, +|}; + +/* eslint-disable react-hooks/rules-of-hooks */ + +// Nullability of returned data type is correct +(useLegacyPaginationFragment( + fragmentInput, + keyNonNullable, +): ExpectedReturnType); + +(useLegacyPaginationFragment( + fragmentInput, + keyNullable, +): ExpectedReturnType); + +// $FlowExpectedError: can't cast nullable to non-nullable +(useLegacyPaginationFragment( + fragmentInput, + keyNullable, +): ExpectedReturnType); + +// $FlowExpectedError: actual type of returned data is correct +(useLegacyPaginationFragment( + fragmentInput, + keyAnotherNonNullable, +): ExpectedReturnType); +// $FlowExpectedError +(useLegacyPaginationFragment( + fragmentInput, + keyAnotherNullable, +): ExpectedReturnType); + +// Refetch function options: +declare var variables: QueryVariables; +declare var environment: IEnvironment; + +const {refetch} = useLegacyPaginationFragment( + fragmentInput, + keyNonNullable, +); +// $FlowExpectedError: internal option +refetch(variables, { + __environment: environment, +}); + +// $FlowExpectedError: doesn't exist +refetch(variables, { + NON_EXIST: 'NON_EXIST', +}); diff --git a/packages/relay-experimental/__flowtests__/useRefetchableFragment-flowtest.js b/packages/relay-experimental/__flowtests__/useRefetchableFragment-flowtest.js new file mode 100644 index 000000000000..a5a22535a594 --- /dev/null +++ b/packages/relay-experimental/__flowtests__/useRefetchableFragment-flowtest.js @@ -0,0 +1,84 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 + */ + +'use strict'; + +const useRefetchableFragment = require('../useRefetchableFragment'); + +import { + fragmentInput, + keyAnotherNonNullable, + keyAnotherNullable, + keyNonNullable, + keyNullable, +} from './utils'; +import type { + FetchFn, + NonNullableData, + NullableData, + QueryOperation, + QueryVariables, + QueryVariablesSubset, +} from './utils'; +import type {IEnvironment} from 'relay-runtime'; + +/* eslint-disable react-hooks/rules-of-hooks */ + +// Nullability of returned data type is correct +(useRefetchableFragment(fragmentInput, keyNonNullable): [ + NonNullableData, + FetchFn, +]); + +(useRefetchableFragment(fragmentInput, keyNullable): [ + NullableData, + FetchFn, +]); + +// $FlowExpectedError: can't cast nullable to non-nullable +(useRefetchableFragment(fragmentInput, keyNullable): [ + NonNullableData, + FetchFn, +]); + +// $FlowExpectedError: refetch requires exact type if key is nullable +(useRefetchableFragment(fragmentInput, keyNullable): [ + NullableData, + FetchFn, +]); + +// $FlowExpectedError: actual type of returned data is correct +(useRefetchableFragment( + fragmentInput, + keyAnotherNonNullable, +): [NonNullableData, FetchFn]); +// $FlowExpectedError +(useRefetchableFragment(fragmentInput, keyAnotherNullable): [ + NullableData, + FetchFn, +]); + +// Refetch function options: +declare var variables: QueryVariables; +declare var environment: IEnvironment; + +const [_, refetch] = useRefetchableFragment( + fragmentInput, + keyNonNullable, +); +// $FlowExpectedError: internal option +refetch(variables, { + __environment: environment, +}); + +// $FlowExpectedError: doesn't exist +refetch(variables, { + NON_EXIST: 'NON_EXIST', +}); diff --git a/packages/relay-experimental/__flowtests__/utils.js b/packages/relay-experimental/__flowtests__/utils.js new file mode 100644 index 000000000000..a676cbc91c66 --- /dev/null +++ b/packages/relay-experimental/__flowtests__/utils.js @@ -0,0 +1,81 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 + */ + +'use strict'; + +import type { + Disposable, + FragmentReference, + GraphQLTaggedNode, +} from 'relay-runtime'; + +declare export var fragmentInput: GraphQLTaggedNode; + +export type NonNullableData = {| + +id: string, + +count: number, +|}; + +export type NullableData = ?NonNullableData; + +export type NonNullablePluralData = $ReadOnlyArray; +export type NullablePluralData = $ReadOnlyArray; + +export type AnotherNonNullableData = {| + +name: ?string, + +friends: ?number, +|}; + +declare export var keyNonNullable: { + +$data?: NonNullableData, + +$fragmentRefs: FragmentReference, +}; + +declare export var keyNonNullablePlural: $ReadOnlyArray<{ + +$data?: NonNullablePluralData, + +$fragmentRefs: FragmentReference, +}>; + +declare export var keyNullablePlural: $ReadOnlyArray<{ + +$data?: NullablePluralData, + +$fragmentRefs: FragmentReference, +}>; + +declare export var keyNullable: ?{ + +$data?: NonNullableData, + +$fragmentRefs: FragmentReference, +}; + +declare export var keyAnotherNonNullable: { + +$data: AnotherNonNullableData, + +$fragmentRefs: FragmentReference, +}; + +declare export var keyAnotherNullable: ?{ + +$data: AnotherNonNullableData, + +$fragmentRefs: FragmentReference, +}; + +export type QueryOperation = {| + +variables: QueryVariables, + +response: {}, +|}; + +export type QueryVariables = {| + id: string, + nickname: ?string, + name: string, +|}; + +export type QueryVariablesSubset = { + id: string, +}; + +export type FetchFn = (vars: TVars) => Disposable; diff --git a/packages/relay-experimental/__tests__/FragmentResource-WithOperationTracker-test.js b/packages/relay-experimental/__tests__/FragmentResource-WithOperationTracker-test.js new file mode 100644 index 000000000000..70202b56831e --- /dev/null +++ b/packages/relay-experimental/__tests__/FragmentResource-WithOperationTracker-test.js @@ -0,0 +1,426 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails oncall+relay + * @flow strict-local + * @format + */ + +'use strict'; + +jest.mock('warning'); +const {createFragmentResource} = require('../FragmentResource'); +const { + createOperationDescriptor, + createReaderSelector, +} = require('relay-runtime'); +const RelayOperationTracker = require('relay-runtime/store/RelayOperationTracker'); +const {MockPayloadGenerator} = require('relay-test-utils'); +const { + createMockEnvironment, + generateAndCompile, +} = require('relay-test-utils-internal'); +const warning = require('warning'); + +describe('FragmentResource with Operation Tracker and Missing Data', () => { + const componentName = 'TestComponent'; + let environment; + let NodeQuery; + let ViewerFriendsQuery; + let FriendsPaginationQuery; + let UserFragment; + let PlainUserNameRenderer_name; + let PlainUserNameRenderer_name$normalization; + let FragmentResource; + let operationLoader; + let operationTracker; + let viewerOperation; + let nodeOperation; + + beforeEach(() => { + operationLoader = { + load: jest.fn(), + get: jest.fn(), + }; + operationTracker = new RelayOperationTracker(); + environment = createMockEnvironment({ + operationTracker, + operationLoader, + }); + const compiled = generateAndCompile(` + query NodeQuery($id: ID!) @relay_test_operation { + node(id: $id) { + ...UserFragment + } + } + + query ViewerFriendsQuery @relay_test_operation { + viewer { + actor { + friends(first: 1) @connection(key: "Viewer_friends") { + edges { + node { + ...UserFragment + } + } + } + } + } + } + + query FriendsPaginationQuery($id: ID!) @relay_test_operation { + node(id: $id) { + ... on User { + friends(first: 1) @connection(key: "Viewer_friends") { + edges { + node { + ...UserFragment + } + } + } + } + } + } + + fragment PlainUserNameRenderer_name on PlainUserNameRenderer { + plaintext + data { + text + } + } + + fragment MarkdownUserNameRenderer_name on MarkdownUserNameRenderer { + markdown + data { + markup + } + } + + fragment UserFragment on User { + id + name + nameRenderer @match { + ...PlainUserNameRenderer_name @module(name: "PlainUserNameRenderer.react") + ...MarkdownUserNameRenderer_name + @module(name: "MarkdownUserNameRenderer.react") + } + plainNameRenderer: nameRenderer @match { + ...PlainUserNameRenderer_name @module(name: "PlainUserNameRenderer.react") + } + } + `); + ({ + NodeQuery, + ViewerFriendsQuery, + FriendsPaginationQuery, + PlainUserNameRenderer_name, + PlainUserNameRenderer_name$normalization, + UserFragment, + } = compiled); + FragmentResource = createFragmentResource(environment); + viewerOperation = createOperationDescriptor(ViewerFriendsQuery, {}); + nodeOperation = createOperationDescriptor(NodeQuery, { + id: 'user-id-1', + }); + environment.execute({operation: viewerOperation}).subscribe({}); + environment.subscribe( + environment.lookup(viewerOperation.fragment), + jest.fn(), + ); + + // This will add data to the store (but not for 3D) + environment.mock.resolve( + viewerOperation, + // TODO: (alunyov) T43369419 [relay-testing] Make sure MockPayloadGenerator can generate data for @match + MockPayloadGenerator.generate(viewerOperation, { + Actor() { + return { + id: 'viewer-id', + }; + }, + User(_, generateId) { + return { + id: 'user-id-1', + }; + }, + }), + ); + + // We need to subscribe to a fragment in order for OperationTracker + // to be able to notify owners if they are affected by any pending operation + environment.subscribe( + environment.lookup( + createReaderSelector( + UserFragment, + 'user-id-1', + viewerOperation.request.variables, + viewerOperation.request, + ), + ), + jest.fn(), + ); + // $FlowFixMe + warning.mockClear(); + }); + + it('should warn if data is missing and it is not being fetched by owner or other operations', () => { + // At this point the viewer query is resolved but, it does not have any 3D data + // So it should throw a waring for missing data + const snapshot = FragmentResource.read( + PlainUserNameRenderer_name, + { + __id: + 'client:user-id-1:nameRenderer(supported:["PlainUserNameRenderer"])', + __fragments: { + PlainUserNameRenderer_name: {}, + }, + __fragmentOwner: viewerOperation.request, + }, + componentName, + ); + expect(snapshot.data).toEqual({ + data: undefined, + plaintext: undefined, + }); + expect(warning).toBeCalled(); + // $FlowFixMe + expect(warning.mock.calls[0][0]).toBe(false); + // $FlowFixMe + expect(warning.mock.calls[0][1]).toMatch(/it has missing data/); + }); + + it('should throw and cache promise for pending operation affecting fragment owner', () => { + environment.execute({operation: nodeOperation}).subscribe({}); + operationLoader.load.mockImplementation(() => + Promise.resolve(PlainUserNameRenderer_name$normalization), + ); + environment.mock.nextValue(nodeOperation, { + data: { + node: { + __typename: 'User', + id: 'user-id-1', + name: 'Alice', + nameRenderer: { + __typename: 'PlainUserNameRenderer', + __module_component_UserFragment: 'PlainUserNameRenderer.react', + __module_operation_UserFragment: + 'PlainUserNameRenderer_name$normalization.graphql', + plaintext: 'Plaintext', + data: { + text: 'Data Text', + }, + }, + plainNameRenderer: { + __typename: 'PlainUserNameRenderer', + __module_component_UserFragment: 'PlainUserNameRenderer.react', + __module_operation_UserFragment: + 'PlainUserNameRenderer_name$normalization.graphql', + plaintext: 'Plaintext', + data: { + text: 'Data Text', + }, + }, + }, + }, + }); + expect(operationLoader.load).toBeCalledTimes(2); + + // Calling `complete` here will just mark network request as completed, but + // we still need to process follow-ups with normalization ASTs by resolving + // the operation loader promise + environment.mock.complete(nodeOperation); + + const fragmentRef = { + __id: + 'client:user-id-1:nameRenderer(supported:["PlainUserNameRenderer"])', + __fragments: { + PlainUserNameRenderer_name: {}, + }, + __fragmentOwner: viewerOperation.request, + }; + + let thrown = null; + try { + FragmentResource.read( + PlainUserNameRenderer_name, + fragmentRef, + componentName, + ); + } catch (promise) { + expect(promise).toBeInstanceOf(Promise); + thrown = promise; + } + expect(thrown).not.toBe(null); + + // Try reading fragment a second time while affecting operation is pending + let cached = null; + try { + FragmentResource.read( + PlainUserNameRenderer_name, + fragmentRef, + componentName, + ); + } catch (promise) { + expect(promise).toBeInstanceOf(Promise); + cached = promise; + } + // Assert that promise from first read was cached + expect(cached).toBe(thrown); + }); + + it('should read the data from the store once operation fully completed', () => { + environment.execute({operation: nodeOperation}).subscribe({}); + operationLoader.load.mockImplementation(() => + Promise.resolve(PlainUserNameRenderer_name$normalization), + ); + environment.mock.nextValue(nodeOperation, { + data: { + node: { + __typename: 'User', + id: 'user-id-1', + name: 'Alice', + nameRenderer: { + __typename: 'PlainUserNameRenderer', + __module_component_UserFragment: 'PlainUserNameRenderer.react', + __module_operation_UserFragment: + 'PlainUserNameRenderer_name$normalization.graphql', + plaintext: 'Plaintext', + data: { + text: 'Data Text', + }, + }, + plainNameRenderer: { + __typename: 'PlainUserNameRenderer', + __module_component_UserFragment: 'PlainUserNameRenderer.react', + __module_operation_UserFragment: + 'PlainUserNameRenderer_name$normalization.graphql', + plaintext: 'Plaintext', + data: { + text: 'Data Text', + }, + }, + }, + }, + }); + expect(operationLoader.load).toBeCalledTimes(2); + environment.mock.complete(nodeOperation); + // To make sure promise is resolved + jest.runAllTimers(); + // $FlowFixMe + warning.mockClear(); + const snapshot = FragmentResource.read( + PlainUserNameRenderer_name, + { + __id: + 'client:user-id-1:nameRenderer(supported:["PlainUserNameRenderer"])', + __fragments: { + PlainUserNameRenderer_name: {}, + }, + __fragmentOwner: viewerOperation.request, + }, + componentName, + ); + expect(warning).not.toBeCalled(); + expect(snapshot.data).toEqual({ + data: { + text: 'Data Text', + }, + plaintext: 'Plaintext', + }); + }); + + it('should suspend on pagination query and then read the data', () => { + const paginationOperation = createOperationDescriptor( + FriendsPaginationQuery, + { + id: 'viewer-id', + }, + ); + environment.execute({operation: paginationOperation}).subscribe({}); + operationLoader.load.mockImplementation(() => + Promise.resolve(PlainUserNameRenderer_name$normalization), + ); + environment.mock.nextValue(paginationOperation, { + data: { + node: { + __typename: 'User', + id: 'viewer-id', + friends: { + edges: [ + { + node: { + __typename: 'User', + id: 'user-id-2', + name: 'Bob', + nameRenderer: { + __typename: 'PlainUserNameRenderer', + __module_component_UserFragment: + 'PlainUserNameRenderer.react', + __module_operation_UserFragment: + 'PlainUserNameRenderer_name$normalization.graphql', + plaintext: 'Plaintext 2', + data: { + text: 'Data Text 2', + }, + }, + plainNameRenderer: { + __typename: 'PlainUserNameRenderer', + __module_component_UserFragment: + 'PlainUserNameRenderer.react', + __module_operation_UserFragment: + 'PlainUserNameRenderer_name$normalization.graphql', + plaintext: 'Plaintext 2', + data: { + text: 'Data Text 2', + }, + }, + }, + }, + ], + }, + }, + }, + }); + expect(operationLoader.load).toBeCalledTimes(2); + const fragmentRef = { + __id: + 'client:user-id-2:nameRenderer(supported:["PlainUserNameRenderer"])', + __fragments: { + PlainUserNameRenderer_name: {}, + }, + __fragmentOwner: viewerOperation.request, + }; + let promiseThrown = false; + try { + FragmentResource.read( + PlainUserNameRenderer_name, + fragmentRef, + componentName, + ); + } catch (promise) { + expect(promise).toBeInstanceOf(Promise); + promiseThrown = true; + } + expect(promiseThrown).toBe(true); + + // Complete the request + environment.mock.complete(paginationOperation); + // This should resolve promises + jest.runAllTimers(); + + const snapshot = FragmentResource.read( + PlainUserNameRenderer_name, + fragmentRef, + componentName, + ); + expect(snapshot.data).toEqual({ + data: { + text: 'Data Text 2', + }, + plaintext: 'Plaintext 2', + }); + }); +}); diff --git a/packages/relay-experimental/__tests__/FragmentResource-test.js b/packages/relay-experimental/__tests__/FragmentResource-test.js new file mode 100644 index 000000000000..60ce5b14bd3c --- /dev/null +++ b/packages/relay-experimental/__tests__/FragmentResource-test.js @@ -0,0 +1,1293 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails oncall+relay + * @flow + * @format + */ + +'use strict'; + +jest.mock('relay-runtime', () => { + const originalRuntime = jest.requireActual('relay-runtime'); + const originalInternal = originalRuntime.__internal; + return { + ...originalRuntime, + __internal: { + ...originalInternal, + getPromiseForRequestInFlight: jest.fn(), + }, + }; +}); + +const {getFragmentResourceForEnvironment} = require('../FragmentResource'); +const { + __internal: {getPromiseForRequestInFlight}, + createOperationDescriptor, + getFragment, +} = require('relay-runtime'); + +describe('FragmentResource', () => { + let environment; + let query; + let queryMissingData; + let queryPlural; + let FragmentResource; + let createMockEnvironment; + let generateAndCompile; + let UserQuery; + let UserFragment; + let UserQueryMissing; + let UserFragmentMissing; + let UsersQuery; + let UsersFragment; + const variables = { + id: '4', + }; + const pluralVariables = {ids: ['4']}; + const componentDisplayName = 'TestComponent'; + + beforeEach(() => { + // jest.resetModules(); + + ({ + createMockEnvironment, + generateAndCompile, + } = require('relay-test-utils-internal')); + + environment = createMockEnvironment(); + FragmentResource = getFragmentResourceForEnvironment(environment); + + ({UserQuery, UserFragment} = generateAndCompile( + ` + fragment UserFragment on User { + id + name + } + query UserQuery($id: ID!) { + node(id: $id) { + __typename + ...UserFragment + } + } + `, + )); + + ({ + UserQuery: UserQueryMissing, + UserFragment: UserFragmentMissing, + } = generateAndCompile( + ` + fragment UserFragment on User { + id + name + username + } + query UserQuery($id: ID!) { + node(id: $id) { + __typename + ...UserFragment + } + } + `, + )); + + ({UsersQuery, UsersFragment} = generateAndCompile( + ` + fragment UsersFragment on User @relay(plural: true) { + id + name + } + query UsersQuery($ids: [ID!]!) { + nodes(ids: $ids) { + __typename + ...UsersFragment + } + } + `, + )); + + query = createOperationDescriptor(UserQuery, variables); + queryMissingData = createOperationDescriptor(UserQueryMissing, variables); + queryPlural = createOperationDescriptor(UsersQuery, pluralVariables); + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '4', + name: 'Mark', + }, + }); + }); + + afterEach(() => { + (getPromiseForRequestInFlight: any).mockReset(); + }); + + describe('read', () => { + it('should read data for the fragment when all data is available', () => { + const result = FragmentResource.read( + getFragment(UserFragment), + { + __id: '4', + __fragments: { + UserFragment: {}, + }, + __fragmentOwner: query.request, + }, + componentDisplayName, + ); + expect(result.data).toEqual({id: '4', name: 'Mark'}); + }); + + it('should read data for plural fragment when all data is available', () => { + const result = FragmentResource.read( + getFragment(UsersFragment), + [ + { + __id: '4', + __fragments: { + UsersFragment: {}, + }, + __fragmentOwner: queryPlural.request, + }, + ], + componentDisplayName, + ); + expect(result.data).toEqual([{id: '4', name: 'Mark'}]); + }); + + it('should return empty array for plural fragment when plural field is empty', () => { + const {UsersFragment} = generateAndCompile( + ` + fragment UsersFragment on User @relay(plural: true) { + id + } + query UsersQuery($ids: [ID!]!) { + nodes(ids: $ids) { + __typename + ...UsersFragment + } + } + `, + ); + + const result = FragmentResource.read( + getFragment(UsersFragment), + [], + componentDisplayName, + ); + expect(result.data).toEqual([]); + }); + + it('should correctly read fragment data when dataID changes', () => { + let result = FragmentResource.read( + getFragment(UserFragment), + { + __id: '4', + __fragments: { + UserFragment: {}, + }, + __fragmentOwner: query.request, + }, + componentDisplayName, + ); + expect(result.data).toEqual({id: '4', name: 'Mark'}); + + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '5', + name: 'User 5', + }, + }); + + result = FragmentResource.read( + getFragment(UserFragment), + { + __id: '5', + __fragments: { + UserFragment: {}, + }, + __fragmentOwner: query.request, + }, + componentDisplayName, + ); + expect(result.data).toEqual({id: '5', name: 'User 5'}); + }); + + it('should correctly read fragment data when variables used by fragment change', () => { + ({UserQuery, UserFragment} = generateAndCompile( + ` + fragment UserFragment on Query { + node(id: $id) { + __typename + id + name + } + } + query UserQuery($id: ID!) { + ...UserFragment + } + `, + )); + const prevVars = {id: '4'}; + query = createOperationDescriptor(UserQuery, prevVars); + let result = FragmentResource.read( + getFragment(UserFragment), + { + __id: 'client:root', + __fragments: { + UserFragment: {}, + }, + __fragmentOwner: query.request, + }, + componentDisplayName, + ); + expect(result.data).toEqual({ + node: {__typename: 'User', id: '4', name: 'Mark'}, + }); + + const nextVars = {id: '5'}; + query = createOperationDescriptor(UserQuery, nextVars); + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '5', + name: 'User 5', + }, + }); + + result = FragmentResource.read( + getFragment(UserFragment), + { + __id: 'client:root', + __fragments: { + UserFragment: {}, + }, + __fragmentOwner: query.request, + }, + componentDisplayName, + ); + expect(result.data).toEqual({ + node: {__typename: 'User', id: '5', name: 'User 5'}, + }); + }); + + it( + 'should correctly read fragment data when variables used by fragment ' + + 'in @argumentDefinitions change', + () => { + ({UserQuery, UserFragment} = generateAndCompile( + ` + fragment UserFragment on Query @argumentDefinitions(id: {type: "ID!"}) { + node(id: $id) { + __typename + id + name + } + } + query UserQuery($id: ID!) { + ...UserFragment @arguments(id: $id) + } + `, + )); + const prevVars = {id: '4'}; + query = createOperationDescriptor(UserQuery, prevVars); + let result = FragmentResource.read( + getFragment(UserFragment), + { + __id: 'client:root', + __fragments: { + UserFragment: prevVars, + }, + __fragmentOwner: query.request, + }, + componentDisplayName, + ); + expect(result.data).toEqual({ + node: {__typename: 'User', id: '4', name: 'Mark'}, + }); + + const nextVars = {id: '5'}; + query = createOperationDescriptor(UserQuery, nextVars); + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '5', + name: 'User 5', + }, + }); + + result = FragmentResource.read( + getFragment(UserFragment), + { + __id: 'client:root', + __fragments: { + UserFragment: nextVars, + }, + __fragmentOwner: query.request, + }, + componentDisplayName, + ); + expect(result.data).toEqual({ + node: {__typename: 'User', id: '5', name: 'User 5'}, + }); + }, + ); + + it( + 'should correctly read fragment data when fragment owner variables ' + + 'change', + () => { + ({UserQuery, UserFragment} = generateAndCompile( + ` + fragment UserFragment on User { + id + name + } + query UserQuery($id: ID!, $foo: Boolean!) { + node(id: $id) { + __typename + ...UserFragment + } + } + `, + )); + const variablesWithFoo = { + id: '4', + foo: false, + }; + query = createOperationDescriptor(UserQuery, variablesWithFoo); + + const environmentSpy = jest.spyOn(environment, 'lookup'); + let result = FragmentResource.read( + getFragment(UserFragment), + { + __id: '4', + __fragments: { + UserFragment: {}, + }, + __fragmentOwner: query.request, + }, + componentDisplayName, + ); + expect(result.data).toEqual({id: '4', name: 'Mark'}); + + const nextVars = { + ...variablesWithFoo, + // Change value of $foo + foo: true, + }; + query = createOperationDescriptor(UserQuery, nextVars); + result = FragmentResource.read( + getFragment(UserFragment), + { + __id: '4', + __fragments: { + UserFragment: {}, + }, + __fragmentOwner: query.request, + }, + componentDisplayName, + ); + expect(result.data).toEqual({id: '4', name: 'Mark'}); + + // Even if variable $foo isn't directly used by the fragment, the cache + // key for the fragment should still change since $foo might affect + // descendants of this fragment; if we return a cached value, the + // fragment ref we pass to our children might be stale. + expect(environmentSpy).toHaveBeenCalledTimes(2); + environmentSpy.mockRestore(); + }, + ); + + it('should return null data if fragment reference is not provided', () => { + const result = FragmentResource.read( + getFragment(UserFragment), + null, + componentDisplayName, + ); + expect(result.data).toBe(null); + }); + + it('should throw and cache promise if reading missing data and network request for parent query is in flight', () => { + (getPromiseForRequestInFlight: any).mockReturnValue(Promise.resolve()); + const fragmentNode = getFragment(UserFragmentMissing); + const fragmentRef = { + __id: '4', + __fragments: { + UserFragment: {}, + }, + __fragmentOwner: queryMissingData.request, + }; + + // Try reading a fragment while parent query is in flight + let thrown = null; + try { + FragmentResource.read(fragmentNode, fragmentRef, componentDisplayName); + } catch (p) { + expect(p).toBeInstanceOf(Promise); + thrown = p; + } + // Assert that promise for request in flight is thrown + expect(thrown).not.toBe(null); + + // Try reading a fragment a second time while parent query is in flight + let cached = null; + try { + FragmentResource.read(fragmentNode, fragmentRef, componentDisplayName); + } catch (p) { + expect(p).toBeInstanceOf(Promise); + cached = p; + } + // Assert that promise from first read was cached + expect(cached).toBe(thrown); + }); + + it('should raise a warning if data is missing and no pending requests', () => { + jest.spyOn(console, 'error').mockImplementationOnce(() => {}); + FragmentResource.read( + getFragment(UserFragmentMissing), + { + __id: '4', + __fragments: { + UserFragment: {}, + }, + __fragmentOwner: queryMissingData.request, + }, + componentDisplayName, + ); + + expect(console.error).toHaveBeenCalledTimes(1); + // $FlowFixMe + const warningMessage = console.error.mock.calls[0][0]; + expect( + warningMessage.startsWith( + 'Warning: Relay: Tried reading fragment `UserFragment` ' + + 'declared in `TestComponent`, but it has ' + + 'missing data and its parent query `UserQuery` is not being fetched.', + ), + ).toEqual(true); + // $FlowFixMe + console.error.mockClear(); + }); + }); + + describe('readSpec', () => { + it('should read data for the fragment when all data is available', () => { + const result = FragmentResource.readSpec( + { + user: getFragment(UserFragment), + user2: getFragment(UserFragment), + }, + { + user: { + __id: '4', + __fragments: { + UserFragment: {}, + }, + __fragmentOwner: query.request, + }, + user2: null, + }, + componentDisplayName, + ); + expect(result.user.data).toEqual({id: '4', name: 'Mark'}); + expect(result.user2.data).toEqual(null); + }); + + it('should throw and cache promise if reading missing data and network request for parent query is in flight', () => { + (getPromiseForRequestInFlight: any).mockReturnValueOnce( + Promise.resolve(), + ); + const fragmentNodes = { + user: getFragment(UserFragmentMissing), + }; + const fragmentRefs = { + user: { + __id: '4', + __fragments: { + UserFragment: {}, + }, + __fragmentOwner: queryMissingData.request, + }, + }; + + // Try reading a fragment while parent query is in flight + let thrown = null; + try { + FragmentResource.readSpec( + fragmentNodes, + fragmentRefs, + componentDisplayName, + ); + } catch (p) { + expect(p).toBeInstanceOf(Promise); + thrown = p; + } + // Assert that promise for request in flight is thrown + expect(thrown).not.toBe(null); + + // Try reading a fragment a second time while parent query is in flight + let cached = null; + try { + FragmentResource.readSpec( + fragmentNodes, + fragmentRefs, + componentDisplayName, + ); + } catch (p) { + expect(p).toBeInstanceOf(Promise); + cached = p; + } + // Assert that promise from first read was cached + expect(cached).toBe(thrown); + }); + }); + + describe('subscribe', () => { + let callback; + beforeEach(() => { + callback = jest.fn(); + }); + + it('subscribes to the fragment that was `read`', () => { + let result = FragmentResource.read( + getFragment(UserFragment), + { + __id: '4', + __fragments: { + UserFragment: {}, + }, + __fragmentOwner: query.request, + }, + componentDisplayName, + ); + expect(environment.subscribe).toHaveBeenCalledTimes(0); + + const disposable = FragmentResource.subscribe(result, callback); + expect(environment.subscribe).toBeCalledTimes(1); + expect(environment.subscribe.mock.dispose).toBeCalledTimes(0); + + // Update data + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '4', + name: 'Mark Updated', + }, + }); + + // Assert that callback gets update + expect(callback).toBeCalledTimes(1); + + // Assert that reading the result again will reflect the latest value + result = FragmentResource.read( + getFragment(UserFragment), + { + __id: '4', + __fragments: { + UserFragment: {}, + }, + __fragmentOwner: query.request, + }, + componentDisplayName, + ); + expect(result.data).toEqual({id: '4', name: 'Mark Updated'}); + + disposable.dispose(); + expect(environment.subscribe).toBeCalledTimes(1); + expect(environment.subscribe.mock.dispose).toBeCalledTimes(1); + }); + + it('immediately notifies of data updates that were missed between calling `read` and `subscribe`', () => { + let result = FragmentResource.read( + getFragment(UserFragment), + { + __id: '4', + __fragments: { + UserFragment: {}, + }, + __fragmentOwner: query.request, + }, + componentDisplayName, + ); + expect(environment.subscribe).toHaveBeenCalledTimes(0); + + // Update data once, before subscribe has been called + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '4', + name: 'Mark Updated 1', + }, + }); + + const disposable = FragmentResource.subscribe(result, callback); + expect(environment.subscribe).toBeCalledTimes(1); + expect(environment.subscribe.mock.dispose).toBeCalledTimes(0); + + // Assert that callback was immediately called + expect(callback).toBeCalledTimes(1); + + // Assert that reading the result again will reflect the latest value + result = FragmentResource.read( + getFragment(UserFragment), + { + __id: '4', + __fragments: { + UserFragment: {}, + }, + __fragmentOwner: query.request, + }, + componentDisplayName, + ); + expect(result.data).toEqual({id: '4', name: 'Mark Updated 1'}); + + // Update data again + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '4', + name: 'Mark Updated 2', + }, + }); + + // Assert that callback gets update + expect(callback).toBeCalledTimes(2); + + // Assert that reading the result again will reflect the latest value + result = FragmentResource.read( + getFragment(UserFragment), + { + __id: '4', + __fragments: { + UserFragment: {}, + }, + __fragmentOwner: query.request, + }, + componentDisplayName, + ); + expect(result.data).toEqual({id: '4', name: 'Mark Updated 2'}); + + disposable.dispose(); + expect(environment.subscribe).toBeCalledTimes(1); + expect(environment.subscribe.mock.dispose).toBeCalledTimes(1); + }); + + it('immediately notifies of data updates that were missed between calling `read` and `subscribe` (revert to original value)', () => { + let result = FragmentResource.read( + getFragment(UserFragment), + { + __id: '4', + __fragments: { + UserFragment: {}, + }, + __fragmentOwner: query.request, + }, + componentDisplayName, + ); + expect(environment.subscribe).toHaveBeenCalledTimes(0); + + // Update data once, before subscribe has been called + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '4', + name: 'Mark Updated 1', + }, + }); + + const disposable = FragmentResource.subscribe(result, callback); + expect(environment.subscribe).toBeCalledTimes(1); + expect(environment.subscribe.mock.dispose).toBeCalledTimes(0); + + // Assert that callback was immediately called + expect(callback).toBeCalledTimes(1); + + // Assert that reading the result again will reflect the latest value + result = FragmentResource.read( + getFragment(UserFragment), + { + __id: '4', + __fragments: { + UserFragment: {}, + }, + __fragmentOwner: query.request, + }, + componentDisplayName, + ); + expect(result.data).toEqual({id: '4', name: 'Mark Updated 1'}); + + // Update data again + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '4', + name: 'Mark', // original value + }, + }); + + // Assert that callback gets update + expect(callback).toBeCalledTimes(2); + + // Assert that reading the result again will reflect the latest value + result = FragmentResource.read( + getFragment(UserFragment), + { + __id: '4', + __fragments: { + UserFragment: {}, + }, + __fragmentOwner: query.request, + }, + componentDisplayName, + ); + expect(result.data).toEqual({id: '4', name: 'Mark'}); + + disposable.dispose(); + expect(environment.subscribe).toBeCalledTimes(1); + expect(environment.subscribe.mock.dispose).toBeCalledTimes(1); + }); + + it("doesn't subscribe when result was null", () => { + const result = FragmentResource.read( + getFragment(UserFragment), + null, + componentDisplayName, + ); + expect(environment.subscribe).toHaveBeenCalledTimes(0); + + const disposable = FragmentResource.subscribe(result, callback); + expect(environment.subscribe).toBeCalledTimes(0); + expect(callback).toBeCalledTimes(0); + + disposable.dispose(); + expect(environment.subscribe).toBeCalledTimes(0); + expect(callback).toBeCalledTimes(0); + }); + + it("doesn't subscribe when result was empty", () => { + const result = FragmentResource.read( + getFragment(UsersFragment), + [], + componentDisplayName, + ); + expect(environment.subscribe).toHaveBeenCalledTimes(0); + + const disposable = FragmentResource.subscribe(result, callback); + expect(environment.subscribe).toBeCalledTimes(0); + expect(callback).toBeCalledTimes(0); + + disposable.dispose(); + expect(environment.subscribe).toBeCalledTimes(0); + expect(callback).toBeCalledTimes(0); + }); + + describe('when subscribing multiple times to the same fragment', () => { + it('maintains subscription even if one of the fragments is disposed of', () => { + const fragmentNode = getFragment(UserFragment); + const fragmentRef = { + __id: '4', + __fragments: { + UserFragment: {}, + }, + __fragmentOwner: query.request, + }; + const callback1 = jest.fn(); + const callback2 = jest.fn(); + + let result = FragmentResource.read( + fragmentNode, + fragmentRef, + componentDisplayName, + ); + expect(environment.subscribe).toHaveBeenCalledTimes(0); + + const disposable1 = FragmentResource.subscribe(result, callback1); + expect(environment.subscribe).toBeCalledTimes(1); + expect(environment.subscribe.mock.dispose).toBeCalledTimes(0); + + const disposable2 = FragmentResource.subscribe(result, callback2); + expect(environment.subscribe).toBeCalledTimes(2); + expect(environment.subscribe.mock.dispose).toBeCalledTimes(0); + + // Update data once + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '4', + name: 'Mark Update 1', + }, + }); + + // Assert that both callbacks receive update + expect(callback1).toBeCalledTimes(1); + expect(callback2).toBeCalledTimes(1); + + // Assert that reading the result again will reflect the latest value + result = FragmentResource.read( + fragmentNode, + fragmentRef, + componentDisplayName, + ); + expect(result.data).toEqual({id: '4', name: 'Mark Update 1'}); + + // Unsubscribe the second listener + disposable2.dispose(); + expect(environment.subscribe).toBeCalledTimes(2); + expect(environment.subscribe.mock.dispose).toBeCalledTimes(1); + + // Update data again + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '4', + name: 'Mark Update 2', + }, + }); + + // Assert that subscription that hasn't been disposed receives update + expect(callback1).toBeCalledTimes(2); + + // Assert that subscription that was already disposed isn't called again + expect(callback2).toBeCalledTimes(1); + + // Assert that reading the result again will reflect the latest value + result = FragmentResource.read( + fragmentNode, + fragmentRef, + componentDisplayName, + ); + expect(result.data).toEqual({id: '4', name: 'Mark Update 2'}); + + disposable1.dispose(); + expect(environment.subscribe).toBeCalledTimes(2); + }); + }); + + describe('when subscribing to plural fragment', () => { + it('subscribes to the plural fragment that was `read`', () => { + const fragmentNode = getFragment(UsersFragment); + const fragmentRef = [ + { + __id: '4', + __fragments: { + UsersFragment: {}, + }, + __fragmentOwner: queryPlural.request, + }, + ]; + let result = FragmentResource.read( + fragmentNode, + fragmentRef, + componentDisplayName, + ); + expect(environment.subscribe).toHaveBeenCalledTimes(0); + + const disposable = FragmentResource.subscribe(result, callback); + expect(environment.subscribe).toBeCalledTimes(1); + expect(environment.subscribe.mock.dispose).toBeCalledTimes(0); + + // Update data + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '4', + name: 'Mark Updated', + }, + }); + + // Assert that callback gets update + expect(callback).toBeCalledTimes(1); + + // Assert that reading the result again will reflect the latest value + result = FragmentResource.read( + fragmentNode, + fragmentRef, + componentDisplayName, + ); + expect(result.data).toEqual([{id: '4', name: 'Mark Updated'}]); + + disposable.dispose(); + expect(environment.subscribe).toBeCalledTimes(1); + expect(environment.subscribe.mock.dispose).toBeCalledTimes(1); + }); + + it('immediately notifies of data updates that were missed between calling `read` and `subscribe`', () => { + const fragmentNode = getFragment(UsersFragment); + const fragmentRef = [ + { + __id: '4', + __fragments: { + UsersFragment: {}, + }, + __fragmentOwner: queryPlural.request, + }, + ]; + let result = FragmentResource.read( + fragmentNode, + fragmentRef, + componentDisplayName, + ); + expect(environment.subscribe).toHaveBeenCalledTimes(0); + + // Update data once, before subscribe has been called + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '4', + name: 'Mark Updated 1', + }, + }); + + const disposable = FragmentResource.subscribe(result, callback); + expect(environment.subscribe).toBeCalledTimes(1); + expect(environment.subscribe.mock.dispose).toBeCalledTimes(0); + + // Assert that callback was immediately called + expect(callback).toBeCalledTimes(1); + + // Assert that reading the result again will reflect the latest value + result = FragmentResource.read( + fragmentNode, + fragmentRef, + componentDisplayName, + ); + expect(result.data).toEqual([{id: '4', name: 'Mark Updated 1'}]); + + // Update data again + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '4', + name: 'Mark Updated 2', + }, + }); + + // Assert that callback gets update + expect(callback).toBeCalledTimes(2); + + // Assert that reading the result again will reflect the latest value + result = FragmentResource.read( + fragmentNode, + fragmentRef, + componentDisplayName, + ); + expect(result.data).toEqual([{id: '4', name: 'Mark Updated 2'}]); + + disposable.dispose(); + expect(environment.subscribe).toBeCalledTimes(1); + expect(environment.subscribe.mock.dispose).toBeCalledTimes(1); + }); + + it('correctly subscribes to a plural fragment with multiple records', () => { + queryPlural = createOperationDescriptor(UsersQuery, {ids: ['4', '5']}); + environment.commitPayload(queryPlural, { + nodes: [ + { + __typename: 'User', + id: '4', + name: 'Mark', + }, + { + __typename: 'User', + id: '5', + name: 'User 5', + }, + ], + }); + const fragmentNode = getFragment(UsersFragment); + const fragmentRef = [ + { + __id: '4', + __fragments: { + UsersFragment: {}, + }, + __fragmentOwner: queryPlural.request, + }, + { + __id: '5', + __fragments: { + UsersFragment: {}, + }, + __fragmentOwner: queryPlural.request, + }, + ]; + + let result = FragmentResource.read( + fragmentNode, + fragmentRef, + componentDisplayName, + ); + expect(environment.subscribe).toHaveBeenCalledTimes(0); + + const disposable = FragmentResource.subscribe(result, callback); + expect(environment.subscribe).toBeCalledTimes(2); + expect(environment.subscribe.mock.dispose).toBeCalledTimes(0); + + // Update data + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '4', + name: 'Mark Updated', + }, + }); + + // Assert that callback gets update + expect(callback).toBeCalledTimes(1); + + // Assert that reading the result again will reflect the latest value + result = FragmentResource.read( + fragmentNode, + fragmentRef, + componentDisplayName, + ); + expect(result.data).toEqual([ + {id: '4', name: 'Mark Updated'}, + {id: '5', name: 'User 5'}, + ]); + + disposable.dispose(); + expect(environment.subscribe).toBeCalledTimes(2); + expect(environment.subscribe.mock.dispose).toBeCalledTimes(1); + }); + + it('immediately notifies of data updates that were missed between calling `read` and `subscribe` when subscribing to multiple records', () => { + queryPlural = createOperationDescriptor(UsersQuery, {ids: ['4', '5']}); + environment.commitPayload(queryPlural, { + nodes: [ + { + __typename: 'User', + id: '4', + name: 'Mark', + }, + { + __typename: 'User', + id: '5', + name: 'User 5', + }, + { + __typename: 'User', + id: '6', + name: 'User 6', + }, + ], + }); + const fragmentNode = getFragment(UsersFragment); + const fragmentRef = [ + { + __id: '4', + __fragments: { + UsersFragment: {}, + }, + __fragmentOwner: queryPlural.request, + }, + { + __id: '5', + __fragments: { + UsersFragment: {}, + }, + __fragmentOwner: queryPlural.request, + }, + { + __id: '6', + __fragments: { + UsersFragment: {}, + }, + __fragmentOwner: queryPlural.request, + }, + ]; + + let result = FragmentResource.read( + fragmentNode, + fragmentRef, + componentDisplayName, + ); + expect(environment.subscribe).toHaveBeenCalledTimes(0); + + // Update data once, before subscribe has been called + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '4', + name: 'Mark Updated 1', + }, + }); + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '6', + name: 'User 6 Updated', + }, + }); + + const disposable = FragmentResource.subscribe(result, callback); + expect(environment.subscribe).toBeCalledTimes(3); + expect(environment.subscribe.mock.dispose).toBeCalledTimes(0); + + // Assert that callback was immediately called + expect(callback).toBeCalledTimes(1); + + // Assert that reading the result again will reflect the latest value + result = FragmentResource.read( + fragmentNode, + fragmentRef, + componentDisplayName, + ); + expect(result.data).toEqual([ + {id: '4', name: 'Mark Updated 1'}, + {id: '5', name: 'User 5'}, + {id: '6', name: 'User 6 Updated'}, + ]); + + // Update data again + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '4', + name: 'Mark Updated 2', + }, + }); + + // Assert that callback gets update + expect(callback).toBeCalledTimes(2); + + // Assert that reading the result again will reflect the latest value + result = FragmentResource.read( + fragmentNode, + fragmentRef, + componentDisplayName, + ); + expect(result.data).toEqual([ + {id: '4', name: 'Mark Updated 2'}, + {id: '5', name: 'User 5'}, + {id: '6', name: 'User 6 Updated'}, + ]); + + disposable.dispose(); + expect(environment.subscribe).toBeCalledTimes(3); + expect(environment.subscribe.mock.dispose).toBeCalledTimes(1); + }); + + it('immediately notifies of data updates that were missed between calling `read` and `subscribe` when subscribing to multiple records (revert to original)', () => { + queryPlural = createOperationDescriptor(UsersQuery, {ids: ['4']}); + environment.commitPayload(queryPlural, { + nodes: [ + { + __typename: 'User', + id: '4', + name: 'Mark', + }, + ], + }); + const fragmentNode = getFragment(UsersFragment); + const fragmentRef = [ + { + __id: '4', + __fragments: { + UsersFragment: {}, + }, + __fragmentOwner: queryPlural.request, + }, + ]; + + let result = FragmentResource.read( + fragmentNode, + fragmentRef, + componentDisplayName, + ); + expect(environment.subscribe).toHaveBeenCalledTimes(0); + + // Update data once, before subscribe has been called + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '4', + name: 'Mark Updated 1', + }, + }); + + const disposable = FragmentResource.subscribe(result, callback); + expect(environment.subscribe).toBeCalledTimes(1); + expect(environment.subscribe.mock.dispose).toBeCalledTimes(0); + + // Assert that callback was immediately called + expect(callback).toBeCalledTimes(1); + + // Assert that reading the result again will reflect the latest value + result = FragmentResource.read( + fragmentNode, + fragmentRef, + componentDisplayName, + ); + expect(result.data).toEqual([{id: '4', name: 'Mark Updated 1'}]); + + // Update data again + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '4', + name: 'Mark', // revert to original + }, + }); + + // Assert that callback gets update + expect(callback).toBeCalledTimes(2); + + // Assert that reading the result again will reflect the latest value + result = FragmentResource.read( + fragmentNode, + fragmentRef, + componentDisplayName, + ); + expect(result.data).toEqual([{id: '4', name: 'Mark'}]); + + disposable.dispose(); + expect(environment.subscribe).toBeCalledTimes(1); + expect(environment.subscribe.mock.dispose).toBeCalledTimes(1); + }); + }); + }); + + describe('subscribeSpec', () => { + let unsubscribe; + let callback; + beforeEach(() => { + unsubscribe = jest.fn(); + callback = jest.fn(); + jest.spyOn(environment, 'subscribe').mockImplementation(() => ({ + dispose: unsubscribe, + })); + }); + + it('subscribes to the fragment spec that was `read`', () => { + const result = FragmentResource.readSpec( + {user: getFragment(UserFragment)}, + { + user: { + __id: '4', + __fragments: { + UserFragment: {}, + }, + __fragmentOwner: query.request, + }, + }, + componentDisplayName, + ); + expect(environment.subscribe).toHaveBeenCalledTimes(0); + + const disposable = FragmentResource.subscribeSpec(result, callback); + expect(unsubscribe).toBeCalledTimes(0); + expect(environment.subscribe).toBeCalledTimes(1); + + disposable.dispose(); + expect(unsubscribe).toBeCalledTimes(1); + expect(environment.subscribe).toBeCalledTimes(1); + }); + }); +}); diff --git a/packages/relay-experimental/__tests__/LRUCache-test.js b/packages/relay-experimental/__tests__/LRUCache-test.js new file mode 100644 index 000000000000..1466a0dee208 --- /dev/null +++ b/packages/relay-experimental/__tests__/LRUCache-test.js @@ -0,0 +1,76 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 + * @emails oncall+relay + * @format + */ + +'use strict'; + +const LRUCache = require('../LRUCache'); + +const invariant = require('invariant'); + +test('LRUCache', () => { + const testInstance = LRUCache.create(3); + + const testCases = [ + ['', null, 'size', 0], + ['', null, 'capacity', 3], + ['a', 1, 'set', undefined], + ['b', 2, 'set', undefined], + ['c', 3, 'set', undefined], + ['c', null, 'get', 3], + ['d', 4, 'set', undefined], + ['', null, 'size', 3], + ['a', null, 'get', undefined], + ['b', null, 'get', 2], + ['b', null, 'delete', undefined], + ['', null, 'size', 2], + ['', null, 'capacity', 1], + ['b', null, 'has', false], + ['b', 2, 'set', undefined], + ['b', null, 'has', true], + ['a', 1, 'set', undefined], + ['e', 5, 'set', undefined], + ['f', 6, 'set', undefined], + ['b', null, 'has', false], + ['a', null, 'has', true], + ['e', null, 'has', true], + ['f', null, 'has', true], + ]; + + for (const testCase of testCases) { + const [key, value, method, expected] = testCase; + let result; + switch (method) { + case 'set': + if (value != null) { + result = testInstance.set(key, value); + } + break; + case 'get': + result = testInstance.get(key); + break; + case 'has': + result = testInstance.has(key); + break; + case 'size': + result = testInstance.size(); + break; + case 'delete': + result = testInstance.delete(key); + break; + case 'capacity': + result = testInstance.capacity(); + break; + default: + invariant(false, 'Test case for method %s is not available.', method); + } + expect(result).toEqual(expected); + } +}); diff --git a/packages/relay-experimental/__tests__/MatchContainer-test.js b/packages/relay-experimental/__tests__/MatchContainer-test.js new file mode 100644 index 000000000000..7023f82a36be --- /dev/null +++ b/packages/relay-experimental/__tests__/MatchContainer-test.js @@ -0,0 +1,459 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails oncall+relay + * @flow + * @format + */ + +'use strict'; + +const MatchContainer = require('../MatchContainer'); +const React = require('react'); +const TestRenderer = require('react-test-renderer'); + +const {FRAGMENT_OWNER_KEY, FRAGMENTS_KEY, ID_KEY} = require('relay-runtime'); + +import type {MatchPointer} from '../MatchContainer'; + +function createMatchPointer({ + id, + fragment, + variables, + propName, + module, +}): MatchPointer { + const pointer = { + $fragmentRefs: {}, + [ID_KEY]: id, + [FRAGMENTS_KEY]: {}, + [FRAGMENT_OWNER_KEY]: null, + __fragmentPropName: propName, + __module_component: module, + }; + if (fragment != null && variables != null) { + pointer[FRAGMENTS_KEY][fragment.name] = variables; + } + return pointer; +} + +describe('MatchContainer', () => { + let ActorComponent; + let UserComponent; + let loader; + + beforeEach(() => { + jest.resetModules(); + + loader = jest.fn(); + UserComponent = jest.fn(props => ( +
+

User

+
{JSON.stringify(props, null, 2)}
+
+ )); + ActorComponent = jest.fn(props => ( +
+

Actor

+
{JSON.stringify(props, null, 2)}
+
+ )); + }); + + it('throws when match prop is null', () => { + // This prevents console.error output in the test, which is expected + jest.spyOn(console, 'error').mockImplementationOnce(() => {}); + expect(() => { + TestRenderer.create( + , + ); + }).toThrow( + 'MatchContainer: Expected `match` value to be an object or null/undefined.', + ); + }); + + it('loads and renders dynamic components', () => { + loader.mockReturnValue(React.memo((UserComponent: $FlowFixMe))); + const match = createMatchPointer({ + id: '4', + fragment: {name: 'UserFragment'}, + variables: {}, + propName: 'user', + module: 'UserContainer.react', + }); + const renderer = TestRenderer.create( + , + ); + expect(renderer.toJSON()).toMatchSnapshot(); + expect(loader).toBeCalledTimes(1); + expect(UserComponent).toBeCalledTimes(1); + }); + + it('reloads if new props have a different component', () => { + loader.mockReturnValue(React.memo((UserComponent: $FlowFixMe))); + const match = createMatchPointer({ + id: '4', + fragment: {name: 'UserFragment'}, + variables: {}, + propName: 'user', + module: 'UserContainer.react', + }); + const renderer = TestRenderer.create( + , + ); + loader.mockReturnValue(React.memo((ActorComponent: $FlowFixMe))); + const match2 = createMatchPointer({ + id: '4', + fragment: {name: 'ActorFragment'}, + variables: {}, + propName: 'actor', + module: 'ActorContainer.react', + }); + renderer.update( + , + ); + expect(renderer.toJSON()).toMatchSnapshot(); + expect(loader).toBeCalledTimes(2); + expect(UserComponent).toBeCalledTimes(1); + expect(ActorComponent).toBeCalledTimes(1); + }); + + it('calls load again when re-rendered, even with the same component', () => { + loader.mockReturnValue(React.memo((UserComponent: $FlowFixMe))); + const match = createMatchPointer({ + id: '4', + fragment: {name: 'UserFragment'}, + variables: {}, + propName: 'user', + module: 'UserContainer.react', + }); + const renderer = TestRenderer.create( + , + ); + const match2 = {...match, __id: '0'}; + renderer.update( + , + ); + expect(renderer.toJSON()).toMatchSnapshot(); + // We expect loader to already be caching module results + expect(loader).toBeCalledTimes(2); + expect(UserComponent).toBeCalledTimes(2); + expect(ActorComponent).toBeCalledTimes(0); + }); + + it('passes the same child props when the match values does not change', () => { + loader.mockReturnValue(React.memo((UserComponent: $FlowFixMe))); + const match = createMatchPointer({ + id: '4', + fragment: {name: 'UserFragment'}, + variables: {}, + propName: 'user', + module: 'UserContainer.react', + }); + const otherProps = {otherProp: 'hello!'}; + const renderer = TestRenderer.create( + , + ); + const match2 = {...match}; + renderer.update( + , + ); + expect(renderer.toJSON()).toMatchSnapshot(); + expect(loader).toBeCalledTimes(2); + expect(UserComponent).toBeCalledTimes(1); + }); + + it('renders the fallback if the match object is empty', () => { + loader.mockReturnValue(React.memo((UserComponent: $FlowFixMe))); + const otherProps = {otherProp: 'hello!'}; + const Fallback = (jest.fn(() =>
fallback
): $FlowFixMe); + const renderer = TestRenderer.create( + : $FlowFixMe)} + />, + ); + expect(renderer.toJSON()).toMatchSnapshot(); + expect(loader).toBeCalledTimes(0); + expect(UserComponent).toBeCalledTimes(0); + expect(ActorComponent).toBeCalledTimes(0); + expect(Fallback).toBeCalledTimes(1); + }); + + it('renders the fallback if the match object is missing expected fields', () => { + loader.mockReturnValue(React.memo((UserComponent: $FlowFixMe))); + const otherProps = {otherProp: 'hello!'}; + const Fallback = (jest.fn(() =>
fallback
): $FlowFixMe); + const renderer = TestRenderer.create( + : $FlowFixMe)} + />, + ); + expect(renderer.toJSON()).toMatchSnapshot(); + expect(loader).toBeCalledTimes(0); + expect(UserComponent).toBeCalledTimes(0); + expect(ActorComponent).toBeCalledTimes(0); + expect(Fallback).toBeCalledTimes(1); + }); + + it('throws if the match object is invalid (__id)', () => { + jest.spyOn(console, 'error').mockImplementationOnce(() => {}); + loader.mockReturnValue(React.memo((UserComponent: $FlowFixMe))); + const otherProps = {otherProp: 'hello!'}; + const Fallback = (jest.fn(() =>
fallback
): $FlowFixMe); + expect(() => { + TestRenderer.create( + : $FlowFixMe)} + />, + ); + }).toThrow( + "MatchContainer: Invalid 'match' value, expected an object that has a '...SomeFragment' spread.", + ); + }); + + it('throws if the match object is invalid (__fragments)', () => { + jest.spyOn(console, 'error').mockImplementationOnce(() => {}); + loader.mockReturnValue(React.memo((UserComponent: $FlowFixMe))); + const otherProps = {otherProp: 'hello!'}; + const Fallback = (jest.fn(() =>
fallback
): $FlowFixMe); + expect(() => { + TestRenderer.create( + : $FlowFixMe)} + />, + ); + }).toThrow( + "MatchContainer: Invalid 'match' value, expected an object that has a '...SomeFragment' spread.", + ); + }); + + it('throws if the match object is invalid (__fragmentOwner)', () => { + jest.spyOn(console, 'error').mockImplementationOnce(() => {}); + loader.mockReturnValue(React.memo((UserComponent: $FlowFixMe))); + const otherProps = {otherProp: 'hello!'}; + const Fallback = (jest.fn(() =>
fallback
): $FlowFixMe); + expect(() => { + TestRenderer.create( + : $FlowFixMe)} + />, + ); + }).toThrow( + "MatchContainer: Invalid 'match' value, expected an object that has a '...SomeFragment' spread.", + ); + }); + + it('throws if the match object is invalid (__fragmentPropName)', () => { + jest.spyOn(console, 'error').mockImplementationOnce(() => {}); + loader.mockReturnValue(React.memo((UserComponent: $FlowFixMe))); + const otherProps = {otherProp: 'hello!'}; + const Fallback = (jest.fn(() =>
fallback
): $FlowFixMe); + expect(() => { + TestRenderer.create( + : $FlowFixMe)} + />, + ); + }).toThrow( + "MatchContainer: Invalid 'match' value, expected an object that has a '...SomeFragment' spread.", + ); + }); + + it('renders the fallback if the match value is null', () => { + loader.mockReturnValue(React.memo((UserComponent: $FlowFixMe))); + const otherProps = {otherProp: 'hello!'}; + const Fallback = (jest.fn(() =>
fallback
): $FlowFixMe); + const renderer = TestRenderer.create( + : $FlowFixMe)} + />, + ); + expect(renderer.toJSON()).toMatchSnapshot(); + expect(loader).toBeCalledTimes(0); + expect(UserComponent).toBeCalledTimes(0); + expect(ActorComponent).toBeCalledTimes(0); + expect(Fallback).toBeCalledTimes(1); + }); + + it('renders null if the match value is null and no fallback is provided', () => { + loader.mockReturnValue(React.memo((UserComponent: $FlowFixMe))); + const otherProps = {otherProp: 'hello!'}; + const renderer = TestRenderer.create( + , + ); + expect(renderer.toJSON()).toMatchSnapshot(); + expect(loader).toBeCalledTimes(0); + expect(UserComponent).toBeCalledTimes(0); + expect(ActorComponent).toBeCalledTimes(0); + }); + + it('renders the fallback if the match value is undefined', () => { + loader.mockReturnValue(React.memo((UserComponent: $FlowFixMe))); + const otherProps = {otherProp: 'hello!'}; + const Fallback = (jest.fn(() =>
fallback
): $FlowFixMe); + const renderer = TestRenderer.create( + : $FlowFixMe)} + />, + ); + expect(renderer.toJSON()).toMatchSnapshot(); + expect(loader).toBeCalledTimes(0); + expect(UserComponent).toBeCalledTimes(0); + expect(ActorComponent).toBeCalledTimes(0); + expect(Fallback).toBeCalledTimes(1); + }); + + it('transitions from fallback when new props have a component', () => { + loader.mockReturnValue(React.memo((UserComponent: $FlowFixMe))); + const Fallback = (jest.fn(() =>
fallback
): $FlowFixMe); + const renderer = TestRenderer.create( + } + />, + ); + expect(Fallback).toBeCalledTimes(1); + loader.mockReturnValue(React.memo((ActorComponent: $FlowFixMe))); + const match2 = createMatchPointer({ + id: '4', + fragment: {name: 'ActorFragment'}, + variables: {}, + propName: 'actor', + module: 'ActorContainer.react', + }); + renderer.update( + } + />, + ); + expect(renderer.toJSON()).toMatchSnapshot(); + expect(loader).toBeCalledTimes(1); + expect(UserComponent).toBeCalledTimes(0); + expect(ActorComponent).toBeCalledTimes(1); + }); + + it('transitions to fallback when new props have a null component', () => { + loader.mockReturnValue(React.memo((ActorComponent: $FlowFixMe))); + const match = createMatchPointer({ + id: '4', + fragment: {name: 'ActorFragment'}, + variables: {}, + propName: 'actor', + module: 'ActorContainer.react', + }); + const Fallback = (jest.fn(() =>
fallback
): $FlowFixMe); + const renderer = TestRenderer.create( + } + />, + ); + expect(ActorComponent).toBeCalledTimes(1); + renderer.update( + } + />, + ); + expect(renderer.toJSON()).toMatchSnapshot(); + expect(loader).toBeCalledTimes(1); + expect(Fallback).toBeCalledTimes(1); + expect(UserComponent).toBeCalledTimes(0); + }); +}); diff --git a/packages/relay-experimental/__tests__/QueryResource-test.js b/packages/relay-experimental/__tests__/QueryResource-test.js new file mode 100644 index 000000000000..e30f56b8736e --- /dev/null +++ b/packages/relay-experimental/__tests__/QueryResource-test.js @@ -0,0 +1,2281 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails oncall+relay + * @flow + * @format + */ + +'use strict'; + +const {getQueryResourceForEnvironment} = require('../QueryResource'); +const { + Observable, + ROOT_ID, + __internal: {fetchQuery}, + createOperationDescriptor, +} = require('relay-runtime'); +const { + createMockEnvironment, + generateAndCompile, +} = require('relay-test-utils-internal'); + +describe('QueryResource', () => { + let environment; + let QueryResource; + let fetchPolicy; + let fetchObservable; + let fetchObservableMissingData; + let gqlQuery; + let query; + let queryMissingData; + let gqlQueryMissingData; + let release; + let renderPolicy; + const variables = { + id: '4', + }; + + beforeEach(() => { + jest.mock('fbjs/lib/ExecutionEnvironment', () => ({ + canUseDOM: () => true, + })); + environment = createMockEnvironment(); + QueryResource = getQueryResourceForEnvironment(environment); + gqlQuery = generateAndCompile( + `query UserQuery($id: ID!) { + node(id: $id) { + ... on User { + id + } + } + } + `, + ).UserQuery; + gqlQueryMissingData = generateAndCompile( + `query UserQuery($id: ID!) { + node(id: $id) { + ... on User { + id + name + } + } + } + `, + ).UserQuery; + + query = createOperationDescriptor(gqlQuery, variables); + queryMissingData = createOperationDescriptor( + gqlQueryMissingData, + variables, + ); + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '4', + }, + }); + + fetchObservable = fetchQuery(environment, query, { + networkCacheConfig: {force: true}, + }); + fetchObservableMissingData = fetchQuery(environment, queryMissingData, { + networkCacheConfig: {force: true}, + }); + + release = jest.fn(); + environment.retain.mockImplementation((...args) => { + return { + dispose: release, + }; + }); + + renderPolicy = 'partial'; + }); + + describe('prepare', () => { + describe('fetchPolicy: store-or-network', () => { + beforeEach(() => { + fetchPolicy = 'store-or-network'; + }); + + describe('renderPolicy: partial', () => { + beforeEach(() => { + renderPolicy = 'partial'; + }); + it('should return result and not send a network request if all data is locally available', () => { + expect(environment.check(query.root)).toEqual(true); + + const result = QueryResource.prepare( + query, + fetchObservable, + fetchPolicy, + renderPolicy, + ); + expect(result).toEqual({ + cacheKey: expect.any(String), + fragmentNode: query.fragment.node, + fragmentRef: { + __id: ROOT_ID, + __fragments: { + UserQuery: variables, + }, + __fragmentOwner: query.request, + }, + operation: query, + }); + expect(environment.execute).toBeCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + + it('should return result and send a network request if data is missing for the query', () => { + expect(environment.check(queryMissingData.root)).toEqual(false); + + const result = QueryResource.prepare( + queryMissingData, + fetchObservableMissingData, + fetchPolicy, + renderPolicy, + ); + expect(result).toEqual({ + cacheKey: expect.any(String), + fragmentNode: queryMissingData.fragment.node, + fragmentRef: { + __id: ROOT_ID, + __fragments: { + UserQuery: variables, + }, + __fragmentOwner: queryMissingData.request, + }, + operation: queryMissingData, + }); + expect(environment.execute).toBeCalledTimes(1); + expect(environment.retain).toBeCalledTimes(1); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + + it('should send a single network request when same query is read multiple times', () => { + const result1 = QueryResource.prepare( + queryMissingData, + fetchObservableMissingData, + fetchPolicy, + renderPolicy, + ); + + // Assert query is temporarily retained during call to prepare + expect(release).toBeCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + + const result2 = QueryResource.prepare( + queryMissingData, + fetchObservableMissingData, + fetchPolicy, + renderPolicy, + ); + + // Assert query is still temporarily retained during second call to prepare + expect(release).toBeCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + + const expected = { + cacheKey: expect.any(String), + fragmentNode: queryMissingData.fragment.node, + fragmentRef: { + __id: ROOT_ID, + __fragments: { + UserQuery: variables, + }, + __fragmentOwner: queryMissingData.request, + }, + operation: queryMissingData, + }; + expect(result1).toEqual(expected); + expect(result2).toEqual(expected); + expect(environment.execute).toBeCalledTimes(1); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + + it('should throw error if network request errors', () => { + let thrown = false; + let sink; + const networkExecute = jest.fn(); + const errorFetchObservable = Observable.create(s => { + networkExecute(); + sink = s; + }); + QueryResource.prepare( + queryMissingData, + errorFetchObservable, + fetchPolicy, + renderPolicy, + ); + if (!sink) { + throw new Error('Expect sink to be defined'); + } + try { + sink.error(new Error('Oops')); + QueryResource.prepare( + queryMissingData, + errorFetchObservable, + fetchPolicy, + renderPolicy, + ); + } catch (e) { + expect(e instanceof Error).toEqual(true); + expect(e.message).toEqual('Oops'); + thrown = true; + } + expect(thrown).toEqual(true); + expect(networkExecute).toBeCalledTimes(1); + + // Assert query is temporarily retained during call to prepare + expect(environment.retain).toBeCalledTimes(1); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + + it('should return result and send a network request if data is missing for the query and observable returns synchronously', () => { + expect(environment.check(queryMissingData.root)).toEqual(false); + + const networkExecute = jest.fn(); + const syncFetchObservable = Observable.create(sink => { + environment.commitPayload(queryMissingData, { + node: { + __typename: 'User', + id: '4', + name: 'User 4', + }, + }); + const snapshot = environment.lookup(queryMissingData.fragment); + networkExecute(); + sink.next((snapshot: $FlowFixMe)); + sink.complete(); + }); + const result = QueryResource.prepare( + queryMissingData, + syncFetchObservable, + fetchPolicy, + renderPolicy, + ); + expect(result).toEqual({ + cacheKey: expect.any(String), + fragmentNode: queryMissingData.fragment.node, + fragmentRef: { + __id: ROOT_ID, + __fragments: { + UserQuery: variables, + }, + __fragmentOwner: queryMissingData.request, + }, + operation: queryMissingData, + }); + expect(networkExecute).toBeCalledTimes(1); + expect(environment.retain).toBeCalledTimes(1); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + + it('should throw error if network request errors synchronously', () => { + let thrown = false; + const networkExecute = jest.fn(); + const errorFetchObservable = Observable.create(sink => { + networkExecute(); + sink.error(new Error('Oops')); + }); + try { + QueryResource.prepare( + queryMissingData, + errorFetchObservable, + fetchPolicy, + renderPolicy, + ); + } catch (e) { + expect(e instanceof Error).toEqual(true); + expect(e.message).toEqual('Oops'); + thrown = true; + } + expect(thrown).toEqual(true); + expect(networkExecute).toBeCalledTimes(1); + expect(environment.retain).toBeCalledTimes(1); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + + describe('when using fragments', () => { + it('should return result and not send a network request if all data is locally available', () => { + const {UserQuery} = generateAndCompile( + ` + fragment UserFragment on User { + id + } + query UserQuery($id: ID!) { + node(id: $id) { + __typename + ...UserFragment + } + } + `, + ); + const queryWithFragments = createOperationDescriptor( + UserQuery, + variables, + ); + const fetchObservableWithFragments = fetchQuery( + environment, + queryWithFragments, + { + networkCacheConfig: {force: true}, + }, + ); + expect(environment.check(queryWithFragments.root)).toEqual(true); + + const result = QueryResource.prepare( + queryWithFragments, + fetchObservableWithFragments, + fetchPolicy, + renderPolicy, + ); + expect(result).toEqual({ + cacheKey: expect.any(String), + fragmentNode: queryWithFragments.fragment.node, + fragmentRef: { + __id: ROOT_ID, + __fragments: { + UserQuery: variables, + }, + __fragmentOwner: queryWithFragments.request, + }, + operation: queryWithFragments, + }); + expect(environment.execute).toBeCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + + it('should return result and send a network request when some data is missing in fragment', () => { + const {UserQuery} = generateAndCompile( + ` + fragment UserFragment on User { + id + username + } + query UserQuery($id: ID!) { + node(id: $id) { + __typename + ...UserFragment + } + } + `, + ); + const queryWithFragments = createOperationDescriptor( + UserQuery, + variables, + ); + const fetchObservableWithFragments = fetchQuery( + environment, + queryWithFragments, + { + networkCacheConfig: {force: true}, + }, + ); + expect(environment.check(queryWithFragments.root)).toEqual(false); + + const result = QueryResource.prepare( + queryWithFragments, + fetchObservableWithFragments, + fetchPolicy, + renderPolicy, + ); + expect(result).toEqual({ + cacheKey: expect.any(String), + fragmentNode: queryWithFragments.fragment.node, + fragmentRef: { + __id: ROOT_ID, + __fragments: { + UserQuery: variables, + }, + __fragmentOwner: queryWithFragments.request, + }, + operation: queryWithFragments, + }); + expect(environment.execute).toBeCalledTimes(1); + expect(environment.retain).toBeCalledTimes(1); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + }); + }); + + describe('renderPolicy: full', () => { + beforeEach(() => { + renderPolicy = 'full'; + }); + it('should return result and not send a network request if all data is locally available', () => { + expect(environment.check(query.root)).toEqual(true); + + const result = QueryResource.prepare( + query, + fetchObservable, + fetchPolicy, + renderPolicy, + ); + expect(result).toEqual({ + cacheKey: expect.any(String), + fragmentNode: query.fragment.node, + fragmentRef: { + __id: ROOT_ID, + __fragments: { + UserQuery: variables, + }, + __fragmentOwner: query.request, + }, + operation: query, + }); + expect(environment.execute).toBeCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + + it('should suspend and send a network request if data is missing for the query', () => { + expect(environment.check(queryMissingData.root)).toEqual(false); + + let thrown = false; + try { + QueryResource.prepare( + queryMissingData, + fetchObservableMissingData, + fetchPolicy, + renderPolicy, + ); + } catch (promise) { + expect(typeof promise.then).toBe('function'); + thrown = true; + } + expect(environment.execute).toBeCalledTimes(1); + expect(environment.retain).toBeCalledTimes(1); + expect(thrown).toBe(true); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + + it('should cache a single promise and send a single network request when same query is read multiple times', () => { + let promise1; + try { + QueryResource.prepare( + queryMissingData, + fetchObservableMissingData, + fetchPolicy, + renderPolicy, + ); + } catch (p) { + expect(typeof p.then).toBe('function'); + promise1 = p; + } + + // Assert query is temporarily retained during call to prepare + expect(release).toBeCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + + let promise2; + try { + QueryResource.prepare( + queryMissingData, + fetchObservableMissingData, + fetchPolicy, + renderPolicy, + ); + } catch (p) { + expect(typeof p.then).toBe('function'); + promise2 = p; + } + + // Assert query is still temporarily retained during second call to prepare + expect(release).toBeCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + + // Assert that same promise was thrown + expect(promise1).toBe(promise2); + // Assert that network was only called once + expect(environment.execute).toBeCalledTimes(1); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + + it('should throw error if network request errors', () => { + let thrown = false; + let sink; + const networkExecute = jest.fn(); + const errorFetchObservable = Observable.create(s => { + networkExecute(); + sink = s; + }); + try { + QueryResource.prepare( + queryMissingData, + errorFetchObservable, + fetchPolicy, + renderPolicy, + ); + } catch (p) { + expect(typeof p.then).toBe('function'); + } + + if (!sink) { + throw new Error('Expect sink to be defined'); + } + + try { + sink.error(new Error('Oops')); + QueryResource.prepare( + queryMissingData, + errorFetchObservable, + fetchPolicy, + renderPolicy, + ); + } catch (e) { + expect(e instanceof Error).toEqual(true); + expect(e.message).toEqual('Oops'); + thrown = true; + } + expect(thrown).toEqual(true); + expect(networkExecute).toBeCalledTimes(1); + + // Assert query is temporarily retained during call to prepare + expect(environment.retain).toBeCalledTimes(1); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + + it('should return result and send a network request if data is missing for the query and observable returns synchronously', () => { + expect(environment.check(queryMissingData.root)).toEqual(false); + + const networkExecute = jest.fn(); + const syncFetchObservable = Observable.create(sink => { + environment.commitPayload(queryMissingData, { + node: { + __typename: 'User', + id: '4', + name: 'User 4', + }, + }); + const snapshot = environment.lookup(queryMissingData.fragment); + networkExecute(); + sink.next((snapshot: $FlowFixMe)); + sink.complete(); + }); + const result = QueryResource.prepare( + queryMissingData, + syncFetchObservable, + fetchPolicy, + renderPolicy, + ); + expect(result).toEqual({ + cacheKey: expect.any(String), + fragmentNode: queryMissingData.fragment.node, + fragmentRef: { + __id: ROOT_ID, + __fragments: { + UserQuery: variables, + }, + __fragmentOwner: queryMissingData.request, + }, + operation: queryMissingData, + }); + expect(networkExecute).toBeCalledTimes(1); + expect(environment.retain).toBeCalledTimes(1); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + + it('should throw error if network request errors synchronously', () => { + let thrown = false; + const networkExecute = jest.fn(); + const errorFetchObservable = Observable.create(sink => { + networkExecute(); + sink.error(new Error('Oops')); + }); + try { + QueryResource.prepare( + queryMissingData, + errorFetchObservable, + fetchPolicy, + renderPolicy, + ); + } catch (e) { + expect(e instanceof Error).toEqual(true); + expect(e.message).toEqual('Oops'); + thrown = true; + } + expect(thrown).toEqual(true); + expect(networkExecute).toBeCalledTimes(1); + expect(environment.retain).toBeCalledTimes(1); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + + describe('when using fragments', () => { + it('should return result and not send a network request if all data is locally available', () => { + const {UserQuery} = generateAndCompile( + ` + fragment UserFragment on User { + id + } + query UserQuery($id: ID!) { + node(id: $id) { + __typename + ...UserFragment + } + } + `, + ); + const queryWithFragments = createOperationDescriptor( + UserQuery, + variables, + ); + const fetchObservableWithFragments = fetchQuery( + environment, + queryWithFragments, + { + networkCacheConfig: {force: true}, + }, + ); + expect(environment.check(queryWithFragments.root)).toEqual(true); + + const result = QueryResource.prepare( + queryWithFragments, + fetchObservableWithFragments, + fetchPolicy, + renderPolicy, + ); + expect(result).toEqual({ + cacheKey: expect.any(String), + fragmentNode: queryWithFragments.fragment.node, + fragmentRef: { + __id: ROOT_ID, + __fragments: { + UserQuery: variables, + }, + __fragmentOwner: queryWithFragments.request, + }, + operation: queryWithFragments, + }); + expect(environment.execute).toBeCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + + it('should suspend and send a network request when some data is missing in fragment', () => { + const {UserQuery} = generateAndCompile( + ` + fragment UserFragment on User { + id + username + } + query UserQuery($id: ID!) { + node(id: $id) { + __typename + ...UserFragment + } + } + `, + ); + const queryWithFragments = createOperationDescriptor( + UserQuery, + variables, + ); + const fetchObservableWithFragments = fetchQuery( + environment, + queryWithFragments, + { + networkCacheConfig: {force: true}, + }, + ); + expect(environment.check(queryWithFragments.root)).toEqual(false); + + let thrown = false; + try { + QueryResource.prepare( + queryWithFragments, + fetchObservableWithFragments, + fetchPolicy, + renderPolicy, + ); + } catch (p) { + expect(typeof p.then).toBe('function'); + thrown = true; + } + + expect(environment.execute).toBeCalledTimes(1); + expect(environment.retain).toBeCalledTimes(1); + expect(thrown).toEqual(true); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + }); + }); + }); + + describe('fetchPolicy: store-and-network', () => { + beforeEach(() => { + fetchPolicy = 'store-and-network'; + }); + + describe('renderPolicy: partial', () => { + beforeEach(() => { + renderPolicy = 'partial'; + }); + + it('should return result and send a network request even when data is locally available', () => { + expect(environment.check(query.root)).toEqual(true); + + const result = QueryResource.prepare( + query, + fetchObservable, + fetchPolicy, + renderPolicy, + ); + expect(result).toEqual({ + cacheKey: expect.any(String), + fragmentNode: query.fragment.node, + fragmentRef: { + __id: ROOT_ID, + __fragments: { + UserQuery: variables, + }, + __fragmentOwner: query.request, + }, + operation: query, + }); + expect(environment.execute).toBeCalledTimes(1); + expect(environment.retain).toBeCalledTimes(1); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + + it('should return result and send a network request if data is missing for the query', () => { + expect(environment.check(queryMissingData.root)).toEqual(false); + + const result = QueryResource.prepare( + queryMissingData, + fetchObservableMissingData, + fetchPolicy, + renderPolicy, + ); + expect(result).toEqual({ + cacheKey: expect.any(String), + fragmentNode: queryMissingData.fragment.node, + fragmentRef: { + __id: ROOT_ID, + __fragments: { + UserQuery: variables, + }, + __fragmentOwner: queryMissingData.request, + }, + operation: queryMissingData, + }); + expect(environment.execute).toBeCalledTimes(1); + expect(environment.retain).toBeCalledTimes(1); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + + it('should send a single network request when same query is read multiple times', () => { + const result1 = QueryResource.prepare( + queryMissingData, + fetchObservableMissingData, + fetchPolicy, + renderPolicy, + ); + const result2 = QueryResource.prepare( + queryMissingData, + fetchObservableMissingData, + fetchPolicy, + renderPolicy, + ); + const expected = { + cacheKey: expect.any(String), + fragmentNode: queryMissingData.fragment.node, + fragmentRef: { + __id: ROOT_ID, + __fragments: { + UserQuery: variables, + }, + __fragmentOwner: queryMissingData.request, + }, + operation: queryMissingData, + }; + expect(result1).toEqual(expected); + expect(result2).toEqual(expected); + expect(environment.execute).toBeCalledTimes(1); + expect(environment.retain).toBeCalledTimes(1); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + + it('should throw error if network request errors', () => { + let thrown = false; + let sink; + const networkExecute = jest.fn(); + const errorFetchObservable = Observable.create(s => { + networkExecute(); + sink = s; + }); + try { + QueryResource.prepare( + queryMissingData, + errorFetchObservable, + fetchPolicy, + renderPolicy, + ); + if (!sink) { + throw new Error('Expect sink to be defined'); + } + sink.error(new Error('Oops')); + QueryResource.prepare( + queryMissingData, + errorFetchObservable, + fetchPolicy, + renderPolicy, + ); + } catch (e) { + expect(e instanceof Error).toEqual(true); + expect(e.message).toEqual('Oops'); + thrown = true; + } + expect(thrown).toEqual(true); + expect(networkExecute).toBeCalledTimes(1); + // Assert query is temporarily retained during call to prepare + expect(environment.retain).toBeCalledTimes(1); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + + it('should return result and send a network request if data is missing for the query and observable returns synchronously', () => { + expect(environment.check(queryMissingData.root)).toEqual(false); + + const networkExecute = jest.fn(); + const syncFetchObservable = Observable.create(sink => { + environment.commitPayload(queryMissingData, { + node: { + __typename: 'User', + id: '4', + name: 'User 4', + }, + }); + const snapshot = environment.lookup(queryMissingData.fragment); + networkExecute(); + sink.next((snapshot: $FlowFixMe)); + sink.complete(); + }); + const result = QueryResource.prepare( + queryMissingData, + syncFetchObservable, + fetchPolicy, + renderPolicy, + ); + expect(result).toEqual({ + cacheKey: expect.any(String), + fragmentNode: queryMissingData.fragment.node, + fragmentRef: { + __id: ROOT_ID, + __fragments: { + UserQuery: variables, + }, + __fragmentOwner: queryMissingData.request, + }, + operation: queryMissingData, + }); + expect(networkExecute).toBeCalledTimes(1); + expect(environment.retain).toBeCalledTimes(1); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + + it('should throw error if network request errors synchronously', () => { + let thrown = false; + const networkExecute = jest.fn(); + const errorFetchObservable = Observable.create(sink => { + networkExecute(); + sink.error(new Error('Oops')); + }); + try { + QueryResource.prepare( + queryMissingData, + errorFetchObservable, + fetchPolicy, + renderPolicy, + ); + } catch (e) { + expect(e instanceof Error).toEqual(true); + expect(e.message).toEqual('Oops'); + thrown = true; + } + expect(thrown).toEqual(true); + expect(networkExecute).toBeCalledTimes(1); + expect(environment.retain).toBeCalledTimes(1); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + }); + + describe('renderPolicy: full', () => { + beforeEach(() => { + renderPolicy = 'full'; + }); + + it('should return result and send a network request even when data is locally available', () => { + expect(environment.check(query.root)).toEqual(true); + + const result = QueryResource.prepare( + query, + fetchObservable, + fetchPolicy, + renderPolicy, + ); + expect(result).toEqual({ + cacheKey: expect.any(String), + fragmentNode: query.fragment.node, + fragmentRef: { + __id: ROOT_ID, + __fragments: { + UserQuery: variables, + }, + __fragmentOwner: query.request, + }, + operation: query, + }); + expect(environment.execute).toBeCalledTimes(1); + expect(environment.retain).toBeCalledTimes(1); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + + it('should suspend and send a network request if data is missing for the query', () => { + expect(environment.check(queryMissingData.root)).toEqual(false); + + let thrown; + try { + QueryResource.prepare( + queryMissingData, + fetchObservableMissingData, + fetchPolicy, + renderPolicy, + ); + } catch (p) { + expect(typeof p.then).toEqual('function'); + thrown = true; + } + expect(environment.execute).toBeCalledTimes(1); + expect(environment.retain).toBeCalledTimes(1); + expect(thrown).toEqual(true); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + + it('should cache a single promise and send a single network request when same query is read multiple times', () => { + let promise1; + try { + QueryResource.prepare( + queryMissingData, + fetchObservableMissingData, + fetchPolicy, + renderPolicy, + ); + } catch (p) { + expect(typeof p.then).toBe('function'); + promise1 = p; + } + + // Assert query is temporarily retained during call to prepare + expect(release).toBeCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + + let promise2; + try { + QueryResource.prepare( + queryMissingData, + fetchObservableMissingData, + fetchPolicy, + renderPolicy, + ); + } catch (p) { + expect(typeof p.then).toBe('function'); + promise2 = p; + } + + // Assert query is still temporarily retained during second call to prepare + expect(release).toBeCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + + // Assert that same promise was thrown + expect(promise1).toBe(promise2); + // Assert that network was only called once + expect(environment.execute).toBeCalledTimes(1); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + + it('should throw error if network request errors', () => { + let thrown = false; + let sink; + const networkExecute = jest.fn(); + const errorFetchObservable = Observable.create(s => { + networkExecute(); + sink = s; + }); + 551; + try { + QueryResource.prepare( + queryMissingData, + errorFetchObservable, + fetchPolicy, + renderPolicy, + ); + } catch (p) { + expect(typeof p.then).toBe('function'); + } + if (!sink) { + throw new Error('Expect sink to be defined'); + } + try { + sink.error(new Error('Oops')); + QueryResource.prepare( + queryMissingData, + errorFetchObservable, + fetchPolicy, + renderPolicy, + ); + } catch (e) { + expect(e instanceof Error).toEqual(true); + expect(e.message).toEqual('Oops'); + thrown = true; + } + expect(thrown).toEqual(true); + expect(networkExecute).toBeCalledTimes(1); + // Assert query is temporarily retained during call to prepare + expect(environment.retain).toBeCalledTimes(1); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + + it('should return result and send a network request if data is missing for the query and observable returns synchronously', () => { + expect(environment.check(queryMissingData.root)).toEqual(false); + + const networkExecute = jest.fn(); + const syncFetchObservable = Observable.create(sink => { + environment.commitPayload(queryMissingData, { + node: { + __typename: 'User', + id: '4', + name: 'User 4', + }, + }); + const snapshot = environment.lookup(queryMissingData.fragment); + networkExecute(); + sink.next((snapshot: $FlowFixMe)); + sink.complete(); + }); + const result = QueryResource.prepare( + queryMissingData, + syncFetchObservable, + fetchPolicy, + renderPolicy, + ); + expect(result).toEqual({ + cacheKey: expect.any(String), + fragmentNode: queryMissingData.fragment.node, + fragmentRef: { + __id: ROOT_ID, + __fragments: { + UserQuery: variables, + }, + __fragmentOwner: queryMissingData.request, + }, + operation: queryMissingData, + }); + expect(networkExecute).toBeCalledTimes(1); + expect(environment.retain).toBeCalledTimes(1); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + + it('should throw error if network request errors synchronously', () => { + let thrown = false; + const networkExecute = jest.fn(); + const errorFetchObservable = Observable.create(sink => { + networkExecute(); + sink.error(new Error('Oops')); + }); + try { + QueryResource.prepare( + queryMissingData, + errorFetchObservable, + fetchPolicy, + renderPolicy, + ); + } catch (e) { + expect(e instanceof Error).toEqual(true); + expect(e.message).toEqual('Oops'); + thrown = true; + } + expect(thrown).toEqual(true); + expect(networkExecute).toBeCalledTimes(1); + expect(environment.retain).toBeCalledTimes(1); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + }); + }); + + describe('fetchPolicy: network-only', () => { + beforeEach(() => { + fetchPolicy = 'network-only'; + }); + + describe('renderPolicy: partial', () => { + beforeEach(() => { + renderPolicy = 'partial'; + }); + it('should suspend and send a network request even if data is available locally', () => { + expect(environment.check(query.root)).toEqual(true); + + let thrown = false; + try { + QueryResource.prepare( + query, + fetchObservable, + fetchPolicy, + renderPolicy, + ); + } catch (promise) { + expect(typeof promise.then).toBe('function'); + thrown = true; + } + expect(environment.execute).toBeCalledTimes(1); + expect(environment.retain).toBeCalledTimes(1); + expect(thrown).toBe(true); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + + it('should suspend and send a network request when query has missing data', () => { + expect(environment.check(queryMissingData.root)).toEqual(false); + + let thrown = false; + try { + QueryResource.prepare( + queryMissingData, + fetchObservableMissingData, + fetchPolicy, + renderPolicy, + ); + } catch (promise) { + expect(typeof promise.then).toBe('function'); + thrown = true; + } + expect(environment.execute).toBeCalledTimes(1); + expect(environment.retain).toBeCalledTimes(1); + expect(thrown).toBe(true); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + + it('should throw error if network request errors', () => { + let thrownPromise = false; + let thrownError = false; + let sink; + const networkExecute = jest.fn(); + const errorFetchObservable = Observable.create(s => { + networkExecute(); + sink = s; + }); + try { + QueryResource.prepare( + queryMissingData, + errorFetchObservable, + fetchPolicy, + renderPolicy, + ); + } catch (promise) { + expect(typeof promise.then).toBe('function'); + thrownPromise = true; + } + expect(thrownPromise).toEqual(true); + if (!sink) { + throw new Error('Expect sink to be defined'); + } + sink.error(new Error('Oops')); + + try { + QueryResource.prepare( + queryMissingData, + errorFetchObservable, + fetchPolicy, + renderPolicy, + ); + } catch (e) { + expect(e instanceof Error).toEqual(true); + expect(e.message).toEqual('Oops'); + thrownError = true; + } + expect(thrownError).toEqual(true); + expect(networkExecute).toBeCalledTimes(1); + // Assert query is temporarily retained during call to prepare + expect(environment.retain).toBeCalledTimes(1); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + + it('should return result if network observable returns synchronously', () => { + const networkExecute = jest.fn(); + const syncFetchObservable = Observable.create(sink => { + const snapshot = environment.lookup(query.fragment); + networkExecute(); + sink.next((snapshot: $FlowFixMe)); + sink.complete(); + }); + const result = QueryResource.prepare( + query, + syncFetchObservable, + fetchPolicy, + renderPolicy, + ); + + expect(result).toEqual({ + cacheKey: expect.any(String), + fragmentNode: query.fragment.node, + fragmentRef: { + __id: ROOT_ID, + __fragments: { + UserQuery: variables, + }, + __fragmentOwner: query.request, + }, + operation: query, + }); + expect(networkExecute).toBeCalledTimes(1); + expect(environment.retain).toBeCalledTimes(1); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + + it('should throw error if network request errors synchronously', () => { + let thrown = false; + const networkExecute = jest.fn(); + const errorFetchObservable = Observable.create(sink => { + networkExecute(); + const error = new Error('Oops'); + sink.error(error); + }); + try { + QueryResource.prepare( + queryMissingData, + errorFetchObservable, + fetchPolicy, + renderPolicy, + ); + } catch (e) { + expect(e instanceof Error).toEqual(true); + expect(e.message).toEqual('Oops'); + thrown = true; + } + expect(thrown).toEqual(true); + expect(networkExecute).toBeCalledTimes(1); + expect(environment.retain).toBeCalledTimes(1); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + }); + + describe('renderPolicy: full', () => { + beforeEach(() => { + renderPolicy = 'full'; + }); + it('should suspend and send a network request even if data is available locally', () => { + expect(environment.check(query.root)).toEqual(true); + + let thrown = false; + try { + QueryResource.prepare( + query, + fetchObservable, + fetchPolicy, + renderPolicy, + ); + } catch (promise) { + expect(typeof promise.then).toBe('function'); + thrown = true; + } + expect(environment.execute).toBeCalledTimes(1); + expect(environment.retain).toBeCalledTimes(1); + expect(thrown).toBe(true); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + + it('should suspend and send a network request when query has missing data', () => { + expect(environment.check(queryMissingData.root)).toEqual(false); + + let thrown = false; + try { + QueryResource.prepare( + queryMissingData, + fetchObservableMissingData, + fetchPolicy, + renderPolicy, + ); + } catch (promise) { + expect(typeof promise.then).toBe('function'); + thrown = true; + } + expect(environment.execute).toBeCalledTimes(1); + expect(environment.retain).toBeCalledTimes(1); + expect(thrown).toBe(true); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + + it('should throw error if network request errors', () => { + let thrownPromise = false; + let thrownError = false; + let sink; + const networkExecute = jest.fn(); + const errorFetchObservable = Observable.create(s => { + networkExecute(); + sink = s; + }); + try { + QueryResource.prepare( + queryMissingData, + errorFetchObservable, + fetchPolicy, + renderPolicy, + ); + } catch (promise) { + expect(typeof promise.then).toBe('function'); + thrownPromise = true; + } + expect(thrownPromise).toEqual(true); + if (!sink) { + throw new Error('Expect sink to be defined'); + } + sink.error(new Error('Oops')); + + try { + QueryResource.prepare( + queryMissingData, + errorFetchObservable, + fetchPolicy, + renderPolicy, + ); + } catch (e) { + expect(e instanceof Error).toEqual(true); + expect(e.message).toEqual('Oops'); + thrownError = true; + } + expect(thrownError).toEqual(true); + expect(networkExecute).toBeCalledTimes(1); + // Assert query is temporarily retained during call to prepare + expect(environment.retain).toBeCalledTimes(1); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + + it('should return result if network observable returns synchronously', () => { + const networkExecute = jest.fn(); + const syncFetchObservable = Observable.create(sink => { + const snapshot = environment.lookup(query.fragment); + networkExecute(); + sink.next((snapshot: $FlowFixMe)); + sink.complete(); + }); + const result = QueryResource.prepare( + query, + syncFetchObservable, + fetchPolicy, + renderPolicy, + ); + + expect(result).toEqual({ + cacheKey: expect.any(String), + fragmentNode: query.fragment.node, + fragmentRef: { + __id: ROOT_ID, + __fragments: { + UserQuery: variables, + }, + __fragmentOwner: query.request, + }, + operation: query, + }); + expect(networkExecute).toBeCalledTimes(1); + expect(environment.retain).toBeCalledTimes(1); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + + it('should throw error if network request errors synchronously', () => { + let thrown = false; + const networkExecute = jest.fn(); + const errorFetchObservable = Observable.create(sink => { + networkExecute(); + const error = new Error('Oops'); + sink.error(error); + }); + try { + QueryResource.prepare( + queryMissingData, + errorFetchObservable, + fetchPolicy, + renderPolicy, + ); + } catch (e) { + expect(e instanceof Error).toEqual(true); + expect(e.message).toEqual('Oops'); + thrown = true; + } + expect(thrown).toEqual(true); + expect(networkExecute).toBeCalledTimes(1); + expect(environment.retain).toBeCalledTimes(1); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + }); + }); + + describe('fetchPolicy: store-only', () => { + beforeEach(() => { + fetchPolicy = 'store-only'; + }); + + describe('renderPolicy: partial', () => { + beforeEach(() => { + renderPolicy = 'partial'; + }); + + it('should not send network request if data is available locally', () => { + expect(environment.check(query.root)).toEqual(true); + + const result = QueryResource.prepare( + query, + fetchObservable, + fetchPolicy, + renderPolicy, + ); + expect(result).toEqual({ + cacheKey: expect.any(String), + fragmentNode: query.fragment.node, + fragmentRef: { + __id: ROOT_ID, + __fragments: { + UserQuery: variables, + }, + __fragmentOwner: query.request, + }, + operation: query, + }); + expect(environment.execute).toBeCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + + it('should not send network request even if data is missing', () => { + expect(environment.check(queryMissingData.root)).toEqual(false); + + const result = QueryResource.prepare( + queryMissingData, + fetchObservableMissingData, + fetchPolicy, + renderPolicy, + ); + expect(result).toEqual({ + cacheKey: expect.any(String), + fragmentNode: queryMissingData.fragment.node, + fragmentRef: { + __id: ROOT_ID, + __fragments: { + UserQuery: variables, + }, + __fragmentOwner: queryMissingData.request, + }, + operation: queryMissingData, + }); + expect(environment.execute).toBeCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + }); + + describe('renderPolicy: full', () => { + beforeEach(() => { + renderPolicy = 'full'; + }); + + it('should not send network request if data is available locally', () => { + expect(environment.check(query.root)).toEqual(true); + + const result = QueryResource.prepare( + query, + fetchObservable, + fetchPolicy, + renderPolicy, + ); + expect(result).toEqual({ + cacheKey: expect.any(String), + fragmentNode: query.fragment.node, + fragmentRef: { + __id: ROOT_ID, + __fragments: { + UserQuery: variables, + }, + __fragmentOwner: query.request, + }, + operation: query, + }); + expect(environment.execute).toBeCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + + it('should not send network request even if data is missing', () => { + expect(environment.check(queryMissingData.root)).toEqual(false); + + const result = QueryResource.prepare( + queryMissingData, + fetchObservableMissingData, + fetchPolicy, + renderPolicy, + ); + expect(result).toEqual({ + cacheKey: expect.any(String), + fragmentNode: queryMissingData.fragment.node, + fragmentRef: { + __id: ROOT_ID, + __fragments: { + UserQuery: variables, + }, + __fragmentOwner: queryMissingData.request, + }, + operation: queryMissingData, + }); + expect(environment.execute).toBeCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + + // Assert that query is released after enough time has passed without + // calling QueryResource.retain + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + }); + }); + }); + }); + + describe('retain', () => { + beforeEach(() => { + fetchPolicy = 'store-or-network'; + }); + + it('should permanently retain the query that was retained during `prepare`', () => { + const result = QueryResource.prepare( + queryMissingData, + fetchObservableMissingData, + fetchPolicy, + renderPolicy, + ); + expect(environment.execute).toBeCalledTimes(1); + expect(environment.retain).toBeCalledTimes(1); + expect(environment.retain.mock.calls[0][0]).toEqual( + queryMissingData.root, + ); + + // Data retention ownership is established permanently: + // - Temporary retain is released + // - New permanent retain is established + const disposable = QueryResource.retain(result); + expect(release).toBeCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + expect(environment.retain.mock.calls[0][0]).toEqual( + queryMissingData.root, + ); + + // Running timers won't release the query since it has been + // permanently retained + jest.runAllTimers(); + expect(release).toBeCalledTimes(0); + // Should not clear the cache entry + expect( + QueryResource.getCacheEntry( + queryMissingData, + fetchPolicy, + renderPolicy, + ), + ).toBeDefined(); + + // Assert that disposing releases the query + disposable.dispose(); + expect(release).toBeCalledTimes(1); + expect(environment.retain).toBeCalledTimes(1); + }); + + it('should auto-release if enough time has passed before `retain` is called after `prepare`', () => { + const result = QueryResource.prepare( + queryMissingData, + fetchObservableMissingData, + fetchPolicy, + renderPolicy, + ); + expect(environment.execute).toBeCalledTimes(1); + expect(release).toBeCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + expect(environment.retain.mock.calls[0][0]).toEqual( + queryMissingData.root, + ); + + // Running timers before calling `retain` auto-releases the query + // retained during `read` + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + + // Cache entry should be removed + expect( + QueryResource.getCacheEntry( + queryMissingData, + fetchPolicy, + renderPolicy, + ), + ).toBeUndefined(); + + // Calling retain after query has been auto-released should retain + // the query again. + const disposable = QueryResource.retain(result); + expect(release).toBeCalledTimes(1); + expect(environment.retain).toBeCalledTimes(2); + expect(environment.retain.mock.calls[1][0]).toEqual( + queryMissingData.root, + ); + + // Assert that disposing releases the query + disposable.dispose(); + expect(release).toBeCalledTimes(2); + expect(environment.retain).toBeCalledTimes(2); + }); + + it("retains the query during `prepare` even if a network request wasn't started", () => { + const result = QueryResource.prepare( + query, + fetchObservable, + fetchPolicy, + renderPolicy, + ); + expect(environment.execute).toBeCalledTimes(0); + expect(release).toBeCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + expect(environment.retain.mock.calls[0][0]).toEqual(query.root); + + // Running timers before calling `retain` auto-releases the query + // retained during `read` + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + + // Calling retain should retain the query. + const disposable = QueryResource.retain(result); + expect(release).toBeCalledTimes(1); + expect(environment.retain).toBeCalledTimes(2); + expect(environment.retain.mock.calls[1][0]).toEqual(query.root); + + // Assert that disposing releases the query + disposable.dispose(); + expect(release).toBeCalledTimes(2); + expect(environment.retain).toBeCalledTimes(2); + }); + + describe('when retaining the same query multiple times', () => { + it('correctly retains query after temporarily retaining multiple times during render phase', () => { + QueryResource.prepare( + queryMissingData, + fetchObservableMissingData, + fetchPolicy, + renderPolicy, + ); + // Assert query is temporarily retained + expect(release).toBeCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + expect(environment.retain.mock.calls[0][0]).toEqual( + queryMissingData.root, + ); + + // Assert that retain count is 1 + const cacheEntry = QueryResource.getCacheEntry( + queryMissingData, + fetchPolicy, + renderPolicy, + ); + expect(cacheEntry && cacheEntry.getRetainCount()).toEqual(1); + + const result = QueryResource.prepare( + queryMissingData, + fetchObservableMissingData, + fetchPolicy, + renderPolicy, + ); + // Assert query is still temporarily retained + expect(release).toHaveBeenCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + expect(environment.retain.mock.calls[0][0]).toEqual( + queryMissingData.root, + ); + // Assert retain count is still 1 + expect(cacheEntry && cacheEntry.getRetainCount()).toEqual(1); + + // Assert network is only called once + expect(environment.execute).toBeCalledTimes(1); + + // Permanently retain the second result, which is what would happen + // if the second render got committed + const disposable = QueryResource.retain(result); + + // Assert permanent retain is established and nothing is released + expect(release).toBeCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + // Assert retain count is still 1 + expect(cacheEntry && cacheEntry.getRetainCount()).toEqual(1); + + // Assert that disposing correctly releases the query + disposable.dispose(); + expect(release).toBeCalledTimes(1); + expect(environment.retain).toBeCalledTimes(1); + }); + + it('correctly retains query after temporarily retaining multiple times during render phase and auto-release timers have expired', () => { + QueryResource.prepare( + queryMissingData, + fetchObservableMissingData, + fetchPolicy, + renderPolicy, + ); + // Assert query is temporarily retained + expect(release).toBeCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + expect(environment.retain.mock.calls[0][0]).toEqual( + queryMissingData.root, + ); + + // Assert that retain count is 1 + const cacheEntry = QueryResource.getCacheEntry( + queryMissingData, + fetchPolicy, + renderPolicy, + ); + expect(cacheEntry && cacheEntry.getRetainCount()).toEqual(1); + + const result = QueryResource.prepare( + queryMissingData, + fetchObservableMissingData, + fetchPolicy, + renderPolicy, + ); + // Assert query is still temporarily retained + expect(release).toHaveBeenCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + expect(environment.retain.mock.calls[0][0]).toEqual( + queryMissingData.root, + ); + // Assert that retain count is still 1 + expect(cacheEntry && cacheEntry.getRetainCount()).toEqual(1); + + // Assert network is only called once + expect(environment.execute).toBeCalledTimes(1); + + // Permanently retain the second result, which is what would happen + // if the second render got committed + const disposable = QueryResource.retain(result); + + // Assert permanent retain is established and nothing is released + expect(release).toBeCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + // Assert that retain count is still 1 + expect(cacheEntry && cacheEntry.getRetainCount()).toEqual(1); + + // Running timers won't release the query since it has been + // permanently retained + jest.runAllTimers(); + expect(release).toBeCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + // Assert that retain count is still 1 + expect(cacheEntry && cacheEntry.getRetainCount()).toEqual(1); + + // Assert that disposing correctly releases the query + disposable.dispose(); + expect(release).toBeCalledTimes(1); + expect(environment.retain).toBeCalledTimes(1); + }); + + it('does not temporarily retain query anymore if it has been permanently retained', () => { + const result = QueryResource.prepare( + queryMissingData, + fetchObservableMissingData, + fetchPolicy, + renderPolicy, + ); + // Assert query is temporarily retained + expect(release).toBeCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + expect(environment.retain.mock.calls[0][0]).toEqual( + queryMissingData.root, + ); + + // Assert that retain count is 1 + const cacheEntry = QueryResource.getCacheEntry( + queryMissingData, + fetchPolicy, + renderPolicy, + ); + expect(cacheEntry && cacheEntry.getRetainCount()).toEqual(1); + + // Assert network is called once + expect(environment.execute).toBeCalledTimes(1); + + // Permanently retain the second result, which is what would happen + // if the second render got committed + const disposable = QueryResource.retain(result); + + // Assert permanent retain is established and nothing is released + expect(release).toBeCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + // Assert that retain count remains at 1 + expect(cacheEntry && cacheEntry.getRetainCount()).toEqual(1); + + // Running timers won't release the query since it has been + // permanently retained + jest.runAllTimers(); + expect(release).toBeCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + // Assert that retain count remains at 1 + expect(cacheEntry && cacheEntry.getRetainCount()).toEqual(1); + + QueryResource.prepare( + queryMissingData, + fetchObservableMissingData, + fetchPolicy, + renderPolicy, + ); + + // Assert that the retain count remains at 1, even after + // temporarily retaining again + expect(cacheEntry && cacheEntry.getRetainCount()).toEqual(1); + + // Assert query is still retained + expect(release).toHaveBeenCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + expect(environment.retain.mock.calls[0][0]).toEqual( + queryMissingData.root, + ); + + // Assert that disposing the first disposable doesn't release the query + disposable.dispose(); + expect(release).toBeCalledTimes(1); + expect(environment.retain).toBeCalledTimes(1); + }); + + it("should not release the query before all callers have released it and auto-release timers haven't expired", () => { + // NOTE: This simulates 2 separate query renderers mounting + // simultaneously + + const result1 = QueryResource.prepare( + queryMissingData, + fetchObservableMissingData, + fetchPolicy, + renderPolicy, + ); + // Assert query is temporarily retained + expect(release).toBeCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + expect(environment.retain.mock.calls[0][0]).toEqual( + queryMissingData.root, + ); + + // Assert that retain count is 1 + const cacheEntry = QueryResource.getCacheEntry( + queryMissingData, + fetchPolicy, + renderPolicy, + ); + expect(cacheEntry && cacheEntry.getRetainCount()).toEqual(1); + + const result2 = QueryResource.prepare( + queryMissingData, + fetchObservableMissingData, + fetchPolicy, + renderPolicy, + ); + // Assert query is still temporarily retained + expect(release).toHaveBeenCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + expect(environment.retain.mock.calls[0][0]).toEqual( + queryMissingData.root, + ); + // Assert that retain count is still 1 + expect(cacheEntry && cacheEntry.getRetainCount()).toEqual(1); + + // Assert network is only called once + expect(environment.execute).toBeCalledTimes(1); + + const disposable1 = QueryResource.retain(result1); + + // Assert permanent retain is established and nothing is released + expect(release).toBeCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + // Assert that retain count is still 1 + expect(cacheEntry && cacheEntry.getRetainCount()).toEqual(1); + + const disposable2 = QueryResource.retain(result2); + + // Assert permanent retain is still established + expect(release).toBeCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + // Assert that retain count is now 2 + expect(cacheEntry && cacheEntry.getRetainCount()).toEqual(2); + + // Assert that disposing the first disposable doesn't release the query + disposable1.dispose(); + expect(release).toBeCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + expect( + QueryResource.getCacheEntry( + queryMissingData, + fetchPolicy, + renderPolicy, + ), + ).toBeDefined(); + + // Assert that disposing the last disposable fully releases the query + disposable2.dispose(); + expect(release).toBeCalledTimes(1); + expect(environment.retain).toBeCalledTimes(1); + expect( + QueryResource.getCacheEntry( + queryMissingData, + fetchPolicy, + renderPolicy, + ), + ).toBeUndefined(); + }); + + it('should not release the query before all callers have released it and auto-release timers have expired', () => { + // NOTE: This simulates 2 separate query renderers mounting + // simultaneously + + const result1 = QueryResource.prepare( + queryMissingData, + fetchObservableMissingData, + fetchPolicy, + renderPolicy, + ); + // Assert query is temporarily retained + expect(release).toBeCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + expect(environment.retain.mock.calls[0][0]).toEqual( + queryMissingData.root, + ); + + // Assert that retain count is 1 + const cacheEntry = QueryResource.getCacheEntry( + queryMissingData, + fetchPolicy, + renderPolicy, + ); + expect(cacheEntry && cacheEntry.getRetainCount()).toEqual(1); + + const result2 = QueryResource.prepare( + queryMissingData, + fetchObservableMissingData, + fetchPolicy, + renderPolicy, + ); + // Assert query is still temporarily retained + expect(release).toHaveBeenCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + expect(environment.retain.mock.calls[0][0]).toEqual( + queryMissingData.root, + ); + // Assert that retain count is still 1 + expect(cacheEntry && cacheEntry.getRetainCount()).toEqual(1); + + // Assert network is only called once + expect(environment.execute).toBeCalledTimes(1); + + const disposable1 = QueryResource.retain(result1); + + // Assert permanent retain is established and nothing is released + expect(release).toBeCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + // Assert that retain count is still 1 + expect(cacheEntry && cacheEntry.getRetainCount()).toEqual(1); + + const disposable2 = QueryResource.retain(result2); + + // Assert permanent retain is still established + expect(release).toBeCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + // Assert that retain count is now 2 + expect(cacheEntry && cacheEntry.getRetainCount()).toEqual(2); + + // Running timers won't release the query since it has been + // permanently retained + jest.runAllTimers(); + expect(release).toBeCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + // Assert that retain count is still 2 + expect(cacheEntry && cacheEntry.getRetainCount()).toEqual(2); + + // Assert that disposing the first disposable doesn't release the query + disposable1.dispose(); + expect(release).toBeCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + expect( + QueryResource.getCacheEntry( + queryMissingData, + fetchPolicy, + renderPolicy, + ), + ).toBeDefined(); + + // Assert that disposing the last disposable fully releases the query + disposable2.dispose(); + expect(release).toBeCalledTimes(1); + expect(environment.retain).toBeCalledTimes(1); + expect( + QueryResource.getCacheEntry( + queryMissingData, + fetchPolicy, + renderPolicy, + ), + ).toBeUndefined(); + }); + + it('correctly retains query when releasing and re-retaining', () => { + // NOTE: This simulates a query renderer unmounting and re-mounting + + const result1 = QueryResource.prepare( + queryMissingData, + fetchObservableMissingData, + fetchPolicy, + renderPolicy, + ); + // Assert query is temporarily retained + expect(release).toBeCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + expect(environment.retain.mock.calls[0][0]).toEqual( + queryMissingData.root, + ); + // Assert that retain count is 1 + let cacheEntry = QueryResource.getCacheEntry( + queryMissingData, + fetchPolicy, + renderPolicy, + ); + expect(cacheEntry && cacheEntry.getRetainCount()).toEqual(1); + + // Assert network is called + expect(environment.execute).toBeCalledTimes(1); + + // Assert permanent retain is established + const disposable1 = QueryResource.retain(result1); + expect(release).toBeCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + // Assert that retain count is still 1 + expect(cacheEntry && cacheEntry.getRetainCount()).toEqual(1); + + // Prepare the query again after it has been permanently retained. + // This will happen if the query component is unmounting and re-mounting + const result2 = QueryResource.prepare( + queryMissingData, + fetchObservableMissingData, + fetchPolicy, + renderPolicy, + ); + // Assert query is still retained + expect(release).toHaveBeenCalledTimes(0); + expect(environment.retain).toBeCalledTimes(1); + expect(environment.retain.mock.calls[0][0]).toEqual( + queryMissingData.root, + ); + // Assert that retain count is still 1 + expect(cacheEntry && cacheEntry.getRetainCount()).toEqual(1); + + // First disposable will be called when query component finally unmounts + disposable1.dispose(); + + // Assert that query is temporarily fully released on unmount + expect(release).toHaveBeenCalledTimes(1); + expect(environment.retain).toBeCalledTimes(1); + // Assert that retain count is now 0 + expect(cacheEntry && cacheEntry.getRetainCount()).toEqual(0); + + // Permanently retain the query after the initial retain has been + // disposed of. This will occur when the query component remounts. + const disposable2 = QueryResource.retain(result2); + + // Assert latest temporary retain is released + expect(release).toBeCalledTimes(1); + expect(environment.retain).toBeCalledTimes(2); + // Assert that retain count is now 1 + cacheEntry = QueryResource.getCacheEntry( + queryMissingData, + fetchPolicy, + renderPolicy, + ); + expect(cacheEntry && cacheEntry.getRetainCount()).toEqual(1); + + // Running timers won't release the query since it has been + // permanently retained + jest.runAllTimers(); + expect(release).toBeCalledTimes(1); + expect(environment.retain).toBeCalledTimes(2); + // Assert that retain count is still 1 + expect(cacheEntry && cacheEntry.getRetainCount()).toEqual(1); + + // Assert that disposing the last disposable fully releases the query + disposable2.dispose(); + expect(release).toBeCalledTimes(2); + expect(environment.retain).toBeCalledTimes(2); + }); + }); + }); +}); diff --git a/packages/relay-experimental/__tests__/__snapshots__/MatchContainer-test.js.snap b/packages/relay-experimental/__tests__/__snapshots__/MatchContainer-test.js.snap new file mode 100644 index 000000000000..09bb4d4eec7a --- /dev/null +++ b/packages/relay-experimental/__tests__/__snapshots__/MatchContainer-test.js.snap @@ -0,0 +1,133 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MatchContainer calls load again when re-rendered, even with the same component 1`] = ` +
+

+ User +

+
+    {
+  "otherProp": "hello!",
+  "user": {
+    "__id": "0",
+    "__fragments": {
+      "UserFragment": {}
+    },
+    "__fragmentOwner": null
+  }
+}
+  
+
+`; + +exports[`MatchContainer loads and renders dynamic components 1`] = ` +
+

+ User +

+
+    {
+  "otherProp": "hello!",
+  "user": {
+    "__id": "4",
+    "__fragments": {
+      "UserFragment": {}
+    },
+    "__fragmentOwner": null
+  }
+}
+  
+
+`; + +exports[`MatchContainer passes the same child props when the match values does not change 1`] = ` +
+

+ User +

+
+    {
+  "otherProp": "hello!",
+  "user": {
+    "__id": "4",
+    "__fragments": {
+      "UserFragment": {}
+    },
+    "__fragmentOwner": null
+  }
+}
+  
+
+`; + +exports[`MatchContainer reloads if new props have a different component 1`] = ` +
+

+ Actor +

+
+    {
+  "otherProp": "hello!",
+  "actor": {
+    "__id": "4",
+    "__fragments": {
+      "ActorFragment": {}
+    },
+    "__fragmentOwner": null
+  }
+}
+  
+
+`; + +exports[`MatchContainer renders null if the match value is null and no fallback is provided 1`] = `null`; + +exports[`MatchContainer renders the fallback if the match object is empty 1`] = ` +
+ fallback +
+`; + +exports[`MatchContainer renders the fallback if the match object is missing expected fields 1`] = ` +
+ fallback +
+`; + +exports[`MatchContainer renders the fallback if the match value is null 1`] = ` +
+ fallback +
+`; + +exports[`MatchContainer renders the fallback if the match value is undefined 1`] = ` +
+ fallback +
+`; + +exports[`MatchContainer transitions from fallback when new props have a component 1`] = ` +
+

+ Actor +

+
+    {
+  "otherProp": "hello!",
+  "actor": {
+    "__id": "4",
+    "__fragments": {
+      "ActorFragment": {}
+    },
+    "__fragmentOwner": null
+  }
+}
+  
+
+`; + +exports[`MatchContainer transitions to fallback when new props have a null component 1`] = ` +
+ fallback +
+`; diff --git a/packages/relay-experimental/__tests__/fetchQuery-test.js b/packages/relay-experimental/__tests__/fetchQuery-test.js new file mode 100644 index 000000000000..9a0c02a1019f --- /dev/null +++ b/packages/relay-experimental/__tests__/fetchQuery-test.js @@ -0,0 +1,165 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 + * @emails oncall+relay + * @format + */ + +'use strict'; + +const fetchQuery = require('../fetchQuery'); + +const { + createMockEnvironment, + generateAndCompile, +} = require('relay-test-utils-internal'); + +const response = { + data: { + node: { + __typename: 'User', + id: '4', + }, + }, +}; + +describe('fetchQuery', () => { + let query; + let variables; + let environment; + let retained = []; + beforeEach(() => { + retained = []; + jest.mock('fbjs/lib/ExecutionEnvironment', () => ({ + canUseDOM: () => true, + })); + environment = createMockEnvironment(); + environment.retain.mockImplementation(obj => { + const idx = retained.push(obj); + return { + dispose: () => { + retained = retained.filter((o, ii) => ii === idx); + }, + }; + }); + variables = {id: '4'}; + query = generateAndCompile( + `query TestQuery($id: ID!) { + node(id: $id) { + id + } + } + `, + ).TestQuery; + }); + + it('fetches request and does not retain data', () => { + let calledObserver = false; + const observer = { + complete: () => { + calledObserver = true; + expect(retained.length).toEqual(0); + }, + }; + const subscription = fetchQuery(environment, query, variables).subscribe( + observer, + ); + environment.mock.nextValue(query, response); + environment.mock.complete(query); + subscription.unsubscribe(); + expect(calledObserver).toEqual(true); + expect(retained.length).toEqual(0); + }); + + it('provides data snapshot on next', () => { + let calledNext = false; + const observer = { + next: data => { + calledNext = true; + expect(retained.length).toEqual(0); + expect(data).toEqual({ + node: { + id: '4', + }, + }); + }, + }; + fetchQuery(environment, query, variables).subscribe(observer); + environment.mock.nextValue(query, response); + expect(calledNext).toEqual(true); + environment.mock.complete(query); + expect(retained.length).toEqual(0); + }); + + it('unsubscribes when request is disposed', () => { + let calledNext = false; + let calledUnsubscribe = false; + const observer = { + next: () => { + calledNext = true; + expect(retained.length).toEqual(0); + }, + unsubscribe: () => { + calledUnsubscribe = true; + }, + }; + const subscription = fetchQuery(environment, query, variables).subscribe( + observer, + ); + environment.mock.nextValue(query, response); + subscription.unsubscribe(); + expect(calledNext).toEqual(true); + expect(calledUnsubscribe).toEqual(true); + }); + + it('handles error correctly', () => { + let calledError = false; + const observer = { + error: error => { + calledError = true; + expect(error.message).toEqual('Oops'); + expect(retained.length).toEqual(0); + }, + }; + const subscription = fetchQuery(environment, query, variables).subscribe( + observer, + ); + environment.mock.reject(query, new Error('Oops')); + expect(calledError).toEqual(true); + expect(retained.length).toEqual(0); + subscription.unsubscribe(); + }); + + describe('.toPromise()', () => { + it('fetches request and does not retain query data', async () => { + const promise = fetchQuery(environment, query, variables).toPromise(); + expect(environment.mock.isLoading(query, variables)).toEqual(true); + environment.mock.nextValue(query, response); + const data = await promise; + expect(data).toEqual({ + node: { + id: '4', + }, + }); + expect(environment.mock.isLoading(query, variables)).toEqual(false); + expect(retained.length).toEqual(0); + }); + + it('rejects when error occurs', async () => { + const promise = fetchQuery(environment, query, variables).toPromise(); + expect(environment.mock.isLoading(query, variables)).toEqual(true); + environment.mock.reject(query, new Error('Oops')); + try { + await promise; + } catch (error) { + expect(error.message).toEqual('Oops'); + } + expect(environment.mock.isLoading(query, variables)).toEqual(false); + expect(retained.length).toEqual(0); + }); + }); +}); diff --git a/packages/relay-experimental/__tests__/getPaginationVariables-test.js b/packages/relay-experimental/__tests__/getPaginationVariables-test.js new file mode 100644 index 000000000000..8992d4400bea --- /dev/null +++ b/packages/relay-experimental/__tests__/getPaginationVariables-test.js @@ -0,0 +1,267 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 + */ + +'use strict'; + +const getPaginationVariables = require('../getPaginationVariables'); + +describe('getPaginationVariables', () => { + let direction; + + describe('forward', () => { + beforeEach(() => { + direction = 'forward'; + }); + + it('throws error if forward pagination metadata is missing', () => { + expect(() => + getPaginationVariables( + direction, + 10, + 'cursor-1', + {order_by: 'LAST_NAME'}, + {forward: null, backward: null, path: []}, + ), + ).toThrowError( + /^Relay: Expected forward pagination metadata to be avialable/, + ); + + // Assert output when forward metadata is malformed + expect(() => + getPaginationVariables( + direction, + 10, + 'cursor-1', + {order_by: 'LAST_NAME'}, + // $FlowFixMe + {forward: {count: null, cursor: 'after'}, backward: null, path: []}, + ), + ).toThrowError( + /^Relay: Expected forward pagination metadata to be avialable/, + ); + }); + + it('returns correct variables when no backward pagination metadata is present', () => { + // Testing using different variable names for count and cursor + let variables; + + variables = getPaginationVariables( + direction, + 10, + 'cursor-1', + {order_by: 'LAST_NAME'}, + {forward: {count: 'count', cursor: 'cursor'}, backward: null, path: []}, + ); + expect(variables).toEqual({ + order_by: 'LAST_NAME', + count: 10, + cursor: 'cursor-1', + }); + + variables = getPaginationVariables( + direction, + 10, + 'cursor-1', + {order_by: 'LAST_NAME'}, + {forward: {count: 'first', cursor: 'after'}, backward: null, path: []}, + ); + expect(variables).toEqual({ + order_by: 'LAST_NAME', + first: 10, + after: 'cursor-1', + }); + + variables = getPaginationVariables( + direction, + 10, + 'cursor-1', + {order_by: 'LAST_NAME'}, + { + forward: {count: 'customCountVar', cursor: 'customCursorVar'}, + backward: null, + path: [], + }, + ); + expect(variables).toEqual({ + order_by: 'LAST_NAME', + customCountVar: 10, + customCursorVar: 'cursor-1', + }); + }); + + it('returns correct variables when backward pagination metadata is present', () => { + let variables; + + variables = getPaginationVariables( + direction, + 10, + 'cursor-1', + {order_by: 'LAST_NAME'}, + { + forward: {count: 'first', cursor: 'after'}, + backward: {count: 'last', cursor: 'before'}, + path: [], + }, + ); + expect(variables).toEqual({ + order_by: 'LAST_NAME', + first: 10, + after: 'cursor-1', + last: null, + before: null, + }); + + // Assert output when backward metadata is malformed + variables = getPaginationVariables( + direction, + 10, + 'cursor-1', + {order_by: 'LAST_NAME'}, + { + forward: {count: 'first', cursor: 'after'}, + // $FlowFixMe + backward: {count: null, cursor: 'before'}, + path: [], + }, + ); + expect(variables).toEqual({ + order_by: 'LAST_NAME', + first: 10, + after: 'cursor-1', + before: null, + }); + }); + }); + + describe('backward', () => { + beforeEach(() => { + direction = 'backward'; + }); + + it('throws error if backward pagination metadata is missing', () => { + expect(() => + getPaginationVariables( + direction, + 10, + 'cursor-1', + {order_by: 'LAST_NAME'}, + {forward: null, backward: null, path: []}, + ), + ).toThrowError( + /^Relay: Expected backward pagination metadata to be avialable/, + ); + + // Assert output when forward metadata is malformed + expect(() => + getPaginationVariables( + direction, + 10, + 'cursor-1', + {order_by: 'LAST_NAME'}, + // $FlowFixMe + {forward: null, backward: {count: null, cursor: 'before'}, path: []}, + ), + ).toThrowError( + /^Relay: Expected backward pagination metadata to be avialable/, + ); + }); + + it('returns correct variables when no forward pagination metadata is present', () => { + // Testing using different variable names for count and cursor + let variables; + + variables = getPaginationVariables( + direction, + 10, + 'cursor-1', + {order_by: 'LAST_NAME'}, + {forward: null, backward: {count: 'count', cursor: 'cursor'}, path: []}, + ); + expect(variables).toEqual({ + order_by: 'LAST_NAME', + count: 10, + cursor: 'cursor-1', + }); + + variables = getPaginationVariables( + direction, + 10, + 'cursor-1', + {order_by: 'LAST_NAME'}, + {forward: null, backward: {count: 'last', cursor: 'before'}, path: []}, + ); + expect(variables).toEqual({ + order_by: 'LAST_NAME', + last: 10, + before: 'cursor-1', + }); + + variables = getPaginationVariables( + direction, + 10, + 'cursor-1', + {order_by: 'LAST_NAME'}, + { + forward: null, + backward: {count: 'customCountVar', cursor: 'customCursorVar'}, + path: [], + }, + ); + expect(variables).toEqual({ + order_by: 'LAST_NAME', + customCountVar: 10, + customCursorVar: 'cursor-1', + }); + }); + + it('returns correct variables when forward pagination metadata is present', () => { + let variables; + + variables = getPaginationVariables( + direction, + 10, + 'cursor-1', + {order_by: 'LAST_NAME'}, + { + forward: {count: 'first', cursor: 'after'}, + backward: {count: 'last', cursor: 'before'}, + path: [], + }, + ); + expect(variables).toEqual({ + order_by: 'LAST_NAME', + first: null, + after: null, + last: 10, + before: 'cursor-1', + }); + + // Assert output when forward metadata is malformed + variables = getPaginationVariables( + direction, + 10, + 'cursor-1', + {order_by: 'LAST_NAME'}, + { + // $FlowFixMe + forward: {count: null, cursor: 'after'}, + backward: {count: 'last', cursor: 'before'}, + path: [], + }, + ); + expect(variables).toEqual({ + order_by: 'LAST_NAME', + last: 10, + before: 'cursor-1', + after: null, + }); + }); + }); +}); diff --git a/packages/relay-experimental/__tests__/useBlockingPaginationFragment-test.js b/packages/relay-experimental/__tests__/useBlockingPaginationFragment-test.js new file mode 100644 index 000000000000..99d2804d862a --- /dev/null +++ b/packages/relay-experimental/__tests__/useBlockingPaginationFragment-test.js @@ -0,0 +1,3644 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails oncall+relay + * @flow + * @format + */ + +'use strict'; + +const React = require('react'); +const Scheduler = require('scheduler'); + +import type {Direction} from '../useLoadMoreFunction'; +import type {OperationDescriptor, Variables} from 'relay-runtime'; +const {useMemo, useState} = React; +const TestRenderer = require('react-test-renderer'); + +const invariant = require('invariant'); +const useBlockingPaginationFragmentOriginal = require('../useBlockingPaginationFragment'); +const ReactRelayContext = require('react-relay/ReactRelayContext'); +const { + ConnectionHandler, + FRAGMENT_OWNER_KEY, + FRAGMENTS_KEY, + ID_KEY, + createOperationDescriptor, +} = require('relay-runtime'); + +describe('useBlockingPaginationFragment', () => { + let environment; + let initialUser; + let gqlQuery; + let gqlQueryNestedFragment; + let gqlQueryWithoutID; + let gqlPaginationQuery; + let gqlFragment; + let query; + let queryNestedFragment; + let queryWithoutID; + let paginationQuery; + let variables; + let variablesNestedFragment; + let variablesWithoutID; + let setEnvironment; + let setOwner; + let renderFragment; + let renderSpy; + let createMockEnvironment; + let generateAndCompile; + let loadNext; + let refetch; + let forceUpdate; + let Renderer; + + class ErrorBoundary extends React.Component { + state = {error: null}; + componentDidCatch(error) { + this.setState({error}); + } + render() { + const {children, fallback} = this.props; + const {error} = this.state; + if (error) { + return React.createElement(fallback, {error}); + } + return children; + } + } + + function useBlockingPaginationFragment(fragmentNode, fragmentRef) { + const {data, ...result} = useBlockingPaginationFragmentOriginal( + fragmentNode, + // $FlowFixMe + fragmentRef, + ); + loadNext = result.loadNext; + refetch = result.refetch; + renderSpy(data, result); + return {data, ...result}; + } + + function assertCall(expected, idx) { + const actualData = renderSpy.mock.calls[idx][0]; + const actualResult = renderSpy.mock.calls[idx][1]; + const actualHasNext = actualResult.hasNext; + const actualHasPrevious = actualResult.hasPrevious; + + expect(actualData).toEqual(expected.data); + expect(actualHasNext).toEqual(expected.hasNext); + expect(actualHasPrevious).toEqual(expected.hasPrevious); + } + + function expectFragmentResults( + expectedCalls: $ReadOnlyArray<{| + data: $FlowFixMe, + hasNext: boolean, + hasPrevious: boolean, + |}>, + ) { + // This ensures that useEffect runs + TestRenderer.act(() => jest.runAllImmediates()); + expect(renderSpy).toBeCalledTimes(expectedCalls.length); + expectedCalls.forEach((expected, idx) => assertCall(expected, idx)); + renderSpy.mockClear(); + } + + function createFragmentRef(id, owner) { + return { + [ID_KEY]: id, + [FRAGMENTS_KEY]: { + NestedUserFragment: {}, + }, + [FRAGMENT_OWNER_KEY]: owner.request, + }; + } + + beforeEach(() => { + // Set up mocks + jest.resetModules(); + jest.spyOn(console, 'warn').mockImplementationOnce(() => {}); + jest.mock('warning'); + jest.mock('fbjs/lib/ExecutionEnvironment', () => ({ + canUseDOM: () => true, + })); + renderSpy = jest.fn(); + + ({ + createMockEnvironment, + generateAndCompile, + } = require('relay-test-utils-internal')); + + // Set up environment and base data + environment = createMockEnvironment({ + handlerProvider: () => ConnectionHandler, + }); + const generated = generateAndCompile( + ` + fragment NestedUserFragment on User { + username + } + + fragment UserFragment on User + @refetchable(queryName: "UserFragmentPaginationQuery") + @argumentDefinitions( + isViewerFriendLocal: {type: "Boolean", defaultValue: false} + orderby: {type: "[String]"} + ) { + id + name + friends( + after: $after, + first: $first, + before: $before, + last: $last, + orderby: $orderby, + isViewerFriend: $isViewerFriendLocal + ) @connection(key: "UserFragment_friends") { + edges { + node { + id + name + ...NestedUserFragment + } + } + } + } + + query UserQuery( + $id: ID! + $after: ID + $first: Int + $before: ID + $last: Int + $orderby: [String] + $isViewerFriend: Boolean + ) { + node(id: $id) { + ...UserFragment @arguments(isViewerFriendLocal: $isViewerFriend, orderby: $orderby) + } + } + + query UserQueryNestedFragment( + $id: ID! + $after: ID + $first: Int + $before: ID + $last: Int + $orderby: [String] + $isViewerFriend: Boolean + ) { + node(id: $id) { + actor { + ...UserFragment @arguments(isViewerFriendLocal: $isViewerFriend, orderby: $orderby) + } + } + } + + query UserQueryWithoutID( + $after: ID + $first: Int + $before: ID + $last: Int + $orderby: [String] + $isViewerFriend: Boolean + ) { + viewer { + actor { + ...UserFragment @arguments(isViewerFriendLocal: $isViewerFriend, orderby: $orderby) + } + } + } + `, + ); + variablesWithoutID = { + after: null, + first: 1, + before: null, + last: null, + isViewerFriend: false, + orderby: ['name'], + }; + variables = { + ...variablesWithoutID, + id: '1', + }; + variablesNestedFragment = { + ...variablesWithoutID, + id: '', + }; + gqlQuery = generated.UserQuery; + gqlQueryNestedFragment = generated.UserQueryNestedFragment; + gqlQueryWithoutID = generated.UserQueryWithoutID; + gqlPaginationQuery = generated.UserFragmentPaginationQuery; + gqlFragment = generated.UserFragment; + invariant( + gqlFragment.metadata?.refetch?.operation === + '@@MODULE_START@@UserFragmentPaginationQuery.graphql@@MODULE_END@@', + 'useRefetchableFragment-test: Expected refetchable fragment metadata to contain operation.', + ); + // Manually set the refetchable operation for the test. + gqlFragment.metadata.refetch.operation = gqlPaginationQuery; + + query = createOperationDescriptor(gqlQuery, variables); + queryNestedFragment = createOperationDescriptor( + gqlQueryNestedFragment, + variablesNestedFragment, + ); + queryWithoutID = createOperationDescriptor( + gqlQueryWithoutID, + variablesWithoutID, + ); + paginationQuery = createOperationDescriptor(gqlPaginationQuery, variables); + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + username: 'username:node:1', + }, + }, + ], + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }, + }); + environment.commitPayload(queryWithoutID, { + viewer: { + actor: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + username: 'username:node:1', + }, + }, + ], + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }, + }, + }); + + // Set up renderers + Renderer = props => null; + + const Container = (props: { + userRef?: {}, + owner: $FlowFixMe, + fragment: $FlowFixMe, + }) => { + // We need a render a component to run a Hook + const [owner, _setOwner] = useState(props.owner); + const [_, _setCount] = useState(0); + const fragment = props.fragment ?? gqlFragment; + const artificialUserRef = useMemo( + () => environment.lookup(owner.fragment).data?.node, + [owner], + ); + const userRef = props.hasOwnProperty('userRef') + ? props.userRef + : artificialUserRef; + + setOwner = _setOwner; + forceUpdate = _setCount; + + const {data: userData} = useBlockingPaginationFragment(fragment, userRef); + return ; + }; + + const ContextProvider = ({children}) => { + const [env, _setEnv] = useState(environment); + // TODO(T39494051) - We set empty variables in relay context to make + // Flow happy, but useBlockingPaginationFragment does not use them, instead it uses + // the variables from the fragment owner. + const relayContext = useMemo(() => ({environment: env, variables: {}}), [ + env, + ]); + + setEnvironment = _setEnv; + + return ( + + {children} + + ); + }; + + renderFragment = (args?: { + isConcurrent?: boolean, + owner?: $FlowFixMe, + userRef?: $FlowFixMe, + fragment?: $FlowFixMe, + }): $FlowFixMe => { + const {isConcurrent = false, ...props} = args ?? {}; + let renderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + `Error: ${error.message}`}> + + + + + + , + {unstable_isConcurrent: isConcurrent}, + ); + }); + return renderer; + }; + + initialUser = { + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', query), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }; + }); + + afterEach(() => { + environment.mockClear(); + renderSpy.mockClear(); + }); + + describe('initial render', () => { + // The bulk of initial render behavior is covered in useFragmentNodes-test, + // so this suite covers the basic cases as a sanity check. + it('should throw error if fragment is plural', () => { + jest.spyOn(console, 'error').mockImplementationOnce(() => {}); + + const generated = generateAndCompile(` + fragment UserFragment on User @relay(plural: true) { + id + } + `); + const renderer = renderFragment({fragment: generated.UserFragment}); + expect( + renderer + .toJSON() + .includes('Remove `@relay(plural: true)` from fragment'), + ).toEqual(true); + }); + + it('should throw error if fragment uses stream', () => { + jest.spyOn(console, 'error').mockImplementationOnce(() => {}); + + const generated = generateAndCompile(` + fragment UserFragment on User + @refetchable(queryName: "UserFragmentPaginationQuery") { + id + friends( + after: $after, + first: $first, + before: $before, + last: $last, + orderby: $orderby, + isViewerFriend: $isViewerFriendLocal + ) @stream_connection(key: "UserFragment_friends", initial_count: 1) { + edges { + node { + id + } + } + } + } + `); + // Manually set the refetchable operation for the test. + generated.UserFragment.metadata.refetch.operation = + generated.UserFragmentPaginationQuery; + + const renderer = renderFragment({fragment: generated.UserFragment}); + expect( + renderer + .toJSON() + .includes('Use `useStreamingPaginationFragment` instead'), + ).toEqual(true); + }); + + it('should throw error if fragment is missing @refetchable directive', () => { + jest.spyOn(console, 'error').mockImplementationOnce(() => {}); + + const generated = generateAndCompile(` + fragment UserFragment on User { + id + } + `); + const renderer = renderFragment({fragment: generated.UserFragment}); + expect( + renderer + .toJSON() + .includes( + 'Did you forget to add a @refetchable directive to the fragment?', + ), + ).toEqual(true); + }); + + it('should throw error if fragment is missing @connection directive', () => { + jest.spyOn(console, 'error').mockImplementationOnce(() => {}); + + const generated = generateAndCompile(` + fragment UserFragment on User + @refetchable(queryName: "UserFragmentRefetchQuery") { + id + } + `); + generated.UserFragment.metadata.refetch.operation = + generated.UserFragmentRefetchQuery; + const renderer = renderFragment({fragment: generated.UserFragment}); + expect( + renderer + .toJSON() + .includes( + 'Did you forget to add a @connection directive to the connection field in the fragment?', + ), + ).toEqual(true); + }); + + it('should render fragment without error when data is available', () => { + renderFragment(); + expectFragmentResults([ + { + data: initialUser, + + hasNext: true, + hasPrevious: false, + }, + ]); + }); + + it('should render fragment without error when ref is null', () => { + renderFragment({userRef: null}); + expectFragmentResults([ + { + data: null, + hasNext: false, + hasPrevious: false, + }, + ]); + }); + + it('should render fragment without error when ref is undefined', () => { + renderFragment({userRef: undefined}); + expectFragmentResults([ + { + data: null, + hasNext: false, + hasPrevious: false, + }, + ]); + }); + + it('should update when fragment data changes', () => { + renderFragment(); + expectFragmentResults([ + { + data: initialUser, + hasNext: true, + hasPrevious: false, + }, + ]); + + // Update parent record + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '1', + // Update name + name: 'Alice in Wonderland', + }, + }); + expectFragmentResults([ + { + data: { + ...initialUser, + // Assert that name is updated + name: 'Alice in Wonderland', + }, + hasNext: true, + hasPrevious: false, + }, + ]); + + // Update edge + environment.commitPayload(query, { + node: { + __typename: 'User', + id: 'node:1', + // Update name + name: 'name:node:1-updated', + }, + }); + expectFragmentResults([ + { + data: { + ...initialUser, + name: 'Alice in Wonderland', + friends: { + ...initialUser.friends, + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + // Assert that name is updated + name: 'name:node:1-updated', + ...createFragmentRef('node:1', query), + }, + }, + ], + }, + }, + + hasNext: true, + hasPrevious: false, + }, + ]); + }); + + it('should throw a promise if data is missing for fragment and request is in flight', () => { + // This prevents console.error output in the test, which is expected + jest.spyOn(console, 'error').mockImplementationOnce(() => {}); + jest + .spyOn( + require('relay-runtime').__internal, + 'getPromiseForRequestInFlight', + ) + .mockImplementationOnce(() => Promise.resolve()); + + const missingDataVariables = {...variables, id: '4'}; + const missingDataQuery = createOperationDescriptor( + gqlQuery, + missingDataVariables, + ); + // Commit a payload with name and profile_picture are missing + environment.commitPayload(missingDataQuery, { + node: { + __typename: 'User', + id: '4', + }, + }); + + const renderer = renderFragment({owner: missingDataQuery}); + expect(renderer.toJSON()).toEqual('Fallback'); + }); + }); + + describe('pagination', () => { + let runScheduledCallback = () => {}; + let release; + + beforeEach(() => { + jest.resetModules(); + jest.doMock('scheduler', () => { + const original = jest.requireActual('scheduler/unstable_mock'); + return { + ...original, + unstable_next: cb => { + runScheduledCallback = () => { + original.unstable_next(cb); + }; + }, + }; + }); + + release = jest.fn(); + environment.retain.mockImplementation((...args) => { + return { + dispose: release, + }; + }); + }); + + afterEach(() => { + jest.dontMock('scheduler'); + }); + + function expectRequestIsInFlight(expected) { + expect(environment.execute).toBeCalledTimes(expected.requestCount); + expect( + environment.mock.isLoading( + expected.gqlPaginationQuery ?? gqlPaginationQuery, + expected.paginationVariables, + {force: true}, + ), + ).toEqual(expected.inFlight); + } + + function expectFragmentIsLoadingMore( + renderer, + direction: Direction, + expected: {| + data: mixed, + hasNext: boolean, + hasPrevious: boolean, + paginationVariables: Variables, + gqlPaginationQuery?: $FlowFixMe, + |}, + ) { + expect(renderSpy).toBeCalledTimes(0); + renderSpy.mockClear(); + + // Assert refetch query was fetched + expectRequestIsInFlight({...expected, inFlight: true, requestCount: 1}); + + // Assert component suspended + expect(renderSpy).toBeCalledTimes(0); + expect(renderer.toJSON()).toEqual('Fallback'); + } + + // TODO + // - backward pagination + // - simultaneous pagination + // - TODO(T41131846): Fetch/Caching policies for loadMore / when network + // returns or errors synchronously + describe('loadNext', () => { + const direction = 'forward'; + + it('does not load more if component has unmounted', () => { + const warning = require('warning'); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + hasNext: true, + hasPrevious: false, + }, + ]); + + renderer.unmount(); + + TestRenderer.act(() => { + loadNext(1); + }); + + expect(warning).toHaveBeenCalledTimes(2); + expect( + (warning: $FlowFixMe).mock.calls[1][1].includes( + 'Relay: Unexpected fetch on unmounted component', + ), + ).toEqual(true); + expect(environment.execute).toHaveBeenCalledTimes(0); + }); + + it('does not load more if fragment ref passed to useBlockingPaginationFragment() was null', () => { + const warning = require('warning'); + renderFragment({userRef: null}); + expectFragmentResults([ + { + data: null, + hasNext: false, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1); + }); + + expect(warning).toHaveBeenCalledTimes(2); + expect( + (warning: $FlowFixMe).mock.calls[1][1].includes( + 'Relay: Unexpected fetch while using a null fragment ref', + ), + ).toEqual(true); + expect(environment.execute).toHaveBeenCalledTimes(0); + }); + + it('does not load more if there are no more items to load and calls onComplete callback', () => { + (environment.getStore().getSource(): $FlowFixMe).clear(); + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + username: 'username:node:1', + }, + }, + ], + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: false, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }, + }); + const callback = jest.fn(); + + renderFragment(); + expectFragmentResults([ + { + data: { + ...initialUser, + friends: { + ...initialUser.friends, + pageInfo: expect.objectContaining({hasNextPage: false}), + }, + }, + hasNext: false, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + expect(environment.execute).toBeCalledTimes(0); + expect(callback).toBeCalledTimes(0); + expect(renderSpy).toBeCalledTimes(0); + + TestRenderer.act(() => { + runScheduledCallback(); + }); + expect(callback).toBeCalledTimes(1); + }); + + it('does not load more if request is already in flight', () => { + const callback = jest.fn(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + expect(environment.execute).toBeCalledTimes(1); + expect(callback).toBeCalledTimes(0); + expect(renderSpy).toBeCalledTimes(0); + }); + + it('does not load more if parent query is already in flight (i.e. during streaming)', () => { + // This prevents console.error output in the test, which is expected + jest.spyOn(console, 'error').mockImplementationOnce(() => {}); + jest + .spyOn(require('relay-runtime').__internal, 'hasRequestInFlight') + .mockImplementationOnce(() => true); + const callback = jest.fn(); + renderFragment(); + + expectFragmentResults([ + { + data: initialUser, + hasNext: true, + hasPrevious: false, + }, + ]); + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + expect(environment.execute).toBeCalledTimes(0); + expect(callback).toBeCalledTimes(0); + expect(renderSpy).toBeCalledTimes(0); + }); + + it('cancels load more if component unmounts', () => { + const unsubscribe = jest.fn(); + jest.doMock('relay-runtime', () => { + const originalRuntime = jest.requireActual('relay-runtime'); + const originalInternal = originalRuntime.__internal; + return { + ...originalRuntime, + __internal: { + ...originalInternal, + fetchQuery: (...args) => { + const observable = originalInternal.fetchQuery(...args); + return { + subscribe: observer => { + return observable.subscribe({ + ...observer, + start: originalSubscription => { + const observerStart = observer?.start; + observerStart && + observerStart({ + ...originalSubscription, + unsubscribe: () => { + originalSubscription.unsubscribe(); + unsubscribe(); + }, + }); + }, + }); + }, + }; + }, + }, + }; + }); + + const callback = jest.fn(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(unsubscribe).toHaveBeenCalledTimes(0); + + TestRenderer.act(() => { + renderer.unmount(); + }); + expect(unsubscribe).toHaveBeenCalledTimes(1); + expect(environment.execute).toBeCalledTimes(1); + expect(callback).toBeCalledTimes(0); + expect(renderSpy).toBeCalledTimes(0); + }); + + it('cancels load more if refetch is called', () => { + const unsubscribe = jest.fn(); + jest.doMock('relay-runtime', () => { + const originalRuntime = jest.requireActual('relay-runtime'); + const originalInternal = originalRuntime.__internal; + return { + ...originalRuntime, + __internal: { + ...originalInternal, + fetchQuery: (...args) => { + const observable = originalInternal.fetchQuery(...args); + return { + subscribe: observer => { + return observable.subscribe({ + ...observer, + start: originalSubscription => { + const observerStart = observer?.start; + observerStart && + observerStart({ + ...originalSubscription, + unsubscribe: () => { + originalSubscription.unsubscribe(); + unsubscribe(); + }, + }); + }, + }); + }, + }; + }, + }, + }; + }); + + const callback = jest.fn(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(unsubscribe).toHaveBeenCalledTimes(0); + + TestRenderer.act(() => { + refetch({id: '4'}); + }); + expect(unsubscribe).toHaveBeenCalledTimes(1); + expect(environment.execute).toBeCalledTimes(2); + expect(callback).toBeCalledTimes(0); + expect(renderSpy).toBeCalledTimes(0); + }); + + it('warns if load more scheduled at high priority', () => { + const warning = require('warning'); + const Scheduler = require('scheduler'); + renderFragment(); + expectFragmentResults([ + { + data: initialUser, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + Scheduler.unstable_runWithPriority( + Scheduler.unstable_ImmediatePriority, + () => { + loadNext(1); + }, + ); + }); + + // $FlowFixMe + const calls = warning.mock.calls.filter(call => call[0] === false); + expect(calls.length).toEqual(1); + expect( + calls[0][1].includes( + 'Relay: Unexpected call to `%s` at a priority higher than expected', + ), + ).toEqual(true); + expect(calls[0][2]).toEqual('loadNext'); + expect(environment.execute).toHaveBeenCalledTimes(1); + }); + + it('loads and renders next items in connection', () => { + const callback = jest.fn(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + environment.mock.resolve(gqlPaginationQuery, { + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + username: 'username:node:2', + }, + }, + ], + pageInfo: { + startCursor: 'cursor:2', + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: true, + }, + }, + }, + }, + }); + + const expectedUser = { + ...initialUser, + friends: { + ...initialUser.friends, + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', query), + }, + }, + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + ...createFragmentRef('node:2', query), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }; + expectFragmentResults([ + { + data: expectedUser, + hasNext: true, + hasPrevious: false, + }, + ]); + expect(callback).toBeCalledTimes(1); + }); + + it('correctly loads and renders next items when paginating multiple times', () => { + const callback = jest.fn(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + let paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + environment.mock.resolve(gqlPaginationQuery, { + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + username: 'username:node:2', + }, + }, + ], + pageInfo: { + startCursor: 'cursor:2', + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: true, + }, + }, + }, + }, + }); + + let expectedUser = { + ...initialUser, + friends: { + ...initialUser.friends, + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', query), + }, + }, + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + ...createFragmentRef('node:2', query), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }; + expectFragmentResults([ + { + data: expectedUser, + hasNext: true, + hasPrevious: false, + }, + ]); + expect(callback).toBeCalledTimes(1); + + // Paginate a second time + renderSpy.mockClear(); + callback.mockClear(); + environment.execute.mockClear(); + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + paginationVariables = { + ...paginationVariables, + after: 'cursor:2', + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: expectedUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + environment.mock.resolve(gqlPaginationQuery, { + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:3', + node: { + __typename: 'User', + id: 'node:3', + name: 'name:node:3', + username: 'username:node:3', + }, + }, + ], + pageInfo: { + startCursor: 'cursor:3', + endCursor: 'cursor:3', + hasNextPage: true, + hasPreviousPage: true, + }, + }, + }, + }, + }); + + expectedUser = { + ...expectedUser, + friends: { + ...expectedUser.friends, + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', query), + }, + }, + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + ...createFragmentRef('node:2', query), + }, + }, + { + cursor: 'cursor:3', + node: { + __typename: 'User', + id: 'node:3', + name: 'name:node:3', + ...createFragmentRef('node:3', query), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:3', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }; + expectFragmentResults([ + { + data: expectedUser, + hasNext: true, + hasPrevious: false, + }, + ]); + expect(callback).toBeCalledTimes(1); + }); + + it('does not suspend if pagination update is interruped before it commits (unsuspends)', () => { + const callback = jest.fn(); + const renderer = renderFragment({isConcurrent: true}); + expectFragmentResults([ + { + data: initialUser, + hasNext: true, + hasPrevious: false, + }, + ]); + + loadNext(1, {onComplete: callback}); + Scheduler.unstable_flushAll(); + jest.runAllTimers(); + Scheduler.unstable_flushAll(); + + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + // Schedule a high-pri update while the component is + // suspended on pagination + Scheduler.unstable_runWithPriority( + Scheduler.unstable_UserBlockingPriority, + () => { + forceUpdate(prev => prev + 1); + }, + ); + + Scheduler.unstable_flushAll(); + + // Assert high-pri update is rendered when initial update + // that suspended hasn't committed + // Assert that the avoided Suspense fallback isn't rendered + expect(renderer.toJSON()).toEqual(null); + expectFragmentResults([ + { + data: initialUser, + hasNext: true, + hasPrevious: false, + }, + ]); + + // Assert list is updated after pagination request completes + environment.mock.resolve(gqlPaginationQuery, { + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + username: 'username:node:2', + }, + }, + ], + pageInfo: { + startCursor: 'cursor:2', + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: true, + }, + }, + }, + }, + }); + + const expectedUser = { + ...initialUser, + friends: { + ...initialUser.friends, + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', query), + }, + }, + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + ...createFragmentRef('node:2', query), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }; + expectFragmentResults([ + { + data: expectedUser, + hasNext: true, + hasPrevious: false, + }, + ]); + expect(callback).toBeCalledTimes(1); + }); + + it('updates are ignored while loading more (i.e. while suspended)', () => { + jest.doMock('../useLoadMoreFunction'); + const useLoadMoreFunction = require('../useLoadMoreFunction'); + // $FlowFixMe + useLoadMoreFunction.mockImplementation((...args) => + jest.requireActual('../useLoadMoreFunction')(...args), + ); + + const callback = jest.fn(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + // $FlowFixMe + useLoadMoreFunction.mockClear(); + + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '1', + name: 'Alice updated', + }, + }); + + // Assert that component did not re-render while suspended + TestRenderer.act(() => jest.runAllImmediates()); + expect(renderSpy).toBeCalledTimes(0); + expect(useLoadMoreFunction).toBeCalledTimes(0); + + jest.dontMock('../useLoadMoreFunction'); + }); + + it('renders with latest updated data from any updates missed while suspended for pagination', () => { + const callback = jest.fn(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '1', + name: 'Alice updated', + }, + }); + + // Assert that component did not re-render while suspended + TestRenderer.act(() => jest.runAllImmediates()); + expect(renderSpy).toBeCalledTimes(0); + + environment.mock.resolve(gqlPaginationQuery, { + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + username: 'username:node:2', + }, + }, + ], + pageInfo: { + startCursor: 'cursor:2', + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: true, + }, + }, + }, + }, + }); + + const expectedUser = { + ...initialUser, + name: 'Alice', + friends: { + ...initialUser.friends, + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', query), + }, + }, + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + ...createFragmentRef('node:2', query), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }; + expectFragmentResults([ + { + data: expectedUser, + hasNext: true, + hasPrevious: false, + }, + ]); + expect(callback).toBeCalledTimes(1); + }); + + it('loads more correctly when original variables do not include an id', () => { + const callback = jest.fn(); + const viewer = environment.lookup(queryWithoutID.fragment).data?.viewer; + const userRef = + typeof viewer === 'object' && viewer != null ? viewer?.actor : null; + invariant(userRef != null, 'Expected to have cached test data'); + + let expectedUser = { + ...initialUser, + friends: { + ...initialUser.friends, + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', queryWithoutID), + }, + }, + ], + }, + }; + + const renderer = renderFragment({owner: queryWithoutID, userRef}); + expectFragmentResults([ + { + data: expectedUser, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: expectedUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + environment.mock.resolve(gqlPaginationQuery, { + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + username: 'username:node:2', + }, + }, + ], + pageInfo: { + startCursor: 'cursor:2', + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: true, + }, + }, + }, + }, + }); + + expectedUser = { + ...initialUser, + friends: { + ...initialUser.friends, + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', queryWithoutID), + }, + }, + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + ...createFragmentRef('node:2', queryWithoutID), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }; + expectFragmentResults([ + { + data: expectedUser, + hasNext: true, + hasPrevious: false, + }, + ]); + expect(callback).toBeCalledTimes(1); + }); + + it('loads more with correct id from refetchable fragment when using a nested fragment', () => { + const callback = jest.fn(); + + // Populate store with data for query using nested fragment + environment.commitPayload(queryNestedFragment, { + node: { + __typename: 'Feedback', + id: '', + actor: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + username: 'username:node:1', + }, + }, + ], + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }, + }, + }); + + // Get fragment ref for user using nested fragment + const userRef = (environment.lookup(queryNestedFragment.fragment) + .data: $FlowFixMe)?.node?.actor; + + initialUser = { + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', queryNestedFragment), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }; + + const renderer = renderFragment({owner: queryNestedFragment, userRef}); + expectFragmentResults([ + { + data: initialUser, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + const paginationVariables = { + // The id here should correspond to the user id, and not the + // feedback id from the query variables (i.e. ``) + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + environment.mock.resolve(gqlPaginationQuery, { + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + username: 'username:node:2', + }, + }, + ], + pageInfo: { + startCursor: 'cursor:2', + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: true, + }, + }, + }, + }, + }); + + const expectedUser = { + ...initialUser, + friends: { + ...initialUser.friends, + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', queryNestedFragment), + }, + }, + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + ...createFragmentRef('node:2', queryNestedFragment), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }; + expectFragmentResults([ + { + data: expectedUser, + hasNext: true, + hasPrevious: false, + }, + ]); + expect(callback).toBeCalledTimes(1); + }); + + it('calls callback with error when error occurs during fetch', () => { + const callback = jest.fn(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + const error = new Error('Oops'); + environment.mock.reject(gqlPaginationQuery, error); + + // We pass the error in the callback, but do not throw during render + // since we want to continue rendering the existing items in the + // connection + expect(callback).toBeCalledTimes(1); + expect(callback).toBeCalledWith(error); + }); + + it('preserves pagination request if re-rendered with same fragment ref', () => { + const callback = jest.fn(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + TestRenderer.act(() => { + setOwner({...query}); + }); + + // Assert that request is still in flight after re-rendering + // with new fragment ref that points to the same data. + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + environment.mock.resolve(gqlPaginationQuery, { + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + username: 'username:node:2', + }, + }, + ], + pageInfo: { + startCursor: 'cursor:2', + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: true, + }, + }, + }, + }, + }); + + const expectedUser = { + ...initialUser, + friends: { + ...initialUser.friends, + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', query), + }, + }, + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + ...createFragmentRef('node:2', query), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }; + expectFragmentResults([ + { + data: expectedUser, + hasNext: true, + hasPrevious: false, + }, + ]); + expect(callback).toBeCalledTimes(1); + }); + + describe('disposing', () => { + let unsubscribe; + beforeEach(() => { + unsubscribe = jest.fn(); + jest.doMock('relay-runtime', () => { + const originalRuntime = jest.requireActual('relay-runtime'); + const originalInternal = originalRuntime.__internal; + return { + ...originalRuntime, + __internal: { + ...originalInternal, + fetchQuery: (...args) => { + const observable = originalInternal.fetchQuery(...args); + return { + subscribe: observer => { + return observable.subscribe({ + ...observer, + start: originalSubscription => { + const observerStart = observer?.start; + observerStart && + observerStart({ + ...originalSubscription, + unsubscribe: () => { + originalSubscription.unsubscribe(); + unsubscribe(); + }, + }); + }, + }); + }, + }; + }, + }, + }; + }); + }); + + afterEach(() => { + jest.dontMock('relay-runtime'); + }); + + it('disposes ongoing request if environment changes', () => { + const callback = jest.fn(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + + // Assert request is started + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + // Set new environment + const newEnvironment = createMockEnvironment({ + handlerProvider: () => ConnectionHandler, + }); + newEnvironment.commitPayload(query, { + node: { + __typename: 'User', + id: '1', + name: 'Alice in a different environment', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + username: 'username:node:1', + }, + }, + ], + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }, + }); + TestRenderer.act(() => { + setEnvironment(newEnvironment); + }); + + // Assert request was canceled + expect(unsubscribe).toBeCalledTimes(1); + expectRequestIsInFlight({ + inFlight: false, + requestCount: 1, + gqlPaginationQuery, + paginationVariables, + }); + + // Assert newly rendered data + expectFragmentResults([ + { + data: { + ...initialUser, + name: 'Alice in a different environment', + }, + hasNext: true, + hasPrevious: false, + }, + { + data: { + ...initialUser, + name: 'Alice in a different environment', + }, + hasNext: true, + hasPrevious: false, + }, + ]); + }); + + it('disposes ongoing request if fragment ref changes', () => { + const callback = jest.fn(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + + // Assert request is started + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + // Pass new parent fragment ref with different variables + const newVariables = {...variables, isViewerFriend: true}; + const newQuery = createOperationDescriptor(gqlQuery, newVariables); + environment.commitPayload(newQuery, { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + username: 'username:node:1', + }, + }, + ], + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }, + }); + TestRenderer.act(() => { + setOwner(newQuery); + }); + + // Assert request was canceled + expect(unsubscribe).toBeCalledTimes(1); + expectRequestIsInFlight({ + inFlight: false, + requestCount: 1, + gqlPaginationQuery, + paginationVariables, + }); + + // Assert newly rendered data + const expectedUser = { + ...initialUser, + friends: { + ...initialUser.friends, + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + // Assert fragment ref points to owner with new variables + ...createFragmentRef('node:1', newQuery), + }, + }, + ], + }, + }; + expectFragmentResults([ + { + data: expectedUser, + hasNext: true, + hasPrevious: false, + }, + { + data: expectedUser, + hasNext: true, + hasPrevious: false, + }, + ]); + }); + + it('disposes ongoing request on unmount', () => { + const callback = jest.fn(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + + // Assert request is started + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + renderer.unmount(); + + // Assert request was canceled + expect(unsubscribe).toBeCalledTimes(1); + expectRequestIsInFlight({ + inFlight: false, + requestCount: 1, + gqlPaginationQuery, + paginationVariables, + }); + }); + + it('disposes ongoing request if it is manually disposed', () => { + const callback = jest.fn(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + hasNext: true, + hasPrevious: false, + }, + ]); + + let disposable; + TestRenderer.act(() => { + disposable = loadNext(1, {onComplete: callback}); + }); + + // Assert request is started + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + // $FlowFixMe + disposable.dispose(); + + // Assert request was canceled + expect(unsubscribe).toBeCalledTimes(1); + expectRequestIsInFlight({ + inFlight: false, + requestCount: 1, + gqlPaginationQuery, + paginationVariables, + }); + expect(renderSpy).toHaveBeenCalledTimes(0); + }); + }); + }); + + describe('hasNext', () => { + const direction = 'forward'; + + it('returns true if it has more items', () => { + (environment.getStore().getSource(): $FlowFixMe).clear(); + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + username: 'username:node:1', + }, + }, + ], + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }, + }); + + renderFragment(); + expectFragmentResults([ + { + data: { + ...initialUser, + friends: { + ...initialUser.friends, + pageInfo: expect.objectContaining({hasNextPage: true}), + }, + }, + // Assert hasNext is true + hasNext: true, + hasPrevious: false, + }, + ]); + }); + + it('returns false if edges are null', () => { + (environment.getStore().getSource(): $FlowFixMe).clear(); + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: null, + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }, + }); + + renderFragment(); + expectFragmentResults([ + { + data: { + ...initialUser, + friends: { + ...initialUser.friends, + edges: null, + pageInfo: expect.objectContaining({hasNextPage: true}), + }, + }, + // Assert hasNext is false + hasNext: false, + hasPrevious: false, + }, + ]); + }); + + it('returns false if edges are undefined', () => { + (environment.getStore().getSource(): $FlowFixMe).clear(); + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: undefined, + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }, + }); + + renderFragment(); + expectFragmentResults([ + { + data: { + ...initialUser, + friends: { + ...initialUser.friends, + edges: undefined, + pageInfo: expect.objectContaining({hasNextPage: true}), + }, + }, + // Assert hasNext is false + hasNext: false, + hasPrevious: false, + }, + ]); + }); + + it('returns false if end cursor is null', () => { + (environment.getStore().getSource(): $FlowFixMe).clear(); + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + username: 'username:node:1', + }, + }, + ], + pageInfo: { + // endCursor is null + endCursor: null, + // but hasNextPage is still true + hasNextPage: true, + hasPreviousPage: false, + startCursor: null, + }, + }, + }, + }); + + renderFragment(); + expectFragmentResults([ + { + data: { + ...initialUser, + friends: { + ...initialUser.friends, + pageInfo: expect.objectContaining({ + endCursor: null, + hasNextPage: true, + }), + }, + }, + // Assert hasNext is false + hasNext: false, + hasPrevious: false, + }, + ]); + }); + + it('returns false if end cursor is undefined', () => { + (environment.getStore().getSource(): $FlowFixMe).clear(); + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + username: 'username:node:1', + }, + }, + ], + pageInfo: { + // endCursor is undefined + endCursor: undefined, + // but hasNextPage is still true + hasNextPage: true, + hasPreviousPage: false, + startCursor: undefined, + }, + }, + }, + }); + + renderFragment(); + expectFragmentResults([ + { + data: { + ...initialUser, + friends: { + ...initialUser.friends, + pageInfo: expect.objectContaining({ + endCursor: null, + hasNextPage: true, + }), + }, + }, + // Assert hasNext is false + hasNext: false, + hasPrevious: false, + }, + ]); + }); + + it('returns false if pageInfo.hasNextPage is false-ish', () => { + (environment.getStore().getSource(): $FlowFixMe).clear(); + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + username: 'username:node:1', + }, + }, + ], + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: null, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }, + }); + + renderFragment(); + expectFragmentResults([ + { + data: { + ...initialUser, + friends: { + ...initialUser.friends, + pageInfo: expect.objectContaining({ + hasNextPage: null, + }), + }, + }, + // Assert hasNext is false + hasNext: false, + hasPrevious: false, + }, + ]); + }); + + it('returns false if pageInfo.hasNextPage is false', () => { + (environment.getStore().getSource(): $FlowFixMe).clear(); + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + username: 'username:node:1', + }, + }, + ], + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: false, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }, + }); + + renderFragment(); + expectFragmentResults([ + { + data: { + ...initialUser, + friends: { + ...initialUser.friends, + pageInfo: expect.objectContaining({ + hasNextPage: false, + }), + }, + }, + // Assert hasNext is false + hasNext: false, + hasPrevious: false, + }, + ]); + }); + + it('updates after pagination if more results are avialable', () => { + const callback = jest.fn(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + environment.mock.resolve(gqlPaginationQuery, { + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + username: 'username:node:2', + }, + }, + ], + pageInfo: { + startCursor: 'cursor:2', + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: true, + }, + }, + }, + }, + }); + + const expectedUser = { + ...initialUser, + friends: { + ...initialUser.friends, + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', query), + }, + }, + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + ...createFragmentRef('node:2', query), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }; + expectFragmentResults([ + { + // First update has updated connection + data: expectedUser, + // Assert hasNext reflects server response + hasNext: true, + hasPrevious: false, + }, + ]); + expect(callback).toBeCalledTimes(1); + }); + + it('updates after pagination if no more results are avialable', () => { + const callback = jest.fn(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + environment.mock.resolve(gqlPaginationQuery, { + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + username: 'username:node:2', + }, + }, + ], + pageInfo: { + startCursor: 'cursor:2', + endCursor: 'cursor:2', + hasNextPage: false, + hasPreviousPage: true, + }, + }, + }, + }, + }); + + const expectedUser = { + ...initialUser, + friends: { + ...initialUser.friends, + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', query), + }, + }, + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + ...createFragmentRef('node:2', query), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:2', + hasNextPage: false, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }; + expectFragmentResults([ + { + data: expectedUser, + // Assert hasNext reflects server response + hasNext: false, + hasPrevious: false, + }, + ]); + expect(callback).toBeCalledTimes(1); + }); + }); + + describe('refetch', () => { + // The bulk of refetch behavior is covered in useRefetchableFragmentNode-test, + // so this suite covers the pagination-related test cases. + function expectRefetchRequestIsInFlight(expected) { + expect(environment.execute).toBeCalledTimes(expected.requestCount); + expect( + environment.mock.isLoading( + expected.gqlRefetchQuery ?? gqlPaginationQuery, + expected.refetchVariables, + {force: true}, + ), + ).toEqual(expected.inFlight); + } + + function expectFragmentIsRefetching( + renderer, + expected: {| + data: mixed, + hasNext: boolean, + hasPrevious: boolean, + refetchVariables: Variables, + refetchQuery?: OperationDescriptor, + gqlRefetchQuery?: $FlowFixMe, + |}, + ) { + expect(renderSpy).toBeCalledTimes(0); + renderSpy.mockClear(); + + // Assert refetch query was fetched + expectRefetchRequestIsInFlight({ + ...expected, + inFlight: true, + requestCount: 1, + }); + + // Assert component suspended + expect(renderSpy).toBeCalledTimes(0); + expect(renderer.toJSON()).toEqual('Fallback'); + + // Assert query is tentatively retained while component is suspended + expect(environment.retain).toBeCalledTimes(1); + expect(environment.retain.mock.calls[0][0]).toEqual( + expected.refetchQuery?.root ?? paginationQuery.root, + ); + } + + it('refetches new variables correctly when refetching new id', () => { + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + refetch({id: '4'}); + }); + + // Assert that fragment is refetching with the right variables and + // suspends upon refetch + const refetchVariables = { + after: null, + first: 1, + before: null, + last: null, + id: '4', + isViewerFriendLocal: false, + orderby: ['name'], + }; + paginationQuery = createOperationDescriptor( + gqlPaginationQuery, + refetchVariables, + ); + expectFragmentIsRefetching(renderer, { + data: initialUser, + hasNext: true, + hasPrevious: false, + refetchVariables, + refetchQuery: paginationQuery, + }); + + // Mock network response + environment.mock.resolve(gqlPaginationQuery, { + data: { + node: { + __typename: 'User', + id: '4', + name: 'Mark', + friends: { + edges: [ + { + cursor: 'cursor:100', + node: { + __typename: 'User', + id: 'node:100', + name: 'name:node:100', + username: 'username:node:100', + }, + }, + ], + pageInfo: { + endCursor: 'cursor:100', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:100', + }, + }, + }, + }, + }); + + // Assert fragment is rendered with new data + const expectedUser = { + id: '4', + name: 'Mark', + friends: { + edges: [ + { + cursor: 'cursor:100', + node: { + __typename: 'User', + id: 'node:100', + name: 'name:node:100', + ...createFragmentRef('node:100', paginationQuery), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:100', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:100', + }, + }, + }; + expectFragmentResults([ + { + data: expectedUser, + hasNext: true, + hasPrevious: false, + }, + { + data: expectedUser, + hasNext: true, + hasPrevious: false, + }, + ]); + + // Assert refetch query was retained + expect(release).not.toBeCalled(); + expect(environment.retain).toBeCalledTimes(1); + expect(environment.retain.mock.calls[0][0]).toEqual( + paginationQuery.root, + ); + }); + + it('refetches new variables correctly when refetching same id', () => { + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + refetch({isViewerFriendLocal: true, orderby: ['lastname']}); + }); + + // Assert that fragment is refetching with the right variables and + // suspends upon refetch + const refetchVariables = { + after: null, + first: 1, + before: null, + last: null, + id: '1', + isViewerFriendLocal: true, + orderby: ['lastname'], + }; + paginationQuery = createOperationDescriptor( + gqlPaginationQuery, + refetchVariables, + ); + expectFragmentIsRefetching(renderer, { + data: initialUser, + hasNext: true, + hasPrevious: false, + refetchVariables, + refetchQuery: paginationQuery, + }); + + // Mock network response + environment.mock.resolve(gqlPaginationQuery, { + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:100', + node: { + __typename: 'User', + id: 'node:100', + name: 'name:node:100', + username: 'username:node:100', + }, + }, + ], + pageInfo: { + endCursor: 'cursor:100', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:100', + }, + }, + }, + }, + }); + + // Assert fragment is rendered with new data + const expectedUser = { + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:100', + node: { + __typename: 'User', + id: 'node:100', + name: 'name:node:100', + ...createFragmentRef('node:100', paginationQuery), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:100', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:100', + }, + }, + }; + expectFragmentResults([ + { + data: expectedUser, + hasNext: true, + hasPrevious: false, + }, + { + data: expectedUser, + hasNext: true, + hasPrevious: false, + }, + ]); + + // Assert refetch query was retained + expect(release).not.toBeCalled(); + expect(environment.retain).toBeCalledTimes(1); + expect(environment.retain.mock.calls[0][0]).toEqual( + paginationQuery.root, + ); + }); + + it('refetches with correct id from refetchable fragment when using nested fragment', () => { + // Populate store with data for query using nested fragment + environment.commitPayload(queryNestedFragment, { + node: { + __typename: 'Feedback', + id: '', + actor: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + username: 'username:node:1', + }, + }, + ], + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }, + }, + }); + + // Get fragment ref for user using nested fragment + const userRef = (environment.lookup(queryNestedFragment.fragment) + .data: $FlowFixMe)?.node?.actor; + + initialUser = { + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', queryNestedFragment), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }; + + const renderer = renderFragment({owner: queryNestedFragment, userRef}); + expectFragmentResults([ + { + data: initialUser, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + refetch({isViewerFriendLocal: true, orderby: ['lastname']}); + }); + + // Assert that fragment is refetching with the right variables and + // suspends upon refetch + const refetchVariables = { + after: null, + first: 1, + before: null, + last: null, + // The id here should correspond to the user id, and not the + // feedback id from the query variables (i.e. ``) + id: '1', + isViewerFriendLocal: true, + orderby: ['lastname'], + }; + paginationQuery = createOperationDescriptor( + gqlPaginationQuery, + refetchVariables, + ); + expectFragmentIsRefetching(renderer, { + data: initialUser, + hasNext: true, + hasPrevious: false, + refetchVariables, + refetchQuery: paginationQuery, + }); + + // Mock network response + environment.mock.resolve(gqlPaginationQuery, { + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:100', + node: { + __typename: 'User', + id: 'node:100', + name: 'name:node:100', + username: 'username:node:100', + }, + }, + ], + pageInfo: { + endCursor: 'cursor:100', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:100', + }, + }, + }, + }, + }); + + // Assert fragment is rendered with new data + const expectedUser = { + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:100', + node: { + __typename: 'User', + id: 'node:100', + name: 'name:node:100', + ...createFragmentRef('node:100', paginationQuery), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:100', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:100', + }, + }, + }; + expectFragmentResults([ + { + data: expectedUser, + hasNext: true, + hasPrevious: false, + }, + { + data: expectedUser, + hasNext: true, + hasPrevious: false, + }, + ]); + + // Assert refetch query was retained + expect(release).not.toBeCalled(); + expect(environment.retain).toBeCalledTimes(1); + expect(environment.retain.mock.calls[0][0]).toEqual( + paginationQuery.root, + ); + }); + + it('loads more items correctly after refetching', () => { + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + refetch({isViewerFriendLocal: true, orderby: ['lastname']}); + }); + + // Assert that fragment is refetching with the right variables and + // suspends upon refetch + const refetchVariables = { + after: null, + first: 1, + before: null, + last: null, + id: '1', + isViewerFriendLocal: true, + orderby: ['lastname'], + }; + paginationQuery = createOperationDescriptor( + gqlPaginationQuery, + refetchVariables, + ); + expectFragmentIsRefetching(renderer, { + data: initialUser, + hasNext: true, + hasPrevious: false, + refetchVariables, + refetchQuery: paginationQuery, + }); + + // Mock network response + environment.mock.resolve(gqlPaginationQuery, { + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:100', + node: { + __typename: 'User', + id: 'node:100', + name: 'name:node:100', + username: 'username:node:100', + }, + }, + ], + pageInfo: { + endCursor: 'cursor:100', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:100', + }, + }, + }, + }, + }); + + // Assert fragment is rendered with new data + const expectedUser = { + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:100', + node: { + __typename: 'User', + id: 'node:100', + name: 'name:node:100', + ...createFragmentRef('node:100', paginationQuery), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:100', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:100', + }, + }, + }; + expectFragmentResults([ + { + data: expectedUser, + hasNext: true, + hasPrevious: false, + }, + { + data: expectedUser, + hasNext: true, + hasPrevious: false, + }, + ]); + + // Assert refetch query was retained + expect(release).not.toBeCalled(); + expect(environment.retain).toBeCalledTimes(1); + expect(environment.retain.mock.calls[0][0]).toEqual( + paginationQuery.root, + ); + + // Paginate after refetching + environment.execute.mockClear(); + TestRenderer.act(() => { + loadNext(1); + }); + const paginationVariables = { + id: '1', + after: 'cursor:100', + first: 1, + before: null, + last: null, + isViewerFriendLocal: true, + orderby: ['lastname'], + }; + expectFragmentIsLoadingMore(renderer, 'forward', { + data: expectedUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + + environment.mock.resolve(gqlPaginationQuery, { + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:200', + node: { + __typename: 'User', + id: 'node:200', + name: 'name:node:200', + username: 'username:node:200', + }, + }, + ], + pageInfo: { + startCursor: 'cursor:200', + endCursor: 'cursor:200', + hasNextPage: true, + hasPreviousPage: true, + }, + }, + }, + }, + }); + + const paginatedUser = { + ...expectedUser, + friends: { + ...expectedUser.friends, + edges: [ + { + cursor: 'cursor:100', + node: { + __typename: 'User', + id: 'node:100', + name: 'name:node:100', + ...createFragmentRef('node:100', paginationQuery), + }, + }, + { + cursor: 'cursor:200', + node: { + __typename: 'User', + id: 'node:200', + name: 'name:node:200', + ...createFragmentRef('node:200', paginationQuery), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:200', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:100', + }, + }, + }; + expectFragmentResults([ + { + // Second update sets isLoading flag back to false + data: paginatedUser, + hasNext: true, + hasPrevious: false, + }, + ]); + }); + }); + }); +}); diff --git a/packages/relay-experimental/__tests__/useBlockingPaginationFragment-with-suspense-transition-test.js b/packages/relay-experimental/__tests__/useBlockingPaginationFragment-with-suspense-transition-test.js new file mode 100644 index 000000000000..b0096a3727ed --- /dev/null +++ b/packages/relay-experimental/__tests__/useBlockingPaginationFragment-with-suspense-transition-test.js @@ -0,0 +1,1305 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails oncall+relay + * @flow + * @format + */ + +'use strict'; + +const React = require('react'); +const Scheduler = require('scheduler'); + +import type {Direction} from '../useLoadMoreFunction'; +import type {OperationDescriptor, Variables} from 'relay-runtime'; +const { + // $FlowFixMe unstable_withSuspenseConfig isn't in the public ReactDOM flow typing + unstable_withSuspenseConfig, + useCallback, + useMemo, + useState, +} = React; +const TestRenderer = require('react-test-renderer'); + +const invariant = require('invariant'); +const useBlockingPaginationFragmentOriginal = require('../useBlockingPaginationFragment'); +const ReactRelayContext = require('react-relay/ReactRelayContext'); +const { + ConnectionHandler, + FRAGMENT_OWNER_KEY, + FRAGMENTS_KEY, + ID_KEY, + createOperationDescriptor, +} = require('relay-runtime'); + +const PAGINATION_SUSPENSE_CONFIG = {timeoutMs: 45 * 1000}; + +function useSuspenseTransition(config: {|timeoutMs: number|}) { + const [isPending, setPending] = useState(false); + const startTransition = useCallback( + (callback: () => void) => { + setPending(true); + Scheduler.unstable_next(() => { + unstable_withSuspenseConfig(() => { + setPending(false); + callback(); + }, config); + }); + }, + [config, setPending], + ); + return [startTransition, isPending]; +} + +describe('useBlockingPaginationFragment with useSuspenseTransition', () => { + let environment; + let initialUser; + let gqlQuery; + let gqlQueryWithoutID; + let gqlPaginationQuery; + let gqlFragment; + let query; + let queryWithoutID; + let paginationQuery; + let variables; + let variablesWithoutID; + let setEnvironment; + let setOwner; + let renderFragment; + let renderSpy; + let createMockEnvironment; + let generateAndCompile; + let loadNext; + let refetch; + let forceUpdate; + let Renderer; + + class ErrorBoundary extends React.Component { + state = {error: null}; + componentDidCatch(error) { + this.setState({error}); + } + render() { + const {children, fallback} = this.props; + const {error} = this.state; + if (error) { + return React.createElement(fallback, {error}); + } + return children; + } + } + + function useBlockingPaginationFragmentWithSuspenseTransition( + fragmentNode, + fragmentRef, + ) { + const [startTransition, isPendingNext] = useSuspenseTransition( + PAGINATION_SUSPENSE_CONFIG, + ); + const {data, ...result} = useBlockingPaginationFragmentOriginal( + fragmentNode, + // $FlowFixMe + fragmentRef, + ); + loadNext = (...args) => { + let disposable = {dispose: () => {}}; + startTransition(() => { + disposable = result.loadNext(...args); + }); + return disposable; + }; + refetch = result.refetch; + // $FlowFixMe + result.isPendingNext = isPendingNext; + renderSpy(data, result); + return {data, ...result}; + } + + function assertCall(expected, idx) { + const actualData = renderSpy.mock.calls[idx][0]; + const actualResult = renderSpy.mock.calls[idx][1]; + // $FlowFixMe + const actualIsNextPending = actualResult.isPendingNext; + const actualHasNext = actualResult.hasNext; + const actualHasPrevious = actualResult.hasPrevious; + + expect(actualData).toEqual(expected.data); + expect(actualIsNextPending).toEqual(expected.isPendingNext); + expect(actualHasNext).toEqual(expected.hasNext); + expect(actualHasPrevious).toEqual(expected.hasPrevious); + } + + function expectFragmentResults( + expectedCalls: $ReadOnlyArray<{| + data: $FlowFixMe, + isPendingNext: boolean, + hasNext: boolean, + hasPrevious: boolean, + |}>, + ) { + // This ensures that useEffect runs + TestRenderer.act(() => jest.runAllImmediates()); + expect(renderSpy).toBeCalledTimes(expectedCalls.length); + expectedCalls.forEach((expected, idx) => assertCall(expected, idx)); + renderSpy.mockClear(); + } + + function createFragmentRef(id, owner) { + return { + [ID_KEY]: id, + [FRAGMENTS_KEY]: { + NestedUserFragment: {}, + }, + [FRAGMENT_OWNER_KEY]: owner.request, + }; + } + + beforeEach(() => { + // Set up mocks + jest.resetModules(); + jest.spyOn(console, 'warn').mockImplementationOnce(() => {}); + jest.mock('warning'); + jest.mock('fbjs/lib/ExecutionEnvironment', () => ({ + canUseDOM: () => true, + })); + renderSpy = jest.fn(); + + ({ + createMockEnvironment, + generateAndCompile, + } = require('relay-test-utils-internal')); + + // Set up environment and base data + environment = createMockEnvironment({ + handlerProvider: () => ConnectionHandler, + }); + const generated = generateAndCompile( + ` + fragment NestedUserFragment on User { + username + } + + fragment UserFragment on User + @refetchable(queryName: "UserFragmentPaginationQuery") + @argumentDefinitions( + isViewerFriendLocal: {type: "Boolean", defaultValue: false} + orderby: {type: "[String]"} + ) { + id + name + friends( + after: $after, + first: $first, + before: $before, + last: $last, + orderby: $orderby, + isViewerFriend: $isViewerFriendLocal + ) @connection(key: "UserFragment_friends") { + edges { + node { + id + name + ...NestedUserFragment + } + } + } + } + + query UserQuery( + $id: ID! + $after: ID + $first: Int + $before: ID + $last: Int + $orderby: [String] + $isViewerFriend: Boolean + ) { + node(id: $id) { + actor { + ...UserFragment @arguments(isViewerFriendLocal: $isViewerFriend, orderby: $orderby) + } + } + } + + query UserQueryWithoutID( + $after: ID + $first: Int + $before: ID + $last: Int + $orderby: [String] + $isViewerFriend: Boolean + ) { + viewer { + actor { + ...UserFragment @arguments(isViewerFriendLocal: $isViewerFriend, orderby: $orderby) + } + } + } + `, + ); + variablesWithoutID = { + after: null, + first: 1, + before: null, + last: null, + isViewerFriend: false, + orderby: ['name'], + }; + variables = { + ...variablesWithoutID, + id: '', + }; + gqlQuery = generated.UserQuery; + gqlQueryWithoutID = generated.UserQueryWithoutID; + gqlPaginationQuery = generated.UserFragmentPaginationQuery; + gqlFragment = generated.UserFragment; + invariant( + gqlFragment.metadata?.refetch?.operation === + '@@MODULE_START@@UserFragmentPaginationQuery.graphql@@MODULE_END@@', + 'useRefetchableFragment-test: Expected refetchable fragment metadata to contain operation.', + ); + // Manually set the refetchable operation for the test. + gqlFragment.metadata.refetch.operation = gqlPaginationQuery; + + query = createOperationDescriptor(gqlQuery, variables); + queryWithoutID = createOperationDescriptor( + gqlQueryWithoutID, + variablesWithoutID, + ); + paginationQuery = createOperationDescriptor(gqlPaginationQuery, variables); + environment.commitPayload(query, { + node: { + __typename: 'Feedback', + id: '', + actor: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + username: 'username:node:1', + }, + }, + ], + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }, + }, + }); + environment.commitPayload(queryWithoutID, { + viewer: { + actor: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + username: 'username:node:1', + }, + }, + ], + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }, + }, + }); + + // Set up renderers + Renderer = props => null; + + const Container = (props: { + userRef?: {}, + owner: $FlowFixMe, + fragment: $FlowFixMe, + }) => { + // We need a render a component to run a Hook + const [owner, _setOwner] = useState(props.owner); + const [_, _setCount] = useState(0); + const fragment = props.fragment ?? gqlFragment; + const artificialUserRef = useMemo(() => { + const snapshot = environment.lookup(owner.fragment); + return (snapshot.data: $FlowFixMe)?.node?.actor; + }, [owner]); + const userRef = props.hasOwnProperty('userRef') + ? props.userRef + : artificialUserRef; + + setOwner = _setOwner; + forceUpdate = _setCount; + + const { + data: userData, + } = useBlockingPaginationFragmentWithSuspenseTransition( + fragment, + userRef, + ); + return ; + }; + + const ContextProvider = ({children}) => { + const [env, _setEnv] = useState(environment); + // TODO(T39494051) - We set empty variables in relay context to make + // Flow happy, but useBlockingPaginationFragment does not use them, instead it uses + // the variables from the fragment owner. + const relayContext = useMemo(() => ({environment: env, variables: {}}), [ + env, + ]); + + setEnvironment = _setEnv; + + return ( + + {children} + + ); + }; + + renderFragment = (args?: { + isConcurrent?: boolean, + owner?: $FlowFixMe, + userRef?: $FlowFixMe, + fragment?: $FlowFixMe, + }): $FlowFixMe => { + const {isConcurrent = false, ...props} = args ?? {}; + let renderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + `Error: ${error.message}`}> + + + + + + , + {unstable_isConcurrent: isConcurrent}, + ); + }); + return renderer; + }; + + initialUser = { + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', query), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }; + }); + + afterEach(() => { + environment.mockClear(); + renderSpy.mockClear(); + }); + + describe('pagination', () => { + let runScheduledCallback = () => {}; + let release; + + beforeEach(() => { + jest.resetModules(); + jest.doMock('scheduler', () => { + const original = jest.requireActual('scheduler/unstable_mock'); + return { + ...original, + unstable_next: cb => { + runScheduledCallback = () => { + original.unstable_next(cb); + }; + }, + }; + }); + + release = jest.fn(); + environment.retain.mockImplementation((...args) => { + return { + dispose: release, + }; + }); + }); + + afterEach(() => { + jest.dontMock('scheduler'); + }); + + function expectRequestIsInFlight(expected) { + expect(environment.execute).toBeCalledTimes(expected.requestCount); + expect( + environment.mock.isLoading( + expected.gqlPaginationQuery ?? gqlPaginationQuery, + expected.paginationVariables, + {force: true}, + ), + ).toEqual(expected.inFlight); + } + + function expectFragmentIsLoadingMore( + renderer, + direction: Direction, + expected: {| + data: mixed, + hasNext: boolean, + hasPrevious: boolean, + paginationVariables: Variables, + gqlPaginationQuery?: $FlowFixMe, + |}, + ) { + // Assert fragment sets isPending to true + expect(renderSpy).toBeCalledTimes(1); + assertCall( + { + data: expected.data, + isPendingNext: direction === 'forward', + hasNext: expected.hasNext, + hasPrevious: expected.hasPrevious, + }, + 0, + ); + renderSpy.mockClear(); + + // $FlowExpectedError batchedUpdats is not part of the public Flow types + TestRenderer.unstable_batchedUpdates(() => { + runScheduledCallback(); + jest.runAllImmediates(); + }); + Scheduler.unstable_flushExpired(); + + // Assert refetch query was fetched + expectRequestIsInFlight({...expected, inFlight: true, requestCount: 1}); + } + + describe('loadNext', () => { + const direction = 'forward'; + + // Sanity check test, should already be tested in useBlockingPagination test + it('loads and renders next items in connection', () => { + const callback = jest.fn(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isPendingNext: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + jest.runAllTimers(); + }); + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + environment.mock.resolve(gqlPaginationQuery, { + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + username: 'username:node:2', + }, + }, + ], + pageInfo: { + startCursor: 'cursor:2', + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: true, + }, + }, + }, + }, + }); + + const expectedUser = { + ...initialUser, + friends: { + ...initialUser.friends, + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', query), + }, + }, + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + ...createFragmentRef('node:2', query), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }; + expectFragmentResults([ + { + data: expectedUser, + isPendingNext: false, + hasNext: true, + hasPrevious: false, + }, + ]); + expect(callback).toBeCalledTimes(1); + }); + + it('renders pending flag correctly if pagination update is interrupted before it commits (unsuspends)', () => { + const callback = jest.fn(); + const renderer = renderFragment({isConcurrent: true}); + expectFragmentResults([ + { + data: initialUser, + isPendingNext: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + Scheduler.unstable_flushAll(); + jest.runAllTimers(); + }); + + expect(renderer.toJSON()).toEqual(null); + + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + // Schedule a high-pri update while the component is + // suspended on pagination + Scheduler.unstable_runWithPriority( + Scheduler.unstable_UserBlockingPriority, + () => { + forceUpdate(prev => prev + 1); + }, + ); + + Scheduler.unstable_flushAll(); + + // Assert high-pri update is rendered when initial update + // that suspended hasn't committed + // Assert that the avoided Suspense fallback isn't rendered + expect(renderer.toJSON()).toEqual(null); + expectFragmentResults([ + { + data: initialUser, + // Assert that isPending flag is still true + isPendingNext: true, + hasNext: true, + hasPrevious: false, + }, + ]); + + // Assert list is updated after pagination request completes + environment.mock.resolve(gqlPaginationQuery, { + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + username: 'username:node:2', + }, + }, + ], + pageInfo: { + startCursor: 'cursor:2', + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: true, + }, + }, + }, + }, + }); + + const expectedUser = { + ...initialUser, + friends: { + ...initialUser.friends, + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', query), + }, + }, + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + ...createFragmentRef('node:2', query), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }; + + expectFragmentResults([ + { + data: expectedUser, + isPendingNext: false, + hasNext: true, + hasPrevious: false, + }, + ]); + expect(callback).toBeCalledTimes(1); + }); + + it('loads more correctly when original variables do not include an id', () => { + const callback = jest.fn(); + const viewer = environment.lookup(queryWithoutID.fragment).data?.viewer; + const userRef = + typeof viewer === 'object' && viewer != null ? viewer?.actor : null; + invariant(userRef != null, 'Expected to have cached test data'); + + let expectedUser = { + ...initialUser, + friends: { + ...initialUser.friends, + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', queryWithoutID), + }, + }, + ], + }, + }; + + const renderer = renderFragment({owner: queryWithoutID, userRef}); + expectFragmentResults([ + { + data: expectedUser, + isPendingNext: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: expectedUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + environment.mock.resolve(gqlPaginationQuery, { + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + username: 'username:node:2', + }, + }, + ], + pageInfo: { + startCursor: 'cursor:2', + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: true, + }, + }, + }, + }, + }); + + expectedUser = { + ...initialUser, + friends: { + ...initialUser.friends, + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', queryWithoutID), + }, + }, + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + ...createFragmentRef('node:2', queryWithoutID), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }; + expectFragmentResults([ + { + data: expectedUser, + isPendingNext: false, + hasNext: true, + hasPrevious: false, + }, + ]); + expect(callback).toBeCalledTimes(1); + }); + + it('calls callback with error when error occurs during fetch', () => { + const callback = jest.fn(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isPendingNext: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + const error = new Error('Oops'); + environment.mock.reject(gqlPaginationQuery, error); + + // We pass the error in the callback, but do not throw during render + // since we want to continue rendering the existing items in the + // connection + expect(callback).toBeCalledTimes(1); + expect(callback).toBeCalledWith(error); + }); + + it('preserves pagination request if re-rendered with same fragment ref', () => { + const callback = jest.fn(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isPendingNext: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + TestRenderer.act(() => { + setOwner({...query}); + }); + + // Assert that request is still in flight after re-rendering + // with new fragment ref that points to the same data. + expectRequestIsInFlight({ + inFlight: true, + requestCount: 1, + gqlPaginationQuery, + paginationVariables, + }); + expect(callback).toBeCalledTimes(0); + + environment.mock.resolve(gqlPaginationQuery, { + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + username: 'username:node:2', + }, + }, + ], + pageInfo: { + startCursor: 'cursor:2', + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: true, + }, + }, + }, + }, + }); + + const expectedUser = { + ...initialUser, + friends: { + ...initialUser.friends, + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', query), + }, + }, + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + ...createFragmentRef('node:2', query), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }; + expectFragmentResults([ + { + data: expectedUser, + isPendingNext: false, + hasNext: true, + hasPrevious: false, + }, + ]); + expect(callback).toBeCalledTimes(1); + }); + }); + + describe('refetch', () => { + // The bulk of refetch behavior is covered in useRefetchableFragmentNode-test, + // so this suite covers the pagination-related test cases. + function expectRefetchRequestIsInFlight(expected) { + expect(environment.execute).toBeCalledTimes(expected.requestCount); + expect( + environment.mock.isLoading( + expected.gqlRefetchQuery ?? gqlPaginationQuery, + expected.refetchVariables, + {force: true}, + ), + ).toEqual(expected.inFlight); + } + + function expectFragmentIsRefetching( + renderer, + expected: {| + data: mixed, + hasNext: boolean, + hasPrevious: boolean, + refetchVariables: Variables, + refetchQuery?: OperationDescriptor, + gqlRefetchQuery?: $FlowFixMe, + |}, + ) { + expect(renderSpy).toBeCalledTimes(0); + renderSpy.mockClear(); + + // Assert refetch query was fetched + expectRefetchRequestIsInFlight({ + ...expected, + inFlight: true, + requestCount: 1, + }); + + // Assert component suspended + expect(renderSpy).toBeCalledTimes(0); + expect(renderer.toJSON()).toEqual('Fallback'); + + // Assert query is tentatively retained while component is suspended + expect(environment.retain).toBeCalledTimes(1); + expect(environment.retain.mock.calls[0][0]).toEqual( + expected.refetchQuery?.root ?? paginationQuery.root, + ); + } + + it('loads more items correctly after refetching', () => { + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isPendingNext: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + refetch({isViewerFriendLocal: true, orderby: ['lastname']}); + }); + + // Assert that fragment is refetching with the right variables and + // suspends upon refetch + const refetchVariables = { + after: null, + first: 1, + before: null, + last: null, + id: '1', + isViewerFriendLocal: true, + orderby: ['lastname'], + }; + paginationQuery = createOperationDescriptor( + gqlPaginationQuery, + refetchVariables, + ); + expectFragmentIsRefetching(renderer, { + data: initialUser, + hasNext: true, + hasPrevious: false, + refetchVariables, + refetchQuery: paginationQuery, + }); + + // Mock network response + environment.mock.resolve(gqlPaginationQuery, { + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:100', + node: { + __typename: 'User', + id: 'node:100', + name: 'name:node:100', + username: 'username:node:100', + }, + }, + ], + pageInfo: { + endCursor: 'cursor:100', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:100', + }, + }, + }, + }, + }); + + // Assert fragment is rendered with new data + const expectedUser = { + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:100', + node: { + __typename: 'User', + id: 'node:100', + name: 'name:node:100', + ...createFragmentRef('node:100', paginationQuery), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:100', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:100', + }, + }, + }; + expectFragmentResults([ + { + data: expectedUser, + isPendingNext: false, + hasNext: true, + hasPrevious: false, + }, + { + data: expectedUser, + isPendingNext: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + // Assert refetch query was retained + expect(release).not.toBeCalled(); + expect(environment.retain).toBeCalledTimes(1); + expect(environment.retain.mock.calls[0][0]).toEqual( + paginationQuery.root, + ); + + // Paginate after refetching + environment.execute.mockClear(); + TestRenderer.act(() => { + loadNext(1); + }); + const paginationVariables = { + id: '1', + after: 'cursor:100', + first: 1, + before: null, + last: null, + isViewerFriendLocal: true, + orderby: ['lastname'], + }; + expectFragmentIsLoadingMore(renderer, 'forward', { + data: expectedUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + + environment.mock.resolve(gqlPaginationQuery, { + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:200', + node: { + __typename: 'User', + id: 'node:200', + name: 'name:node:200', + username: 'username:node:200', + }, + }, + ], + pageInfo: { + startCursor: 'cursor:200', + endCursor: 'cursor:200', + hasNextPage: true, + hasPreviousPage: true, + }, + }, + }, + }, + }); + + const paginatedUser = { + ...expectedUser, + friends: { + ...expectedUser.friends, + edges: [ + { + cursor: 'cursor:100', + node: { + __typename: 'User', + id: 'node:100', + name: 'name:node:100', + ...createFragmentRef('node:100', paginationQuery), + }, + }, + { + cursor: 'cursor:200', + node: { + __typename: 'User', + id: 'node:200', + name: 'name:node:200', + ...createFragmentRef('node:200', paginationQuery), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:200', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:100', + }, + }, + }; + expectFragmentResults([ + { + data: paginatedUser, + // Assert pending flag is set back to false + isPendingNext: false, + hasNext: true, + hasPrevious: false, + }, + ]); + }); + }); + }); +}); diff --git a/packages/relay-experimental/__tests__/useFragment-test.js b/packages/relay-experimental/__tests__/useFragment-test.js new file mode 100644 index 000000000000..679885264532 --- /dev/null +++ b/packages/relay-experimental/__tests__/useFragment-test.js @@ -0,0 +1,266 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails oncall+relay + * @flow + * @format + */ + +'use strict'; + +const React = require('react'); +const ReactRelayContext = require('react-relay/ReactRelayContext'); +const TestRenderer = require('react-test-renderer'); + +const useFragmentOriginal = require('../useFragment'); + +const { + FRAGMENT_OWNER_KEY, + FRAGMENTS_KEY, + ID_KEY, + createOperationDescriptor, +} = require('relay-runtime'); + +describe('useFragment', () => { + let environment; + let createMockEnvironment; + let generateAndCompile; + let gqlSingularQuery; + let gqlSingularFragment; + let gqlPluralQuery; + let gqlPluralFragment; + let singularQuery; + let pluralQuery; + let singularVariables; + let pluralVariables; + let renderSingularFragment; + let renderPluralFragment; + let renderSpy; + let SingularRenderer; + let PluralRenderer; + let ContextProvider; + + function useFragment(fragmentNode, fragmentRef) { + // $FlowFixMe + const data = useFragmentOriginal(fragmentNode, fragmentRef); + renderSpy(data); + return data; + } + + function assertFragmentResults(expected) { + // This ensures that useEffect runs + jest.runAllImmediates(); + expect(renderSpy).toBeCalledTimes(1); + const actualData = renderSpy.mock.calls[0][0]; + expect(actualData).toEqual(expected); + renderSpy.mockClear(); + } + + function createFragmentRef(id, owner) { + return { + [ID_KEY]: id, + [FRAGMENTS_KEY]: { + NestedUserFragment: {}, + }, + [FRAGMENT_OWNER_KEY]: owner.request, + }; + } + + beforeEach(() => { + // Set up mocks + jest.resetModules(); + renderSpy = jest.fn(); + + ({ + createMockEnvironment, + generateAndCompile, + } = require('relay-test-utils-internal')); + + // Set up environment and base data + environment = createMockEnvironment(); + const generated = generateAndCompile( + ` + fragment NestedUserFragment on User { + username + } + + fragment UserFragment on User { + id + name + ...NestedUserFragment + } + + query UserQuery($id: ID!) { + node(id: $id) { + ...UserFragment + } + } + + fragment UsersFragment on User @relay(plural: true) { + id + name + ...NestedUserFragment + } + + query UsersQuery($ids: [ID!]!, $scale: Int!) { + nodes(ids: $ids) { + ...UsersFragment + } + } + `, + ); + singularVariables = {id: '1'}; + pluralVariables = {ids: ['1', '2']}; + gqlSingularQuery = generated.UserQuery; + gqlSingularFragment = generated.UserFragment; + gqlPluralQuery = generated.UsersQuery; + gqlPluralFragment = generated.UsersFragment; + singularQuery = createOperationDescriptor( + gqlSingularQuery, + singularVariables, + ); + pluralQuery = createOperationDescriptor(gqlPluralQuery, pluralVariables); + environment.commitPayload(singularQuery, { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + username: 'useralice', + }, + }); + environment.commitPayload(pluralQuery, { + nodes: [ + { + __typename: 'User', + id: '1', + name: 'Alice', + username: 'useralice', + profile_picture: null, + }, + { + __typename: 'User', + id: '2', + name: 'Bob', + username: 'userbob', + profile_picture: null, + }, + ], + }); + + // Set up renderers + SingularRenderer = props => null; + PluralRenderer = props => null; + const SingularContainer = (props: {userRef?: {}, owner: $FlowFixMe}) => { + // We need a render a component to run a Hook + const owner = props.owner; + const userRef = props.hasOwnProperty('userRef') + ? props.userRef + : { + [ID_KEY]: owner.request.variables.id, + [FRAGMENTS_KEY]: { + UserFragment: {}, + }, + [FRAGMENT_OWNER_KEY]: owner.request, + }; + const userData = useFragment(gqlSingularFragment, userRef); + return ; + }; + + const PluralContainer = (props: { + usersRef?: $ReadOnlyArray<{}>, + owner: $FlowFixMe, + }) => { + // We need a render a component to run a Hook + const owner = props.owner; + const usersRef = props.hasOwnProperty('usersRef') + ? props.usersRef + : owner.request.variables.ids.map(id => ({ + [ID_KEY]: id, + [FRAGMENTS_KEY]: { + UsersFragment: {}, + }, + [FRAGMENT_OWNER_KEY]: owner.request, + })); + + const usersData = useFragment(gqlPluralFragment, usersRef); + return ; + }; + + const relayContext = { + environment, + // TODO(T39494051) - We set empty variables in relay context to make + // Flow happy, but useFragmentNodes does not use them, instead it uses + // the variables from the fragment owner. + variables: {}, + }; + ContextProvider = ({children}) => { + return ( + + {children} + + ); + }; + + renderSingularFragment = (props?: { + owner?: $FlowFixMe, + userRef?: $FlowFixMe, + }) => { + return TestRenderer.create( + + + + + , + ); + }; + + renderPluralFragment = (props?: { + owner?: $FlowFixMe, + userRef?: $FlowFixMe, + }) => { + return TestRenderer.create( + + + + + , + ); + }; + }); + + afterEach(() => { + environment.mockClear(); + renderSpy.mockClear(); + }); + + // These tests are only a sanity check for useFragment as a wrapper + // around useFragmentNodes + // See full test behavior in useFragmentNodes-test. + it('should render singular fragment without error when data is available', () => { + renderSingularFragment(); + assertFragmentResults({ + id: '1', + name: 'Alice', + ...createFragmentRef('1', singularQuery), + }); + }); + + it('should render plural fragment without error when data is available', () => { + renderPluralFragment(); + assertFragmentResults([ + { + id: '1', + name: 'Alice', + ...createFragmentRef('1', pluralQuery), + }, + { + id: '2', + name: 'Bob', + ...createFragmentRef('2', pluralQuery), + }, + ]); + }); +}); diff --git a/packages/relay-experimental/__tests__/useFragmentNodes-test.js b/packages/relay-experimental/__tests__/useFragmentNodes-test.js new file mode 100644 index 000000000000..9c01164f743b --- /dev/null +++ b/packages/relay-experimental/__tests__/useFragmentNodes-test.js @@ -0,0 +1,1570 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails oncall+relay + * @flow + * @format + */ + +'use strict'; + +const React = require('react'); +const {useMemo, useRef, useState} = React; +const TestRenderer = require('react-test-renderer'); + +const useFragmentNodesOriginal = require('../useFragmentNodes'); +const ReactRelayContext = require('react-relay/ReactRelayContext'); +const { + FRAGMENT_OWNER_KEY, + FRAGMENTS_KEY, + ID_KEY, + createOperationDescriptor, +} = require('relay-runtime'); + +import type {OperationDescriptor} from 'relay-runtime'; + +function captureAssertion(fn) { + // Trick to use a Jest matcher inside another Jest matcher. `fn` contains an + // assertion; if it throws, we capture the error and return it, so the stack + // trace presented to the user points to the original assertion in the + // test file. + try { + fn(); + } catch (error) { + return { + pass: false, + message: () => error.message, + }; + } + return {pass: true}; +} + +function assertYieldsWereCleared(_scheduler) { + // $FlowFixMe + const actualYields = _scheduler.unstable_clearYields(); + if (actualYields.length !== 0) { + throw new Error( + 'Log of yielded values is not empty. ' + + 'Call expect(Scheduler).toHaveYielded(...) first.', + ); + } +} + +function expectSchedulerToFlushAndYield(expectedYields) { + const Scheduler = require('scheduler'); + assertYieldsWereCleared(Scheduler); + Scheduler.unstable_flushAllWithoutAsserting(); + const actualYields = Scheduler.unstable_clearYields(); + return captureAssertion(() => { + expect(actualYields).toEqual(expectedYields); + }); +} + +describe('useFragmentNodes', () => { + let environment; + let createMockEnvironment; + let disableStoreUpdates; + let enableStoreUpdates; + let generateAndCompile; + let gqlSingularQuery; + let gqlSingularFragment; + let gqlPluralQuery; + let gqlPluralFragment; + let singularQuery; + let pluralQuery; + let singularVariables; + let pluralVariables; + let setEnvironment; + let setSingularOwner; + let setSingularFooScalar; + let setSingularFooObject; + let renderSingularFragment; + let renderPluralFragment; + let forceSingularUpdate; + let renderSpy; + let SingularRenderer; + let PluralRenderer; + + function resetRenderMock() { + renderSpy.mockClear(); + } + + function useFragmentNodes(fragmentNodes, fragmentRefs) { + const result = useFragmentNodesOriginal( + fragmentNodes, + fragmentRefs, + 'TestDisplayName', + ); + const {data, shouldUpdateGeneration} = result; + disableStoreUpdates = result.disableStoreUpdates; + enableStoreUpdates = result.enableStoreUpdates; + + const prevShouldUpdateGeneration = useRef(null); + let shouldUpdate = false; + if (prevShouldUpdateGeneration.current !== shouldUpdateGeneration) { + shouldUpdate = true; + prevShouldUpdateGeneration.current = shouldUpdateGeneration; + } + + renderSpy(data, shouldUpdate); + return [data, shouldUpdate]; + } + + function assertCall(key, expected, idx) { + const actualData = renderSpy.mock.calls[idx][0]; + const actualShouldUpdate = renderSpy.mock.calls[idx][1]; + + expect(actualData[key]).toEqual(expected.data[key]); + expect(actualShouldUpdate).toEqual(expected.shouldUpdate); + } + + function assertFragmentResults( + key, + expectedCalls: $ReadOnlyArray<{|data: $FlowFixMe, shouldUpdate: boolean|}>, + ) { + // This ensures that useEffect runs + TestRenderer.act(() => jest.runAllImmediates()); + expect(renderSpy).toBeCalledTimes(expectedCalls.length); + expectedCalls.forEach((expected, idx) => assertCall(key, expected, idx)); + renderSpy.mockClear(); + } + + function expectSingularFragmentResults(expectedCalls) { + assertFragmentResults('user', expectedCalls); + } + + function expectPluralFragmentResults(expectedCalls) { + assertFragmentResults('users', expectedCalls); + } + + function createFragmentRef(id, owner) { + return { + [ID_KEY]: id, + [FRAGMENTS_KEY]: { + NestedUserFragment: {}, + }, + [FRAGMENT_OWNER_KEY]: owner.request, + }; + } + + beforeEach(() => { + // Set up mocks + jest.resetModules(); + jest.spyOn(console, 'warn').mockImplementationOnce(() => {}); + jest.mock('warning'); + jest.mock('scheduler', () => { + return jest.requireActual('scheduler/unstable_mock'); + }); + jest.mock('fbjs/lib/ExecutionEnvironment', () => ({ + canUseDOM: () => true, + })); + renderSpy = jest.fn(); + + ({ + createMockEnvironment, + generateAndCompile, + } = require('relay-test-utils-internal')); + + // Set up environment and base data + environment = createMockEnvironment(); + const generated = generateAndCompile( + ` + fragment NestedUserFragment on User { + username + } + + fragment UserFragment on User { + id + name + profile_picture(scale: $scale) { + uri + } + ...NestedUserFragment + } + + fragment UsersFragment on User @relay(plural: true) { + id + name + profile_picture(scale: $scale) { + uri + } + ...NestedUserFragment + } + + query UsersQuery($ids: [ID!]!, $scale: Int!) { + nodes(ids: $ids) { + ...UsersFragment + } + } + + query UserQuery($id: ID!, $scale: Int!) { + node(id: $id) { + ...UserFragment + } + } + `, + ); + singularVariables = {id: '1', scale: 16}; + pluralVariables = {ids: ['1', '2'], scale: 16}; + gqlSingularQuery = generated.UserQuery; + gqlSingularFragment = generated.UserFragment; + gqlPluralQuery = generated.UsersQuery; + gqlPluralFragment = generated.UsersFragment; + singularQuery = createOperationDescriptor( + gqlSingularQuery, + singularVariables, + ); + pluralQuery = createOperationDescriptor(gqlPluralQuery, pluralVariables); + environment.commitPayload(singularQuery, { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + username: 'useralice', + profile_picture: null, + }, + }); + environment.commitPayload(pluralQuery, { + nodes: [ + { + __typename: 'User', + id: '1', + name: 'Alice', + username: 'useralice', + profile_picture: null, + }, + { + __typename: 'User', + id: '2', + name: 'Bob', + username: 'userbob', + profile_picture: null, + }, + ], + }); + + // Set up renderers + SingularRenderer = props => null; + PluralRenderer = props => null; + + const SingularContainer = (props: { + userRef?: {}, + fooScalar?: boolean, + fooObject?: {}, + owner: OperationDescriptor, + }) => { + // We need a render a component to run a Hook + const [owner, _setOwner] = useState(props.owner); + const [_, _setCount] = useState(0); + const [{fooScalar}, _setFooScalar] = useState({ + fooScalar: props.fooScalar, + }); + const [{fooObject}, _setFooObject] = useState({ + fooObject: props.fooObject, + }); + const userRef = props.hasOwnProperty('userRef') + ? props.userRef + : { + [ID_KEY]: owner.request.variables.id, + [FRAGMENTS_KEY]: { + UserFragment: {}, + }, + [FRAGMENT_OWNER_KEY]: owner.request, + }; + + setSingularOwner = _setOwner; + setSingularFooScalar = _setFooScalar; + setSingularFooObject = _setFooObject; + forceSingularUpdate = () => _setCount(count => count + 1); + + let fragmentRefs = { + user: userRef, + }; + + // Pass extra props resembling component props + if (fooScalar != null) { + fragmentRefs = {...fragmentRefs, fooScalar}; + } + if (fooObject != null) { + fragmentRefs = {...fragmentRefs, fooObject}; + } + const [userData] = useFragmentNodes( + {user: gqlSingularFragment}, + fragmentRefs, + ); + return ; + }; + + const PluralContainer = (props: {usersRef?: {}, owner: $FlowFixMe}) => { + // We need a render a component to run a Hook + const owner = props.owner; + const usersRef = props.hasOwnProperty('usersRef') + ? props.usersRef + : owner.request.variables.ids.map(id => ({ + [ID_KEY]: id, + [FRAGMENTS_KEY]: { + UsersFragment: {}, + }, + [FRAGMENT_OWNER_KEY]: owner.request, + })); + + const [usersData] = useFragmentNodes( + {users: gqlPluralFragment}, + {users: usersRef}, + ); + return ; + }; + + const ContextProvider = ({children}) => { + const [env, _setEnv] = useState(environment); + // TODO(T39494051) - We set empty variables in relay context to make + // Flow happy, but useFragmentNodes does not use them, instead it uses + // the variables from the fragment owner. + const relayContext = useMemo(() => ({environment: env, variables: {}}), [ + env, + ]); + + setEnvironment = _setEnv; + + return ( + + {children} + + ); + }; + + renderSingularFragment = (args?: { + fooScalar?: boolean, + fooObject?: {}, + isConcurrent?: boolean, + owner?: $FlowFixMe, + userRef?: $FlowFixMe, + }) => { + const {isConcurrent = false, ...props} = args ?? {}; + return TestRenderer.create( + + + + + , + {unstable_isConcurrent: isConcurrent}, + ); + }; + + renderPluralFragment = (args?: { + isConcurrent?: boolean, + owner?: $FlowFixMe, + usersRef?: $FlowFixMe, + }) => { + const {isConcurrent = false, ...props} = args ?? {}; + return TestRenderer.create( + + + + + , + {unstable_isConcurrent: isConcurrent}, + ); + }; + }); + + afterEach(() => { + environment.mockClear(); + renderSpy.mockClear(); + }); + + it('should render singular fragment without error when data is available', () => { + renderSingularFragment(); + expectSingularFragmentResults([ + { + data: { + user: { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', singularQuery), + }, + }, + shouldUpdate: true, + }, + ]); + }); + + it('should render singular fragment without error when ref is null', () => { + renderSingularFragment({userRef: null}); + expectSingularFragmentResults([ + { + data: {user: null}, + shouldUpdate: true, + }, + ]); + }); + + it('should render singular fragment without error when ref is undefined', () => { + renderSingularFragment({userRef: undefined}); + expectSingularFragmentResults([ + { + data: {user: null}, + shouldUpdate: true, + }, + ]); + }); + + it('should render plural fragment without error when data is available', () => { + renderPluralFragment(); + expectPluralFragmentResults([ + { + data: { + users: [ + { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', pluralQuery), + }, + { + id: '2', + name: 'Bob', + profile_picture: null, + ...createFragmentRef('2', pluralQuery), + }, + ], + }, + shouldUpdate: true, + }, + ]); + }); + + it('should render plural fragment without error when plural field is empty', () => { + renderPluralFragment({usersRef: []}); + expectPluralFragmentResults([ + { + data: {users: []}, + shouldUpdate: true, + }, + ]); + }); + + it('should update when fragment data changes', () => { + renderSingularFragment(); + expectSingularFragmentResults([ + { + data: { + user: { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', singularQuery), + }, + }, + shouldUpdate: true, + }, + ]); + + environment.commitPayload(singularQuery, { + node: { + __typename: 'User', + id: '1', + // Update name + name: 'Alice in Wonderland', + }, + }); + expectSingularFragmentResults([ + { + data: { + user: { + id: '1', + // Assert that name is updated + name: 'Alice in Wonderland', + profile_picture: null, + ...createFragmentRef('1', singularQuery), + }, + }, + shouldUpdate: true, + }, + ]); + }); + + it('should re-read and resubscribe to fragment when environment changes', () => { + renderSingularFragment(); + expectSingularFragmentResults([ + { + data: { + user: { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', singularQuery), + }, + }, + shouldUpdate: true, + }, + ]); + + const newEnvironment = createMockEnvironment(); + newEnvironment.commitPayload(singularQuery, { + node: { + __typename: 'User', + id: '1', + name: 'Alice in a different env', + profile_picture: null, + }, + }); + + setEnvironment(newEnvironment); + + const expectedUser = { + id: '1', + name: 'Alice in a different env', + profile_picture: null, + ...createFragmentRef('1', singularQuery), + }; + expectSingularFragmentResults([ + {data: {user: expectedUser}, shouldUpdate: true}, + {data: {user: expectedUser}, shouldUpdate: false}, + ]); + + newEnvironment.commitPayload(singularQuery, { + node: { + __typename: 'User', + id: '1', + // Update name + name: 'Alice in Wonderland', + }, + }); + expectSingularFragmentResults([ + { + data: { + user: { + id: '1', + // Assert that name is updated + name: 'Alice in Wonderland', + profile_picture: null, + ...createFragmentRef('1', singularQuery), + }, + }, + shouldUpdate: true, + }, + ]); + }); + + it('should re-read and resubscribe to fragment when fragment pointers change', () => { + renderSingularFragment(); + expectSingularFragmentResults([ + { + data: { + user: { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', singularQuery), + }, + }, + shouldUpdate: true, + }, + ]); + + const newVariables = {...singularVariables, id: '200'}; + const newQuery = createOperationDescriptor(gqlSingularQuery, newVariables); + environment.commitPayload(newQuery, { + node: { + __typename: 'User', + id: '200', + name: 'Foo', + profile_picture: null, + username: 'userfoo', + }, + }); + + setSingularOwner(newQuery); + + const expectedUser = { + // Assert updated data + id: '200', + name: 'Foo', + profile_picture: null, + // Assert that ref now points to newQuery owner + ...createFragmentRef('200', newQuery), + }; + expectSingularFragmentResults([ + {data: {user: expectedUser}, shouldUpdate: true}, + {data: {user: expectedUser}, shouldUpdate: false}, + ]); + + environment.commitPayload(newQuery, { + node: { + __typename: 'User', + id: '200', + // Update name + name: 'Foo Updated', + }, + }); + expectSingularFragmentResults([ + { + data: { + user: { + id: '200', + // Assert that name is updated + name: 'Foo Updated', + profile_picture: null, + ...createFragmentRef('200', newQuery), + }, + }, + shouldUpdate: true, + }, + ]); + }); + + it('should render correct data when changing fragment refs multiple times', () => { + // Render component with data for ID 1 + renderSingularFragment(); + expectSingularFragmentResults([ + { + data: { + user: { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', singularQuery), + }, + }, + shouldUpdate: true, + }, + ]); + + // Update fragment refs to render data for ID 200 + const newVariables = {...singularVariables, id: '200'}; + const newQuery = createOperationDescriptor(gqlSingularQuery, newVariables); + environment.commitPayload(newQuery, { + node: { + __typename: 'User', + id: '200', + name: 'Foo', + username: 'userfoo', + profile_picture: null, + }, + }); + + setSingularOwner(newQuery); + + let expectedUser = { + // Assert updated data + id: '200', + name: 'Foo', + profile_picture: null, + // Assert that ref now points to newQuery owner + ...createFragmentRef('200', newQuery), + }; + expectSingularFragmentResults([ + {data: {user: expectedUser}, shouldUpdate: true}, + {data: {user: expectedUser}, shouldUpdate: false}, + ]); + + // Udpate data for ID 1 + environment.commitPayload(singularQuery, { + node: { + __typename: 'User', + id: '1', + // Update name + name: 'Alice in Wonderland', + }, + }); + + // Switch back to rendering data for ID 1 + setSingularOwner(singularQuery); + + // We expect to see the latest data + expectedUser = { + // Assert updated data + id: '1', + name: 'Alice in Wonderland', + profile_picture: null, + // Assert that ref points to original singularQuery owner + ...createFragmentRef('1', singularQuery), + }; + expectSingularFragmentResults([ + {data: {user: expectedUser}, shouldUpdate: true}, + {data: {user: expectedUser}, shouldUpdate: false}, + ]); + + // Assert it correctly subscribes to new data + environment.commitPayload(singularQuery, { + node: { + __typename: 'User', + id: '1', + // Update name + name: 'Alice Updated', + }, + }); + expectSingularFragmentResults([ + { + data: { + user: { + id: '1', + // Assert anme is updated + name: 'Alice Updated', + profile_picture: null, + ...createFragmentRef('1', singularQuery), + }, + }, + shouldUpdate: true, + }, + ]); + }); + + // TODO(T52977316) Re-enable concurent mode tests + it.skip('should ignore updates to initially rendered data when fragment pointers change', () => { + const Scheduler = require('scheduler'); + const YieldChild = props => { + // NOTE the unstable_yield method will move to the static renderer. + // When React sync runs we need to update this. + Scheduler.unstable_yieldValue(props.children); + return props.children; + }; + const YieldyUserComponent = ({user}) => ( + <> + Hey user, + {user.name} + with id {user.id}! + + ); + + // Assert initial render + SingularRenderer = YieldyUserComponent; + renderSingularFragment({isConcurrent: true}); + expectSchedulerToFlushAndYield([ + 'Hey user,', + 'Alice', + ['with id ', '1', '!'], + ]); + expectSingularFragmentResults([ + { + data: { + user: { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', singularQuery), + }, + }, + shouldUpdate: true, + }, + ]); + + const newVariables = {...singularVariables, id: '200'}; + const newQuery = createOperationDescriptor(gqlSingularQuery, newVariables); + environment.commitPayload(newQuery, { + node: { + __typename: 'User', + id: '200', + name: 'Foo', + username: 'userfoo', + profile_picture: null, + }, + }); + + // Pass new fragment ref that points to new ID 200 + setSingularOwner(newQuery); + + // Flush some of the changes, but don't commit + expectSchedulerToFlushAndYield(['Hey user,', 'Foo']); + + // In Concurrent mode component gets rendered even if not committed + // so we reset our mock here + resetRenderMock(); + + // Trigger an update for initially rendered data while second + // render is in progress + environment.commitPayload(singularQuery, { + node: { + __typename: 'User', + id: '1', + name: 'Alice in Wonderland', + }, + }); + + // Assert the component renders the data from newQuery/newVariables, + // ignoring any updates triggered while render was in progress + expectSchedulerToFlushAndYield([ + ['with id ', '200', '!'], + 'Hey user,', + 'Foo', + ['with id ', '200', '!'], + ]); + expectSingularFragmentResults([ + { + data: { + user: { + id: '200', + name: 'Foo', + profile_picture: null, + ...createFragmentRef('200', newQuery), + }, + }, + shouldUpdate: true, + }, + ]); + + // Update latest rendered data + environment.commitPayload(newQuery, { + node: { + __typename: 'User', + id: '200', + // Update name + name: 'Foo Updated', + }, + }); + expectSchedulerToFlushAndYield([ + 'Hey user,', + 'Foo Updated', + ['with id ', '200', '!'], + ]); + expectSingularFragmentResults([ + { + data: { + user: { + id: '200', + // Assert name is updated + name: 'Foo Updated', + profile_picture: null, + ...createFragmentRef('200', newQuery), + }, + }, + shouldUpdate: true, + }, + ]); + }); + + it('should re-read and resubscribe to fragment when variables change', () => { + renderSingularFragment(); + expectSingularFragmentResults([ + { + data: { + user: { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', singularQuery), + }, + }, + shouldUpdate: true, + }, + ]); + + const newVariables = {...singularVariables, id: '1', scale: 32}; + const newQuery = createOperationDescriptor(gqlSingularQuery, newVariables); + environment.commitPayload(newQuery, { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + profile_picture: { + uri: 'uri32', + }, + username: 'useralice', + }, + }); + + setSingularOwner(newQuery); + + const expectedUser = { + id: '1', + name: 'Alice', + profile_picture: { + // Asset updated uri + uri: 'uri32', + }, + // Assert that ref now points to newQuery owner + ...createFragmentRef('1', newQuery), + }; + expectSingularFragmentResults([ + {data: {user: expectedUser}, shouldUpdate: true}, + {data: {user: expectedUser}, shouldUpdate: false}, + ]); + + environment.commitPayload(newQuery, { + node: { + __typename: 'User', + id: '1', + // Update name + name: 'Alice in Wonderland', + }, + }); + expectSingularFragmentResults([ + { + data: { + user: { + id: '1', + // Assert that name is updated + name: 'Alice in Wonderland', + profile_picture: { + uri: 'uri32', + }, + ...createFragmentRef('1', newQuery), + }, + }, + shouldUpdate: true, + }, + ]); + }); + + // TODO(T52977316) Re-enable concurent mode tests + it.skip('should ignore updates to initially rendered data when variables change', () => { + const Scheduler = require('scheduler'); + const YieldChild = props => { + Scheduler.unstable_yieldValue(props.children); + return props.children; + }; + const YieldyUserComponent = ({user}) => ( + <> + Hey user, + {user.profile_picture?.uri ?? 'no uri'} + with id {user.id}! + + ); + + // Assert initial render + SingularRenderer = YieldyUserComponent; + renderSingularFragment({isConcurrent: true}); + expectSchedulerToFlushAndYield([ + 'Hey user,', + 'no uri', + ['with id ', '1', '!'], + ]); + expectSingularFragmentResults([ + { + data: { + user: { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', singularQuery), + }, + }, + shouldUpdate: true, + }, + ]); + + const newVariables = {...singularVariables, id: '1', scale: 32}; + const newQuery = createOperationDescriptor(gqlSingularQuery, newVariables); + environment.commitPayload(newQuery, { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + username: 'useralice', + profile_picture: { + uri: 'uri32', + }, + }, + }); + + // Pass new fragment ref which contains newVariables + setSingularOwner(newQuery); + + // Flush some of the changes, but don't commit + expectSchedulerToFlushAndYield(['Hey user,', 'uri32']); + + // In Concurrent mode component gets rendered even if not committed + // so we reset our mock here + resetRenderMock(); + + // Trigger an update for initially rendered data while second + // render is in progress + environment.commitPayload(singularQuery, { + node: { + __typename: 'User', + id: '1', + // Update name + name: 'Alice', + // Update profile_picture value + profile_picture: { + uri: 'uri16', + }, + }, + }); + + // Assert the component renders the data from newQuery/newVariables, + // ignoring any updates triggered while render was in progress + expectSchedulerToFlushAndYield([ + ['with id ', '1', '!'], + 'Hey user,', + 'uri32', + ['with id ', '1', '!'], + ]); + expectSingularFragmentResults([ + { + data: { + user: { + id: '1', + name: 'Alice', + profile_picture: { + uri: 'uri32', + }, + ...createFragmentRef('1', newQuery), + }, + }, + shouldUpdate: true, + }, + ]); + + // Update latest rendered data + environment.commitPayload(newQuery, { + node: { + __typename: 'User', + id: '1', + // Update name + name: 'Alice latest update', + }, + }); + expectSchedulerToFlushAndYield([ + 'Hey user,', + 'uri32', + ['with id ', '1', '!'], + ]); + expectSingularFragmentResults([ + { + data: { + user: { + id: '1', + // Assert name is updated + name: 'Alice latest update', + profile_picture: { + uri: 'uri32', + }, + ...createFragmentRef('1', newQuery), + }, + }, + shouldUpdate: true, + }, + ]); + }); + + it('should NOT update if fragment refs dont change', () => { + renderSingularFragment(); + expectSingularFragmentResults([ + { + data: { + user: { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', singularQuery), + }, + }, + shouldUpdate: true, + }, + ]); + + // Force a re-render with the exact same fragment refs + forceSingularUpdate(); + + expectSingularFragmentResults([ + { + data: { + user: { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', singularQuery), + }, + }, + // Assert that update to consuming component wont be triggered + shouldUpdate: false, + }, + ]); + }); + + it('should NOT update even if fragment ref changes but doesnt point to a different ID', () => { + renderSingularFragment(); + expectSingularFragmentResults([ + { + data: { + user: { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', singularQuery), + }, + }, + shouldUpdate: true, + }, + ]); + + // Setting a new owner with the same query/variables will cause new + // fragment refs that point to the same IDs to be passed + const newOwner = createOperationDescriptor( + gqlSingularQuery, + singularVariables, + ); + setSingularOwner(newOwner); + + expectSingularFragmentResults([ + { + data: { + user: { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', singularQuery), + }, + }, + // Assert that update to consuming component wont be triggered + shouldUpdate: false, + }, + ]); + }); + + it('should NOT update if scalar props dont change', () => { + renderSingularFragment({fooScalar: false}); + expectSingularFragmentResults([ + { + data: { + user: { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', singularQuery), + }, + }, + shouldUpdate: true, + }, + ]); + + // Re-render with same scalar value + setSingularFooScalar({fooScalar: false}); + + expectSingularFragmentResults([ + { + data: { + user: { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', singularQuery), + }, + }, + // Assert that update to consuming component wont be triggered + shouldUpdate: false, + }, + ]); + }); + + it('should update if scalar props change', () => { + renderSingularFragment({fooScalar: false}); + expectSingularFragmentResults([ + { + data: { + user: { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', singularQuery), + }, + }, + shouldUpdate: true, + }, + ]); + + // Re-render with different scalar value + setSingularFooScalar({fooScalar: true}); + + const expectedUser = { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', singularQuery), + }; + expectSingularFragmentResults([ + { + data: {user: expectedUser}, + // Assert that consuming component knows it needs to update + shouldUpdate: true, + }, + { + data: {user: expectedUser}, + shouldUpdate: false, + }, + ]); + }); + + it('should update even if non-scalar props dont change', () => { + const fooObject = {}; + const expectedUser = { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', singularQuery), + }; + renderSingularFragment({fooObject}); + expectSingularFragmentResults([ + { + data: {user: expectedUser}, + shouldUpdate: true, + }, + ]); + + // Re-render with the exact same non-scalar prop + setSingularFooObject({fooObject}); + + expectSingularFragmentResults([ + { + data: { + user: expectedUser, + }, + // Assert that consuming component knows it needs to update + shouldUpdate: true, + }, + ]); + }); + + it('should update if non-scalar props change', () => { + const expectedUser = { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', singularQuery), + }; + renderSingularFragment({fooObject: {}}); + expectSingularFragmentResults([ + { + data: {user: expectedUser}, + shouldUpdate: true, + }, + ]); + + // Re-render with different non-scalar value + setSingularFooObject({fooObject: {}}); + + expectSingularFragmentResults([ + { + data: {user: expectedUser}, + // Assert that consuming component knows it needs to update + shouldUpdate: true, + }, + { + data: {user: expectedUser}, + shouldUpdate: true, + }, + ]); + }); + + it('should throw a promise if if data is missing for fragment and request is in flight', () => { + // This prevents console.error output in the test, which is expected + jest.spyOn(console, 'error').mockImplementationOnce(() => {}); + jest + .spyOn( + require('relay-runtime').__internal, + 'getPromiseForRequestInFlight', + ) + .mockImplementationOnce(() => Promise.resolve()); + + const missingDataVariables = {...singularVariables, id: '4'}; + const missingDataQuery = createOperationDescriptor( + gqlSingularQuery, + missingDataVariables, + ); + // Commit a payload with name and profile_picture are missing + environment.commitPayload(missingDataQuery, { + node: { + __typename: 'User', + id: '4', + }, + }); + + const renderer = renderSingularFragment({owner: missingDataQuery}); + expect(renderer.toJSON()).toEqual('Singular Fallback'); + }); + + it('should throw an error if fragment reference is non-null but read-out data is null', () => { + // Clearing the data in the environment will make it so the fragment ref + // we pass to useFragmentNodes points to data that does not exist; we expect + // an error to be thrown in this case. + (environment.getStore().getSource(): $FlowFixMe).clear(); + const warning = require('warning'); + // $FlowFixMe + warning.mockClear(); + + renderSingularFragment(); + expect(warning).toBeCalledTimes(2); + // $FlowFixMe + const [_, warningMessage] = warning.mock.calls[1]; + expect( + warningMessage.startsWith( + 'Relay: Expected to have been able to read non-null data for fragment `%s`', + ), + ).toEqual(true); + // $FlowFixMe + warning.mockClear(); + }); + + it('should warn if data is missing and there are no pending requests', () => { + // This prevents console.error output in the test, which is expected + jest.spyOn(console, 'error').mockImplementationOnce(() => {}); + const warning = require('warning'); + + const missingDataVariables = {...singularVariables, id: '4'}; + const missingDataQuery = createOperationDescriptor( + gqlSingularQuery, + missingDataVariables, + ); + + // Commit a payload where name is missing. + environment.commitPayload(missingDataQuery, { + node: { + __typename: 'User', + id: '4', + }, + }); + + // $FlowFixMe + warning.mockClear(); + TestRenderer.act(() => { + renderSingularFragment({owner: missingDataQuery}); + }); + + // Assert warning message + expect(warning).toHaveBeenCalledTimes(1); + // $FlowFixMe + const [_, warningMessage, ...warningArgs] = warning.mock.calls[0]; + expect( + warningMessage.startsWith( + 'Relay: Tried reading fragment `%s` ' + + 'declared in `%s`, but it has ' + + 'missing data and its parent query `%s` is not being fetched.', + ), + ).toEqual(true); + expect(warningArgs).toEqual([ + 'UserFragment', + 'TestDisplayName', + 'UserQuery', + 'UserQuery', + ]); + + // Assert render output with missing data + expectSingularFragmentResults([ + { + data: { + user: { + id: '4', + name: undefined, + profile_picture: undefined, + ...createFragmentRef('4', missingDataQuery), + }, + }, + shouldUpdate: true, + }, + ]); + }); + + it('should subscribe for updates even if there is missing data', () => { + // This prevents console.error output in the test, which is expected + jest.spyOn(console, 'error').mockImplementationOnce(() => {}); + const warning = require('warning'); + + const missingDataVariables = {...singularVariables, id: '4'}; + const missingDataQuery = createOperationDescriptor( + gqlSingularQuery, + missingDataVariables, + ); + + // Commit a payload where name is missing. + environment.commitPayload(missingDataQuery, { + node: { + __typename: 'User', + id: '4', + }, + }); + + // $FlowFixMe + warning.mockClear(); + renderSingularFragment({owner: missingDataQuery}); + + // Assert render output with missing data + expectSingularFragmentResults([ + { + data: { + user: { + id: '4', + name: undefined, + profile_picture: undefined, + ...createFragmentRef('4', missingDataQuery), + }, + }, + shouldUpdate: true, + }, + ]); + + // Commit a payload with updated name. + environment.commitPayload(missingDataQuery, { + node: { + __typename: 'User', + id: '4', + name: 'Mark', + }, + }); + + // Assert render output with updated data + expectSingularFragmentResults([ + { + data: { + user: { + id: '4', + name: 'Mark', + profile_picture: undefined, + ...createFragmentRef('4', missingDataQuery), + }, + }, + shouldUpdate: true, + }, + ]); + }); + + describe('disableStoreUpdates', () => { + it('does not listen to store updates after disableStoreUpdates is called', () => { + renderSingularFragment(); + expectSingularFragmentResults([ + { + data: { + user: { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', singularQuery), + }, + }, + shouldUpdate: true, + }, + ]); + + disableStoreUpdates(); + + // Update data in the store + environment.commitPayload(singularQuery, { + node: { + __typename: 'User', + id: '1', + name: 'Alice updated', + }, + }); + + // Assert that component did not re-render + TestRenderer.act(() => jest.runAllImmediates()); + expect(renderSpy).toBeCalledTimes(0); + }); + + it('re-renders with latest data after re-enabling updates, if any updates were missed', () => { + renderSingularFragment(); + expectSingularFragmentResults([ + { + data: { + user: { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', singularQuery), + }, + }, + shouldUpdate: true, + }, + ]); + + disableStoreUpdates(); + + // Update data in the store + environment.commitPayload(singularQuery, { + node: { + __typename: 'User', + id: '1', + name: 'Alice updated', + }, + }); + + // Assert that component did not re-render while updates are disabled + TestRenderer.act(() => jest.runAllImmediates()); + expect(renderSpy).toBeCalledTimes(0); + + enableStoreUpdates(); + + // Assert that component re-renders with latest updated data + expectSingularFragmentResults([ + { + data: { + user: { + id: '1', + name: 'Alice updated', + profile_picture: null, + ...createFragmentRef('1', singularQuery), + }, + }, + shouldUpdate: true, + }, + ]); + }); + + it('does not re-render after re-enabling updates, if no updates were missed', () => { + renderSingularFragment(); + expectSingularFragmentResults([ + { + data: { + user: { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', singularQuery), + }, + }, + shouldUpdate: true, + }, + ]); + + disableStoreUpdates(); + expect(renderSpy).toBeCalledTimes(0); + + enableStoreUpdates(); + + // Assert that component did not re-render after enabling updates + TestRenderer.act(() => jest.runAllImmediates()); + expect(renderSpy).toBeCalledTimes(0); + }); + + it('does not re-render after re-enabling updates, if data did not change', () => { + renderSingularFragment(); + expectSingularFragmentResults([ + { + data: { + user: { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', singularQuery), + }, + }, + shouldUpdate: true, + }, + ]); + + disableStoreUpdates(); + + environment.commitPayload(singularQuery, { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + }, + }); + + TestRenderer.act(() => jest.runAllImmediates()); + expect(renderSpy).toBeCalledTimes(0); + + enableStoreUpdates(); + + // Assert that component did not re-render after enabling updates + TestRenderer.act(() => jest.runAllImmediates()); + expect(renderSpy).toBeCalledTimes(0); + }); + }); +}); diff --git a/packages/relay-experimental/__tests__/useIsParentQueryInFlight-test.js b/packages/relay-experimental/__tests__/useIsParentQueryInFlight-test.js new file mode 100644 index 000000000000..24e7b4e2919f --- /dev/null +++ b/packages/relay-experimental/__tests__/useIsParentQueryInFlight-test.js @@ -0,0 +1,414 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails oncall+relay + * @flow strict-local + * @format + */ + +'use strict'; + +const React = require('react'); +const RelayEnvironmentProvider = require('../RelayEnvironmentProvider'); +// $FlowFixMe +const TestRenderer = require('react-test-renderer'); + +const useIsParentQueryInFlight = require('../useIsParentQueryInFlight'); + +const { + Environment, + Network, + Observable, + RecordSource, + Store, + __internal: {fetchQuery}, + createOperationDescriptor, +} = require('relay-runtime'); + +let dataSource; +let environment; +let fetch; +let fragment; +let fragmentRef; +let operation; +let query; + +beforeEach(() => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); + const {generateAndCompile} = require('relay-test-utils-internal'); + const source = new RecordSource(); + const store = new Store(source); + fetch = jest.fn((_query, _variables, _cacheConfig) => + Observable.create(sink => { + dataSource = sink; + }), + ); + environment = new Environment({ + network: Network.create((fetch: $FlowFixMe)), + store, + }); + const {UserQuery, UserFragment} = generateAndCompile(` + query UserQuery($id: ID!) { + node(id: $id) { + ...UserFragment + } + } + + fragment UserFragment on User { + id + name + } + `); + query = UserQuery; + fragment = UserFragment; + operation = createOperationDescriptor(UserQuery, {id: '4'}); + + environment.commitPayload(operation, { + node: { + __typename: 'User', + id: '4', + name: 'Zuck', + }, + }); + + const snapshot = environment.lookup(operation.fragment); + fragmentRef = (snapshot.data?.node: $FlowFixMe); +}); + +it('returns false when owner is not pending', () => { + let pending = null; + function Component() { + pending = useIsParentQueryInFlight(fragment, fragmentRef); + return null; + } + TestRenderer.create( + + + , + ); + expect(fetch).toBeCalledTimes(0); + expect(pending).toBe(false); +}); + +it('returns false when an unrelated owner is pending', () => { + // fetch a different id + fetchQuery( + environment, + createOperationDescriptor(query, {id: '842472'}), + ).subscribe({}); + expect(fetch).toBeCalledTimes(1); + let pending = null; + function Component() { + pending = useIsParentQueryInFlight(fragment, fragmentRef); + return null; + } + TestRenderer.create( + + + , + ); + expect(pending).toBe(false); +}); + +it('returns true when owner is started but has not returned payloads', () => { + fetchQuery(environment, operation).subscribe({}); + expect(fetch).toBeCalledTimes(1); + let pending = null; + function Component() { + pending = useIsParentQueryInFlight(fragment, fragmentRef); + return null; + } + TestRenderer.create( + + + , + ); + expect(pending).toBe(true); +}); + +it('returns true when owner fetch has returned payloads but not completed', () => { + fetchQuery(environment, operation).subscribe({}); + expect(fetch).toBeCalledTimes(1); + TestRenderer.act(() => { + dataSource.next({ + data: { + node: { + __typename: 'User', + id: '4', + name: 'Mark', + }, + }, + }); + }); + let pending = null; + function Component() { + pending = useIsParentQueryInFlight(fragment, fragmentRef); + return null; + } + TestRenderer.create( + + + , + ); + expect(pending).toBe(true); +}); + +it('returns false when owner fetch completed', () => { + fetchQuery(environment, operation).subscribe({}); + expect(fetch).toBeCalledTimes(1); + TestRenderer.act(() => { + dataSource.next({ + data: { + node: { + __typename: 'User', + id: '4', + name: 'Mark', + }, + }, + }); + dataSource.complete(); + }); + let pending = null; + function Component() { + pending = useIsParentQueryInFlight(fragment, fragmentRef); + return null; + } + TestRenderer.create( + + + , + ); + expect(pending).toBe(false); +}); + +// TODO(T52981865): Re-enable when we figure out how to handle assertions +// when throwing errors inside observables +it.skip('returns false when owner fetch errored', () => { + fetchQuery(environment, operation).subscribe({}); + expect(fetch).toBeCalledTimes(1); + dataSource.next({ + data: { + node: { + __typename: 'User', + id: '4', + name: 'Mark', + }, + }, + }); + dataSource.error(new Error('wtf')); + let pending = null; + function Component() { + pending = useIsParentQueryInFlight(fragment, fragmentRef); + return null; + } + TestRenderer.create( + + + , + ); + expect(pending).toBe(false); +}); + +it('does not update the component when the owner is fetched', () => { + const states = []; + function Component() { + states.push(useIsParentQueryInFlight(fragment, fragmentRef)); + return null; + } + TestRenderer.create( + + + , + ); + // Ensure that useEffect runs + TestRenderer.act(() => jest.runAllImmediates()); + + expect(fetch).toBeCalledTimes(0); + expect(states).toEqual([false]); + fetchQuery(environment, operation).subscribe({}); + expect(fetch).toBeCalledTimes(1); + expect(states).toEqual([false]); +}); + +it('does not update the component when a pending owner fetch returns a payload', () => { + fetchQuery(environment, operation).subscribe({}); + expect(fetch).toBeCalledTimes(1); + const states = []; + function Component() { + states.push(useIsParentQueryInFlight(fragment, fragmentRef)); + return null; + } + TestRenderer.create( + + + , + ); + // Ensure that useEffect runs + TestRenderer.act(() => jest.runAllImmediates()); + + expect(states).toEqual([true]); + TestRenderer.act(() => { + dataSource.next({ + data: { + node: { + __typename: 'User', + id: '4', + name: 'Mark', + }, + }, + }); + jest.runAllImmediates(); + }); + expect(states).toEqual([true]); +}); + +it('updates the component when a pending owner fetch completes', () => { + fetchQuery(environment, operation).subscribe({}); + expect(fetch).toBeCalledTimes(1); + const states = []; + function Component() { + states.push(useIsParentQueryInFlight(fragment, fragmentRef)); + return null; + } + TestRenderer.create( + + + , + ); + // Ensure that useEffect runs + TestRenderer.act(() => jest.runAllImmediates()); + + expect(states).toEqual([true]); + TestRenderer.act(() => { + dataSource.complete(); + jest.runAllImmediates(); + }); + expect(states).toEqual([true, false]); +}); + +// TODO(T52981865): Re-enable when we figure out how to handle assertions +// when throwing errors inside observables +it.skip('updates the component when a pending owner fetch errors', () => { + fetchQuery(environment, operation).subscribe({}); + expect(fetch).toBeCalledTimes(1); + const states = []; + function Component() { + states.push(useIsParentQueryInFlight(fragment, fragmentRef)); + return null; + } + TestRenderer.create( + + + , + ); + // Ensure that useEffect runs + TestRenderer.act(() => jest.runAllImmediates()); + + expect(states).toEqual([true]); + TestRenderer.act(() => { + dataSource.error(new Error('wtf')); + jest.runAllImmediates(); + }); + expect(states).toEqual([true, false]); +}); + +it('updates the component when a pending owner fetch with multiple payloads completes ', () => { + fetchQuery(environment, operation).subscribe({}); + expect(fetch).toBeCalledTimes(1); + const states = []; + function Component() { + states.push(useIsParentQueryInFlight(fragment, fragmentRef)); + return null; + } + + TestRenderer.create( + + + , + ); + // Ensure that useEffect runs + TestRenderer.act(() => jest.runAllImmediates()); + expect(states).toEqual([true]); + + TestRenderer.act(() => { + dataSource.next({ + data: { + node: { + id: '1', + __typename: 'User', + }, + }, + }); + jest.runAllImmediates(); + }); + TestRenderer.act(() => { + dataSource.next({ + data: { + id: '1', + __typename: 'User', + name: 'Mark', + }, + label: 'UserQuery$defer$UserFragment', + path: ['node'], + }); + dataSource.complete(); + jest.runAllImmediates(); + }); + expect(states).toEqual([true, false]); +}); + +it('should only update if the latest owner completes the query', () => { + fetchQuery(environment, operation).subscribe({}); + const oldDataSource = dataSource; + expect(fetch).toBeCalledTimes(1); + let setRef = ref => {}; + const mockFn = jest.fn(() => {}); + const Renderer = props => { + mockFn(props.pending); + return props.pending; + }; + function Component() { + const [ref, setRefFn] = React.useState(fragmentRef); + setRef = setRefFn; + const pending = useIsParentQueryInFlight(fragment, ref); + return ; + } + TestRenderer.create( + + + , + ); + TestRenderer.act(() => jest.runAllImmediates()); + expect(mockFn.mock.calls[0]).toEqual([true]); + + const newOperation = createOperationDescriptor(query, {id: '5'}); + environment.commitPayload(newOperation, { + node: { + __typename: 'User', + id: '5', + name: 'Mark', + }, + }); + const snapshot = environment.lookup(newOperation.fragment); + const newFragmentRef = (snapshot.data?.node: $FlowFixMe); + expect(mockFn.mock.calls[0]).toEqual([true]); + + TestRenderer.act(() => { + fetchQuery(environment, newOperation).subscribe({}); + setRef(newFragmentRef); + }); + expect(mockFn.mock.calls).toEqual([[true], [true]]); + TestRenderer.act(() => oldDataSource.complete()); + expect(mockFn.mock.calls).toEqual([[true], [true]]); + + TestRenderer.act(() => dataSource.complete()); + expect(mockFn.mock.calls).toEqual([[true], [true], [false]]); + TestRenderer.act(() => { + setRef(fragmentRef); + }); + expect(mockFn.mock.calls).toEqual([[true], [true], [false], [false]]); +}); diff --git a/packages/relay-experimental/__tests__/useLegacyPaginationFragment-test.js b/packages/relay-experimental/__tests__/useLegacyPaginationFragment-test.js new file mode 100644 index 000000000000..e7e7b4f25ef9 --- /dev/null +++ b/packages/relay-experimental/__tests__/useLegacyPaginationFragment-test.js @@ -0,0 +1,3252 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails oncall+relay + * @flow + * @format + */ + +'use strict'; + +const React = require('react'); + +import type {Direction} from '../useLoadMoreFunction'; +import type {OperationDescriptor, Variables} from 'relay-runtime'; +const {useMemo, useState} = React; +const TestRenderer = require('react-test-renderer'); + +const invariant = require('invariant'); +const useLegacyPaginationFragmentOriginal = require('../useLegacyPaginationFragment'); +const ReactRelayContext = require('react-relay/ReactRelayContext'); +const { + ConnectionHandler, + FRAGMENT_OWNER_KEY, + FRAGMENTS_KEY, + ID_KEY, + createOperationDescriptor, +} = require('relay-runtime'); + +describe('useLegacyPaginationFragment', () => { + let environment; + let initialUser; + let gqlQuery; + let gqlQueryNestedFragment; + let gqlQueryWithoutID; + let gqlPaginationQuery; + let gqlFragment; + let query; + let queryNestedFragment; + let queryWithoutID; + let paginationQuery; + let variables; + let variablesNestedFragment; + let variablesWithoutID; + let setEnvironment; + let setOwner; + let renderFragment; + let renderSpy; + let createMockEnvironment; + let generateAndCompile; + let loadNext; + let refetch; + let Renderer; + + class ErrorBoundary extends React.Component { + state = {error: null}; + componentDidCatch(error) { + this.setState({error}); + } + render() { + const {children, fallback} = this.props; + const {error} = this.state; + if (error) { + return React.createElement(fallback, {error}); + } + return children; + } + } + + function useLegacyPaginationFragment(fragmentNode, fragmentRef) { + const {data, ...result} = useLegacyPaginationFragmentOriginal( + fragmentNode, + // $FlowFixMe + fragmentRef, + ); + loadNext = result.loadNext; + refetch = result.refetch; + renderSpy(data, result); + return {data, ...result}; + } + + function assertCall(expected, idx) { + const actualData = renderSpy.mock.calls[idx][0]; + const actualResult = renderSpy.mock.calls[idx][1]; + const actualIsLoadingNext = actualResult.isLoadingNext; + const actualIsLoadingPrevious = actualResult.isLoadingPrevious; + const actualHasNext = actualResult.hasNext; + const actualHasPrevious = actualResult.hasPrevious; + + expect(actualData).toEqual(expected.data); + expect(actualIsLoadingNext).toEqual(expected.isLoadingNext); + expect(actualIsLoadingPrevious).toEqual(expected.isLoadingPrevious); + expect(actualHasNext).toEqual(expected.hasNext); + expect(actualHasPrevious).toEqual(expected.hasPrevious); + } + + function expectFragmentResults( + expectedCalls: $ReadOnlyArray<{| + data: $FlowFixMe, + isLoadingNext: boolean, + isLoadingPrevious: boolean, + hasNext: boolean, + hasPrevious: boolean, + |}>, + ) { + // This ensures that useEffect runs + TestRenderer.act(() => jest.runAllImmediates()); + expect(renderSpy).toBeCalledTimes(expectedCalls.length); + expectedCalls.forEach((expected, idx) => assertCall(expected, idx)); + renderSpy.mockClear(); + } + + function createFragmentRef(id, owner) { + return { + [ID_KEY]: id, + [FRAGMENTS_KEY]: { + NestedUserFragment: {}, + }, + [FRAGMENT_OWNER_KEY]: owner.request, + }; + } + + beforeEach(() => { + // Set up mocks + jest.resetModules(); + jest.spyOn(console, 'warn').mockImplementationOnce(() => {}); + jest.mock('warning'); + renderSpy = jest.fn(); + + ({ + createMockEnvironment, + generateAndCompile, + } = require('relay-test-utils-internal')); + + // Set up environment and base data + environment = createMockEnvironment({ + handlerProvider: () => ConnectionHandler, + }); + const generated = generateAndCompile( + ` + fragment NestedUserFragment on User { + username + } + + fragment UserFragment on User + @refetchable(queryName: "UserFragmentPaginationQuery") + @argumentDefinitions( + isViewerFriendLocal: {type: "Boolean", defaultValue: false} + orderby: {type: "[String]"} + ) { + id + name + friends( + after: $after, + first: $first, + before: $before, + last: $last, + orderby: $orderby, + isViewerFriend: $isViewerFriendLocal + ) @connection(key: "UserFragment_friends") { + edges { + node { + id + name + ...NestedUserFragment + } + } + } + } + + query UserQuery( + $id: ID! + $after: ID + $first: Int + $before: ID + $last: Int + $orderby: [String] + $isViewerFriend: Boolean + ) { + node(id: $id) { + ...UserFragment @arguments(isViewerFriendLocal: $isViewerFriend, orderby: $orderby) + } + } + + query UserQueryNestedFragment( + $id: ID! + $after: ID + $first: Int + $before: ID + $last: Int + $orderby: [String] + $isViewerFriend: Boolean + ) { + node(id: $id) { + actor { + ...UserFragment @arguments(isViewerFriendLocal: $isViewerFriend, orderby: $orderby) + } + } + } + + query UserQueryWithoutID( + $after: ID + $first: Int + $before: ID + $last: Int + $orderby: [String] + $isViewerFriend: Boolean + ) { + viewer { + actor { + ...UserFragment @arguments(isViewerFriendLocal: $isViewerFriend, orderby: $orderby) + } + } + } + `, + ); + variablesWithoutID = { + after: null, + first: 1, + before: null, + last: null, + isViewerFriend: false, + orderby: ['name'], + }; + variables = { + ...variablesWithoutID, + id: '1', + }; + variablesNestedFragment = { + ...variablesWithoutID, + id: '', + }; + gqlQuery = generated.UserQuery; + gqlQueryNestedFragment = generated.UserQueryNestedFragment; + gqlQueryWithoutID = generated.UserQueryWithoutID; + gqlPaginationQuery = generated.UserFragmentPaginationQuery; + gqlFragment = generated.UserFragment; + invariant( + gqlFragment.metadata?.refetch?.operation === + '@@MODULE_START@@UserFragmentPaginationQuery.graphql@@MODULE_END@@', + 'useRefetchableFragment-test: Expected refetchable fragment metadata to contain operation.', + ); + // Manually set the refetchable operation for the test. + gqlFragment.metadata.refetch.operation = gqlPaginationQuery; + + query = createOperationDescriptor(gqlQuery, variables); + queryNestedFragment = createOperationDescriptor( + gqlQueryNestedFragment, + variablesNestedFragment, + ); + queryWithoutID = createOperationDescriptor( + gqlQueryWithoutID, + variablesWithoutID, + ); + paginationQuery = createOperationDescriptor(gqlPaginationQuery, variables); + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + username: 'username:node:1', + }, + }, + ], + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }, + }); + environment.commitPayload(queryWithoutID, { + viewer: { + actor: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + username: 'username:node:1', + }, + }, + ], + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }, + }, + }); + + // Set up renderers + Renderer = props => null; + + const Container = (props: { + userRef?: {}, + owner: $FlowFixMe, + fragment: $FlowFixMe, + }) => { + // We need a render a component to run a Hook + const [owner, _setOwner] = useState(props.owner); + const [_, _setCount] = useState(0); + const fragment = props.fragment ?? gqlFragment; + const artificialUserRef = useMemo( + () => environment.lookup(owner.fragment).data?.node, + [owner], + ); + const userRef = props.hasOwnProperty('userRef') + ? props.userRef + : artificialUserRef; + + setOwner = _setOwner; + + const {data: userData} = useLegacyPaginationFragment(fragment, userRef); + return ; + }; + + const ContextProvider = ({children}) => { + const [env, _setEnv] = useState(environment); + // TODO(T39494051) - We set empty variables in relay context to make + // Flow happy, but useLegacyPaginationFragment does not use them, instead it uses + // the variables from the fragment owner. + const relayContext = useMemo(() => ({environment: env, variables: {}}), [ + env, + ]); + + setEnvironment = _setEnv; + + return ( + + {children} + + ); + }; + + renderFragment = (args?: { + isConcurrent?: boolean, + owner?: $FlowFixMe, + userRef?: $FlowFixMe, + fragment?: $FlowFixMe, + }): $FlowFixMe => { + const {isConcurrent = false, ...props} = args ?? {}; + let renderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + `Error: ${error.message}`}> + + + + + + , + {unstable_isConcurrent: isConcurrent}, + ); + }); + return renderer; + }; + + initialUser = { + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', query), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }; + }); + + afterEach(() => { + environment.mockClear(); + renderSpy.mockClear(); + }); + + describe('initial render', () => { + // The bulk of initial render behavior is covered in useFragmentNodes-test, + // so this suite covers the basic cases as a sanity check. + it('should throw error if fragment is plural', () => { + jest.spyOn(console, 'error').mockImplementationOnce(() => {}); + + const generated = generateAndCompile(` + fragment UserFragment on User @relay(plural: true) { + id + } + `); + const renderer = renderFragment({fragment: generated.UserFragment}); + expect( + renderer + .toJSON() + .includes('Remove `@relay(plural: true)` from fragment'), + ).toEqual(true); + }); + + it('should throw error if fragment is missing @refetchable directive', () => { + jest.spyOn(console, 'error').mockImplementationOnce(() => {}); + + const generated = generateAndCompile(` + fragment UserFragment on User { + id + } + `); + const renderer = renderFragment({fragment: generated.UserFragment}); + expect( + renderer + .toJSON() + .includes( + 'Did you forget to add a @refetchable directive to the fragment?', + ), + ).toEqual(true); + }); + + it('should throw error if fragment is missing @connection directive', () => { + jest.spyOn(console, 'error').mockImplementationOnce(() => {}); + + const generated = generateAndCompile(` + fragment UserFragment on User + @refetchable(queryName: "UserFragmentRefetchQuery") { + id + } + `); + generated.UserFragment.metadata.refetch.operation = + generated.UserFragmentRefetchQuery; + const renderer = renderFragment({fragment: generated.UserFragment}); + expect( + renderer + .toJSON() + .includes( + 'Did you forget to add a @connection directive to the connection field in the fragment?', + ), + ).toEqual(true); + }); + + it('should render fragment without error when data is available', () => { + renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + + hasNext: true, + hasPrevious: false, + }, + ]); + }); + + it('should render fragment without error when ref is null', () => { + renderFragment({userRef: null}); + expectFragmentResults([ + { + data: null, + isLoadingNext: false, + isLoadingPrevious: false, + + hasNext: false, + hasPrevious: false, + }, + ]); + }); + + it('should render fragment without error when ref is undefined', () => { + renderFragment({userRef: undefined}); + expectFragmentResults([ + { + data: null, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: false, + hasPrevious: false, + }, + ]); + }); + + it('should update when fragment data changes', () => { + renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + // Update parent record + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '1', + // Update name + name: 'Alice in Wonderland', + }, + }); + expectFragmentResults([ + { + data: { + ...initialUser, + // Assert that name is updated + name: 'Alice in Wonderland', + }, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + // Update edge + environment.commitPayload(query, { + node: { + __typename: 'User', + id: 'node:1', + // Update name + name: 'name:node:1-updated', + }, + }); + expectFragmentResults([ + { + data: { + ...initialUser, + name: 'Alice in Wonderland', + friends: { + ...initialUser.friends, + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + // Assert that name is updated + name: 'name:node:1-updated', + ...createFragmentRef('node:1', query), + }, + }, + ], + }, + }, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + }); + + it('should throw a promise if data is missing for fragment and request is in flight', () => { + // This prevents console.error output in the test, which is expected + jest.spyOn(console, 'error').mockImplementationOnce(() => {}); + jest + .spyOn( + require('relay-runtime').__internal, + 'getPromiseForRequestInFlight', + ) + .mockImplementationOnce(() => Promise.resolve()); + + const missingDataVariables = {...variables, id: '4'}; + const missingDataQuery = createOperationDescriptor( + gqlQuery, + missingDataVariables, + ); + // Commit a payload with name and profile_picture are missing + environment.commitPayload(missingDataQuery, { + node: { + __typename: 'User', + id: '4', + }, + }); + + const renderer = renderFragment({owner: missingDataQuery}); + expect(renderer.toJSON()).toEqual('Fallback'); + }); + }); + + describe('pagination', () => { + let runScheduledCallback = () => {}; + let release; + + beforeEach(() => { + jest.resetModules(); + jest.mock('fbjs/lib/ExecutionEnvironment', () => ({ + canUseDOM: () => true, + })); + jest.doMock('scheduler', () => { + const original = jest.requireActual('scheduler/unstable_mock'); + return { + ...original, + unstable_next: cb => { + runScheduledCallback = () => { + original.unstable_next(cb); + }; + }, + }; + }); + + release = jest.fn(); + environment.retain.mockImplementation((...args) => { + return { + dispose: release, + }; + }); + }); + + afterEach(() => { + jest.dontMock('scheduler'); + }); + + function expectRequestIsInFlight(expected) { + expect(environment.execute).toBeCalledTimes(expected.requestCount); + expect( + environment.mock.isLoading( + expected.gqlPaginationQuery ?? gqlPaginationQuery, + expected.paginationVariables, + {force: true}, + ), + ).toEqual(expected.inFlight); + } + + function expectFragmentIsLoadingMore( + renderer, + direction: Direction, + expected: {| + data: mixed, + hasNext: boolean, + hasPrevious: boolean, + paginationVariables: Variables, + gqlPaginationQuery?: $FlowFixMe, + |}, + ) { + // Assert fragment sets isLoading to true + expect(renderSpy).toBeCalledTimes(1); + assertCall( + { + data: expected.data, + isLoadingNext: direction === 'forward', + isLoadingPrevious: direction === 'backward', + hasNext: expected.hasNext, + hasPrevious: expected.hasPrevious, + }, + 0, + ); + renderSpy.mockClear(); + + // Assert refetch query was fetched + expectRequestIsInFlight({...expected, inFlight: true, requestCount: 1}); + } + + // TODO + // - backward pagination + // - simultaneous pagination + // - TODO(T41131846): Fetch/Caching policies for loadMore / when network + // returns or errors synchronously + // - TODO(T41140071): Handle loadMore while refetch is in flight and vice-versa + + describe('loadNext', () => { + const direction = 'forward'; + + it('does not load more if component has unmounted', () => { + const warning = require('warning'); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + renderer.unmount(); + + TestRenderer.act(() => { + loadNext(1); + }); + + expect(warning).toHaveBeenCalledTimes(2); + expect( + (warning: $FlowFixMe).mock.calls[1][1].includes( + 'Relay: Unexpected fetch on unmounted component', + ), + ).toEqual(true); + expect(environment.execute).toHaveBeenCalledTimes(0); + }); + + it('does not load more if fragment ref passed to useLegacyPaginationFragment() was null', () => { + const warning = require('warning'); + renderFragment({userRef: null}); + expectFragmentResults([ + { + data: null, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: false, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1); + }); + + expect(warning).toHaveBeenCalledTimes(2); + expect( + (warning: $FlowFixMe).mock.calls[1][1].includes( + 'Relay: Unexpected fetch while using a null fragment ref', + ), + ).toEqual(true); + expect(environment.execute).toHaveBeenCalledTimes(0); + }); + + it('does not load more if there are no more items to load and calls onComplete callback', () => { + (environment.getStore().getSource(): $FlowFixMe).clear(); + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + username: 'username:node:1', + }, + }, + ], + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: false, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }, + }); + const callback = jest.fn(); + + renderFragment(); + expectFragmentResults([ + { + data: { + ...initialUser, + friends: { + ...initialUser.friends, + pageInfo: expect.objectContaining({hasNextPage: false}), + }, + }, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: false, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + expect(environment.execute).toBeCalledTimes(0); + expect(callback).toBeCalledTimes(0); + expect(renderSpy).toBeCalledTimes(0); + + TestRenderer.act(() => { + runScheduledCallback(); + }); + expect(callback).toBeCalledTimes(1); + }); + + it('does not load more if request is already in flight', () => { + const callback = jest.fn(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + expect(environment.execute).toBeCalledTimes(1); + expect(callback).toBeCalledTimes(0); + expect(renderSpy).toBeCalledTimes(0); + }); + + it('does not load more if parent query is already in flight (i.e. during streaming)', () => { + // This prevents console.error output in the test, which is expected + jest.spyOn(console, 'error').mockImplementationOnce(() => {}); + jest + .spyOn(require('relay-runtime').__internal, 'hasRequestInFlight') + .mockImplementationOnce(() => true); + const callback = jest.fn(); + renderFragment(); + + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + expect(environment.execute).toBeCalledTimes(0); + expect(callback).toBeCalledTimes(0); + expect(renderSpy).toBeCalledTimes(0); + }); + + it('cancels load more if component unmounts', () => { + const unsubscribe = jest.fn(); + jest.doMock('relay-runtime', () => { + const originalRuntime = jest.requireActual('relay-runtime'); + const originalInternal = originalRuntime.__internal; + return { + ...originalRuntime, + __internal: { + ...originalInternal, + fetchQuery: (...args) => { + const observable = originalInternal.fetchQuery(...args); + return { + subscribe: observer => { + return observable.subscribe({ + ...observer, + start: originalSubscription => { + const observerStart = observer?.start; + observerStart && + observerStart({ + ...originalSubscription, + unsubscribe: () => { + originalSubscription.unsubscribe(); + unsubscribe(); + }, + }); + }, + }); + }, + }; + }, + }, + }; + }); + + const callback = jest.fn(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(unsubscribe).toHaveBeenCalledTimes(0); + + TestRenderer.act(() => { + renderer.unmount(); + }); + expect(unsubscribe).toHaveBeenCalledTimes(1); + expect(environment.execute).toBeCalledTimes(1); + expect(callback).toBeCalledTimes(0); + expect(renderSpy).toBeCalledTimes(0); + }); + + it('cancels load more if refetch is called', () => { + const unsubscribe = jest.fn(); + jest.doMock('relay-runtime', () => { + const originalRuntime = jest.requireActual('relay-runtime'); + const originalInternal = originalRuntime.__internal; + return { + ...originalRuntime, + __internal: { + ...originalInternal, + fetchQuery: (...args) => { + const observable = originalInternal.fetchQuery(...args); + return { + subscribe: observer => { + return observable.subscribe({ + ...observer, + start: originalSubscription => { + const observerStart = observer?.start; + observerStart && + observerStart({ + ...originalSubscription, + unsubscribe: () => { + originalSubscription.unsubscribe(); + unsubscribe(); + }, + }); + }, + }); + }, + }; + }, + }, + }; + }); + + const callback = jest.fn(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(unsubscribe).toHaveBeenCalledTimes(0); + + TestRenderer.act(() => { + refetch({id: '4'}); + }); + expect(unsubscribe).toHaveBeenCalledTimes(1); + expect(environment.execute).toBeCalledTimes(2); + expect(callback).toBeCalledTimes(0); + expect(renderSpy).toBeCalledTimes(0); + }); + + it('loads and renders next items in connection', () => { + const callback = jest.fn(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + environment.mock.resolve(gqlPaginationQuery, { + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + username: 'username:node:2', + }, + }, + ], + pageInfo: { + startCursor: 'cursor:2', + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: true, + }, + }, + }, + }, + }); + + const expectedUser = { + ...initialUser, + friends: { + ...initialUser.friends, + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', query), + }, + }, + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + ...createFragmentRef('node:2', query), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }; + expectFragmentResults([ + { + // First update has updated connection + data: expectedUser, + isLoadingNext: true, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + { + // Second update sets isLoading flag back to false + data: expectedUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + expect(callback).toBeCalledTimes(1); + }); + + it('loads more correctly when original variables do not include an id', () => { + const callback = jest.fn(); + const viewer = environment.lookup(queryWithoutID.fragment).data?.viewer; + const userRef = + typeof viewer === 'object' && viewer != null ? viewer?.actor : null; + invariant(userRef != null, 'Expected to have cached test data'); + + let expectedUser = { + ...initialUser, + friends: { + ...initialUser.friends, + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', queryWithoutID), + }, + }, + ], + }, + }; + + const renderer = renderFragment({owner: queryWithoutID, userRef}); + expectFragmentResults([ + { + data: expectedUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: expectedUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + environment.mock.resolve(gqlPaginationQuery, { + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + username: 'username:node:2', + }, + }, + ], + pageInfo: { + startCursor: 'cursor:2', + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: true, + }, + }, + }, + }, + }); + + expectedUser = { + ...initialUser, + friends: { + ...initialUser.friends, + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', queryWithoutID), + }, + }, + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + ...createFragmentRef('node:2', queryWithoutID), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }; + expectFragmentResults([ + { + // First update has updated connection + data: expectedUser, + isLoadingNext: true, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + { + // Second update sets isLoading flag back to false + data: expectedUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + expect(callback).toBeCalledTimes(1); + }); + + it('loads more with correct id from refetchable fragment when using a nested fragment', () => { + const callback = jest.fn(); + + // Populate store with data for query using nested fragment + environment.commitPayload(queryNestedFragment, { + node: { + __typename: 'Feedback', + id: '', + actor: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + username: 'username:node:1', + }, + }, + ], + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }, + }, + }); + + // Get fragment ref for user using nested fragment + const userRef = (environment.lookup(queryNestedFragment.fragment) + .data: $FlowFixMe)?.node?.actor; + + initialUser = { + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', queryNestedFragment), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }; + + const renderer = renderFragment({owner: queryNestedFragment, userRef}); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + const paginationVariables = { + // The id here should correspond to the user id, and not the + // feedback id from the query variables (i.e. ``) + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + environment.mock.resolve(gqlPaginationQuery, { + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + username: 'username:node:2', + }, + }, + ], + pageInfo: { + startCursor: 'cursor:2', + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: true, + }, + }, + }, + }, + }); + + const expectedUser = { + ...initialUser, + friends: { + ...initialUser.friends, + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', queryNestedFragment), + }, + }, + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + ...createFragmentRef('node:2', queryNestedFragment), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }; + expectFragmentResults([ + { + // First update has updated connection + data: expectedUser, + isLoadingNext: true, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + { + // Second update sets isLoading flag back to false + data: expectedUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + expect(callback).toBeCalledTimes(1); + }); + + it('calls callback with error when error occurs during fetch', () => { + const callback = jest.fn(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + const error = new Error('Oops'); + environment.mock.reject(gqlPaginationQuery, error); + + // We pass the error in the callback, but do not throw during render + // since we want to continue rendering the existing items in the + // connection + expect(callback).toBeCalledTimes(1); + expect(callback).toBeCalledWith(error); + }); + + it('preserves pagination request if re-rendered with same fragment ref', () => { + const callback = jest.fn(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + TestRenderer.act(() => { + setOwner({...query}); + }); + + // Assert that request is still in flight after re-rendering + // with new fragment ref that points to the same data. + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + environment.mock.resolve(gqlPaginationQuery, { + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + username: 'username:node:2', + }, + }, + ], + pageInfo: { + startCursor: 'cursor:2', + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: true, + }, + }, + }, + }, + }); + + const expectedUser = { + ...initialUser, + friends: { + ...initialUser.friends, + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', query), + }, + }, + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + ...createFragmentRef('node:2', query), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }; + expectFragmentResults([ + { + // First update has updated connection + data: expectedUser, + isLoadingNext: true, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + { + // Second update sets isLoading flag back to false + data: expectedUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + expect(callback).toBeCalledTimes(1); + }); + + describe('disposing', () => { + let unsubscribe; + beforeEach(() => { + unsubscribe = jest.fn(); + jest.doMock('relay-runtime', () => { + const originalRuntime = jest.requireActual('relay-runtime'); + const originalInternal = originalRuntime.__internal; + return { + ...originalRuntime, + __internal: { + ...originalInternal, + fetchQuery: (...args) => { + const observable = originalInternal.fetchQuery(...args); + return { + subscribe: observer => { + return observable.subscribe({ + ...observer, + start: originalSubscription => { + const observerStart = observer?.start; + observerStart && + observerStart({ + ...originalSubscription, + unsubscribe: () => { + originalSubscription.unsubscribe(); + unsubscribe(); + }, + }); + }, + }); + }, + }; + }, + }, + }; + }); + }); + + afterEach(() => { + jest.dontMock('relay-runtime'); + }); + + it('disposes ongoing request if environment changes', () => { + const callback = jest.fn(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + + // Assert request is started + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + // Set new environment + const newEnvironment = createMockEnvironment({ + handlerProvider: () => ConnectionHandler, + }); + newEnvironment.commitPayload(query, { + node: { + __typename: 'User', + id: '1', + name: 'Alice in a different environment', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + username: 'username:node:1', + }, + }, + ], + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }, + }); + TestRenderer.act(() => { + setEnvironment(newEnvironment); + }); + + // Assert request was canceled + expect(unsubscribe).toBeCalledTimes(1); + expectRequestIsInFlight({ + inFlight: false, + requestCount: 1, + gqlPaginationQuery, + paginationVariables, + }); + + // Assert newly rendered data + expectFragmentResults([ + { + data: { + ...initialUser, + name: 'Alice in a different environment', + }, + isLoadingNext: true, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + { + data: { + ...initialUser, + name: 'Alice in a different environment', + }, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + }); + + it('disposes ongoing request if fragment ref changes', () => { + const callback = jest.fn(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + + // Assert request is started + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + // Pass new parent fragment ref with different variables + const newVariables = {...variables, isViewerFriend: true}; + const newQuery = createOperationDescriptor(gqlQuery, newVariables); + environment.commitPayload(newQuery, { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + username: 'username:node:1', + }, + }, + ], + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }, + }); + TestRenderer.act(() => { + setOwner(newQuery); + }); + + // Assert request was canceled + expect(unsubscribe).toBeCalledTimes(1); + expectRequestIsInFlight({ + inFlight: false, + requestCount: 1, + gqlPaginationQuery, + paginationVariables, + }); + + // Assert newly rendered data + const expectedUser = { + ...initialUser, + friends: { + ...initialUser.friends, + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + // Assert fragment ref points to owner with new variables + ...createFragmentRef('node:1', newQuery), + }, + }, + ], + }, + }; + expectFragmentResults([ + { + data: expectedUser, + isLoadingNext: true, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + { + data: expectedUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + }); + + it('disposes ongoing request on unmount', () => { + const callback = jest.fn(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + + // Assert request is started + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + renderer.unmount(); + + // Assert request was canceled + expect(unsubscribe).toBeCalledTimes(1); + expectRequestIsInFlight({ + inFlight: false, + requestCount: 1, + gqlPaginationQuery, + paginationVariables, + }); + }); + + it('disposes ongoing request if it is manually disposed', () => { + const callback = jest.fn(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + let disposable; + TestRenderer.act(() => { + disposable = loadNext(1, {onComplete: callback}); + }); + + // Assert request is started + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + // $FlowFixMe + disposable.dispose(); + + // Assert request was canceled + expect(unsubscribe).toBeCalledTimes(1); + expectRequestIsInFlight({ + inFlight: false, + requestCount: 1, + gqlPaginationQuery, + paginationVariables, + }); + expect(renderSpy).toHaveBeenCalledTimes(0); + }); + }); + }); + + describe('hasNext', () => { + const direction = 'forward'; + + it('returns true if it has more items', () => { + (environment.getStore().getSource(): $FlowFixMe).clear(); + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + username: 'username:node:1', + }, + }, + ], + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }, + }); + + renderFragment(); + expectFragmentResults([ + { + data: { + ...initialUser, + friends: { + ...initialUser.friends, + pageInfo: expect.objectContaining({hasNextPage: true}), + }, + }, + isLoadingNext: false, + isLoadingPrevious: false, + // Assert hasNext is true + hasNext: true, + hasPrevious: false, + }, + ]); + }); + + it('returns false if edges are null', () => { + (environment.getStore().getSource(): $FlowFixMe).clear(); + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: null, + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }, + }); + + renderFragment(); + expectFragmentResults([ + { + data: { + ...initialUser, + friends: { + ...initialUser.friends, + edges: null, + pageInfo: expect.objectContaining({hasNextPage: true}), + }, + }, + isLoadingNext: false, + isLoadingPrevious: false, + // Assert hasNext is false + hasNext: false, + hasPrevious: false, + }, + ]); + }); + + it('returns false if edges are undefined', () => { + (environment.getStore().getSource(): $FlowFixMe).clear(); + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: undefined, + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }, + }); + + renderFragment(); + expectFragmentResults([ + { + data: { + ...initialUser, + friends: { + ...initialUser.friends, + edges: undefined, + pageInfo: expect.objectContaining({hasNextPage: true}), + }, + }, + isLoadingNext: false, + isLoadingPrevious: false, + // Assert hasNext is false + hasNext: false, + hasPrevious: false, + }, + ]); + }); + + it('returns false if end cursor is null', () => { + (environment.getStore().getSource(): $FlowFixMe).clear(); + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + username: 'username:node:1', + }, + }, + ], + pageInfo: { + // endCursor is null + endCursor: null, + // but hasNextPage is still true + hasNextPage: true, + hasPreviousPage: false, + startCursor: null, + }, + }, + }, + }); + + renderFragment(); + expectFragmentResults([ + { + data: { + ...initialUser, + friends: { + ...initialUser.friends, + pageInfo: expect.objectContaining({ + endCursor: null, + hasNextPage: true, + }), + }, + }, + isLoadingNext: false, + isLoadingPrevious: false, + // Assert hasNext is false + hasNext: false, + hasPrevious: false, + }, + ]); + }); + + it('returns false if end cursor is undefined', () => { + (environment.getStore().getSource(): $FlowFixMe).clear(); + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + username: 'username:node:1', + }, + }, + ], + pageInfo: { + // endCursor is undefined + endCursor: undefined, + // but hasNextPage is still true + hasNextPage: true, + hasPreviousPage: false, + startCursor: undefined, + }, + }, + }, + }); + + renderFragment(); + expectFragmentResults([ + { + data: { + ...initialUser, + friends: { + ...initialUser.friends, + pageInfo: expect.objectContaining({ + endCursor: null, + hasNextPage: true, + }), + }, + }, + isLoadingNext: false, + isLoadingPrevious: false, + // Assert hasNext is false + hasNext: false, + hasPrevious: false, + }, + ]); + }); + + it('returns false if pageInfo.hasNextPage is false-ish', () => { + (environment.getStore().getSource(): $FlowFixMe).clear(); + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + username: 'username:node:1', + }, + }, + ], + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: null, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }, + }); + + renderFragment(); + expectFragmentResults([ + { + data: { + ...initialUser, + friends: { + ...initialUser.friends, + pageInfo: expect.objectContaining({ + hasNextPage: null, + }), + }, + }, + isLoadingNext: false, + isLoadingPrevious: false, + // Assert hasNext is false + hasNext: false, + hasPrevious: false, + }, + ]); + }); + + it('returns false if pageInfo.hasNextPage is false', () => { + (environment.getStore().getSource(): $FlowFixMe).clear(); + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + username: 'username:node:1', + }, + }, + ], + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: false, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }, + }); + + renderFragment(); + expectFragmentResults([ + { + data: { + ...initialUser, + friends: { + ...initialUser.friends, + pageInfo: expect.objectContaining({ + hasNextPage: false, + }), + }, + }, + isLoadingNext: false, + isLoadingPrevious: false, + // Assert hasNext is false + hasNext: false, + hasPrevious: false, + }, + ]); + }); + + it('updates after pagination if more results are avialable', () => { + const callback = jest.fn(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + environment.mock.resolve(gqlPaginationQuery, { + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + username: 'username:node:2', + }, + }, + ], + pageInfo: { + startCursor: 'cursor:2', + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: true, + }, + }, + }, + }, + }); + + const expectedUser = { + ...initialUser, + friends: { + ...initialUser.friends, + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', query), + }, + }, + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + ...createFragmentRef('node:2', query), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }; + expectFragmentResults([ + { + // First update has updated connection + data: expectedUser, + isLoadingNext: true, + isLoadingPrevious: false, + // Assert hasNext reflects server response + hasNext: true, + hasPrevious: false, + }, + { + // Second update sets isLoading flag back to false + data: expectedUser, + isLoadingNext: false, + isLoadingPrevious: false, + // Assert hasNext reflects server response + hasNext: true, + hasPrevious: false, + }, + ]); + expect(callback).toBeCalledTimes(1); + }); + + it('updates after pagination if no more results are avialable', () => { + const callback = jest.fn(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + environment.mock.resolve(gqlPaginationQuery, { + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + username: 'username:node:2', + }, + }, + ], + pageInfo: { + startCursor: 'cursor:2', + endCursor: 'cursor:2', + hasNextPage: false, + hasPreviousPage: true, + }, + }, + }, + }, + }); + + const expectedUser = { + ...initialUser, + friends: { + ...initialUser.friends, + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', query), + }, + }, + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + ...createFragmentRef('node:2', query), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:2', + hasNextPage: false, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }; + expectFragmentResults([ + { + // First update has updated connection + data: expectedUser, + isLoadingNext: true, + isLoadingPrevious: false, + // Assert hasNext reflects server response + hasNext: false, + hasPrevious: false, + }, + { + // Second update sets isLoading flag back to false + data: expectedUser, + isLoadingNext: false, + isLoadingPrevious: false, + // Assert hasNext reflects server response + hasNext: false, + hasPrevious: false, + }, + ]); + expect(callback).toBeCalledTimes(1); + }); + }); + + describe('refetch', () => { + // The bulk of refetch behavior is covered in useRefetchableFragmentNode-test, + // so this suite covers the pagination-related test cases. + function expectRefetchRequestIsInFlight(expected) { + expect(environment.execute).toBeCalledTimes(expected.requestCount); + expect( + environment.mock.isLoading( + expected.gqlRefetchQuery ?? gqlPaginationQuery, + expected.refetchVariables, + {force: true}, + ), + ).toEqual(expected.inFlight); + } + + function expectFragmentIsRefetching( + renderer, + expected: {| + data: mixed, + hasNext: boolean, + hasPrevious: boolean, + refetchVariables: Variables, + refetchQuery?: OperationDescriptor, + gqlRefetchQuery?: $FlowFixMe, + |}, + ) { + expect(renderSpy).toBeCalledTimes(0); + renderSpy.mockClear(); + + // Assert refetch query was fetched + expectRefetchRequestIsInFlight({ + ...expected, + inFlight: true, + requestCount: 1, + }); + + // Assert component suspended + expect(renderSpy).toBeCalledTimes(0); + expect(renderer.toJSON()).toEqual('Fallback'); + + // Assert query is tentatively retained while component is suspended + expect(environment.retain).toBeCalledTimes(1); + expect(environment.retain.mock.calls[0][0]).toEqual( + expected.refetchQuery?.root ?? paginationQuery.root, + ); + } + + it('refetches new variables correctly when refetching new id', () => { + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + refetch({id: '4'}); + }); + + // Assert that fragment is refetching with the right variables and + // suspends upon refetch + const refetchVariables = { + after: null, + first: 1, + before: null, + last: null, + id: '4', + isViewerFriendLocal: false, + orderby: ['name'], + }; + paginationQuery = createOperationDescriptor( + gqlPaginationQuery, + refetchVariables, + ); + expectFragmentIsRefetching(renderer, { + data: initialUser, + hasNext: true, + hasPrevious: false, + refetchVariables, + refetchQuery: paginationQuery, + }); + + // Mock network response + environment.mock.resolve(gqlPaginationQuery, { + data: { + node: { + __typename: 'User', + id: '4', + name: 'Mark', + friends: { + edges: [ + { + cursor: 'cursor:100', + node: { + __typename: 'User', + id: 'node:100', + name: 'name:node:100', + username: 'username:node:100', + }, + }, + ], + pageInfo: { + endCursor: 'cursor:100', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:100', + }, + }, + }, + }, + }); + + // Assert fragment is rendered with new data + const expectedUser = { + id: '4', + name: 'Mark', + friends: { + edges: [ + { + cursor: 'cursor:100', + node: { + __typename: 'User', + id: 'node:100', + name: 'name:node:100', + ...createFragmentRef('node:100', paginationQuery), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:100', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:100', + }, + }, + }; + expectFragmentResults([ + { + data: expectedUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + { + data: expectedUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + // Assert refetch query was retained + expect(release).not.toBeCalled(); + expect(environment.retain).toBeCalledTimes(1); + expect(environment.retain.mock.calls[0][0]).toEqual( + paginationQuery.root, + ); + }); + + it('refetches new variables correctly when refetching same id', () => { + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + refetch({isViewerFriendLocal: true, orderby: ['lastname']}); + }); + + // Assert that fragment is refetching with the right variables and + // suspends upon refetch + const refetchVariables = { + after: null, + first: 1, + before: null, + last: null, + id: '1', + isViewerFriendLocal: true, + orderby: ['lastname'], + }; + paginationQuery = createOperationDescriptor( + gqlPaginationQuery, + refetchVariables, + ); + expectFragmentIsRefetching(renderer, { + data: initialUser, + hasNext: true, + hasPrevious: false, + refetchVariables, + refetchQuery: paginationQuery, + }); + + // Mock network response + environment.mock.resolve(gqlPaginationQuery, { + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:100', + node: { + __typename: 'User', + id: 'node:100', + name: 'name:node:100', + username: 'username:node:100', + }, + }, + ], + pageInfo: { + endCursor: 'cursor:100', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:100', + }, + }, + }, + }, + }); + + // Assert fragment is rendered with new data + const expectedUser = { + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:100', + node: { + __typename: 'User', + id: 'node:100', + name: 'name:node:100', + ...createFragmentRef('node:100', paginationQuery), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:100', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:100', + }, + }, + }; + expectFragmentResults([ + { + data: expectedUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + { + data: expectedUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + // Assert refetch query was retained + expect(release).not.toBeCalled(); + expect(environment.retain).toBeCalledTimes(1); + expect(environment.retain.mock.calls[0][0]).toEqual( + paginationQuery.root, + ); + }); + + it('refetches with correct id from refetchable fragment when using nested fragment', () => { + // Populate store with data for query using nested fragment + environment.commitPayload(queryNestedFragment, { + node: { + __typename: 'Feedback', + id: '', + actor: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + username: 'username:node:1', + }, + }, + ], + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }, + }, + }); + + // Get fragment ref for user using nested fragment + const userRef = (environment.lookup(queryNestedFragment.fragment) + .data: $FlowFixMe)?.node?.actor; + + initialUser = { + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', queryNestedFragment), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }; + + const renderer = renderFragment({owner: queryNestedFragment, userRef}); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + refetch({isViewerFriendLocal: true, orderby: ['lastname']}); + }); + + // Assert that fragment is refetching with the right variables and + // suspends upon refetch + const refetchVariables = { + after: null, + first: 1, + before: null, + last: null, + // The id here should correspond to the user id, and not the + // feedback id from the query variables (i.e. ``) + id: '1', + isViewerFriendLocal: true, + orderby: ['lastname'], + }; + paginationQuery = createOperationDescriptor( + gqlPaginationQuery, + refetchVariables, + ); + expectFragmentIsRefetching(renderer, { + data: initialUser, + hasNext: true, + hasPrevious: false, + refetchVariables, + refetchQuery: paginationQuery, + }); + + // Mock network response + environment.mock.resolve(gqlPaginationQuery, { + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:100', + node: { + __typename: 'User', + id: 'node:100', + name: 'name:node:100', + username: 'username:node:100', + }, + }, + ], + pageInfo: { + endCursor: 'cursor:100', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:100', + }, + }, + }, + }, + }); + + // Assert fragment is rendered with new data + const expectedUser = { + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:100', + node: { + __typename: 'User', + id: 'node:100', + name: 'name:node:100', + ...createFragmentRef('node:100', paginationQuery), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:100', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:100', + }, + }, + }; + expectFragmentResults([ + { + data: expectedUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + { + data: expectedUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + // Assert refetch query was retained + expect(release).not.toBeCalled(); + expect(environment.retain).toBeCalledTimes(1); + expect(environment.retain.mock.calls[0][0]).toEqual( + paginationQuery.root, + ); + }); + + it('loads more items correctly after refetching', () => { + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + refetch({isViewerFriendLocal: true, orderby: ['lastname']}); + }); + + // Assert that fragment is refetching with the right variables and + // suspends upon refetch + const refetchVariables = { + after: null, + first: 1, + before: null, + last: null, + id: '1', + isViewerFriendLocal: true, + orderby: ['lastname'], + }; + paginationQuery = createOperationDescriptor( + gqlPaginationQuery, + refetchVariables, + ); + expectFragmentIsRefetching(renderer, { + data: initialUser, + hasNext: true, + hasPrevious: false, + refetchVariables, + refetchQuery: paginationQuery, + }); + + // Mock network response + environment.mock.resolve(gqlPaginationQuery, { + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:100', + node: { + __typename: 'User', + id: 'node:100', + name: 'name:node:100', + username: 'username:node:100', + }, + }, + ], + pageInfo: { + endCursor: 'cursor:100', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:100', + }, + }, + }, + }, + }); + + // Assert fragment is rendered with new data + const expectedUser = { + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:100', + node: { + __typename: 'User', + id: 'node:100', + name: 'name:node:100', + ...createFragmentRef('node:100', paginationQuery), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:100', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:100', + }, + }, + }; + expectFragmentResults([ + { + data: expectedUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + { + data: expectedUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + // Assert refetch query was retained + expect(release).not.toBeCalled(); + expect(environment.retain).toBeCalledTimes(1); + expect(environment.retain.mock.calls[0][0]).toEqual( + paginationQuery.root, + ); + + // Paginate after refetching + environment.execute.mockClear(); + TestRenderer.act(() => { + loadNext(1); + }); + const paginationVariables = { + id: '1', + after: 'cursor:100', + first: 1, + before: null, + last: null, + isViewerFriendLocal: true, + orderby: ['lastname'], + }; + expectFragmentIsLoadingMore(renderer, 'forward', { + data: expectedUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + + environment.mock.resolve(gqlPaginationQuery, { + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:200', + node: { + __typename: 'User', + id: 'node:200', + name: 'name:node:200', + username: 'username:node:200', + }, + }, + ], + pageInfo: { + startCursor: 'cursor:200', + endCursor: 'cursor:200', + hasNextPage: true, + hasPreviousPage: true, + }, + }, + }, + }, + }); + + const paginatedUser = { + ...expectedUser, + friends: { + ...expectedUser.friends, + edges: [ + { + cursor: 'cursor:100', + node: { + __typename: 'User', + id: 'node:100', + name: 'name:node:100', + ...createFragmentRef('node:100', paginationQuery), + }, + }, + { + cursor: 'cursor:200', + node: { + __typename: 'User', + id: 'node:200', + name: 'name:node:200', + ...createFragmentRef('node:200', paginationQuery), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:200', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:100', + }, + }, + }; + expectFragmentResults([ + { + // First update has updated connection + data: paginatedUser, + isLoadingNext: true, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + { + // Second update sets isLoading flag back to false + data: paginatedUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + }); + }); + }); +}); diff --git a/packages/relay-experimental/__tests__/useQueryNode-test.js b/packages/relay-experimental/__tests__/useQueryNode-test.js new file mode 100644 index 000000000000..01f2fcb17e65 --- /dev/null +++ b/packages/relay-experimental/__tests__/useQueryNode-test.js @@ -0,0 +1,221 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails oncall+relay + * @flow + * @format + */ + +'use strict'; + +const React = require('react'); +const ReactTestRenderer = require('react-test-renderer'); +const RelayEnvironmentProvider = require('../RelayEnvironmentProvider'); + +const useQueryNode = require('../useQueryNode'); + +const {createOperationDescriptor} = require('relay-runtime'); + +const fetchPolicy = 'network-only'; + +function expectToBeRendered(renderFn, readyState) { + // Ensure useEffect is called before other timers + ReactTestRenderer.act(() => { + jest.runAllImmediates(); + }); + expect(renderFn).toBeCalledTimes(1); + expect(renderFn.mock.calls[0][0]).toEqual(readyState); + renderFn.mockClear(); +} + +function expectToBeFetched(environment, node, variables) { + expect(environment.execute).toBeCalledTimes(1); + expect(environment.execute.mock.calls[0][0].operation).toMatchObject({ + fragment: expect.anything(), + root: expect.anything(), + request: { + node, + variables, + }, + }); +} + +type Props = { + variables: Object, +}; + +describe('useQueryNode', () => { + let environment; + let gqlQuery; + let renderFn; + let render; + let createMockEnvironment; + let generateAndCompile; + let Container; + + beforeEach(() => { + jest.resetModules(); + jest.spyOn(console, 'warn').mockImplementationOnce(() => {}); + jest.mock('fbjs/lib/ExecutionEnvironment', () => ({ + canUseDOM: () => true, + })); + + ({ + createMockEnvironment, + generateAndCompile, + } = require('relay-test-utils-internal')); + + class ErrorBoundary extends React.Component { + state = {error: null}; + componentDidCatch(error) { + this.setState({error}); + } + render() { + const {children, fallback} = this.props; + const {error} = this.state; + if (error) { + return React.createElement(fallback, {error}); + } + return children; + } + } + + const Renderer = props => { + const query = createOperationDescriptor(gqlQuery, props.variables); + const data = useQueryNode<_>({ + query, + fetchPolicy, + componentDisplayName: 'TestDisplayName', + }); + return renderFn(data); + }; + + Container = (props: Props) => { + return ; + }; + + render = (environment, children) => { + return ReactTestRenderer.create( + + + `Error: ${error.message + ': ' + error.stack}` + }> + {children} + + , + ); + }; + + environment = createMockEnvironment(); + + const generated = generateAndCompile(` + fragment UserFragment on User { + name + } + + query UserQuery($id: ID) { + node(id: $id) { + id + ...UserFragment + } + } + `); + gqlQuery = generated.UserQuery; + renderFn = jest.fn(() =>
); + }); + + afterEach(() => { + environment.mockClear(); + jest.clearAllTimers(); + }); + + it('fetches and renders the query data', () => { + const variables = {id: '1'}; + const instance = render(environment, ); + const operation = createOperationDescriptor(gqlQuery, variables); + + expect(instance.toJSON()).toEqual('Fallback'); + expectToBeFetched(environment, gqlQuery, variables); + expect(renderFn).not.toBeCalled(); + expect(environment.retain).toHaveBeenCalledTimes(1); + + environment.mock.resolve(gqlQuery, { + data: { + node: { + __typename: 'User', + id: variables.id, + name: 'Alice', + }, + }, + }); + + const data = environment.lookup(operation.fragment).data; + expectToBeRendered(renderFn, data); + }); + + it('fetches and renders correctly if previously useEffect does not run', () => { + const variables = {id: '1'}; + const operation = createOperationDescriptor(gqlQuery, variables); + + const payload = { + data: { + node: { + __typename: 'User', + id: variables.id, + name: 'Alice', + }, + }, + }; + + let instance = render(environment, ); + + expect(instance.toJSON()).toEqual('Fallback'); + expectToBeFetched(environment, gqlQuery, variables); + expect(renderFn).not.toBeCalled(); + expect(environment.retain).toHaveBeenCalledTimes(1); + + ReactTestRenderer.act(() => { + environment.mock.resolve(gqlQuery, payload); + }); + + // Unmount the component before it gets to permanently retain the data + instance.unmount(); + expect(renderFn).not.toBeCalled(); + + // Running all immediates makes sure all useEffects run and GC isn't + // Triggered by mistake + ReactTestRenderer.act(() => jest.runAllImmediates()); + // Trigger timeout and GC to clear all references + ReactTestRenderer.act(() => jest.runAllTimers()); + // Verify GC has run + expect( + environment + .getStore() + .getSource() + // $FlowFixMe + .toJSON(), + ).toEqual({}); + + renderFn.mockClear(); + environment.retain.mockClear(); + environment.execute.mockClear(); + + instance = render(environment, ); + + expect(instance.toJSON()).toEqual('Fallback'); + expectToBeFetched(environment, gqlQuery, variables); + expect(renderFn).not.toBeCalled(); + expect(environment.retain).toHaveBeenCalledTimes(1); + + ReactTestRenderer.act(() => { + environment.mock.resolve(gqlQuery, payload); + }); + + const data = environment.lookup(operation.fragment).data; + expectToBeRendered(renderFn, data); + }); +}); diff --git a/packages/relay-experimental/__tests__/useRefetchableFragment-test.js b/packages/relay-experimental/__tests__/useRefetchableFragment-test.js new file mode 100644 index 000000000000..2a6da23ae429 --- /dev/null +++ b/packages/relay-experimental/__tests__/useRefetchableFragment-test.js @@ -0,0 +1,213 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails oncall+relay + * @flow + * @format + */ + +'use strict'; + +const React = require('react'); +const {useMemo} = React; +const TestRenderer = require('react-test-renderer'); + +const invariant = require('invariant'); +const useRefetchableFragmentOriginal = require('../useRefetchableFragment'); +const ReactRelayContext = require('react-relay/ReactRelayContext'); +const { + FRAGMENT_OWNER_KEY, + FRAGMENTS_KEY, + ID_KEY, + createOperationDescriptor, +} = require('relay-runtime'); + +describe('useRefetchableFragment', () => { + let environment; + let gqlQuery; + let gqlRefetchQuery; + let gqlFragment; + let createMockEnvironment; + let generateAndCompile; + let query; + let variables; + let renderFragment; + let renderSpy; + let Renderer; + + function useRefetchableFragment(fragmentNode, fragmentRef) { + const [data, refetch] = useRefetchableFragmentOriginal( + fragmentNode, + fragmentRef, + ); + renderSpy(data, refetch); + return [data, refetch]; + } + + function assertCall(expected, idx) { + const actualData = renderSpy.mock.calls[idx][0]; + + expect(actualData).toEqual(expected.data); + } + + function assertFragmentResults( + expectedCalls: $ReadOnlyArray<{|data: $FlowFixMe|}>, + ) { + // This ensures that useEffect runs + TestRenderer.act(() => jest.runAllImmediates()); + expect(renderSpy).toBeCalledTimes(expectedCalls.length); + expectedCalls.forEach((expected, idx) => assertCall(expected, idx)); + renderSpy.mockClear(); + } + + function expectFragmentResults(expectedCalls) { + assertFragmentResults(expectedCalls); + } + + function createFragmentRef(id, owner) { + return { + [ID_KEY]: id, + [FRAGMENTS_KEY]: { + NestedUserFragment: {}, + }, + [FRAGMENT_OWNER_KEY]: owner.request, + }; + } + + beforeEach(() => { + // Set up mocks + jest.resetModules(); + jest.spyOn(console, 'warn').mockImplementationOnce(() => {}); + jest.mock('warning'); + renderSpy = jest.fn(); + + const { + createMockEnvironment, + generateAndCompile, + } = require('relay-test-utils-internal'); + + // Set up environment and base data + environment = createMockEnvironment(); + const generated = generateAndCompile( + ` + fragment NestedUserFragment on User { + username + } + + fragment UserFragment on User + @refetchable(queryName: "UserFragmentRefetchQuery") { + id + name + profile_picture(scale: $scale) { + uri + } + ...NestedUserFragment + } + + query UserQuery($id: ID!, $scale: Int!) { + node(id: $id) { + ...UserFragment + } + } + `, + ); + variables = {id: '1', scale: 16}; + gqlQuery = generated.UserQuery; + gqlRefetchQuery = generated.UserFragmentRefetchQuery; + gqlFragment = generated.UserFragment; + invariant( + gqlFragment.metadata?.refetch?.operation === + '@@MODULE_START@@UserFragmentRefetchQuery.graphql@@MODULE_END@@', + 'useRefetchableFragment-test: Expected refetchable fragment metadata to contain operation.', + ); + // Manually set the refetchable operation for the test. + gqlFragment.metadata.refetch.operation = gqlRefetchQuery; + + query = createOperationDescriptor(gqlQuery, variables); + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + username: 'useralice', + profile_picture: null, + }, + }); + + // Set up renderers + Renderer = props => null; + + const Container = (props: {userRef?: {}, fragment: $FlowFixMe}) => { + // We need a render a component to run a Hook + const artificialUserRef = useMemo( + () => ({ + [ID_KEY]: + query.request.variables.id ?? query.request.variables.nodeID, + [FRAGMENTS_KEY]: { + [gqlFragment.name]: {}, + }, + [FRAGMENT_OWNER_KEY]: query.request, + }), + [], + ); + + const [userData] = useRefetchableFragment(gqlFragment, artificialUserRef); + return ; + }; + + const ContextProvider = ({children}) => { + // TODO(T39494051) - We set empty variables in relay context to make + // Flow happy, but useRefetchableFragment does not use them, instead it uses + // the variables from the fragment owner. + const relayContext = useMemo(() => ({environment, variables: {}}), []); + + return ( + + {children} + + ); + }; + + renderFragment = (args?: { + isConcurrent?: boolean, + owner?: $FlowFixMe, + userRef?: $FlowFixMe, + fragment?: $FlowFixMe, + }) => { + const {isConcurrent = false, ...props} = args ?? {}; + return TestRenderer.create( + + + + + , + {unstable_isConcurrent: isConcurrent}, + ); + }; + }); + + afterEach(() => { + environment.mockClear(); + renderSpy.mockClear(); + }); + + // This test is only a sanity check for useRefetchableFragment as a wrapper + // around useRefetchableFragmentNode. + // See full test behavior in useRefetchableFragmentNode-test. + it('should render fragment without error when data is available', () => { + renderFragment(); + expectFragmentResults([ + { + data: { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', query), + }, + }, + ]); + }); +}); diff --git a/packages/relay-experimental/__tests__/useRefetchableFragmentNode-test.js b/packages/relay-experimental/__tests__/useRefetchableFragmentNode-test.js new file mode 100644 index 000000000000..467e654b8f8c --- /dev/null +++ b/packages/relay-experimental/__tests__/useRefetchableFragmentNode-test.js @@ -0,0 +1,2986 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails oncall+relay + * @flow + * @format + */ + +'use strict'; + +const React = require('react'); + +import type {OperationDescriptor, Variables} from 'relay-runtime'; +const {useMemo, useState} = React; +const TestRenderer = require('react-test-renderer'); + +const invariant = require('invariant'); +const useRefetchableFragmentNodeOriginal = require('../useRefetchableFragmentNode'); +const ReactRelayContext = require('react-relay/ReactRelayContext'); +const { + FRAGMENT_OWNER_KEY, + FRAGMENTS_KEY, + ID_KEY, + createOperationDescriptor, +} = require('relay-runtime'); + +describe('useRefetchableFragmentNode', () => { + let environment; + let gqlQuery; + let gqlQueryNestedFragment; + let gqlRefetchQuery; + let gqlQueryWithArgs; + let gqlQueryWithLiteralArgs; + let gqlRefetchQueryWithArgs; + let gqlFragment; + let gqlFragmentWithArgs; + let query; + let queryNestedFragment; + let refetchQuery; + let queryWithArgs; + let queryWithLiteralArgs; + let refetchQueryWithArgs; + let variables; + let variablesNestedFragment; + let setEnvironment; + let setOwner; + let fetchPolicy; + let renderPolicy; + let createMockEnvironment; + let generateAndCompile; + let renderFragment; + let forceUpdate; + let renderSpy; + let refetch; + let Renderer; + + class ErrorBoundary extends React.Component { + state = {error: null}; + componentDidCatch(error) { + this.setState({error}); + } + render() { + const {children, fallback} = this.props; + const {error} = this.state; + if (error) { + return React.createElement(fallback, {error}); + } + return children; + } + } + + function useRefetchableFragmentNode(fragmentNode, fragmentRef) { + const result = useRefetchableFragmentNodeOriginal( + fragmentNode, + fragmentRef, + 'TestDisplayName', + ); + refetch = result.refetch; + renderSpy(result.fragmentData, refetch); + return result; + } + + function assertCall(expected, idx) { + const actualData = renderSpy.mock.calls[idx][0]; + + expect(actualData).toEqual(expected.data); + } + + function expectFragmentResults( + expectedCalls: $ReadOnlyArray<{|data: $FlowFixMe|}>, + ) { + // This ensures that useEffect runs + TestRenderer.act(() => jest.runAllImmediates()); + expect(renderSpy).toBeCalledTimes(expectedCalls.length); + expectedCalls.forEach((expected, idx) => assertCall(expected, idx)); + renderSpy.mockClear(); + } + + function createFragmentRef(id, owner) { + return { + [ID_KEY]: id, + [FRAGMENTS_KEY]: { + NestedUserFragment: {}, + }, + [FRAGMENT_OWNER_KEY]: owner.request, + }; + } + + beforeEach(() => { + // Set up mocks + jest.resetModules(); + jest.spyOn(console, 'warn').mockImplementationOnce(() => {}); + jest.mock('warning'); + jest.mock('scheduler', () => { + return jest.requireActual('scheduler/unstable_mock'); + }); + jest.mock('fbjs/lib/ExecutionEnvironment', () => ({ + canUseDOM: () => true, + })); + renderSpy = jest.fn(); + + fetchPolicy = 'store-or-network'; + renderPolicy = 'partial'; + + ({ + createMockEnvironment, + generateAndCompile, + } = require('relay-test-utils-internal')); + + // Set up environment and base data + environment = createMockEnvironment(); + const generated = generateAndCompile( + ` + fragment NestedUserFragment on User { + username + } + + fragment UserFragmentWithArgs on User + @refetchable(queryName: "UserFragmentWithArgsRefetchQuery") + @argumentDefinitions(scaleLocal: {type: "Float!"}) { + id + name + profile_picture(scale: $scaleLocal) { + uri + } + ...NestedUserFragment + } + + fragment UserFragment on User + @refetchable(queryName: "UserFragmentRefetchQuery") { + id + name + profile_picture(scale: $scale) { + uri + } + ...NestedUserFragment + } + + query UserQuery($id: ID!, $scale: Int!) { + node(id: $id) { + ...UserFragment + } + } + + query UserQueryNestedFragment($id: ID!, $scale: Int!) { + node(id: $id) { + actor { + ...UserFragment + } + } + } + + query UserQueryWithArgs($id: ID!, $scale: Float!) { + node(id: $id) { + ...UserFragmentWithArgs @arguments(scaleLocal: $scale) + } + } + + query UserQueryWithLiteralArgs($id: ID!) { + node(id: $id) { + ...UserFragmentWithArgs @arguments(scaleLocal: 16) + } + } + `, + ); + variables = {id: '1', scale: 16}; + variablesNestedFragment = {id: '', scale: 16}; + gqlQuery = generated.UserQuery; + gqlQueryNestedFragment = generated.UserQueryNestedFragment; + gqlRefetchQuery = generated.UserFragmentRefetchQuery; + gqlQueryWithArgs = generated.UserQueryWithArgs; + gqlQueryWithLiteralArgs = generated.UserQueryWithLiteralArgs; + gqlRefetchQueryWithArgs = generated.UserFragmentWithArgsRefetchQuery; + gqlFragment = generated.UserFragment; + gqlFragmentWithArgs = generated.UserFragmentWithArgs; + invariant( + gqlFragment.metadata?.refetch?.operation === + '@@MODULE_START@@UserFragmentRefetchQuery.graphql@@MODULE_END@@', + 'useRefetchableFragment-test: Expected refetchable fragment metadata to contain operation.', + ); + invariant( + gqlFragmentWithArgs.metadata?.refetch?.operation === + '@@MODULE_START@@UserFragmentWithArgsRefetchQuery.graphql@@MODULE_END@@', + 'useRefetchableFragment-test: Expected refetchable fragment metadata to contain operation.', + ); + // Manually set the refetchable operation for the test. + gqlFragment.metadata.refetch.operation = gqlRefetchQuery; + gqlFragmentWithArgs.metadata.refetch.operation = gqlRefetchQueryWithArgs; + + query = createOperationDescriptor(gqlQuery, variables); + queryNestedFragment = createOperationDescriptor( + gqlQueryNestedFragment, + variablesNestedFragment, + ); + refetchQuery = createOperationDescriptor(gqlRefetchQuery, variables); + queryWithArgs = createOperationDescriptor(gqlQueryWithArgs, variables); + queryWithLiteralArgs = createOperationDescriptor(gqlQueryWithLiteralArgs, { + id: variables.id, + }); + refetchQueryWithArgs = createOperationDescriptor( + gqlRefetchQueryWithArgs, + variables, + ); + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + username: 'useralice', + profile_picture: null, + }, + }); + + // Set up renderers + Renderer = props => null; + + const Container = (props: { + userRef?: {}, + owner: OperationDescriptor, + fragment: $FlowFixMe, + }) => { + // We need a render a component to run a Hook + const [owner, _setOwner] = useState(props.owner); + const [_, _setCount] = useState(0); + const fragment = props.fragment ?? gqlFragment; + const artificialUserRef = useMemo( + () => ({ + [ID_KEY]: + owner.request.variables.id ?? owner.request.variables.nodeID, + [FRAGMENTS_KEY]: { + [fragment.name]: {}, + }, + [FRAGMENT_OWNER_KEY]: owner.request, + }), + [owner, fragment.name], + ); + const userRef = props.hasOwnProperty('userRef') + ? props.userRef + : artificialUserRef; + + setOwner = _setOwner; + forceUpdate = () => _setCount(count => count + 1); + + const {fragmentData: userData} = useRefetchableFragmentNode( + fragment, + userRef, + ); + return ; + }; + + const ContextProvider = ({children}) => { + const [env, _setEnv] = useState(environment); + // TODO(T39494051) - We set empty variables in relay context to make + // Flow happy, but useRefetchableFragmentNode does not use them, instead it uses + // the variables from the fragment owner. + const relayContext = useMemo(() => ({environment: env, variables: {}}), [ + env, + ]); + + setEnvironment = _setEnv; + + return ( + + {children} + + ); + }; + + renderFragment = (args?: { + isConcurrent?: boolean, + owner?: $FlowFixMe, + userRef?: $FlowFixMe, + fragment?: $FlowFixMe, + }): $FlowFixMe => { + const {isConcurrent = false, ...props} = args ?? {}; + let renderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + `Error: ${error.message}`}> + + + + + + , + {unstable_isConcurrent: isConcurrent}, + ); + }); + return renderer; + }; + }); + + afterEach(() => { + environment.mockClear(); + renderSpy.mockClear(); + }); + + describe('initial render', () => { + // The bulk of initial render behavior is covered in useFragmentNodes-test, + // so this suite covers the basic cases as a sanity check. + it('should throw error if fragment is plural', () => { + jest.spyOn(console, 'error').mockImplementationOnce(() => {}); + + const generated = generateAndCompile(` + fragment UserFragment on User @relay(plural: true) { + id + } + `); + const renderer = renderFragment({fragment: generated.UserFragment}); + expect( + renderer + .toJSON() + .includes('Remove `@relay(plural: true)` from fragment'), + ).toEqual(true); + }); + + it('should throw error if fragment is missing @refetchable directive', () => { + jest.spyOn(console, 'error').mockImplementationOnce(() => {}); + + const generated = generateAndCompile(` + fragment UserFragment on User { + id + } + `); + const renderer = renderFragment({fragment: generated.UserFragment}); + expect( + renderer + .toJSON() + .includes( + 'Did you forget to add a @refetchable directive to the fragment?', + ), + ).toEqual(true); + }); + + it('should render fragment without error when data is available', () => { + renderFragment(); + expectFragmentResults([ + { + data: { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', query), + }, + }, + ]); + }); + + it('should render fragment without error when ref is null', () => { + renderFragment({userRef: null}); + expectFragmentResults([{data: null}]); + }); + + it('should render fragment without error when ref is undefined', () => { + renderFragment({userRef: undefined}); + expectFragmentResults([{data: null}]); + }); + + it('should update when fragment data changes', () => { + renderFragment(); + expectFragmentResults([ + { + data: { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', query), + }, + }, + ]); + + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '1', + // Update name + name: 'Alice in Wonderland', + }, + }); + expectFragmentResults([ + { + data: { + id: '1', + // Assert that name is updated + name: 'Alice in Wonderland', + profile_picture: null, + ...createFragmentRef('1', query), + }, + }, + ]); + }); + + it('should throw a promise if data is missing for fragment and request is in flight', () => { + // This prevents console.error output in the test, which is expected + jest.spyOn(console, 'error').mockImplementationOnce(() => {}); + jest + .spyOn( + require('relay-runtime').__internal, + 'getPromiseForRequestInFlight', + ) + .mockImplementationOnce(() => Promise.resolve()); + + const missingDataVariables = {...variables, id: '4'}; + const missingDataQuery = createOperationDescriptor( + gqlQuery, + missingDataVariables, + ); + // Commit a payload with name and profile_picture are missing + environment.commitPayload(missingDataQuery, { + node: { + __typename: 'User', + id: '4', + }, + }); + + const renderer = renderFragment({owner: missingDataQuery}); + expect(renderer.toJSON()).toEqual('Fallback'); + }); + }); + + describe('refetch', () => { + let release; + + beforeEach(() => { + jest.resetModules(); + ({ + createMockEnvironment, + generateAndCompile, + } = require('relay-test-utils-internal')); + + release = jest.fn(); + environment.retain.mockImplementation((...args) => { + return { + dispose: release, + }; + }); + }); + + function expectRequestIsInFlight( + expected, + requestEnvironment = environment, + ) { + expect(requestEnvironment.execute).toBeCalledTimes(expected.requestCount); + expect( + requestEnvironment.mock.isLoading( + expected.gqlRefetchQuery ?? gqlRefetchQuery, + expected.refetchVariables, + {force: true}, + ), + ).toEqual(expected.inFlight); + } + + function expectFragmentIsRefetching( + renderer, + expected: {| + refetchVariables: Variables, + refetchQuery?: OperationDescriptor, + gqlRefetchQuery?: $FlowFixMe, + |}, + env = environment, + ) { + expect(renderSpy).toBeCalledTimes(0); + renderSpy.mockClear(); + + // Assert refetch query was fetched + expectRequestIsInFlight( + {...expected, inFlight: true, requestCount: 1}, + env, + ); + + // Assert component suspended + expect(renderSpy).toBeCalledTimes(0); + expect(renderer.toJSON()).toEqual('Fallback'); + + // Assert query is tentatively retained while component is suspended + expect(env.retain).toBeCalledTimes(1); + expect(env.retain.mock.calls[0][0]).toEqual( + expected.refetchQuery?.root ?? refetchQuery.root, + ); + } + + it('does not refetch and warns if component has unmounted', () => { + const warning = require('warning'); + const renderer = renderFragment(); + const initialUser = { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', query), + }; + expectFragmentResults([{data: initialUser}]); + + renderer.unmount(); + + TestRenderer.act(() => { + refetch({id: '4'}); + }); + + expect(warning).toHaveBeenCalledTimes(1); + expect( + // $FlowFixMe + warning.mock.calls[0][1].includes( + 'Relay: Unexpected call to `refetch` on unmounted component', + ), + ).toEqual(true); + expect(environment.execute).toHaveBeenCalledTimes(0); + }); + + it('warns if fragment ref passed to useRefetchableFragmentNode() was null', () => { + const warning = require('warning'); + renderFragment({userRef: null}); + expectFragmentResults([{data: null}]); + + TestRenderer.act(() => { + refetch({id: '4'}); + }); + + expect(warning).toHaveBeenCalledTimes(1); + expect( + // $FlowFixMe + warning.mock.calls[0][1].includes( + 'Relay: Unexpected call to `refetch` while using a null fragment ref', + ), + ).toEqual(true); + expect(environment.execute).toHaveBeenCalledTimes(1); + }); + + it('warns if refetch scheduled at high priority', () => { + const warning = require('warning'); + const Scheduler = require('scheduler'); + renderFragment(); + const initialUser = { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', query), + }; + expectFragmentResults([{data: initialUser}]); + + TestRenderer.act(() => { + Scheduler.unstable_runWithPriority( + Scheduler.unstable_ImmediatePriority, + () => { + refetch({id: '4'}); + }, + ); + }); + + expect(warning).toHaveBeenCalledTimes(1); + expect( + // $FlowFixMe + warning.mock.calls[0][1].includes( + 'Relay: Unexpected call to `refetch` at a priority higher than expected', + ), + ).toEqual(true); + expect(environment.execute).toHaveBeenCalledTimes(1); + }); + + it('throws error when error occurs during refetch', () => { + jest.spyOn(console, 'error').mockImplementationOnce(() => {}); + + const callback = jest.fn(); + const renderer = renderFragment(); + const initialUser = { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', query), + }; + expectFragmentResults([{data: initialUser}]); + + TestRenderer.act(() => { + refetch({id: '4'}, {onComplete: callback}); + }); + + // Assert that fragment is refetching with the right variables and + // suspends upon refetch + const refetchVariables = { + id: '4', + scale: 16, + }; + refetchQuery = createOperationDescriptor( + gqlRefetchQuery, + refetchVariables, + ); + expectFragmentIsRefetching(renderer, { + refetchVariables, + refetchQuery, + }); + + // Mock network error + environment.mock.reject(gqlRefetchQuery, new Error('Oops')); + TestRenderer.act(() => { + jest.runAllImmediates(); + }); + + // Assert error is caught in Error boundary + expect(renderer.toJSON()).toEqual('Error: Oops'); + expect(callback).toBeCalledTimes(1); + expect(callback.mock.calls[0][0]).toMatchObject({message: 'Oops'}); + + // Assert refetch query wasn't retained + TestRenderer.act(() => { + jest.runAllTimers(); + }); + expect(release).toBeCalledTimes(1); + expect(environment.retain).toBeCalledTimes(1); + }); + + it('refetches new variables correctly when refetching new id', () => { + const renderer = renderFragment(); + const initialUser = { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', query), + }; + expectFragmentResults([{data: initialUser}]); + + TestRenderer.act(() => { + refetch({id: '4'}); + }); + + // Assert that fragment is refetching with the right variables and + // suspends upon refetch + const refetchVariables = { + id: '4', + scale: 16, + }; + refetchQuery = createOperationDescriptor( + gqlRefetchQuery, + refetchVariables, + ); + expectFragmentIsRefetching(renderer, { + refetchVariables, + refetchQuery, + }); + + // Mock network response + environment.mock.resolve(gqlRefetchQuery, { + data: { + node: { + __typename: 'User', + id: '4', + name: 'Mark', + profile_picture: { + uri: 'scale16', + }, + username: 'usermark', + }, + }, + }); + + // Assert fragment is rendered with new data + const refetchedUser = { + id: '4', + name: 'Mark', + profile_picture: { + uri: 'scale16', + }, + ...createFragmentRef('4', refetchQuery), + }; + expectFragmentResults([{data: refetchedUser}, {data: refetchedUser}]); + + // Assert refetch query was retained + expect(release).not.toBeCalled(); + expect(environment.retain).toBeCalledTimes(1); + expect(environment.retain.mock.calls[0][0]).toEqual(refetchQuery.root); + }); + + it('refetches new variables correctly when refetching same id', () => { + const renderer = renderFragment(); + const initialUser = { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', query), + }; + expectFragmentResults([{data: initialUser}]); + + TestRenderer.act(() => { + refetch({scale: 32}); + }); + + // Assert that fragment is refetching with the right variables and + // suspends upon refetch + const refetchVariables = { + id: '1', + scale: 32, + }; + refetchQuery = createOperationDescriptor( + gqlRefetchQuery, + refetchVariables, + ); + expectFragmentIsRefetching(renderer, { + refetchVariables, + refetchQuery, + }); + + // Mock network response + environment.mock.resolve(gqlRefetchQuery, { + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + profile_picture: { + uri: 'scale32', + }, + username: 'useralice', + }, + }, + }); + + // Assert fragment is rendered with new data + const refetchedUser = { + id: '1', + name: 'Alice', + profile_picture: { + uri: 'scale32', + }, + ...createFragmentRef('1', refetchQuery), + }; + expectFragmentResults([{data: refetchedUser}, {data: refetchedUser}]); + + // Assert refetch query was retained + expect(release).not.toBeCalled(); + expect(environment.retain).toBeCalledTimes(1); + expect(environment.retain.mock.calls[0][0]).toEqual(refetchQuery.root); + }); + + it('with correct id from refetchable fragment when using nested fragment', () => { + // Populate store with data for query using nested fragment + environment.commitPayload(queryNestedFragment, { + node: { + __typename: 'Feedback', + id: '', + actor: { + __typename: 'User', + id: '1', + name: 'Alice', + username: 'useralice', + profile_picture: null, + }, + }, + }); + + // Get fragment ref for user using nested fragment + const userRef = (environment.lookup(queryNestedFragment.fragment) + .data: $FlowFixMe)?.node?.actor; + + const renderer = renderFragment({owner: queryNestedFragment, userRef}); + const initialUser = { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', queryNestedFragment), + }; + expectFragmentResults([{data: initialUser}]); + + TestRenderer.act(() => { + refetch({scale: 32}); + }); + + // Assert that fragment is refetching with the right variables and + // suspends upon refetch + const refetchVariables = { + // The id here should correspond to the user id, and not the + // feedback id from the query variables (i.e. ``) + id: '1', + scale: 32, + }; + refetchQuery = createOperationDescriptor( + gqlRefetchQuery, + refetchVariables, + ); + expectFragmentIsRefetching(renderer, { + refetchVariables, + refetchQuery, + }); + + // Mock network response + environment.mock.resolve(gqlRefetchQuery, { + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + profile_picture: { + uri: 'scale32', + }, + username: 'useralice', + }, + }, + }); + + // Assert fragment is rendered with new data + const refetchedUser = { + id: '1', + name: 'Alice', + profile_picture: { + uri: 'scale32', + }, + ...createFragmentRef('1', refetchQuery), + }; + expectFragmentResults([{data: refetchedUser}, {data: refetchedUser}]); + + // Assert refetch query was retained + expect(release).not.toBeCalled(); + expect(environment.retain).toBeCalledTimes(1); + expect(environment.retain.mock.calls[0][0]).toEqual(refetchQuery.root); + }); + + it('refetches correctly when refetching multiple times', () => { + const renderer = renderFragment(); + const initialUser = { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', query), + }; + expectFragmentResults([{data: initialUser}]); + + const refetchVariables = { + id: '1', + scale: 32, + }; + refetchQuery = createOperationDescriptor( + gqlRefetchQuery, + refetchVariables, + ); + const refetchedUser = { + id: '1', + name: 'Alice', + profile_picture: { + uri: 'scale32', + }, + ...createFragmentRef('1', refetchQuery), + }; + + const doAndAssertRefetch = fragmentResults => { + renderSpy.mockClear(); + environment.execute.mockClear(); + environment.retain.mockClear(); + release.mockClear(); + + TestRenderer.act(() => { + // We use fetchPolicy network-only to ensure the call to refetch + // always suspends + refetch({scale: 32}, {fetchPolicy: 'network-only'}); + }); + + // Assert that fragment is refetching with the right variables and + // suspends upon refetch + expectFragmentIsRefetching(renderer, { + refetchVariables, + refetchQuery, + }); + + // Mock network response + environment.mock.resolve(gqlRefetchQuery, { + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + profile_picture: { + uri: 'scale32', + }, + username: 'useralice', + }, + }, + }); + + // Assert fragment is rendered with new data + expectFragmentResults(fragmentResults); + + // Assert refetch query was retained + expect(release).not.toBeCalled(); + expect(environment.retain).toBeCalledTimes(1); + expect(environment.retain.mock.calls[0][0]).toEqual(refetchQuery.root); + }; + + // Refetch once + doAndAssertRefetch([{data: refetchedUser}, {data: refetchedUser}]); + + // Refetch twice + doAndAssertRefetch([{data: refetchedUser}]); + }); + + it('refetches new variables correctly when using @arguments', () => { + const userRef = environment.lookup(queryWithArgs.fragment).data?.node; + const renderer = renderFragment({ + fragment: gqlFragmentWithArgs, + userRef, + }); + const initialUser = { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', queryWithArgs), + }; + expectFragmentResults([{data: initialUser}]); + + TestRenderer.act(() => { + refetch({scaleLocal: 32}); + }); + + // Assert that fragment is refetching with the right variables and + // suspends upon refetch + const refetchVariables = { + id: '1', + scaleLocal: 32, + }; + refetchQueryWithArgs = createOperationDescriptor( + gqlRefetchQueryWithArgs, + refetchVariables, + ); + expectFragmentIsRefetching(renderer, { + refetchVariables, + refetchQuery: refetchQueryWithArgs, + gqlRefetchQuery: gqlRefetchQueryWithArgs, + }); + + // Mock network response + environment.mock.resolve(gqlRefetchQueryWithArgs, { + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + profile_picture: { + uri: 'scale32', + }, + username: 'useralice', + }, + }, + }); + + // Assert fragment is rendered with new data + const refetchedUser = { + id: '1', + name: 'Alice', + profile_picture: { + uri: 'scale32', + }, + ...createFragmentRef('1', refetchQueryWithArgs), + }; + expectFragmentResults([{data: refetchedUser}, {data: refetchedUser}]); + + // Assert refetch query was retained + expect(release).not.toBeCalled(); + expect(environment.retain).toBeCalledTimes(1); + expect(environment.retain.mock.calls[0][0]).toEqual( + refetchQueryWithArgs.root, + ); + }); + + it('refetches new variables correctly when using @arguments with literal values', () => { + const userRef = environment.lookup(queryWithLiteralArgs.fragment).data + ?.node; + const renderer = renderFragment({ + fragment: gqlFragmentWithArgs, + userRef, + }); + const initialUser = { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', queryWithLiteralArgs), + }; + expectFragmentResults([{data: initialUser}]); + + TestRenderer.act(() => { + refetch({id: '4'}); + }); + + // Assert that fragment is refetching with the right variables and + // suspends upon refetch + const refetchVariables = { + id: '4', + scaleLocal: 16, + }; + refetchQueryWithArgs = createOperationDescriptor( + gqlRefetchQueryWithArgs, + refetchVariables, + ); + expectFragmentIsRefetching(renderer, { + refetchVariables, + refetchQuery: refetchQueryWithArgs, + gqlRefetchQuery: gqlRefetchQueryWithArgs, + }); + + // Mock network response + environment.mock.resolve(gqlRefetchQueryWithArgs, { + data: { + node: { + __typename: 'User', + id: '4', + name: 'Mark', + profile_picture: { + uri: 'scale16', + }, + username: 'usermark', + }, + }, + }); + + // Assert fragment is rendered with new data + const refetchedUser = { + id: '4', + name: 'Mark', + profile_picture: { + uri: 'scale16', + }, + ...createFragmentRef('4', refetchQueryWithArgs), + }; + expectFragmentResults([{data: refetchedUser}, {data: refetchedUser}]); + + // Assert refetch query was retained + expect(release).not.toBeCalled(); + expect(environment.retain).toBeCalledTimes(1); + expect(environment.retain.mock.calls[0][0]).toEqual( + refetchQueryWithArgs.root, + ); + }); + + it('subscribes to changes in refetched data', () => { + renderFragment(); + renderSpy.mockClear(); + TestRenderer.act(() => { + refetch({id: '4'}); + }); + + // Mock network response + environment.mock.resolve(gqlRefetchQuery, { + data: { + node: { + __typename: 'User', + id: '4', + name: 'Mark', + profile_picture: { + uri: 'scale16', + }, + username: 'usermark', + }, + }, + }); + + // Assert fragment is rendered with new data + const refetchVariables = { + id: '4', + scale: 16, + }; + refetchQuery = createOperationDescriptor( + gqlRefetchQuery, + refetchVariables, + ); + const refetchedUser = { + id: '4', + name: 'Mark', + profile_picture: { + uri: 'scale16', + }, + ...createFragmentRef('4', refetchQuery), + }; + expectFragmentResults([{data: refetchedUser}, {data: refetchedUser}]); + + // Assert refetch query was retained + expect(release).not.toBeCalled(); + expect(environment.retain).toBeCalledTimes(1); + expect(environment.retain.mock.calls[0][0]).toEqual(refetchQuery.root); + + // Update refetched data + environment.commitPayload(refetchQuery, { + node: { + __typename: 'User', + id: '4', + name: 'Mark Updated', + }, + }); + + // Assert that refetched data is updated + expectFragmentResults([ + { + data: { + id: '4', + // Name is updated + name: 'Mark Updated', + profile_picture: { + uri: 'scale16', + }, + ...createFragmentRef('4', refetchQuery), + }, + }, + ]); + }); + + it('resets to parent data when environment changes', () => { + renderFragment(); + renderSpy.mockClear(); + TestRenderer.act(() => { + refetch({id: '4'}); + }); + + // Mock network response + environment.mock.resolve(gqlRefetchQuery, { + data: { + node: { + __typename: 'User', + id: '4', + name: 'Mark', + profile_picture: { + uri: 'scale16', + }, + username: 'usermark', + }, + }, + }); + + // Assert fragment is rendered with new data + const refetchVariables = { + id: '4', + scale: 16, + }; + refetchQuery = createOperationDescriptor( + gqlRefetchQuery, + refetchVariables, + ); + const refetchedUser = { + id: '4', + name: 'Mark', + profile_picture: { + uri: 'scale16', + }, + ...createFragmentRef('4', refetchQuery), + }; + expectFragmentResults([{data: refetchedUser}, {data: refetchedUser}]); + + // Assert refetch query was retained + expect(release).not.toBeCalled(); + expect(environment.retain).toBeCalledTimes(1); + expect(environment.retain.mock.calls[0][0]).toEqual(refetchQuery.root); + + // Set new environment + const newEnvironment = createMockEnvironment(); + newEnvironment.commitPayload(query, { + node: { + __typename: 'User', + id: '1', + name: 'Alice in a different env', + username: 'useralice', + profile_picture: null, + }, + }); + TestRenderer.act(() => { + setEnvironment(newEnvironment); + }); + + // Assert that parent data is rendered + const expectedUser = { + id: '1', + name: 'Alice in a different env', + profile_picture: null, + ...createFragmentRef('1', query), + }; + expectFragmentResults([ + {data: expectedUser}, + {data: expectedUser}, + {data: expectedUser}, + ]); + + // Assert refetch query was released + expect(release).toBeCalledTimes(1); + expect(environment.retain).toBeCalledTimes(1); + + // Update data in new environment + newEnvironment.commitPayload(query, { + node: { + __typename: 'User', + id: '1', + name: 'Alice Updated', + }, + }); + + // Assert that data in new environment is updated + expectFragmentResults([ + { + data: { + id: '1', + name: 'Alice Updated', + profile_picture: null, + ...createFragmentRef('1', query), + }, + }, + ]); + }); + + it('resets to parent data when parent fragment ref changes', () => { + renderFragment(); + renderSpy.mockClear(); + TestRenderer.act(() => { + refetch({id: '4'}); + }); + + // Mock network response + environment.mock.resolve(gqlRefetchQuery, { + data: { + node: { + __typename: 'User', + id: '4', + name: 'Mark', + profile_picture: { + uri: 'scale16', + }, + username: 'usermark', + }, + }, + }); + + // Assert fragment is rendered with new data + const refetchVariables = { + id: '4', + scale: 16, + }; + refetchQuery = createOperationDescriptor( + gqlRefetchQuery, + refetchVariables, + ); + const refetchedUser = { + id: '4', + name: 'Mark', + profile_picture: { + uri: 'scale16', + }, + ...createFragmentRef('4', refetchQuery), + }; + expectFragmentResults([{data: refetchedUser}, {data: refetchedUser}]); + + // Assert refetch query was retained + expect(release).not.toBeCalled(); + expect(environment.retain).toBeCalledTimes(1); + expect(environment.retain.mock.calls[0][0]).toEqual(refetchQuery.root); + + // Pass new parent fragment ref with different variables + const newVariables = {...variables, scale: 32}; + const newQuery = createOperationDescriptor(gqlQuery, newVariables); + environment.commitPayload(newQuery, { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + username: 'useralice', + profile_picture: { + uri: 'uri32', + }, + }, + }); + TestRenderer.act(() => { + setOwner(newQuery); + }); + + // Assert that parent data is rendered + const expectedUser = { + id: '1', + name: 'Alice', + profile_picture: { + uri: 'uri32', + }, + ...createFragmentRef('1', newQuery), + }; + expectFragmentResults([ + {data: expectedUser}, + {data: expectedUser}, + {data: expectedUser}, + ]); + + // Assert refetch query was released + expect(release).toBeCalledTimes(1); + expect(environment.retain).toBeCalledTimes(1); + + // Update new parent data + environment.commitPayload(newQuery, { + node: { + __typename: 'User', + id: '1', + name: 'Alice Updated', + }, + }); + + // Assert that new data from parent is updated + expectFragmentResults([ + { + data: { + id: '1', + name: 'Alice Updated', + profile_picture: { + uri: 'uri32', + }, + ...createFragmentRef('1', newQuery), + }, + }, + ]); + }); + + it('warns if data retured has different __typename', () => { + const renderer = renderFragment(); + + const initialUser = { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', query), + }; + expectFragmentResults([{data: initialUser}]); + + const refetchVariables = { + id: '1', + scale: 32, + }; + refetchQuery = createOperationDescriptor( + gqlRefetchQuery, + refetchVariables, + ); + + renderSpy.mockClear(); + environment.execute.mockClear(); + environment.retain.mockClear(); + release.mockClear(); + + TestRenderer.act(() => { + refetch({scale: 32}, {fetchPolicy: 'network-only'}); + }); + + expectFragmentIsRefetching(renderer, { + refetchVariables, + refetchQuery, + }); + + // Mock network response + environment.mock.resolve(gqlRefetchQuery, { + data: { + node: { + __typename: 'MessagingParticipant', + id: '1', + name: 'Alice', + profile_picture: { + uri: 'scale32', + }, + username: 'useralice', + }, + }, + }); + + TestRenderer.act(() => { + jest.runAllImmediates(); + }); + + const warning = require('warning'); + + // $FlowFixMe + const warningCalls = warning.mock.calls.filter(call => call[0] === false); + expect(warningCalls.length).toEqual(4); // the other warnings are from FragmentResource.js + expect( + warningCalls[1][1].includes( + 'Relay: Call to `refetch` returns data with a different __typename:', + ), + ).toEqual(true); + }); + + it('warns if a different id is returned', () => { + const renderer = renderFragment(); + + const initialUser = { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', query), + }; + expectFragmentResults([{data: initialUser}]); + + const refetchVariables = { + id: '1', + scale: 32, + }; + refetchQuery = createOperationDescriptor( + gqlRefetchQuery, + refetchVariables, + ); + + renderSpy.mockClear(); + environment.execute.mockClear(); + environment.retain.mockClear(); + release.mockClear(); + + TestRenderer.act(() => { + refetch({scale: 32}, {fetchPolicy: 'network-only'}); + }); + + expectFragmentIsRefetching(renderer, { + refetchVariables, + refetchQuery, + }); + + environment.mock.resolve(gqlRefetchQuery, { + data: { + node: { + __typename: 'User', + id: '2', + name: 'Mark', + profile_picture: { + uri: 'scale32', + }, + username: 'usermark', + }, + }, + }); + + TestRenderer.act(() => { + jest.runAllImmediates(); + }); + + const warning = require('warning'); + // $FlowFixMe + const warningCalls = warning.mock.calls.filter(call => call[0] === false); + expect(warningCalls.length).toEqual(2); + expect( + warningCalls[0][1].includes( + 'Relay: Call to `refetch` returns a different id, expected', + ), + ).toEqual(true); + }); + + it("doesn't warn if refetching on a different id than the current one in display", () => { + renderFragment(); + + const initialUser = { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', query), + }; + expectFragmentResults([{data: initialUser}]); + + const refetchVariables = { + id: '1', + scale: 32, + }; + refetchQuery = createOperationDescriptor( + gqlRefetchQuery, + refetchVariables, + ); + + renderSpy.mockClear(); + environment.execute.mockClear(); + environment.retain.mockClear(); + release.mockClear(); + + TestRenderer.act(() => { + refetch({id: '2', scale: 32}, {fetchPolicy: 'network-only'}); + jest.runAllImmediates(); + }); + + TestRenderer.act(() => { + refetch({id: '3', scale: 32}, {fetchPolicy: 'network-only'}); + }); + + environment.mock.resolve(gqlRefetchQuery, { + data: { + node: { + __typename: 'User', + id: '3', + name: 'Mark', + profile_picture: { + uri: 'scale32', + }, + username: 'usermark', + }, + }, + }); + + TestRenderer.act(() => { + jest.runAllTimers(); + }); + + const warning = require('warning'); + expect( + // $FlowFixMe + warning.mock.calls.filter(call => call[0] === false).length, + ).toEqual(0); + }); + + describe('fetchPolicy', () => { + describe('store-or-network', () => { + beforeEach(() => { + fetchPolicy = 'store-or-network'; + }); + + describe('renderPolicy: partial', () => { + beforeEach(() => { + renderPolicy = 'partial'; + }); + it("doesn't start network request if refetch query is fully cached", () => { + renderFragment(); + renderSpy.mockClear(); + TestRenderer.act(() => { + refetch({id: '1'}, {fetchPolicy, renderPolicy}); + }); + + // Assert request is not started + const refetchVariables = {...variables}; + refetchQuery = createOperationDescriptor( + gqlRefetchQuery, + refetchVariables, + ); + expectRequestIsInFlight({ + inFlight: false, + requestCount: 0, + gqlRefetchQuery, + refetchVariables, + }); + + // Assert component renders immediately since data is cached + const refetchingUser = { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', refetchQuery), + }; + expectFragmentResults([ + {data: refetchingUser}, + {data: refetchingUser}, + ]); + }); + + it('starts network request if refetch query is not fully cached and suspends if fragment has missing data', () => { + const renderer = renderFragment(); + const initialUser = { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', query), + }; + expectFragmentResults([{data: initialUser}]); + + TestRenderer.act(() => { + refetch({id: '4'}, {fetchPolicy, renderPolicy}); + }); + + // Assert that fragment is refetching with the right variables and + // suspends upon refetch + const refetchVariables = { + id: '4', + scale: 16, + }; + refetchQuery = createOperationDescriptor( + gqlRefetchQuery, + refetchVariables, + ); + expectFragmentIsRefetching(renderer, { + refetchVariables, + refetchQuery, + }); + + // Mock network response + environment.mock.resolve(gqlRefetchQuery, { + data: { + node: { + __typename: 'User', + id: '4', + name: 'Mark', + profile_picture: { + uri: 'scale16', + }, + username: 'usermark', + }, + }, + }); + + // Assert fragment is rendered with new data + const refetchedUser = { + id: '4', + name: 'Mark', + profile_picture: { + uri: 'scale16', + }, + ...createFragmentRef('4', refetchQuery), + }; + expectFragmentResults([ + {data: refetchedUser}, + {data: refetchedUser}, + ]); + }); + + it("starts network request if refetch query is not fully cached and doesn't suspend if fragment doesn't have missing data", () => { + // Cache user with missing username + const refetchVariables = {id: '4', scale: 16}; + refetchQuery = createOperationDescriptor( + gqlRefetchQuery, + refetchVariables, + ); + environment.commitPayload(refetchQuery, { + node: { + __typename: 'User', + id: '4', + name: 'Mark', + profile_picture: null, + }, + }); + + renderFragment(); + renderSpy.mockClear(); + TestRenderer.act(() => { + refetch({id: '4'}, {fetchPolicy, renderPolicy}); + }); + + // Assert request is started + expectRequestIsInFlight({ + inFlight: true, + requestCount: 1, + gqlRefetchQuery, + refetchVariables, + }); + + // Assert component renders immediately since data is cached + const refetchingUser = { + id: '4', + name: 'Mark', + profile_picture: null, + ...createFragmentRef('4', refetchQuery), + }; + expectFragmentResults([ + {data: refetchingUser}, + {data: refetchingUser}, + ]); + }); + }); + + describe('renderPolicy: full', () => { + beforeEach(() => { + renderPolicy = 'full'; + }); + it("doesn't start network request if refetch query is fully cached", () => { + renderFragment(); + renderSpy.mockClear(); + TestRenderer.act(() => { + refetch({id: '1'}, {fetchPolicy, renderPolicy}); + }); + + // Assert request is not started + const refetchVariables = {...variables}; + refetchQuery = createOperationDescriptor( + gqlRefetchQuery, + refetchVariables, + ); + expectRequestIsInFlight({ + inFlight: false, + requestCount: 0, + gqlRefetchQuery, + refetchVariables, + }); + + // Assert component renders immediately since data is cached + const refetchingUser = { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', refetchQuery), + }; + expectFragmentResults([ + {data: refetchingUser}, + {data: refetchingUser}, + ]); + }); + + it('starts network request if refetch query is not fully cached and suspends if fragment has missing data', () => { + const renderer = renderFragment(); + const initialUser = { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', query), + }; + expectFragmentResults([{data: initialUser}]); + + TestRenderer.act(() => { + refetch({id: '4'}, {fetchPolicy, renderPolicy}); + }); + + // Assert that fragment is refetching with the right variables and + // suspends upon refetch + const refetchVariables = { + id: '4', + scale: 16, + }; + refetchQuery = createOperationDescriptor( + gqlRefetchQuery, + refetchVariables, + ); + expectFragmentIsRefetching(renderer, { + refetchVariables, + refetchQuery, + }); + + // Mock network response + environment.mock.resolve(gqlRefetchQuery, { + data: { + node: { + __typename: 'User', + id: '4', + name: 'Mark', + profile_picture: { + uri: 'scale16', + }, + username: 'usermark', + }, + }, + }); + + // Assert fragment is rendered with new data + const refetchedUser = { + id: '4', + name: 'Mark', + profile_picture: { + uri: 'scale16', + }, + ...createFragmentRef('4', refetchQuery), + }; + expectFragmentResults([ + {data: refetchedUser}, + {data: refetchedUser}, + ]); + }); + + it("starts network request if refetch query is not fully cached and suspends even if fragment doesn't have missing data", () => { + // Cache user with missing username + const refetchVariables = {id: '4', scale: 16}; + refetchQuery = createOperationDescriptor( + gqlRefetchQuery, + refetchVariables, + ); + environment.commitPayload(refetchQuery, { + node: { + __typename: 'User', + id: '4', + name: 'Mark', + profile_picture: null, + }, + }); + + const renderer = renderFragment(); + const initialUser = { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', query), + }; + expectFragmentResults([{data: initialUser}]); + + TestRenderer.act(() => { + refetch({id: '4'}, {fetchPolicy, renderPolicy}); + }); + + expectFragmentIsRefetching(renderer, { + refetchVariables, + refetchQuery, + }); + + // Mock network response + environment.mock.resolve(gqlRefetchQuery, { + data: { + node: { + __typename: 'User', + id: '4', + name: 'Mark', + profile_picture: { + uri: 'scale16', + }, + username: 'usermark', + }, + }, + }); + + // Assert fragment is rendered with new data + const refetchedUser = { + id: '4', + name: 'Mark', + profile_picture: { + uri: 'scale16', + }, + ...createFragmentRef('4', refetchQuery), + }; + expectFragmentResults([ + {data: refetchedUser}, + {data: refetchedUser}, + ]); + }); + }); + }); + + describe('store-and-network', () => { + beforeEach(() => { + fetchPolicy = 'store-and-network'; + }); + + describe('renderPolicy: partial', () => { + beforeEach(() => { + renderPolicy = 'partial'; + }); + + it('starts network request if refetch query is fully cached', () => { + renderFragment(); + renderSpy.mockClear(); + TestRenderer.act(() => { + refetch({id: '1'}, {fetchPolicy, renderPolicy}); + }); + + // Assert request is not started + const refetchVariables = {...variables}; + refetchQuery = createOperationDescriptor( + gqlRefetchQuery, + refetchVariables, + ); + expectRequestIsInFlight({ + inFlight: true, + requestCount: 1, + gqlRefetchQuery, + refetchVariables, + }); + + // Assert component renders immediately since data is cached + const refetchingUser = { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', refetchQuery), + }; + expectFragmentResults([ + {data: refetchingUser}, + {data: refetchingUser}, + ]); + }); + + it('starts network request if refetch query is not fully cached and suspends if fragment has missing data', () => { + const renderer = renderFragment(); + const initialUser = { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', query), + }; + expectFragmentResults([{data: initialUser}]); + + TestRenderer.act(() => { + refetch({id: '4'}, {fetchPolicy, renderPolicy}); + }); + + // Assert that fragment is refetching with the right variables and + // suspends upon refetch + const refetchVariables = { + id: '4', + scale: 16, + }; + refetchQuery = createOperationDescriptor( + gqlRefetchQuery, + refetchVariables, + ); + expectFragmentIsRefetching(renderer, { + refetchVariables, + refetchQuery, + }); + + // Mock network response + environment.mock.resolve(gqlRefetchQuery, { + data: { + node: { + __typename: 'User', + id: '4', + name: 'Mark', + profile_picture: { + uri: 'scale16', + }, + username: 'usermark', + }, + }, + }); + + // Assert fragment is rendered with new data + const refetchedUser = { + id: '4', + name: 'Mark', + profile_picture: { + uri: 'scale16', + }, + ...createFragmentRef('4', refetchQuery), + }; + expectFragmentResults([ + {data: refetchedUser}, + {data: refetchedUser}, + ]); + }); + + it("starts network request if refetch query is not fully cached and doesn't suspend if fragment doesn't have missing data", () => { + // Cache user with missing username + const refetchVariables = {id: '4', scale: 16}; + refetchQuery = createOperationDescriptor( + gqlRefetchQuery, + refetchVariables, + ); + environment.commitPayload(refetchQuery, { + node: { + __typename: 'User', + id: '4', + name: 'Mark', + profile_picture: null, + }, + }); + + renderFragment(); + renderSpy.mockClear(); + TestRenderer.act(() => { + refetch({id: '4'}, {fetchPolicy, renderPolicy}); + }); + + // Assert request is started + expectRequestIsInFlight({ + inFlight: true, + requestCount: 1, + gqlRefetchQuery, + refetchVariables, + }); + + // Assert component renders immediately since data is cached + const refetchingUser = { + id: '4', + name: 'Mark', + profile_picture: null, + ...createFragmentRef('4', refetchQuery), + }; + expectFragmentResults([ + {data: refetchingUser}, + {data: refetchingUser}, + ]); + }); + }); + + describe('renderPolicy: full', () => { + beforeEach(() => { + renderPolicy = 'full'; + }); + + it('starts network request if refetch query is fully cached', () => { + renderFragment(); + renderSpy.mockClear(); + TestRenderer.act(() => { + refetch({id: '1'}, {fetchPolicy, renderPolicy}); + }); + + // Assert request is not started + const refetchVariables = {...variables}; + refetchQuery = createOperationDescriptor( + gqlRefetchQuery, + refetchVariables, + ); + expectRequestIsInFlight({ + inFlight: true, + requestCount: 1, + gqlRefetchQuery, + refetchVariables, + }); + + // Assert component renders immediately since data is cached + const refetchingUser = { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', refetchQuery), + }; + expectFragmentResults([ + {data: refetchingUser}, + {data: refetchingUser}, + ]); + }); + + it('starts network request if refetch query is not fully cached and suspends if fragment has missing data', () => { + const renderer = renderFragment(); + const initialUser = { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', query), + }; + expectFragmentResults([{data: initialUser}]); + + TestRenderer.act(() => { + refetch({id: '4'}, {fetchPolicy, renderPolicy}); + }); + + // Assert that fragment is refetching with the right variables and + // suspends upon refetch + const refetchVariables = { + id: '4', + scale: 16, + }; + refetchQuery = createOperationDescriptor( + gqlRefetchQuery, + refetchVariables, + ); + expectFragmentIsRefetching(renderer, { + refetchVariables, + refetchQuery, + }); + + // Mock network response + environment.mock.resolve(gqlRefetchQuery, { + data: { + node: { + __typename: 'User', + id: '4', + name: 'Mark', + profile_picture: { + uri: 'scale16', + }, + username: 'usermark', + }, + }, + }); + + // Assert fragment is rendered with new data + const refetchedUser = { + id: '4', + name: 'Mark', + profile_picture: { + uri: 'scale16', + }, + ...createFragmentRef('4', refetchQuery), + }; + expectFragmentResults([ + {data: refetchedUser}, + {data: refetchedUser}, + ]); + }); + + it("starts network request if refetch query is not fully cached and doesn't suspend if fragment doesn't have missing data", () => { + // Cache user with missing username + const refetchVariables = {id: '4', scale: 16}; + refetchQuery = createOperationDescriptor( + gqlRefetchQuery, + refetchVariables, + ); + environment.commitPayload(refetchQuery, { + node: { + __typename: 'User', + id: '4', + name: 'Mark', + profile_picture: null, + }, + }); + + const renderer = renderFragment(); + renderSpy.mockClear(); + TestRenderer.act(() => { + refetch({id: '4'}, {fetchPolicy, renderPolicy}); + }); + + // Assert component suspended + expectFragmentIsRefetching(renderer, { + refetchVariables, + refetchQuery, + }); + + // Mock network response + environment.mock.resolve(gqlRefetchQuery, { + data: { + node: { + __typename: 'User', + id: '4', + name: 'Mark', + profile_picture: { + uri: 'scale16', + }, + username: 'usermark', + }, + }, + }); + + // Assert fragment is rendered with new data + const refetchedUser = { + id: '4', + name: 'Mark', + profile_picture: { + uri: 'scale16', + }, + ...createFragmentRef('4', refetchQuery), + }; + expectFragmentResults([ + {data: refetchedUser}, + {data: refetchedUser}, + ]); + }); + }); + }); + + describe('network-only', () => { + beforeEach(() => { + fetchPolicy = 'network-only'; + }); + + it('starts network request and suspends if refetch query is fully cached', () => { + const renderer = renderFragment(); + const initialUser = { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', query), + }; + expectFragmentResults([{data: initialUser}]); + + TestRenderer.act(() => { + refetch({id: '1'}, {fetchPolicy, renderPolicy}); + }); + + // Assert that fragment is refetching with the right variables and + // suspends upon refetch + const refetchVariables = { + ...variables, + }; + refetchQuery = createOperationDescriptor( + gqlRefetchQuery, + refetchVariables, + ); + expectFragmentIsRefetching(renderer, { + refetchVariables, + refetchQuery, + }); + + // Mock network response + environment.mock.resolve(gqlRefetchQuery, { + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + profile_picture: null, + username: 'useralice', + }, + }, + }); + + // Assert fragment is rendered with new data + const refetchedUser = { + ...initialUser, + ...createFragmentRef('1', refetchQuery), + }; + expectFragmentResults([{data: refetchedUser}, {data: refetchedUser}]); + }); + + it('starts network request and suspends if refetch query is not fully cached', () => { + const renderer = renderFragment(); + const initialUser = { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', query), + }; + expectFragmentResults([{data: initialUser}]); + + TestRenderer.act(() => { + refetch({id: '4'}, {fetchPolicy, renderPolicy}); + }); + + // Assert that fragment is refetching with the right variables and + // suspends upon refetch + const refetchVariables = { + id: '4', + scale: 16, + }; + refetchQuery = createOperationDescriptor( + gqlRefetchQuery, + refetchVariables, + ); + expectFragmentIsRefetching(renderer, { + refetchVariables, + refetchQuery, + }); + + // Mock network response + environment.mock.resolve(gqlRefetchQuery, { + data: { + node: { + __typename: 'User', + id: '4', + name: 'Mark', + profile_picture: { + uri: 'scale16', + }, + username: 'usermark', + }, + }, + }); + + // Assert fragment is rendered with new data + const refetchedUser = { + id: '4', + name: 'Mark', + profile_picture: { + uri: 'scale16', + }, + ...createFragmentRef('4', refetchQuery), + }; + expectFragmentResults([{data: refetchedUser}, {data: refetchedUser}]); + }); + }); + + describe('store-only', () => { + beforeEach(() => { + fetchPolicy = 'store-only'; + }); + + it("doesn't start network request if refetch query is fully cached", () => { + renderFragment(); + renderSpy.mockClear(); + TestRenderer.act(() => { + refetch({id: '1'}, {fetchPolicy, renderPolicy}); + }); + + // Assert request is not started + const refetchVariables = {...variables}; + refetchQuery = createOperationDescriptor( + gqlRefetchQuery, + refetchVariables, + ); + expectRequestIsInFlight({ + inFlight: false, + requestCount: 0, + gqlRefetchQuery, + refetchVariables, + }); + + // Assert component renders immediately since data is cached + const refetchingUser = { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', refetchQuery), + }; + expectFragmentResults([ + {data: refetchingUser}, + {data: refetchingUser}, + ]); + }); + + it("doesn't start network request if refetch query is not fully cached", () => { + renderFragment(); + renderSpy.mockClear(); + TestRenderer.act(() => { + refetch({id: '4'}, {fetchPolicy, renderPolicy}); + }); + + // Assert request is not started + const refetchVariables = {id: '4', scale: 32}; + refetchQuery = createOperationDescriptor( + gqlRefetchQuery, + refetchVariables, + ); + expectRequestIsInFlight({ + inFlight: false, + requestCount: 0, + gqlRefetchQuery, + refetchVariables, + }); + + // Assert component renders immediately with empty daa + expectFragmentResults([{data: null}, {data: null}]); + }); + }); + }); + + describe('disposing', () => { + let unsubscribe; + const fetchPolicy = 'store-and-network'; + beforeEach(() => { + unsubscribe = jest.fn(); + jest.doMock('relay-runtime', () => { + const originalRuntime = jest.requireActual('relay-runtime'); + const originalInternal = originalRuntime.__internal; + return { + ...originalRuntime, + __internal: { + ...originalInternal, + fetchQuery: (...args) => { + const observable = originalInternal.fetchQuery(...args); + return { + subscribe: observer => { + return observable.subscribe({ + ...observer, + start: originalSubscription => { + const observerStart = observer?.start; + observerStart && + observerStart({ + ...originalSubscription, + unsubscribe: () => { + originalSubscription.unsubscribe(); + unsubscribe(); + }, + }); + }, + }); + }, + }; + }, + }, + }; + }); + }); + + afterEach(() => { + jest.dontMock('relay-runtime'); + }); + + it('disposes ongoing request if environment changes', () => { + renderFragment(); + renderSpy.mockClear(); + TestRenderer.act(() => { + refetch({id: '1'}, {fetchPolicy, renderPolicy}); + }); + + // Assert request is started + const refetchVariables = {id: '1', scale: 16}; + refetchQuery = createOperationDescriptor( + gqlRefetchQuery, + refetchVariables, + ); + expectRequestIsInFlight({ + inFlight: true, + requestCount: 1, + gqlRefetchQuery, + refetchVariables, + }); + + // Component renders immediately even though request is in flight + // since data is cached + const refetchingUser = { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', refetchQuery), + }; + expectFragmentResults([{data: refetchingUser}, {data: refetchingUser}]); + + // Set new environment + const newEnvironment = createMockEnvironment(); + newEnvironment.commitPayload(query, { + node: { + __typename: 'User', + id: '1', + name: 'Alice in a different env', + username: 'useralice', + profile_picture: null, + }, + }); + TestRenderer.act(() => { + setEnvironment(newEnvironment); + }); + + // Assert request was canceled + expect(unsubscribe).toBeCalledTimes(1); + expectRequestIsInFlight({ + inFlight: false, + requestCount: 1, + gqlRefetchQuery, + refetchVariables, + }); + + // Assert newly rendered data + const expectedUser = { + id: '1', + name: 'Alice in a different env', + profile_picture: null, + ...createFragmentRef('1', query), + }; + expectFragmentResults([ + {data: expectedUser}, + {data: expectedUser}, + {data: expectedUser}, + ]); + }); + + it('disposes ongoing request if fragment ref changes', () => { + renderFragment(); + renderSpy.mockClear(); + TestRenderer.act(() => { + refetch({id: '1'}, {fetchPolicy, renderPolicy}); + }); + + // Assert request is started + const refetchVariables = {id: '1', scale: 16}; + refetchQuery = createOperationDescriptor( + gqlRefetchQuery, + refetchVariables, + ); + expectRequestIsInFlight({ + inFlight: true, + requestCount: 1, + gqlRefetchQuery, + refetchVariables, + }); + + // Component renders immediately even though request is in flight + // since data is cached + const refetchingUser = { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', refetchQuery), + }; + expectFragmentResults([{data: refetchingUser}, {data: refetchingUser}]); + + // Pass new parent fragment ref with different variables + const newVariables = {...variables, scale: 32}; + const newQuery = createOperationDescriptor(gqlQuery, newVariables); + environment.commitPayload(newQuery, { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + username: 'useralice', + profile_picture: { + uri: 'uri32', + }, + }, + }); + TestRenderer.act(() => { + setOwner(newQuery); + }); + + // Assert request was canceled + expect(unsubscribe).toBeCalledTimes(1); + expectRequestIsInFlight({ + inFlight: false, + requestCount: 1, + gqlRefetchQuery, + refetchVariables, + }); + + // Assert newly rendered data + const expectedUser = { + id: '1', + name: 'Alice', + profile_picture: { + uri: 'uri32', + }, + ...createFragmentRef('1', newQuery), + }; + expectFragmentResults([ + {data: expectedUser}, + {data: expectedUser}, + {data: expectedUser}, + ]); + }); + + it('disposes ongoing request if refetch is called again', () => { + const renderer = renderFragment(); + renderSpy.mockClear(); + TestRenderer.act(() => { + refetch({id: '1'}, {fetchPolicy, renderPolicy}); + }); + + // Assert request is started + const refetchVariables1 = {id: '1', scale: 16}; + const refetchQuery1 = createOperationDescriptor( + gqlRefetchQuery, + refetchVariables1, + ); + expectRequestIsInFlight({ + inFlight: true, + requestCount: 1, + gqlRefetchQuery, + refetchVariables: refetchVariables1, + }); + + // Component renders immediately even though request is in flight + // since data is cached + const refetchingUser = { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', refetchQuery1), + }; + expectFragmentResults([{data: refetchingUser}, {data: refetchingUser}]); + + // Call refetch a second time + environment.execute.mockClear(); + const refetchVariables2 = {id: '4', scale: 16}; + TestRenderer.act(() => { + refetch({id: '4'}, {fetchPolicy, renderPolicy}); + }); + + // Assert first request was canceled + expect(unsubscribe).toBeCalledTimes(1); + expectRequestIsInFlight({ + inFlight: false, + requestCount: 1, + gqlRefetchQuery, + refetchVariables: refetchVariables1, + }); + + // Assert second request is started + expectRequestIsInFlight({ + inFlight: true, + requestCount: 1, + gqlRefetchQuery, + refetchVariables: refetchVariables2, + }); + // Assert component suspended + expect(renderSpy).toBeCalledTimes(0); + expect(renderer.toJSON()).toEqual('Fallback'); + }); + + it('disposes of ongoing request on unmount', () => { + const renderer = renderFragment(); + renderSpy.mockClear(); + TestRenderer.act(() => { + refetch({id: '1'}, {fetchPolicy, renderPolicy}); + }); + + // Assert request is started + const refetchVariables = {id: '1', scale: 16}; + refetchQuery = createOperationDescriptor( + gqlRefetchQuery, + refetchVariables, + ); + expectRequestIsInFlight({ + inFlight: true, + requestCount: 1, + gqlRefetchQuery, + refetchVariables, + }); + + // Component renders immediately even though request is in flight + // since data is cached + const refetchingUser = { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', refetchQuery), + }; + expectFragmentResults([{data: refetchingUser}, {data: refetchingUser}]); + + renderer.unmount(); + + // Assert request was canceled + expect(unsubscribe).toBeCalledTimes(1); + expectRequestIsInFlight({ + inFlight: false, + requestCount: 1, + gqlRefetchQuery, + refetchVariables, + }); + }); + + it('disposes ongoing request if it is manually disposed', () => { + renderFragment(); + renderSpy.mockClear(); + let disposable; + TestRenderer.act(() => { + disposable = refetch({id: '1'}, {fetchPolicy, renderPolicy}); + }); + + // Assert request is started + const refetchVariables = {id: '1', scale: 16}; + refetchQuery = createOperationDescriptor( + gqlRefetchQuery, + refetchVariables, + ); + expectRequestIsInFlight({ + inFlight: true, + requestCount: 1, + gqlRefetchQuery, + refetchVariables, + }); + + // Component renders immediately even though request is in flight + // since data is cached + const refetchingUser = { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', refetchQuery), + }; + expectFragmentResults([{data: refetchingUser}, {data: refetchingUser}]); + + disposable && disposable.dispose(); + + // Assert request was canceled + expect(unsubscribe).toBeCalledTimes(1); + expectRequestIsInFlight({ + inFlight: false, + requestCount: 1, + gqlRefetchQuery, + refetchVariables, + }); + }); + }); + + describe('when id variable has a different variable name in original query', () => { + beforeEach(() => { + const generated = generateAndCompile( + ` + fragment NestedUserFragment on User { + username + } + + fragment UserFragment on User + @refetchable(queryName: "UserFragmentRefetchQuery") { + id + name + profile_picture(scale: $scale) { + uri + } + ...NestedUserFragment + } + + query UserQuery($nodeID: ID!, $scale: Int!) { + node(id: $nodeID) { + ...UserFragment + } + } + `, + ); + variables = {nodeID: '1', scale: 16}; + gqlQuery = generated.UserQuery; + gqlRefetchQuery = generated.UserFragmentRefetchQuery; + gqlFragment = generated.UserFragment; + invariant( + gqlFragment.metadata?.refetch?.operation === + '@@MODULE_START@@UserFragmentRefetchQuery.graphql@@MODULE_END@@', + 'useRefetchableFragment-test: Expected refetchable fragment metadata to contain operation.', + ); + // Manually set the refetchable operation for the test. + gqlFragment.metadata.refetch.operation = gqlRefetchQuery; + + query = createOperationDescriptor(gqlQuery, variables); + refetchQuery = createOperationDescriptor(gqlRefetchQuery, variables); + + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + username: 'useralice', + profile_picture: null, + }, + }); + }); + + it('refetches new variables correctly when refetching new id', () => { + const renderer = renderFragment(); + const initialUser = { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', query), + }; + expectFragmentResults([ + { + data: initialUser, + }, + ]); + + TestRenderer.act(() => { + refetch({id: '4'}); + }); + + // Assert that fragment is refetching with the right variables and + // suspends upon refetch + const refetchVariables = { + id: '4', + scale: 16, + }; + refetchQuery = createOperationDescriptor( + gqlRefetchQuery, + refetchVariables, + ); + expectFragmentIsRefetching(renderer, { + refetchVariables, + refetchQuery, + }); + + // Mock network response + environment.mock.resolve(gqlRefetchQuery, { + data: { + node: { + __typename: 'User', + id: '4', + name: 'Mark', + profile_picture: { + uri: 'scale16', + }, + username: 'usermark', + }, + }, + }); + + // Assert fragment is rendered with new data + const refetchedUser = { + id: '4', + name: 'Mark', + profile_picture: { + uri: 'scale16', + }, + ...createFragmentRef('4', refetchQuery), + }; + expectFragmentResults([ + { + data: refetchedUser, + }, + { + data: refetchedUser, + }, + ]); + + // Assert refetch query was retained + expect(release).not.toBeCalled(); + expect(environment.retain).toBeCalledTimes(1); + expect(environment.retain.mock.calls[0][0]).toEqual(refetchQuery.root); + }); + + it('refetches new variables correctly when refetching same id', () => { + const renderer = renderFragment(); + const initialUser = { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', query), + }; + expectFragmentResults([ + { + data: initialUser, + }, + ]); + + TestRenderer.act(() => { + refetch({scale: 32}); + }); + + // Assert that fragment is refetching with the right variables and + // suspends upon refetch + const refetchVariables = { + id: '1', + scale: 32, + }; + refetchQuery = createOperationDescriptor( + gqlRefetchQuery, + refetchVariables, + ); + expectFragmentIsRefetching(renderer, { + refetchVariables, + refetchQuery, + }); + + // Mock network response + environment.mock.resolve(gqlRefetchQuery, { + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + profile_picture: { + uri: 'scale32', + }, + username: 'useralice', + }, + }, + }); + + // Assert fragment is rendered with new data + const refetchedUser = { + id: '1', + name: 'Alice', + profile_picture: { + uri: 'scale32', + }, + ...createFragmentRef('1', refetchQuery), + }; + expectFragmentResults([ + { + data: refetchedUser, + }, + { + data: refetchedUser, + }, + ]); + + // Assert refetch query was retained + expect(release).not.toBeCalled(); + expect(environment.retain).toBeCalledTimes(1); + expect(environment.retain.mock.calls[0][0]).toEqual(refetchQuery.root); + }); + }); + + describe('internal environment option', () => { + let newRelease; + let newEnvironment; + + beforeEach(() => { + ({createMockEnvironment} = require('relay-test-utils-internal')); + newEnvironment = createMockEnvironment(); + newRelease = jest.fn(); + newEnvironment.retain.mockImplementation((...args) => { + return { + dispose: newRelease, + }; + }); + }); + + it('reloads new data into new environment, and renders successfully', () => { + const renderer = renderFragment(); + const initialUser = { + id: '1', + name: 'Alice', + profile_picture: null, + ...createFragmentRef('1', query), + }; + // initial data on default environment + expectFragmentResults([{data: initialUser}]); + + TestRenderer.act(() => { + refetch( + {id: '1'}, + { + __environment: newEnvironment, + }, + ); + }); + const refetchVariables = { + id: '1', + scale: 16, + }; + refetchQuery = createOperationDescriptor( + gqlRefetchQuery, + refetchVariables, + ); + + // Fetch on newEnvironment + expectFragmentIsRefetching( + renderer, + { + refetchVariables, + refetchQuery, + }, + newEnvironment, + ); + + newEnvironment.mock.resolve(gqlRefetchQuery, { + data: { + node: { + __typename: 'User', + id: '1', + name: 'Mark', + username: 'usermark', + profile_picture: { + uri: 'scale16', + }, + }, + }, + }); + TestRenderer.act(() => jest.runAllImmediates()); + + // Data should be loaded on the newEnvironment + const dataInSource = { + __id: '1', + __typename: 'User', + 'profile_picture(scale:16)': { + __ref: 'client:1:profile_picture(scale:16)', + }, + id: '1', + name: 'Mark', + username: 'usermark', + }; + const source = newEnvironment.getStore().getSource(); + expect(source.get('1')).toEqual(dataInSource); + + // Assert refetch query was retained + expect(newRelease).not.toBeCalled(); + expect(newEnvironment.retain).toBeCalledTimes(1); + expect(newEnvironment.retain.mock.calls[0][0]).toEqual( + refetchQuery.root, + ); + + // Should be able to use the new data if switched to new environment + renderSpy.mockClear(); + newRelease.mockClear(); + TestRenderer.act(() => { + setEnvironment(newEnvironment); + }); + // refetch on the same newEnvironment after switching should not be reset + expect(release).not.toBeCalled(); + + const refetchedUser = { + id: '1', + name: 'Mark', + profile_picture: { + uri: 'scale16', + }, + ...createFragmentRef('1', refetchQuery), + }; + + expectFragmentResults([ + { + data: refetchedUser, + }, + { + data: refetchedUser, + }, + ]); + + // Refetch on another enironment afterwards should work + renderSpy.mockClear(); + environment.execute.mockClear(); + const anotherNewEnvironment = createMockEnvironment(); + TestRenderer.act(() => jest.runAllImmediates()); + + TestRenderer.act(() => { + refetch( + {id: '1'}, + { + __environment: anotherNewEnvironment, + }, + ); + }); + expectFragmentIsRefetching( + renderer, + { + refetchVariables, + refetchQuery, + }, + anotherNewEnvironment, + ); + + anotherNewEnvironment.mock.resolve(gqlRefetchQuery, { + data: { + node: { + __typename: 'User', + id: '1', + name: 'Mark', + username: 'usermark', + profile_picture: { + uri: 'scale16', + }, + }, + }, + }); + expect( + anotherNewEnvironment + .getStore() + .getSource() + .get('1'), + ).toEqual(dataInSource); + }); + }); + }); +}); diff --git a/packages/relay-experimental/__tests__/useStaticPropWarning-test.js b/packages/relay-experimental/__tests__/useStaticPropWarning-test.js new file mode 100644 index 000000000000..2ab714975e95 --- /dev/null +++ b/packages/relay-experimental/__tests__/useStaticPropWarning-test.js @@ -0,0 +1,49 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails oncall+relay + * @flow strict-local + * @format + */ + +'use strict'; + +const mockWarning = jest.fn(); +jest.mock('warning', () => mockWarning); + +const React = require('react'); +// $FlowFixMe +const TestRenderer = require('react-test-renderer'); + +const useStaticPropWarning = require('../useStaticPropWarning'); + +const warningMessage = + 'The %s has to remain the same over the lifetime of a component. Changing ' + + 'it is not supported and will result in unexpected behavior.'; +const notWarned = [true, warningMessage, 'prop foo']; +const warned = [false, warningMessage, 'prop foo']; + +function Example(props: {|+foo: string, +bar: string|}) { + useStaticPropWarning(props.foo, 'prop foo'); + return null; +} + +test('warn when a static prop changes', () => { + // initial render doesn't warn + const testRenderer = TestRenderer.create(); + expect(mockWarning.mock.calls.length).toBe(1); + expect(mockWarning.mock.calls[0]).toEqual(notWarned); + + // updating a non-checked prop doesn't warn + testRenderer.update(); + expect(mockWarning.mock.calls.length).toBe(2); + expect(mockWarning.mock.calls[1]).toEqual(notWarned); + + // updating a expected static prop warns + testRenderer.update(); + expect(mockWarning.mock.calls.length).toBe(3); + expect(mockWarning.mock.calls[2]).toEqual(warned); +}); diff --git a/packages/relay-experimental/fetchQuery.js b/packages/relay-experimental/fetchQuery.js new file mode 100644 index 000000000000..c2542c7779b2 --- /dev/null +++ b/packages/relay-experimental/fetchQuery.js @@ -0,0 +1,125 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 + */ + +'use strict'; + +const invariant = require('invariant'); + +const { + __internal: RelayRuntimeInternal, + createOperationDescriptor, + getRequest, +} = require('relay-runtime'); + +import type { + CacheConfig, + GraphQLTaggedNode, + IEnvironment, + Observable, + OperationType, +} from 'relay-runtime'; + +/** + * Fetches the given query and variables on the provided environment, + * and de-dupes identical in-flight requests. + * + * Observing a request: + * ==================== + * fetchQuery returns an Observable which you can call .subscribe() + * on. Subscribe optionally takes an Observer, which you can provide to + * observe network events: + * + * ``` + * fetchQuery(environment, query, variables).subscribe({ + * // Called when network requests starts + * start: (subsctiption) => {}, + * + * // Called after a payload is received and written to the local store + * next: (payload) => {}, + * + * // Called when network requests errors + * error: (error) => {}, + * + * // Called when network requests fully completes + * complete: () => {}, + * + * // Called when network request is unsubscribed + * unsubscribe: (subscription) => {}, + * }); + * ``` + * + * Request Promise: + * ================ + * The obervable can be converted to a Promise with .toPromise(), which will + * resolve to a snapshot of the query data when the first response is received + * from the server. + * + * ``` + * fetchQuery(environment, query, variables).then((data) => { + * // ... + * }); + * ``` + * + * In-flight request de-duping: + * ============================ + * By default, calling fetchQuery multiple times with the same + * environment, query and variables will not initiate a new request if a request + * for those same parameters is already in flight. + * + * A request is marked in-flight from the moment it starts until the moment it + * fully completes, regardless of error or successful completion. + * + * NOTE: If the request completes _synchronously_, calling fetchQuery + * a second time with the same arguments in the same tick will _NOT_ de-dupe + * the request given that it will no longer be in-flight. + * + * + * Data Retention: + * =============== + * This function will NOT retain query data, meaning that it is not guaranteed + * that the fetched data will remain in the Relay store after the request has + * completed. + * If you need to retain the query data outside of the network request, + * you need to use `environment.retain()`. + * + * + * Cancelling requests: + * ==================== + * If the disposable returned by subscribe is called while the + * request is in-flight, the request will be cancelled. + * + * ``` + * const disposable = fetchQuery(...).subscribe(...); + * + * // This will cancel the request if it is in-flight. + * disposable.dispose(); + * ``` + * NOTE: When using .toPromise(), the request cannot be cancelled. + */ +function fetchQuery( + environment: IEnvironment, + query: GraphQLTaggedNode, + variables: $ElementType, + options?: {| + networkCacheConfig?: CacheConfig, + |}, +): Observable<$ElementType> { + const queryNode = getRequest(query); + invariant( + queryNode.params.operationKind === 'query', + 'fetchQuery: Expected query operation', + ); + const operation = createOperationDescriptor(queryNode, variables); + return RelayRuntimeInternal.fetchQuery(environment, operation, options).map( + () => environment.lookup(operation.fragment).data, + ); +} + +module.exports = fetchQuery; diff --git a/packages/relay-experimental/getPaginationMetadata.js b/packages/relay-experimental/getPaginationMetadata.js new file mode 100644 index 000000000000..7a47db0facc0 --- /dev/null +++ b/packages/relay-experimental/getPaginationMetadata.js @@ -0,0 +1,67 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 + */ + +'use strict'; + +const getRefetchMetadata = require('./getRefetchMetadata'); +const invariant = require('invariant'); + +import type { + ConcreteRequest, + ReaderFragment, + ReaderPaginationMetadata, +} from 'relay-runtime'; + +function getPaginationMetadata( + fragmentNode: ReaderFragment, + componentDisplayName: string, +): {| + connectionPathInFragmentData: $ReadOnlyArray, + fragmentRefPathInResponse: $ReadOnlyArray, + paginationRequest: ConcreteRequest, + paginationMetadata: ReaderPaginationMetadata, + stream: boolean, +|} { + const { + refetchableRequest: paginationRequest, + fragmentRefPathInResponse, + refetchMetadata, + } = getRefetchMetadata(fragmentNode, componentDisplayName); + + const paginationMetadata = refetchMetadata.connection; + invariant( + paginationMetadata != null, + 'Relay: getPaginationMetadata(): Expected fragment `%s` to include a ' + + 'connection when using `%s`. Did you forget to add a @connection ' + + 'directive to the connection field in the fragment?', + componentDisplayName, + fragmentNode.name, + ); + const connectionPathInFragmentData = paginationMetadata.path; + + const connectionMetadata = (fragmentNode.metadata?.connection ?? [])[0]; + invariant( + connectionMetadata != null, + 'Relay: getPaginationMetadata(): Expected fragment `%s` to include a ' + + 'connection when using `%s`. Did you forget to add a @connection ' + + 'directive to the connection field in the fragment?', + componentDisplayName, + fragmentNode.name, + ); + return { + connectionPathInFragmentData, + fragmentRefPathInResponse, + paginationRequest, + paginationMetadata, + stream: connectionMetadata.stream === true, + }; +} + +module.exports = getPaginationMetadata; diff --git a/packages/relay-experimental/getPaginationVariables.js b/packages/relay-experimental/getPaginationVariables.js new file mode 100644 index 000000000000..4a62808a0561 --- /dev/null +++ b/packages/relay-experimental/getPaginationVariables.js @@ -0,0 +1,73 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 + */ + +'use strict'; + +const invariant = require('invariant'); + +import type {Direction} from './useLoadMoreFunction'; +import type {ReaderPaginationMetadata, Variables} from 'relay-runtime'; + +function getPaginationVariables( + direction: Direction, + count: number, + cursor: ?string, + parentVariables: Variables, + paginationMetadata: ReaderPaginationMetadata, +): {[string]: mixed} { + const { + backward: backwardMetadata, + forward: forwardMetadata, + } = paginationMetadata; + + if (direction === 'backward') { + invariant( + backwardMetadata != null && + backwardMetadata.count != null && + backwardMetadata.cursor != null, + 'Relay: Expected backward pagination metadata to be avialable. ' + + "If you're seeing this, this is likely a bug in Relay.", + ); + const paginationVariables = { + ...parentVariables, + [backwardMetadata.cursor]: cursor, + [backwardMetadata.count]: count, + }; + if (forwardMetadata && forwardMetadata.cursor) { + paginationVariables[forwardMetadata.cursor] = null; + } + if (forwardMetadata && forwardMetadata.count) { + paginationVariables[forwardMetadata.count] = null; + } + return paginationVariables; + } + + invariant( + forwardMetadata != null && + forwardMetadata.count != null && + forwardMetadata.cursor != null, + 'Relay: Expected forward pagination metadata to be avialable. ' + + "If you're seeing this, this is likely a bug in Relay.", + ); + const paginationVariables = { + ...parentVariables, + [forwardMetadata.cursor]: cursor, + [forwardMetadata.count]: count, + }; + if (backwardMetadata && backwardMetadata.cursor) { + paginationVariables[backwardMetadata.cursor] = null; + } + if (backwardMetadata && backwardMetadata.count) { + paginationVariables[backwardMetadata.count] = null; + } + return paginationVariables; +} + +module.exports = getPaginationVariables; diff --git a/packages/relay-experimental/getRefetchMetadata.js b/packages/relay-experimental/getRefetchMetadata.js new file mode 100644 index 000000000000..43b7adbef715 --- /dev/null +++ b/packages/relay-experimental/getRefetchMetadata.js @@ -0,0 +1,61 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 + */ + +'use strict'; + +const invariant = require('invariant'); + +import type { + ConcreteRequest, + ReaderFragment, + ReaderRefetchMetadata, +} from 'relay-runtime'; + +function getRefetchMetadata( + fragmentNode: ReaderFragment, + componentDisplayName: string, +): {| + refetchableRequest: ConcreteRequest, + fragmentRefPathInResponse: $ReadOnlyArray, + refetchMetadata: ReaderRefetchMetadata, +|} { + invariant( + fragmentNode.metadata?.plural !== true, + 'Relay: getRefetchMetadata(): Expected fragment `%s` not to be plural when using ' + + '`%s`. Remove `@relay(plural: true)` from fragment `%s` ' + + 'in order to use it with `%s`.', + fragmentNode.name, + componentDisplayName, + fragmentNode.name, + componentDisplayName, + ); + + const refetchMetadata = fragmentNode.metadata?.refetch; + invariant( + refetchMetadata != null, + 'Relay: getRefetchMetadata(): Expected fragment `%s` to be refetchable when using `%s`. ' + + 'Did you forget to add a @refetchable directive to the fragment?', + componentDisplayName, + fragmentNode.name, + ); + + const refetchableRequest = refetchMetadata.operation; + const fragmentRefPathInResponse = refetchMetadata.fragmentPathInResult; + invariant( + typeof refetchableRequest !== 'string', + 'Relay: getRefetchMetadata(): Expected refetch query to be an ' + + "operation and not a string when using `%s`. If you're seeing this, " + + 'this is likely a bug in Relay.', + componentDisplayName, + ); + return {refetchableRequest, fragmentRefPathInResponse, refetchMetadata}; +} + +module.exports = getRefetchMetadata; diff --git a/packages/relay-experimental/getValueAtPath.js b/packages/relay-experimental/getValueAtPath.js new file mode 100644 index 000000000000..ddc65ba74c45 --- /dev/null +++ b/packages/relay-experimental/getValueAtPath.js @@ -0,0 +1,43 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 + */ + +'use strict'; + +const invariant = require('invariant'); + +function getValueAtPath( + data: mixed, + path: $ReadOnlyArray, +): mixed { + let result = data; + for (const key of path) { + if (result == null) { + return null; + } + if (typeof key === 'number') { + invariant( + Array.isArray(result), + 'Relay: Expected an array when extracting value at path. ' + + "If you're seeing this, this is likely a bug in Relay.", + ); + result = result[key]; + } else { + invariant( + typeof result === 'object' && !Array.isArray(result), + 'Relay: Expected an object when extracting value at path. ' + + "If you're seeing this, this is likely a bug in Relay.", + ); + result = result[key]; + } + } + return result; +} + +module.exports = getValueAtPath; diff --git a/packages/relay-experimental/index.js b/packages/relay-experimental/index.js new file mode 100644 index 000000000000..e9c05052dcba --- /dev/null +++ b/packages/relay-experimental/index.js @@ -0,0 +1,42 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 + */ + +'use strict'; + +const MatchContainer = require('./MatchContainer'); +const RelayEnvironmentProvider = require('./RelayEnvironmentProvider'); + +const fetchQuery = require('./fetchQuery'); +const useBlockingPaginationFragment = require('./useBlockingPaginationFragment'); +const useFragment = require('./useFragment'); +const useLegacyPaginationFragment = require('./useLegacyPaginationFragment'); +const useQuery = require('./useQuery'); +const useRefetchableFragment = require('./useRefetchableFragment'); +const useRelayEnvironment = require('./useRelayEnvironment'); + +export type {MatchContainerProps, MatchPointer} from './MatchContainer'; +export type {FetchPolicy} from './QueryResource'; +export type {Direction, LoadMoreFn} from './useLoadMoreFunction'; +export type {RefetchFn} from './useRefetchableFragmentNode'; + +module.exports = { + MatchContainer: MatchContainer, + RelayEnvironmentProvider: RelayEnvironmentProvider, + + fetchQuery: fetchQuery, + + useQuery: useQuery, + useFragment: useFragment, + useBlockingPaginationFragment: useBlockingPaginationFragment, + usePaginationFragment: useLegacyPaginationFragment, + useRefetchableFragment: useRefetchableFragment, + useRelayEnvironment: useRelayEnvironment, + useLegacyPaginationFragment: useLegacyPaginationFragment, +}; diff --git a/packages/relay-experimental/package.json b/packages/relay-experimental/package.json new file mode 100644 index 000000000000..69890669171f --- /dev/null +++ b/packages/relay-experimental/package.json @@ -0,0 +1,24 @@ +{ + "name": "relay-experimental", + "description": "Contains unstable, experimental code", + "version": "5.0.0", + "keywords": [ + "graphql", + "relay" + ], + "license": "MIT", + "homepage": "https://relay.dev", + "bugs": "https://github.com/facebook/relay/issues", + "repository": "facebook/relay", + "dependencies": { + "@babel/runtime": "^7.0.0", + "fbjs": "^1.0.0", + "react-relay": "5.0.0", + "relay-runtime": "5.0.0" + }, + "directories": { + "": "./" + }, + "main": "index.js", + "haste_commonjs": true +} diff --git a/packages/relay-experimental/useBlockingPaginationFragment.js b/packages/relay-experimental/useBlockingPaginationFragment.js new file mode 100644 index 000000000000..2f90eb820db2 --- /dev/null +++ b/packages/relay-experimental/useBlockingPaginationFragment.js @@ -0,0 +1,272 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 + */ + +'use strict'; + +// flowlint untyped-import:off +const Scheduler = require('scheduler'); + +// flowlint untyped-import:error + +const getPaginationMetadata = require('./getPaginationMetadata'); +const invariant = require('invariant'); +const useLoadMoreFunction = require('./useLoadMoreFunction'); +const useRefetchableFragmentNode = require('./useRefetchableFragmentNode'); +const useStaticPropWarning = require('./useStaticPropWarning'); +const warning = require('warning'); + +const {useCallback, useEffect, useRef, useState} = require('react'); +const { + getFragment, + getFragmentIdentifier, + getFragmentOwner, +} = require('relay-runtime'); + +import type {LoadMoreFn, UseLoadMoreFunctionArgs} from './useLoadMoreFunction'; +import type {RefetchFnDynamic} from './useRefetchableFragmentNode'; +import type { + GraphQLResponse, + GraphQLTaggedNode, + Observer, + OperationType, +} from 'relay-runtime'; + +export type ReturnType = {| + data: TFragmentData, + loadNext: LoadMoreFn, + loadPrevious: LoadMoreFn, + hasNext: boolean, + hasPrevious: boolean, + refetch: RefetchFnDynamic, +|}; + +function useBlockingPaginationFragment< + TQuery: OperationType, + TKey: ?{+$data?: mixed}, +>( + fragmentInput: GraphQLTaggedNode, + parentFragmentRef: TKey, + componentDisplayName: string = 'useBlockingPaginationFragment()', +): ReturnType< + TQuery, + TKey, + // NOTE: This $Call ensures that the type of the returned data is either: + // - nullable if the provided ref type is nullable + // - non-nullable if the provided ref type is non-nullable + // prettier-ignore + $Call< + & (( {+$data?: TFragmentData}) => TFragmentData) + & ((?{+$data?: TFragmentData}) => ?TFragmentData), + TKey, + >, +> { + useStaticPropWarning( + fragmentInput, + `first argument of ${componentDisplayName}`, + ); + const fragmentNode = getFragment(fragmentInput); + + const { + connectionPathInFragmentData, + fragmentRefPathInResponse, + paginationRequest, + paginationMetadata, + stream, + } = getPaginationMetadata(fragmentNode, componentDisplayName); + invariant( + stream === false, + 'Relay: @stream_connection is not compatible with `useBlockingPaginationFragment`. ' + + 'Use `useStreamingPaginationFragment` instead.', + ); + + const { + fragmentData, + fragmentRef, + refetch, + disableStoreUpdates, + enableStoreUpdates, + } = useRefetchableFragmentNode( + fragmentNode, + parentFragmentRef, + componentDisplayName, + ); + const fragmentIdentifier = getFragmentIdentifier(fragmentNode, fragmentRef); + + // $FlowFixMe - TODO T39154660 Use FragmentPointer type instead of mixed + const fragmentOwner = getFragmentOwner(fragmentNode, fragmentRef); + + // Backward pagination + const [loadPrevious, hasPrevious, disposeFetchPrevious] = useLoadMore({ + direction: 'backward', + fragmentNode, + fragmentIdentifier, + fragmentOwner, + fragmentData, + connectionPathInFragmentData, + fragmentRefPathInResponse, + paginationRequest, + paginationMetadata, + disableStoreUpdates, + enableStoreUpdates, + componentDisplayName, + }); + + // Forward pagination + const [loadNext, hasNext, disposeFetchNext] = useLoadMore({ + direction: 'forward', + fragmentNode, + fragmentIdentifier, + fragmentOwner, + fragmentData, + connectionPathInFragmentData, + fragmentRefPathInResponse, + paginationRequest, + paginationMetadata, + disableStoreUpdates, + enableStoreUpdates, + componentDisplayName, + }); + + const refetchPagination: RefetchFnDynamic = useCallback( + (variables, options) => { + disposeFetchNext(); + disposeFetchPrevious(); + return refetch(variables, {...options, __environment: undefined}); + }, + [disposeFetchNext, disposeFetchPrevious, refetch], + ); + + return { + data: fragmentData, + loadNext, + loadPrevious, + hasNext, + hasPrevious, + refetch: refetchPagination, + }; +} + +function useLoadMore(args: {| + disableStoreUpdates: () => void, + enableStoreUpdates: () => void, + ...$Exact< + $Diff< + UseLoadMoreFunctionArgs, + {observer: Observer, onReset: () => void}, + >, + >, +|}): [LoadMoreFn, boolean, () => void] { + const {disableStoreUpdates, enableStoreUpdates, ...loadMoreArgs} = args; + const [requestPromise, setRequestPromise] = useState(null); + const requestPromiseRef = useRef(null); + const promiseResolveRef = useRef(null); + + const promiseResolve = () => { + if (promiseResolveRef.current != null) { + promiseResolveRef.current(); + promiseResolveRef.current = null; + } + }; + + const handleReset = () => { + promiseResolve(); + }; + + const observer = { + complete: promiseResolve, + // NOTE: loadMore is a no-op if a request is already in flight, so we + // can safely assume that `start` will only be called once while a + // request is in flight. + start: () => { + // NOTE: We disable store updates when we suspend to ensure + // that higher-pri updates from the Relay store don't disrupt + // any Suspense timeouts passed via withSuspenseConfig. + disableStoreUpdates(); + + const promise = new Promise(resolve => { + promiseResolveRef.current = () => { + requestPromiseRef.current = null; + resolve(); + }; + }); + requestPromiseRef.current = promise; + setRequestPromise(promise); + }, + + // NOTE: Since streaming is disallowed with this hook, this means that the + // first payload will always contain the entire next page of items, + // while subsequent paylaods will contain @defer'd payloads. + // This allows us to unsuspend here, on the first payload, and allow + // descendant components to suspend on their respective @defer payloads + next: promiseResolve, + + // TODO: Handle error; we probably don't want to throw an error + // and blow away the whole list of items. + error: promiseResolve, + }; + const [_loadMore, hasMore, disposeFetch] = useLoadMoreFunction({ + ...loadMoreArgs, + observer, + onReset: handleReset, + }); + + // NOTE: To determine if we need to suspend, we check that the promise in + // state is the same as the promise on the ref, which ensures that we + // wont incorrectly suspend on other higher-pri updates before the update + // to suspend has committed. + if (requestPromise != null && requestPromise === requestPromiseRef.current) { + throw requestPromise; + } + + useEffect(() => { + if (requestPromise == null) { + // NOTE: After suspense pagination has resolved, we re-enable store updates + // for this fragment. This may cause the component to re-render if + // we missed any updates to the fragment data other than the pagination update. + enableStoreUpdates(); + } + // NOTE: We know the identity of enableStoreUpdates wont change + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [requestPromise]); + + const loadMore = useCallback( + (...callArgs) => { + if ( + Scheduler.unstable_getCurrentPriorityLevel() < + Scheduler.unstable_NormalPriority + ) { + warning( + false, + 'Relay: Unexpected call to `%s` at a priority higher than ' + + 'expected on fragment `%s` in `%s`. It looks like you tried to ' + + 'call `refetch` under a high priority update, but updates that ' + + 'can cause the component to suspend should be scheduled at ' + + 'normal priority. Make sure you are calling `refetch` inside ' + + '`startTransition()` from the `useSuspenseTransition()` hook.', + args.direction === 'forward' ? 'loadNext' : 'loadPrevious', + args.fragmentNode.name, + args.componentDisplayName, + ); + } + + return _loadMore(...callArgs); + }, + [ + _loadMore, + args.componentDisplayName, + args.direction, + args.fragmentNode.name, + ], + ); + + return [loadMore, hasMore, disposeFetch]; +} + +module.exports = useBlockingPaginationFragment; diff --git a/packages/relay-experimental/useFetchTrackingRef.js b/packages/relay-experimental/useFetchTrackingRef.js new file mode 100644 index 000000000000..27311869cf77 --- /dev/null +++ b/packages/relay-experimental/useFetchTrackingRef.js @@ -0,0 +1,67 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 + */ + +'use strict'; + +const {useCallback, useEffect, useRef} = require('react'); + +import type {Subscription} from 'relay-runtime'; + +/** + * This hook returns a mutable React ref that holds the value of whether a + * fetch request is in flight. The reason this is a mutable ref instead of + * state is because we don't actually want to trigger an update when this + * changes, but instead synchronously keep track of whether the network request + * is in flight, for example in order to bail out of a request if one is + * already in flight. If this was state, due to the nature of concurrent + * updates, this value wouldn't be in sync with when the request is actually + * in flight. + * The additional functions returned by this Hook can be used to mutate + * the ref. + */ +function useFetchTrackingRef(): {| + isFetchingRef: {current: ?boolean}, + startFetch: Subscription => void, + disposeFetch: () => void, + completeFetch: () => void, +|} { + const subscriptionRef = useRef(null); + const isFetchingRef = useRef(false); + + const disposeFetch = useCallback(() => { + if (subscriptionRef.current != null) { + subscriptionRef.current.unsubscribe(); + subscriptionRef.current = null; + } + isFetchingRef.current = false; + }, []); + + const startFetch = useCallback( + (subscription: Subscription) => { + // Dispose of fetch subscription in flight before starting a new one + disposeFetch(); + subscriptionRef.current = subscription; + isFetchingRef.current = true; + }, + [disposeFetch], + ); + + const completeFetch = useCallback(() => { + subscriptionRef.current = null; + isFetchingRef.current = false; + }, []); + + // Dipose of ongoing fetch on unmount + useEffect(() => disposeFetch, [disposeFetch]); + + return {isFetchingRef, startFetch, disposeFetch, completeFetch}; +} + +module.exports = useFetchTrackingRef; diff --git a/packages/relay-experimental/useFragment.js b/packages/relay-experimental/useFragment.js new file mode 100644 index 000000000000..6ea0eb91ccf7 --- /dev/null +++ b/packages/relay-experimental/useFragment.js @@ -0,0 +1,63 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 + */ + +'use strict'; + +const useFragmentNode = require('./useFragmentNode'); +const useStaticPropWarning = require('./useStaticPropWarning'); + +const {getFragment} = require('relay-runtime'); + +import type {GraphQLTaggedNode} from 'relay-runtime'; + +// NOTE: These declares ensure that the type of the returned data is: +// - non-nullable if the provided ref type is non-nullable +// - nullable if the provided ref type is nullable +// - array of non-nullable if the privoided ref type is an array of +// non-nullable refs +// - array of nullable if the privoided ref type is an array of nullable refs + +declare function useFragment( + fragmentInput: GraphQLTaggedNode, + fragmentRef: TKey, +): $Call<({+$data?: TFragmentData}) => TFragmentData, TKey>; + +declare function useFragment( + fragmentInput: GraphQLTaggedNode, + fragmentRef: TKey, +): $Call<(?{+$data?: TFragmentData}) => ?TFragmentData, TKey>; + +declare function useFragment>( + fragmentInput: GraphQLTaggedNode, + fragmentRef: TKey, +): $Call< + ($ReadOnlyArray<{+$data?: TFragmentData}>) => TFragmentData, + TKey, +>; + +declare function useFragment>( + fragmentInput: GraphQLTaggedNode, + fragmentRef: TKey, +): $Call< + ($ReadOnlyArray) => ?TFragmentData, + TKey, +>; + +function useFragment( + fragmentInput: GraphQLTaggedNode, + fragmentRef: mixed, +): mixed { + useStaticPropWarning(fragmentInput, 'first argument of useFragment()'); + const fragmentNode = getFragment(fragmentInput); + const {data} = useFragmentNode<_>(fragmentNode, fragmentRef, 'useFragment()'); + return data; +} + +module.exports = useFragment; diff --git a/packages/relay-experimental/useFragmentNode.js b/packages/relay-experimental/useFragmentNode.js new file mode 100644 index 000000000000..6d25e49f594c --- /dev/null +++ b/packages/relay-experimental/useFragmentNode.js @@ -0,0 +1,51 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 + */ + +'use strict'; + +import type {ReaderFragment} from 'relay-runtime'; + +type ReturnType = {| + data: TFragmentData, + disableStoreUpdates: () => void, + enableStoreUpdates: () => void, + shouldUpdateGeneration: number | null, +|}; + +const {useMemo} = require('react'); + +const useFragmentNodes = require('./useFragmentNodes'); + +function useFragmentNode( + fragmentNode: ReaderFragment, + fragmentRef: mixed, + containerDisplayName: string, +): ReturnType { + const fragmentNodes = useMemo(() => ({result: fragmentNode}), [fragmentNode]); + const fragmentRefs = useMemo(() => ({result: fragmentRef}), [fragmentRef]); + + const { + data, + disableStoreUpdates, + enableStoreUpdates, + shouldUpdateGeneration, + } = useFragmentNodes<{| + result: TFragmentData, + |}>(fragmentNodes, fragmentRefs, containerDisplayName); + + return { + data: data.result, + disableStoreUpdates, + enableStoreUpdates, + shouldUpdateGeneration, + }; +} + +module.exports = useFragmentNode; diff --git a/packages/relay-experimental/useFragmentNodes.js b/packages/relay-experimental/useFragmentNodes.js new file mode 100644 index 000000000000..5c48058be5bd --- /dev/null +++ b/packages/relay-experimental/useFragmentNodes.js @@ -0,0 +1,228 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 + */ + +'use strict'; + +const mapObject = require('mapObject'); +const useRelayEnvironment = require('./useRelayEnvironment'); +const warning = require('warning'); + +const {getFragmentResourceForEnvironment} = require('./FragmentResource'); +const {useEffect, useRef, useState} = require('react'); +const { + RelayProfiler, + getFragmentSpecIdentifier, + isScalarAndEqual, +} = require('relay-runtime'); + +import type {ReaderFragment} from 'relay-runtime'; + +type ReturnType = {| + data: TFragmentSpec, + disableStoreUpdates: () => void, + enableStoreUpdates: () => void, + shouldUpdateGeneration: number | null, +|}; + +function useFragmentNodes( + fragmentNodes: {[key: string]: ReaderFragment}, + props: {[key: string]: mixed}, + componentDisplayName: string, +): ReturnType { + const environment = useRelayEnvironment(); + const FragmentResource = getFragmentResourceForEnvironment(environment); + + const isMountedRef = useRef(false); + const [_, forceUpdate] = useState(0); + const fragmentSpecIdentifier = getFragmentSpecIdentifier( + fragmentNodes, + props, + ); + + // The values of these React refs are counters that should be incremented + // under their respective conditions. This allows us to use the counters as + // memoization values to indicate if computations for useMemo or useEffect + // should be re-executed. + const mustResubscribeGenerationRef = useRef(0); + const shouldUpdateGenerationRef = useRef(0); + + // We mirror the environment to check if it has changed between renders + const [mirroredEnvironment, setMirroredEnvironment] = useState(environment); + const environmentChanged = mirroredEnvironment !== environment; + + // We mirror the fragmentSpec identifier to check if it has changed between + // renders + const [ + mirroredFragmentSpecIdentifier, + setMirroredFragmentSpecIdentifier, + ] = useState(fragmentSpecIdentifier); + const fragmentSpecIdentifierChanged = + mirroredFragmentSpecIdentifier !== fragmentSpecIdentifier; + + // If the fragment identifier changes, it means that the variables on the + // fragment owner changed, or the fragment refs point to different records. + // In this case, we need to resubscribe to the Relay store. + const mustResubscribe = environmentChanged || fragmentSpecIdentifierChanged; + + // We mirror the props to check if they have changed between renders + const [mirroredProps, setMirroredProps] = useState(props); + + // `props` contains both fragment refs and regular component + // props, so we extract here the props that aren't fragment refs. + // TODO(T38931859) This can be simplified if we use named fragment refs + const nonFragmentRefPropKeys = Object.keys(props).filter( + key => !fragmentNodes.hasOwnProperty(key), + ); + const nonFragmentRefPropsChanged = nonFragmentRefPropKeys.some( + key => mirroredProps[key] !== props[key], + ); + const scalarNonFragmentRefPropsChanged = nonFragmentRefPropKeys.some( + key => !isScalarAndEqual(mirroredProps[key], props[key]), + ); + + // We only want to update the component consuming this fragment under the + // following circumstances: + // - We receive an update from the Relay store, indicating that the data + // the component is directly subscribed to has changed. + // - We need to subscribe and render /different/ data (i.e. the fragment refs + // now point to different records, or the context changed). + // Note that even if identity of the fragment ref objects changes, we + // don't consider them as different unless they point to a different data ID. + // - Any props that are /not/ fragment refs have changed. + // + // This prevents unnecessary updates when a parent re-renders this component + // with the same props, which is a common case when the parent updates due + // to change in the data /it/ is subscribed to, but which doesn't affect the + // child. + const shouldUpdate = mustResubscribe || scalarNonFragmentRefPropsChanged; + + if (shouldUpdate) { + shouldUpdateGenerationRef.current = + (shouldUpdateGenerationRef.current ?? 0) + 1; + } + + if (mustResubscribe) { + mustResubscribeGenerationRef.current = + (mustResubscribeGenerationRef.current ?? 0) + 1; + if (environmentChanged) { + setMirroredEnvironment(environment); + } + if (fragmentSpecIdentifierChanged) { + setMirroredFragmentSpecIdentifier(fragmentSpecIdentifier); + } + } + + // Since `props` contains both fragment refs and regular props, we need to + // ensure we keep the mirrored version in sync if non fragment ref props + // change , to be able to compare them between renders + if (nonFragmentRefPropsChanged) { + setMirroredProps(props); + } + + // Read fragment data; this might suspend. + const fragmentSpecResult = FragmentResource.readSpec( + fragmentNodes, + props, + componentDisplayName, + ); + + const isListeningForUpdatesRef = useRef(true); + function enableStoreUpdates() { + isListeningForUpdatesRef.current = true; + const didMissUpdates = FragmentResource.checkMissedUpdatesSpec( + fragmentSpecResult, + ); + if (didMissUpdates) { + handleDataUpdate(); + } + } + + function disableStoreUpdates() { + isListeningForUpdatesRef.current = false; + } + + function handleDataUpdate() { + if ( + isMountedRef.current === false || + isListeningForUpdatesRef.current === false + ) { + return; + } + + // If we receive an update from the Relay store, we need to make sure the + // consuming component updates. + shouldUpdateGenerationRef.current = + (shouldUpdateGenerationRef.current ?? 0) + 1; + + // React bails out on noop state updates as an optimization. + // If we want to force an update via setState, we need to pass an value. + // The actual value can be arbitrary though, e.g. an incremented number. + forceUpdate(count => count + 1); + } + + // Establish Relay store subscriptions in the commit phase, only if + // rendering for the first time, or if we need to subscribe to new data + useEffect(() => { + isMountedRef.current = true; + const disposable = FragmentResource.subscribeSpec( + fragmentSpecResult, + handleDataUpdate, + ); + + return () => { + // When unmounting or resubscribing to new data, clean up current + // subscription. This will also make sure fragment data is no longer + // cached for the so next time it its read, it will be read fresh from the + // Relay store + isMountedRef.current = false; + disposable.dispose(); + }; + // NOTE: We disable react-hooks-deps warning because mustResubscribeGenerationRef + // is capturing all information about whether the effect should be re-ran. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mustResubscribeGenerationRef.current]); + + const data = mapObject(fragmentSpecResult, (result, key) => { + if (__DEV__) { + if (props[key] != null && result.data == null) { + const fragmentName = fragmentNodes[key]?.name ?? 'Unknown fragment'; + warning( + false, + 'Relay: Expected to have been able to read non-null data for ' + + 'fragment `%s` declared in ' + + '`%s`, since fragment reference was non-null. ' + + "Make sure that that `%s`'s parent isn't " + + 'holding on to and/or passing a fragment reference for data that ' + + 'has been deleted.', + fragmentName, + componentDisplayName, + componentDisplayName, + ); + } + } + return result.data; + }); + return { + // $FlowFixMe + data, + disableStoreUpdates, + enableStoreUpdates, + shouldUpdateGeneration: shouldUpdateGenerationRef.current, + }; +} + +module.exports = (RelayProfiler.instrument( + 'useFragmentNodes', + useFragmentNodes, +): ( + fragmentNodes: {[key: string]: ReaderFragment}, + props: {[key: string]: mixed}, + componentDisplayName: string, +) => ReturnType); diff --git a/packages/relay-experimental/useIsMountedRef.js b/packages/relay-experimental/useIsMountedRef.js new file mode 100644 index 000000000000..2d5f319256d3 --- /dev/null +++ b/packages/relay-experimental/useIsMountedRef.js @@ -0,0 +1,27 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 + */ + +'use strict'; + +const {useEffect, useRef} = require('react'); + +function useIsMountedRef(): {current: ?boolean} { + const isMountedRef = useRef(true); + + useEffect(() => { + return () => { + isMountedRef.current = false; + }; + }, []); + + return isMountedRef; +} + +module.exports = useIsMountedRef; diff --git a/packages/relay-experimental/useIsParentQueryInFlight.js b/packages/relay-experimental/useIsParentQueryInFlight.js new file mode 100644 index 000000000000..dd45d90f5394 --- /dev/null +++ b/packages/relay-experimental/useIsParentQueryInFlight.js @@ -0,0 +1,75 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 + */ + +'use strict'; + +const React = require('react'); + +const invariant = require('invariant'); +const useRelayEnvironment = require('./useRelayEnvironment'); +const useStaticPropWarning = require('./useStaticPropWarning'); + +const { + __internal: {getObservableForRequestInFlight}, + getFragment, + getFragmentOwner, +} = require('relay-runtime'); + +import type {GraphQLTaggedNode} from 'relay-runtime'; + +const {useEffect, useState, useMemo} = React; + +function useIsParentQueryInFlight( + fragmentInput: GraphQLTaggedNode, + fragmentRef: TKey, +): boolean { + const environment = useRelayEnvironment(); + useStaticPropWarning( + fragmentInput, + 'first argument of useIsParentQueryInFlight()', + ); + const fragmentNode = getFragment(fragmentInput); + const observable = useMemo(() => { + // $FlowFixMe - TODO T39154660 Use FragmentPointer type instead of mixed + const fragmentOwnerOrOwners = getFragmentOwner(fragmentNode, fragmentRef); + if (fragmentOwnerOrOwners == null) { + return null; + } + invariant( + !Array.isArray(fragmentOwnerOrOwners), + 'useIsParentQueryInFlight: Plural fragments are not supported.', + ); + return getObservableForRequestInFlight(environment, fragmentOwnerOrOwners); + }, [environment, fragmentNode, fragmentRef]); + const [isInFlight, setIsInFlight] = useState(observable != null); + + useEffect(() => { + let subscription; + setIsInFlight(observable != null); + if (observable != null) { + const onCompleteOrError = () => { + setIsInFlight(false); + }; + subscription = observable.subscribe({ + complete: onCompleteOrError, + error: onCompleteOrError, + }); + } + return () => { + if (subscription) { + subscription.unsubscribe(); + } + }; + }, [observable]); + + return isInFlight; +} + +module.exports = useIsParentQueryInFlight; diff --git a/packages/relay-experimental/useLegacyPaginationFragment.js b/packages/relay-experimental/useLegacyPaginationFragment.js new file mode 100644 index 000000000000..85fe6425a3c1 --- /dev/null +++ b/packages/relay-experimental/useLegacyPaginationFragment.js @@ -0,0 +1,162 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 + */ + +'use strict'; + +const getPaginationMetadata = require('./getPaginationMetadata'); +const useLoadMoreFunction = require('./useLoadMoreFunction'); +const useRefetchableFragmentNode = require('./useRefetchableFragmentNode'); +const useStaticPropWarning = require('./useStaticPropWarning'); + +const {useCallback, useState} = require('react'); +const { + getFragment, + getFragmentIdentifier, + getFragmentOwner, +} = require('relay-runtime'); + +import type {LoadMoreFn, UseLoadMoreFunctionArgs} from './useLoadMoreFunction'; +import type {RefetchFnDynamic} from './useRefetchableFragmentNode'; +import type { + GraphQLResponse, + GraphQLTaggedNode, + Observer, + OperationType, +} from 'relay-runtime'; + +export type ReturnType = {| + data: TFragmentData, + loadNext: LoadMoreFn, + loadPrevious: LoadMoreFn, + hasNext: boolean, + hasPrevious: boolean, + isLoadingNext: boolean, + isLoadingPrevious: boolean, + refetch: RefetchFnDynamic, +|}; + +function useLegacyPaginationFragment< + TQuery: OperationType, + TKey: ?{+$data?: mixed}, +>( + fragmentInput: GraphQLTaggedNode, + parentFragmentRef: TKey, +): ReturnType< + TQuery, + TKey, + // NOTE: This $Call ensures that the type of the returned data is either: + // - nullable if the provided ref type is nullable + // - non-nullable if the provided ref type is non-nullable + // prettier-ignore + $Call< + & (( {+$data?: TFragmentData}) => TFragmentData) + & ((?{+$data?: TFragmentData}) => ?TFragmentData), + TKey, + >, +> { + useStaticPropWarning( + fragmentInput, + 'first argument of useLegacyPaginationFragment()', + ); + const componentDisplayName = 'useLegacyPaginationFragment()'; + const fragmentNode = getFragment(fragmentInput); + + const { + connectionPathInFragmentData, + fragmentRefPathInResponse, + paginationRequest, + paginationMetadata, + } = getPaginationMetadata(fragmentNode, componentDisplayName); + + const {fragmentData, fragmentRef, refetch} = useRefetchableFragmentNode< + TQuery, + TKey, + >(fragmentNode, parentFragmentRef, componentDisplayName); + const fragmentIdentifier = getFragmentIdentifier(fragmentNode, fragmentRef); + + // $FlowFixMe - TODO T39154660 Use FragmentPointer type instead of mixed + const fragmentOwner = getFragmentOwner(fragmentNode, fragmentRef); + + // Backward pagination + const [ + loadPrevious, + hasPrevious, + isLoadingPrevious, + disposeFetchPrevious, + ] = useLoadMore({ + direction: 'backward', + fragmentNode, + fragmentIdentifier, + fragmentOwner, + fragmentData, + connectionPathInFragmentData, + fragmentRefPathInResponse, + paginationRequest, + paginationMetadata, + componentDisplayName, + }); + + // Forward pagination + const [loadNext, hasNext, isLoadingNext, disposeFetchNext] = useLoadMore({ + direction: 'forward', + fragmentNode, + fragmentIdentifier, + fragmentOwner, + fragmentData, + connectionPathInFragmentData, + fragmentRefPathInResponse, + paginationRequest, + paginationMetadata, + componentDisplayName, + }); + + const refetchPagination: RefetchFnDynamic = useCallback( + (variables, options) => { + disposeFetchNext(); + disposeFetchPrevious(); + return refetch(variables, {...options, __environment: undefined}); + }, + [disposeFetchNext, disposeFetchPrevious, refetch], + ); + + return { + data: fragmentData, + loadNext, + loadPrevious, + hasNext, + hasPrevious, + isLoadingNext, + isLoadingPrevious, + refetch: refetchPagination, + }; +} + +function useLoadMore( + args: $Diff< + UseLoadMoreFunctionArgs, + {observer: Observer, onReset: () => void}, + >, +): [LoadMoreFn, boolean, boolean, () => void] { + const [isLoadingMore, setIsLoadingMore] = useState(false); + const observer = { + start: () => setIsLoadingMore(true), + complete: () => setIsLoadingMore(false), + error: () => setIsLoadingMore(false), + }; + const handleReset = () => setIsLoadingMore(false); + const [loadMore, hasMore, disposeFetch] = useLoadMoreFunction({ + ...args, + observer, + onReset: handleReset, + }); + return [loadMore, hasMore, isLoadingMore, disposeFetch]; +} + +module.exports = useLegacyPaginationFragment; diff --git a/packages/relay-experimental/useLoadMoreFunction.js b/packages/relay-experimental/useLoadMoreFunction.js new file mode 100644 index 000000000000..bc11c8ac2574 --- /dev/null +++ b/packages/relay-experimental/useLoadMoreFunction.js @@ -0,0 +1,333 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 + */ + +'use strict'; + +// flowlint untyped-import:off +const Scheduler = require('scheduler'); + +// flowlint untyped-import:error + +const getPaginationVariables = require('./getPaginationVariables'); +const getValueAtPath = require('./getValueAtPath'); +const invariant = require('invariant'); +const useFetchTrackingRef = require('./useFetchTrackingRef'); +const useIsMountedRef = require('./useIsMountedRef'); +const useRelayEnvironment = require('./useRelayEnvironment'); +// flowlint untyped-import:off +const warning = require('warning'); + +// flowlint untyped-import:error + +const {useCallback, useEffect, useState} = require('react'); +const { + ConnectionInterface, + __internal: {fetchQuery, hasRequestInFlight}, + createOperationDescriptor, +} = require('relay-runtime'); + +import type { + ConcreteRequest, + Disposable, + GraphQLResponse, + Observer, + ReaderFragment, + ReaderPaginationMetadata, + RequestDescriptor, +} from 'relay-runtime'; + +export type Direction = 'forward' | 'backward'; + +export type LoadMoreFn = ( + count: number, + options?: {| + onComplete?: (Error | null) => void, + |}, +) => Disposable; + +export type UseLoadMoreFunctionArgs = {| + direction: Direction, + fragmentNode: ReaderFragment, + fragmentIdentifier: string, + fragmentOwner: ?RequestDescriptor | $ReadOnlyArray, + fragmentData: mixed, + connectionPathInFragmentData: $ReadOnlyArray, + fragmentRefPathInResponse: $ReadOnlyArray, + paginationRequest: ConcreteRequest, + paginationMetadata: ReaderPaginationMetadata, + componentDisplayName: string, + observer: Observer, + onReset: () => void, +|}; + +function useLoadMoreFunction( + args: UseLoadMoreFunctionArgs, +): [LoadMoreFn, boolean, () => void] { + const { + direction, + fragmentNode, + fragmentIdentifier, + fragmentOwner, + fragmentData, + connectionPathInFragmentData, + fragmentRefPathInResponse, + paginationRequest, + paginationMetadata, + componentDisplayName, + observer, + onReset, + } = args; + const environment = useRelayEnvironment(); + const { + isFetchingRef, + startFetch, + disposeFetch, + completeFetch, + } = useFetchTrackingRef(); + // $FlowFixMe + const dataID = fragmentData?.id; + const isMountedRef = useIsMountedRef(); + const [mirroredEnvironment, setMirroredEnvironment] = useState(environment); + const [mirroredFragmentIdentifier, setMirroredFragmentIdentifier] = useState( + fragmentIdentifier, + ); + + const shouldReset = + environment !== mirroredEnvironment || + fragmentIdentifier !== mirroredFragmentIdentifier; + if (shouldReset) { + disposeFetch(); + onReset(); + setMirroredEnvironment(environment); + setMirroredFragmentIdentifier(fragmentIdentifier); + } + + const {cursor, hasMore} = getConnectionState( + direction, + fragmentNode, + fragmentData, + connectionPathInFragmentData, + ); + + // Dispose of pagination requests in flight when unmounting + useEffect(() => { + return () => { + disposeFetch(); + }; + }, [disposeFetch]); + + const loadMore = useCallback( + (count, options) => { + // TODO(T41131846): Fetch/Caching policies for loadMore + // TODO(T41140071): Handle loadMore while refetch is in flight and vice-versa + + const onComplete = options?.onComplete; + if (isMountedRef.current !== true) { + // Bail out and warn if we're trying to paginate after the component + // has unmounted + warning( + false, + 'Relay: Unexpected fetch 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: () => {}}; + } + + const isParentQueryInFlight = + fragmentOwner != null && + !Array.isArray(fragmentOwner) && + hasRequestInFlight(environment, fragmentOwner); + if (isFetchingRef.current === true || !hasMore || isParentQueryInFlight) { + if (fragmentOwner == null) { + warning( + false, + 'Relay: Unexpected fetch while using a null fragment ref ' + + 'for fragment `%s` in `%s`. When fetching more items, we expect ' + + "initial fragment data to be non-null. Please make sure you're " + + 'passing a valid fragment ref to `%s` before paginating.', + fragmentNode.name, + componentDisplayName, + componentDisplayName, + ); + } + + if (onComplete) { + // We make sure to always call onComplete asynchronously to prevent + // accidental loops in product code. + Scheduler.unstable_next(() => onComplete(null)); + } + return {dispose: () => {}}; + } + + invariant( + fragmentOwner != null && !Array.isArray(fragmentOwner), + 'Relay: Expected to be able to find a non-plural fragment owner for ' + + "fragment `%s` when using `%s`. If you're seeing this, " + + 'this is likely a bug in Relay.', + fragmentNode.name, + componentDisplayName, + ); + + const parentVariables = fragmentOwner.variables; + const paginationVariables = getPaginationVariables( + direction, + count, + cursor, + parentVariables, + paginationMetadata, + ); + + // TODO (T40777961): Tweak output of @refetchable transform to more + // easily tell if we need an $id in the refetch vars + if (fragmentRefPathInResponse.includes('node')) { + // @refetchable fragments are guaranteed to have an `id` selection + // if the type is Node or implements Node. Double-check that there + // actually is a value at runtime. + if (typeof dataID !== 'string') { + warning( + false, + 'Relay: Expected result to have a string ' + + '`id` in order to refetch/paginate, got `%s`.', + dataID, + ); + } + paginationVariables.id = dataID; + } + + const paginationQuery = createOperationDescriptor( + paginationRequest, + paginationVariables, + ); + fetchQuery(environment, paginationQuery, { + networkCacheConfig: {force: true}, + }).subscribe({ + ...observer, + start: subscription => { + startFetch(subscription); + observer.start && observer.start(subscription); + }, + complete: () => { + completeFetch(); + observer.complete && observer.complete(); + onComplete && onComplete(null); + }, + error: error => { + completeFetch(); + observer.error && observer.error(error); + onComplete && onComplete(error); + }, + }); + return {dispose: disposeFetch}; + }, + // NOTE: We disable react-hooks-deps warning because all values + // inside paginationMetadata are static + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + environment, + dataID, + direction, + cursor, + hasMore, + startFetch, + disposeFetch, + completeFetch, + isFetchingRef, + fragmentNode.name, + fragmentOwner, + componentDisplayName, + ], + ); + return [loadMore, hasMore, disposeFetch]; +} + +function getConnectionState( + direction: Direction, + fragmentNode: ReaderFragment, + fragmentData: mixed, + connectionPathInFragmentData: $ReadOnlyArray, +): {| + cursor: ?string, + hasMore: boolean, +|} { + const { + EDGES, + PAGE_INFO, + HAS_NEXT_PAGE, + HAS_PREV_PAGE, + END_CURSOR, + START_CURSOR, + } = ConnectionInterface.get(); + const connection = getValueAtPath(fragmentData, connectionPathInFragmentData); + if (connection == null) { + return {cursor: null, hasMore: false}; + } + + invariant( + typeof connection === 'object', + 'Relay: Expected connection in fragment `%s` to have been `null`, or ' + + 'a plain object with %s and %s properties. Instead got `%s`.', + fragmentNode.name, + EDGES, + PAGE_INFO, + connection, + ); + + const edges = connection[EDGES]; + const pageInfo = connection[PAGE_INFO]; + if (edges == null || pageInfo == null) { + return {cursor: null, hasMore: false}; + } + + invariant( + Array.isArray(edges), + 'Relay: Expected connection in fragment `%s` to have a plural `%s` field. ' + + 'Instead got `%s`.', + fragmentNode.name, + EDGES, + edges, + ); + invariant( + typeof pageInfo === 'object', + 'Relay: Expected connection in fragment `%s` to have a `%s` field. ' + + 'Instead got `%s`.', + fragmentNode.name, + PAGE_INFO, + pageInfo, + ); + + const cursor = + direction === 'forward' + ? pageInfo[END_CURSOR] ?? null + : pageInfo[START_CURSOR] ?? null; + invariant( + cursor === null || typeof cursor === 'string', + 'Relay: Expected page info for connection in fragment `%s` to have a ' + + 'valid `%s`. Instead got `%s`.', + fragmentNode.name, + START_CURSOR, + cursor, + ); + + let hasMore; + if (direction === 'forward') { + hasMore = cursor != null && pageInfo[HAS_NEXT_PAGE] === true; + } else { + hasMore = cursor != null && pageInfo[HAS_PREV_PAGE] === true; + } + + return {cursor, hasMore}; +} + +module.exports = useLoadMoreFunction; diff --git a/packages/relay-experimental/useMemoOperationDescriptor.js b/packages/relay-experimental/useMemoOperationDescriptor.js new file mode 100644 index 000000000000..4f3536329345 --- /dev/null +++ b/packages/relay-experimental/useMemoOperationDescriptor.js @@ -0,0 +1,38 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 + */ + +'use strict'; + +const React = require('react'); + +const useMemoVariables = require('./useMemoVariables'); + +const {createOperationDescriptor, getRequest} = require('relay-runtime'); + +import type { + GraphQLTaggedNode, + OperationDescriptor, + Variables, +} from 'relay-runtime'; + +const {useMemo} = React; + +function useMemoOperationDescriptor( + gqlQuery: GraphQLTaggedNode, + variables: Variables, +): OperationDescriptor { + const [memoVariables] = useMemoVariables(variables); + return useMemo( + () => createOperationDescriptor(getRequest(gqlQuery), memoVariables), + [gqlQuery, memoVariables], + ); +} + +module.exports = useMemoOperationDescriptor; diff --git a/packages/relay-experimental/useMemoVariables.js b/packages/relay-experimental/useMemoVariables.js new file mode 100644 index 000000000000..479d5f849357 --- /dev/null +++ b/packages/relay-experimental/useMemoVariables.js @@ -0,0 +1,51 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 + */ + +'use strict'; + +const React = require('react'); + +const areEqual = require('areEqual'); + +import type {Variables} from 'relay-runtime'; + +const {useMemo, useRef, useState} = React; + +function useMemoVariables( + variables: TVariables, +): [TVariables, number] { + // The value of this ref is a counter that should be incremented when + // variables change. This allows us to use the counter as a + // memoization value to indicate if the computation for useMemo + // should be re-executed. + const variablesChangedGenerationRef = useRef(0); + + // We mirror the variables to check if they have changed between renders + const [mirroredVariables, setMirroredVariables] = useState( + variables, + ); + + const variablesChanged = !areEqual(variables, mirroredVariables); + if (variablesChanged) { + variablesChangedGenerationRef.current = + (variablesChangedGenerationRef.current ?? 0) + 1; + setMirroredVariables(variables); + } + + // NOTE: We disable react-hooks-deps warning because we explicitly + // don't want to memoize on object identity + // eslint-disable-next-line react-hooks/exhaustive-deps + const memoVariables = useMemo(() => variables, [ + variablesChangedGenerationRef.current, + ]); + return [memoVariables, variablesChangedGenerationRef.current ?? 0]; +} + +module.exports = useMemoVariables; diff --git a/packages/relay-experimental/useQuery.js b/packages/relay-experimental/useQuery.js new file mode 100644 index 000000000000..8f7e60b0d523 --- /dev/null +++ b/packages/relay-experimental/useQuery.js @@ -0,0 +1,42 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 + */ + +'use strict'; + +const useMemoOperationDescriptor = require('./useMemoOperationDescriptor'); +const useQueryNode = require('./useQueryNode'); + +import type {FetchPolicy} from './QueryResource'; +import type { + CacheConfig, + GraphQLTaggedNode, + OperationType, +} from 'relay-runtime'; + +function useQuery( + gqlQuery: GraphQLTaggedNode, + variables: $ElementType, + options?: {| + fetchKey?: string | number, + fetchPolicy?: FetchPolicy, + networkCacheConfig?: CacheConfig, + |}, +): $ElementType { + const query = useMemoOperationDescriptor(gqlQuery, variables); + const data = useQueryNode({ + query, + fetchKey: options?.fetchKey, + fetchPolicy: options?.fetchPolicy, + componentDisplayName: 'useQuery()', + }); + return data; +} + +module.exports = useQuery; diff --git a/packages/relay-experimental/useQueryNode.js b/packages/relay-experimental/useQueryNode.js new file mode 100644 index 000000000000..625f7e850173 --- /dev/null +++ b/packages/relay-experimental/useQueryNode.js @@ -0,0 +1,87 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 + */ + +'use strict'; + +const ProfilerContext = require('./ProfilerContext'); +const React = require('react'); + +const useFetchTrackingRef = require('./useFetchTrackingRef'); +const useFragmentNode = require('./useFragmentNode'); +const useRelayEnvironment = require('./useRelayEnvironment'); + +const {getQueryResourceForEnvironment} = require('./QueryResource'); +const { + __internal: {fetchQuery}, +} = require('relay-runtime'); + +import type {FetchPolicy} from './QueryResource'; +import type { + CacheConfig, + GraphQLResponse, + Observable, + OperationDescriptor, + OperationType, +} from 'relay-runtime'; + +const {useContext, useEffect} = React; + +function useQueryNode(args: {| + query: OperationDescriptor, + componentDisplayName: string, + fetchObservable?: Observable, + fetchPolicy?: ?FetchPolicy, + fetchKey?: ?string | ?number, + networkCacheConfig?: CacheConfig, +|}): $ElementType { + const environment = useRelayEnvironment(); + const profilerContext = useContext(ProfilerContext); + const QueryResource = getQueryResourceForEnvironment(environment); + + const {query, componentDisplayName, fetchKey, fetchPolicy} = args; + const fetchObservable = + args.fetchObservable ?? + fetchQuery(environment, query, { + networkCacheConfig: args.networkCacheConfig, + }); + const {startFetch, completeFetch} = useFetchTrackingRef(); + + const preparedQueryResult = profilerContext.wrapPrepareQueryResource(() => { + return QueryResource.prepare( + query, + fetchObservable, + fetchPolicy, + null, + {start: startFetch, complete: completeFetch, error: completeFetch}, + fetchKey, + ); + }); + + useEffect(() => { + const disposable = QueryResource.retain(preparedQueryResult); + return () => { + disposable.dispose(); + }; + // NOTE: We disable react-hooks-deps warning because the `environment` + // and `query` identities are capturing all information about whether + // the effect should be re-ran and the query re-retained. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [environment, query]); + + const {fragmentNode, fragmentRef} = preparedQueryResult; + const {data} = useFragmentNode( + fragmentNode, + fragmentRef, + componentDisplayName, + ); + return data; +} + +module.exports = useQueryNode; diff --git a/packages/relay-experimental/useRefetchableFragment.js b/packages/relay-experimental/useRefetchableFragment.js new file mode 100644 index 000000000000..0d56bf356f9f --- /dev/null +++ b/packages/relay-experimental/useRefetchableFragment.js @@ -0,0 +1,52 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 + */ + +'use strict'; + +const useRefetchableFragmentNode = require('./useRefetchableFragmentNode'); +const useStaticPropWarning = require('./useStaticPropWarning'); + +const {getFragment} = require('relay-runtime'); + +import type {RefetchFnDynamic} from './useRefetchableFragmentNode'; +import type {GraphQLTaggedNode, OperationType} from 'relay-runtime'; + +type ReturnType = [ + // NOTE: This $Call ensures that the type of the returned data is either: + // - nullable if the provided ref type is nullable + // - non-nullable if the provided ref type is non-nullable + // prettier-ignore + $Call< + & (( {+$data?: TFragmentData}) => TFragmentData) + & ((?{+$data?: TFragmentData}) => ?TFragmentData), + TKey, + >, + RefetchFnDynamic, +]; + +function useRefetchableFragment( + fragmentInput: GraphQLTaggedNode, + fragmentRef: TKey, +): ReturnType { + useStaticPropWarning( + fragmentInput, + 'first argument of useRefetchableFragment()', + ); + const fragmentNode = getFragment(fragmentInput); + const {fragmentData, refetch} = useRefetchableFragmentNode( + fragmentNode, + fragmentRef, + 'useRefetchableFragment()', + ); + // $FlowExpectedError: Exposed options is a subset of internal options + return [fragmentData, (refetch: RefetchFnDynamic)]; +} + +module.exports = useRefetchableFragment; diff --git a/packages/relay-experimental/useRefetchableFragmentNode.js b/packages/relay-experimental/useRefetchableFragmentNode.js new file mode 100644 index 000000000000..32fe602e58e0 --- /dev/null +++ b/packages/relay-experimental/useRefetchableFragmentNode.js @@ -0,0 +1,613 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 + */ + +'use strict'; + +const ProfilerContext = require('./ProfilerContext'); +// flowlint untyped-import:off +const Scheduler = require('scheduler'); + +// flowlint untyped-import:error + +const getRefetchMetadata = require('./getRefetchMetadata'); +const getValueAtPath = require('./getValueAtPath'); +const invariant = require('invariant'); +const useFetchTrackingRef = require('./useFetchTrackingRef'); +const useFragmentNode = require('./useFragmentNode'); +const useIsMountedRef = require('./useIsMountedRef'); +const useMemoVariables = require('./useMemoVariables'); +const useRelayEnvironment = require('./useRelayEnvironment'); +// flowlint untyped-import:off +const warning = require('warning'); + +// flowlint untyped-import:error + +const {getFragmentResourceForEnvironment} = require('./FragmentResource'); +const {getQueryResourceForEnvironment} = require('./QueryResource'); +const { + useCallback, + useContext, + useEffect, + useMemo, + useReducer, + useRef, +} = require('react'); +const { + __internal: {fetchQuery}, + createOperationDescriptor, + getFragmentIdentifier, + getSelector, +} = require('relay-runtime'); + +import type {FetchPolicy, RenderPolicy} from './QueryResource'; +import type { + Disposable, + IEnvironment, + OperationType, + ReaderFragment, + Variables, +} from 'relay-runtime'; + +export type RefetchFn< + TQuery: OperationType, + TOptions = Options, +> = RefetchFnExact; + +// 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/. +// prettier-ignore +export type RefetchFnDynamic< + TQuery: OperationType, + TKey: ?{+$data?: mixed}, + TOptions = Options, +> = $Call< + & (( {+$data?: mixed}) => RefetchFnInexact) + & ((?{+$data?: mixed}) => RefetchFnExact), + TKey +>; + +export type ReturnType< + TQuery: OperationType, + TKey: ?{+$data?: mixed}, + TOptions = Options, +> = {| + fragmentData: mixed, + fragmentRef: mixed, + refetch: RefetchFnDynamic, + disableStoreUpdates: () => void, + enableStoreUpdates: () => void, +|}; + +export type Options = {| + fetchPolicy?: FetchPolicy, + onComplete?: (Error | null) => void, +|}; + +type InternalOptions = {| + ...Options, + __environment?: IEnvironment, + renderPolicy?: RenderPolicy, +|}; + +type RefetchFnBase = ( + vars: TVars, + options?: TOptions, +) => Disposable; + +type RefetchFnExact = RefetchFnBase< + $ElementType, + TOptions, +>; +type RefetchFnInexact< + TQuery: OperationType, + TOptions = Options, +> = RefetchFnBase<$Shape<$ElementType>, TOptions>; + +type Action = + | {| + type: 'reset', + environment: IEnvironment, + fragmentIdentifier: string, + |} + | {| + type: 'refetch', + refetchVariables: Variables, + fetchPolicy?: FetchPolicy, + renderPolicy?: RenderPolicy, + onComplete?: (Error | null) => void, + environment: ?IEnvironment, + |}; + +type RefetchState = {| + fetchPolicy: FetchPolicy | void, + renderPolicy: RenderPolicy | void, + mirroredEnvironment: IEnvironment, + mirroredFragmentIdentifier: string, + onComplete: ((Error | null) => void) | void, + refetchEnvironment?: ?IEnvironment, + refetchVariables: Variables | null, +|}; + +type DebugIDandTypename = { + id: string, + typename: string, +}; + +function reducer(state: RefetchState, action: Action): RefetchState { + switch (action.type) { + case 'refetch': { + return { + ...state, + refetchVariables: action.refetchVariables, + fetchPolicy: action.fetchPolicy, + renderPolicy: action.renderPolicy, + onComplete: action.onComplete, + refetchEnvironment: action.environment, + mirroredEnvironment: action.environment ?? state.mirroredEnvironment, + }; + } + case 'reset': { + return { + fetchPolicy: undefined, + renderPolicy: undefined, + onComplete: undefined, + refetchVariables: null, + mirroredEnvironment: action.environment, + mirroredFragmentIdentifier: action.fragmentIdentifier, + }; + } + default: { + (action.type: empty); + throw new Error('useRefetchableFragmentNode: Unexpected action type'); + } + } +} + +function useRefetchableFragmentNode< + TQuery: OperationType, + TKey: ?{+$data?: mixed}, +>( + fragmentNode: ReaderFragment, + parentFragmentRef: mixed, + componentDisplayName: string, +): ReturnType { + const parentEnvironment = useRelayEnvironment(); + const {refetchableRequest, fragmentRefPathInResponse} = getRefetchMetadata( + fragmentNode, + componentDisplayName, + ); + const fragmentIdentifier = getFragmentIdentifier( + fragmentNode, + parentFragmentRef, + ); + + const [refetchState, dispatch] = useReducer(reducer, { + fetchPolicy: undefined, + renderPolicy: undefined, + onComplete: undefined, + refetchVariables: null, + refetchEnvironment: null, + mirroredEnvironment: parentEnvironment, + mirroredFragmentIdentifier: fragmentIdentifier, + }); + const {startFetch, disposeFetch, completeFetch} = useFetchTrackingRef(); + const refetchGenerationRef = useRef(0); + const { + refetchVariables, + refetchEnvironment, + fetchPolicy, + renderPolicy, + onComplete, + mirroredEnvironment, + mirroredFragmentIdentifier, + } = refetchState; + const environment = refetchEnvironment ?? parentEnvironment; + + const QueryResource = getQueryResourceForEnvironment(environment); + const profilerContext = useContext(ProfilerContext); + + const shouldReset = + environment !== mirroredEnvironment || + fragmentIdentifier !== mirroredFragmentIdentifier; + const [memoRefetchVariables] = useMemoVariables(refetchVariables); + const refetchQuery = useMemo( + () => + memoRefetchVariables != null + ? createOperationDescriptor(refetchableRequest, memoRefetchVariables) + : null, + [memoRefetchVariables, refetchableRequest], + ); + + let refetchedQueryResult; + let fragmentRef = parentFragmentRef; + if (shouldReset) { + disposeFetch(); + dispatch({ + type: 'reset', + environment, + fragmentIdentifier, + }); + } else if (refetchQuery != null) { + // check __typename/id is consistent if refetch existing data on Node + let debugPreviousIDAndTypename: ?DebugIDandTypename; + if (__DEV__) { + debugPreviousIDAndTypename = debugFunctions.getInitialIDAndType( + memoRefetchVariables, + fragmentRefPathInResponse, + environment, + ); + } + + // If refetch has been called, we read/fetch the refetch query here. If + // the refetch query hasn't been fetched or isn't cached, we will suspend + // at this point. + const [queryResult, queryData] = readQuery( + environment, + refetchQuery, + fetchPolicy, + renderPolicy, + refetchGenerationRef.current ?? 0, + componentDisplayName, + { + start: startFetch, + complete: maybeError => { + completeFetch(); + onComplete && onComplete(maybeError ?? null); + + if (__DEV__) { + if (!maybeError) { + debugFunctions.checkSameTypeAfterRefetch( + debugPreviousIDAndTypename, + environment, + fragmentNode, + componentDisplayName, + ); + } + } + }, + }, + profilerContext, + ); + refetchedQueryResult = queryResult; + // 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__) { + 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 { + data: fragmentData, + disableStoreUpdates, + enableStoreUpdates, + } = useFragmentNode(fragmentNode, fragmentRef, componentDisplayName); + + useEffect(() => { + // Retain the refetch query if it was fetched and release it + // in the useEffect cleanup. + const queryDisposable = + refetchedQueryResult != null + ? QueryResource.retain(refetchedQueryResult) + : null; + + return () => { + if (queryDisposable) { + queryDisposable.dispose(); + } + }; + // NOTE: We disable react-hooks-deps warning because: + // - queryResult is captured by including refetchQuery, which is + // already capturing if the query or variables changed. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [QueryResource, fragmentIdentifier, refetchQuery]); + + const refetch = useRefetchFunction( + fragmentNode, + parentFragmentRef, + fragmentIdentifier, + fragmentRefPathInResponse, + fragmentData, + refetchGenerationRef, + dispatch, + disposeFetch, + componentDisplayName, + ); + return { + fragmentData, + fragmentRef, + refetch, + disableStoreUpdates, + enableStoreUpdates, + }; +} + +function useRefetchFunction( + fragmentNode, + parentFragmentRef, + fragmentIdentifier, + fragmentRefPathInResponse, + fragmentData, + refetchGenerationRef, + dispatch, + disposeFetch, + componentDisplayName, +): RefetchFn { + const isMountedRef = useIsMountedRef(); + // $FlowFixMe + const dataID = fragmentData?.id; + + return useCallback( + (providedRefetchVariables, options) => { + // 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 ( + Scheduler.unstable_getCurrentPriorityLevel() < + Scheduler.unstable_NormalPriority + ) { + warning( + false, + 'Relay: Unexpected call to `refetch` at a priority higher than ' + + 'expected on fragment `%s` in `%s`. It looks like you tried to ' + + 'call `refetch` under a high priority update, but updates that ' + + 'can cause the component to suspend should be scheduled at ' + + 'normal priority. Make sure you are calling `refetch` inside ' + + '`startTransition()` from the `useSuspenseTransition()` hook.', + fragmentNode.name, + componentDisplayName, + ); + } + 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, + ); + } + refetchGenerationRef.current = (refetchGenerationRef.current ?? 0) + 1; + + const environment = options?.__environment; + const fetchPolicy = options?.fetchPolicy; + const renderPolicy = options?.renderPolicy; + const onComplete = options?.onComplete; + const fragmentSelector = getSelector(fragmentNode, parentFragmentRef); + let parentVariables; + let fragmentVariables; + 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; + } + + // NOTE: 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 = { + ...parentVariables, + ...fragmentVariables, + ...providedRefetchVariables, + }; + // TODO (T40777961): Tweak output of @refetchable transform to more + // easily tell if we need an $id in the refetch vars + if ( + fragmentRefPathInResponse.includes('node') && + !providedRefetchVariables.hasOwnProperty('id') + ) { + // @refetchable fragments are guaranteed to have an `id` selection + // if the type is Node or implements Node. Double-check that there + // actually is a value at runtime. + if (typeof dataID !== 'string') { + warning( + false, + 'useRefetchableFragment(): Expected result to have a string ' + + '`id` in order to refetch, got `%s`.', + dataID, + ); + } + refetchVariables.id = dataID; + } + + dispatch({ + type: 'refetch', + refetchVariables, + fetchPolicy, + renderPolicy, + onComplete, + environment, + }); + return {dispose: disposeFetch}; + }, + // 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, dataID, dispatch, disposeFetch], + ); +} + +function readQuery( + environment, + query, + fetchPolicy, + renderPolicy, + refetchGeneration, + componentDisplayName, + {start, complete}, + profilerContext, +) { + const QueryResource = getQueryResourceForEnvironment(environment); + const FragmentResource = getFragmentResourceForEnvironment(environment); + const queryResult = profilerContext.wrapPrepareQueryResource(() => { + return QueryResource.prepare( + query, + fetchQuery(environment, query, { + networkCacheConfig: {force: true}, + }), + fetchPolicy, + renderPolicy, + {start, error: complete, complete}, + // NOTE: QueryResource will keep a cache entry for a query for the + // entire lifetime of this component. However, every time refetch is + // called, we want to make sure we correctly attempt to fetch the query + // (taking into account the fetchPolicy), even if we're refetching the exact + // same query (e.g. refreshing it). + // To do so, we keep track of every time refetch is called with + // `refetchGenerationRef`, which we can use as a key for the query in + // QueryResource. + refetchGeneration, + ); + }); + const queryData = FragmentResource.read( + 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.", + ); + return [queryResult, queryData]; +} + +let debugFunctions; +if (__DEV__) { + debugFunctions = { + getInitialIDAndType( + memoRefetchVariables: ?Variables, + fragmentRefPathInResponse: $ReadOnlyArray, + environment: IEnvironment, + ): ?DebugIDandTypename { + const RelayModernRecord = require('relay-runtime/store/RelayModernRecord'); + const id = memoRefetchVariables?.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 && RelayModernRecord.getType(record); + if (typename == null) { + return null; + } + return { + id, + typename, + }; + }, + + checkSameTypeAfterRefetch( + previousIDAndType: ?DebugIDandTypename, + environment: IEnvironment, + fragmentNode: ReaderFragment, + componentDisplayName: string, + ): void { + const RelayModernRecord = require('relay-runtime/store/RelayModernRecord'); + if (!previousIDAndType) { + return; + } + const recordSource = environment.getStore().getSource(); + const record = recordSource.get(previousIDAndType.id); + const typename = record && RelayModernRecord.getType(record); + if (typename !== previousIDAndType.typename) { + warning( + false, + 'Relay: Call to `refetch` returns 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/store/RelayStoreUtils'); + // $FlowExpectedError + const resultID = refetchedFragmentRef[ID_KEY]; + if (resultID != null && resultID !== previousIDAndTypename.id) { + warning( + false, + 'Relay: Call to `refetch` returns 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; diff --git a/packages/relay-experimental/useRelayEnvironment.js b/packages/relay-experimental/useRelayEnvironment.js new file mode 100644 index 000000000000..45d1e30ab5f5 --- /dev/null +++ b/packages/relay-experimental/useRelayEnvironment.js @@ -0,0 +1,35 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 + */ + +'use strict'; + +const ReactRelayContext = require('react-relay/ReactRelayContext'); + +const invariant = require('invariant'); + +const {useContext} = require('react'); + +import type {IEnvironment} from 'relay-runtime'; + +function useRelayEnvironment(): IEnvironment { + const context = useContext(ReactRelayContext); + invariant( + context != null, + 'useRelayEnvironment: Expected to have found a Relay environment provided by ' + + 'a `RelayEnvironmentProvider` component. ' + + 'This usually means that useRelayEnvironment was used in a ' + + 'component that is not a descendant of a `RelayEnvironmentProvider`.' + + 'Please make sure a `RelayEnvironmentProvider` has been rendered somewhere ' + + 'as a parent of ancestor your compontent.', + ); + return context.environment; +} + +module.exports = useRelayEnvironment; diff --git a/packages/relay-experimental/useStaticPropWarning.js b/packages/relay-experimental/useStaticPropWarning.js new file mode 100644 index 000000000000..349b6b974016 --- /dev/null +++ b/packages/relay-experimental/useStaticPropWarning.js @@ -0,0 +1,36 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 + */ + +'use strict'; + +// flowlint untyped-import:off +const warning = require('warning'); + +// flowlint untyped-import:error + +const {useRef} = require('react'); + +function useStaticPropWarning(prop: mixed, context: string): void { + if (__DEV__) { + // This is calling `useRef` conditionally, but based on the environment + // __DEV__ setting which shouldn't change. This allows us to only pay the + // cost of `useRef` in development mode to produce the warning. + // eslint-disable-next-line react-hooks/rules-of-hooks + const initialPropRef = useRef(prop); + warning( + initialPropRef.current === prop, + 'The %s has to remain the same over the lifetime of a component. ' + + 'Changing it is not supported and will result in unexpected behavior.', + context, + ); + } +} + +module.exports = useStaticPropWarning; diff --git a/packages/relay-runtime/index.js b/packages/relay-runtime/index.js index d9474f48cb88..cf2ceca4d6b2 100644 --- a/packages/relay-runtime/index.js +++ b/packages/relay-runtime/index.js @@ -48,6 +48,7 @@ const fetchQueryInternal = require('./query/fetchQueryInternal'); const getFragmentIdentifier = require('./util/getFragmentIdentifier'); const getFragmentSpecIdentifier = require('./util/getFragmentSpecIdentifier'); const getRelayHandleKey = require('./util/getRelayHandleKey'); +const isPromise = require('./util/isPromise'); const isRelayModernEnvironment = require('./store/isRelayModernEnvironment'); const isScalarAndEqual = require('./util/isScalarAndEqual'); const recycleNodesInto = require('./util/recycleNodesInto'); @@ -295,6 +296,7 @@ module.exports = { deepFreeze: deepFreeze, generateClientID: generateClientID, getRelayHandleKey: getRelayHandleKey, + isPromise: isPromise, isScalarAndEqual: isScalarAndEqual, recycleNodesInto: recycleNodesInto, stableCopy: stableCopy, diff --git a/packages/relay-runtime/store/RelayModernEnvironment.js b/packages/relay-runtime/store/RelayModernEnvironment.js index 007687150964..86613359e234 100644 --- a/packages/relay-runtime/store/RelayModernEnvironment.js +++ b/packages/relay-runtime/store/RelayModernEnvironment.js @@ -72,26 +72,6 @@ export type EnvironmentConfig = {| +UNSTABLE_DO_NOT_USE_getDataID?: ?GetDataID, |}; -let DefaultLoggerProvider = null; -if (__DEV__) { - if (console.groupCollapsed) { - const RelayNetworkLoggerTransaction = require('../network/RelayNetworkLoggerTransaction'); - DefaultLoggerProvider = { - getLogger(config: LoggerTransactionConfig) { - const logger = new RelayNetworkLoggerTransaction(config); - return { - log(message: string, ...args: Array) { - return logger.addLog(message, ...args); - }, - flushLogs() { - return logger.flushLogs(); - }, - }; - }, - }; - } -} - class RelayModernEnvironment implements Environment { _loggerProvider: ?LoggerProvider; _operationLoader: ?OperationLoader; @@ -122,7 +102,7 @@ class RelayModernEnvironment implements Environment { ); } } - this._loggerProvider = config.loggerProvider ?? DefaultLoggerProvider; + this._loggerProvider = config.loggerProvider; this._operationLoader = operationLoader; this._network = config.network; this._getDataID = config.UNSTABLE_DO_NOT_USE_getDataID ?? defaultGetDataID;