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:
+2
@@ -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>
|
||||
);
|
||||
|
||||
+2
-2
@@ -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';
|
||||
|
||||
|
||||
+57
-29
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [
|
||||
|
||||
+165
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user