From 53de4c557bf44a3d63aae81637ad42e9e4b576ed Mon Sep 17 00:00:00 2001 From: Sri Hari Haran Sharma <139791035+channi23@users.noreply.github.com> Date: Sun, 14 Jun 2026 22:55:06 +0530 Subject: [PATCH] Fix record index sync when view fields arrive via SSE (#19069) Fixes #19023 ## What changed This updates the record index/view field state flow so the current view can react to late-arriving `viewFields` coming from SSE without requiring a page refresh. Changes: - extracted a narrower `syncRecordIndexViewFields` path in `useLoadRecordIndexStates` - kept the initial full record-index load for first entry into a view - added a follow-up sync in `RecordIndexLoadBaseOnContextStoreEffect` when the same view receives updated `viewFields` - updated `ViewBarRecordFieldEffect` so it re-syncs current record fields when `currentView.viewFields` changes instead of only initializing once ## Why There is a race when a user navigates to a custom object while AI is still creating metadata. In that case, the record index can initialize from a partial view, and later SSE `viewFields` updates were not being applied to the active view state. That could leave the table visually empty or incomplete until a refresh. ## Impact This should allow: - record index columns to update live when view fields arrive via SSE - view bar field state to update live as well - the current view to stay usable without a refresh while AI-created metadata is still streaming in ## Validation Validated locally with: - `npx prettier --check` on modified files - `npx oxlint --type-aware` on modified files Manual verification: - confirmed live record creation appeared without refresh - manual AI/SSE testing was partially limited by Groq TPM/token caps on the selected model, but the state-sync path was verified in code and local behavior checks image --------- Co-authored-by: Charles Bochet --- .../components/RecordIndexContainerGater.tsx | 2 + .../RecordIndexViewFieldsSSESyncEffect.tsx | 43 ++++++ .../hooks/useLoadRecordIndexStates.ts | 146 +++++++++++------- 3 files changed, 135 insertions(+), 56 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexViewFieldsSSESyncEffect.tsx diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainerGater.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainerGater.tsx index a90ccf902c..648be4912a 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainerGater.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainerGater.tsx @@ -12,6 +12,7 @@ import { RecordIndexContainerContextStoreNumberOfSelectedRecordsEffect } from '@ import { RecordIndexEmptyStateNotShared } from '@/object-record/record-index/components/RecordIndexEmptyStateNotShared'; import { RecordIndexLoadBaseOnContextStoreEffect } from '@/object-record/record-index/components/RecordIndexLoadBaseOnContextStoreEffect'; import { RecordIndexPageHeader } from '@/object-record/record-index/components/RecordIndexPageHeader'; +import { RecordIndexViewFieldsSSESyncEffect } from '@/object-record/record-index/components/RecordIndexViewFieldsSSESyncEffect'; import { useHandleIndexIdentifierClick } from '@/object-record/record-index/hooks/useHandleIndexIdentifierClick'; import { useRecordIndexFieldMetadataDerivedStates } from '@/object-record/record-index/hooks/useRecordIndexFieldMetadataDerivedStates'; import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore'; @@ -114,6 +115,7 @@ export const RecordIndexContainerGater = () => { + diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexViewFieldsSSESyncEffect.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexViewFieldsSSESyncEffect.tsx new file mode 100644 index 0000000000..0072d38869 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexViewFieldsSSESyncEffect.tsx @@ -0,0 +1,43 @@ +import { useListenToMetadataOperationBrowserEvent } from '@/browser-event/hooks/useListenToMetadataOperationBrowserEvent'; +import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow'; +import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState'; +import { useLoadRecordIndexStates } from '@/object-record/record-index/hooks/useLoadRecordIndexStates'; +import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; +import { viewFromViewIdFamilySelector } from '@/views/states/selectors/viewFromViewIdFamilySelector'; +import { useStore } from 'jotai'; +import { isDefined } from 'twenty-shared/utils'; +import { AllMetadataName } from '~/generated-metadata/graphql'; + +export const RecordIndexViewFieldsSSESyncEffect = () => { + const store = useStore(); + + const { syncRecordIndexViewFields } = useLoadRecordIndexStates(); + const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow(); + + const contextStoreCurrentViewId = useAtomComponentStateValue( + contextStoreCurrentViewIdComponentState, + ); + + useListenToMetadataOperationBrowserEvent({ + metadataName: AllMetadataName.viewField, + onMetadataOperationBrowserEvent: () => { + if (!isDefined(contextStoreCurrentViewId)) { + return; + } + + const currentView = store.get( + viewFromViewIdFamilySelector.selectorFamily({ + viewId: contextStoreCurrentViewId, + }), + ); + + if (!isDefined(currentView)) { + return; + } + + syncRecordIndexViewFields(currentView, objectMetadataItem); + }, + }); + + return null; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexStates.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexStates.ts index e8f57494b9..67bb1d2081 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexStates.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexStates.ts @@ -73,9 +73,9 @@ export const useLoadRecordIndexStates = () => { const { setRecordGroupsFromViewGroups } = useSetRecordGroups(); - const loadRecordIndexStates = useCallback( + const syncRecordIndexViewFields = useCallback( ( - view: View, + view: Pick, objectMetadataItem: EnrichedObjectMetadataItem, options?: { skipGlobalIndexStates?: boolean }, ) => { @@ -134,6 +134,88 @@ export const useLoadRecordIndexStates = () => { .map(mapViewFieldToRecordField) .filter(isDefined); + const recordIndexId = getRecordIndexIdFromObjectNamePluralAndViewId( + objectMetadataItem.namePlural, + view.id, + ); + + const currentRecordFieldsAtom = + currentRecordFieldsComponentState.atomFamily({ + instanceId: recordIndexId, + }); + + const hasInitializedFieldsAtom = + hasInitializedCurrentRecordFieldsComponentFamilyState.atomFamily({ + instanceId: recordIndexId, + familyKey: { viewId: view.id }, + }); + + store.set( + atom(null, (get, batchSet) => { + if (!skipGlobalIndexStates) { + const existingFieldDefs = get( + recordIndexFieldDefinitionsState.atom, + ); + + if (!isDeeplyEqual(existingFieldDefs, newFieldDefinitions)) { + batchSet( + recordIndexFieldDefinitionsState.atom, + newFieldDefinitions, + ); + } + } + + for (const viewField of view.viewFields) { + const viewFieldMetadataType = objectMetadataItem.fields?.find( + (field) => field.id === viewField.fieldMetadataId, + )?.type; + + const existingAggregateOp = get( + viewFieldAggregateOperationState.atomFamily({ + viewFieldId: viewField.id, + }), + ); + + const convertedViewFieldAggregateOp = isDefined( + viewField.aggregateOperation, + ) + ? convertAggregateOperationToExtendedAggregateOperation( + viewField.aggregateOperation, + viewFieldMetadataType, + ) + : viewField.aggregateOperation; + + if (existingAggregateOp !== convertedViewFieldAggregateOp) { + batchSet( + viewFieldAggregateOperationState.atomFamily({ + viewFieldId: viewField.id, + }), + convertedViewFieldAggregateOp, + ); + } + } + + const existingRecordFields = get(currentRecordFieldsAtom); + + if (!isDeeplyEqual(existingRecordFields, recordFields)) { + batchSet(currentRecordFieldsAtom, recordFields); + } + + batchSet(hasInitializedFieldsAtom, true); + }), + ); + }, + [store], + ); + + const loadRecordIndexStates = useCallback( + ( + view: View, + objectMetadataItem: EnrichedObjectMetadataItem, + options?: { skipGlobalIndexStates?: boolean }, + ) => { + const skipGlobalIndexStates = options?.skipGlobalIndexStates ?? false; + const flattenedFieldMetadataItems = store.get( flattenedFieldMetadataItemsSelector.atom, ); @@ -178,10 +260,6 @@ export const useLoadRecordIndexStates = () => { view.id, ); - const currentRecordFieldsAtom = - currentRecordFieldsComponentState.atomFamily({ - instanceId: recordIndexId, - }); const currentRecordFiltersAtom = currentRecordFiltersComponentState.atomFamily({ instanceId: recordIndexId, @@ -195,11 +273,6 @@ export const useLoadRecordIndexStates = () => { instanceId: recordIndexId, }); - const hasInitializedFieldsAtom = - hasInitializedCurrentRecordFieldsComponentFamilyState.atomFamily({ - instanceId: recordIndexId, - familyKey: { viewId: view.id }, - }); const hasInitializedFiltersAtom = hasInitializedCurrentRecordFiltersComponentFamilyState.atomFamily({ instanceId: recordIndexId, @@ -211,53 +284,12 @@ export const useLoadRecordIndexStates = () => { familyKey: { viewId: view.id }, }); + syncRecordIndexViewFields(view, objectMetadataItem, { + skipGlobalIndexStates, + }); + store.set( atom(null, (get, batchSet) => { - if (!skipGlobalIndexStates) { - const existingFieldDefs = get( - recordIndexFieldDefinitionsState.atom, - ); - if (!isDeeplyEqual(existingFieldDefs, newFieldDefinitions)) { - batchSet( - recordIndexFieldDefinitionsState.atom, - newFieldDefinitions, - ); - } - } - - for (const viewField of view.viewFields) { - const viewFieldMetadataType = objectMetadataItem.fields?.find( - (field) => field.id === viewField.fieldMetadataId, - )?.type; - - const existingAggregateOp = get( - viewFieldAggregateOperationState.atomFamily({ - viewFieldId: viewField.id, - }), - ); - - const convertedViewFieldAggregateOp = isDefined( - viewField.aggregateOperation, - ) - ? convertAggregateOperationToExtendedAggregateOperation( - viewField.aggregateOperation, - viewFieldMetadataType, - ) - : viewField.aggregateOperation; - - if (existingAggregateOp !== convertedViewFieldAggregateOp) { - batchSet( - viewFieldAggregateOperationState.atomFamily({ - viewFieldId: viewField.id, - }), - convertedViewFieldAggregateOp, - ); - } - } - - batchSet(currentRecordFieldsAtom, recordFields); - batchSet(hasInitializedFieldsAtom, true); - batchSet(currentRecordFiltersAtom, recordFilters); batchSet(currentRecordFilterGroupsAtom, recordFilterGroups); batchSet(hasInitializedFiltersAtom, true); @@ -325,10 +357,12 @@ export const useLoadRecordIndexStates = () => { recordIndexShouldHideEmptyRecordGroupsAtom, getFieldMetadataItemByIdOrThrow, setRecordGroupsFromViewGroups, + syncRecordIndexViewFields, ], ); return { loadRecordIndexStates, + syncRecordIndexViewFields, }; };