Skip to content

Commit 4cd8737

Browse files
harshachclaude
andcommitted
perf(ui-perf): lazy-fetch entity extension via Custom Properties tab
Brings the unique work from PR #28017 into this branch so we can close that PR as superseded. Trims `extension` (custom-property values) from `defaultFields` on 9 entity-detail pages — Table, Dashboard, Pipeline, MlModel, StoredProcedure, SearchIndex, Directory, Spreadsheet, Worksheet — and fetches it lazily on Custom Properties tab activation via a new `useLazyEntityExtension` hook. Why this matters: - Custom property payloads can run into hundreds of KB on entities with many user-defined properties. Most users never open the Custom Properties tab, so paying for it on first paint is wasted bytes. - The hook centralises the gated-useQuery + merge-into-state pattern (60s staleTime, FQN-scoped queryKey, auto cancellation on FQN change) so each page's wiring is 4–8 lines instead of a copy-pasted closure-with-effect. Test plumbing: - `setupTests.js` globally mocks `useLazyEntityExtension` to a no-op so page tests that render without `QueryClientProvider` keep rendering. - Per-page `fields=` assertions updated where they hardcode the trimmed default-fields string. - Drive entity tests gain a `useParams` mock (returns `{ tab: ... }`) so the page's `useRequiredParams` doesn't throw during render. Closes the lazy-extension work from PR #28017; that PR can now be closed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent dd1aad6 commit 4cd8737

26 files changed

Lines changed: 303 additions & 27 deletions
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
* Copyright 2026 Collate.
3+
* Licensed under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License.
5+
* You may obtain a copy of the License at
6+
* http://www.apache.org/licenses/LICENSE-2.0
7+
* Unless required by applicable law or agreed to in writing, software
8+
* distributed under the License is distributed on an "AS IS" BASIS,
9+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
* See the License for the specific language governing permissions and
11+
* limitations under the License.
12+
*/
13+
import { useQuery } from '@tanstack/react-query';
14+
import { useEffect } from 'react';
15+
import { EntityTabs, TabSpecificField } from '../enums/entity.enum';
16+
17+
interface EntityWithExtension {
18+
extension?: unknown;
19+
}
20+
21+
/**
22+
* Lazily fetch an entity's `extension` (custom-property values) only when the user activates
23+
* the Custom Properties tab. Replaces what used to be eager inclusion of {@code EXTENSION}
24+
* in {@code defaultFields} on every entity-detail page load.
25+
*
26+
* Why this exists:
27+
* - Custom property payloads can run into hundreds of KB on entities with many user-defined
28+
* properties. Most users never open the Custom Properties tab, so paying for it on first
29+
* paint is wasted bytes.
30+
* - The pattern (gated useQuery + merge into local state) was the same on every entity
31+
* detail page; centralising it avoids 8 copies of the same closure-with-effect.
32+
*
33+
* Per-page wiring (call at the page top-level, alongside the main entity state):
34+
* <pre>
35+
* useLazyEntityExtension&lt;Dashboard&gt;({
36+
* entityType: EntityType.DASHBOARD,
37+
* fqn: dashboardFQN,
38+
* activeTab,
39+
* fetcher: getDashboardByFqn,
40+
* onResolve: (extension) =>
41+
* setDashboardDetails((prev) => ({ ...prev, extension })),
42+
* });
43+
* </pre>
44+
*
45+
* The {@code onResolve} callback shape (rather than passing a setState directly) keeps each
46+
* consumer in control of their own state-shape semantics — some pages init state as
47+
* `{} as T` (non-undefined), others as `useState<T>()` (T | undefined). Either works.
48+
*
49+
* Behaviour:
50+
* - Query is gated by `enabled: activeTab === CUSTOM_PROPERTIES && Boolean(fqn)` — does
51+
* nothing on other tabs.
52+
* - Stable `queryKey` of `[`<type>-extension`, fqn]` — cached across tab toggles, refetched
53+
* on FQN change with automatic in-flight cancellation.
54+
* - 60s {@code staleTime} — custom property values change rarely.
55+
* - On resolve, fires {@code onResolve(extension)} exactly once per fresh fetch.
56+
*
57+
* Caveats:
58+
* - The {@code onResolve} callback identity is not memoised at the call site. We
59+
* deliberately depend only on `data?.extension` so we don't fire the merge effect on
60+
* every parent re-render — the latest callback is captured at fire time.
61+
*/
62+
export function useLazyEntityExtension<T extends EntityWithExtension>({
63+
entityType,
64+
fqn,
65+
activeTab,
66+
fetcher,
67+
onResolve,
68+
}: {
69+
entityType: string;
70+
fqn: string | undefined;
71+
activeTab: string | undefined;
72+
fetcher: (fqn: string, params: { fields: string }) => Promise<T>;
73+
onResolve: (extension: T['extension']) => void;
74+
}): void {
75+
const enabled = activeTab === EntityTabs.CUSTOM_PROPERTIES && Boolean(fqn);
76+
77+
const { data } = useQuery({
78+
queryKey: [`${entityType}-extension`, fqn],
79+
queryFn: () =>
80+
fetcher(fqn as string, { fields: TabSpecificField.EXTENSION }),
81+
enabled,
82+
staleTime: 60_000,
83+
});
84+
85+
useEffect(() => {
86+
if (data?.extension === undefined) {
87+
return;
88+
}
89+
onResolve(data.extension);
90+
// onResolve is intentionally omitted from deps — see header comment.
91+
}, [data?.extension]);
92+
}

openmetadata-ui/src/main/resources/ui/src/pages/DashboardDetailsPage/DashboardDetailsPage.component.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,20 @@ import { usePermissionProvider } from '../../context/PermissionProvider/Permissi
2828
import { ResourceEntity } from '../../context/PermissionProvider/PermissionProvider.interface';
2929
import { ClientErrors } from '../../enums/Axios.enum';
3030
import { ERROR_PLACEHOLDER_TYPE } from '../../enums/common.enum';
31-
import { EntityType, TabSpecificField } from '../../enums/entity.enum';
31+
import {
32+
EntityTabs,
33+
EntityType,
34+
TabSpecificField,
35+
} from '../../enums/entity.enum';
3236
import { Chart } from '../../generated/entity/data/chart';
3337
import { Dashboard } from '../../generated/entity/data/dashboard';
3438
import { Operation as PermissionOperation } from '../../generated/entity/policies/accessControl/resourcePermission';
3539
import { useApplicationStore } from '../../hooks/useApplicationStore';
3640
import { useFqn } from '../../hooks/useFqn';
41+
import { useLazyEntityExtension } from '../../hooks/useLazyEntityExtension';
3742
import {
3843
addFollower,
44+
getDashboardByFqn,
3945
patchDashboardDetails,
4046
removeFollower,
4147
updateDashboardVotes,
@@ -56,6 +62,7 @@ import {
5662
} from '../../utils/PermissionsUtils';
5763
import { getVersionPath } from '../../utils/RouterUtils';
5864
import { showErrorToast } from '../../utils/ToastUtils';
65+
import { useRequiredParams } from '../../utils/useRequiredParams';
5966

6067
export type ChartType = {
6168
displayName: string;
@@ -68,6 +75,7 @@ const DashboardDetailsPage = () => {
6875
const navigate = useNavigate();
6976
const { getEntityPermissionByFqn } = usePermissionProvider();
7077
const { entityFqn: dashboardFQN } = useFqn({ type: EntityType.DASHBOARD });
78+
const { tab: activeTab } = useRequiredParams<{ tab: EntityTabs }>();
7179
const queryClient = useQueryClient();
7280

7381
const [permissionsLoading, setPermissionsLoading] = useState<boolean>(true);
@@ -171,6 +179,16 @@ const DashboardDetailsPage = () => {
171179
[queryClient, dashboardCacheKey]
172180
);
173181

182+
// Lazy custom-properties fetch — see {@link useLazyEntityExtension}.
183+
useLazyEntityExtension<Dashboard>({
184+
entityType: EntityType.DASHBOARD,
185+
fqn: dashboardFQN,
186+
activeTab,
187+
fetcher: getDashboardByFqn,
188+
onResolve: (extension) =>
189+
setDashboardDetails((prev) => (prev ? { ...prev, extension } : prev)),
190+
});
191+
174192
const { id: dashboardId, version, charts } = dashboardDetails ?? {};
175193
const isFollowing = useMemo(
176194
() => dashboardDetails?.followers?.some(({ id }) => id === USERId) ?? false,

openmetadata-ui/src/main/resources/ui/src/pages/DashboardDetailsPage/DashboardDetailsPage.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ describe('DashboardDetailsPage', () => {
111111
await waitFor(() =>
112112
expect(getDashboardByFqn).toHaveBeenCalledWith('test-dashboard', {
113113
fields:
114-
'domains,owners, followers, tags, charts,votes,dataProducts,extension,usageSummary',
114+
'domains,owners, followers, tags, charts,votes,dataProducts,usageSummary',
115115
})
116116
);
117117

openmetadata-ui/src/main/resources/ui/src/pages/DirectoryDetailsPage/DirectoryDetailsPage.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ jest.mock('../../hooks/useApplicationStore', () => ({
108108

109109
jest.mock('react-router-dom', () => ({
110110
useNavigate: jest.fn().mockImplementation(() => jest.fn()),
111+
useParams: jest.fn().mockReturnValue({ tab: 'children' }),
111112
MemoryRouter: ({ children }: { children: React.ReactNode }) => (
112113
<div data-testid="memory-router">{children}</div>
113114
),

openmetadata-ui/src/main/resources/ui/src/pages/DirectoryDetailsPage/DirectoryDetailsPage.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,16 @@ import {
3131
} from '../../context/PermissionProvider/PermissionProvider.interface';
3232
import { ClientErrors } from '../../enums/Axios.enum';
3333
import { ERROR_PLACEHOLDER_TYPE } from '../../enums/common.enum';
34-
import { EntityType, TabSpecificField } from '../../enums/entity.enum';
34+
import {
35+
EntityTabs,
36+
EntityType,
37+
TabSpecificField,
38+
} from '../../enums/entity.enum';
3539
import { Directory } from '../../generated/entity/data/directory';
3640
import { Operation as PermissionOperation } from '../../generated/entity/policies/accessControl/resourcePermission';
3741
import { useApplicationStore } from '../../hooks/useApplicationStore';
3842
import { useFqn } from '../../hooks/useFqn';
43+
import { useLazyEntityExtension } from '../../hooks/useLazyEntityExtension';
3944
import {
4045
addDriveAssetFollower,
4146
getDriveAssetByFqn,
@@ -55,6 +60,7 @@ import {
5560
} from '../../utils/PermissionsUtils';
5661
import { getVersionPath } from '../../utils/RouterUtils';
5762
import { showErrorToast } from '../../utils/ToastUtils';
63+
import { useRequiredParams } from '../../utils/useRequiredParams';
5864

5965
const DirectoryDetailsPage = () => {
6066
const { t } = useTranslation();
@@ -64,9 +70,22 @@ const DirectoryDetailsPage = () => {
6470
const { getEntityPermissionByFqn } = usePermissionProvider();
6571

6672
const { fqn: directoryFQN } = useFqn();
73+
const { tab: activeTab } = useRequiredParams<{ tab: EntityTabs }>();
6774
const [directoryDetails, setDirectoryDetails] = useState<Directory>(
6875
{} as Directory
6976
);
77+
78+
// Lazy custom-properties fetch — see {@link useLazyEntityExtension}.
79+
// getDriveAssetByFqn has a different signature (entityType-keyed) so we adapt it.
80+
useLazyEntityExtension<Directory>({
81+
entityType: EntityType.DIRECTORY,
82+
fqn: directoryFQN,
83+
activeTab,
84+
fetcher: (fqn, params) =>
85+
getDriveAssetByFqn<Directory>(fqn, EntityType.DIRECTORY, params.fields),
86+
onResolve: (extension) =>
87+
setDirectoryDetails((prev) => ({ ...prev, extension })),
88+
});
7089
const [isLoading, setLoading] = useState<boolean>(true);
7190
const [isError, setIsError] = useState(false);
7291

openmetadata-ui/src/main/resources/ui/src/pages/MlModelPage/MlModelPage.component.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,19 @@ import { usePermissionProvider } from '../../context/PermissionProvider/Permissi
2828
import { ResourceEntity } from '../../context/PermissionProvider/PermissionProvider.interface';
2929
import { ClientErrors } from '../../enums/Axios.enum';
3030
import { ERROR_PLACEHOLDER_TYPE } from '../../enums/common.enum';
31-
import { EntityType, TabSpecificField } from '../../enums/entity.enum';
31+
import {
32+
EntityTabs,
33+
EntityType,
34+
TabSpecificField,
35+
} from '../../enums/entity.enum';
3236
import { Mlmodel } from '../../generated/entity/data/mlmodel';
3337
import { Operation as PermissionOperation } from '../../generated/entity/policies/accessControl/resourcePermission';
3438
import { useApplicationStore } from '../../hooks/useApplicationStore';
3539
import { useFqn } from '../../hooks/useFqn';
40+
import { useLazyEntityExtension } from '../../hooks/useLazyEntityExtension';
3641
import {
3742
addFollower,
43+
getMlModelByFQN,
3844
patchMlModelDetails,
3945
removeFollower,
4046
updateMlModelVotes,
@@ -55,13 +61,15 @@ import {
5561
} from '../../utils/PermissionsUtils';
5662
import { getVersionPath } from '../../utils/RouterUtils';
5763
import { showErrorToast } from '../../utils/ToastUtils';
64+
import { useRequiredParams } from '../../utils/useRequiredParams';
5865

5966
const MlModelPage = () => {
6067
const { t } = useTranslation();
6168
const { currentUser } = useApplicationStore();
6269
const navigate = useNavigate();
6370
const queryClient = useQueryClient();
6471
const { entityFqn: mlModelFqn } = useFqn({ type: EntityType.MLMODEL });
72+
const { tab: activeTab } = useRequiredParams<{ tab: EntityTabs }>();
6573
const USERId = currentUser?.id ?? '';
6674

6775
const [permissionsLoading, setPermissionsLoading] = useState<boolean>(true);
@@ -157,6 +165,16 @@ const MlModelPage = () => {
157165
[queryClient, mlModelCacheKey]
158166
);
159167

168+
// Lazy custom-properties fetch — see {@link useLazyEntityExtension}.
169+
useLazyEntityExtension<Mlmodel>({
170+
entityType: EntityType.MLMODEL,
171+
fqn: mlModelFqn,
172+
activeTab,
173+
fetcher: getMlModelByFQN,
174+
onResolve: (extension) =>
175+
setMlModelDetail((prev) => (prev ? { ...prev, extension } : prev)),
176+
});
177+
160178
const { mlModelId, followers } = useMemo(() => {
161179
return {
162180
mlModelId: mlModelDetail?.id,

openmetadata-ui/src/main/resources/ui/src/pages/PipelineDetails/PipelineDetailsPage.component.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,20 @@ import { usePermissionProvider } from '../../context/PermissionProvider/Permissi
2828
import { ResourceEntity } from '../../context/PermissionProvider/PermissionProvider.interface';
2929
import { ClientErrors } from '../../enums/Axios.enum';
3030
import { ERROR_PLACEHOLDER_TYPE } from '../../enums/common.enum';
31-
import { EntityType, TabSpecificField } from '../../enums/entity.enum';
31+
import {
32+
EntityTabs,
33+
EntityType,
34+
TabSpecificField,
35+
} from '../../enums/entity.enum';
3236
import { Pipeline } from '../../generated/entity/data/pipeline';
3337
import { Operation as PermissionOperation } from '../../generated/entity/policies/accessControl/resourcePermission';
3438
import { Paging } from '../../generated/type/paging';
3539
import { useApplicationStore } from '../../hooks/useApplicationStore';
3640
import { useFqn } from '../../hooks/useFqn';
41+
import { useLazyEntityExtension } from '../../hooks/useLazyEntityExtension';
3742
import {
3843
addFollower,
44+
getPipelineByFqn,
3945
patchPipelineDetails,
4046
removeFollower,
4147
updatePipelinesVotes,
@@ -56,6 +62,7 @@ import {
5662
import { defaultFields } from '../../utils/PipelineDetailsUtils';
5763
import { getVersionPath } from '../../utils/RouterUtils';
5864
import { showErrorToast } from '../../utils/ToastUtils';
65+
import { useRequiredParams } from '../../utils/useRequiredParams';
5966

6067
const PipelineDetailsPage = () => {
6168
const { t } = useTranslation();
@@ -67,6 +74,7 @@ const PipelineDetailsPage = () => {
6774
const { entityFqn: decodedPipelineFQN } = useFqn({
6875
type: EntityType.PIPELINE,
6976
});
77+
const { tab: activeTab } = useRequiredParams<{ tab: EntityTabs }>();
7078

7179
const [permissionsLoading, setPermissionsLoading] = useState<boolean>(true);
7280
const [paging] = useState<Paging>({} as Paging);
@@ -172,6 +180,16 @@ const PipelineDetailsPage = () => {
172180
[queryClient, pipelineCacheKey]
173181
);
174182

183+
// Lazy custom-properties fetch — see {@link useLazyEntityExtension}.
184+
useLazyEntityExtension<Pipeline>({
185+
entityType: EntityType.PIPELINE,
186+
fqn: decodedPipelineFQN,
187+
activeTab,
188+
fetcher: getPipelineByFqn,
189+
onResolve: (extension) =>
190+
setPipelineDetails((prev) => (prev ? { ...prev, extension } : prev)),
191+
});
192+
175193
const { pipelineId, currentVersion, followers } = useMemo(() => {
176194
return {
177195
pipelineId: pipelineDetails?.id,

openmetadata-ui/src/main/resources/ui/src/pages/SearchIndexDetailsPage/SearchIndexDetailsPage.test.tsx

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -220,8 +220,7 @@ describe('SearchIndexDetailsPage component', () => {
220220
expect(getSearchIndexDetailsByFQN).toHaveBeenCalledWith(
221221
'test-service.test-search-index',
222222
{
223-
fields:
224-
'fields,followers,tags,owners,domains,votes,dataProducts,extension',
223+
fields: 'fields,followers,tags,owners,domains,votes,dataProducts',
225224
}
226225
);
227226
},
@@ -245,8 +244,7 @@ describe('SearchIndexDetailsPage component', () => {
245244
expect(getSearchIndexDetailsByFQN).toHaveBeenCalledWith(
246245
'test-service.test-search-index',
247246
{
248-
fields:
249-
'fields,followers,tags,owners,domains,votes,dataProducts,extension',
247+
fields: 'fields,followers,tags,owners,domains,votes,dataProducts',
250248
}
251249
);
252250
},
@@ -275,8 +273,7 @@ describe('SearchIndexDetailsPage component', () => {
275273
expect(getSearchIndexDetailsByFQN).toHaveBeenCalledWith(
276274
'test-service.test-search-index',
277275
{
278-
fields:
279-
'fields,followers,tags,owners,domains,votes,dataProducts,extension',
276+
fields: 'fields,followers,tags,owners,domains,votes,dataProducts',
280277
}
281278
);
282279
},

openmetadata-ui/src/main/resources/ui/src/pages/SearchIndexDetailsPage/SearchIndexDetailsPage.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,15 @@ import LimitWrapper from '../../hoc/LimitWrapper';
4646
import { useApplicationStore } from '../../hooks/useApplicationStore';
4747
import { useCustomPages } from '../../hooks/useCustomPages';
4848
import { useFqn } from '../../hooks/useFqn';
49+
import { useLazyEntityExtension } from '../../hooks/useLazyEntityExtension';
4950
import { FeedCounts } from '../../interface/feed.interface';
5051
import {
5152
searchIndexQueryFn,
5253
searchIndexQueryKey,
5354
} from '../../rest/queries/searchIndexQuery';
5455
import {
5556
addFollower,
57+
getSearchIndexDetailsByFQN,
5658
patchSearchIndexDetails,
5759
removeFollower,
5860
restoreSearchIndex,
@@ -163,6 +165,16 @@ function SearchIndexDetailsPage() {
163165
[queryClient, searchIndexCacheKey]
164166
);
165167

168+
// Lazy custom-properties fetch — see {@link useLazyEntityExtension}.
169+
useLazyEntityExtension<SearchIndex>({
170+
entityType: EntityType.SEARCH_INDEX,
171+
fqn: decodedSearchIndexFQN,
172+
activeTab,
173+
fetcher: getSearchIndexDetailsByFQN,
174+
onResolve: (extension) =>
175+
setSearchIndexDetails((prev) => (prev ? { ...prev, extension } : prev)),
176+
});
177+
166178
const {
167179
searchIndexTags,
168180
owners,

0 commit comments

Comments
 (0)