Implemented SSE subscription mechanism on the frontend (#17017)

This PR is a follow-up of https://github.com/twentyhq/twenty/pull/16966
and implements a new mechanism to handle SSE events.

It creates only one event stream per browser tab, then use mutations to
tell the backend which query to listen to, without re-mounting the event
stream connexion.

Then each event that comes from this unique subscription is then
dispatched in a new JavaScript CustomEvent, per queryId, on which
specific hooks add an event listener.

This PR introduces the generic tooling as well as the handling of update
events on table.
This commit is contained in:
Lucas Bordeau
2026-01-12 18:58:46 +01:00
committed by GitHub
parent d7638a9075
commit 04a370e043
38 changed files with 747 additions and 300 deletions
@@ -9,6 +9,7 @@ import { RecordTableCellPortals } from '@/object-record/record-table/record-tabl
import { RecordTableAggregateFooter } from '@/object-record/record-table/record-table-footer/components/RecordTableAggregateFooter';
import { isRecordTableInitialLoadingComponentState } from '@/object-record/record-table/states/isRecordTableInitialLoadingComponentState';
import { RecordTableVirtualizedDataChangedEffect } from '@/object-record/record-table/virtualization/components/RecordTableVirtualizedDataChangedEffect';
import { RecordTableVirtualizedOnObjectRecordEventsEffect } from '@/object-record/record-table/virtualization/components/RecordTableVirtualizedOnObjectRecordEventsEffect';
import { RecordTableVirtualizedRowTreadmillEffect } from '@/object-record/record-table/virtualization/components/RecordTableVirtualizedRowTreadmillEffect';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
@@ -37,6 +38,7 @@ export const RecordTableNoRecordGroupBody = () => {
)}
<RecordTableVirtualizedRowTreadmillEffect />
<RecordTableVirtualizedDataChangedEffect />
<RecordTableVirtualizedOnObjectRecordEventsEffect />
</RecordTableBodyNoRecordGroupDragDropContextProvider>
</RecordTableNoRecordGroupBodyContextProvider>
);
@@ -11,8 +11,8 @@ import { RecordTableRowArrowKeysEffect } from '@/object-record/record-table/reco
import { RecordTableRowHotkeyEffect } from '@/object-record/record-table/record-table-row/components/RecordTableRowHotkeyEffect';
import { isRecordTableRowFocusActiveComponentState } from '@/object-record/record-table/states/isRecordTableRowFocusActiveComponentState';
import { isRecordTableRowFocusedComponentFamilyState } from '@/object-record/record-table/states/isRecordTableRowFocusedComponentFamilyState';
import { ListenRecordUpdatesEffect } from '@/subscription/components/ListenRecordUpdatesEffect';
import { getDefaultRecordFieldsToListen } from '@/subscription/utils/getDefaultRecordFieldsToListen.util';
import { ListenRecordUpdatesEffect } from '@/sse-db-event/components/ListenRecordUpdatesEffect';
import { getDefaultRecordFieldsToListen } from '@/sse-db-event/utils/getDefaultRecordFieldsToListen';
import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
@@ -68,46 +68,74 @@ export const RecordTableVirtualizedDataChangedEffect = () => {
return;
}
if (lastObjectOperation.data.type === 'update-one') {
const updateInput = lastObjectOperation.data.result.updateInput;
const updatedFieldNames = new Set<string>();
const updatedFieldNames = Object.keys(updateInput ?? {}) ?? [];
let thereIsAnUpdateOnAFilteredField = false;
let thereIsAnUpdateOnASortedField = false;
const updatedFieldMetadataItems = activeFieldMetadataItems.filter(
(fieldMetadataItemToFilter) =>
updatedFieldNames.includes(fieldMetadataItemToFilter.name) ||
(fieldMetadataItemToFilter.type === FieldMetadataType.RELATION &&
updatedFieldNames.includes(
`${fieldMetadataItemToFilter.name}Id`,
)),
);
if (
lastObjectOperation.data.type === 'update-one' ||
lastObjectOperation.data.type === 'update-many'
) {
const updateInputs =
lastObjectOperation.data.type === 'update-one'
? [lastObjectOperation.data.result.updateInput]
: lastObjectOperation.data.result.updateInputs;
const updatedFieldMetadataItemIds =
updatedFieldMetadataItems.map(mapById);
for (const updateInput of updateInputs) {
const fieldNamesForUpdateInput =
Object.keys(updateInput ?? {}) ?? [];
const thereIsAnUpdateOnAFilteredField = currentRecordFilters.some(
(recordFilter) =>
updatedFieldMetadataItemIds.includes(
recordFilter.fieldMetadataId,
),
);
for (const fieldName of fieldNamesForUpdateInput) {
updatedFieldNames.add(fieldName);
}
const thereIsAnUpdateOnASortedField = currentRecordSorts.some(
(recordSort) =>
const updatedFieldMetadataItems = activeFieldMetadataItems.filter(
(fieldMetadataItemToFilter) =>
fieldNamesForUpdateInput.includes(
fieldMetadataItemToFilter.name,
) ||
(fieldMetadataItemToFilter.type ===
FieldMetadataType.RELATION &&
fieldNamesForUpdateInput.includes(
`${fieldMetadataItemToFilter.name}Id`,
)),
);
const updatedFieldMetadataItemIds =
updatedFieldMetadataItems.map(mapById);
const updateOnAFilteredField = currentRecordFilters.some(
(recordFilter) =>
updatedFieldMetadataItemIds.includes(
recordFilter.fieldMetadataId,
),
);
const updateOnASortedField = currentRecordSorts.some((recordSort) =>
updatedFieldMetadataItemIds.includes(recordSort.fieldMetadataId),
);
);
if (updatedFieldNames.includes('position')) {
resetVirtualizationBecauseDataChanged();
} else if (
thereIsAnUpdateOnAFilteredField ||
thereIsAnUpdateOnASortedField
) {
resetVirtualizationBecauseDataChanged();
if (updateOnAFilteredField) {
thereIsAnUpdateOnAFilteredField = true;
}
if (updateOnASortedField) {
thereIsAnUpdateOnASortedField = true;
}
}
} else {
resetVirtualizationBecauseDataChanged();
}
if (updatedFieldNames.has('position')) {
resetVirtualizationBecauseDataChanged();
} else if (
thereIsAnUpdateOnAFilteredField ||
thereIsAnUpdateOnASortedField
) {
resetVirtualizationBecauseDataChanged();
}
}
}
}, [
@@ -0,0 +1,165 @@
import { triggerUpdateRecordOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache';
import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename';
import { getRecordNodeFromRecord } from '@/object-record/cache/utils/getRecordNodeFromRecord';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { useRegisterObjectOperation } from '@/object-record/hooks/useRegisterObjectOperation';
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { useRecordsFieldVisibleGqlFields } from '@/object-record/record-field/hooks/useRecordsFieldVisibleGqlFields';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useListenToObjectRecordEventsForQuery } from '@/sse-db-event/hooks/useListenToObjectRecordEventsForQuery';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import {
computeRecordGqlOperationFilter,
isDefined,
isNonEmptyArray,
} from 'twenty-shared/utils';
import { useDebouncedCallback } from 'use-debounce';
import { DatabaseEventAction } from '~/generated/graphql';
export const RecordTableVirtualizedOnObjectRecordEventsEffect = () => {
const { objectMetadataItem } = useRecordIndexContextOrThrow();
const { objectNameSingular } = useRecordTableContextOrThrow();
const { registerObjectOperation } = useRegisterObjectOperation();
const { refetchAggregateQueries } = useRefetchAggregateQueries({
objectMetadataNamePlural: objectMetadataItem.namePlural,
});
const apolloCoreClient = useApolloCoreClient();
const getRecordFromCache = useGetRecordFromCache({
objectNameSingular,
});
const { upsertRecordsInStore } = useUpsertRecordsInStore();
const { objectMetadataItems } = useObjectMetadataItems();
const recordGqlFields = useRecordsFieldVisibleGqlFields({
objectMetadataItem,
});
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const debouncedRefetchAggregateQueries = useDebouncedCallback(
refetchAggregateQueries,
200,
);
const currentRecordFilters = useRecoilComponentValue(
currentRecordFiltersComponentState,
);
const currentRecordSorts = useRecoilComponentValue(
currentRecordSortsComponentState,
);
const currentRecordFilterGroups = useRecoilComponentValue(
currentRecordFilterGroupsComponentState,
);
const { filterValueDependencies } = useFilterValueDependencies();
const queryId = `record-table-virtualized-${objectMetadataItem.nameSingular}`;
useListenToObjectRecordEventsForQuery({
queryId,
operationSignature: {
objectNameSingular: objectMetadataItem.nameSingular,
variables: {
filter: computeRecordGqlOperationFilter({
fields: objectMetadataItem.fields,
recordFilters: currentRecordFilters,
recordFilterGroups: currentRecordFilterGroups,
filterValueDependencies,
}),
orderBy: turnSortsIntoOrderBy(objectMetadataItem, currentRecordSorts),
},
},
onObjectRecordEvents: (objectRecordEvents) => {
const cache = apolloCoreClient.cache;
const updateEvents = objectRecordEvents.filter(
(objectRecordEvent) =>
objectRecordEvent.action === DatabaseEventAction.UPDATED,
);
const updatedRecordsWithUpdatedFieldsOnly: ObjectRecord[] = [];
for (const updateEvent of updateEvents) {
const updatedRecord = updateEvent.properties.after;
upsertRecordsInStore({ partialRecords: [updatedRecord] });
const cachedRecord = getRecordFromCache<ObjectRecord>(updatedRecord.id);
const cachedRecordWithConnection =
getRecordNodeFromRecord<ObjectRecord>({
record: cachedRecord,
objectMetadataItem,
objectMetadataItems,
recordGqlFields: recordGqlFields,
computeReferences: false,
});
const computedOptimisticRecord = {
...updatedRecord,
id: updatedRecord.id,
__typename: getObjectTypename(objectMetadataItem.nameSingular),
};
const computedOptimisticRecordWithConnection =
getRecordNodeFromRecord<ObjectRecord>({
record: computedOptimisticRecord,
objectMetadataItem,
objectMetadataItems,
recordGqlFields: recordGqlFields,
});
if (
!isDefined(cachedRecordWithConnection) ||
!isDefined(computedOptimisticRecordWithConnection)
) {
continue;
}
triggerUpdateRecordOptimisticEffect({
cache,
objectMetadataItem,
currentRecord: cachedRecordWithConnection,
updatedRecord: computedOptimisticRecordWithConnection,
objectMetadataItems,
objectPermissionsByObjectMetadataId,
upsertRecordsInStore,
});
const updatedFields = updateEvent.properties?.updatedFields ?? [];
updatedRecordsWithUpdatedFieldsOnly.push({
...Object.fromEntries(
updatedFields.map((fieldName) => {
return [fieldName, updatedRecord[fieldName]];
}),
),
id: updatedRecord.id,
__typename: getObjectTypename(objectMetadataItem.nameSingular),
});
}
registerObjectOperation(objectMetadataItem, {
type: 'update-many',
result: { updateInputs: updatedRecordsWithUpdatedFieldsOnly },
});
if (isNonEmptyArray(updateEvents)) {
debouncedRefetchAggregateQueries();
}
},
});
return null;
};