From 8e022d3c49e158d4a1b8e7858686bb6fe26b3970 Mon Sep 17 00:00:00 2001 From: Weiko Date: Mon, 13 Jul 2026 14:50:42 +0200 Subject: [PATCH] Fix stale relation table in field widget when switching records in side panel (#22829) The relation table rendered by a FIELD widget in TABLE display mode kept its jotai component states (loaded rows, virtualization maps, loading guards, query identifiers) in instances keyed only by widget id and view id. Since the side panel record pages share those instances across records, switching to another record kept rendering the previous record's related rows until an asynchronous catch-up reload landed, and any race or error in that catch-up left the previous record's data on screen permanently. Scope the record-table widget's context store instance and record index instance by target record id (and side panel surface), the same way FieldsWidget already scopes its field list instances. Each record now gets its own table state, so a record's rows can never appear under another record, and loads that land after a record switch write into their own instance instead of the visible one. loadRecordIndexStates and setRecordGroupsFromViewGroups accept an optional recordIndexId override so the widget view load effect can populate the record-scoped instance instead of deriving the shared one from object name and view id. Review in cubic --- .../record-group/hooks/useSetRecordGroups.ts | 12 ++++++--- .../hooks/useLoadRecordIndexStates.ts | 26 ++++++++++++------- .../components/RecordTableWidgetProvider.tsx | 22 ++++++++++++---- .../RecordTableWidgetViewLoadEffect.tsx | 5 ++++ .../components/FieldWidgetRelationTable.tsx | 4 +++ .../RecordTableWidgetRendererContent.tsx | 3 +++ 6 files changed, 53 insertions(+), 19 deletions(-) diff --git a/packages/twenty-front/src/modules/object-record/record-group/hooks/useSetRecordGroups.ts b/packages/twenty-front/src/modules/object-record/record-group/hooks/useSetRecordGroups.ts index cd051b54cd..ccce7d0284 100644 --- a/packages/twenty-front/src/modules/object-record/record-group/hooks/useSetRecordGroups.ts +++ b/packages/twenty-front/src/modules/object-record/record-group/hooks/useSetRecordGroups.ts @@ -110,16 +110,20 @@ export const useSetRecordGroups = () => { mainGroupByFieldMetadataId, viewGroups, objectMetadataItem, + recordIndexId: recordIndexIdFromOptions, }: { viewId: string; mainGroupByFieldMetadataId: string; viewGroups: ViewGroup[]; objectMetadataItem: EnrichedObjectMetadataItem; + recordIndexId?: string; }) => { - const recordIndexId = getRecordIndexIdFromObjectNamePluralAndViewId( - objectMetadataItem.namePlural, - viewId, - ); + const recordIndexId = + recordIndexIdFromOptions ?? + getRecordIndexIdFromObjectNamePluralAndViewId( + objectMetadataItem.namePlural, + viewId, + ); const newGroupDefinitions = mapViewGroupsToRecordGroupDefinitions({ mainGroupByFieldMetadataId, 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 bbcff22f0f..2932203c1c 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 @@ -84,7 +84,7 @@ export const useLoadRecordIndexStates = () => { ( view: Pick, objectMetadataItem: EnrichedObjectMetadataItem, - options?: { skipGlobalIndexStates?: boolean }, + options?: { skipGlobalIndexStates?: boolean; recordIndexId?: string }, ) => { const skipGlobalIndexStates = options?.skipGlobalIndexStates ?? false; @@ -141,10 +141,12 @@ export const useLoadRecordIndexStates = () => { .map(mapViewFieldToRecordField) .filter(isDefined); - const recordIndexId = getRecordIndexIdFromObjectNamePluralAndViewId( - objectMetadataItem.namePlural, - view.id, - ); + const recordIndexId = + options?.recordIndexId ?? + getRecordIndexIdFromObjectNamePluralAndViewId( + objectMetadataItem.namePlural, + view.id, + ); const currentRecordFieldsAtom = currentRecordFieldsComponentState.atomFamily({ @@ -219,7 +221,7 @@ export const useLoadRecordIndexStates = () => { ( view: View, objectMetadataItem: EnrichedObjectMetadataItem, - options?: { skipGlobalIndexStates?: boolean }, + options?: { skipGlobalIndexStates?: boolean; recordIndexId?: string }, ) => { const skipGlobalIndexStates = options?.skipGlobalIndexStates ?? false; @@ -262,10 +264,12 @@ export const useLoadRecordIndexStates = () => { ); } - const recordIndexId = getRecordIndexIdFromObjectNamePluralAndViewId( - objectMetadataItem.namePlural, - view.id, - ); + const recordIndexId = + options?.recordIndexId ?? + getRecordIndexIdFromObjectNamePluralAndViewId( + objectMetadataItem.namePlural, + view.id, + ); const currentRecordFiltersAtom = currentRecordFiltersComponentState.atomFamily({ @@ -293,6 +297,7 @@ export const useLoadRecordIndexStates = () => { syncRecordIndexViewFields(view, objectMetadataItem, { skipGlobalIndexStates, + recordIndexId: options?.recordIndexId, }); store.set( @@ -360,6 +365,7 @@ export const useLoadRecordIndexStates = () => { mainGroupByFieldMetadataId: view.mainGroupByFieldMetadataId ?? '', viewGroups: view.viewGroups, objectMetadataItem, + recordIndexId: options?.recordIndexId, }); }, [ diff --git a/packages/twenty-front/src/modules/object-record/record-table-widget/components/RecordTableWidgetProvider.tsx b/packages/twenty-front/src/modules/object-record/record-table-widget/components/RecordTableWidgetProvider.tsx index 62868c88c7..412618b58b 100644 --- a/packages/twenty-front/src/modules/object-record/record-table-widget/components/RecordTableWidgetProvider.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table-widget/components/RecordTableWidgetProvider.tsx @@ -9,6 +9,7 @@ import { RecordTableWidgetContextStoreInitEffect } from '@/object-record/record- import { RecordTableWidgetViewLoadEffect } from '@/object-record/record-table-widget/components/RecordTableWidgetViewLoadEffect'; import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId'; import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext'; +import { isNonEmptyString } from '@sniptt/guards'; import { type PropsWithChildren, useCallback } from 'react'; import { AppPath } from 'twenty-shared/types'; import { getAppPath } from 'twenty-shared/utils'; @@ -18,6 +19,7 @@ type RecordTableWidgetProviderProps = PropsWithChildren<{ viewId: string; widgetId: string; recordLimit?: number; + instanceIdSuffix?: string; }>; export const RecordTableWidgetProvider = ({ @@ -25,16 +27,22 @@ export const RecordTableWidgetProvider = ({ viewId, widgetId, recordLimit, + instanceIdSuffix, children, }: RecordTableWidgetProviderProps) => { const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular, }); - const recordIndexId = getRecordIndexIdFromObjectNamePluralAndViewId( - objectMetadataItem.namePlural, - viewId, - ); + const recordIndexIdWithoutSuffix = + getRecordIndexIdFromObjectNamePluralAndViewId( + objectMetadataItem.namePlural, + viewId, + ); + + const recordIndexId = isNonEmptyString(instanceIdSuffix) + ? `${recordIndexIdWithoutSuffix}-${instanceIdSuffix}` + : recordIndexIdWithoutSuffix; const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); const objectPermissions = getObjectPermissionsForObject( @@ -70,7 +78,11 @@ export const RecordTableWidgetProvider = ({ return ( { const { loadRecordIndexStates } = useLoadRecordIndexStates(); + const { recordIndexId } = useRecordIndexContextOrThrow(); + const [ lastLoadedRecordTableWidgetViewId, setLastLoadedRecordTableWidgetViewId, @@ -77,6 +80,7 @@ export const RecordTableWidgetViewLoadEffect = ({ loadRecordIndexStates(currentView, objectMetadataItem, { skipGlobalIndexStates: true, + recordIndexId, }); setLastLoadedRecordTableWidgetViewId({ @@ -92,6 +96,7 @@ export const RecordTableWidgetViewLoadEffect = ({ viewHasFields, objectMetadataItem, loadRecordIndexStates, + recordIndexId, ]); return null; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetRelationTable.tsx b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetRelationTable.tsx index 4053ab7fcf..ef9bf522ee 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetRelationTable.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetRelationTable.tsx @@ -6,6 +6,7 @@ import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutIn import { RecordTableWidgetRendererContent } from '@/page-layout/widgets/record-table/components/RecordTableWidgetRendererContent'; import { isFieldWidget } from '@/page-layout/widgets/field/utils/isFieldWidget'; import { useCurrentWidget } from '@/page-layout/widgets/hooks/useCurrentWidget'; +import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext'; import { styled } from '@linaria/react'; import { isDefined } from 'twenty-shared/utils'; @@ -35,6 +36,8 @@ export const FieldWidgetRelationTable = ({ const isPageLayoutInEditMode = useIsPageLayoutInEditMode(); + const { isInSidePanel } = useLayoutRenderingContext(); + const viewId = isFieldWidget(widget) ? widget.configuration.viewId : undefined; @@ -68,6 +71,7 @@ export const FieldWidgetRelationTable = ({ widgetId={widget.id} isReadOnly={isPageLayoutInEditMode} isEmptyStateHidden + instanceIdSuffix={`${recordId}${isInSidePanel ? '-side-panel' : ''}`} /> diff --git a/packages/twenty-front/src/modules/page-layout/widgets/record-table/components/RecordTableWidgetRendererContent.tsx b/packages/twenty-front/src/modules/page-layout/widgets/record-table/components/RecordTableWidgetRendererContent.tsx index e241904135..9df5e8e57a 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/record-table/components/RecordTableWidgetRendererContent.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/record-table/components/RecordTableWidgetRendererContent.tsx @@ -10,6 +10,7 @@ type RecordTableWidgetRendererContentProps = { isReadOnly?: boolean; isEmptyStateHidden?: boolean; recordLimit?: number; + instanceIdSuffix?: string; }; export const RecordTableWidgetRendererContent = ({ @@ -19,6 +20,7 @@ export const RecordTableWidgetRendererContent = ({ isReadOnly = true, isEmptyStateHidden = false, recordLimit, + instanceIdSuffix, }: RecordTableWidgetRendererContentProps) => { const { objectMetadataItem } = useObjectMetadataItemById({ objectId: objectMetadataId, @@ -35,6 +37,7 @@ export const RecordTableWidgetRendererContent = ({ viewId={viewId} widgetId={widgetId} recordLimit={recordLimit} + instanceIdSuffix={instanceIdSuffix} >