Fixed Apollo cache bug (#16523)

Fixes https://github.com/twentyhq/twenty/issues/16520

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Lucas Bordeau
2025-12-12 17:45:50 +01:00
committed by GitHub
parent f0af947331
commit ee47a773bd
22 changed files with 826 additions and 104 deletions
@@ -2,13 +2,17 @@ import { type ApolloCache, type StoreObject } from '@apollo/client';
import { triggerUpdateGroupByQueriesOptimisticEffect } from '@/apollo/optimistic-effect/group-by/utils/triggerUpdateGroupByQueriesOptimisticEffect';
import { triggerUpdateRelationsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect';
import { type CachedObjectRecordQueryVariables } from '@/apollo/types/CachedObjectRecordQueryVariables';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type RecordGqlRefEdge } from '@/object-record/cache/types/RecordGqlRefEdge';
import { isObjectRecordConnection } from '@/object-record/cache/utils/isObjectRecordConnection';
import { isObjectRecordConnectionWithRefs } from '@/object-record/cache/utils/isObjectRecordConnectionWithRefs';
import { type RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode';
import { isRecordMatchingFilter } from '@/object-record/record-filter/utils/isRecordMatchingFilter';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { type ObjectPermissions } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { parseApolloStoreFieldName } from '~/utils/parseApolloStoreFieldName';
export const triggerDestroyRecordsOptimisticEffect = ({
cache,
@@ -32,16 +36,50 @@ export const triggerDestroyRecordsOptimisticEffect = ({
fields: {
[objectMetadataItem.namePlural]: (
rootQueryCachedResponse,
{ readField },
{ readField, storeFieldName },
) => {
const rootQueryCachedResponseIsNotACachedObjectRecordConnection =
const { fieldVariables: rootQueryVariables } =
parseApolloStoreFieldName<CachedObjectRecordQueryVariables>(
storeFieldName,
);
if (
!isObjectRecordConnection(
objectMetadataItem.nameSingular,
rootQueryCachedResponse,
)
) {
return rootQueryCachedResponse;
}
const totalCount = readField<number | undefined>(
'totalCount',
rootQueryCachedResponse,
);
const recordsMatchingRootQueryFilter = recordsToDestroy.filter(
(record) =>
isRecordMatchingFilter({
record,
filter: rootQueryVariables?.filter ?? {},
objectMetadataItem,
}),
);
const newTotalCount = isDefined(totalCount)
? Math.max(totalCount - recordsMatchingRootQueryFilter.length, 0)
: undefined;
if (
!isObjectRecordConnectionWithRefs(
objectMetadataItem.nameSingular,
rootQueryCachedResponse,
);
if (rootQueryCachedResponseIsNotACachedObjectRecordConnection) {
return rootQueryCachedResponse;
)
) {
return {
...rootQueryCachedResponse,
totalCount: newTotalCount,
};
}
const rootQueryCachedObjectRecordConnection = rootQueryCachedResponse;
@@ -52,11 +90,6 @@ export const triggerDestroyRecordsOptimisticEffect = ({
rootQueryCachedObjectRecordConnection,
);
const totalCount = readField<number | undefined>(
'totalCount',
rootQueryCachedObjectRecordConnection,
);
const nextCachedEdges =
cachedEdges?.filter((cachedEdge) => {
const nodeId = readField<string>('id', cachedEdge.node);
@@ -65,14 +98,15 @@ export const triggerDestroyRecordsOptimisticEffect = ({
}) || [];
if (nextCachedEdges.length === cachedEdges?.length)
return rootQueryCachedObjectRecordConnection;
return {
...rootQueryCachedObjectRecordConnection,
totalCount: newTotalCount,
};
return {
...rootQueryCachedObjectRecordConnection,
edges: nextCachedEdges,
totalCount: isDefined(totalCount)
? totalCount - recordIdsToDestroy.length
: undefined,
totalCount: newTotalCount,
};
},
},
@@ -82,6 +82,26 @@ export const triggerUpdateRecordOptimisticEffect = ({
objectMetadataItem,
});
const currentRecordIndexInRootQueryEdges = isRecordMatchingFilter({
record: currentRecord,
filter: rootQueryFilter ?? {},
objectMetadataItem,
});
const totalCount = readField<number | undefined>(
'totalCount',
rootQueryConnection,
);
const newTotalCount = isDefined(totalCount)
? Math.max(
totalCount +
(updatedRecordMatchesThisRootQueryFilter ? 1 : 0) +
(currentRecordIndexInRootQueryEdges ? -1 : 0),
0,
)
: undefined;
const updatedRecordIndexInRootQueryEdges =
rootQueryCurrentEdges.findIndex(
(cachedEdge) =>
@@ -131,6 +151,7 @@ export const triggerUpdateRecordOptimisticEffect = ({
return {
...rootQueryConnection,
edges: rootQueryNextEdges,
totalCount: newTotalCount,
};
},
},
@@ -7,15 +7,18 @@ import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataIte
import { type FieldMetadataItemRelation } from '@/object-metadata/types/FieldMetadataItemRelation';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getFieldMetadataItemById } from '@/object-metadata/utils/getFieldMetadataItemById';
import { isObjectRecordConnection } from '@/object-record/cache/utils/isObjectRecordConnection';
import { type RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection';
import { type RecordGqlConnectionEdgesRequired } from '@/object-record/graphql/types/RecordGqlConnectionEdgesRequired';
import { type RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode';
import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation';
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { type ApolloCache } from '@apollo/client';
import { isArray } from '@sniptt/guards';
import { FieldMetadataType, type ObjectPermissions } from 'twenty-shared/types';
import {
FieldMetadataType,
RelationType,
type ObjectPermissions,
} from 'twenty-shared/types';
import {
computeMorphRelationFieldName,
CustomError,
@@ -147,12 +150,12 @@ const triggerUpdateRelationOptimisticEffect = ({
}
const currentFieldValueOnSourceRecord:
| RecordGqlConnection
| RecordGqlConnectionEdgesRequired
| RecordGqlNode
| null = currentSourceRecord?.[fieldMetadataItemOnSourceRecord.name];
const updatedFieldValueOnSourceRecord:
| RecordGqlConnection
| RecordGqlConnectionEdgesRequired
| RecordGqlNode
| null = updatedSourceRecord?.[fieldMetadataItemOnSourceRecord.name];
@@ -179,6 +182,7 @@ const triggerUpdateRelationOptimisticEffect = ({
CORE_OBJECT_NAMES_TO_DELETE_ON_TRIGGER_RELATION_DETACH.includes(
targetObjectMetadata.nameSingular as CoreObjectNameSingular,
);
const gqlFieldNameOnTargetRecord =
targetFieldMetadataFullObject.type === FieldMetadataType.RELATION
? targetFieldMetadataFullObject.name
@@ -189,7 +193,10 @@ const triggerUpdateRelationOptimisticEffect = ({
sourceObjectMetadataItem.nameSingular,
targetObjectMetadataNamePlural: sourceObjectMetadataItem.namePlural,
});
if (shouldCascadeDeleteTargetRecords) {
if (
shouldCascadeDeleteTargetRecords &&
targetRecordsToDetachFrom.length > 0
) {
triggerDestroyRecordsOptimisticEffect({
cache,
objectMetadataItem: fullTargetObjectMetadataItem,
@@ -198,7 +205,10 @@ const triggerUpdateRelationOptimisticEffect = ({
upsertRecordsInStore,
objectPermissionsByObjectMetadataId,
});
} else if (isDefined(currentSourceRecord)) {
} else if (
isDefined(currentSourceRecord) &&
targetRecordsToDetachFrom.length > 0
) {
targetRecordsToDetachFrom.forEach((targetRecordToDetachFrom) => {
triggerDetachRelationOptimisticEffect({
cache,
@@ -306,12 +316,12 @@ const triggerUpdateMorphRelationOptimisticEffect = ({
}
const currentFieldValueOnSourceRecord:
| RecordGqlConnection
| RecordGqlConnectionEdgesRequired
| RecordGqlNode
| null = currentSourceRecord?.[gqlFieldMorphRelation];
const updatedFieldValueOnSourceRecord:
| RecordGqlConnection
| RecordGqlConnectionEdgesRequired
| RecordGqlNode
| null = updatedSourceRecord?.[gqlFieldMorphRelation];
@@ -338,7 +348,10 @@ const triggerUpdateMorphRelationOptimisticEffect = ({
CORE_OBJECT_NAMES_TO_DELETE_ON_TRIGGER_RELATION_DETACH.includes(
targetObjectMetadata.nameSingular as CoreObjectNameSingular,
);
if (shouldCascadeDeleteTargetRecords) {
if (
shouldCascadeDeleteTargetRecords &&
targetRecordsToDetachFrom.length > 0
) {
triggerDestroyRecordsOptimisticEffect({
cache,
objectMetadataItem: fullTargetObjectMetadataItem,
@@ -347,7 +360,10 @@ const triggerUpdateMorphRelationOptimisticEffect = ({
objectPermissionsByObjectMetadataId,
upsertRecordsInStore,
});
} else if (isDefined(currentSourceRecord)) {
} else if (
isDefined(currentSourceRecord) &&
targetRecordsToDetachFrom.length > 0
) {
targetRecordsToDetachFrom.forEach((targetRecordToDetachFrom) => {
triggerDetachRelationOptimisticEffect({
cache,
@@ -387,7 +403,7 @@ const triggerUpdateMorphRelationOptimisticEffect = ({
};
const extractTargetRecordsFromRelation = (
value: RecordGqlConnection | RecordGqlNode | null,
value: RecordGqlConnectionEdgesRequired | RecordGqlNode | null,
relation: FieldMetadataItemRelation,
): RecordGqlNode[] => {
// TODO investigate on the root cause of array injection here, should never occurs
@@ -399,9 +415,9 @@ const extractTargetRecordsFromRelation = (
if (!isDefined(relation)) {
throw new Error('Relation found is undefined');
}
if (isObjectRecordConnection(relation, value)) {
return value.edges.map(({ node }) => node);
if (relation.type === RelationType.ONE_TO_MANY) {
return value.edges.map(({ node }: { node: RecordGqlNode }) => node);
}
return [value];
return [value as RecordGqlNode];
};