From 87c878b10167210ae2431618c9135c70869412ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Mon, 15 Jun 2026 16:01:11 +0200 Subject: [PATCH] fix(twenty-front): stop unbounded default-view creation on record-index load (#21592) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Since the 2.13.0 deploy, `core.viewField` and `core.view` rows are being created without bound. From Sentry (`twenty-server`, prod), comparing equal 24h windows before/after the deploy: | INSERT (per day) | Before (Jun 11→12) | After (Jun 14→15) | |---|---|---| | `core.viewField` | 1,885 | 193,719 (**103×**) | | `core.view` | 161 | 12,130 (**75×**) | All under `POST /metadata`, via the `CreateManyViewFields` operation (with frequent "Could not find view for given viewId" races). The accumulating rows then feed a quadratic flat-map rebuild, ramping `POST /metadata` tail latency (p99 0.67s → 7s → 11s and climbing) and server CPU. ## Root cause `useCreateDefaultViewForObject` is a temporary fallback that creates a view + a view field per field, each with a fresh `v4()` id. [`RecordIndexLoadBaseOnContextStoreEffect`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexLoadBaseOnContextStoreEffect.tsx) calls it whenever the current view id has no match in the loaded views: ```ts if (isDefined(view)) { loadRecordIndexStates(...) } else { createDefaultViewForObject(objectMetadataItem); } // fires when the lookup misses ``` This is **non-convergent**: the created view gets a *fresh* id, never equal to the requested `contextStoreCurrentViewId`, so the next load misses again and creates another duplicate — every record-index load mints a view + ~17 view fields forever. **Why it started at 2.13.0:** the lookup now misses during normal loads because of the cache-first bootstrap experiment (#21532, which is the `v2.13.0` tag commit). It opens the app gate from cache before the network revalidation, so the record-index effect runs while `contextStoreCurrentViewId` is set but the views aren't settled — the exact window that trips the fallback. ## Fix Make the fallback idempotent: never auto-create a default view for an object that already has one. During the cache-first load window the object's views are present (just not the specifically-requested id), so the guard short-circuits; and once any view exists, it can never re-create. The legitimate case (an object genuinely without views) still creates exactly one. ## Scope / follow-ups - This is the **root-cause** fix for the leak. - The quadratic amplification is mitigated separately by the O(N²)→O(N) change in the flat-map builder (#21585). - The cache-first experiment (#21532) should be reviewed — it's marked "[Experiment] — not for merge as-is" yet shipped; reverting/gating it is the fastest standalone stop-gap, and confirms the trigger if `viewField` inserts drop. - The already-leaked duplicate `core.view` / `core.viewField` rows need a cleanup pass. Review in cubic --- .../views/hooks/useCreateDefaultViewForObject.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/twenty-front/src/modules/views/hooks/useCreateDefaultViewForObject.ts b/packages/twenty-front/src/modules/views/hooks/useCreateDefaultViewForObject.ts index e37cea4835..ab44b5d700 100644 --- a/packages/twenty-front/src/modules/views/hooks/useCreateDefaultViewForObject.ts +++ b/packages/twenty-front/src/modules/views/hooks/useCreateDefaultViewForObject.ts @@ -2,6 +2,8 @@ import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/Enriche import { isHiddenSystemField } from '@/object-metadata/utils/isHiddenSystemField'; import { usePerformViewAPIPersist } from '@/views/hooks/internal/usePerformViewAPIPersist'; import { usePerformViewFieldAPIPersist } from '@/views/hooks/internal/usePerformViewFieldAPIPersist'; +import { viewsSelector } from '@/views/states/selectors/viewsSelector'; +import { useStore } from 'jotai'; import { useCallback } from 'react'; import { v4 } from 'uuid'; import { ViewType } from '~/generated-metadata/graphql'; @@ -15,9 +17,18 @@ const pendingViewCreations = new Set(); export const useCreateDefaultViewForObject = () => { const { performViewAPICreate } = usePerformViewAPIPersist(); const { performViewFieldAPICreate } = usePerformViewFieldAPIPersist(); + const store = useStore(); const createDefaultViewForObject = useCallback( async (objectMetadataItem: EnrichedObjectMetadataItem) => { + const objectAlreadyHasView = store + .get(viewsSelector.atom) + .some((view) => view.objectMetadataId === objectMetadataItem.id); + + if (objectAlreadyHasView) { + return; + } + if (pendingViewCreations.has(objectMetadataItem.id)) { return; } @@ -77,7 +88,7 @@ export const useCreateDefaultViewForObject = () => { pendingViewCreations.delete(objectMetadataItem.id); } }, - [performViewAPICreate, performViewFieldAPICreate], + [performViewAPICreate, performViewFieldAPICreate, store], ); return {