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,100 +0,0 @@
import { useApolloClient } from '@apollo/client';
import { MockedProvider } from '@apollo/client/testing';
import { act, renderHook, waitFor } from '@testing-library/react';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect';
import { optimisticEffectState } from '@/apollo/optimistic-effect/states/optimisticEffectState';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<MockedProvider addTypename={false}>
<RecoilRoot>{children}</RecoilRoot>
</MockedProvider>
);
describe('useOptimisticEffect', () => {
it('should work as expected', async () => {
const { result } = renderHook(
() => {
const optimisticEffect = useRecoilValue(optimisticEffectState);
const client = useApolloClient();
const { findManyRecordsQuery } = useObjectMetadataItem({
objectNameSingular: 'person',
});
return {
...useOptimisticEffect({ objectNameSingular: 'person' }),
optimisticEffect,
cache: client.cache,
findManyRecordsQuery,
};
},
{
wrapper: Wrapper,
},
);
const {
registerOptimisticEffect,
unregisterOptimisticEffect,
triggerOptimisticEffects,
optimisticEffect,
findManyRecordsQuery,
} = result.current;
expect(registerOptimisticEffect).toBeDefined();
expect(typeof registerOptimisticEffect).toBe('function');
expect(optimisticEffect).toEqual({});
const optimisticEffectDefinition = {
variables: {},
definition: {
typename: 'Person',
resolver: () => ({
people: [],
pageInfo: {
endCursor: '',
hasNextPage: false,
hasPreviousPage: false,
startCursor: '',
},
edges: [],
}),
},
};
act(() => {
registerOptimisticEffect(optimisticEffectDefinition);
});
await waitFor(() => {
expect(result.current.optimisticEffect).toHaveProperty('Person-{}');
});
expect(
result.current.cache.readQuery({ query: findManyRecordsQuery }),
).toBeNull();
act(() => {
triggerOptimisticEffects({
typename: 'Person',
createdRecords: [{ id: 'id-0' }],
});
});
await waitFor(() => {
expect(
result.current.cache.readQuery({ query: findManyRecordsQuery }),
).toHaveProperty('people');
});
act(() => {
unregisterOptimisticEffect(optimisticEffectDefinition);
});
await waitFor(() => {
expect(result.current.optimisticEffect).not.toHaveProperty('Person-{}');
expect(result.current.optimisticEffect).toEqual({});
});
});
});

View File

@ -1,198 +0,0 @@
import { useApolloClient } from '@apollo/client';
import { isNonEmptyArray } from '@sniptt/guards';
import { useRecoilCallback } from 'recoil';
import { computeOptimisticEffectKey } from '@/apollo/optimistic-effect/utils/computeOptimisticEffectKey';
import {
EMPTY_QUERY,
useObjectMetadataItem,
} from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier';
import { ObjectRecordQueryVariables } from '@/object-record/types/ObjectRecordQueryVariables';
import { optimisticEffectState } from '../states/optimisticEffectState';
import {
OptimisticEffect,
OptimisticEffectWriter,
} from '../types/internal/OptimisticEffect';
import { OptimisticEffectDefinition } from '../types/OptimisticEffectDefinition';
export const useOptimisticEffect = ({
objectNameSingular,
}: ObjectMetadataItemIdentifier) => {
const apolloClient = useApolloClient();
const { findManyRecordsQuery, objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const unregisterOptimisticEffect = useRecoilCallback(
({ snapshot, set }) =>
({
variables,
definition,
}: {
variables: ObjectRecordQueryVariables;
definition: OptimisticEffectDefinition;
}) => {
const optimisticEffects = snapshot
.getLoadable(optimisticEffectState)
.getValue();
const computedKey = computeOptimisticEffectKey({
variables,
definition,
});
const { [computedKey]: _, ...rest } = optimisticEffects;
set(optimisticEffectState, rest);
},
);
const registerOptimisticEffect = useRecoilCallback(
({ snapshot, set }) =>
({
variables,
definition,
}: {
variables: ObjectRecordQueryVariables;
definition: OptimisticEffectDefinition;
}) => {
if (findManyRecordsQuery === EMPTY_QUERY) {
throw new Error(
`Trying to register an optimistic effect for unknown object ${objectNameSingular}`,
);
}
const optimisticEffects = snapshot
.getLoadable(optimisticEffectState)
.getValue();
const optimisticEffectWriter: OptimisticEffectWriter = ({
cache,
createdRecords,
updatedRecords,
deletedRecordIds,
query,
variables,
objectMetadataItem,
}) => {
if (objectMetadataItem) {
const existingData = cache.readQuery({
query: findManyRecordsQuery,
variables,
});
if (
!existingData &&
(isNonEmptyArray(updatedRecords) ||
isNonEmptyArray(deletedRecordIds))
) {
return;
}
cache.writeQuery({
query: findManyRecordsQuery,
variables,
data: {
[objectMetadataItem.namePlural]: definition.resolver({
currentCacheData: (existingData as any)?.[
objectMetadataItem.namePlural
],
updatedRecords,
createdRecords,
deletedRecordIds,
variables,
}),
},
});
return;
}
const existingData = cache.readQuery({
query: query ?? findManyRecordsQuery,
variables,
});
if (!existingData) {
return;
}
};
const computedKey = computeOptimisticEffectKey({
variables,
definition,
});
const optimisticEffect = {
variables,
typename: definition.typename,
query: definition.query,
writer: optimisticEffectWriter,
objectMetadataItem,
} satisfies OptimisticEffect;
set(optimisticEffectState, {
...optimisticEffects,
[computedKey]: optimisticEffect,
});
},
[findManyRecordsQuery, objectNameSingular, objectMetadataItem],
);
const triggerOptimisticEffects = useRecoilCallback(
({ snapshot }) =>
({
typename,
createdRecords = [],
updatedRecords = [],
deletedRecordIds,
}: {
typename: string;
createdRecords?: Record<string, unknown>[];
updatedRecords?: Record<string, unknown>[];
deletedRecordIds?: string[];
}) => {
const optimisticEffects = snapshot
.getLoadable(optimisticEffectState)
.getValue();
for (const optimisticEffect of Object.values(optimisticEffects)) {
// We need to update the typename when createObject type differs from listObject types
// It is the case for apiKey, where the creation route returns an ApiKeyToken type
const formattedCreatedRecords = createdRecords.map((createdRecord) =>
typename.endsWith('Edge')
? createdRecord
: { ...createdRecord, __typename: typename },
);
const formattedUpdatedRecords = updatedRecords.map((updatedRecord) =>
typename.endsWith('Edge')
? updatedRecord
: { ...updatedRecord, __typename: typename },
);
if (optimisticEffect.typename === typename) {
optimisticEffect.writer({
cache: apolloClient.cache,
query: optimisticEffect.query,
createdRecords: formattedCreatedRecords,
updatedRecords: formattedUpdatedRecords,
deletedRecordIds,
variables: optimisticEffect.variables,
objectMetadataItem: optimisticEffect.objectMetadataItem,
});
}
}
},
[apolloClient.cache],
);
return {
registerOptimisticEffect,
triggerOptimisticEffects,
unregisterOptimisticEffect,
};
};

View File

@ -1,8 +0,0 @@
import { atom } from 'recoil';
import { OptimisticEffect } from '../types/internal/OptimisticEffect';
export const optimisticEffectState = atom<Record<string, OptimisticEffect>>({
key: 'optimisticEffectState',
default: {},
});

View File

@ -1,12 +0,0 @@
import { DocumentNode } from 'graphql';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { OptimisticEffectResolver } from './OptimisticEffectResolver';
export type OptimisticEffectDefinition = {
query?: DocumentNode;
typename: string;
resolver: OptimisticEffectResolver;
objectMetadataItem?: ObjectMetadataItem;
};

View File

@ -1,15 +0,0 @@
import { OperationVariables } from '@apollo/client';
export type OptimisticEffectResolver = ({
currentCacheData,
createdRecords,
updatedRecords,
deletedRecordIds,
variables,
}: {
currentCacheData: any; //TODO: Change when decommissioning v1
createdRecords?: Record<string, unknown>[];
updatedRecords?: Record<string, unknown>[];
deletedRecordIds?: string[];
variables: OperationVariables;
}) => void;

View File

@ -1,30 +0,0 @@
import { ApolloCache, DocumentNode } from '@apollo/client';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { ObjectRecordQueryVariables } from '@/object-record/types/ObjectRecordQueryVariables';
export type OptimisticEffectWriter = ({
cache,
query,
createdRecords,
updatedRecords,
deletedRecordIds,
variables,
objectMetadataItem,
}: {
cache: ApolloCache<any>;
query?: DocumentNode;
createdRecords?: Record<string, unknown>[];
updatedRecords?: Record<string, unknown>[];
deletedRecordIds?: string[];
variables: ObjectRecordQueryVariables;
objectMetadataItem: ObjectMetadataItem;
}) => void;
export type OptimisticEffect = {
query?: DocumentNode;
typename: string;
variables: ObjectRecordQueryVariables;
writer: OptimisticEffectWriter;
objectMetadataItem: ObjectMetadataItem;
};

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 };
},
},
});
};

View File

@ -0,0 +1,3 @@
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
export type CachedObjectRecord = ObjectRecord & { __typename: string };

View File

@ -0,0 +1,9 @@
import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge';
import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection';
export type CachedObjectRecordConnection = Omit<
ObjectRecordConnection,
'edges'
> & {
edges: CachedObjectRecordEdge[];
};

View File

@ -0,0 +1,7 @@
import { Reference } from '@apollo/client';
import { ObjectRecordEdge } from '@/object-record/types/ObjectRecordEdge';
export type CachedObjectRecordEdge = Omit<ObjectRecordEdge, 'node'> & {
node: Reference;
};

View File

@ -0,0 +1,6 @@
import { ObjectRecordQueryVariables } from '@/object-record/types/ObjectRecordQueryVariables';
export type CachedObjectRecordQueryVariables = Omit<
ObjectRecordQueryVariables,
'limit'
> & { first?: ObjectRecordQueryVariables['limit'] };