Skip to content

Instantly share code, notes, and snippets.

@helielson
Forked from janicduplessis/QueryRenderer.js
Created October 4, 2018 14:40
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save helielson/c3bc0ffebc540d0a6661a83716990289 to your computer and use it in GitHub Desktop.
Save helielson/c3bc0ffebc540d0a6661a83716990289 to your computer and use it in GitHub Desktop.
// @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);
// 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;
// @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