diff --git a/packages/graphql-playground-react/package.json b/packages/graphql-playground-react/package.json index d5e8d9d43..9b7af6571 100644 --- a/packages/graphql-playground-react/package.json +++ b/packages/graphql-playground-react/package.json @@ -105,6 +105,7 @@ "why-did-you-update": "0.1.1" }, "dependencies": { + "@types/lru-cache": "^4.1.1", "apollo-link": "^1.0.7", "apollo-link-http": "^1.3.2", "apollo-link-ws": "1.0.8", diff --git a/packages/graphql-playground-react/src/components/Playground.tsx b/packages/graphql-playground-react/src/components/Playground.tsx index 2b33a3d9b..df2855370 100644 --- a/packages/graphql-playground-react/src/components/Playground.tsx +++ b/packages/graphql-playground-react/src/components/Playground.tsx @@ -27,7 +27,7 @@ import { } from '../state/sessions/actions' import { setConfigString } from '../state/general/actions' import { initState } from '../state/workspace/actions' -import { GraphQLSchema, printSchema } from 'graphql' +import { GraphQLSchema } from 'graphql' import { createStructuredSelector } from 'reselect' import { getIsConfigTab, @@ -50,6 +50,7 @@ import { getWorkspaceId } from './Playground/util/getWorkspaceId' import { getSettings, getSettingsString } from '../state/workspace/reducers' import { Backoff } from './Playground/util/fibonacci-backoff' import { debounce } from 'lodash' +import { cachedPrintSchema } from './util' export interface Response { resultID: string @@ -173,6 +174,7 @@ export class Playground extends React.PureComponent { private backoff: Backoff private initialIndex: number = -1 private mounted = false + private initialSchemaFetch = true constructor(props: Props & ReduxProps) { super(props) @@ -270,11 +272,14 @@ export class Playground extends React.PureComponent { }) if (schema) { this.updateSchema(currentSchema, schema.schema, props) - this.props.schemaFetchingSuccess( - data.endpoint, - schema.tracingSupported, - props.isPollingSchema, - ) + if (this.initialSchemaFetch) { + this.props.schemaFetchingSuccess( + data.endpoint, + schema.tracingSupported, + props.isPollingSchema, + ) + this.initialSchemaFetch = false + } this.backoff.stop() } } catch (e) { @@ -367,10 +372,17 @@ export class Playground extends React.PureComponent { props: Readonly<{ children?: React.ReactNode }> & Readonly, ) { - const currentSchemaStr = currentSchema ? printSchema(currentSchema) : null - const newSchemaStr = printSchema(newSchema) - if (newSchemaStr !== currentSchemaStr || !props.isPollingSchema) { - this.setState({ schema: newSchema }) + // first check for reference equality + if (currentSchema !== newSchema) { + // if references are not equal, do an equality check on the printed schema + const currentSchemaStr = currentSchema + ? cachedPrintSchema(currentSchema) + : null + const newSchemaStr = cachedPrintSchema(newSchema) + + if (newSchemaStr !== currentSchemaStr || !props.isPollingSchema) { + this.setState({ schema: newSchema }) + } } } diff --git a/packages/graphql-playground-react/src/components/Playground/SchemaFetcher.ts b/packages/graphql-playground-react/src/components/Playground/SchemaFetcher.ts index 938fcd6d0..51a1c8da5 100644 --- a/packages/graphql-playground-react/src/components/Playground/SchemaFetcher.ts +++ b/packages/graphql-playground-react/src/components/Playground/SchemaFetcher.ts @@ -5,6 +5,7 @@ import { Map, set } from 'immutable' import { makeOperation } from './util/makeOperation' import { parseHeaders } from './util/parseHeaders' import { LinkCreatorProps } from '../../state/sessions/fetchingSagas' +import * as LRU from 'lru-cache' export interface TracingSchemaTuple { schema: GraphQLSchema @@ -18,19 +19,53 @@ export interface SchemaFetchProps { export type LinkGetter = (session: LinkCreatorProps) => { link: ApolloLink } +/** + * The SchemaFetcher class servers the purpose of providing the GraphQLSchema. + * All sagas and every part of the UI is using this as a singleton to prevent + * unnecessary calls to the server. We're not storing this information in Redux, + * as it's a good practice to only store serializable data in Redux. + * GraphQLSchema objects are serializable, but can easily exceed the localStorage + * max. Another reason to keep this in a separate class is, that we have more + * advanced requirements like caching. + */ export class SchemaFetcher { - cache: Map + /** + * The `sessionCache` property is used for UI components, that need fast access to the current schema. + * If the relevant information of the session didn't change (endpoint and headers), + * the cached schema will be returned. + */ + sessionCache: LRU.Cache + /** + * The `schemaInstanceCache` property is used to prevent unnecessary buildClientSchema calls. + * It's tested by stringifying the introspection result, which is orders of magnitude + * faster than rebuilding the schema. + */ + schemaInstanceCache: LRU.Cache + /** + * The `linkGetter` property is a callback that provides an ApolloLink instance. + * This can be overriden by the user. + */ linkGetter: LinkGetter + /** + * In order to prevent duplicate fetching of the same schema, we keep track + * of all subsequent calls to `.fetch` with the `fetching` property. + */ fetching: Map> + /** + * Other parts of the application can subscribe to change of a schema for a + * particular session. These subscribers are being kept track of in the + * `subscriptions` property + */ subscriptions: Map void> = Map() constructor(linkGetter: LinkGetter) { - this.cache = Map() + this.sessionCache = new LRU({ max: 10 }) + this.schemaInstanceCache = new LRU({ max: 10 }) this.fetching = Map() this.linkGetter = linkGetter } async fetch(session: SchemaFetchProps) { const hash = this.hash(session) - const cachedSchema = this.cache.get(hash) + const cachedSchema = this.sessionCache.get(hash) if (cachedSchema) { return cachedSchema } @@ -52,6 +87,19 @@ export class SchemaFetcher { hash(session: SchemaFetchProps) { return `${session.endpoint}~${session.headers || ''}` } + private getSchema(data: any) { + const schemaString = JSON.stringify(data) + const cachedSchema = this.schemaInstanceCache.get(schemaString) + if (cachedSchema) { + return cachedSchema + } + + const schema = buildClientSchema(data as any) + + this.schemaInstanceCache.set(schemaString, schema) + + return schema + } private fetchSchema( session: SchemaFetchProps, ): Promise<{ schema: GraphQLSchema; tracingSupported: boolean } | null> { @@ -83,15 +131,15 @@ export class SchemaFetcher { throw new NoSchemaError(endpoint) } - const schema = buildClientSchema(schemaData.data as any) + const schema = this.getSchema(schemaData.data as any) const tracingSupported = (schemaData.extensions && Boolean(schemaData.extensions.tracing)) || false - const result = { + const result: TracingSchemaTuple = { schema, tracingSupported, } - this.cache = this.cache.set(this.hash(session), result) + this.sessionCache.set(this.hash(session), result) resolve(result) this.fetching = this.fetching.remove(hash) const subscription = this.subscriptions.get(hash) diff --git a/packages/graphql-playground-react/src/components/Playground/TopBar/Polling.tsx b/packages/graphql-playground-react/src/components/Playground/TopBar/Polling.tsx index 395f7e861..2bca34034 100644 --- a/packages/graphql-playground-react/src/components/Playground/TopBar/Polling.tsx +++ b/packages/graphql-playground-react/src/components/Playground/TopBar/Polling.tsx @@ -3,7 +3,6 @@ import PollingIcon from './PollingIcon' export interface Props { interval: number - isReloadingSchema: boolean onReloadSchema: () => void } @@ -47,9 +46,7 @@ class SchemaPolling extends React.Component { } } componentWillReceiveProps(nextProps: Props) { - if (nextProps.isReloadingSchema !== this.props.isReloadingSchema) { - this.updatePolling(nextProps) - } + this.updatePolling(nextProps) } render() { @@ -57,7 +54,7 @@ class SchemaPolling extends React.Component { } private updatePolling = (props: Props = this.props) => { this.clearTimer() - if (!props.isReloadingSchema && this.state.windowVisible) { + if (this.state.windowVisible) { // timer starts only when introspection not in flight this.timer = setInterval(() => props.onReloadSchema(), props.interval) } diff --git a/packages/graphql-playground-react/src/components/Playground/TopBar/SchemaReload.tsx b/packages/graphql-playground-react/src/components/Playground/TopBar/SchemaReload.tsx index a56e678ed..cc48a429e 100644 --- a/packages/graphql-playground-react/src/components/Playground/TopBar/SchemaReload.tsx +++ b/packages/graphql-playground-react/src/components/Playground/TopBar/SchemaReload.tsx @@ -2,6 +2,9 @@ import * as React from 'react' import ReloadIcon from './Reload' import Polling from './Polling' import { ISettings } from '../../../types' +import { createStructuredSelector } from 'reselect' +import { getIsReloadingSchema } from '../../../state/sessions/selectors' +import { connect } from 'react-redux' export interface Props { isPollingSchema: boolean @@ -10,12 +13,11 @@ export interface Props { settings: ISettings } -export default (props: Props) => { +const SchemaReload = (props: Props) => { if (props.isPollingSchema) { return ( ) @@ -27,3 +29,9 @@ export default (props: Props) => { /> ) } + +const mapStateToProps = createStructuredSelector({ + isReloadingSchema: getIsReloadingSchema, +}) + +export default connect(mapStateToProps)(SchemaReload) diff --git a/packages/graphql-playground-react/src/components/Playground/TopBar/TopBar.tsx b/packages/graphql-playground-react/src/components/Playground/TopBar/TopBar.tsx index e5ce6e211..1236efaeb 100644 --- a/packages/graphql-playground-react/src/components/Playground/TopBar/TopBar.tsx +++ b/packages/graphql-playground-react/src/components/Playground/TopBar/TopBar.tsx @@ -8,7 +8,6 @@ import { createStructuredSelector } from 'reselect' import { getEndpoint, getSelectedSession, - getIsReloadingSchema, getEndpointUnreachable, getIsPollingSchema, } from '../../../state/sessions/selectors' @@ -29,7 +28,6 @@ export interface Props { endpoint: string shareEnabled?: boolean fixedEndpoint?: boolean - isReloadingSchema: boolean isPollingSchema: boolean endpointUnreachable: boolean @@ -83,7 +81,6 @@ class TopBar extends React.Component { @@ -157,7 +154,6 @@ class TopBar extends React.Component { const mapStateToProps = createStructuredSelector({ endpoint: getEndpoint, fixedEndpoint: getFixedEndpoint, - isReloadingSchema: getIsReloadingSchema, isPollingSchema: getIsPollingSchema, endpointUnreachable: getEndpointUnreachable, settings: getSettings, diff --git a/packages/graphql-playground-react/src/components/PlaygroundWrapper.tsx b/packages/graphql-playground-react/src/components/PlaygroundWrapper.tsx index 40b999d7c..7bf3c0445 100644 --- a/packages/graphql-playground-react/src/components/PlaygroundWrapper.tsx +++ b/packages/graphql-playground-react/src/components/PlaygroundWrapper.tsx @@ -427,10 +427,8 @@ class PlaygroundWrapper extends React.Component< handleSaveConfig = () => { /* tslint:disable-next-line */ - console.log('handleSaveConfig called') if (typeof this.props.onSaveConfig === 'function') { /* tslint:disable-next-line */ - console.log('calling this.props.onSaveConfig', this.state.configString) this.props.onSaveConfig(this.state.configString!) } } diff --git a/packages/graphql-playground-react/src/components/util.ts b/packages/graphql-playground-react/src/components/util.ts index 2e818134e..d5751c399 100644 --- a/packages/graphql-playground-react/src/components/util.ts +++ b/packages/graphql-playground-react/src/components/util.ts @@ -1,4 +1,6 @@ import { GraphQLConfig, GraphQLConfigEnpointConfig } from '../graphqlConfig' +import { GraphQLSchema, printSchema } from 'graphql' +import * as LRU from 'lru-cache' export function getActiveEndpoints( config: GraphQLConfig, @@ -32,3 +34,20 @@ export function getEndpointFromEndpointConfig( } } } + +const printSchemaCache: LRU.Cache = new LRU({ max: 10 }) +/** + * A cached version of `printSchema` + * @param schema GraphQLSchema instance + */ +export function cachedPrintSchema(schema: GraphQLSchema) { + const cachedString = printSchemaCache.get(schema) + if (cachedString) { + return cachedString + } + + const schemaString = printSchema(schema) + printSchemaCache.set(schema, schemaString) + + return schemaString +} diff --git a/packages/graphql-playground-react/src/state/sessions/reducers.ts b/packages/graphql-playground-react/src/state/sessions/reducers.ts index 1d353b394..9989a608e 100644 --- a/packages/graphql-playground-react/src/state/sessions/reducers.ts +++ b/packages/graphql-playground-react/src/state/sessions/reducers.ts @@ -142,10 +142,12 @@ export class ResponseRecord extends Record({ resultID: '', date: '', time: new Date(), + isSchemaError: false, }) { resultID: string date: string time: Date + isSchemaError: boolean } function makeSession(endpoint = '') { @@ -301,31 +303,26 @@ const reducer = handleActions( return state }, SCHEMA_FETCHING_SUCCESS: (state, { payload }) => { - const newSessions = state - .get('sessions') - .map((session: Session, sessionId) => { - if (session.endpoint === payload.endpoint) { - // if there was an error, clear it away - const data: any = { - tracingSupported: payload.tracingSupported, - isReloadingSchema: false, - endpointUnreachable: false, - } - const response = session.responses - ? session.responses!.first() - : null - if ( - response && - session.responses!.size === 1 && - ((response.date.includes('error') && !payload.isPollingSchema) || - response.date.includes('Failed to fetch')) - ) { - data.responses = List([]) - } - return session.merge(Map(data)) + const newSessions = state.get('sessions').map((session: Session) => { + if (session.endpoint === payload.endpoint) { + // if there was an error, clear it away + const data: any = { + tracingSupported: payload.tracingSupported, + isReloadingSchema: false, + endpointUnreachable: false, } - return session - }) + const response = session.responses ? session.responses!.first() : null + if ( + response && + session.responses!.size === 1 && + response.isSchemaError + ) { + data.responses = List([]) + } + return session.merge(Map(data)) + } + return session + }) return state.set('sessions', newSessions) }, SET_ENDPOINT_UNREACHABLE: (state, { payload }) => { @@ -344,21 +341,28 @@ const reducer = handleActions( SCHEMA_FETCHING_ERROR: (state, { payload }) => { const newSessions = state.get('sessions').map((session, sessionId) => { if (session.get('endpoint') === payload.endpoint) { + let { responses } = session + + // Only override the responses if there is one or zero and that one is a schemaError + // Don't override user's responses! + if (responses.size <= 1) { + let response = session.responses ? session.responses!.first() : null + if (!response || response.isSchemaError) { + response = new ResponseRecord({ + resultID: cuid(), + isSchemaError: true, + date: JSON.stringify(formatError(payload.error, true), null, 2), + time: new Date(), + }) + } + responses = List([response]) + } + return session.merge( Map({ isReloadingSchema: false, endpointUnreachable: true, - responses: List([ - new ResponseRecord({ - resultID: cuid(), - date: JSON.stringify( - formatError(payload.error, true), - null, - 2, - ), - time: new Date(), - }), - ]), + responses, }), ) } diff --git a/packages/graphql-playground-react/src/state/sessions/sagas.ts b/packages/graphql-playground-react/src/state/sessions/sagas.ts index 905e8ec03..dffb4eea1 100644 --- a/packages/graphql-playground-react/src/state/sessions/sagas.ts +++ b/packages/graphql-playground-react/src/state/sessions/sagas.ts @@ -18,10 +18,12 @@ import { setOperationName, schemaFetchingSuccess, schemaFetchingError, - fetchSchema, + // fetchSchema, runQuery, setTracingSupported, setQueryTypes, + refetchSchema, + fetchSchema, } from './actions' import { getRootMap, getNewStack } from '../../components/Playground/util/stack' import { DocsSessionState } from '../docs/reducers' @@ -137,8 +139,8 @@ function* getSessionWithCredentials() { function* fetchSchemaSaga() { const session: Session = yield getSessionWithCredentials() - yield schemaFetcher.fetch(session) try { + yield schemaFetcher.fetch(session) yield put( schemaFetchingSuccess( session.endpoint, @@ -155,8 +157,8 @@ function* fetchSchemaSaga() { function* refetchSchemaSaga() { const session: Session = yield getSessionWithCredentials() - yield schemaFetcher.refetch(session) try { + yield schemaFetcher.refetch(session) yield put( schemaFetchingSuccess( session.endpoint, @@ -167,23 +169,26 @@ function* refetchSchemaSaga() { } catch (e) { yield put(schemaFetchingError(session.endpoint)) yield call(delay, 5000) - yield put(fetchSchema()) + yield put(refetchSchema()) } } +let lastSchema + function* renewStacks() { const session: Session = yield select(getSelectedSession) const fetchSession = yield getSessionWithCredentials() const docs: DocsSessionState = yield select(getSessionDocsState) const result = yield schemaFetcher.fetch(fetchSession) const { schema, tracingSupported } = result - if (schema) { + if (schema && (!lastSchema || lastSchema !== schema)) { const rootMap = getRootMap(schema) const stacks = docs.navStack .map(stack => getNewStack(rootMap, schema, stack)) .filter(s => s) yield put(setStacks(session.id, stacks)) yield put(setTracingSupported(tracingSupported)) + lastSchema = schema } } @@ -211,7 +216,7 @@ function* prettifyQuery() { }) yield put(editQuery(prettyQuery)) } catch (e) { - // TODO show erros somewhere + // TODO show errors somewhere // tslint:disable-next-line console.log(e) } diff --git a/packages/graphql-playground-react/src/state/sessions/selectors.ts b/packages/graphql-playground-react/src/state/sessions/selectors.ts index 8430eae7c..c65b24397 100644 --- a/packages/graphql-playground-react/src/state/sessions/selectors.ts +++ b/packages/graphql-playground-react/src/state/sessions/selectors.ts @@ -71,11 +71,15 @@ export const getIsPollingSchema = createSelector( [getEndpoint, getSettings], (endpoint, settings) => { const json = JSON.parse(settings) - return ( - json['schema.polling.enable'] && - endpoint.match(`/${json['schema.polling.endpointFilter']}`) && - true - ) + try { + const isPolling = + json['schema.polling.enable'] && + endpoint.match(`/${json['schema.polling.endpointFilter']}`) && + true + return isPolling + } catch (e) { + return false + } }, ) diff --git a/packages/graphql-playground-react/src/state/workspace/deserialize.ts b/packages/graphql-playground-react/src/state/workspace/deserialize.ts index ec73d0f87..99a0488bb 100644 --- a/packages/graphql-playground-react/src/state/workspace/deserialize.ts +++ b/packages/graphql-playground-react/src/state/workspace/deserialize.ts @@ -92,7 +92,11 @@ function deserializeSession(session) { } function deserializeResponses(responses) { - return List(responses.map(response => deserializeResponse(response))) + return List( + responses + .filter(r => r.isSchemaError) + .map(response => deserializeResponse(response)), + ) } function deserializeResponse(response) { @@ -100,6 +104,7 @@ function deserializeResponse(response) { resultID: response.resultID, date: response.date, time: new Date(response.time), + isSchemaError: response.isSchemaError || false, }) } diff --git a/packages/graphql-playground-react/yarn.lock b/packages/graphql-playground-react/yarn.lock index 4e03bb8a7..29a81100a 100644 --- a/packages/graphql-playground-react/yarn.lock +++ b/packages/graphql-playground-react/yarn.lock @@ -58,6 +58,11 @@ version "4.14.92" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.92.tgz#6e3cb0b71a1e12180a47a42a744e856c3ae99a57" +"@types/lru-cache@^4.1.1": + version "4.1.1" + resolved "https://registry.yarnpkg.com/@types/lru-cache/-/lru-cache-4.1.1.tgz#b2d87a5e3df8d4b18ca426c5105cd701c2306d40" + integrity sha512-8mNEUG6diOrI6pMqOHrHPDBB1JsrpedeMK9AWGzVCQ7StRRribiT9BRvUmF8aUws9iBbVlgVekOT5Sgzc1MTKw== + "@types/node@*": version "9.3.0" resolved "https://registry.yarnpkg.com/@types/node/-/node-9.3.0.tgz#3a129cda7c4e5df2409702626892cb4b96546dd5"