Merge pull request #1186 from coralproject/performance-enhancements

Performance enhancements
This commit is contained in:
Wyatt Johnson
2017-12-04 15:16:57 -07:00
committed by GitHub
18 changed files with 465 additions and 48 deletions
+1 -1
View File
@@ -19,6 +19,6 @@ export default withQuery(gql`
}
`, {
options: {
pollInterval: 5000
pollInterval: 10000
}
})(Header);
@@ -19,7 +19,7 @@ import update from 'immutability-helper';
import {notify} from 'coral-framework/actions/notification';
const commentConnectionFragment = gql`
fragment CoralAdmin_Moderation_CommentConnection on CommentConnection {
fragment CoralAdmin_UserDetail_CommentConnection on CommentConnection {
nodes {
...${getDefinitionName(UserDetailComment.fragments.comment)}
}
@@ -155,7 +155,7 @@ export const withUserDetailQuery = withQuery(gql`
author_id: $author_id,
statuses: $statuses
}) {
...CoralAdmin_Moderation_CommentConnection
...CoralAdmin_UserDetail_CommentConnection
}
...${getDefinitionName(UserDetailComment.fragments.root)}
${getSlotFragmentSpreads(slots, 'root')}
@@ -138,7 +138,7 @@ class ModerationContainer extends Component {
},
];
this.subscriptions = parameters.map((param) => this.props.data.subscribeToMore(param));
this.subscriptions = parameters.map((param) => this.props.data.subscribeToMoreThrottled(param));
}
unsubscribe() {
@@ -1,7 +1,6 @@
import React from 'react';
import ExtendableTabPanel from '../components/ExtendableTabPanel';
import {connect} from 'react-redux';
import omit from 'lodash/omit';
import {TabPane} from 'coral-ui';
import ExtendableTab from '../components/ExtendableTab';
import {getShallowChanges} from 'coral-framework/utils';
@@ -128,7 +127,7 @@ ExtendableTabPanelContainer.propTypes = {
};
const mapStateToProps = (state) => ({
reduxState: omit(state, 'apollo'),
reduxState: state,
});
export default connect(mapStateToProps, null)(ExtendableTabPanelContainer);
@@ -252,7 +252,7 @@ class Stream extends React.Component {
enable={asset.settings.infoBoxEnable}
/>
{questionBoxEnable && (
<QuestionBox
<QuestionBox
content={asset.settings.questionBoxContent}
icon={asset.settings.questionBoxIcon}>
<Slot
@@ -1,7 +1,6 @@
import React, {Children} from 'react';
import {connect} from 'react-redux';
import PropTypes from 'prop-types';
import omit from 'lodash/omit';
import {getShallowChanges} from 'coral-framework/utils';
class IfSlotIsEmpty extends React.Component {
@@ -39,7 +38,7 @@ IfSlotIsEmpty.propTypes = {
};
const mapStateToProps = (state) => ({
reduxState: omit(state, 'apollo'),
reduxState: state,
});
export default connect(mapStateToProps, null)(IfSlotIsEmpty);
@@ -1,7 +1,6 @@
import React, {Children} from 'react';
import {connect} from 'react-redux';
import PropTypes from 'prop-types';
import omit from 'lodash/omit';
import {getShallowChanges} from 'coral-framework/utils';
class IfSlotIsNotEmpty extends React.Component {
@@ -39,7 +38,7 @@ IfSlotIsNotEmpty.propTypes = {
};
const mapStateToProps = (state) => ({
reduxState: omit(state, 'apollo'),
reduxState: state,
});
export default connect(mapStateToProps, null)(IfSlotIsNotEmpty);
+1 -2
View File
@@ -2,7 +2,6 @@ import React from 'react';
import cn from 'classnames';
import styles from './Slot.css';
import {connect} from 'react-redux';
import omit from 'lodash/omit';
import kebabCase from 'lodash/kebabCase';
import PropTypes from 'prop-types';
import isEqual from 'lodash/isEqual';
@@ -121,7 +120,7 @@ Slot.propTypes = {
};
const mapStateToProps = (state) => ({
reduxState: omit(state, 'apollo'),
reduxState: state,
});
export default connect(mapStateToProps, null)(Slot);
@@ -9,17 +9,18 @@ class TalkProvider extends React.Component {
pym: this.props.pym,
plugins: this.props.plugins,
rest: this.props.rest,
graphqlRegistry: this.props.graphqlRegistry,
graphql: this.props.graphql,
notification: this.props.notification,
storage: this.props.storage,
history: this.props.history,
store: this.props.store,
};
}
render() {
const {children, client, store} = this.props;
const {children, client} = this.props;
return (
<ApolloProvider client={client} store={store}>
<ApolloProvider client={client}>
{children}
</ApolloProvider>
);
@@ -31,10 +32,11 @@ TalkProvider.childContextTypes = {
eventEmitter: PropTypes.object,
plugins: PropTypes.object,
rest: PropTypes.func,
graphqlRegistry: PropTypes.object,
graphql: PropTypes.object,
notification: PropTypes.object,
storage: PropTypes.object,
history: PropTypes.object,
store: PropTypes.object,
};
export default TalkProvider;
@@ -0,0 +1,293 @@
import {
getMainDefinition,
getFragmentDefinitions,
createFragmentMap,
shouldInclude,
getOperationDefinition,
} from 'apollo-utilities';
function getDirectivesID(directives) {
let id = '';
directives.forEach((directive) => {
id += `@${directive.name.value}(`;
let first = true;
directive.arguments.forEach((arg) => {
if (!first) {
id += ',';
}
first = false;
const value = arg.value.kind === 'Variable'
? `$${arg.value.name.value}`
: arg.value.value;
id += `${arg.name.value}:${value}`;
});
id += ')';
});
return id;
}
// If two definitions have the same id, they can be merged.
function getDefinitionID(definition) {
// Only merge when directives are exactly the same.
const trailing = definition.directives.length
? `_${getDirectivesID(definition.directives)}`
: '';
switch (definition.kind) {
case 'FragmentSpread':
return `FragmentSpread_${definition.name.value}`;
case 'Field':
return `Field_${definition.alias ? definition.alias.value : definition.name.value}${trailing}`;
case 'InlineFragment':
return `InlineFragment_${definition.typeCondition.name.value}${trailing}`;
default:
throw new Error(`unknown definition kind ${definition.kind}`);
}
}
/**
* Merge selections of 2 definitions.
*/
export function mergeDefinitions(a, b) {
const name = getDefinitionID(a);
if (!!a.selectionSet !== !!b.selectionSet) {
throw Error(`incompatible field definition for ${name}`);
}
if (!a.selectionSet) {
return b;
}
const selectionSet = mergeSelectionSets(a.selectionSet, b.selectionSet);
return {
...b,
selectionSet,
};
}
/**
* Merge selectionSets
*/
export function mergeSelectionSets(a, b) {
const selectionsMap = [...a.selections, ...b.selections].reduce((o, sel) => {
const selName = getDefinitionID(sel);
if (!(selName in o)) {
o[selName] = sel;
return o;
}
o[selName] = mergeDefinitions(o[selName], sel);
return o;
}, {});
const selections = Object.keys(selectionsMap).map((key) => selectionsMap[key]);
return {
...b,
selections,
};
}
function getFragmentOrDie(name, execContext) {
const {
rawFragmentMap,
fragmentMap,
} = execContext;
if (!(name in fragmentMap)) {
const fragment = rawFragmentMap[name];
if (!fragment) {
throw new Error(`fragment ${fragment.name.value} does not exist`);
}
const typeCondition = fragment.typeCondition.name.value;
const transformed = transformDefinition(fragment, execContext, `type.${typeCondition}`, typeCondition);
fragmentMap[name] = transformed;
}
return fragmentMap[name];
}
/**
* Return selections with resolved named fragments and directives.
*/
function getTransformedSelections(definition, path, gqlType, execContext) {
const {
variables,
} = execContext;
const selectionsMap = definition.selectionSet.selections.reduce((o, sel) => {
if (variables && !shouldInclude(sel, variables)) {
// Skip this entirely
return o;
}
if (sel.kind !== 'FragmentSpread') {
const transformed = transformDefinition(sel, execContext, path, gqlType);
const name = getDefinitionID(sel);
// Merge existing value.
if (name in o) {
o[name] = mergeDefinitions(o[name], transformed);
return o;
}
o[name] = transformed;
return o;
}
const fragment = getFragmentOrDie(sel.name.value, execContext);
const typeCondition = fragment.typeCondition.name.value;
// Turn NamedFragment into an InlineFragment.
if (gqlType !== typeCondition || fragment.directives.length) {
const node = {
...fragment,
kind: 'InlineFragment',
};
const name = getDefinitionID(node);
// Merge existing value.
if (name in o) {
o[name] = mergeDefinitions(o[name], node);
return o;
}
o[name] = node;
return o;
}
// Merge NamedFragment directly into selections.
const fragmentSelections = fragment.selectionSet.selections;
fragmentSelections.forEach((s) => {
if (variables && !shouldInclude(s, variables)) {
// Skip this entirely
return;
}
const selName = getDefinitionID(s);
if (!(selName in o)) {
o[selName] = s;
return;
}
o[selName] = mergeDefinitions(o[selName], s);
});
return o;
}, {});
const selections = Object.keys(selectionsMap).map((key) => selectionsMap[key]);
return selections;
}
/**
* Resolve named fragments and directives in a definition.
*/
function transformDefinition(definition, execContext, path = '', type = null) {
if (!definition.selectionSet) {
return definition;
}
const {typeGetter} = execContext;
if (definition.kind === 'Field') {
const fieldName = definition.name.value;
path = `${path}.${fieldName}`;
if (typeGetter) {
type = typeGetter(path);
}
}
// InlineFragments
else if(!type && typeGetter) {
type = typeGetter(path);
}
return {
...definition,
selectionSet: {
...definition.selectionSet,
selections: getTransformedSelections(definition, path, type, execContext),
},
};
}
export default function reduceDocument(document, options = {}) {
const mainDefinition = getMainDefinition(document);
const fragments = getFragmentDefinitions(document);
const operationDefinition = getOperationDefinition(document);
const path = operationDefinition
? operationDefinition.operation
: `type.${mainDefinition.typeCondition.name.value}`;
const execContext = {
rawFragmentMap: createFragmentMap(fragments),
fragmentMap: options.fragmentMap || {},
variables: options.variables,
typeGetter: options.typeGetter || (() => null),
};
return {
kind: 'Document',
definitions: [transformDefinition(mainDefinition, execContext, path)],
};
}
function getObjectType(fieldType) {
if (['NON_NULL', 'LIST'].indexOf(fieldType.kind) > -1) {
return getObjectType(fieldType.ofType);
}
return fieldType.name;
}
function getFieldType(parentType, fieldName) {
const field = parentType.fields.find((f) => f.name === fieldName);
return getObjectType(field.type);
}
export function createTypeGetter(introspectionData) {
const types = {};
introspectionData.__schema.types.forEach((type) => types[type.name] = type);
const result = {
'query': introspectionData.__schema.queryType.name,
'mutation': introspectionData.__schema.mutationType.name,
'subscription': introspectionData.__schema.subscriptionType.name,
};
return (path) => {
if (result[path]) {
return result[path];
}
let currentPath = '';
const parts = path.split('.');
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
// Handle special path e.g. 'type.ROOT_QUERY.fieldName'
if (part === 'type') {
const type = parts[i + 1];
const nextPath = `type.${type}`;
result[nextPath] = type;
currentPath = nextPath;
i++;
continue;
}
const nextPath = currentPath ? `${currentPath}.${part}` : part;
if (nextPath in result) {
currentPath = nextPath;
continue;
}
result[nextPath] = getFieldType(types[result[currentPath]], part);
currentPath = nextPath;
}
return result[path];
};
}
+3 -6
View File
@@ -64,18 +64,15 @@ function hasEqualLeaves(a, b, path = '') {
export default (fragments) => hoistStatics((BaseComponent) => {
class WithFragments extends React.Component {
static contextTypes = {
graphqlRegistry: PropTypes.object,
graphql: PropTypes.object,
};
get graphqlRegistry() {
return this.context.graphqlRegistry;
return this.context.graphql.registry;
}
resolveDocument(documentOrCallback) {
const document = typeof documentOrCallback === 'function'
? documentOrCallback(this.props, this.context)
: documentOrCallback;
return this.graphqlRegistry.resolveFragments(document);
return this.context.graphql.resolveDocument(documentOrCallback, this.props, this.context);
}
fragments = mapValues(fragments, (val) => this.resolveDocument(val));
+3 -6
View File
@@ -42,18 +42,15 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
static contextTypes = {
eventEmitter: PropTypes.object,
store: PropTypes.object,
graphqlRegistry: PropTypes.object,
graphql: PropTypes.object,
};
get graphqlRegistry() {
return this.context.graphqlRegistry;
return this.context.graphql.registry;
}
resolveDocument(documentOrCallback) {
const document = typeof documentOrCallback === 'function'
? documentOrCallback(this.props, this.context)
: documentOrCallback;
return this.graphqlRegistry.resolveFragments(document);
return this.context.graphql.resolveDocument(documentOrCallback, this.props, this.context);
}
// Lazily resolve fragments from graphRegistry to support circular dependencies.
+78 -9
View File
@@ -3,6 +3,8 @@ import {graphql} from 'react-apollo';
import {getDefinitionName, separateDataAndRoot, getResponseErrors} from '../utils';
import PropTypes from 'prop-types';
import hoistStatics from 'recompose/hoistStatics';
import {getOperationName} from 'apollo-client/queries/getFromAST';
import throttle from 'lodash/throttle';
const withSkipOnErrors = (reducer) => (prev, action, ...rest) => {
if (action.type === 'APOLLO_MUTATION_RESULT' && getResponseErrors(action.result)) {
@@ -39,24 +41,30 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
return class WithQuery extends React.Component {
static contextTypes = {
eventEmitter: PropTypes.object,
graphqlRegistry: PropTypes.object,
graphql: PropTypes.object,
client: PropTypes.object,
};
// Lazily resolve fragments from graphRegistry to support circular dependencies.
memoized = null;
resolvedDocument = null;
lastNetworkStatus = null;
data = null;
name = '';
// Pending subscription data.
subscriptionQueue = [];
get graphqlRegistry() {
return this.context.graphqlRegistry;
return this.context.graphql.registry;
}
get client() {
return this.context.client;
}
resolveDocument(documentOrCallback) {
const document = typeof documentOrCallback === 'function'
? documentOrCallback(this.props, this.context)
: documentOrCallback;
return this.graphqlRegistry.resolveFragments(document);
return this.context.graphql.resolveDocument(documentOrCallback, this.props, this.context);
}
emitWhenNeeded(data) {
@@ -72,6 +80,66 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
this.context.eventEmitter.emit(`query.${this.name}.${status}`, {variables, data: root});
}
// Handle any pending susbcription data in the subscription queue at max once every second.
// Updates are batched in written into apollo in one go.
processSubscriptionQueue = throttle(() => {
const variables = typeof this.wrappedOptions === 'function'
? this.wrappedOptions(this.props).variables
: this.wrappedOptions.variables;
const previousResult = this.client.readQuery({
query: this.resolvedDocument,
variables,
});
let result = previousResult;
this.subscriptionQueue.forEach(([updateQuery, data]) => {
result = updateQuery(result, {subscriptionData: {data}});
});
if (result !== previousResult) {
this.client.writeQuery({
query: this.resolvedDocument,
variables,
data: result,
});
}
this.subscriptionQueue = [];
}, 1000);
subscribeToMoreThrottled = ({document, variables, updateQuery}) => {
// We need to add the typenames and resolve fragments.
const query = this.resolveDocument(document);
const handler = (error, data) => {
if (error) {
// TODO: shuld this show a notification?
console.error(error);
return;
}
if (data) {
this.subscriptionQueue.push([updateQuery, data]);
// Triggers the throttled subscription queue processor.
this.processSubscriptionQueue();
}
};
// Start subscription.
const request = {
query,
variables,
operationName: getOperationName(query),
};
const id = this.client.networkInterface.subscribe(request, handler);
// Return unsubscribe callback.
return () => this.client.networkInterface.unsubscribe(id);
};
nextData(data) {
this.emitWhenNeeded(data);
@@ -102,6 +170,7 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
stopPolling: data.stopPolling,
refetch: data.refetch,
updateQuery: data.updateQuery,
subscribeToMoreThrottled: this.subscribeToMoreThrottled,
subscribeToMore: (stmArgs) => {
const resolvedDocument = this.resolveDocument(stmArgs.document);
@@ -190,10 +259,10 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
getWrapped = () => {
if (!this.memoized) {
const resolvedDocument = this.resolveDocument(document);
this.name = getDefinitionName(resolvedDocument);
this.resolvedDocument = this.resolveDocument(document);
this.name = getDefinitionName(this.resolvedDocument);
this.memoized = graphql(
resolvedDocument,
this.resolvedDocument,
{...this.wrappedConfig, options: this.wrappedOptions},
)(WrappedComponent);
}
+22 -8
View File
@@ -12,6 +12,7 @@ import {BASE_PATH} from 'coral-framework/constants/url';
import {createPluginsService} from './plugins';
import {createNotificationService} from './notification';
import {createGraphQLRegistry} from './graphqlRegistry';
import {createGraphQLService} from './graphql';
import globalFragments from 'coral-framework/graphql/fragments';
import {createStorage, createPymStorage} from 'coral-framework/services/storage';
import {createHistory} from 'coral-framework/services/history';
@@ -121,7 +122,13 @@ export async function createContext({
introspectionData,
});
const plugins = createPluginsService(pluginsConfig);
const graphqlRegistry = createGraphQLRegistry(plugins.getSlotFragments.bind(plugins));
const graphql = createGraphQLService(
createGraphQLRegistry(plugins.getSlotFragments.bind(plugins)),
{
introspectionData,
optimize: process.env.NODE_ENV === 'production',
},
);
if (!notification) {
// Use default notification service (pym based)
@@ -134,7 +141,7 @@ export async function createContext({
plugins,
eventEmitter,
rest,
graphqlRegistry,
graphql,
notification,
storage,
history,
@@ -143,13 +150,13 @@ export async function createContext({
};
// Load framework fragments.
Object.keys(globalFragments).forEach((key) => graphqlRegistry.addFragment(key, globalFragments[key]));
Object.keys(globalFragments).forEach((key) => graphql.registry.addFragment(key, globalFragments[key]));
// Register graphql extension
graphqlRegistry.add(graphqlExtension);
graphql.registry.add(graphqlExtension);
// Register plugin graphql extensions.
plugins.getGraphQLExtensions().forEach((ext) => graphqlRegistry.add(ext));
plugins.getGraphQLExtensions().forEach((ext) => graphql.registry.add(ext));
// Load plugin translations.
plugins.getTranslations().forEach((t) => loadTranslations(t));
@@ -159,21 +166,28 @@ export async function createContext({
pym.sendMessage('event', JSON.stringify({eventName, value}));
});
// Create our redux store.
const finalReducers = {
...reducers,
...plugins.getReducers(),
apollo: client.reducer(),
};
store = createStore(finalReducers, [
client.middleware(),
thunk.withExtraArgument(context),
apolloErrorReporter,
createReduxEmitter(eventEmitter),
]);
context.store = store;
// Create apollo redux store.
context.apolloStore = createStore({
apollo: client.reducer(),
}, [
client.middleware(),
apolloErrorReporter,
createReduxEmitter(eventEmitter),
]);
// Run pre initialization.
if (preInit) {
await preInit(context);
@@ -0,0 +1,39 @@
import reduceDocument, {createTypeGetter} from '../graphql/reduceDocument';
import {addTypenameToDocument} from 'apollo-client/queries/queryTransform';
/**
* createGraphQLService
* @param {string} basename base path of the url
* @return {Object} histor service
*/
export function createGraphQLService(registry, {
introspectionData,
optimize = false,
}) {
const reduceOptions = {
typeGetter: optimize && introspectionData ? createTypeGetter(introspectionData) : null,
// Use shared fragment map.
// Attention: Fragment names must be unique otherwise weird things will happen.
fragmentMap: {},
};
return {
registry,
resolveDocument(documentOrCallback, props, context) {
let document = typeof documentOrCallback === 'function'
? documentOrCallback(props, context)
: documentOrCallback;
document = registry.resolveFragments(document);
if (optimize) {
document = reduceDocument(document, reduceOptions);
}
// We also add typenames to the document which apollo would usually do,
// but we also use the network interface in subscriptions directly
// which require the resolved typenames.
return addTypenameToDocument(document);
},
};
}
+2 -1
View File
@@ -58,8 +58,10 @@
},
"homepage": "https://github.com/coralproject/talk#readme",
"dependencies": {
"@coralproject/graphql-anywhere-optimized": "^0.1.0",
"accepts": "^1.3.4",
"apollo-client": "^1.9.1",
"apollo-utilities": "^1.0.3",
"app-module-path": "^2.2.0",
"autoprefixer": "^6.5.2",
"babel-cli": "6.26.0",
@@ -105,7 +107,6 @@
"fs-extra": "^4.0.1",
"gql-merge": "^0.0.4",
"graphql": "^0.9.1",
"graphql-anywhere": "^3.1.0",
"graphql-docs": "0.2.0",
"graphql-errors": "^2.1.0",
"graphql-redis-subscriptions": "1.3.0",
+1
View File
@@ -120,6 +120,7 @@ const config = {
},
resolve: {
alias: {
'graphql-anywhere': '@coralproject/graphql-anywhere-optimized',
'plugin-api': path.resolve(__dirname, 'plugin-api/'),
plugins: path.resolve(__dirname, 'plugins/'),
pluginsConfig: pluginsPath
+9 -1
View File
@@ -11,6 +11,10 @@
eslint-plugin-promise "^3.3.1"
eslint-plugin-react "^7.3.0"
"@coralproject/graphql-anywhere-optimized@^0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@coralproject/graphql-anywhere-optimized/-/graphql-anywhere-optimized-0.1.0.tgz#3456f16f790d8593b0ca4355910578ab1c6edab5"
"@kadira/storybook-deployer@^1.1.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@kadira/storybook-deployer/-/storybook-deployer-1.2.0.tgz#1708f5cb37fa08ab4173b1bd99df6f4717dfae12"
@@ -259,6 +263,10 @@ apollo-link-core@^0.5.0:
graphql-tag "^2.4.2"
zen-observable-ts "^0.4.4"
apollo-utilities@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/apollo-utilities/-/apollo-utilities-1.0.3.tgz#bf435277609850dd442cf1d5c2e8bc6655eaa943"
app-module-path@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/app-module-path/-/app-module-path-2.2.0.tgz#641aa55dfb7d6a6f0a8141c4b9c0aa50b6c24dd5"
@@ -3507,7 +3515,7 @@ graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.4, graceful-fs@^4.1.6:
version "1.0.1"
resolved "https://registry.yarnpkg.com/graceful-readlink/-/graceful-readlink-1.0.1.tgz#4cafad76bc62f02fa039b2f94e9a3dd3a391a725"
graphql-anywhere@^3.0.1, graphql-anywhere@^3.1.0:
graphql-anywhere@^3.0.1:
version "3.1.0"
resolved "https://registry.yarnpkg.com/graphql-anywhere/-/graphql-anywhere-3.1.0.tgz#3ea0d8e8646b5cee68035016a9a7557c15c21e96"