Fix merge with null value + reset data virtualization before init load (#19633)

**Merge records fix:**

selectPriorityFieldValue throws when merging records if the priority
record has no value for a field (e.g., null/empty) but 2+ other records
do. The recordsWithValues array is pre-filtered to only records with
non-empty values, so the priority record isn't in the list. The fix:
instead of throwing, fall back to null since this is the priority record
actual value


**Duplicated IDs fix**


https://github.com/user-attachments/assets/bd6d7d08-d079-49a5-aad4-740b59a3c246


When applying a filter that reduces the record count, the virtualized
table's record ID array keeps stale entries from the previous larger
result set. loadRecordsToVirtualRows clones the old array (e.g., 60
entries) and only overwrites the first N positions (e.g., 9) with the
new filtered results, leaving positions 9-59 with old IDs. If any old ID
matches a new one, it appears twice in the selection, causing "-> 2
selected" for a single click and a duplicate ID in the merge mutation
payload. The fix: clear the record IDs array in
useTriggerInitialRecordTableDataLoad before repopulating it with fresh
data.

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Thomas Trompette
2026-04-13 15:50:58 +00:00
committed by GitHub
co-authored by Charles Bochet
parent 87f5c0083f
commit 353d1e89d5
7 changed files with 176 additions and 140 deletions
@@ -0,0 +1,128 @@
import { defaultMergeFieldValue } from 'src/engine/api/graphql/graphql-query-runner/utils/default-merge-field-value.util';
describe('defaultMergeFieldValue', () => {
it('should return priority record value when available', () => {
const recordsWithValues = [
{
recordId: '1',
value: 'priority value',
},
{
recordId: '2',
value: 'other value',
},
];
const result = defaultMergeFieldValue(recordsWithValues, '1');
expect(result).toBe('priority value');
});
it('should fall back to first record with value when priority record not found', () => {
const recordsWithValues = [
{
recordId: '2',
value: 'first value',
},
{
recordId: '3',
value: 'second value',
},
];
const result = defaultMergeFieldValue(recordsWithValues, '1');
expect(result).toBe('first value');
});
it('should fall back to other record when priority record has null value', () => {
const recordsWithValues = [
{
recordId: '1',
value: null,
},
{
recordId: '2',
value: 'fallback value',
},
];
const result = defaultMergeFieldValue(recordsWithValues, '1');
expect(result).toBe('fallback value');
});
it('should fall back to other record when priority record has empty string', () => {
const recordsWithValues = [
{
recordId: '1',
value: '',
},
{
recordId: '2',
value: 'fallback value',
},
];
const result = defaultMergeFieldValue(recordsWithValues, '1');
expect(result).toBe('fallback value');
});
it('should fall back to other record when priority record has undefined value', () => {
const recordsWithValues = [
{
recordId: '1',
value: undefined,
},
{
recordId: '2',
value: 'fallback value',
},
];
const result = defaultMergeFieldValue(recordsWithValues, '1');
expect(result).toBe('fallback value');
});
it('should return null when no records exist', () => {
const result = defaultMergeFieldValue([], '1');
expect(result).toBeNull();
});
it('should return null when no record has a value', () => {
const recordsWithValues = [
{
recordId: '1',
value: null,
},
{
recordId: '2',
value: '',
},
];
const result = defaultMergeFieldValue(recordsWithValues, '1');
expect(result).toBeNull();
});
it('should handle complex object values', () => {
const recordsWithValues = [
{
recordId: '1',
value: { name: 'priority object', id: 1 },
},
{
recordId: '2',
value: { name: 'fallback object', id: 2 },
},
];
const result = defaultMergeFieldValue(recordsWithValues, '1');
expect(result).toEqual({ name: 'priority object', id: 1 });
});
});
@@ -21,20 +21,19 @@ describe('mergeFieldValues', () => {
expect(result).toBe('value2');
});
it('should throw error when priority record is not found', () => {
const recordsWithoutPriorityValue = [
it('should fall back to first record with value when priority record is not found', () => {
const recordsWithValues = [
{ value: 'value1', recordId: 'record1' },
{ value: null, recordId: PRIORITY_RECORD_ID },
{ value: 'value3', recordId: 'record3' },
];
expect(() =>
mergeFieldValues(
FieldMetadataType.TEXT,
recordsWithoutPriorityValue,
'non-existent-id',
),
).toThrow('Priority record with ID non-existent-id not found');
const result = mergeFieldValues(
FieldMetadataType.TEXT,
recordsWithValues,
'non-existent-id',
);
expect(result).toBe('value1');
});
});
@@ -1,111 +0,0 @@
import { selectPriorityFieldValue } from 'src/engine/api/graphql/graphql-query-runner/utils/select-priority-field-value.util';
describe('selectPriorityFieldValue', () => {
it('should return priority record value when available', () => {
const recordsWithValues = [
{
recordId: '1',
value: 'priority value',
},
{
recordId: '2',
value: 'other value',
},
];
const result = selectPriorityFieldValue(recordsWithValues, '1');
expect(result).toBe('priority value');
});
it('should throw error when priority record not found', () => {
const recordsWithValues = [
{
recordId: '2',
value: 'first value',
},
{
recordId: '3',
value: 'second value',
},
];
expect(() => selectPriorityFieldValue(recordsWithValues, '1')).toThrow(
'Priority record with ID 1 not found in merge candidates',
);
});
it('should return null when priority record has no value', () => {
const recordsWithValues = [
{
recordId: '1',
value: null,
},
{
recordId: '2',
value: 'fallback value',
},
];
const result = selectPriorityFieldValue(recordsWithValues, '1');
expect(result).toBeNull();
});
it('should return null when priority record has empty string', () => {
const recordsWithValues = [
{
recordId: '1',
value: '',
},
{
recordId: '2',
value: 'fallback value',
},
];
const result = selectPriorityFieldValue(recordsWithValues, '1');
expect(result).toBeNull();
});
it('should return null when priority record has undefined value', () => {
const recordsWithValues = [
{
recordId: '1',
value: undefined,
},
{
recordId: '2',
value: 'fallback value',
},
];
const result = selectPriorityFieldValue(recordsWithValues, '1');
expect(result).toBeNull();
});
it('should throw error when no records exist', () => {
expect(() => selectPriorityFieldValue([], '1')).toThrow(
'Priority record with ID 1 not found in merge candidates',
);
});
it('should handle complex object values', () => {
const recordsWithValues = [
{
recordId: '1',
value: { name: 'priority object', id: 1 },
},
{
recordId: '2',
value: { name: 'fallback object', id: 2 },
},
];
const result = selectPriorityFieldValue(recordsWithValues, '1');
expect(result).toEqual({ name: 'priority object', id: 1 });
});
});
@@ -2,7 +2,7 @@ import { isDefined } from 'twenty-shared/utils';
import { hasRecordFieldValue } from 'src/engine/api/graphql/graphql-query-runner/utils/has-record-field-value.util';
export const selectPriorityFieldValue = <T>(
export const defaultMergeFieldValue = <T>(
recordsWithValues: { value: T; recordId: string }[],
priorityRecordId: string,
): T | null => {
@@ -10,14 +10,16 @@ export const selectPriorityFieldValue = <T>(
(record) => record.recordId === priorityRecordId,
);
if (!isDefined(priorityRecord)) {
throw new Error(
`Priority record with ID ${priorityRecordId} not found in merge candidates`,
);
if (isDefined(priorityRecord) && hasRecordFieldValue(priorityRecord.value)) {
return priorityRecord.value;
}
if (hasRecordFieldValue(priorityRecord.value)) {
return priorityRecord.value;
const fallbackRecord = recordsWithValues.find((record) =>
hasRecordFieldValue(record.value),
);
if (fallbackRecord) {
return fallbackRecord.value;
}
return null;
@@ -1,9 +1,9 @@
import {
FieldMetadataType,
type RelationType,
type EmailsMetadata,
type LinksMetadata,
type PhonesMetadata,
type RelationType,
} from 'twenty-shared/types';
import { mergeArrayFieldValues } from './merge-array-field-values.util';
@@ -11,7 +11,7 @@ import { mergeEmailsFieldValues } from './merge-emails-field-values.util';
import { mergeLinksFieldValues } from './merge-links-field-values.util';
import { mergePhonesFieldValues } from './merge-phones-field-values.util';
import { mergeRelationFieldValuesForDryRunRecord } from './merge-relation-field-values-for-dry-run-record.util';
import { selectPriorityFieldValue } from './select-priority-field-value.util';
import { defaultMergeFieldValue } from './default-merge-field-value.util';
export const mergeFieldValues = (
fieldType: FieldMetadataType,
@@ -34,7 +34,7 @@ export const mergeFieldValues = (
);
}
return selectPriorityFieldValue(recordsWithValues, priorityRecordId);
return defaultMergeFieldValue(recordsWithValues, priorityRecordId);
case FieldMetadataType.EMAILS:
return mergeEmailsFieldValues(
@@ -55,6 +55,6 @@ export const mergeFieldValues = (
);
default:
return selectPriorityFieldValue(recordsWithValues, priorityRecordId);
return defaultMergeFieldValue(recordsWithValues, priorityRecordId);
}
};
@@ -1,7 +1,7 @@
import { type ObjectRecord, RelationType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { selectPriorityFieldValue } from 'src/engine/api/graphql/graphql-query-runner/utils/select-priority-field-value.util';
import { defaultMergeFieldValue } from 'src/engine/api/graphql/graphql-query-runner/utils/default-merge-field-value.util';
export const mergeRelationFieldValuesForDryRunRecord = (
recordsWithValues: { value: unknown; recordId: string }[],
@@ -12,7 +12,7 @@ export const mergeRelationFieldValuesForDryRunRecord = (
return mergeOneToManyRelationArrays(recordsWithValues);
}
return selectPriorityFieldValue(
return defaultMergeFieldValue(
recordsWithValues as { value: ObjectRecord | null; recordId: string }[],
priorityRecordId,
);