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.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22829?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:
Weiko
2026-07-13 14:50:42 +02:00
committed by GitHub
parent b2a4bb0e0c
commit 8e022d3c49
6 changed files with 53 additions and 19 deletions
@@ -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,
@@ -84,7 +84,7 @@ export const useLoadRecordIndexStates = () => {
(
view: Pick<View, 'id' | 'viewFields'>,
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,
});
},
[
@@ -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 (
<ContextStoreComponentInstanceContext.Provider
value={{ instanceId: `record-table-widget-${widgetId}` }}
value={{
instanceId: isNonEmptyString(instanceIdSuffix)
? `record-table-widget-${widgetId}-${instanceIdSuffix}`
: `record-table-widget-${widgetId}`,
}}
>
<RecordTableWidgetContextStoreInitEffect
objectMetadataItemId={objectMetadataItem.id}
@@ -1,4 +1,5 @@
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { useLoadRecordIndexStates } from '@/object-record/record-index/hooks/useLoadRecordIndexStates';
import { lastLoadedRecordTableWidgetViewIdComponentState } from '@/object-record/record-table-widget/states/lastLoadedRecordTableWidgetViewIdComponentState';
import { computeRecordTableWidgetViewLoadContentSignature } from '@/object-record/record-table-widget/utils/computeRecordTableWidgetViewLoadContentSignature';
@@ -25,6 +26,8 @@ export const RecordTableWidgetViewLoadEffect = ({
}: RecordTableWidgetViewLoadEffectProps) => {
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;
@@ -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' : ''}`}
/>
</StyledContainer>
</RecordFilterValueDependenciesContext.Provider>
@@ -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}
>
<RecordTableWidget
isReadOnly={isReadOnly}