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

<img width="3024" height="1964" alt="image"
src="https://github.com/user-attachments/assets/f71c7490-bf57-4357-9d5f-087b2424b53b"
/>

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Sri Hari Haran Sharma
2026-06-14 22:55:06 +05:30
committed by GitHub
parent f45c54679c
commit 53de4c557b
3 changed files with 135 additions and 56 deletions
@@ -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 = () => {
</CommandMenuComponentInstanceContext.Provider>
</RecordComponentInstanceContextsWrapper>
<RecordIndexLoadBaseOnContextStoreEffect />
<RecordIndexViewFieldsSSESyncEffect />
</ViewComponentInstanceContext.Provider>
</RecordIndexContextProvider>
</>
@@ -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;
};
@@ -73,9 +73,9 @@ export const useLoadRecordIndexStates = () => {
const { setRecordGroupsFromViewGroups } = useSetRecordGroups();
const loadRecordIndexStates = useCallback(
const syncRecordIndexViewFields = useCallback(
(
view: View,
view: Pick<View, 'id' | 'viewFields'>,
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,
};
};