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
---------
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,
};
};