fix(front): read fresh metadata in SSE update path to avoid false unknown-field warnings (#22897)

## Problem

Sentry `warning`: *"SSE update event for person carried fields unknown
to this tab's metadata: lastInboundAt, pdlCertifications, pdlBirthYear,
…"* ([TWENTY-FRONT
issue](https://twenty-v7.sentry.io/issues/7610855477)).

The listed fields are all custom fields created by the **People Data
Labs app** (`packages/twenty-apps/public/people-data-labs`). The flow
that triggers this:

1. The app installs a batch of `person` fields (emits metadata SSE
events).
2. Its enrichment logic-function updates a person record with all of
those fields (emits a record-update SSE event).

Field creation already emits metadata SSE events, and the front applies
them **synchronously** to the Jotai metadata store
(`MetadataStoreSSEEffect` → `applyChanges` → `store.set`). So the store
converges.

The bug is that the SSE **record-update** handler doesn't read the
converged store. `useTriggerOptimisticEffectFromSseUpdateEvents` reads
`objectMetadataItems` from a React/Jotai closure captured at render
time. The long-lived SSE subscription in `useTriggerEventStreamCreation`
holds a metadata snapshot that lags the store, so even after the
field-create events have been applied, the record path still sees the
old field set. It then:

- flags the new fields as "unknown" and logs to Sentry, and
- **drops those field values** from the optimistic update
(`getUnknownRecordInputFields` filters them out), so already-loaded
views miss the enriched data until a refetch.

Metadata events are dispatched before record events within each SSE
message (`useTriggerEventStreamCreation` lines 126-128), and the store
update is synchronous, so a fresh store read at processing time sees
fields that converged in the same or any earlier message.

## Fix

Read `objectMetadataItems` fresh from the Jotai store at
event-processing time instead of from the render closure, and re-resolve
the object metadata item from that fresh list. This eliminates the
false-positive warnings and stops dropping legitimately-known field
values.

Follows the design from #22474: the metadata-event pipeline owns schema
convergence; the record pipeline just reads the converged store (now
actually reading the current store rather than a stale snapshot). A
genuine race where the record update truly precedes the field-create
event is still tolerated and still warns.

## Testing

- `nx typecheck twenty-front` passes
- `nx lint:diff-with-main twenty-front` passes

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22897?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Thomas Trompette
2026-07-15 13:53:08 +02:00
committed by GitHub
parent 055e8b5335
commit 4e83a64f81
@@ -1,6 +1,6 @@
import { triggerUpdateRecordOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { objectMetadataItemsWithFieldsSelector } from '@/object-metadata/states/objectMetadataItemsWithFieldsSelector';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename';
import { getRecordFromCache } from '@/object-record/cache/utils/getRecordFromCache';
@@ -13,6 +13,7 @@ import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useU
import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput';
import { getUnknownRecordInputFields } from '@/object-record/utils/getUnknownRecordInputFields';
import { captureMessage } from '@sentry/react';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
import {
@@ -21,8 +22,8 @@ import {
} from '~/generated-metadata/graphql';
export const useTriggerOptimisticEffectFromSseUpdateEvents = () => {
const store = useStore();
const apolloCoreClient = useApolloCoreClient();
const { objectMetadataItems } = useObjectMetadataItems();
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const { refetchAggregateQueriesForObjectMetadataItem } =
useRefetchAggregateQueriesForObjectMetadataItem();
@@ -31,11 +32,20 @@ export const useTriggerOptimisticEffectFromSseUpdateEvents = () => {
const triggerOptimisticEffectFromSseUpdateEvents = useCallback(
({
objectRecordEvents,
objectMetadataItem,
objectMetadataItem: objectMetadataItemFromCaller,
}: {
objectRecordEvents: ObjectRecordEvent[];
objectMetadataItem: EnrichedObjectMetadataItem;
}) => {
const objectMetadataItems = store.get(
objectMetadataItemsWithFieldsSelector.atom,
);
const objectMetadataItem =
objectMetadataItems.find(
(item) => item.id === objectMetadataItemFromCaller.id,
) ?? objectMetadataItemFromCaller;
const updateEvents = objectRecordEvents.filter((objectRecordEvent) => {
return objectRecordEvent.action === DatabaseEventAction.UPDATED;
});
@@ -164,8 +174,8 @@ export const useTriggerOptimisticEffectFromSseUpdateEvents = () => {
return isNonEmptyArray(updateEvents);
},
[
store,
apolloCoreClient.cache,
objectMetadataItems,
objectPermissionsByObjectMetadataId,
refetchAggregateQueriesForObjectMetadataItem,
upsertRecordsInStore,