From f667ba500cc4d7b3797b9cab6d031254a967ff4d Mon Sep 17 00:00:00 2001 From: Marie <51697796+ijreilly@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:55:25 +0200 Subject: [PATCH] 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. --- .../MetadataStoreSSEEffect.tsx | 7 ++ ...MorphRelationsTargetingObjectMetadataId.ts | 38 +++++++++++ ...RelationsTargetingObjectMetadataId.test.ts | 66 +++++++++++++++++++ ...MorphRelationsTargetingObjectMetadataId.ts | 33 ++++++++++ .../hooks/useDeleteOneObjectMetadataItem.ts | 4 ++ 5 files changed, 148 insertions(+) create mode 100644 packages/twenty-front/src/modules/metadata-store/hooks/useCleanMorphRelationsTargetingObjectMetadataId.ts create mode 100644 packages/twenty-front/src/modules/metadata-store/utils/__tests__/cleanMorphRelationsTargetingObjectMetadataId.test.ts create mode 100644 packages/twenty-front/src/modules/metadata-store/utils/cleanMorphRelationsTargetingObjectMetadataId.ts diff --git a/packages/twenty-front/src/modules/metadata-store/effect-components/MetadataStoreSSEEffect.tsx b/packages/twenty-front/src/modules/metadata-store/effect-components/MetadataStoreSSEEffect.tsx index db665dca5d..6e2513b14f 100644 --- a/packages/twenty-front/src/modules/metadata-store/effect-components/MetadataStoreSSEEffect.tsx +++ b/packages/twenty-front/src/modules/metadata-store/effect-components/MetadataStoreSSEEffect.tsx @@ -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; } } diff --git a/packages/twenty-front/src/modules/metadata-store/hooks/useCleanMorphRelationsTargetingObjectMetadataId.ts b/packages/twenty-front/src/modules/metadata-store/hooks/useCleanMorphRelationsTargetingObjectMetadataId.ts new file mode 100644 index 0000000000..113af6db51 --- /dev/null +++ b/packages/twenty-front/src/modules/metadata-store/hooks/useCleanMorphRelationsTargetingObjectMetadataId.ts @@ -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 }; +}; diff --git a/packages/twenty-front/src/modules/metadata-store/utils/__tests__/cleanMorphRelationsTargetingObjectMetadataId.test.ts b/packages/twenty-front/src/modules/metadata-store/utils/__tests__/cleanMorphRelationsTargetingObjectMetadataId.test.ts new file mode 100644 index 0000000000..2dce0e4cf3 --- /dev/null +++ b/packages/twenty-front/src/modules/metadata-store/utils/__tests__/cleanMorphRelationsTargetingObjectMetadataId.test.ts @@ -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[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([]); + }); +}); diff --git a/packages/twenty-front/src/modules/metadata-store/utils/cleanMorphRelationsTargetingObjectMetadataId.ts b/packages/twenty-front/src/modules/metadata-store/utils/cleanMorphRelationsTargetingObjectMetadataId.ts new file mode 100644 index 0000000000..f7a0238061 --- /dev/null +++ b/packages/twenty-front/src/modules/metadata-store/utils/cleanMorphRelationsTargetingObjectMetadataId.ts @@ -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; +}; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useDeleteOneObjectMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useDeleteOneObjectMetadataItem.ts index 601778ea0a..aec2bd2c56 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useDeleteOneObjectMetadataItem.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useDeleteOneObjectMetadataItem.ts @@ -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();