Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refacto views #10272

Merged
merged 15 commits into from
Feb 18, 2025
Merged

Refacto views #10272

merged 15 commits into from
Feb 18, 2025

Conversation

charlesBochet
Copy link
Member

@charlesBochet charlesBochet commented Feb 17, 2025

In this huge (sorry!) PR:

  • introducing objectMetadataItem in contextStore instead of objectMetadataId which is more convenient
  • splitting some big hooks into smaller parts to avoid re-renders
  • removing Effects to avoid re-renders (especially onViewChange)
  • making the view prefetch separate from favorites to avoid re-renders
  • making the view prefetch load a state and add selectors on top of it to avoir re-renders

As a result, the performance is WAY better (I suspect the favorite implementation to trigger a lot of re-renders unfortunately).
However, we are still facing a random app freeze on view creation. I could not investigate the root cause. As this seems to be already there in the precedent release, we can move forward but this seems a urgent follow up to me ==> EDIT: I've found the root cause after a few ours of deep dive... an infinite loop in RecordTableNoRecordGroupBodyEffect...

prastoin edit: close #10253

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Summary

This PR implements a significant refactoring of the views and context store management system. Here's a concise summary of the key changes:

  • Replaces contextStoreCurrentObjectMetadataIdComponentState with contextStoreCurrentObjectMetadataItemComponentState across the application, moving from storing just IDs to complete metadata objects.
  • Introduces new RecordIndexContainerGater component to centralize record index view management and context providers.
  • Removes ViewBarEffect, QueryParamsViewIdEffect, and ViewEventContext in favor of a simpler, more centralized state management approach.
  • Adds new Recoil selectors for prefetching views and managing view state, including prefetchViewsState and related selector families.
  • Changes error handling in mapViewFiltersToFilters to handle race conditions during view changes by returning undefined instead of throwing errors.

Key points to review:

  • The removal of error throwing in mapViewFiltersToFilters could mask legitimate metadata issues
  • The TODO comment in RecordIndexContainerGater indicates potential technical debt around state reset logic
  • The new prefetch selectors assume single INDEX views per object metadata item
  • The change from null to undefined for certain states while keeping null for others suggests inconsistent null handling
  • The removal of direct URL parameter handling in RecordIndexPage might affect deep linking functionality

66 file(s) reviewed, 17 comment(s)
Edit PR Review Bot Settings | Greptile

@@ -42,7 +41,7 @@ export const RecordShowActionMenu = ({

return (
<>
{contextStoreCurrentObjectMetadataId && (
{contextStoreCurrentObjectMetadataItem && (
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: The conditional check here may be redundant since objectMetadataItem is already passed as a required prop to this component

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False positive

mainContextStoreComponentInstanceId,
);
console.log('Provider effect');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Remove debug console.log before merging

Suggested change
console.log('Provider effect');

);

if (!objectMetadataItem) {
throw new Error('Object metadata item is not se in context store');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

syntax: 'not se' should be 'not set'

Suggested change
throw new Error('Object metadata item is not se in context store');
throw new Error('Object metadata item is not set in context store');

Comment on lines +28 to +51
const columnDefinitions: ColumnDefinition<FieldMetadata>[] =
activeFieldMetadataItems
.map((field, index) =>
formatFieldMetadataItemAsColumnDefinition({
position: index,
field,
objectMetadataItem,
}),
)
.filter(filterAvailableTableColumns)
.map((column) => {
const existsInFilterDefinitions = filterableFieldMetadataItems.some(
(fieldMetadataItem) =>
fieldMetadataItem.id === column.fieldMetadataId,
);
const existsInSortDefinitions = sortDefinitions.some(
(sort) => sort.fieldMetadataId === column.fieldMetadataId,
);
return {
...column,
isFilterable: existsInFilterDefinitions,
isSortable: existsInSortDefinitions,
};
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Complex computation chain (multiple maps and filters) should be memoized to prevent unnecessary recalculations on each render

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Removing this context will break any components that consume ViewEventContext.onCurrentViewChange. Ensure all consumers have been updated to use the new pattern.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: This state was used by critical view management hooks like useGetCurrentView and useUpdateCurrentView. Ensure all dependencies have been updated to use the new state management approach.

throw new Error(
`Field metadata item not found for view filter ${viewFilter.id} and field metadata id ${viewFilter.fieldMetadataId}`,
);
// Todo: we we don't throw an error yet as we have race condition on view change
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

syntax: Typo in comment: 'we we' should be 'we'

Suggested change
// Todo: we we don't throw an error yet as we have race condition on view change
// Todo: we don't throw an error yet as we have race condition on view change

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok

prefetchIsLoadedFamilyState(PrefetchKey.AllViews),
);

console.log('prefetchIsLoaded', prefetchIsLoaded);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: console.log statement should be removed before production

Suggested change
console.log('prefetchIsLoaded', prefetchIsLoaded);

setContextStoreNumberOfSelectedRecords(contextStoreNumberOfSelectedRecords);
setcontextStoreFiltersComponentState(contextStoreFilters);

setIsLoaded(true);
}, [
setContextStoreTargetedRecordsRule,
setContextStoreCurrentObjectMetadataId,
setContextStoreCurrentObjectMetadataItem,
contextStoreTargetedRecordsRule,
contextStoreCurrentObjectMetadataId,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: contextStoreCurrentObjectMetadataId in dependencies array is not needed since it's not used in the effect

Suggested change
contextStoreCurrentObjectMetadataId,

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good bot
Not wrong but out of scope a little bit

width: 100%;
`;

export const RecordIndexContainerGater = () => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding this "Gater" to introduce an intermediate level to avoid re-renders

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the naming is appropriate because there's no conditionnal rendering here. Also maybe we should let backend naming for backend related stuff.

import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared';

export const RecordIndexLoadBaseOnContextStoreEffect = () => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have introduced this effect for now but I have voluntarily put everything into a loadRecordIndexStates hook. I think we would like to load this from the PageChangeEffect at some point. open to discussion

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like a good idea if it's based on the URL.

Also should we use BasedOn in the naming ?

The naming loaded, load seems to be duplicating some concept that could already be described as context store current view no ?

import { isDefined } from 'twenty-shared';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';

export const useLoadRecordIndexStates = () => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the monster, which is actually code moved from different places. This code should disappear as we remove all intermediate states

import { useRecoilCallback } from 'recoil';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';

export const useSetTableColumns = () => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved from useRecordTable

@@ -12,10 +11,6 @@ export const PREFETCH_CONFIG: Record<
operationSignatureFactory: RecordGqlOperationSignatureFactory;
}
> = {
ALL_VIEWS: {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removing ALL Views from here. Prefetch was a bad idea overall

import { View } from '@/views/types/View';
import { selector } from 'recoil';

export const favoriteViewsWithMinimalDataSelector = selector<
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

selector designed for favorites that need to have minimal info on views (to avoid re-renders too)

Copy link
Contributor

@lucasbordeau lucasbordeau Feb 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we use viewsForMenuItem or something that tells us what the fields curation is for ?

@charlesBochet
Copy link
Member Author

charlesBochet commented Feb 18, 2025

image ==> Fixed

const contextStoreCurrentObjectMetadataId = useRecoilComponentValueV2(
contextStoreCurrentObjectMetadataIdComponentState,
const contextStoreCurrentObjectMetadataItem = useRecoilComponentValueV2(
contextStoreCurrentObjectMetadataItemComponentState,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Went from an atomic ID listener to the whole objectMetadataItem ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, because the DevXP was painful (always have to find the objectMetadataItem from the Id). Another possibility is to create a hook or a selector on top of that. Which would actually be better I believe

({ get }) => {
const views = get(prefetchViewsState);
return views
.filter((view) => view.objectMetadataId === objectMetadataItemId)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recoil newbie question: Could we and is it recommended to use nested recoil selectors ? ( in this case overkill but could have used prefetchViewsFromObjectMetadataItemFamilySelector)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes we can call other selectors in selectors, as long as we have the necessary parameters available, what we cannot do is call hooks in selectors.

Copy link
Contributor

@prastoin prastoin Feb 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense here to use the prefetchViewsFromObjectMetadataItemFamilySelector selector in order to retrieve objectMetadaItem views ? I feel like it's redundant states access even if very cheap to perform


const objectMetadataItem = objectMetadataItems.find(
(item) => item.id === contextStoreCurrentObjectMetadataId,
const contextStoreCurrentObjectMetadataItem = useRecoilComponentValueV2(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any risk that this state get desynchronized from the objectMetadataItems state ? Could this be a component selector instead ? Based on the id ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good idea!

),
});

const { records } = useFindManyRecords({
Copy link
Contributor

@prastoin prastoin Feb 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Unless I'm mistaken passing View predicate to useFindManyRecords could avoid having to force casting in below useEffect

  const { records: views } = useFindManyRecords<View>({
  //...
  useEffect(() => {
    if (isDefined(views) && !isPersistingViewFields) {
      setPrefetchViewsState(views);
    }
  }, [isPersistingViewFields, records, setPrefetchViewsState]);

const view = views.find((view) => view.id === viewPickerReferenceViewId);
const view = useRecoilValue(
prefetchViewFromViewIdFamilySelector({
viewId: viewPickerReferenceViewId ?? '',
Copy link
Contributor

@prastoin prastoin Feb 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remark: Not the first occurence of passing an empty string to this selector
Maybe we should create a specific one or mutate exsiting for it to accept optional viewId ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I would prefer introduce a hook useContextStoreViewIdOrThrow to force the caller to take a decision

@@ -15,7 +13,7 @@ type BooleanDisplayProps = {
};

export const BooleanDisplay = ({ value }: BooleanDisplayProps) => {
if (!isDefined(value)) {
if (value === null || value === undefined) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why ? Is it different than !isDefined ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

because of storybook build failing:
BooleanDisplay is in vyw + using twenty-shared, this seems to be an issue currently. We should investigate but I'm protecting the devXP

Copy link
Contributor

@prastoin prastoin Feb 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What kind of error had you when building over this ? Emotion.styled is not a function one ?

setViewPickerIsPersisting,
setViewPickerMode,
]);
closeDropdown(VIEW_PICKER_KANBAN_FIELD_DROPDOWN_ID);
Copy link
Contributor

@prastoin prastoin Feb 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: We should strictly typed as much as possible any static occurences of specificComponentId and gather them all in a Literal type or tsc enum, as I guess there're also dynamic dropdown ids

In order to keep consistency between each declarated const like VIEW_PICKER_KANBAN_FIELD_DROPDOWN_ID and bind them to a single TypeScript source of thruth

Something like:

const closeDropdown: (specificComponentId: DeterministicDropdownId | string) => void

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we don't want to type from the inside as the dropdown is a lower layer component that should not take dependency on the app.
In this case we could introduce an enum
ViewDropdownsInstanceId to help the devXp

Copy link
Contributor

@prastoin prastoin Feb 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we don't want to type from the inside as the dropdown is a lower layer component that should not take dependency on the app.

We could add an abstraction layer over it scoped to the app or add a TypeScript generic to the closeDropdown to make it as generic as possible

import { prefetchViewFromViewIdFamilySelector } from '@/prefetch/states/selector/prefetchViewFromViewIdFamilySelector';
import { useRecoilCallback } from 'recoil';

export const useGetViewFromPrefetchState = () => {
Copy link
Contributor

@lucasbordeau lucasbordeau Feb 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useGetPrefetchedView ?

Copy link
Contributor

@lucasbordeau lucasbordeau left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For naming I would suggest :

  • Prefetched when we talk about values
  • Removing state suffix

@@ -53,7 +52,7 @@ export const useUpsertCombinedViewSorts = (viewBarComponentId?: string) => {
return;
}

const currentView = await getViewFromCache(currentViewId);
const currentView = await getViewFromPrefetchState(currentViewId);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: Useless await

@@ -53,7 +53,7 @@ export const useUpsertCombinedViewFilters = (viewBarComponentId?: string) => {
return;
}

const currentView = await getViewFromCache(currentViewId);
const currentView = await getViewFromPrefetchState(currentViewId);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: useless await

@@ -31,7 +31,7 @@ export const useSaveCurrentViewGroups = (viewBarComponentId?: string) => {
return;
}

const view = await getViewFromCache(currentViewId);
const view = await getViewFromPrefetchState(currentViewId);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: useless await

@@ -31,7 +31,7 @@ export const useSaveCurrentViewGroups = (viewBarComponentId?: string) => {
return;
}

const view = await getViewFromCache(currentViewId);
const view = await getViewFromPrefetchState(currentViewId);

if (isUndefinedOrNull(view)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: Could now become isDefined

@@ -77,7 +77,7 @@ export const useSaveCurrentViewFiltersAndSorts = (
unsavedToUpsertViewSortsCallbackState({ viewId }),
);

const view = await getViewFromCache(viewId);
const view = await getViewFromPrefetchState(viewId);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: useless await

@@ -123,7 +123,7 @@ export const useSaveCurrentViewFiltersAndSorts = (
unsavedToUpsertViewFilterGroupsCallbackState({ viewId }),
);

const view = await getViewFromCache(viewId);
const view = await getViewFromPrefetchState(viewId);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: useless await


const view = await getViewFromCache(currentViewId);
const view = await getViewFromPrefetchState(currentViewId);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: useless await

views,
const viewsOnCurrentObject = useRecoilValue(
prefetchViewsFromObjectMetadataItemFamilySelector({
objectMetadataItemId: objectMetadataItem.id,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: Unfortunately id here is typed as any ( not for too long ), would stick to ?? '' to keep consistency and avoid future tsc errors

instanceId,
const indexViewId = useRecoilValue(
prefetchIndexViewIdFromObjectMetadataItemFamilySelector({
objectMetadataItemId: objectMetadataItem.id,
Copy link
Contributor

@prastoin prastoin Feb 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: Unfortunately id here is typed as any ( not for too long ), would stick to ?? '' to keep consistency and avoid future tsc errors

@@ -50,7 +50,7 @@ export const useDeleteCombinedViewFilters = (viewBarComponentId?: string) => {
return;
}

const currentView = await getViewFromCache(currentViewId);
const currentView = await getViewFromPrefetchState(currentViewId);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: Useless await

getViewFromCache,
isPersistingViewFieldsCallbackState,
findManyRecords,
objectMetadataItem?.fields,
Copy link
Contributor

@prastoin prastoin Feb 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React newbie concerns: optional and specific property ? Could this lead to re-render issue ?

@@ -54,7 +49,7 @@ export const useSetLastVisitedViewForObjectMetadataNamePlural = () => {
});
}
},
[findManyRecords, views],
[],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: yummy

views,
const views = useRecoilValue(
prefetchViewsFromObjectMetadataItemFamilySelector({
objectMetadataItemId: objectMetadataItem.id,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: typed as any would ?? ''

@@ -56,7 +55,7 @@ export const useDeleteCombinedViewFilterGroup = (
return;
}

const currentView = await getViewFromCache(currentViewId);
const currentView = await getViewFromPrefetchState(currentViewId);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: useless await

@@ -120,7 +113,7 @@ export const useHandleRecordGroupField = ({
return;
}

const view = await getViewFromCache(currentViewId);
const view = await getViewFromPrefetchState(currentViewId);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: useless await

@@ -36,7 +29,7 @@ export const useHandleRecordGroupField = ({
return;
}

const view = await getViewFromCache(currentViewId);
const view = await getViewFromPrefetchState(currentViewId);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: useless await

view.objectMetadataId === relationObjectMetadataItem.id,
const indexViewId = useRecoilValue(
prefetchIndexViewIdFromObjectMetadataItemFamilySelector({
objectMetadataItemId: relationObjectMetadataItem.id,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: nice

@prastoin prastoin self-requested a review February 18, 2025 11:27
Copy link
Contributor

@prastoin prastoin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gg a lot of work, re-rendering master !
I think we should merge this after fixing critical points raised by reviews, and handle nitpicks afterwards to avoid any conflicts and friction due to the PR size

@prastoin prastoin self-requested a review February 18, 2025 11:35
Copy link
Contributor

@prastoin prastoin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stamping this to get to the release ! We will handle nitpicks and non-critical feedbacks afterwards

@charlesBochet charlesBochet merged commit fb42046 into main Feb 18, 2025
26 of 28 checks passed
@charlesBochet charlesBochet deleted the refacto-views branch February 18, 2025 12:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[BUG] Infinite loop when creating a view from a filtered view
3 participants