diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-01-19 06:07:38 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-01-19 06:07:38 +0300 |
commit | 0cee6f1577cd31cae7dc0e82f65dcad462a4d18a (patch) | |
tree | 180a48cc0b5b15e6f2ac489a6c828ebab591d4cf /app/assets/javascripts/lib | |
parent | dcd01617a750c41fd082cc3383fc7ad2f2afd026 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts/lib')
-rw-r--r-- | app/assets/javascripts/lib/apollo/persist_link.js | 141 | ||||
-rw-r--r-- | app/assets/javascripts/lib/apollo/persistence_mapper.js | 67 | ||||
-rw-r--r-- | app/assets/javascripts/lib/graphql.js | 41 |
3 files changed, 238 insertions, 11 deletions
diff --git a/app/assets/javascripts/lib/apollo/persist_link.js b/app/assets/javascripts/lib/apollo/persist_link.js new file mode 100644 index 00000000000..9d95409d96c --- /dev/null +++ b/app/assets/javascripts/lib/apollo/persist_link.js @@ -0,0 +1,141 @@ +// this file is based on https://github.com/apollographql/apollo-cache-persist/blob/master/examples/react-native/src/utils/persistence/persistLink.ts +// with some heavy refactororing + +/* eslint-disable consistent-return */ +/* eslint-disable @gitlab/require-i18n-strings */ +/* eslint-disable no-param-reassign */ +import { visit } from 'graphql'; +import { ApolloLink } from '@apollo/client/core'; +import traverse from 'traverse'; + +const extractPersistDirectivePaths = (originalQuery, directive = 'persist') => { + const paths = []; + const fragmentPaths = {}; + const fragmentPersistPaths = {}; + + const query = visit(originalQuery, { + FragmentSpread: ({ name: { value: name } }, _key, _parent, _path, ancestors) => { + const root = ancestors.find( + ({ kind }) => kind === 'OperationDefinition' || kind === 'FragmentDefinition', + ); + + const rootKey = root.kind === 'FragmentDefinition' ? root.name.value : '$ROOT'; + + const fieldPath = ancestors + .filter(({ kind }) => kind === 'Field') + .map(({ name: { value } }) => value); + + fragmentPaths[name] = [rootKey].concat(fieldPath); + }, + Directive: ({ name: { value: name } }, _key, _parent, _path, ancestors) => { + if (name === directive) { + const fieldPath = ancestors + .filter(({ kind }) => kind === 'Field') + .map(({ name: { value } }) => value); + + const fragmentDefinition = ancestors.find(({ kind }) => kind === 'FragmentDefinition'); + + // If we are inside a fragment, we must save the reference. + if (fragmentDefinition) { + fragmentPersistPaths[fragmentDefinition.name.value] = fieldPath; + } else if (fieldPath.length) { + paths.push(fieldPath); + } + return null; + } + }, + }); + + // In case there are any FragmentDefinition items, we need to combine paths. + if (Object.keys(fragmentPersistPaths).length) { + visit(originalQuery, { + FragmentSpread: ({ name: { value: name } }, _key, _parent, _path, ancestors) => { + if (fragmentPersistPaths[name]) { + let fieldPath = ancestors + .filter(({ kind }) => kind === 'Field') + .map(({ name: { value } }) => value); + + fieldPath = fieldPath.concat(fragmentPersistPaths[name]); + + const fragment = name; + let parent = fragmentPaths[fragment][0]; + + while (parent && parent !== '$ROOT' && fragmentPaths[parent]) { + fieldPath = fragmentPaths[parent].slice(1).concat(fieldPath); + // eslint-disable-next-line prefer-destructuring + parent = fragmentPaths[parent][0]; + } + + paths.push(fieldPath); + } + }, + }); + } + + return { query, paths }; +}; + +/** + * Given a data result object path, return the equivalent query selection path. + * + * @param {Array} path The data result object path. i.e.: ["a", 0, "b"] + * @return {String} the query selection path. i.e.: "a.b" + */ +const toQueryPath = (path) => path.filter((key) => Number.isNaN(Number(key))).join('.'); + +const attachPersists = (paths, object) => { + const queryPaths = paths.map(toQueryPath); + function mapperFunction() { + if ( + !this.isRoot && + this.node && + typeof this.node === 'object' && + Object.keys(this.node).length && + !Array.isArray(this.node) + ) { + const path = toQueryPath(this.path); + + this.update({ + __persist: Boolean( + queryPaths.find( + (queryPath) => queryPath.indexOf(path) === 0 || path.indexOf(queryPath) === 0, + ), + ), + ...this.node, + }); + } + } + + return traverse(object).map(mapperFunction); +}; + +export const getPersistLink = () => { + return new ApolloLink((operation, forward) => { + const { query, paths } = extractPersistDirectivePaths(operation.query); + + // Noop if not a persist query + if (!paths.length) { + return forward(operation); + } + + // Replace query with one without @persist directives. + operation.query = query; + + // Remove requesting __persist fields. + operation.query = visit(operation.query, { + Field: ({ name: { value: name } }) => { + if (name === '__persist') { + return null; + } + }, + }); + + return forward(operation).map((result) => { + if (result.data) { + result.data = attachPersists(paths, result.data); + } + + return result; + }); + }); +}; diff --git a/app/assets/javascripts/lib/apollo/persistence_mapper.js b/app/assets/javascripts/lib/apollo/persistence_mapper.js new file mode 100644 index 00000000000..8fc7c69c79d --- /dev/null +++ b/app/assets/javascripts/lib/apollo/persistence_mapper.js @@ -0,0 +1,67 @@ +// this file is based on https://github.com/apollographql/apollo-cache-persist/blob/master/examples/react-native/src/utils/persistence/persistenceMapper.ts +// with some heavy refactororing + +/* eslint-disable @gitlab/require-i18n-strings */ +/* eslint-disable no-underscore-dangle */ +/* eslint-disable no-param-reassign */ +/* eslint-disable dot-notation */ +export const persistenceMapper = async (data) => { + const parsed = JSON.parse(data); + + const mapped = {}; + const persistEntities = []; + const rootQuery = parsed['ROOT_QUERY']; + + // cache entities that have `__persist: true` + Object.keys(parsed).forEach((key) => { + if (parsed[key]['__persist']) { + persistEntities.push(key); + } + }); + + // cache root queries that have `@persist` directive + mapped['ROOT_QUERY'] = Object.keys(rootQuery).reduce( + (obj, key) => { + if (key === '__typename') return obj; + + if (/@persist$/.test(key)) { + obj[key] = rootQuery[key]; + + if (Array.isArray(rootQuery[key])) { + const entities = rootQuery[key].map((item) => item.__ref); + persistEntities.push(...entities); + } else { + const entity = rootQuery[key].__ref; + persistEntities.push(entity); + } + } + + return obj; + }, + { __typename: 'Query' }, + ); + + persistEntities.reduce((obj, key) => { + const parsedEntity = parsed[key]; + + // check for root queries and only cache root query properties that have `__persist: true` + // we need this to prevent overcaching when we fetch the same entity (e.g. project) more than once + // with different set of fields + + if (Object.values(rootQuery).some((value) => value.__ref === key)) { + const mappedEntity = {}; + Object.entries(parsedEntity).forEach(([parsedKey, parsedValue]) => { + if (!parsedValue || typeof parsedValue !== 'object' || parsedValue['__persist']) { + mappedEntity[parsedKey] = parsedValue; + } + }); + obj[key] = mappedEntity; + } else { + obj[key] = parsed[key]; + } + + return obj; + }, mapped); + + return JSON.stringify(mapped); +}; diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js index 98e45f95b38..c0e923b2670 100644 --- a/app/assets/javascripts/lib/graphql.js +++ b/app/assets/javascripts/lib/graphql.js @@ -1,6 +1,7 @@ import { ApolloClient, InMemoryCache, ApolloLink, HttpLink } from '@apollo/client/core'; import { BatchHttpLink } from '@apollo/client/link/batch-http'; import { createUploadLink } from 'apollo-upload-client'; +import { persistCacheSync, LocalStorageWrapper } from 'apollo3-cache-persist'; import ActionCableLink from '~/actioncable_link'; import { apolloCaptchaLink } from '~/captcha/apollo_captcha_link'; import possibleTypes from '~/graphql_shared/possible_types.json'; @@ -10,6 +11,8 @@ import { objectToQuery, queryToObject } from '~/lib/utils/url_utility'; import PerformanceBarService from '~/performance_bar/services/performance_bar_service'; import { getInstrumentationLink } from './apollo/instrumentation_link'; import { getSuppressNetworkErrorsDuringNavigationLink } from './apollo/suppress_network_errors_during_navigation_link'; +import { getPersistLink } from './apollo/persist_link'; +import { persistenceMapper } from './apollo/persistence_mapper'; export const fetchPolicies = { CACHE_FIRST: 'cache-first', @@ -110,6 +113,7 @@ export default (resolvers = {}, config = {}) => { typeDefs, path = '/api/graphql', useGet = false, + localCacheKey = null, } = config; let ac = null; let uri = `${gon.relative_url_root || ''}${path}`; @@ -201,6 +205,8 @@ export default (resolvers = {}, config = {}) => { }); }); + const persistLink = getPersistLink(); + const appLink = ApolloLink.split( hasSubscriptionOperation, new ActionCableLink(), @@ -212,27 +218,40 @@ export default (resolvers = {}, config = {}) => { performanceBarLink, new StartupJSLink(), apolloCaptchaLink, + persistLink, uploadsLink, requestLink, ].filter(Boolean), ), ); + const newCache = new InMemoryCache({ + ...cacheConfig, + typePolicies: { + ...typePolicies, + ...cacheConfig.typePolicies, + }, + possibleTypes: { + ...possibleTypes, + ...cacheConfig.possibleTypes, + }, + }); + + if (localCacheKey) { + persistCacheSync({ + cache: newCache, + // we leave NODE_ENV here temporarily for visibility so developers can easily see caching happening in dev mode + debug: process.env.NODE_ENV === 'development', + storage: new LocalStorageWrapper(window.localStorage), + persistenceMapper, + }); + } + ac = new ApolloClient({ typeDefs, link: appLink, connectToDevTools: process.env.NODE_ENV !== 'production', - cache: new InMemoryCache({ - ...cacheConfig, - typePolicies: { - ...typePolicies, - ...cacheConfig.typePolicies, - }, - possibleTypes: { - ...possibleTypes, - ...cacheConfig.possibleTypes, - }, - }), + cache: newCache, resolvers, defaultOptions: { query: { |