fix(front): dedupe morph relation fields in view field pickers (#21580)

## Issue

Reported in quality-feedbacks: **"Issues with morph relation view
field"** — a morph relation column added to a view **disappears after
refresh** (and can be added several times).

## Root cause — the SSE metadata sync

A morph relation is stored as **one `fieldMetadata` row per target
object**, all sharing a `morphId`. Collapsing those rows into the single
field that represents the relation is a **read-time projection** in the
server's `objects.fieldsList` resolver — it is *not* a storage
invariant, and the rows are never merged.

The frontend metadata store is kept in sync with the raw rows **one row
at a time over SSE** (`MetadataStoreSSEEffect`): every metadata change
broadcasts a single created/updated record that's pushed straight into
the store. Creating a morph relation creates N rows (one per target), so
**N `create` events arrive and N raw sub-fields land in the store —
bypassing the `fieldsList` projection entirely.**

The view-field pickers read straight from that store, so they saw the
morph relation **once per target**. Each could be added as a column
referencing a different sub-field id; after a refresh the view reloads
from the projected (deduped) data, the non-survivor columns no longer
resolve, and they disappear.

## Fix & architecture note

Because the store deliberately mirrors raw rows (that's what the SSE
sync maintains), the fix applies the **same read-time projection on the
client** — deduping morph rows by `morphId` in
`useActiveFieldMetadataItems` — rather than filtering rows at each
insert path (SSE, optimistic create, …). This matches how the backend
already models morph fields and is robust regardless of which path
delivered the rows.

The survivor-selection rule (which sub-field id represents the relation)
now lives in `twenty-shared` (`pickMorphGroupSurvivor`) so client and
server can't drift.
This commit is contained in:
Charles Bochet
2026-06-15 13:56:35 +02:00
committed by GitHub
parent ecc7b38b75
commit 0a99f784eb
10 changed files with 293 additions and 116 deletions
@@ -1,5 +1,6 @@
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { isDefined } from 'twenty-shared/utils';
import { dedupeMorphRelationFieldMetadataItems } from '@/object-metadata/utils/dedupeMorphRelationFieldMetadataItems';
import { isActiveFieldMetadataItem } from '@/object-metadata/utils/isActiveFieldMetadataItem';
import { useMemo } from 'react';
@@ -11,14 +12,16 @@ export const useActiveFieldMetadataItems = ({
const activeFieldMetadataItems = useMemo(
() =>
isDefined(objectMetadataItem)
? objectMetadataItem.readableFields.filter(
({ id, isActive, isSystem, name }) =>
isActiveFieldMetadataItem({
objectNameSingular: objectMetadataItem.nameSingular,
fieldMetadata: { isActive, isSystem, name },
}) ||
// Allow label identifier field even if it's a system field
id === objectMetadataItem.labelIdentifierFieldMetadataId,
? dedupeMorphRelationFieldMetadataItems(
objectMetadataItem.readableFields.filter(
({ id, isActive, isSystem, name }) =>
isActiveFieldMetadataItem({
objectNameSingular: objectMetadataItem.nameSingular,
fieldMetadata: { isActive, isSystem, name },
}) ||
// Allow label identifier field even if it's a system field
id === objectMetadataItem.labelIdentifierFieldMetadataId,
),
)
: [],
[objectMetadataItem],
@@ -0,0 +1,146 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { dedupeMorphRelationFieldMetadataItems } from '@/object-metadata/utils/dedupeMorphRelationFieldMetadataItems';
const buildField = (
field: Partial<FieldMetadataItem> & Pick<FieldMetadataItem, 'id' | 'type'>,
): FieldMetadataItem =>
({
isActive: true,
isSystem: false,
morphId: null,
...field,
}) as FieldMetadataItem;
describe('dedupeMorphRelationFieldMetadataItems', () => {
it('should keep non-morph fields untouched', () => {
const fields = [
buildField({ id: '1', type: FieldMetadataType.TEXT }),
buildField({ id: '2', type: FieldMetadataType.NUMBER }),
];
expect(dedupeMorphRelationFieldMetadataItems(fields)).toEqual(fields);
});
it('should keep a single field per morphId', () => {
const fields = [
buildField({
id: 'b',
type: FieldMetadataType.MORPH_RELATION,
morphId: 'morph-1',
}),
buildField({
id: 'a',
type: FieldMetadataType.MORPH_RELATION,
morphId: 'morph-1',
}),
];
const result = dedupeMorphRelationFieldMetadataItems(fields);
expect(result).toHaveLength(1);
expect(result[0].id).toBe('a');
});
it('should preserve the position of the surviving morph field', () => {
const fields = [
buildField({ id: 'name', type: FieldMetadataType.TEXT }),
buildField({
id: 'a',
type: FieldMetadataType.MORPH_RELATION,
morphId: 'morph-1',
}),
buildField({
id: 'z',
type: FieldMetadataType.MORPH_RELATION,
morphId: 'morph-1',
}),
buildField({ id: 'tag', type: FieldMetadataType.TEXT }),
];
const result = dedupeMorphRelationFieldMetadataItems(fields);
expect(result.map((field) => field.id)).toEqual(['name', 'a', 'tag']);
});
it('should prefer active non-system fields over system ones', () => {
const fields = [
buildField({
id: 'a',
type: FieldMetadataType.MORPH_RELATION,
morphId: 'morph-1',
isSystem: true,
}),
buildField({
id: 'z',
type: FieldMetadataType.MORPH_RELATION,
morphId: 'morph-1',
isSystem: false,
}),
];
const result = dedupeMorphRelationFieldMetadataItems(fields);
expect(result).toHaveLength(1);
expect(result[0].id).toBe('z');
});
it('should keep the active morph field over an inactive one', () => {
const fields = [
buildField({
id: 'a',
type: FieldMetadataType.MORPH_RELATION,
morphId: 'morph-1',
isActive: false,
}),
buildField({
id: 'z',
type: FieldMetadataType.MORPH_RELATION,
morphId: 'morph-1',
isActive: true,
}),
];
const result = dedupeMorphRelationFieldMetadataItems(fields);
expect(result).toHaveLength(1);
expect(result[0].id).toBe('z');
});
it('should dedupe each morphId independently', () => {
const fields = [
buildField({
id: 'a1',
type: FieldMetadataType.MORPH_RELATION,
morphId: 'morph-1',
}),
buildField({
id: 'a2',
type: FieldMetadataType.MORPH_RELATION,
morphId: 'morph-1',
}),
buildField({
id: 'b1',
type: FieldMetadataType.MORPH_RELATION,
morphId: 'morph-2',
}),
];
const result = dedupeMorphRelationFieldMetadataItems(fields);
expect(result.map((field) => field.id).sort()).toEqual(['a1', 'b1']);
});
it('should keep morph fields without a morphId', () => {
const fields = [
buildField({
id: 'a',
type: FieldMetadataType.MORPH_RELATION,
morphId: null,
}),
];
expect(dedupeMorphRelationFieldMetadataItems(fields)).toEqual(fields);
});
});
@@ -0,0 +1,38 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined, pickMorphGroupSurvivorOrThrow } from 'twenty-shared/utils';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
export const dedupeMorphRelationFieldMetadataItems = (
fieldMetadataItems: FieldMetadataItem[],
): FieldMetadataItem[] => {
const morphGroupsByMorphId = new Map<string, FieldMetadataItem[]>();
for (const fieldMetadataItem of fieldMetadataItems) {
if (
fieldMetadataItem.type !== FieldMetadataType.MORPH_RELATION ||
!isDefined(fieldMetadataItem.morphId)
) {
continue;
}
const group = morphGroupsByMorphId.get(fieldMetadataItem.morphId) ?? [];
group.push(fieldMetadataItem);
morphGroupsByMorphId.set(fieldMetadataItem.morphId, group);
}
const survivorIdByMorphId = new Map<string, string>();
for (const [morphId, group] of morphGroupsByMorphId) {
survivorIdByMorphId.set(morphId, pickMorphGroupSurvivorOrThrow(group).id);
}
return fieldMetadataItems.filter(
(fieldMetadataItem) =>
fieldMetadataItem.type !== FieldMetadataType.MORPH_RELATION ||
!isDefined(fieldMetadataItem.morphId) ||
survivorIdByMorphId.get(fieldMetadataItem.morphId) ===
fieldMetadataItem.id,
);
};
@@ -1,85 +0,0 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { pickMorphGroupSurvivor } from 'src/engine/dataloaders/utils/pick-morph-group-survivor.util';
const makeMorphField = (
overrides: Partial<FlatFieldMetadata<FieldMetadataType.MORPH_RELATION>> & {
id: string;
},
): FlatFieldMetadata<FieldMetadataType.MORPH_RELATION> =>
({
type: FieldMetadataType.MORPH_RELATION,
isActive: true,
isSystem: false,
morphId: 'morph-1',
...overrides,
}) as FlatFieldMetadata<FieldMetadataType.MORPH_RELATION>;
describe('pickMorphGroupSurvivor', () => {
it('should return the only field when group has one element', () => {
const field = makeMorphField({ id: 'a' });
expect(pickMorphGroupSurvivor([field])).toBe(field);
});
it('should prefer active non-system over active system', () => {
const standard = makeMorphField({
id: 'b',
isActive: true,
isSystem: false,
});
const system = makeMorphField({
id: 'a',
isActive: true,
isSystem: true,
});
expect(pickMorphGroupSurvivor([system, standard])).toBe(standard);
});
it('should prefer active over inactive', () => {
const active = makeMorphField({
id: 'b',
isActive: true,
isSystem: true,
});
const inactive = makeMorphField({
id: 'a',
isActive: false,
isSystem: false,
});
expect(pickMorphGroupSurvivor([inactive, active])).toBe(active);
});
it('should break ties by smallest id', () => {
const fieldA = makeMorphField({
id: 'aaa',
isActive: true,
isSystem: false,
});
const fieldB = makeMorphField({
id: 'bbb',
isActive: true,
isSystem: false,
});
expect(pickMorphGroupSurvivor([fieldB, fieldA])).toBe(fieldA);
});
it('should prefer active+non-system (score 3) over inactive+non-system (score 1)', () => {
const best = makeMorphField({
id: 'z',
isActive: true,
isSystem: false,
});
const worse = makeMorphField({
id: 'a',
isActive: false,
isSystem: false,
});
expect(pickMorphGroupSurvivor([worse, best])).toBe(best);
});
});
@@ -1,8 +1,8 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { pickMorphGroupSurvivorOrThrow } from 'twenty-shared/utils';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
import { pickMorphGroupSurvivor } from 'src/engine/dataloaders/utils/pick-morph-group-survivor.util';
export const filterMorphRelationDuplicateFields = (
flatFieldMetadatas: FlatFieldMetadata[],
@@ -36,7 +36,7 @@ export const filterMorphRelationDuplicateFields = (
[];
for (const group of morphGroupsByMorphId.values()) {
filteredMorphFlatFieldMetadatas.push(pickMorphGroupSurvivor(group));
filteredMorphFlatFieldMetadatas.push(pickMorphGroupSurvivorOrThrow(group));
}
return [...otherFlatFieldMetadatas, ...filteredMorphFlatFieldMetadatas];
@@ -1,19 +0,0 @@
import { type FieldMetadataType } from 'twenty-shared/types';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
// Prefers active non-system fields (standard targets) over system ones
// (auto-created for custom objects). Smallest id breaks ties.
const scoreMorphField = (
field: FlatFieldMetadata<FieldMetadataType.MORPH_RELATION>,
): number => (field.isActive ? 2 : 0) + (field.isSystem ? 0 : 1);
export const pickMorphGroupSurvivor = (
group: FlatFieldMetadata<FieldMetadataType.MORPH_RELATION>[],
): FlatFieldMetadata<FieldMetadataType.MORPH_RELATION> => {
return group.reduce((best, current) => {
const diff = scoreMorphField(current) - scoreMorphField(best);
return diff > 0 || (diff === 0 && current.id < best.id) ? current : best;
});
};
@@ -1,6 +1,6 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { pickMorphGroupSurvivorOrThrow } from 'twenty-shared/utils';
import { pickMorphGroupSurvivor } from 'src/engine/dataloaders/utils/pick-morph-group-survivor.util';
import { RelationDTO } from 'src/engine/metadata-modules/field-metadata/dtos/relation.dto';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
@@ -64,7 +64,7 @@ export const resolveRelationFromFlatFieldMetadata = ({
}),
];
const survivorMorphField = pickMorphGroupSurvivor(
const survivorMorphField = pickMorphGroupSurvivorOrThrow(
allMorphFlatFieldMetadatas,
);
@@ -0,0 +1,61 @@
import { pickMorphGroupSurvivorOrThrow } from '@/utils/fieldMetadata/pick-morph-group-survivor-or-throw';
const makeMorphField = (overrides: {
id: string;
isActive?: boolean;
isSystem?: boolean;
}) => ({
isActive: true,
isSystem: false,
...overrides,
});
describe('pickMorphGroupSurvivorOrThrow', () => {
it('should return the only field when group has one element', () => {
const field = makeMorphField({ id: 'a' });
expect(pickMorphGroupSurvivorOrThrow([field])).toBe(field);
});
it('should prefer active non-system over active system', () => {
const standard = makeMorphField({
id: 'b',
isActive: true,
isSystem: false,
});
const system = makeMorphField({ id: 'a', isActive: true, isSystem: true });
expect(pickMorphGroupSurvivorOrThrow([system, standard])).toBe(standard);
});
it('should prefer active over inactive', () => {
const active = makeMorphField({ id: 'b', isActive: true, isSystem: true });
const inactive = makeMorphField({
id: 'a',
isActive: false,
isSystem: false,
});
expect(pickMorphGroupSurvivorOrThrow([inactive, active])).toBe(active);
});
it('should break ties by smallest id', () => {
const fieldA = makeMorphField({ id: 'aaa' });
const fieldB = makeMorphField({ id: 'bbb' });
expect(pickMorphGroupSurvivorOrThrow([fieldB, fieldA])).toBe(fieldA);
});
it('should treat nullish isActive/isSystem as falsy', () => {
const nullishField = { id: 'a', isActive: null, isSystem: null };
const activeField = makeMorphField({ id: 'b', isActive: true });
expect(pickMorphGroupSurvivorOrThrow([nullishField, activeField])).toBe(
activeField,
);
});
it('should throw on an empty group', () => {
expect(() => pickMorphGroupSurvivorOrThrow([])).toThrow();
});
});
@@ -0,0 +1,32 @@
import { CustomError } from '@/utils/errors';
type MorphGroupSurvivorCandidate = {
id: string;
isActive?: boolean | null;
isSystem?: boolean | null;
};
const scoreMorphField = (field: MorphGroupSurvivorCandidate): number =>
(field.isActive ? 2 : 0) + (field.isSystem ? 0 : 1);
export const pickMorphGroupSurvivorOrThrow = <
T extends MorphGroupSurvivorCandidate,
>(
group: T[],
): T => {
if (group.length === 0) {
throw new CustomError(
'pickMorphGroupSurvivorOrThrow requires a non-empty morph group',
'EMPTY_MORPH_GROUP',
);
}
return group.reduce((best, current) => {
const scoreDifference = scoreMorphField(current) - scoreMorphField(best);
return scoreDifference > 0 ||
(scoreDifference === 0 && current.id < best.id)
? current
: best;
});
};
@@ -58,6 +58,7 @@ export { isFieldMetadataNumericKind } from './fieldMetadata/isFieldMetadataNumer
export { isFieldMetadataSelectKind } from './fieldMetadata/isFieldMetadataSelectKind';
export { isFieldMetadataSupportedInGroupBy } from './fieldMetadata/isFieldMetadataSupportedInGroupBy';
export { isFieldMetadataTextKind } from './fieldMetadata/isFieldMetadataTextKind';
export { pickMorphGroupSurvivorOrThrow } from './fieldMetadata/pick-morph-group-survivor-or-throw';
export { shouldExcludeFieldFromAgentToolSchema } from './fieldMetadata/shouldExcludeFieldFromAgentToolSchema';
export { extractFolderPathFilenameAndTypeOrThrow } from './files/extractFolderPathFilenameAndTypeOrThrow.util';
export { checkIfShouldComputeEmptinessFilter } from './filter/checkIfShouldComputeEmptinessFilter';