fix(front): clean stale morph relations from metadata store on object deletion (#22681)
## Problem After deleting a custom object (e.g. `meeting`), the app crashes with "Sorry, something went wrong" on pages that load records referencing that object through a morph relation. The console shows: ``` Target object metadata item not found for target (morph target meeting) ``` It reproduces on the machine that used the object before deletion but not on a fresh machine, which points at a stale client metadata store rather than a server issue. ## Root cause Every field carries its own server-provided `morphRelations` array; a morph relation field (note/task/timeline targets, etc.) lists every object it can point to, including the deleted one. When an object is deleted, `useDeleteOneObjectMetadataItem` and the SSE `delete` handler only remove the deleted **object** and its own fields from the metadata store. The sibling morph fields on other objects keep their now-dangling `morphRelations` entry pointing at the deleted object. Those stale entries were only meant to be cleaned up later by a collection-hash-triggered `network-only` refetch. When that reconciliation does not win, `generateDepthRecordGqlFieldsFromFields` can't resolve the deleted morph target in `objectMetadataItems` and throws, crashing the page. ## Fix Clean `morphRelations` entries referencing the deleted object from the field metadata store at deletion time, so the store stays self-consistent immediately instead of relying on an async refetch. Applied in both paths that handle object deletion: - `useDeleteOneObjectMetadataItem` (the client performing the deletion) - `MetadataStoreSSEEffect` delete handler (other tabs/clients receiving the event) The throw in `generateDepthRecordGqlFieldsFromFields` is intentionally left in place so any genuine future metadata inconsistency still surfaces rather than being silently swallowed. ## Test Added a unit test for the cleaning util covering: morph relations targeting the deleted object are removed, only changed fields are returned, non-morph fields are untouched, and nothing is returned when no relation targets the deleted object.
This commit is contained in:
+7
@@ -1,4 +1,5 @@
|
||||
import { useListenToMetadataOperationBrowserEvent } from '@/browser-event/hooks/useListenToMetadataOperationBrowserEvent';
|
||||
import { useCleanMorphRelationsTargetingObjectMetadataId } from '@/metadata-store/hooks/useCleanMorphRelationsTargetingObjectMetadataId';
|
||||
import { useUpdateMetadataStoreDraft } from '@/metadata-store/hooks/useUpdateMetadataStoreDraft';
|
||||
import { type MetadataEntityKey } from '@/metadata-store/states/metadataStoreState';
|
||||
import { type MetadataEntityTypeMap } from '@/metadata-store/types/MetadataEntityTypeMap';
|
||||
@@ -10,6 +11,8 @@ type AnyMetadataEntity = MetadataEntityTypeMap[MetadataEntityKey];
|
||||
export const MetadataStoreSSEEffect = () => {
|
||||
const { addToDraft, removeFromDraft, applyChanges } =
|
||||
useUpdateMetadataStoreDraft();
|
||||
const { cleanMorphRelations } =
|
||||
useCleanMorphRelationsTargetingObjectMetadataId();
|
||||
|
||||
useListenToMetadataOperationBrowserEvent({
|
||||
onMetadataOperationBrowserEvent: (eventDetail) => {
|
||||
@@ -50,6 +53,10 @@ export const MetadataStoreSSEEffect = () => {
|
||||
itemIds: [eventDetail.operation.deletedRecordId],
|
||||
collectionHash,
|
||||
});
|
||||
|
||||
if (entityKey === 'objectMetadataItems') {
|
||||
cleanMorphRelations(eventDetail.operation.deletedRecordId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { useUpdateMetadataStoreDraft } from '@/metadata-store/hooks/useUpdateMetadataStoreDraft';
|
||||
import { metadataStoreState } from '@/metadata-store/states/metadataStoreState';
|
||||
import { type FlatFieldMetadataItem } from '@/metadata-store/types/FlatFieldMetadataItem';
|
||||
import { cleanMorphRelationsTargetingObjectMetadataId } from '@/metadata-store/utils/cleanMorphRelationsTargetingObjectMetadataId';
|
||||
import { useStore } from 'jotai';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export const useCleanMorphRelationsTargetingObjectMetadataId = () => {
|
||||
const store = useStore();
|
||||
const { updateInDraft } = useUpdateMetadataStoreDraft();
|
||||
|
||||
const cleanMorphRelations = useCallback(
|
||||
(deletedObjectMetadataId: string) => {
|
||||
const entry = store.get(
|
||||
metadataStoreState.atomFamily('fieldMetadataItems'),
|
||||
);
|
||||
|
||||
const baseFieldMetadataItems = (
|
||||
entry.status === 'draft-pending' ? entry.draft : entry.current
|
||||
) as FlatFieldMetadataItem[];
|
||||
|
||||
const cleanedFieldMetadataItems =
|
||||
cleanMorphRelationsTargetingObjectMetadataId(
|
||||
baseFieldMetadataItems,
|
||||
deletedObjectMetadataId,
|
||||
);
|
||||
|
||||
if (cleanedFieldMetadataItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateInDraft('fieldMetadataItems', cleanedFieldMetadataItems);
|
||||
},
|
||||
[store, updateInDraft],
|
||||
);
|
||||
|
||||
return { cleanMorphRelations };
|
||||
};
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import { type FlatFieldMetadataItem } from '@/metadata-store/types/FlatFieldMetadataItem';
|
||||
import { cleanMorphRelationsTargetingObjectMetadataId } from '@/metadata-store/utils/cleanMorphRelationsTargetingObjectMetadataId';
|
||||
|
||||
const buildMorphRelation = (targetObjectMetadataId: string) =>
|
||||
({
|
||||
targetObjectMetadata: {
|
||||
id: targetObjectMetadataId,
|
||||
nameSingular: targetObjectMetadataId,
|
||||
namePlural: `${targetObjectMetadataId}s`,
|
||||
},
|
||||
}) as unknown as NonNullable<FlatFieldMetadataItem['morphRelations']>[number];
|
||||
|
||||
const buildField = (
|
||||
id: string,
|
||||
morphRelations: FlatFieldMetadataItem['morphRelations'],
|
||||
): FlatFieldMetadataItem =>
|
||||
({
|
||||
id,
|
||||
name: id,
|
||||
objectMetadataId: 'source-object',
|
||||
morphRelations,
|
||||
}) as unknown as FlatFieldMetadataItem;
|
||||
|
||||
describe('cleanMorphRelationsTargetingObjectMetadataId', () => {
|
||||
it('should remove morph relations targeting the deleted object and return only changed fields', () => {
|
||||
const morphField = buildField('morph-field', [
|
||||
buildMorphRelation('company-id'),
|
||||
buildMorphRelation('meeting-id'),
|
||||
]);
|
||||
const untouchedField = buildField('other-morph-field', [
|
||||
buildMorphRelation('company-id'),
|
||||
]);
|
||||
const nonMorphField = buildField('text-field', null);
|
||||
|
||||
const result = cleanMorphRelationsTargetingObjectMetadataId(
|
||||
[morphField, untouchedField, nonMorphField],
|
||||
'meeting-id',
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('morph-field');
|
||||
expect(result[0].morphRelations).toEqual([
|
||||
buildMorphRelation('company-id'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should keep a morph field with an empty morphRelations array when all its targets are deleted', () => {
|
||||
const result = cleanMorphRelationsTargetingObjectMetadataId(
|
||||
[buildField('exclusive-morph', [buildMorphRelation('meeting-id')])],
|
||||
'meeting-id',
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('exclusive-morph');
|
||||
expect(result[0].morphRelations).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return an empty array when no morph relation targets the deleted object', () => {
|
||||
const result = cleanMorphRelationsTargetingObjectMetadataId(
|
||||
[buildField('morph-field', [buildMorphRelation('company-id')])],
|
||||
'meeting-id',
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { type FlatFieldMetadataItem } from '@/metadata-store/types/FlatFieldMetadataItem';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const cleanMorphRelationsTargetingObjectMetadataId = (
|
||||
fieldMetadataItems: FlatFieldMetadataItem[],
|
||||
deletedObjectMetadataId: string,
|
||||
): FlatFieldMetadataItem[] => {
|
||||
const cleanedFieldMetadataItems: FlatFieldMetadataItem[] = [];
|
||||
|
||||
for (const fieldMetadataItem of fieldMetadataItems) {
|
||||
const morphRelations = fieldMetadataItem.morphRelations;
|
||||
|
||||
if (
|
||||
!isDefined(morphRelations) ||
|
||||
!morphRelations.some(
|
||||
(morphRelation) =>
|
||||
morphRelation.targetObjectMetadata.id === deletedObjectMetadataId,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cleanedFieldMetadataItems.push({
|
||||
...fieldMetadataItem,
|
||||
morphRelations: morphRelations.filter(
|
||||
(morphRelation) =>
|
||||
morphRelation.targetObjectMetadata.id !== deletedObjectMetadataId,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return cleanedFieldMetadataItems;
|
||||
};
|
||||
+4
@@ -2,6 +2,7 @@ import { useMutation } from '@apollo/client/react';
|
||||
import { DeleteOneObjectMetadataItemDocument } from '~/generated-metadata/graphql';
|
||||
|
||||
import { useInvalidateMetadataStore } from '@/metadata-store/hooks/useInvalidateMetadataStore';
|
||||
import { useCleanMorphRelationsTargetingObjectMetadataId } from '@/metadata-store/hooks/useCleanMorphRelationsTargetingObjectMetadataId';
|
||||
import { useMetadataErrorHandler } from '@/metadata-error-handler/hooks/useMetadataErrorHandler';
|
||||
import { useUpdateMetadataStoreDraft } from '@/metadata-store/hooks/useUpdateMetadataStoreDraft';
|
||||
import { type MetadataRequestResult } from '@/object-metadata/types/MetadataRequestResult.type';
|
||||
@@ -19,6 +20,8 @@ export const useDeleteOneObjectMetadataItem = () => {
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
const { removeFromDraft, applyChanges } = useUpdateMetadataStoreDraft();
|
||||
const { invalidateMetadataStore } = useInvalidateMetadataStore();
|
||||
const { cleanMorphRelations } =
|
||||
useCleanMorphRelationsTargetingObjectMetadataId();
|
||||
|
||||
const deleteOneObjectMetadataItem = async (
|
||||
idToDelete: string,
|
||||
@@ -35,6 +38,7 @@ export const useDeleteOneObjectMetadataItem = () => {
|
||||
});
|
||||
|
||||
removeFromDraft({ key: 'objectMetadataItems', itemIds: [idToDelete] });
|
||||
cleanMorphRelations(idToDelete);
|
||||
applyChanges();
|
||||
|
||||
invalidateMetadataStore();
|
||||
|
||||
Reference in New Issue
Block a user