diff --git a/openmetadata-ui/src/main/resources/ui/package.json b/openmetadata-ui/src/main/resources/ui/package.json
index 494d4a905f67..5d77de2cecb6 100644
--- a/openmetadata-ui/src/main/resources/ui/package.json
+++ b/openmetadata-ui/src/main/resources/ui/package.json
@@ -85,6 +85,7 @@
"@rjsf/core": "5.24.13",
"@rjsf/utils": "5.24.13",
"@rjsf/validator-ajv8": "5.24.13",
+ "@tanstack/react-query": "^5.62.0",
"@tiptap/core": "^2.3.0",
"@tiptap/extension-link": "^2.10.4",
"@tiptap/extension-placeholder": "^2.3.0",
diff --git a/openmetadata-ui/src/main/resources/ui/src/App.tsx b/openmetadata-ui/src/main/resources/ui/src/App.tsx
index 35f6d39c8ce1..66cc89e3b862 100644
--- a/openmetadata-ui/src/main/resources/ui/src/App.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/App.tsx
@@ -11,15 +11,23 @@
* limitations under the License.
*/
+import { QueryClientProvider } from '@tanstack/react-query';
import { FC } from 'react';
import AppRouter from './components/AppRouter/AppRouter';
import { AuthProvider } from './components/Auth/AuthProviders/AuthProvider';
+import { queryClient } from './queryClient';
const App: FC = () => {
+ // QueryClientProvider sits OUTSIDE AuthProvider so any query made during the auth flow
+ // (e.g. fetching feature flags before login) reuses the same cache. AuthProvider remounts
+ // on logout — wrapping QueryClient inside would discard the cache on every logout,
+ // which is the opposite of what we want here.
return (
-
-
-
+
+
+
+
+
);
};
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx
index 74ecf1275491..451991bf74fe 100644
--- a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx
@@ -53,6 +53,7 @@ import { AuthProvider as AuthProviderEnum } from '../../../generated/settings/se
import { withDomainFilter } from '../../../hoc/withDomainFilter';
import { useApplicationStore } from '../../../hooks/useApplicationStore';
import useCustomLocation from '../../../hooks/useCustomLocation/useCustomLocation';
+import { queryClient } from '../../../queryClient';
import axiosClient from '../../../rest';
import {
fetchAuthenticationConfig,
@@ -223,6 +224,10 @@ export const AuthProvider = ({
// Clear the refresh flag (used after refresh is complete)
tokenService.current.clearRefreshInProgress();
+ // Wipe React Query cache so cached responses from the previous user do not bleed into
+ // the next user's session on shared machines.
+ queryClient.clear();
+
// Upon logout, redirect to the login page
navigate(ROUTES.SIGNIN);
}, [timeoutId]);
diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useLazyEntityExtension.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useLazyEntityExtension.ts
new file mode 100644
index 000000000000..c479377a00df
--- /dev/null
+++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useLazyEntityExtension.ts
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2026 Collate.
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { useQuery } from '@tanstack/react-query';
+import { useEffect } from 'react';
+import { EntityTabs, TabSpecificField } from '../enums/entity.enum';
+
+interface EntityWithExtension {
+ extension?: unknown;
+}
+
+/**
+ * Lazily fetch an entity's `extension` (custom-property values) only when the user activates
+ * the Custom Properties tab. Replaces what used to be eager inclusion of {@code EXTENSION}
+ * in {@code defaultFields} on every entity-detail page load.
+ *
+ * Why this exists:
+ * - 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 pattern (gated useQuery + merge into local state) was the same on every entity
+ * detail page; centralising it avoids 8 copies of the same closure-with-effect.
+ *
+ * Per-page wiring (call at the page top-level, alongside the main entity state):
+ *
+ * useLazyEntityExtension<Dashboard>({
+ * entityType: EntityType.DASHBOARD,
+ * fqn: dashboardFQN,
+ * activeTab,
+ * fetcher: getDashboardByFqn,
+ * onResolve: (extension) =>
+ * setDashboardDetails((prev) => ({ ...prev, extension })),
+ * });
+ *
+ *
+ * The {@code onResolve} callback shape (rather than passing a setState directly) keeps each
+ * consumer in control of their own state-shape semantics — some pages init state as
+ * `{} as T` (non-undefined), others as `useState()` (T | undefined). Either works.
+ *
+ * Behaviour:
+ * - Query is gated by `enabled: activeTab === CUSTOM_PROPERTIES && Boolean(fqn)` — does
+ * nothing on other tabs.
+ * - Stable `queryKey` of `[`-extension`, fqn]` — cached across tab toggles, refetched
+ * on FQN change with automatic in-flight cancellation.
+ * - 60s {@code staleTime} — custom property values change rarely.
+ * - On resolve, fires {@code onResolve(extension)} exactly once per fresh fetch.
+ *
+ * Caveats:
+ * - The {@code onResolve} callback identity is not memoised at the call site. We
+ * deliberately depend only on `data?.extension` so we don't fire the merge effect on
+ * every parent re-render — the latest callback is captured at fire time.
+ */
+export function useLazyEntityExtension({
+ entityType,
+ fqn,
+ activeTab,
+ fetcher,
+ onResolve,
+}: {
+ entityType: string;
+ fqn: string | undefined;
+ activeTab: string | undefined;
+ fetcher: (fqn: string, params: { fields: string }) => Promise;
+ onResolve: (extension: T['extension']) => void;
+}): void {
+ const enabled = activeTab === EntityTabs.CUSTOM_PROPERTIES && Boolean(fqn);
+
+ const { data } = useQuery({
+ queryKey: [`${entityType}-extension`, fqn],
+ queryFn: () =>
+ fetcher(fqn as string, { fields: TabSpecificField.EXTENSION }),
+ enabled,
+ staleTime: 60_000,
+ });
+
+ useEffect(() => {
+ if (data?.extension === undefined) {
+ return;
+ }
+ onResolve(data.extension);
+ // onResolve is intentionally omitted from deps — see header comment.
+ }, [data?.extension]);
+}
diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/DashboardDetailsPage/DashboardDetailsPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/DashboardDetailsPage/DashboardDetailsPage.component.tsx
index 9e991915ae19..3c3bb03cb316 100644
--- a/openmetadata-ui/src/main/resources/ui/src/pages/DashboardDetailsPage/DashboardDetailsPage.component.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/pages/DashboardDetailsPage/DashboardDetailsPage.component.tsx
@@ -27,12 +27,17 @@ import { usePermissionProvider } from '../../context/PermissionProvider/Permissi
import { ResourceEntity } from '../../context/PermissionProvider/PermissionProvider.interface';
import { ClientErrors } from '../../enums/Axios.enum';
import { ERROR_PLACEHOLDER_TYPE } from '../../enums/common.enum';
-import { EntityType, TabSpecificField } from '../../enums/entity.enum';
+import {
+ EntityTabs,
+ EntityType,
+ TabSpecificField,
+} from '../../enums/entity.enum';
import { Chart } from '../../generated/entity/data/chart';
import { Dashboard } from '../../generated/entity/data/dashboard';
import { Operation as PermissionOperation } from '../../generated/entity/policies/accessControl/resourcePermission';
import { useApplicationStore } from '../../hooks/useApplicationStore';
import { useFqn } from '../../hooks/useFqn';
+import { useLazyEntityExtension } from '../../hooks/useLazyEntityExtension';
import {
addFollower,
getDashboardByFqn,
@@ -52,6 +57,7 @@ import {
} from '../../utils/PermissionsUtils';
import { getVersionPath } from '../../utils/RouterUtils';
import { showErrorToast } from '../../utils/ToastUtils';
+import { useRequiredParams } from '../../utils/useRequiredParams';
export type ChartType = {
displayName: string;
@@ -64,10 +70,21 @@ const DashboardDetailsPage = () => {
const navigate = useNavigate();
const { getEntityPermissionByFqn } = usePermissionProvider();
const { entityFqn: dashboardFQN } = useFqn({ type: EntityType.DASHBOARD });
+ const { tab: activeTab } = useRequiredParams<{ tab: EntityTabs }>();
const [dashboardDetails, setDashboardDetails] = useState(
{} as Dashboard
);
+
+ // Lazy custom-properties fetch — see {@link useLazyEntityExtension}.
+ useLazyEntityExtension({
+ entityType: EntityType.DASHBOARD,
+ fqn: dashboardFQN,
+ activeTab,
+ fetcher: getDashboardByFqn,
+ onResolve: (extension) =>
+ setDashboardDetails((prev) => ({ ...prev, extension })),
+ });
const [isLoading, setLoading] = useState(false);
const [isError, setIsError] = useState(false);
diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/DashboardDetailsPage/DashboardDetailsPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/DashboardDetailsPage/DashboardDetailsPage.test.tsx
index 2419b911e46e..d371cfa10104 100644
--- a/openmetadata-ui/src/main/resources/ui/src/pages/DashboardDetailsPage/DashboardDetailsPage.test.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/pages/DashboardDetailsPage/DashboardDetailsPage.test.tsx
@@ -105,7 +105,7 @@ describe('DashboardDetailsPage', () => {
expect(getDashboardByFqn).toHaveBeenCalledWith('test-dashboard', {
fields:
- 'domains,owners, followers, tags, charts,votes,dataProducts,extension,usageSummary',
+ 'domains,owners, followers, tags, charts,votes,dataProducts,usageSummary',
});
expect(screen.getByTestId('no-data-placeholder')).toBeInTheDocument();
diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/DirectoryDetailsPage/DirectoryDetailsPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/DirectoryDetailsPage/DirectoryDetailsPage.tsx
index 040fd8898052..26b60676e54d 100644
--- a/openmetadata-ui/src/main/resources/ui/src/pages/DirectoryDetailsPage/DirectoryDetailsPage.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/pages/DirectoryDetailsPage/DirectoryDetailsPage.tsx
@@ -31,11 +31,16 @@ import {
} from '../../context/PermissionProvider/PermissionProvider.interface';
import { ClientErrors } from '../../enums/Axios.enum';
import { ERROR_PLACEHOLDER_TYPE } from '../../enums/common.enum';
-import { EntityType, TabSpecificField } from '../../enums/entity.enum';
+import {
+ EntityTabs,
+ EntityType,
+ TabSpecificField,
+} from '../../enums/entity.enum';
import { Directory } from '../../generated/entity/data/directory';
import { Operation as PermissionOperation } from '../../generated/entity/policies/accessControl/resourcePermission';
import { useApplicationStore } from '../../hooks/useApplicationStore';
import { useFqn } from '../../hooks/useFqn';
+import { useLazyEntityExtension } from '../../hooks/useLazyEntityExtension';
import {
addDriveAssetFollower,
getDriveAssetByFqn,
@@ -55,6 +60,7 @@ import {
} from '../../utils/PermissionsUtils';
import { getVersionPath } from '../../utils/RouterUtils';
import { showErrorToast } from '../../utils/ToastUtils';
+import { useRequiredParams } from '../../utils/useRequiredParams';
const DirectoryDetailsPage = () => {
const { t } = useTranslation();
@@ -64,9 +70,22 @@ const DirectoryDetailsPage = () => {
const { getEntityPermissionByFqn } = usePermissionProvider();
const { fqn: directoryFQN } = useFqn();
+ const { tab: activeTab } = useRequiredParams<{ tab: EntityTabs }>();
const [directoryDetails, setDirectoryDetails] = useState(
{} as Directory
);
+
+ // Lazy custom-properties fetch — see {@link useLazyEntityExtension}.
+ // getDriveAssetByFqn has a different signature (entityType-keyed) so we adapt it.
+ useLazyEntityExtension({
+ entityType: EntityType.DIRECTORY,
+ fqn: directoryFQN,
+ activeTab,
+ fetcher: (fqn, params) =>
+ getDriveAssetByFqn(fqn, EntityType.DIRECTORY, params.fields),
+ onResolve: (extension) =>
+ setDirectoryDetails((prev) => ({ ...prev, extension })),
+ });
const [isLoading, setLoading] = useState(true);
const [isError, setIsError] = useState(false);
diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/MlModelPage/MlModelPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/MlModelPage/MlModelPage.component.tsx
index 1e1c57d35d0c..dee084635dcf 100644
--- a/openmetadata-ui/src/main/resources/ui/src/pages/MlModelPage/MlModelPage.component.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/pages/MlModelPage/MlModelPage.component.tsx
@@ -27,11 +27,16 @@ import { usePermissionProvider } from '../../context/PermissionProvider/Permissi
import { ResourceEntity } from '../../context/PermissionProvider/PermissionProvider.interface';
import { ClientErrors } from '../../enums/Axios.enum';
import { ERROR_PLACEHOLDER_TYPE } from '../../enums/common.enum';
-import { EntityType, TabSpecificField } from '../../enums/entity.enum';
+import {
+ EntityTabs,
+ EntityType,
+ TabSpecificField,
+} from '../../enums/entity.enum';
import { Mlmodel } from '../../generated/entity/data/mlmodel';
import { Operation as PermissionOperation } from '../../generated/entity/policies/accessControl/resourcePermission';
import { useApplicationStore } from '../../hooks/useApplicationStore';
import { useFqn } from '../../hooks/useFqn';
+import { useLazyEntityExtension } from '../../hooks/useLazyEntityExtension';
import {
addFollower,
getMlModelByFQN,
@@ -51,13 +56,25 @@ import {
} from '../../utils/PermissionsUtils';
import { getVersionPath } from '../../utils/RouterUtils';
import { showErrorToast } from '../../utils/ToastUtils';
+import { useRequiredParams } from '../../utils/useRequiredParams';
const MlModelPage = () => {
const { t } = useTranslation();
const { currentUser } = useApplicationStore();
const navigate = useNavigate();
const { entityFqn: mlModelFqn } = useFqn({ type: EntityType.MLMODEL });
+ const { tab: activeTab } = useRequiredParams<{ tab: EntityTabs }>();
const [mlModelDetail, setMlModelDetail] = useState({} as Mlmodel);
+
+ // Lazy custom-properties fetch — see {@link useLazyEntityExtension}.
+ useLazyEntityExtension({
+ entityType: EntityType.MLMODEL,
+ fqn: mlModelFqn,
+ activeTab,
+ fetcher: getMlModelByFQN,
+ onResolve: (extension) =>
+ setMlModelDetail((prev) => ({ ...prev, extension })),
+ });
const [isDetailLoading, setIsDetailLoading] = useState(false);
const USERId = currentUser?.id ?? '';
diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/PipelineDetails/PipelineDetailsPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/PipelineDetails/PipelineDetailsPage.component.tsx
index 7cd312470555..363c706421c4 100644
--- a/openmetadata-ui/src/main/resources/ui/src/pages/PipelineDetails/PipelineDetailsPage.component.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/pages/PipelineDetails/PipelineDetailsPage.component.tsx
@@ -27,12 +27,17 @@ import { usePermissionProvider } from '../../context/PermissionProvider/Permissi
import { ResourceEntity } from '../../context/PermissionProvider/PermissionProvider.interface';
import { ClientErrors } from '../../enums/Axios.enum';
import { ERROR_PLACEHOLDER_TYPE } from '../../enums/common.enum';
-import { EntityType, TabSpecificField } from '../../enums/entity.enum';
+import {
+ EntityTabs,
+ EntityType,
+ TabSpecificField,
+} from '../../enums/entity.enum';
import { Pipeline } from '../../generated/entity/data/pipeline';
import { Operation as PermissionOperation } from '../../generated/entity/policies/accessControl/resourcePermission';
import { Paging } from '../../generated/type/paging';
import { useApplicationStore } from '../../hooks/useApplicationStore';
import { useFqn } from '../../hooks/useFqn';
+import { useLazyEntityExtension } from '../../hooks/useLazyEntityExtension';
import {
addFollower,
getPipelineByFqn,
@@ -52,6 +57,7 @@ import {
import { defaultFields } from '../../utils/PipelineDetailsUtils';
import { getVersionPath } from '../../utils/RouterUtils';
import { showErrorToast } from '../../utils/ToastUtils';
+import { useRequiredParams } from '../../utils/useRequiredParams';
const PipelineDetailsPage = () => {
const { t } = useTranslation();
@@ -62,10 +68,21 @@ const PipelineDetailsPage = () => {
const { entityFqn: decodedPipelineFQN } = useFqn({
type: EntityType.PIPELINE,
});
+ const { tab: activeTab } = useRequiredParams<{ tab: EntityTabs }>();
const [pipelineDetails, setPipelineDetails] = useState(
{} as Pipeline
);
+ // Lazy custom-properties fetch — see {@link useLazyEntityExtension}.
+ useLazyEntityExtension({
+ entityType: EntityType.PIPELINE,
+ fqn: decodedPipelineFQN,
+ activeTab,
+ fetcher: getPipelineByFqn,
+ onResolve: (extension) =>
+ setPipelineDetails((prev) => ({ ...prev, extension })),
+ });
+
const [isLoading, setLoading] = useState(true);
const [isError, setIsError] = useState(false);
diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/SearchIndexDetailsPage/SearchIndexDetailsPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/SearchIndexDetailsPage/SearchIndexDetailsPage.test.tsx
index 5ae487ba0a6b..53078a5e87ce 100644
--- a/openmetadata-ui/src/main/resources/ui/src/pages/SearchIndexDetailsPage/SearchIndexDetailsPage.test.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/pages/SearchIndexDetailsPage/SearchIndexDetailsPage.test.tsx
@@ -224,8 +224,7 @@ describe('SearchIndexDetailsPage component', () => {
expect(getSearchIndexDetailsByFQN).toHaveBeenCalledWith(
'test-service.test-search-index',
{
- fields:
- 'fields,followers,tags,owners,domains,votes,dataProducts,extension',
+ fields: 'fields,followers,tags,owners,domains,votes,dataProducts',
}
);
},
@@ -253,8 +252,7 @@ describe('SearchIndexDetailsPage component', () => {
expect(getSearchIndexDetailsByFQN).toHaveBeenCalledWith(
'test-service.test-search-index',
{
- fields:
- 'fields,followers,tags,owners,domains,votes,dataProducts,extension',
+ fields: 'fields,followers,tags,owners,domains,votes,dataProducts',
}
);
},
@@ -287,8 +285,7 @@ describe('SearchIndexDetailsPage component', () => {
expect(getSearchIndexDetailsByFQN).toHaveBeenCalledWith(
'test-service.test-search-index',
{
- fields:
- 'fields,followers,tags,owners,domains,votes,dataProducts,extension',
+ fields: 'fields,followers,tags,owners,domains,votes,dataProducts',
}
);
},
diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/SearchIndexDetailsPage/SearchIndexDetailsPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/SearchIndexDetailsPage/SearchIndexDetailsPage.tsx
index d1da2ef2a6b8..e63c685dc1c7 100644
--- a/openmetadata-ui/src/main/resources/ui/src/pages/SearchIndexDetailsPage/SearchIndexDetailsPage.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/pages/SearchIndexDetailsPage/SearchIndexDetailsPage.tsx
@@ -45,6 +45,7 @@ import LimitWrapper from '../../hoc/LimitWrapper';
import { useApplicationStore } from '../../hooks/useApplicationStore';
import { useCustomPages } from '../../hooks/useCustomPages';
import { useFqn } from '../../hooks/useFqn';
+import { useLazyEntityExtension } from '../../hooks/useLazyEntityExtension';
import { FeedCounts } from '../../interface/feed.interface';
import {
addFollower,
@@ -89,6 +90,16 @@ function SearchIndexDetailsPage() {
const USERId = currentUser?.id ?? '';
const [loading, setLoading] = useState(true);
const [searchIndexDetails, setSearchIndexDetails] = useState();
+
+ // Lazy custom-properties fetch — see {@link useLazyEntityExtension}.
+ useLazyEntityExtension({
+ entityType: EntityType.SEARCH_INDEX,
+ fqn: decodedSearchIndexFQN,
+ activeTab,
+ fetcher: getSearchIndexDetailsByFQN,
+ onResolve: (extension) =>
+ setSearchIndexDetails((prev) => (prev ? { ...prev, extension } : prev)),
+ });
const [feedCount, setFeedCount] = useState(
FEED_COUNT_INITIAL_DATA
);
diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/SpreadsheetDetailsPage/SpreadsheetDetailsPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/SpreadsheetDetailsPage/SpreadsheetDetailsPage.test.tsx
index 5f77cf94e3fc..11fe08f9e092 100644
--- a/openmetadata-ui/src/main/resources/ui/src/pages/SpreadsheetDetailsPage/SpreadsheetDetailsPage.test.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/pages/SpreadsheetDetailsPage/SpreadsheetDetailsPage.test.tsx
@@ -367,7 +367,7 @@ describe('SpreadsheetDetailsPage', () => {
expect(getDriveAssetByFqn).toHaveBeenCalledWith(
'test-service.test-spreadsheet',
EntityType.SPREADSHEET,
- 'owners,worksheets,followers,tags,domains,dataProducts,votes,extension,mimeType,createdTime,modifiedTime'
+ 'owners,worksheets,followers,tags,domains,dataProducts,votes,mimeType,createdTime,modifiedTime'
);
});
});
diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/SpreadsheetDetailsPage/SpreadsheetDetailsPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/SpreadsheetDetailsPage/SpreadsheetDetailsPage.tsx
index 1022ea8bbdda..0612489fd3a1 100644
--- a/openmetadata-ui/src/main/resources/ui/src/pages/SpreadsheetDetailsPage/SpreadsheetDetailsPage.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/pages/SpreadsheetDetailsPage/SpreadsheetDetailsPage.tsx
@@ -31,11 +31,16 @@ import {
} from '../../context/PermissionProvider/PermissionProvider.interface';
import { ClientErrors } from '../../enums/Axios.enum';
import { ERROR_PLACEHOLDER_TYPE } from '../../enums/common.enum';
-import { EntityType, TabSpecificField } from '../../enums/entity.enum';
+import {
+ EntityTabs,
+ EntityType,
+ TabSpecificField,
+} from '../../enums/entity.enum';
import { Spreadsheet } from '../../generated/entity/data/spreadsheet';
import { Operation as PermissionOperation } from '../../generated/entity/policies/accessControl/resourcePermission';
import { useApplicationStore } from '../../hooks/useApplicationStore';
import { useFqn } from '../../hooks/useFqn';
+import { useLazyEntityExtension } from '../../hooks/useLazyEntityExtension';
import {
addDriveAssetFollower,
getDriveAssetByFqn,
@@ -55,6 +60,7 @@ import {
import { getVersionPath } from '../../utils/RouterUtils';
import { defaultFields } from '../../utils/SpreadsheetDetailsUtils';
import { showErrorToast } from '../../utils/ToastUtils';
+import { useRequiredParams } from '../../utils/useRequiredParams';
const SpreadsheetDetailsPage = () => {
const { t } = useTranslation();
@@ -64,9 +70,25 @@ const SpreadsheetDetailsPage = () => {
const { getEntityPermissionByFqn } = usePermissionProvider();
const { fqn: spreadsheetFQN } = useFqn();
+ const { tab: activeTab } = useRequiredParams<{ tab: EntityTabs }>();
const [spreadsheetDetails, setSpreadsheetDetails] = useState(
{} as Spreadsheet
);
+
+ // Lazy custom-properties fetch — see {@link useLazyEntityExtension}.
+ useLazyEntityExtension({
+ entityType: EntityType.SPREADSHEET,
+ fqn: spreadsheetFQN,
+ activeTab,
+ fetcher: (fqn, params) =>
+ getDriveAssetByFqn(
+ fqn,
+ EntityType.SPREADSHEET,
+ params.fields
+ ),
+ onResolve: (extension) =>
+ setSpreadsheetDetails((prev) => ({ ...prev, extension })),
+ });
const [isLoading, setLoading] = useState(true);
const [isError, setIsError] = useState(false);
diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/StoredProcedure/StoredProcedurePage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/StoredProcedure/StoredProcedurePage.tsx
index 1d3bbaed362a..2cfde5f325ff 100644
--- a/openmetadata-ui/src/main/resources/ui/src/pages/StoredProcedure/StoredProcedurePage.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/pages/StoredProcedure/StoredProcedurePage.tsx
@@ -48,6 +48,7 @@ import LimitWrapper from '../../hoc/LimitWrapper';
import { useApplicationStore } from '../../hooks/useApplicationStore';
import { useCustomPages } from '../../hooks/useCustomPages';
import { useFqn } from '../../hooks/useFqn';
+import { useLazyEntityExtension } from '../../hooks/useLazyEntityExtension';
import { FeedCounts } from '../../interface/feed.interface';
import {
addStoredProceduresFollower,
@@ -93,6 +94,16 @@ const StoredProcedurePage = () => {
const { getEntityPermissionByFqn } = usePermissionProvider();
const [isLoading, setIsLoading] = useState(true);
const [storedProcedure, setStoredProcedure] = useState();
+
+ // Lazy custom-properties fetch — see {@link useLazyEntityExtension}.
+ useLazyEntityExtension({
+ entityType: EntityType.STORED_PROCEDURE,
+ fqn: decodedStoredProcedureFQN,
+ activeTab,
+ fetcher: getStoredProceduresByFqn,
+ onResolve: (extension) =>
+ setStoredProcedure((prev) => (prev ? { ...prev, extension } : prev)),
+ });
const [storedProcedurePermissions, setStoredProcedurePermissions] =
useState(DEFAULT_ENTITY_PERMISSION);
const [isTabExpanded, setIsTabExpanded] = useState(false);
diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.test.tsx
index 960f94e209d0..9b85cdeaa8c7 100644
--- a/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.test.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.test.tsx
@@ -10,8 +10,9 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { act, render, screen } from '@testing-library/react';
-import React from 'react';
+import React, { ReactElement } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { GenericTab } from '../../components/Customization/GenericTab/GenericTab';
import PageLayoutV1 from '../../components/PageLayoutV1/PageLayoutV1';
@@ -21,6 +22,19 @@ import { getTableDetailsByFQN } from '../../rest/tableAPI';
import { DEFAULT_ENTITY_PERMISSION } from '../../utils/PermissionsUtils';
import TableDetailsPageV1 from './TableDetailsPageV1';
+// Each render gets a fresh QueryClient so cache state never leaks between tests.
+const renderWithProviders = (ui: ReactElement) => {
+ const testQueryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false, gcTime: 0 } },
+ });
+
+ return render(
+
+ {ui}
+
+ );
+};
+
/**
* Mock MUI components that have Jest compatibility issues
*/
@@ -105,7 +119,7 @@ const mockEntityPermissionByFqn = jest
.mockImplementation(() => DEFAULT_ENTITY_PERMISSION);
const COMMON_API_FIELDS =
- 'columns,followers,joins,tags,owners,dataModel,tableConstraints,schemaDefinition,domains,dataProducts,votes,extension';
+ 'columns,followers,joins,tags,owners,dataModel,tableConstraints,schemaDefinition,domains,dataProducts,votes';
jest.mock('../../context/PermissionProvider/PermissionProvider', () => ({
usePermissionProvider: jest.fn().mockImplementation(() => ({
@@ -139,6 +153,7 @@ jest.mock('../../rest/suggestionsAPI', () => ({
}));
jest.mock('../../utils/CommonUtils', () => ({
+ addToRecentViewed: jest.fn(),
getFeedCounts: jest.fn(),
getPartialNameFromTableFQN: jest.fn().mockImplementation(() => 'fqn'),
getTableFQNFromColumnFQN: jest.fn(),
@@ -320,21 +335,13 @@ jest.mock(
describe('TestDetailsPageV1 component', () => {
it('TableDetailsPageV1 should fetch permissions', () => {
- render(
-
-
-
- );
+ renderWithProviders();
expect(mockEntityPermissionByFqn).toHaveBeenCalledWith('table', 'fqn');
});
it('TableDetailsPageV1 should not fetch table details if permission is there', () => {
- render(
-
-
-
- );
+ renderWithProviders();
expect(getTableDetailsByFQN).not.toHaveBeenCalled();
});
@@ -347,11 +354,7 @@ describe('TestDetailsPageV1 component', () => {
}));
await act(async () => {
- render(
-
-
-
- );
+ renderWithProviders();
});
expect(getTableDetailsByFQN).toHaveBeenCalledWith('fqn', {
@@ -369,11 +372,7 @@ describe('TestDetailsPageV1 component', () => {
}));
await act(async () => {
- render(
-
-
-
- );
+ renderWithProviders();
});
expect(getTableDetailsByFQN).toHaveBeenCalledWith('fqn', {
@@ -389,11 +388,7 @@ describe('TestDetailsPageV1 component', () => {
}));
await act(async () => {
- render(
-
-
-
- );
+ renderWithProviders();
});
expect(getTableDetailsByFQN).toHaveBeenCalledWith('fqn', {
@@ -435,11 +430,7 @@ describe('TestDetailsPageV1 component', () => {
);
await act(async () => {
- render(
-
-
-
- );
+ renderWithProviders();
});
expect(await screen.findByText('label.dbt-lowercase')).toBeInTheDocument();
@@ -464,11 +455,7 @@ describe('TestDetailsPageV1 component', () => {
);
await act(async () => {
- render(
-
-
-
- );
+ renderWithProviders();
});
expect(await screen.findByText('label.dbt-lowercase')).toBeInTheDocument();
@@ -493,11 +480,7 @@ describe('TestDetailsPageV1 component', () => {
);
await act(async () => {
- render(
-
-
-
- );
+ renderWithProviders();
});
expect(await screen.findByText('label.dbt-lowercase')).toBeInTheDocument();
@@ -522,11 +505,7 @@ describe('TestDetailsPageV1 component', () => {
);
await act(async () => {
- render(
-
-
-
- );
+ renderWithProviders();
});
expect(await screen.findByText('label.dbt-lowercase')).toBeInTheDocument();
@@ -551,11 +530,7 @@ describe('TestDetailsPageV1 component', () => {
);
await act(async () => {
- render(
-
-
-
- );
+ renderWithProviders();
});
expect(await screen.findByText('label.dbt-lowercase')).toBeInTheDocument();
@@ -579,14 +554,12 @@ describe('TestDetailsPageV1 component', () => {
);
await act(async () => {
- render(
-
-
-
- );
+ renderWithProviders();
});
- expect(screen.getByText('label.schema-definition')).toBeInTheDocument();
+ expect(
+ await screen.findByText('label.schema-definition')
+ ).toBeInTheDocument();
expect(screen.queryByText('label.dbt-lowercase')).not.toBeInTheDocument();
});
@@ -608,14 +581,12 @@ describe('TestDetailsPageV1 component', () => {
);
await act(async () => {
- render(
-
-
-
- );
+ renderWithProviders();
});
- expect(screen.getByText('label.view-definition')).toBeInTheDocument();
+ expect(
+ await screen.findByText('label.view-definition')
+ ).toBeInTheDocument();
});
it('TableDetailsPageV1 should render schemaTab by default', async () => {
@@ -626,11 +597,7 @@ describe('TestDetailsPageV1 component', () => {
}));
await act(async () => {
- render(
-
-
-
- );
+ renderWithProviders();
});
expect(getTableDetailsByFQN).toHaveBeenCalledWith('fqn', {
@@ -659,13 +626,12 @@ describe('TestDetailsPageV1 component', () => {
);
await act(async () => {
- render(
-
-
-
- );
+ renderWithProviders();
});
+ // Await query resolution so PageLayoutV1 has been called with the resolved entity.
+ expect(await screen.findByText('GenericTab')).toBeInTheDocument();
+
expect(PageLayoutV1).toHaveBeenCalledWith(
expect.objectContaining({
pageTitle: 'test-table',
diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx
index 966e5ead5f89..2b3a925a8344 100644
--- a/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx
@@ -11,6 +11,7 @@
* limitations under the License.
*/
+import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Col, Row, Tabs, Tooltip } from 'antd';
import { AxiosError } from 'axios';
import { compare } from 'fast-json-patch';
@@ -57,6 +58,7 @@ import LimitWrapper from '../../hoc/LimitWrapper';
import { useApplicationStore } from '../../hooks/useApplicationStore';
import { useCustomPages } from '../../hooks/useCustomPages';
import { useFqn } from '../../hooks/useFqn';
+import { useLazyEntityExtension } from '../../hooks/useLazyEntityExtension';
import { useSub } from '../../hooks/usePubSub';
import { FeedCounts } from '../../interface/feed.interface';
import { fetchTestCaseResultByTestSuiteId } from '../../rest/dataQualityDashboardAPI';
@@ -108,7 +110,7 @@ const TableDetailsPageV1: React.FC = () => {
useTourProvider();
const { currentUser } = useApplicationStore();
const { setDqLineageData } = useTestCaseStore();
- const [tableDetails, setTableDetails] = useState();
+ const queryClient = useQueryClient();
const { tab: activeTab } = useRequiredParams<{ tab: EntityTabs }>();
const { t } = useTranslation();
const navigate = useNavigate();
@@ -120,10 +122,10 @@ const TableDetailsPageV1: React.FC = () => {
const [queryCount, setQueryCount] = useState(0);
- const [loading, setLoading] = useState(!isTourOpen);
const [tablePermissions, setTablePermissions] = useState(
DEFAULT_ENTITY_PERMISSION
);
+ const [tablePermissionsLoaded, setTablePermissionsLoaded] = useState(false);
const [dqFailureCount, setDqFailureCount] = useState(0);
const { customizedPage } = useCustomPages(PageType.Table);
const [isTabExpanded, setIsTabExpanded] = useState(false);
@@ -156,6 +158,156 @@ const TableDetailsPageV1: React.FC = () => {
) : undefined;
}, [dqFailureCount, tableFqn]);
+ const { viewUsagePermission, viewTestCasePermission, viewBasicPermission } =
+ useMemo(
+ () => ({
+ viewUsagePermission: getPrioritizedViewPermission(
+ tablePermissions,
+ Operation.ViewUsage
+ ),
+ viewTestCasePermission: getPrioritizedViewPermission(
+ tablePermissions,
+ Operation.ViewTests
+ ),
+ viewBasicPermission: getPrioritizedViewPermission(
+ tablePermissions,
+ Operation.ViewBasic
+ ),
+ }),
+ [tablePermissions]
+ );
+
+ // Composed `fields=` value, derived from permissions. USAGE_SUMMARY / TESTSUITE are only
+ // appended when the caller can read them — drives both the queryKey identity and the
+ // queryFn payload, so changing permissions automatically invalidates the cache for the
+ // wrong-shape entry and refetches.
+ const tableQueryFields = useMemo(() => {
+ let fields: string = defaultFieldsWithColumns;
+ if (viewUsagePermission) {
+ fields += `,${TabSpecificField.USAGE_SUMMARY}`;
+ }
+ if (viewTestCasePermission) {
+ fields += `,${TabSpecificField.TESTSUITE}`;
+ }
+
+ return fields;
+ }, [viewUsagePermission, viewTestCasePermission]);
+
+ // Stable React Query key. Includes the FQN and the fields string so that:
+ // * navigating from one table to another swaps queryKey, picking up the new cache slot
+ // (or refetching on miss);
+ // * a permission change that mutates the fields string invalidates the existing entry
+ // because the key shape is different.
+ const tableQueryKey = useMemo(
+ () => ['table-detail', tableFqn, tableQueryFields] as const,
+ [tableFqn, tableQueryFields]
+ );
+
+ // Main entity fetch — migrated from a hand-rolled `useState + useCallback + useEffect`
+ // pattern to React Query (P3.1). Replaces `fetchTableDetails` and the `[tableDetails,
+ // setTableDetails]` useState below. Existing call sites that did `setTableDetails(...)` or
+ // `fetchTableDetails()` continue to work via the wrapper functions defined below — the
+ // page state-shape contract is preserved.
+ //
+ // `enabled` gates on:
+ // * `tablePermissionsLoaded` — wait for the async permission fetch before firing, so we
+ // have an accurate fields string and don't race the permission resolve.
+ // * `viewBasicPermission` — match legacy behaviour: don't issue a guaranteed-403 request
+ // for users who can't view the entity. The early-return placeholder branch below
+ // renders the permission error instead.
+ const {
+ data: tableDetails,
+ isLoading: isTableLoading,
+ error: tableQueryError,
+ refetch: refetchTable,
+ } = useQuery({
+ queryKey: tableQueryKey,
+ queryFn: () => getTableDetailsByFQN(tableFqn, { fields: tableQueryFields }),
+ enabled:
+ !isTourOpen &&
+ !isTourPage &&
+ tablePermissionsLoaded &&
+ viewBasicPermission &&
+ Boolean(tableFqn),
+ });
+
+ // Bridge: existing call sites do `setTableDetails((prev) => ({...prev, ...patch}))` or
+ // `setTableDetails(newTable)`. Both forms still work because we forward to
+ // `queryClient.setQueryData`, which accepts a plain value or an updater closure. Keeping
+ // this wrapper means the ~25 mutation call sites in this file (edit handlers, follow,
+ // vote, restore, certification, tier, …) need no changes.
+ const setTableDetails = useCallback(
+ (
+ next: Table | undefined | ((prev: Table | undefined) => Table | undefined)
+ ) => {
+ queryClient.setQueryData(tableQueryKey, (prev) =>
+ typeof next === 'function'
+ ? (next as (prev: Table | undefined) => Table | undefined)(prev)
+ : next
+ );
+ },
+ [queryClient, tableQueryKey]
+ );
+
+ // Bridge: `fetchTableDetails(showLoading?)` used to be a force-refetch helper. With React
+ // Query, the equivalent is `refetch()` — `isFetching` flips during the refetch but
+ // `isLoading` stays `false` after first success, which matches the old `showLoading=false`
+ // semantics naturally. The argument is now ignored.
+ const fetchTableDetails = useCallback(
+ () => refetchTable().then(() => undefined),
+ [refetchTable]
+ );
+
+ // Surface a loader while permissions are still loading too — otherwise the page briefly
+ // renders the "no data" placeholder before the query is even enabled.
+ const loading =
+ !isTourOpen && !isTourPage && (!tablePermissionsLoaded || isTableLoading);
+
+ // Forbidden navigation — used to live in fetchTableDetails' catch. React Query surfaces
+ // the same error via `error`; redirect on FORBIDDEN once.
+ useEffect(() => {
+ if (
+ tableQueryError &&
+ (tableQueryError as AxiosError)?.response?.status ===
+ ClientErrors.FORBIDDEN
+ ) {
+ navigate(ROUTES.FORBIDDEN, { replace: true });
+ }
+ }, [tableQueryError, navigate]);
+
+ // Recently-viewed bookkeeping — used to live in fetchTableDetails' success path. Now an
+ // effect on the freshly-resolved entity id.
+ useEffect(() => {
+ if (!tableDetails || isTourOpen || isTourPage) {
+ return;
+ }
+ addToRecentViewed({
+ displayName: getEntityName(tableDetails),
+ entityType: EntityType.TABLE,
+ fqn: tableDetails.fullyQualifiedName ?? '',
+ serviceType: tableDetails.serviceType,
+ timestamp: 0,
+ id: tableDetails.id,
+ });
+ }, [tableDetails?.id, isTourOpen, isTourPage]);
+
+ // Tour mode — prime the cache with mock data so consumers (which read from the cache via
+ // `tableDetails`) see the mock entity. Replaces a `setTableDetails(mock)` call from the
+ // legacy useEffect.
+ useEffect(() => {
+ if (isTourOpen || isTourPage) {
+ queryClient.setQueryData(
+ tableQueryKey,
+ mockDatasetData.tableDetails as unknown as Table
+ );
+ }
+ }, [isTourOpen, isTourPage, queryClient, tableQueryKey]);
+
+ const isViewTableType = useMemo(
+ () => tableDetails?.tableType === TableType.View,
+ [tableDetails?.tableType]
+ );
+
const extraDropdownContent = useMemo(
() =>
tableDetails
@@ -167,71 +319,21 @@ const TableDetailsPageV1: React.FC = () => {
navigate
)
: [],
- [tablePermissions, tableFqn, tableDetails]
- );
-
- const { viewUsagePermission, viewTestCasePermission } = useMemo(
- () => ({
- viewUsagePermission: getPrioritizedViewPermission(
- tablePermissions,
- Operation.ViewUsage
- ),
- viewTestCasePermission: getPrioritizedViewPermission(
- tablePermissions,
- Operation.ViewTests
- ),
- }),
- [
- tablePermissions,
- getPrioritizedViewPermission,
- getPrioritizedEditPermission,
- ]
+ [tablePermissions, tableFqn, tableDetails, navigate]
);
- const isViewTableType = useMemo(
- () => tableDetails?.tableType === TableType.View,
- [tableDetails?.tableType]
- );
-
- const fetchTableDetails = useCallback(
- async (showLoading = true) => {
- if (showLoading) {
- setLoading(true);
- }
- try {
- let fields = defaultFieldsWithColumns;
- if (viewUsagePermission) {
- fields += `,${TabSpecificField.USAGE_SUMMARY}`;
- }
- if (viewTestCasePermission) {
- fields += `,${TabSpecificField.TESTSUITE}`;
- }
-
- const tableDetails = await getTableDetailsByFQN(tableFqn, { fields });
-
- setTableDetails(tableDetails);
- addToRecentViewed({
- displayName: getEntityName(tableDetails),
- entityType: EntityType.TABLE,
- fqn: tableDetails.fullyQualifiedName ?? '',
- serviceType: tableDetails.serviceType,
- timestamp: 0,
- id: tableDetails.id,
- });
- } catch (error) {
- if (
- (error as AxiosError)?.response?.status === ClientErrors.FORBIDDEN
- ) {
- navigate(ROUTES.FORBIDDEN, { replace: true });
- }
- } finally {
- if (showLoading) {
- setLoading(false);
- }
- }
- },
- [tableFqn, viewUsagePermission]
- );
+ // Lazy custom-properties fetch — see {@link useLazyEntityExtension} for rationale and
+ // the P3.1 React Query pilot pattern. Eager `defaultFieldsWithColumns` deliberately omits
+ // `extension` because the blob can be hundreds of KB on tables with many user-defined
+ // properties and only the Custom Properties tab consumes it.
+ useLazyEntityExtension({
+ entityType: EntityType.TABLE,
+ fqn: tableFqn,
+ activeTab: isTourOpen ? undefined : activeTab,
+ fetcher: getTableDetailsByFQN,
+ onResolve: (extension) =>
+ setTableDetails((prev) => (prev ? { ...prev, extension } : prev)),
+ });
const fetchDQUpstreamFailureCount = async () => {
if (!tableClassBase.getAlertEnableStatus()) {
@@ -353,12 +455,16 @@ const TableDetailsPageV1: React.FC = () => {
})
);
} finally {
- setLoading(false);
+ setTablePermissionsLoaded(true);
}
},
[getEntityPermissionByFqn, setTablePermissions]
);
+ useEffect(() => {
+ setTablePermissionsLoaded(false);
+ }, [tableFqn]);
+
useEffect(() => {
if (tableFqn) {
fetchResourcePermission(tableFqn);
@@ -472,7 +578,6 @@ const TableDetailsPageV1: React.FC = () => {
viewQueriesPermission,
viewProfilerPermission,
viewAllPermission,
- viewBasicPermission,
viewCustomPropertiesPermission,
} = useMemo(
() => ({
@@ -511,10 +616,6 @@ const TableDetailsPageV1: React.FC = () => {
Operation.ViewDataProfile
),
viewAllPermission: tablePermissions.ViewAll,
- viewBasicPermission: getPrioritizedViewPermission(
- tablePermissions,
- Operation.ViewBasic
- ),
viewCustomPropertiesPermission: getPrioritizedViewPermission(
tablePermissions,
Operation.ViewCustomFields
@@ -775,12 +876,12 @@ const TableDetailsPageV1: React.FC = () => {
[]
);
+ // Tour-mode mock priming and the table fetch itself now both live with the React Query
+ // setup above. This effect remains to drive the *non-table* side-effect — `getEntityFeedCount`
+ // — that used to be co-located with the legacy fetch on FQN change. Kept gated on
+ // `viewBasicPermission` so we don't issue a feed-count fetch the user isn't allowed to see.
useEffect(() => {
- if (isTourOpen || isTourPage) {
- setTableDetails(mockDatasetData.tableDetails as unknown as Table);
- } else if (viewBasicPermission) {
- setTableDetails(undefined);
- fetchTableDetails();
+ if (!isTourOpen && !isTourPage && viewBasicPermission) {
getEntityFeedCount();
}
}, [tableFqn, isTourOpen, isTourPage, viewBasicPermission]);
diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/WorksheetDetailsPage/WorksheetDetailsPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/WorksheetDetailsPage/WorksheetDetailsPage.test.tsx
index d1ceb8fd17c2..69c98d085fdd 100644
--- a/openmetadata-ui/src/main/resources/ui/src/pages/WorksheetDetailsPage/WorksheetDetailsPage.test.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/pages/WorksheetDetailsPage/WorksheetDetailsPage.test.tsx
@@ -374,7 +374,7 @@ describe('WorksheetDetailsPage', () => {
expect(getDriveAssetByFqn).toHaveBeenCalledWith(
'test-service.test-worksheet',
EntityType.WORKSHEET,
- 'owners,followers,tags,domains,dataProducts,votes,extension,rowCount,columns,rowCount'
+ 'owners,followers,tags,domains,dataProducts,votes,rowCount,columns,rowCount'
);
});
});
diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/WorksheetDetailsPage/WorksheetDetailsPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/WorksheetDetailsPage/WorksheetDetailsPage.tsx
index cd9a0711a4d1..f7d04409d351 100644
--- a/openmetadata-ui/src/main/resources/ui/src/pages/WorksheetDetailsPage/WorksheetDetailsPage.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/pages/WorksheetDetailsPage/WorksheetDetailsPage.tsx
@@ -32,11 +32,16 @@ import {
} from '../../context/PermissionProvider/PermissionProvider.interface';
import { ClientErrors } from '../../enums/Axios.enum';
import { ERROR_PLACEHOLDER_TYPE } from '../../enums/common.enum';
-import { EntityType, TabSpecificField } from '../../enums/entity.enum';
+import {
+ EntityTabs,
+ EntityType,
+ TabSpecificField,
+} from '../../enums/entity.enum';
import { Worksheet } from '../../generated/entity/data/worksheet';
import { Operation as PermissionOperation } from '../../generated/entity/policies/accessControl/resourcePermission';
import { useApplicationStore } from '../../hooks/useApplicationStore';
import { useFqn } from '../../hooks/useFqn';
+import { useLazyEntityExtension } from '../../hooks/useLazyEntityExtension';
import {
addDriveAssetFollower,
getDriveAssetByFqn,
@@ -56,6 +61,7 @@ import {
} from '../../utils/PermissionsUtils';
import { getVersionPath } from '../../utils/RouterUtils';
import { showErrorToast } from '../../utils/ToastUtils';
+import { useRequiredParams } from '../../utils/useRequiredParams';
import { defaultFields } from '../../utils/WorksheetDetailsUtils';
const WorksheetDetailsPage = () => {
@@ -66,9 +72,21 @@ const WorksheetDetailsPage = () => {
const { getEntityPermissionByFqn } = usePermissionProvider();
const { fqn: decodedWorksheetFQN } = useFqn();
+ const { tab: activeTab } = useRequiredParams<{ tab: EntityTabs }>();
const [worksheetDetails, setWorksheetDetails] = useState(
{} as Worksheet
);
+
+ // Lazy custom-properties fetch — see {@link useLazyEntityExtension}.
+ useLazyEntityExtension({
+ entityType: EntityType.WORKSHEET,
+ fqn: decodedWorksheetFQN,
+ activeTab,
+ fetcher: (fqn, params) =>
+ getDriveAssetByFqn(fqn, EntityType.WORKSHEET, params.fields),
+ onResolve: (extension) =>
+ setWorksheetDetails((prev) => ({ ...prev, extension })),
+ });
const [isLoading, setLoading] = useState(true);
const [isError, setIsError] = useState(false);
const [resolvedEntityFqn, setResolvedEntityFqn] = useState('');
diff --git a/openmetadata-ui/src/main/resources/ui/src/queryClient.ts b/openmetadata-ui/src/main/resources/ui/src/queryClient.ts
new file mode 100644
index 000000000000..371ea4ff476b
--- /dev/null
+++ b/openmetadata-ui/src/main/resources/ui/src/queryClient.ts
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2026 Collate.
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { QueryClient } from '@tanstack/react-query';
+
+/**
+ * App-wide React Query client.
+ *
+ * Defaults are tuned for OpenMetadata's data shape:
+ * - {@link staleTime} 30 s — most entity reads are stable for tens of seconds at a time
+ * and pages flip back-and-forth (Schema → Lineage → Schema). The same query reissued
+ * within the window serves cached data instead of refetching.
+ * - {@link gcTime} 5 min — keep results around long enough for a tab-switch round-trip
+ * without holding memory for users who navigate away.
+ * - {@link refetchOnWindowFocus} true — picks up backend changes when the user returns
+ * to the tab, but not so aggressively that idle tabs hammer the API.
+ * - {@link retry} 1 — one network blip retry, no exponential backoff cascade.
+ *
+ * Per-query overrides in `useQuery({ queryKey, queryFn, staleTime, ... })` always win.
+ */
+export const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 30_000,
+ gcTime: 5 * 60_000,
+ refetchOnWindowFocus: true,
+ retry: 1,
+ },
+ mutations: {
+ retry: 0,
+ },
+ },
+});
diff --git a/openmetadata-ui/src/main/resources/ui/src/setupTests.js b/openmetadata-ui/src/main/resources/ui/src/setupTests.js
index b7ff1e84e5e4..83a383d15f30 100644
--- a/openmetadata-ui/src/main/resources/ui/src/setupTests.js
+++ b/openmetadata-ui/src/main/resources/ui/src/setupTests.js
@@ -104,6 +104,18 @@ jest.mock('./utils/ToastUtils', () => ({
showErrorToast: jest.fn(),
}));
+/**
+ * Global mock for useLazyEntityExtension. The real hook calls `useQuery`, which
+ * requires a `QueryClientProvider` ancestor in the render tree. Entity-detail page
+ * tests render the page component directly without that provider; mock the hook
+ * to a no-op here so existing tests do not need to set up React Query plumbing
+ * just to render. Individual tests that exercise the lazy-extension behaviour
+ * can override this mock locally.
+ */
+jest.mock('./hooks/useLazyEntityExtension', () => ({
+ useLazyEntityExtension: jest.fn(),
+}));
+
jest.mock('./components/ActivityFeed/FeedEditor/FeedEditor.tsx', () => ({
FeedEditor: jest.fn().mockImplementation(() => 'FeedEditor'),
}));
diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/DashboardDetailsUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/DashboardDetailsUtils.tsx
index 4ce69e41a22d..391decc33af1 100644
--- a/openmetadata-ui/src/main/resources/ui/src/utils/DashboardDetailsUtils.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/utils/DashboardDetailsUtils.tsx
@@ -41,8 +41,11 @@ const EntityLineageTab = lazy(() =>
)
);
+// `EXTENSION` (custom-property values) is fetched lazily by {@link useLazyEntityExtension}
+// when the user activates the Custom Properties tab — it can run into hundreds of KB on
+// dashboards with many user-defined properties and only that tab consumes it.
// eslint-disable-next-line max-len
-export const defaultFields = `${TabSpecificField.DOMAINS},${TabSpecificField.OWNERS}, ${TabSpecificField.FOLLOWERS}, ${TabSpecificField.TAGS}, ${TabSpecificField.CHARTS},${TabSpecificField.VOTES},${TabSpecificField.DATA_PRODUCTS},${TabSpecificField.EXTENSION}`;
+export const defaultFields = `${TabSpecificField.DOMAINS},${TabSpecificField.OWNERS}, ${TabSpecificField.FOLLOWERS}, ${TabSpecificField.TAGS}, ${TabSpecificField.CHARTS},${TabSpecificField.VOTES},${TabSpecificField.DATA_PRODUCTS}`;
export const fetchCharts = async (
charts: Dashboard['charts'],
diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/DatasetDetailsUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/DatasetDetailsUtils.ts
index c04f9f1152df..375d771e9656 100644
--- a/openmetadata-ui/src/main/resources/ui/src/utils/DatasetDetailsUtils.ts
+++ b/openmetadata-ui/src/main/resources/ui/src/utils/DatasetDetailsUtils.ts
@@ -13,12 +13,16 @@
import { TabSpecificField } from '../enums/entity.enum';
-// Fields for table details - excludes columns which will be fetched separately with pagination
+// Fields for table details first paint. Excludes columns (paginated separately) and
+// `extension` (custom-property values — only the Custom Properties tab consumes this; the
+// {@link useLazyEntityExtension} hook fetches it lazily on tab activation). Custom
+// extension payloads can run into hundreds of KB on tables with many user-defined
+// properties; trimming it saves wire bytes on every initial table-page load.
// eslint-disable-next-line max-len
-export const defaultFields = `${TabSpecificField.FOLLOWERS},${TabSpecificField.JOINS},${TabSpecificField.TAGS},${TabSpecificField.OWNERS},${TabSpecificField.DATAMODEL},${TabSpecificField.TABLE_CONSTRAINTS},${TabSpecificField.SCHEMA_DEFINITION},${TabSpecificField.DOMAINS},${TabSpecificField.DATA_PRODUCTS},${TabSpecificField.VOTES},${TabSpecificField.EXTENSION}`;
+export const defaultFields = `${TabSpecificField.FOLLOWERS},${TabSpecificField.JOINS},${TabSpecificField.TAGS},${TabSpecificField.OWNERS},${TabSpecificField.DATAMODEL},${TabSpecificField.TABLE_CONSTRAINTS},${TabSpecificField.SCHEMA_DEFINITION},${TabSpecificField.DOMAINS},${TabSpecificField.DATA_PRODUCTS},${TabSpecificField.VOTES}`;
// Legacy fields that include columns - only use when pagination is not needed
// eslint-disable-next-line max-len
-export const defaultFieldsWithColumns = `${TabSpecificField.COLUMNS},${TabSpecificField.FOLLOWERS},${TabSpecificField.JOINS},${TabSpecificField.TAGS},${TabSpecificField.OWNERS},${TabSpecificField.DATAMODEL},${TabSpecificField.TABLE_CONSTRAINTS},${TabSpecificField.SCHEMA_DEFINITION},${TabSpecificField.DOMAINS},${TabSpecificField.DATA_PRODUCTS},${TabSpecificField.VOTES},${TabSpecificField.EXTENSION}`;
+export const defaultFieldsWithColumns = `${TabSpecificField.COLUMNS},${TabSpecificField.FOLLOWERS},${TabSpecificField.JOINS},${TabSpecificField.TAGS},${TabSpecificField.OWNERS},${TabSpecificField.DATAMODEL},${TabSpecificField.TABLE_CONSTRAINTS},${TabSpecificField.SCHEMA_DEFINITION},${TabSpecificField.DOMAINS},${TabSpecificField.DATA_PRODUCTS},${TabSpecificField.VOTES}`;
export const commonTableFields = `${TabSpecificField.TAGS},${TabSpecificField.OWNERS},${TabSpecificField.DOMAINS},${TabSpecificField.DATA_PRODUCTS},${TabSpecificField.CERTIFICATION}`;
diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/DirectoryDetailsUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/DirectoryDetailsUtils.tsx
index 8c258a0f4a97..5fe0689a0d6d 100644
--- a/openmetadata-ui/src/main/resources/ui/src/utils/DirectoryDetailsUtils.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/utils/DirectoryDetailsUtils.tsx
@@ -35,6 +35,8 @@ export interface DirectoryDetailPageTabProps {
labelMap?: Record;
}
+// `EXTENSION` (custom-property values) is fetched lazily by {@link useLazyEntityExtension}
+// when the user activates the Custom Properties tab. See DatasetDetailsUtils.ts for context.
export const defaultFields = [
TabSpecificField.OWNERS,
TabSpecificField.CHILDREN,
@@ -43,7 +45,6 @@ export const defaultFields = [
TabSpecificField.DOMAINS,
TabSpecificField.DATA_PRODUCTS,
TabSpecificField.VOTES,
- TabSpecificField.EXTENSION,
TabSpecificField.DIRECTORY_TYPE,
TabSpecificField.NUMBER_OF_FILES,
TabSpecificField.NUMBER_OF_SUB_DIRECTORIES,
diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/MlModelDetailsUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/MlModelDetailsUtils.tsx
index ad0b56190d2a..c9d81841b052 100644
--- a/openmetadata-ui/src/main/resources/ui/src/utils/MlModelDetailsUtils.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/utils/MlModelDetailsUtils.tsx
@@ -37,8 +37,10 @@ const EntityLineageTab = lazy(() =>
)
);
+// `EXTENSION` (custom-property values) is fetched lazily by {@link useLazyEntityExtension}
+// when the user activates the Custom Properties tab. See DatasetDetailsUtils.ts for context.
// eslint-disable-next-line max-len
-export const defaultFields = `${TabSpecificField.FOLLOWERS}, ${TabSpecificField.TAGS}, ${TabSpecificField.DOMAINS},${TabSpecificField.OWNERS}, ${TabSpecificField.DASHBOARD},${TabSpecificField.DATA_PRODUCTS},${TabSpecificField.VOTES},${TabSpecificField.EXTENSION}`;
+export const defaultFields = `${TabSpecificField.FOLLOWERS}, ${TabSpecificField.TAGS}, ${TabSpecificField.DOMAINS},${TabSpecificField.OWNERS}, ${TabSpecificField.DASHBOARD},${TabSpecificField.DATA_PRODUCTS},${TabSpecificField.VOTES}`;
export const getMlModelDetailsPageTabs = ({
feedCount,
diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/PipelineDetailsUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/PipelineDetailsUtils.tsx
index add5289066fc..37bf41237b59 100644
--- a/openmetadata-ui/src/main/resources/ui/src/utils/PipelineDetailsUtils.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/utils/PipelineDetailsUtils.tsx
@@ -46,8 +46,11 @@ const EntityLineageTab = lazy(() =>
)
);
+// `EXTENSION` (custom-property values) is fetched lazily by {@link useLazyEntityExtension}
+// when the user activates the Custom Properties tab. See DatasetDetailsUtils.ts for the
+// rationale behind this trim — same logic, applied here.
// eslint-disable-next-line max-len
-export const defaultFields = `${TabSpecificField.FOLLOWERS}, ${TabSpecificField.TAGS}, ${TabSpecificField.OWNERS},${TabSpecificField.TASKS}, ${TabSpecificField.PIPELINE_STATUS}, ${TabSpecificField.DOMAINS},${TabSpecificField.DATA_PRODUCTS},${TabSpecificField.VOTES},${TabSpecificField.EXTENSION}`;
+export const defaultFields = `${TabSpecificField.FOLLOWERS}, ${TabSpecificField.TAGS}, ${TabSpecificField.OWNERS},${TabSpecificField.TASKS}, ${TabSpecificField.PIPELINE_STATUS}, ${TabSpecificField.DOMAINS},${TabSpecificField.DATA_PRODUCTS},${TabSpecificField.VOTES}`;
export const getTaskExecStatus = (taskName: string, tasks: TaskStatus[]) => {
return tasks.find((task) => task.name === taskName)?.executionStatus;
diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/SearchIndexUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/SearchIndexUtils.tsx
index 65db1b4f129e..3ee46d3d1ab8 100644
--- a/openmetadata-ui/src/main/resources/ui/src/utils/SearchIndexUtils.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/utils/SearchIndexUtils.tsx
@@ -44,8 +44,10 @@ const EntityLineageTab = lazy(() =>
)
);
+// `EXTENSION` (custom-property values) is fetched lazily by {@link useLazyEntityExtension}
+// when the user activates the Custom Properties tab. See DatasetDetailsUtils.ts for context.
// eslint-disable-next-line max-len
-export const defaultFields = `${TabSpecificField.FIELDS},${TabSpecificField.FOLLOWERS},${TabSpecificField.TAGS},${TabSpecificField.OWNERS},${TabSpecificField.DOMAINS},${TabSpecificField.VOTES},${TabSpecificField.DATA_PRODUCTS},${TabSpecificField.EXTENSION}`;
+export const defaultFields = `${TabSpecificField.FIELDS},${TabSpecificField.FOLLOWERS},${TabSpecificField.TAGS},${TabSpecificField.OWNERS},${TabSpecificField.DOMAINS},${TabSpecificField.VOTES},${TabSpecificField.DATA_PRODUCTS}`;
export const makeData = (
columns: SearchIndexField[] = []
diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/SpreadsheetDetailsUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/SpreadsheetDetailsUtils.tsx
index 65125b8feedf..e49e18ae655b 100644
--- a/openmetadata-ui/src/main/resources/ui/src/utils/SpreadsheetDetailsUtils.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/utils/SpreadsheetDetailsUtils.tsx
@@ -35,6 +35,8 @@ export interface SpreadsheetDetailPageTabProps {
labelMap?: Record;
}
+// `EXTENSION` (custom-property values) is fetched lazily by {@link useLazyEntityExtension}
+// when the user activates the Custom Properties tab. See DatasetDetailsUtils.ts for context.
export const defaultFields = [
TabSpecificField.OWNERS,
TabSpecificField.WORKSHEETS,
@@ -43,7 +45,6 @@ export const defaultFields = [
TabSpecificField.DOMAINS,
TabSpecificField.DATA_PRODUCTS,
TabSpecificField.VOTES,
- TabSpecificField.EXTENSION,
TabSpecificField.MIME_TYPE,
TabSpecificField.CREATED_TIME,
TabSpecificField.MODIFIED_TIME,
diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/StoredProceduresUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/StoredProceduresUtils.tsx
index 4bdf01f49632..40512da6072c 100644
--- a/openmetadata-ui/src/main/resources/ui/src/utils/StoredProceduresUtils.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/utils/StoredProceduresUtils.tsx
@@ -34,8 +34,10 @@ const EntityLineageTab = lazy(() =>
)
);
+// `EXTENSION` (custom-property values) is fetched lazily by {@link useLazyEntityExtension}
+// when the user activates the Custom Properties tab. See DatasetDetailsUtils.ts for context.
// eslint-disable-next-line max-len
-export const STORED_PROCEDURE_DEFAULT_FIELDS = `${TabSpecificField.OWNERS}, ${TabSpecificField.FOLLOWERS},${TabSpecificField.TAGS}, ${TabSpecificField.DOMAINS},${TabSpecificField.DATA_PRODUCTS}, ${TabSpecificField.VOTES},${TabSpecificField.EXTENSION}`;
+export const STORED_PROCEDURE_DEFAULT_FIELDS = `${TabSpecificField.OWNERS}, ${TabSpecificField.FOLLOWERS},${TabSpecificField.TAGS}, ${TabSpecificField.DOMAINS},${TabSpecificField.DATA_PRODUCTS}, ${TabSpecificField.VOTES}`;
export const getStoredProcedureDetailsPageTabs = ({
activeTab,
diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/WorksheetDetailsUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/WorksheetDetailsUtils.tsx
index 8ab775988535..c07634a60767 100644
--- a/openmetadata-ui/src/main/resources/ui/src/utils/WorksheetDetailsUtils.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/utils/WorksheetDetailsUtils.tsx
@@ -34,6 +34,8 @@ export interface WorksheetDetailPageTabProps {
labelMap?: Record;
}
+// `EXTENSION` (custom-property values) is fetched lazily by {@link useLazyEntityExtension}
+// when the user activates the Custom Properties tab. See DatasetDetailsUtils.ts for context.
export const defaultFields = [
TabSpecificField.OWNERS,
TabSpecificField.FOLLOWERS,
@@ -41,7 +43,6 @@ export const defaultFields = [
TabSpecificField.DOMAINS,
TabSpecificField.DATA_PRODUCTS,
TabSpecificField.VOTES,
- TabSpecificField.EXTENSION,
TabSpecificField.ROW_COUNT,
TabSpecificField.COLUMNS,
TabSpecificField.ROW_COUNT,
diff --git a/openmetadata-ui/src/main/resources/ui/yarn.lock b/openmetadata-ui/src/main/resources/ui/yarn.lock
index 480e680fe3a4..e80029dfeca4 100644
--- a/openmetadata-ui/src/main/resources/ui/yarn.lock
+++ b/openmetadata-ui/src/main/resources/ui/yarn.lock
@@ -2375,9 +2375,8 @@
compare-versions "^4.1.2"
"@openmetadata/ui-core-components@link:../../../../../openmetadata-ui-core-components/src/main/resources/ui":
- version "1.0.0"
- dependencies:
- "@material/material-color-utilities" "^0.3.0"
+ version "0.0.0"
+ uid ""
"@peculiar/asn1-schema@^2.3.13", "@peculiar/asn1-schema@^2.3.8":
version "2.6.0"
@@ -3542,6 +3541,18 @@
"@tailwindcss/oxide" "4.2.4"
tailwindcss "4.2.4"
+"@tanstack/query-core@5.100.9":
+ version "5.100.9"
+ resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.100.9.tgz#dcf44ef25cf42a4da229bcab1d8d33e80a740a99"
+ integrity sha512-SJSFw1S8+kQ0+knv/XGfrbocWoAlT7vDKsSImtLx3ZPQmEcR46hkDjLSvynSy25N8Ms4tIEini1FuBd5k7IscQ==
+
+"@tanstack/react-query@^5.62.0":
+ version "5.100.9"
+ resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.100.9.tgz#0c701bf56f38b484602255a92d4c9e452a04807d"
+ integrity sha512-Oa44XkaI3kCNN6ME0KByU3xT3SEUNOMfZpHxL6+wFoTm+OeUFYHKdeYVe0aOXlRDm/f15sgLwEt2HDorIdW8+A==
+ dependencies:
+ "@tanstack/query-core" "5.100.9"
+
"@testing-library/dom@^9.0.0":
version "9.3.4"
resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-9.3.4.tgz#50696ec28376926fec0a1bf87d9dbac5e27f60ce"