perf: apply record optimistic effects with cache.modify on mutation (#3540)

* perf: apply record optimistic effects with cache.modify on mutation

Closes #3509

* refactor: return early when created records do not match filter

* fix: fix id generation on record creation

* fix: comment filtering behavior on record creation

* Fixed typing error

* refactor: review - use ??

* refactor: review - add variables in readFieldValueToSort

* docs: review - add comments for variables.first in triggerUpdateRecordOptimisticEffect

* refactor: review - add intermediary variable for 'not' filter in useMultiObjectSearchMatchesSearchFilterAndToSelectQuery

* refactor: review - add filter utils

* fix: fix tests

---------

Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
Thaïs
2024-01-23 14:13:00 -03:00
committed by GitHub
parent 9ebc0deaaf
commit 014f11fb6f
57 changed files with 852 additions and 1118 deletions

View File

@ -1,17 +0,0 @@
import { OptimisticEffectDefinition } from '@/apollo/optimistic-effect/types/OptimisticEffectDefinition';
import { ObjectRecordQueryVariables } from '@/object-record/types/ObjectRecordQueryVariables';
export const computeOptimisticEffectKey = ({
variables,
definition,
}: {
variables: ObjectRecordQueryVariables;
definition: OptimisticEffectDefinition;
}) => {
const computedKey =
(definition.objectMetadataItem?.namePlural ?? definition.typename) +
'-' +
JSON.stringify(variables);
return computedKey;
};

View File

@ -0,0 +1,21 @@
import { StoreValue } from '@apollo/client';
import { z } from 'zod';
import { CachedObjectRecordConnection } from '@/apollo/types/CachedObjectRecordConnection';
import { capitalize } from '~/utils/string/capitalize';
export const isCachedObjectConnection = (
objectNameSingular: string,
storeValue: StoreValue,
): storeValue is CachedObjectRecordConnection => {
const objectConnectionTypeName = `${capitalize(
objectNameSingular,
)}Connection`;
const cachedObjectConnectionSchema = z.object({
__typename: z.literal(objectConnectionTypeName),
});
const cachedConnectionValidation =
cachedObjectConnectionSchema.safeParse(storeValue);
return cachedConnectionValidation.success;
};

View File

@ -0,0 +1,66 @@
import { Reference, StoreObject } from '@apollo/client';
import { ReadFieldFunction } from '@apollo/client/cache/core/types/common';
import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge';
import { OrderBy } from '@/object-metadata/types/OrderBy';
import { OrderByField } from '@/object-metadata/types/OrderByField';
import { isDefined } from '~/utils/isDefined';
import { sortAsc, sortDesc, sortNullsFirst, sortNullsLast } from '~/utils/sort';
export const sortCachedObjectEdges = ({
edges,
orderBy,
readCacheField,
}: {
edges: CachedObjectRecordEdge[];
orderBy: OrderByField;
readCacheField: ReadFieldFunction;
}) => {
const [orderByFieldName, orderByFieldValue] = Object.entries(orderBy)[0];
const [orderBySubFieldName, orderBySubFieldValue] =
typeof orderByFieldValue === 'string'
? []
: Object.entries(orderByFieldValue)[0];
const readFieldValueToSort = (
edge: CachedObjectRecordEdge,
): string | number | null => {
const recordFromCache = edge.node;
const fieldValue =
readCacheField<Reference | StoreObject | string | number | null>(
orderByFieldName,
recordFromCache,
) ?? null;
const isSubFieldFilter = isDefined(fieldValue) && !!orderBySubFieldName;
if (!isSubFieldFilter) return fieldValue as string | number | null;
const subFieldValue =
readCacheField<string | number | null>(
orderBySubFieldName,
fieldValue as Reference | StoreObject,
) ?? null;
return subFieldValue;
};
const orderByValue = orderBySubFieldValue || (orderByFieldValue as OrderBy);
const isAsc = orderByValue.startsWith('Asc');
const isNullsFirst = orderByValue.endsWith('NullsFirst');
return [...edges].sort((edgeA, edgeB) => {
const fieldValueA = readFieldValueToSort(edgeA);
const fieldValueB = readFieldValueToSort(edgeB);
if (fieldValueA === null || fieldValueB === null) {
return isNullsFirst
? sortNullsFirst(fieldValueA, fieldValueB)
: sortNullsLast(fieldValueA, fieldValueB);
}
return isAsc
? sortAsc(fieldValueA, fieldValueB)
: sortDesc(fieldValueA, fieldValueB);
});
};

View File

@ -0,0 +1,112 @@
import { ApolloCache, StoreObject } from '@apollo/client';
import { isCachedObjectConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectConnection';
import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord';
import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { capitalize } from '~/utils/string/capitalize';
/*
TODO: for now new records are added to all cached record lists, no matter what the variables (filters, orderBy, etc.) are.
We need to refactor how the record creation works in the RecordTable so the created record row is temporarily displayed with a local state,
then we'll be able to uncomment the code below so the cached lists are updated coherently with the variables.
*/
export const triggerCreateRecordsOptimisticEffect = ({
cache,
objectMetadataItem,
records,
}: {
cache: ApolloCache<unknown>;
objectMetadataItem: ObjectMetadataItem;
records: CachedObjectRecord[];
}) => {
const objectEdgeTypeName = `${capitalize(
objectMetadataItem.nameSingular,
)}Edge`;
cache.modify<StoreObject>({
fields: {
[objectMetadataItem.namePlural]: (
cachedConnection,
{
INVALIDATE: _INVALIDATE,
readField,
storeFieldName: _storeFieldName,
toReference,
},
) => {
if (
!isCachedObjectConnection(
objectMetadataItem.nameSingular,
cachedConnection,
)
)
return cachedConnection;
/* const { variables } =
parseApolloStoreFieldName<CachedObjectRecordQueryVariables>(
storeFieldName,
); */
const cachedEdges = readField<CachedObjectRecordEdge[]>(
'edges',
cachedConnection,
);
const nextCachedEdges = cachedEdges ? [...cachedEdges] : [];
const hasAddedRecords = records
.map((record) => {
/* const matchesFilter =
!variables?.filter ||
isRecordMatchingFilter({
record,
filter: variables.filter,
objectMetadataItem,
}); */
if (/* matchesFilter && */ record.id) {
const nodeReference = toReference(record);
if (nodeReference) {
nextCachedEdges.unshift({
__typename: objectEdgeTypeName,
node: nodeReference,
cursor: '',
});
return true;
}
}
return false;
})
.some((hasAddedRecord) => hasAddedRecord);
if (!hasAddedRecords) return cachedConnection;
/* if (variables?.orderBy) {
nextCachedEdges = sortCachedObjectEdges({
edges: nextCachedEdges,
orderBy: variables.orderBy,
readCacheField: readField,
});
}
if (isDefined(variables?.first)) {
if (
cachedEdges?.length === variables.first &&
nextCachedEdges.length < variables.first
) {
return INVALIDATE;
}
if (nextCachedEdges.length > variables.first) {
nextCachedEdges.splice(variables.first);
}
} */
return { ...cachedConnection, edges: nextCachedEdges };
},
},
});
};

View File

@ -0,0 +1,65 @@
import { ApolloCache, StoreObject } from '@apollo/client';
import { isCachedObjectConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectConnection';
import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord';
import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge';
import { CachedObjectRecordQueryVariables } from '@/apollo/types/CachedObjectRecordQueryVariables';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isDefined } from '~/utils/isDefined';
import { parseApolloStoreFieldName } from '~/utils/parseApolloStoreFieldName';
export const triggerDeleteRecordsOptimisticEffect = ({
cache,
objectMetadataItem,
records,
}: {
cache: ApolloCache<unknown>;
objectMetadataItem: ObjectMetadataItem;
records: Pick<CachedObjectRecord, 'id' | '__typename'>[];
}) => {
cache.modify<StoreObject>({
fields: {
[objectMetadataItem.namePlural]: (
cachedConnection,
{ INVALIDATE, readField, storeFieldName },
) => {
if (
!isCachedObjectConnection(
objectMetadataItem.nameSingular,
cachedConnection,
)
)
return cachedConnection;
const { variables } =
parseApolloStoreFieldName<CachedObjectRecordQueryVariables>(
storeFieldName,
);
const recordIds = records.map(({ id }) => id);
const cachedEdges = readField<CachedObjectRecordEdge[]>(
'edges',
cachedConnection,
);
const nextCachedEdges =
cachedEdges?.filter((cachedEdge) => {
const nodeId = readField<string>('id', cachedEdge.node);
return nodeId && !recordIds.includes(nodeId);
}) || [];
if (
isDefined(variables?.first) &&
cachedEdges?.length === variables.first &&
nextCachedEdges.length < variables.first
) {
return INVALIDATE;
}
return { ...cachedConnection, edges: nextCachedEdges };
},
},
});
records.forEach((record) => cache.evict({ id: cache.identify(record) }));
};

View File

@ -0,0 +1,110 @@
import { ApolloCache, StoreObject } from '@apollo/client';
import { isCachedObjectConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectConnection';
import { sortCachedObjectEdges } from '@/apollo/optimistic-effect/utils/sortCachedObjectEdges';
import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord';
import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge';
import { CachedObjectRecordQueryVariables } from '@/apollo/types/CachedObjectRecordQueryVariables';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isRecordMatchingFilter } from '@/object-record/record-filter/utils/isRecordMatchingFilter';
import { isDefined } from '~/utils/isDefined';
import { parseApolloStoreFieldName } from '~/utils/parseApolloStoreFieldName';
import { capitalize } from '~/utils/string/capitalize';
export const triggerUpdateRecordOptimisticEffect = ({
cache,
objectMetadataItem,
record,
}: {
cache: ApolloCache<unknown>;
objectMetadataItem: ObjectMetadataItem;
record: CachedObjectRecord;
}) => {
const objectEdgeTypeName = `${capitalize(
objectMetadataItem.nameSingular,
)}Edge`;
cache.modify<StoreObject>({
fields: {
[objectMetadataItem.namePlural]: (
cachedConnection,
{ INVALIDATE, readField, storeFieldName, toReference },
) => {
if (
!isCachedObjectConnection(
objectMetadataItem.nameSingular,
cachedConnection,
)
)
return cachedConnection;
const { variables } =
parseApolloStoreFieldName<CachedObjectRecordQueryVariables>(
storeFieldName,
);
const cachedEdges = readField<CachedObjectRecordEdge[]>(
'edges',
cachedConnection,
);
let nextCachedEdges = cachedEdges ? [...cachedEdges] : [];
if (variables?.filter) {
const matchesFilter = isRecordMatchingFilter({
record,
filter: variables.filter,
objectMetadataItem,
});
const recordIndex = nextCachedEdges.findIndex(
(cachedEdge) => readField('id', cachedEdge.node) === record.id,
);
if (matchesFilter && recordIndex === -1) {
const nodeReference = toReference(record);
nodeReference &&
nextCachedEdges.push({
__typename: objectEdgeTypeName,
node: nodeReference,
cursor: '',
});
}
if (!matchesFilter && recordIndex > -1) {
nextCachedEdges.splice(recordIndex, 1);
}
}
if (variables?.orderBy) {
nextCachedEdges = sortCachedObjectEdges({
edges: nextCachedEdges,
orderBy: variables.orderBy,
readCacheField: readField,
});
}
if (isDefined(variables?.first)) {
// If previous edges length was exactly at the required limit,
// but after update next edges length is under the limit,
// we cannot for sure know if re-fetching the query
// would return more edges, so we cannot optimistically deduce
// the query's result.
// In this case, invalidate the cache entry so it can be re-fetched.
if (
cachedEdges?.length === variables.first &&
nextCachedEdges.length < variables.first
) {
return INVALIDATE;
}
// If next edges length exceeds the required limit,
// trim the next edges array to the correct length.
if (nextCachedEdges.length > variables.first) {
nextCachedEdges.splice(variables.first);
}
}
return { ...cachedConnection, edges: nextCachedEdges };
},
},
});
};