Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions openmetadata-ui/src/main/resources/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 11 additions & 3 deletions openmetadata-ui/src/main/resources/ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<AuthProvider childComponentType={AppRouter}>
<AppRouter />
</AuthProvider>
<QueryClientProvider client={queryClient}>
<AuthProvider childComponentType={AppRouter}>
<AppRouter />
</AuthProvider>
</QueryClientProvider>
Comment on lines +26 to +30
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Security: QueryClient cache not cleared on logout leaks data between users

The QueryClientProvider is deliberately placed outside AuthProvider so the cache survives auth-flow remounts. However, there is no queryClient.clear() call in the logout handler. If user A logs out and user B logs in on the same browser tab, user B may briefly see user A's cached query results (e.g. the extension/custom-properties payload keyed by table FQN). This is a data-leakage vector in shared-machine environments.

The PR description justifies the placement for caching feature-flag fetches across auth transitions, but the trade-off needs an explicit cache wipe on credential change.

Suggested fix:

// In AuthProvider's onLogoutHandler (or a useEffect watching the user identity):
import { queryClient } from '../../queryClient';

const onLogoutHandler = async () => {
  // ... existing cleanup ...
  queryClient.clear(); // wipe all cached queries on logout
};
  • Apply suggested fix

Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎

Comment on lines 20 to +30
Comment on lines +21 to +30
);
};

Expand Down
Original file line number Diff line number Diff line change
@@ -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):
* <pre>
* useLazyEntityExtension&lt;Dashboard&gt;({
* entityType: EntityType.DASHBOARD,
* fqn: dashboardFQN,
* activeTab,
* fetcher: getDashboardByFqn,
* onResolve: (extension) =>
* setDashboardDetails((prev) => ({ ...prev, extension })),
* });
* </pre>
*
* 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>()` (T | undefined). Either works.
*
* Behaviour:
* - Query is gated by `enabled: activeTab === CUSTOM_PROPERTIES && Boolean(fqn)` — does
* nothing on other tabs.
* - Stable `queryKey` of `[`<type>-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<T extends EntityWithExtension>({
entityType,
fqn,
activeTab,
fetcher,
onResolve,
}: {
entityType: string;
fqn: string | undefined;
activeTab: string | undefined;
fetcher: (fqn: string, params: { fields: string }) => Promise<T>;
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]);
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,18 @@ 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 { useRequiredParams } from '../../utils/useRequiredParams';
import {
addFollower,
getDashboardByFqn,
Expand Down Expand Up @@ -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<Dashboard>(
{} as Dashboard
);

// Lazy custom-properties fetch — see {@link useLazyEntityExtension}.
useLazyEntityExtension<Dashboard>({
entityType: EntityType.DASHBOARD,
fqn: dashboardFQN,
activeTab,
fetcher: getDashboardByFqn,
onResolve: (extension) =>
setDashboardDetails((prev) => ({ ...prev, extension })),
});
const [isLoading, setLoading] = useState<boolean>(false);
const [isError, setIsError] = useState(false);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,17 @@ 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 { useRequiredParams } from '../../utils/useRequiredParams';
import {
addDriveAssetFollower,
getDriveAssetByFqn,
Expand Down Expand Up @@ -64,9 +70,22 @@ const DirectoryDetailsPage = () => {
const { getEntityPermissionByFqn } = usePermissionProvider();

const { fqn: directoryFQN } = useFqn();
const { tab: activeTab } = useRequiredParams<{ tab: EntityTabs }>();
const [directoryDetails, setDirectoryDetails] = useState<Directory>(
{} as Directory
);

// Lazy custom-properties fetch — see {@link useLazyEntityExtension}.
// getDriveAssetByFqn has a different signature (entityType-keyed) so we adapt it.
useLazyEntityExtension<Directory>({
entityType: EntityType.DIRECTORY,
fqn: directoryFQN,
activeTab,
fetcher: (fqn, params) =>
getDriveAssetByFqn<Directory>(fqn, EntityType.DIRECTORY, params.fields),
onResolve: (extension) =>
setDirectoryDetails((prev) => ({ ...prev, extension })),
});
const [isLoading, setLoading] = useState<boolean>(true);
const [isError, setIsError] = useState(false);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +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 { 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 { useRequiredParams } from '../../utils/useRequiredParams';
import {
addFollower,
getMlModelByFQN,
Expand All @@ -57,7 +63,18 @@ const MlModelPage = () => {
const { currentUser } = useApplicationStore();
const navigate = useNavigate();
const { entityFqn: mlModelFqn } = useFqn({ type: EntityType.MLMODEL });
const { tab: activeTab } = useRequiredParams<{ tab: EntityTabs }>();
const [mlModelDetail, setMlModelDetail] = useState<Mlmodel>({} as Mlmodel);

// Lazy custom-properties fetch — see {@link useLazyEntityExtension}.
useLazyEntityExtension<Mlmodel>({
entityType: EntityType.MLMODEL,
fqn: mlModelFqn,
activeTab,
fetcher: getMlModelByFQN,
onResolve: (extension) =>
setMlModelDetail((prev) => ({ ...prev, extension })),
});
const [isDetailLoading, setIsDetailLoading] = useState<boolean>(false);
const USERId = currentUser?.id ?? '';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,18 @@ 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 { useRequiredParams } from '../../utils/useRequiredParams';
import {
addFollower,
getPipelineByFqn,
Expand Down Expand Up @@ -62,10 +68,21 @@ const PipelineDetailsPage = () => {
const { entityFqn: decodedPipelineFQN } = useFqn({
type: EntityType.PIPELINE,
});
const { tab: activeTab } = useRequiredParams<{ tab: EntityTabs }>();
const [pipelineDetails, setPipelineDetails] = useState<Pipeline>(
{} as Pipeline
);

// Lazy custom-properties fetch — see {@link useLazyEntityExtension}.
useLazyEntityExtension<Pipeline>({
entityType: EntityType.PIPELINE,
fqn: decodedPipelineFQN,
activeTab,
fetcher: getPipelineByFqn,
onResolve: (extension) =>
setPipelineDetails((prev) => ({ ...prev, extension })),
});

const [isLoading, setLoading] = useState<boolean>(true);

const [isError, setIsError] = useState(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -89,6 +90,16 @@ function SearchIndexDetailsPage() {
const USERId = currentUser?.id ?? '';
const [loading, setLoading] = useState<boolean>(true);
const [searchIndexDetails, setSearchIndexDetails] = useState<SearchIndex>();

// Lazy custom-properties fetch — see {@link useLazyEntityExtension}.
useLazyEntityExtension<SearchIndex>({
entityType: EntityType.SEARCH_INDEX,
fqn: decodedSearchIndexFQN,
activeTab,
fetcher: getSearchIndexDetailsByFQN,
onResolve: (extension) =>
setSearchIndexDetails((prev) => (prev ? { ...prev, extension } : prev)),
});
const [feedCount, setFeedCount] = useState<FeedCounts>(
FEED_COUNT_INITIAL_DATA
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,17 @@ 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 { useRequiredParams } from '../../utils/useRequiredParams';
import {
addDriveAssetFollower,
getDriveAssetByFqn,
Expand Down Expand Up @@ -64,9 +70,25 @@ const SpreadsheetDetailsPage = () => {
const { getEntityPermissionByFqn } = usePermissionProvider();

const { fqn: spreadsheetFQN } = useFqn();
const { tab: activeTab } = useRequiredParams<{ tab: EntityTabs }>();
const [spreadsheetDetails, setSpreadsheetDetails] = useState<Spreadsheet>(
{} as Spreadsheet
);

// Lazy custom-properties fetch — see {@link useLazyEntityExtension}.
useLazyEntityExtension<Spreadsheet>({
entityType: EntityType.SPREADSHEET,
fqn: spreadsheetFQN,
activeTab,
fetcher: (fqn, params) =>
getDriveAssetByFqn<Spreadsheet>(
fqn,
EntityType.SPREADSHEET,
params.fields
),
onResolve: (extension) =>
setSpreadsheetDetails((prev) => ({ ...prev, extension })),
});
const [isLoading, setLoading] = useState<boolean>(true);
const [isError, setIsError] = useState(false);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -93,6 +94,16 @@ const StoredProcedurePage = () => {
const { getEntityPermissionByFqn } = usePermissionProvider();
const [isLoading, setIsLoading] = useState<boolean>(true);
const [storedProcedure, setStoredProcedure] = useState<StoredProcedure>();

// Lazy custom-properties fetch — see {@link useLazyEntityExtension}.
useLazyEntityExtension<StoredProcedure>({
entityType: EntityType.STORED_PROCEDURE,
fqn: decodedStoredProcedureFQN,
activeTab,
fetcher: getStoredProceduresByFqn,
onResolve: (extension) =>
setStoredProcedure((prev) => (prev ? { ...prev, extension } : prev)),
});
const [storedProcedurePermissions, setStoredProcedurePermissions] =
useState<OperationPermission>(DEFAULT_ENTITY_PERMISSION);
const [isTabExpanded, setIsTabExpanded] = useState(false);
Expand Down
Loading
Loading