From a024a04e01ce8571492a2a26aa5aecfae05bee75 Mon Sep 17 00:00:00 2001 From: Thomas Trompette Date: Wed, 11 Mar 2026 18:26:02 +0100 Subject: [PATCH] Fix breadcrumb infinite loop (#18561) `RecordTableNoRecordGroupScrollToPreviousRecordEffect` uses `useAtomState(lastShowPageRecordIdState)` to read the atom value and check whether to trigger an effect. Inside `run()`, it calls `setLastShowPageRecordId(null)` to reset the atom, then` triggerInitialRecordTableDataLoad()` which fires many `store.set()` calls on other atoms. These high-frequency store updates cause the component to re-render before Jotai's internal useReducer dispatch (propagating the null value) is processed by React. The result: useAtomState returns a stale non-null value on every subsequent render, even though the Jotai store already holds null. The effect re-runs, sees the stale non-null value, calls `run()` again, creating an infinite loop. This is a Jotai v2 edge case where useAtom's rendered value desyncs from the actual store value under high-frequency concurrent updates. ### The fix Read lastShowPageRecordId directly from the Jotai store via `store.get()` inside the effect instead of relying on the rendered value from useAtomState. This guarantees the effect always sees the true store value and correctly skips when the atom is null. --- .../hooks/useRecordShowPagePagination.ts | 4 +-- ...ecordGroupScrollToPreviousRecordEffect.tsx | 25 +++++++++++-------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordShowPagePagination.ts b/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordShowPagePagination.ts index d20436f744..77f9c8e8c0 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordShowPagePagination.ts +++ b/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordShowPagePagination.ts @@ -201,8 +201,6 @@ export const useRecordShowPagePagination = ( }; const navigateToIndexView = () => { - setLastShowPageRecordId(objectRecordId); - navigate( AppPath.RecordIndexPage, { @@ -212,6 +210,8 @@ export const useRecordShowPagePagination = ( viewId: viewIdQueryParam, }, ); + + setLastShowPageRecordId(objectRecordId); }; const rankInView = recordIdsInCache.findIndex((id) => id === objectRecordId); diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableNoRecordGroupScrollToPreviousRecordEffect.tsx b/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableNoRecordGroupScrollToPreviousRecordEffect.tsx index f1d919a99c..d118dc59e3 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableNoRecordGroupScrollToPreviousRecordEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableNoRecordGroupScrollToPreviousRecordEffect.tsx @@ -7,21 +7,19 @@ import { useTriggerFetchPages } from '@/object-record/record-table/virtualizatio import { useTriggerInitialRecordTableDataLoad } from '@/object-record/record-table/virtualization/hooks/useTriggerInitialRecordTableDataLoad'; import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement'; import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue'; -import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState'; import { isNonEmptyString } from '@sniptt/guards'; +import { useStore } from 'jotai'; import { useEffect, useState } from 'react'; export const RecordTableNoRecordGroupScrollToPreviousRecordEffect = () => { + const store = useStore(); + const { getScrollWrapperElement } = useScrollWrapperHTMLElement(); const allRecordIds = useAtomComponentSelectorValue( recordIndexAllRecordIdsComponentSelector, ); - const [lastShowPageRecordId, setLastShowPageRecordId] = useAtomState( - lastShowPageRecordIdState, - ); - const [hasInitializedScroll, setHasInitializedScroll] = useState(false); const { scrollTableToPosition } = useScrollTableToPosition(); @@ -34,8 +32,16 @@ export const RecordTableNoRecordGroupScrollToPreviousRecordEffect = () => { const { triggerFetchPagesWithoutDebounce } = useTriggerFetchPages(); useEffect(() => { + // Read directly from the Jotai store to avoid stale values from useAtom's + // internal useReducer, which can desync under high-frequency store updates. + const lastShowPageRecordId = store.get(lastShowPageRecordIdState.atom); + + if (!isNonEmptyString(lastShowPageRecordId)) { + return; + } + const run = async () => { - setLastShowPageRecordId(null); + store.set(lastShowPageRecordIdState.atom, null); const recordPosition = allRecordIds.findIndex( (recordId) => recordId === lastShowPageRecordId, @@ -76,15 +82,12 @@ export const RecordTableNoRecordGroupScrollToPreviousRecordEffect = () => { await triggerFetchPagesWithoutDebounce(); }; - if (isNonEmptyString(lastShowPageRecordId)) { - run(); - } + run(); }, [ + store, hasInitializedScroll, - lastShowPageRecordId, scrollTableToPosition, allRecordIds, - setLastShowPageRecordId, triggerInitialRecordTableDataLoad, processTreadmillScrollTop, getScrollWrapperElement,