UNPKG

react-relay

Version:

A framework for building GraphQL-driven React applications.

489 lines (441 loc) • 14.2 kB
/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @flow * @format * @oncall relay */ 'use strict'; import type {ReactRelayQueryRendererContext as ReactRelayQueryRendererContextType} from './ReactRelayQueryRendererContext'; import type { CacheConfig, GraphQLTaggedNode, IEnvironment, RelayContext, RequestParameters, Snapshot, Variables, } from 'relay-runtime'; const ReactRelayContext = require('./ReactRelayContext'); const ReactRelayQueryFetcher = require('./ReactRelayQueryFetcher'); const ReactRelayQueryRendererContext = require('./ReactRelayQueryRendererContext'); const areEqual = require('areEqual'); const React = require('react'); const { createOperationDescriptor, deepFreeze, getRequest, } = require('relay-runtime'); type RetryCallbacks = { handleDataChange: | null | (({error?: Error, snapshot?: Snapshot, ...}) => void), handleRetryAfterError: null | ((error: Error) => void), }; export type RenderProps<T> = { error: ?Error, props: ?T, retry: ?(cacheConfigOverride?: CacheConfig) => void, }; /** * React may double-fire the constructor, and we call 'fetch' in the * constructor. If a request is already in flight from a previous call to the * constructor, just reuse the query fetcher and wait for the response. */ const requestCache: { [string]: void | { queryFetcher: ReactRelayQueryFetcher, snapshot: ?Snapshot, }, } = {}; const queryRendererContext: ReactRelayQueryRendererContextType = { rootIsQueryRenderer: true, }; export type Props = $ReadOnly<{ cacheConfig?: ?CacheConfig, fetchPolicy?: 'store-and-network' | 'network-only', environment: IEnvironment, query: ?GraphQLTaggedNode, render: (renderProps: RenderProps<Object>) => React.Node, variables: Variables, }>; type State = { error: Error | null, prevPropsEnvironment: IEnvironment, prevPropsVariables: Variables, prevQuery: ?GraphQLTaggedNode, queryFetcher: ReactRelayQueryFetcher, relayContext: RelayContext, renderProps: RenderProps<Object>, retryCallbacks: RetryCallbacks, requestCacheKey: ?string, snapshot: Snapshot | null, }; /** * @public * * Orchestrates fetching and rendering data for a single view or view hierarchy: * - Fetches the query/variables using the given network implementation. * - Normalizes the response(s) to that query, publishing them to the given * store. * - Renders the pending/fail/success states with the provided render function. * - Subscribes for updates to the root data and re-renders with any changes. */ class ReactRelayQueryRenderer extends React.Component<Props, State> { _maybeHiddenOrFastRefresh: boolean; constructor(props: Props) { super(props); // Callbacks are attached to the current instance and shared with static // lifecyles by bundling with state. This is okay to do because the // callbacks don't change in reaction to props. However we should not // "leak" them before mounting (since we would be unable to clean up). For // that reason, we define them as null initially and fill them in after // mounting to avoid leaking memory. const retryCallbacks = { handleDataChange: null, handleRetryAfterError: null, }; let queryFetcher; let requestCacheKey; if (props.query) { const {query} = props; const request = getRequest(query); requestCacheKey = getRequestCacheKey(request.params, props.variables); queryFetcher = requestCache[requestCacheKey] ? requestCache[requestCacheKey].queryFetcher : new ReactRelayQueryFetcher(); } else { queryFetcher = new ReactRelayQueryFetcher(); } this._maybeHiddenOrFastRefresh = false; // $FlowFixMe[incompatible-type] this.state = { prevPropsEnvironment: props.environment, prevPropsVariables: props.variables, prevQuery: props.query, queryFetcher, retryCallbacks, ...fetchQueryAndComputeStateFromProps( props, queryFetcher, retryCallbacks, requestCacheKey, ), }; } static getDerivedStateFromProps( nextProps: Props, prevState: State, ): Partial<State> | null { if ( prevState.prevQuery !== nextProps.query || prevState.prevPropsEnvironment !== nextProps.environment || !areEqual(prevState.prevPropsVariables, nextProps.variables) ) { return resetQueryStateForUpdate(nextProps, prevState); } return null; } componentDidMount() { if (this._maybeHiddenOrFastRefresh === true) { // This block only runs if the component has previously "unmounted" // due to it being hidden by the Offscreen API, or during fast refresh. // At this point, the current cached resource will have been disposed // by the previous cleanup, so instead of attempting to // do our regular commit setup, so that the query is re-evaluated // (and potentially cause a refetch). this._maybeHiddenOrFastRefresh = false; // eslint-disable-next-line react/no-did-mount-set-state this.setState(prevState => { const newState = resetQueryStateForUpdate(this.props, prevState); const {requestCacheKey, queryFetcher} = newState; if (requestCacheKey != null && requestCache[requestCacheKey] != null) { // $FlowFixMe[incompatible-use] queryFetcher.setOnDataChange(this._handleDataChange); } return newState; }); return; } const {retryCallbacks, queryFetcher, requestCacheKey} = this.state; // We don't need to cache the request after the component commits if (requestCacheKey) { delete requestCache[requestCacheKey]; } retryCallbacks.handleDataChange = this._handleDataChange; retryCallbacks.handleRetryAfterError = (error: Error) => this.setState(prevState => { const {requestCacheKey: prevRequestCacheKey} = prevState; if (prevRequestCacheKey) { delete requestCache[prevRequestCacheKey]; } return { renderProps: getLoadingRenderProps(), requestCacheKey: null, }; }); // Re-initialize the ReactRelayQueryFetcher with callbacks. // If data has changed since constructions, this will re-render. if (this.props.query) { queryFetcher.setOnDataChange(this._handleDataChange); } } componentDidUpdate(_prevProps: Props, prevState: State): void { // We don't need to cache the request after the component commits const {queryFetcher, requestCacheKey} = this.state; if (requestCacheKey) { delete requestCache[requestCacheKey]; // HACK delete this.state.requestCacheKey; } if (this.props.query && queryFetcher !== prevState.queryFetcher) { queryFetcher.setOnDataChange(this._handleDataChange); } } componentWillUnmount(): void { this.state.queryFetcher.dispose(); this._maybeHiddenOrFastRefresh = true; } shouldComponentUpdate(nextProps: Props, nextState: State): boolean { return ( nextProps.render !== this.props.render || nextState.renderProps !== this.state.renderProps ); } _handleDataChange = (params: { error?: Error, snapshot?: Snapshot, ... }): void => { const error = params.error == null ? null : params.error; const snapshot = params.snapshot == null ? null : params.snapshot; this.setState(prevState => { const {requestCacheKey: prevRequestCacheKey} = prevState; if (prevRequestCacheKey) { delete requestCache[prevRequestCacheKey]; } // Don't update state if nothing has changed. if (snapshot === prevState.snapshot && error === prevState.error) { return null; } return { renderProps: getRenderProps( error, snapshot, prevState.queryFetcher, prevState.retryCallbacks, ), snapshot, requestCacheKey: null, }; }); }; render(): React.MixedElement { const {renderProps, relayContext} = this.state; // Note that the root fragment results in `renderProps.props` is already // frozen by the store; this call is to freeze the renderProps object and // error property if set. if (__DEV__) { deepFreeze(renderProps); } return ( <ReactRelayContext.Provider value={relayContext}> <ReactRelayQueryRendererContext.Provider value={queryRendererContext}> {this.props.render(renderProps)} </ReactRelayQueryRendererContext.Provider> </ReactRelayContext.Provider> ); } } function getLoadingRenderProps(): RenderProps<Object> { return { error: null, props: null, // `props: null` indicates that the data is being fetched (i.e. loading) retry: null, }; } function getEmptyRenderProps(): RenderProps<Object> { return { error: null, props: {}, // `props: {}` indicates no data available retry: null, }; } function getRenderProps( error: ?Error, snapshot: ?Snapshot, queryFetcher: ReactRelayQueryFetcher, retryCallbacks: RetryCallbacks, ): RenderProps<Object> { return { error: error ? error : null, props: snapshot ? snapshot.data : null, retry: (cacheConfigOverride?: CacheConfig) => { const syncSnapshot = queryFetcher.retry(cacheConfigOverride); if ( syncSnapshot && typeof retryCallbacks.handleDataChange === 'function' ) { retryCallbacks.handleDataChange({snapshot: syncSnapshot}); } else if ( error && typeof retryCallbacks.handleRetryAfterError === 'function' ) { // If retrying after an error and no synchronous result available, // reset the render props retryCallbacks.handleRetryAfterError(error); } }, }; } function getRequestCacheKey( request: RequestParameters, variables: Variables, ): string { return JSON.stringify({ id: request.cacheID ? request.cacheID : request.id, variables, }); } function resetQueryStateForUpdate( props: Props, prevState: State, ): Partial<State> { const {query} = props; const prevSelectionReferences = prevState.queryFetcher.getSelectionReferences(); prevState.queryFetcher.disposeRequest(); let queryFetcher; if (query) { const request = getRequest(query); const requestCacheKey = getRequestCacheKey(request.params, props.variables); queryFetcher = requestCache[requestCacheKey] ? requestCache[requestCacheKey].queryFetcher : new ReactRelayQueryFetcher(prevSelectionReferences); } else { queryFetcher = new ReactRelayQueryFetcher(prevSelectionReferences); } return { prevQuery: props.query, prevPropsEnvironment: props.environment, prevPropsVariables: props.variables, queryFetcher: queryFetcher, ...fetchQueryAndComputeStateFromProps( props, queryFetcher, prevState.retryCallbacks, // passing no requestCacheKey will cause it to be recalculated internally // and we want the updated requestCacheKey, since variables may have changed ), }; } function fetchQueryAndComputeStateFromProps( props: Props, queryFetcher: ReactRelayQueryFetcher, retryCallbacks: RetryCallbacks, requestCacheKey: ?string, ): Partial<State> { const {environment, query, variables, cacheConfig} = props; const genericEnvironment: IEnvironment = environment; if (query) { const request = getRequest(query); const operation = createOperationDescriptor( request, variables, cacheConfig, ); const relayContext: RelayContext = { environment: genericEnvironment, }; if (typeof requestCacheKey === 'string' && requestCache[requestCacheKey]) { // This same request is already in flight. const {snapshot} = requestCache[requestCacheKey]; if (snapshot) { // Use the cached response return { error: null, relayContext, renderProps: getRenderProps( null, snapshot, queryFetcher, retryCallbacks, ), snapshot, requestCacheKey, }; } else { // Render loading state return { error: null, relayContext, renderProps: getLoadingRenderProps(), snapshot: null, requestCacheKey, }; } } try { const storeSnapshot = queryFetcher.lookupInStore( genericEnvironment, operation, props.fetchPolicy, ); const querySnapshot = queryFetcher.fetch({ environment: genericEnvironment, onDataChange: null, operation, }); // Use network data first, since it may be fresher const snapshot = querySnapshot || storeSnapshot; // cache the request to avoid duplicate requests requestCacheKey = requestCacheKey || getRequestCacheKey(request.params, props.variables); requestCache[requestCacheKey] = {queryFetcher, snapshot}; if (!snapshot) { return { error: null, relayContext, renderProps: getLoadingRenderProps(), snapshot: null, requestCacheKey, }; } return { error: null, relayContext, renderProps: getRenderProps( null, snapshot, queryFetcher, retryCallbacks, ), snapshot, requestCacheKey, }; } catch (error) { return { error, relayContext, renderProps: getRenderProps(error, null, queryFetcher, retryCallbacks), snapshot: null, requestCacheKey, }; } } else { queryFetcher.dispose(); const relayContext: RelayContext = { environment: genericEnvironment, }; return { error: null, relayContext, renderProps: getEmptyRenderProps(), requestCacheKey: null, // if there is an error, don't cache request }; } } module.exports = ReactRelayQueryRenderer;