Add SSE for metadata and plug front components (#17998)

Create the necessary tooling to listen to metadata events and plug it to
the front components. Now we have a hot reload like experience when we
edit a component in an app.

## Backend

- Split `EventWithQueryIds` into `ObjectRecordEventWithQueryIds` and
`MetadataEventWithQueryIds`
- Publish metadata event batches to active SSE streams in
`MetadataEventsToDbListener`

## Frontend

- Create a metadata event dispatching pipeline: SSE metadata events are
grouped by metadata name, transformed into
`MetadataOperationBrowserEventDetail` objects, and dispatched as browser
`CustomEvents`
- Add `useListenToMetadataOperationBrowserEvent` hook for consuming
metadata operation events filtered by metadata name and operation type
- Rename `useListenToObjectRecordEventsForQuery` to
`useListenToEventsForQuery`, now accepting both
`RecordGqlOperationSignature` and `MetadataGqlOperationSignature`
- Implement `useOnFrontComponentUpdated` which subscribes to front
component metadata events and updates the Apollo cache when the
component is modified
- Add `builtComponentChecksum` to the front component query and appends
it to the component URL for browser cache invalidation
This commit is contained in:
Raphaël Bosi
2026-02-18 12:26:20 +01:00
committed by GitHub
parent e3753bf822
commit 2455c859b4
76 changed files with 1636 additions and 173 deletions
@@ -1,5 +1,6 @@
import { REST_API_BASE_URL } from '@/apollo/constant/rest-api-base-url';
import { useFrontComponentExecutionContext } from '@/front-components/hooks/useFrontComponentExecutionContext';
import { useOnFrontComponentUpdated } from '@/front-components/hooks/useOnFrontComponentUpdated';
import { getFrontComponentUrl } from '@/front-components/utils/getFrontComponentUrl';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useTheme } from '@emotion/react';
import { t } from '@lingui/core/macro';
@@ -21,8 +22,6 @@ export const FrontComponentRenderer = ({
const { executionContext, frontComponentHostCommunicationApi } =
useFrontComponentExecutionContext();
const componentUrl = `${REST_API_BASE_URL}/front-components/${frontComponentId}`;
const handleError = useCallback(
(error?: Error) => {
if (!isDefined(error)) {
@@ -43,6 +42,15 @@ export const FrontComponentRenderer = ({
onError: handleError,
});
useOnFrontComponentUpdated({
frontComponentId,
});
const componentUrl = getFrontComponentUrl({
frontComponentId,
checksum: data?.frontComponent?.builtComponentChecksum,
});
if (
loading ||
!isDefined(data?.frontComponent) ||
@@ -6,6 +6,7 @@ export const FIND_ONE_FRONT_COMPONENT = gql`
id
name
applicationId
builtComponentChecksum
applicationTokenPair {
applicationAccessToken {
token
@@ -0,0 +1,37 @@
import { useUpdateFrontComponentApolloCache } from '@/front-components/hooks/useUpdateFrontComponentApolloCache';
import { useListenToMetadataOperationBrowserEvent } from '@/browser-event/hooks/useListenToMetadataOperationBrowserEvent';
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
import {
AllMetadataName,
type FrontComponent,
} from '~/generated-metadata/graphql';
type UseOnFrontComponentUpdatedArgs = {
frontComponentId: string;
};
export const useOnFrontComponentUpdated = ({
frontComponentId,
}: UseOnFrontComponentUpdatedArgs) => {
const queryId = `front-component-updated-${frontComponentId}`;
useListenToEventsForQuery({
queryId,
operationSignature: {
metadataName: AllMetadataName.frontComponent,
variables: {
filter: { id: { eq: frontComponentId } },
},
},
});
const { updateFrontComponentApolloCache } =
useUpdateFrontComponentApolloCache({
frontComponentId,
});
useListenToMetadataOperationBrowserEvent<FrontComponent>({
metadataName: AllMetadataName.frontComponent,
onMetadataOperationBrowserEvent: updateFrontComponentApolloCache,
});
};
@@ -0,0 +1,54 @@
import { type MetadataOperationBrowserEventDetail } from '@/browser-event/types/MetadataOperationBrowserEventDetail';
import { useApolloClient } from '@apollo/client';
import { isDefined } from 'twenty-shared/utils';
import {
FindOneFrontComponentDocument,
type FindOneFrontComponentQuery,
type FrontComponent,
} from '~/generated-metadata/graphql';
type UseUpdateFrontComponentApolloCacheArgs = {
frontComponentId: string;
};
export const useUpdateFrontComponentApolloCache = ({
frontComponentId,
}: UseUpdateFrontComponentApolloCacheArgs) => {
const apolloClient = useApolloClient();
const updateFrontComponentApolloCache = (
detail: MetadataOperationBrowserEventDetail<FrontComponent>,
) => {
if (detail.operation.type !== 'update') {
return;
}
const { updatedRecord } = detail.operation;
if (!isDefined(updatedRecord) || updatedRecord.id !== frontComponentId) {
return;
}
apolloClient.cache.updateQuery<FindOneFrontComponentQuery>(
{
query: FindOneFrontComponentDocument,
variables: { id: frontComponentId },
},
(existingData) => {
if (!isDefined(existingData?.frontComponent)) {
return existingData;
}
return {
...existingData,
frontComponent: {
...existingData.frontComponent,
...updatedRecord,
},
};
},
);
};
return { updateFrontComponentApolloCache };
};
@@ -0,0 +1,14 @@
import { REST_API_BASE_URL } from '@/apollo/constant/rest-api-base-url';
import { isDefined } from 'twenty-shared/utils';
export const getFrontComponentUrl = ({
frontComponentId,
checksum,
}: {
frontComponentId: string;
checksum?: string;
}): string => {
return isDefined(checksum)
? `${REST_API_BASE_URL}/front-components/${frontComponentId}?checksum=${checksum}`
: `${REST_API_BASE_URL}/front-components/${frontComponentId}`;
};