Handled SSE create event (#17293)

This PR handles SSE create event.

It also fixes a regression on board, which was making it flash with
skeletons on any update / create.

This PR was a good test case to experience how each main components of
the app : table, board, calendar, reacts to a create event.

It is not straightforward for now, because each component handles
records with its own state management to turn those records into a
coherent display of rows or cards.

The requests for each component are also different : fetch more, group
by, multiple requests in parallel, so the cleanest way to handle
optimistic effect requires to create one small optimistic engine per
component tuned for its internal data logic.

For now we've decided to implement what's doable in a reasonable amount
of time and that includes not handling table with groups for now.

Creating a clean optimistic logic for each component will be done later.

# QA

## Importing 100 records via the API (could be a script, a workflow, an
AI calling tools, a CSV import, etc.)


https://github.com/user-attachments/assets/d38a3770-8b0a-4f83-8275-5e7d1be0a5c6

## Creating from different components and seeing the SSE event being
processed by other components


https://github.com/user-attachments/assets/106b2f49-19cb-4190-92b7-0653c8373366
This commit is contained in:
Lucas Bordeau
2026-01-21 11:49:20 +01:00
committed by GitHub
parent 596b7cc62d
commit 2835935f11
12 changed files with 311 additions and 51 deletions
@@ -11,7 +11,7 @@ export const CreateNewIndexRecordNoSelectionRecordAction = () => {
return (
<Action
onClick={() => createNewIndexRecord()}
onClick={() => createNewIndexRecord({ position: 'first' })}
closeSidePanelOnCommandMenuListActionExecution={false}
/>
);
@@ -18,13 +18,18 @@ import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import {
type ObjectRecord as ObjectRecordShared,
type RecordGqlOperationGqlRecordFields,
} from 'twenty-shared/types';
import { type BaseObjectRecord } from '@/object-record/types/BaseObjectRecord';
import { computeOptimisticCreateRecordBaseRecordInput } from '@/object-record/utils/computeOptimisticCreateRecordBaseRecordInput';
import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput';
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
import { getCreateOneRecordMutationResponseField } from '@/object-record/utils/getCreateOneRecordMutationResponseField';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
import { useRecoilValue } from 'recoil';
import { type RecordGqlOperationGqlRecordFields } from 'twenty-shared/types';
import { CustomError, isDefined } from 'twenty-shared/utils';
type useCreateOneRecordProps = {
@@ -135,7 +140,7 @@ export const useCreateOneRecord = <
getCreateOneRecordMutationResponseField(objectNameSingular);
const createdObject = await apolloCoreClient
.mutate({
.mutate<ObjectRecord>({
mutation: createOneRecordMutation,
variables: {
input: sanitizedInput,
@@ -186,17 +191,31 @@ export const useCreateOneRecord = <
await refetchAggregateQueries();
const positionToUse =
recordInput.position === 'first'
? 'first'
: recordInput.position === 'last'
? 'last'
: null;
const createdRecord = createdObject.data?.[
mutationResponseField
] as ObjectRecordShared & BaseObjectRecord;
dispatchObjectRecordOperationBrowserEvent({
objectMetadataItem,
operation: { type: 'create-one' },
operation: {
type: 'create-one',
createdRecord: { ...createdRecord, position: positionToUse },
},
});
if (!isDefined(createdObject.data?.[mutationResponseField])) {
if (!isDefined(createdRecord)) {
throw new CustomError('Failed to create record');
}
return getRecordFromRecordNode({
recordNode: createdObject.data?.[mutationResponseField],
recordNode: createdRecord,
});
};
@@ -1,8 +1,16 @@
import { useListenToObjectRecordOperationBrowserEvent } from '@/object-record/hooks/useListenToObjectRecordOperationBrowserEvent';
import { useGetShouldInitializeRecordBoardForUpdateInputs } from '@/object-record/record-board/hooks/useGetShouldInitializeRecordBoardForUpdateInputs';
import { useTriggerRecordBoardInitialQuery } from '@/object-record/record-board/hooks/useTriggerRecordBoardInitialQuery';
import { recordGroupFromGroupValueComponentFamilySelector } from '@/object-record/record-group/states/selectors/recordGroupFromGroupValueComponentFamilySelector';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState';
import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordIdsByGroupComponentFamilyState';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { type ObjectRecordOperationBrowserEventDetail } from '@/object-record/types/ObjectRecordOperationBrowserEventDetail';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
export const RecordBoardDataChangedEffect = () => {
const { objectMetadataItem } = useRecordIndexContextOrThrow();
@@ -11,31 +19,126 @@ export const RecordBoardDataChangedEffect = () => {
const { getShouldInitializeRecordBoardForUpdateInputs } =
useGetShouldInitializeRecordBoardForUpdateInputs();
const handleObjectRecordOperation = (
objectRecordOperationEventDetail: ObjectRecordOperationBrowserEventDetail,
) => {
const objectRecordOperation = objectRecordOperationEventDetail.operation;
const recordGroupFromGroupValueCallbackState =
useRecoilComponentCallbackState(
recordGroupFromGroupValueComponentFamilySelector,
);
const recordIndexGroupFieldMetadataItemCallbackState =
useRecoilComponentCallbackState(
recordIndexGroupFieldMetadataItemComponentState,
);
const recordIndexRecordIdsByGroupCallbackState =
useRecoilComponentCallbackState(
recordIndexRecordIdsByGroupComponentFamilyState,
);
const isUpdateOperation =
objectRecordOperation.type === 'update-one' ||
objectRecordOperation.type === 'update-many';
const handleObjectRecordOperation = useRecoilCallback(
({ snapshot }) =>
(
objectRecordOperationEventDetail: ObjectRecordOperationBrowserEventDetail,
) => {
const objectRecordOperation =
objectRecordOperationEventDetail.operation;
if (isUpdateOperation) {
const updateInputs =
objectRecordOperation.type === 'update-one'
? [objectRecordOperation.result.updateInput]
: objectRecordOperation.result.updateInputs;
switch (objectRecordOperation.type) {
case 'update-one':
case 'update-many':
{
const updateInputs =
objectRecordOperation.type === 'update-one'
? [objectRecordOperation.result.updateInput]
: objectRecordOperation.result.updateInputs;
const shouldInitializeForUpdateOperation =
getShouldInitializeRecordBoardForUpdateInputs(updateInputs);
const shouldInitializeForUpdateOperation =
getShouldInitializeRecordBoardForUpdateInputs(updateInputs);
if (shouldInitializeForUpdateOperation) {
triggerRecordBoardInitialQuery();
}
} else {
triggerRecordBoardInitialQuery();
}
};
if (shouldInitializeForUpdateOperation) {
triggerRecordBoardInitialQuery();
}
}
break;
case 'create-one': {
if (objectRecordOperation.createdRecord.position === 'first') {
triggerRecordBoardInitialQuery();
} else {
const createdRecordPosition =
objectRecordOperation.createdRecord.position;
if (!isDefined(createdRecordPosition)) {
return;
}
const recordIndexGroupFieldMetadataItem = getSnapshotValue(
snapshot,
recordIndexGroupFieldMetadataItemCallbackState,
);
if (!isDefined(recordIndexGroupFieldMetadataItem)) {
return;
}
const recordGroupValue =
objectRecordOperation.createdRecord[
recordIndexGroupFieldMetadataItem.name
];
const recordGroupDefinitionFromGroupValue = getSnapshotValue(
snapshot,
recordGroupFromGroupValueCallbackState({ recordGroupValue }),
);
if (!isDefined(recordGroupDefinitionFromGroupValue)) {
return;
}
const recordIdsForGroup = getSnapshotValue(
snapshot,
recordIndexRecordIdsByGroupCallbackState(
recordGroupDefinitionFromGroupValue.id,
),
);
const recordIdsWithoutCreatedRecord = recordIdsForGroup.filter(
(recordId) =>
recordId !== objectRecordOperation.createdRecord.id,
);
const groupIsEmpty = recordIdsWithoutCreatedRecord.length === 0;
if (groupIsEmpty) {
triggerRecordBoardInitialQuery();
return;
}
const firstRecordIdInGroup = recordIdsWithoutCreatedRecord[0];
const firstExistingRecordInGroup = getSnapshotValue(
snapshot,
recordStoreFamilyState(firstRecordIdInGroup),
);
if (!isDefined(firstExistingRecordInGroup)) {
return;
}
if (createdRecordPosition < firstExistingRecordInGroup.position) {
triggerRecordBoardInitialQuery();
}
}
break;
}
default: {
triggerRecordBoardInitialQuery();
}
}
},
[
triggerRecordBoardInitialQuery,
getShouldInitializeRecordBoardForUpdateInputs,
recordIndexGroupFieldMetadataItemCallbackState,
recordGroupFromGroupValueCallbackState,
recordIndexRecordIdsByGroupCallbackState,
],
);
useListenToObjectRecordOperationBrowserEvent({
onObjectRecordOperationBrowserEvent: handleObjectRecordOperation,
@@ -84,7 +84,7 @@ export const useGetShouldInitializeRecordBoardForUpdateInputs = () => {
}
if (updatedFieldNames.has('position')) {
return true;
return false;
}
return (
@@ -9,10 +9,8 @@ import { RecordBoardColumnContext } from '@/object-record/record-board/record-bo
import { RecordBoardColumnLoadingSkeletonCards } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnLoadingSkeletonCards';
import { recordBoardShouldFetchMoreInColumnComponentFamilyState } from '@/object-record/record-board/states/recordBoardShouldFetchMoreInColumnComponentFamilyState';
import { recordIndexRecordGroupsAreInInitialLoadingComponentState } from '@/object-record/record-index/states/recordIndexRecordGroupsAreInInitialLoadingComponentState';
import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordIdsByGroupComponentFamilyState';
import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
const StyledColumnCardsContainer = styled.div`
display: flex;
@@ -40,10 +38,6 @@ export const RecordBoardColumnCardsContainer = ({
recordBoardColumnId,
);
const recordIndexRecordGroupsAreInInitialLoading = useRecoilComponentValue(
recordIndexRecordGroupsAreInInitialLoadingComponentState,
);
const recordBoardShouldFetchMoreInColumn = useRecoilComponentFamilyValue(
recordBoardShouldFetchMoreInColumnComponentFamilyState,
recordBoardColumnId,
@@ -55,17 +49,13 @@ export const RecordBoardColumnCardsContainer = ({
// eslint-disable-next-line react/jsx-props-no-spreading
{...droppableProvided?.droppableProps}
>
{recordIndexRecordGroupsAreInInitialLoading ? (
<RecordBoardColumnLoadingSkeletonCards />
) : (
recordIds.map((recordId, index) => (
<RecordBoardCardDraggableContainer
key={recordId}
recordId={recordId}
rowIndex={index}
/>
))
)}
{recordIds.map((recordId, index) => (
<RecordBoardCardDraggableContainer
key={recordId}
recordId={recordId}
rowIndex={index}
/>
))}
{recordBoardShouldFetchMoreInColumn ? (
<RecordBoardColumnLoadingSkeletonCards />
) : null}
@@ -4,6 +4,6 @@ import { createComponentFamilyState } from '@/ui/utilities/state/component-state
export const recordBoardShouldFetchMoreInColumnComponentFamilyState =
createComponentFamilyState<boolean, string>({
key: 'recordBoardShouldFetchMoreInColumnComponentFamilyState',
defaultValue: false,
defaultValue: true,
componentInstanceContext: RecordBoardComponentInstanceContext,
});
@@ -0,0 +1,44 @@
import { recordGroupDefinitionFamilyState } from '@/object-record/record-group/states/recordGroupDefinitionFamilyState';
import { recordGroupIdsComponentState } from '@/object-record/record-group/states/recordGroupIdsComponentState';
import { type RecordGroupDefinition } from '@/object-record/record-group/types/RecordGroupDefinition';
import { createComponentFamilySelector } from '@/ui/utilities/state/component-state/utils/createComponentFamilySelector';
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
import { type Nullable } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
export const recordGroupFromGroupValueComponentFamilySelector =
createComponentFamilySelector<
RecordGroupDefinition | undefined,
{ recordGroupValue: Nullable<string> }
>({
key: 'recordGroupFromGroupValueComponentSelector',
componentInstanceContext: ViewComponentInstanceContext,
get:
({ instanceId, familyKey }) =>
({ get }): RecordGroupDefinition | undefined => {
const recordGroupIds = get(
recordGroupIdsComponentState.atomFamily({
instanceId,
}),
);
const recordGroupId = recordGroupIds.find((recordGroupId) => {
const recordGroupDefinition = get(
recordGroupDefinitionFamilyState(recordGroupId),
);
return recordGroupDefinition?.value === familyKey.recordGroupValue;
});
if (!isDefined(recordGroupId)) {
return undefined;
}
const recordGroupDefinition = get(
recordGroupDefinitionFamilyState(recordGroupId),
);
return recordGroupDefinition;
},
});
@@ -23,7 +23,9 @@ export const RecordTableHeaderLabelIdentifierCellPlusButton = () => {
});
const handlePlusButtonClick = () => {
createNewIndexRecord();
createNewIndexRecord({
position: 'first',
});
};
const isReadOnly = isObjectMetadataReadOnly({
@@ -1,4 +1,5 @@
import { type ObjectRecordOperationUpdateInput } from '@/object-record/types/ObjectRecordOperationUpdateInput';
import { type ObjectRecord } from 'twenty-shared/types';
export type ObjectRecordOperation =
| {
@@ -13,9 +14,12 @@ export type ObjectRecordOperation =
updateInputs: ObjectRecordOperationUpdateInput[];
};
}
| {
type: 'create-one';
createdRecord: ObjectRecord;
}
| {
type:
| 'create-one'
| 'create-many'
| 'destroy-one'
| 'destroy-many'
@@ -0,0 +1,76 @@
import { triggerCreateRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename';
import { type RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useRefetchAggregateQueriesForObjectMetadataItem } from '@/object-record/hooks/useRefetchAggregateQueriesForObjectMetadataItem';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { useCallback } from 'react';
import { useDebouncedCallback } from 'use-debounce';
import {
DatabaseEventAction,
type ObjectRecordEvent,
} from '~/generated/graphql';
export const useTriggerOptimisticEffectFromSseCreateEvents = () => {
const apolloCoreClient = useApolloCoreClient();
const { objectMetadataItems } = useObjectMetadataItems();
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const { refetchAggregateQueriesForObjectMetadataItem } =
useRefetchAggregateQueriesForObjectMetadataItem();
const { upsertRecordsInStore } = useUpsertRecordsInStore();
const debouncedRefetchAggregateQueriesForObjectMetadataItem =
useDebouncedCallback(refetchAggregateQueriesForObjectMetadataItem, 100);
const triggerOptimisticEffectFromSseCreateEvents = useCallback(
({
objectRecordEvents,
objectMetadataItem,
}: {
objectRecordEvents: ObjectRecordEvent[];
objectMetadataItem: ObjectMetadataItem;
}) => {
const createEvents = objectRecordEvents.filter((objectRecordEvent) => {
return objectRecordEvent.action === DatabaseEventAction.CREATED;
});
const cache = apolloCoreClient.cache;
const recordsToCreate = createEvents.map((createEvent) => {
return {
...createEvent.properties.after,
__typename: getObjectTypename(objectMetadataItem.nameSingular),
} as RecordGqlNode;
});
triggerCreateRecordsOptimisticEffect({
cache,
objectMetadataItem,
recordsToCreate: recordsToCreate,
objectMetadataItems,
shouldMatchRootQueryFilter: true,
checkForRecordInCache: true,
objectPermissionsByObjectMetadataId,
upsertRecordsInStore,
});
debouncedRefetchAggregateQueriesForObjectMetadataItem({
objectMetadataItem,
});
},
[
apolloCoreClient.cache,
objectMetadataItems,
objectPermissionsByObjectMetadataId,
upsertRecordsInStore,
debouncedRefetchAggregateQueriesForObjectMetadataItem,
],
);
return {
triggerOptimisticEffectFromSseCreateEvents,
};
};
@@ -1,4 +1,5 @@
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useTriggerOptimisticEffectFromSseCreateEvents } from '@/sse-db-event/hooks/useTriggerOptimisticEffectFromSseCreateEvents';
import { useTriggerOptimisticEffectFromSseUpdateEvents } from '@/sse-db-event/hooks/useTriggerOptimisticEffectFromSseUpdateEvents';
import { groupObjectRecordSseEventsByEventType } from '@/sse-db-event/utils/groupObjectRecordSseEventsByEventType';
import { groupObjectRecordSseEventsByObjectMetadataItemNameSingular } from '@/sse-db-event/utils/groupObjectRecordSseEventsByObjectMetadataItemNameSingular';
@@ -15,6 +16,9 @@ export const useTriggerOptimisticEffectFromSseEvents = () => {
const { triggerOptimisticEffectFromSseUpdateEvents } =
useTriggerOptimisticEffectFromSseUpdateEvents();
const { triggerOptimisticEffectFromSseCreateEvents } =
useTriggerOptimisticEffectFromSseCreateEvents();
const triggerOptimisticEffectFromSseEvents = useCallback(
({ objectRecordEvents }: { objectRecordEvents: ObjectRecordEvent[] }) => {
const objectRecordEventsByObjectMetadataItemNameSingular =
@@ -58,11 +62,21 @@ export const useTriggerOptimisticEffectFromSseEvents = () => {
objectMetadataItem,
});
break;
case DatabaseEventAction.CREATED:
triggerOptimisticEffectFromSseCreateEvents({
objectRecordEvents: objectRecordEventsForThisEventType,
objectMetadataItem,
});
break;
}
}
}
},
[objectMetadataItems, triggerOptimisticEffectFromSseUpdateEvents],
[
objectMetadataItems,
triggerOptimisticEffectFromSseUpdateEvents,
triggerOptimisticEffectFromSseCreateEvents,
],
);
return { triggerOptimisticEffectFromSseEvents };
@@ -90,7 +90,11 @@ export const turnSseObjectRecordEventsToObjectRecordOperationBrowserEvents = ({
if (hasSingleEvent) {
objectRecordOperationBrowserEvents.push({
objectMetadataItem,
operation: { type: 'create-one' },
operation: {
type: 'create-one',
createdRecord:
objectRecordEventsForThisEventType[0].properties.after,
},
});
} else {
objectRecordOperationBrowserEvents.push({
@@ -103,7 +107,11 @@ export const turnSseObjectRecordEventsToObjectRecordOperationBrowserEvents = ({
if (hasSingleEvent) {
objectRecordOperationBrowserEvents.push({
objectMetadataItem,
operation: { type: 'create-one' },
operation: {
type: 'create-one',
createdRecord:
objectRecordEventsForThisEventType[0].properties.after,
},
});
} else {
objectRecordOperationBrowserEvents.push({