Last active
July 29, 2019 16:04
Final code of https://medium.com/@janicduplessis/how-to-make-your-own-really-awesome-relay-queryrenderer-in-react-native-be9aa3c71dd3
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// @flow | |
import { StatusBar, Platform } from 'react-native'; | |
import { Network, Observable } from 'relay-runtime'; | |
import RelayQueryResponseCache from 'relay-runtime/lib/RelayQueryResponseCache'; | |
import { Sentry } from 'react-native-sentry'; | |
import config from './config'; | |
const ENDPOINT_URL = `${config.baseURL}/graphql`; | |
// Choose between network or store based cache. | |
const CACHE_ENABLED = false; | |
const _cache = new RelayQueryResponseCache({ size: 30, ttl: 5 * 60 * 1000 }); | |
async function fetchQuery(operation, variables, cacheConfig) { | |
const isQuery = operation.operationKind === 'query'; | |
if (CACHE_ENABLED) { | |
const cachedResponse = _cache.get(operation.name, variables); | |
if ( | |
isQuery && | |
cachedResponse !== null && | |
cacheConfig && | |
!cacheConfig.force | |
) { | |
return Promise.resolve(cachedResponse); | |
} | |
} | |
// Clear cache on mutations for now to avoid stale data. | |
if (CACHE_ENABLED && operation.operationKind === 'mutation') { | |
_cache.clear(); | |
} | |
if (!__DEV__) { | |
Sentry.captureBreadcrumb({ | |
message: `Relay: ${operation.name}`, | |
category: 'relay', | |
data: { | |
operation: operation.operationKind, | |
name: operation.name, | |
query: operation.text, | |
variables: JSON.stringify(variables, null, 2), | |
endpoint: ENDPOINT_URL, | |
}, | |
}); | |
} | |
if (Platform.OS === 'ios') { | |
StatusBar.setNetworkActivityIndicatorVisible(true); | |
} | |
const headers: Object = { | |
'Content-Type': 'application/json', | |
Accept: 'application/json', | |
}; | |
// Add user authentication... | |
try { | |
const response = await fetch(ENDPOINT_URL, { | |
method: 'POST', | |
headers, | |
body: JSON.stringify({ | |
query: operation.text, | |
variables, | |
}), | |
}).then(res => res.json()); | |
if (response.errors) { | |
throw new Error(response.errors[0].message); | |
} | |
if (CACHE_ENABLED && isQuery) { | |
_cache.set(operation.name, variables, response); | |
} | |
return response; | |
} finally { | |
if (Platform.OS === 'ios') { | |
StatusBar.setNetworkActivityIndicatorVisible(false); | |
} | |
} | |
} | |
export function clearCache() { | |
if (CACHE_ENABLED) { | |
_cache.clear(); | |
} | |
} | |
export default Network.create(fetchQuery); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Based on https://github.com/facebook/relay/blob/master/packages/react-relay/modern/ReactRelayQueryRenderer.js | |
/** | |
* Copyright (c) 2013-present, Facebook, Inc. | |
* All rights reserved. | |
* | |
* This source code is licensed under the BSD-style license found in the | |
* LICENSE file in the root directory of this source tree. An additional grant | |
* of patent rights can be found in the PATENTS file in the same directory. | |
* | |
* @noflow | |
* @format | |
*/ | |
/* eslint-disable */ | |
'use strict'; | |
const React = require('React'); | |
const ReactRelayQueryFetcher = require('react-relay/lib/ReactRelayQueryFetcher'); | |
const RelayPropTypes = require('react-relay/lib/RelayPropTypes'); | |
const areEqual = require('fbjs/lib/areEqual'); | |
const deepFreeze = require('react-relay/lib/deepFreeze'); | |
import type { CacheConfig } from '../classic/environment/RelayCombinedEnvironmentTypes'; | |
import type { RelayEnvironmentInterface as ClassicEnvironment } from '../classic/store/RelayEnvironment'; | |
import type { DataFrom } from './ReactRelayQueryFetcher'; | |
import type { | |
GraphQLTaggedNode, | |
IEnvironment, | |
RelayContext, | |
Snapshot, | |
Variables, | |
} from 'RelayRuntime'; | |
export type RenderProps = { | |
error: ?Error, | |
props: ?Object, | |
retry: ?() => void, | |
}; | |
function getLoadingRenderProps(): RenderProps { | |
return { | |
error: null, | |
props: null, // `props: null` indicates that the data is being fetched (i.e. loading) | |
retry: null, | |
stale: false, | |
}; | |
} | |
function getEmptyRenderProps(): RenderProps { | |
return { | |
error: null, | |
props: {}, // `props: {}` indicates no data available | |
retry: null, | |
stale: false, | |
}; | |
} | |
export type Props = { | |
cacheConfig?: ?CacheConfig, | |
dataFrom?: DataFrom, | |
environment: IEnvironment | ClassicEnvironment, | |
query: ?GraphQLTaggedNode, | |
render: (renderProps: RenderProps) => React.Node, | |
variables: Variables, | |
renderStaleVariables?: boolean, | |
}; | |
type State = { | |
renderProps: RenderProps, | |
}; | |
/** | |
* @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> { | |
_queryFetcher: ReactRelayQueryFetcher = new ReactRelayQueryFetcher(); | |
_relayContext: RelayContext; | |
constructor(props: Props, context: Object) { | |
super(props, context); | |
this.state = { renderProps: this._fetchForProps(this.props) }; | |
} | |
componentWillReceiveProps(nextProps: Props): void { | |
if ( | |
nextProps.query !== this.props.query || | |
nextProps.environment !== this.props.environment || | |
!areEqual(nextProps.variables, this.props.variables) | |
) { | |
this.setState({ | |
renderProps: this._fetchForProps(nextProps), | |
}); | |
} | |
} | |
componentWillUnmount(): void { | |
this._queryFetcher.dispose(); | |
} | |
shouldComponentUpdate(nextProps: Props, nextState: State): boolean { | |
return ( | |
nextProps.render !== this.props.render || | |
nextState.renderProps !== this.state.renderProps | |
); | |
} | |
refresh() { | |
this._fetchForProps({ | |
...this.props, | |
cacheConfig: { force: true }, | |
dataFrom: 'NETWORK', | |
}); | |
} | |
_getRenderProps({ snapshot, error }: { snapshot?: Snapshot, error?: Error }) { | |
return { | |
error: error ? error : null, | |
props: snapshot ? snapshot.data : null, | |
retry: () => { | |
const syncSnapshot = this._queryFetcher.retry(); | |
if (syncSnapshot) { | |
this._onDataChange({ snapshot: syncSnapshot }); | |
} else if (error) { | |
// If retrying after an error and no synchronous result available, | |
// reset the render props | |
this.setState({ renderProps: getLoadingRenderProps() }); | |
} | |
}, | |
stale: false, | |
}; | |
} | |
_fetchForProps(props: Props): RenderProps { | |
// TODO (#16225453) QueryRenderer works with old and new environment, but | |
// the flow typing doesn't quite work abstracted. | |
// $FlowFixMe | |
const environment: IEnvironment = props.environment; | |
const { query, variables } = props; | |
if (query) { | |
const { | |
createOperationSelector, | |
getRequest, | |
} = environment.unstable_internal; | |
const request = getRequest(query); | |
const operation = createOperationSelector(request, variables); | |
const renderingStaleVariables = | |
props.renderStaleVariables && | |
this._relayContext && | |
this.props.environment === props.environment; | |
if (!renderingStaleVariables) { | |
this._relayContext = { | |
environment, | |
variables: operation.variables, | |
}; | |
} | |
try { | |
const snapshot = this._queryFetcher.fetch({ | |
cacheConfig: props.cacheConfig, | |
dataFrom: props.dataFrom, | |
environment, | |
onDataChange: this._onDataChange, | |
operation, | |
}); | |
if (snapshot) { | |
if (renderingStaleVariables) { | |
this._relayContext = { | |
environment, | |
variables: operation.variables, | |
}; | |
} | |
return this._getRenderProps({ snapshot }); | |
} | |
if (renderingStaleVariables) { | |
return { | |
...this.state.renderProps, | |
stale: true, | |
}; | |
} | |
return getLoadingRenderProps(); | |
} catch (error) { | |
return this._getRenderProps({ error }); | |
} | |
} | |
this._relayContext = { | |
environment, | |
variables, | |
}; | |
this._queryFetcher.dispose(); | |
return getEmptyRenderProps(); | |
} | |
_onDataChange = ({ | |
error, | |
snapshot, | |
}: { | |
error?: Error, | |
snapshot?: Snapshot, | |
}): void => { | |
if (this.props.renderStaleVariables) { | |
this._relayContext = { | |
environment: this.props.environment, | |
variables: this.props.variables, | |
}; | |
} | |
this.setState({ renderProps: this._getRenderProps({ error, snapshot }) }); | |
}; | |
getChildContext(): Object { | |
return { | |
relay: this._relayContext, | |
}; | |
} | |
render() { | |
// 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(this.state.renderProps); | |
} | |
return this.props.render(this.state.renderProps); | |
} | |
} | |
ReactRelayQueryRenderer.childContextTypes = { | |
relay: RelayPropTypes.Relay, | |
}; | |
module.exports = ReactRelayQueryRenderer; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// @flow | |
import * as React from 'react'; | |
import { Animated, AppState, AsyncStorage, NetInfo } from 'react-native'; | |
import { Environment, RecordSource, Store } from 'relay-runtime'; | |
import { Sentry } from 'react-native-sentry'; | |
import DeviceInfo from 'react-native-device-info'; | |
import network from './network'; | |
import ErrorView from './components/ErrorView'; | |
import LoadingView from './components/LoadingView'; | |
import QueryRenderer from './QueryRenderer'; | |
import * as Log from './utils/LogUtils'; | |
import appConfig from './config'; | |
const createRelayEnvironment = data => { | |
const source = new RecordSource(data); | |
const relayStore = new Store(source); | |
relayStore.__disableGC(); | |
const env = new Environment({ | |
network, | |
store: relayStore, | |
}); | |
return env; | |
}; | |
// The disk cache implementation is still somewhat experimental | |
// and not optimal, ideally operations would be saved to disk | |
// one by one, this simply serializes the whole store when the | |
// app enters background and reloaded in the initialize method | |
// before content is rendered. | |
// | |
// To make sure the cache doesn't grow too big we expire it after | |
// a certain time and limit it to a certain size. Also clear it | |
// on memory warning and environment / app version changes. | |
const ONE_DAY = 24 * 60 * 60 * 1000; | |
const MAX_STORE_SIZE = 1 * 1024 * 1024; // 1mb, TODO: figure out how big this can be. | |
const RELAY_STORE_KEY = 'relay_store'; | |
const RELAY_STORE_META_KEY = 'relay_store_meta'; | |
const DEFAULT_META = { | |
lastGC: Date.now(), | |
environment: appConfig.environment, | |
appVersion: DeviceInfo.getVersion(), | |
}; | |
let _relayEnvironment; | |
let _relayStoreMeta = DEFAULT_META; | |
export async function initialize() { | |
try { | |
// For now we will be conservative with the offline cache and purge it after | |
// one day. | |
const storeMetaJSON = await AsyncStorage.getItem(RELAY_STORE_META_KEY); | |
_relayStoreMeta = storeMetaJSON ? JSON.parse(storeMetaJSON) : DEFAULT_META; | |
if ( | |
Date.now() - _relayStoreMeta.lastGC > ONE_DAY || | |
_relayStoreMeta.appVersion !== DeviceInfo.getVersion() || | |
_relayStoreMeta.environment !== appConfig.environment | |
) { | |
doStoreGC(); | |
_relayEnvironment = createRelayEnvironment(); | |
} else { | |
const serializedData = await AsyncStorage.getItem(RELAY_STORE_KEY); | |
_relayEnvironment = createRelayEnvironment( | |
serializedData ? JSON.parse(serializedData) : null, | |
); | |
} | |
} catch (err) { | |
Log.error(err); | |
_relayEnvironment = createRelayEnvironment(); | |
} | |
} | |
async function saveStore() { | |
if (!_relayEnvironment) { | |
return; | |
} | |
const seralizedData = JSON.stringify( | |
_relayEnvironment.getStore().getSource(), | |
); | |
if (seralizedData.length > MAX_STORE_SIZE) { | |
doStoreGC(); | |
} else { | |
AsyncStorage.setItem(RELAY_STORE_KEY, seralizedData); | |
updateStoreMeta({ | |
environment: appConfig.environment, | |
appVersion: DeviceInfo.getVersion(), | |
}); | |
} | |
} | |
function updateStoreMeta(data) { | |
_relayStoreMeta = { | |
..._relayStoreMeta, | |
...data, | |
}; | |
AsyncStorage.setItem(RELAY_STORE_META_KEY, JSON.stringify(_relayStoreMeta)); | |
} | |
function doStoreGC() { | |
if (_relayEnvironment) { | |
_relayEnvironment.getStore()._gc(); | |
} | |
AsyncStorage.removeItem(RELAY_STORE_KEY); | |
updateStoreMeta({ lastGC: Date.now() }); | |
} | |
export function getStore() { | |
return _relayEnvironment; | |
} | |
// Since we don't use GC run it manually on low memory. | |
AppState.addListener('memoryWarning', doStoreGC); | |
// Persist relay store fully on app background, see if we can also do this | |
// more ofter or better on each operation granually. | |
AppState.addEventListener('change', nextAppState => { | |
if (nextAppState === 'background') { | |
saveStore(); | |
} | |
}); | |
class FadeInWrapper extends React.Component< | |
{ | |
enabled: boolean, | |
}, | |
{ | |
anim: Animated.Value, | |
}, | |
> { | |
state = { | |
anim: new Animated.Value(this.props.enabled ? 0 : 1), | |
}; | |
componentDidMount() { | |
if (this.props.enabled) { | |
Animated.timing(this.state.anim, { | |
toValue: 1, | |
duration: 300, | |
useNativeDriver: true, | |
}).start(); | |
} | |
} | |
render() { | |
return ( | |
<Animated.View style={{ opacity: this.state.anim, flex: 1 }}> | |
{this.props.children} | |
</Animated.View> | |
); | |
} | |
} | |
const REFRESH_INTERVAL = 5 * 60 * 1000; | |
type RendererState = { | |
hasError: boolean, | |
relayEnvironment: any, | |
}; | |
export class Renderer extends React.Component<$FlowFixMe, RendererState> { | |
static defaultProps = { | |
renderLoading: () => <LoadingView />, | |
renderFailure: (error, retry) => <ErrorView error={error} retry={retry} />, | |
}; | |
state = { | |
relayEnvironment: _relayEnvironment, | |
hasError: false, | |
}; | |
_showedLoading = false; | |
_showedLoadingTimeout: any; | |
_appState = AppState.currentState; | |
_renderer: ?QueryRenderer; | |
_lastRefresh = Date.now(); | |
_isConnected: ?boolean = null; | |
componentDidMount() { | |
AppState.addEventListener('change', this._handleAppStateChange); | |
NetInfo.isConnected.addEventListener( | |
'connectionChange', | |
this._handleFirstConnectivityChange, | |
); | |
} | |
componentWillUnmount() { | |
AppState.removeEventListener('change', this._handleAppStateChange); | |
NetInfo.isConnected.removeEventListener( | |
'connectionChange', | |
this._handleFirstConnectivityChange, | |
); | |
} | |
componentDidCatch(error) { | |
this.setState({ hasError: true }); | |
if (!__DEV__) { | |
Sentry.captureException(error); | |
} | |
} | |
refresh() { | |
if (this._renderer) { | |
this._renderer.refresh(); | |
} | |
} | |
_handleAppStateChange = nextAppState => { | |
const now = Date.now(); | |
if ( | |
this._lastRefresh + REFRESH_INTERVAL < now && | |
(this._appState === 'background' || this._appState === 'inactive') && | |
nextAppState === 'active' && | |
this._renderer | |
) { | |
this._lastRefresh = now; | |
this._renderer.refresh(); | |
} | |
this._appState = nextAppState; | |
}; | |
_handleFirstConnectivityChange = isConnected => { | |
if (this._isConnected === false && isConnected && this._renderer) { | |
this._renderer.refresh(); | |
} | |
this._isConnected = isConnected; | |
}; | |
_clearError = () => { | |
this.setState({ hasError: false }); | |
}; | |
_setRef = ref => { | |
this._renderer = ref; | |
}; | |
render() { | |
const { | |
query, | |
cacheConfig, | |
render, | |
renderLoading, | |
renderFailure, | |
renderStaleVariables = true, | |
variables, | |
dataFrom = 'STORE_THEN_NETWORK', | |
} = this.props; | |
const renderInternal = ({ error, props, retry, stale }) => { | |
if (error) { | |
if (renderFailure) { | |
return renderFailure(error, retry); | |
} | |
} else if (props) { | |
clearTimeout(this._showedLoadingTimeout); | |
return ( | |
<FadeInWrapper enabled={this._showedLoading}> | |
{render(props, stale)} | |
</FadeInWrapper> | |
); | |
} else if (renderLoading) { | |
// This is called everytime even if data is in cache so wait 100 ms | |
// to see if we actually loaded from network. | |
this._showedLoadingTimeout = setTimeout( | |
() => (this._showedLoading = true), | |
100, | |
); | |
return renderLoading(); | |
} | |
return undefined; | |
}; | |
if (this.state.hasError) { | |
// Render some error view. | |
} | |
return ( | |
<QueryRenderer | |
ref={this._setRef} | |
query={query} | |
dataFrom={dataFrom} | |
environment={this.state.relayEnvironment} | |
render={renderInternal} | |
variables={variables} | |
cacheConfig={cacheConfig} | |
renderStaleVariables={renderStaleVariables} | |
/> | |
); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment