react-relay
Version:
A framework for building GraphQL-driven React applications.
717 lines (684 loc) • 26 kB
Flow
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
* @oncall relay
*/
;
import type {QueryResult} from './QueryResource';
import type {
CacheConfig,
DataID,
FetchPolicy,
IEnvironment,
ReaderFragment,
ReaderSelector,
SelectorData,
Snapshot,
} from 'relay-runtime';
import type {MissingClientEdgeRequestInfo} from 'relay-runtime/store/RelayStoreTypes';
const {getQueryResourceForEnvironment} = require('./QueryResource');
const useRelayEnvironment = require('./useRelayEnvironment');
const invariant = require('invariant');
const {useDebugValue, useEffect, useMemo, useRef, useState} = require('react');
const {
__internal: {fetchQuery: fetchQueryInternal, getPromiseForActiveRequest},
RelayFeatureFlags,
areEqualSelectors,
createOperationDescriptor,
getPendingOperationsForFragment,
getSelector,
getVariablesFromFragment,
handlePotentialSnapshotErrors,
recycleNodesInto,
} = require('relay-runtime');
const warning = require('warning');
export type FragmentQueryOptions = {
fetchPolicy?: FetchPolicy,
networkCacheConfig?: ?CacheConfig,
};
type FragmentState = $ReadOnly<
| {kind: 'bailout', environment: IEnvironment}
| {
kind: 'singular',
snapshot: Snapshot,
epoch: number,
selector: ReaderSelector,
environment: IEnvironment,
}
| {
kind: 'plural',
snapshots: $ReadOnlyArray<Snapshot>,
epoch: number,
selector: ReaderSelector,
environment: IEnvironment,
},
>;
type StateUpdaterFunction<T> = ((T) => T) => void;
function isMissingData(state: FragmentState): boolean {
if (state.kind === 'bailout') {
return false;
} else if (state.kind === 'singular') {
return state.snapshot.isMissingData;
} else {
return state.snapshots.some(s => s.isMissingData);
}
}
function getMissingClientEdges(
state: FragmentState,
): $ReadOnlyArray<MissingClientEdgeRequestInfo> | null {
if (state.kind === 'bailout') {
return null;
} else if (state.kind === 'singular') {
return state.snapshot.missingClientEdges ?? null;
} else {
let edges: null | Array<MissingClientEdgeRequestInfo> = null;
for (const snapshot of state.snapshots) {
if (snapshot.missingClientEdges) {
edges = edges ?? [];
for (const edge of snapshot.missingClientEdges) {
edges.push(edge);
}
}
}
return edges;
}
}
function getSuspendingLiveResolver(
state: FragmentState,
): $ReadOnlyArray<DataID> | null {
if (state.kind === 'bailout') {
return null;
} else if (state.kind === 'singular') {
return state.snapshot.missingLiveResolverFields ?? null;
} else {
let missingFields: null | Array<DataID> = null;
for (const snapshot of state.snapshots) {
if (snapshot.missingLiveResolverFields) {
missingFields = missingFields ?? [];
for (const edge of snapshot.missingLiveResolverFields) {
missingFields.push(edge);
}
}
}
return missingFields;
}
}
function handlePotentialSnapshotErrorsForState(
environment: IEnvironment,
state: FragmentState,
): void {
if (state.kind === 'singular') {
handlePotentialSnapshotErrors(
environment,
state.snapshot.errorResponseFields,
);
} else if (state.kind === 'plural') {
for (const snapshot of state.snapshots) {
handlePotentialSnapshotErrors(environment, snapshot.errorResponseFields);
}
}
}
/**
* Check for updates to the store that occurred concurrently with rendering the given `state` value,
* returning a new (updated) state if there were updates or null if there were no changes.
*/
function handleMissedUpdates(
environment: IEnvironment,
state: FragmentState,
): null | [/* has data changed */ boolean, FragmentState] {
if (state.kind === 'bailout') {
return null;
}
// FIXME this is invalid if we've just switched environments.
const currentEpoch = environment.getStore().getEpoch();
if (currentEpoch === state.epoch) {
return null;
}
// The store has updated since we rendered (without us being subscribed yet),
// so check for any updates to the data we're rendering:
if (state.kind === 'singular') {
const currentSnapshot = environment.lookup(state.snapshot.selector);
const updatedData = recycleNodesInto(
state.snapshot.data,
currentSnapshot.data,
);
const updatedCurrentSnapshot: Snapshot = {
data: updatedData,
isMissingData: currentSnapshot.isMissingData,
missingClientEdges: currentSnapshot.missingClientEdges,
missingLiveResolverFields: currentSnapshot.missingLiveResolverFields,
seenRecords: currentSnapshot.seenRecords,
selector: currentSnapshot.selector,
errorResponseFields: currentSnapshot.errorResponseFields,
};
return [
updatedData !== state.snapshot.data,
{
kind: 'singular',
snapshot: updatedCurrentSnapshot,
epoch: currentEpoch,
selector: state.selector,
environment: state.environment,
},
];
} else {
let didMissUpdates = false;
const currentSnapshots = [];
for (let index = 0; index < state.snapshots.length; index++) {
const snapshot = state.snapshots[index];
const currentSnapshot = environment.lookup(snapshot.selector);
const updatedData = recycleNodesInto(snapshot.data, currentSnapshot.data);
const updatedCurrentSnapshot: Snapshot = {
data: updatedData,
isMissingData: currentSnapshot.isMissingData,
missingClientEdges: currentSnapshot.missingClientEdges,
missingLiveResolverFields: currentSnapshot.missingLiveResolverFields,
seenRecords: currentSnapshot.seenRecords,
selector: currentSnapshot.selector,
errorResponseFields: currentSnapshot.errorResponseFields,
};
if (updatedData !== snapshot.data) {
didMissUpdates = true;
}
currentSnapshots.push(updatedCurrentSnapshot);
}
invariant(
currentSnapshots.length === state.snapshots.length,
'Expected same number of snapshots',
);
return [
didMissUpdates,
{
kind: 'plural',
snapshots: currentSnapshots,
epoch: currentEpoch,
selector: state.selector,
environment: state.environment,
},
];
}
}
function handleMissingClientEdge(
environment: IEnvironment,
parentFragmentNode: ReaderFragment,
parentFragmentRef: mixed,
missingClientEdgeRequestInfo: MissingClientEdgeRequestInfo,
queryOptions?: FragmentQueryOptions,
): [QueryResult, ?Promise<mixed>] {
const originalVariables = getVariablesFromFragment(
parentFragmentNode,
parentFragmentRef,
);
const variables = {
...originalVariables,
id: missingClientEdgeRequestInfo.clientEdgeDestinationID, // TODO should be a reserved name
};
const queryOperationDescriptor = createOperationDescriptor(
missingClientEdgeRequestInfo.request,
variables,
queryOptions?.networkCacheConfig,
);
// This may suspend. We don't need to do anything with the results; all we're
// doing here is started the query if needed and retaining and releasing it
// according to the component mount/suspense cycle; QueryResource
// already handles this by itself.
const QueryResource = getQueryResourceForEnvironment(environment);
const queryResult = QueryResource.prepare(
queryOperationDescriptor,
fetchQueryInternal(environment, queryOperationDescriptor),
queryOptions?.fetchPolicy,
);
return [
queryResult,
getPromiseForActiveRequest(environment, queryOperationDescriptor.request),
];
}
function subscribeToSnapshot(
environment: IEnvironment,
state: FragmentState,
setState: StateUpdaterFunction<FragmentState>,
): () => void {
if (state.kind === 'bailout') {
return () => {};
} else if (state.kind === 'singular') {
const disposable = environment.subscribe(state.snapshot, latestSnapshot => {
setState(prevState => {
// In theory a setState from a subscription could be batched together
// with a setState to change the fragment selector. Guard against this
// by bailing out of the state update if the selector has changed.
let nextState: FragmentState | null = null;
if (
prevState.kind !== 'singular' ||
prevState.snapshot.selector !== latestSnapshot.selector ||
prevState.environment !== environment
) {
const updates = handleMissedUpdates(prevState.environment, prevState);
if (updates != null) {
const [dataChanged, updatedState] = updates;
environment.__log({
name: 'useFragment.subscription.missedUpdates',
hasDataChanges: dataChanged,
});
nextState = dataChanged ? updatedState : prevState;
} else {
nextState = prevState;
}
} else {
nextState = {
kind: 'singular',
snapshot: latestSnapshot,
epoch: environment.getStore().getEpoch(),
selector: state.selector,
environment: state.environment,
};
}
return nextState;
});
});
return () => {
disposable.dispose();
};
} else {
const disposables = state.snapshots.map((snapshot, index) =>
environment.subscribe(snapshot, latestSnapshot => {
setState(prevState => {
// In theory a setState from a subscription could be batched together
// with a setState to change the fragment selector. Guard against this
// by bailing out of the state update if the selector has changed.
let nextState: FragmentState | null = null;
if (
prevState.kind !== 'plural' ||
prevState.snapshots[index]?.selector !== latestSnapshot.selector ||
prevState.environment !== environment
) {
const updates = handleMissedUpdates(
prevState.environment,
prevState,
);
if (updates != null) {
const [dataChanged, updatedState] = updates;
environment.__log({
name: 'useFragment.subscription.missedUpdates',
hasDataChanges: dataChanged,
});
nextState = dataChanged ? updatedState : prevState;
} else {
nextState = prevState;
}
} else {
const updated = [...prevState.snapshots];
updated[index] = latestSnapshot;
nextState = {
kind: 'plural',
snapshots: updated,
epoch: environment.getStore().getEpoch(),
selector: state.selector,
environment: state.environment,
};
}
return nextState;
});
}),
);
return () => {
for (const d of disposables) {
d.dispose();
}
};
}
}
function getFragmentState(
environment: IEnvironment,
fragmentSelector: ?ReaderSelector,
): FragmentState {
if (fragmentSelector == null) {
return {kind: 'bailout', environment};
} else if (fragmentSelector.kind === 'PluralReaderSelector') {
// Note that if fragmentRef is an empty array, fragmentSelector will be null so we'll hit the above case.
// Null is returned by getSelector if fragmentRef has no non-null items.
return {
kind: 'plural',
snapshots: fragmentSelector.selectors.map(s => environment.lookup(s)),
epoch: environment.getStore().getEpoch(),
selector: fragmentSelector,
environment: environment,
};
} else {
return {
kind: 'singular',
snapshot: environment.lookup(fragmentSelector),
epoch: environment.getStore().getEpoch(),
selector: fragmentSelector,
environment: environment,
};
}
}
// fragmentNode cannot change during the lifetime of the component, though fragmentRef may change.
hook useFragmentInternal_EXPERIMENTAL(
fragmentNode: ReaderFragment,
fragmentRef: mixed,
hookDisplayName: string,
queryOptions?: FragmentQueryOptions,
): ?SelectorData | Array<?SelectorData> {
const fragmentSelector = useMemo(
() => getSelector(fragmentNode, fragmentRef),
[fragmentNode, fragmentRef],
);
const isPlural = fragmentNode?.metadata?.plural === true;
if (isPlural) {
invariant(
fragmentRef == null || 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.',
fragmentNode.name,
typeof fragmentRef,
fragmentNode.name,
);
} else {
invariant(
!Array.isArray(fragmentRef),
'Relay: Expected fragment pointer%s for fragment `%s` not to be ' +
'an array, instead got `%s`. Add `@relay(plural: true)` ' +
'to fragment `%s` to allow the prop to be an array.',
fragmentNode.name,
typeof fragmentRef,
fragmentNode.name,
);
}
invariant(
fragmentRef == null ||
(isPlural && Array.isArray(fragmentRef) && fragmentRef.length === 0) ||
fragmentSelector != null,
'Relay: Expected to receive an object where `...%s` was spread, ' +
'but the fragment reference was not found`. This is most ' +
'likely the result of:\n' +
"- Forgetting to spread `%s` in `%s`'s parent's fragment.\n" +
'- Conditionally fetching `%s` but unconditionally passing %s prop ' +
'to `%s`. If the parent fragment only fetches the fragment conditionally ' +
'- with e.g. `@include`, `@skip`, or inside a `... on SomeType { }` ' +
'spread - then the fragment reference will not exist. ' +
'In this case, pass `null` if the conditions for evaluating the ' +
'fragment are not met (e.g. if the `@include(if)` value is false.)',
fragmentNode.name,
fragmentNode.name,
hookDisplayName,
fragmentNode.name,
hookDisplayName,
);
const environment = useRelayEnvironment();
const [_state, setState] = useState<FragmentState>(() =>
getFragmentState(environment, fragmentSelector),
);
let state = _state;
const previousEnvironment = state.environment;
if (
!areEqualSelectors(fragmentSelector, state.selector) ||
environment !== state.environment
) {
// Enqueue setState to record the new selector and state
const newState = getFragmentState(environment, fragmentSelector);
setState(newState);
// But render with the latest state w/o waiting for the setState. Otherwise
// the component would render the wrong information temporarily (including
// possibly incorrectly triggering some warnings below).
state = newState;
}
// The purpose of this is to detect whether we have ever committed, because we
// don't suspend on store updates, only when the component either is first trying
// to mount or when the our selector changes. The selector change in particular is
// how we suspend for pagination and refetch. Also, fragment selector can be null
// or undefined, so we use false as a special value to distinguish from all fragment
// selectors; false means that the component hasn't mounted yet.
const committedFragmentSelectorRef = useRef<false | ?ReaderSelector>(false);
useEffect(() => {
committedFragmentSelectorRef.current = fragmentSelector;
}, [fragmentSelector]);
// Handle the queries for any missing client edges; this may suspend.
// FIXME handle client edges in parallel.
if (fragmentNode.metadata?.hasClientEdges === true) {
// The fragment is validated to be static (in useFragment) and hasClientEdges is
// a static (constant) property of the fragment. In practice, this effect will
// always or never run for a given invocation of this hook.
// eslint-disable-next-line react-hooks/rules-of-hooks
// $FlowFixMe[react-rule-hook]
const [clientEdgeQueries, activeRequestPromises] = useMemo(() => {
const missingClientEdges = getMissingClientEdges(state);
// eslint-disable-next-line no-shadow
let clientEdgeQueries;
const activeRequestPromises = [];
if (missingClientEdges?.length) {
clientEdgeQueries = ([]: Array<QueryResult>);
for (const edge of missingClientEdges) {
const [queryResult, requestPromise] = handleMissingClientEdge(
environment,
fragmentNode,
fragmentRef,
edge,
queryOptions,
);
clientEdgeQueries.push(queryResult);
if (requestPromise != null) {
activeRequestPromises.push(requestPromise);
}
}
}
return [clientEdgeQueries, activeRequestPromises];
}, [state, environment, fragmentNode, fragmentRef, queryOptions]);
if (activeRequestPromises.length) {
throw Promise.all(activeRequestPromises);
}
// See above note
// eslint-disable-next-line react-hooks/rules-of-hooks
// $FlowFixMe[react-rule-hook]
useEffect(() => {
const QueryResource = getQueryResourceForEnvironment(environment);
if (clientEdgeQueries?.length) {
const disposables = [];
for (const query of clientEdgeQueries) {
disposables.push(QueryResource.retain(query));
}
return () => {
for (const disposable of disposables) {
disposable.dispose();
}
};
}
}, [environment, clientEdgeQueries]);
}
if (isMissingData(state)) {
// Suspend if a Live Resolver within this fragment is in a suspended state:
const suspendingLiveResolvers = getSuspendingLiveResolver(state);
if (suspendingLiveResolvers != null && suspendingLiveResolvers.length > 0) {
throw Promise.all(
suspendingLiveResolvers.map(liveStateID => {
// $FlowFixMe[prop-missing] This is expected to be a RelayModernStore
return environment.getStore().getLiveResolverPromise(liveStateID);
}),
);
}
// Suspend if an active operation bears on this fragment, either the
// fragment's owner or some other mutation etc. that could affect it.
// We only suspend when the component is first trying to mount or changing
// selectors, not if data becomes missing later:
if (
RelayFeatureFlags.ENABLE_RELAY_OPERATION_TRACKER_SUSPENSE ||
environment !== previousEnvironment ||
!committedFragmentSelectorRef.current ||
// $FlowFixMe[react-rule-unsafe-ref]
!areEqualSelectors(committedFragmentSelectorRef.current, fragmentSelector)
) {
invariant(fragmentSelector != null, 'refinement, see invariants above');
const fragmentOwner =
fragmentSelector.kind === 'PluralReaderSelector'
? fragmentSelector.selectors[0].owner
: fragmentSelector.owner;
const pendingOperationsResult = getPendingOperationsForFragment(
environment,
fragmentNode,
fragmentOwner,
);
if (pendingOperationsResult) {
throw pendingOperationsResult.promise;
}
}
}
// Report required fields only if we're not suspending, since that means
// they're missing even though we are out of options for possibly fetching them:
handlePotentialSnapshotErrorsForState(environment, state);
// We emulate CRUD effects using a ref and two effects:
// - The ref tracks the current state (including updates from the subscription)
// and the dispose function for the current subscription. This is null until
// a subscription is established.
// - The first effect is the "update" effect, and re-runs when the environment
// or state changes. It is responsible for disposing of the previous subscription
// and establishing a new one, but it manualy reconciles the current state
// with the subscribed state and bails out if it is already subscribed to the
// correct (current) state.
// - The second effect is the mount/unmount (and attach/reattach effect). It
// makes sure that the subscription is disposed when the component unmounts
// or detaches (<Activity> going hidden), and then re-subscribes when the component
// re-attaches (<Activity> going visible). These cases wouldn't fire the
// "update" effect because the state and environment don't change.
const storeSubscriptionRef = useRef<{
dispose: () => void,
selector: ?ReaderSelector,
environment: IEnvironment,
} | null>(null);
useEffect(() => {
const storeSubscription = storeSubscriptionRef.current;
if (storeSubscription != null) {
if (
state.environment === storeSubscription.environment &&
state.selector === storeSubscription.selector
) {
// We're already subscribed to the same selector, so no need to do anything
return;
} else {
// The selector has changed, so we need to dispose of the previous subscription
storeSubscription.dispose();
}
}
if (state.kind === 'bailout') {
return;
}
// The FragmentState that we'll actually subscribe to. Note that it's possible that
// a concurrent modification to the store didn't affect the snapshot _data_ (so we don't
// need to re-render), but did affect the seen records. So if there were missed updates
// we use that state to subscribe.
let stateForSubscription: FragmentState = state;
// No subscription yet or the selector has changed, so we need to subscribe
// first check for updates since the state was rendered
const updates = handleMissedUpdates(state.environment, state);
if (updates !== null) {
const [didMissUpdates, updatedState] = updates;
// TODO: didMissUpdates only checks for changes to snapshot data, but it's possible
// that other snapshot properties may have changed that should also trigger a re-render,
// such as changed missing resolver fields, missing client edges, etc.
// A potential alternative is for handleMissedUpdates() to recycle the entire state
// value, and return the new (recycled) state only if there was some change. In that
// case the code would always setState if something in the snapshot changed, in addition
// to using the latest snapshot to subscribe.
if (didMissUpdates) {
setState(updatedState);
// We missed updates, we're going to render again anyway so wait until then to subscribe
return;
}
stateForSubscription = updatedState;
}
const dispose = subscribeToSnapshot(
state.environment,
stateForSubscription,
setState,
);
storeSubscriptionRef.current = {
dispose,
selector: state.selector,
environment: state.environment,
};
}, [state]);
useEffect(() => {
if (storeSubscriptionRef.current == null && state.kind !== 'bailout') {
const dispose = subscribeToSnapshot(state.environment, state, setState);
storeSubscriptionRef.current = {
dispose,
selector: state.selector,
environment: state.environment,
};
}
return () => {
storeSubscriptionRef.current?.dispose();
storeSubscriptionRef.current = null;
};
// NOTE: this intentionally has no dependencies, see above comment about
// simulating a CRUD effect
}, []);
let data: ?SelectorData | Array<?SelectorData>;
if (isPlural) {
// Plural fragments require allocating an array of the snapshot data values,
// which has to be memoized to avoid triggering downstream re-renders.
//
// Note that isPlural is a constant property of the fragment and does not change
// for a particular useFragment invocation site
const fragmentRefIsNullish = fragmentRef == null; // for less sensitive memoization
// eslint-disable-next-line react-hooks/rules-of-hooks
// $FlowFixMe[react-rule-hook]
data = useMemo(() => {
if (state.kind === 'bailout') {
// Bailout state can happen if the fragmentRef is a plural array that is empty or has no
// non-null entries. In that case, the compatible behavior is to return [] instead of null.
return fragmentRefIsNullish ? null : [];
} else {
invariant(
state.kind === 'plural',
'Expected state to be plural because fragment is plural',
);
return state.snapshots.map(s => s.data);
}
}, [state, fragmentRefIsNullish]);
} else if (state.kind === 'bailout') {
// This case doesn't allocate a new object so it doesn't have to be memoized
data = null;
} else {
// This case doesn't allocate a new object so it doesn't have to be memoized
invariant(
state.kind === 'singular',
'Expected state to be singular because fragment is singular',
);
data = state.snapshot.data;
}
if (RelayFeatureFlags.LOG_MISSING_RECORDS_IN_PROD || __DEV__) {
if (
fragmentRef != null &&
(data === undefined ||
(Array.isArray(data) &&
data.length > 0 &&
data.every(d => d === undefined)))
) {
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.',
fragmentNode.name,
hookDisplayName,
hookDisplayName,
);
}
}
if (__DEV__) {
// eslint-disable-next-line react-hooks/rules-of-hooks
// $FlowFixMe[react-rule-hook]
useDebugValue({fragment: fragmentNode.name, data});
}
return data;
}
module.exports = useFragmentInternal_EXPERIMENTAL;