Compare commits

...

3 Commits

Author SHA1 Message Date
Etienne 9a7d878a85 Fix kanban view when grouping select field has null value (#16998) 2026-01-07 19:26:05 +01:00
Etienne e83ed53d79 Fix memory crash when creating record in table view (#16984)
**Bug Fixed**
Creating a new record in the People table view caused a browser memory
crash ("Paused before potential out of memory crash").
**Root Cause**
In useResetVirtualizationBecauseDataChanged.ts, when creating a record,
a loop iterated from 0 to totalNumberOfRecordsToVirtualize (the total
database count).
**Fix Applied**
Changed the loop to only iterate through indices from actually loaded
pages

Fixes https://github.com/twentyhq/twenty/issues/16980
2026-01-07 15:27:20 +01:00
Paul Rastoin a42d7baef4 Workspace creation prefill fix (#16983)
# Introduction
Fixing prefill in v2 workspace creation code flow
2026-01-07 15:27:10 +01:00
5 changed files with 159 additions and 83 deletions
@@ -0,0 +1,54 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { computeRecordGroupOptionsFilter } from '@/object-record/record-group/utils/computeRecordGroupOptionsFilter';
const mockFieldMetadata = {
id: 'field-1',
name: 'status',
} as FieldMetadataItem;
describe('computeRecordGroupOptionsFilter', () => {
it('should return empty object when recordGroupFieldMetadata is undefined', () => {
const result = computeRecordGroupOptionsFilter({
recordGroupFieldMetadata: undefined,
recordGroupValues: ['value1', 'value2'],
});
expect(result).toEqual({});
});
it('should return empty object when recordGroupFieldMetadata is null', () => {
const result = computeRecordGroupOptionsFilter({
recordGroupFieldMetadata: null,
recordGroupValues: ['value1', 'value2'],
});
expect(result).toEqual({});
});
it('should return simple IN filter when no null values present', () => {
const result = computeRecordGroupOptionsFilter({
recordGroupFieldMetadata: mockFieldMetadata,
recordGroupValues: ['value1', 'value2', 'value3'],
});
expect(result).toEqual({
status: {
in: ['value1', 'value2', 'value3'],
},
});
});
it('should return OR filter with IS NULL when null value is present', () => {
const result = computeRecordGroupOptionsFilter({
recordGroupFieldMetadata: mockFieldMetadata,
recordGroupValues: ['value1', null, 'value2'],
});
expect(result).toEqual({
or: [
{ status: { is: 'NULL' } },
{ status: { in: ['value1', 'value2'] } },
],
});
});
});
@@ -0,0 +1,39 @@
import { isNull } from '@sniptt/guards';
import { type RecordGqlOperationFilter } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type RecordGroupDefinition } from '@/object-record/record-group/types/RecordGroupDefinition';
export const computeRecordGroupOptionsFilter = ({
recordGroupFieldMetadata,
recordGroupValues,
}: {
recordGroupFieldMetadata: FieldMetadataItem | null | undefined;
recordGroupValues: RecordGroupDefinition['value'][];
}): RecordGqlOperationFilter => {
if (!isDefined(recordGroupFieldMetadata) || recordGroupValues.length === 0) {
return {};
}
const fieldName = recordGroupFieldMetadata.name;
const hasNullValue = recordGroupValues.some(isNull);
const nonNullValues = recordGroupValues.filter(
(value): value is NonNullable<typeof value> => !isNull(value),
);
return hasNullValue
? {
or: [
{ [fieldName]: { is: 'NULL' } },
...(nonNullValues.length > 0
? [{ [fieldName]: { in: nonNullValues } }]
: []),
],
}
: nonNullValues.length > 0
? {
[fieldName]: { in: recordGroupValues },
}
: {};
};
@@ -5,6 +5,7 @@ import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/
import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { recordGroupDefinitionsComponentSelector } from '@/object-record/record-group/states/selectors/recordGroupDefinitionsComponentSelector';
import { computeRecordGroupOptionsFilter } from '@/object-record/record-group/utils/computeRecordGroupOptionsFilter';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
@@ -12,7 +13,6 @@ import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/ho
import {
combineFilters,
computeRecordGqlOperationFilter,
isDefined,
turnAnyFieldFilterIntoRecordGqlFilter,
} from 'twenty-shared/utils';
@@ -73,13 +73,10 @@ export const useRecordIndexGroupCommonQueryVariables = () => {
(recordGroupDefinition) => recordGroupDefinition.value,
);
const recordGroupOptionsFilter = isDefined(recordGroupFieldMetadata)
? {
[recordGroupFieldMetadata.name]: {
in: recordGroupValues,
},
}
: {};
const recordGroupOptionsFilter = computeRecordGroupOptionsFilter({
recordGroupFieldMetadata,
recordGroupValues,
});
const combinedFilters = combineFilters([
anyFieldFilter,
@@ -4,16 +4,9 @@ import { useRecordsFieldVisibleGqlFields } from '@/object-record/record-field/ho
import { useFindManyRecordIndexTableParams } from '@/object-record/record-index/hooks/useFindManyRecordIndexTableParams';
import { useTriggerFetchPages } from '@/object-record/record-table/virtualization/hooks/useTriggerFetchPages';
import { dataLoadingStatusByRealIndexComponentFamilyState } from '@/object-record/record-table/virtualization/states/dataLoadingStatusByRealIndexComponentFamilyState';
import { dataPagesLoadedComponentState } from '@/object-record/record-table/virtualization/states/dataPagesLoadedComponentState';
import { lastScrollPositionComponentState } from '@/object-record/record-table/virtualization/states/lastScrollPositionComponentState';
import { recordIdByRealIndexComponentFamilyState } from '@/object-record/record-table/virtualization/states/recordIdByRealIndexComponentFamilyState';
import { totalNumberOfRecordsToVirtualizeComponentState } from '@/object-record/record-table/virtualization/states/totalNumberOfRecordsToVirtualizeComponentState';
import { getVirtualizationOverscanWindow } from '@/object-record/record-table/virtualization/utils/getVirtualizationOverscanWindow';
import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useRecoilComponentFamilyCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyCallbackState';
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { useCallback } from 'react';
import { useRecoilCallback } from 'recoil';
import { sleep } from '~/utils/sleep';
@@ -26,7 +19,6 @@ export const useResetVirtualizationBecauseDataChanged = (
});
const params = useFindManyRecordIndexTableParams(objectNameSingular);
const { scrollWrapperHTMLElement } = useScrollWrapperHTMLElement();
// TODO: we could optimize this by using an aggregate or using only id: true in recordGqlFields
const recordGqlFields = useRecordsFieldVisibleGqlFields({
@@ -49,82 +41,20 @@ export const useResetVirtualizationBecauseDataChanged = (
dataPagesLoadedComponentState,
);
const lastScrollPositionCallbackState = useRecoilComponentCallbackState(
lastScrollPositionComponentState,
);
const recordIdByRealIndexCallbackState =
useRecoilComponentFamilyCallbackState(
recordIdByRealIndexComponentFamilyState,
);
const dataLoadingStatusByRealIndexCallbackState =
useRecoilComponentCallbackState(
dataLoadingStatusByRealIndexComponentFamilyState,
);
const { triggerFetchPagesWithoutDebounce } = useTriggerFetchPages();
const resetVirtualization = useRecoilCallback(
({ set, snapshot }) =>
({ set }) =>
async () => {
const { totalCount } = await findManyRecordsLazy();
const tableScrollWrapperHeight =
scrollWrapperHTMLElement?.clientHeight ?? 0;
const lastScrollPosition = getSnapshotValue(
snapshot,
lastScrollPositionCallbackState,
);
const totalNumberOfRecordsToVirtualize =
getSnapshotValue(
snapshot,
totalNumberOfRecordsToVirtualizeCallbackState,
) ?? 0;
const {
firstRealIndexInOverscanWindow,
lastRealIndexInOverscanWindow,
} = getVirtualizationOverscanWindow(
lastScrollPosition,
tableScrollWrapperHeight,
totalNumberOfRecordsToVirtualize,
);
for (let i = 0; i < totalNumberOfRecordsToVirtualize; i++) {
const indexIsInOverscanWindow =
i >= firstRealIndexInOverscanWindow &&
i <= lastRealIndexInOverscanWindow;
if (!indexIsInOverscanWindow) {
set(
dataLoadingStatusByRealIndexCallbackState({
realIndex: i,
}),
null,
);
set(
recordIdByRealIndexCallbackState({
realIndex: i,
}),
null,
);
}
}
set(dataPagesLoadedCallbackState, []);
set(totalNumberOfRecordsToVirtualizeCallbackState, totalCount);
},
[
findManyRecordsLazy,
scrollWrapperHTMLElement?.clientHeight,
lastScrollPositionCallbackState,
totalNumberOfRecordsToVirtualizeCallbackState,
dataPagesLoadedCallbackState,
dataLoadingStatusByRealIndexCallbackState,
recordIdByRealIndexCallbackState,
totalNumberOfRecordsToVirtualizeCallbackState,
findManyRecordsLazy,
],
);
@@ -20,9 +20,11 @@ import { RoleService } from 'src/engine/metadata-modules/role/role.service';
import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { WorkspaceMigrationService } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
import { prefillCompanies } from 'src/engine/workspace-manager/standard-objects-prefill-data/prefill-companies';
import { prefillCoreViews } from 'src/engine/workspace-manager/standard-objects-prefill-data/prefill-core-views';
import { prefillPeople } from 'src/engine/workspace-manager/standard-objects-prefill-data/prefill-people';
import { prefillWorkflows } from 'src/engine/workspace-manager/standard-objects-prefill-data/prefill-workflows';
import { standardObjectsPrefillData } from 'src/engine/workspace-manager/standard-objects-prefill-data/standard-objects-prefill-data';
import { TwentyStandardApplicationService } from 'src/engine/workspace-manager/twenty-standard-application/services/twenty-standard-application.service';
import { ADMIN_ROLE } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-roles/roles/admin-role';
@@ -52,7 +54,6 @@ export class WorkspaceManagerService {
private readonly roleTargetRepository: Repository<RoleTargetEntity>,
@InjectRepository(ServerlessFunctionEntity)
private readonly serverlessFunctionRepository: Repository<ServerlessFunctionEntity>,
protected readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly applicationService: ApplicationService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly featureFlagService: FeatureFlagService,
@@ -103,6 +104,11 @@ export class WorkspaceManagerService {
workspaceId,
},
);
await this.prefillCreatedWorkspaceRecords({
workspaceId,
schemaName,
});
} else {
await this.workspaceSyncMetadataService.synchronize({
workspaceId,
@@ -148,6 +154,56 @@ export class WorkspaceManagerService {
});
}
private async prefillCreatedWorkspaceRecords({
workspaceId,
schemaName,
}: {
workspaceId: string;
schemaName: string;
}): Promise<void> {
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
},
);
const queryRunner = this.coreDataSource.createQueryRunner();
await queryRunner.connect();
try {
await queryRunner.startTransaction();
await prefillCompanies(queryRunner.manager, schemaName);
await prefillPeople(queryRunner.manager, schemaName);
await prefillWorkflows(
queryRunner.manager,
schemaName,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
);
await queryRunner.commitTransaction();
} catch (error) {
if (queryRunner.isTransactionActive) {
try {
await queryRunner.rollbackTransaction();
} catch (rollbackError) {
this.logger.error(
`Failed to rollback prefill transaction: ${rollbackError.message}`,
);
}
}
throw error;
} finally {
await queryRunner.release();
}
}
private async prefillWorkspaceWithStandardObjectsRecords({
dataSourceMetadata,
workspaceId,