Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make schema reloading more resilient. Closes #940 #951

Merged
merged 9 commits into from
Feb 1, 2019
1 change: 1 addition & 0 deletions packages/graphql-playground-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
32 changes: 22 additions & 10 deletions packages/graphql-playground-react/src/components/Playground.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -173,6 +174,7 @@ export class Playground extends React.PureComponent<Props & ReduxProps, State> {
private backoff: Backoff
private initialIndex: number = -1
private mounted = false
private initialSchemaFetch = true

constructor(props: Props & ReduxProps) {
super(props)
Expand Down Expand Up @@ -270,11 +272,14 @@ export class Playground extends React.PureComponent<Props & ReduxProps, State> {
})
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) {
Expand Down Expand Up @@ -367,10 +372,17 @@ export class Playground extends React.PureComponent<Props & ReduxProps, State> {
props: Readonly<{ children?: React.ReactNode }> &
Readonly<Props & ReduxProps>,
) {
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 })
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<string, TracingSchemaTuple>
/**
* 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<string, TracingSchemaTuple>
/**
* 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<string, GraphQLSchema>
/**
* 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<string, Promise<any>>
/**
* 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<string, (schema: GraphQLSchema) => void> = Map()
constructor(linkGetter: LinkGetter) {
this.cache = Map()
this.sessionCache = new LRU<string, TracingSchemaTuple>({ 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
}
Expand All @@ -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> {
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import PollingIcon from './PollingIcon'

export interface Props {
interval: number
isReloadingSchema: boolean
onReloadSchema: () => void
}

Expand Down Expand Up @@ -47,17 +46,15 @@ class SchemaPolling extends React.Component<Props, State> {
}
}
componentWillReceiveProps(nextProps: Props) {
if (nextProps.isReloadingSchema !== this.props.isReloadingSchema) {
this.updatePolling(nextProps)
}
this.updatePolling(nextProps)
}

render() {
return <PollingIcon animate={this.state.windowVisible} />
}
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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -10,12 +13,11 @@ export interface Props {
settings: ISettings
}

export default (props: Props) => {
const SchemaReload = (props: Props) => {
if (props.isPollingSchema) {
return (
<Polling
interval={props.settings['schema.polling.interval']}
isReloadingSchema={props.isReloadingSchema}
onReloadSchema={props.onReloadSchema}
/>
)
Expand All @@ -27,3 +29,9 @@ export default (props: Props) => {
/>
)
}

const mapStateToProps = createStructuredSelector({
isReloadingSchema: getIsReloadingSchema,
})

export default connect(mapStateToProps)(SchemaReload)
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { createStructuredSelector } from 'reselect'
import {
getEndpoint,
getSelectedSession,
getIsReloadingSchema,
getEndpointUnreachable,
getIsPollingSchema,
} from '../../../state/sessions/selectors'
Expand All @@ -29,7 +28,6 @@ export interface Props {
endpoint: string
shareEnabled?: boolean
fixedEndpoint?: boolean
isReloadingSchema: boolean
isPollingSchema: boolean
endpointUnreachable: boolean

Expand Down Expand Up @@ -83,7 +81,6 @@ class TopBar extends React.Component<Props, {}> {
<SchemaReload
settings={settings}
isPollingSchema={this.props.isPollingSchema}
isReloadingSchema={this.props.isReloadingSchema}
onReloadSchema={this.props.refetchSchema}
/>
</div>
Expand Down Expand Up @@ -157,7 +154,6 @@ class TopBar extends React.Component<Props, {}> {
const mapStateToProps = createStructuredSelector({
endpoint: getEndpoint,
fixedEndpoint: getFixedEndpoint,
isReloadingSchema: getIsReloadingSchema,
isPollingSchema: getIsPollingSchema,
endpointUnreachable: getEndpointUnreachable,
settings: getSettings,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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!)
}
}
Expand Down
19 changes: 19 additions & 0 deletions packages/graphql-playground-react/src/components/util.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -32,3 +34,20 @@ export function getEndpointFromEndpointConfig(
}
}
}

const printSchemaCache: LRU.Cache<GraphQLSchema, string> = 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
}
Loading