From d5cebdecbe55ed26037c21422833e2eefcc77d0a Mon Sep 17 00:00:00 2001 From: Rohit0301 Date: Mon, 10 Aug 2026 20:22:05 +0530 Subject: [PATCH 01/17] perf(ui): deduplicate docStore requests via React Query shared key Three identical GET /api/v1/docStore/name/persona.* requests fired on every /my-data navigation because MyDataPage and multiple useCustomPages consumers each fetched independently with no cache coordination. Introduce docStoreQuery.ts (shared queryKey + queryFn), migrate useCustomPages to useQuery, and rewrite MyDataPage's manual fetch/effect to useQuery with the same key. React Query's in-flight deduplication collapses N concurrent subscribers to one network request. Co-Authored-By: Claude Sonnet 4.6 --- .../ui/src/hooks/useCustomPages.test.ts | 57 ++++++++-- .../resources/ui/src/hooks/useCustomPages.ts | 55 ++++----- .../pages/MyDataPage/MyDataPage.component.tsx | 104 ++++++++---------- .../ui/src/rest/queries/docStoreQuery.ts | 31 ++++++ 4 files changed, 147 insertions(+), 100 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/rest/queries/docStoreQuery.ts diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.test.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.test.ts index 3328bc2aec13..7d20db84416e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.test.ts @@ -10,7 +10,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { renderHook } from '@testing-library/react-hooks'; +import React from 'react'; import { Document } from '../generated/entity/docStore/document'; import { PageType } from '../generated/system/ui/page'; import { getDocumentByFQN } from '../rest/DocStoreAPI'; @@ -32,7 +34,16 @@ jest.mock('../rest/DocStoreAPI', () => ({ getDocumentByFQN: jest.fn(), })); +const createWrapper = (queryClient: QueryClient) => { + const Wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + return Wrapper; +}; + describe('useCustomPages', () => { + let queryClient: QueryClient; + const mockSelectedPersona = { fullyQualifiedName: 'test-persona', }; @@ -61,6 +72,11 @@ describe('useCustomPages', () => { beforeEach(() => { jest.clearAllMocks(); + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); mockUseApplicationStore.mockReturnValue({ selectedPersona: mockSelectedPersona, }); @@ -69,8 +85,9 @@ describe('useCustomPages', () => { it('should fetch and return customized page and navigation when persona is selected', async () => { mockGetDocumentByFQN.mockResolvedValue(mockDocument); - const { result, waitForNextUpdate } = renderHook(() => - useCustomPages(PageType.Table) + const { result, waitForNextUpdate } = renderHook( + () => useCustomPages(PageType.Table), + { wrapper: createWrapper(queryClient) } ); expect(result.current.isLoading).toBe(true); @@ -86,8 +103,9 @@ describe('useCustomPages', () => { it('should handle error when fetching document fails', async () => { mockGetDocumentByFQN.mockRejectedValue(new Error('API Error')); - const { result, waitForNextUpdate } = renderHook(() => - useCustomPages(PageType.Table) + const { result, waitForNextUpdate } = renderHook( + () => useCustomPages(PageType.Table), + { wrapper: createWrapper(queryClient) } ); expect(result.current.isLoading).toBe(true); @@ -105,7 +123,9 @@ describe('useCustomPages', () => { selectedPersona: null, }); - const { result } = renderHook(() => useCustomPages(PageType.Table)); + const { result } = renderHook(() => useCustomPages(PageType.Table), { + wrapper: createWrapper(queryClient), + }); expect(mockGetDocumentByFQN).not.toHaveBeenCalled(); expect(result.current.customizedPage).toBeNull(); @@ -113,25 +133,37 @@ describe('useCustomPages', () => { expect(result.current.isLoading).toBe(false); }); - it('should refetch document when pageType changes', async () => { - mockGetDocumentByFQN.mockResolvedValue(mockDocument); + it('should filter by pageType from cached doc without re-fetching', async () => { + const mockDocWithMultiplePages: Document = { + ...mockDocument, + data: { + pages: [ + { pageType: PageType.Table, tabs: [] }, + { pageType: PageType.Dashboard, tabs: [] }, + ], + navigation: mockNavigation, + }, + }; + mockGetDocumentByFQN.mockResolvedValue(mockDocWithMultiplePages); - const { rerender, waitForNextUpdate } = renderHook( - ({ pageType }) => useCustomPages(pageType), + const { result, rerender, waitForNextUpdate } = renderHook( + ({ pageType }: { pageType: PageType }) => useCustomPages(pageType), { initialProps: { pageType: PageType.Table }, + wrapper: createWrapper(queryClient), } ); await waitForNextUpdate(); expect(mockGetDocumentByFQN).toHaveBeenCalledTimes(1); + expect(result.current.customizedPage?.pageType).toBe(PageType.Table); rerender({ pageType: PageType.Dashboard }); - await waitForNextUpdate(); - - expect(mockGetDocumentByFQN).toHaveBeenCalledTimes(2); + // Changing pageType filters from the cached doc — no additional network request. + expect(mockGetDocumentByFQN).toHaveBeenCalledTimes(1); + expect(result.current.customizedPage?.pageType).toBe(PageType.Dashboard); }); it('should return updated results when selected persona changes', async () => { @@ -149,6 +181,7 @@ describe('useCustomPages', () => { initialProps: { selectedPersona: { fullyQualifiedName: 'test-persona' }, }, + wrapper: createWrapper(queryClient), } ); diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts index 97324011fa38..648ba44d3564 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts @@ -10,48 +10,39 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useCallback, useEffect, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { FQN_SEPARATOR_CHAR } from '../constants/char.constants'; import { EntityType } from '../enums/entity.enum'; import { Page, PageType } from '../generated/system/ui/page'; import { NavigationItem } from '../generated/system/ui/uiCustomization'; -import { getDocumentByFQN } from '../rest/DocStoreAPI'; +import { + docStoreQueryFn, + docStoreQueryKey, +} from '../rest/queries/docStoreQuery'; import { useApplicationStore } from './useApplicationStore'; export const useCustomPages = (pageType: PageType | 'Navigation') => { const { selectedPersona } = useApplicationStore(); - const [customizedPage, setCustomizedPage] = useState(null); - const [navigation, setNavigation] = useState(null); - const [isLoading, setIsLoading] = useState(true); + const fqn = selectedPersona?.fullyQualifiedName + ? `${EntityType.PERSONA}${FQN_SEPARATOR_CHAR}${selectedPersona.fullyQualifiedName}` + : null; - const fetchDocument = useCallback(async () => { - const pageFQN = `${EntityType.PERSONA}${FQN_SEPARATOR_CHAR}${selectedPersona?.fullyQualifiedName}`; - try { - const doc = await getDocumentByFQN(pageFQN); - setCustomizedPage( - doc.data?.pages?.find((p: Page | null) => p?.pageType === pageType) - ); - setNavigation(doc.data?.navigation); - } catch (error) { - // Need to reset Navigation to avoid showing old navigation items - setNavigation([]); - setCustomizedPage(null); - } finally { - setIsLoading(false); - } - }, [selectedPersona?.fullyQualifiedName, pageType]); - - useEffect(() => { - if (selectedPersona?.fullyQualifiedName) { - fetchDocument(); - } else { - setIsLoading(false); - } - }, [selectedPersona, pageType]); + const { data: doc, isPending, isError } = useQuery({ + queryKey: docStoreQueryKey(fqn ?? ''), + queryFn: docStoreQueryFn(fqn ?? ''), + enabled: !!fqn, + retry: false, + }); return { - customizedPage, - navigation, - isLoading, + customizedPage: + (doc?.data?.pages?.find( + (p: Page | null) => p?.pageType === pageType + ) as Page | undefined) ?? null, + // Reset to [] on error to clear stale navigation items, null when no persona selected. + navigation: isError + ? ([] as NavigationItem[]) + : ((doc?.data?.navigation ?? null) as NavigationItem[] | null), + isLoading: !!fqn && isPending, }; }; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx index f2d08f0f074e..eb419ee9cb95 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx @@ -11,6 +11,7 @@ * limitations under the License. */ +import { useQuery } from '@tanstack/react-query'; import { AxiosError } from 'axios'; import { compare } from 'fast-json-patch'; import { isEmpty } from 'lodash'; @@ -23,6 +24,7 @@ import withSuspenseFallback from '../../components/AppRouter/withSuspenseFallbac import DeferredWidget from '../../components/common/DeferredWidget/DeferredWidget.component'; import CustomiseLandingPageHeader from '../../components/MyData/CustomizableComponents/CustomiseLandingPageHeader/CustomiseLandingPageHeader'; import PageLayoutV1 from '../../components/PageLayoutV1/PageLayoutV1'; +import { FQN_SEPARATOR_CHAR } from '../../constants/char.constants'; import { LOGGED_IN_USER_STORAGE_KEY } from '../../constants/constants'; import { LandingPageWidgetKeys } from '../../enums/CustomizablePage.enum'; import { EntityType } from '../../enums/entity.enum'; @@ -37,7 +39,10 @@ import { AnnouncementEntity, getActiveAnnouncements, } from '../../rest/announcementsAPI'; -import { getDocumentByFQN } from '../../rest/DocStoreAPI'; +import { + docStoreQueryFn, + docStoreQueryKey, +} from '../../rest/queries/docStoreQuery'; import { updateUserDetail } from '../../rest/userAPI'; import { getConstrainedWidgetWidth } from '../../utils/CustomizableLandingPagePureUtils'; import customizeMyDataPageClassBase from '../../utils/CustomizeMyDataPageClassBase'; @@ -75,18 +80,52 @@ const MyDataPage = () => { useApplicationStore(); const { isWelcomeVisible } = useWelcomeStore(); - const [isLoading, setIsLoading] = useState(true); - const [layout, setLayout] = useState>( - getDefaultLandingPageLayout - ); - const [showWelcomeScreen, setShowWelcomeScreen] = useState(false); const [isAnnouncementLoading, setIsAnnouncementLoading] = useState(true); const [announcements, setAnnouncements] = useState([]); - const [personaPreferences, setPersonaPreferences] = useState< - PersonaPreferences[] - >([]); + + const personaFqn = selectedPersona?.fullyQualifiedName + ? `${EntityType.PERSONA}${FQN_SEPARATOR_CHAR}${selectedPersona.fullyQualifiedName}` + : null; + + const { data: docData, isPending: isDocPending } = useQuery({ + queryKey: docStoreQueryKey(personaFqn ?? ''), + queryFn: docStoreQueryFn(personaFqn ?? ''), + enabled: !!personaFqn, + retry: false, + }); + + const isLoading = !!personaFqn && isDocPending; + + const personaPreferences = useMemo( + () => docData?.data?.personPreferences ?? [], + [docData] + ); + + const layout = useMemo>(() => { + if (!docData || !selectedPersona) { + return getDefaultLandingPageLayout(); + } + const pageData = docData.data?.pages?.find( + (p: Page) => p.pageType === PageType.LandingPage + ) ?? { layout: [], pageType: PageType.LandingPage }; + const filteredLayout = (pageData.layout as WidgetConfig[]) + .filter( + (widget: WidgetConfig) => + !widget.i.startsWith(LandingPageWidgetKeys.CURATED_ASSETS) || + !isEmpty(widget.config) + ) + .map((widget: WidgetConfig) => ({ + ...widget, + w: getConstrainedWidgetWidth(widget.w), + h: 3, + })); + + return isEmpty(filteredLayout) + ? getDefaultLandingPageLayout() + : filteredLayout; + }, [docData, selectedPersona]); const storageData = localStorage.getItem(LOGGED_IN_USER_STORAGE_KEY); const loggedInUserName = useMemo(() => { @@ -115,49 +154,6 @@ const MyDataPage = () => { return userPersonaBackgroundColor ?? adminPersonaBackgroundColor; }, [userPersonaBackgroundColor, adminPersonaBackgroundColor]); - const fetchDocument = async () => { - setIsLoading(true); - - try { - if (selectedPersona) { - const pageFQN = `${EntityType.PERSONA}.${selectedPersona.fullyQualifiedName}`; - const docData = await getDocumentByFQN(pageFQN); - - setPersonaPreferences(docData.data?.personPreferences ?? []); - - const pageData = docData.data?.pages?.find( - (p: Page) => p.pageType === PageType.LandingPage - ) ?? { layout: [], pageType: PageType.LandingPage }; - - const filteredLayout = pageData.layout - .filter( - (widget: WidgetConfig) => - !widget.i.startsWith(LandingPageWidgetKeys.CURATED_ASSETS) || - !isEmpty(widget.config) - ) - .map((widget: WidgetConfig) => { - return { - ...widget, - w: getConstrainedWidgetWidth(widget.w), - h: 3, - }; - }); - - setLayout( - isEmpty(filteredLayout) - ? getDefaultLandingPageLayout() - : filteredLayout - ); - } else { - setLayout(getDefaultLandingPageLayout()); - } - } catch { - setLayout(getDefaultLandingPageLayout()); - } finally { - setIsLoading(false); - } - }; - const updateWelcomeScreen = (show: boolean) => { if (loggedInUserName) { const arr = storageData ? storageData.split(',') : []; @@ -169,10 +165,6 @@ const MyDataPage = () => { setShowWelcomeScreen(show); }; - useEffect(() => { - fetchDocument(); - }, [selectedPersona]); - useEffect(() => { updateWelcomeScreen(!usernameExistsInCookie && isWelcomeVisible); diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/queries/docStoreQuery.ts b/openmetadata-ui/src/main/resources/ui/src/rest/queries/docStoreQuery.ts new file mode 100644 index 000000000000..fa16654eb718 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/rest/queries/docStoreQuery.ts @@ -0,0 +1,31 @@ +/* + * 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 { Document } from '../../generated/entity/docStore/document'; +import { getDocumentByFQN } from '../DocStoreAPI'; + +/** + * Shared query plumbing for a single DocStore document by FQN. Any consumer + * that wants a cache-aware read — page customisation hooks, sidebar navigation, + * the My Data landing page — should go through {@link docStoreQueryKey} + + * {@link docStoreQueryFn} so they all hit the same normalised cache slot. + * + * React Query deduplicates in-flight requests: if multiple components mount + * simultaneously with the same FQN key (e.g. the sidebar navigation hook and + * the My Data page both reading `persona.X`), only one network request fires + * and both subscribers receive the result. + */ +export const docStoreQueryKey = (fqn: string) => ['docStore', fqn] as const; + +export const docStoreQueryFn = (fqn: string) => (): Promise => + getDocumentByFQN(fqn); From fc5245dd83603cb1804531fad3b64c9053f660b6 Mon Sep 17 00:00:00 2001 From: Rohit0301 Date: Mon, 10 Aug 2026 20:25:17 +0530 Subject: [PATCH 02/17] lint fix --- .../main/resources/ui/src/hooks/useCustomPages.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts index 648ba44d3564..afb8d0c05816 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts @@ -27,7 +27,11 @@ export const useCustomPages = (pageType: PageType | 'Navigation') => { ? `${EntityType.PERSONA}${FQN_SEPARATOR_CHAR}${selectedPersona.fullyQualifiedName}` : null; - const { data: doc, isPending, isError } = useQuery({ + const { + data: doc, + isPending, + isError, + } = useQuery({ queryKey: docStoreQueryKey(fqn ?? ''), queryFn: docStoreQueryFn(fqn ?? ''), enabled: !!fqn, @@ -36,9 +40,9 @@ export const useCustomPages = (pageType: PageType | 'Navigation') => { return { customizedPage: - (doc?.data?.pages?.find( - (p: Page | null) => p?.pageType === pageType - ) as Page | undefined) ?? null, + (doc?.data?.pages?.find((p: Page | null) => p?.pageType === pageType) as + | Page + | undefined) ?? null, // Reset to [] on error to clear stale navigation items, null when no persona selected. navigation: isError ? ([] as NavigationItem[]) From dcb5759345fd78e0534012a76cba7c20266566fa Mon Sep 17 00:00:00 2001 From: Rohit0301 Date: Mon, 10 Aug 2026 20:32:28 +0530 Subject: [PATCH 03/17] refactor(ui): centralize persona FQN construction in personaDocFqn helper Both useCustomPages and MyDataPage were independently building the persona docStore FQN string. Extract to personaDocFqn() in docStoreQuery.ts so the cache key derivation has a single definition and consumers can't silently drift apart. Co-Authored-By: Claude Sonnet 4.6 --- .../main/resources/ui/src/hooks/useCustomPages.ts | 7 ++----- .../src/pages/MyDataPage/MyDataPage.component.tsx | 6 ++---- .../resources/ui/src/rest/queries/docStoreQuery.ts | 13 +++++++++++++ 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts index afb8d0c05816..dd65b795e821 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts @@ -11,21 +11,18 @@ * limitations under the License. */ import { useQuery } from '@tanstack/react-query'; -import { FQN_SEPARATOR_CHAR } from '../constants/char.constants'; -import { EntityType } from '../enums/entity.enum'; import { Page, PageType } from '../generated/system/ui/page'; import { NavigationItem } from '../generated/system/ui/uiCustomization'; import { docStoreQueryFn, docStoreQueryKey, + personaDocFqn, } from '../rest/queries/docStoreQuery'; import { useApplicationStore } from './useApplicationStore'; export const useCustomPages = (pageType: PageType | 'Navigation') => { const { selectedPersona } = useApplicationStore(); - const fqn = selectedPersona?.fullyQualifiedName - ? `${EntityType.PERSONA}${FQN_SEPARATOR_CHAR}${selectedPersona.fullyQualifiedName}` - : null; + const fqn = personaDocFqn(selectedPersona); const { data: doc, diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx index eb419ee9cb95..99204adddb5d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx @@ -24,7 +24,6 @@ import withSuspenseFallback from '../../components/AppRouter/withSuspenseFallbac import DeferredWidget from '../../components/common/DeferredWidget/DeferredWidget.component'; import CustomiseLandingPageHeader from '../../components/MyData/CustomizableComponents/CustomiseLandingPageHeader/CustomiseLandingPageHeader'; import PageLayoutV1 from '../../components/PageLayoutV1/PageLayoutV1'; -import { FQN_SEPARATOR_CHAR } from '../../constants/char.constants'; import { LOGGED_IN_USER_STORAGE_KEY } from '../../constants/constants'; import { LandingPageWidgetKeys } from '../../enums/CustomizablePage.enum'; import { EntityType } from '../../enums/entity.enum'; @@ -42,6 +41,7 @@ import { import { docStoreQueryFn, docStoreQueryKey, + personaDocFqn, } from '../../rest/queries/docStoreQuery'; import { updateUserDetail } from '../../rest/userAPI'; import { getConstrainedWidgetWidth } from '../../utils/CustomizableLandingPagePureUtils'; @@ -85,9 +85,7 @@ const MyDataPage = () => { useState(true); const [announcements, setAnnouncements] = useState([]); - const personaFqn = selectedPersona?.fullyQualifiedName - ? `${EntityType.PERSONA}${FQN_SEPARATOR_CHAR}${selectedPersona.fullyQualifiedName}` - : null; + const personaFqn = personaDocFqn(selectedPersona); const { data: docData, isPending: isDocPending } = useQuery({ queryKey: docStoreQueryKey(personaFqn ?? ''), diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/queries/docStoreQuery.ts b/openmetadata-ui/src/main/resources/ui/src/rest/queries/docStoreQuery.ts index fa16654eb718..30d3b89edc92 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/queries/docStoreQuery.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/queries/docStoreQuery.ts @@ -11,6 +11,8 @@ * limitations under the License. */ +import { FQN_SEPARATOR_CHAR } from '../../constants/char.constants'; +import { EntityType } from '../../enums/entity.enum'; import { Document } from '../../generated/entity/docStore/document'; import { getDocumentByFQN } from '../DocStoreAPI'; @@ -29,3 +31,14 @@ export const docStoreQueryKey = (fqn: string) => ['docStore', fqn] as const; export const docStoreQueryFn = (fqn: string) => (): Promise => getDocumentByFQN(fqn); + +/** + * Derive the docStore FQN for a persona's UICustomization document. + * Returns null when the persona has no FQN (disabled query guard). + */ +export const personaDocFqn = (persona?: { + fullyQualifiedName?: string; +} | null): string | null => + persona?.fullyQualifiedName + ? `${EntityType.PERSONA}${FQN_SEPARATOR_CHAR}${persona.fullyQualifiedName}` + : null; From efbb78bfbd2600aad3eb5c09f0d385a79eaa1673 Mon Sep 17 00:00:00 2001 From: Rohit0301 Date: Mon, 10 Aug 2026 22:19:12 +0530 Subject: [PATCH 04/17] fix(test): restore 5 failing tests after React Query migration useCustomPages now uses useQuery internally, so components that call it require a QueryClientProvider ancestor. Fix each test with the appropriate strategy: - GlossaryV1, GlossaryDetails, LeftSidebar: add jest.mock for useCustomPages (same pattern used by 20+ other component tests) - MyDataPage: add QueryClientProvider wrapper via a renderMyDataPage() helper + fresh QueryClient per test to avoid cache pollution Co-Authored-By: Claude Sonnet 4.6 --- .../GlossaryDetails/GlossaryDetails.test.tsx | 8 ++++ .../components/Glossary/GlossaryV1.test.tsx | 8 ++++ .../MyData/LeftSidebar/LeftSidebar.test.tsx | 8 ++++ .../src/pages/MyDataPage/MyDataPage.test.tsx | 37 +++++++++++++------ 4 files changed, 49 insertions(+), 12 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.test.tsx index c815a44fbb4c..63498e38b26f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.test.tsx @@ -52,6 +52,14 @@ jest.mock('../../common/EntityDescription/Description', () => jest.fn().mockImplementation(() =>
Description
) ); +jest.mock('../../../hooks/useCustomPages', () => ({ + useCustomPages: jest.fn().mockReturnValue({ + customizedPage: null, + navigation: null, + isLoading: false, + }), +})); + const mockProps = { glossary: mockedGlossaries[0], glossaryTerms: [], diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.test.tsx index 8439fb85c040..0873e26b47a5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.test.tsx @@ -130,6 +130,14 @@ jest.mock( }) ); +jest.mock('../../hooks/useCustomPages', () => ({ + useCustomPages: jest.fn().mockReturnValue({ + customizedPage: null, + navigation: null, + isLoading: false, + }), +})); + const mockProps: GlossaryV1Props = { selectedData: mockedGlossaries[0], isGlossaryActive: true, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MyData/LeftSidebar/LeftSidebar.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/MyData/LeftSidebar/LeftSidebar.test.tsx index cbe646a3b3a8..9df044fb1959 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MyData/LeftSidebar/LeftSidebar.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/MyData/LeftSidebar/LeftSidebar.test.tsx @@ -21,6 +21,14 @@ jest.mock( }) ); +jest.mock('../../../hooks/useCustomPages', () => ({ + useCustomPages: jest.fn().mockReturnValue({ + customizedPage: null, + navigation: null, + isLoading: false, + }), +})); + describe('LeftSidebar', () => { it('renders sidebar links correctly', () => { render( diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.test.tsx index 410f13e0d4bb..656ec80e2c36 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.test.tsx @@ -10,6 +10,7 @@ * 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, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { PageType } from '../../generated/system/ui/page'; @@ -213,8 +214,20 @@ jest.mock( }) ); +let queryClient: QueryClient; + +const renderMyDataPage = () => + render( + + + + ); + describe('MyDataPage component', () => { beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); localStorage.setItem('loggedInUsers', mockUserData.name); mockSelectedPersona = { fullyQualifiedName: mockPersonaName, @@ -227,7 +240,7 @@ describe('MyDataPage component', () => { // Simulate no user is logged in condition localStorage.clear(); - render(); + renderMyDataPage(); expect(await screen.findByText('WelcomeScreen')).toBeInTheDocument(); }); @@ -236,7 +249,7 @@ describe('MyDataPage component', () => { // Simulate no user is logged in condition localStorage.clear(); - render(); + renderMyDataPage(); const welcomeScreen = await screen.findByText('WelcomeScreen'); @@ -249,7 +262,7 @@ describe('MyDataPage component', () => { }); it('MyDataPage should display skeleton while resolving the landing page layout', async () => { - render(); + renderMyDataPage(); expect(screen.getByText('MyDataPageSkeleton')).toBeInTheDocument(); expect(screen.queryByTestId('react-grid-layout')).not.toBeInTheDocument(); @@ -260,7 +273,7 @@ describe('MyDataPage component', () => { }); it('MyDataPage should render CustomiseLandingPageHeader component', async () => { - render(); + renderMyDataPage(); expect( screen.getByTestId('customise-landing-page-header') @@ -269,7 +282,7 @@ describe('MyDataPage component', () => { }); it('MyDataPage should display all the widgets in the config and the announcements widget if there are announcements', async () => { - render(); + renderMyDataPage(); expect( await screen.findByText('KnowledgePanel.ActivityFeed') @@ -295,7 +308,7 @@ describe('MyDataPage component', () => { data: [], }) ); - render(); + renderMyDataPage(); expect( await screen.findByText('KnowledgePanel.ActivityFeed') @@ -318,7 +331,7 @@ describe('MyDataPage component', () => { (getDocumentByFQN as jest.Mock).mockImplementationOnce(() => Promise.reject(new Error('API failure')) ); - render(); + renderMyDataPage(); expect( await screen.findByText('KnowledgePanel.ActivityFeed') @@ -344,7 +357,7 @@ describe('MyDataPage component', () => { it('MyDataPage should render default widgets when there is no selected persona', async () => { mockSelectedPersona = null; await act(async () => { - render(); + renderMyDataPage(); }); await screen.findByTestId('page-layout-v1'); @@ -367,7 +380,7 @@ describe('MyDataPage component', () => { describe('Component Structure', () => { it('should render the correct page structure with grid wrapper', async () => { await act(async () => { - render(); + renderMyDataPage(); }); expect(screen.getByTestId('page-layout-v1')).toBeInTheDocument(); @@ -380,7 +393,7 @@ describe('MyDataPage component', () => { it('should render CustomiseLandingPageHeader before the grid layout', async () => { await act(async () => { - render(); + renderMyDataPage(); }); const pageLayout = screen.getByTestId('page-layout-v1'); @@ -396,7 +409,7 @@ describe('MyDataPage component', () => { // Simulate no user is logged in condition localStorage.clear(); await act(async () => { - render(); + renderMyDataPage(); }); expect(screen.getByText('WelcomeScreen')).toBeInTheDocument(); @@ -407,7 +420,7 @@ describe('MyDataPage component', () => { it('should render the main content structure when not loading or showing welcome screen', async () => { await act(async () => { - render(); + renderMyDataPage(); }); // Verify main content elements are present From 960fc5c33ca416c928315e17b042ea30c3dc28bd Mon Sep 17 00:00:00 2001 From: Rohit0301 Date: Mon, 10 Aug 2026 22:20:27 +0530 Subject: [PATCH 05/17] lint fix --- .../ui/src/pages/MyDataPage/MyDataPage.component.tsx | 1 - .../main/resources/ui/src/rest/queries/docStoreQuery.ts | 8 +++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx index 99204adddb5d..adbb0b5b3c5a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx @@ -26,7 +26,6 @@ import CustomiseLandingPageHeader from '../../components/MyData/CustomizableComp import PageLayoutV1 from '../../components/PageLayoutV1/PageLayoutV1'; import { LOGGED_IN_USER_STORAGE_KEY } from '../../constants/constants'; import { LandingPageWidgetKeys } from '../../enums/CustomizablePage.enum'; -import { EntityType } from '../../enums/entity.enum'; import type { Page } from '../../generated/system/ui/page'; import { PageType } from '../../generated/system/ui/page'; import type { PersonaPreferences } from '../../generated/type/personaPreferences'; diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/queries/docStoreQuery.ts b/openmetadata-ui/src/main/resources/ui/src/rest/queries/docStoreQuery.ts index 30d3b89edc92..2d165ba75b1a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/queries/docStoreQuery.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/queries/docStoreQuery.ts @@ -36,9 +36,11 @@ export const docStoreQueryFn = (fqn: string) => (): Promise => * Derive the docStore FQN for a persona's UICustomization document. * Returns null when the persona has no FQN (disabled query guard). */ -export const personaDocFqn = (persona?: { - fullyQualifiedName?: string; -} | null): string | null => +export const personaDocFqn = ( + persona?: { + fullyQualifiedName?: string; + } | null +): string | null => persona?.fullyQualifiedName ? `${EntityType.PERSONA}${FQN_SEPARATOR_CHAR}${persona.fullyQualifiedName}` : null; From 3255aba2114fd964d0f443f3682d87a71919815a Mon Sep 17 00:00:00 2001 From: Rohit0301 Date: Wed, 12 Aug 2026 22:56:39 +0530 Subject: [PATCH 06/17] perf(ui): deduplicate docStore requests via React Query shared key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace useCustomPages('Navigation') in useSidebarItems with a direct useQuery(docStoreQueryKey) call so useSidebarItems and MyDataPage share the same React Query cache entry. On /my-data navigation both callers now get the persona doc from one network request instead of two. useCustomPages is left entirely unchanged — no behavioral impact on entity detail pages (Glossary, Table, Dashboard, etc.). Co-Authored-By: Claude Sonnet 4.6 --- .../MyData/LeftSidebar/LeftSidebar.test.tsx | 23 +- .../ui/src/hooks/useSidebarItems.test.ts | 352 +++++++++--------- .../resources/ui/src/hooks/useSidebarItems.ts | 26 +- 3 files changed, 208 insertions(+), 193 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MyData/LeftSidebar/LeftSidebar.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/MyData/LeftSidebar/LeftSidebar.test.tsx index 9df044fb1959..c92ae94ad652 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MyData/LeftSidebar/LeftSidebar.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/MyData/LeftSidebar/LeftSidebar.test.tsx @@ -10,6 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, screen } from '@testing-library/react'; import { BrowserRouter } from 'react-router-dom'; import LeftSidebar from './LeftSidebar.component'; @@ -21,20 +22,24 @@ jest.mock( }) ); -jest.mock('../../../hooks/useCustomPages', () => ({ - useCustomPages: jest.fn().mockReturnValue({ - customizedPage: null, - navigation: null, - isLoading: false, - }), +// No persona → personaDocFqn returns null → query disabled → no QueryClient needed for the fetch, +// but useQuery still requires a provider to be mounted. +jest.mock('../../../hooks/useApplicationStore', () => ({ + useApplicationStore: jest.fn().mockReturnValue({ selectedPersona: null }), })); describe('LeftSidebar', () => { it('renders sidebar links correctly', () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + render( - - - + + + + + ); expect(screen.getByTestId('image')).toBeInTheDocument(); diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useSidebarItems.test.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useSidebarItems.test.ts index 096edcb0980a..1ed136e59dfe 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useSidebarItems.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useSidebarItems.test.ts @@ -10,30 +10,36 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { renderHook } from '@testing-library/react-hooks'; -import { ReactNode } from 'react'; -import { LeftSidebarItem } from '../components/MyData/LeftSidebar/LeftSidebar.interface'; +import React, { ReactNode } from 'react'; import { useApplicationsProvider } from '../components/Settings/Applications/ApplicationsProvider/ApplicationsProvider'; import { AppPlugin } from '../components/Settings/Applications/plugins/AppPlugin'; import { NavigationItem } from '../generated/system/ui/uiCustomization'; +import { getDocumentByFQN } from '../rest/DocStoreAPI'; import { filterHiddenNavigationItems } from '../utils/CustomizaNavigation/CustomizeNavigation'; -import { useCustomPages } from './useCustomPages'; +import { useApplicationStore } from './useApplicationStore'; import { useSidebarItems } from './useSidebarItems'; -const mockUseCustomPages = useCustomPages as jest.MockedFunction< - typeof useCustomPages +const mockUseApplicationStore = useApplicationStore as jest.MockedFunction< + typeof useApplicationStore >; const mockUseApplicationsProvider = - useApplicationsProvider as jest.MockedFunction< - typeof useApplicationsProvider - >; + useApplicationsProvider as jest.MockedFunction; const mockFilterHiddenNavigationItems = filterHiddenNavigationItems as jest.MockedFunction< typeof filterHiddenNavigationItems >; +const mockGetDocumentByFQN = getDocumentByFQN as jest.MockedFunction< + typeof getDocumentByFQN +>; + +jest.mock('./useApplicationStore', () => ({ + useApplicationStore: jest.fn(), +})); -jest.mock('./useCustomPages', () => ({ - useCustomPages: jest.fn(), +jest.mock('../rest/DocStoreAPI', () => ({ + getDocumentByFQN: jest.fn(), })); jest.mock( @@ -47,99 +53,83 @@ jest.mock('../utils/CustomizaNavigation/CustomizeNavigation', () => ({ filterHiddenNavigationItems: jest.fn(), })); -describe('useSidebarItems', () => { - const mockNavigationItems: NavigationItem[] = [ - { - id: 'explore', - title: 'Explore', - isHidden: false, - pageId: 'test-page', - children: [ - { - id: 'tables', - pageId: 'test-page', - title: 'Tables', - isHidden: false, - }, - { - id: 'topics', - pageId: 'test-page-', - title: 'Topics', - isHidden: false, - }, - ], - }, - { - id: 'glossary', - pageId: 'test-page', - title: 'Glossary', - isHidden: false, - }, - ]; - - const mockSidebarItems: LeftSidebarItem[] = [ - { - key: 'explore', - title: 'Explore', - dataTestId: 'explore', - icon: () => ({} as ReactNode), - children: [ - { - key: 'tables', - dataTestId: 'tables', - title: 'Tables', - icon: () => ({} as ReactNode), - }, - { - key: 'topics', - dataTestId: 'topics', - title: 'Topics', - icon: () => ({} as ReactNode), - }, - ], - }, - { - key: 'glossary', - dataTestId: 'glossary', - title: 'Glossary', - icon: () => ({} as ReactNode), - }, - ]; - - const mockPlugins: AppPlugin[] = [ - { - name: 'test-plugin', - getSidebarActions: jest.fn(() => [ - { - key: 'plugin-item', - title: 'Plugin Item', - icon: {} as ReactNode, - index: 0, - }, - ]), - } as unknown as AppPlugin, - ]; +const mockPersona = { fullyQualifiedName: 'test-persona' }; + +const mockNavigationItems: NavigationItem[] = [ + { + id: 'explore', + title: 'Explore', + isHidden: false, + pageId: 'test-page', + children: [ + { id: 'tables', pageId: 'test-page', title: 'Tables', isHidden: false }, + { id: 'topics', pageId: 'test-page-', title: 'Topics', isHidden: false }, + ], + }, + { id: 'glossary', pageId: 'test-page', title: 'Glossary', isHidden: false }, +]; + +const mockDocument = { + name: 'test-persona', + fullyQualifiedName: 'persona.test-persona', + entityType: 'PERSONA', + data: { navigation: mockNavigationItems }, +}; + +const mockSidebarItems = [ + { + key: 'explore', + title: 'Explore', + dataTestId: 'explore', + icon: () => ({} as ReactNode), + }, +]; + +const mockPlugins: AppPlugin[] = [ + { + name: 'test-plugin', + getSidebarActions: jest.fn(() => [ + { key: 'plugin-item', title: 'Plugin Item', icon: {} as ReactNode, index: 0 }, + ]), + } as unknown as AppPlugin, +]; + +let queryClient: QueryClient; + +const createWrapper = () => { + const Wrapper = ({ children }: { children: ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + return Wrapper; +}; + +const mockDefaultProvider = () => + mockUseApplicationsProvider.mockReturnValue( + { plugins: [], applications: [], extensionRegistry: {} } as unknown as ReturnType< + typeof useApplicationsProvider + > + ); +describe('useSidebarItems', () => { beforeEach(() => { - jest.clearAllMocks(); - mockUseCustomPages.mockReturnValue({ - navigation: mockNavigationItems, - customizedPage: null, - isLoading: false, + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, }); - mockUseApplicationsProvider.mockReturnValue({ - plugins: [], - applications: [], - extensionRegistry: {} as never, - }); - mockFilterHiddenNavigationItems.mockReturnValue(mockSidebarItems); + jest.clearAllMocks(); + mockUseApplicationStore.mockReturnValue({ selectedPersona: mockPersona }); + mockGetDocumentByFQN.mockResolvedValue(mockDocument); + mockDefaultProvider(); + mockFilterHiddenNavigationItems.mockReturnValue(mockSidebarItems as never); }); - it('should return filtered sidebar items with navigation and empty plugins', () => { - const { result } = renderHook(() => useSidebarItems()); + it('should return filtered sidebar items with navigation and empty plugins', async () => { + const { result, waitForNextUpdate } = renderHook(() => useSidebarItems(), { + wrapper: createWrapper(), + }); + + await waitForNextUpdate(); - expect(mockUseCustomPages).toHaveBeenCalledWith('Navigation'); - expect(mockUseApplicationsProvider).toHaveBeenCalled(); + expect(mockGetDocumentByFQN).toHaveBeenCalledWith('persona.test-persona'); expect(mockFilterHiddenNavigationItems).toHaveBeenCalledWith( mockNavigationItems, [] @@ -147,161 +137,165 @@ describe('useSidebarItems', () => { expect(result.current).toEqual(mockSidebarItems); }); - it('should pass plugins to filterHiddenNavigationItems when plugins are available', () => { - mockUseApplicationsProvider.mockReturnValue({ - plugins: mockPlugins, - applications: [], - extensionRegistry: {} as never, + it('should pass plugins to filterHiddenNavigationItems when plugins are available', async () => { + mockUseApplicationsProvider.mockReturnValue( + { plugins: mockPlugins, applications: [], extensionRegistry: {} } as unknown as ReturnType< + typeof useApplicationsProvider + > + ); + + const { waitForNextUpdate } = renderHook(() => useSidebarItems(), { + wrapper: createWrapper(), }); - const { result } = renderHook(() => useSidebarItems()); + await waitForNextUpdate(); expect(mockFilterHiddenNavigationItems).toHaveBeenCalledWith( mockNavigationItems, mockPlugins ); - expect(result.current).toEqual(mockSidebarItems); }); - it('should handle null navigation items', () => { - mockUseCustomPages.mockReturnValue({ - navigation: null, - customizedPage: null, - isLoading: false, - }); + it('should handle null navigation when no persona is selected', () => { + mockUseApplicationStore.mockReturnValue({ selectedPersona: null }); - const { result } = renderHook(() => useSidebarItems()); + renderHook(() => useSidebarItems(), { wrapper: createWrapper() }); + expect(mockGetDocumentByFQN).not.toHaveBeenCalled(); expect(mockFilterHiddenNavigationItems).toHaveBeenCalledWith(null, []); - expect(result.current).toEqual(mockSidebarItems); }); it('should handle undefined plugins', () => { - mockUseApplicationsProvider.mockReturnValue({ - plugins: undefined as unknown as AppPlugin[], - applications: [], - extensionRegistry: {} as never, - }); + mockUseApplicationStore.mockReturnValue({ selectedPersona: null }); + mockUseApplicationsProvider.mockReturnValue( + { plugins: undefined, applications: [], extensionRegistry: {} } as unknown as ReturnType< + typeof useApplicationsProvider + > + ); - const { result } = renderHook(() => useSidebarItems()); + renderHook(() => useSidebarItems(), { wrapper: createWrapper() }); - expect(mockFilterHiddenNavigationItems).toHaveBeenCalledWith( - mockNavigationItems, - [] - ); - expect(result.current).toEqual(mockSidebarItems); + expect(mockFilterHiddenNavigationItems).toHaveBeenCalledWith(null, []); }); - it('should recalculate sidebar items when navigation changes', () => { - const { rerender } = renderHook(() => useSidebarItems()); + it('should recalculate sidebar items when persona changes', async () => { + const newPersona = { fullyQualifiedName: 'new-persona' }; + const newNavigation: NavigationItem[] = [ + { id: 'settings', pageId: 'settings-page', title: 'Settings', isHidden: false }, + ]; + mockGetDocumentByFQN + .mockResolvedValueOnce(mockDocument) + .mockResolvedValueOnce({ ...mockDocument, data: { navigation: newNavigation } }); + + const { rerender, waitForNextUpdate } = renderHook( + () => useSidebarItems(), + { wrapper: createWrapper() } + ); - expect(mockFilterHiddenNavigationItems).toHaveBeenCalledTimes(1); + await waitForNextUpdate(); - const newNavigationItems: NavigationItem[] = [ - { - id: 'settings', - pageId: 'settings-page', - title: 'Settings', - isHidden: false, - }, - ]; - mockUseCustomPages.mockReturnValue({ - navigation: newNavigationItems, - customizedPage: null, - isLoading: false, - }); + expect(mockFilterHiddenNavigationItems).toHaveBeenCalledWith( + mockNavigationItems, + [] + ); + mockUseApplicationStore.mockReturnValue({ selectedPersona: newPersona }); rerender(); - expect(mockFilterHiddenNavigationItems).toHaveBeenCalledTimes(2); + await waitForNextUpdate(); + + expect(mockGetDocumentByFQN).toHaveBeenCalledWith('persona.new-persona'); expect(mockFilterHiddenNavigationItems).toHaveBeenLastCalledWith( - newNavigationItems, + newNavigation, [] ); }); - it('should recalculate sidebar items when plugins change', () => { - const { rerender } = renderHook(() => useSidebarItems()); + it('should recalculate sidebar items when plugins change', async () => { + const { rerender, waitForNextUpdate } = renderHook( + () => useSidebarItems(), + { wrapper: createWrapper() } + ); - expect(mockFilterHiddenNavigationItems).toHaveBeenCalledTimes(1); + await waitForNextUpdate(); - mockUseApplicationsProvider.mockReturnValue({ - plugins: mockPlugins, - applications: [], - extensionRegistry: {} as never, - }); + expect(mockFilterHiddenNavigationItems).toHaveBeenCalledTimes(1); + mockUseApplicationsProvider.mockReturnValue( + { plugins: mockPlugins, applications: [], extensionRegistry: {} } as unknown as ReturnType< + typeof useApplicationsProvider + > + ); rerender(); - expect(mockFilterHiddenNavigationItems).toHaveBeenCalledTimes(2); expect(mockFilterHiddenNavigationItems).toHaveBeenLastCalledWith( mockNavigationItems, mockPlugins ); }); - it('should memoize result when navigation and plugins do not change', () => { - const { result, rerender } = renderHook(() => useSidebarItems()); + it('should memoize result when navigation and plugins do not change', async () => { + const { result, rerender, waitForNextUpdate } = renderHook( + () => useSidebarItems(), + { wrapper: createWrapper() } + ); + + await waitForNextUpdate(); const firstResult = result.current; rerender(); expect(result.current).toBe(firstResult); - expect(mockFilterHiddenNavigationItems).toHaveBeenCalledTimes(1); + expect(mockGetDocumentByFQN).toHaveBeenCalledTimes(1); }); - it('should handle empty navigation array', () => { - mockUseCustomPages.mockReturnValue({ - navigation: [], - customizedPage: null, - isLoading: false, + it('should handle empty navigation array', async () => { + mockGetDocumentByFQN.mockResolvedValue({ + ...mockDocument, + data: { navigation: [] }, }); - const { result } = renderHook(() => useSidebarItems()); + const { waitForNextUpdate } = renderHook(() => useSidebarItems(), { + wrapper: createWrapper(), + }); + + await waitForNextUpdate(); expect(mockFilterHiddenNavigationItems).toHaveBeenCalledWith([], []); - expect(result.current).toEqual(mockSidebarItems); }); - it('should handle multiple plugins', () => { + it('should handle multiple plugins', async () => { const multiplePlugins: AppPlugin[] = [ { name: 'plugin-1', getSidebarActions: jest.fn(() => [ - { - key: 'plugin-1-item', - title: 'Plugin 1 Item', - icon: {} as ReactNode, - index: 0, - }, + { key: 'plugin-1-item', title: 'Plugin 1 Item', icon: {} as ReactNode, index: 0 }, ]), } as unknown as AppPlugin, { name: 'plugin-2', getSidebarActions: jest.fn(() => [ - { - key: 'plugin-2-item', - title: 'Plugin 2 Item', - icon: {} as ReactNode, - index: 1, - }, + { key: 'plugin-2-item', title: 'Plugin 2 Item', icon: {} as ReactNode, index: 1 }, ]), } as unknown as AppPlugin, ]; - mockUseApplicationsProvider.mockReturnValue({ - plugins: multiplePlugins, - applications: [], - extensionRegistry: {} as never, + mockUseApplicationsProvider.mockReturnValue( + { plugins: multiplePlugins, applications: [], extensionRegistry: {} } as unknown as ReturnType< + typeof useApplicationsProvider + > + ); + + const { waitForNextUpdate } = renderHook(() => useSidebarItems(), { + wrapper: createWrapper(), }); - const { result } = renderHook(() => useSidebarItems()); + await waitForNextUpdate(); expect(mockFilterHiddenNavigationItems).toHaveBeenCalledWith( mockNavigationItems, multiplePlugins ); - expect(result.current).toEqual(mockSidebarItems); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useSidebarItems.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useSidebarItems.ts index 1827b3640e8d..290035b51ce5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useSidebarItems.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useSidebarItems.ts @@ -10,19 +10,35 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { useQuery } from '@tanstack/react-query'; import { useMemo } from 'react'; import { useApplicationsProvider } from '../components/Settings/Applications/ApplicationsProvider/ApplicationsProvider'; +import { NavigationItem } from '../generated/system/ui/uiCustomization'; +import { + docStoreQueryFn, + docStoreQueryKey, + personaDocFqn, +} from '../rest/queries/docStoreQuery'; import { filterHiddenNavigationItems } from '../utils/CustomizaNavigation/CustomizeNavigation'; -import { useCustomPages } from './useCustomPages'; +import { useApplicationStore } from './useApplicationStore'; export const useSidebarItems = () => { - const { navigation } = useCustomPages('Navigation'); + const { selectedPersona } = useApplicationStore(); + const fqn = personaDocFqn(selectedPersona); + + const { data: doc } = useQuery({ + queryKey: docStoreQueryKey(fqn ?? ''), + queryFn: docStoreQueryFn(fqn ?? ''), + enabled: !!fqn, + retry: false, + }); + + const navigation = + (doc?.data?.navigation as NavigationItem[] | undefined) ?? null; const { plugins = [] } = useApplicationsProvider(); - const sideBarItems = useMemo( + return useMemo( () => filterHiddenNavigationItems(navigation, plugins), [navigation, plugins] ); - - return sideBarItems; }; From 3d283ca2b60f8daa5d88c0149c42a317c2c70fe3 Mon Sep 17 00:00:00 2001 From: Rohit0301 Date: Wed, 12 Aug 2026 22:58:42 +0530 Subject: [PATCH 07/17] revert: restore useCustomPages and Glossary tests to original state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useCustomPages migration to React Query is no longer needed — the duplicate request problem is solved by useSidebarItems and MyDataPage sharing the same docStoreQueryKey. Reverting useCustomPages avoids the Playwright regressions on Glossary and other entity detail pages. Co-Authored-By: Claude Sonnet 4.6 --- .../GlossaryDetails/GlossaryDetails.test.tsx | 8 --- .../components/Glossary/GlossaryV1.test.tsx | 8 --- .../ui/src/hooks/useCustomPages.test.ts | 57 ++++-------------- .../resources/ui/src/hooks/useCustomPages.ts | 60 +++++++++++-------- 4 files changed, 46 insertions(+), 87 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.test.tsx index 63498e38b26f..c815a44fbb4c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.test.tsx @@ -52,14 +52,6 @@ jest.mock('../../common/EntityDescription/Description', () => jest.fn().mockImplementation(() =>
Description
) ); -jest.mock('../../../hooks/useCustomPages', () => ({ - useCustomPages: jest.fn().mockReturnValue({ - customizedPage: null, - navigation: null, - isLoading: false, - }), -})); - const mockProps = { glossary: mockedGlossaries[0], glossaryTerms: [], diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.test.tsx index 0873e26b47a5..8439fb85c040 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.test.tsx @@ -130,14 +130,6 @@ jest.mock( }) ); -jest.mock('../../hooks/useCustomPages', () => ({ - useCustomPages: jest.fn().mockReturnValue({ - customizedPage: null, - navigation: null, - isLoading: false, - }), -})); - const mockProps: GlossaryV1Props = { selectedData: mockedGlossaries[0], isGlossaryActive: true, diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.test.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.test.ts index 7d20db84416e..3328bc2aec13 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.test.ts @@ -10,9 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { renderHook } from '@testing-library/react-hooks'; -import React from 'react'; import { Document } from '../generated/entity/docStore/document'; import { PageType } from '../generated/system/ui/page'; import { getDocumentByFQN } from '../rest/DocStoreAPI'; @@ -34,16 +32,7 @@ jest.mock('../rest/DocStoreAPI', () => ({ getDocumentByFQN: jest.fn(), })); -const createWrapper = (queryClient: QueryClient) => { - const Wrapper = ({ children }: { children: React.ReactNode }) => - React.createElement(QueryClientProvider, { client: queryClient }, children); - - return Wrapper; -}; - describe('useCustomPages', () => { - let queryClient: QueryClient; - const mockSelectedPersona = { fullyQualifiedName: 'test-persona', }; @@ -72,11 +61,6 @@ describe('useCustomPages', () => { beforeEach(() => { jest.clearAllMocks(); - queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - }, - }); mockUseApplicationStore.mockReturnValue({ selectedPersona: mockSelectedPersona, }); @@ -85,9 +69,8 @@ describe('useCustomPages', () => { it('should fetch and return customized page and navigation when persona is selected', async () => { mockGetDocumentByFQN.mockResolvedValue(mockDocument); - const { result, waitForNextUpdate } = renderHook( - () => useCustomPages(PageType.Table), - { wrapper: createWrapper(queryClient) } + const { result, waitForNextUpdate } = renderHook(() => + useCustomPages(PageType.Table) ); expect(result.current.isLoading).toBe(true); @@ -103,9 +86,8 @@ describe('useCustomPages', () => { it('should handle error when fetching document fails', async () => { mockGetDocumentByFQN.mockRejectedValue(new Error('API Error')); - const { result, waitForNextUpdate } = renderHook( - () => useCustomPages(PageType.Table), - { wrapper: createWrapper(queryClient) } + const { result, waitForNextUpdate } = renderHook(() => + useCustomPages(PageType.Table) ); expect(result.current.isLoading).toBe(true); @@ -123,9 +105,7 @@ describe('useCustomPages', () => { selectedPersona: null, }); - const { result } = renderHook(() => useCustomPages(PageType.Table), { - wrapper: createWrapper(queryClient), - }); + const { result } = renderHook(() => useCustomPages(PageType.Table)); expect(mockGetDocumentByFQN).not.toHaveBeenCalled(); expect(result.current.customizedPage).toBeNull(); @@ -133,37 +113,25 @@ describe('useCustomPages', () => { expect(result.current.isLoading).toBe(false); }); - it('should filter by pageType from cached doc without re-fetching', async () => { - const mockDocWithMultiplePages: Document = { - ...mockDocument, - data: { - pages: [ - { pageType: PageType.Table, tabs: [] }, - { pageType: PageType.Dashboard, tabs: [] }, - ], - navigation: mockNavigation, - }, - }; - mockGetDocumentByFQN.mockResolvedValue(mockDocWithMultiplePages); + it('should refetch document when pageType changes', async () => { + mockGetDocumentByFQN.mockResolvedValue(mockDocument); - const { result, rerender, waitForNextUpdate } = renderHook( - ({ pageType }: { pageType: PageType }) => useCustomPages(pageType), + const { rerender, waitForNextUpdate } = renderHook( + ({ pageType }) => useCustomPages(pageType), { initialProps: { pageType: PageType.Table }, - wrapper: createWrapper(queryClient), } ); await waitForNextUpdate(); expect(mockGetDocumentByFQN).toHaveBeenCalledTimes(1); - expect(result.current.customizedPage?.pageType).toBe(PageType.Table); rerender({ pageType: PageType.Dashboard }); - // Changing pageType filters from the cached doc — no additional network request. - expect(mockGetDocumentByFQN).toHaveBeenCalledTimes(1); - expect(result.current.customizedPage?.pageType).toBe(PageType.Dashboard); + await waitForNextUpdate(); + + expect(mockGetDocumentByFQN).toHaveBeenCalledTimes(2); }); it('should return updated results when selected persona changes', async () => { @@ -181,7 +149,6 @@ describe('useCustomPages', () => { initialProps: { selectedPersona: { fullyQualifiedName: 'test-persona' }, }, - wrapper: createWrapper(queryClient), } ); diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts index dd65b795e821..97324011fa38 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts @@ -10,40 +10,48 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useQuery } from '@tanstack/react-query'; +import { useCallback, useEffect, useState } from 'react'; +import { FQN_SEPARATOR_CHAR } from '../constants/char.constants'; +import { EntityType } from '../enums/entity.enum'; import { Page, PageType } from '../generated/system/ui/page'; import { NavigationItem } from '../generated/system/ui/uiCustomization'; -import { - docStoreQueryFn, - docStoreQueryKey, - personaDocFqn, -} from '../rest/queries/docStoreQuery'; +import { getDocumentByFQN } from '../rest/DocStoreAPI'; import { useApplicationStore } from './useApplicationStore'; export const useCustomPages = (pageType: PageType | 'Navigation') => { const { selectedPersona } = useApplicationStore(); - const fqn = personaDocFqn(selectedPersona); + const [customizedPage, setCustomizedPage] = useState(null); + const [navigation, setNavigation] = useState(null); + const [isLoading, setIsLoading] = useState(true); - const { - data: doc, - isPending, - isError, - } = useQuery({ - queryKey: docStoreQueryKey(fqn ?? ''), - queryFn: docStoreQueryFn(fqn ?? ''), - enabled: !!fqn, - retry: false, - }); + const fetchDocument = useCallback(async () => { + const pageFQN = `${EntityType.PERSONA}${FQN_SEPARATOR_CHAR}${selectedPersona?.fullyQualifiedName}`; + try { + const doc = await getDocumentByFQN(pageFQN); + setCustomizedPage( + doc.data?.pages?.find((p: Page | null) => p?.pageType === pageType) + ); + setNavigation(doc.data?.navigation); + } catch (error) { + // Need to reset Navigation to avoid showing old navigation items + setNavigation([]); + setCustomizedPage(null); + } finally { + setIsLoading(false); + } + }, [selectedPersona?.fullyQualifiedName, pageType]); + + useEffect(() => { + if (selectedPersona?.fullyQualifiedName) { + fetchDocument(); + } else { + setIsLoading(false); + } + }, [selectedPersona, pageType]); return { - customizedPage: - (doc?.data?.pages?.find((p: Page | null) => p?.pageType === pageType) as - | Page - | undefined) ?? null, - // Reset to [] on error to clear stale navigation items, null when no persona selected. - navigation: isError - ? ([] as NavigationItem[]) - : ((doc?.data?.navigation ?? null) as NavigationItem[] | null), - isLoading: !!fqn && isPending, + customizedPage, + navigation, + isLoading, }; }; From 98d210425922872a1aeb21e5ead7b4b423729aee Mon Sep 17 00:00:00 2001 From: Rohit0301 Date: Wed, 12 Aug 2026 23:12:31 +0530 Subject: [PATCH 08/17] perf(ui): deduplicate docStore requests via React Query in useCustomPages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate useCustomPages to useQuery(docStoreQueryKey) so all concurrent callers — useSidebarItems, MyDataPage, and every entity detail page — share a single React Query cache entry per persona FQN. Multiple mounts with the same FQN fire exactly one network request instead of N. useSidebarItems keeps using useCustomPages('Navigation') to preserve the abstraction layer; the deduplication is transparent to all consumers. Test impact: add QueryClientProvider wrapper to useCustomPages.test.ts; add jest.mock for useCustomPages in GlossaryV1, GlossaryDetails, and LeftSidebar component tests (same pattern used by 20+ other tests). Co-Authored-By: Claude Sonnet 4.6 --- .../GlossaryDetails/GlossaryDetails.test.tsx | 8 + .../components/Glossary/GlossaryV1.test.tsx | 8 + .../MyData/LeftSidebar/LeftSidebar.test.tsx | 23 +- .../ui/src/hooks/useCustomPages.test.ts | 56 ++- .../resources/ui/src/hooks/useCustomPages.ts | 60 ++- .../ui/src/hooks/useSidebarItems.test.ts | 352 +++++++++--------- .../resources/ui/src/hooks/useSidebarItems.ts | 26 +- 7 files changed, 278 insertions(+), 255 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.test.tsx index c815a44fbb4c..63498e38b26f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.test.tsx @@ -52,6 +52,14 @@ jest.mock('../../common/EntityDescription/Description', () => jest.fn().mockImplementation(() =>
Description
) ); +jest.mock('../../../hooks/useCustomPages', () => ({ + useCustomPages: jest.fn().mockReturnValue({ + customizedPage: null, + navigation: null, + isLoading: false, + }), +})); + const mockProps = { glossary: mockedGlossaries[0], glossaryTerms: [], diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.test.tsx index 8439fb85c040..0873e26b47a5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.test.tsx @@ -130,6 +130,14 @@ jest.mock( }) ); +jest.mock('../../hooks/useCustomPages', () => ({ + useCustomPages: jest.fn().mockReturnValue({ + customizedPage: null, + navigation: null, + isLoading: false, + }), +})); + const mockProps: GlossaryV1Props = { selectedData: mockedGlossaries[0], isGlossaryActive: true, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MyData/LeftSidebar/LeftSidebar.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/MyData/LeftSidebar/LeftSidebar.test.tsx index c92ae94ad652..9df044fb1959 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MyData/LeftSidebar/LeftSidebar.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/MyData/LeftSidebar/LeftSidebar.test.tsx @@ -10,7 +10,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, screen } from '@testing-library/react'; import { BrowserRouter } from 'react-router-dom'; import LeftSidebar from './LeftSidebar.component'; @@ -22,24 +21,20 @@ jest.mock( }) ); -// No persona → personaDocFqn returns null → query disabled → no QueryClient needed for the fetch, -// but useQuery still requires a provider to be mounted. -jest.mock('../../../hooks/useApplicationStore', () => ({ - useApplicationStore: jest.fn().mockReturnValue({ selectedPersona: null }), +jest.mock('../../../hooks/useCustomPages', () => ({ + useCustomPages: jest.fn().mockReturnValue({ + customizedPage: null, + navigation: null, + isLoading: false, + }), })); describe('LeftSidebar', () => { it('renders sidebar links correctly', () => { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - render( - - - - - + + + ); expect(screen.getByTestId('image')).toBeInTheDocument(); diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.test.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.test.ts index 3328bc2aec13..d433a5506f71 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.test.ts @@ -10,7 +10,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { renderHook } from '@testing-library/react-hooks'; +import React from 'react'; import { Document } from '../generated/entity/docStore/document'; import { PageType } from '../generated/system/ui/page'; import { getDocumentByFQN } from '../rest/DocStoreAPI'; @@ -32,7 +34,16 @@ jest.mock('../rest/DocStoreAPI', () => ({ getDocumentByFQN: jest.fn(), })); +const createWrapper = (queryClient: QueryClient) => { + const Wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + return Wrapper; +}; + describe('useCustomPages', () => { + let queryClient: QueryClient; + const mockSelectedPersona = { fullyQualifiedName: 'test-persona', }; @@ -60,6 +71,9 @@ describe('useCustomPages', () => { }; beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); jest.clearAllMocks(); mockUseApplicationStore.mockReturnValue({ selectedPersona: mockSelectedPersona, @@ -69,8 +83,9 @@ describe('useCustomPages', () => { it('should fetch and return customized page and navigation when persona is selected', async () => { mockGetDocumentByFQN.mockResolvedValue(mockDocument); - const { result, waitForNextUpdate } = renderHook(() => - useCustomPages(PageType.Table) + const { result, waitForNextUpdate } = renderHook( + () => useCustomPages(PageType.Table), + { wrapper: createWrapper(queryClient) } ); expect(result.current.isLoading).toBe(true); @@ -86,8 +101,9 @@ describe('useCustomPages', () => { it('should handle error when fetching document fails', async () => { mockGetDocumentByFQN.mockRejectedValue(new Error('API Error')); - const { result, waitForNextUpdate } = renderHook(() => - useCustomPages(PageType.Table) + const { result, waitForNextUpdate } = renderHook( + () => useCustomPages(PageType.Table), + { wrapper: createWrapper(queryClient) } ); expect(result.current.isLoading).toBe(true); @@ -105,7 +121,9 @@ describe('useCustomPages', () => { selectedPersona: null, }); - const { result } = renderHook(() => useCustomPages(PageType.Table)); + const { result } = renderHook(() => useCustomPages(PageType.Table), { + wrapper: createWrapper(queryClient), + }); expect(mockGetDocumentByFQN).not.toHaveBeenCalled(); expect(result.current.customizedPage).toBeNull(); @@ -113,25 +131,37 @@ describe('useCustomPages', () => { expect(result.current.isLoading).toBe(false); }); - it('should refetch document when pageType changes', async () => { - mockGetDocumentByFQN.mockResolvedValue(mockDocument); + it('should filter by pageType from cached doc without re-fetching', async () => { + const mockDocWithMultiplePages: Document = { + ...mockDocument, + data: { + pages: [ + { pageType: PageType.Table, tabs: [] }, + { pageType: PageType.Dashboard, tabs: [] }, + ], + navigation: mockNavigation, + }, + }; + mockGetDocumentByFQN.mockResolvedValue(mockDocWithMultiplePages); - const { rerender, waitForNextUpdate } = renderHook( - ({ pageType }) => useCustomPages(pageType), + const { result, rerender, waitForNextUpdate } = renderHook( + ({ pageType }: { pageType: PageType }) => useCustomPages(pageType), { initialProps: { pageType: PageType.Table }, + wrapper: createWrapper(queryClient), } ); await waitForNextUpdate(); + // Changing pageType filters locally from the cached doc — no extra network request. expect(mockGetDocumentByFQN).toHaveBeenCalledTimes(1); + expect(result.current.customizedPage?.pageType).toBe(PageType.Table); rerender({ pageType: PageType.Dashboard }); - await waitForNextUpdate(); - - expect(mockGetDocumentByFQN).toHaveBeenCalledTimes(2); + expect(mockGetDocumentByFQN).toHaveBeenCalledTimes(1); + expect(result.current.customizedPage?.pageType).toBe(PageType.Dashboard); }); it('should return updated results when selected persona changes', async () => { @@ -149,6 +179,7 @@ describe('useCustomPages', () => { initialProps: { selectedPersona: { fullyQualifiedName: 'test-persona' }, }, + wrapper: createWrapper(queryClient), } ); @@ -158,7 +189,6 @@ describe('useCustomPages', () => { expect(result.current.customizedPage).toEqual(mockDocument.data.pages[0]); expect(result.current.navigation).toEqual(mockDocument.data.navigation); - // Change the selected persona const newPersona = { fullyQualifiedName: 'new-persona' }; mockGetDocumentByFQN.mockResolvedValueOnce({ entityType: 'PERSONA', diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts index 97324011fa38..3489f958f271 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts @@ -10,48 +10,40 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useCallback, useEffect, useState } from 'react'; -import { FQN_SEPARATOR_CHAR } from '../constants/char.constants'; -import { EntityType } from '../enums/entity.enum'; +import { useQuery } from '@tanstack/react-query'; import { Page, PageType } from '../generated/system/ui/page'; import { NavigationItem } from '../generated/system/ui/uiCustomization'; -import { getDocumentByFQN } from '../rest/DocStoreAPI'; +import { + docStoreQueryFn, + docStoreQueryKey, + personaDocFqn, +} from '../rest/queries/docStoreQuery'; import { useApplicationStore } from './useApplicationStore'; export const useCustomPages = (pageType: PageType | 'Navigation') => { const { selectedPersona } = useApplicationStore(); - const [customizedPage, setCustomizedPage] = useState(null); - const [navigation, setNavigation] = useState(null); - const [isLoading, setIsLoading] = useState(true); + const fqn = personaDocFqn(selectedPersona); - const fetchDocument = useCallback(async () => { - const pageFQN = `${EntityType.PERSONA}${FQN_SEPARATOR_CHAR}${selectedPersona?.fullyQualifiedName}`; - try { - const doc = await getDocumentByFQN(pageFQN); - setCustomizedPage( - doc.data?.pages?.find((p: Page | null) => p?.pageType === pageType) - ); - setNavigation(doc.data?.navigation); - } catch (error) { - // Need to reset Navigation to avoid showing old navigation items - setNavigation([]); - setCustomizedPage(null); - } finally { - setIsLoading(false); - } - }, [selectedPersona?.fullyQualifiedName, pageType]); - - useEffect(() => { - if (selectedPersona?.fullyQualifiedName) { - fetchDocument(); - } else { - setIsLoading(false); - } - }, [selectedPersona, pageType]); + const { + data: doc, + isPending, + isError, + } = useQuery({ + queryKey: docStoreQueryKey(fqn ?? ''), + queryFn: docStoreQueryFn(fqn ?? ''), + enabled: !!fqn, + retry: false, + }); return { - customizedPage, - navigation, - isLoading, + customizedPage: + (doc?.data?.pages?.find( + (p: Page | null) => p?.pageType === pageType + ) as Page | undefined) ?? null, + // Reset to [] on error to clear stale navigation items, null when no persona selected. + navigation: isError + ? ([] as NavigationItem[]) + : ((doc?.data?.navigation ?? null) as NavigationItem[] | null), + isLoading: !!fqn && isPending, }; }; diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useSidebarItems.test.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useSidebarItems.test.ts index 1ed136e59dfe..096edcb0980a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useSidebarItems.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useSidebarItems.test.ts @@ -10,36 +10,30 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { renderHook } from '@testing-library/react-hooks'; -import React, { ReactNode } from 'react'; +import { ReactNode } from 'react'; +import { LeftSidebarItem } from '../components/MyData/LeftSidebar/LeftSidebar.interface'; import { useApplicationsProvider } from '../components/Settings/Applications/ApplicationsProvider/ApplicationsProvider'; import { AppPlugin } from '../components/Settings/Applications/plugins/AppPlugin'; import { NavigationItem } from '../generated/system/ui/uiCustomization'; -import { getDocumentByFQN } from '../rest/DocStoreAPI'; import { filterHiddenNavigationItems } from '../utils/CustomizaNavigation/CustomizeNavigation'; -import { useApplicationStore } from './useApplicationStore'; +import { useCustomPages } from './useCustomPages'; import { useSidebarItems } from './useSidebarItems'; -const mockUseApplicationStore = useApplicationStore as jest.MockedFunction< - typeof useApplicationStore +const mockUseCustomPages = useCustomPages as jest.MockedFunction< + typeof useCustomPages >; const mockUseApplicationsProvider = - useApplicationsProvider as jest.MockedFunction; + useApplicationsProvider as jest.MockedFunction< + typeof useApplicationsProvider + >; const mockFilterHiddenNavigationItems = filterHiddenNavigationItems as jest.MockedFunction< typeof filterHiddenNavigationItems >; -const mockGetDocumentByFQN = getDocumentByFQN as jest.MockedFunction< - typeof getDocumentByFQN ->; - -jest.mock('./useApplicationStore', () => ({ - useApplicationStore: jest.fn(), -})); -jest.mock('../rest/DocStoreAPI', () => ({ - getDocumentByFQN: jest.fn(), +jest.mock('./useCustomPages', () => ({ + useCustomPages: jest.fn(), })); jest.mock( @@ -53,83 +47,99 @@ jest.mock('../utils/CustomizaNavigation/CustomizeNavigation', () => ({ filterHiddenNavigationItems: jest.fn(), })); -const mockPersona = { fullyQualifiedName: 'test-persona' }; - -const mockNavigationItems: NavigationItem[] = [ - { - id: 'explore', - title: 'Explore', - isHidden: false, - pageId: 'test-page', - children: [ - { id: 'tables', pageId: 'test-page', title: 'Tables', isHidden: false }, - { id: 'topics', pageId: 'test-page-', title: 'Topics', isHidden: false }, - ], - }, - { id: 'glossary', pageId: 'test-page', title: 'Glossary', isHidden: false }, -]; - -const mockDocument = { - name: 'test-persona', - fullyQualifiedName: 'persona.test-persona', - entityType: 'PERSONA', - data: { navigation: mockNavigationItems }, -}; - -const mockSidebarItems = [ - { - key: 'explore', - title: 'Explore', - dataTestId: 'explore', - icon: () => ({} as ReactNode), - }, -]; - -const mockPlugins: AppPlugin[] = [ - { - name: 'test-plugin', - getSidebarActions: jest.fn(() => [ - { key: 'plugin-item', title: 'Plugin Item', icon: {} as ReactNode, index: 0 }, - ]), - } as unknown as AppPlugin, -]; - -let queryClient: QueryClient; - -const createWrapper = () => { - const Wrapper = ({ children }: { children: ReactNode }) => - React.createElement(QueryClientProvider, { client: queryClient }, children); - - return Wrapper; -}; - -const mockDefaultProvider = () => - mockUseApplicationsProvider.mockReturnValue( - { plugins: [], applications: [], extensionRegistry: {} } as unknown as ReturnType< - typeof useApplicationsProvider - > - ); - describe('useSidebarItems', () => { + const mockNavigationItems: NavigationItem[] = [ + { + id: 'explore', + title: 'Explore', + isHidden: false, + pageId: 'test-page', + children: [ + { + id: 'tables', + pageId: 'test-page', + title: 'Tables', + isHidden: false, + }, + { + id: 'topics', + pageId: 'test-page-', + title: 'Topics', + isHidden: false, + }, + ], + }, + { + id: 'glossary', + pageId: 'test-page', + title: 'Glossary', + isHidden: false, + }, + ]; + + const mockSidebarItems: LeftSidebarItem[] = [ + { + key: 'explore', + title: 'Explore', + dataTestId: 'explore', + icon: () => ({} as ReactNode), + children: [ + { + key: 'tables', + dataTestId: 'tables', + title: 'Tables', + icon: () => ({} as ReactNode), + }, + { + key: 'topics', + dataTestId: 'topics', + title: 'Topics', + icon: () => ({} as ReactNode), + }, + ], + }, + { + key: 'glossary', + dataTestId: 'glossary', + title: 'Glossary', + icon: () => ({} as ReactNode), + }, + ]; + + const mockPlugins: AppPlugin[] = [ + { + name: 'test-plugin', + getSidebarActions: jest.fn(() => [ + { + key: 'plugin-item', + title: 'Plugin Item', + icon: {} as ReactNode, + index: 0, + }, + ]), + } as unknown as AppPlugin, + ]; + beforeEach(() => { - queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); jest.clearAllMocks(); - mockUseApplicationStore.mockReturnValue({ selectedPersona: mockPersona }); - mockGetDocumentByFQN.mockResolvedValue(mockDocument); - mockDefaultProvider(); - mockFilterHiddenNavigationItems.mockReturnValue(mockSidebarItems as never); - }); - - it('should return filtered sidebar items with navigation and empty plugins', async () => { - const { result, waitForNextUpdate } = renderHook(() => useSidebarItems(), { - wrapper: createWrapper(), + mockUseCustomPages.mockReturnValue({ + navigation: mockNavigationItems, + customizedPage: null, + isLoading: false, + }); + mockUseApplicationsProvider.mockReturnValue({ + plugins: [], + applications: [], + extensionRegistry: {} as never, }); + mockFilterHiddenNavigationItems.mockReturnValue(mockSidebarItems); + }); - await waitForNextUpdate(); + it('should return filtered sidebar items with navigation and empty plugins', () => { + const { result } = renderHook(() => useSidebarItems()); - expect(mockGetDocumentByFQN).toHaveBeenCalledWith('persona.test-persona'); + expect(mockUseCustomPages).toHaveBeenCalledWith('Navigation'); + expect(mockUseApplicationsProvider).toHaveBeenCalled(); expect(mockFilterHiddenNavigationItems).toHaveBeenCalledWith( mockNavigationItems, [] @@ -137,165 +147,161 @@ describe('useSidebarItems', () => { expect(result.current).toEqual(mockSidebarItems); }); - it('should pass plugins to filterHiddenNavigationItems when plugins are available', async () => { - mockUseApplicationsProvider.mockReturnValue( - { plugins: mockPlugins, applications: [], extensionRegistry: {} } as unknown as ReturnType< - typeof useApplicationsProvider - > - ); - - const { waitForNextUpdate } = renderHook(() => useSidebarItems(), { - wrapper: createWrapper(), + it('should pass plugins to filterHiddenNavigationItems when plugins are available', () => { + mockUseApplicationsProvider.mockReturnValue({ + plugins: mockPlugins, + applications: [], + extensionRegistry: {} as never, }); - await waitForNextUpdate(); + const { result } = renderHook(() => useSidebarItems()); expect(mockFilterHiddenNavigationItems).toHaveBeenCalledWith( mockNavigationItems, mockPlugins ); + expect(result.current).toEqual(mockSidebarItems); }); - it('should handle null navigation when no persona is selected', () => { - mockUseApplicationStore.mockReturnValue({ selectedPersona: null }); + it('should handle null navigation items', () => { + mockUseCustomPages.mockReturnValue({ + navigation: null, + customizedPage: null, + isLoading: false, + }); - renderHook(() => useSidebarItems(), { wrapper: createWrapper() }); + const { result } = renderHook(() => useSidebarItems()); - expect(mockGetDocumentByFQN).not.toHaveBeenCalled(); expect(mockFilterHiddenNavigationItems).toHaveBeenCalledWith(null, []); + expect(result.current).toEqual(mockSidebarItems); }); it('should handle undefined plugins', () => { - mockUseApplicationStore.mockReturnValue({ selectedPersona: null }); - mockUseApplicationsProvider.mockReturnValue( - { plugins: undefined, applications: [], extensionRegistry: {} } as unknown as ReturnType< - typeof useApplicationsProvider - > - ); - - renderHook(() => useSidebarItems(), { wrapper: createWrapper() }); - - expect(mockFilterHiddenNavigationItems).toHaveBeenCalledWith(null, []); - }); - - it('should recalculate sidebar items when persona changes', async () => { - const newPersona = { fullyQualifiedName: 'new-persona' }; - const newNavigation: NavigationItem[] = [ - { id: 'settings', pageId: 'settings-page', title: 'Settings', isHidden: false }, - ]; - mockGetDocumentByFQN - .mockResolvedValueOnce(mockDocument) - .mockResolvedValueOnce({ ...mockDocument, data: { navigation: newNavigation } }); - - const { rerender, waitForNextUpdate } = renderHook( - () => useSidebarItems(), - { wrapper: createWrapper() } - ); + mockUseApplicationsProvider.mockReturnValue({ + plugins: undefined as unknown as AppPlugin[], + applications: [], + extensionRegistry: {} as never, + }); - await waitForNextUpdate(); + const { result } = renderHook(() => useSidebarItems()); expect(mockFilterHiddenNavigationItems).toHaveBeenCalledWith( mockNavigationItems, [] ); + expect(result.current).toEqual(mockSidebarItems); + }); - mockUseApplicationStore.mockReturnValue({ selectedPersona: newPersona }); - rerender(); + it('should recalculate sidebar items when navigation changes', () => { + const { rerender } = renderHook(() => useSidebarItems()); - await waitForNextUpdate(); + expect(mockFilterHiddenNavigationItems).toHaveBeenCalledTimes(1); - expect(mockGetDocumentByFQN).toHaveBeenCalledWith('persona.new-persona'); + const newNavigationItems: NavigationItem[] = [ + { + id: 'settings', + pageId: 'settings-page', + title: 'Settings', + isHidden: false, + }, + ]; + mockUseCustomPages.mockReturnValue({ + navigation: newNavigationItems, + customizedPage: null, + isLoading: false, + }); + + rerender(); + + expect(mockFilterHiddenNavigationItems).toHaveBeenCalledTimes(2); expect(mockFilterHiddenNavigationItems).toHaveBeenLastCalledWith( - newNavigation, + newNavigationItems, [] ); }); - it('should recalculate sidebar items when plugins change', async () => { - const { rerender, waitForNextUpdate } = renderHook( - () => useSidebarItems(), - { wrapper: createWrapper() } - ); - - await waitForNextUpdate(); + it('should recalculate sidebar items when plugins change', () => { + const { rerender } = renderHook(() => useSidebarItems()); expect(mockFilterHiddenNavigationItems).toHaveBeenCalledTimes(1); - mockUseApplicationsProvider.mockReturnValue( - { plugins: mockPlugins, applications: [], extensionRegistry: {} } as unknown as ReturnType< - typeof useApplicationsProvider - > - ); + mockUseApplicationsProvider.mockReturnValue({ + plugins: mockPlugins, + applications: [], + extensionRegistry: {} as never, + }); + rerender(); + expect(mockFilterHiddenNavigationItems).toHaveBeenCalledTimes(2); expect(mockFilterHiddenNavigationItems).toHaveBeenLastCalledWith( mockNavigationItems, mockPlugins ); }); - it('should memoize result when navigation and plugins do not change', async () => { - const { result, rerender, waitForNextUpdate } = renderHook( - () => useSidebarItems(), - { wrapper: createWrapper() } - ); - - await waitForNextUpdate(); + it('should memoize result when navigation and plugins do not change', () => { + const { result, rerender } = renderHook(() => useSidebarItems()); const firstResult = result.current; rerender(); expect(result.current).toBe(firstResult); - expect(mockGetDocumentByFQN).toHaveBeenCalledTimes(1); + expect(mockFilterHiddenNavigationItems).toHaveBeenCalledTimes(1); }); - it('should handle empty navigation array', async () => { - mockGetDocumentByFQN.mockResolvedValue({ - ...mockDocument, - data: { navigation: [] }, + it('should handle empty navigation array', () => { + mockUseCustomPages.mockReturnValue({ + navigation: [], + customizedPage: null, + isLoading: false, }); - const { waitForNextUpdate } = renderHook(() => useSidebarItems(), { - wrapper: createWrapper(), - }); - - await waitForNextUpdate(); + const { result } = renderHook(() => useSidebarItems()); expect(mockFilterHiddenNavigationItems).toHaveBeenCalledWith([], []); + expect(result.current).toEqual(mockSidebarItems); }); - it('should handle multiple plugins', async () => { + it('should handle multiple plugins', () => { const multiplePlugins: AppPlugin[] = [ { name: 'plugin-1', getSidebarActions: jest.fn(() => [ - { key: 'plugin-1-item', title: 'Plugin 1 Item', icon: {} as ReactNode, index: 0 }, + { + key: 'plugin-1-item', + title: 'Plugin 1 Item', + icon: {} as ReactNode, + index: 0, + }, ]), } as unknown as AppPlugin, { name: 'plugin-2', getSidebarActions: jest.fn(() => [ - { key: 'plugin-2-item', title: 'Plugin 2 Item', icon: {} as ReactNode, index: 1 }, + { + key: 'plugin-2-item', + title: 'Plugin 2 Item', + icon: {} as ReactNode, + index: 1, + }, ]), } as unknown as AppPlugin, ]; - mockUseApplicationsProvider.mockReturnValue( - { plugins: multiplePlugins, applications: [], extensionRegistry: {} } as unknown as ReturnType< - typeof useApplicationsProvider - > - ); - - const { waitForNextUpdate } = renderHook(() => useSidebarItems(), { - wrapper: createWrapper(), + mockUseApplicationsProvider.mockReturnValue({ + plugins: multiplePlugins, + applications: [], + extensionRegistry: {} as never, }); - await waitForNextUpdate(); + const { result } = renderHook(() => useSidebarItems()); expect(mockFilterHiddenNavigationItems).toHaveBeenCalledWith( mockNavigationItems, multiplePlugins ); + expect(result.current).toEqual(mockSidebarItems); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useSidebarItems.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useSidebarItems.ts index 290035b51ce5..1827b3640e8d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useSidebarItems.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useSidebarItems.ts @@ -10,35 +10,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useQuery } from '@tanstack/react-query'; import { useMemo } from 'react'; import { useApplicationsProvider } from '../components/Settings/Applications/ApplicationsProvider/ApplicationsProvider'; -import { NavigationItem } from '../generated/system/ui/uiCustomization'; -import { - docStoreQueryFn, - docStoreQueryKey, - personaDocFqn, -} from '../rest/queries/docStoreQuery'; import { filterHiddenNavigationItems } from '../utils/CustomizaNavigation/CustomizeNavigation'; -import { useApplicationStore } from './useApplicationStore'; +import { useCustomPages } from './useCustomPages'; export const useSidebarItems = () => { - const { selectedPersona } = useApplicationStore(); - const fqn = personaDocFqn(selectedPersona); - - const { data: doc } = useQuery({ - queryKey: docStoreQueryKey(fqn ?? ''), - queryFn: docStoreQueryFn(fqn ?? ''), - enabled: !!fqn, - retry: false, - }); - - const navigation = - (doc?.data?.navigation as NavigationItem[] | undefined) ?? null; + const { navigation } = useCustomPages('Navigation'); const { plugins = [] } = useApplicationsProvider(); - return useMemo( + const sideBarItems = useMemo( () => filterHiddenNavigationItems(navigation, plugins), [navigation, plugins] ); + + return sideBarItems; }; From 7610a3b68f759a1588ca6a805ee7e410e43f2975 Mon Sep 17 00:00:00 2001 From: Rohit0301 Date: Wed, 12 Aug 2026 23:26:20 +0530 Subject: [PATCH 09/17] perf(ui): add staleTime to docStore queries to cover staggered mounts React Query's in-flight deduplication only fires for exactly concurrent requests. Without staleTime, a sidebar that mounts a tick after the page body sees a stale cache entry and triggers a background refetch. Add PERSONA_DOC_STALE_TIME (5 min) to docStoreQuery.ts and apply it to both useCustomPages and MyDataPage useQuery calls. Matches the staleTime already used in useResolvedAppMode for the same endpoint. Now cached persona docs are reused across staggered subscribers for the full 5-min window, not just while the first request is still in-flight. Co-Authored-By: Claude Sonnet 4.6 --- .../src/main/resources/ui/src/hooks/useCustomPages.ts | 2 ++ .../ui/src/pages/MyDataPage/MyDataPage.component.tsx | 2 ++ .../main/resources/ui/src/rest/queries/docStoreQuery.ts | 7 +++++++ 3 files changed, 11 insertions(+) diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts index 3489f958f271..b30f82176637 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts @@ -16,6 +16,7 @@ import { NavigationItem } from '../generated/system/ui/uiCustomization'; import { docStoreQueryFn, docStoreQueryKey, + PERSONA_DOC_STALE_TIME, personaDocFqn, } from '../rest/queries/docStoreQuery'; import { useApplicationStore } from './useApplicationStore'; @@ -33,6 +34,7 @@ export const useCustomPages = (pageType: PageType | 'Navigation') => { queryFn: docStoreQueryFn(fqn ?? ''), enabled: !!fqn, retry: false, + staleTime: PERSONA_DOC_STALE_TIME, }); return { diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx index 3e92577cd41f..f5b98ec3c7ce 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx @@ -40,6 +40,7 @@ import { import { docStoreQueryFn, docStoreQueryKey, + PERSONA_DOC_STALE_TIME, personaDocFqn, } from '../../rest/queries/docStoreQuery'; import { updateUserDetail } from '../../rest/userAPI'; @@ -91,6 +92,7 @@ const MyDataPage = () => { queryFn: docStoreQueryFn(personaFqn ?? ''), enabled: !!personaFqn, retry: false, + staleTime: PERSONA_DOC_STALE_TIME, }); const isLoading = !!personaFqn && isDocPending; diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/queries/docStoreQuery.ts b/openmetadata-ui/src/main/resources/ui/src/rest/queries/docStoreQuery.ts index 2d165ba75b1a..48a1ef3d63f0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/queries/docStoreQuery.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/queries/docStoreQuery.ts @@ -26,7 +26,14 @@ import { getDocumentByFQN } from '../DocStoreAPI'; * simultaneously with the same FQN key (e.g. the sidebar navigation hook and * the My Data page both reading `persona.X`), only one network request fires * and both subscribers receive the result. + * + * {@link PERSONA_DOC_STALE_TIME} extends deduplication beyond concurrent + * mounts: staggered subscribers (sidebar renders a tick before page body) + * reuse the cached document instead of triggering a background refetch. + * Matches the staleTime used in useResolvedAppMode for the same endpoint. */ +export const PERSONA_DOC_STALE_TIME = 5 * 60 * 1000; + export const docStoreQueryKey = (fqn: string) => ['docStore', fqn] as const; export const docStoreQueryFn = (fqn: string) => (): Promise => From b31aa554f0b5fa7d3b85d3e215c596753205b80b Mon Sep 17 00:00:00 2001 From: Rohit0301 Date: Wed, 12 Aug 2026 23:29:43 +0530 Subject: [PATCH 10/17] lint fix --- .../src/main/resources/ui/src/hooks/useCustomPages.ts | 8 ++++---- .../ui/src/pages/MyDataPage/MyDataPage.component.tsx | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts index b30f82176637..5a5789bff91f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts @@ -16,8 +16,8 @@ import { NavigationItem } from '../generated/system/ui/uiCustomization'; import { docStoreQueryFn, docStoreQueryKey, - PERSONA_DOC_STALE_TIME, personaDocFqn, + PERSONA_DOC_STALE_TIME, } from '../rest/queries/docStoreQuery'; import { useApplicationStore } from './useApplicationStore'; @@ -39,9 +39,9 @@ export const useCustomPages = (pageType: PageType | 'Navigation') => { return { customizedPage: - (doc?.data?.pages?.find( - (p: Page | null) => p?.pageType === pageType - ) as Page | undefined) ?? null, + (doc?.data?.pages?.find((p: Page | null) => p?.pageType === pageType) as + | Page + | undefined) ?? null, // Reset to [] on error to clear stale navigation items, null when no persona selected. navigation: isError ? ([] as NavigationItem[]) diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx index f5b98ec3c7ce..1f4632e01d2c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx @@ -40,8 +40,8 @@ import { import { docStoreQueryFn, docStoreQueryKey, - PERSONA_DOC_STALE_TIME, personaDocFqn, + PERSONA_DOC_STALE_TIME, } from '../../rest/queries/docStoreQuery'; import { updateUserDetail } from '../../rest/userAPI'; import { getConstrainedWidgetWidth } from '../../utils/CustomizableLandingPagePureUtils'; From 5d921c72699de8f71e3617d6c5e2e71c8aadf23a Mon Sep 17 00:00:00 2001 From: Rohit0301 Date: Thu, 13 Aug 2026 19:13:06 +0530 Subject: [PATCH 11/17] fixed unit test --- .../ui/src/hooks/useCustomPages.test.ts | 51 ++++++++++--------- 1 file changed, 27 insertions(+), 24 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.test.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.test.ts index d433a5506f71..d9fdf839babc 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.test.ts @@ -11,7 +11,7 @@ * limitations under the License. */ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook, waitFor } from '@testing-library/react'; import React from 'react'; import { Document } from '../generated/entity/docStore/document'; import { PageType } from '../generated/system/ui/page'; @@ -83,37 +83,37 @@ describe('useCustomPages', () => { it('should fetch and return customized page and navigation when persona is selected', async () => { mockGetDocumentByFQN.mockResolvedValue(mockDocument); - const { result, waitForNextUpdate } = renderHook( - () => useCustomPages(PageType.Table), - { wrapper: createWrapper(queryClient) } - ); + const { result } = renderHook(() => useCustomPages(PageType.Table), { + wrapper: createWrapper(queryClient), + }); expect(result.current.isLoading).toBe(true); - await waitForNextUpdate(); + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); expect(mockGetDocumentByFQN).toHaveBeenCalledWith('persona.test-persona'); expect(result.current.customizedPage).toEqual(mockPage); expect(result.current.navigation).toEqual(mockNavigation); - expect(result.current.isLoading).toBe(false); }); it('should handle error when fetching document fails', async () => { mockGetDocumentByFQN.mockRejectedValue(new Error('API Error')); - const { result, waitForNextUpdate } = renderHook( - () => useCustomPages(PageType.Table), - { wrapper: createWrapper(queryClient) } - ); + const { result } = renderHook(() => useCustomPages(PageType.Table), { + wrapper: createWrapper(queryClient), + }); expect(result.current.isLoading).toBe(true); - await waitForNextUpdate(); + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); expect(mockGetDocumentByFQN).toHaveBeenCalledWith('persona.test-persona'); expect(result.current.customizedPage).toBeNull(); expect(result.current.navigation).toEqual([]); - expect(result.current.isLoading).toBe(false); }); it('should not fetch document when no persona is selected', () => { @@ -144,7 +144,7 @@ describe('useCustomPages', () => { }; mockGetDocumentByFQN.mockResolvedValue(mockDocWithMultiplePages); - const { result, rerender, waitForNextUpdate } = renderHook( + const { result, rerender } = renderHook( ({ pageType }: { pageType: PageType }) => useCustomPages(pageType), { initialProps: { pageType: PageType.Table }, @@ -152,11 +152,12 @@ describe('useCustomPages', () => { } ); - await waitForNextUpdate(); + await waitFor(() => { + expect(result.current.customizedPage?.pageType).toBe(PageType.Table); + }); // Changing pageType filters locally from the cached doc — no extra network request. expect(mockGetDocumentByFQN).toHaveBeenCalledTimes(1); - expect(result.current.customizedPage?.pageType).toBe(PageType.Table); rerender({ pageType: PageType.Dashboard }); @@ -167,7 +168,7 @@ describe('useCustomPages', () => { it('should return updated results when selected persona changes', async () => { mockGetDocumentByFQN.mockResolvedValueOnce(mockDocument); - const { result, waitForNextUpdate, rerender } = renderHook( + const { result, rerender } = renderHook( ({ selectedPersona }) => { mockUseApplicationStore.mockReturnValue({ selectedPersona, @@ -183,10 +184,11 @@ describe('useCustomPages', () => { } ); - await waitForNextUpdate(); + await waitFor(() => { + expect(result.current.customizedPage).toEqual(mockDocument.data.pages[0]); + }); expect(mockGetDocumentByFQN).toHaveBeenCalledWith('persona.test-persona'); - expect(result.current.customizedPage).toEqual(mockDocument.data.pages[0]); expect(result.current.navigation).toEqual(mockDocument.data.navigation); const newPersona = { fullyQualifiedName: 'new-persona' }; @@ -202,13 +204,14 @@ describe('useCustomPages', () => { rerender({ selectedPersona: newPersona }); - await waitForNextUpdate(); + await waitFor(() => { + expect(result.current.customizedPage).toEqual({ + pageType: PageType.Table, + content: 'New Content', + }); + }); expect(mockGetDocumentByFQN).toHaveBeenCalledWith('persona.new-persona'); - expect(result.current.customizedPage).toEqual({ - pageType: PageType.Table, - content: 'New Content', - }); expect(result.current.navigation).toEqual([{ name: 'New Navigation' }]); }); }); From b34265d7f45f85c2b9d9fadd544173f6d38ab1e7 Mon Sep 17 00:00:00 2001 From: Rohit0301 Date: Sat, 15 Aug 2026 11:22:21 +0530 Subject: [PATCH 12/17] fix(ui): restore first-render skeleton in MyDataPage to prevent Playwright timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this, a user with no persona gets isLoading=false on the very first render (React Query computes it synchronously), widgets mount immediately, and their loaders appear before waitForAllLoadersToDisappear starts polling in the data-contract Playwright test — causing a 30 s timeout. Restores the pre-React-Query invariant: useState(true) ensures the skeleton always renders on the first paint; a useEffect syncs isLoading to the actual query state after that, so widgets are only deferred by one effect flush rather than a full network round-trip. Co-Authored-By: Claude Sonnet 4.6 --- .../ui/src/pages/MyDataPage/MyDataPage.component.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx index 1f4632e01d2c..a34662adb518 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx @@ -95,7 +95,15 @@ const MyDataPage = () => { staleTime: PERSONA_DOC_STALE_TIME, }); - const isLoading = !!personaFqn && isDocPending; + // Start with isLoading=true so the skeleton renders on the first paint and + // widget loaders are deferred until the persona query settles. A user with + // no persona would otherwise get isLoading=false immediately, exposing widget + // loaders to Playwright's waitForAllLoadersToDisappear before it starts polling. + const isQueryLoading = !!personaFqn && isDocPending; + const [isLoading, setIsLoading] = useState(true); + useEffect(() => { + setIsLoading(isQueryLoading); + }, [isQueryLoading]); const personaPreferences = useMemo( () => docData?.data?.personPreferences ?? [], From 25ed7c07747ae92d99261d382ee2c77a9391aad8 Mon Sep 17 00:00:00 2001 From: Rohit0301 Date: Sat, 15 Aug 2026 11:29:07 +0530 Subject: [PATCH 13/17] refactor(ui): replace derived-state anti-pattern with hasMounted flag The previous fix used useState+useEffect to mirror isQueryLoading into an isLoading state variable, which is the classic derived-state anti-pattern: extra render on every query transition and a stale window between renders. Replace with a one-shot hasMounted flag that flips true after the first paint. isLoading is now fully derived: !hasMounted forces skeleton on first render; after mount it equals the actual query loading expression with no lag. Addresses gitar-bot review comment on PR #31300. Co-Authored-By: Claude Sonnet 4.6 --- .../pages/MyDataPage/MyDataPage.component.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx index a34662adb518..ae94358ae15a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx @@ -95,15 +95,15 @@ const MyDataPage = () => { staleTime: PERSONA_DOC_STALE_TIME, }); - // Start with isLoading=true so the skeleton renders on the first paint and - // widget loaders are deferred until the persona query settles. A user with - // no persona would otherwise get isLoading=false immediately, exposing widget - // loaders to Playwright's waitForAllLoadersToDisappear before it starts polling. - const isQueryLoading = !!personaFqn && isDocPending; - const [isLoading, setIsLoading] = useState(true); + // hasMounted flips once after the first paint so the skeleton always shows on + // first render, deferring widget loaders until after that paint. Without this + // guard a user with no persona gets isLoading=false immediately, exposing + // widget loaders to Playwright's waitForAllLoadersToDisappear too early. + const [hasMounted, setHasMounted] = useState(false); useEffect(() => { - setIsLoading(isQueryLoading); - }, [isQueryLoading]); + setHasMounted(true); + }, []); + const isLoading = !hasMounted || (!!personaFqn && isDocPending); const personaPreferences = useMemo( () => docData?.data?.personPreferences ?? [], From 60c7daf339f43622146ea568ea8ecdb6c849af1f Mon Sep 17 00:00:00 2001 From: Rohit0301 Date: Sat, 15 Aug 2026 17:30:17 +0530 Subject: [PATCH 14/17] fix(ui): restore first-render skeleton in useCustomPages to prevent Playwright timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Data Contracts With Persona" Playwright tests all fail at waitForAllLoadersToDisappear after visitEntityPage. Every failing entity detail component (Topic, Dashboard, MlModel, Pipeline, StoredProcedure, SearchIndex, Container, APICollection, etc.) gates its loader on isLoading from useCustomPages: if (isLoading || permissionsLoading || ...) return ; Before this PR, isLoading = useState(true) always started true and quickly resolved. After the React Query migration, isLoading = !!fqn && isPending starts false when selectedPersona is not yet in the Zustand store, then jumps to true when the persona arrives asynchronously — after waitForAllLoadersToDisappear may have already returned count=0, leaving the test interacting with a page covered by PageLoader. Apply the same hasMounted pattern already used in MyDataPage: isLoading is true on the first render regardless of persona state, then derives from the query after the mount effect fires. This restores the well-defined always-loading-on-first-render invariant that Playwright tests relied on. Also update the no-persona unit test to await isLoading=false (hasMounted now makes the initial value true for one effect tick). Co-Authored-By: Claude Sonnet 4.6 --- .../resources/ui/src/hooks/useCustomPages.test.ts | 8 ++++++-- .../main/resources/ui/src/hooks/useCustomPages.ts | 13 ++++++++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.test.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.test.ts index d9fdf839babc..ad9968af40ec 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.test.ts @@ -116,7 +116,7 @@ describe('useCustomPages', () => { expect(result.current.navigation).toEqual([]); }); - it('should not fetch document when no persona is selected', () => { + it('should not fetch document when no persona is selected', async () => { mockUseApplicationStore.mockReturnValue({ selectedPersona: null, }); @@ -128,7 +128,11 @@ describe('useCustomPages', () => { expect(mockGetDocumentByFQN).not.toHaveBeenCalled(); expect(result.current.customizedPage).toBeNull(); expect(result.current.navigation).toBeNull(); - expect(result.current.isLoading).toBe(false); + + // hasMounted starts false (isLoading = true), flips after the mount effect. + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); }); it('should filter by pageType from cached doc without re-fetching', async () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts index 5a5789bff91f..9836524e8b21 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts @@ -11,6 +11,7 @@ * limitations under the License. */ import { useQuery } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; import { Page, PageType } from '../generated/system/ui/page'; import { NavigationItem } from '../generated/system/ui/uiCustomization'; import { @@ -37,6 +38,16 @@ export const useCustomPages = (pageType: PageType | 'Navigation') => { staleTime: PERSONA_DOC_STALE_TIME, }); + // hasMounted flips once after the first paint so entity pages always show + // their loader on first render — identical to the old useState(true) pattern. + // Without this, selectedPersona arrives asynchronously after first render, + // causing isLoading to flip false→true→false in a window where + // waitForAllLoadersToDisappear may have already returned. + const [hasMounted, setHasMounted] = useState(false); + useEffect(() => { + setHasMounted(true); + }, []); + return { customizedPage: (doc?.data?.pages?.find((p: Page | null) => p?.pageType === pageType) as @@ -46,6 +57,6 @@ export const useCustomPages = (pageType: PageType | 'Navigation') => { navigation: isError ? ([] as NavigationItem[]) : ((doc?.data?.navigation ?? null) as NavigationItem[] | null), - isLoading: !!fqn && isPending, + isLoading: !hasMounted || (!!fqn && isPending), }; }; From 071670fea32b2d10852ae450060e98f787742045 Mon Sep 17 00:00:00 2001 From: Rohit0301 Date: Sat, 15 Aug 2026 20:47:13 +0530 Subject: [PATCH 15/17] refactor(ui): replace derived-state anti-pattern with hasMounted flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop `!!fqn && isPending` from `useCustomPages.isLoading`. Root cause of remaining test failures: the old `fetchDocument()` inside `useCustomPages` never called `setIsLoading(true)` — it only called `setIsLoading(false)` in the finally block. So when `selectedPersona` arrived asynchronously after the initial render, `isLoading` stayed false while the background API fetch ran. `waitForAllLoadersToDisappear` returned cleanly and the entity page stayed visible throughout. The previous hasMounted fix used `isLoading = !hasMounted || (!!fqn && isPending)`. When `selectedPersona` arrives asynchronously (`fqn` transitions null→non-null after mount), `isPending=true` makes `isLoading` jump back to true — a second loader wave AFTER `waitForAllLoadersToDisappear` returned, covering the entity page and blocking all subsequent test interactions. Fix: `isLoading = !hasMounted` only. The persona doc still fetches in the background via React Query; `customizedPage`/`navigation` update when data arrives, entity page re-renders without a loader — exactly matching old behaviour. Also drop the two sync `expect(isLoading).toBe(true)` assertions in the unit tests: RTL flushes the mount effect synchronously, so `hasMounted` is already `true` by the time the first assertion runs. Co-Authored-By: Claude Sonnet 4.6 --- .../main/resources/ui/src/hooks/useCustomPages.test.ts | 4 ---- .../src/main/resources/ui/src/hooks/useCustomPages.ts | 8 +++++++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.test.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.test.ts index ad9968af40ec..47073e7c9686 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.test.ts @@ -87,8 +87,6 @@ describe('useCustomPages', () => { wrapper: createWrapper(queryClient), }); - expect(result.current.isLoading).toBe(true); - await waitFor(() => { expect(result.current.isLoading).toBe(false); }); @@ -105,8 +103,6 @@ describe('useCustomPages', () => { wrapper: createWrapper(queryClient), }); - expect(result.current.isLoading).toBe(true); - await waitFor(() => { expect(result.current.isLoading).toBe(false); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts index 9836524e8b21..322110315b32 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts @@ -57,6 +57,12 @@ export const useCustomPages = (pageType: PageType | 'Navigation') => { navigation: isError ? ([] as NavigationItem[]) : ((doc?.data?.navigation ?? null) as NavigationItem[] | null), - isLoading: !hasMounted || (!!fqn && isPending), + // Only block render on the very first paint (hasMounted=false). + // The persona doc fetches silently in the background after that — exactly + // matching the old fetchDocument pattern which never called setIsLoading(true) + // on subsequent selectedPersona updates. Adding !!fqn&&isPending here causes + // a second loader wave after waitForAllLoadersToDisappear has already returned, + // covering the entity page when selectedPersona arrives asynchronously. + isLoading: !hasMounted, }; }; From 9b35ec96149ff3e174522387c0f53c2c6324e2a4 Mon Sep 17 00:00:00 2001 From: Rohit0301 Date: Sat, 15 Aug 2026 20:53:44 +0530 Subject: [PATCH 16/17] fix(ui): drop unused isPending from useCustomPages destructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isPending was left dangling after !!fqn&&isPending was removed from the isLoading expression — dead binding that would fail no-unused-vars lint. Co-Authored-By: Claude Sonnet 4.6 --- .../src/main/resources/ui/src/hooks/useCustomPages.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts index 322110315b32..b71831f3eca9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts @@ -28,7 +28,6 @@ export const useCustomPages = (pageType: PageType | 'Navigation') => { const { data: doc, - isPending, isError, } = useQuery({ queryKey: docStoreQueryKey(fqn ?? ''), @@ -57,12 +56,9 @@ export const useCustomPages = (pageType: PageType | 'Navigation') => { navigation: isError ? ([] as NavigationItem[]) : ((doc?.data?.navigation ?? null) as NavigationItem[] | null), - // Only block render on the very first paint (hasMounted=false). - // The persona doc fetches silently in the background after that — exactly - // matching the old fetchDocument pattern which never called setIsLoading(true) - // on subsequent selectedPersona updates. Adding !!fqn&&isPending here causes - // a second loader wave after waitForAllLoadersToDisappear has already returned, - // covering the entity page when selectedPersona arrives asynchronously. + // Only block render on the very first paint. The persona doc fetches in the + // background after that; customizedPage/navigation update when it arrives + // without re-showing a loader (matches the old fetchDocument behaviour). isLoading: !hasMounted, }; }; From a2aa9874a05daf7831048d6356435534c72b2272 Mon Sep 17 00:00:00 2001 From: Rohit0301 Date: Sat, 15 Aug 2026 20:55:51 +0530 Subject: [PATCH 17/17] lint fix --- .../src/main/resources/ui/src/hooks/useCustomPages.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts index b71831f3eca9..9ac91b98fc88 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useCustomPages.ts @@ -26,10 +26,7 @@ export const useCustomPages = (pageType: PageType | 'Navigation') => { const { selectedPersona } = useApplicationStore(); const fqn = personaDocFqn(selectedPersona); - const { - data: doc, - isError, - } = useQuery({ + const { data: doc, isError } = useQuery({ queryKey: docStoreQueryKey(fqn ?? ''), queryFn: docStoreQueryFn(fqn ?? ''), enabled: !!fqn,