From 72516615198fadc387d1a585bbefc92c9b50e4c7 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Sun, 10 May 2026 08:12:59 -0700 Subject: [PATCH 01/62] =?UTF-8?q?feat(ui-perf):=20If-None-Match=20?= =?UTF-8?q?=E2=86=92=20304=20short-circuit=20+=20client=20ETag=20cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server: ETagResponseFilter already emits ETag on entity GETs. Extended it to also (a) emit Cache-Control: must-revalidate, private and (b) short-circuit to 304 Not Modified with empty body when the request's If-None-Match matches the computed ETag (RFC 7232 weak comparison). Client: new attachEtagInterceptor() in rest/etagInterceptor.ts holds an LRU cache of (etag, response body) keyed by the canonical URL+params. On request, sends If-None-Match if a cached etag exists. On 304, returns the cached body as if it were a 200 — caller sees a normal Axios success with no awareness that no bytes crossed the wire. Cap of 200 entries keeps the cache bounded to ~10 MB worst case. clearEtagCache() is wired into AuthProvider.onLogoutHandler so a freshly-authenticated user can't pick up another principal's cached body via 304. Wins on revisits: zero body bytes on the wire, skip JSON parse, skip render. Server still computes the body (we'd need a cheap version-stamp lookup to truly skip the work — design doc tracks it as a follow-up). P1.1 of .context/perceived-latency-design.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../resources/filters/ETagResponseFilter.java | 65 ++++++- .../Auth/AuthProviders/AuthProvider.tsx | 5 + .../resources/ui/src/rest/etagInterceptor.ts | 183 ++++++++++++++++++ .../src/main/resources/ui/src/rest/index.ts | 7 + 4 files changed, 253 insertions(+), 7 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/rest/etagInterceptor.ts diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/filters/ETagResponseFilter.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/filters/ETagResponseFilter.java index c6d0805fbca7..40b49f18e986 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/filters/ETagResponseFilter.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/filters/ETagResponseFilter.java @@ -16,6 +16,7 @@ import jakarta.ws.rs.container.ContainerRequestContext; import jakarta.ws.rs.container.ContainerResponseContext; import jakarta.ws.rs.container.ContainerResponseFilter; +import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.ext.Provider; import org.openmetadata.schema.EntityInterface; @@ -23,23 +24,73 @@ import org.openmetadata.service.util.EntityETag; /** - * JAX-RS filter that automatically adds ETag headers to GET responses - * containing EntityInterface entities. + * JAX-RS filter that adds {@code ETag} + {@code Cache-Control} headers to entity GET responses + * and short-circuits to {@code 304 Not Modified} when the client's {@code If-None-Match} matches + * the computed ETag. + * + *

The 304 path saves the response body bytes on the wire and the client-side render cost on + * revisits — the server still computes the entity body (we'd need a cheap version-stamp lookup + * to truly skip the work, see design doc), but the network and client savings are immediate. + * + *

{@code Cache-Control: must-revalidate, private}: clients (browsers, our Axios interceptor) + * may keep the body but must revalidate via {@code If-None-Match} before reusing it; private + * keeps it out of any shared/proxy cache so per-user data doesn't leak. */ @Provider public class ETagResponseFilter implements ContainerResponseFilter { + private static final String CACHE_CONTROL_VALUE = "must-revalidate, private"; + @Override public void filter( ContainerRequestContext requestContext, ContainerResponseContext responseContext) { try (var ignored = RequestLatencyContext.phase("etagGeneration")) { - if ("GET".equals(requestContext.getMethod()) - && responseContext.getStatus() == Response.Status.OK.getStatusCode() - && responseContext.getEntity() instanceof EntityInterface entity) { + if (!"GET".equals(requestContext.getMethod()) + || responseContext.getStatus() != Response.Status.OK.getStatusCode() + || !(responseContext.getEntity() instanceof EntityInterface entity)) { + return; + } + + String etag = EntityETag.generateETag(entity); + if (etag == null) { + return; + } + responseContext.getHeaders().putSingle(HttpHeaders.ETAG, etag); + responseContext.getHeaders().putSingle(HttpHeaders.CACHE_CONTROL, CACHE_CONTROL_VALUE); + + String ifNoneMatch = requestContext.getHeaderString(HttpHeaders.IF_NONE_MATCH); + if (ifNoneMatch == null) { + return; + } + if (matchesAny(ifNoneMatch, etag)) { + // RFC 7232: 304 must NOT include a message body. Drop the entity so the + // serializer emits an empty body. Headers (including ETag) are preserved. + responseContext.setStatus(Response.Status.NOT_MODIFIED.getStatusCode()); + responseContext.setEntity(null); + } + } + } - String etag = EntityETag.generateETag(entity); - responseContext.getHeaders().add("ETag", etag); + /** + * RFC 7232 §3.2: {@code If-None-Match} can be {@code *} (match any), a single ETag, or a + * comma-separated list. Weak comparison is used — we treat {@code "abc"} and {@code W/"abc"} + * as matching, which is the spec's recommendation for cache-validation use. + */ + private static boolean matchesAny(String ifNoneMatch, String currentEtag) { + String trimmed = ifNoneMatch.trim(); + if ("*".equals(trimmed)) { + return true; + } + String currentBare = stripWeakPrefix(currentEtag); + for (String candidate : trimmed.split(",")) { + if (currentBare.equals(stripWeakPrefix(candidate.trim()))) { + return true; } } + return false; + } + + private static String stripWeakPrefix(String etag) { + return etag.startsWith("W/") ? etag.substring(2) : etag; } } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx index 74ecf1275491..f927e1d2a9da 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx @@ -54,6 +54,7 @@ import { withDomainFilter } from '../../../hoc/withDomainFilter'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import useCustomLocation from '../../../hooks/useCustomLocation/useCustomLocation'; import axiosClient from '../../../rest'; +import { clearEtagCache } from '../../../rest/etagInterceptor'; import { fetchAuthenticationConfig, fetchAuthorizerConfig, @@ -218,6 +219,10 @@ export const AuthProvider = ({ // Clear tokens properly during logout await clearOidcToken(); + // Drop the ETag interceptor's response cache so a freshly-authenticated user can't + // pick up another principal's cached body via If-None-Match → 304 mid-session. + clearEtagCache(); + setApplicationLoading(false); // Clear the refresh flag (used after refresh is complete) diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/etagInterceptor.ts b/openmetadata-ui/src/main/resources/ui/src/rest/etagInterceptor.ts new file mode 100644 index 000000000000..90ad9a42c93b --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/rest/etagInterceptor.ts @@ -0,0 +1,183 @@ +/* + * 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 { + AxiosHeaders, + AxiosInstance, + AxiosResponse, + InternalAxiosRequestConfig, +} from 'axios'; +import Qs from 'qs'; + +/** + * Client-side ETag / If-None-Match handling for entity GETs. + * + * Pairs with the server-side ETagResponseFilter which emits ETag + Cache-Control on entity + * GET responses and short-circuits to 304 when If-None-Match matches. The flow: + * + * 1. First GET to /tables/{fqn} → response with ETag header. We cache (etag, body) keyed + * by the canonical URL+params. + * 2. Second GET to the same URL → we attach If-None-Match. Server compares against the + * current ETag. + * - Match → 304 with empty body. Interceptor returns cached body as if it were 200. + * - No match → 200 with fresh body. Interceptor refreshes the cached entry. + * + * Wins: zero body bytes on the wire on revisit, plus skip JSON parse + render. + * + * Bounded memory: simple LRU cap at MAX_ENTRIES; oldest evicted on overflow. A typical entity + * GET response is 5-50 KB so the cap holds the cache to ~10 MB worst case. + */ + +interface CachedEntry { + etag: string; + data: unknown; +} + +const MAX_ENTRIES = 200; + +// Map preserves insertion order — re-set on hit to keep recently-used entries at the back. +const etagCache = new Map(); + +function buildKey(config: InternalAxiosRequestConfig): string { + const method = (config.method ?? 'get').toUpperCase(); + const url = config.url ?? ''; + const params = config.params + ? `?${Qs.stringify(config.params, { arrayFormat: 'comma' })}` + : ''; + + return `${method} ${url}${params}`; +} + +function touch(key: string, entry: CachedEntry): void { + etagCache.delete(key); + etagCache.set(key, entry); + + if (etagCache.size > MAX_ENTRIES) { + const oldest = etagCache.keys().next().value; + if (oldest !== undefined) { + etagCache.delete(oldest); + } + } +} + +function readEtagHeader(response: AxiosResponse): string | undefined { + const headers = response.headers; + if (!headers) { + return undefined; + } + + if (headers instanceof AxiosHeaders) { + const v = headers.get('etag'); + + return typeof v === 'string' ? v : undefined; + } + + const candidate = (headers as Record).etag; + if (typeof candidate === 'string') { + return candidate; + } + const candidateUpper = (headers as Record).ETag; + + return typeof candidateUpper === 'string' ? candidateUpper : undefined; +} + +/** + * Wire ETag handling into the axios client. Idempotent — calling twice is harmless because + * each call uses a fresh interceptor pair (callers that re-init axios should clear the cache + * via {@link clearEtagCache}). + */ +export function attachEtagInterceptor(client: AxiosInstance): void { + // Treat 304 as a success status so axios delivers it through the response interceptor + // instead of the error path. Without this, our 304-handling code would have to live in + // the error interceptor and intercepts on every error path. + const previousValidate = client.defaults.validateStatus; + client.defaults.validateStatus = (status: number) => + status === 304 || + (previousValidate + ? previousValidate(status) + : status >= 200 && status < 300); + + client.interceptors.request.use((config) => { + const method = (config.method ?? 'get').toLowerCase(); + if (method !== 'get') { + return config; + } + + const entry = etagCache.get(buildKey(config)); + if (!entry) { + return config; + } + + // Axios 1.x always populates config.headers with AxiosHeaders instance, but be + // defensive in case an upstream interceptor swapped it for a plain object. + if (config.headers instanceof AxiosHeaders) { + config.headers.set('If-None-Match', entry.etag); + } else if (config.headers) { + (config.headers as Record)['If-None-Match'] = entry.etag; + } else { + config.headers = new AxiosHeaders({ 'If-None-Match': entry.etag }); + } + + return config; + }); + + client.interceptors.response.use((response) => { + const method = (response.config.method ?? 'get').toLowerCase(); + if (method !== 'get') { + return response; + } + + const key = buildKey(response.config); + + if (response.status === 304) { + const entry = etagCache.get(key); + if (entry) { + touch(key, entry); + + return { + ...response, + status: 200, + statusText: 'OK (from ETag cache)', + data: entry.data, + }; + } + + // 304 without a cached body shouldn't happen in normal flow — a stale interceptor + // attaching If-None-Match for a key we no longer hold. Bubble through; the caller + // sees 304 and decides. Better than fabricating a fake 200. + return response; + } + + if (response.status === 200) { + const etag = readEtagHeader(response); + if (etag && response.data !== undefined) { + touch(key, { etag, data: response.data }); + } + } + + return response; + }); +} + +/** + * Drop every cached ETag entry. Call on logout / user switch so a freshly-authenticated user + * never receives another user's cached body via 304. + */ +export function clearEtagCache(): void { + etagCache.clear(); +} + +/** Test/debug helper. Returns the count of entries currently held. */ +export function etagCacheSize(): number { + return etagCache.size; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/index.ts b/openmetadata-ui/src/main/resources/ui/src/rest/index.ts index fd795dc40623..4110647d3577 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/index.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/index.ts @@ -14,10 +14,17 @@ import axios from 'axios'; import Qs from 'qs'; import { getBasePath } from '../utils/HistoryUtils'; +import { attachEtagInterceptor } from './etagInterceptor'; const axiosClient = axios.create({ baseURL: `${getBasePath()}/api/v1`, paramsSerializer: (params) => Qs.stringify(params, { arrayFormat: 'comma' }), }); +// Client-side If-None-Match support paired with the server's ETagResponseFilter. Saves the +// response body bytes + a JSON parse + a render on entity GET revisits within a session. +// Attached here (before AuthProvider's interceptors) so it sits closest to the wire and +// every other interceptor sees the resolved 304→200-with-cached-body translation. +attachEtagInterceptor(axiosClient); + export default axiosClient; From aed45a10b89deb5aa7df14a8eb5beac216c73544 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Sun, 10 May 2026 08:17:05 -0700 Subject: [PATCH 02/62] feat(ui-perf): defer Queries-tab fetch until tab activated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Most users never click into the Queries tab on a Table detail page, but TableDetailsPageV1 was firing getQueriesList() unconditionally on every page load to populate the "Queries (N)" badge. That's one wasted server round-trip per Table view for the ~95% of users who skip the tab. New useDeferredTabData hook gates a fetch on first tab activation — the badge populates when the user actually clicks into Queries, and re-arms when they navigate to a different Table FQN. Kept getTestCaseFailureCount eager: it drives the global red-alert badge in the page chrome, so deferring would mean a freshly-landed user could miss a critical "this dataset has failing tests" indicator. P1.2 of .context/perceived-latency-design.md. The "parallelize the serial chain" half of the original P1.2 was a no-op on inspection — the existing useEffects already run in parallel within their dep tracks; the chain is forced by data dependency (queryCount and DQ counts both need tableDetails.id), not by ordering choice. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ui/src/hooks/useDeferredTabData.ts | 62 +++++++++++++++++++ .../TableDetailsPageV1/TableDetailsPageV1.tsx | 13 +++- 2 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/hooks/useDeferredTabData.ts diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useDeferredTabData.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useDeferredTabData.ts new file mode 100644 index 000000000000..5df7773881ee --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useDeferredTabData.ts @@ -0,0 +1,62 @@ +/* + * 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 { useEffect, useRef } from 'react'; + +/** + * Fire {@code fetcher} the first time {@code activeTab} matches {@code tabKey}, and never + * again unless one of the {@code resetDeps} changes (in which case the hook arms itself for + * a fresh fetch on the next activation). + * + * Use case: tabs whose data drives a count badge (e.g. Queries (5), Tests (2)) AND whose + * fetch is independent of the entity-detail render. Most users never click these tabs, so + * eagerly fetching them on page load wastes a server round-trip per page view. Deferring + * the fetch until first activation moves the cost off the critical path. + * + * Caveat: the badge count won't appear before the user activates the tab. Render the tab + * label without the count until the fetch resolves; the count populates from the consumer's + * own state once the fetcher resolves. + * + * @param tabKey The tab id this hook is gated on (e.g. 'queries'). + * @param activeTab The currently-active tab id, typically from the URL or page state. + * @param fetcher Async function to run on first activation. Errors are swallowed by + * the caller's own try/catch — this hook just fires it. + * @param resetDeps Dependencies that should reset the "already fetched" flag. Typically + * includes the entity FQN so navigating to a different entity re-arms. + */ +export function useDeferredTabData( + tabKey: string, + activeTab: string | undefined, + fetcher: () => void | Promise, + resetDeps: ReadonlyArray = [] +): void { + const fetchedRef = useRef(false); + + // Reset the once-flag when any reset dep changes — typically when the user navigates to + // a different entity, even if the tab id is the same. The empty-deps default never + // re-arms; useful for ambient hooks that genuinely fire once. + useEffect(() => { + fetchedRef.current = false; + }, resetDeps); + + useEffect(() => { + if (activeTab !== tabKey || fetchedRef.current) { + return; + } + fetchedRef.current = true; + void fetcher(); + // The fetcher closure changes on every render in most callers — depending on it would + // re-fire the fetch. We deliberately depend only on the tab id so we fire exactly once + // per activation window. + }, [activeTab, tabKey]); +} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx index 966e5ead5f89..1190b0f3899e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx @@ -56,6 +56,7 @@ import { TagLabel } from '../../generated/type/tagLabel'; import LimitWrapper from '../../hoc/LimitWrapper'; import { useApplicationStore } from '../../hooks/useApplicationStore'; import { useCustomPages } from '../../hooks/useCustomPages'; +import { useDeferredTabData } from '../../hooks/useDeferredTabData'; import { useFqn } from '../../hooks/useFqn'; import { useSub } from '../../hooks/usePubSub'; import { FeedCounts } from '../../interface/feed.interface'; @@ -785,13 +786,23 @@ const TableDetailsPageV1: React.FC = () => { } }, [tableFqn, isTourOpen, isTourPage, viewBasicPermission]); + // P1.2: getTestCaseFailureCount drives the global red-alert badge in the page chrome, + // so it must run as soon as tableDetails resolves — deferring would mean the user could + // miss a critical "this dataset has failing tests" indicator on first paint. useEffect(() => { if (tableDetails) { - fetchQueryCount(); getTestCaseFailureCount(); } }, [tableDetails?.fullyQualifiedName]); + // P1.2: queryCount only drives the "Queries (N)" tab badge — most users never click that + // tab, so eagerly fetching it on every page load wasted a server round-trip per view. + // Defer until the user actually activates the Queries tab (or any of its column-scoped + // sub-tabs); the badge then populates on first activation. + useDeferredTabData(EntityTabs.TABLE_QUERIES, activeTab, fetchQueryCount, [ + tableDetails?.fullyQualifiedName, + ]); + useSub( 'updateDetails', (suggestion: Suggestion) => { From 101a972f0c3d4dcd4b27b384baace8f8f3362312 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Sun, 10 May 2026 08:18:48 -0700 Subject: [PATCH 03/62] feat(ui-perf): lazy-mount below-fold widgets on landing page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MyDataPage's grid renders every widget eagerly, and each widget runs its own data-fetch effect on mount — so eagerly mounting all of them on first paint pays for multiple below-fold network requests the user may never scroll to. New DeferredWidget wraps each grid item. It uses react-intersection-observer (already a dep) to defer rendering the child tree until the wrapper enters the viewport, with a 200px root-margin look-ahead so a normal scroll never reveals a placeholder. Once revealed, the widget stays mounted — no remount on scroll-out. Users with very tall screens see no behavior change (every widget is already in view on initial paint, so all mount immediately). Users on typical viewports save 2-4 widget worth of network and JS-render cost on the critical path. P1.3 of .context/perceived-latency-design.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../DeferredWidget.component.tsx | 88 +++++++++++++++++++ .../pages/MyDataPage/MyDataPage.component.tsx | 16 +++- 2 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/DeferredWidget/DeferredWidget.component.tsx diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DeferredWidget/DeferredWidget.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/DeferredWidget/DeferredWidget.component.tsx new file mode 100644 index 000000000000..fd258a8325b3 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DeferredWidget/DeferredWidget.component.tsx @@ -0,0 +1,88 @@ +/* + * 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 { ReactNode, useState } from 'react'; +import { useInView } from 'react-intersection-observer'; + +interface DeferredWidgetProps { + /** Content to render once the wrapper enters the viewport. */ + children: ReactNode; + + /** + * Placeholder shown while the wrapper is below the fold. Should reserve roughly the same + * height as the real widget so the page layout doesn't jump on reveal. Defaults to an + * invisible spacer — supply a skeleton if the widget is tall. + */ + placeholder?: ReactNode; + + /** + * IntersectionObserver root margin — how far ahead of the actual viewport edge to start + * loading. Default {@code "200px 0px"} pre-loads widgets that are within ~200px of being + * visible so users don't see placeholders flash during a normal scroll. + */ + rootMargin?: string; + + /** + * Threshold proportion of the wrapper that must be inside the viewport+rootMargin region + * before {@code inView} becomes true. {@code 0} fires as soon as a single pixel intersects + * — what we want for prefetch. + */ + threshold?: number; + + /** Optional class on the wrapper div — for layout grids that style by selector. */ + className?: string; +} + +/** + * Wraps a widget so its children only render once the wrapper enters the viewport (with a + * small look-ahead margin). Once revealed, it stays mounted — no remount on scroll-out. + * + * Use case: landing-page widgets that each fire their own data-fetch effect on mount. Eagerly + * mounting all of them on first paint pays for several below-fold fetches the user may never + * scroll to. Wrapping each in {@link DeferredWidget} keeps initial-paint network traffic + * proportional to what's actually visible. + * + * Caveat: if the user has very tall screens where the entire grid is above the fold, every + * widget mounts immediately and this is a no-op (correct behavior — no over-optimization for + * the rare-case). + */ +export const DeferredWidget = ({ + children, + placeholder, + rootMargin = '200px 0px', + threshold = 0, + className, +}: DeferredWidgetProps) => { + const [hasBeenVisible, setHasBeenVisible] = useState(false); + + const { ref, inView } = useInView({ + rootMargin, + threshold, + // Fire only the first crossing into view — once revealed, the widget mounts and the + // observer detaches. Re-scrolling above and back doesn't re-trigger because the child + // tree stays mounted (we drive that via {@link hasBeenVisible}). + triggerOnce: true, + }); + + if (inView && !hasBeenVisible) { + setHasBeenVisible(true); + } + + return ( +

+ {hasBeenVisible ? children : placeholder ?? null} +
+ ); +}; + +export default DeferredWidget; 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 cab5295a6956..b8d1724e6045 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 @@ -17,6 +17,7 @@ import { isEmpty } from 'lodash'; import { useCallback, useEffect, useMemo, useState } from 'react'; import RGL, { ReactGridLayoutProps, WidthProvider } from 'react-grid-layout'; import { useTranslation } from 'react-i18next'; +import DeferredWidget from '../../components/common/DeferredWidget/DeferredWidget.component'; import Loader from '../../components/common/Loader/Loader'; import { AdvanceSearchProvider } from '../../components/Explore/AdvanceSearchProvider/AdvanceSearchProvider.component'; import CustomiseLandingPageHeader from '../../components/MyData/CustomizableComponents/CustomiseLandingPageHeader/CustomiseLandingPageHeader'; @@ -160,11 +161,18 @@ const MyDataPage = () => { const widgets = useMemo( () => layout.map((widget) => ( + // P1.3: defer below-fold widget mounting until the user actually scrolls them into + // view. Each widget runs its own data-fetch effect on mount; eagerly mounting them + // all on first paint pays for several below-fold network requests the user may + // never scroll to. The 200px root margin pre-loads widgets that are about to enter + // view so a normal scroll never reveals an empty placeholder.
- {getWidgetFromKey({ - widgetConfig: widget, - currentLayout: layout, - })} + + {getWidgetFromKey({ + widgetConfig: widget, + currentLayout: layout, + })} +
)), [layout, isAnnouncementLoading, announcements] From f6654fca48238fa67f01f62a9e36262fdfbc2328 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Sun, 10 May 2026 12:49:02 -0700 Subject: [PATCH 04/62] fix(ui-perf): address PR review feedback for P1 perceived-latency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six review-driven fixes spanning all three P1 commits: DeferredWidget (lazy widget loader): - Move state update out of render — was calling `setHasBeenVisible(true)` in the component body guarded by `!hasBeenVisible`, which is a React anti-pattern (works, but trips warnings and triggers extra render cycles). Now drives state via `useInView`'s `onChange` callback so the update only fires once on the IntersectionObserver event. - Add `fallbackInView: true` so children render eagerly in environments where IntersectionObserver is unavailable (SSR, very old browsers, some Jest setups). - Add `initialInView` opt-out prop so callers (e.g. test wrappers, above-the-fold widgets) can skip the observer entirely. etagInterceptor (304 client-side cache): - Make `attachEtagInterceptor` properly idempotent. The previous "called twice is harmless" claim was wrong — each call stacked another interceptor pair and re-wrapped `validateStatus`. Now guarded by a symbol marker on the AxiosInstance so re-invocation (HMR, test bootstrap re-runs) is a true no-op. - Deep-clone cached body on read (304 hit path) via `structuredClone`. The cache stored a shared reference; consumers that mutated the entity (edit handlers, UI-local state mixing) would leak those mutations back into the cache and the next 304 would serve the mutated copy. useDeferredTabData (per-tab gated fetch): - When `resetDeps` change while the gated tab is already the active tab, fire the fetcher immediately rather than waiting for a tab toggle the user has no reason to make. Without this, navigating from table A → table B while staying on Queries left the badge showing A's count until the user clicked elsewhere and back. - Latest-fetcher captured via a ref so the reset effect always calls the up-to-date closure without re-firing on every render. TableDetailsPageV1: - Reset `queryCount` to 0 when `tableDetails?.fullyQualifiedName` changes. The badge previously kept showing the previous table's count until the deferred fetch (which also re-arms via the fix above) resolved for the new entity. Tested locally: `yarn build` green, eslint + prettier clean on all four touched files, tsc clean (the one pre-existing tsc error at TableDetailsPageV1:746 is unrelated — `findColumnByEntityLink` typing on a `string | undefined`). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../DeferredWidget.component.tsx | 36 +++++++++++++++---- .../ui/src/hooks/useDeferredTabData.ts | 30 ++++++++++++++-- .../TableDetailsPageV1/TableDetailsPageV1.tsx | 12 ++++++- .../resources/ui/src/rest/etagInterceptor.ts | 28 ++++++++++++--- 4 files changed, 91 insertions(+), 15 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DeferredWidget/DeferredWidget.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/DeferredWidget/DeferredWidget.component.tsx index fd258a8325b3..d65f9de0577f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/DeferredWidget/DeferredWidget.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DeferredWidget/DeferredWidget.component.tsx @@ -11,7 +11,7 @@ * limitations under the License. */ -import { ReactNode, useState } from 'react'; +import { ReactNode, useCallback, useState } from 'react'; import { useInView } from 'react-intersection-observer'; interface DeferredWidgetProps { @@ -41,6 +41,15 @@ interface DeferredWidgetProps { /** Optional class on the wrapper div — for layout grids that style by selector. */ className?: string; + + /** + * Render children immediately, bypassing the IntersectionObserver wait. Use cases: + * - Tests where {@code IntersectionObserver} is mocked and never fires + * (the repo's Jest setup installs a no-op mock). + * - Known-above-fold widgets where the observer round-trip is wasted work. + * Defaults to {@code false} (production lazy behaviour). + */ + initialInView?: boolean; } /** @@ -62,22 +71,35 @@ export const DeferredWidget = ({ rootMargin = '200px 0px', threshold = 0, className, + initialInView = false, }: DeferredWidgetProps) => { - const [hasBeenVisible, setHasBeenVisible] = useState(false); + const [hasBeenVisible, setHasBeenVisible] = useState(initialInView); - const { ref, inView } = useInView({ + // Drive the state update through useInView's `onChange` callback rather than reading + // `inView` and calling setState during render. setState-in-render works because of the + // `!hasBeenVisible` guard but it's a React anti-pattern that can trigger extra render + // passes and dev warnings. + const handleChange = useCallback((visible: boolean) => { + if (visible) { + setHasBeenVisible(true); + } + }, []); + + const { ref } = useInView({ rootMargin, threshold, // Fire only the first crossing into view — once revealed, the widget mounts and the // observer detaches. Re-scrolling above and back doesn't re-trigger because the child // tree stays mounted (we drive that via {@link hasBeenVisible}). triggerOnce: true, + // When IntersectionObserver is unavailable (SSR, very old browsers, some test + // environments) treat the wrapper as "in view" so children render eagerly rather than + // staying invisible forever. The repo's Jest setup installs a no-op IO mock that never + // fires — combined with `initialInView` above, tests get sane defaults. + fallbackInView: true, + onChange: handleChange, }); - if (inView && !hasBeenVisible) { - setHasBeenVisible(true); - } - return (
{hasBeenVisible ? children : placeholder ?? null} diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useDeferredTabData.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useDeferredTabData.ts index 5df7773881ee..23ca33311f8a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useDeferredTabData.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useDeferredTabData.ts @@ -16,7 +16,8 @@ import { useEffect, useRef } from 'react'; /** * Fire {@code fetcher} the first time {@code activeTab} matches {@code tabKey}, and never * again unless one of the {@code resetDeps} changes (in which case the hook arms itself for - * a fresh fetch on the next activation). + * a fresh fetch on the next activation — or fires immediately if the gated tab is already + * the active one). * * Use case: tabs whose data drives a count badge (e.g. Queries (5), Tests (2)) AND whose * fetch is independent of the entity-detail render. Most users never click these tabs, so @@ -27,6 +28,12 @@ import { useEffect, useRef } from 'react'; * label without the count until the fetch resolves; the count populates from the consumer's * own state once the fetcher resolves. * + * Re-arm semantics: when any {@code resetDeps} entry changes (typically the entity FQN), the + * hook treats the situation as "new entity, stale data". If the user is already on the gated + * tab when that change happens, we fire {@code fetcher} immediately rather than waiting for + * a tab toggle the user has no reason to do — otherwise the badge would show the previous + * entity's count until something forced a re-activation. + * * @param tabKey The tab id this hook is gated on (e.g. 'queries'). * @param activeTab The currently-active tab id, typically from the URL or page state. * @param fetcher Async function to run on first activation. Errors are swallowed by @@ -41,12 +48,29 @@ export function useDeferredTabData( resetDeps: ReadonlyArray = [] ): void { const fetchedRef = useRef(false); + // Latest-fetcher ref so the resetDeps effect (which deliberately doesn't depend on + // {@code fetcher}) always calls the closure with up-to-date scope (e.g. tableFqn captured + // by the consumer). + const fetcherRef = useRef(fetcher); + fetcherRef.current = fetcher; // Reset the once-flag when any reset dep changes — typically when the user navigates to // a different entity, even if the tab id is the same. The empty-deps default never // re-arms; useful for ambient hooks that genuinely fire once. + // + // If the gated tab is the currently-active tab at reset time, fire immediately so the + // badge updates for the new entity without waiting for a tab toggle. We set the flag to + // true *before* firing so the activation effect below doesn't double-fire on the same + // render cycle. useEffect(() => { fetchedRef.current = false; + if (activeTab === tabKey) { + fetchedRef.current = true; + void fetcherRef.current(); + } + // `activeTab` and `tabKey` are read above but the intent is to fire only on resetDeps + // changes — including them in deps would cause every tab switch to also reset, which + // is the opposite of what we want. }, resetDeps); useEffect(() => { @@ -54,9 +78,9 @@ export function useDeferredTabData( return; } fetchedRef.current = true; - void fetcher(); + void fetcherRef.current(); // The fetcher closure changes on every render in most callers — depending on it would // re-fire the fetch. We deliberately depend only on the tab id so we fire exactly once - // per activation window. + // per activation window. `fetcherRef` keeps the latest closure available. }, [activeTab, tabKey]); } diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx index 1190b0f3899e..2cec32ec47eb 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx @@ -798,11 +798,21 @@ const TableDetailsPageV1: React.FC = () => { // P1.2: queryCount only drives the "Queries (N)" tab badge — most users never click that // tab, so eagerly fetching it on every page load wasted a server round-trip per view. // Defer until the user actually activates the Queries tab (or any of its column-scoped - // sub-tabs); the badge then populates on first activation. + // sub-tabs); the badge then populates on first activation. {@link useDeferredTabData} + // also re-fires on FQN change if the user is already on the Queries tab, so badge counts + // never show stale data from a previous entity. useDeferredTabData(EntityTabs.TABLE_QUERIES, activeTab, fetchQueryCount, [ tableDetails?.fullyQualifiedName, ]); + // Reset the badge count to 0 when navigating to a different entity. Without this the + // badge would show the previous table's queryCount until the deferred fetch resolves, + // which is briefly misleading when navigating between tables that have differing query + // counts. + useEffect(() => { + setQueryCount(0); + }, [tableDetails?.fullyQualifiedName]); + useSub( 'updateDetails', (suggestion: Suggestion) => { diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/etagInterceptor.ts b/openmetadata-ui/src/main/resources/ui/src/rest/etagInterceptor.ts index 90ad9a42c93b..e6bb48d349a2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/etagInterceptor.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/etagInterceptor.ts @@ -91,12 +91,26 @@ function readEtagHeader(response: AxiosResponse): string | undefined { return typeof candidateUpper === 'string' ? candidateUpper : undefined; } +// Marker we stamp on an AxiosInstance once we've installed our interceptor pair. Lets the +// function be properly idempotent — re-invocation (HMR, test setup re-runs, callers that +// accidentally re-init) is a no-op rather than stacking another interceptor pair plus another +// `validateStatus` override on top of itself. +const ETAG_INTERCEPTOR_INSTALLED = Symbol.for( + '@openmetadata/etag-interceptor-installed' +); + /** - * Wire ETag handling into the axios client. Idempotent — calling twice is harmless because - * each call uses a fresh interceptor pair (callers that re-init axios should clear the cache - * via {@link clearEtagCache}). + * Wire ETag handling into the axios client. Idempotent — calling twice on the same client is + * a no-op (guarded via a symbol marker on the instance). Callers that re-init axios from + * scratch should also clear the cache via {@link clearEtagCache}. */ export function attachEtagInterceptor(client: AxiosInstance): void { + const marker = client as unknown as Record; + if (marker[ETAG_INTERCEPTOR_INSTALLED]) { + return; + } + marker[ETAG_INTERCEPTOR_INSTALLED] = true; + // Treat 304 as a success status so axios delivers it through the response interceptor // instead of the error path. Without this, our 304-handling code would have to live in // the error interceptor and intercepts on every error path. @@ -144,11 +158,17 @@ export function attachEtagInterceptor(client: AxiosInstance): void { if (entry) { touch(key, entry); + // Deep-clone the cached body before handing it back. Consumers (UI components, + // utilities, edit handlers) routinely mutate the entity object they receive — adding + // local UI state, normalising fields, stripping properties — and a shared reference + // would let those mutations leak back into the cache. The next 304 would then return + // the mutated copy and cross-page bugs become very hard to track. structuredClone is + // available in all supported browsers (Chrome 98+, Firefox 94+, Safari 15.4+). return { ...response, status: 200, statusText: 'OK (from ETag cache)', - data: entry.data, + data: structuredClone(entry.data), }; } From 5ab5276648f192b4269c580e8007ca071a6bd18f Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Sun, 10 May 2026 14:17:17 -0700 Subject: [PATCH 05/62] test(ui-perf): unblock tests against DeferredWidget on landing page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1.3 wrapped landing-page widgets in DeferredWidget so below-fold widgets mount only after entering the viewport. Existing tests assumed eager mount and broke: Jest (MyDataPage.test.tsx): jsdom's IntersectionObserver mock is a no-op jest.fn() that never fires, so `useInView` keeps `inView` false forever and children never render. The 4 `findByText('KnowledgePanel.*')` assertions in the test couldn't find widgets the wrapper was holding back. Fix: mock `DeferredWidget` to render children directly — same pattern as the existing LimitWrapper / DataInsightProvider mocks. Playwright (CustomizeLandingPage.spec.ts): real browser, real IO — but the 1280x720 CI viewport leaves widgets like `KnowledgePanel.KPI` below the fold. Without a scroll, those widgets are never observed, never mount, and `toBeVisible()` resolves to false. Fix: call `scrollIntoViewIfNeeded()` before each `toBeVisible` assertion in `checkAllDefaultWidgets`, plus the two inline assertions in "Add, Remove and Reset widget" and "Widget drag and drop reordering". Existing `not.toBeVisible()` checks stay as-is — a placeholder div without children is correctly "not visible", so the deferred-mount behaviour matches the assertion intent. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../e2e/Flow/CustomizeLandingPage.spec.ts | 20 ++++++++++++--- .../playwright/utils/customizeLandingPage.ts | 25 ++++++++++++++----- .../src/pages/MyDataPage/MyDataPage.test.tsx | 12 +++++++++ 3 files changed, 47 insertions(+), 10 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/CustomizeLandingPage.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/CustomizeLandingPage.spec.ts index f0d7ac3483ac..f8455eb0ee17 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/CustomizeLandingPage.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/CustomizeLandingPage.spec.ts @@ -213,10 +213,13 @@ test.describe( adminPage.getByTestId('KnowledgePanel.KPI') ).not.toBeVisible(); - // Check if newly added widgets are present on the landing page - await expect( - adminPage.getByTestId('KnowledgePanel.Following') - ).toBeVisible(); + // DeferredWidget only mounts widgets once they enter the viewport — scroll the + // newly-added widget into view before asserting visibility. + const followingWidget = adminPage.getByTestId( + 'KnowledgePanel.Following' + ); + await followingWidget.scrollIntoViewIfNeeded(); + await expect(followingWidget).toBeVisible(); }); await test.step('Resetting the layout flow should work properly', async () => { @@ -299,6 +302,15 @@ test.describe( await removeLandingBanner(adminPage); await waitForAllLoadersToDisappear(adminPage).catch(() => undefined); + // DeferredWidget only mounts widgets once they enter the viewport — scroll each + // into view so the assertion checks the mounted widget rather than the placeholder. + await adminPage + .getByTestId('KnowledgePanel.MyData') + .scrollIntoViewIfNeeded(); + await adminPage + .getByTestId('KnowledgePanel.Following') + .scrollIntoViewIfNeeded(); + await expect .poll( async () => ({ diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts index e8cd14664f57..3061166d5517 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts @@ -173,12 +173,25 @@ export const checkAllDefaultWidgets = async (page: Page) => { await waitForAllLoadersToDisappear(page, 'entity-list-skeleton'); await expect(page.getByTestId('page-layout-v1')).toBeVisible(); - await expect(page.getByTestId('KnowledgePanel.ActivityFeed')).toBeVisible(); - await expect(page.getByTestId('KnowledgePanel.Following')).toBeVisible(); - await expect(page.getByTestId('KnowledgePanel.DataAssets')).toBeVisible(); - await expect(page.getByTestId('KnowledgePanel.MyData')).toBeVisible(); - await expect(page.getByTestId('KnowledgePanel.KPI')).toBeVisible(); - await expect(page.getByTestId('KnowledgePanel.TotalAssets')).toBeVisible(); + + // Widgets below the fold are wrapped in DeferredWidget — IntersectionObserver only mounts + // them once they enter the viewport. Scroll each into view so the test simulates a normal + // user scrolling to inspect the full layout, otherwise below-fold widgets stay placeholders + // and toBeVisible() fails on an element that never rendered. + const widgetIds = [ + 'KnowledgePanel.ActivityFeed', + 'KnowledgePanel.Following', + 'KnowledgePanel.DataAssets', + 'KnowledgePanel.MyData', + 'KnowledgePanel.KPI', + 'KnowledgePanel.TotalAssets', + ]; + + for (const widgetId of widgetIds) { + const widget = page.getByTestId(widgetId); + await widget.scrollIntoViewIfNeeded(); + await expect(widget).toBeVisible(); + } }; export const setUserDefaultPersona = async ( 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 18c5f9519878..38d3a300c1bb 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 @@ -144,6 +144,18 @@ jest.mock('../../hoc/LimitWrapper', () => { .mockImplementation(({ children }) => <>LimitWrapper{children}); }); +jest.mock( + '../../components/common/DeferredWidget/DeferredWidget.component', + () => ({ + __esModule: true, + default: jest + .fn() + .mockImplementation(({ children }: { children: React.ReactNode }) => ( + <>{children} + )), + }) +); + jest.mock('../DataInsightPage/DataInsightProvider', async () => { return jest.fn().mockImplementation(({ children }) => <>{children}); }); From 3782a0cc626f80b380bfc30112dbdeb34a3590cd Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Sun, 10 May 2026 14:45:44 -0700 Subject: [PATCH 06/62] test(ui): force single React copy for core-components in jest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `FailedTestCaseSampleData.test.tsx` (and any other test that mounts a core-components component) fails with: TypeError: Cannot read properties of null (reading 'useContext') at openmetadata-ui-core-components/.../node_modules/react/cjs/react.development.js:1618 at ... openmetadata-ui-core-components/.../dist/SnackbarContent.cjs.js The error is the classic dual-React-instance — the second React (with a null hooks dispatcher) lives at `openmetadata-ui-core-components/src/main/resources/ui/node_modules/react/`, which the core-components package installs for its own dev/test. When the consumer (`openmetadata-ui`) loads the CJS bundle from `dist/*.cjs.js`, Node's `require('react')` resolution walks up from the bundle file and finds the core-components-local React first, not the consumer's copy. Add explicit `moduleNameMapper` entries so every `require('react')` and `require('react-dom')` (and submodules) resolves to the consumer's `/node_modules/react[-dom]`. Single React instance, shared hooks dispatcher, no more null-useContext crash. Verified: failing test now passes, and a sampling of 22 unrelated suites (304 tests across Loader, Lineage, IncidentManager, AlertsListing, etc.) all stay green. Co-Authored-By: Claude Opus 4.7 (1M context) --- openmetadata-ui/src/main/resources/ui/jest.config.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/openmetadata-ui/src/main/resources/ui/jest.config.js b/openmetadata-ui/src/main/resources/ui/jest.config.js index f9b94ac41c31..a0e1f01a2149 100644 --- a/openmetadata-ui/src/main/resources/ui/jest.config.js +++ b/openmetadata-ui/src/main/resources/ui/jest.config.js @@ -75,6 +75,16 @@ module.exports = { '/src/test/unit/mocks/reactColumnResize.mock.js', '^.*/Lineage/Layout/ELKUtil/ELKUtil$': '/src/test/unit/mocks/elkLayout.mock.js', + // Force every `require('react')` / `require('react-dom')` to resolve to the consumer's + // copy. The `openmetadata-ui-core-components` package has its own `node_modules/react` + // (for its own dev/test) — without these mappings the CJS bundle loaded from + // `dist/*.cjs.js` resolves React from the core-components tree, producing a second React + // instance with a null hooks dispatcher and the classic "Invalid hook call ... reading + // 'useContext'" TypeError. + '^react$': '/node_modules/react', + '^react-dom$': '/node_modules/react-dom', + '^react/(.*)$': '/node_modules/react/$1', + '^react-dom/(.*)$': '/node_modules/react-dom/$1', }, transformIgnorePatterns: [ 'node_modules/(?!(@azure/msal-react|react-dnd|react-dnd-html5-backend|dnd-core|@react-dnd/invariant|@react-dnd/asap|@react-dnd/shallowequal|@melloware/react-logviewer|@material/material-color-utilities|@openmetadata/ui-core-components|nanoid|@rjsf/core|@rjsf/utils|@rjsf/validator-ajv8|uuid|elkjs))', From c515580468ca4c82a030098595357a6368070e62 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Sun, 10 May 2026 18:41:55 -0700 Subject: [PATCH 07/62] =?UTF-8?q?revert(ui-perf):=20drop=20P1.3=20Deferred?= =?UTF-8?q?Widget=20=E2=80=94=20broke=20Playwright=20shards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeferredWidget kept its children inside an unkeyed wrapper with no data-testid and no min-height. On the home page, below-fold widgets (KnowledgePanel.KPI etc.) never entered the viewport in a 1280x720 Playwright browser, so: - The wrapper div with `ref` was 0-height and never crossed the IntersectionObserver threshold. - `page.getByTestId('KnowledgePanel.KPI')` matched nothing because the testid lives on the *child* widget that hadn't mounted yet. - `scrollIntoViewIfNeeded()` from my previous fix attempt waited forever for that non-existent element — hanging every shard for 27 minutes before the test timeout fired. All 6 Playwright shards were failing as a result. The earlier scroll-based mitigation was strictly worse than the original code: it turned a 60s timeout into a 27-minute hang. Reverting the DeferredWidget integration on MyDataPage. The component itself, its only consumer, the Jest mock, and the playwright workarounds are all removed. P1.1 (ETag/304) and P1.2 (defer Queries-tab) stand — those are clean wins. Lazy widget mounting can come back later with proper testid-on-wrapper + min-height semantics so tests can find the slot before its contents mount. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../e2e/Flow/CustomizeLandingPage.spec.ts | 20 +--- .../playwright/utils/customizeLandingPage.ts | 25 +--- .../DeferredWidget.component.tsx | 110 ------------------ .../pages/MyDataPage/MyDataPage.component.tsx | 16 +-- .../src/pages/MyDataPage/MyDataPage.test.tsx | 12 -- 5 files changed, 14 insertions(+), 169 deletions(-) delete mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/DeferredWidget/DeferredWidget.component.tsx diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/CustomizeLandingPage.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/CustomizeLandingPage.spec.ts index f8455eb0ee17..f0d7ac3483ac 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/CustomizeLandingPage.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/CustomizeLandingPage.spec.ts @@ -213,13 +213,10 @@ test.describe( adminPage.getByTestId('KnowledgePanel.KPI') ).not.toBeVisible(); - // DeferredWidget only mounts widgets once they enter the viewport — scroll the - // newly-added widget into view before asserting visibility. - const followingWidget = adminPage.getByTestId( - 'KnowledgePanel.Following' - ); - await followingWidget.scrollIntoViewIfNeeded(); - await expect(followingWidget).toBeVisible(); + // Check if newly added widgets are present on the landing page + await expect( + adminPage.getByTestId('KnowledgePanel.Following') + ).toBeVisible(); }); await test.step('Resetting the layout flow should work properly', async () => { @@ -302,15 +299,6 @@ test.describe( await removeLandingBanner(adminPage); await waitForAllLoadersToDisappear(adminPage).catch(() => undefined); - // DeferredWidget only mounts widgets once they enter the viewport — scroll each - // into view so the assertion checks the mounted widget rather than the placeholder. - await adminPage - .getByTestId('KnowledgePanel.MyData') - .scrollIntoViewIfNeeded(); - await adminPage - .getByTestId('KnowledgePanel.Following') - .scrollIntoViewIfNeeded(); - await expect .poll( async () => ({ diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts index 3061166d5517..e8cd14664f57 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts @@ -173,25 +173,12 @@ export const checkAllDefaultWidgets = async (page: Page) => { await waitForAllLoadersToDisappear(page, 'entity-list-skeleton'); await expect(page.getByTestId('page-layout-v1')).toBeVisible(); - - // Widgets below the fold are wrapped in DeferredWidget — IntersectionObserver only mounts - // them once they enter the viewport. Scroll each into view so the test simulates a normal - // user scrolling to inspect the full layout, otherwise below-fold widgets stay placeholders - // and toBeVisible() fails on an element that never rendered. - const widgetIds = [ - 'KnowledgePanel.ActivityFeed', - 'KnowledgePanel.Following', - 'KnowledgePanel.DataAssets', - 'KnowledgePanel.MyData', - 'KnowledgePanel.KPI', - 'KnowledgePanel.TotalAssets', - ]; - - for (const widgetId of widgetIds) { - const widget = page.getByTestId(widgetId); - await widget.scrollIntoViewIfNeeded(); - await expect(widget).toBeVisible(); - } + await expect(page.getByTestId('KnowledgePanel.ActivityFeed')).toBeVisible(); + await expect(page.getByTestId('KnowledgePanel.Following')).toBeVisible(); + await expect(page.getByTestId('KnowledgePanel.DataAssets')).toBeVisible(); + await expect(page.getByTestId('KnowledgePanel.MyData')).toBeVisible(); + await expect(page.getByTestId('KnowledgePanel.KPI')).toBeVisible(); + await expect(page.getByTestId('KnowledgePanel.TotalAssets')).toBeVisible(); }; export const setUserDefaultPersona = async ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DeferredWidget/DeferredWidget.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/DeferredWidget/DeferredWidget.component.tsx deleted file mode 100644 index d65f9de0577f..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/DeferredWidget/DeferredWidget.component.tsx +++ /dev/null @@ -1,110 +0,0 @@ -/* - * 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 { ReactNode, useCallback, useState } from 'react'; -import { useInView } from 'react-intersection-observer'; - -interface DeferredWidgetProps { - /** Content to render once the wrapper enters the viewport. */ - children: ReactNode; - - /** - * Placeholder shown while the wrapper is below the fold. Should reserve roughly the same - * height as the real widget so the page layout doesn't jump on reveal. Defaults to an - * invisible spacer — supply a skeleton if the widget is tall. - */ - placeholder?: ReactNode; - - /** - * IntersectionObserver root margin — how far ahead of the actual viewport edge to start - * loading. Default {@code "200px 0px"} pre-loads widgets that are within ~200px of being - * visible so users don't see placeholders flash during a normal scroll. - */ - rootMargin?: string; - - /** - * Threshold proportion of the wrapper that must be inside the viewport+rootMargin region - * before {@code inView} becomes true. {@code 0} fires as soon as a single pixel intersects - * — what we want for prefetch. - */ - threshold?: number; - - /** Optional class on the wrapper div — for layout grids that style by selector. */ - className?: string; - - /** - * Render children immediately, bypassing the IntersectionObserver wait. Use cases: - * - Tests where {@code IntersectionObserver} is mocked and never fires - * (the repo's Jest setup installs a no-op mock). - * - Known-above-fold widgets where the observer round-trip is wasted work. - * Defaults to {@code false} (production lazy behaviour). - */ - initialInView?: boolean; -} - -/** - * Wraps a widget so its children only render once the wrapper enters the viewport (with a - * small look-ahead margin). Once revealed, it stays mounted — no remount on scroll-out. - * - * Use case: landing-page widgets that each fire their own data-fetch effect on mount. Eagerly - * mounting all of them on first paint pays for several below-fold fetches the user may never - * scroll to. Wrapping each in {@link DeferredWidget} keeps initial-paint network traffic - * proportional to what's actually visible. - * - * Caveat: if the user has very tall screens where the entire grid is above the fold, every - * widget mounts immediately and this is a no-op (correct behavior — no over-optimization for - * the rare-case). - */ -export const DeferredWidget = ({ - children, - placeholder, - rootMargin = '200px 0px', - threshold = 0, - className, - initialInView = false, -}: DeferredWidgetProps) => { - const [hasBeenVisible, setHasBeenVisible] = useState(initialInView); - - // Drive the state update through useInView's `onChange` callback rather than reading - // `inView` and calling setState during render. setState-in-render works because of the - // `!hasBeenVisible` guard but it's a React anti-pattern that can trigger extra render - // passes and dev warnings. - const handleChange = useCallback((visible: boolean) => { - if (visible) { - setHasBeenVisible(true); - } - }, []); - - const { ref } = useInView({ - rootMargin, - threshold, - // Fire only the first crossing into view — once revealed, the widget mounts and the - // observer detaches. Re-scrolling above and back doesn't re-trigger because the child - // tree stays mounted (we drive that via {@link hasBeenVisible}). - triggerOnce: true, - // When IntersectionObserver is unavailable (SSR, very old browsers, some test - // environments) treat the wrapper as "in view" so children render eagerly rather than - // staying invisible forever. The repo's Jest setup installs a no-op IO mock that never - // fires — combined with `initialInView` above, tests get sane defaults. - fallbackInView: true, - onChange: handleChange, - }); - - return ( -
- {hasBeenVisible ? children : placeholder ?? null} -
- ); -}; - -export default DeferredWidget; 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 b8d1724e6045..cab5295a6956 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 @@ -17,7 +17,6 @@ import { isEmpty } from 'lodash'; import { useCallback, useEffect, useMemo, useState } from 'react'; import RGL, { ReactGridLayoutProps, WidthProvider } from 'react-grid-layout'; import { useTranslation } from 'react-i18next'; -import DeferredWidget from '../../components/common/DeferredWidget/DeferredWidget.component'; import Loader from '../../components/common/Loader/Loader'; import { AdvanceSearchProvider } from '../../components/Explore/AdvanceSearchProvider/AdvanceSearchProvider.component'; import CustomiseLandingPageHeader from '../../components/MyData/CustomizableComponents/CustomiseLandingPageHeader/CustomiseLandingPageHeader'; @@ -161,18 +160,11 @@ const MyDataPage = () => { const widgets = useMemo( () => layout.map((widget) => ( - // P1.3: defer below-fold widget mounting until the user actually scrolls them into - // view. Each widget runs its own data-fetch effect on mount; eagerly mounting them - // all on first paint pays for several below-fold network requests the user may - // never scroll to. The 200px root margin pre-loads widgets that are about to enter - // view so a normal scroll never reveals an empty placeholder.
- - {getWidgetFromKey({ - widgetConfig: widget, - currentLayout: layout, - })} - + {getWidgetFromKey({ + widgetConfig: widget, + currentLayout: layout, + })}
)), [layout, isAnnouncementLoading, announcements] 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 38d3a300c1bb..18c5f9519878 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 @@ -144,18 +144,6 @@ jest.mock('../../hoc/LimitWrapper', () => { .mockImplementation(({ children }) => <>LimitWrapper{children}); }); -jest.mock( - '../../components/common/DeferredWidget/DeferredWidget.component', - () => ({ - __esModule: true, - default: jest - .fn() - .mockImplementation(({ children }: { children: React.ReactNode }) => ( - <>{children} - )), - }) -); - jest.mock('../DataInsightPage/DataInsightProvider', async () => { return jest.fn().mockImplementation(({ children }) => <>{children}); }); From d825f3dbe23241ba2ef87d6b2a0b465368cab642 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Sat, 16 May 2026 21:02:24 -0700 Subject: [PATCH 08/62] =?UTF-8?q?fix(ui-perf):=20drop=20Cache-Control=20on?= =?UTF-8?q?=20ETag=20responses=20=E2=80=94=20broke=20DataContract=20Playwr?= =?UTF-8?q?ight=20shards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `Cache-Control: must-revalidate, private` header instructed browsers to HTTP-cache entity GETs and revalidate on `page.reload()`. The DataContract* specs all rely on `await page.reload() → expect(...).toContainText('Failed')` to observe state after triggering a validation, and 5 of 6 PG shards started failing on that pattern. Root cause is in `DataContractRepository.updateLatestResult` (and similar non-standard mutation paths): it deep-copies the entity, mutates only `latestResult`, then calls `getUpdater(...)` without setting `updatedAt`. Version bumps so the ETag differs, but the wider interaction with browser-level conditional GETs surfaces staleness our in-memory axios cache never had to deal with. Fixing every such mutation path is a bigger audit; for the PR landing now we just stop telling the browser to cache. The client-side win is unchanged — `etagInterceptor` still keeps its in-memory cache and sends `If-None-Match` explicitly, so the 304 short-circuit on revisits within a session still fires. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../resources/filters/ETagResponseFilter.java | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/filters/ETagResponseFilter.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/filters/ETagResponseFilter.java index 40b49f18e986..747f977ba34d 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/filters/ETagResponseFilter.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/filters/ETagResponseFilter.java @@ -24,23 +24,25 @@ import org.openmetadata.service.util.EntityETag; /** - * JAX-RS filter that adds {@code ETag} + {@code Cache-Control} headers to entity GET responses - * and short-circuits to {@code 304 Not Modified} when the client's {@code If-None-Match} matches - * the computed ETag. + * JAX-RS filter that adds an {@code ETag} header to entity GET responses and short-circuits to + * {@code 304 Not Modified} when the client's {@code If-None-Match} matches the computed ETag. * *

The 304 path saves the response body bytes on the wire and the client-side render cost on * revisits — the server still computes the entity body (we'd need a cheap version-stamp lookup * to truly skip the work, see design doc), but the network and client savings are immediate. * - *

{@code Cache-Control: must-revalidate, private}: clients (browsers, our Axios interceptor) - * may keep the body but must revalidate via {@code If-None-Match} before reusing it; private - * keeps it out of any shared/proxy cache so per-user data doesn't leak. + *

No {@code Cache-Control} header is emitted on purpose. Adding {@code must-revalidate, + * private} caused the browser to actively HTTP-cache entity GETs and revalidate on + * {@code page.reload()}; non-standard mutation paths that bump entity version without bumping + * {@code updatedAt} (e.g. {@code DataContractRepository.updateLatestResult}) interact poorly + * with browser-level conditional GETs and showed up as Playwright failures on the DataContract + * specs. Our client-side Axios interceptor still gets the 304 win because it caches in memory + * and sends {@code If-None-Match} explicitly — we just stop instructing the browser to do the + * same. */ @Provider public class ETagResponseFilter implements ContainerResponseFilter { - private static final String CACHE_CONTROL_VALUE = "must-revalidate, private"; - @Override public void filter( ContainerRequestContext requestContext, ContainerResponseContext responseContext) { @@ -56,7 +58,6 @@ public void filter( return; } responseContext.getHeaders().putSingle(HttpHeaders.ETAG, etag); - responseContext.getHeaders().putSingle(HttpHeaders.CACHE_CONTROL, CACHE_CONTROL_VALUE); String ifNoneMatch = requestContext.getHeaderString(HttpHeaders.IF_NONE_MATCH); if (ifNoneMatch == null) { From 45c973093e9eee9f030fb51f14494ffeaf9b27bb Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Sun, 17 May 2026 08:14:11 -0700 Subject: [PATCH 09/62] fix(ui-perf): invalidate in-memory ETag cache on any mutation response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same root cause as d825f3dbe2 (dropping Cache-Control), but on the client side. Server endpoints that mutate entities without bumping `version`/`updatedAt` — `addFollower`, `updateVote`, `removeFollower`, `DataContractRepository.updateLatestResult`, and similar — leave the entity's ETag unchanged. Our client-side cache would then send `If-None-Match` on the next GET, the server's 304 short-circuit would fire, and we'd hand back the pre-mutation body. Tests that follow → re-visit → expect "Unfollow" (and sibling vote/validate/restore flows) failed because the UI was painting from stale cached state. Targeted invalidation by URL wouldn't help — the server would still 304 with our stale body because the ETag didn't move. Wiping the whole cache on every non-GET response forces the next read to go out without `If-None-Match`, so the server returns 200 with current state. Cache is in-memory + bounded (200 entries) so the cost is just refetching some entities; correctness wins. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../main/resources/ui/src/rest/etagInterceptor.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/etagInterceptor.ts b/openmetadata-ui/src/main/resources/ui/src/rest/etagInterceptor.ts index e6bb48d349a2..7e38f1162c93 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/etagInterceptor.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/etagInterceptor.ts @@ -147,7 +147,19 @@ export function attachEtagInterceptor(client: AxiosInstance): void { client.interceptors.response.use((response) => { const method = (response.config.method ?? 'get').toLowerCase(); + + // Any successful non-GET response is a mutation — wipe the entire ETag cache so the next + // read on any URL fetches fresh state. We have to be aggressive here because several + // server endpoints mutate an entity without bumping its {@code version}/{@code updatedAt} + // (e.g. {@code addFollower}, {@code updateVote}, {@code DataContractRepository.updateLatestResult}), + // so the entity's ETag is unchanged after the mutation and a targeted invalidation by URL + // wouldn't help — the server would still return 304 with our stale cached body. Clearing + // the cache means the next GET goes out without {@code If-None-Match} and the server + // returns 200 with the current body. The cost is small (per-session in-memory map, 200 + // entries max) and avoids correctness bugs in tests and in production. if (method !== 'get') { + etagCache.clear(); + return response; } From 94e5062b90383dac351f25cca87365e68696f1ed Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Mon, 18 May 2026 11:25:34 -0700 Subject: [PATCH 10/62] fix(ui-perf): address PR review on ETag cache (clone-on-write + 304 race recovery) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two correctness fixes the reviewers flagged: 1. Clone on cache write (Copilot @ etagInterceptor.ts:197). Storing `response.data` by reference let any consumer that mutated the entity object after the initial 200 (stamping UI-local fields, normalising, stripping properties) corrupt the cached entry, so the next 304 returned a clone of the already-mutated object. We now `structuredClone` on write; read-side clone remains as defense-in-depth. 2. Snapshot the cached entry on the request config and use it as a fallback on 304 (Copilot @ etagInterceptor.ts:190). LRU eviction and — much more likely after the previous commit (45c973093e) — a concurrent mutation clearing the cache between the request firing and the response arriving would surface as a 304 with `response.data === undefined`, since `validateStatus` treats 304 as success. Stashing the entry on `config[ETAG_REQUEST_SNAPSHOT]` lets the response interceptor still hand back a synthetic 200 even when the global Map entry is gone. Also dropped the stale `+ Cache-Control` mention from the file-header JSDoc (we removed that header in d825f3dbe2). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../resources/ui/src/rest/etagInterceptor.ts | 53 +++++++++++++++---- 1 file changed, 42 insertions(+), 11 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/etagInterceptor.ts b/openmetadata-ui/src/main/resources/ui/src/rest/etagInterceptor.ts index 7e38f1162c93..7d1809a748c3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/etagInterceptor.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/etagInterceptor.ts @@ -22,8 +22,8 @@ import Qs from 'qs'; /** * Client-side ETag / If-None-Match handling for entity GETs. * - * Pairs with the server-side ETagResponseFilter which emits ETag + Cache-Control on entity - * GET responses and short-circuits to 304 when If-None-Match matches. The flow: + * Pairs with the server-side ETagResponseFilter which emits an ETag header on entity GET + * responses and short-circuits to 304 when If-None-Match matches. The flow: * * 1. First GET to /tables/{fqn} → response with ETag header. We cache (etag, body) keyed * by the canonical URL+params. @@ -99,6 +99,18 @@ const ETAG_INTERCEPTOR_INSTALLED = Symbol.for( '@openmetadata/etag-interceptor-installed' ); +// Per-request stash slot for the cached body that backs the `If-None-Match` header we +// attached. The response interceptor consults this on a 304 response so we can still +// hand the caller a 200 even if the global Map entry was evicted (LRU overflow) or +// wiped (concurrent mutation → cache clear) between the request firing and the +// response arriving. Without this, a concurrent POST that clears the cache while a +// GET is in flight would surface as a 304 with `response.data === undefined`, which +// the caller has every right to treat as a runtime error. +const ETAG_REQUEST_SNAPSHOT = '__etagSnapshot' as const; +type ConfigWithSnapshot = InternalAxiosRequestConfig & { + [ETAG_REQUEST_SNAPSHOT]?: CachedEntry; +}; + /** * Wire ETag handling into the axios client. Idempotent — calling twice on the same client is * a no-op (guarded via a symbol marker on the instance). Callers that re-init axios from @@ -142,6 +154,12 @@ export function attachEtagInterceptor(client: AxiosInstance): void { config.headers = new AxiosHeaders({ 'If-None-Match': entry.etag }); } + // Stash a reference to the cached entry on the config. Safe to share by reference + // because the entry's `data` is the immutable snapshot we wrote at cache-write time + // (see the 200-handling branch below); we only ever hand consumers `structuredClone` + // copies of it. + (config as ConfigWithSnapshot)[ETAG_REQUEST_SNAPSHOT] = entry; + return config; }); @@ -166,16 +184,23 @@ export function attachEtagInterceptor(client: AxiosInstance): void { const key = buildKey(response.config); if (response.status === 304) { - const entry = etagCache.get(key); + // Prefer the live cache entry (re-populating LRU recency) but fall back to the + // per-request snapshot stashed at request time. The snapshot covers two races: + // (1) LRU eviction pushed the entry out between request and response, and + // (2) a concurrent mutation cleared the entire cache. Either way the cached body + // is still on `response.config` and we can hand it back as a 200. + const entry = + etagCache.get(key) ?? + (response.config as ConfigWithSnapshot)[ETAG_REQUEST_SNAPSHOT]; if (entry) { touch(key, entry); // Deep-clone the cached body before handing it back. Consumers (UI components, - // utilities, edit handlers) routinely mutate the entity object they receive — adding + // utilities, edit handlers) sometimes mutate the entity object they receive — adding // local UI state, normalising fields, stripping properties — and a shared reference - // would let those mutations leak back into the cache. The next 304 would then return - // the mutated copy and cross-page bugs become very hard to track. structuredClone is - // available in all supported browsers (Chrome 98+, Firefox 94+, Safari 15.4+). + // would let those mutations leak back into the cache. We also clone on cache write + // (see the 200 branch), so this read-side clone is a defense-in-depth measure in + // case a future caller hands us back the same reference we stored. return { ...response, status: 200, @@ -184,16 +209,22 @@ export function attachEtagInterceptor(client: AxiosInstance): void { }; } - // 304 without a cached body shouldn't happen in normal flow — a stale interceptor - // attaching If-None-Match for a key we no longer hold. Bubble through; the caller - // sees 304 and decides. Better than fabricating a fake 200. + // 304 with no cached body and no stashed snapshot — would only happen if the request + // interceptor didn't run (e.g. caller built the If-None-Match header directly). Bubble + // through; better than fabricating a fake 200. return response; } if (response.status === 200) { const etag = readEtagHeader(response); if (etag && response.data !== undefined) { - touch(key, { etag, data: response.data }); + // Clone on write so the cached entry is decoupled from the live response object the + // caller is about to consume. Without this, a caller that mutates `response.data` + // (common pattern: stamping UI-local fields onto an entity) would corrupt the cache, + // and the next 304 would hand the next caller a clone of the already-mutated object. + // structuredClone is sub-millisecond for typical OpenMetadata entities (5-50 KB JSON) + // and is available in all supported browsers (Chrome 98+, Firefox 94+, Safari 15.4+). + touch(key, { etag, data: structuredClone(response.data) }); } } From a9efde0a610986a7fa27a908935cdf6ee7a5d8ae Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Mon, 18 May 2026 14:45:07 -0700 Subject: [PATCH 11/62] fix(ui-perf): emit Cache-Control: no-store on ETag responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit (94e5062b90) didn't actually unblock the Follow/Unfollow and UpVote/DownVote tests because the regression isn't only in the in-memory client cache — it's in the browser's HTTP cache too. Chrome heuristically caches responses with an ETag but no Cache-Control. On a revisit it sends If-None-Match, the server returns 304 (because addFollower/removeFollower/updateVote add a relationship without bumping entity version/updatedAt, so the ETag is unchanged), and the browser serves its stale cached body — followers/votes that the JS layer recorded post- mutation are gone from the entity object the page now renders. `Cache-Control: no-store` tells the browser to never cache. The only conditional-GET path is our explicit Axios interceptor, and that already clears its in-memory cache on every non-GET response (commit 45c973093e). Keep the ETag header itself — future clients can still opt in. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../resources/filters/ETagResponseFilter.java | 21 ++++++++++++------- .../resources/ui/src/rest/etagInterceptor.ts | 8 +++++-- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/filters/ETagResponseFilter.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/filters/ETagResponseFilter.java index 747f977ba34d..d5722c4044ca 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/filters/ETagResponseFilter.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/filters/ETagResponseFilter.java @@ -31,18 +31,22 @@ * revisits — the server still computes the entity body (we'd need a cheap version-stamp lookup * to truly skip the work, see design doc), but the network and client savings are immediate. * - *

No {@code Cache-Control} header is emitted on purpose. Adding {@code must-revalidate, - * private} caused the browser to actively HTTP-cache entity GETs and revalidate on - * {@code page.reload()}; non-standard mutation paths that bump entity version without bumping - * {@code updatedAt} (e.g. {@code DataContractRepository.updateLatestResult}) interact poorly - * with browser-level conditional GETs and showed up as Playwright failures on the DataContract - * specs. Our client-side Axios interceptor still gets the 304 win because it caches in memory - * and sends {@code If-None-Match} explicitly — we just stop instructing the browser to do the - * same. + *

{@code Cache-Control: no-store} is emitted alongside the ETag. Without an explicit + * Cache-Control, Chrome falls back to heuristic caching for ETag-bearing responses and reuses + * the cached body on a 304. That breaks any mutation path where the server returns 304 with + * stale-relative-to-the-client state — notably the relationship-only mutations + * ({@code addFollower}, {@code removeFollower}, {@code updateVote}, + * {@code DataContractRepository.updateLatestResult}) that don't bump entity {@code version} or + * {@code updatedAt} and therefore leave the ETag unchanged. With {@code no-store} the browser + * never caches a body, so the only conditional-GET path is our explicit Axios interceptor, + * which already invalidates its cache on every mutation response. We keep emitting the ETag + * header so any future client (or our own interceptor) can opt in to conditional GETs. */ @Provider public class ETagResponseFilter implements ContainerResponseFilter { + private static final String CACHE_CONTROL_VALUE = "no-store"; + @Override public void filter( ContainerRequestContext requestContext, ContainerResponseContext responseContext) { @@ -58,6 +62,7 @@ public void filter( return; } responseContext.getHeaders().putSingle(HttpHeaders.ETAG, etag); + responseContext.getHeaders().putSingle(HttpHeaders.CACHE_CONTROL, CACHE_CONTROL_VALUE); String ifNoneMatch = requestContext.getHeaderString(HttpHeaders.IF_NONE_MATCH); if (ifNoneMatch == null) { diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/etagInterceptor.ts b/openmetadata-ui/src/main/resources/ui/src/rest/etagInterceptor.ts index 7d1809a748c3..6180ffbc80ff 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/etagInterceptor.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/etagInterceptor.ts @@ -22,8 +22,12 @@ import Qs from 'qs'; /** * Client-side ETag / If-None-Match handling for entity GETs. * - * Pairs with the server-side ETagResponseFilter which emits an ETag header on entity GET - * responses and short-circuits to 304 when If-None-Match matches. The flow: + * Pairs with the server-side ETagResponseFilter which emits ETag + Cache-Control: no-store on + * entity GET responses and short-circuits to 304 when If-None-Match matches. The server uses + * no-store specifically so the browser doesn't HTTP-cache and serve stale bodies on a 304 from + * mutation paths that don't bump the entity version (addFollower/updateVote/etc.); all + * conditional-GET logic lives here in the in-memory cache, which we invalidate on every + * non-GET response. The flow: * * 1. First GET to /tables/{fqn} → response with ETag header. We cache (etag, body) keyed * by the canonical URL+params. From 83085e42663202a1f0d615f5fead43ba125c37b8 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 21 May 2026 11:32:02 -0700 Subject: [PATCH 12/62] fix(ui-perf): don't re-insert snapshot fallback into the ETag cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the 304 handler falls back to the per-request snapshot — because the live cache was cleared by a concurrent mutation (or a logout) between the request firing and the response arriving — calling touch() to re-insert the snapshot resurrects an entry the rest of the system explicitly invalidated. The next GET for the same URL would hit it, attach If-None-Match, get 304 back from a server that has changed state in a non-version-bumping way (followers/votes/etc.), and serve the same stale body again — exactly the class of bug we cleared the cache to avoid. Only touch() when the entry came from etagCache.get(); leave the snapshot path one-shot. Reported by gitar-bot on PR #28014. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../resources/ui/src/rest/etagInterceptor.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/etagInterceptor.ts b/openmetadata-ui/src/main/resources/ui/src/rest/etagInterceptor.ts index 6180ffbc80ff..79e3bd7ac0b2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/etagInterceptor.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/etagInterceptor.ts @@ -191,13 +191,23 @@ export function attachEtagInterceptor(client: AxiosInstance): void { // Prefer the live cache entry (re-populating LRU recency) but fall back to the // per-request snapshot stashed at request time. The snapshot covers two races: // (1) LRU eviction pushed the entry out between request and response, and - // (2) a concurrent mutation cleared the entire cache. Either way the cached body - // is still on `response.config` and we can hand it back as a 200. + // (2) a concurrent mutation (or logout) cleared the entire cache. Either way the + // cached body is still on `response.config` and we can hand it back as a 200. + const liveEntry = etagCache.get(key); const entry = - etagCache.get(key) ?? + liveEntry ?? (response.config as ConfigWithSnapshot)[ETAG_REQUEST_SNAPSHOT]; if (entry) { - touch(key, entry); + // Only touch (re-insert) when the entry came from the live cache. If we're using + // the snapshot fallback the cache was intentionally cleared (mutation invalidation + // or logout), and re-inserting would resurrect a stale entry — the next GET for + // the same URL would hit it, attach If-None-Match, get 304 from a server that may + // have changed state in a non-version-bumping way (followers/votes), and serve the + // same stale body again. Returning the snapshot one-shot is fine; persisting it + // is not. + if (liveEntry) { + touch(key, entry); + } // Deep-clone the cached body before handing it back. Consumers (UI components, // utilities, edit handlers) sometimes mutate the entity object they receive — adding From 25d79b06881fcb387c3f389e840dffa5fd9ca413 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 21 May 2026 12:50:45 -0700 Subject: [PATCH 13/62] feat(ui-perf): defer Activity Feed activity-count fetch across all entity-detail pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 (PR #28014) shipped the `useDeferredTabData` hook and applied it once (Table → Queries tab). This rolls the pattern out across the 26 other entity-detail pages — Dashboard, Pipeline, Topic, Container, MlModel, SearchIndex, Database, DatabaseSchema, StoredProcedure, APICollection, APIEndpoint, Metric, Chart, DashboardDataModel, Glossary, GlossaryTerm, Domain, DataProduct, Tag, IncidentManager, KnowledgePage, plus the four Drive variants (Directory, File, Spreadsheet, Worksheet). The current `getFeedCounts` helper fires two server requests in parallel: `getTaskCounts` (cheap aggregate) and `getEntityActivityByFqn` (pulls up to 100 ActivityEvent objects just to count them). Task counts drive the always-visible "Open Tasks" button in the page header, so they must stay eager. Activity counts only feed the Activity Feed tab badge, which the vast majority of users never click. Splitting the fetch lets us defer the heavy side. Implementation: - New `fetchEntityTaskCountsInto` and `fetchEntityActivityCountInto` helpers in `CommonUtils.tsx` that do partial setState merges and recompute `totalCount = (conversationCount ?? 0) + totalTasksCount` so the badge stays correct whichever fetch arrives first. - Each entity page replaces its on-mount `getEntityFeedCount()` with `fetchTaskCounts()` (eager) and adds `useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [fqn])`. - Mutation-triggered refetches (e.g. after posting a comment) keep calling the existing `getEntityFeedCount()` for a full refresh of both counts. - IncidentManager is a special case — `TestCasePageTabs` has no ACTIVITY_FEED key and the page only consumes `openTaskCount`. It gets `fetchTaskCounts()` on mount and skips the activity fetch entirely. Across a 30-entity browsing session this saves ~30 server requests + the activity-events payload they'd have hauled, with no visible UI change on first paint (the Activity Feed badge populates on first tab click). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../APIEndpointDetails/APIEndpointDetails.tsx | 31 +++++++- .../ChartDetails/ChartDetails.component.tsx | 31 +++++++- .../DashboardDetails.component.tsx | 31 +++++++- .../DataModels/DataModelDetails.component.tsx | 31 +++++++- .../DataProductsDetailsPage.component.tsx | 25 ++++++- .../DomainDetails/DomainDetails.component.tsx | 29 +++++++- .../Directory/DirectoryDetails.tsx | 31 +++++++- .../DriveService/File/FileDetails.tsx | 31 +++++++- .../Spreadsheet/SpreadsheetDetails.tsx | 31 +++++++- .../Worksheet/WorksheetDetails.tsx | 29 +++++++- .../GlossaryDetails.component.tsx | 29 +++++++- .../GlossaryTermsV1.component.tsx | 32 +++++++- .../KnowledgePageDetailComponent.tsx | 31 +++++++- .../Metric/MetricDetails/MetricDetails.tsx | 31 +++++++- .../MlModelDetail/MlModelDetail.component.tsx | 31 +++++++- .../PipelineDetails.component.tsx | 33 ++++++++- .../TopicDetails/TopicDetails.component.tsx | 31 +++++++- .../APICollectionPage/APICollectionPage.tsx | 36 +++++++-- .../src/pages/ContainerPage/ContainerPage.tsx | 27 ++++++- .../DatabaseDetailsPage.tsx | 34 ++++++++- .../DatabaseSchemaPage.component.tsx | 34 ++++++++- .../IncidentManagerDetailPage.tsx | 17 ++++- .../SearchIndexDetailsPage.tsx | 32 +++++++- .../StoredProcedure/StoredProcedurePage.tsx | 32 +++++++- .../TableDetailsPageV1/TableDetailsPageV1.tsx | 32 +++++++- .../ui/src/pages/TagPage/TagPage.tsx | 32 +++++++- .../resources/ui/src/utils/CommonUtils.tsx | 73 ++++++++++++++++++- 27 files changed, 809 insertions(+), 58 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/APIEndpoint/APIEndpointDetails/APIEndpointDetails.tsx b/openmetadata-ui/src/main/resources/ui/src/components/APIEndpoint/APIEndpointDetails/APIEndpointDetails.tsx index f050fb53925d..f3461a3f3abc 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/APIEndpoint/APIEndpointDetails/APIEndpointDetails.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/APIEndpoint/APIEndpointDetails/APIEndpointDetails.tsx @@ -25,11 +25,16 @@ import { PageType } from '../../../generated/system/ui/page'; import LimitWrapper from '../../../hoc/LimitWrapper'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import { useCustomPages } from '../../../hooks/useCustomPages'; +import { useDeferredTabData } from '../../../hooks/useDeferredTabData'; import { useFqn } from '../../../hooks/useFqn'; import { FeedCounts } from '../../../interface/feed.interface'; import { restoreApiEndPoint } from '../../../rest/apiEndpointsAPI'; import apiEndpointClassBase from '../../../utils/APIEndpoints/APIEndpointClassBase'; -import { getFeedCounts } from '../../../utils/CommonUtils'; +import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getFeedCounts, +} from '../../../utils/CommonUtils'; import { checkIfExpandViewSupported, getDetailsTabWithNewLabel, @@ -179,6 +184,24 @@ const APIEndpointDetails: React.FC = ({ handleFeedCount ); + // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events + // (drives only the Activity Feed tab badge) until first tab activation. + const fetchTaskCounts = useCallback(() => { + if (decodedApiEndpointFqn) { + fetchEntityTaskCountsInto(decodedApiEndpointFqn, setFeedCount); + } + }, [decodedApiEndpointFqn]); + + const fetchActivityCount = useCallback(() => { + if (decodedApiEndpointFqn) { + fetchEntityActivityCountInto( + EntityType.API_ENDPOINT, + decodedApiEndpointFqn, + setFeedCount + ); + } + }, [decodedApiEndpointFqn]); + const afterDeleteAction = useCallback( (isSoftDelete?: boolean) => !isSoftDelete && navigate('/'), [navigate] @@ -209,9 +232,13 @@ const APIEndpointDetails: React.FC = ({ ); useEffect(() => { - getEntityFeedCount(); + fetchTaskCounts(); }, [apiEndpointPermissions, decodedApiEndpointFqn]); + useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ + decodedApiEndpointFqn, + ]); + const tabs = useMemo(() => { const tabLabelMap = getTabLabelMapFromTabs(customizedPage?.tabs); const tabs = apiEndpointClassBase.getAPIEndpointDetailPageTabs({ diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Chart/ChartDetails/ChartDetails.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Chart/ChartDetails/ChartDetails.component.tsx index 06793797b27e..331de7cfc082 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Chart/ChartDetails/ChartDetails.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Chart/ChartDetails/ChartDetails.component.tsx @@ -27,11 +27,16 @@ import { PageType } from '../../../generated/system/ui/page'; import LimitWrapper from '../../../hoc/LimitWrapper'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import { useCustomPages } from '../../../hooks/useCustomPages'; +import { useDeferredTabData } from '../../../hooks/useDeferredTabData'; import { useFqn } from '../../../hooks/useFqn'; import { FeedCounts } from '../../../interface/feed.interface'; import { restoreChart } from '../../../rest/chartsAPI'; import chartDetailsClassBase from '../../../utils/ChartDetailsClassBase'; -import { getFeedCounts } from '../../../utils/CommonUtils'; +import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getFeedCounts, +} from '../../../utils/CommonUtils'; import { checkIfExpandViewSupported, getDetailsTabWithNewLabel, @@ -127,10 +132,32 @@ const ChartDetails = ({ const getEntityFeedCount = () => getFeedCounts(EntityType.CHART, decodedChartFQN, handleFeedCount); + // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events + // (drives only the Activity Feed tab badge) until first tab activation. + const fetchTaskCounts = useCallback(() => { + if (decodedChartFQN) { + fetchEntityTaskCountsInto(decodedChartFQN, setFeedCount); + } + }, [decodedChartFQN]); + + const fetchActivityCount = useCallback(() => { + if (decodedChartFQN) { + fetchEntityActivityCountInto( + EntityType.CHART, + decodedChartFQN, + setFeedCount + ); + } + }, [decodedChartFQN]); + useEffect(() => { - getEntityFeedCount(); + fetchTaskCounts(); }, [decodedChartFQN]); + useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ + decodedChartFQN, + ]); + const handleTabChange = (activeKey: string) => { if (activeKey !== activeTab) { navigate( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DashboardDetails/DashboardDetails.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DashboardDetails/DashboardDetails.component.tsx index 34b8aeea04aa..87daeac49ad1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DashboardDetails/DashboardDetails.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DashboardDetails/DashboardDetails.component.tsx @@ -28,10 +28,15 @@ import { PageType } from '../../../generated/system/ui/uiCustomization'; import LimitWrapper from '../../../hoc/LimitWrapper'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import { useCustomPages } from '../../../hooks/useCustomPages'; +import { useDeferredTabData } from '../../../hooks/useDeferredTabData'; import { useFqn } from '../../../hooks/useFqn'; import { FeedCounts } from '../../../interface/feed.interface'; import { restoreDashboard } from '../../../rest/dashboardAPI'; -import { getFeedCounts } from '../../../utils/CommonUtils'; +import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getFeedCounts, +} from '../../../utils/CommonUtils'; import { checkIfExpandViewSupported, getDetailsTabWithNewLabel, @@ -139,10 +144,32 @@ const DashboardDetails = ({ const getEntityFeedCount = () => getFeedCounts(EntityType.DASHBOARD, decodedDashboardFQN, handleFeedCount); + // P2-A: keep task counts eager (drive header "Open Tasks" button); defer the activity + // events fetch (drives only the Activity Feed tab badge) until first tab activation. + const fetchTaskCounts = useCallback(() => { + if (decodedDashboardFQN) { + fetchEntityTaskCountsInto(decodedDashboardFQN, setFeedCount); + } + }, [decodedDashboardFQN]); + + const fetchActivityCount = useCallback(() => { + if (decodedDashboardFQN) { + fetchEntityActivityCountInto( + EntityType.DASHBOARD, + decodedDashboardFQN, + setFeedCount + ); + } + }, [decodedDashboardFQN]); + useEffect(() => { - getEntityFeedCount(); + fetchTaskCounts(); }, [decodedDashboardFQN]); + useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ + decodedDashboardFQN, + ]); + const handleTabChange = (activeKey: string) => { if (activeKey !== activeTab) { navigate( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DataModel/DataModels/DataModelDetails.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DataModel/DataModels/DataModelDetails.component.tsx index fcc5293e36b9..b9b6793fe9f9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DataModel/DataModels/DataModelDetails.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DataModel/DataModels/DataModelDetails.component.tsx @@ -24,10 +24,15 @@ import { DashboardDataModel } from '../../../../generated/entity/data/dashboardD import { Operation } from '../../../../generated/entity/policies/policy'; import { PageType } from '../../../../generated/system/ui/page'; import { useCustomPages } from '../../../../hooks/useCustomPages'; +import { useDeferredTabData } from '../../../../hooks/useDeferredTabData'; import { useFqn } from '../../../../hooks/useFqn'; import { FeedCounts } from '../../../../interface/feed.interface'; import { restoreDataModel } from '../../../../rest/dataModelsAPI'; -import { getFeedCounts } from '../../../../utils/CommonUtils'; +import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getFeedCounts, +} from '../../../../utils/CommonUtils'; import { checkIfExpandViewSupported, getDetailsTabWithNewLabel, @@ -99,10 +104,32 @@ const DataModelDetails = ({ ); }; + // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events + // (drives only the Activity Feed tab badge) until first tab activation. + const fetchTaskCounts = useCallback(() => { + if (decodedDataModelFQN) { + fetchEntityTaskCountsInto(decodedDataModelFQN, setFeedCount); + } + }, [decodedDataModelFQN]); + + const fetchActivityCount = useCallback(() => { + if (decodedDataModelFQN) { + fetchEntityActivityCountInto( + EntityType.DASHBOARD_DATA_MODEL, + decodedDataModelFQN, + setFeedCount + ); + } + }, [decodedDataModelFQN]); + useEffect(() => { - decodedDataModelFQN && getEntityFeedCount(); + decodedDataModelFQN && fetchTaskCounts(); }, [decodedDataModelFQN]); + useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ + decodedDataModelFQN, + ]); + const handleUpdateDisplayName = async (data: EntityName) => { if (isUndefined(dataModelData)) { return; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx index 52ab46f02b9e..bf2ac35d4e50 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx @@ -60,6 +60,7 @@ import { Style } from '../../../generated/type/tagLabel'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import { useCustomPages } from '../../../hooks/useCustomPages'; import { useDataAccessRequest } from '../../../hooks/useDataAccessRequest'; +import { useDeferredTabData } from '../../../hooks/useDeferredTabData'; import { useFqn } from '../../../hooks/useFqn'; import { useMarketplaceStore } from '../../../hooks/useMarketplaceStore'; import { FeedCounts } from '../../../interface/feed.interface'; @@ -72,6 +73,8 @@ import { getContractByEntityId } from '../../../rest/contractAPI'; import { getDataProductPortsView } from '../../../rest/dataProductAPI'; import { searchQuery } from '../../../rest/searchAPI'; import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, getEntityDeleteMessage, getFeedCounts, hasEditAccess, @@ -198,6 +201,22 @@ const DataProductsDetailsPage = ({ ); }; + // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events + // (drives only the Activity Feed tab badge) until first tab activation. + const fetchTaskCounts = useCallback(() => { + const fqn = dataProduct.fullyQualifiedName ?? ''; + if (fqn) { + fetchEntityTaskCountsInto(fqn, setFeedCount); + } + }, [dataProduct.fullyQualifiedName]); + + const fetchActivityCount = useCallback(() => { + const fqn = dataProduct.fullyQualifiedName ?? ''; + if (fqn) { + fetchEntityActivityCountInto(EntityType.DATA_PRODUCT, fqn, setFeedCount); + } + }, [dataProduct.fullyQualifiedName]); + const openAssetDrawer = useCallback(() => { setIsAssetDrawerOpen(true); }, []); @@ -666,12 +685,16 @@ const DataProductsDetailsPage = ({ useEffect(() => { fetchDataProductPermission(); fetchDataProductAssets(); - getEntityFeedCount(); + fetchTaskCounts(); fetchActiveAnnouncement(); fetchDataProductContract(); fetchPortCounts(); }, [dataProductFqn, fetchPortCounts]); + useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ + dataProductFqn, + ]); + const toggleTabExpanded = () => { setIsTabExpanded(!isTabExpanded); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetails/DomainDetails.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetails/DomainDetails.component.tsx index 6482d50cf014..8ee5c4dc2af2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetails/DomainDetails.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetails/DomainDetails.component.tsx @@ -56,6 +56,7 @@ import { PageType } from '../../../generated/system/ui/page'; import { Style } from '../../../generated/type/tagLabel'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import { useCustomPages } from '../../../hooks/useCustomPages'; +import { useDeferredTabData } from '../../../hooks/useDeferredTabData'; import { useMarketplaceStore } from '../../../hooks/useMarketplaceStore'; import { AnnouncementEntity, @@ -67,7 +68,11 @@ import { } from '../../../rest/dataProductAPI'; import { addDomains, patchDomains } from '../../../rest/domainAPI'; import { searchQuery } from '../../../rest/searchAPI'; -import { getFeedCounts } from '../../../utils/CommonUtils'; +import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getFeedCounts, +} from '../../../utils/CommonUtils'; import { createEntityWithCoverImage } from '../../../utils/CoverImageUploadUtils'; import { checkIfExpandViewSupported, @@ -356,6 +361,22 @@ const DomainDetails = ({ ); }; + // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events + // (drives only the Activity Feed tab badge) until first tab activation. + const fetchTaskCounts = useCallback(() => { + const fqn = domain.fullyQualifiedName ?? ''; + if (fqn) { + fetchEntityTaskCountsInto(fqn, setFeedCount); + } + }, [domain.fullyQualifiedName]); + + const fetchActivityCount = useCallback(() => { + const fqn = domain.fullyQualifiedName ?? ''; + if (fqn) { + fetchEntityActivityCountInto(EntityType.DOMAIN, fqn, setFeedCount); + } + }, [domain.fullyQualifiedName]); + const handleDataProductSubmit = useCallback( async (data: DomainFormValues) => { const formData = transformDomainFormData( @@ -915,10 +936,14 @@ const DomainDetails = ({ fetchDomainPermission(); fetchDomainAssets(); fetchDataProducts(); - getEntityFeedCount(); + fetchTaskCounts(); fetchActiveAnnouncement(); }, [domain.fullyQualifiedName]); + useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ + domain.fullyQualifiedName, + ]); + useEffect(() => { fetchSubDomainsCount(); }, [domainFqn, fetchSubDomainsCount]); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Directory/DirectoryDetails.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Directory/DirectoryDetails.tsx index 9d32bb90f390..afd7720f16d8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Directory/DirectoryDetails.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Directory/DirectoryDetails.tsx @@ -35,10 +35,15 @@ import { TagLabel } from '../../../generated/type/tagLabel'; import LimitWrapper from '../../../hoc/LimitWrapper'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import { useCustomPages } from '../../../hooks/useCustomPages'; +import { useDeferredTabData } from '../../../hooks/useDeferredTabData'; import { useFqn } from '../../../hooks/useFqn'; import { FeedCounts } from '../../../interface/feed.interface'; import { restoreDriveAsset } from '../../../rest/driveAPI'; -import { getFeedCounts } from '../../../utils/CommonUtils'; +import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getFeedCounts, +} from '../../../utils/CommonUtils'; import { checkIfExpandViewSupported, getDetailsTabWithNewLabel, @@ -271,6 +276,24 @@ function DirectoryDetails({ const getEntityFeedCount = () => getFeedCounts(EntityType.DIRECTORY, decodedDirectoryFQN, handleFeedCount); + // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events + // (drives only the Activity Feed tab badge) until first tab activation. + const fetchTaskCounts = useCallback(() => { + if (decodedDirectoryFQN) { + fetchEntityTaskCountsInto(decodedDirectoryFQN, setFeedCount); + } + }, [decodedDirectoryFQN]); + + const fetchActivityCount = useCallback(() => { + if (decodedDirectoryFQN) { + fetchEntityActivityCountInto( + EntityType.DIRECTORY, + decodedDirectoryFQN, + setFeedCount + ); + } + }, [decodedDirectoryFQN]); + const afterDeleteAction = useCallback( (isSoftDelete?: boolean) => !isSoftDelete && navigate('/'), [] @@ -323,9 +346,13 @@ function DirectoryDetails({ ); useEffect(() => { - getEntityFeedCount(); + fetchTaskCounts(); }, [directoryPermissions, decodedDirectoryFQN]); + useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ + decodedDirectoryFQN, + ]); + const tabs = useMemo(() => { const tabLabelMap = getTabLabelMapFromTabs(customizedPage?.tabs); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DriveService/File/FileDetails.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DriveService/File/FileDetails.tsx index f32b12850c8f..f7f5a045be7e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DriveService/File/FileDetails.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DriveService/File/FileDetails.tsx @@ -34,10 +34,15 @@ import { TagLabel } from '../../../generated/type/tagLabel'; import LimitWrapper from '../../../hoc/LimitWrapper'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import { useCustomPages } from '../../../hooks/useCustomPages'; +import { useDeferredTabData } from '../../../hooks/useDeferredTabData'; import { useFqn } from '../../../hooks/useFqn'; import { FeedCounts } from '../../../interface/feed.interface'; import { restoreDriveAsset } from '../../../rest/driveAPI'; -import { getFeedCounts } from '../../../utils/CommonUtils'; +import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getFeedCounts, +} from '../../../utils/CommonUtils'; import { checkIfExpandViewSupported, getDetailsTabWithNewLabel, @@ -245,6 +250,24 @@ function FileDetails({ const getEntityFeedCount = () => getFeedCounts(EntityType.FILE, decodedFileFQN, handleFeedCount); + // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events + // (drives only the Activity Feed tab badge) until first tab activation. + const fetchTaskCounts = useCallback(() => { + if (decodedFileFQN) { + fetchEntityTaskCountsInto(decodedFileFQN, setFeedCount); + } + }, [decodedFileFQN]); + + const fetchActivityCount = useCallback(() => { + if (decodedFileFQN) { + fetchEntityActivityCountInto( + EntityType.FILE, + decodedFileFQN, + setFeedCount + ); + } + }, [decodedFileFQN]); + const afterDeleteAction = useCallback( (isSoftDelete?: boolean) => !isSoftDelete && navigate('/'), [] @@ -293,9 +316,13 @@ function FileDetails({ ); useEffect(() => { - getEntityFeedCount(); + fetchTaskCounts(); }, [filePermissions, decodedFileFQN]); + useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ + decodedFileFQN, + ]); + const tabs = useMemo(() => { const tabLabelMap = getTabLabelMapFromTabs(customizedPage?.tabs); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Spreadsheet/SpreadsheetDetails.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Spreadsheet/SpreadsheetDetails.tsx index 059e86405071..9c4ba20d339e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Spreadsheet/SpreadsheetDetails.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Spreadsheet/SpreadsheetDetails.tsx @@ -34,10 +34,15 @@ import { TagLabel } from '../../../generated/type/tagLabel'; import LimitWrapper from '../../../hoc/LimitWrapper'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import { useCustomPages } from '../../../hooks/useCustomPages'; +import { useDeferredTabData } from '../../../hooks/useDeferredTabData'; import { useFqn } from '../../../hooks/useFqn'; import { FeedCounts } from '../../../interface/feed.interface'; import { restoreDriveAsset } from '../../../rest/driveAPI'; -import { getFeedCounts } from '../../../utils/CommonUtils'; +import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getFeedCounts, +} from '../../../utils/CommonUtils'; import { checkIfExpandViewSupported, getDetailsTabWithNewLabel, @@ -253,6 +258,24 @@ function SpreadsheetDetails({ handleFeedCount ); + // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events + // (drives only the Activity Feed tab badge) until first tab activation. + const fetchTaskCounts = useCallback(() => { + if (decodedSpreadsheetFQN) { + fetchEntityTaskCountsInto(decodedSpreadsheetFQN, setFeedCount); + } + }, [decodedSpreadsheetFQN]); + + const fetchActivityCount = useCallback(() => { + if (decodedSpreadsheetFQN) { + fetchEntityActivityCountInto( + EntityType.SPREADSHEET, + decodedSpreadsheetFQN, + setFeedCount + ); + } + }, [decodedSpreadsheetFQN]); + const afterDeleteAction = useCallback( (isSoftDelete?: boolean) => !isSoftDelete && navigate('/'), [] @@ -305,9 +328,13 @@ function SpreadsheetDetails({ ); useEffect(() => { - getEntityFeedCount(); + fetchTaskCounts(); }, [spreadsheetPermissions, decodedSpreadsheetFQN]); + useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ + decodedSpreadsheetFQN, + ]); + const tabs = useMemo(() => { const tabLabelMap = getTabLabelMapFromTabs(customizedPage?.tabs); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Worksheet/WorksheetDetails.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Worksheet/WorksheetDetails.tsx index 4623b5407a8b..2cb1a45ae40f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Worksheet/WorksheetDetails.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Worksheet/WorksheetDetails.tsx @@ -34,9 +34,14 @@ import { TagLabel } from '../../../generated/type/tagLabel'; import LimitWrapper from '../../../hoc/LimitWrapper'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import { useCustomPages } from '../../../hooks/useCustomPages'; +import { useDeferredTabData } from '../../../hooks/useDeferredTabData'; import { FeedCounts } from '../../../interface/feed.interface'; import { restoreDriveAsset } from '../../../rest/driveAPI'; -import { getFeedCounts } from '../../../utils/CommonUtils'; +import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getFeedCounts, +} from '../../../utils/CommonUtils'; import { checkIfExpandViewSupported, getDetailsTabWithNewLabel, @@ -253,6 +258,22 @@ function WorksheetDetails({ handleFeedCount ); + // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events + // (drives only the Activity Feed tab badge) until first tab activation. + const fetchTaskCounts = useCallback(() => { + const fqn = worksheetDetails.fullyQualifiedName ?? ''; + if (fqn) { + fetchEntityTaskCountsInto(fqn, setFeedCount); + } + }, [worksheetDetails.fullyQualifiedName]); + + const fetchActivityCount = useCallback(() => { + const fqn = worksheetDetails.fullyQualifiedName ?? ''; + if (fqn) { + fetchEntityActivityCountInto(EntityType.WORKSHEET, fqn, setFeedCount); + } + }, [worksheetDetails.fullyQualifiedName]); + const afterDeleteAction = useCallback( (isSoftDelete?: boolean) => !isSoftDelete && navigate('/'), [] @@ -306,10 +327,14 @@ function WorksheetDetails({ useEffect(() => { if (worksheetDetails.fullyQualifiedName) { - getEntityFeedCount(); + fetchTaskCounts(); } }, [worksheetPermissions, worksheetDetails.fullyQualifiedName]); + useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ + worksheetDetails.fullyQualifiedName, + ]); + const tabs = useMemo(() => { const tabLabelMap = getTabLabelMapFromTabs(customizedPage?.tabs); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.component.tsx index 716aa17b65d6..ac05c591c146 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.component.tsx @@ -20,8 +20,13 @@ import { FEED_COUNT_INITIAL_DATA } from '../../../constants/entity.constants'; import { EntityTabs, EntityType } from '../../../enums/entity.enum'; import { PageType } from '../../../generated/system/ui/page'; import { useCustomPages } from '../../../hooks/useCustomPages'; +import { useDeferredTabData } from '../../../hooks/useDeferredTabData'; import { FeedCounts } from '../../../interface/feed.interface'; -import { getFeedCounts } from '../../../utils/CommonUtils'; +import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getFeedCounts, +} from '../../../utils/CommonUtils'; import { checkIfExpandViewSupported, getDetailsTabWithNewLabel, @@ -72,6 +77,22 @@ const GlossaryDetails = ({ ); }; + // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events + // (drives only the Activity Feed tab badge) until first tab activation. + const fetchTaskCounts = useCallback(() => { + const fqn = glossary.fullyQualifiedName ?? ''; + if (fqn) { + fetchEntityTaskCountsInto(fqn, setFeedCount); + } + }, [glossary.fullyQualifiedName]); + + const fetchActivityCount = useCallback(() => { + const fqn = glossary.fullyQualifiedName ?? ''; + if (fqn) { + fetchEntityActivityCountInto(EntityType.GLOSSARY, fqn, setFeedCount); + } + }, [glossary.fullyQualifiedName]); + const handleTabChange = (activeKey: string) => { if (activeKey !== activeTab) { navigate( @@ -164,9 +185,13 @@ const GlossaryDetails = ({ ]); useEffect(() => { - getEntityFeedCount(); + fetchTaskCounts(); }, [glossary.fullyQualifiedName]); + useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ + glossary.fullyQualifiedName, + ]); + const isExpandViewSupported = useMemo( () => checkIfExpandViewSupported(tabs[0], activeTab, PageType.Glossary), [tabs[0], activeTab] diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/GlossaryTermsV1.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/GlossaryTermsV1.component.tsx index a8467e5f4bd6..7d5c0fb0d8a1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/GlossaryTermsV1.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/GlossaryTermsV1.component.tsx @@ -29,11 +29,16 @@ import { import { Operation } from '../../../generated/entity/policies/policy'; import { PageType } from '../../../generated/system/ui/page'; import { useCustomPages } from '../../../hooks/useCustomPages'; +import { useDeferredTabData } from '../../../hooks/useDeferredTabData'; import { useFqn } from '../../../hooks/useFqn'; import { FeedCounts } from '../../../interface/feed.interface'; import { MOCK_GLOSSARY_NO_PERMISSIONS } from '../../../mocks/Glossary.mock'; import { searchQuery } from '../../../rest/searchAPI'; -import { getFeedCounts } from '../../../utils/CommonUtils'; +import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getFeedCounts, +} from '../../../utils/CommonUtils'; import { checkIfExpandViewSupported, getDetailsTabWithNewLabel, @@ -126,6 +131,22 @@ const GlossaryTermsV1 = ({ ); }; + // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events + // (drives only the Activity Feed tab badge) until first tab activation. + const fetchTaskCounts = useCallback(() => { + const fqn = glossaryTerm.fullyQualifiedName ?? ''; + if (fqn) { + fetchEntityTaskCountsInto(fqn, setFeedCount); + } + }, [glossaryTerm.fullyQualifiedName]); + + const fetchActivityCount = useCallback(() => { + const fqn = glossaryTerm.fullyQualifiedName ?? ''; + if (fqn) { + fetchEntityActivityCountInto(EntityType.GLOSSARY_TERM, fqn, setFeedCount); + } + }, [glossaryTerm.fullyQualifiedName]); + const fetchGlossaryTermAssets = async () => { if (glossaryTerm) { try { @@ -225,10 +246,17 @@ const GlossaryTermsV1 = ({ fetchGlossaryTermAssets(); }, 500); if (!isVersionView) { - getEntityFeedCount(); + fetchTaskCounts(); } }, [glossaryFqn, isVersionView]); + useDeferredTabData( + EntityTabs.ACTIVITY_FEED, + isVersionView ? undefined : activeTab, + fetchActivityCount, + [glossaryFqn, isVersionView] + ); + const updatedGlossaryTerm = useMemo(() => { const name = isVersionView ? getEntityVersionByField( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeCenter/KnowledgePageDetailComponent/KnowledgePageDetailComponent.tsx b/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeCenter/KnowledgePageDetailComponent/KnowledgePageDetailComponent.tsx index a57fd57c2adc..47bdc6c63fc8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeCenter/KnowledgePageDetailComponent/KnowledgePageDetailComponent.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeCenter/KnowledgePageDetailComponent/KnowledgePageDetailComponent.tsx @@ -63,6 +63,7 @@ import { TagLabel } from '../../../generated/type/tagLabel'; import { useCurrentUserPreferences } from '../../../hooks/currentUserStore/useCurrentUserStore'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import useCustomLocation from '../../../hooks/useCustomLocation/useCustomLocation'; +import { useDeferredTabData } from '../../../hooks/useDeferredTabData'; import { FeedCounts } from '../../../interface/feed.interface'; import { ContentChangeState, @@ -78,7 +79,11 @@ import { unFollowKnowledgePage, updateKnowledgePageVote, } from '../../../rest/knowledgeCenterAPI'; -import { getFeedCounts } from '../../../utils/CommonUtils'; +import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getFeedCounts, +} from '../../../utils/CommonUtils'; import contextCenterClassBase from '../../../utils/ContextCenterClassBase'; import i18n from '../../../utils/i18next/LocalUtil'; import { @@ -527,6 +532,24 @@ const KnowledgePageDetailComponent: FC = ({ } }; + // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events + // (drives only the Activity Feed tab badge) until first tab activation. + const fetchTaskCounts = useCallback(() => { + if (knowledgePage?.fullyQualifiedName) { + fetchEntityTaskCountsInto(knowledgePage.fullyQualifiedName, setFeedCount); + } + }, [knowledgePage?.fullyQualifiedName]); + + const fetchActivityCount = useCallback(() => { + if (knowledgePage?.fullyQualifiedName) { + fetchEntityActivityCountInto( + EntityType.KNOWLEDGE_PAGE, + knowledgePage.fullyQualifiedName, + setFeedCount + ); + } + }, [knowledgePage?.fullyQualifiedName]); + const handleTabChange = (activeKey: string) => { if (activeKey !== activeTab) { navigate(contextCenterClassBase.getArticlePath(fqn, activeKey)); @@ -659,10 +682,14 @@ const KnowledgePageDetailComponent: FC = ({ useEffect(() => { if (knowledgePage?.fullyQualifiedName) { - getEntityFeedCount(); + fetchTaskCounts(); } }, [knowledgePage?.fullyQualifiedName]); + useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ + knowledgePage?.fullyQualifiedName, + ]); + useEffect(() => { const handleBeforeUnload = (event: BeforeUnloadEvent) => { if (isContentUnsaved) { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Metric/MetricDetails/MetricDetails.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Metric/MetricDetails/MetricDetails.tsx index 381195240d6d..d3b42a733a2c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Metric/MetricDetails/MetricDetails.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Metric/MetricDetails/MetricDetails.tsx @@ -27,10 +27,15 @@ import { PageType } from '../../../generated/system/ui/page'; import LimitWrapper from '../../../hoc/LimitWrapper'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import { useCustomPages } from '../../../hooks/useCustomPages'; +import { useDeferredTabData } from '../../../hooks/useDeferredTabData'; import { useFqn } from '../../../hooks/useFqn'; import { FeedCounts } from '../../../interface/feed.interface'; import { restoreMetric } from '../../../rest/metricsAPI'; -import { getFeedCounts } from '../../../utils/CommonUtils'; +import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getFeedCounts, +} from '../../../utils/CommonUtils'; import { checkIfExpandViewSupported, getDetailsTabWithNewLabel, @@ -191,6 +196,24 @@ const MetricDetails: React.FC = ({ const getEntityFeedCount = () => getFeedCounts(EntityType.METRIC, decodedMetricFqn, handleFeedCount); + // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events + // (drives only the Activity Feed tab badge) until first tab activation. + const fetchTaskCounts = useCallback(() => { + if (decodedMetricFqn) { + fetchEntityTaskCountsInto(decodedMetricFqn, setFeedCount); + } + }, [decodedMetricFqn]); + + const fetchActivityCount = useCallback(() => { + if (decodedMetricFqn) { + fetchEntityActivityCountInto( + EntityType.METRIC, + decodedMetricFqn, + setFeedCount + ); + } + }, [decodedMetricFqn]); + const afterDeleteAction = useCallback( (isSoftDelete?: boolean) => !isSoftDelete && navigate(ROUTES.METRICS), [] @@ -231,9 +254,13 @@ const MetricDetails: React.FC = ({ ); useEffect(() => { - getEntityFeedCount(); + fetchTaskCounts(); }, [metricPermissions, decodedMetricFqn]); + useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ + decodedMetricFqn, + ]); + const tabs = useMemo(() => { const tabLabelMap = getTabLabelMapFromTabs(customizedPage?.tabs); const tabs = metricDetailsClassBase.getMetricDetailPageTabs({ diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MlModel/MlModelDetail/MlModelDetail.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/MlModel/MlModelDetail/MlModelDetail.component.tsx index 35e9b402fbbb..71431ab21eaa 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MlModel/MlModelDetail/MlModelDetail.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/MlModel/MlModelDetail/MlModelDetail.component.tsx @@ -31,10 +31,15 @@ import { PageType } from '../../../generated/system/ui/page'; import LimitWrapper from '../../../hoc/LimitWrapper'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import { useCustomPages } from '../../../hooks/useCustomPages'; +import { useDeferredTabData } from '../../../hooks/useDeferredTabData'; import { useFqn } from '../../../hooks/useFqn'; import { FeedCounts } from '../../../interface/feed.interface'; import { restoreMlmodel } from '../../../rest/mlModelAPI'; -import { getFeedCounts } from '../../../utils/CommonUtils'; +import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getFeedCounts, +} from '../../../utils/CommonUtils'; import { checkIfExpandViewSupported, getDetailsTabWithNewLabel, @@ -142,12 +147,34 @@ const MlModelDetail: FC = ({ const fetchEntityFeedCount = () => getFeedCounts(EntityType.MLMODEL, decodedMlModelFqn, handleFeedCount); + // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events + // (drives only the Activity Feed tab badge) until first tab activation. + const fetchTaskCounts = useCallback(() => { + if (decodedMlModelFqn) { + fetchEntityTaskCountsInto(decodedMlModelFqn, setFeedCount); + } + }, [decodedMlModelFqn]); + + const fetchActivityCount = useCallback(() => { + if (decodedMlModelFqn) { + fetchEntityActivityCountInto( + EntityType.MLMODEL, + decodedMlModelFqn, + setFeedCount + ); + } + }, [decodedMlModelFqn]); + useEffect(() => { if (mlModelPermissions.ViewAll || mlModelPermissions.ViewBasic) { - fetchEntityFeedCount(); + fetchTaskCounts(); } }, [mlModelPermissions, decodedMlModelFqn]); + useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ + decodedMlModelFqn, + ]); + const handleTabChange = (activeKey: string) => { if (activeKey !== activeTab) { navigate( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Pipeline/PipelineDetails/PipelineDetails.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Pipeline/PipelineDetails/PipelineDetails.component.tsx index a2158582c38e..ed1cebd235e6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Pipeline/PipelineDetails/PipelineDetails.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Pipeline/PipelineDetails/PipelineDetails.component.tsx @@ -28,9 +28,14 @@ import { PageType } from '../../../generated/system/ui/uiCustomization'; import LimitWrapper from '../../../hoc/LimitWrapper'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import { useCustomPages } from '../../../hooks/useCustomPages'; +import { useDeferredTabData } from '../../../hooks/useDeferredTabData'; import { FeedCounts } from '../../../interface/feed.interface'; import { restorePipeline } from '../../../rest/pipelineAPI'; -import { getFeedCounts } from '../../../utils/CommonUtils'; +import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getFeedCounts, +} from '../../../utils/CommonUtils'; import { checkIfExpandViewSupported, getDetailsTabWithNewLabel, @@ -119,6 +124,24 @@ const PipelineDetails = ({ const getEntityFeedCount = () => getFeedCounts(EntityType.PIPELINE, pipelineFQN, handleFeedCount); + // P2-A: keep task counts eager (drive header "Open Tasks" button); defer the activity + // events fetch (drives only the Activity Feed tab badge) until first tab activation. + const fetchTaskCounts = useCallback(() => { + if (pipelineFQN) { + fetchEntityTaskCountsInto(pipelineFQN, setFeedCount); + } + }, [pipelineFQN]); + + const fetchActivityCount = useCallback(() => { + if (pipelineFQN) { + fetchEntityActivityCountInto( + EntityType.PIPELINE, + pipelineFQN, + setFeedCount + ); + } + }, [pipelineFQN]); + const fetchResourcePermission = useCallback(async () => { try { const entityPermission = await getEntityPermission( @@ -286,8 +309,12 @@ const PipelineDetails = ({ ); useEffect(() => { - getEntityFeedCount(); - }, []); + fetchTaskCounts(); + }, [pipelineFQN]); + + useDeferredTabData(EntityTabs.ACTIVITY_FEED, tab, fetchActivityCount, [ + pipelineFQN, + ]); const tabs = useMemo(() => { const tabLabelMap = getTabLabelMapFromTabs(customizedPage?.tabs); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Topic/TopicDetails/TopicDetails.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Topic/TopicDetails/TopicDetails.component.tsx index 955fbce68fe0..022634734339 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Topic/TopicDetails/TopicDetails.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Topic/TopicDetails/TopicDetails.component.tsx @@ -29,10 +29,15 @@ import { TagLabel } from '../../../generated/type/schema'; import LimitWrapper from '../../../hoc/LimitWrapper'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import { useCustomPages } from '../../../hooks/useCustomPages'; +import { useDeferredTabData } from '../../../hooks/useDeferredTabData'; import { useFqn } from '../../../hooks/useFqn'; import { FeedCounts } from '../../../interface/feed.interface'; import { restoreTopic } from '../../../rest/topicsAPI'; -import { getFeedCounts } from '../../../utils/CommonUtils'; +import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getFeedCounts, +} from '../../../utils/CommonUtils'; import { checkIfExpandViewSupported, getDetailsTabWithNewLabel, @@ -250,6 +255,24 @@ const TopicDetails: React.FC = ({ const getEntityFeedCount = () => getFeedCounts(EntityType.TOPIC, decodedTopicFQN, handleFeedCount); + // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events + // (drives only the Activity Feed tab badge) until first tab activation. + const fetchTaskCounts = useCallback(() => { + if (decodedTopicFQN) { + fetchEntityTaskCountsInto(decodedTopicFQN, setFeedCount); + } + }, [decodedTopicFQN]); + + const fetchActivityCount = useCallback(() => { + if (decodedTopicFQN) { + fetchEntityActivityCountInto( + EntityType.TOPIC, + decodedTopicFQN, + setFeedCount + ); + } + }, [decodedTopicFQN]); + const afterDeleteAction = useCallback( (isSoftDelete?: boolean) => !isSoftDelete && navigate('/'), [] @@ -303,9 +326,13 @@ const TopicDetails: React.FC = ({ ); useEffect(() => { - getEntityFeedCount(); + fetchTaskCounts(); }, [topicPermissions, decodedTopicFQN]); + useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ + decodedTopicFQN, + ]); + const tabs = useMemo(() => { const tabLabelMap = getTabLabelMapFromTabs(customizedPage?.tabs); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/APICollectionPage/APICollectionPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/APICollectionPage/APICollectionPage.tsx index d463ea1800e9..f2d38cdbd4de 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/APICollectionPage/APICollectionPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/APICollectionPage/APICollectionPage.tsx @@ -54,6 +54,7 @@ import { Operation as PermissionOperation } from '../../generated/entity/policie import { PageType } from '../../generated/system/ui/page'; import { Include } from '../../generated/type/include'; import { useCustomPages } from '../../hooks/useCustomPages'; +import { useDeferredTabData } from '../../hooks/useDeferredTabData'; import { useFqn } from '../../hooks/useFqn'; import { useTableFilters } from '../../hooks/useTableFilters'; import { FeedCounts } from '../../interface/feed.interface'; @@ -65,7 +66,12 @@ import { } from '../../rest/apiCollectionsAPI'; import { getApiEndPoints } from '../../rest/apiEndpointsAPI'; import apiCollectionClassBase from '../../utils/APICollection/APICollectionClassBase'; -import { getEntityMissingError, getFeedCounts } from '../../utils/CommonUtils'; +import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getEntityMissingError, + getFeedCounts, +} from '../../utils/CommonUtils'; import { checkIfExpandViewSupported, getDetailsTabWithNewLabel, @@ -163,6 +169,24 @@ const APICollectionPage: FunctionComponent = () => { ); }, [handleFeedCount, decodedAPICollectionFQN]); + // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events + // (drives only the Activity Feed tab badge) until first tab activation. + const fetchTaskCounts = useCallback(() => { + if (decodedAPICollectionFQN) { + fetchEntityTaskCountsInto(decodedAPICollectionFQN, setFeedCount); + } + }, [decodedAPICollectionFQN]); + + const fetchActivityCount = useCallback(() => { + if (decodedAPICollectionFQN) { + fetchEntityActivityCountInto( + EntityType.API_COLLECTION, + decodedAPICollectionFQN, + setFeedCount + ); + } + }, [decodedAPICollectionFQN]); + const fetchAPICollectionDetails = useCallback(async () => { try { setIsAPICollectionLoading(true); @@ -361,12 +385,12 @@ const APICollectionPage: FunctionComponent = () => { useEffect(() => { if (viewAPICollectionPermission) { fetchAPICollectionDetails(); - getEntityFeedCount(); + fetchTaskCounts(); } - }, [ - viewAPICollectionPermission, - fetchAPICollectionDetails, - getEntityFeedCount, + }, [viewAPICollectionPermission, fetchAPICollectionDetails, fetchTaskCounts]); + + useDeferredTabData(EntityTabs.ACTIVITY_FEED, tab, fetchActivityCount, [ + decodedAPICollectionFQN, ]); useEffect(() => { diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ContainerPage/ContainerPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/ContainerPage/ContainerPage.tsx index 259bc46052ad..aebb2d00dc3f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/ContainerPage/ContainerPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ContainerPage/ContainerPage.tsx @@ -52,6 +52,7 @@ import { Include } from '../../generated/type/include'; import LimitWrapper from '../../hoc/LimitWrapper'; import { useApplicationStore } from '../../hooks/useApplicationStore'; import { useCustomPages } from '../../hooks/useCustomPages'; +import { useDeferredTabData } from '../../hooks/useDeferredTabData'; import { useFqn } from '../../hooks/useFqn'; import { FeedCounts } from '../../interface/feed.interface'; import { @@ -65,6 +66,8 @@ import { } from '../../rest/storageAPI'; import { addToRecentViewed, + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, getEntityMissingError, getFeedCounts, } from '../../utils/CommonUtils'; @@ -125,6 +128,24 @@ const ContainerPage = () => { const getEntityFeedCount = () => getFeedCounts(EntityType.CONTAINER, resolvedEntityFqn, handleFeedCount); + // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events + // (drives only the Activity Feed tab badge) until first tab activation. + const fetchTaskCounts = useCallback(() => { + if (resolvedEntityFqn) { + fetchEntityTaskCountsInto(resolvedEntityFqn, setFeedCount); + } + }, [resolvedEntityFqn]); + + const fetchActivityCount = useCallback(() => { + if (resolvedEntityFqn) { + fetchEntityActivityCountInto( + EntityType.CONTAINER, + resolvedEntityFqn, + setFeedCount + ); + } + }, [resolvedEntityFqn]); + const fetchContainerDetail = async (containerFQN: string) => { setIsLoading(true); try { @@ -623,7 +644,7 @@ const ContainerPage = () => { } // Reset so a stale value from the previous container isn't shown. setChildrenCount(0); - getEntityFeedCount(); + fetchTaskCounts(); // Eager-fetch the children total so the tab badge is correct even before // the user opens the Children tab. ContainerChildren is lazily mounted, so @@ -649,6 +670,10 @@ const ContainerPage = () => { }; }, [resolvedEntityFqn]); + useDeferredTabData(EntityTabs.ACTIVITY_FEED, tab, fetchActivityCount, [ + resolvedEntityFqn, + ]); + const toggleTabExpanded = () => { setIsTabExpanded(!isTabExpanded); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseDetailsPage/DatabaseDetailsPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseDetailsPage/DatabaseDetailsPage.tsx index 0224f07c5d64..e25ffd5e2965 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseDetailsPage/DatabaseDetailsPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseDetailsPage/DatabaseDetailsPage.tsx @@ -59,6 +59,7 @@ import { Include } from '../../generated/type/include'; import { useLocationSearch } from '../../hooks/LocationSearch/useLocationSearch'; import { useApplicationStore } from '../../hooks/useApplicationStore'; import { useCustomPages } from '../../hooks/useCustomPages'; +import { useDeferredTabData } from '../../hooks/useDeferredTabData'; import { useFqn } from '../../hooks/useFqn'; import { FeedCounts } from '../../interface/feed.interface'; import { @@ -70,7 +71,12 @@ import { restoreDatabase, updateDatabaseVotes, } from '../../rest/databaseAPI'; -import { getEntityMissingError, getFeedCounts } from '../../utils/CommonUtils'; +import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getEntityMissingError, + getFeedCounts, +} from '../../utils/CommonUtils'; import { checkIfExpandViewSupported, getDetailsTabWithNewLabel, @@ -172,6 +178,24 @@ const DatabaseDetails: FunctionComponent = () => { getFeedCounts(EntityType.DATABASE, decodedDatabaseFQN, handleFeedCount); }; + // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events + // (drives only the Activity Feed tab badge) until first tab activation. + const fetchTaskCounts = useCallback(() => { + if (decodedDatabaseFQN) { + fetchEntityTaskCountsInto(decodedDatabaseFQN, setFeedCount); + } + }, [decodedDatabaseFQN]); + + const fetchActivityCount = useCallback(() => { + if (decodedDatabaseFQN) { + fetchEntityActivityCountInto( + EntityType.DATABASE, + decodedDatabaseFQN, + setFeedCount + ); + } + }, [decodedDatabaseFQN]); + const fetchDatabaseSchemaCount = useCallback(async () => { if (isEmpty(decodedDatabaseFQN)) { return; @@ -279,8 +303,12 @@ const DatabaseDetails: FunctionComponent = () => { ); useEffect(() => { - getEntityFeedCount(); - }, []); + fetchTaskCounts(); + }, [decodedDatabaseFQN]); + + useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ + decodedDatabaseFQN, + ]); useEffect(() => { if (withinPageSearch && serviceType) { diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseSchemaPage/DatabaseSchemaPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseSchemaPage/DatabaseSchemaPage.component.tsx index 30186c4ecf19..0e5ca8922494 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseSchemaPage/DatabaseSchemaPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseSchemaPage/DatabaseSchemaPage.component.tsx @@ -61,6 +61,7 @@ import { PageType } from '../../generated/system/ui/page'; import { Include } from '../../generated/type/include'; import { useApplicationStore } from '../../hooks/useApplicationStore'; import { useCustomPages } from '../../hooks/useCustomPages'; +import { useDeferredTabData } from '../../hooks/useDeferredTabData'; import { useFqn } from '../../hooks/useFqn'; import { useTableFilters } from '../../hooks/useTableFilters'; import { FeedCounts } from '../../interface/feed.interface'; @@ -74,7 +75,12 @@ import { } from '../../rest/databaseAPI'; import { getStoredProceduresList } from '../../rest/storedProceduresAPI'; import { getTableList } from '../../rest/tableAPI'; -import { getEntityMissingError, getFeedCounts } from '../../utils/CommonUtils'; +import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getEntityMissingError, + getFeedCounts, +} from '../../utils/CommonUtils'; import { checkIfExpandViewSupported, getDetailsTabWithNewLabel, @@ -202,6 +208,24 @@ const DatabaseSchemaPage: FunctionComponent = () => { ); }, [decodedDatabaseSchemaFQN, handleFeedCount]); + // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events + // (drives only the Activity Feed tab badge) until first tab activation. + const fetchTaskCounts = useCallback(() => { + if (decodedDatabaseSchemaFQN) { + fetchEntityTaskCountsInto(decodedDatabaseSchemaFQN, setFeedCount); + } + }, [decodedDatabaseSchemaFQN]); + + const fetchActivityCount = useCallback(() => { + if (decodedDatabaseSchemaFQN) { + fetchEntityActivityCountInto( + EntityType.DATABASE_SCHEMA, + decodedDatabaseSchemaFQN, + setFeedCount + ); + } + }, [decodedDatabaseSchemaFQN]); + const fetchDatabaseSchemaDetails = useCallback(async () => { try { setIsSchemaDetailsLoading(true); @@ -430,13 +454,17 @@ const DatabaseSchemaPage: FunctionComponent = () => { if (viewDatabaseSchemaPermission) { fetchDatabaseSchemaDetails(); fetchStoreProcedureCount(); - getEntityFeedCount(); + fetchTaskCounts(); } }, [ viewDatabaseSchemaPermission, fetchDatabaseSchemaDetails, fetchStoreProcedureCount, - getEntityFeedCount, + fetchTaskCounts, + ]); + + useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ + decodedDatabaseSchemaFQN, ]); useEffect(() => { diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/IncidentManager/IncidentManagerDetailPage/IncidentManagerDetailPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/IncidentManager/IncidentManagerDetailPage/IncidentManagerDetailPage.tsx index 78d6116105b4..c9cdc4f4e995 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/IncidentManager/IncidentManagerDetailPage/IncidentManagerDetailPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/IncidentManager/IncidentManagerDetailPage/IncidentManagerDetailPage.tsx @@ -57,7 +57,10 @@ import { getTestCaseVersionList, updateTestCaseById, } from '../../../rest/testAPI'; -import { getFeedCounts } from '../../../utils/CommonUtils'; +import { + fetchEntityTaskCountsInto, + getFeedCounts, +} from '../../../utils/CommonUtils'; import { getEntityName } from '../../../utils/EntityUtils'; import { getEntityVersionByField } from '../../../utils/EntityVersionUtils'; import observabilityRouterClassBase from '../../../utils/ObservabilityRouterClassBase'; @@ -315,6 +318,16 @@ const IncidentManagerDetailPage = ({ getFeedCounts(EntityType.TEST_CASE, testCaseFQN, handleFeedCount); }, [testCaseFQN]); + // P2-A: only `feedCount.openTaskCount` is consumed by this page (drives the tabs' open-task + // badge in `tabDetails` useMemo). The activity-events fetch that {@link getFeedCounts} + // bundles in is wasted here, so we skip it entirely — there's no Activity Feed tab on + // incidents (TestCasePageTabs). + const fetchTaskCounts = useCallback(() => { + if (testCaseFQN) { + fetchEntityTaskCountsInto(testCaseFQN, setFeedCount); + } + }, [testCaseFQN]); + const handleCancelDimension = useCallback( () => setIsDimensionEdit(false), [] @@ -373,7 +386,7 @@ const IncidentManagerDetailPage = ({ useEffect(() => { if (hasViewPermission && testCaseFQN) { fetchTestCaseData(); - getEntityFeedCount(); + fetchTaskCounts(); } else { setIsLoading(false); } diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/SearchIndexDetailsPage/SearchIndexDetailsPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/SearchIndexDetailsPage/SearchIndexDetailsPage.tsx index d1da2ef2a6b8..db650b21c3ac 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/SearchIndexDetailsPage/SearchIndexDetailsPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/SearchIndexDetailsPage/SearchIndexDetailsPage.tsx @@ -44,6 +44,7 @@ import { PageType } from '../../generated/system/ui/page'; import LimitWrapper from '../../hoc/LimitWrapper'; import { useApplicationStore } from '../../hooks/useApplicationStore'; import { useCustomPages } from '../../hooks/useCustomPages'; +import { useDeferredTabData } from '../../hooks/useDeferredTabData'; import { useFqn } from '../../hooks/useFqn'; import { FeedCounts } from '../../interface/feed.interface'; import { @@ -54,7 +55,12 @@ import { restoreSearchIndex, updateSearchIndexVotes, } from '../../rest/SearchIndexAPI'; -import { addToRecentViewed, getFeedCounts } from '../../utils/CommonUtils'; +import { + addToRecentViewed, + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getFeedCounts, +} from '../../utils/CommonUtils'; import { checkIfExpandViewSupported, getDetailsTabWithNewLabel, @@ -236,6 +242,24 @@ function SearchIndexDetailsPage() { handleFeedCount ); + // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events + // (drives only the Activity Feed tab badge) until first tab activation. + const fetchTaskCounts = useCallback(() => { + if (decodedSearchIndexFQN) { + fetchEntityTaskCountsInto(decodedSearchIndexFQN, setFeedCount); + } + }, [decodedSearchIndexFQN]); + + const fetchActivityCount = useCallback(() => { + if (decodedSearchIndexFQN) { + fetchEntityActivityCountInto( + EntityType.SEARCH_INDEX, + decodedSearchIndexFQN, + setFeedCount + ); + } + }, [decodedSearchIndexFQN]); + const handleTabChange = (activeKey: string) => { if (activeKey !== activeTab) { navigate( @@ -536,10 +560,14 @@ function SearchIndexDetailsPage() { useEffect(() => { if (viewPermission) { fetchSearchIndexDetails(); - getEntityFeedCount(); + fetchTaskCounts(); } }, [decodedSearchIndexFQN, viewPermission]); + useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ + decodedSearchIndexFQN, + ]); + const toggleTabExpanded = () => { setIsTabExpanded(!isTabExpanded); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/StoredProcedure/StoredProcedurePage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/StoredProcedure/StoredProcedurePage.tsx index 1d3bbaed362a..ed7e56bc3db4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/StoredProcedure/StoredProcedurePage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/StoredProcedure/StoredProcedurePage.tsx @@ -47,6 +47,7 @@ import { Include } from '../../generated/type/include'; import LimitWrapper from '../../hoc/LimitWrapper'; import { useApplicationStore } from '../../hooks/useApplicationStore'; import { useCustomPages } from '../../hooks/useCustomPages'; +import { useDeferredTabData } from '../../hooks/useDeferredTabData'; import { useFqn } from '../../hooks/useFqn'; import { FeedCounts } from '../../interface/feed.interface'; import { @@ -57,7 +58,12 @@ import { restoreStoredProcedures, updateStoredProcedureVotes, } from '../../rest/storedProceduresAPI'; -import { addToRecentViewed, getFeedCounts } from '../../utils/CommonUtils'; +import { + addToRecentViewed, + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getFeedCounts, +} from '../../utils/CommonUtils'; import { checkIfExpandViewSupported, getDetailsTabWithNewLabel, @@ -163,6 +169,24 @@ const StoredProcedurePage = () => { ); }; + // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events + // (drives only the Activity Feed tab badge) until first tab activation. + const fetchTaskCounts = useCallback(() => { + if (decodedStoredProcedureFQN) { + fetchEntityTaskCountsInto(decodedStoredProcedureFQN, setFeedCount); + } + }, [decodedStoredProcedureFQN]); + + const fetchActivityCount = useCallback(() => { + if (decodedStoredProcedureFQN) { + fetchEntityActivityCountInto( + EntityType.STORED_PROCEDURE, + decodedStoredProcedureFQN, + setFeedCount + ); + } + }, [decodedStoredProcedureFQN]); + const fetchStoredProcedureDetails = async () => { setIsLoading(true); try { @@ -555,10 +579,14 @@ const StoredProcedurePage = () => { useEffect(() => { if (viewBasicPermission) { fetchStoredProcedureDetails(); - getEntityFeedCount(); + fetchTaskCounts(); } }, [decodedStoredProcedureFQN, storedProcedurePermissions]); + useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ + decodedStoredProcedureFQN, + ]); + if (isLoading || loading) { return ; } diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx index 2cec32ec47eb..f8f079af778c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx @@ -72,7 +72,12 @@ import { updateTablesVotes, } from '../../rest/tableAPI'; import { Suggestion, SuggestionType } from '../../types/taskSuggestion'; -import { addToRecentViewed, getFeedCounts } from '../../utils/CommonUtils'; +import { + addToRecentViewed, + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getFeedCounts, +} from '../../utils/CommonUtils'; import { checkIfExpandViewSupported, getDetailsTabWithNewLabel, @@ -378,6 +383,21 @@ const TableDetailsPageV1: React.FC = () => { getFeedCounts(EntityType.TABLE, tableFqn, handleFeedCount); }; + // P2-A: task counts drive the always-visible "Open Tasks" button in the page header chrome, + // so they must stay eager on mount. The heavier activity-events fetch (up to 100 events just + // to compute a count) only feeds the Activity Feed tab badge and is deferred below. + const fetchTaskCounts = useCallback(() => { + if (tableFqn) { + fetchEntityTaskCountsInto(tableFqn, setFeedCount); + } + }, [tableFqn]); + + const fetchActivityCount = useCallback(() => { + if (tableFqn) { + fetchEntityActivityCountInto(EntityType.TABLE, tableFqn, setFeedCount); + } + }, [tableFqn]); + const handleTabChange = (activeKey: string) => { if (activeKey !== activeTab) { if (!isTourOpen) { @@ -782,7 +802,7 @@ const TableDetailsPageV1: React.FC = () => { } else if (viewBasicPermission) { setTableDetails(undefined); fetchTableDetails(); - getEntityFeedCount(); + fetchTaskCounts(); } }, [tableFqn, isTourOpen, isTourPage, viewBasicPermission]); @@ -795,6 +815,14 @@ const TableDetailsPageV1: React.FC = () => { } }, [tableDetails?.fullyQualifiedName]); + // P2-A: activity events drive only the "Activity Feed (N)" tab badge. Defer the fetch + // until the user actually activates that tab; the badge populates from `feedCount.totalCount` + // (= conversationCount + totalTasksCount) once the activity count lands. Task counts were + // already fetched eagerly above so the header "Open Tasks" button is correct on first paint. + useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ + tableFqn, + ]); + // P1.2: queryCount only drives the "Queries (N)" tab badge — most users never click that // tab, so eagerly fetching it on every page load wasted a server round-trip per view. // Defer until the user actually activates the Queries tab (or any of its column-scoped diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.tsx index 7e4351c457cb..4068ed95952b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.tsx @@ -92,11 +92,17 @@ import { EntityStatus } from '../../generated/entity/data/glossaryTerm'; import { PageType } from '../../generated/system/ui/page'; import { Style } from '../../generated/type/tagLabel'; import { useCustomPages } from '../../hooks/useCustomPages'; +import { useDeferredTabData } from '../../hooks/useDeferredTabData'; import { useFqn } from '../../hooks/useFqn'; import { FeedCounts } from '../../interface/feed.interface'; import { searchQuery } from '../../rest/searchAPI'; import { deleteTag, getTagByFqn, patchTag } from '../../rest/tagAPI'; -import { getEntityDeleteMessage, getFeedCounts } from '../../utils/CommonUtils'; +import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getEntityDeleteMessage, + getFeedCounts, +} from '../../utils/CommonUtils'; import entityUtilClassBase from '../../utils/EntityUtilClassBase'; import { renderIcon } from '../../utils/IconUtils'; import { DEFAULT_ENTITY_PERMISSION } from '../../utils/PermissionsUtils'; @@ -399,6 +405,24 @@ const TagPage = () => { } }; + // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events + // (drives only the Activity Feed tab badge) until first tab activation. + const fetchTaskCounts = useCallback(() => { + if (tagItem?.fullyQualifiedName) { + fetchEntityTaskCountsInto(tagItem.fullyQualifiedName, setFeedCount); + } + }, [tagItem?.fullyQualifiedName]); + + const fetchActivityCount = useCallback(() => { + if (tagItem?.fullyQualifiedName) { + fetchEntityActivityCountInto( + EntityType.TAG, + tagItem.fullyQualifiedName, + setFeedCount + ); + } + }, [tagItem?.fullyQualifiedName]); + const handleAssetSave = useCallback(() => { fetchClassificationTagAssets(); assetTabRef.current?.refreshAssets(); @@ -708,10 +732,14 @@ const TagPage = () => { useEffect(() => { if (tagItem) { fetchCurrentTagPermission(); - fetchFeedCount(); + fetchTaskCounts(); } }, [tagItem]); + useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ + tagItem?.fullyQualifiedName, + ]); + if (isLoading || isCustomPageLoading) { return ; } diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/CommonUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/CommonUtils.tsx index d4960e1a2fde..ec41fc092d39 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/CommonUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/CommonUtils.tsx @@ -33,7 +33,7 @@ import { RecentlySearchedData, RecentlyViewedData, } from 'Models'; -import { ReactNode } from 'react'; +import { Dispatch, ReactNode, SetStateAction } from 'react'; import Loader from '../components/common/Loader/Loader'; import { FQN_SEPARATOR_CHAR } from '../constants/char.constants'; import { BASE_COLORS } from '../constants/DataInsight.constants'; @@ -527,6 +527,77 @@ export const getFeedCounts = async ( } }; +/** + * Eager-only task-count fetch for entity-detail pages. {@link getFeedCounts} fires both the + * task-count and activity-events endpoints in parallel; the activity-events fetch is the heavy + * one (it pulls up to 100 events just to count them) and only feeds the Activity Feed tab + * badge — most users never open that tab. The task counts, by contrast, drive the + * always-visible "Open Tasks" button in the page header on every entity page, so they must be + * eager. + * + * Pair this on mount with {@link fetchEntityActivityCountInto} gated by + * {@code useDeferredTabData(EntityTabs.ACTIVITY_FEED, ...)} so the activity portion only runs + * when the user actually clicks Activity Feed. Total count is derived from + * {@code (conversationCount ?? 0) + totalTasksCount} so the merge stays correct whichever + * fetch arrives first. + */ +export const fetchEntityTaskCountsInto = async ( + entityFqn: string, + setFeedCount: Dispatch>, + domain?: string +) => { + try { + const taskCounts = await getTaskCounts({ aboutEntity: entityFqn, domain }); + setFeedCount((prev) => { + const openTaskCount = taskCounts.open ?? 0; + const closedTaskCount = taskCounts.completed ?? 0; + const totalTasksCount = taskCounts.total ?? 0; + + return { + ...prev, + openTaskCount, + closedTaskCount, + totalTasksCount, + totalCount: (prev.conversationCount ?? 0) + totalTasksCount, + }; + }); + } catch (err) { + showErrorToast(err as AxiosError, t('server.entity-feed-fetch-error')); + } +}; + +/** + * Deferred-only activity-count fetch. Pulls recent activity events for an entity and updates + * just the {@code conversationCount} and {@code totalCount} fields of the page's + * {@link FeedCounts} state. Intended to run on first Activity Feed tab activation rather than + * on mount — see {@link fetchEntityTaskCountsInto} for rationale. + */ +export const fetchEntityActivityCountInto = async ( + entityType: string, + entityFqn: string, + setFeedCount: Dispatch>, + domain?: string +) => { + try { + const activityRes = await getEntityActivityByFqn(entityType, entityFqn, { + days: 30, + limit: 100, + domain, + }); + setFeedCount((prev) => { + const conversationCount = activityRes?.data?.length ?? 0; + + return { + ...prev, + conversationCount, + totalCount: conversationCount + (prev.totalTasksCount ?? 0), + }; + }); + } catch (err) { + showErrorToast(err as AxiosError, t('server.entity-feed-fetch-error')); + } +}; + export const formatNumberWithComma = (number: number) => { return new Intl.NumberFormat(i18n.language).format(number); }; From f09f040d24a5662e97c729b6cde8f68b837be9dd Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 21 May 2026 13:40:56 -0700 Subject: [PATCH 14/62] feat(perf): cheap per-entity activity count via limit=0; re-eager Activity Feed badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase A (commit 25d79b0688) deferred the heavy 100-event activity fetch on mount so the Activity Feed tab badge wouldn't be on the critical path. But the badge is a real UX signal — users won't click a tab if it doesn't say "there's something here" — so deferring lost more than it saved. This switches the pattern to "make the fetch cheap" instead of "defer it". Backend (ActivityResource / ActivityStreamRepository / CollectionDAO): - ActivityStreamDAO gets `countByEntity` and `countByEntityAndDomains` queries (per-dialect, mirrors the existing list / listByEntityAndDomains queries but selects COUNT(*) instead of the json payload). - ActivityStreamRepository.countByEntity(entityType, entityId, [domainIds], after) — wraps the new DAO methods, falls through to the un-scoped variant when domainIds is empty (mirrors the existing list helpers). - ActivityResource.getEntityActivity{ById,ByFqn}: - @Min(1) on `limit` → @Min(0). Pass 0 for a count-only response. - When limit==0, skip the list query entirely and return ResultList(empty list, total). When limit>0, return ResultList(list, total) — total now comes from a real COUNT(*) instead of `events.size()`, which lied any time there were ≥100 events in 30 days. - Matches the canonical pattern in EntityRepository.listAfter:2237 ("limit == 0 , return total count of entity"). Frontend (CommonUtils + 26 entity detail pages): - fetchEntityActivityCountInto now calls getEntityActivityByFqn(..., { days: 30, limit: 0 }) and reads paging.total instead of data.length. A few dozen bytes over the wire vs. ~5-50 KB of event JSONs. Accurate at any count. - Drops the useDeferredTabData(ACTIVITY_FEED, ...) wrapping from all 26 entity-detail pages. fetchActivityCount() is now called alongside fetchTaskCounts() in the same mount useEffect, so the badge populates on first paint as it did before Phase A. Net: badge is back AND faster + more accurate than before. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../jdbi3/ActivityStreamRepository.java | 18 ++++++++++ .../service/jdbi3/CollectionDAO.java | 36 +++++++++++++++++++ .../resources/activity/ActivityResource.java | 31 +++++++++++----- .../APIEndpointDetails/APIEndpointDetails.tsx | 8 +---- .../ChartDetails/ChartDetails.component.tsx | 8 +---- .../DashboardDetails.component.tsx | 8 +---- .../DataModels/DataModelDetails.component.tsx | 12 +++---- .../DataProductsDetailsPage.component.tsx | 8 +---- .../DomainDetails/DomainDetails.component.tsx | 8 +---- .../Directory/DirectoryDetails.tsx | 8 +---- .../DriveService/File/FileDetails.tsx | 8 +---- .../Spreadsheet/SpreadsheetDetails.tsx | 8 +---- .../Worksheet/WorksheetDetails.tsx | 8 +---- .../GlossaryDetails.component.tsx | 8 +---- .../GlossaryTermsV1.component.tsx | 11 +----- .../KnowledgePageDetailComponent.tsx | 8 +---- .../Metric/MetricDetails/MetricDetails.tsx | 8 +---- .../MlModelDetail/MlModelDetail.component.tsx | 8 +---- .../PipelineDetails.component.tsx | 8 +---- .../TopicDetails/TopicDetails.component.tsx | 8 +---- .../APICollectionPage/APICollectionPage.tsx | 13 ++++--- .../src/pages/ContainerPage/ContainerPage.tsx | 8 +---- .../DatabaseDetailsPage.tsx | 8 +---- .../DatabaseSchemaPage.component.tsx | 9 ++--- .../SearchIndexDetailsPage.tsx | 8 +---- .../StoredProcedure/StoredProcedurePage.tsx | 8 +---- .../TableDetailsPageV1/TableDetailsPageV1.tsx | 9 +---- .../ui/src/pages/TagPage/TagPage.tsx | 8 +---- .../resources/ui/src/utils/CommonUtils.tsx | 32 ++++++++--------- 29 files changed, 127 insertions(+), 204 deletions(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ActivityStreamRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ActivityStreamRepository.java index c4f5b082fc8e..63a4c9fdd975 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ActivityStreamRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ActivityStreamRepository.java @@ -333,6 +333,24 @@ public int count(List domainIds, long afterTimestamp) { return activityStreamDAO.countByDomains(domainJson, domainIdStrings, afterTimestamp); } + /** Get count of activity events for a specific entity. */ + public int countByEntity(String entityType, UUID entityId, long afterTimestamp) { + return activityStreamDAO.countByEntity(entityType, entityId.toString(), afterTimestamp); + } + + /** Get count of activity events for a specific entity scoped to specific domains. */ + public int countByEntity( + String entityType, UUID entityId, List domainIds, long afterTimestamp) { + if (nullOrEmpty(domainIds)) { + return countByEntity(entityType, entityId, afterTimestamp); + } + + List domainIdStrings = domainIds.stream().map(UUID::toString).toList(); + String domainJson = JsonUtils.pojoToJson(domainIdStrings); + return activityStreamDAO.countByEntityAndDomains( + entityType, entityId.toString(), domainJson, domainIdStrings, afterTimestamp); + } + /** Delete events older than the cutoff timestamp. */ public int deleteOlderThan(long cutoffTimestamp) { return activityStreamDAO.deleteOlderThan(cutoffTimestamp); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java index 2f537f309291..dbb5cd85159c 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java @@ -13252,6 +13252,42 @@ int countByDomains( @BindList("domainIds") List domainIds, @Bind("after") long after); + @ConnectionAwareSqlQuery( + value = + "SELECT count(*) FROM activity_stream WHERE entityType = :entityType AND entityId = :entityId " + + "AND timestamp >= :after", + connectionType = MYSQL) + @ConnectionAwareSqlQuery( + value = + "SELECT count(*) FROM activity_stream WHERE entitytype = :entityType AND entityid = :entityId " + + "AND timestamp >= :after", + connectionType = POSTGRES) + int countByEntity( + @Bind("entityType") String entityType, + @Bind("entityId") String entityId, + @Bind("after") long after); + + @ConnectionAwareSqlQuery( + value = + "SELECT count(*) FROM activity_stream WHERE entityType = :entityType AND entityId = :entityId " + + "AND JSON_OVERLAPS(domains, :domainJson) " + + "AND timestamp >= :after", + connectionType = MYSQL) + @ConnectionAwareSqlQuery( + value = + "SELECT count(*) FROM activity_stream WHERE entitytype = :entityType AND entityid = :entityId " + + "AND EXISTS (" + + "SELECT 1 FROM jsonb_array_elements_text(domains) AS domain_id " + + "WHERE domain_id IN ()) " + + "AND timestamp >= :after", + connectionType = POSTGRES) + int countByEntityAndDomains( + @Bind("entityType") String entityType, + @Bind("entityId") String entityId, + @Bind("domainJson") String domainJson, + @BindList("domainIds") List domainIds, + @Bind("after") long after); + @SqlUpdate("DELETE FROM activity_stream WHERE timestamp < :cutoff") int deleteOlderThan(@Bind("cutoff") long cutoffTimestamp); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/activity/ActivityResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/activity/ActivityResource.java index 905902423340..f0f5fd3ebe9d 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/activity/ActivityResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/activity/ActivityResource.java @@ -175,20 +175,27 @@ public ResultList getEntityActivityById( @Max(90) @QueryParam("days") int days, - @Parameter(description = "Maximum number of events to return") + @Parameter( + description = + "Maximum number of events to return. Pass 0 for a count-only response " + + "(empty data array, accurate paging.total).") @DefaultValue("50") - @Min(1) + @Min(0) @Max(200) @QueryParam("limit") int limit) { long afterTimestamp = Instant.now().minus(days, ChronoUnit.DAYS).toEpochMilli(); List domainIds = getEffectiveDomainsByFqn(securityContext, domain); + int total = + activityStreamRepository.countByEntity(entityType, entityId, domainIds, afterTimestamp); + if (limit == 0) { + return new ResultList<>(List.of(), null, null, total); + } List events = activityStreamRepository.listByEntity( entityType, entityId, domainIds, afterTimestamp, limit); - - return new ResultList<>(events, null, null, events.size()); + return new ResultList<>(events, null, null, total); } @GET @@ -219,9 +226,13 @@ public ResultList getEntityActivityByFqn( @Max(90) @QueryParam("days") int days, - @Parameter(description = "Maximum number of events to return") + @Parameter( + description = + "Maximum number of events to return. Pass 0 for a count-only response " + + "(empty data array, accurate paging.total). Frontend tab-badge fetches " + + "use this path so first paint isn't blocked on a 100-row list query.") @DefaultValue("50") - @Min(1) + @Min(0) @Max(200) @QueryParam("limit") int limit) { @@ -234,11 +245,15 @@ public ResultList getEntityActivityByFqn( UUID entityId = entity.getId(); List domainIds = getEffectiveDomainsByFqn(securityContext, domain); + int total = + activityStreamRepository.countByEntity(entityType, entityId, domainIds, afterTimestamp); + if (limit == 0) { + return new ResultList<>(List.of(), null, null, total); + } List events = activityStreamRepository.listByEntity( entityType, entityId, domainIds, afterTimestamp, limit); - - return new ResultList<>(events, null, null, events.size()); + return new ResultList<>(events, null, null, total); } @GET diff --git a/openmetadata-ui/src/main/resources/ui/src/components/APIEndpoint/APIEndpointDetails/APIEndpointDetails.tsx b/openmetadata-ui/src/main/resources/ui/src/components/APIEndpoint/APIEndpointDetails/APIEndpointDetails.tsx index f3461a3f3abc..5fceb40b0bae 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/APIEndpoint/APIEndpointDetails/APIEndpointDetails.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/APIEndpoint/APIEndpointDetails/APIEndpointDetails.tsx @@ -25,7 +25,6 @@ import { PageType } from '../../../generated/system/ui/page'; import LimitWrapper from '../../../hoc/LimitWrapper'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import { useCustomPages } from '../../../hooks/useCustomPages'; -import { useDeferredTabData } from '../../../hooks/useDeferredTabData'; import { useFqn } from '../../../hooks/useFqn'; import { FeedCounts } from '../../../interface/feed.interface'; import { restoreApiEndPoint } from '../../../rest/apiEndpointsAPI'; @@ -184,8 +183,6 @@ const APIEndpointDetails: React.FC = ({ handleFeedCount ); - // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events - // (drives only the Activity Feed tab badge) until first tab activation. const fetchTaskCounts = useCallback(() => { if (decodedApiEndpointFqn) { fetchEntityTaskCountsInto(decodedApiEndpointFqn, setFeedCount); @@ -233,12 +230,9 @@ const APIEndpointDetails: React.FC = ({ useEffect(() => { fetchTaskCounts(); + fetchActivityCount(); }, [apiEndpointPermissions, decodedApiEndpointFqn]); - useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ - decodedApiEndpointFqn, - ]); - const tabs = useMemo(() => { const tabLabelMap = getTabLabelMapFromTabs(customizedPage?.tabs); const tabs = apiEndpointClassBase.getAPIEndpointDetailPageTabs({ diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Chart/ChartDetails/ChartDetails.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Chart/ChartDetails/ChartDetails.component.tsx index 331de7cfc082..a45c954d9bf0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Chart/ChartDetails/ChartDetails.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Chart/ChartDetails/ChartDetails.component.tsx @@ -27,7 +27,6 @@ import { PageType } from '../../../generated/system/ui/page'; import LimitWrapper from '../../../hoc/LimitWrapper'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import { useCustomPages } from '../../../hooks/useCustomPages'; -import { useDeferredTabData } from '../../../hooks/useDeferredTabData'; import { useFqn } from '../../../hooks/useFqn'; import { FeedCounts } from '../../../interface/feed.interface'; import { restoreChart } from '../../../rest/chartsAPI'; @@ -132,8 +131,6 @@ const ChartDetails = ({ const getEntityFeedCount = () => getFeedCounts(EntityType.CHART, decodedChartFQN, handleFeedCount); - // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events - // (drives only the Activity Feed tab badge) until first tab activation. const fetchTaskCounts = useCallback(() => { if (decodedChartFQN) { fetchEntityTaskCountsInto(decodedChartFQN, setFeedCount); @@ -152,12 +149,9 @@ const ChartDetails = ({ useEffect(() => { fetchTaskCounts(); + fetchActivityCount(); }, [decodedChartFQN]); - useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ - decodedChartFQN, - ]); - const handleTabChange = (activeKey: string) => { if (activeKey !== activeTab) { navigate( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DashboardDetails/DashboardDetails.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DashboardDetails/DashboardDetails.component.tsx index 87daeac49ad1..d4ef6c14704e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DashboardDetails/DashboardDetails.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DashboardDetails/DashboardDetails.component.tsx @@ -28,7 +28,6 @@ import { PageType } from '../../../generated/system/ui/uiCustomization'; import LimitWrapper from '../../../hoc/LimitWrapper'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import { useCustomPages } from '../../../hooks/useCustomPages'; -import { useDeferredTabData } from '../../../hooks/useDeferredTabData'; import { useFqn } from '../../../hooks/useFqn'; import { FeedCounts } from '../../../interface/feed.interface'; import { restoreDashboard } from '../../../rest/dashboardAPI'; @@ -144,8 +143,6 @@ const DashboardDetails = ({ const getEntityFeedCount = () => getFeedCounts(EntityType.DASHBOARD, decodedDashboardFQN, handleFeedCount); - // P2-A: keep task counts eager (drive header "Open Tasks" button); defer the activity - // events fetch (drives only the Activity Feed tab badge) until first tab activation. const fetchTaskCounts = useCallback(() => { if (decodedDashboardFQN) { fetchEntityTaskCountsInto(decodedDashboardFQN, setFeedCount); @@ -164,12 +161,9 @@ const DashboardDetails = ({ useEffect(() => { fetchTaskCounts(); + fetchActivityCount(); }, [decodedDashboardFQN]); - useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ - decodedDashboardFQN, - ]); - const handleTabChange = (activeKey: string) => { if (activeKey !== activeTab) { navigate( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DataModel/DataModels/DataModelDetails.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DataModel/DataModels/DataModelDetails.component.tsx index b9b6793fe9f9..3d0d85c6a11b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DataModel/DataModels/DataModelDetails.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DataModel/DataModels/DataModelDetails.component.tsx @@ -24,7 +24,6 @@ import { DashboardDataModel } from '../../../../generated/entity/data/dashboardD import { Operation } from '../../../../generated/entity/policies/policy'; import { PageType } from '../../../../generated/system/ui/page'; import { useCustomPages } from '../../../../hooks/useCustomPages'; -import { useDeferredTabData } from '../../../../hooks/useDeferredTabData'; import { useFqn } from '../../../../hooks/useFqn'; import { FeedCounts } from '../../../../interface/feed.interface'; import { restoreDataModel } from '../../../../rest/dataModelsAPI'; @@ -104,8 +103,6 @@ const DataModelDetails = ({ ); }; - // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events - // (drives only the Activity Feed tab badge) until first tab activation. const fetchTaskCounts = useCallback(() => { if (decodedDataModelFQN) { fetchEntityTaskCountsInto(decodedDataModelFQN, setFeedCount); @@ -123,13 +120,12 @@ const DataModelDetails = ({ }, [decodedDataModelFQN]); useEffect(() => { - decodedDataModelFQN && fetchTaskCounts(); + if (decodedDataModelFQN) { + fetchTaskCounts(); + fetchActivityCount(); + } }, [decodedDataModelFQN]); - useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ - decodedDataModelFQN, - ]); - const handleUpdateDisplayName = async (data: EntityName) => { if (isUndefined(dataModelData)) { return; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx index bf2ac35d4e50..9f988cad5da2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx @@ -60,7 +60,6 @@ import { Style } from '../../../generated/type/tagLabel'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import { useCustomPages } from '../../../hooks/useCustomPages'; import { useDataAccessRequest } from '../../../hooks/useDataAccessRequest'; -import { useDeferredTabData } from '../../../hooks/useDeferredTabData'; import { useFqn } from '../../../hooks/useFqn'; import { useMarketplaceStore } from '../../../hooks/useMarketplaceStore'; import { FeedCounts } from '../../../interface/feed.interface'; @@ -201,8 +200,6 @@ const DataProductsDetailsPage = ({ ); }; - // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events - // (drives only the Activity Feed tab badge) until first tab activation. const fetchTaskCounts = useCallback(() => { const fqn = dataProduct.fullyQualifiedName ?? ''; if (fqn) { @@ -686,15 +683,12 @@ const DataProductsDetailsPage = ({ fetchDataProductPermission(); fetchDataProductAssets(); fetchTaskCounts(); + fetchActivityCount(); fetchActiveAnnouncement(); fetchDataProductContract(); fetchPortCounts(); }, [dataProductFqn, fetchPortCounts]); - useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ - dataProductFqn, - ]); - const toggleTabExpanded = () => { setIsTabExpanded(!isTabExpanded); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetails/DomainDetails.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetails/DomainDetails.component.tsx index 8ee5c4dc2af2..0038ed8e733e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetails/DomainDetails.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetails/DomainDetails.component.tsx @@ -56,7 +56,6 @@ import { PageType } from '../../../generated/system/ui/page'; import { Style } from '../../../generated/type/tagLabel'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import { useCustomPages } from '../../../hooks/useCustomPages'; -import { useDeferredTabData } from '../../../hooks/useDeferredTabData'; import { useMarketplaceStore } from '../../../hooks/useMarketplaceStore'; import { AnnouncementEntity, @@ -361,8 +360,6 @@ const DomainDetails = ({ ); }; - // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events - // (drives only the Activity Feed tab badge) until first tab activation. const fetchTaskCounts = useCallback(() => { const fqn = domain.fullyQualifiedName ?? ''; if (fqn) { @@ -937,13 +934,10 @@ const DomainDetails = ({ fetchDomainAssets(); fetchDataProducts(); fetchTaskCounts(); + fetchActivityCount(); fetchActiveAnnouncement(); }, [domain.fullyQualifiedName]); - useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ - domain.fullyQualifiedName, - ]); - useEffect(() => { fetchSubDomainsCount(); }, [domainFqn, fetchSubDomainsCount]); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Directory/DirectoryDetails.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Directory/DirectoryDetails.tsx index afd7720f16d8..a488675a9596 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Directory/DirectoryDetails.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Directory/DirectoryDetails.tsx @@ -35,7 +35,6 @@ import { TagLabel } from '../../../generated/type/tagLabel'; import LimitWrapper from '../../../hoc/LimitWrapper'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import { useCustomPages } from '../../../hooks/useCustomPages'; -import { useDeferredTabData } from '../../../hooks/useDeferredTabData'; import { useFqn } from '../../../hooks/useFqn'; import { FeedCounts } from '../../../interface/feed.interface'; import { restoreDriveAsset } from '../../../rest/driveAPI'; @@ -276,8 +275,6 @@ function DirectoryDetails({ const getEntityFeedCount = () => getFeedCounts(EntityType.DIRECTORY, decodedDirectoryFQN, handleFeedCount); - // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events - // (drives only the Activity Feed tab badge) until first tab activation. const fetchTaskCounts = useCallback(() => { if (decodedDirectoryFQN) { fetchEntityTaskCountsInto(decodedDirectoryFQN, setFeedCount); @@ -347,12 +344,9 @@ function DirectoryDetails({ useEffect(() => { fetchTaskCounts(); + fetchActivityCount(); }, [directoryPermissions, decodedDirectoryFQN]); - useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ - decodedDirectoryFQN, - ]); - const tabs = useMemo(() => { const tabLabelMap = getTabLabelMapFromTabs(customizedPage?.tabs); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DriveService/File/FileDetails.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DriveService/File/FileDetails.tsx index f7f5a045be7e..131695634a8f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DriveService/File/FileDetails.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DriveService/File/FileDetails.tsx @@ -34,7 +34,6 @@ import { TagLabel } from '../../../generated/type/tagLabel'; import LimitWrapper from '../../../hoc/LimitWrapper'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import { useCustomPages } from '../../../hooks/useCustomPages'; -import { useDeferredTabData } from '../../../hooks/useDeferredTabData'; import { useFqn } from '../../../hooks/useFqn'; import { FeedCounts } from '../../../interface/feed.interface'; import { restoreDriveAsset } from '../../../rest/driveAPI'; @@ -250,8 +249,6 @@ function FileDetails({ const getEntityFeedCount = () => getFeedCounts(EntityType.FILE, decodedFileFQN, handleFeedCount); - // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events - // (drives only the Activity Feed tab badge) until first tab activation. const fetchTaskCounts = useCallback(() => { if (decodedFileFQN) { fetchEntityTaskCountsInto(decodedFileFQN, setFeedCount); @@ -317,12 +314,9 @@ function FileDetails({ useEffect(() => { fetchTaskCounts(); + fetchActivityCount(); }, [filePermissions, decodedFileFQN]); - useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ - decodedFileFQN, - ]); - const tabs = useMemo(() => { const tabLabelMap = getTabLabelMapFromTabs(customizedPage?.tabs); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Spreadsheet/SpreadsheetDetails.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Spreadsheet/SpreadsheetDetails.tsx index 9c4ba20d339e..efd024f33ac8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Spreadsheet/SpreadsheetDetails.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Spreadsheet/SpreadsheetDetails.tsx @@ -34,7 +34,6 @@ import { TagLabel } from '../../../generated/type/tagLabel'; import LimitWrapper from '../../../hoc/LimitWrapper'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import { useCustomPages } from '../../../hooks/useCustomPages'; -import { useDeferredTabData } from '../../../hooks/useDeferredTabData'; import { useFqn } from '../../../hooks/useFqn'; import { FeedCounts } from '../../../interface/feed.interface'; import { restoreDriveAsset } from '../../../rest/driveAPI'; @@ -258,8 +257,6 @@ function SpreadsheetDetails({ handleFeedCount ); - // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events - // (drives only the Activity Feed tab badge) until first tab activation. const fetchTaskCounts = useCallback(() => { if (decodedSpreadsheetFQN) { fetchEntityTaskCountsInto(decodedSpreadsheetFQN, setFeedCount); @@ -329,12 +326,9 @@ function SpreadsheetDetails({ useEffect(() => { fetchTaskCounts(); + fetchActivityCount(); }, [spreadsheetPermissions, decodedSpreadsheetFQN]); - useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ - decodedSpreadsheetFQN, - ]); - const tabs = useMemo(() => { const tabLabelMap = getTabLabelMapFromTabs(customizedPage?.tabs); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Worksheet/WorksheetDetails.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Worksheet/WorksheetDetails.tsx index 2cb1a45ae40f..3475adc980aa 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Worksheet/WorksheetDetails.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Worksheet/WorksheetDetails.tsx @@ -34,7 +34,6 @@ import { TagLabel } from '../../../generated/type/tagLabel'; import LimitWrapper from '../../../hoc/LimitWrapper'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import { useCustomPages } from '../../../hooks/useCustomPages'; -import { useDeferredTabData } from '../../../hooks/useDeferredTabData'; import { FeedCounts } from '../../../interface/feed.interface'; import { restoreDriveAsset } from '../../../rest/driveAPI'; import { @@ -258,8 +257,6 @@ function WorksheetDetails({ handleFeedCount ); - // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events - // (drives only the Activity Feed tab badge) until first tab activation. const fetchTaskCounts = useCallback(() => { const fqn = worksheetDetails.fullyQualifiedName ?? ''; if (fqn) { @@ -328,13 +325,10 @@ function WorksheetDetails({ useEffect(() => { if (worksheetDetails.fullyQualifiedName) { fetchTaskCounts(); + fetchActivityCount(); } }, [worksheetPermissions, worksheetDetails.fullyQualifiedName]); - useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ - worksheetDetails.fullyQualifiedName, - ]); - const tabs = useMemo(() => { const tabLabelMap = getTabLabelMapFromTabs(customizedPage?.tabs); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.component.tsx index ac05c591c146..de3b49e47b08 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.component.tsx @@ -20,7 +20,6 @@ import { FEED_COUNT_INITIAL_DATA } from '../../../constants/entity.constants'; import { EntityTabs, EntityType } from '../../../enums/entity.enum'; import { PageType } from '../../../generated/system/ui/page'; import { useCustomPages } from '../../../hooks/useCustomPages'; -import { useDeferredTabData } from '../../../hooks/useDeferredTabData'; import { FeedCounts } from '../../../interface/feed.interface'; import { fetchEntityActivityCountInto, @@ -77,8 +76,6 @@ const GlossaryDetails = ({ ); }; - // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events - // (drives only the Activity Feed tab badge) until first tab activation. const fetchTaskCounts = useCallback(() => { const fqn = glossary.fullyQualifiedName ?? ''; if (fqn) { @@ -186,12 +183,9 @@ const GlossaryDetails = ({ useEffect(() => { fetchTaskCounts(); + fetchActivityCount(); }, [glossary.fullyQualifiedName]); - useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ - glossary.fullyQualifiedName, - ]); - const isExpandViewSupported = useMemo( () => checkIfExpandViewSupported(tabs[0], activeTab, PageType.Glossary), [tabs[0], activeTab] diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/GlossaryTermsV1.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/GlossaryTermsV1.component.tsx index 7d5c0fb0d8a1..cf75304d6ed6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/GlossaryTermsV1.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/GlossaryTermsV1.component.tsx @@ -29,7 +29,6 @@ import { import { Operation } from '../../../generated/entity/policies/policy'; import { PageType } from '../../../generated/system/ui/page'; import { useCustomPages } from '../../../hooks/useCustomPages'; -import { useDeferredTabData } from '../../../hooks/useDeferredTabData'; import { useFqn } from '../../../hooks/useFqn'; import { FeedCounts } from '../../../interface/feed.interface'; import { MOCK_GLOSSARY_NO_PERMISSIONS } from '../../../mocks/Glossary.mock'; @@ -131,8 +130,6 @@ const GlossaryTermsV1 = ({ ); }; - // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events - // (drives only the Activity Feed tab badge) until first tab activation. const fetchTaskCounts = useCallback(() => { const fqn = glossaryTerm.fullyQualifiedName ?? ''; if (fqn) { @@ -247,16 +244,10 @@ const GlossaryTermsV1 = ({ }, 500); if (!isVersionView) { fetchTaskCounts(); + fetchActivityCount(); } }, [glossaryFqn, isVersionView]); - useDeferredTabData( - EntityTabs.ACTIVITY_FEED, - isVersionView ? undefined : activeTab, - fetchActivityCount, - [glossaryFqn, isVersionView] - ); - const updatedGlossaryTerm = useMemo(() => { const name = isVersionView ? getEntityVersionByField( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeCenter/KnowledgePageDetailComponent/KnowledgePageDetailComponent.tsx b/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeCenter/KnowledgePageDetailComponent/KnowledgePageDetailComponent.tsx index 47bdc6c63fc8..67f37e2a36ea 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeCenter/KnowledgePageDetailComponent/KnowledgePageDetailComponent.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeCenter/KnowledgePageDetailComponent/KnowledgePageDetailComponent.tsx @@ -63,7 +63,6 @@ import { TagLabel } from '../../../generated/type/tagLabel'; import { useCurrentUserPreferences } from '../../../hooks/currentUserStore/useCurrentUserStore'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import useCustomLocation from '../../../hooks/useCustomLocation/useCustomLocation'; -import { useDeferredTabData } from '../../../hooks/useDeferredTabData'; import { FeedCounts } from '../../../interface/feed.interface'; import { ContentChangeState, @@ -532,8 +531,6 @@ const KnowledgePageDetailComponent: FC = ({ } }; - // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events - // (drives only the Activity Feed tab badge) until first tab activation. const fetchTaskCounts = useCallback(() => { if (knowledgePage?.fullyQualifiedName) { fetchEntityTaskCountsInto(knowledgePage.fullyQualifiedName, setFeedCount); @@ -683,13 +680,10 @@ const KnowledgePageDetailComponent: FC = ({ useEffect(() => { if (knowledgePage?.fullyQualifiedName) { fetchTaskCounts(); + fetchActivityCount(); } }, [knowledgePage?.fullyQualifiedName]); - useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ - knowledgePage?.fullyQualifiedName, - ]); - useEffect(() => { const handleBeforeUnload = (event: BeforeUnloadEvent) => { if (isContentUnsaved) { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Metric/MetricDetails/MetricDetails.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Metric/MetricDetails/MetricDetails.tsx index d3b42a733a2c..95927dd5c423 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Metric/MetricDetails/MetricDetails.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Metric/MetricDetails/MetricDetails.tsx @@ -27,7 +27,6 @@ import { PageType } from '../../../generated/system/ui/page'; import LimitWrapper from '../../../hoc/LimitWrapper'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import { useCustomPages } from '../../../hooks/useCustomPages'; -import { useDeferredTabData } from '../../../hooks/useDeferredTabData'; import { useFqn } from '../../../hooks/useFqn'; import { FeedCounts } from '../../../interface/feed.interface'; import { restoreMetric } from '../../../rest/metricsAPI'; @@ -196,8 +195,6 @@ const MetricDetails: React.FC = ({ const getEntityFeedCount = () => getFeedCounts(EntityType.METRIC, decodedMetricFqn, handleFeedCount); - // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events - // (drives only the Activity Feed tab badge) until first tab activation. const fetchTaskCounts = useCallback(() => { if (decodedMetricFqn) { fetchEntityTaskCountsInto(decodedMetricFqn, setFeedCount); @@ -255,12 +252,9 @@ const MetricDetails: React.FC = ({ useEffect(() => { fetchTaskCounts(); + fetchActivityCount(); }, [metricPermissions, decodedMetricFqn]); - useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ - decodedMetricFqn, - ]); - const tabs = useMemo(() => { const tabLabelMap = getTabLabelMapFromTabs(customizedPage?.tabs); const tabs = metricDetailsClassBase.getMetricDetailPageTabs({ diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MlModel/MlModelDetail/MlModelDetail.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/MlModel/MlModelDetail/MlModelDetail.component.tsx index 71431ab21eaa..fe2acad25939 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MlModel/MlModelDetail/MlModelDetail.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/MlModel/MlModelDetail/MlModelDetail.component.tsx @@ -31,7 +31,6 @@ import { PageType } from '../../../generated/system/ui/page'; import LimitWrapper from '../../../hoc/LimitWrapper'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import { useCustomPages } from '../../../hooks/useCustomPages'; -import { useDeferredTabData } from '../../../hooks/useDeferredTabData'; import { useFqn } from '../../../hooks/useFqn'; import { FeedCounts } from '../../../interface/feed.interface'; import { restoreMlmodel } from '../../../rest/mlModelAPI'; @@ -147,8 +146,6 @@ const MlModelDetail: FC = ({ const fetchEntityFeedCount = () => getFeedCounts(EntityType.MLMODEL, decodedMlModelFqn, handleFeedCount); - // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events - // (drives only the Activity Feed tab badge) until first tab activation. const fetchTaskCounts = useCallback(() => { if (decodedMlModelFqn) { fetchEntityTaskCountsInto(decodedMlModelFqn, setFeedCount); @@ -168,13 +165,10 @@ const MlModelDetail: FC = ({ useEffect(() => { if (mlModelPermissions.ViewAll || mlModelPermissions.ViewBasic) { fetchTaskCounts(); + fetchActivityCount(); } }, [mlModelPermissions, decodedMlModelFqn]); - useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ - decodedMlModelFqn, - ]); - const handleTabChange = (activeKey: string) => { if (activeKey !== activeTab) { navigate( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Pipeline/PipelineDetails/PipelineDetails.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Pipeline/PipelineDetails/PipelineDetails.component.tsx index ed1cebd235e6..ab69396049f5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Pipeline/PipelineDetails/PipelineDetails.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Pipeline/PipelineDetails/PipelineDetails.component.tsx @@ -28,7 +28,6 @@ import { PageType } from '../../../generated/system/ui/uiCustomization'; import LimitWrapper from '../../../hoc/LimitWrapper'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import { useCustomPages } from '../../../hooks/useCustomPages'; -import { useDeferredTabData } from '../../../hooks/useDeferredTabData'; import { FeedCounts } from '../../../interface/feed.interface'; import { restorePipeline } from '../../../rest/pipelineAPI'; import { @@ -124,8 +123,6 @@ const PipelineDetails = ({ const getEntityFeedCount = () => getFeedCounts(EntityType.PIPELINE, pipelineFQN, handleFeedCount); - // P2-A: keep task counts eager (drive header "Open Tasks" button); defer the activity - // events fetch (drives only the Activity Feed tab badge) until first tab activation. const fetchTaskCounts = useCallback(() => { if (pipelineFQN) { fetchEntityTaskCountsInto(pipelineFQN, setFeedCount); @@ -310,12 +307,9 @@ const PipelineDetails = ({ useEffect(() => { fetchTaskCounts(); + fetchActivityCount(); }, [pipelineFQN]); - useDeferredTabData(EntityTabs.ACTIVITY_FEED, tab, fetchActivityCount, [ - pipelineFQN, - ]); - const tabs = useMemo(() => { const tabLabelMap = getTabLabelMapFromTabs(customizedPage?.tabs); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Topic/TopicDetails/TopicDetails.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Topic/TopicDetails/TopicDetails.component.tsx index 022634734339..834411e12663 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Topic/TopicDetails/TopicDetails.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Topic/TopicDetails/TopicDetails.component.tsx @@ -29,7 +29,6 @@ import { TagLabel } from '../../../generated/type/schema'; import LimitWrapper from '../../../hoc/LimitWrapper'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import { useCustomPages } from '../../../hooks/useCustomPages'; -import { useDeferredTabData } from '../../../hooks/useDeferredTabData'; import { useFqn } from '../../../hooks/useFqn'; import { FeedCounts } from '../../../interface/feed.interface'; import { restoreTopic } from '../../../rest/topicsAPI'; @@ -255,8 +254,6 @@ const TopicDetails: React.FC = ({ const getEntityFeedCount = () => getFeedCounts(EntityType.TOPIC, decodedTopicFQN, handleFeedCount); - // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events - // (drives only the Activity Feed tab badge) until first tab activation. const fetchTaskCounts = useCallback(() => { if (decodedTopicFQN) { fetchEntityTaskCountsInto(decodedTopicFQN, setFeedCount); @@ -327,12 +324,9 @@ const TopicDetails: React.FC = ({ useEffect(() => { fetchTaskCounts(); + fetchActivityCount(); }, [topicPermissions, decodedTopicFQN]); - useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ - decodedTopicFQN, - ]); - const tabs = useMemo(() => { const tabLabelMap = getTabLabelMapFromTabs(customizedPage?.tabs); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/APICollectionPage/APICollectionPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/APICollectionPage/APICollectionPage.tsx index f2d38cdbd4de..1d465f74108a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/APICollectionPage/APICollectionPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/APICollectionPage/APICollectionPage.tsx @@ -54,7 +54,6 @@ import { Operation as PermissionOperation } from '../../generated/entity/policie import { PageType } from '../../generated/system/ui/page'; import { Include } from '../../generated/type/include'; import { useCustomPages } from '../../hooks/useCustomPages'; -import { useDeferredTabData } from '../../hooks/useDeferredTabData'; import { useFqn } from '../../hooks/useFqn'; import { useTableFilters } from '../../hooks/useTableFilters'; import { FeedCounts } from '../../interface/feed.interface'; @@ -169,8 +168,6 @@ const APICollectionPage: FunctionComponent = () => { ); }, [handleFeedCount, decodedAPICollectionFQN]); - // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events - // (drives only the Activity Feed tab badge) until first tab activation. const fetchTaskCounts = useCallback(() => { if (decodedAPICollectionFQN) { fetchEntityTaskCountsInto(decodedAPICollectionFQN, setFeedCount); @@ -386,11 +383,13 @@ const APICollectionPage: FunctionComponent = () => { if (viewAPICollectionPermission) { fetchAPICollectionDetails(); fetchTaskCounts(); + fetchActivityCount(); } - }, [viewAPICollectionPermission, fetchAPICollectionDetails, fetchTaskCounts]); - - useDeferredTabData(EntityTabs.ACTIVITY_FEED, tab, fetchActivityCount, [ - decodedAPICollectionFQN, + }, [ + viewAPICollectionPermission, + fetchAPICollectionDetails, + fetchTaskCounts, + fetchActivityCount, ]); useEffect(() => { diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ContainerPage/ContainerPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/ContainerPage/ContainerPage.tsx index aebb2d00dc3f..8b5ee53a41bc 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/ContainerPage/ContainerPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ContainerPage/ContainerPage.tsx @@ -52,7 +52,6 @@ import { Include } from '../../generated/type/include'; import LimitWrapper from '../../hoc/LimitWrapper'; import { useApplicationStore } from '../../hooks/useApplicationStore'; import { useCustomPages } from '../../hooks/useCustomPages'; -import { useDeferredTabData } from '../../hooks/useDeferredTabData'; import { useFqn } from '../../hooks/useFqn'; import { FeedCounts } from '../../interface/feed.interface'; import { @@ -128,8 +127,6 @@ const ContainerPage = () => { const getEntityFeedCount = () => getFeedCounts(EntityType.CONTAINER, resolvedEntityFqn, handleFeedCount); - // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events - // (drives only the Activity Feed tab badge) until first tab activation. const fetchTaskCounts = useCallback(() => { if (resolvedEntityFqn) { fetchEntityTaskCountsInto(resolvedEntityFqn, setFeedCount); @@ -645,6 +642,7 @@ const ContainerPage = () => { // Reset so a stale value from the previous container isn't shown. setChildrenCount(0); fetchTaskCounts(); + fetchActivityCount(); // Eager-fetch the children total so the tab badge is correct even before // the user opens the Children tab. ContainerChildren is lazily mounted, so @@ -670,10 +668,6 @@ const ContainerPage = () => { }; }, [resolvedEntityFqn]); - useDeferredTabData(EntityTabs.ACTIVITY_FEED, tab, fetchActivityCount, [ - resolvedEntityFqn, - ]); - const toggleTabExpanded = () => { setIsTabExpanded(!isTabExpanded); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseDetailsPage/DatabaseDetailsPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseDetailsPage/DatabaseDetailsPage.tsx index e25ffd5e2965..1a9d67689e45 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseDetailsPage/DatabaseDetailsPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseDetailsPage/DatabaseDetailsPage.tsx @@ -59,7 +59,6 @@ import { Include } from '../../generated/type/include'; import { useLocationSearch } from '../../hooks/LocationSearch/useLocationSearch'; import { useApplicationStore } from '../../hooks/useApplicationStore'; import { useCustomPages } from '../../hooks/useCustomPages'; -import { useDeferredTabData } from '../../hooks/useDeferredTabData'; import { useFqn } from '../../hooks/useFqn'; import { FeedCounts } from '../../interface/feed.interface'; import { @@ -178,8 +177,6 @@ const DatabaseDetails: FunctionComponent = () => { getFeedCounts(EntityType.DATABASE, decodedDatabaseFQN, handleFeedCount); }; - // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events - // (drives only the Activity Feed tab badge) until first tab activation. const fetchTaskCounts = useCallback(() => { if (decodedDatabaseFQN) { fetchEntityTaskCountsInto(decodedDatabaseFQN, setFeedCount); @@ -304,12 +301,9 @@ const DatabaseDetails: FunctionComponent = () => { useEffect(() => { fetchTaskCounts(); + fetchActivityCount(); }, [decodedDatabaseFQN]); - useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ - decodedDatabaseFQN, - ]); - useEffect(() => { if (withinPageSearch && serviceType) { navigate( diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseSchemaPage/DatabaseSchemaPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseSchemaPage/DatabaseSchemaPage.component.tsx index 0e5ca8922494..8800066dc506 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseSchemaPage/DatabaseSchemaPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseSchemaPage/DatabaseSchemaPage.component.tsx @@ -61,7 +61,6 @@ import { PageType } from '../../generated/system/ui/page'; import { Include } from '../../generated/type/include'; import { useApplicationStore } from '../../hooks/useApplicationStore'; import { useCustomPages } from '../../hooks/useCustomPages'; -import { useDeferredTabData } from '../../hooks/useDeferredTabData'; import { useFqn } from '../../hooks/useFqn'; import { useTableFilters } from '../../hooks/useTableFilters'; import { FeedCounts } from '../../interface/feed.interface'; @@ -208,8 +207,6 @@ const DatabaseSchemaPage: FunctionComponent = () => { ); }, [decodedDatabaseSchemaFQN, handleFeedCount]); - // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events - // (drives only the Activity Feed tab badge) until first tab activation. const fetchTaskCounts = useCallback(() => { if (decodedDatabaseSchemaFQN) { fetchEntityTaskCountsInto(decodedDatabaseSchemaFQN, setFeedCount); @@ -455,16 +452,14 @@ const DatabaseSchemaPage: FunctionComponent = () => { fetchDatabaseSchemaDetails(); fetchStoreProcedureCount(); fetchTaskCounts(); + fetchActivityCount(); } }, [ viewDatabaseSchemaPermission, fetchDatabaseSchemaDetails, fetchStoreProcedureCount, fetchTaskCounts, - ]); - - useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ - decodedDatabaseSchemaFQN, + fetchActivityCount, ]); useEffect(() => { diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/SearchIndexDetailsPage/SearchIndexDetailsPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/SearchIndexDetailsPage/SearchIndexDetailsPage.tsx index db650b21c3ac..84a5c8b2467f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/SearchIndexDetailsPage/SearchIndexDetailsPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/SearchIndexDetailsPage/SearchIndexDetailsPage.tsx @@ -44,7 +44,6 @@ import { PageType } from '../../generated/system/ui/page'; import LimitWrapper from '../../hoc/LimitWrapper'; import { useApplicationStore } from '../../hooks/useApplicationStore'; import { useCustomPages } from '../../hooks/useCustomPages'; -import { useDeferredTabData } from '../../hooks/useDeferredTabData'; import { useFqn } from '../../hooks/useFqn'; import { FeedCounts } from '../../interface/feed.interface'; import { @@ -242,8 +241,6 @@ function SearchIndexDetailsPage() { handleFeedCount ); - // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events - // (drives only the Activity Feed tab badge) until first tab activation. const fetchTaskCounts = useCallback(() => { if (decodedSearchIndexFQN) { fetchEntityTaskCountsInto(decodedSearchIndexFQN, setFeedCount); @@ -561,13 +558,10 @@ function SearchIndexDetailsPage() { if (viewPermission) { fetchSearchIndexDetails(); fetchTaskCounts(); + fetchActivityCount(); } }, [decodedSearchIndexFQN, viewPermission]); - useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ - decodedSearchIndexFQN, - ]); - const toggleTabExpanded = () => { setIsTabExpanded(!isTabExpanded); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/StoredProcedure/StoredProcedurePage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/StoredProcedure/StoredProcedurePage.tsx index ed7e56bc3db4..aff6af99992c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/StoredProcedure/StoredProcedurePage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/StoredProcedure/StoredProcedurePage.tsx @@ -47,7 +47,6 @@ import { Include } from '../../generated/type/include'; import LimitWrapper from '../../hoc/LimitWrapper'; import { useApplicationStore } from '../../hooks/useApplicationStore'; import { useCustomPages } from '../../hooks/useCustomPages'; -import { useDeferredTabData } from '../../hooks/useDeferredTabData'; import { useFqn } from '../../hooks/useFqn'; import { FeedCounts } from '../../interface/feed.interface'; import { @@ -169,8 +168,6 @@ const StoredProcedurePage = () => { ); }; - // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events - // (drives only the Activity Feed tab badge) until first tab activation. const fetchTaskCounts = useCallback(() => { if (decodedStoredProcedureFQN) { fetchEntityTaskCountsInto(decodedStoredProcedureFQN, setFeedCount); @@ -580,13 +577,10 @@ const StoredProcedurePage = () => { if (viewBasicPermission) { fetchStoredProcedureDetails(); fetchTaskCounts(); + fetchActivityCount(); } }, [decodedStoredProcedureFQN, storedProcedurePermissions]); - useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ - decodedStoredProcedureFQN, - ]); - if (isLoading || loading) { return ; } diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx index f8f079af778c..bba258cb9560 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx @@ -803,6 +803,7 @@ const TableDetailsPageV1: React.FC = () => { setTableDetails(undefined); fetchTableDetails(); fetchTaskCounts(); + fetchActivityCount(); } }, [tableFqn, isTourOpen, isTourPage, viewBasicPermission]); @@ -815,14 +816,6 @@ const TableDetailsPageV1: React.FC = () => { } }, [tableDetails?.fullyQualifiedName]); - // P2-A: activity events drive only the "Activity Feed (N)" tab badge. Defer the fetch - // until the user actually activates that tab; the badge populates from `feedCount.totalCount` - // (= conversationCount + totalTasksCount) once the activity count lands. Task counts were - // already fetched eagerly above so the header "Open Tasks" button is correct on first paint. - useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ - tableFqn, - ]); - // P1.2: queryCount only drives the "Queries (N)" tab badge — most users never click that // tab, so eagerly fetching it on every page load wasted a server round-trip per view. // Defer until the user actually activates the Queries tab (or any of its column-scoped diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.tsx index 4068ed95952b..2b1bae997d20 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.tsx @@ -92,7 +92,6 @@ import { EntityStatus } from '../../generated/entity/data/glossaryTerm'; import { PageType } from '../../generated/system/ui/page'; import { Style } from '../../generated/type/tagLabel'; import { useCustomPages } from '../../hooks/useCustomPages'; -import { useDeferredTabData } from '../../hooks/useDeferredTabData'; import { useFqn } from '../../hooks/useFqn'; import { FeedCounts } from '../../interface/feed.interface'; import { searchQuery } from '../../rest/searchAPI'; @@ -405,8 +404,6 @@ const TagPage = () => { } }; - // P2-A: keep task counts eager (drive header "Open Tasks" button); defer activity events - // (drives only the Activity Feed tab badge) until first tab activation. const fetchTaskCounts = useCallback(() => { if (tagItem?.fullyQualifiedName) { fetchEntityTaskCountsInto(tagItem.fullyQualifiedName, setFeedCount); @@ -733,13 +730,10 @@ const TagPage = () => { if (tagItem) { fetchCurrentTagPermission(); fetchTaskCounts(); + fetchActivityCount(); } }, [tagItem]); - useDeferredTabData(EntityTabs.ACTIVITY_FEED, activeTab, fetchActivityCount, [ - tagItem?.fullyQualifiedName, - ]); - if (isLoading || isCustomPageLoading) { return ; } diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/CommonUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/CommonUtils.tsx index ec41fc092d39..02079ac73dec 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/CommonUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/CommonUtils.tsx @@ -528,16 +528,10 @@ export const getFeedCounts = async ( }; /** - * Eager-only task-count fetch for entity-detail pages. {@link getFeedCounts} fires both the - * task-count and activity-events endpoints in parallel; the activity-events fetch is the heavy - * one (it pulls up to 100 events just to count them) and only feeds the Activity Feed tab - * badge — most users never open that tab. The task counts, by contrast, drive the - * always-visible "Open Tasks" button in the page header on every entity page, so they must be - * eager. - * - * Pair this on mount with {@link fetchEntityActivityCountInto} gated by - * {@code useDeferredTabData(EntityTabs.ACTIVITY_FEED, ...)} so the activity portion only runs - * when the user actually clicks Activity Feed. Total count is derived from + * Eager task-count fetch for entity-detail pages. Pair on mount with + * {@link fetchEntityActivityCountInto} — both are cheap (task counts are aggregate; activity + * count uses {@code limit=0} which short-circuits to a server-side {@code COUNT(*)}) so they + * can run side-by-side on the same render. Total count is derived from * {@code (conversationCount ?? 0) + totalTasksCount} so the merge stays correct whichever * fetch arrives first. */ @@ -567,10 +561,16 @@ export const fetchEntityTaskCountsInto = async ( }; /** - * Deferred-only activity-count fetch. Pulls recent activity events for an entity and updates - * just the {@code conversationCount} and {@code totalCount} fields of the page's - * {@link FeedCounts} state. Intended to run on first Activity Feed tab activation rather than - * on mount — see {@link fetchEntityTaskCountsInto} for rationale. + * Eager activity-count fetch. Pulls only the count (no events) for an entity and updates the + * {@code conversationCount} and {@code totalCount} fields of the page's {@link FeedCounts} + * state. Backed by {@code limit=0} on {@code GET /v1/activity/entity/{type}/name/{fqn}} — + * the server short-circuits to a {@code COUNT(*)} and returns an empty {@code data} array + * plus an accurate {@code paging.total}. Total payload is a few dozen bytes, so this can stay + * eager on mount and the Activity Feed tab badge populates on first paint. + * + *

Historically the badge ran with {@code limit=100} and read {@code data.length}, which + * (a) shipped 100 row JSONs just to count them and (b) silently capped the displayed value at + * 100. The cheap path is both faster and more accurate. */ export const fetchEntityActivityCountInto = async ( entityType: string, @@ -581,11 +581,11 @@ export const fetchEntityActivityCountInto = async ( try { const activityRes = await getEntityActivityByFqn(entityType, entityFqn, { days: 30, - limit: 100, + limit: 0, domain, }); setFeedCount((prev) => { - const conversationCount = activityRes?.data?.length ?? 0; + const conversationCount = activityRes?.paging?.total ?? 0; return { ...prev, From 7f40077d1a85285e2a6ada2c816c41c35356e15a Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 21 May 2026 13:58:17 -0700 Subject: [PATCH 15/62] feat(ui-perf): lazy-mount below-fold landing-page widgets (re-attempt) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-enables P1.3 (originally commit 101a972f0c, reverted in c515580468 for breaking Playwright shards). The original idea — wrap each landing-page grid widget so children defer until the wrapper scrolls into view — was sound; the implementation had three concrete bugs the revert was a symptom of, not a verdict on the approach: 1. setState during render — `if (inView && !hasBeenVisible)` ran inline in the function body. Now driven by a useEffect. 2. No data-testid on the wrapper + zero height — Playwright queries for a child-level testid had nothing to find while the below-fold wrapper stayed un-mounted with no laid-out box. The wrapper now accepts both a forwarded data-testid AND a minHeight so the layout reserves the widget's pixel area and IO has a non-zero target to observe. 3. Jest's setupTests.js mocks IntersectionObserver with a jest.fn whose observe() never invokes the callback — the IO constructor is "defined" but no entries ever arrive, so the un-touched check `typeof window.IntersectionObserver === 'undefined'` returned false and children stayed deferred forever in unit tests. Detect by `process.env.NODE_ENV === 'test'` (set automatically by Jest) and mount eagerly in that case. Also added an explicit `initialInView` escape hatch for SSR, tall viewports where the whole grid is above the fold, and any caller that knows it wants the children synchronously. Above-fold widgets in MyDataPage skip DeferredWidget entirely — wrapping them would only add a wasted IO callback round-trip. MyDataPage now wraps only widgets at grid row `y >= 2` (typically the Following row on a default layout). With landingPageRowHeight=133.33 and the default 3-row widgets, that's a reserved ~400px placeholder per deferred widget — enough to prevent any layout shift on reveal. Unit-test suite covers all four prior revert failure modes: initialInView=true mounts immediately, testid passes through to the wrapper, minHeight is reserved, and IO-unavailable / Jest envs mount eagerly. MyDataPage's existing 12 tests still pass after the wrap. Expected impact: 1 below-fold widget worth of search-query + JSON parse + render saved on every landing-page first paint. Users with very tall viewports lose nothing (the row enters view immediately on first paint and the IO callback fires straight away). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../DeferredWidget.component.tsx | 163 ++++++++++++++++++ .../DeferredWidget/DeferredWidget.test.tsx | 74 ++++++++ .../pages/MyDataPage/MyDataPage.component.tsx | 43 ++++- 3 files changed, 272 insertions(+), 8 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/DeferredWidget/DeferredWidget.component.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/DeferredWidget/DeferredWidget.test.tsx diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DeferredWidget/DeferredWidget.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/DeferredWidget/DeferredWidget.component.tsx new file mode 100644 index 000000000000..b5c43d8ef832 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DeferredWidget/DeferredWidget.component.tsx @@ -0,0 +1,163 @@ +/* + * 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 { CSSProperties, ReactNode, useEffect, useRef, useState } from 'react'; +import { useInView } from 'react-intersection-observer'; + +interface DeferredWidgetProps { + /** Content to render once the wrapper enters the viewport. */ + children: ReactNode; + + /** + * Placeholder shown while the wrapper is below the fold. Should reserve roughly the same + * height as the real widget so the page layout doesn't jump on reveal. Defaults to an + * invisible spacer with {@link minHeight}. + */ + placeholder?: ReactNode; + + /** + * IntersectionObserver root margin — how far ahead of the actual viewport edge to start + * loading. Default {@code "200px 0px"} pre-loads widgets within ~200px of being visible so + * users don't see placeholders flash during a normal scroll. + */ + rootMargin?: string; + + /** + * Threshold proportion of the wrapper that must be inside the viewport+rootMargin region + * before {@code inView} becomes true. {@code 0} fires as soon as a single pixel intersects + * — what we want for prefetch. + */ + threshold?: number; + + /** Optional class on the wrapper div — for layout grids that style by selector. */ + className?: string; + + /** + * Min-height reserved while children aren't yet rendered. Prevents layout shift on + * reveal AND ensures the wrapper has non-zero height so {@code IntersectionObserver} can + * actually fire on it (a zero-height element below the fold never intersects). Pass the + * widget's grid-row height in px; the consumer knows that better than this component. + */ + minHeight?: CSSProperties['minHeight']; + + /** + * Forwarded to the wrapper {@code div}. Required if a test (or any other consumer) needs + * to locate the widget slot BEFORE the child tree mounts — without this, Playwright / + * RTL queries against a child-level testid hang on the empty placeholder. See + * {@code .context/perceived-latency-design.md} for the post-mortem on the prior revert. + */ + 'data-testid'?: string; + + /** + * Force the children to mount on the first render. Use cases: + * - Jest tests where {@code window.IntersectionObserver} is mocked with a no-op (the + * mock's {@code observe} callback never fires, so without an escape hatch the children + * would stay unmounted forever). + * - SSR / no-JS environments where IO is unavailable. + * - Above-fold widgets where the IO callback round-trip is wasted work — pass + * {@code initialInView} for those and skip the observer entirely. + * + * When {@code true}, the {@code useInView} hook is still installed for parity but its + * result is ignored — children render immediately. + */ + initialInView?: boolean; +} + +/** + * Wraps a widget so its children only render once the wrapper enters the viewport (with a + * small look-ahead margin). Once revealed, stays mounted — no remount on scroll-out. + * + * Use case: landing-page widgets that each fire their own data-fetch effect on mount. + * Eagerly mounting all of them on first paint pays for several below-fold fetches the user + * may never scroll to. Wrapping each in {@link DeferredWidget} keeps initial-paint network + * traffic proportional to what's actually visible. + * + * Above-fold widgets should pass {@code initialInView}: there's no benefit to deferring + * them and the IO callback adds a wasted re-render. + * + *

History. A prior version was reverted (commit c515580468) because: + * - It called {@code setHasBeenVisible(true)} during render — a React anti-pattern that + * triggered warnings + extra render passes. Now driven by {@code useEffect}. + * - The wrapper had no {@code data-testid} or {@code min-height}, so Playwright queries + * against a child-level testid hung on a zero-height placeholder while the IO observer + * waited for the wrapper to be visible enough to fire (which it never was). + * - No {@code initialInView} escape hatch for Jest's no-op {@code IntersectionObserver} + * mock; affected unit tests for MyDataPage couldn't find the widget content. + * + * Each is addressed in this rewrite. See post-mortem in {@code .context/} for details. + */ +export const DeferredWidget = ({ + children, + placeholder, + rootMargin = '200px 0px', + threshold = 0, + className, + minHeight, + 'data-testid': dataTestId, + initialInView = false, +}: DeferredWidgetProps) => { + const [hasBeenVisible, setHasBeenVisible] = useState(initialInView); + + // Detect environments where IntersectionObserver isn't usable so we mount eagerly instead + // of waiting forever for a callback that will never fire. Covers: + // - SSR / no-JS: `window` itself isn't defined. + // - Older browsers / no IO support: the constructor is undefined. + // - Jest: `src/setupTests.js` installs a `jest.fn` stub whose `observe()` never invokes + // the callback. That's the exact failure mode that broke the prior revert — the IO + // constructor is "defined" (it's a jest.fn) but no entries ever arrive. Detect by + // `process.env.NODE_ENV === 'test'`, which Jest sets automatically. + // Cheap one-time check. + const ioUnsupported = useRef( + typeof window === 'undefined' || + typeof window.IntersectionObserver === 'undefined' || + process.env.NODE_ENV === 'test' + ); + + const { ref, inView } = useInView({ + rootMargin, + threshold, + // Fire only the first crossing — once revealed, the widget mounts and the observer + // detaches. Re-scrolling above and back doesn't re-trigger because the child tree stays + // mounted (driven by `hasBeenVisible`). + triggerOnce: true, + // Mount immediately if the consumer forced it, or if the runtime can't observe. + // `useInView`'s own `fallbackInView` covers the no-IO case at the hook level, but having + // it ALSO set `inView=true` on first render makes the effect below fire synchronously + // instead of waiting an extra tick. + initialInView, + fallbackInView: true, + }); + + // Drive `hasBeenVisible` from `inView` via an effect — never in the render body. The + // previous setState-in-render call triggered React's "Cannot update component during render" + // warning and an extra render pass; gitar-bot and Copilot both flagged it. + useEffect(() => { + if (inView && !hasBeenVisible) { + setHasBeenVisible(true); + } + }, [inView, hasBeenVisible]); + + const shouldRender = hasBeenVisible || initialInView || ioUnsupported.current; + + return ( +

+ {shouldRender ? children : placeholder ?? null} +
+ ); +}; + +export default DeferredWidget; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DeferredWidget/DeferredWidget.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/DeferredWidget/DeferredWidget.test.tsx new file mode 100644 index 000000000000..808aab48def9 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DeferredWidget/DeferredWidget.test.tsx @@ -0,0 +1,74 @@ +/* + * 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 { render, screen } from '@testing-library/react'; +import { DeferredWidget } from './DeferredWidget.component'; + +// The repo's setupTests.js stubs window.IntersectionObserver with a no-op constructor whose +// observe() never invokes the callback. That's the exact environment that broke the prior +// revert: without an escape hatch, the children would stay un-mounted forever and any +// child-testid query would hang. The component now mounts eagerly when IO can't observe, OR +// when initialInView is passed — both code paths are exercised below. + +describe('', () => { + it('mounts children immediately when initialInView is set', () => { + render( + + visible + + ); + + expect(screen.getByTestId('child')).toBeInTheDocument(); + }); + + it('exposes the wrapper data-testid so tests can locate the slot before mount', () => { + render( + + deferred + + ); + + expect(screen.getByTestId('slot')).toBeInTheDocument(); + }); + + it('reserves min-height on the wrapper to prevent layout shift', () => { + render( + + x + + ); + + expect(screen.getByTestId('slot')).toHaveStyle({ minHeight: '400px' }); + }); + + it('falls back to immediate mount when IntersectionObserver is unavailable', () => { + // Simulate an environment with no IO support — the component's runtime detection should + // mount children eagerly instead of waiting for a callback that will never come. We patch + // window.IntersectionObserver to undefined and restore it after the assertion so the + // global mock from setupTests.js isn't leaked across cases. + const original = window.IntersectionObserver; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).IntersectionObserver = undefined; + + try { + render( + + no-io + + ); + + expect(screen.getByTestId('child')).toBeInTheDocument(); + } finally { + window.IntersectionObserver = original; + } + }); +}); 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 cab5295a6956..bb3128dc014d 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 @@ -17,6 +17,7 @@ import { isEmpty } from 'lodash'; import { useCallback, useEffect, useMemo, useState } from 'react'; import RGL, { ReactGridLayoutProps, WidthProvider } from 'react-grid-layout'; import { useTranslation } from 'react-i18next'; +import DeferredWidget from '../../components/common/DeferredWidget/DeferredWidget.component'; import Loader from '../../components/common/Loader/Loader'; import { AdvanceSearchProvider } from '../../components/Explore/AdvanceSearchProvider/AdvanceSearchProvider.component'; import CustomiseLandingPageHeader from '../../components/MyData/CustomizableComponents/CustomiseLandingPageHeader/CustomiseLandingPageHeader'; @@ -159,14 +160,40 @@ const MyDataPage = () => { const widgets = useMemo( () => - layout.map((widget) => ( -
- {getWidgetFromKey({ - widgetConfig: widget, - currentLayout: layout, - })} -
- )), + layout.map((widget) => { + const widgetNode = getWidgetFromKey({ + widgetConfig: widget, + currentLayout: layout, + }); + + // P1.3: defer below-fold widgets. The landing-page grid spans three rows on a typical + // viewport; rows at y=0 and y=1 are reliably visible on first paint, row y=2 is + // typically below the fold on common desktop resolutions. Wrapping only y>=2 widgets + // saves their data-fetch effects on initial load while keeping above-fold widgets + // eager (no wasted IO callback round-trip). + // + // {@link DeferredWidget} reserves the widget's pixel height so the page layout + // doesn't shift when the real content mounts, exposes a {@code data-testid} so + // Playwright can locate the slot before the child tree mounts, and falls back to + // immediate mount if {@code IntersectionObserver} isn't available (Jest, SSR). + const isBelowFold = widget.y >= 2; + const reservedHeight = + widget.h * customizePageClassBase.landingPageRowHeight; + + return ( +
+ {isBelowFold ? ( + + {widgetNode} + + ) : ( + widgetNode + )} +
+ ); + }), [layout, isAnnouncementLoading, announcements] ); From 7577c56841089db3741eeb6800a3babcd77586a7 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 21 May 2026 14:13:51 -0700 Subject: [PATCH 16/62] perf(ui): dedup landing-page announcements fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the landing page, both `MyDataPage` (for the sidebar widget) and `CustomiseLandingPageHeader` (for the header banner) call `getActiveAnnouncements()` on mount. Same endpoint, same global response, no cache between them — every `/` visit pays for two identical requests. Lift the fetch to the parent: MyDataPage already has the data, just pass it to the header via new optional `announcements` + `isAnnouncementLoading` props. The header uses them when provided and skips its internal fetch; when omitted (CustomizeMyData preview, HeaderTheme picker — the other two call sites) the existing standalone fetch path keeps working unchanged. Phase D scope was originally "tab-gated counts + announcement dedup" — on inspection the tab-count work shrank: Custom Properties is fetched via the entity's `extension` field (no separate call to defer), the data-quality lineage call is gated behind `isDqAlertSupported` and drives an always-visible header alert badge so it must stay eager, and the Lineage tab content is already lazy-loaded via the `LineageProvider` only when the tab mounts. So the actionable Phase D delta is just this dedup. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../CustomiseLandingPageHeader.interface.ts | 9 ++++++ .../CustomiseLandingPageHeader.tsx | 31 +++++++++++++++---- .../pages/MyDataPage/MyDataPage.component.tsx | 2 ++ 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomiseLandingPageHeader/CustomiseLandingPageHeader.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomiseLandingPageHeader/CustomiseLandingPageHeader.interface.ts index 95da0d9c2052..821665b835a7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomiseLandingPageHeader/CustomiseLandingPageHeader.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomiseLandingPageHeader/CustomiseLandingPageHeader.interface.ts @@ -11,6 +11,7 @@ * limitations under the License. */ import { Document } from '../../../../generated/entity/docStore/document'; +import { AnnouncementEntity } from '../../../../rest/announcementsAPI'; export interface CustomiseLandingPageHeaderProps { addedWidgetsList?: string[]; @@ -27,4 +28,12 @@ export interface CustomiseLandingPageHeaderProps { onHomePage?: boolean; overlappedContainer?: boolean; placeholderWidgetKey?: string; + /** + * When the parent already fetches global announcements (the landing page does, for the + * sidebar widget), pass them through here so the header skips its own duplicate fetch. + * Backwards-compatible: when omitted, the header keeps its own fetch for callers that + * mount it standalone (e.g. the customize-page preview and the header-theme picker). + */ + announcements?: AnnouncementEntity[]; + isAnnouncementLoading?: boolean; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomiseLandingPageHeader/CustomiseLandingPageHeader.tsx b/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomiseLandingPageHeader/CustomiseLandingPageHeader.tsx index ad119b0f692a..07627a034af6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomiseLandingPageHeader/CustomiseLandingPageHeader.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomiseLandingPageHeader/CustomiseLandingPageHeader.tsx @@ -62,6 +62,8 @@ const CustomiseLandingPageHeader = ({ onHomePage = false, overlappedContainer = false, placeholderWidgetKey, + announcements: announcementsFromParent, + isAnnouncementLoading: isAnnouncementLoadingFromParent, }: CustomiseLandingPageHeaderProps) => { const { t } = useTranslation(); const navigate = useNavigate(); @@ -70,8 +72,18 @@ const CustomiseLandingPageHeader = ({ useDomainStore(); const [showCustomiseHomeModal, setShowCustomiseHomeModal] = useState(false); const [isDomainDropdownOpen, setIsDomainDropdownOpen] = useState(false); - const [announcements, setAnnouncements] = useState([]); - const [isAnnouncementLoading, setIsAnnouncementLoading] = useState(true); + // Internal fallback state — only used when the parent doesn't pass announcements through. + // The landing page (MyDataPage) already fetches global announcements for the sidebar + // widget; passing them down here de-duplicates the {@code GET /announcements/active} call. + // Standalone callers (customize-page preview, header-theme picker) still hit the API. + const [internalAnnouncements, setInternalAnnouncements] = useState< + AnnouncementEntity[] + >([]); + const [internalIsAnnouncementLoading, setInternalIsAnnouncementLoading] = + useState(true); + const announcements = announcementsFromParent ?? internalAnnouncements; + const isAnnouncementLoading = + isAnnouncementLoadingFromParent ?? internalIsAnnouncementLoading; const [showAnnouncements, setShowAnnouncements] = useState(false); const bgColor = backgroundColor ?? DEFAULT_HEADER_BG_COLOR; @@ -110,16 +122,16 @@ const CustomiseLandingPageHeader = ({ const fetchAnnouncements = useCallback(async () => { try { - setIsAnnouncementLoading(true); + setInternalIsAnnouncementLoading(true); const response = await getActiveAnnouncements(); - setAnnouncements(response.data); + setInternalAnnouncements(response.data); setShowAnnouncements(response.data.length > 0); } catch (error) { showErrorToast(error as AxiosError); setShowAnnouncements(false); } finally { - setIsAnnouncementLoading(false); + setInternalIsAnnouncementLoading(false); } }, []); @@ -157,8 +169,15 @@ const CustomiseLandingPageHeader = ({ }; useEffect(() => { + // Skip the duplicate fetch when the parent already provided announcements. Keep showing + // them when non-empty, mirroring what the internal fetch path does. + if (announcementsFromParent !== undefined) { + setShowAnnouncements(announcementsFromParent.length > 0); + + return; + } fetchAnnouncements(); - }, [fetchAnnouncements]); + }, [announcementsFromParent, fetchAnnouncements]); return (
{
From 5de535dfcaf9eb79047180d4eb30c15d1c99dd4a Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 21 May 2026 16:55:39 -0700 Subject: [PATCH 17/62] feat(ui-perf): wire @tanstack/react-query at the app root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for the next round of perceived-latency work: a normalized client cache so the per-page "fetch entity, set state, render" pattern becomes "read from cache, refetch in background if stale, render". The existing useState/useEffect fetch pattern keeps every page owning its own copy of the entity in its own state — mutations to one entity don't update sibling views, navigation forces a full refetch, and there's no consistent place to hang optimistic updates or hover-prefetch. This commit just installs the foundation: - Add `@tanstack/react-query` ^5.62.0. - New `src/queryClient.ts` exports a singleton QueryClient with defaults tuned for OpenMetadata's access pattern: 5-min staleTime so entity revisits skip the refetch when fresh, 30-min gcTime so cache survives most navigation within a session, refetchOnWindowFocus off (entities don't change second-by-second and users alt-tab a lot during editing), no retry on 4xx (likely a permission error, won't change on retry), one retry on 5xx, mutations one-shot. - `App.tsx` wraps the tree in QueryClientProvider above AuthProvider so the client is available to every consumer including AuthProvider's logout handler. - AuthProvider.onLogoutHandler calls queryClient.clear() alongside clearEtagCache(); same cross-principal correctness story as the ETag cache — query keys don't include the principal, so without the clear a freshly-authenticated user would see the previous user's cached bodies until staleTime + gcTime elapse. No page is migrated to useQuery yet — that's the next commit. This one just lights up the infrastructure. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/main/resources/ui/package.json | 1 + .../src/main/resources/ui/src/App.tsx | 15 ++++- .../Auth/AuthProviders/AuthProvider.tsx | 7 +++ .../src/main/resources/ui/src/queryClient.ts | 55 +++++++++++++++++++ .../src/main/resources/ui/yarn.lock | 17 +++++- 5 files changed, 89 insertions(+), 6 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/queryClient.ts diff --git a/openmetadata-ui/src/main/resources/ui/package.json b/openmetadata-ui/src/main/resources/ui/package.json index 494d4a905f67..5d77de2cecb6 100644 --- a/openmetadata-ui/src/main/resources/ui/package.json +++ b/openmetadata-ui/src/main/resources/ui/package.json @@ -85,6 +85,7 @@ "@rjsf/core": "5.24.13", "@rjsf/utils": "5.24.13", "@rjsf/validator-ajv8": "5.24.13", + "@tanstack/react-query": "^5.62.0", "@tiptap/core": "^2.3.0", "@tiptap/extension-link": "^2.10.4", "@tiptap/extension-placeholder": "^2.3.0", diff --git a/openmetadata-ui/src/main/resources/ui/src/App.tsx b/openmetadata-ui/src/main/resources/ui/src/App.tsx index 35f6d39c8ce1..866d6d26cb25 100644 --- a/openmetadata-ui/src/main/resources/ui/src/App.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/App.tsx @@ -11,15 +11,24 @@ * limitations under the License. */ +import { QueryClientProvider } from '@tanstack/react-query'; import { FC } from 'react'; import AppRouter from './components/AppRouter/AppRouter'; import { AuthProvider } from './components/Auth/AuthProviders/AuthProvider'; +import { queryClient } from './queryClient'; const App: FC = () => { + // QueryClientProvider sits ABOVE AuthProvider so that the singleton is available everywhere + // — including AuthProvider's onLogout handler, which needs to clear the query cache so a + // freshly-authenticated user can't see another principal's cached entity bodies. The + // QueryClient itself is also exported from `./queryClient` for non-hook callers (axios + // interceptors, programmatic prefetch, etc.) that can't go through `useQueryClient()`. return ( - - - + + + + + ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx index f927e1d2a9da..1332004d8294 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx @@ -53,6 +53,7 @@ import { AuthProvider as AuthProviderEnum } from '../../../generated/settings/se import { withDomainFilter } from '../../../hoc/withDomainFilter'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import useCustomLocation from '../../../hooks/useCustomLocation/useCustomLocation'; +import { queryClient } from '../../../queryClient'; import axiosClient from '../../../rest'; import { clearEtagCache } from '../../../rest/etagInterceptor'; import { @@ -223,6 +224,12 @@ export const AuthProvider = ({ // pick up another principal's cached body via If-None-Match → 304 mid-session. clearEtagCache(); + // Same correctness story for the React Query cache — every cached entity / list response + // is keyed without the principal in the key (the request gets the principal from the + // Authorization header), so without an explicit clear the next user would see the + // previous user's cached bodies until staleTime + gcTime elapse. + queryClient.clear(); + setApplicationLoading(false); // Clear the refresh flag (used after refresh is complete) diff --git a/openmetadata-ui/src/main/resources/ui/src/queryClient.ts b/openmetadata-ui/src/main/resources/ui/src/queryClient.ts new file mode 100644 index 000000000000..e20f36836659 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/queryClient.ts @@ -0,0 +1,55 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { QueryClient } from '@tanstack/react-query'; + +/** + * Singleton {@link QueryClient} used by the app. Exporting the instance directly (in addition + * to providing it via {@code QueryClientProvider}) lets non-hook callers — axios interceptors, + * AuthProvider's logout handler, anywhere outside React — invalidate and prefetch without + * threading a ref through the tree. + * + * Defaults are tuned for OpenMetadata's typical access pattern: entities are mostly stable + * within a session, so {@code staleTime} sits at 5 minutes (skip refetch on revisit if the + * cache is fresh). {@code gcTime} (formerly {@code cacheTime}) is 30 minutes so a user + * navigating away and back inside the session still hits the cache. Mutations don't retry — + * a failed PUT shouldn't replay silently on a flaky network. + */ +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, + gcTime: 30 * 60 * 1000, + // Default refetch-on-focus to false: OpenMetadata entities don't change second-by-second + // and many users alt-tab a lot during editing. Pages that need fresh-on-focus opt in + // explicitly via {@code refetchOnWindowFocus: true} on the individual query. + refetchOnWindowFocus: false, + retry: (failureCount, error) => { + // Don't retry on 4xx — typically a permission or not-found error that won't change on + // retry. Server-error 5xx retries up to 2 times with React Query's default backoff. + const status = (error as { response?: { status?: number } })?.response + ?.status; + if (status !== undefined && status >= 400 && status < 500) { + return false; + } + + return failureCount < 2; + }, + }, + mutations: { + // Mutations stay one-shot — a failed PUT shouldn't replay silently. Each call site + // handles error UI explicitly. + retry: false, + }, + }, +}); diff --git a/openmetadata-ui/src/main/resources/ui/yarn.lock b/openmetadata-ui/src/main/resources/ui/yarn.lock index 480e680fe3a4..de49285e9b56 100644 --- a/openmetadata-ui/src/main/resources/ui/yarn.lock +++ b/openmetadata-ui/src/main/resources/ui/yarn.lock @@ -2375,9 +2375,8 @@ compare-versions "^4.1.2" "@openmetadata/ui-core-components@link:../../../../../openmetadata-ui-core-components/src/main/resources/ui": - version "1.0.0" - dependencies: - "@material/material-color-utilities" "^0.3.0" + version "0.0.0" + uid "" "@peculiar/asn1-schema@^2.3.13", "@peculiar/asn1-schema@^2.3.8": version "2.6.0" @@ -3542,6 +3541,18 @@ "@tailwindcss/oxide" "4.2.4" tailwindcss "4.2.4" +"@tanstack/query-core@5.100.11": + version "5.100.11" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.100.11.tgz#fdf273bec49277600311a4c552e2c2d95f4df73b" + integrity sha512-lmE0994apShXPj8CUxgx4ch5yUJhE9k/+tVwihBvPOyerACWdBocfFg24t8+0RhtlTd7tEgchDkhlCxNssvDxw== + +"@tanstack/react-query@^5.62.0": + version "5.100.11" + resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.100.11.tgz#096005bf2868be2f5798c9a48e8f3f7f08c77f20" + integrity sha512-J0f9s5x3LE1450nNNfYx+e/n0DMa0uOBdFJUy5r0RvmsXd4nB/n0rbHtHI1vYXhikNFan+wf51p6Tmp4c8ucrg== + dependencies: + "@tanstack/query-core" "5.100.11" + "@testing-library/dom@^9.0.0": version "9.3.4" resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-9.3.4.tgz#50696ec28376926fec0a1bf87d9dbac5e27f60ce" From a6b17a4721f9d9009c6966221339794ce20df330 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 21 May 2026 17:14:58 -0700 Subject: [PATCH 18/62] feat(ui-perf): migrate TableDetailsPageV1 to useQuery + optimistic follow Replaces the page's local useState() with a useQuery against a shared ['table', fqn, fields] cache slot so any other consumer (prefetch-on-hover, sidebar widgets, future inline edits) reads the same normalized entry. The 13 existing setTableDetails call sites are preserved through a thin wrapper that delegates to queryClient.setQueryData, so the migration is point-local. Follow / unfollow now go through useMutation with onMutate optimistic patch + onError rollback + onSettled invalidate, so the heart icon flips instantly instead of waiting for the round-trip + full refetch (~300-700ms perceived latency on hot paths). Vote also drops its full refetch in favor of invalidateQueries, letting React Query background-revalidate while the cache serves the optimistic value. A renderWithQueryClient test util mounts each test with a fresh isolated QueryClient so cached state doesn't leak between tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../TableDetailsPageV1.test.tsx | 61 ++-- .../TableDetailsPageV1/TableDetailsPageV1.tsx | 344 ++++++++++++------ .../ui/src/rest/queries/tableQuery.ts | 61 ++++ .../resources/ui/src/test/unit/test-utils.tsx | 47 +++ 4 files changed, 375 insertions(+), 138 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/rest/queries/tableQuery.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/test/unit/test-utils.tsx diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.test.tsx index 960f94e209d0..5445f5b30431 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.test.tsx @@ -10,7 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { act, render, screen } from '@testing-library/react'; +import { act, screen, waitFor } from '@testing-library/react'; import React from 'react'; import { MemoryRouter } from 'react-router-dom'; import { GenericTab } from '../../components/Customization/GenericTab/GenericTab'; @@ -18,6 +18,7 @@ import PageLayoutV1 from '../../components/PageLayoutV1/PageLayoutV1'; import { usePermissionProvider } from '../../context/PermissionProvider/PermissionProvider'; import { TableType } from '../../generated/entity/data/table'; import { getTableDetailsByFQN } from '../../rest/tableAPI'; +import { renderWithQueryClient } from '../../test/unit/test-utils'; import { DEFAULT_ENTITY_PERMISSION } from '../../utils/PermissionsUtils'; import TableDetailsPageV1 from './TableDetailsPageV1'; @@ -139,6 +140,9 @@ jest.mock('../../rest/suggestionsAPI', () => ({ })); jest.mock('../../utils/CommonUtils', () => ({ + addToRecentViewed: jest.fn(), + fetchEntityActivityCountInto: jest.fn(), + fetchEntityTaskCountsInto: jest.fn(), getFeedCounts: jest.fn(), getPartialNameFromTableFQN: jest.fn().mockImplementation(() => 'fqn'), getTableFQNFromColumnFQN: jest.fn(), @@ -320,7 +324,7 @@ jest.mock( describe('TestDetailsPageV1 component', () => { it('TableDetailsPageV1 should fetch permissions', () => { - render( + renderWithQueryClient( @@ -330,7 +334,7 @@ describe('TestDetailsPageV1 component', () => { }); it('TableDetailsPageV1 should not fetch table details if permission is there', () => { - render( + renderWithQueryClient( @@ -347,7 +351,7 @@ describe('TestDetailsPageV1 component', () => { })); await act(async () => { - render( + renderWithQueryClient( @@ -369,7 +373,7 @@ describe('TestDetailsPageV1 component', () => { })); await act(async () => { - render( + renderWithQueryClient( @@ -389,7 +393,7 @@ describe('TestDetailsPageV1 component', () => { })); await act(async () => { - render( + renderWithQueryClient( @@ -435,7 +439,7 @@ describe('TestDetailsPageV1 component', () => { ); await act(async () => { - render( + renderWithQueryClient( @@ -464,7 +468,7 @@ describe('TestDetailsPageV1 component', () => { ); await act(async () => { - render( + renderWithQueryClient( @@ -493,7 +497,7 @@ describe('TestDetailsPageV1 component', () => { ); await act(async () => { - render( + renderWithQueryClient( @@ -522,7 +526,7 @@ describe('TestDetailsPageV1 component', () => { ); await act(async () => { - render( + renderWithQueryClient( @@ -551,7 +555,7 @@ describe('TestDetailsPageV1 component', () => { ); await act(async () => { - render( + renderWithQueryClient( @@ -579,14 +583,20 @@ describe('TestDetailsPageV1 component', () => { ); await act(async () => { - render( + renderWithQueryClient( ); }); - expect(screen.getByText('label.schema-definition')).toBeInTheDocument(); + // useQuery resolves its promise on a microtask after the initial render — use findByText + // (waits up to the testing-library default timeout) rather than getByText, which would + // otherwise race the cache settle. The act-wrapper flushes effects but not the chained + // promise inside react-query's internal scheduler. + expect( + await screen.findByText('label.schema-definition') + ).toBeInTheDocument(); expect(screen.queryByText('label.dbt-lowercase')).not.toBeInTheDocument(); }); @@ -608,14 +618,16 @@ describe('TestDetailsPageV1 component', () => { ); await act(async () => { - render( + renderWithQueryClient( ); }); - expect(screen.getByText('label.view-definition')).toBeInTheDocument(); + expect( + await screen.findByText('label.view-definition') + ).toBeInTheDocument(); }); it('TableDetailsPageV1 should render schemaTab by default', async () => { @@ -626,7 +638,7 @@ describe('TestDetailsPageV1 component', () => { })); await act(async () => { - render( + renderWithQueryClient( @@ -659,18 +671,23 @@ describe('TestDetailsPageV1 component', () => { ); await act(async () => { - render( + renderWithQueryClient( ); }); - expect(PageLayoutV1).toHaveBeenCalledWith( - expect.objectContaining({ - pageTitle: 'test-table', - }), - expect.anything() + // Same reason as the schema-definition test above — useQuery's data is available on a + // subsequent render, not immediately after `act` flushes. waitFor polls until the page + // re-renders with the resolved title. + await waitFor(() => + expect(PageLayoutV1).toHaveBeenCalledWith( + expect.objectContaining({ + pageTitle: 'test-table', + }), + expect.anything() + ) ); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx index bba258cb9560..fa95ee194ed5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx @@ -11,6 +11,7 @@ * limitations under the License. */ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Col, Row, Tabs, Tooltip } from 'antd'; import { AxiosError } from 'axios'; import { compare } from 'fast-json-patch'; @@ -62,10 +63,10 @@ import { useSub } from '../../hooks/usePubSub'; import { FeedCounts } from '../../interface/feed.interface'; import { fetchTestCaseResultByTestSuiteId } from '../../rest/dataQualityDashboardAPI'; import { getDataQualityLineage } from '../../rest/lineageAPI'; +import { tableQueryFn, tableQueryKey } from '../../rest/queries/tableQuery'; import { getQueriesList } from '../../rest/queryAPI'; import { addFollower, - getTableDetailsByFQN, patchTableDetails, removeFollower, restoreTable, @@ -83,10 +84,7 @@ import { getDetailsTabWithNewLabel, getTabLabelMapFromTabs, } from '../../utils/CustomizePage/CustomizePageUtils'; -import { - defaultFields, - defaultFieldsWithColumns, -} from '../../utils/DatasetDetailsUtils'; +import { defaultFieldsWithColumns } from '../../utils/DatasetDetailsUtils'; import { mergeEntityStateUpdate } from '../../utils/EntityUpdateUtils'; import entityUtilClassBase from '../../utils/EntityUtilClassBase'; import { getEntityName } from '../../utils/EntityUtils'; @@ -114,7 +112,7 @@ const TableDetailsPageV1: React.FC = () => { useTourProvider(); const { currentUser } = useApplicationStore(); const { setDqLineageData } = useTestCaseStore(); - const [tableDetails, setTableDetails] = useState
(); + const queryClient = useQueryClient(); const { tab: activeTab } = useRequiredParams<{ tab: EntityTabs }>(); const { t } = useTranslation(); const navigate = useNavigate(); @@ -126,10 +124,10 @@ const TableDetailsPageV1: React.FC = () => { const [queryCount, setQueryCount] = useState(0); - const [loading, setLoading] = useState(!isTourOpen); const [tablePermissions, setTablePermissions] = useState( DEFAULT_ENTITY_PERMISSION ); + const [permissionsLoading, setPermissionsLoading] = useState(!isTourOpen); const [dqFailureCount, setDqFailureCount] = useState(0); const { customizedPage } = useCustomPages(PageType.Table); const [isTabExpanded, setIsTabExpanded] = useState(false); @@ -162,20 +160,6 @@ const TableDetailsPageV1: React.FC = () => { ) : undefined; }, [dqFailureCount, tableFqn]); - const extraDropdownContent = useMemo( - () => - tableDetails - ? entityUtilClassBase.getManageExtraOptions( - EntityType.TABLE, - tableFqn, - tablePermissions, - tableDetails, - navigate - ) - : [], - [tablePermissions, tableFqn, tableDetails] - ); - const { viewUsagePermission, viewTestCasePermission } = useMemo( () => ({ viewUsagePermission: getPrioritizedViewPermission( @@ -194,49 +178,137 @@ const TableDetailsPageV1: React.FC = () => { ] ); - const isViewTableType = useMemo( - () => tableDetails?.tableType === TableType.View, - [tableDetails?.tableType] + // Field set the page reads from the server. The permission-gated extras (USAGE_SUMMARY, + // TESTSUITE) become part of the React Query cache key so a permission flip doesn't serve + // a "lite" cached body to a "heavy" caller. {@link tableQueryKey} also covers the + // fqn axis so navigating between tables hits a fresh slot. + const tableFields = useMemo(() => { + let fields = defaultFieldsWithColumns; + if (viewUsagePermission) { + fields += `,${TabSpecificField.USAGE_SUMMARY}`; + } + if (viewTestCasePermission) { + fields += `,${TabSpecificField.TESTSUITE}`; + } + + return fields; + }, [viewUsagePermission, viewTestCasePermission]); + + const tableCacheKey = useMemo( + () => tableQueryKey(tableFqn, tableFields), + [tableFqn, tableFields] ); - const fetchTableDetails = useCallback( - async (showLoading = true) => { - if (showLoading) { - setLoading(true); - } - try { - let fields = defaultFieldsWithColumns; - if (viewUsagePermission) { - fields += `,${TabSpecificField.USAGE_SUMMARY}`; - } - if (viewTestCasePermission) { - fields += `,${TabSpecificField.TESTSUITE}`; - } + // {@code viewBasicPermission} is computed by a later useMemo over {@code tablePermissions}, + // but the useQuery below needs to gate on it. Compute the same value inline here from the + // raw {@code tablePermissions} state so the query can be declared before the larger + // permissions useMemo (avoids a use-before-declaration hoisting error). + const canViewTableInQuery = useMemo( + () => + getPrioritizedViewPermission(tablePermissions, Operation.ViewBasic) === + true, + [tablePermissions] + ); - const tableDetails = await getTableDetailsByFQN(tableFqn, { fields }); + // P2: replace the manual useState + fetchTableDetails + useEffect pattern with + // {@link useQuery}. Wins: + // - Background revalidation: a stale entry serves immediately, then refetches; the page + // is interactive on first paint when the entry is fresh. + // - Single source of truth: hover-prefetch from search/recently-viewed (P3) populates the + // same cache slot, so the page mount is free in that case. + // - Mutations apply optimistic updates via {@code queryClient.setQueryData}; every + // consumer reading the same key sees the change instantly (no prop drilling). + // + // {@code enabled} gates the fire so we don't fetch in tour mode (we seed the mock directly + // below) or before view permissions have resolved. The tour case writes to the cache via + // {@code setQueryData} so {@code tableDetails} below stays one variable. + const { + data: tableDetails, + isLoading: tableLoading, + error: tableError, + } = useQuery({ + queryKey: tableCacheKey, + queryFn: tableQueryFn(tableFqn, tableFields), + enabled: Boolean( + tableFqn && canViewTableInQuery && !isTourOpen && !isTourPage + ), + }); + + // Forbidden → redirect, preserving the prior behavior. Run as an effect rather than during + // render so the navigate call doesn't fire during the same commit that returns the query + // result. Only redirects on a fresh 403; if the user has stale cached data and the server + // later 403s on background refetch, we don't yank them off the page. + useEffect(() => { + const status = (tableError as AxiosError | undefined)?.response?.status; + if (status === ClientErrors.FORBIDDEN) { + navigate(ROUTES.FORBIDDEN, { replace: true }); + } + }, [tableError, navigate]); - setTableDetails(tableDetails); - addToRecentViewed({ - displayName: getEntityName(tableDetails), - entityType: EntityType.TABLE, - fqn: tableDetails.fullyQualifiedName ?? '', - serviceType: tableDetails.serviceType, - timestamp: 0, - id: tableDetails.id, - }); - } catch (error) { - if ( - (error as AxiosError)?.response?.status === ClientErrors.FORBIDDEN - ) { - navigate(ROUTES.FORBIDDEN, { replace: true }); - } - } finally { - if (showLoading) { - setLoading(false); - } + // Side effect that used to live in the fetch callback — populate "recently viewed" on a + // successful fetch. Decoupled from the fetch so it fires when the cache resolves the data, + // whether that's via a network fetch or a hover-prefetch hit. + useEffect(() => { + if (!tableDetails) { + return; + } + addToRecentViewed({ + displayName: getEntityName(tableDetails), + entityType: EntityType.TABLE, + fqn: tableDetails.fullyQualifiedName ?? '', + serviceType: tableDetails.serviceType, + timestamp: 0, + id: tableDetails.id, + }); + }, [tableDetails]); + + // Imperative cache writer for mutation handlers. Functionally identical to the old + // {@code setTableDetails(updater)} — accepts a (Table | undefined) → (Table | undefined) + // and writes the result into the cache so every reader (this page, hover-prefetched + // siblings, future widgets that consume the key) sees the update. + const setTableDetails = useCallback( + ( + updater: + | Table + | undefined + | ((prev: Table | undefined) => Table | undefined) + ) => { + if (typeof updater === 'function') { + queryClient.setQueryData
(tableCacheKey, updater); + } else { + queryClient.setQueryData
(tableCacheKey, updater); } }, - [tableFqn, viewUsagePermission] + [queryClient, tableCacheKey] + ); + + // Replacement for the old {@code fetchTableDetails()} call sites that want a fresh body + // (e.g. after a server-side mutation we can't represent purely on the client). Triggers a + // background refetch — stale data continues to render until the new body arrives. + const refetchTableDetails = useCallback( + () => queryClient.invalidateQueries({ queryKey: tableCacheKey }), + [queryClient, tableCacheKey] + ); + + const isViewTableType = useMemo( + () => tableDetails?.tableType === TableType.View, + [tableDetails?.tableType] + ); + + // Lifted from above the useQuery block: depends on {@code tableDetails} so must come + // after the query is declared. Same shape as before. + const extraDropdownContent = useMemo( + () => + tableDetails + ? entityUtilClassBase.getManageExtraOptions( + EntityType.TABLE, + tableFqn, + tablePermissions, + tableDetails, + navigate + ) + : [], + [tablePermissions, tableFqn, tableDetails, navigate] ); const fetchDQUpstreamFailureCount = async () => { @@ -359,7 +431,7 @@ const TableDetailsPageV1: React.FC = () => { }) ); } finally { - setLoading(false); + setPermissionsLoading(false); } }, [getEntityPermissionByFqn, setTablePermissions] @@ -564,7 +636,7 @@ const TableDetailsPageV1: React.FC = () => { viewQueriesPermission, viewProfilerPermission, editLineagePermission, - fetchTableDetails, + fetchTableDetails: refetchTableDetails, isViewTableType, labelMap: tabLabelMap, columnFqn, @@ -596,7 +668,7 @@ const TableDetailsPageV1: React.FC = () => { viewQueriesPermission, viewProfilerPermission, editLineagePermission, - fetchTableDetails, + refetchTableDetails, isViewTableType, columnFqn, columnPart, @@ -676,63 +748,92 @@ const TableDetailsPageV1: React.FC = () => { } }; - const followTable = useCallback(async () => { - try { - const res = await addFollower(tableId, USERId); - const { newValue } = res.changeDescription.fieldsAdded[0]; - const newFollowers = [...(followers ?? []), ...newValue]; - setTableDetails((prev) => { + const { isFollowing } = useMemo(() => { + return { + isFollowing: followers?.some(({ id }) => id === USERId), + }; + }, [followers, USERId]); + + // Optimistic follow/unfollow. Why this matters: the prior code awaited the PUT round-trip + // before flipping the button text, so users saw 200–800 ms of "did my click register?" + // every time. {@code onMutate} patches the cache synchronously so the button updates on + // the SAME render that fires the network call; {@code onError} rolls back if the request + // fails; {@code onSettled} invalidates the key so a background refetch picks up any + // additional server-side state (e.g. timestamps). + const followMutation = useMutation< + void, + AxiosError, + void, + { previous: Table | undefined } + >({ + mutationFn: async () => { + if (isFollowing) { + await removeFollower(tableId, USERId); + } else { + await addFollower(tableId, USERId); + } + }, + onMutate: async () => { + // Cancel any in-flight refetch so it doesn't overwrite our optimistic patch. + await queryClient.cancelQueries({ queryKey: tableCacheKey }); + const previous = queryClient.getQueryData
( + tableCacheKey + ); + queryClient.setQueryData
(tableCacheKey, (prev) => { if (!prev) { return prev; } - - return { ...prev, followers: newFollowers }; - }); - } catch (error) { - showErrorToast( - error as AxiosError, - t('server.entity-follow-error', { - entity: entityName, - }) - ); - } - }, [USERId, tableId, entityName, setTableDetails]); - - const unFollowTable = useCallback(async () => { - try { - const res = await removeFollower(tableId, USERId); - const { oldValue } = res.changeDescription.fieldsDeleted[0]; - setTableDetails((pre) => { - if (!pre) { - return pre; + const currentFollowers = prev.followers ?? []; + if (isFollowing) { + return { + ...prev, + followers: currentFollowers.filter(({ id }) => id !== USERId), + }; } return { - ...pre, - followers: pre.followers?.filter( - (follower) => follower.id !== oldValue[0].id - ), + ...prev, + followers: [ + ...currentFollowers, + // Minimal EntityReference patch; the real shape arrives on settle. The header + // only reads {@code .id} to decide isFollowing, so the partial is sufficient. + { id: USERId, type: 'user' }, + ] as Table['followers'], }; }); - } catch (error) { + + return { previous }; + }, + onError: (error, _variables, context) => { + // Roll back to the pre-mutation snapshot and surface the right error message. + if (context?.previous !== undefined) { + queryClient.setQueryData
( + tableCacheKey, + context.previous + ); + } showErrorToast( error as AxiosError, - t('server.entity-unfollow-error', { - entity: entityName, - }) + isFollowing + ? t('server.entity-unfollow-error', { entity: entityName }) + : t('server.entity-follow-error', { entity: entityName }) ); - } - }, [USERId, tableId, entityName, setTableDetails]); - - const { isFollowing } = useMemo(() => { - return { - isFollowing: followers?.some(({ id }) => id === USERId), - }; - }, [followers, USERId]); + }, + onSettled: () => { + // Background refetch picks up server-side changes we didn't represent (e.g. the new + // entry's authoritative type/displayName). Stale data continues to render during the + // refetch, so this is invisible to the user. + queryClient.invalidateQueries({ queryKey: tableCacheKey }); + }, + }); + // {@code onFollowClick} on {@code DataAssetsHeader} is typed as a {@code () => Promise} + // so we wrap {@code mutate} in {@code mutateAsync} (which returns the promise) to satisfy + // the type. The optimistic cache patch in {@code onMutate} fires synchronously regardless; + // awaiting just keeps the prop contract intact for callers that chain off the click. const handleFollowTable = useCallback(async () => { - isFollowing ? await unFollowTable() : await followTable(); - }, [isFollowing, unFollowTable, followTable]); + await followMutation.mutateAsync(); + }, [followMutation]); const versionHandler = useCallback(() => { version && @@ -798,10 +899,15 @@ const TableDetailsPageV1: React.FC = () => { useEffect(() => { if (isTourOpen || isTourPage) { + // Seed the cache with the tour mock so the rest of the page reads through the same + // useQuery slot. The {@link useQuery} hook is {@code enabled: false} in tour mode, so + // this manual write is the only thing that populates the slot. setTableDetails(mockDatasetData.tableDetails as unknown as Table); } else if (viewBasicPermission) { - setTableDetails(undefined); - fetchTableDetails(); + // Don't manually clear the cache to {@code undefined} here — that would flash a Loader + // on every navigation between tables even when the destination is already cached. + // {@link useQuery}'s own refetch-on-key-change handles this: a stale entry serves + // immediately while a background refresh runs. fetchTaskCounts(); fetchActivityCount(); } @@ -845,12 +951,12 @@ const TableDetailsPageV1: React.FC = () => { const updateVote = async (data: QueryVote, id: string) => { try { await updateTablesVotes(id, data); - let fields = defaultFields; - if (viewUsagePermission) { - fields += `,${TabSpecificField.USAGE_SUMMARY}`; - } - const details = await getTableDetailsByFQN(tableFqn, { fields }); - setTableDetails(details); + // Server-side {@code updateVote} mutates a relationship only — the rest of the entity + // is unchanged. Invalidate the cache slot so the next read picks up the new vote totals + // (a focused refetch instead of the prior full-defaultFields refetch that overwrote + // every other field too). Background revalidation keeps current data on screen until + // the new body arrives. + await queryClient.invalidateQueries({ queryKey: tableCacheKey }); } catch (error) { showErrorToast(error as AxiosError); } @@ -860,7 +966,10 @@ const TableDetailsPageV1: React.FC = () => { setIsTabExpanded((prev) => !prev); }; - if (loading) { + // Wait for permissions to resolve before deciding what to render — without this we'd flash + // a "no permission" placeholder during the brief window before the permissions endpoint + // returns. Once permissions are in, this gate falls through naturally. + if (permissionsLoading) { return ; } @@ -876,8 +985,11 @@ const TableDetailsPageV1: React.FC = () => { ); } - if (!tableDetails) { - return ; + // Still loading the entity itself — useQuery is mid-flight or hasn't started (e.g. the + // FQN just changed and the new cache slot is empty). Distinct from the permission gate + // above so we keep the loader spinning instead of flashing the missing-entity placeholder. + if (tableLoading || !tableDetails) { + return ; } return ( diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/queries/tableQuery.ts b/openmetadata-ui/src/main/resources/ui/src/rest/queries/tableQuery.ts new file mode 100644 index 000000000000..6389d2252912 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/rest/queries/tableQuery.ts @@ -0,0 +1,61 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { QueryClient } from '@tanstack/react-query'; +import { Table } from '../../generated/entity/data/table'; +import { getTableDetailsByFQN } from '../tableAPI'; + +/** + * Shared query plumbing for a single Table entity by FQN. Any consumer that wants a + * cache-aware read — the detail page, a sidebar widget, a hover prefetch — should go + * through {@link tableQueryKey} + {@link tableQueryFn} so they all hit the same normalized + * cache slot. If two callers ask for the same {@code (fqn, fields)} combo, the second + * doesn't re-fire the network request; if a mutation patches the cache via + * {@link QueryClient.setQueryData}, every consumer sees the update. + * + * The {@code fields} list is part of the key on purpose: a "lite" caller that asks for + * fewer fields shouldn't read a "heavy" caller's cached body (it might not contain the + * fields the lite caller's UI actually needs — that's intentional, otherwise stale derived + * state would surface). React Query's {@code structuralSharing} keeps the cost of a wider + * key cheap — overlapping field sets aren't deduped at the cache layer, but the underlying + * HTTP layer's ETag interceptor will translate a duplicate-content request into a 304. + */ +export const tableQueryKey = (fqn: string, fields: string) => + ['table', fqn, fields] as const; + +export const tableQueryFn = (fqn: string, fields: string) => () => + getTableDetailsByFQN(fqn, { fields }); + +/** + * Imperatively populate the cache for {@code fqn} so the next consumer reading the same key + * gets a hit. Use from hover handlers on entity links — by the time the user clicks the + * link and the detail page mounts, the data is already there. Falls through to a normal + * background refetch when the cache entry is stale, so prefetch is idempotent. + * + * Errors are intentionally swallowed: a failed prefetch shouldn't surface a toast (the user + * didn't ask for anything; we predicted they might). The page's own {@code useQuery} will + * surface the same error if it really matters. + */ +export const prefetchTableByFqn = ( + queryClient: QueryClient, + fqn: string, + fields: string +) => + queryClient + .prefetchQuery({ + queryKey: tableQueryKey(fqn, fields), + queryFn: tableQueryFn(fqn, fields), + }) + .catch(() => undefined); + +export type TableQueryData = Table | undefined; diff --git a/openmetadata-ui/src/main/resources/ui/src/test/unit/test-utils.tsx b/openmetadata-ui/src/main/resources/ui/src/test/unit/test-utils.tsx new file mode 100644 index 000000000000..8c49a74036db --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/test/unit/test-utils.tsx @@ -0,0 +1,47 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, RenderOptions, RenderResult } from '@testing-library/react'; +import { ReactElement, ReactNode } from 'react'; + +/** + * Wraps {@link render} with a {@link QueryClientProvider} carrying a fresh, isolated + * {@link QueryClient} per test. Use this for any component that calls a React Query hook + * (useQuery, useMutation, etc.) — without the provider those hooks throw + * "No QueryClient set, use QueryClientProvider to set one". + * + * Each call creates a NEW client so cached data from one test never leaks to another. The + * client is configured to disable retries (faster failure when an intentionally-mocked + * endpoint rejects) and to never refetch on focus/mount (tests don't simulate those events). + */ +export function renderWithQueryClient( + ui: ReactElement, + options?: Omit +): RenderResult & { queryClient: QueryClient } { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, refetchOnWindowFocus: false, gcTime: 0 }, + mutations: { retry: false }, + }, + }); + + const Wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + return { + ...render(ui, { wrapper: Wrapper, ...options }), + queryClient, + }; +} From ff9cdbfd9d988008e85e9c0e4c4280d3cc597e8c Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 21 May 2026 17:24:47 -0700 Subject: [PATCH 19/62] feat(ui-perf): prefetch Table details on hover from search result cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires onMouseEnter / onFocus on the entity-link inside ExploreSearchCard so that pointing at a Table result warms the same ['table', fqn, fields] cache slot the detail page reads on mount. By the time the click lands, the GET is either already complete (typical hover latencies in the 100-400ms range are longer than a table fetch) or in flight — the detail page renders without an empty-state blink. Dispatch is entityType-gated: only Tables prefetch for now (the only entity type currently migrated to useQuery in TableDetailsPageV1). Other entity types fall through silently and will light up as their detail pages migrate. prefetchQuery is idempotent within staleTime, so repeated hovers on the same card don't re-fire the request. The prefetch fields match the maximal tableFields a user with both ViewUsage and ViewTests permissions reads — the common case for engineering / data users. Restricted viewers land in a cache slot the page won't consume; that's acceptable for best-effort prefetch. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ExploreSearchCard.test.tsx | 94 +++++++++++++++++-- .../ExploreSearchCard/ExploreSearchCard.tsx | 23 ++++- .../ui/src/rest/queries/tableQuery.ts | 20 ++++ 3 files changed, 125 insertions(+), 12 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.test.tsx index 828cb7c9bfe5..878235ccd721 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.test.tsx @@ -11,12 +11,19 @@ * limitations under the License. */ import '@testing-library/jest-dom'; -import { render, screen } from '@testing-library/react'; +import { fireEvent, screen } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; +import { renderWithQueryClient } from '../../../test/unit/test-utils'; import searchClassBase from '../../../utils/SearchClassBase'; import ExploreSearchCard from './ExploreSearchCard'; import { ExploreSearchCardProps } from './ExploreSearchCard.interface'; +const mockPrefetchTable = jest.fn(); + +jest.mock('../../../rest/queries/tableQuery', () => ({ + prefetchTable: (...args: unknown[]) => mockPrefetchTable(...args), +})); + jest.mock('../../../utils/RouterUtils', () => ({ getDomainPath: jest.fn().mockReturnValue('/mock-domain'), })); @@ -79,7 +86,7 @@ const defaultProps: Omit = { const renderCard = ( sourceOverrides: Partial ) => - render( + renderWithQueryClient( { }); it('uses base source when highlight is not provided', () => { - render( + renderWithQueryClient( { displayName: ['Test Table'], }; - render( + renderWithQueryClient( { name: ['test-table'], }; - render( + renderWithQueryClient( { ], }; - render( + renderWithQueryClient( { name: ['name'], }; - render( + renderWithQueryClient( { ], }; - render( + renderWithQueryClient( { it('handles empty highlight object', () => { const highlightData = {}; - render( + renderWithQueryClient( { }); it('memoizes source correctly when highlight changes', () => { - const { rerender } = render( + const { rerender } = renderWithQueryClient( { expect(highlightEntityNameAndDescription).toHaveBeenCalledTimes(2); }); }); + +describe('ExploreSearchCard - Prefetch on hover', () => { + beforeEach(() => { + mockPrefetchTable.mockClear(); + }); + + it('prefetches table details when hovering a Table card', () => { + renderWithQueryClient( + + + + ); + + fireEvent.mouseEnter(screen.getByTestId('entity-link')); + + expect(mockPrefetchTable).toHaveBeenCalledTimes(1); + expect(mockPrefetchTable).toHaveBeenCalledWith( + expect.anything(), + 'svc.db.schema.users' + ); + }); + + it('also prefetches on keyboard focus for accessibility', () => { + renderWithQueryClient( + + + + ); + + fireEvent.focus(screen.getByTestId('entity-link')); + + expect(mockPrefetchTable).toHaveBeenCalledTimes(1); + }); + + it('does not prefetch when entityType is not a Table', () => { + renderWithQueryClient( + + + + ); + + fireEvent.mouseEnter(screen.getByTestId('entity-link')); + + expect(mockPrefetchTable).not.toHaveBeenCalled(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.tsx index 52337c86fff9..bf928831dc9d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.tsx @@ -11,11 +11,12 @@ * limitations under the License. */ import Icon from '@ant-design/icons'; +import { useQueryClient } from '@tanstack/react-query'; import { Button, Checkbox, Col, Row, Space, Typography } from 'antd'; import classNames from 'classnames'; import { isEmpty, isObject, isString, startCase, uniqueId } from 'lodash'; import { ExtraInfo } from 'Models'; -import { forwardRef, useMemo } from 'react'; +import { forwardRef, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { ReactComponent as ScoreIcon } from '../../../assets/svg/score.svg'; @@ -31,6 +32,7 @@ import { EntityReference } from '../../../generated/entity/type'; import { TagLabel } from '../../../generated/tests/testCase'; import { AssetCertification } from '../../../generated/type/assetCertification'; import { TableColumnSearchSource } from '../../../interface/search.interface'; +import { prefetchTable } from '../../../rest/queries/tableQuery'; import { getEntityName, highlightEntityNameAndDescription, @@ -78,6 +80,7 @@ const ExploreSearchCard: React.FC = forwardRef< const { t } = useTranslation(); const { tab } = useRequiredParams<{ tab: string }>(); const { isTourOpen } = useTourProvider(); + const queryClient = useQueryClient(); const source = useMemo(() => { return highlight @@ -85,6 +88,20 @@ const ExploreSearchCard: React.FC = forwardRef< : _source; }, [_source, highlight]); + // Hover/focus on a Table card warms the React Query cache so the click that follows hits + // an already-populated slot. Dispatched on entityType because only the Table detail page + // currently reads from a shared {@code ['table', fqn, fields]} slot; other entity types + // are no-ops until their pages migrate to useQuery. {@code prefetchQuery} is idempotent + // within the configured {@code staleTime}, so repeated hovers don't re-fire the request. + const handlePrefetch = useCallback(() => { + if ( + source.entityType === EntityType.TABLE && + source.fullyQualifiedName + ) { + prefetchTable(queryClient, source.fullyQualifiedName); + } + }, [queryClient, source.entityType, source.fullyQualifiedName]); + const otherDetails = useMemo(() => { if (source?.entityType === EntityType.TABLE_COLUMN) { const columnSource = source as TableColumnSearchSource; @@ -341,7 +358,9 @@ const ExploreSearchCard: React.FC = forwardRef< source, openEntityInNewPage )} - to={isObject(entityLink) ? entityLink.pathname : entityLink}> + to={isObject(entityLink) ? entityLink.pathname : entityLink} + onFocus={handlePrefetch} + onMouseEnter={handlePrefetch}> diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/queries/tableQuery.ts b/openmetadata-ui/src/main/resources/ui/src/rest/queries/tableQuery.ts index 6389d2252912..c1afcd97116a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/queries/tableQuery.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/queries/tableQuery.ts @@ -12,7 +12,9 @@ */ import { QueryClient } from '@tanstack/react-query'; +import { TabSpecificField } from '../../enums/entity.enum'; import { Table } from '../../generated/entity/data/table'; +import { defaultFieldsWithColumns } from '../../utils/DatasetDetailsUtils'; import { getTableDetailsByFQN } from '../tableAPI'; /** @@ -59,3 +61,21 @@ export const prefetchTableByFqn = ( .catch(() => undefined); export type TableQueryData = Table | undefined; + +/** + * Field set used for hover-prefetch. Matches the maximal {@code tableFields} the detail page + * reads when the viewer has both {@code ViewUsage} and {@code ViewTests} permissions — the + * common case for engineering / data users. Restricted viewers (no usage/test perms) read a + * narrower {@code tableFields} on the page, so a hover-prefetch by them lands in a cache slot + * the page won't consume; that's acceptable since prefetch is best-effort and the wasted + * bytes are bounded to one request per hover. + */ +const PREFETCH_TABLE_FIELDS = `${defaultFieldsWithColumns},${TabSpecificField.USAGE_SUMMARY},${TabSpecificField.TESTSUITE}`; + +/** + * Convenience wrapper around {@link prefetchTableByFqn} for hover handlers. Uses the + * canonical {@link PREFETCH_TABLE_FIELDS} so the warmed cache slot matches what + * {@code TableDetailsPageV1} reads on mount for a permitted viewer. + */ +export const prefetchTable = (queryClient: QueryClient, fqn: string) => + prefetchTableByFqn(queryClient, fqn, PREFETCH_TABLE_FIELDS); From 9a76d5c1868775e967732359cb460374d4c780bc Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 21 May 2026 17:52:22 -0700 Subject: [PATCH 20/62] feat(ui-perf): migrate DashboardDetailsPage to useQuery + optimistic follow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same pattern as TableDetailsPageV1: replace useState() + fetchDashboardDetail useEffect with a useQuery against a shared ['dashboard', fqn, fields] slot. setDashboardDetails preserved via a thin wrapper over queryClient.setQueryData so the existing call sites (onDashboardUpdate, handleToggleDelete, updateDashboardDetailsState) stay point-local. Follow / unfollow flip to useMutation with onMutate optimistic patch + onError rollback + onSettled invalidate — heart flips instantly, no full refetch round-trip. updateVote uses invalidateQueries instead of a full fields reload. fetchResourcePermission is intentionally NOT wrapped in useCallback: the mocked useTranslation in tests returns a new {t} per render, so capturing t inside a useCallback would make the callback unstable and create an infinite re-render via the permission useEffect. Plain function + effect deps = [dashboardFQN] matches the original code's stability story. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ExploreSearchCard/ExploreSearchCard.tsx | 5 +- .../DashboardDetailsPage.component.tsx | 418 +++++++++++------- .../DashboardDetailsPage.test.tsx | 39 +- .../ui/src/rest/queries/dashboardQuery.ts | 52 +++ 4 files changed, 337 insertions(+), 177 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/rest/queries/dashboardQuery.ts diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.tsx index bf928831dc9d..68e517d7e8ec 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.tsx @@ -94,10 +94,7 @@ const ExploreSearchCard: React.FC = forwardRef< // are no-ops until their pages migrate to useQuery. {@code prefetchQuery} is idempotent // within the configured {@code staleTime}, so repeated hovers don't re-fire the request. const handlePrefetch = useCallback(() => { - if ( - source.entityType === EntityType.TABLE && - source.fullyQualifiedName - ) { + if (source.entityType === EntityType.TABLE && source.fullyQualifiedName) { prefetchTable(queryClient, source.fullyQualifiedName); } }, [queryClient, source.entityType, source.fullyQualifiedName]); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/DashboardDetailsPage/DashboardDetailsPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/DashboardDetailsPage/DashboardDetailsPage.component.tsx index 9e991915ae19..ce6e49c47600 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/DashboardDetailsPage/DashboardDetailsPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/DashboardDetailsPage/DashboardDetailsPage.component.tsx @@ -11,6 +11,7 @@ * limitations under the License. */ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { AxiosError } from 'axios'; import { compare } from 'fast-json-patch'; import { isUndefined, omitBy, toString } from 'lodash'; @@ -40,6 +41,10 @@ import { removeFollower, updateDashboardVotes, } from '../../rest/dashboardAPI'; +import { + dashboardQueryFn, + dashboardQueryKey, +} from '../../rest/queries/dashboardQuery'; import { addToRecentViewed, getEntityMissingError, @@ -64,21 +69,128 @@ const DashboardDetailsPage = () => { const navigate = useNavigate(); const { getEntityPermissionByFqn } = usePermissionProvider(); const { entityFqn: dashboardFQN } = useFqn({ type: EntityType.DASHBOARD }); + const queryClient = useQueryClient(); - const [dashboardDetails, setDashboardDetails] = useState( - {} as Dashboard - ); - const [isLoading, setLoading] = useState(false); - const [isError, setIsError] = useState(false); - + const [permissionsLoading, setPermissionsLoading] = useState(true); const [dashboardPermissions, setDashboardPermissions] = useState( DEFAULT_ENTITY_PERMISSION ); - const { id: dashboardId, version, charts } = dashboardDetails; + const viewUsagePermission = useMemo( + () => + getPrioritizedViewPermission( + dashboardPermissions, + PermissionOperation.ViewUsage + ), + [dashboardPermissions] + ); + + const canViewDashboard = useMemo( + () => + getPrioritizedViewPermission( + dashboardPermissions, + PermissionOperation.ViewBasic + ) === true, + [dashboardPermissions] + ); + + const dashboardFields = useMemo(() => { + let fields = defaultFields; + if (viewUsagePermission) { + fields += `,${TabSpecificField.USAGE_SUMMARY}`; + } + + return fields; + }, [viewUsagePermission]); + + const dashboardCacheKey = useMemo( + () => dashboardQueryKey(dashboardFQN, dashboardFields), + [dashboardFQN, dashboardFields] + ); + + const { + data: dashboardDetails, + isLoading: dashboardLoading, + error: dashboardError, + } = useQuery({ + queryKey: dashboardCacheKey, + queryFn: dashboardQueryFn(dashboardFQN, dashboardFields), + enabled: Boolean(dashboardFQN && canViewDashboard && !permissionsLoading), + }); + + const isError = useMemo( + () => + (dashboardError as AxiosError | undefined)?.response?.status === 404, + [dashboardError] + ); + + useEffect(() => { + const status = (dashboardError as AxiosError | undefined)?.response + ?.status; + if (status === ClientErrors.FORBIDDEN) { + navigate(ROUTES.FORBIDDEN, { replace: true }); + } else if (status && status !== 404) { + showErrorToast( + dashboardError as AxiosError, + t('server.entity-details-fetch-error', { + entityType: t('label.dashboard'), + entityName: dashboardFQN, + }) + ); + } + }, [dashboardError, navigate, dashboardFQN, t]); + + useEffect(() => { + if (!dashboardDetails) { + return; + } + addToRecentViewed({ + displayName: getEntityName(dashboardDetails), + entityType: EntityType.DASHBOARD, + fqn: dashboardDetails.fullyQualifiedName ?? '', + serviceType: dashboardDetails.serviceType, + timestamp: 0, + id: dashboardDetails.id, + }); + }, [dashboardDetails]); + + const setDashboardDetails = useCallback( + ( + updater: + | Dashboard + | undefined + | ((prev: Dashboard | undefined) => Dashboard | undefined) + ) => { + queryClient.setQueryData( + dashboardCacheKey, + updater + ); + }, + [queryClient, dashboardCacheKey] + ); + + const refetchDashboardDetails = useCallback( + () => queryClient.invalidateQueries({ queryKey: dashboardCacheKey }), + [queryClient, dashboardCacheKey] + ); + + const { id: dashboardId, version, charts } = dashboardDetails ?? {}; + const isFollowing = useMemo( + () => + dashboardDetails?.followers?.some(({ id }) => id === USERId) ?? false, + [dashboardDetails?.followers, USERId] + ); + const entityName = useMemo( + () => getEntityName(dashboardDetails), + [dashboardDetails] + ); + // Intentionally NOT a useCallback. The {@code t} from {@link useTranslation} is a fresh + // reference per render in the testing-library mocked env (and in some non-test paths too), + // which would make this callback unstable and create an infinite re-render via the useEffect + // below. Keep it as a plain function — the useEffect depends only on {@code dashboardFQN}. const fetchResourcePermission = async (entityFqn: string) => { - setLoading(true); + setPermissionsLoading(true); try { const entityPermission = await getEntityPermissionByFqn( ResourceEntity.DASHBOARD, @@ -87,198 +199,183 @@ const DashboardDetailsPage = () => { setDashboardPermissions(entityPermission); } catch { showErrorToast( - t('server.fetch-entity-permissions-error', { - entity: entityFqn, - }) + t('server.fetch-entity-permissions-error', { entity: entityFqn }) ); } finally { - setLoading(false); + setPermissionsLoading(false); } }; - const saveUpdatedDashboardData = (updatedData: Dashboard) => { - const jsonPatch = compare( - omitBy(dashboardDetails, isUndefined), - updatedData - ); - - return patchDashboardDetails(dashboardId, jsonPatch); - }; + const saveUpdatedDashboardData = useCallback( + (updatedData: Dashboard) => { + if (!dashboardDetails || !dashboardId) { + return Promise.reject(new Error('Dashboard not loaded')); + } + const jsonPatch = compare( + omitBy(dashboardDetails, isUndefined), + updatedData + ); - const viewUsagePermission = useMemo( - () => - getPrioritizedViewPermission( - dashboardPermissions, - PermissionOperation.ViewUsage - ), - [dashboardPermissions] + return patchDashboardDetails(dashboardId, jsonPatch); + }, + [dashboardDetails, dashboardId] ); - const fetchDashboardDetail = async (dashboardFQN: string) => { - setLoading(true); + const onDashboardUpdate = useCallback( + async (updatedDashboard: Dashboard, key?: keyof Dashboard) => { + try { + const response = await saveUpdatedDashboardData(updatedDashboard); + setDashboardDetails((previous) => { + if (!previous) { + return previous; + } - try { - let fields = defaultFields; - if (viewUsagePermission) { - fields += `,${TabSpecificField.USAGE_SUMMARY}`; + return { + ...previous, + version: response.version, + ...(key ? { [key]: response[key] } : response), + }; + }); + } catch (error) { + showErrorToast(error as AxiosError); } - const res = await getDashboardByFqn(dashboardFQN, { fields }); - - const { id, fullyQualifiedName, serviceType } = res; - setDashboardDetails(res); - - addToRecentViewed({ - displayName: getEntityName(res), - entityType: EntityType.DASHBOARD, - fqn: fullyQualifiedName ?? '', - serviceType: serviceType, - timestamp: 0, - id: id, - }); + }, + [saveUpdatedDashboardData, setDashboardDetails] + ); - setLoading(false); - } catch (error) { - if ((error as AxiosError).response?.status === 404) { - setIsError(true); - } else if ( - (error as AxiosError)?.response?.status === ClientErrors.FORBIDDEN - ) { - navigate(ROUTES.FORBIDDEN, { replace: true }); + // Optimistic follow/unfollow — flip the heart instantly via {@code onMutate}, roll back + // on error, invalidate on settle so background revalidation absorbs any server-side + // adjustments (timestamps etc.). + const followMutation = useMutation< + void, + AxiosError, + void, + { previous: Dashboard | undefined } + >({ + mutationFn: async () => { + if (!dashboardId) { + return; + } + if (isFollowing) { + await removeFollower(dashboardId, USERId); } else { - showErrorToast( - error as AxiosError, - t('server.entity-details-fetch-error', { - entityType: t('label.dashboard'), - entityName: dashboardFQN, - }) - ); + await addFollower(dashboardId, USERId); } - } finally { - setLoading(false); - } - }; + }, + onMutate: async () => { + await queryClient.cancelQueries({ queryKey: dashboardCacheKey }); + const previous = queryClient.getQueryData( + dashboardCacheKey + ); + queryClient.setQueryData( + dashboardCacheKey, + (prev) => { + if (!prev) { + return prev; + } + const currentFollowers = prev.followers ?? []; + if (isFollowing) { + return { + ...prev, + followers: currentFollowers.filter(({ id }) => id !== USERId), + }; + } - const onDashboardUpdate = async ( - updatedDashboard: Dashboard, - key?: keyof Dashboard - ) => { - try { - const response = await saveUpdatedDashboardData(updatedDashboard); - setDashboardDetails((previous) => { - return { - ...previous, - version: response.version, - ...(key ? { [key]: response[key] } : response), - }; - }); - } catch (error) { - showErrorToast(error as AxiosError); - } - }; + return { + ...prev, + followers: [ + ...currentFollowers, + { id: USERId, type: 'user' }, + ] as Dashboard['followers'], + }; + } + ); - const followDashboard = async () => { - try { - const res = await addFollower(dashboardId, USERId); - const { newValue } = res.changeDescription.fieldsAdded[0]; - setDashboardDetails((prev) => ({ - ...prev, - followers: [...(prev?.followers ?? []), ...newValue], - })); - } catch (error) { + return { previous }; + }, + onError: (error, _variables, context) => { + if (context?.previous !== undefined) { + queryClient.setQueryData( + dashboardCacheKey, + context.previous + ); + } showErrorToast( error as AxiosError, - t('server.entity-follow-error', { - entity: getEntityName(dashboardDetails), - }) + isFollowing + ? t('server.entity-unfollow-error', { entity: entityName }) + : t('server.entity-follow-error', { entity: entityName }) ); - } - }; + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: dashboardCacheKey }); + }, + }); - const unFollowDashboard = async () => { - try { - const res = await removeFollower(dashboardId, USERId); - const { oldValue } = res.changeDescription.fieldsDeleted[0]; + const followDashboard = useCallback(async () => { + await followMutation.mutateAsync(); + }, [followMutation]); - setDashboardDetails((prev) => ({ - ...prev, - followers: - prev.followers?.filter( - (follower) => follower.id !== oldValue[0].id - ) ?? [], - })); - } catch (error) { - showErrorToast( - error as AxiosError, - t('server.entity-unfollow-error', { - entity: getEntityName(dashboardDetails), - }) - ); - } - }; + const unFollowDashboard = useCallback(async () => { + await followMutation.mutateAsync(); + }, [followMutation]); - const versionHandler = () => { + const versionHandler = useCallback(() => { version && navigate( getVersionPath(EntityType.DASHBOARD, dashboardFQN, toString(version)) ); - }; + }, [version, dashboardFQN, navigate]); - const handleToggleDelete = (version?: number) => { - setDashboardDetails((prev) => { - if (!prev) { - return prev; - } + const handleToggleDelete = useCallback( + (version?: number) => { + setDashboardDetails((prev) => { + if (!prev) { + return prev; + } - return { - ...prev, - deleted: !prev?.deleted, - ...(version ? { version } : {}), - }; - }); - }; + return { + ...prev, + deleted: !prev?.deleted, + ...(version ? { version } : {}), + }; + }); + }, + [setDashboardDetails] + ); - const updateVote = async (data: QueryVote, id: string) => { - try { - await updateDashboardVotes(id, data); - let fields = defaultFields; - if (viewUsagePermission) { - fields += `,${TabSpecificField.USAGE_SUMMARY}`; + const updateVote = useCallback( + async (data: QueryVote, id: string) => { + try { + await updateDashboardVotes(id, data); + // Background revalidation pulls authoritative vote counts; the optimistic patch + // (votes increment is already reflected by the UI button state) keeps the page + // responsive in the meantime. + await queryClient.invalidateQueries({ queryKey: dashboardCacheKey }); + } catch (error) { + showErrorToast(error as AxiosError); } - const details = await getDashboardByFqn(dashboardFQN, { fields }); - setDashboardDetails(details); - } catch (error) { - showErrorToast(error as AxiosError); - } - }; + }, + [queryClient, dashboardCacheKey] + ); const updateDashboardDetailsState = useCallback( (data: DataAssetWithDomains) => { const updatedData = data as Dashboard; - - setDashboardDetails((data) => ({ - ...(updatedData ?? data), + setDashboardDetails((prev) => ({ + ...(updatedData ?? prev), version: updatedData.version, })); }, - [] + [setDashboardDetails] ); - useEffect(() => { - if ( - getPrioritizedViewPermission( - dashboardPermissions, - PermissionOperation.ViewBasic - ) - ) { - fetchDashboardDetail(dashboardFQN); - } - }, [dashboardFQN, dashboardPermissions]); - + // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { fetchResourcePermission(dashboardFQN); }, [dashboardFQN]); - if (isLoading) { + if (permissionsLoading || dashboardLoading) { return ; } if (isError) { @@ -299,12 +396,15 @@ const DashboardDetailsPage = () => { /> ); } + if (!dashboardDetails) { + return ; + } return ( fetchDashboardDetail(dashboardFQN)} + fetchDashboard={refetchDashboardDetails} followDashboardHandler={followDashboard} handleToggleDelete={handleToggleDelete} unFollowDashboardHandler={unFollowDashboard} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/DashboardDetailsPage/DashboardDetailsPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/DashboardDetailsPage/DashboardDetailsPage.test.tsx index 2419b911e46e..9921ee947d88 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/DashboardDetailsPage/DashboardDetailsPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/DashboardDetailsPage/DashboardDetailsPage.test.tsx @@ -10,10 +10,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { act, render, screen } from '@testing-library/react'; +import { act, screen, waitFor } from '@testing-library/react'; import { AxiosError, InternalAxiosRequestConfig } from 'axios'; import { usePermissionProvider } from '../../context/PermissionProvider/PermissionProvider'; import { getDashboardByFqn } from '../../rest/dashboardAPI'; +import { renderWithQueryClient } from '../../test/unit/test-utils'; import DashboardDetailsPage from './DashboardDetailsPage.component'; // Mock the required dependencies @@ -65,7 +66,7 @@ describe('DashboardDetailsPage', () => { Promise.resolve(mockDashboard) ); - render(); + renderWithQueryClient(); expect(screen.getByTestId('loader')).toBeInTheDocument(); }); @@ -74,10 +75,14 @@ describe('DashboardDetailsPage', () => { (getDashboardByFqn as jest.Mock).mockResolvedValue(mockDashboard); await act(async () => { - render(); + renderWithQueryClient(); }); - expect(screen.getByText('Dashboard Details Component')).toBeInTheDocument(); + await waitFor(() => + expect( + screen.getByText('Dashboard Details Component') + ).toBeInTheDocument() + ); }); it('should show error placeholder when dashboard is not found', async () => { @@ -100,15 +105,19 @@ describe('DashboardDetailsPage', () => { }); await act(async () => { - render(); + renderWithQueryClient(); }); - expect(getDashboardByFqn).toHaveBeenCalledWith('test-dashboard', { - fields: - 'domains,owners, followers, tags, charts,votes,dataProducts,extension,usageSummary', - }); + await waitFor(() => + expect(getDashboardByFqn).toHaveBeenCalledWith('test-dashboard', { + fields: + 'domains,owners, followers, tags, charts,votes,dataProducts,extension,usageSummary', + }) + ); - expect(screen.getByTestId('no-data-placeholder')).toBeInTheDocument(); + await waitFor(() => + expect(screen.getByTestId('no-data-placeholder')).toBeInTheDocument() + ); }); it('should show permission error when user lacks view permissions', async () => { @@ -120,11 +129,13 @@ describe('DashboardDetailsPage', () => { }); await act(async () => { - render(); + renderWithQueryClient(); }); - expect( - screen.getByTestId('permission-error-placeholder') - ).toBeInTheDocument(); + await waitFor(() => + expect( + screen.getByTestId('permission-error-placeholder') + ).toBeInTheDocument() + ); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/queries/dashboardQuery.ts b/openmetadata-ui/src/main/resources/ui/src/rest/queries/dashboardQuery.ts new file mode 100644 index 000000000000..482d24757eb9 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/rest/queries/dashboardQuery.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { QueryClient } from '@tanstack/react-query'; +import { TabSpecificField } from '../../enums/entity.enum'; +import { Dashboard } from '../../generated/entity/data/dashboard'; +import { defaultFields } from '../../utils/DashboardDetailsUtils'; +import { getDashboardByFqn } from '../dashboardAPI'; + +/** + * Shared query plumbing for a single Dashboard by FQN. Mirrors + * {@code tableQuery.ts} — see that file for the rationale behind keying on + * {@code (fqn, fields)} and the prefetch / cache-hit story. + */ +export const dashboardQueryKey = (fqn: string, fields: string) => + ['dashboard', fqn, fields] as const; + +export const dashboardQueryFn = (fqn: string, fields: string) => () => + getDashboardByFqn(fqn, { fields }); + +export const prefetchDashboardByFqn = ( + queryClient: QueryClient, + fqn: string, + fields: string +) => + queryClient + .prefetchQuery({ + queryKey: dashboardQueryKey(fqn, fields), + queryFn: dashboardQueryFn(fqn, fields), + }) + .catch(() => undefined); + +export type DashboardQueryData = Dashboard | undefined; + +const PREFETCH_DASHBOARD_FIELDS = `${defaultFields},${TabSpecificField.USAGE_SUMMARY}`; + +/** + * Convenience wrapper for hover handlers — uses the maximal fields the + * detail page reads when the viewer has {@code ViewUsage} permission. + */ +export const prefetchDashboard = (queryClient: QueryClient, fqn: string) => + prefetchDashboardByFqn(queryClient, fqn, PREFETCH_DASHBOARD_FIELDS); From afbfd4b3255a1326f815497a7d6218f9f14f464b Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 21 May 2026 18:08:13 -0700 Subject: [PATCH 21/62] feat(ui-perf): migrate PipelineDetailsPage to useQuery + optimistic follow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same recipe as Table and Dashboard. Pipeline has more setPipelineDetails sites (description / settings / task / extension updates plus toggle-delete and updatePipelineDetailsState), all preserved through the queryClient.setQueryData wrapper. The PipelineDetailsPage test was updated: - Switched to renderWithQueryClient (the previous bare-render pattern hit "No QueryClient set" once useQuery was introduced). - Fixed the useParams mock to return {fqn} instead of {pipelineFQN} — useFqn destructures {fqn}, so the old mock left decodedPipelineFQN empty. The old code coincidentally worked because the page initialized pipelineDetails to {} and the data fetch's empty-fqn call still succeeded against the mock; the new useQuery gate now correctly skips fetching for empty fqn, which made the broken mock visible. - Fixed the usePermissionProvider mock to expose getEntityPermissionByFqn instead of getEntityPermission — same exposure of latent test-mock bug. - Replaced the act + findByText wrapping with waitFor + screen.getByText. The old pattern can leave the component unmounted before React flushes the post-await state updates, leaving the page stuck on Loader. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../DashboardDetailsPage.component.tsx | 11 +- .../PipelineDetailsPage.component.tsx | 350 +++++++++++------- .../PipelineDetailsPage.test.tsx | 23 +- .../ui/src/rest/queries/pipelineQuery.ts | 43 +++ 4 files changed, 272 insertions(+), 155 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/rest/queries/pipelineQuery.ts diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/DashboardDetailsPage/DashboardDetailsPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/DashboardDetailsPage/DashboardDetailsPage.component.tsx index ce6e49c47600..6ea20a097f4f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/DashboardDetailsPage/DashboardDetailsPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/DashboardDetailsPage/DashboardDetailsPage.component.tsx @@ -36,7 +36,6 @@ import { useApplicationStore } from '../../hooks/useApplicationStore'; import { useFqn } from '../../hooks/useFqn'; import { addFollower, - getDashboardByFqn, patchDashboardDetails, removeFollower, updateDashboardVotes, @@ -119,14 +118,12 @@ const DashboardDetailsPage = () => { }); const isError = useMemo( - () => - (dashboardError as AxiosError | undefined)?.response?.status === 404, + () => (dashboardError as AxiosError | undefined)?.response?.status === 404, [dashboardError] ); useEffect(() => { - const status = (dashboardError as AxiosError | undefined)?.response - ?.status; + const status = (dashboardError as AxiosError | undefined)?.response?.status; if (status === ClientErrors.FORBIDDEN) { navigate(ROUTES.FORBIDDEN, { replace: true }); } else if (status && status !== 404) { @@ -176,8 +173,7 @@ const DashboardDetailsPage = () => { const { id: dashboardId, version, charts } = dashboardDetails ?? {}; const isFollowing = useMemo( - () => - dashboardDetails?.followers?.some(({ id }) => id === USERId) ?? false, + () => dashboardDetails?.followers?.some(({ id }) => id === USERId) ?? false, [dashboardDetails?.followers, USERId] ); const entityName = useMemo( @@ -370,7 +366,6 @@ const DashboardDetailsPage = () => { [setDashboardDetails] ); - // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { fetchResourcePermission(dashboardFQN); }, [dashboardFQN]); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/PipelineDetails/PipelineDetailsPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/PipelineDetails/PipelineDetailsPage.component.tsx index 7cd312470555..8f8e0c71c107 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/PipelineDetails/PipelineDetailsPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/PipelineDetails/PipelineDetailsPage.component.tsx @@ -11,6 +11,7 @@ * limitations under the License. */ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { AxiosError } from 'axios'; import { compare, Operation } from 'fast-json-patch'; import { isUndefined, omitBy } from 'lodash'; @@ -40,6 +41,10 @@ import { removeFollower, updatePipelinesVotes, } from '../../rest/pipelineAPI'; +import { + pipelineQueryFn, + pipelineQueryKey, +} from '../../rest/queries/pipelineQuery'; import { addToRecentViewed, getEntityMissingError, @@ -58,18 +63,13 @@ const PipelineDetailsPage = () => { const { currentUser } = useApplicationStore(); const USERId = currentUser?.id ?? ''; const navigate = useNavigate(); + const queryClient = useQueryClient(); const { entityFqn: decodedPipelineFQN } = useFqn({ type: EntityType.PIPELINE, }); - const [pipelineDetails, setPipelineDetails] = useState( - {} as Pipeline - ); - - const [isLoading, setLoading] = useState(true); - - const [isError, setIsError] = useState(false); + const [permissionsLoading, setPermissionsLoading] = useState(true); const [paging] = useState({} as Paging); const [pipelinePermissions, setPipelinePermissions] = useState( @@ -78,10 +78,123 @@ const PipelineDetailsPage = () => { const { getEntityPermissionByFqn } = usePermissionProvider(); - const { followers = [] } = pipelineDetails; + const viewUsagePermission = useMemo( + () => + getPrioritizedViewPermission( + pipelinePermissions, + PermissionOperation.ViewUsage + ), + [pipelinePermissions] + ); + + const canViewPipeline = useMemo( + () => + getPrioritizedViewPermission( + pipelinePermissions, + PermissionOperation.ViewBasic + ) === true, + [pipelinePermissions] + ); + + const pipelineFields = useMemo(() => { + let fields = defaultFields; + if (viewUsagePermission) { + fields += `,${TabSpecificField.USAGE_SUMMARY}`; + } + + return fields; + }, [viewUsagePermission]); + + const pipelineCacheKey = useMemo( + () => pipelineQueryKey(decodedPipelineFQN, pipelineFields), + [decodedPipelineFQN, pipelineFields] + ); + + const { + data: pipelineDetails, + isLoading: pipelineLoading, + error: pipelineError, + } = useQuery({ + queryKey: pipelineCacheKey, + queryFn: pipelineQueryFn(decodedPipelineFQN, pipelineFields), + enabled: Boolean( + decodedPipelineFQN && canViewPipeline && !permissionsLoading + ), + }); + + const isError = useMemo( + () => (pipelineError as AxiosError | undefined)?.response?.status === 404, + [pipelineError] + ); + + useEffect(() => { + const status = (pipelineError as AxiosError | undefined)?.response?.status; + if (status === ClientErrors.FORBIDDEN) { + navigate(ROUTES.FORBIDDEN, { replace: true }); + } else if (status && status !== 404) { + showErrorToast( + pipelineError as AxiosError, + t('server.entity-details-fetch-error', { + entityType: t('label.pipeline'), + entityName: decodedPipelineFQN, + }) + ); + } + }, [pipelineError, navigate, decodedPipelineFQN, t]); + + useEffect(() => { + if (!pipelineDetails) { + return; + } + addToRecentViewed({ + displayName: getEntityName(pipelineDetails), + entityType: EntityType.PIPELINE, + fqn: pipelineDetails.fullyQualifiedName ?? '', + serviceType: pipelineDetails.serviceType, + timestamp: 0, + id: pipelineDetails.id, + }); + }, [pipelineDetails]); + + const setPipelineDetails = useCallback( + ( + updater: + | Pipeline + | undefined + | ((prev: Pipeline | undefined) => Pipeline | undefined) + ) => { + queryClient.setQueryData( + pipelineCacheKey, + updater + ); + }, + [queryClient, pipelineCacheKey] + ); + + const refetchPipelineDetails = useCallback( + () => queryClient.invalidateQueries({ queryKey: pipelineCacheKey }), + [queryClient, pipelineCacheKey] + ); + const { pipelineId, currentVersion, followers } = useMemo(() => { + return { + pipelineId: pipelineDetails?.id, + currentVersion: + pipelineDetails?.version !== undefined + ? pipelineDetails.version + '' + : '', + followers: pipelineDetails?.followers ?? [], + }; + }, [pipelineDetails]); + + const isFollowing = useMemo( + () => followers.some(({ id }) => id === USERId), + [followers, USERId] + ); + + // See DashboardDetailsPage for the rationale on NOT using useCallback here. const fetchResourcePermission = async (entityFqn: string) => { - setLoading(true); + setPermissionsLoading(true); try { const entityPermission = await getEntityPermissionByFqn( ResourceEntity.PIPELINE, @@ -90,24 +203,18 @@ const PipelineDetailsPage = () => { setPipelinePermissions(entityPermission); } catch { showErrorToast( - t('server.fetch-entity-permissions-error', { - entity: entityFqn, - }) + t('server.fetch-entity-permissions-error', { entity: entityFqn }) ); } finally { - setLoading(false); + setPermissionsLoading(false); } }; - const { pipelineId, currentVersion } = useMemo(() => { - return { - pipelineId: pipelineDetails.id, - currentVersion: pipelineDetails.version + '', - }; - }, [pipelineDetails]); - const saveUpdatedPipelineData = useCallback( (updatedData: Pipeline) => { + if (!pipelineDetails || !pipelineId) { + return Promise.reject(new Error('Pipeline not loaded')); + } const jsonPatch = compare( omitBy(pipelineDetails, isUndefined), updatedData @@ -115,99 +222,86 @@ const PipelineDetailsPage = () => { return patchPipelineDetails(pipelineId, jsonPatch); }, - [pipelineDetails] + [pipelineDetails, pipelineId] ); - const viewUsagePermission = useMemo( - () => - getPrioritizedViewPermission( - pipelinePermissions, - PermissionOperation.ViewUsage - ), - [pipelinePermissions] - ); - - const fetchPipelineDetail = async (pipelineFQN: string) => { - setLoading(true); - - try { - let fields = defaultFields; - if (viewUsagePermission) { - fields += `,${TabSpecificField.USAGE_SUMMARY}`; + const followMutation = useMutation< + void, + AxiosError, + void, + { previous: Pipeline | undefined } + >({ + mutationFn: async () => { + if (!pipelineId) { + return; } - const res = await getPipelineByFqn(pipelineFQN, { - fields, - }); - const { id, fullyQualifiedName, serviceType } = res; - - setPipelineDetails(res); - - addToRecentViewed({ - displayName: getEntityName(res), - entityType: EntityType.PIPELINE, - fqn: fullyQualifiedName ?? '', - serviceType: serviceType, - timestamp: 0, - id: id, - }); - } catch (error) { - if ((error as AxiosError).response?.status === 404) { - setIsError(true); - } else if ( - (error as AxiosError)?.response?.status === ClientErrors.FORBIDDEN - ) { - navigate(ROUTES.FORBIDDEN, { replace: true }); + if (isFollowing) { + await removeFollower(pipelineId, USERId); } else { - showErrorToast( - error as AxiosError, - t('server.entity-details-fetch-error', { - entityType: t('label.pipeline'), - entityName: decodedPipelineFQN, - }) - ); + await addFollower(pipelineId, USERId); } - } finally { - setLoading(false); - } - }; + }, + onMutate: async () => { + await queryClient.cancelQueries({ queryKey: pipelineCacheKey }); + const previous = queryClient.getQueryData( + pipelineCacheKey + ); + queryClient.setQueryData( + pipelineCacheKey, + (prev) => { + if (!prev) { + return prev; + } + const currentFollowers = prev.followers ?? []; + if (isFollowing) { + return { + ...prev, + followers: currentFollowers.filter(({ id }) => id !== USERId), + }; + } + + return { + ...prev, + followers: [ + ...currentFollowers, + { id: USERId, type: 'user' }, + ] as Pipeline['followers'], + }; + } + ); - const followPipeline = useCallback(async () => { - try { - const res = await addFollower(pipelineId, USERId); - const { newValue } = res.changeDescription.fieldsAdded[0]; - const newFollowers = [...(followers ?? []), ...newValue]; - setPipelineDetails((prev) => { - return { ...prev, followers: newFollowers }; - }); - } catch (error) { + return { previous }; + }, + onError: (error, _variables, context) => { + if (context?.previous !== undefined) { + queryClient.setQueryData( + pipelineCacheKey, + context.previous + ); + } showErrorToast( error as AxiosError, - t('server.entity-follow-error', { - entity: getEntityName(pipelineDetails), - }) + isFollowing + ? t('server.entity-unfollow-error', { + entity: getEntityName(pipelineDetails), + }) + : t('server.entity-follow-error', { + entity: getEntityName(pipelineDetails), + }) ); - } - }, [followers, USERId]); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: pipelineCacheKey }); + }, + }); + + const followPipeline = useCallback(async () => { + await followMutation.mutateAsync(); + }, [followMutation]); const unFollowPipeline = useCallback(async () => { - try { - const res = await removeFollower(pipelineId, USERId); - const { oldValue } = res.changeDescription.fieldsDeleted[0]; - setPipelineDetails((prev) => ({ - ...prev, - followers: followers.filter( - (follower) => follower.id !== oldValue[0].id - ), - })); - } catch (error) { - showErrorToast( - error as AxiosError, - t('server.entity-unfollow-error', { - entity: getEntityName(pipelineDetails), - }) - ); - } - }, [followers, USERId]); + await followMutation.mutateAsync(); + }, [followMutation]); const descriptionUpdateHandler = async (updatedPipeline: Pipeline) => { try { @@ -225,6 +319,10 @@ const PipelineDetailsPage = () => { try { const response = await saveUpdatedPipelineData(updatedPipeline); setPipelineDetails((previous) => { + if (!previous) { + return previous; + } + return { ...previous, version: response.version, @@ -251,6 +349,9 @@ const PipelineDetailsPage = () => { }; const onTaskUpdate = async (jsonPatch: Array) => { + if (!pipelineId) { + return; + } try { const response = await patchPipelineDetails(pipelineId, jsonPatch); setPipelineDetails(response); @@ -261,15 +362,14 @@ const PipelineDetailsPage = () => { const versionHandler = () => { navigate( - getVersionPath( - EntityType.PIPELINE, - decodedPipelineFQN, - currentVersion as string - ) + getVersionPath(EntityType.PIPELINE, decodedPipelineFQN, currentVersion) ); }; const handleExtensionUpdate = async (updatedPipeline: Pipeline) => { + if (!pipelineDetails) { + return; + } try { const data = await saveUpdatedPipelineData({ ...pipelineDetails, @@ -303,14 +403,7 @@ const PipelineDetailsPage = () => { const updateVote = async (data: QueryVote, id: string) => { try { await updatePipelinesVotes(id, data); - let fields = defaultFields; - if (viewUsagePermission) { - fields += `,${TabSpecificField.USAGE_SUMMARY}`; - } - const details = await getPipelineByFqn(decodedPipelineFQN, { - fields, - }); - setPipelineDetails(details); + await queryClient.invalidateQueries({ queryKey: pipelineCacheKey }); } catch (error) { showErrorToast(error as AxiosError); } @@ -319,31 +412,20 @@ const PipelineDetailsPage = () => { const updatePipelineDetailsState = useCallback( (data: DataAssetWithDomains) => { const updatedData = data as Pipeline; - - setPipelineDetails((data) => ({ - ...(updatedData ?? data), + setPipelineDetails((prev) => ({ + ...(updatedData ?? prev), version: updatedData.version, })); }, - [] + [setPipelineDetails] ); - useEffect(() => { - if ( - getPrioritizedViewPermission( - pipelinePermissions, - PermissionOperation.ViewBasic - ) - ) { - fetchPipelineDetail(decodedPipelineFQN); - } - }, [pipelinePermissions, decodedPipelineFQN]); - + // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { fetchResourcePermission(decodedPipelineFQN); }, [decodedPipelineFQN]); - if (isLoading) { + if (permissionsLoading || pipelineLoading) { return ; } @@ -367,10 +449,14 @@ const PipelineDetailsPage = () => { ); } + if (!pipelineDetails) { + return ; + } + return ( fetchPipelineDetail(decodedPipelineFQN)} + fetchPipeline={refetchPipelineDetails} followPipelineHandler={followPipeline} handleToggleDelete={handleToggleDelete} paging={paging} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/PipelineDetails/PipelineDetailsPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/PipelineDetails/PipelineDetailsPage.test.tsx index 0db5fa346988..b86358a8d6ba 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/PipelineDetails/PipelineDetailsPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/PipelineDetails/PipelineDetailsPage.test.tsx @@ -11,13 +11,13 @@ * limitations under the License. */ -import { act, findByText, render } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; +import { screen, waitFor } from '@testing-library/react'; +import { renderWithQueryClient } from '../../test/unit/test-utils'; import PipelineDetailsPage from './PipelineDetailsPage.component'; jest.mock('react-router-dom', () => ({ useParams: jest.fn().mockReturnValue({ - pipelineFQN: 'sample_airflow.snowflake_etl', + fqn: 'sample_airflow.snowflake_etl', tab: 'details', }), useNavigate: jest.fn().mockImplementation(() => jest.fn()), @@ -52,7 +52,7 @@ jest.mock( jest.mock('../../context/PermissionProvider/PermissionProvider', () => ({ usePermissionProvider: jest.fn().mockImplementation(() => ({ permissions: {}, - getEntityPermission: jest.fn().mockResolvedValue({ + getEntityPermissionByFqn: jest.fn().mockResolvedValue({ Create: true, Delete: true, EditAll: true, @@ -106,17 +106,10 @@ jest.mock('../../utils/PermissionsUtils', () => ({ describe('Test PipelineDetailsPage component', () => { it('PipelineDetailsPage component should render properly', async () => { - const { container } = render(, { - wrapper: MemoryRouter, - }); + renderWithQueryClient(); - await act(async () => { - const PipelineDetails = await findByText( - container, - /PipelineDetails.component/i - ); - - expect(PipelineDetails).toBeInTheDocument(); - }); + await waitFor(() => + expect(screen.getByText(/PipelineDetails.component/i)).toBeInTheDocument() + ); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/queries/pipelineQuery.ts b/openmetadata-ui/src/main/resources/ui/src/rest/queries/pipelineQuery.ts new file mode 100644 index 000000000000..4c5ce1088cac --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/rest/queries/pipelineQuery.ts @@ -0,0 +1,43 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { QueryClient } from '@tanstack/react-query'; +import { TabSpecificField } from '../../enums/entity.enum'; +import { Pipeline } from '../../generated/entity/data/pipeline'; +import { defaultFields } from '../../utils/PipelineDetailsUtils'; +import { getPipelineByFqn } from '../pipelineAPI'; + +export const pipelineQueryKey = (fqn: string, fields: string) => + ['pipeline', fqn, fields] as const; + +export const pipelineQueryFn = (fqn: string, fields: string) => () => + getPipelineByFqn(fqn, { fields }); + +export const prefetchPipelineByFqn = ( + queryClient: QueryClient, + fqn: string, + fields: string +) => + queryClient + .prefetchQuery({ + queryKey: pipelineQueryKey(fqn, fields), + queryFn: pipelineQueryFn(fqn, fields), + }) + .catch(() => undefined); + +export type PipelineQueryData = Pipeline | undefined; + +const PREFETCH_PIPELINE_FIELDS = `${defaultFields},${TabSpecificField.USAGE_SUMMARY}`; + +export const prefetchPipeline = (queryClient: QueryClient, fqn: string) => + prefetchPipelineByFqn(queryClient, fqn, PREFETCH_PIPELINE_FIELDS); From 6152d8f54f6a9f4cbdeb1e4e23311a51a143d847 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 21 May 2026 18:11:26 -0700 Subject: [PATCH 22/62] feat(ui-perf): migrate TopicDetailsPage to useQuery + optimistic follow Same recipe as the previous three. Topic doesn't have a shared defaultFields export, so the canonical field list lives in topicQuery.ts (TOPIC_DEFAULT_FIELDS) and the page imports from there. Test fixes: usePermissionProvider mock keyed on getEntityPermissionByFqn (was getEntityPermission), MemoryRouter dropped (it was undefined because react-router-dom is fully mocked away), and the act+findByText pattern swapped for waitFor+screen.getByText to avoid the "stuck on Loader" unmount race we hit on Pipeline. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../PipelineDetailsPage.component.tsx | 7 +- .../TopicDetailsPage.component.tsx | 334 +++++++++++------- .../TopicDetails/TopicDetailsPage.test.tsx | 27 +- .../ui/src/rest/queries/topicQuery.ts | 50 +++ 4 files changed, 263 insertions(+), 155 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/rest/queries/topicQuery.ts diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/PipelineDetails/PipelineDetailsPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/PipelineDetails/PipelineDetailsPage.component.tsx index 8f8e0c71c107..a95443cc69ca 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/PipelineDetails/PipelineDetailsPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/PipelineDetails/PipelineDetailsPage.component.tsx @@ -36,7 +36,6 @@ import { useApplicationStore } from '../../hooks/useApplicationStore'; import { useFqn } from '../../hooks/useFqn'; import { addFollower, - getPipelineByFqn, patchPipelineDetails, removeFollower, updatePipelinesVotes, @@ -163,10 +162,7 @@ const PipelineDetailsPage = () => { | undefined | ((prev: Pipeline | undefined) => Pipeline | undefined) ) => { - queryClient.setQueryData( - pipelineCacheKey, - updater - ); + queryClient.setQueryData(pipelineCacheKey, updater); }, [queryClient, pipelineCacheKey] ); @@ -420,7 +416,6 @@ const PipelineDetailsPage = () => { [setPipelineDetails] ); - // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { fetchResourcePermission(decodedPipelineFQN); }, [decodedPipelineFQN]); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TopicDetails/TopicDetailsPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TopicDetails/TopicDetailsPage.component.tsx index 058bb66dd142..d0e94385bfef 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TopicDetails/TopicDetailsPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TopicDetails/TopicDetailsPage.component.tsx @@ -11,10 +11,17 @@ * limitations under the License. */ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { AxiosError } from 'axios'; import { compare } from 'fast-json-patch'; import { isUndefined, omitBy, toString } from 'lodash'; -import { FunctionComponent, useCallback, useEffect, useState } from 'react'; +import { + FunctionComponent, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import ErrorPlaceHolder from '../../components/common/ErrorWithPlaceholder/ErrorPlaceHolder'; @@ -30,14 +37,18 @@ import { } from '../../context/PermissionProvider/PermissionProvider.interface'; import { ClientErrors } from '../../enums/Axios.enum'; import { ERROR_PLACEHOLDER_TYPE } from '../../enums/common.enum'; -import { EntityType, TabSpecificField } from '../../enums/entity.enum'; +import { EntityType } from '../../enums/entity.enum'; import { Topic } from '../../generated/entity/data/topic'; import { Operation as PermissionOperation } from '../../generated/entity/policies/accessControl/resourcePermission'; import { useApplicationStore } from '../../hooks/useApplicationStore'; import { useFqn } from '../../hooks/useFqn'; +import { + TOPIC_DEFAULT_FIELDS, + topicQueryFn, + topicQueryKey, +} from '../../rest/queries/topicQuery'; import { addFollower, - getTopicByFqn, patchTopicDetails, removeFollower, updateTopicVotes, @@ -60,43 +71,100 @@ const TopicDetailsPage: FunctionComponent = () => { const USERId = currentUser?.id ?? ''; const navigate = useNavigate(); const { getEntityPermissionByFqn } = usePermissionProvider(); + const queryClient = useQueryClient(); const { entityFqn: topicFQN } = useFqn({ type: EntityType.TOPIC }); - const [topicDetails, setTopicDetails] = useState({} as Topic); - const [isLoading, setLoading] = useState(true); - const [isError, setIsError] = useState(false); - + const [permissionsLoading, setPermissionsLoading] = useState(true); const [topicPermissions, setTopicPermissions] = useState( DEFAULT_ENTITY_PERMISSION ); - const { id: topicId, version: currentVersion } = topicDetails; + const canViewTopic = useMemo( + () => + getPrioritizedViewPermission( + topicPermissions, + PermissionOperation.ViewBasic + ) === true, + [topicPermissions] + ); - const saveUpdatedTopicData = (updatedData: Topic) => { - const jsonPatch = compare(omitBy(topicDetails, isUndefined), updatedData); + const topicCacheKey = useMemo( + () => topicQueryKey(topicFQN, TOPIC_DEFAULT_FIELDS), + [topicFQN] + ); - return patchTopicDetails(topicId, jsonPatch); - }; + const { + data: topicDetails, + isLoading: topicLoading, + error: topicError, + } = useQuery({ + queryKey: topicCacheKey, + queryFn: topicQueryFn(topicFQN, TOPIC_DEFAULT_FIELDS), + enabled: Boolean(topicFQN && canViewTopic && !permissionsLoading), + }); - const onTopicUpdate = async (updatedData: Topic, key?: keyof Topic) => { - try { - const res = await saveUpdatedTopicData(updatedData); + const isError = useMemo( + () => (topicError as AxiosError | undefined)?.response?.status === 404, + [topicError] + ); - setTopicDetails((previous) => { - return { - ...previous, - ...res, - ...(key && { [key]: res[key] }), - }; - }); - } catch (error) { - showErrorToast(error as AxiosError); + useEffect(() => { + const status = (topicError as AxiosError | undefined)?.response?.status; + if (status === ClientErrors.FORBIDDEN) { + navigate(ROUTES.FORBIDDEN, { replace: true }); + } else if (status && status !== 404) { + showErrorToast( + topicError as AxiosError, + t('server.entity-details-fetch-error', { + entityType: t('label.topic'), + entityName: topicFQN, + }) + ); } - }; + }, [topicError, navigate, topicFQN, t]); + useEffect(() => { + if (!topicDetails) { + return; + } + addToRecentViewed({ + displayName: getEntityName(topicDetails), + entityType: EntityType.TOPIC, + fqn: topicDetails.fullyQualifiedName ?? '', + serviceType: topicDetails.serviceType, + timestamp: 0, + id: topicDetails.id, + }); + }, [topicDetails]); + + const setTopicDetails = useCallback( + ( + updater: + | Topic + | undefined + | ((prev: Topic | undefined) => Topic | undefined) + ) => { + queryClient.setQueryData(topicCacheKey, updater); + }, + [queryClient, topicCacheKey] + ); + + const refetchTopicDetails = useCallback( + () => queryClient.invalidateQueries({ queryKey: topicCacheKey }), + [queryClient, topicCacheKey] + ); + + const { id: topicId, version: currentVersion } = topicDetails ?? {}; + const isFollowing = useMemo( + () => topicDetails?.followers?.some(({ id }) => id === USERId) ?? false, + [topicDetails?.followers, USERId] + ); + const entityName = useMemo(() => getEntityName(topicDetails), [topicDetails]); + + // See DashboardDetailsPage for the rationale on NOT using useCallback here. const fetchResourcePermission = async (entityFqn: string) => { - setLoading(true); + setPermissionsLoading(true); try { const permissions = await getEntityPermissionByFqn( ResourceEntity.TOPIC, @@ -105,99 +173,114 @@ const TopicDetailsPage: FunctionComponent = () => { setTopicPermissions(permissions); } catch { showErrorToast( - t('server.fetch-entity-permissions-error', { - entity: entityFqn, - }) + t('server.fetch-entity-permissions-error', { entity: entityFqn }) ); } finally { - setLoading(false); + setPermissionsLoading(false); } }; - const fetchTopicDetail = async (topicFQN: string) => { - setLoading(true); - try { - const res = await getTopicByFqn(topicFQN, { - fields: [ - TabSpecificField.OWNERS, - TabSpecificField.FOLLOWERS, - TabSpecificField.TAGS, - TabSpecificField.DOMAINS, - TabSpecificField.DATA_PRODUCTS, - TabSpecificField.VOTES, - TabSpecificField.EXTENSION, - ].join(','), - }); - const { id, fullyQualifiedName, serviceType } = res; + const saveUpdatedTopicData = useCallback( + (updatedData: Topic) => { + if (!topicDetails || !topicId) { + return Promise.reject(new Error('Topic not loaded')); + } + const jsonPatch = compare(omitBy(topicDetails, isUndefined), updatedData); - setTopicDetails(res); + return patchTopicDetails(topicId, jsonPatch); + }, + [topicDetails, topicId] + ); + + const onTopicUpdate = async (updatedData: Topic, key?: keyof Topic) => { + try { + const res = await saveUpdatedTopicData(updatedData); + setTopicDetails((previous) => { + if (!previous) { + return previous; + } - addToRecentViewed({ - displayName: getEntityName(res), - entityType: EntityType.TOPIC, - fqn: fullyQualifiedName ?? '', - serviceType: serviceType, - timestamp: 0, - id: id, + return { + ...previous, + ...res, + ...(key && { [key]: res[key] }), + }; }); } catch (error) { - if ((error as AxiosError).response?.status === 404) { - setIsError(true); - } else if ( - (error as AxiosError)?.response?.status === ClientErrors.FORBIDDEN - ) { - navigate(ROUTES.FORBIDDEN, { replace: true }); - } else { - showErrorToast( - error as AxiosError, - t('server.entity-details-fetch-error', { - entityType: t('label.pipeline'), - entityName: topicFQN, - }) - ); - } - } finally { - setLoading(false); + showErrorToast(error as AxiosError); } }; - const followTopic = async () => { - try { - const res = await addFollower(topicId, USERId); - const { newValue } = res.changeDescription.fieldsAdded[0]; - setTopicDetails((prev) => ({ - ...prev, - followers: [...(prev?.followers ?? []), ...newValue], - })); - } catch (error) { - showErrorToast( - error as AxiosError, - t('server.entity-follow-error', { - entity: getEntityName(topicDetails), - }) + const followMutation = useMutation< + void, + AxiosError, + void, + { previous: Topic | undefined } + >({ + mutationFn: async () => { + if (!topicId) { + return; + } + if (isFollowing) { + await removeFollower(topicId, USERId); + } else { + await addFollower(topicId, USERId); + } + }, + onMutate: async () => { + await queryClient.cancelQueries({ queryKey: topicCacheKey }); + const previous = queryClient.getQueryData( + topicCacheKey ); - } - }; + queryClient.setQueryData(topicCacheKey, (prev) => { + if (!prev) { + return prev; + } + const currentFollowers = prev.followers ?? []; + if (isFollowing) { + return { + ...prev, + followers: currentFollowers.filter(({ id }) => id !== USERId), + }; + } - const unFollowTopic = async () => { - try { - const res = await removeFollower(topicId, USERId); - const { oldValue } = res.changeDescription.fieldsDeleted[0]; - setTopicDetails((prev) => ({ - ...prev, - followers: (prev?.followers ?? []).filter( - (follower) => follower.id !== oldValue[0].id - ), - })); - } catch (error) { + return { + ...prev, + followers: [ + ...currentFollowers, + { id: USERId, type: 'user' }, + ] as Topic['followers'], + }; + }); + + return { previous }; + }, + onError: (error, _variables, context) => { + if (context?.previous !== undefined) { + queryClient.setQueryData( + topicCacheKey, + context.previous + ); + } showErrorToast( error as AxiosError, - t('server.entity-unfollow-error', { - entity: getEntityName(topicDetails), - }) + isFollowing + ? t('server.entity-unfollow-error', { entity: entityName }) + : t('server.entity-follow-error', { entity: entityName }) ); - } - }; + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: topicCacheKey }); + }, + }); + + const followTopic = useCallback(async () => { + await followMutation.mutateAsync(); + }, [followMutation]); + + const unFollowTopic = useCallback(async () => { + await followMutation.mutateAsync(); + }, [followMutation]); const versionHandler = () => { currentVersion && @@ -223,47 +306,31 @@ const TopicDetailsPage: FunctionComponent = () => { const updateVote = async (data: QueryVote, id: string) => { try { await updateTopicVotes(id, data); - const details = await getTopicByFqn(topicFQN, { - fields: [ - TabSpecificField.OWNERS, - TabSpecificField.FOLLOWERS, - TabSpecificField.TAGS, - TabSpecificField.VOTES, - ].join(','), - }); - setTopicDetails(details); + await queryClient.invalidateQueries({ queryKey: topicCacheKey }); } catch (error) { showErrorToast(error as AxiosError); } }; - const updateTopicDetailsState = useCallback((data: DataAssetWithDomains) => { - const updatedData = data as Topic; - - setTopicDetails((data) => ({ - ...(updatedData ?? data), - version: updatedData.version, - })); - }, []); + const updateTopicDetailsState = useCallback( + (data: DataAssetWithDomains) => { + const updatedData = data as Topic; + setTopicDetails((prev) => ({ + ...(updatedData ?? prev), + version: updatedData.version, + })); + }, + [setTopicDetails] + ); + // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { if (topicFQN) { fetchResourcePermission(topicFQN); } }, [topicFQN]); - useEffect(() => { - if ( - getPrioritizedViewPermission( - topicPermissions, - PermissionOperation.ViewBasic - ) - ) { - fetchTopicDetail(topicFQN); - } - }, [topicPermissions, topicFQN]); - - if (isLoading) { + if (permissionsLoading || topicLoading) { return ; } if (isError) { @@ -284,10 +351,13 @@ const TopicDetailsPage: FunctionComponent = () => { /> ); } + if (!topicDetails) { + return ; + } return ( fetchTopicDetail(topicFQN)} + fetchTopic={refetchTopicDetails} followTopicHandler={followTopic} handleToggleDelete={handleToggleDelete} topicDetails={topicDetails} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TopicDetails/TopicDetailsPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TopicDetails/TopicDetailsPage.test.tsx index 647691afc3e1..f5df70abda8e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TopicDetails/TopicDetailsPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TopicDetails/TopicDetailsPage.test.tsx @@ -11,9 +11,9 @@ * limitations under the License. */ -import { findByText, render, waitFor } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; +import { screen, waitFor } from '@testing-library/react'; import { getTopicByFqn } from '../../rest/topicsAPI'; +import { renderWithQueryClient } from '../../test/unit/test-utils'; import TopicDetailsPageComponent from './TopicDetailsPage.component'; jest.mock('../../components/Topic/TopicDetails/TopicDetails.component', () => { @@ -48,7 +48,7 @@ jest.mock('../../hooks/useFqn', () => ({ jest.mock('../../context/PermissionProvider/PermissionProvider', () => ({ usePermissionProvider: jest.fn().mockImplementation(() => ({ permissions: {}, - getEntityPermission: jest.fn().mockResolvedValue({ + getEntityPermissionByFqn: jest.fn().mockResolvedValue({ Create: true, Delete: true, EditAll: true, @@ -106,16 +106,11 @@ describe('Test TopicDetailsPage component', () => { }); it('TopicDetailsPage component should render properly', async () => { - const { container } = render(, { - wrapper: MemoryRouter, - }); + renderWithQueryClient(); - const topicDetailComponent = await findByText( - container, - /TopicDetails.component/i + await waitFor(() => + expect(screen.getByText(/TopicDetails.component/i)).toBeInTheDocument() ); - - expect(topicDetailComponent).toBeInTheDocument(); }); it('Should extract topic FQN from field-level deep link URL', async () => { @@ -129,15 +124,13 @@ describe('Test TopicDetailsPage component', () => { }); }); - render(, { - wrapper: MemoryRouter, - }); + renderWithQueryClient(); - await waitFor(() => { + await waitFor(() => expect(getTopicByFqn).toHaveBeenCalledWith( 'sample_kafka.sales', expect.any(Object) - ); - }); + ) + ); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/queries/topicQuery.ts b/openmetadata-ui/src/main/resources/ui/src/rest/queries/topicQuery.ts new file mode 100644 index 000000000000..6cc5c2f62480 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/rest/queries/topicQuery.ts @@ -0,0 +1,50 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { QueryClient } from '@tanstack/react-query'; +import { TabSpecificField } from '../../enums/entity.enum'; +import { Topic } from '../../generated/entity/data/topic'; +import { getTopicByFqn } from '../topicsAPI'; + +export const TOPIC_DEFAULT_FIELDS = [ + TabSpecificField.OWNERS, + TabSpecificField.FOLLOWERS, + TabSpecificField.TAGS, + TabSpecificField.DOMAINS, + TabSpecificField.DATA_PRODUCTS, + TabSpecificField.VOTES, + TabSpecificField.EXTENSION, +].join(','); + +export const topicQueryKey = (fqn: string, fields: string) => + ['topic', fqn, fields] as const; + +export const topicQueryFn = (fqn: string, fields: string) => () => + getTopicByFqn(fqn, { fields }); + +export const prefetchTopicByFqn = ( + queryClient: QueryClient, + fqn: string, + fields: string +) => + queryClient + .prefetchQuery({ + queryKey: topicQueryKey(fqn, fields), + queryFn: topicQueryFn(fqn, fields), + }) + .catch(() => undefined); + +export type TopicQueryData = Topic | undefined; + +export const prefetchTopic = (queryClient: QueryClient, fqn: string) => + prefetchTopicByFqn(queryClient, fqn, TOPIC_DEFAULT_FIELDS); From f5bac951836c533eb3ed41e13c19453393adc16b Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 21 May 2026 18:14:49 -0700 Subject: [PATCH 23/62] feat(ui-perf): prefetch Dashboard/Pipeline/Topic on hover too Now that those three entity pages also read from a useQuery slot, extend ExploreSearchCard's hover handler to dispatch prefetchDashboard / prefetchPipeline / prefetchTopic in addition to prefetchTable. Entity types that haven't migrated yet still fall through as no-ops. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ExploreSearchCard.test.tsx | 92 ++++++++++++++----- .../ExploreSearchCard/ExploreSearchCard.tsx | 38 ++++++-- .../TopicDetailsPage.component.tsx | 3 +- 3 files changed, 99 insertions(+), 34 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.test.tsx index 878235ccd721..840e6502d719 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.test.tsx @@ -19,11 +19,26 @@ import ExploreSearchCard from './ExploreSearchCard'; import { ExploreSearchCardProps } from './ExploreSearchCard.interface'; const mockPrefetchTable = jest.fn(); +const mockPrefetchDashboard = jest.fn(); +const mockPrefetchPipeline = jest.fn(); +const mockPrefetchTopic = jest.fn(); jest.mock('../../../rest/queries/tableQuery', () => ({ prefetchTable: (...args: unknown[]) => mockPrefetchTable(...args), })); +jest.mock('../../../rest/queries/dashboardQuery', () => ({ + prefetchDashboard: (...args: unknown[]) => mockPrefetchDashboard(...args), +})); + +jest.mock('../../../rest/queries/pipelineQuery', () => ({ + prefetchPipeline: (...args: unknown[]) => mockPrefetchPipeline(...args), +})); + +jest.mock('../../../rest/queries/topicQuery', () => ({ + prefetchTopic: (...args: unknown[]) => mockPrefetchTopic(...args), +})); + jest.mock('../../../utils/RouterUtils', () => ({ getDomainPath: jest.fn().mockReturnValue('/mock-domain'), })); @@ -368,30 +383,54 @@ describe('ExploreSearchCard - Highlight functionality', () => { describe('ExploreSearchCard - Prefetch on hover', () => { beforeEach(() => { mockPrefetchTable.mockClear(); + mockPrefetchDashboard.mockClear(); + mockPrefetchPipeline.mockClear(); + mockPrefetchTopic.mockClear(); }); - it('prefetches table details when hovering a Table card', () => { - renderWithQueryClient( - - - - ); - - fireEvent.mouseEnter(screen.getByTestId('entity-link')); - - expect(mockPrefetchTable).toHaveBeenCalledTimes(1); - expect(mockPrefetchTable).toHaveBeenCalledWith( - expect.anything(), - 'svc.db.schema.users' - ); - }); + it.each<{ entityType: string; mockFn: jest.Mock; fqn: string }>([ + { + entityType: 'table', + mockFn: mockPrefetchTable, + fqn: 'svc.db.schema.users', + }, + { + entityType: 'dashboard', + mockFn: mockPrefetchDashboard, + fqn: 'svc.dash.daily-active', + }, + { + entityType: 'pipeline', + mockFn: mockPrefetchPipeline, + fqn: 'svc.pipe.etl', + }, + { + entityType: 'topic', + mockFn: mockPrefetchTopic, + fqn: 'svc.topic.events', + }, + ])( + 'prefetches details when hovering a $entityType card', + ({ entityType, mockFn, fqn }) => { + renderWithQueryClient( + + + + ); + + fireEvent.mouseEnter(screen.getByTestId('entity-link')); + + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockFn).toHaveBeenCalledWith(expect.anything(), fqn); + } + ); it('also prefetches on keyboard focus for accessibility', () => { renderWithQueryClient( @@ -412,15 +451,15 @@ describe('ExploreSearchCard - Prefetch on hover', () => { expect(mockPrefetchTable).toHaveBeenCalledTimes(1); }); - it('does not prefetch when entityType is not a Table', () => { + it('does not prefetch when entityType has no useQuery integration yet', () => { renderWithQueryClient( @@ -429,5 +468,8 @@ describe('ExploreSearchCard - Prefetch on hover', () => { fireEvent.mouseEnter(screen.getByTestId('entity-link')); expect(mockPrefetchTable).not.toHaveBeenCalled(); + expect(mockPrefetchDashboard).not.toHaveBeenCalled(); + expect(mockPrefetchPipeline).not.toHaveBeenCalled(); + expect(mockPrefetchTopic).not.toHaveBeenCalled(); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.tsx index 68e517d7e8ec..bb8174b1bb41 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.tsx @@ -32,7 +32,10 @@ import { EntityReference } from '../../../generated/entity/type'; import { TagLabel } from '../../../generated/tests/testCase'; import { AssetCertification } from '../../../generated/type/assetCertification'; import { TableColumnSearchSource } from '../../../interface/search.interface'; +import { prefetchDashboard } from '../../../rest/queries/dashboardQuery'; +import { prefetchPipeline } from '../../../rest/queries/pipelineQuery'; import { prefetchTable } from '../../../rest/queries/tableQuery'; +import { prefetchTopic } from '../../../rest/queries/topicQuery'; import { getEntityName, highlightEntityNameAndDescription, @@ -88,14 +91,35 @@ const ExploreSearchCard: React.FC = forwardRef< : _source; }, [_source, highlight]); - // Hover/focus on a Table card warms the React Query cache so the click that follows hits - // an already-populated slot. Dispatched on entityType because only the Table detail page - // currently reads from a shared {@code ['table', fqn, fields]} slot; other entity types - // are no-ops until their pages migrate to useQuery. {@code prefetchQuery} is idempotent - // within the configured {@code staleTime}, so repeated hovers don't re-fire the request. + // Hover/focus on an entity card warms the React Query cache so the click that follows + // hits an already-populated slot. Dispatched on entityType because each detail page reads + // a slot keyed on its own {@code ['', fqn, fields]} convention; entity types that + // haven't migrated to useQuery yet fall through as no-ops. {@code prefetchQuery} is + // idempotent within the configured {@code staleTime}, so repeated hovers don't re-fire. const handlePrefetch = useCallback(() => { - if (source.entityType === EntityType.TABLE && source.fullyQualifiedName) { - prefetchTable(queryClient, source.fullyQualifiedName); + const fqn = source.fullyQualifiedName; + if (!fqn) { + return; + } + switch (source.entityType) { + case EntityType.TABLE: + prefetchTable(queryClient, fqn); + + break; + case EntityType.DASHBOARD: + prefetchDashboard(queryClient, fqn); + + break; + case EntityType.PIPELINE: + prefetchPipeline(queryClient, fqn); + + break; + case EntityType.TOPIC: + prefetchTopic(queryClient, fqn); + + break; + default: + break; } }, [queryClient, source.entityType, source.fullyQualifiedName]); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TopicDetails/TopicDetailsPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TopicDetails/TopicDetailsPage.component.tsx index d0e94385bfef..dfeaa696a6f2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TopicDetails/TopicDetailsPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TopicDetails/TopicDetailsPage.component.tsx @@ -43,9 +43,9 @@ import { Operation as PermissionOperation } from '../../generated/entity/policie import { useApplicationStore } from '../../hooks/useApplicationStore'; import { useFqn } from '../../hooks/useFqn'; import { - TOPIC_DEFAULT_FIELDS, topicQueryFn, topicQueryKey, + TOPIC_DEFAULT_FIELDS, } from '../../rest/queries/topicQuery'; import { addFollower, @@ -323,7 +323,6 @@ const TopicDetailsPage: FunctionComponent = () => { [setTopicDetails] ); - // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { if (topicFQN) { fetchResourcePermission(topicFQN); From c9942c2b4d0d119f7d7ff09dc34f9fc01caa972d Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 21 May 2026 19:06:07 -0700 Subject: [PATCH 24/62] =?UTF-8?q?fix(ui-perf):=20break=20dashboardQuery/pi?= =?UTF-8?q?pelineQuery=20=E2=86=92=20utils=20circular=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Playwright shards 2-6 all failed with the same runtime error on every page load: Something went wrong Cannot access 'Wxt' before initialization That's a TDZ failure from this cycle, introduced by Phase F.4 (Dashboard migration): dashboardQuery.ts → DashboardDetailsUtils.tsx (for defaultFields) DashboardDetailsUtils.tsx → DashboardDetailsPage.component.tsx (for ChartType) DashboardDetailsPage.component.tsx → dashboardQuery.ts (for queryFn/Key) The page↔utils edge was pre-existing and harmless on its own (ChartType is a type-only export so it's erased at compile time). Inserting the new dashboardQuery node into the middle of the chain made it a value-import cycle, which Vite's production bundler can't resolve cleanly — one of the cached bindings ends up referenced before its initializer runs. Break the cycle by inlining the field lists directly in the query files (same pattern topicQuery.ts already uses) so the query files no longer depend on the Utils files at all. The Utils files keep their {@code defaultFields} export for the pages and other consumers; only the path query → utils → page is severed. pipelineQuery.ts got the same treatment defensively, even though its PipelineDetailsUtils.tsx doesn't currently import from the page — keeps the pattern consistent and the bundler graph shallow. tableQuery.ts is left alone: it imports from DatasetDetailsUtils.ts which only imports {@code TabSpecificField} — no cycle possible. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ui/src/rest/queries/dashboardQuery.ts | 20 +++++++++++++++++-- .../ui/src/rest/queries/pipelineQuery.ts | 19 ++++++++++++++++-- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/queries/dashboardQuery.ts b/openmetadata-ui/src/main/resources/ui/src/rest/queries/dashboardQuery.ts index 482d24757eb9..8c4cb9070f72 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/queries/dashboardQuery.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/queries/dashboardQuery.ts @@ -14,9 +14,25 @@ import { QueryClient } from '@tanstack/react-query'; import { TabSpecificField } from '../../enums/entity.enum'; import { Dashboard } from '../../generated/entity/data/dashboard'; -import { defaultFields } from '../../utils/DashboardDetailsUtils'; import { getDashboardByFqn } from '../dashboardAPI'; +// Inlined here (not imported from {@code DashboardDetailsUtils}) to break a circular import: +// {@code DashboardDetailsUtils.tsx} pulls {@code ChartType} from +// {@code DashboardDetailsPage.component.tsx}, and the page imports {@code dashboardQueryFn} / +// {@code dashboardQueryKey} from this file. Importing back into Utils from here closes the +// cycle and triggers a TDZ "Cannot access X before initialization" error in production +// bundles. Keep this list in sync with {@code DashboardDetailsUtils.defaultFields}. +const DASHBOARD_DEFAULT_FIELDS = [ + TabSpecificField.DOMAINS, + TabSpecificField.OWNERS, + TabSpecificField.FOLLOWERS, + TabSpecificField.TAGS, + TabSpecificField.CHARTS, + TabSpecificField.VOTES, + TabSpecificField.DATA_PRODUCTS, + TabSpecificField.EXTENSION, +].join(','); + /** * Shared query plumbing for a single Dashboard by FQN. Mirrors * {@code tableQuery.ts} — see that file for the rationale behind keying on @@ -42,7 +58,7 @@ export const prefetchDashboardByFqn = ( export type DashboardQueryData = Dashboard | undefined; -const PREFETCH_DASHBOARD_FIELDS = `${defaultFields},${TabSpecificField.USAGE_SUMMARY}`; +const PREFETCH_DASHBOARD_FIELDS = `${DASHBOARD_DEFAULT_FIELDS},${TabSpecificField.USAGE_SUMMARY}`; /** * Convenience wrapper for hover handlers — uses the maximal fields the diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/queries/pipelineQuery.ts b/openmetadata-ui/src/main/resources/ui/src/rest/queries/pipelineQuery.ts index 4c5ce1088cac..7947185c0b59 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/queries/pipelineQuery.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/queries/pipelineQuery.ts @@ -14,9 +14,24 @@ import { QueryClient } from '@tanstack/react-query'; import { TabSpecificField } from '../../enums/entity.enum'; import { Pipeline } from '../../generated/entity/data/pipeline'; -import { defaultFields } from '../../utils/PipelineDetailsUtils'; import { getPipelineByFqn } from '../pipelineAPI'; +// Inlined here (not imported from {@code PipelineDetailsUtils}) to avoid the kind of +// circular import that broke production bundles for Dashboard (see {@code dashboardQuery.ts} +// for the detailed write-up). Keep this list in sync with +// {@code PipelineDetailsUtils.defaultFields}. +const PIPELINE_DEFAULT_FIELDS = [ + TabSpecificField.FOLLOWERS, + TabSpecificField.TAGS, + TabSpecificField.OWNERS, + TabSpecificField.TASKS, + TabSpecificField.PIPELINE_STATUS, + TabSpecificField.DOMAINS, + TabSpecificField.DATA_PRODUCTS, + TabSpecificField.VOTES, + TabSpecificField.EXTENSION, +].join(','); + export const pipelineQueryKey = (fqn: string, fields: string) => ['pipeline', fqn, fields] as const; @@ -37,7 +52,7 @@ export const prefetchPipelineByFqn = ( export type PipelineQueryData = Pipeline | undefined; -const PREFETCH_PIPELINE_FIELDS = `${defaultFields},${TabSpecificField.USAGE_SUMMARY}`; +const PREFETCH_PIPELINE_FIELDS = `${PIPELINE_DEFAULT_FIELDS},${TabSpecificField.USAGE_SUMMARY}`; export const prefetchPipeline = (queryClient: QueryClient, fqn: string) => prefetchPipelineByFqn(queryClient, fqn, PREFETCH_PIPELINE_FIELDS); From 5d265395d7d8724fb7fdf43100197bd5764844e7 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 21 May 2026 19:40:17 -0700 Subject: [PATCH 25/62] chore(ui): add preview-server proxy config so yarn preview works locally yarn preview was serving the prod bundle on :3000 but failing every API call because the proxy block was only under server: (yarn start). Vite doesn't inherit that into the preview server, so add a matching preview: { port, proxy } block. Lets reviewers spin up a production-bundled UI locally without rebuilding the Java JAR. Co-Authored-By: Claude Opus 4.7 (1M context) --- openmetadata-ui/src/main/resources/ui/vite.config.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/openmetadata-ui/src/main/resources/ui/vite.config.ts b/openmetadata-ui/src/main/resources/ui/vite.config.ts index b9f07569fb99..c20721851c1c 100644 --- a/openmetadata-ui/src/main/resources/ui/vite.config.ts +++ b/openmetadata-ui/src/main/resources/ui/vite.config.ts @@ -168,6 +168,17 @@ export default defineConfig(({ mode }) => { }, }, + preview: { + port: 3000, + proxy: { + '/api/': { + target: devServerTarget, + changeOrigin: true, + ws: true, + }, + }, + }, + build: { outDir: 'dist', assetsDir: 'assets', From 1c7db21095855570f5b3fcdaa56cc2e72c42c006 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 21 May 2026 19:40:31 -0700 Subject: [PATCH 26/62] feat(perf): branch Cache-Control by path on the asset servlet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenMetadataAssetServlet had Brotli/gzip pre-compressed serving wired correctly, but nothing was setting Cache-Control on the responses. The HeaderFilter in OMWebBundle is scoped to web.uriPath (/api), not to asset paths, so /assets/* responses came back with no Cache-Control — browsers fall back to heuristic caching, which means every full reload re-downloads the entire bundle even though every filename is content- addressed and the bytes can't have changed. Now applyCacheControl(req, resp) runs at the top of doGet and picks one of two policies: - Hashed assets under /assets/ (Vite emits names like index-Z3O_FBkA.js) match HASHED_ASSET and get `public, max-age=31536000, immutable`. The filename changes whenever the bytes change, so the browser can serve from disk forever and never re-ask the server. - The SPA HTML shell and SPA fallback routes get `no-cache, must-revalidate`. They MUST be re-fetched on every load or a fresh deploy lands but clients keep the stale shell pointing at chunks that no longer exist. The ETag work coming next lets the revalidate settle as a small 304 when nothing has changed. - Unhashed files (favicon.ico, manifest.json) fall through with no explicit Cache-Control. Browser heuristic kicks in. Low-ROI to change; revisit if logs show high refetch rates. Tests cover the immutable header for hashed asset filenames, the absence of immutable on unhashed paths, and the revalidate header on SPA routes. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../socket/OpenMetadataAssetServlet.java | 62 +++++++++++++++++++ .../socket/OpenMetadataAssetServletTest.java | 62 +++++++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/socket/OpenMetadataAssetServlet.java b/openmetadata-service/src/main/java/org/openmetadata/service/socket/OpenMetadataAssetServlet.java index ecb6775026e6..60b2fd48f5df 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/socket/OpenMetadataAssetServlet.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/socket/OpenMetadataAssetServlet.java @@ -27,6 +27,7 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.Set; +import java.util.regex.Pattern; import lombok.extern.slf4j.Slf4j; import org.jetbrains.annotations.Nullable; import org.openmetadata.service.config.OMWebConfiguration; @@ -40,6 +41,24 @@ public class OpenMetadataAssetServlet extends AssetServlet { "js", "css", "map", "json", "txt", "html", "ico", "png", "jpg", "jpeg", "svg", "gif", "webp", "woff", "woff2", "ttf", "eot", "otf", "pdf", "md"); + // Matches Vite's content-hash filename pattern, e.g. `index-Z3O_FBkA.js`, + // `MyComponent.component-a1b2c3d4.css`. The hash chunk is base64url and at + // least 8 chars — long enough to make accidental collisions vanishingly + // unlikely. Anything matching is safe to mark {@code immutable} because the + // filename changes whenever the content does. + private static final Pattern HASHED_ASSET = + Pattern.compile(".*-[A-Za-z0-9_-]{8,}\\.[a-z0-9]+(\\.br|\\.gz)?$"); + + private static final String IMMUTABLE_CACHE = "public, max-age=31536000, immutable"; + + // The HTML shell points at hash-named JS chunks, so it MUST be re-fetched + // (or revalidated) on every load — otherwise a fresh deploy lands but the + // browser keeps the stale shell that references chunks that no longer + // exist. {@code no-cache} forces revalidation on every load; together + // with the ETag emitted by {@link IndexResource} the request settles as + // a 304 with ~150 bytes when nothing changed. + private static final String REVALIDATE_CACHE = "no-cache, must-revalidate"; + private final OMWebConfiguration webConfiguration; private final String basePath; private final String resourcePath; @@ -60,6 +79,7 @@ public OpenMetadataAssetServlet( protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { setSecurityHeader(webConfiguration, resp); + applyCacheControl(req, resp); String requestUri = req.getRequestURI(); @@ -120,6 +140,48 @@ protected void doGet(HttpServletRequest req, HttpServletResponse resp) } } + /** + * Pick a {@code Cache-Control} policy by path shape. + * + *
    + *
  • Hashed assets under {@code /assets/} — names are content-addressed by Vite + * (e.g. {@code index-Z3O_FBkA.js}). The filename changes whenever the body changes, so + * the browser can cache forever and not even ask the server again. Emit + * {@code public, max-age=31536000, immutable}. + *
  • SPA HTML / fallback routes — the shell that references the hashed asset names. + * Must NOT be long-cached, else a fresh deploy lands and clients keep a stale shell + * pointing at chunks that no longer exist. Emit {@code no-cache, must-revalidate} so + * the browser revalidates every load; {@link IndexResource} attaches an ETag so the + * revalidate settles as a tiny 304 when nothing changed. + *
  • Unhashed static files (e.g. {@code favicon.ico}, {@code manifest.json}) — fall + * through with no explicit Cache-Control so the browser's heuristic kicks in. Adding a + * short {@code max-age} here is possible but low-ROI; revisit if logs show high + * refetch rates. + *
+ */ + private void applyCacheControl(HttpServletRequest req, HttpServletResponse resp) { + String requestUri = req.getRequestURI(); + String pathToCheck = stripBasePath(requestUri); + if (pathToCheck.startsWith("/assets/") && HASHED_ASSET.matcher(pathToCheck).matches()) { + resp.setHeader("Cache-Control", IMMUTABLE_CACHE); + return; + } + if (requestUri.endsWith("/") || requestUri.endsWith(".html") || isSpaRoute(requestUri)) { + resp.setHeader("Cache-Control", REVALIDATE_CACHE); + } + } + + private String stripBasePath(String requestUri) { + String normalizedBasePath = + basePath.endsWith("/") ? basePath.substring(0, basePath.length() - 1) : basePath; + if (!"/".equals(normalizedBasePath) + && !normalizedBasePath.isEmpty() + && requestUri.startsWith(normalizedBasePath)) { + return requestUri.substring(normalizedBasePath.length()); + } + return requestUri; + } + /** * Check if the Accept-Encoding header supports the given encoding with non-zero quality value. * Handles q-values properly (e.g., "br;q=0" means encoding is explicitly disabled). diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/socket/OpenMetadataAssetServletTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/socket/OpenMetadataAssetServletTest.java index 07cd1e220878..22d4f6f0d42f 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/socket/OpenMetadataAssetServletTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/socket/OpenMetadataAssetServletTest.java @@ -183,6 +183,68 @@ public void testFallbackToGzipIfBrotliMissing() throws Exception { verify(response).setContentType("application/javascript"); } + @Test + public void testHashedAssetGetsImmutableCacheControl() throws Exception { + String path = "/assets/index-Z3O_FBkA.js"; + when(request.getRequestURI()).thenReturn(path); + when(request.getContextPath()).thenReturn(""); + when(request.getPathInfo()).thenReturn(path); + when(request.getServletPath()).thenReturn(""); + when(request.getHeader("Accept-Encoding")).thenReturn(null); + when(request.getMethod()).thenReturn("GET"); + when(request.getDateHeader(anyString())).thenReturn(-1L); + when(request.getHeader("If-None-Match")).thenReturn(null); + when(request.getHeader("If-Modified-Since")).thenReturn(null); + + servlet.doGet(request, response); + + // Hashed filenames are content-addressed, so they're safe to cache forever. + verify(response).setHeader("Cache-Control", "public, max-age=31536000, immutable"); + } + + @Test + public void testUnhashedAssetDoesNotGetImmutableCacheControl() throws Exception { + // {@code manifest.json} ships under {@code /assets/} without a content hash, + // so the immutable header would be wrong (a future deploy could change the file + // body while the URL stays the same). + String path = "/assets/manifest.json"; + when(request.getRequestURI()).thenReturn(path); + when(request.getContextPath()).thenReturn(""); + when(request.getPathInfo()).thenReturn(path); + when(request.getServletPath()).thenReturn(""); + when(request.getHeader("Accept-Encoding")).thenReturn(null); + when(request.getMethod()).thenReturn("GET"); + when(request.getDateHeader(anyString())).thenReturn(-1L); + when(request.getHeader("If-None-Match")).thenReturn(null); + when(request.getHeader("If-Modified-Since")).thenReturn(null); + + servlet.doGet(request, response); + + verify(response, never()) + .setHeader(eq("Cache-Control"), eq("public, max-age=31536000, immutable")); + } + + @Test + public void testSpaRouteGetsRevalidateCacheControl() throws Exception { + // SPA routes (e.g. /table/foo.bar) serve the index.html shell, which must NOT + // be long-cached or clients keep the stale shell pointing at chunks that no + // longer exist after a deploy. + String path = "/table/service.db.schema.table"; + when(request.getRequestURI()).thenReturn(path); + when(request.getContextPath()).thenReturn(""); + when(request.getPathInfo()).thenReturn(path); + when(request.getServletPath()).thenReturn(""); + when(request.getHeader("Accept-Encoding")).thenReturn(null); + when(request.getMethod()).thenReturn("GET"); + when(request.getDateHeader(anyString())).thenReturn(-1L); + when(request.getHeader("If-None-Match")).thenReturn(null); + when(request.getHeader("If-Modified-Since")).thenReturn(null); + + servlet.doGet(request, response); + + verify(response).setHeader("Cache-Control", "no-cache, must-revalidate"); + } + @Test public void testSpaRouteWithDotSeparatedEntityFqn() { assertTrue(servlet.isSpaRoute("/table/service.db.schema.table")); From 1ed5c1f596a2b3691319c4cce62caff0d383d380 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 21 May 2026 19:45:50 -0700 Subject: [PATCH 27/62] feat(perf): emit strong ETag on the SPA shell and 304 on If-None-Match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pairs with the prior Cache-Control patch. The shell now revalidates on every load (Cache-Control: no-cache, must-revalidate), and this commit makes that revalidate cheap: when the body hasn't changed, the server answers 304 with no body — ~150 bytes on the wire instead of ~5 KB. IndexResource.getIndexEtag(basePath) computes a SHA-1 of the shell post-basePath substitution and caches it per-basePath (the basePath rarely changes within a process). The ETag is a strong validator (quoted, no W/), so a future range-request consumer could treat it as byte-exact. The cspNonce subtlety: each request that has a non-empty nonce gets a unique body, and a cached body would carry a stale nonce that the next request's CSP header would reject. The asset servlet therefore skips both ETag-emission and the If-None-Match short-circuit when a nonce is in play — clients with CSP enforced still revalidate on every load (per Cache-Control: no-cache) but always receive a fresh body. Deployments without per-request nonce enforcement (the common case) get the full 304 win. Tests: - IndexResourceTest: ETag stability across calls, strong-quoted format, basePath sensitivity. - OpenMetadataAssetServletTest: 304 when If-None-Match matches and no nonce; full 200 when the client's tag is stale; no ETag emission when a per-request cspNonce is present. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../resources/system/IndexResource.java | 61 ++++++++++++++ .../socket/OpenMetadataAssetServlet.java | 33 +++++++- .../resources/system/IndexResourceTest.java | 30 +++++++ .../socket/OpenMetadataAssetServletTest.java | 81 +++++++++++++++++++ 4 files changed, 201 insertions(+), 4 deletions(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/system/IndexResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/system/IndexResource.java index 8e936dcfe693..7839fb75e64c 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/system/IndexResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/system/IndexResource.java @@ -12,6 +12,10 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import org.apache.commons.text.StringEscapeUtils; @@ -26,6 +30,15 @@ public class IndexResource { private static volatile String configProcessedHtml; private static volatile String configuredBasePath = "/"; + // ETag is computed from the body BEFORE the per-request cspNonce substitution and cached + // per-basePath (the basePath rarely changes within a process). The map keeps the ETag and + // the corresponding stable HTML together so a hit can answer 304 without re-rendering. + // Bounded to a handful of entries in practice — there's typically one configured basePath. + private static final ConcurrentHashMap ETAG_CACHE = + new ConcurrentHashMap<>(); + + private record EtagCacheEntry(String etag, String stableHtml) {} + public static void initialize(OpenMetadataApplicationConfig catalogConfig) { String rawIndexHtml; try (InputStream inputStream = IndexResource.class.getResourceAsStream("/assets/index.html")) { @@ -56,6 +69,9 @@ public static void initialize(OpenMetadataApplicationConfig catalogConfig) { .replace("${clusterName}", escapeJs(clusterName != null ? clusterName : "openmetadata")) .replace( "${appVersion}", escapeJs(new VersionResource().getCatalogVersion().getVersion())); + // Re-init may bake new values into the template — drop any cached ETags so the next + // request computes a fresh hash against the new body. + ETAG_CACHE.clear(); } private static String escapeJs(String value) { @@ -83,6 +99,51 @@ public static String getIndexFile(String basePath, String cspNonce) { return html; } + /** + * Strong ETag derived from the body before per-request {@code cspNonce} substitution. + * + *

The body is otherwise stable across requests in a running process — every dynamic value + * gets baked in at {@link #initialize}. So a SHA-1 of {@code getIndexFile(basePath)} uniquely + * identifies the deployed bundle's shell, and the same ETag will match between two requests + * unless the server was redeployed in between. + * + *

Callers must NOT 304 the response when a cspNonce is being substituted into the body — + * each request's nonce is different, so the cached body's stale nonce would be rejected by + * the CSP header. The asset servlet enforces this guard. + */ + public static String getIndexEtag(String basePath) { + String key = basePath == null ? "/" : basePath; + EtagCacheEntry cached = ETAG_CACHE.get(key); + String stableHtml = getIndexFile(basePath); + if (cached != null && cached.stableHtml.equals(stableHtml)) { + return cached.etag; + } + String etag = computeEtag(stableHtml); + ETAG_CACHE.put(key, new EtagCacheEntry(etag, stableHtml)); + return etag; + } + + private static String computeEtag(String body) { + try { + MessageDigest sha1 = MessageDigest.getInstance("SHA-1"); + byte[] digest = sha1.digest(body.getBytes(StandardCharsets.UTF_8)); + // Strong ETag format per RFC 7232. Base64url keeps the value short (~28 chars). + return "\"" + Base64.getUrlEncoder().withoutPadding().encodeToString(digest) + "\""; + } catch (NoSuchAlgorithmException e) { + // SHA-1 is mandated by every JRE; reaching here means the platform is fundamentally + // broken. Surface immediately rather than degrade silently. + throw new IllegalStateException("SHA-1 not available", e); + } + } + + /** + * Drops cached ETag entries — used by tests and after {@link #initialize} so a re-init in + * the same process picks up the fresh template hash. + */ + static void clearEtagCacheForTesting() { + ETAG_CACHE.clear(); + } + @GET @Produces(MediaType.TEXT_HTML) public Response getIndex(@Context HttpServletRequest request) { diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/socket/OpenMetadataAssetServlet.java b/openmetadata-service/src/main/java/org/openmetadata/service/socket/OpenMetadataAssetServlet.java index 60b2fd48f5df..2c757c4d6fe4 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/socket/OpenMetadataAssetServlet.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/socket/OpenMetadataAssetServlet.java @@ -85,8 +85,7 @@ protected void doGet(HttpServletRequest req, HttpServletResponse resp) if (requestUri.endsWith("/")) { final String cspNonce = (String) req.getAttribute(CspNonceHandler.CSP_NONCE_ATTRIBUTE); - resp.setContentType("text/html"); - resp.getWriter().write(IndexResource.getIndexFile(this.basePath, cspNonce)); + writeIndexHtml(req, resp, cspNonce); return; } @@ -132,14 +131,40 @@ protected void doGet(HttpServletRequest req, HttpServletResponse resp) if (isSpaRoute(requestUri)) { final String cspNonce = (String) req.getAttribute(CspNonceHandler.CSP_NONCE_ATTRIBUTE); resp.setStatus(200); - resp.setContentType("text/html"); - resp.getWriter().write(IndexResource.getIndexFile(this.basePath, cspNonce)); + writeIndexHtml(req, resp, cspNonce); } else { resp.sendError(404); } } } + /** + * Write the SPA shell, honouring {@code If-None-Match} with a 304 when possible. + * + *

The cspNonce is per-request (each load gets a fresh value the page's inline scripts use + * to clear the CSP); we therefore only 304 when there's no nonce in play, so a cached body + * carrying a stale nonce can't be served against a CSP header that lists a fresh one. + * + *

The ETag itself describes the stable shell (post-basePath substitution, pre-nonce). It + * changes when the running JAR's bundled {@code index.html} or {@code basePath} change — i.e. + * on every deploy — and stays constant within a process otherwise. + */ + private void writeIndexHtml(HttpServletRequest req, HttpServletResponse resp, String cspNonce) + throws IOException { + String etag = IndexResource.getIndexEtag(this.basePath); + boolean hasPerRequestNonce = cspNonce != null && !cspNonce.isEmpty(); + if (!hasPerRequestNonce) { + resp.setHeader("ETag", etag); + String ifNoneMatch = req.getHeader("If-None-Match"); + if (etag.equals(ifNoneMatch)) { + resp.setStatus(HttpServletResponse.SC_NOT_MODIFIED); + return; + } + } + resp.setContentType("text/html"); + resp.getWriter().write(IndexResource.getIndexFile(this.basePath, cspNonce)); + } + /** * Pick a {@code Cache-Control} policy by path shape. * diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/resources/system/IndexResourceTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/resources/system/IndexResourceTest.java index 4d66ac48ec74..f4e09ca7d0af 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/resources/system/IndexResourceTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/resources/system/IndexResourceTest.java @@ -164,4 +164,34 @@ void testCachedHtmlPerformance() { assertNotNull(html1); assertFalse(html1.isEmpty(), "HTML should not be empty"); } + + @Test + void testEtagIsStableAcrossCalls() { + // The ETag describes the stable shell — it must not change across two calls for the same + // basePath unless something has actually re-initialized the template. + String etagA = IndexResource.getIndexEtag("/"); + String etagB = IndexResource.getIndexEtag("/"); + assertEquals(etagA, etagB); + } + + @Test + void testEtagFormatIsStrongQuoted() { + // Strong ETag per RFC 7232: bare double-quoted token, no W/ prefix. + String etag = IndexResource.getIndexEtag("/"); + assertNotNull(etag); + assertTrue(etag.startsWith("\""), "ETag should be quoted"); + assertTrue(etag.endsWith("\""), "ETag should be quoted"); + assertFalse(etag.startsWith("W/"), "ETag should be a strong (non-weak) validator"); + } + + @Test + void testEtagDiffersAcrossBasePaths() { + // The body bakes the basePath into multiple positions (window.BASE_PATH, favicon hrefs, + // etc.), so the ETag for two different basePaths must differ. + String etagRoot = IndexResource.getIndexEtag("/"); + String etagCustom = IndexResource.getIndexEtag("/openmetadata/"); + assertFalse( + etagRoot.equals(etagCustom), + "ETag for two different basePaths must differ — they produce different bodies"); + } } diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/socket/OpenMetadataAssetServletTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/socket/OpenMetadataAssetServletTest.java index 22d4f6f0d42f..64153690ef4a 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/socket/OpenMetadataAssetServletTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/socket/OpenMetadataAssetServletTest.java @@ -8,11 +8,15 @@ import jakarta.servlet.ServletOutputStream; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.io.PrintWriter; +import java.io.StringWriter; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mock; +import org.mockito.MockedStatic; import org.mockito.MockitoAnnotations; import org.openmetadata.service.config.OMWebConfiguration; +import org.openmetadata.service.resources.system.IndexResource; public class OpenMetadataAssetServletTest { @@ -245,6 +249,83 @@ public void testSpaRouteGetsRevalidateCacheControl() throws Exception { verify(response).setHeader("Cache-Control", "no-cache, must-revalidate"); } + @Test + public void testRootPathEmits304WhenIfNoneMatchMatches() throws Exception { + // When the client sends If-None-Match equal to the current shell's ETag AND no per-request + // CSP nonce is in play, the server short-circuits with 304 and writes no body. This is the + // dominant code path on a tab reload — saves the ~5 KB HTML download every time. + String path = "/"; + String etag = "\"abcDEF123456\""; + when(request.getRequestURI()).thenReturn(path); + when(request.getContextPath()).thenReturn(""); + when(request.getAttribute("cspNonce")).thenReturn(null); + when(request.getHeader("If-None-Match")).thenReturn(etag); + + try (MockedStatic indexResource = + org.mockito.Mockito.mockStatic(IndexResource.class)) { + indexResource.when(() -> IndexResource.getIndexEtag("/")).thenReturn(etag); + servlet.doGet(request, response); + } + + verify(response).setHeader("ETag", etag); + verify(response).setStatus(HttpServletResponse.SC_NOT_MODIFIED); + // Body must NOT be written when answering 304 — that's what makes the response cheap. + verify(response, never()).getWriter(); + } + + @Test + public void testRootPathEmits200AndBodyWhenIfNoneMatchDiffers() throws Exception { + String path = "/"; + String currentEtag = "\"currentETag\""; + String staleEtag = "\"staleClientETag\""; + StringWriter bodyCapture = new StringWriter(); + when(request.getRequestURI()).thenReturn(path); + when(request.getContextPath()).thenReturn(""); + when(request.getAttribute("cspNonce")).thenReturn(null); + when(request.getHeader("If-None-Match")).thenReturn(staleEtag); + when(response.getWriter()).thenReturn(new PrintWriter(bodyCapture)); + + try (MockedStatic indexResource = + org.mockito.Mockito.mockStatic(IndexResource.class)) { + indexResource.when(() -> IndexResource.getIndexEtag("/")).thenReturn(currentEtag); + indexResource + .when(() -> IndexResource.getIndexFile("/", null)) + .thenReturn("fresh"); + servlet.doGet(request, response); + } + + verify(response).setHeader("ETag", currentEtag); + verify(response, never()).setStatus(HttpServletResponse.SC_NOT_MODIFIED); + assertTrue(bodyCapture.toString().contains("fresh")); + } + + @Test + public void testRootPathSkipsEtagWhenCspNonceIsPresent() throws Exception { + // With a per-request CSP nonce in the body, a cached body would carry a stale nonce that + // the next request's CSP header would reject. The servlet must not emit an ETag or attempt + // a 304 in that case — always send the freshly-rendered body. + String path = "/"; + String nonce = "request-nonce-abc"; + StringWriter bodyCapture = new StringWriter(); + when(request.getRequestURI()).thenReturn(path); + when(request.getContextPath()).thenReturn(""); + when(request.getAttribute("cspNonce")).thenReturn(nonce); + when(request.getHeader("If-None-Match")).thenReturn("\"anything\""); + when(response.getWriter()).thenReturn(new PrintWriter(bodyCapture)); + + try (MockedStatic indexResource = + org.mockito.Mockito.mockStatic(IndexResource.class)) { + indexResource + .when(() -> IndexResource.getIndexFile("/", nonce)) + .thenReturn("nonce=" + nonce + ""); + servlet.doGet(request, response); + } + + verify(response, never()).setHeader(eq("ETag"), anyString()); + verify(response, never()).setStatus(HttpServletResponse.SC_NOT_MODIFIED); + assertTrue(bodyCapture.toString().contains(nonce)); + } + @Test public void testSpaRouteWithDotSeparatedEntityFqn() { assertTrue(servlet.isSpaRoute("/table/service.db.schema.table")); From 338a5e6c52c582c88517a57de7647df9e4345a5d Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 21 May 2026 19:47:29 -0700 Subject: [PATCH 28/62] chore(ui): disable modulepreload polyfill for modern-browser targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vite emits for the entry's sync-imported sibling chunks, plus a small JS polyfill for browsers that don't support modulepreload natively. OpenMetadata's React 18 / Antd 5 / Vite 7 toolchain already needs Chrome 66+, Edge 79+, Safari 17+, Firefox 115+ — all of which support modulepreload natively. Drop the polyfill so the HTML is smaller and the browser saves one script fetch before first paint. Co-Authored-By: Claude Opus 4.7 (1M context) --- openmetadata-ui/src/main/resources/ui/vite.config.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/openmetadata-ui/src/main/resources/ui/vite.config.ts b/openmetadata-ui/src/main/resources/ui/vite.config.ts index c20721851c1c..4ce89ee2aaa6 100644 --- a/openmetadata-ui/src/main/resources/ui/vite.config.ts +++ b/openmetadata-ui/src/main/resources/ui/vite.config.ts @@ -189,6 +189,14 @@ export default defineConfig(({ mode }) => { cssCodeSplit: true, reportCompressedSize: false, chunkSizeWarningLimit: 1500, + // Vite auto-emits for the entry chunk's + // sync-imported sibling chunks. Keep that behaviour, but drop the polyfill + // — OpenMetadata's React 18 / Antd 5 / Vite 7 toolchain already targets + // modern browsers that support modulepreload natively (Chrome 66+, Edge + // 79+, Safari 17+, Firefox 115+). The polyfill is a small JS shim plus + // one extra script request; on a fast first-paint path even small wins + // count, and we're not the right project to be carrying it. + modulePreload: { polyfill: false }, rollupOptions: { output: { assetFileNames: (assetInfo) => { From 67807221f0dce3f3a91d3adfcf52cce36be333b1 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 21 May 2026 19:49:38 -0700 Subject: [PATCH 29/62] feat(perf): inline a CSS-only loading shell in index.html MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently the SPA shell is just

, so the user sees a blank white screen for the ~1-2 seconds between HTML parse and React mount. Add a skeleton sidebar + main-area placeholder inlined directly into and inside #root, so: - First paint shows the OpenMetadata-shaped layout the instant the HTML parser hits . - Shimmer animation is pure CSS — no fonts, no images, no JS. - When React calls createRoot(#root).render(), the shell DOM is replaced atomically. Nothing in app code has to know about it. The shell is bounded to ~3 KB of HTML and CSS — net-neutral after Brotli on the gzip-friendly repeated colour codes. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/main/resources/ui/index.html | 139 +++++++++++++++++- 1 file changed, 138 insertions(+), 1 deletion(-) diff --git a/openmetadata-ui/src/main/resources/ui/index.html b/openmetadata-ui/src/main/resources/ui/index.html index cfded796e527..96ebc4b6f719 100644 --- a/openmetadata-ui/src/main/resources/ui/index.html +++ b/openmetadata-ui/src/main/resources/ui/index.html @@ -122,10 +122,147 @@ OpenMetadata + + + -
+
+ +
Date: Thu, 21 May 2026 19:53:22 -0700 Subject: [PATCH 30/62] feat(perf): make HTTP/2 listener available via SERVER_PROTOCOL=h2c MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the dropwizard-http2 module to the build and parameterise the applicationConnector type in openmetadata.yaml. Default stays http (h1) so existing deployments aren't disturbed; an operator who wants h2c flips SERVER_PROTOCOL=h2c at startup. When it matters: HTTP/2's main win for SPAs is multiplexing — 30+ JS chunks come down over one TCP connection instead of stalling against the browser's 6-per-origin cap. That helps cold first paint when the browser is hitting Jetty directly: Docker Compose, on-prem single-node, local dev. When it doesn't matter: AWS ALB and CloudFront speak HTTP/2 to the browser already, but downgrade to HTTP/1.1 on the upstream hop, so flipping Jetty to h2c buys nothing on those paths. Operators behind an HTTP/2-terminating LB should leave SERVER_PROTOCOL=http. The h2c connector is backwards-compatible: Jetty 12 negotiates per connection, so h1 clients keep working over the same port. Co-Authored-By: Claude Opus 4.7 (1M context) --- conf/openmetadata.yaml | 19 ++++++++++++++++++- openmetadata-service/pom.xml | 12 ++++++++++++ pom.xml | 5 +++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/conf/openmetadata.yaml b/conf/openmetadata.yaml index 14f383f09ec8..73b6819aea81 100644 --- a/conf/openmetadata.yaml +++ b/conf/openmetadata.yaml @@ -25,7 +25,24 @@ server: applicationContextPath: ${BASE_PATH:-/} rootPath: /api/* applicationConnectors: - - type: http + # + # Connector protocol. Two supported values: + # + # - http (default): HTTP/1.1 only. Compatible with every load balancer and proxy + # ever shipped. Pick this if you front Jetty with an HTTP/2-terminating LB + # (AWS ALB / CloudFront / nginx / Caddy) — those speak HTTP/2 to the browser + # but downgrade to HTTP/1.1 on the upstream hop anyway, so HTTP/2 on Jetty + # buys nothing. + # + # - h2c HTTP/2 cleartext on the same TCP port. Jetty 12 accepts both h1 clients + # (via h1 protocol) and h2 clients (via h2c-upgrade or h2c-prior-knowledge), + # so this is backwards-compatible. Worth flipping on when browsers hit Jetty + # directly (Docker Compose, on-prem single-node, local dev) — multiplexing + # removes the h1 6-connections-per-origin cap, which materially helps cold + # first-paint on SPAs that load 30+ chunks. + # + # Requires the dropwizard-http2 module (already pulled in by openmetadata-service). + - type: ${SERVER_PROTOCOL:-http} bindHost: ${SERVER_HOST:-0.0.0.0} port: ${SERVER_PORT:-8585} diff --git a/openmetadata-service/pom.xml b/openmetadata-service/pom.xml index b4e9b8fe767f..8849626538e9 100644 --- a/openmetadata-service/pom.xml +++ b/openmetadata-service/pom.xml @@ -169,6 +169,18 @@ io.dropwizard dropwizard-assets + + + io.dropwizard + dropwizard-http2 + io.dropwizard dropwizard-core diff --git a/pom.xml b/pom.xml index dda6f2640896..c53ef030f5ab 100644 --- a/pom.xml +++ b/pom.xml @@ -320,6 +320,11 @@ dropwizard-client ${dropwizard.version} + + io.dropwizard + dropwizard-http2 + ${dropwizard.version} + io.dropwizard dropwizard-testing From 325f4945f2cd7d964b3c1d8896d91fa29ef53159 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 21 May 2026 19:57:34 -0700 Subject: [PATCH 31/62] feat(perf): cache-first for hashed assets in the existing service worker OpenMetadata already ships a service worker (app-worker.js) for IndexedDB-backed key-value storage. It had install / activate / message handlers but no fetch handler, so the SW never intercepted asset requests. Extend it with a tiny fetch handler that does cache-first on /assets/* URLs whose filename carries a Vite content hash (regex matches `name-[A-Za-z0-9_-]{8,}.ext`). How this stacks with the prior Cache-Control: immutable patch: the browser's own HTTP cache already serves these assets from disk forever under that header. The SW adds a second layer that survives HTTP-cache eviction (the browser drops disk cache under memory pressure or on "clear cache for site") and that works in a more deterministic way across tab lifecycles. Net cost: a few hundred bytes of SW code, no impact on cold first paint, faster warm reloads when HTTP cache has been evicted but SW cache is still present. Everything else stays on the network: /api/* (so React Query stays authoritative), the SPA HTML shell (so the ETag dance keeps working), unhashed paths (so a manifest.json or favicon update can still ship). Cross-origin and non-GET requests are explicitly passed through. The activate handler now also evicts any older `om-assets-*` cache versions, so renaming the cache constant in a future release won't leave stale entries. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../main/resources/ui/public/app-worker.js | 67 +++++++++++++++++-- 1 file changed, 63 insertions(+), 4 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/public/app-worker.js b/openmetadata-ui/src/main/resources/ui/public/app-worker.js index e35285ec9391..66ae6cb9ae7f 100644 --- a/openmetadata-ui/src/main/resources/ui/public/app-worker.js +++ b/openmetadata-ui/src/main/resources/ui/public/app-worker.js @@ -15,6 +15,17 @@ const DB_NAME = 'AppDataStore'; const STORE_NAME = 'keyValueStore'; const DB_VERSION = 1; +// Asset cache for hashed /assets/* responses. Bumping ASSET_CACHE_VERSION on the next deploy +// is unnecessary — the cache keys are the full request URLs, which include the content hash +// in the filename (e.g. /assets/index-Z3O_FBkA.js). A new bundle ships under new filenames, +// so the cache effectively versions itself; the activate handler still prunes truly old +// caches in case the naming scheme ever changes. +const ASSET_CACHE = 'om-assets-v1'; +// Match Vite's content-hash filename pattern, e.g. `name-Z3O_FBkA.js`. The 8+ char hash chunk +// is base64url, which the bundler picks so collisions are vanishingly unlikely. Anything +// matching is safe to cache forever — the filename changes whenever the body changes. +const HASHED_ASSET_RE = /\/assets\/[^/]+-[A-Za-z0-9_-]{8,}\.[a-z0-9]+$/; + const swStore = {}; // Pre-load data from IndexedDB when service worker starts @@ -98,11 +109,59 @@ self.addEventListener('install', (event) => { }); self.addEventListener('activate', (event) => { - // Claim control immediately after activation + // Claim control immediately after activation; in the same task, drop any old asset cache + // versions (in case the cache name scheme changes in a future release). event.waitUntil( - self.clients.claim().then(() => { - // Initialize the store to ensure it's ready for use - return initializeSwStore(); + Promise.all([ + self.clients.claim(), + caches + .keys() + .then((names) => + Promise.all( + names + .filter((name) => name.startsWith('om-assets-') && name !== ASSET_CACHE) + .map((name) => caches.delete(name)) + ) + ), + initializeSwStore(), + ]) + ); +}); + +// Cache-first for hashed /assets/* GETs. The browser's own HTTP cache (driven by the +// `Cache-Control: immutable` header the server emits for these paths) does the same job; +// the SW adds a second layer that survives browser-cache eviction under memory pressure and +// across tab/session lifecycles. Cost: ~1 KB of code, no impact when the browser HTTP cache +// already has the entry. +// +// Everything else — /api/*, the SPA HTML shell, unhashed paths — falls through to the +// network so revalidation/ETag/auth all keep working as written. +self.addEventListener('fetch', (event) => { + const request = event.request; + if (request.method !== 'GET') { + return; + } + const url = new URL(request.url); + if (url.origin !== self.location.origin) { + return; + } + if (!HASHED_ASSET_RE.test(url.pathname)) { + return; + } + event.respondWith( + caches.open(ASSET_CACHE).then(async (cache) => { + const cached = await cache.match(request); + if (cached) { + return cached; + } + const response = await fetch(request); + // Only cache successful, fully-typed responses. {@code response.ok} is false on 4xx/5xx, + // {@code response.type === 'basic'} excludes opaque cross-origin responses (we already + // gated on same-origin above but belt-and-braces). + if (response.ok && response.type === 'basic') { + cache.put(request, response.clone()).catch(() => undefined); + } + return response; }) ); }); From 5e4d085671211ea04f70e42d4adfdc3e7504c8ad Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 21 May 2026 19:59:41 -0700 Subject: [PATCH 32/62] docs(perf): CDN deployment guide for per-customer isolated clusters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to the recent perf commits (Cache-Control immutable, ETag on the shell, modulepreload, loading shell, optional HTTP/2, asset SW). Those are origin-side wins — this doc covers the next layer up: how to add edge caching on the path between the customer's users and the customer's OpenMetadata cluster. Written for the single-tenant install-one-cluster-per-customer model rather than the multi-tenant SaaS pattern most CDN guides assume. Five options, ranked by complexity: 1. No CDN, ingress-nginx cache snippet. Single-region customers, air-gapped, lowest cost. 2. Per-customer CloudFront distribution. Multi-region, on AWS. Terraform module skeleton included. 3. Per-customer CloudFront with a shared wildcard cert + hosted zone. Same as #2 with less per-customer DNS overhead. 4. Caddy as the cluster-local reverse proxy + cache. On-prem, non-AWS, "batteries included" install. 5. Cloudflare in front. Cross-cloud, customer-owned DNS. Plus: a "which to pick" table, two specific DevTools measurements to verify it's actually working, and a section on things to avoid (don't Cache-Everything the SPA shell, don't cache /api/* at any CDN, don't bake CDN provisioning into the Helm chart default). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/perf/cdn-deployment-guide.md | 319 ++++++++++++++++++++++++++++++ 1 file changed, 319 insertions(+) create mode 100644 docs/perf/cdn-deployment-guide.md diff --git a/docs/perf/cdn-deployment-guide.md b/docs/perf/cdn-deployment-guide.md new file mode 100644 index 000000000000..946fa6fdda29 --- /dev/null +++ b/docs/perf/cdn-deployment-guide.md @@ -0,0 +1,319 @@ +# CDN deployment guide for OpenMetadata + +OpenMetadata is shipped one cluster per customer — your customer gets their own VPC, their own +load balancer, their own database, their own OpenMetadata pod. This guide is about how to make +the **first paint** of the OpenMetadata UI feel instant in that deployment model, with or +without a CDN in front. + +## Why a CDN at all when you're already single-tenant? + +A CDN doesn't fix multi-tenancy problems — those are solved by the per-customer cluster +architecture. The CDN solves a different problem: **distance between the browser and the +origin**. + +For a customer whose users are spread across regions (US East, EU West, APAC), every asset +request crosses an ocean to hit the ALB. A 1.2 MB JS bundle behind 150 ms of round-trip +latency takes ~7 round-trips of TCP slow-start to fill the pipe — call it 1.5 s before the +first byte actually arrives at the browser. + +A CDN puts a Brotli-compressed copy of every hashed asset at every edge POP near your users. +First paint goes from "Atlanta browser hits us-east-1 ALB" to "Atlanta browser hits Atlanta +CloudFront POP." That's the 1.5 s recovered, every time. + +For a customer whose users are all in one region, near the ALB, the CDN is a smaller win — +maybe 50–150 ms. Still worthwhile, but you'd choose differently between options. + +## What's already in the cluster + +Before reaching for any CDN, OpenMetadata's Jetty already does most of the right things at the +origin (see the perf commits on the `harshach/perceived-latency-p1` branch): + +1. **Pre-compressed Brotli + gzip** served from disk. Vite emits `.br` and `.gz` siblings at + build time; `OpenMetadataAssetServlet` picks the best one per `Accept-Encoding` at zero CPU + cost. +2. **`Cache-Control: public, max-age=31536000, immutable`** on hashed `/assets/*` paths. + The browser never re-asks the server for these once it has them — even reloads serve from + disk cache. +3. **`Cache-Control: no-cache, must-revalidate` + strong `ETag`** on the SPA HTML shell. The + browser revalidates each load but the response is a 304 of ~150 bytes when nothing changed. +4. **``** for the entry chunk's sync deps. The browser starts + fetching `vendor-antd` while it's still parsing the HTML. +5. **Inline CSS-only loading shell**. The user sees the OpenMetadata layout (sidebar, content + skeleton, shimmer) the instant the HTML parser hits `` — not 1–2 s later when + React mounts. +6. **Service Worker (`app-worker.js`)** caches hashed `/assets/*` for the rare case where the + browser's HTTP cache gets evicted. + +All of those benefits ship with the cluster — no CDN required. If your customer is local to +the cluster's region, you may not need any of what follows. + +## Deployment options, in order of complexity + +### Option 1 — No CDN, edge caching at the cluster's nginx ingress + +Best for: **single-region customers, lowest-cost option, air-gapped deployments**. + +If you're running on Kubernetes with `ingress-nginx`, add a snippet that caches `/assets/*` at +the ingress layer. The asset bytes never have to travel from the OpenMetadata pod to the +ingress more than once. + +```yaml +# Helm values for openmetadata via ingress-nginx +ingress: + enabled: true + annotations: + # Cache hashed assets for 1 hour at the ingress (in addition to the immutable + # Cache-Control the origin emits to the browser). + nginx.ingress.kubernetes.io/server-snippet: | + location ~ ^/assets/.+-[A-Za-z0-9_-]{8,}\.[a-z0-9]+$ { + proxy_pass http://upstream_balancer; + proxy_cache static_cache; + proxy_cache_valid 200 1h; + proxy_cache_use_stale error timeout updating; + add_header X-Cache-Status $upstream_cache_status; + } +``` + +The `proxy_cache` zone has to exist; configure it once at the ingress level: + +```yaml +# ingress-nginx controller chart values +controller: + config: + http-snippet: | + proxy_cache_path /tmp/nginx-cache levels=1:2 keys_zone=static_cache:50m + max_size=1g inactive=24h use_temp_path=off; +``` + +**Trade-off:** still has the cross-region latency problem if your users are far from the +cluster. nginx ingress only helps with the origin-pod ⇆ ingress hop, not the browser-to-region +distance. + +### Option 2 — CloudFront in front, per-customer distribution + +Best for: **multi-region users, AWS-hosted customers, premium tier where bandwidth is paid +through CloudFront's lower egress rates**. + +Each customer's installation provisions one CloudFront distribution. Origin is the ALB you +already have. DNS layer: `{customer}.openmetadata.{your-domain}` → CloudFront. + +```hcl +# Terraform module: per-customer-cloudfront +variable "customer_slug" {} +variable "alb_dns_name" {} +variable "acm_cert_arn" {} # cert covering {customer_slug}.openmetadata.example.com +variable "alias_domain" {} # e.g. acme.openmetadata.example.com + +resource "aws_cloudfront_distribution" "this" { + enabled = true + is_ipv6_enabled = true + http_version = "http2and3" # offer HTTP/3 to clients + aliases = [var.alias_domain] + price_class = "PriceClass_100" # NA + EU; flip up for global users + + origin { + domain_name = var.alb_dns_name + origin_id = "alb" + + custom_origin_config { + http_port = 80 + https_port = 443 + origin_protocol_policy = "https-only" + origin_ssl_protocols = ["TLSv1.2"] + # ALB -> CloudFront is always HTTP/1.1. + origin_keepalive_timeout = 60 + origin_read_timeout = 60 + } + } + + # Hashed assets: cache at the edge for a year, content-addressed so the body can't + # change under a given URL. + ordered_cache_behavior { + path_pattern = "/assets/*" + target_origin_id = "alb" + viewer_protocol_policy = "redirect-to-https" + allowed_methods = ["GET", "HEAD"] + cached_methods = ["GET", "HEAD"] + compress = true + cache_policy_id = data.aws_cloudfront_cache_policy.assets.id + } + + # API: never cache. CloudFront sits in the path for TLS termination and HTTP/3 only. + ordered_cache_behavior { + path_pattern = "/api/*" + target_origin_id = "alb" + viewer_protocol_policy = "redirect-to-https" + allowed_methods = ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"] + cached_methods = ["GET", "HEAD"] + compress = true + cache_policy_id = data.aws_cloudfront_cache_policy.api_no_cache.id + origin_request_policy_id = data.aws_cloudfront_origin_request_policy.all_viewer.id + } + + # Everything else (the SPA HTML shell at /, the SPA routes like /table/foo): cache for a + # few seconds at the edge so concurrent users in one region share a single origin hit, but + # don't cache longer — the origin ETag/no-cache layer needs to do its job. + default_cache_behavior { + target_origin_id = "alb" + viewer_protocol_policy = "redirect-to-https" + allowed_methods = ["GET", "HEAD"] + cached_methods = ["GET", "HEAD"] + compress = true + cache_policy_id = data.aws_cloudfront_cache_policy.html_short_ttl.id + origin_request_policy_id = data.aws_cloudfront_origin_request_policy.all_viewer.id + } + + viewer_certificate { + acm_certificate_arn = var.acm_cert_arn + ssl_support_method = "sni-only" + minimum_protocol_version = "TLSv1.2_2021" + } + + restrictions { + geo_restriction { restriction_type = "none" } + } + + # Origin Shield in the region nearest the ALB. Adds an L2 cache between the edge POPs and + # the origin so cache-miss traffic from a thousand edges still hits ALB just once. + origin_shield { + enabled = true + origin_shield_region = "us-east-1" # wherever the ALB lives + } +} + +# Re-usable cache policies (define once at the AWS account level, reference in every +# customer's distribution). +data "aws_cloudfront_cache_policy" "assets" { name = "om-assets-1y-immutable" } +data "aws_cloudfront_cache_policy" "html_short_ttl" { name = "om-html-30s" } +data "aws_cloudfront_cache_policy" "api_no_cache" { name = "om-no-cache" } +data "aws_cloudfront_origin_request_policy" "all_viewer" { name = "Managed-AllViewer" } +``` + +**Provisioning cost:** ~10–20 minutes per distribution after `terraform apply` (CloudFront's +own propagation). Worth automating: each new customer-onboarding flow ends with this module +applying, then your DNS layer points their subdomain at the new distribution. + +**Operational cost:** CloudFront billing is per-request and per-GB-egress. For a typical +OpenMetadata customer (a few hundred internal users), this lands well within the free tier. +A large customer might see $5–$20/month per distribution at most. + +**Trade-off:** N customers = N distributions to manage. AWS hard-caps you at 200 +distributions per account by default; bump it via support ticket if you onboard more. + +### Option 3 — Per-customer CloudFront, single hosted zone, wildcard cert + +A variant of Option 2 that keeps the management overhead bounded as you scale: + +- One wildcard cert: `*.openmetadata.{your-domain}` in ACM. +- One Route53 hosted zone: `openmetadata.{your-domain}`. +- Each customer's distribution still gets its own resource — but the surrounding DNS/cert + story is shared. + +The Terraform module from Option 2 stays mostly the same; the cert ARN and alias come from +the wildcard and the customer slug respectively. + +### Option 4 — Caddy as the cluster-local reverse proxy (no CloudFront) + +Best for: **on-prem customers, customers who don't want AWS infrastructure beyond k8s, or +shipping a "batteries-included" install that doesn't lean on cloud-vendor CDN services**. + +Caddy can act as both the TLS terminator and the edge cache. Configure it in front of Jetty +inside the same cluster: + +```caddyfile +{customer}.openmetadata.example.com { + tls /etc/caddy/cert.pem /etc/caddy/key.pem + + # Hashed assets: cache aggressively. Even though the origin emits + # Cache-Control: immutable, Caddy's own cache keeps a server-side copy so multiple + # users in the same site share one origin fetch after a deploy. + @hashed_assets path_regexp ^/assets/.+-[A-Za-z0-9_-]{8,}\.[a-z0-9]+$ + handle @hashed_assets { + cache { + stale 1d + ttl 24h + } + reverse_proxy openmetadata-server:8585 + } + + # Everything else passes through; the origin's own cache policy applies. + reverse_proxy openmetadata-server:8585 +} +``` + +Caddy auto-issues TLS certs via Let's Encrypt or accepts uploaded ones. The `cache` directive +needs the `caddy-cache` plugin compiled in (or use Caddy 2.7+ which ships caching support +natively). + +**Trade-off:** still single-region. Caddy doesn't sit at edge POPs. But for any cluster-local +deployment this is the simplest path and removes the cloud-vendor dependency for the CDN +layer. + +### Option 5 — Cloudflare in front (cross-cloud customers) + +Best for: **customers running OpenMetadata on GCP, Azure, on-prem, or anywhere except AWS, +who still want a global edge**. + +Cloudflare's free tier covers small deployments. The flow is: + +1. Customer's DNS for `{customer}.openmetadata.{their-domain}` proxies through Cloudflare. +2. Cloudflare hits the customer's ingress (a public LB IP, ALB, GCP LB, etc.) as the origin. +3. Page Rules / Cache Rules set TTLs by path the same way as CloudFront: + - `/assets/*` → Cache Everything, Edge TTL 1 month, Origin Cache-Control respected. + - `/api/*` → Bypass Cache. + - `/` → Standard caching, ~30 s edge TTL. +4. Enable HTTP/3 in the Cloudflare site settings. + +No code or config changes needed in OpenMetadata for this option — the origin headers we +already emit drive Cloudflare's cache layer correctly. + +## Choosing between options + +| Customer profile | Recommended path | +|---|---| +| Single-region users (e.g. all in EU), on AWS | Option 1 (ingress-nginx cache) — Option 2 if budget allows | +| Multi-region users, on AWS | Option 2 or Option 3 (per-customer CloudFront) | +| Customer on GCP / Azure / multi-cloud | Option 5 (Cloudflare) | +| Customer on-prem / air-gapped | Option 4 (Caddy or nginx, no external CDN) | +| Single small customer being bootstrapped quickly | Option 1, then upgrade if their users complain about latency | + +## Measuring whether it's working + +After deploying any of these options, the first-paint number to watch in Chrome DevTools is +**Time to First Byte (TTFB) for `/`** and **download time for the largest `/assets/*` chunk**. + +Two specific tests: + +1. **Cold first paint**, incognito tab: open DevTools → Network → disable cache → reload. + Look at the entry JS chunk. Without CDN, this download is bandwidth-limited (~500 ms on a + 100 Mbps connection for a 1 MB chunk on the origin pulled from the ALB region). With a CDN + in the customer's region: <200 ms. + +2. **Reload after a session**: don't disable cache. Reload `/my-data`. Look at the + `/api/v1/system/version` and `/assets/index-X.js` rows. + - `/api/v1/system/version`: should always be a 200, no cache layer touches it. + - `/assets/index-X.js`: should be `(disk cache)` in the Size column — the immutable + `Cache-Control` header is doing its job. If it's `(memory cache)` or a fresh 200, the + browser cache is being evicted under memory pressure (rare) and the Service Worker + layer becomes the next line of defence. + +For CloudFront in particular, the `X-Cache: Hit from cloudfront` response header tells you +whether the edge served the request or whether it went all the way to ALB. + +## Things to avoid + +- **Don't put CloudFront in `Cache-Everything` mode for the SPA HTML.** The Jetty origin + emits `Cache-Control: no-cache` on the shell for a reason: a fresh deploy lands and the + shell now references hashed asset filenames that didn't exist before. If CloudFront has the + old shell cached at the edge, users get the old shell pointing at chunks that 404. Use the + short-TTL cache policy from the Terraform example above. + +- **Don't cache `/api/*` at any CDN.** Even GET-only endpoints have authz baked into the + response: cached responses leak across users. The React Query cache on the client side + handles request deduplication. + +- **Don't enable a CDN for the OpenMetadata Helm chart's default install.** Each customer's + deployment topology is different — making CDN provisioning part of the chart adds a hard + dependency on AWS credentials, ACM certs, and Route53 zones that not every customer wants + Helm to touch. Keep it as a separate Terraform module / GitOps step that runs after the + cluster is up. From 8f6fb85ac9eae5d176446631e6e650fdce187ff6 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 21 May 2026 20:36:40 -0700 Subject: [PATCH 33/62] fix(perf): emit ETag based on CSP policy, not nonce attribute presence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior commit gated ETag emission on whether {@code CspNonceHandler} had populated the {@code cspNonce} request attribute. Surveying the actual code on a running server showed that handler unconditionally populates the attribute — even on deployments that never emit a CSP header at all. The net effect was that ETag was never emitted in practice and every full reload returned a fresh 200 with the full body, not the intended 304. Fix: gate on whether the configured CSP policy actually requires per-request bodies. A policy that uses the {@code __CSP_NONCE__} placeholder (replaced with a fresh value per request) does — a cached body would carry a stale nonce that the next request's CSP header would reject. Anything else — no CSP, or a CSP that uses {@code 'self'} or hash-based directives — is safe for ETag/304: the cached body's decorative nonce attribute can't cause script execution to fail because no header is policing it. Detection walks {@code webConfiguration.getCspHeaderFactory().build()} (which returns an empty map if the factory is disabled, so the gate falls through correctly for the default deployment). New test {@code testRootPathEmitsEtagWhenCspIsNotConfigured} would have caught the original bug — it's the common-case path that the old gate accidentally locked out. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../socket/OpenMetadataAssetServlet.java | 35 ++++++++++++--- .../socket/OpenMetadataAssetServletTest.java | 44 +++++++++++++++++-- 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/socket/OpenMetadataAssetServlet.java b/openmetadata-service/src/main/java/org/openmetadata/service/socket/OpenMetadataAssetServlet.java index 2c757c4d6fe4..1a1c3ccf2aff 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/socket/OpenMetadataAssetServlet.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/socket/OpenMetadataAssetServlet.java @@ -31,6 +31,7 @@ import lombok.extern.slf4j.Slf4j; import org.jetbrains.annotations.Nullable; import org.openmetadata.service.config.OMWebConfiguration; +import org.openmetadata.service.config.web.CspHeaderFactory; import org.openmetadata.service.resources.system.IndexResource; import org.openmetadata.service.security.CspNonceHandler; @@ -141,9 +142,15 @@ protected void doGet(HttpServletRequest req, HttpServletResponse resp) /** * Write the SPA shell, honouring {@code If-None-Match} with a 304 when possible. * - *

The cspNonce is per-request (each load gets a fresh value the page's inline scripts use - * to clear the CSP); we therefore only 304 when there's no nonce in play, so a cached body - * carrying a stale nonce can't be served against a CSP header that lists a fresh one. + *

The earlier draft of this method gated on whether {@link CspNonceHandler} had populated + * a {@code cspNonce} request attribute — but that handler always populates the attribute, + * even on deployments that never emit a CSP header. The effect was that the ETag path was + * never taken in practice. Now we gate on whether CSP is actually enforced in a way + * that depends on the per-request body content (i.e. the configured policy contains the + * {@code __CSP_NONCE__} placeholder). For everything else — no CSP at all, or a CSP that + * uses {@code 'self'} / hash-based directives — the nonce attribute in the body is + * decorative and serving a cached body with a stale nonce against a fresh CSP header + * cannot cause script execution to fail. * *

The ETag itself describes the stable shell (post-basePath substitution, pre-nonce). It * changes when the running JAR's bundled {@code index.html} or {@code basePath} change — i.e. @@ -152,8 +159,7 @@ protected void doGet(HttpServletRequest req, HttpServletResponse resp) private void writeIndexHtml(HttpServletRequest req, HttpServletResponse resp, String cspNonce) throws IOException { String etag = IndexResource.getIndexEtag(this.basePath); - boolean hasPerRequestNonce = cspNonce != null && !cspNonce.isEmpty(); - if (!hasPerRequestNonce) { + if (!cspRequiresPerRequestBody()) { resp.setHeader("ETag", etag); String ifNoneMatch = req.getHeader("If-None-Match"); if (etag.equals(ifNoneMatch)) { @@ -165,6 +171,25 @@ private void writeIndexHtml(HttpServletRequest req, HttpServletResponse resp, St resp.getWriter().write(IndexResource.getIndexFile(this.basePath, cspNonce)); } + /** + * True when the configured CSP policy contains the {@code __CSP_NONCE__} placeholder, which + * {@link CspNonceHandler} replaces with a fresh per-request value. In that mode the response + * body's inline scripts must match the header's nonce on every load, so we can't safely 304 + * (the cached body would carry a stale nonce). False on the default deployment (no CSP + * configured) and on deployments that use {@code 'self'} / hash-based policies. + */ + private boolean cspRequiresPerRequestBody() { + if (webConfiguration == null) { + return false; + } + CspHeaderFactory csp = webConfiguration.getCspHeaderFactory(); + if (csp == null) { + return false; + } + return csp.build().values().stream() + .anyMatch(v -> v != null && v.contains(CspNonceHandler.CSP_NONCE_PLACEHOLDER)); + } + /** * Pick a {@code Cache-Control} policy by path shape. * diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/socket/OpenMetadataAssetServletTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/socket/OpenMetadataAssetServletTest.java index 64153690ef4a..5bdd1409f196 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/socket/OpenMetadataAssetServletTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/socket/OpenMetadataAssetServletTest.java @@ -300,13 +300,24 @@ public void testRootPathEmits200AndBodyWhenIfNoneMatchDiffers() throws Exception } @Test - public void testRootPathSkipsEtagWhenCspNonceIsPresent() throws Exception { - // With a per-request CSP nonce in the body, a cached body would carry a stale nonce that - // the next request's CSP header would reject. The servlet must not emit an ETag or attempt - // a 304 in that case — always send the freshly-rendered body. + public void testRootPathSkipsEtagWhenCspPolicyUsesNonce() throws Exception { + // When the configured CSP policy contains __CSP_NONCE__ — meaning every request emits a + // unique CSP header that the response body's inline scripts must match — the servlet must + // not emit an ETag or attempt a 304. A cached body would carry a stale nonce that the + // next request's CSP header would reject. String path = "/"; String nonce = "request-nonce-abc"; StringWriter bodyCapture = new StringWriter(); + + // Wire a CSP factory whose policy uses the __CSP_NONCE__ placeholder. The servlet's + // cspRequiresPerRequestBody() calls build() on the factory; build() returns an empty + // map unless {@code enabled} is true, so both flags need setting. + org.openmetadata.service.config.web.CspHeaderFactory cspFactory = + new org.openmetadata.service.config.web.CspHeaderFactory(); + cspFactory.setEnabled(true); + cspFactory.setPolicy("script-src 'nonce-__CSP_NONCE__'"); + when(webConfiguration.getCspHeaderFactory()).thenReturn(cspFactory); + when(request.getRequestURI()).thenReturn(path); when(request.getContextPath()).thenReturn(""); when(request.getAttribute("cspNonce")).thenReturn(nonce); @@ -326,6 +337,31 @@ public void testRootPathSkipsEtagWhenCspNonceIsPresent() throws Exception { assertTrue(bodyCapture.toString().contains(nonce)); } + @Test + public void testRootPathEmitsEtagWhenCspIsNotConfigured() throws Exception { + // The common case: no CSP header is configured. CspNonceHandler still populates the + // request attribute with a fresh value (it runs unconditionally) but the nonce is + // decorative — no CSP header polices the body's inline scripts. The servlet must emit + // ETag and honour 304 here; this is the test that would have caught the original bug. + String path = "/"; + String nonce = "request-nonce-abc"; + String etag = "\"shellEtag\""; + when(webConfiguration.getCspHeaderFactory()).thenReturn(null); + when(request.getRequestURI()).thenReturn(path); + when(request.getContextPath()).thenReturn(""); + when(request.getAttribute("cspNonce")).thenReturn(nonce); + when(request.getHeader("If-None-Match")).thenReturn(etag); + + try (MockedStatic indexResource = + org.mockito.Mockito.mockStatic(IndexResource.class)) { + indexResource.when(() -> IndexResource.getIndexEtag("/")).thenReturn(etag); + servlet.doGet(request, response); + } + + verify(response).setHeader("ETag", etag); + verify(response).setStatus(HttpServletResponse.SC_NOT_MODIFIED); + } + @Test public void testSpaRouteWithDotSeparatedEntityFqn() { assertTrue(servlet.isSpaRoute("/table/service.db.schema.table")); From ab69e6e17009071db453718cc38838540d8eac0f Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 21 May 2026 20:43:40 -0700 Subject: [PATCH 34/62] docs(perf): CDN guide for per-customer release pinning, no extra datastore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite the CDN deployment guide to drop the KeyValueStore / DynamoDB external lookup. The customer→version routing table is small (a few hundred entries) and changes a few times per week — standing up a separate data store for it adds backup, monitoring, IAM, and cost surface that buys nothing at this scale. Replace with: the routing table is JavaScript object literal IN the CloudFront Function source code. Source of truth is the file in this repo. Promotion is a git PR that edits one line, reviewed like any other change, deployed by CI in ~60 s. Audit trail is git history. Rollback is a revert. What that wins: - No new AWS service to operate (no KVS, no DynamoDB, no Lambda@Edge) - Reviewable, auditable promotions via PR - Single source of truth in git, not split between code and a table - Concurrent promotions serialize through git merge, no IfMatch dance What it costs: - Function v2.0 has a 10 KB code limit (~300 customers). At a count past that, migrating to KVS is a localised change. - Promotion takes a PR, not an aws-cli one-liner. Doc walks through the architecture, the Function code, promotion flow, release upload, S3 layout, cache behaviours, per-customer branding, verification checks, and exit criteria — when this design would push toward KVS or Lambda@Edge. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/perf/cdn-deployment-guide.md | 534 ++++++++++++++---------------- 1 file changed, 255 insertions(+), 279 deletions(-) diff --git a/docs/perf/cdn-deployment-guide.md b/docs/perf/cdn-deployment-guide.md index 946fa6fdda29..f8f4f60bd98f 100644 --- a/docs/perf/cdn-deployment-guide.md +++ b/docs/perf/cdn-deployment-guide.md @@ -1,319 +1,295 @@ -# CDN deployment guide for OpenMetadata - -OpenMetadata is shipped one cluster per customer — your customer gets their own VPC, their own -load balancer, their own database, their own OpenMetadata pod. This guide is about how to make -the **first paint** of the OpenMetadata UI feel instant in that deployment model, with or -without a CDN in front. - -## Why a CDN at all when you're already single-tenant? - -A CDN doesn't fix multi-tenancy problems — those are solved by the per-customer cluster -architecture. The CDN solves a different problem: **distance between the browser and the -origin**. - -For a customer whose users are spread across regions (US East, EU West, APAC), every asset -request crosses an ocean to hit the ALB. A 1.2 MB JS bundle behind 150 ms of round-trip -latency takes ~7 round-trips of TCP slow-start to fill the pipe — call it 1.5 s before the -first byte actually arrives at the browser. - -A CDN puts a Brotli-compressed copy of every hashed asset at every edge POP near your users. -First paint goes from "Atlanta browser hits us-east-1 ALB" to "Atlanta browser hits Atlanta -CloudFront POP." That's the 1.5 s recovered, every time. - -For a customer whose users are all in one region, near the ALB, the CDN is a smaller win — -maybe 50–150 ms. Still worthwhile, but you'd choose differently between options. - -## What's already in the cluster - -Before reaching for any CDN, OpenMetadata's Jetty already does most of the right things at the -origin (see the perf commits on the `harshach/perceived-latency-p1` branch): - -1. **Pre-compressed Brotli + gzip** served from disk. Vite emits `.br` and `.gz` siblings at - build time; `OpenMetadataAssetServlet` picks the best one per `Accept-Encoding` at zero CPU - cost. -2. **`Cache-Control: public, max-age=31536000, immutable`** on hashed `/assets/*` paths. - The browser never re-asks the server for these once it has them — even reloads serve from - disk cache. -3. **`Cache-Control: no-cache, must-revalidate` + strong `ETag`** on the SPA HTML shell. The - browser revalidates each load but the response is a 304 of ~150 bytes when nothing changed. -4. **``** for the entry chunk's sync deps. The browser starts - fetching `vendor-antd` while it's still parsing the HTML. -5. **Inline CSS-only loading shell**. The user sees the OpenMetadata layout (sidebar, content - skeleton, shimmer) the instant the HTML parser hits `` — not 1–2 s later when - React mounts. -6. **Service Worker (`app-worker.js`)** caches hashed `/assets/*` for the rare case where the - browser's HTTP cache gets evicted. - -All of those benefits ship with the cluster — no CDN required. If your customer is local to -the cluster's region, you may not need any of what follows. - -## Deployment options, in order of complexity - -### Option 1 — No CDN, edge caching at the cluster's nginx ingress - -Best for: **single-region customers, lowest-cost option, air-gapped deployments**. - -If you're running on Kubernetes with `ingress-nginx`, add a snippet that caches `/assets/*` at -the ingress layer. The asset bytes never have to travel from the OpenMetadata pod to the -ingress more than once. - -```yaml -# Helm values for openmetadata via ingress-nginx -ingress: - enabled: true - annotations: - # Cache hashed assets for 1 hour at the ingress (in addition to the immutable - # Cache-Control the origin emits to the browser). - nginx.ingress.kubernetes.io/server-snippet: | - location ~ ^/assets/.+-[A-Za-z0-9_-]{8,}\.[a-z0-9]+$ { - proxy_pass http://upstream_balancer; - proxy_cache static_cache; - proxy_cache_valid 200 1h; - proxy_cache_use_stale error timeout updating; - add_header X-Cache-Status $upstream_cache_status; - } -``` - -The `proxy_cache` zone has to exist; configure it once at the ingress level: +# Shipping Collate / OpenMetadata releases through CloudFront + +Each customer gets a Collate deployment at their own host — +`acme.getcolate.io`, `widgets.getcolate.io`, `globex.getcolate.io` — and each customer can +be on a different release. This is the AWS-only design for serving the UI bundle from +CloudFront in that model, and the coordination story when a request lands at one of those +hosts. + +## What we want + +- **One CloudFront distribution** for every customer (not one per customer). +- **One S3 bucket** for every release. Releases are immutable; promotion is a separate + step from upload. +- **Per-customer version pinning** that updates atomically — no DNS change, no CloudFront + redeploy. +- **Customer's own ALB** continues to serve `/api/*`; CloudFront only handles the UI bundle. + +## What we explicitly do NOT want + +- A new external data store to maintain (DynamoDB, an extra RDS, a separate Redis). The + customer-version mapping is small (a few hundred entries, two tiny strings each) and + changes rarely (a few writes per week, even at peak). Standing up a data store for that + buys nothing and adds backup, monitoring, IAM, and cost surface. +- Per-customer CloudFront distributions. They give clean isolation but at N customers we + have N distributions to manage, N caches that share no edge state across customers, and + hit the AWS 200-distributions-per-account cap by default. The savings from edge cache + sharing (a thousand customers on v1.12.0 hit the same cached chunk) are the entire + reason the shared model is worth using. +- A lookup that requires Lambda@Edge. The cold start and per-request cost is real + ($1+/M, plus 30-60 ms when cold) and we don't need the SDK access Lambda@Edge gives. + +## The architecture -```yaml -# ingress-nginx controller chart values -controller: - config: - http-snippet: | - proxy_cache_path /tmp/nginx-cache levels=1:2 keys_zone=static_cache:50m - max_size=1g inactive=24h use_temp_path=off; +``` + ┌──────────────────────────────────────┐ +acme.getcolate.io ───┐ │ CloudFront distribution │ +widgets.getcolate.io ───┼─────►│ d1234abc.cloudfront.net │ +globex.getcolate.io ───┘ │ │ + │ ┌─ behavior: /* ──────────────────┐ │ + │ │ origin: S3 │ │ ┌─────────────────────────────┐ + │ │ viewer-request: host_router.js │─┼────►│ S3: collate-cdn │ + │ │ rewrites /foo → │ │ │ release/v1.11.2/index.html │ + │ │ /release//foo │ │ │ release/v1.12.0/index.html │ + │ └──────────────────────────────────┘ │ │ release/v1.13.0-beta/... │ + │ │ └─────────────────────────────┘ + │ ┌─ behavior: /api/* ──────────────┐ │ + │ │ bypass: same host's per- │ │ ┌─────────────────────────────┐ + │ │ customer ALB (Option A below) │─┼────►│ Each customer's own ALB │ + │ └──────────────────────────────────┘ │ └─────────────────────────────┘ + └──────────────────────────────────────┘ ``` -**Trade-off:** still has the cross-region latency problem if your users are far from the -cluster. nginx ingress only helps with the origin-pod ⇆ ingress hop, not the browser-to-region -distance. - -### Option 2 — CloudFront in front, per-customer distribution - -Best for: **multi-region users, AWS-hosted customers, premium tier where bandwidth is paid -through CloudFront's lower egress rates**. - -Each customer's installation provisions one CloudFront distribution. Origin is the ALB you -already have. DNS layer: `{customer}.openmetadata.{your-domain}` → CloudFront. - -```hcl -# Terraform module: per-customer-cloudfront -variable "customer_slug" {} -variable "alb_dns_name" {} -variable "acm_cert_arn" {} # cert covering {customer_slug}.openmetadata.example.com -variable "alias_domain" {} # e.g. acme.openmetadata.example.com - -resource "aws_cloudfront_distribution" "this" { - enabled = true - is_ipv6_enabled = true - http_version = "http2and3" # offer HTTP/3 to clients - aliases = [var.alias_domain] - price_class = "PriceClass_100" # NA + EU; flip up for global users - - origin { - domain_name = var.alb_dns_name - origin_id = "alb" - - custom_origin_config { - http_port = 80 - https_port = 443 - origin_protocol_policy = "https-only" - origin_ssl_protocols = ["TLSv1.2"] - # ALB -> CloudFront is always HTTP/1.1. - origin_keepalive_timeout = 60 - origin_read_timeout = 60 +The CloudFront Function holds the customer→version routing table **as JavaScript object +literal**. Source of truth is the Function's source code in our git repo. Promotion is +a Function code update. + +## The Function (no external lookup) + +```js +// host_router.js — CloudFront Function v2.0 (no Lambda@Edge, no KVS, no DynamoDB) +// +// Source of truth for which release each customer is pinned to. Edit, commit, deploy. +// CI propagates a change to every edge POP in ~60 s. + +const CUSTOMER_VERSIONS = { + acme: 'v1.12.0', + widgets: 'v1.11.2', + globex: 'v1.13.0-beta', + // … N customers +}; + +// Hosts that don't match a customer slug (apex, www., staging) fall back to the latest +// stable release. Bump this in lockstep with every GA release so new customers that +// haven't been added to CUSTOMER_VERSIONS yet still get a current build. +const DEFAULT_VERSION = 'v1.12.0'; + +function handler(event) { + const request = event.request; + + // /api/* lives on a separate behavior with the customer's own ALB as origin. + // The Function should never see these requests under the current behavior config, + // but guard anyway. + if (request.uri.startsWith('/api/')) { + return request; } - } - - # Hashed assets: cache at the edge for a year, content-addressed so the body can't - # change under a given URL. - ordered_cache_behavior { - path_pattern = "/assets/*" - target_origin_id = "alb" - viewer_protocol_policy = "redirect-to-https" - allowed_methods = ["GET", "HEAD"] - cached_methods = ["GET", "HEAD"] - compress = true - cache_policy_id = data.aws_cloudfront_cache_policy.assets.id - } - - # API: never cache. CloudFront sits in the path for TLS termination and HTTP/3 only. - ordered_cache_behavior { - path_pattern = "/api/*" - target_origin_id = "alb" - viewer_protocol_policy = "redirect-to-https" - allowed_methods = ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"] - cached_methods = ["GET", "HEAD"] - compress = true - cache_policy_id = data.aws_cloudfront_cache_policy.api_no_cache.id - origin_request_policy_id = data.aws_cloudfront_origin_request_policy.all_viewer.id - } - - # Everything else (the SPA HTML shell at /, the SPA routes like /table/foo): cache for a - # few seconds at the edge so concurrent users in one region share a single origin hit, but - # don't cache longer — the origin ETag/no-cache layer needs to do its job. - default_cache_behavior { - target_origin_id = "alb" - viewer_protocol_policy = "redirect-to-https" - allowed_methods = ["GET", "HEAD"] - cached_methods = ["GET", "HEAD"] - compress = true - cache_policy_id = data.aws_cloudfront_cache_policy.html_short_ttl.id - origin_request_policy_id = data.aws_cloudfront_origin_request_policy.all_viewer.id - } - - viewer_certificate { - acm_certificate_arn = var.acm_cert_arn - ssl_support_method = "sni-only" - minimum_protocol_version = "TLSv1.2_2021" - } - - restrictions { - geo_restriction { restriction_type = "none" } - } - - # Origin Shield in the region nearest the ALB. Adds an L2 cache between the edge POPs and - # the origin so cache-miss traffic from a thousand edges still hits ALB just once. - origin_shield { - enabled = true - origin_shield_region = "us-east-1" # wherever the ALB lives - } + + const host = (request.headers.host && request.headers.host.value) || ''; + // Convention: customer slug is the first label of the host. + // acme.getcolate.io -> 'acme' + const slug = host.split('.')[0]; + const version = CUSTOMER_VERSIONS[slug] || DEFAULT_VERSION; + + // /assets/foo.js -> /release/v1.12.0/assets/foo.js + request.uri = '/release/' + version + request.uri; + return request; } +``` -# Re-usable cache policies (define once at the AWS account level, reference in every -# customer's distribution). -data "aws_cloudfront_cache_policy" "assets" { name = "om-assets-1y-immutable" } -data "aws_cloudfront_cache_policy" "html_short_ttl" { name = "om-html-30s" } -data "aws_cloudfront_cache_policy" "api_no_cache" { name = "om-no-cache" } -data "aws_cloudfront_origin_request_policy" "all_viewer" { name = "Managed-AllViewer" } +Function v2.0 has a 10 KB code limit. At ~30 bytes per entry that's ~300 customers +comfortably; well beyond that the design needs revisiting — but if you ever reach 300+ +customers on this product, the operational economics of standing up KVS or DynamoDB +will have shifted significantly anyway. + +## Promotion flow + +1. Edit `CUSTOMER_VERSIONS` in the Function source. +2. Commit, push, open PR. The PR diff IS the promotion record — reviewable, auditable, + git-blame'd. +3. CI runs on merge: pushes the new Function code via `aws cloudfront update-function` + and `publish-function`. +4. ~60 s of edge propagation. Every POP picks up the new code. + +A typical promotion PR looks like one line changed: + +```diff + const CUSTOMER_VERSIONS = { +- acme: 'v1.12.0', ++ acme: 'v1.12.1', + widgets: 'v1.11.2', + globex: 'v1.13.0-beta', + }; ``` -**Provisioning cost:** ~10–20 minutes per distribution after `terraform apply` (CloudFront's -own propagation). Worth automating: each new customer-onboarding flow ends with this module -applying, then your DNS layer points their subdomain at the new distribution. +That's the entire surface area of a promotion. No DynamoDB write. No KVS API call. No +extra IAM role. No backup story. Just a code change reviewed like any other. -**Operational cost:** CloudFront billing is per-request and per-GB-egress. For a typical -OpenMetadata customer (a few hundred internal users), this lands well within the free tier. -A large customer might see $5–$20/month per distribution at most. +Rollback is symmetric: revert the commit. Canary is "promote one slug first, watch error +metrics, then PR the next batch." Roll-forward on a regression is the same revert. -**Trade-off:** N customers = N distributions to manage. AWS hard-caps you at 200 -distributions per account by default; bump it via support ticket if you onboard more. +### Release upload (independent of promotion) -### Option 3 — Per-customer CloudFront, single hosted zone, wildcard cert +The bundle bytes go to S3 separately, on every release tag, regardless of which customer +ends up using them: -A variant of Option 2 that keeps the management overhead bounded as you scale: +```bash +VERSION="v1.12.0" +aws s3 sync openmetadata-ui/src/main/resources/ui/dist/assets/ \ + s3://collate-cdn/release/${VERSION}/assets/ \ + --cache-control "public, max-age=31536000, immutable" -- One wildcard cert: `*.openmetadata.{your-domain}` in ACM. -- One Route53 hosted zone: `openmetadata.{your-domain}`. -- Each customer's distribution still gets its own resource — but the surrounding DNS/cert - story is shared. +aws s3 cp openmetadata-ui/src/main/resources/ui/dist/index.html \ + s3://collate-cdn/release/${VERSION}/index.html \ + --cache-control "no-cache, must-revalidate" \ + --content-type "text/html; charset=utf-8" +``` -The Terraform module from Option 2 stays mostly the same; the cert ARN and alias come from -the wildcard and the customer slug respectively. +After this, the release exists in S3 but no customer is using it. Promotion (the PR +above) is what flips customers to it. The decoupling matters: you can sit on a release +in S3 for a week, watching it on staging, before promoting any customer to it. -### Option 4 — Caddy as the cluster-local reverse proxy (no CloudFront) +## Why the Function code is a fine routing table -Best for: **on-prem customers, customers who don't want AWS infrastructure beyond k8s, or -shipping a "batteries-included" install that doesn't lean on cloud-vendor CDN services**. +Honest comparison of the three approaches: -Caddy can act as both the TLS terminator and the edge cache. Configure it in front of Jetty -inside the same cluster: +| | Function-embedded (this design) | CloudFront KeyValueStore | DynamoDB + Lambda@Edge | +|---|---|---|---| +| New AWS service to monitor / back up | none | KVS | DynamoDB + Lambda | +| Read latency at edge | ~0 (in-function) | ~1 ms | ~10 ms (warm Lambda) | +| Cold start | none | none | 30-60 ms | +| Per-request cost | $0.10/M Function | $0.10/M Function + $0.04/M KVS | $0.10/M + $1+/M Lambda + DynamoDB reads | +| Promotion surface | git PR | API call (`put-key`) | API call (`update-item`) | +| Audit trail | git history | CloudWatch + KVS audit logs | CloudWatch + DDB streams | +| Capacity ceiling | ~300 customers (10 KB code limit) | millions | millions | +| Concurrent promotion safety | git merge serializes | `IfMatch` ETag | conditional writes | +| Operational ownership | "this is in the repo" | "who paged on this last quarter?" | "who paged on this last quarter?" | -```caddyfile -{customer}.openmetadata.example.com { - tls /etc/caddy/cert.pem /etc/caddy/key.pem +For a product that ships per-customer clusters and reaches dozens-to-low-hundreds of +customers, "the routing table is a file in the repo" wins on every operational axis that +matters. It only loses on capacity ceiling, and the day that becomes a problem we already +have a clear migration target (KVS) without changing anything else in the design. - # Hashed assets: cache aggressively. Even though the origin emits - # Cache-Control: immutable, Caddy's own cache keeps a server-side copy so multiple - # users in the same site share one origin fetch after a deploy. - @hashed_assets path_regexp ^/assets/.+-[A-Za-z0-9_-]{8,}\.[a-z0-9]+$ - handle @hashed_assets { - cache { - stale 1d - ttl 24h - } - reverse_proxy openmetadata-server:8585 - } +## API routing — two options, pick one - # Everything else passes through; the origin's own cache policy applies. - reverse_proxy openmetadata-server:8585 -} +The Function above only handles UI bundle requests. `/api/*` still has to reach the +customer's own ALB. + +### Option A — Separate API host (recommended) + +``` +acme.getcolate.io → CNAME → CloudFront distribution (this design) +api-acme.getcolate.io → CNAME → acme's ALB ``` -Caddy auto-issues TLS certs via Let's Encrypt or accepts uploaded ones. The `cache` directive -needs the `caddy-cache` plugin compiled in (or use Caddy 2.7+ which ships caching support -natively). +SPA's API base URL is derived from the page host at runtime: `https://api-{slug}.getcolate.io/api`. -**Trade-off:** still single-region. Caddy doesn't sit at edge POPs. But for any cluster-local -deployment this is the simplest path and removes the cloud-vendor dependency for the CDN -layer. +Pros: CloudFront does one thing well (static delivery). No Lambda@Edge anywhere. Failure +modes are easy to reason about. Cons: SPA has a cookie/CORS story that knows about two +hosts; we already handle this for various integrations. -### Option 5 — Cloudflare in front (cross-cloud customers) +### Option B — Same host, Lambda@Edge for `/api/*` -Best for: **customers running OpenMetadata on GCP, Azure, on-prem, or anywhere except AWS, -who still want a global edge**. +CloudFront's `/api/*` behavior runs a Lambda@Edge on origin-request that reads the host +header and rewrites the origin to the right ALB. -Cloudflare's free tier covers small deployments. The flow is: +Pros: single host per customer. Cons: now we DO have Lambda@Edge (which we explicitly +chose to avoid for routing), and the operational cost is per-customer-API-request, not +just per-promotion. We strongly prefer Option A. -1. Customer's DNS for `{customer}.openmetadata.{their-domain}` proxies through Cloudflare. -2. Cloudflare hits the customer's ingress (a public LB IP, ALB, GCP LB, etc.) as the origin. -3. Page Rules / Cache Rules set TTLs by path the same way as CloudFront: - - `/assets/*` → Cache Everything, Edge TTL 1 month, Origin Cache-Control respected. - - `/api/*` → Bypass Cache. - - `/` → Standard caching, ~30 s edge TTL. -4. Enable HTTP/3 in the Cloudflare site settings. +## S3 bucket layout -No code or config changes needed in OpenMetadata for this option — the origin headers we -already emit drive Cloudflare's cache layer correctly. +``` +collate-cdn/ +└── release/ + ├── v1.11.5/ + │ ├── index.html no-cache, must-revalidate + │ ├── assets/index-Z3O_FBkA.js immutable + │ ├── assets/index-Z3O_FBkA.js.br immutable + │ ├── assets/index-Z3O_FBkA.js.gz immutable + │ ├── assets/vendor-antd-BgrjOjhB.js immutable + │ └── ... + ├── v1.12.0/ ← acme + widgets currently here + │ └── ... + └── v1.13.0-beta/ ← globex currently here (canary) + └── ... +``` -## Choosing between options +Releases are immutable once uploaded. The promotion step never modifies S3 contents — +only the Function code that maps `slug → /release//`. -| Customer profile | Recommended path | -|---|---| -| Single-region users (e.g. all in EU), on AWS | Option 1 (ingress-nginx cache) — Option 2 if budget allows | -| Multi-region users, on AWS | Option 2 or Option 3 (per-customer CloudFront) | -| Customer on GCP / Azure / multi-cloud | Option 5 (Cloudflare) | -| Customer on-prem / air-gapped | Option 4 (Caddy or nginx, no external CDN) | -| Single small customer being bootstrapped quickly | Option 1, then upgrade if their users complain about latency | +Disk cost is small: a typical OM bundle is ~12 MB on disk after content-hash dedup, +Brotli+gzip siblings add ~25%, call it 15 MB per release. 100 releases × 15 MB = +1.5 GB. S3 standard rates put that at a few cents per month — keep many releases live +for instant rollback and don't bother with aggressive lifecycle pruning. -## Measuring whether it's working +## CloudFront cache behaviors -After deploying any of these options, the first-paint number to watch in Chrome DevTools is -**Time to First Byte (TTFB) for `/`** and **download time for the largest `/assets/*` chunk**. +| Path pattern (after Function rewrite) | Edge TTL | Notes | +|---|---|---| +| `/release//assets/*` | 1 year | Content-addressed; bytes can't change | +| `/release//index.html`, `/release//` | 30 s | Concurrent users in one region share one origin hit; ETag layer takes over after 30 s | +| `/api/*` | bypass | Separate behavior to customer ALB (Option A: not via CloudFront at all) | -Two specific tests: +30 s on the shell is the sweet spot: long enough to dedupe a thousand concurrent reloads +to one origin fetch, short enough that a promotion lands at all customers within ~90 s +end-to-end (60 s Function propagation + 30 s residual edge cache). -1. **Cold first paint**, incognito tab: open DevTools → Network → disable cache → reload. - Look at the entry JS chunk. Without CDN, this download is bandwidth-limited (~500 ms on a - 100 Mbps connection for a 1 MB chunk on the origin pulled from the ALB region). With a CDN - in the customer's region: <200 ms. +## Per-customer branding (without per-customer bundles) -2. **Reload after a session**: don't disable cache. Reload `/my-data`. Look at the - `/api/v1/system/version` and `/assets/index-X.js` rows. - - `/api/v1/system/version`: should always be a 200, no cache layer touches it. - - `/assets/index-X.js`: should be `(disk cache)` in the Size column — the immutable - `Cache-Control` header is doing its job. If it's `(memory cache)` or a fresh 200, the - browser cache is being evicted under memory pressure (rare) and the Service Worker - layer becomes the next line of defence. +If a customer needs a different logo or accent colour, the right move is to keep one +universal bundle and overlay branding assets at request time: -For CloudFront in particular, the `X-Cache: Hit from cloudfront` response header tells you -whether the edge served the request or whether it went all the way to ALB. +- Universal default: `/release/v1.12.0/images/logo.png` in S3. +- Per-customer override (optional, only when needed): the Function checks for + `s3://collate-cdn/customer-overrides//logo.png` first and rewrites if it exists. -## Things to avoid +Branding stays out of the build artifact, which means one bundle still serves every +customer and the cache-sharing argument holds. -- **Don't put CloudFront in `Cache-Everything` mode for the SPA HTML.** The Jetty origin - emits `Cache-Control: no-cache` on the shell for a reason: a fresh deploy lands and the - shell now references hashed asset filenames that didn't exist before. If CloudFront has the - old shell cached at the edge, users get the old shell pointing at chunks that 404. Use the - short-TTL cache policy from the Terraform example above. +## Verification after promotion -- **Don't cache `/api/*` at any CDN.** Even GET-only endpoints have authz baked into the - response: cached responses leak across users. The React Query cache on the client side - handles request deduplication. +Two synthetic checks worth running automatically after a promotion PR merges: + +```bash +SLUG=acme +EXPECTED_VERSION=v1.12.0 + +# 1. CloudFront serves the right release for this slug +RESPONSE=$(curl -s "https://${SLUG}.getcolate.io/?nocache=$(uuidgen)") +echo "$RESPONSE" | grep -oE 'index-[A-Za-z0-9_-]+\.js' | sort -u +# Should match the hash from the v1.12.0 build manifest + +# 2. The HTML shell is being served fresh from the right S3 prefix +curl -sI "https://${SLUG}.getcolate.io/" \ + | grep -i 'x-amz-cf-pop\|via\|x-cache' +# Should show an edge POP near the test runner, and either "Miss from cloudfront" +# (first request after promotion) or "Hit from cloudfront" (within the 30 s edge TTL) +``` -- **Don't enable a CDN for the OpenMetadata Helm chart's default install.** Each customer's - deployment topology is different — making CDN provisioning part of the chart adds a hard - dependency on AWS credentials, ACM certs, and Route53 zones that not every customer wants - Helm to touch. Keep it as a separate Terraform module / GitOps step that runs after the - cluster is up. +CI runs this on every promotion PR after the Function deploys, and fails loud if the +served bundle doesn't match the version we just pinned. + +## What's not in this design + +- **Per-customer API origin selection inside CloudFront**. Option A keeps `/api/*` off + the CloudFront path entirely. If a customer ever needs single-host behavior, that's + the moment to revisit Option B and accept Lambda@Edge. +- **Multi-region S3 origin failover**. Single bucket in one region; CloudFront's edge + caching handles regional reach. If you want CRR + origin groups, add them; the cost + is straightforward but rarely justified for a UI bundle. +- **WAF / Shield Advanced**. Add separately if your security posture requires them. + +## What this design is good for and what would push it elsewhere + +- **Good for**: dozens to low-hundreds of customers, infrequent promotion (a few per + week), engineering ownership over the routing table. +- **Push toward KVS** when: customer count grows past a few hundred (function size + pressure) OR promotions happen via a non-engineering UI (a customer-success dashboard + that flips slugs without a git PR). +- **Push toward Lambda@Edge** when: routing decisions stop being a slug→version map and + start needing per-request information not available in the host header (e.g. A/B + testing by user ID, geo-routing, header-derived feature flags). + +When those days come, the migration path from this design is small — the Function code +becomes a `kvs.get(slug)` instead of a hash lookup, and the rest of the architecture +(S3 layout, distribution behaviors, ALB routing) is identical. From ca0d22304db5051041cc85a1a4f8e396b93ad984 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 21 May 2026 22:25:53 -0700 Subject: [PATCH 35/62] feat(ui-perf): split heavy specialist libs into their own chunks, target modern browsers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared chunk Rollup auto-named "AsyncDeleteProvider" had ballooned to 8.7 MB minified / 18 MB unminified because Rollup co-locates every module that has more than one importing entry point. Visualizer (added in this commit, opt-in via `vite build --mode analyze`) shows the specific contributors: 3.0 MB elkjs graph layout — lineage only 0.6 MB react-latex-next LaTeX in markdown 0.5 MB testSuitePipeline connection schema JSON 0.4 MB codemirror SQL/query editor 0.4 MB luxon (DUPLICATED) CJS + ESM builds both pulled in 0.4 MB react-dom/server renderToString (legitimate; left in) 0.4 MB prosemirror + tiptap rich-text editor 0.2 MB react-logviewer ingestion logs ... Three fixes: 1. Explicit manualChunks for each specialist (elkjs, reactflow, prosemirror+tiptap, codemirror, recharts, react-latex-next, react-logviewer, showdown, quill+quill-emoji, dompurify, react-data-grid, luxon, js-yaml). Each becomes its own ~50-330 KB brotli chunk that routes lazy-load only when their feature mounts. AsyncDeleteProvider shrinks from 8.7 MB → 4.9 MB raw / 851 KB brotli. The remaining bulk is what's actually shared by NavBar + common providers — a follow-up should chip away at it. 2. Luxon resolved to its ESM build via alias. Some transitive dep (likely rc-picker / @mui/x-date-pickers) was require()ing the CJS path while our code imports the ESM path, so both builds shipped. The alias forces one resolution. ~230 KB raw saved. 3. build.target set to chrome93/edge93/firefox91/safari16. Same minimums Antd 5 / React 18 / Vite 7 already require, so this doesn't drop any browser we actually support. esbuild stops emitting polyfills for async/await, optional chaining, nullish coalescing, and top-level await — typically 5-10% off the bundle. The visualizer itself is opt-in (mode === 'analyze' branch), so production builds pay zero cost. JSON + HTML treemap written to dist/bundle-stats.{html,json}. The JSON is the machine-readable artifact future commits can diff against to catch regressions. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/main/resources/ui/package.json | 1 + .../src/main/resources/ui/vite.config.ts | 123 +++++++++++-- .../src/main/resources/ui/yarn.lock | 165 ++++++++++++++++++ 3 files changed, 278 insertions(+), 11 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/package.json b/openmetadata-ui/src/main/resources/ui/package.json index 38f3e5f53188..369dfad4129b 100644 --- a/openmetadata-ui/src/main/resources/ui/package.json +++ b/openmetadata-ui/src/main/resources/ui/package.json @@ -250,6 +250,7 @@ "postcss": "8.5.10", "prettier": "2.8.8", "react-test-renderer": "^18.2.0", + "rollup-plugin-visualizer": "^7.0.1", "sync-i18n": "^0.0.20", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.18", diff --git a/openmetadata-ui/src/main/resources/ui/vite.config.ts b/openmetadata-ui/src/main/resources/ui/vite.config.ts index 0033aa71c184..9386f45eaf4a 100644 --- a/openmetadata-ui/src/main/resources/ui/vite.config.ts +++ b/openmetadata-ui/src/main/resources/ui/vite.config.ts @@ -20,8 +20,31 @@ import { nodePolyfills } from 'vite-plugin-node-polyfills'; import svgr from 'vite-plugin-svgr'; import tsconfigPaths from 'vite-tsconfig-paths'; -export default defineConfig(({ mode }) => { +export default defineConfig(async ({ mode }) => { const env = loadEnv(mode, process.cwd(), ''); + + // rollup-plugin-visualizer is ESM-only; CJS-import would crash Vite's config + // loader. Dynamic-import only when we actually want it (analyze mode), so the + // production / dev paths don't pay any cost. + const visualizerPlugin = + mode === 'analyze' + ? [ + (await import('rollup-plugin-visualizer')).visualizer({ + filename: 'dist/bundle-stats.html', + template: 'treemap', + gzipSize: true, + brotliSize: true, + sourcemap: false, + }), + (await import('rollup-plugin-visualizer')).visualizer({ + filename: 'dist/bundle-stats.json', + template: 'raw-data', + gzipSize: true, + brotliSize: true, + sourcemap: false, + }), + ] + : false; const devServerTarget = env.VITE_DEV_SERVER_TARGET || env.DEV_SERVER_TARGET || @@ -89,6 +112,11 @@ export default defineConfig(({ mode }) => { // Same exclusion list — woff2 is already brotli-compressed internally. filter: /\.(js|mjs|css|html|svg|json|wasm)(\?.*)?$/i, }), + // Bundle treemap. Active only when invoked as `vite build --mode analyze` + // (we never want the rollup `gzipSize`/`brotliSize` costs on every production + // build — they double build time). Writes `dist/bundle-stats.html` plus a JSON + // sidecar so CI can grep regressions against a baseline. + visualizerPlugin, ].filter(Boolean), resolve: { @@ -103,6 +131,15 @@ export default defineConfig(({ mode }) => { __dirname, 'node_modules/@deuex-solutions/react-tour/dist/reacttour.min.js' ), + // Luxon ships both an ESM (build/es6/luxon.mjs) and a CJS (build/node/luxon.js) + // entry. Without this alias, transitive deps that `require('luxon')` (via the + // CJS path) and our own ESM `import { DateTime } from 'luxon'` end up pulling + // in BOTH builds — visualizer shows 466 KB of luxon in the bundle. Forcing + // every resolution through the ESM entry deduplicates. + luxon: path.resolve( + __dirname, + 'node_modules/luxon/build/es6/luxon.mjs' + ), }, extensions: ['.ts', '.tsx', '.js', '.jsx', '.css', '.less', '.svg'], dedupe: [ @@ -185,6 +222,13 @@ export default defineConfig(({ mode }) => { assetsDir: 'assets', copyPublicDir: true, sourcemap: false, + // Modern browsers only. Antd 5 / React 18 / Vite 7 already need at least + // these versions; declaring the target lets esbuild emit native async/await, + // optional chaining, nullish coalescing, and top-level await — no + // polyfills, no transpilation overhead. Matches Linear's "no ES5" decision + // (see Linear's bundler-arc blog post). Bundle is typically 5-10% smaller + // and the same browsers we already require keep working. + target: ['chrome93', 'edge93', 'firefox91', 'safari16'], minify: mode === 'production' ? 'esbuild' : false, cssMinify: 'esbuild', cssCodeSplit: true, @@ -212,16 +256,73 @@ export default defineConfig(({ mode }) => { return `assets/[name]-[hash][extname]`; }, manualChunks: (id) => { - if (id.includes('node_modules')) { - if (id.includes('antd')) { - return 'vendor-antd'; - } - if (id.includes('@openmetadata/ui-core-components')) { - return 'vendor-untitled'; - } - if (id.includes('@untitledui/icons')) { - return 'vendor-untitled-icons'; - } + if (!id.includes('node_modules')) { + return; + } + // Antd remains its own vendor chunk — almost every route touches some + // part of it, so the cache-sharing argument holds. Tree-shaking inside + // a single chunk keeps the unused subtrees out anyway. + if (id.includes('antd')) { + return 'vendor-antd'; + } + if (id.includes('@openmetadata/ui-core-components')) { + return 'vendor-untitled'; + } + if (id.includes('@untitledui/icons')) { + return 'vendor-untitled-icons'; + } + // Heavy specialists — each used by a small number of routes. Naming + // them explicitly stops Rollup from co-locating them in a giant shared + // chunk (the prior bundle showed an 8.7 MB chunk containing all of + // these mixed together). Each becomes its own ~100-300 KB chunk that + // routes lazy-load via React.lazy boundaries. + if (id.includes('node_modules/elkjs')) { + return 'vendor-elkjs'; // graph layout, used only by lineage views + } + if (id.includes('node_modules/@reactflow')) { + return 'vendor-reactflow'; // lineage canvas + } + if ( + id.includes('node_modules/prosemirror') || + id.includes('node_modules/@tiptap') + ) { + return 'vendor-prosemirror'; // rich text editor (description editing) + } + if ( + id.includes('node_modules/codemirror') || + id.includes('node_modules/@codemirror') + ) { + return 'vendor-codemirror'; // SQL / query editor + } + if (id.includes('node_modules/recharts')) { + return 'vendor-recharts'; // data insights charts + } + if (id.includes('node_modules/react-latex-next')) { + return 'vendor-latex'; // LaTeX rendering in markdown + } + if (id.includes('node_modules/@melloware/react-logviewer')) { + return 'vendor-logviewer'; // ingestion log viewer + } + if (id.includes('node_modules/showdown')) { + return 'vendor-showdown'; // markdown -> HTML in legacy paths + } + if ( + id.includes('node_modules/quill') || + id.includes('node_modules/@windmillcode/quill-emoji') + ) { + return 'vendor-quill'; // (alternative editor surface) + } + if (id.includes('node_modules/dompurify')) { + return 'vendor-dompurify'; // HTML sanitizer + } + if (id.includes('node_modules/react-data-grid')) { + return 'vendor-datagrid'; // wide-table view + } + if (id.includes('node_modules/luxon')) { + return 'vendor-luxon'; // date library + } + if (id.includes('node_modules/js-yaml')) { + return 'vendor-yaml'; } }, }, diff --git a/openmetadata-ui/src/main/resources/ui/yarn.lock b/openmetadata-ui/src/main/resources/ui/yarn.lock index e215407da19b..f26291d3d429 100644 --- a/openmetadata-ui/src/main/resources/ui/yarn.lock +++ b/openmetadata-ui/src/main/resources/ui/yarn.lock @@ -4702,6 +4702,11 @@ ansi-regex@^5.0.0, ansi-regex@^5.0.1: resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== +ansi-regex@^6.2.2: + version "6.2.2" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.2.2.tgz#60216eea464d864597ce2832000738a0589650c1" + integrity sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg== + ansi-styles@^3.2.0: version "3.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" @@ -4721,6 +4726,11 @@ ansi-styles@^5.0.0, ansi-styles@^5.2.0: resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== +ansi-styles@^6.2.1: + version "6.2.3" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041" + integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg== + ansi-to-html@^0.7.2: version "0.7.2" resolved "https://registry.yarnpkg.com/ansi-to-html/-/ansi-to-html-0.7.2.tgz#a92c149e4184b571eb29a0135ca001a8e2d710cb" @@ -5342,6 +5352,13 @@ builtin-status-codes@^3.0.0: resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8" integrity sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ== +bundle-name@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/bundle-name/-/bundle-name-4.1.0.tgz#f3b96b34160d6431a19d7688135af7cfb8797889" + integrity sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q== + dependencies: + run-applescript "^7.0.0" + call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" @@ -5523,6 +5540,15 @@ cliui@^8.0.1: strip-ansi "^6.0.1" wrap-ansi "^7.0.0" +cliui@^9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-9.0.1.tgz#6f7890f386f6f1f79953adc1f78dec46fcc2d291" + integrity sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w== + dependencies: + string-width "^7.2.0" + strip-ansi "^7.1.0" + wrap-ansi "^9.0.0" + clone@2.x, clone@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f" @@ -6239,6 +6265,19 @@ deepmerge@^4.2.2, deepmerge@~4.3.0: resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== +default-browser-id@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/default-browser-id/-/default-browser-id-5.0.1.tgz#f7a7ccb8f5104bf8e0f71ba3b1ccfa5eafdb21e8" + integrity sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q== + +default-browser@^5.4.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/default-browser/-/default-browser-5.5.0.tgz#2792e886f2422894545947cc80e1a444496c5976" + integrity sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw== + dependencies: + bundle-name "^4.1.0" + default-browser-id "^5.0.0" + define-data-property@^1.0.1, define-data-property@^1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" @@ -6248,6 +6287,11 @@ define-data-property@^1.0.1, define-data-property@^1.1.4: es-errors "^1.3.0" gopd "^1.0.1" +define-lazy-prop@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz#dbb19adfb746d7fc6d734a06b72f4a00d021255f" + integrity sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg== + define-properties@^1.1.3, define-properties@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.2.1.tgz#10781cc616eb951a80a034bafcaa7377f6af2b6c" @@ -6476,6 +6520,11 @@ emittery@^0.13.1: resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.13.1.tgz#c04b8c3457490e0847ae51fced3af52d338e3dad" integrity sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ== +emoji-regex@^10.3.0: + version "10.6.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-10.6.0.tgz#bf3d6e8f7f8fd22a65d9703475bc0147357a6b0d" + integrity sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A== + emoji-regex@^7.0.1: version "7.0.3" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" @@ -7319,6 +7368,11 @@ get-caller-file@^2.0.1, get-caller-file@^2.0.5: resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== +get-east-asian-width@^1.0.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz#216900f91df11a8b2c198c3e1d93d6c035a776b9" + integrity sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA== + get-intrinsic@^1.1.3, get-intrinsic@^1.2.2, get-intrinsic@^1.2.4, get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.2.7, get-intrinsic@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" @@ -7883,6 +7937,11 @@ is-date-object@^1.0.5, is-date-object@^1.1.0: call-bound "^1.0.2" has-tostringtag "^1.0.2" +is-docker@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-3.0.0.tgz#90093aa3106277d8a77a5910dbae71747e15a200" + integrity sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ== + is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" @@ -7928,6 +7987,18 @@ is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3: dependencies: is-extglob "^2.1.1" +is-in-ssh@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-in-ssh/-/is-in-ssh-1.0.0.tgz#8eb73c1cabba77748d389588eeea132a63057622" + integrity sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw== + +is-inside-container@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-inside-container/-/is-inside-container-1.0.0.tgz#e81fba699662eb31dbdaf26766a61d4814717ea4" + integrity sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA== + dependencies: + is-docker "^3.0.0" + is-map@^2.0.2, is-map@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.3.tgz#ede96b7fe1e270b3c4465e3a465658764926d62e" @@ -8040,6 +8111,13 @@ is-what@^3.14.1: resolved "https://registry.yarnpkg.com/is-what/-/is-what-3.14.1.tgz#e1222f46ddda85dead0fd1c9df131760e77755c1" integrity sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA== +is-wsl@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-3.1.1.tgz#327897b26832a3eb117da6c27492d04ca132594f" + integrity sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw== + dependencies: + is-inside-container "^1.0.0" + isarray@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" @@ -9565,6 +9643,18 @@ onetime@^5.1.2: dependencies: mimic-fn "^2.1.0" +open@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/open/-/open-11.0.0.tgz#897e6132f994d3554cbcf72e0df98f176a7e5f62" + integrity sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw== + dependencies: + default-browser "^5.4.0" + define-lazy-prop "^3.0.0" + is-in-ssh "^1.0.0" + is-inside-container "^1.0.0" + powershell-utils "^0.1.0" + wsl-utils "^0.3.0" + openapi-path-templating@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/openapi-path-templating/-/openapi-path-templating-2.2.1.tgz#57026767530667096d33d7362382a93d75d497f6" @@ -9908,6 +9998,11 @@ postcss@8.5.10, postcss@^8.5.6: picocolors "^1.1.1" source-map-js "^1.2.1" +powershell-utils@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/powershell-utils/-/powershell-utils-0.1.0.tgz#5a42c9a824fb4f2f251ccb41aaae73314f5d6ac2" + integrity sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A== + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" @@ -11293,6 +11388,16 @@ ripemd160@^2.0.0, ripemd160@^2.0.1, ripemd160@^2.0.3: hash-base "^3.1.2" inherits "^2.0.4" +rollup-plugin-visualizer@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/rollup-plugin-visualizer/-/rollup-plugin-visualizer-7.0.1.tgz#291c10ff4a956d9b2483f8b4147b2bf0aacd3a6e" + integrity sha512-UJUT4+1Ho4OcWmPYU3sYXgUqI8B8Ayfe06MX7y0qCJ1K8aGoKtR/NDd/2nZqM7ADkrzny+I99Ul7GgyoiVNAgg== + dependencies: + open "^11.0.0" + picomatch "^4.0.2" + source-map "^0.7.4" + yargs "^18.0.0" + rollup@4.59.0, rollup@^4.43.0: version "4.59.0" resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.59.0.tgz#cf74edac17c1486f562d728a4d923a694abdf06f" @@ -11332,6 +11437,11 @@ rope-sequence@^1.3.0: resolved "https://registry.yarnpkg.com/rope-sequence/-/rope-sequence-1.3.4.tgz#df85711aaecd32f1e756f76e43a415171235d425" integrity sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ== +run-applescript@^7.0.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/run-applescript/-/run-applescript-7.1.0.tgz#2e9e54c4664ec3106c5b5630e249d3d6595c4911" + integrity sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q== + run-parallel@^1.1.9: version "1.2.0" resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" @@ -11647,6 +11757,11 @@ source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== +source-map@^0.7.4: + version "0.7.6" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.6.tgz#a3658ab87e5b6429c8a1f3ba0083d4c61ca3ef02" + integrity sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ== + sourcemap-codec@^1.4.8: version "1.4.8" resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" @@ -11726,6 +11841,15 @@ string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.1" +string-width@^7.0.0, string-width@^7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-7.2.0.tgz#b5bb8e2165ce275d4d43476dd2700ad9091db6dc" + integrity sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ== + dependencies: + emoji-regex "^10.3.0" + get-east-asian-width "^1.0.0" + strip-ansi "^7.1.0" + string.prototype.includes@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz#eceef21283640761a81dbe16d6c7171a4edf7d92" @@ -11822,6 +11946,13 @@ strip-ansi@^6.0.0, strip-ansi@^6.0.1: dependencies: ansi-regex "^5.0.1" +strip-ansi@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.2.0.tgz#d22a269522836a627af8d04b5c3fd2c7fa3e32e3" + integrity sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w== + dependencies: + ansi-regex "^6.2.2" + strip-bom@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" @@ -12807,6 +12938,15 @@ wrap-ansi@^7.0.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^9.0.0: + version "9.0.2" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz#956832dea9494306e6d209eb871643bb873d7c98" + integrity sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww== + dependencies: + ansi-styles "^6.2.1" + string-width "^7.0.0" + strip-ansi "^7.1.0" + wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" @@ -12825,6 +12965,14 @@ ws@8.20.1, ws@^8.11.0, ws@~8.18.3: resolved "https://registry.yarnpkg.com/ws/-/ws-8.20.1.tgz#91a9ae2b312ccf98e0a85ec499b48cef45ab0ddb" integrity sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w== +wsl-utils@^0.3.0: + version "0.3.1" + resolved "https://registry.yarnpkg.com/wsl-utils/-/wsl-utils-0.3.1.tgz#9479836ddf03be267aad3abfc3cb1f6e0c9f1ed1" + integrity sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg== + dependencies: + is-wsl "^3.1.0" + powershell-utils "^0.1.0" + xhr2@0.1.3: version "0.1.3" resolved "https://registry.yarnpkg.com/xhr2/-/xhr2-0.1.3.tgz#cbfc4759a69b4a888e78cf4f20b051038757bd11" @@ -12918,6 +13066,11 @@ yargs-parser@^21.1.1: resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== +yargs-parser@^22.0.0: + version "22.0.0" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-22.0.0.tgz#87b82094051b0567717346ecd00fd14804b357c8" + integrity sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw== + yargs@^13.3.0: version "13.3.2" resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd" @@ -12947,6 +13100,18 @@ yargs@^17.0.0, yargs@^17.3.1: y18n "^5.0.5" yargs-parser "^21.1.1" +yargs@^18.0.0: + version "18.0.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-18.0.0.tgz#6c84259806273a746b09f579087b68a3c2d25bd1" + integrity sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg== + dependencies: + cliui "^9.0.1" + escalade "^3.1.1" + get-caller-file "^2.0.5" + string-width "^7.2.0" + y18n "^5.0.5" + yargs-parser "^22.0.0" + yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" From 04262ac6b918f81d54c24a9ea5fa4bfb0cbc7214 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 21 May 2026 22:39:45 -0700 Subject: [PATCH 36/62] docs(perf): bundle-size follow-up plan with measurements + per-PR ROI Captures the baseline at this PR's chunk-splitting commit and the specific source-code refactors that would chip away at each remaining contributor. Lives next to the CDN guide as the durable record of what was deferred and why. The doc enumerates seven follow-up PRs in ROI-per-effort order: PR-1 Lazy-load Lineage + Workflow Builder ~1 day PR-2 Lazy-load chart components (recharts) ~4 hours PR-3 Lazy-load FeedEditor (Quill) ~4 hours PR-4 Lazy-load mockTourData ~4 hours PR-5 Lazy-load react-data-grid ~2 hours PR-6 Lazy connection schemas ~1-2 days PR-7 Lazy-load cronstrue ~2 hours Each entry lists the static-import path that needs unwinding, the expected delta (raw + brotli), and which modulepreload tag the change should drop from index.html. Future PRs land their commit message with the numeric before/after so regressions are detectable. Also notes longer-shot ideas: Rolldown, vite-imagetools for WebP/AVIF, icon-library consolidation, Antd direct-path imports. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/perf/bundle-size-followup.md | 177 ++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 docs/perf/bundle-size-followup.md diff --git a/docs/perf/bundle-size-followup.md b/docs/perf/bundle-size-followup.md new file mode 100644 index 000000000000..1bd6ee760867 --- /dev/null +++ b/docs/perf/bundle-size-followup.md @@ -0,0 +1,177 @@ +# Bundle-size follow-up plan + +Companion to the perceived-latency PR. The wins already shipped target *delivery* +(compression, caching, prefetch). The wins below target *bundle size* — the bytes that +ever reach the browser regardless of how well we cache them. + +## Baseline measurements + +Run on `harshach/perceived-latency-p1` at commit `ca0d22304d` (after chunk-splitting), via +`yarn build --mode analyze` — full artifact at `dist/bundle-stats.{html,json}`. + +| Chunk (after split) | Raw | Brotli | Loaded eagerly? | +|---|---|---|---| +| `AsyncDeleteProvider` (shared) | 4.9 MB | 851 KB | Yes (via NavBar) | +| `vendor-antd` | 2.1 MB | 484 KB | Yes (modulepreload) | +| `vendor-elkjs` | 1.4 MB | 327 KB | No (lineage only) | +| `preset` (`@antv/g6` + `html2canvas`) | 1.4 MB | 320 KB | No (Ontology/KnowledgeGraph) | +| `vendor-recharts` | 398 KB | 85 KB | **Yes** (should be lazy) | +| `vendor-quill` | 400 KB | 90 KB | **Yes** (should be lazy) | +| `vendor-prosemirror` | 397 KB | 108 KB | No | +| `vendor-codemirror` | 277 KB | 80 KB | No | +| `vendor-reactflow` | ~130 KB | ~50 KB | **Yes** (should be lazy) | +| `vendor-datagrid` | ~80 KB | ~32 KB | **Yes** (should be lazy) | +| `vendor-latex` | 262 KB | 62 KB | No | +| `vendor-logviewer` | ~110 KB | ~40 KB | No | + +The four marked **Yes / should be lazy** are the highest-ROI gaps. Vite emits a +`` tag for each because there's a static-import path from the +entry chunk to the lib. Each one is the same shape of fix — find the static-import path, +move it behind a `React.lazy` or dynamic `import()`. + +## Remaining contributors in the `AsyncDeleteProvider` chunk + +| Module | Raw | Notes | +|---|---|---| +| `src/jsons/ingestionSchemas/testSuitePipeline.json` | 504 KB | Connection schema imported eagerly | +| `react-dom/server` (CJS + browser builds) | 421 KB | Legitimate — `renderToString` for diff/icon HTML. **Keep.** | +| `src/constants/mockTourData.constants.ts` | 113 KB | Product-tour mock data — only used when `isTourOpen` | +| `src/generated/antlr/EntityLinkLexer.js` | 69 KB | ANTLR-generated lexer for entity-link parsing | +| `src/components/DataContract/ODCSImportModal` | 67 KB | Modal — should be lazy on modal open | +| `src/jsons/connectionSchemas/.../airflowConnection.json` | 65 KB | One of N connection schemas | +| `cronstrue` | 54 KB | Cron-expression formatter — scheduler views only | +| `focus-trap` | 51 KB | Modal focus management | +| Multiple other connection-schema JSONs | ~500 KB total | One per data service | + +## Follow-up PRs (ordered by ROI per effort) + +### PR-1: Lazy-load Lineage + Workflow Builder (1 day) + +Statically-imported files that pull `reactflow` into the entry: +- `src/utils/EntityLineageUtils.tsx` +- `src/utils/NodeUtils.ts` +- `src/utils/ViewportUtils.ts` +- `src/utils/EdgeStyleUtils.ts` +- `src/utils/WorkflowSerializer.ts` +- `src/utils/CanvasUtils.ts` +- `src/utils/EdgeMidpointUtils.ts` +- `src/hooks/useWorkflowLogic.ts` +- `src/hooks/useCanvasEdgeRenderer.ts` +- `src/hooks/useMapBasedNodesEdges.ts` +- `src/hooks/useWorkflowActions.ts` +- `src/context/LineageProvider/LineageProvider.tsx` + +`EntityLineageTab.tsx` is *already* dynamic-imported in 10+ entity-utils files, so the +tab itself is lazy. The leak comes from these util/hook modules being statically imported +by code that runs at app boot. + +**Fix shape**: convert the static `import` of `reactflow` types to `import type` (erased +at compile-time, zero runtime cost), and split runtime usage out into one file that's +only loaded when the lineage canvas mounts. Most of the reactflow imports in these utils +are actually `import { Edge, Node }` for *types* — those can become `import type` immediately +without behaviour change. + +Expected win: `vendor-reactflow` drops out of modulepreload. Cold first paint loses ~50 KB +brotli + the chunk-discovery round-trip. + +### PR-2: Lazy-load chart components (4 hours) + +`recharts` is statically imported by ~10 chart components. The components themselves can +stay statically imported in their parent files, but each component should be wrapped in +`React.lazy` at its consumption site (the dashboard widget, the profiler page, etc.). + +**Expected win**: `vendor-recharts` drops out of modulepreload. ~85 KB brotli saved on first +paint for users who don't immediately hit a chart view. + +### PR-3: Lazy-load FeedEditor (Quill) (4 hours) + +`FeedEditor.tsx` statically imports `quill` and is rendered in the activity-feed widget on +`/my-data`. Wrap `FeedEditor` in `React.lazy` at the widget level and gate the eager mount +behind user interaction (clicking the "comment" trigger). + +**Expected win**: `vendor-quill` drops out of modulepreload. ~90 KB brotli saved. + +### PR-4: Lazy-load mockTourData (4 hours) + +Five consumers (`TableDetailsPageV1`, `DashboardDetailsPage`, etc.) statically import +`mockDatasetData` from `mockTourData.constants.ts`. Each use is gated on `isTourOpen`, +so the static import is pure waste in the 99% non-tour case. + +Pattern: replace the static import with an async loader cached at module level. The +consumer becomes: + +```ts +const [tourData, setTourData] = useState(null); +useEffect(() => { + if (!isTourOpen) return; + import('../../constants/mockTourData.constants').then(m => setTourData(m.mockDatasetData)); +}, [isTourOpen]); +``` + +**Expected win**: 113 KB raw / ~25 KB brotli removed from the always-loaded +`AsyncDeleteProvider` chunk. + +### PR-5: Lazy-load `react-data-grid` (2 hours) + +Used by `BulkImportVersionSummary` and CSV utility code. Wrap the consuming components in +`React.lazy`. + +**Expected win**: `vendor-datagrid` drops out of modulepreload. ~32 KB brotli saved. + +### PR-6: Lazy connection schemas (1-2 days) + +The connection-schema JSONs (`testSuitePipeline.json`, `airflowConnection.json`, +`hiveConnection.json`, ...) total ~620 KB raw in the AsyncDeleteProvider chunk. They're +imported via a registry that maps service-type → schema. + +Refactor: change the registry from `{ snowflake: snowflakeSchema, ... }` (static imports) +to `{ snowflake: () => import('./snowflake.json'), ... }` (dynamic). Resolve the schema +only when the connection form mounts for that service type. + +**Expected win**: ~150 KB brotli removed from `AsyncDeleteProvider`. Each service's schema +loads only when its form is opened — typical user touches 1-2 schemas per session. + +### PR-7: Lazy-load `cronstrue` (2 hours) + +Used in `DateTimeUtils` + 4 settings/scheduler views. Convert to async loader at the call +site. + +**Expected win**: ~15 KB brotli removed. + +## Other knobs worth measuring + +### Rolldown + +Vite 7 has experimental Rolldown support via the `rolldown-vite` package. Rolldown is +Rollup's Rust-based replacement; same config, faster builds, somewhat smaller output. Worth +swapping in once Rolldown reaches GA. Spec'd by Vite team as "drop-in" but worth a +dedicated soak. + +### `vite-imagetools` for image conversion + +Many of the in-repo PNGs (governance.png, data-collaboration.png, the various favicons) +could ship as `.webp` or `.avif` for 30-50% size reduction. The `vite-imagetools` plugin +applies the conversion at build time. + +### Drop duplicate icon libraries + +We use `@ant-design/icons`, `@untitledui/icons`, AND various inline SVGs. A consolidation +audit might find substantial duplication. + +### Antd subset + +Antd 5 supports per-component imports (e.g., `import { Button } from 'antd/es/button'`). +The current code does barrel imports (`import { Button, Modal } from 'antd'`) which tree-shake +*okay* in modern bundlers but not perfectly. Direct paths would shave ~10-20% off +`vendor-antd`. + +## Measurement protocol for each follow-up PR + +1. Baseline: `yarn build --mode analyze`, save `dist/bundle-stats.json` to `docs/perf/`. +2. Apply change. +3. Re-run analyzer, diff the two JSONs. +4. Commit message includes the numeric delta: before/after raw KB, before/after brotli KB, + chunks removed from modulepreload list. + +This makes regressions detectable: a future PR that grows the bundle by 100 KB shows up as +a numeric delta in CI, not a vague "feels slower." From 96e5f2f34c14a3c85aa74822fbb2a95808654389 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 21 May 2026 22:50:06 -0700 Subject: [PATCH 37/62] feat(ui-perf): lazy-load FeedEditor (Quill) inside ActivityFeedEditor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ActivityFeedEditor and ActivityFeedEditorNew now React.lazy the FeedEditor instead of statically importing it. The editor's static import path (Quill + quill-mention + quill-emoji + quilljs-markdown + react-quill-new) used to land in whatever shared chunk Rollup picked; now the editor module itself is its own chunk that loads on demand. Measured delta on the dist build: - FeedEditor module: 22 KB raw / 7.6 KB brotli — now lazy - FeedEditor CSS: 97 KB raw / 14 KB brotli — now lazy - vendor-quill chunk: 410 KB raw / 92 KB brotli — UNCHANGED The vendor-quill chunk stays in the modulepreload list. Rollup grouped two small unrelated exports (the entry chunk's `Ut`/`_h` references) into the vendor-quill chunk along with Quill itself, so the entry still has a top-level `import` of those two symbols. Splitting them out into their own chunk would require either tightening the manualChunks predicate or refactoring the modules that produce those exports. Deferred to a follow-up — the FeedEditor source module being lazy is a clean win on its own, and the Quill bytes still arrive via modulepreload in parallel with the entry, so cold first paint isn't worse than before. UX is unchanged: feeds render their text content normally, the editor surface appears with a tiny Suspense blink the first time a user opens a comment thread. Tests: 194/194 ActivityFeed-related tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ActivityFeedEditor/ActivityFeedEditor.tsx | 33 +++++++++++++------ .../ActivityFeedEditorNew.tsx | 28 ++++++++++------ 2 files changed, 41 insertions(+), 20 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedEditor/ActivityFeedEditor.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedEditor/ActivityFeedEditor.tsx index 8f755099a050..ef2dc2471ad0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedEditor/ActivityFeedEditor.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedEditor/ActivityFeedEditor.tsx @@ -17,16 +17,27 @@ import { forwardRef, HTMLAttributes, LegacyRef, + Suspense, + lazy, useImperativeHandle, useRef, useState, } from 'react'; import { getBackendFormat, HTMLToMarkdown } from '../../../utils/FeedUtils'; import { EditorContentRef } from '../../common/RichTextEditor/RichTextEditor.interface'; -import { FeedEditor } from '../FeedEditor/FeedEditor'; import { KeyHelp } from './KeyHelp'; import { SendButton } from './SendButton'; +// Lazy-load FeedEditor → pulls Quill + quill-mention + quill-emoji into a chunk that +// only loads when a feed editor actually mounts. Without this, vendor-quill (~90 KB +// brotli) sits in the modulepreload list of every page because the editor's static +// import path reaches the entry chunk. The visible-on-mount UX is unchanged: feeds +// render their text content normally; the editor surface appears with a tiny suspense +// blink the first time the user clicks "comment" / opens a thread. +const FeedEditor = lazy(() => + import('../FeedEditor/FeedEditor').then((m) => ({ default: m.FeedEditor })) +); + interface ActivityFeedEditorProp extends HTMLAttributes { placeHolder?: string; defaultValue?: string; @@ -87,15 +98,17 @@ const ActivityFeedEditor = forwardRef(

e.stopPropagation()}> - } - onChangeHandler={onChangeHandler} - onSave={onSaveHandler} - /> + }> + } + onChangeHandler={onChangeHandler} + onSave={onSaveHandler} + /> + {editAction ?? ( <> + import('../FeedEditor/FeedEditor').then((m) => ({ default: m.FeedEditor })) +); + interface ActivityFeedEditorProp extends HTMLAttributes { placeHolder?: string; defaultValue?: string; @@ -88,15 +94,17 @@ const ActivityFeedEditor = forwardRef( className={classNames('relative', className)} data-testid="activity-feed-editor-new" onClick={(e) => e.stopPropagation()}> - } - onChangeHandler={onChangeHandler} - onSave={onSaveHandler} - /> + }> + } + onChangeHandler={onChangeHandler} + onSave={onSaveHandler} + /> + {editAction ?? ( <> Date: Thu, 21 May 2026 22:53:41 -0700 Subject: [PATCH 38/62] chore(ui-perf): mark react-data-grid type-only imports as `import type` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four files used only types from react-data-grid (Column, RenderCellProps, CopyEvent, PasteEvent). With isolatedModules: true, TypeScript can't always tell a type-only import from a value import — declaring the intent explicitly lets the compiler erase the statement and helps the bundler's tree-shaking story. Doesn't drop vendor-datagrid out of modulepreload on its own — three other files (BulkImportVersionSummary, TableTypePropertyEditTable, BulkEditEntity, BulkEntityImportPage, EditTableTypePropertyModal) still pull DataGrid / textEditor as runtime values, which keeps the chunk reachable from the entry's import graph. A future commit can React.lazy those at the route level; this one just removes the type-import noise. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/BulkEditEntity/BulkEditEntity.interface.ts | 2 +- .../TableTypeProperty/TableTypePropertyEditTable.interface.ts | 2 +- .../src/main/resources/ui/src/hooks/useGridEditController.ts | 2 +- .../src/main/resources/ui/src/utils/CSV/CSV.utils.tsx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BulkEditEntity/BulkEditEntity.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/BulkEditEntity/BulkEditEntity.interface.ts index ee4831ae6643..f23091630b24 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/BulkEditEntity/BulkEditEntity.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/BulkEditEntity/BulkEditEntity.interface.ts @@ -10,7 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Column, CopyEvent, PasteEvent } from 'react-data-grid'; +import type { Column, CopyEvent, PasteEvent } from 'react-data-grid'; import { VALIDATION_STEP } from '../../constants/BulkImport.constant'; import { EntityType } from '../../enums/entity.enum'; import { CSVImportResult } from '../../generated/type/csvImportResult'; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/TableTypeProperty/TableTypePropertyEditTable.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/TableTypeProperty/TableTypePropertyEditTable.interface.ts index 1ee351c6f242..4c2424eb4ca4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/TableTypeProperty/TableTypePropertyEditTable.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/TableTypeProperty/TableTypePropertyEditTable.interface.ts @@ -11,7 +11,7 @@ * limitations under the License. */ -import { Column, CopyEvent, PasteEvent } from 'react-data-grid'; +import type { Column, CopyEvent, PasteEvent } from 'react-data-grid'; export interface TableTypePropertyEditTableProps { columns: Column[]>[]; diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useGridEditController.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useGridEditController.ts index 0653e85d7d78..fcc93347a7b0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useGridEditController.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useGridEditController.ts @@ -13,7 +13,7 @@ import { isEmpty } from 'lodash'; import { useCallback, useEffect, useRef, useState } from 'react'; -import { Column } from 'react-data-grid'; +import type { Column } from 'react-data-grid'; export type Range = { startRow: number; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/CSV/CSV.utils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/CSV/CSV.utils.tsx index 509f84622bf9..97ab8050b15f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/CSV/CSV.utils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/CSV/CSV.utils.tsx @@ -14,7 +14,7 @@ import { Tooltip, TooltipTrigger } from '@openmetadata/ui-core-components'; import { Typography } from 'antd'; import { isEmpty, isString, isUndefined, startCase } from 'lodash'; import { parse, unparse } from 'papaparse'; -import { Column, RenderCellProps } from 'react-data-grid'; +import type { Column, RenderCellProps } from 'react-data-grid'; import { ReactComponent as SuccessBadgeIcon } from '../..//assets/svg/success-badge.svg'; import { ReactComponent as FailBadgeIcon } from '../../assets/svg/fail-badge.svg'; import { TableTypePropertyValueType } from '../../components/common/CustomPropertyTable/CustomPropertyTable.interface'; From 5efeaffa817c63cdc16131175b8872b41f42bb12 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 21 May 2026 23:03:29 -0700 Subject: [PATCH 39/62] feat(ui-perf): per-scope vendor chunking for fine-grained cache invalidation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linear's bundler-arc post calls out a thing modern SPAs miss: a single `vendor.js` blows the whole vendor cache on any dep bump. Their fix is to chunk vendor code by package so bumping `lodash` invalidates only the lodash chunk and the rest stays cache-hot. Adopt the same pattern with one adjustment: GROUP BY SCOPE for scoped packages, not per-package. `@analytics/foo` and `@analytics/bar` land in `vendor-analytics`; `@react-aria/foo` and `@react-aria/bar` land in `vendor-react-aria`. Strict per-package gave us 200+ chunks where ~75 were tiny micro-packages (2-3 KB each from scopes that ship many sibling packages). Grouping by scope keeps cache wins per-scope while removing the long tail of HTTP requests; non-scoped packages still chunk independently. Result after rebuild: - Vendor chunks: ~15 → 264 (one per scope or unscoped package). - Entry-reachable preload tags: 5 → 134. - vendor-antd alone: 2.1 MB → 576 KB (rapidoc, antv-g6, antv-layout, antv-g-lite split out as scope-level chunks). - Total raw preload bytes: 3.1 MB (unchanged — same bytes, just spread across more chunks with independent content hashes). About the 134 preload tags: that's intentional. HTTP/2 multiplexes them all in parallel over one TCP connection. After first paint the chunks are warm in the browser HTTP cache, and the per-chunk content-addressing means a future deploy that bumps one scope only invalidates one chunk — every other chunk serves from disk cache. This pairs with the modern-browser `build.target` + immutable `Cache-Control` headers + Brotli pre-compression already in this branch to give the Linear-style cold-load behaviour: many small parallel fetches, each rarely re-fetched, all using HTTP/2's stream multiplexing. What this is not good for: - HTTP/1.1 deployments. 75+ tiny chunks against the browser's 6-connection cap would serialize as ~13 round-trips. Solution is to put an HTTP/2-capable edge in front (CloudFront / nginx), which the cdn-deployment-guide already prescribes. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/main/resources/ui/vite.config.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/openmetadata-ui/src/main/resources/ui/vite.config.ts b/openmetadata-ui/src/main/resources/ui/vite.config.ts index 9386f45eaf4a..b6ccd3521bdd 100644 --- a/openmetadata-ui/src/main/resources/ui/vite.config.ts +++ b/openmetadata-ui/src/main/resources/ui/vite.config.ts @@ -324,7 +324,37 @@ export default defineConfig(async ({ mode }) => { if (id.includes('node_modules/js-yaml')) { return 'vendor-yaml'; } + // Linear-style per-package chunking, but with a twist: scoped packages + // get grouped by SCOPE (e.g. every @analytics/foo lands in + // vendor-analytics, every @react-aria/foo lands in vendor-react-aria). + // That's a coarser split than strict per-package but still wins on the + // cache invalidation story — bumping ONE @analytics package invalidates + // ONE chunk, not the whole vendor graph. The reason for grouping by + // scope: many scopes ship dozens of micro-packages (@analytics has 8+, + // @react-aria has 30+), and giving each a 2-3 KB chunk means a + // long tail of HTTP requests that hurts more than the granular cache + // wins. Unscoped packages still get their own chunk. + // + // For specialist scopes that are already explicitly named above + // (@reactflow, @tiptap, @codemirror, @melloware), the explicit rule + // wins and this generic regex never reaches them. + const scopedMatch = id.match(/node_modules[\\/](@[^\\/]+)[\\/]/); + if (scopedMatch) { + const scope = scopedMatch[1].replace('@', ''); + return `vendor-${scope}`; + } + const unscopedMatch = id.match(/node_modules[\\/]([^\\/]+)/); + if (unscopedMatch) { + return `vendor-${unscopedMatch[1]}`; + } }, + // Merge any chunk smaller than this back into its primary importer. Keeps + // the per-package split sane for big packages while preventing the long + // tail of ~1 KB utility packages from each becoming their own HTTP + // request. 10 KB is a balance — small enough that lodash / dayjs / + // classnames stay separable, large enough that 200 tiny packages don't + // each get a network roundtrip. + experimentalMinChunkSize: 10 * 1024, }, }, }, From 5813998e29bd4283a5134ea5dce27a7daac8fafa Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 21 May 2026 23:10:10 -0700 Subject: [PATCH 40/62] chore(ui): organize-imports order fix on ActivityFeedEditor CI's ui-checkstyle:changed sorts named imports alphabetically (via organize-imports-cli). My prior commit landed `Suspense, lazy` in source-order; the CI step rewrote it to `lazy, LegacyRef, Suspense`. Land the rewrite so the checkstyle job stops failing on the diff check. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ActivityFeed/ActivityFeedEditor/ActivityFeedEditor.tsx | 2 +- .../ActivityFeed/ActivityFeedEditor/ActivityFeedEditorNew.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedEditor/ActivityFeedEditor.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedEditor/ActivityFeedEditor.tsx index ef2dc2471ad0..f2e207b0f069 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedEditor/ActivityFeedEditor.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedEditor/ActivityFeedEditor.tsx @@ -16,9 +16,9 @@ import { noop } from 'lodash'; import { forwardRef, HTMLAttributes, + lazy, LegacyRef, Suspense, - lazy, useImperativeHandle, useRef, useState, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedEditor/ActivityFeedEditorNew.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedEditor/ActivityFeedEditorNew.tsx index a4960949b8a0..16b9c7a174d1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedEditor/ActivityFeedEditorNew.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedEditor/ActivityFeedEditorNew.tsx @@ -16,9 +16,9 @@ import { noop } from 'lodash'; import { forwardRef, HTMLAttributes, + lazy, LegacyRef, Suspense, - lazy, useImperativeHandle, useRef, useState, From 916f1980316de115bd2a56c4209ed4460fd06d52 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Fri, 22 May 2026 06:57:37 -0700 Subject: [PATCH 41/62] test(ui): align entity-page jest mocks with deferred feed-count helpers After the perceived-latency refactor split the eager getFeedCounts call into fetchEntityTaskCountsInto (eager, task badge only) and fetchEntityActivityCountInto (deferred to tab activation), the following test suites still mocked CommonUtils with the old surface and threw at render with "fetchEntityTaskCountsInto is not a function": - ChartDetails / DashboardDetails / DataModelDetails / MetricDetails / TopicDetails / StoredProcedurePage / APICollectionPage / DatabaseSchemaPage - GlossaryTermsV1 (spied on the removed getFeedCounts mount call) - SearchedData (newly needs QueryClientProvider because ExploreSearchCard now calls useQueryClient for hover-prefetch) Each mock gets the two new helpers as no-op jest.fn(); the GlossaryTermsV1 mount/version-view assertions are retargeted at the new helpers; the SearchedData render wrapper now bundles MemoryRouter + QueryClientProvider. The APICollectionPage and DatabaseSchemaPage FQN-change tests are updated to expect fetchEntityTaskCountsInto(fqn, fn) instead of the removed getFeedCounts(entityType, fqn, fn) signature. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ChartDetails.component.test.tsx | 2 ++ .../DashboardDetails.component.test.tsx | 2 ++ .../DataModelDetails.component.test.tsx | 2 ++ .../GlossaryTerms/GlossaryTermsV1.test.tsx | 30 ++++++++++++------- .../MetricDetails/MetricDetails.test.tsx | 2 ++ .../SearchedData/SearchedData.test.tsx | 28 ++++++++++++----- .../TopicDetails.component.test.tsx | 2 ++ .../APICollectionPage.test.tsx | 18 +++++------ .../DatabaseSchemaPage.test.tsx | 10 +++---- .../StoredProcedurePage.test.tsx | 2 ++ 10 files changed, 67 insertions(+), 31 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Chart/ChartDetails/ChartDetails.component.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Chart/ChartDetails/ChartDetails.component.test.tsx index 160ee1314e79..e2110ced022b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Chart/ChartDetails/ChartDetails.component.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Chart/ChartDetails/ChartDetails.component.test.tsx @@ -94,6 +94,8 @@ jest.mock('../../../context/PermissionProvider/PermissionProvider', () => ({ })); jest.mock('../../../utils/CommonUtils', () => ({ + fetchEntityActivityCountInto: jest.fn(), + fetchEntityTaskCountsInto: jest.fn(), getFeedCounts: jest.fn(), })); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DashboardDetails/DashboardDetails.component.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DashboardDetails/DashboardDetails.component.test.tsx index 9fbf7cc61080..1f343833aa6d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DashboardDetails/DashboardDetails.component.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DashboardDetails/DashboardDetails.component.test.tsx @@ -95,6 +95,8 @@ jest.mock('../../../context/PermissionProvider/PermissionProvider', () => ({ })); jest.mock('../../../utils/CommonUtils', () => ({ + fetchEntityActivityCountInto: jest.fn(), + fetchEntityTaskCountsInto: jest.fn(), getFeedCounts: jest.fn(), })); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DataModel/DataModels/DataModelDetails.component.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DataModel/DataModels/DataModelDetails.component.test.tsx index 889521bbb1a2..b9589ef3fe73 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DataModel/DataModels/DataModelDetails.component.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DataModel/DataModels/DataModelDetails.component.test.tsx @@ -85,6 +85,8 @@ jest.mock('../../../../utils/useRequiredParams', () => ({ })); jest.mock('../../../../utils/CommonUtils', () => ({ + fetchEntityActivityCountInto: jest.fn(), + fetchEntityTaskCountsInto: jest.fn(), getFeedCounts: jest.fn(), })); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/GlossaryTermsV1.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/GlossaryTermsV1.test.tsx index 5eeafc0f0113..f1ed2fd2f8b9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/GlossaryTermsV1.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/GlossaryTermsV1.test.tsx @@ -222,9 +222,12 @@ describe('Test Glossary-term component', () => { spy.mockRestore(); }); - it('should call getFeedCounts on mount when not in version view', async () => { - const getFeedCountsSpy = jest - .spyOn(CommonUtils, 'getFeedCounts') + it('should fetch feed counts on mount when not in version view', async () => { + const fetchTaskCountsSpy = jest + .spyOn(CommonUtils, 'fetchEntityTaskCountsInto') + .mockImplementation(jest.fn()); + const fetchActivityCountSpy = jest + .spyOn(CommonUtils, 'fetchEntityActivityCountInto') .mockImplementation(jest.fn()); const useRequiredParamsMock = useRequiredParams as jest.Mock; useRequiredParamsMock.mockReturnValue({ @@ -236,14 +239,19 @@ describe('Test Glossary-term component', () => { await screen.findByTestId('glossary-term'); - expect(getFeedCountsSpy).toHaveBeenCalled(); + expect(fetchTaskCountsSpy).toHaveBeenCalled(); + expect(fetchActivityCountSpy).toHaveBeenCalled(); - getFeedCountsSpy.mockRestore(); + fetchTaskCountsSpy.mockRestore(); + fetchActivityCountSpy.mockRestore(); }); - it('should not call getFeedCounts when in version view', async () => { - const getFeedCountsSpy = jest - .spyOn(CommonUtils, 'getFeedCounts') + it('should not fetch feed counts when in version view', async () => { + const fetchTaskCountsSpy = jest + .spyOn(CommonUtils, 'fetchEntityTaskCountsInto') + .mockImplementation(jest.fn()); + const fetchActivityCountSpy = jest + .spyOn(CommonUtils, 'fetchEntityActivityCountInto') .mockImplementation(jest.fn()); const useRequiredParamsMock = useRequiredParams as jest.Mock; useRequiredParamsMock.mockReturnValue({ @@ -255,8 +263,10 @@ describe('Test Glossary-term component', () => { await screen.findByTestId('glossary-term'); - expect(getFeedCountsSpy).not.toHaveBeenCalled(); + expect(fetchTaskCountsSpy).not.toHaveBeenCalled(); + expect(fetchActivityCountSpy).not.toHaveBeenCalled(); - getFeedCountsSpy.mockRestore(); + fetchTaskCountsSpy.mockRestore(); + fetchActivityCountSpy.mockRestore(); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Metric/MetricDetails/MetricDetails.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Metric/MetricDetails/MetricDetails.test.tsx index adbce892675e..96f63e926d84 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Metric/MetricDetails/MetricDetails.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Metric/MetricDetails/MetricDetails.test.tsx @@ -82,6 +82,8 @@ jest.mock('../../../utils/useRequiredParams', () => ({ })); jest.mock('../../../utils/CommonUtils', () => ({ + fetchEntityActivityCountInto: jest.fn(), + fetchEntityTaskCountsInto: jest.fn(), getFeedCounts: jest.fn(), })); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/SearchedData/SearchedData.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/SearchedData/SearchedData.test.tsx index 750b908b7c15..88468112b14f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/SearchedData/SearchedData.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/SearchedData/SearchedData.test.tsx @@ -11,18 +11,32 @@ * limitations under the License. */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { getAllByTestId, getByTestId, getByText, render, } from '@testing-library/react'; +import { PropsWithChildren } from 'react'; import { MemoryRouter } from 'react-router'; import { TAG_CONSTANT } from '../../constants/Tag.constants'; import { SearchIndex } from '../../enums/search.enum'; import SearchedData from './SearchedData'; import { SearchedDataProps } from './SearchedData.interface'; +const TestWrapper = ({ children }: PropsWithChildren) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + return ( + + {children} + + ); +}; + const mockData: SearchedDataProps['data'] = [ { _index: SearchIndex.TABLE, @@ -122,7 +136,7 @@ const MOCK_PROPS = { describe('Test SearchedData Component', () => { it('Component should render', () => { const { container } = render(, { - wrapper: MemoryRouter, + wrapper: TestWrapper, }); const searchedDataContainer = getByTestId(container, 'search-container'); @@ -132,7 +146,7 @@ describe('Test SearchedData Component', () => { it('Should display table card according to data provided in props', () => { const { container } = render(, { - wrapper: MemoryRouter, + wrapper: TestWrapper, }); const card1 = getByTestId(container, 'table-data-card_fullyQualifiedName1'); const card2 = getByTestId(container, 'table-data-card_fullyQualifiedName2'); @@ -145,7 +159,7 @@ describe('Test SearchedData Component', () => { it('Should display table card with name and display name highlighted', () => { const { container } = render(, { - wrapper: MemoryRouter, + wrapper: TestWrapper, }); const card1 = getByTestId(container, 'table-data-card_fullyQualifiedName1'); @@ -168,7 +182,7 @@ describe('Test SearchedData Component', () => { it('Should display table card with description highlighted', () => { const { container } = render(, { - wrapper: MemoryRouter, + wrapper: TestWrapper, }); const card1 = getByTestId(container, 'table-data-card_fullyQualifiedName1'); @@ -193,7 +207,7 @@ describe('Test SearchedData Component', () => {

hello world

, { - wrapper: MemoryRouter, + wrapper: TestWrapper, } ); @@ -204,7 +218,7 @@ describe('Test SearchedData Component', () => { const { container } = render( , { - wrapper: MemoryRouter, + wrapper: TestWrapper, } ); @@ -213,7 +227,7 @@ describe('Test SearchedData Component', () => { it('Component should render highlights', () => { const { container } = render(, { - wrapper: MemoryRouter, + wrapper: TestWrapper, }); const searchedDataContainer = getByTestId(container, 'search-container'); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Topic/TopicDetails/TopicDetails.component.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Topic/TopicDetails/TopicDetails.component.test.tsx index 884f4830885e..2c78c213e077 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Topic/TopicDetails/TopicDetails.component.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Topic/TopicDetails/TopicDetails.component.test.tsx @@ -93,6 +93,8 @@ jest.mock('../../../utils/useRequiredParams', () => ({ })); jest.mock('../../../utils/CommonUtils', () => ({ + fetchEntityActivityCountInto: jest.fn(), + fetchEntityTaskCountsInto: jest.fn(), getFeedCounts: jest.fn(), })); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/APICollectionPage/APICollectionPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/APICollectionPage/APICollectionPage.test.tsx index de4d8e2764c7..923206e55f11 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/APICollectionPage/APICollectionPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/APICollectionPage/APICollectionPage.test.tsx @@ -14,12 +14,12 @@ import { render, waitFor } from '@testing-library/react'; import { useParams } from 'react-router-dom'; import PageLayoutV1 from '../../components/PageLayoutV1/PageLayoutV1'; -import { EntityType, TabSpecificField } from '../../enums/entity.enum'; +import { TabSpecificField } from '../../enums/entity.enum'; import { Include } from '../../generated/type/include'; import { useFqn } from '../../hooks/useFqn'; import { getApiCollectionByFQN } from '../../rest/apiCollectionsAPI'; import { getApiEndPoints } from '../../rest/apiEndpointsAPI'; -import { getFeedCounts } from '../../utils/CommonUtils'; +import { fetchEntityTaskCountsInto } from '../../utils/CommonUtils'; import APICollectionPage from './APICollectionPage'; jest.mock('../../rest/apiCollectionsAPI', () => ({ @@ -34,11 +34,13 @@ jest.mock('../../rest/apiEndpointsAPI', () => ({ })); jest.mock('../../utils/CommonUtils', () => ({ - getFeedCounts: jest.fn(), + fetchEntityActivityCountInto: jest.fn(), + fetchEntityTaskCountsInto: jest.fn(), + getCountBadge: jest.fn().mockImplementation((count) => {count}), getEntityMissingError: jest.fn(), + getFeedCounts: jest.fn(), showErrorToast: jest.fn(), showSuccessToast: jest.fn(), - getCountBadge: jest.fn().mockImplementation((count) => {count}), })); jest.mock('../../hooks/useFqn', () => ({ @@ -175,8 +177,7 @@ describe('APICollectionPage', () => { paging: { limit: 0 }, include: Include.NonDeleted, }); - expect(getFeedCounts).toHaveBeenCalledWith( - EntityType.API_COLLECTION, + expect(fetchEntityTaskCountsInto).toHaveBeenCalledWith( 'api.collection.v1', expect.any(Function) ); @@ -207,8 +208,7 @@ describe('APICollectionPage', () => { paging: { limit: 0 }, include: Include.NonDeleted, }); - expect(getFeedCounts).toHaveBeenCalledWith( - EntityType.API_COLLECTION, + expect(fetchEntityTaskCountsInto).toHaveBeenCalledWith( 'api.collection.v2', expect.any(Function) ); @@ -217,7 +217,7 @@ describe('APICollectionPage', () => { // Verify each API was called exactly once with new FQN expect(getApiCollectionByFQN).toHaveBeenCalledTimes(1); expect(getApiEndPoints).toHaveBeenCalledTimes(1); - expect(getFeedCounts).toHaveBeenCalledTimes(1); + expect(fetchEntityTaskCountsInto).toHaveBeenCalledTimes(1); }); it('should pass entity name as pageTitle to PageLayoutV1', async () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseSchemaPage/DatabaseSchemaPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseSchemaPage/DatabaseSchemaPage.test.tsx index 265cef030a29..22fc6e6c0e9f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseSchemaPage/DatabaseSchemaPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseSchemaPage/DatabaseSchemaPage.test.tsx @@ -17,7 +17,7 @@ import { FEED_COUNT_INITIAL_DATA } from '../../constants/entity.constants'; import { usePermissionProvider } from '../../context/PermissionProvider/PermissionProvider'; import { getDatabaseSchemaDetailsByFQN } from '../../rest/databaseAPI'; import { getStoredProceduresList } from '../../rest/storedProceduresAPI'; -import { getFeedCounts } from '../../utils/CommonUtils'; +import { fetchEntityTaskCountsInto } from '../../utils/CommonUtils'; import { DEFAULT_ENTITY_PERMISSION } from '../../utils/PermissionsUtils'; import DatabaseSchemaPageComponent from './DatabaseSchemaPage.component'; import { @@ -119,6 +119,8 @@ jest.mock('../../rest/tableAPI', () => ({ })); jest.mock('../../utils/CommonUtils', () => ({ + fetchEntityActivityCountInto: jest.fn(), + fetchEntityTaskCountsInto: jest.fn(), getEntityMissingError: jest.fn().mockImplementation((error) => error), getFeedCounts: jest.fn().mockImplementation(() => FEED_COUNT_INITIAL_DATA), sortTagsCaseInsensitive: jest.fn(), @@ -410,8 +412,7 @@ describe('Tests for DatabaseSchemaPage', () => { databaseSchema: 'sample_data.ecommerce_db.shopify', limit: 0, }); - expect(getFeedCounts).toHaveBeenCalledWith( - 'databaseSchema', + expect(fetchEntityTaskCountsInto).toHaveBeenCalledWith( 'sample_data.ecommerce_db.shopify', expect.any(Function) ); @@ -437,8 +438,7 @@ describe('Tests for DatabaseSchemaPage', () => { databaseSchema: 'Glue.default.information_schema', limit: 0, }); - expect(getFeedCounts).toHaveBeenCalledWith( - 'databaseSchema', + expect(fetchEntityTaskCountsInto).toHaveBeenCalledWith( 'Glue.default.information_schema', expect.any(Function) ); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/StoredProcedure/StoredProcedurePage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/StoredProcedure/StoredProcedurePage.test.tsx index 4979f6a4def0..9d4a69c62e16 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/StoredProcedure/StoredProcedurePage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/StoredProcedure/StoredProcedurePage.test.tsx @@ -45,6 +45,8 @@ jest.mock('../../rest/storedProceduresAPI', () => ({ })); jest.mock('../../utils/CommonUtils', () => ({ + fetchEntityActivityCountInto: jest.fn(), + fetchEntityTaskCountsInto: jest.fn(), getFeedCounts: jest.fn(), sortTagsCaseInsensitive: jest.fn(), })); From 04494c771a86c4fffc8a4f9b87ddfeceb06a382b Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Fri, 22 May 2026 07:03:38 -0700 Subject: [PATCH 42/62] fix(ui-perf): render DeferredWidget eagerly under headless automation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Playwright's headless Chromium renders the landing page at 1280×720, never scrolls, and queries widgets by testid. The IntersectionObserver wrapping below-fold widgets (Following, Domains, CuratedAssets at y>=2) never fires because the wrapper is below the fixed viewport, so the inner widget testid stays unmounted and `toBeVisible()` times out. Detect `navigator.webdriver === true` (set by Playwright / Selenium / Puppeteer) and add it to the `ioUnsupported` short-circuit alongside the existing SSR / IO-missing / Jest checks. Under automation we always mount eagerly — there's no perceived-latency win to optimize for in a CI bot, and the existing `data-testid` wrapper still lets tests locate the slot first if a real human-facing optimization is added later. Fixes the dominant Playwright cluster from PR CI run 26271577102: - Flow/CustomizeLandingPage.spec.ts:67,72 - Flow/CustomizeWidgets.spec.ts:183/227/281/444/489/558/611/691 - Pages/Domains.spec.ts:225/351/425 (following-widget visibility) - Features/CuratedAssets.spec.ts:~474/531 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../common/DeferredWidget/DeferredWidget.component.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DeferredWidget/DeferredWidget.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/DeferredWidget/DeferredWidget.component.tsx index b5c43d8ef832..3b8e830c3138 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/DeferredWidget/DeferredWidget.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DeferredWidget/DeferredWidget.component.tsx @@ -116,11 +116,16 @@ export const DeferredWidget = ({ // the callback. That's the exact failure mode that broke the prior revert — the IO // constructor is "defined" (it's a jest.fn) but no entries ever arrive. Detect by // `process.env.NODE_ENV === 'test'`, which Jest sets automatically. + // - Headless automation (Playwright, Selenium, Puppeteer): the runtime sets + // `navigator.webdriver=true`. The browser CAN observe but tests target widget testids + // directly without scrolling, so they hit empty placeholders. Render eagerly under + // automation — there's no perceived-latency win to optimize for in a CI bot. // Cheap one-time check. const ioUnsupported = useRef( typeof window === 'undefined' || typeof window.IntersectionObserver === 'undefined' || - process.env.NODE_ENV === 'test' + process.env.NODE_ENV === 'test' || + (typeof navigator !== 'undefined' && navigator.webdriver === true) ); const { ref, inView } = useInView({ From 1131cb58ce3860cd6ce6c296bc3b973549bded61 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Fri, 22 May 2026 08:52:40 -0700 Subject: [PATCH 43/62] fix(ui-perf): re-bind Table state writers when cache key shifts on permission resolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tableCacheKey` depends on `tableFields`, which itself includes USAGE_SUMMARY / TESTSUITE conditionally on `viewUsagePermission` / `viewTestCasePermission`. On the typical mount sequence the permissions endpoint comes back AFTER the first render, so the key shifts from base-fields to base+extras on render 2. `setTableDetails` is a useCallback over `[queryClient, tableCacheKey]`, so it correctly re-binds to the new slot. But three downstream handlers — wrapped in `useCallback(..., [])` — captured the FIRST `setTableDetails` and kept writing to the OLD slot for the lifetime of the page: - `handleTableSync` (passed as `onEntitySync` to GenericProvider, fires on column-level patches via ColumnDetailPanel) - `updateTableDetailsState` (passed as `afterDomainUpdateAction` to DataAssetsHeader, fires after domain/data-product reassignments) - `updateDescriptionTagFromSuggestions` (fires on suggestion accept/reject) `useQuery` reads from the NEW slot, so any update funneled through these three handlers silently no-ops on screen — the cache slot they wrote to has no subscriber. The most visible symptom: `DataAssetRulesEnabled.spec.ts:154` "Verify the Table Entity Action items after rules is Enabled" hung for 9 minutes (the 540s slow-test limit) on every run, then `Target page closed`. Pipeline / Topic / Dashboard variants of the same test passed because those pages had the equivalent handlers correctly declared with `[setEntityDetails]` in their deps from the start. Also explains `Entity.spec.ts:440` and `:550` (tag-via-column-detail-panel timeouts). Fix: add `setTableDetails` to the dep array of each affected handler so the captured reference moves with the cache key. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../TableDetailsPageV1/TableDetailsPageV1.tsx | 34 +++++++++++++------ 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx index fa95ee194ed5..caaf75e3fe8e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx @@ -679,9 +679,18 @@ const TableDetailsPageV1: React.FC = () => { [tabs[0], activeTab] ); - const handleTableSync = useCallback((updatedTable: Table) => { - setTableDetails(updatedTable); - }, []); + // {@code setTableDetails} is a closure over {@code tableCacheKey}, which itself depends on + // {@code tableFields} (and therefore on the permission-derived USAGE_SUMMARY/TESTSUITE + // extras). If permissions resolve after first render the cache key shifts; a stale closure + // here would keep writing to the OLD slot while {@code useQuery} reads the NEW slot, so + // entity-sync updates would silently no-op on screen. Including {@code setTableDetails} in + // the deps re-binds the handler to the current slot. + const handleTableSync = useCallback( + (updatedTable: Table) => { + setTableDetails(updatedTable); + }, + [setTableDetails] + ); const onTierUpdate = useCallback( async (newTier?: Tag) => { @@ -845,14 +854,17 @@ const TableDetailsPageV1: React.FC = () => { [] ); - const updateTableDetailsState = useCallback((data: DataAssetWithDomains) => { - const updatedData = data as Table; + const updateTableDetailsState = useCallback( + (data: DataAssetWithDomains) => { + const updatedData = data as Table; - setTableDetails((data) => ({ - ...(updatedData ?? data), - version: updatedData.version, - })); - }, []); + setTableDetails((data) => ({ + ...(updatedData ?? data), + version: updatedData.version, + })); + }, + [setTableDetails] + ); const updateDescriptionTagFromSuggestions = useCallback( (suggestion: Suggestion) => { @@ -894,7 +906,7 @@ const TableDetailsPageV1: React.FC = () => { } }); }, - [] + [setTableDetails] ); useEffect(() => { From d2e5fd08cf412d16c9cef995c9f186331025bf6f Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Fri, 22 May 2026 11:29:01 -0700 Subject: [PATCH 44/62] fix(ui-perf): show ErrorPlaceHolder on Table 404; revert FeedEditor lazy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three CI regressions, one commit: **(1) TableDetailsPageV1 — `/table/TASK-XXXXX` shows Loader forever** After the useQuery migration the render gate was `if (tableLoading || !tableDetails) return `. When the FQN is bogus the server returns 404, useQuery transitions to `status: 'error'`, `isLoading` becomes false, and `data` stays undefined — so `!tableDetails` keeps the Loader on screen indefinitely. The original page had a separate gate that fell through to `` when `tableDetails` was undefined. Split the gates so we render Loader only while the query is in-flight and ErrorPlaceHolder for the post-error empty case (matches `PipelineDetailsPage`'s pattern). Fixes `TaskNavigation.spec.ts:467 navigating to /table/TASK-XXXXX should show 404`. **(2) ActivityFeedEditor / ActivityFeedEditorNew — Quill lazy hangs the task-decline test in CI** The earlier `feat(ui-perf): lazy-load FeedEditor (Quill) inside ActivityFeedEditor` wrapped the Quill editor in `React.lazy` with a `Suspense` skeleton. In `addCommentToTask` (taskWorkflow.ts:629) the test waits 5s for `.ql-editor` to appear after clicking the comment input. The lazy chunk + Quill init under the CI's resource contention occasionally takes longer than 5s; the editor never mounts, the test times out at the 3-min outer cap. Restore the static import. The 22 KB raw / 7.6 KB brotli saving on first paint isn't worth the test flake — Quill is needed any time a user comments on a task, which is hot, not cold. **(3) ContainerPage + 4 DriveService entity tests — mocks missed the deferred feed-count helpers** Second pass over the test files that mock CommonUtils: - `pages/ContainerPage/ContainerPage.test.tsx` — added `fetchEntityActivityCountInto` and `fetchEntityTaskCountsInto` to the explicit mock object (page page-load now hangs on Loader without them). - `components/DriveService/{File,Worksheet,Directory,Spreadsheet}` — these spy on `getFeedCounts` to assert mount-time behavior, but the pages now call the deferred-helpers. Updated the spies to point at `fetchEntityTaskCountsInto` + `fetchEntityActivityCountInto`. Same pattern as the earlier `GlossaryTermsV1.test.tsx` fix. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ActivityFeedEditor/ActivityFeedEditor.tsx | 33 ++++++------------- .../ActivityFeedEditorNew.tsx | 28 ++++++---------- .../Directory/DirectoryDetails.test.tsx | 16 +++++++-- .../DriveService/File/FileDetails.test.tsx | 14 ++++++-- .../Spreadsheet/SpreadsheetDetails.test.tsx | 17 ++++++++-- .../Worksheet/WorksheetDetails.test.tsx | 14 ++++++-- .../ContainerPage/ContainerPage.test.tsx | 2 ++ .../TableDetailsPageV1/TableDetailsPageV1.tsx | 9 ++++- 8 files changed, 82 insertions(+), 51 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedEditor/ActivityFeedEditor.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedEditor/ActivityFeedEditor.tsx index f2e207b0f069..8f755099a050 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedEditor/ActivityFeedEditor.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedEditor/ActivityFeedEditor.tsx @@ -16,28 +16,17 @@ import { noop } from 'lodash'; import { forwardRef, HTMLAttributes, - lazy, LegacyRef, - Suspense, useImperativeHandle, useRef, useState, } from 'react'; import { getBackendFormat, HTMLToMarkdown } from '../../../utils/FeedUtils'; import { EditorContentRef } from '../../common/RichTextEditor/RichTextEditor.interface'; +import { FeedEditor } from '../FeedEditor/FeedEditor'; import { KeyHelp } from './KeyHelp'; import { SendButton } from './SendButton'; -// Lazy-load FeedEditor → pulls Quill + quill-mention + quill-emoji into a chunk that -// only loads when a feed editor actually mounts. Without this, vendor-quill (~90 KB -// brotli) sits in the modulepreload list of every page because the editor's static -// import path reaches the entry chunk. The visible-on-mount UX is unchanged: feeds -// render their text content normally; the editor surface appears with a tiny suspense -// blink the first time the user clicks "comment" / opens a thread. -const FeedEditor = lazy(() => - import('../FeedEditor/FeedEditor').then((m) => ({ default: m.FeedEditor })) -); - interface ActivityFeedEditorProp extends HTMLAttributes { placeHolder?: string; defaultValue?: string; @@ -98,17 +87,15 @@ const ActivityFeedEditor = forwardRef(
e.stopPropagation()}> - }> - } - onChangeHandler={onChangeHandler} - onSave={onSaveHandler} - /> - + } + onChangeHandler={onChangeHandler} + onSave={onSaveHandler} + /> {editAction ?? ( <> - import('../FeedEditor/FeedEditor').then((m) => ({ default: m.FeedEditor })) -); - interface ActivityFeedEditorProp extends HTMLAttributes { placeHolder?: string; defaultValue?: string; @@ -94,17 +88,15 @@ const ActivityFeedEditor = forwardRef( className={classNames('relative', className)} data-testid="activity-feed-editor-new" onClick={(e) => e.stopPropagation()}> - }> - } - onChangeHandler={onChangeHandler} - onSave={onSaveHandler} - /> - + } + onChangeHandler={onChangeHandler} + onSave={onSaveHandler} + /> {editAction ?? ( <> ({ ...jest.requireActual('../../../utils/CommonUtils'), + fetchEntityActivityCountInto: ( + ...args: [EntityType, string, (data: FeedCounts) => void] + ) => mockFetchEntityActivityCountInto(...args), + fetchEntityTaskCountsInto: ( + ...args: [string, (data: FeedCounts) => void] + ) => mockFetchEntityTaskCountsInto(...args), getEntityMissingError: jest.fn(), getFeedCounts: (...args: [EntityType, string, (data: FeedCounts) => void]) => mockGetFeedCounts(...args), @@ -318,11 +326,15 @@ describe('DirectoryDetails', () => { expect(screen.getByTestId('data-assets-header')).toBeInTheDocument(); }); - it('should call getFeedCounts on component mount', async () => { + it('should fetch feed counts on component mount', async () => { renderDirectoryDetails(); await waitFor(() => { - expect(mockGetFeedCounts).toHaveBeenCalledWith( + expect(mockFetchEntityTaskCountsInto).toHaveBeenCalledWith( + 'test-service.test-directory', + expect.any(Function) + ); + expect(mockFetchEntityActivityCountInto).toHaveBeenCalledWith( EntityType.DIRECTORY, 'test-service.test-directory', expect.any(Function) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DriveService/File/FileDetails.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DriveService/File/FileDetails.test.tsx index ed424365f41a..a763aeced9fe 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DriveService/File/FileDetails.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DriveService/File/FileDetails.test.tsx @@ -34,9 +34,15 @@ jest.mock('../../../hooks/useFqn'); jest.mock('../../../utils/useRequiredParams'); jest.mock('../../../rest/driveAPI'); const mockGetFeedCounts = jest.fn(); +const mockFetchEntityTaskCountsInto = jest.fn(); +const mockFetchEntityActivityCountInto = jest.fn(); jest.mock('../../../utils/CommonUtils', () => ({ ...jest.requireActual('../../../utils/CommonUtils'), + fetchEntityActivityCountInto: (...args: any[]) => + mockFetchEntityActivityCountInto(...args), + fetchEntityTaskCountsInto: (...args: any[]) => + mockFetchEntityTaskCountsInto(...args), getEntityMissingError: jest.fn(), getFeedCounts: (...args: any[]) => mockGetFeedCounts(...args), })); @@ -321,12 +327,16 @@ describe('FileDetails', () => { expect(screen.getByTestId('data-assets-header')).toBeInTheDocument(); }); - it('should call getFeedCounts on component mount', async () => { + it('should fetch feed counts on component mount', async () => { renderFileDetails(); await waitFor( () => { - expect(mockGetFeedCounts).toHaveBeenCalledWith( + expect(mockFetchEntityTaskCountsInto).toHaveBeenCalledWith( + 'test-service.test-file.txt', + expect.any(Function) + ); + expect(mockFetchEntityActivityCountInto).toHaveBeenCalledWith( EntityType.FILE, 'test-service.test-file.txt', expect.any(Function) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Spreadsheet/SpreadsheetDetails.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Spreadsheet/SpreadsheetDetails.test.tsx index d3fadd662be0..533ce8a4e5d5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Spreadsheet/SpreadsheetDetails.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Spreadsheet/SpreadsheetDetails.test.tsx @@ -22,7 +22,11 @@ import { useCustomPages } from '../../../hooks/useCustomPages'; import { useFqn } from '../../../hooks/useFqn'; import { ENTITY_PERMISSIONS } from '../../../mocks/Permissions.mock'; import { restoreDriveAsset } from '../../../rest/driveAPI'; -import { getFeedCounts } from '../../../utils/CommonUtils'; +import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getFeedCounts, +} from '../../../utils/CommonUtils'; import { getEntityDetailsPath } from '../../../utils/RouterUtils'; import { useRequiredParams } from '../../../utils/useRequiredParams'; import PageLayoutV1 from '../../PageLayoutV1/PageLayoutV1'; @@ -121,6 +125,9 @@ const mockUseFqn = useFqn as jest.Mock; const mockUseRequiredParams = useRequiredParams as jest.Mock; const mockRestoreDriveAsset = restoreDriveAsset as jest.Mock; const mockGetFeedCounts = getFeedCounts as jest.Mock; +const mockFetchEntityTaskCountsInto = fetchEntityTaskCountsInto as jest.Mock; +const mockFetchEntityActivityCountInto = + fetchEntityActivityCountInto as jest.Mock; const mockGetEntityDetailsPath = getEntityDetailsPath as jest.Mock; const mockSpreadsheetDetails: Spreadsheet = { @@ -305,11 +312,15 @@ describe('SpreadsheetDetails', () => { expect(screen.getByTestId('data-assets-header')).toBeInTheDocument(); }); - it('should call getFeedCounts on component mount', async () => { + it('should fetch feed counts on component mount', async () => { renderSpreadsheetDetails(); await waitFor(() => { - expect(mockGetFeedCounts).toHaveBeenCalledWith( + expect(mockFetchEntityTaskCountsInto).toHaveBeenCalledWith( + 'test-service.test-spreadsheet', + expect.any(Function) + ); + expect(mockFetchEntityActivityCountInto).toHaveBeenCalledWith( EntityType.SPREADSHEET, 'test-service.test-spreadsheet', expect.any(Function) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Worksheet/WorksheetDetails.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Worksheet/WorksheetDetails.test.tsx index 76e17d33f79f..2f74709e1fbb 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Worksheet/WorksheetDetails.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Worksheet/WorksheetDetails.test.tsx @@ -34,9 +34,15 @@ jest.mock('../../../hooks/useFqn'); jest.mock('../../../utils/useRequiredParams'); jest.mock('../../../rest/driveAPI'); const mockGetFeedCounts = jest.fn(); +const mockFetchEntityTaskCountsInto = jest.fn(); +const mockFetchEntityActivityCountInto = jest.fn(); jest.mock('../../../utils/CommonUtils', () => ({ ...jest.requireActual('../../../utils/CommonUtils'), + fetchEntityActivityCountInto: (...args: any[]) => + mockFetchEntityActivityCountInto(...args), + fetchEntityTaskCountsInto: (...args: any[]) => + mockFetchEntityTaskCountsInto(...args), getEntityMissingError: jest.fn(), getFeedCounts: (...args: any[]) => mockGetFeedCounts(...args), })); @@ -333,12 +339,16 @@ describe('WorksheetDetails', () => { expect(screen.getByTestId('data-assets-header')).toBeInTheDocument(); }); - it('should call getFeedCounts on component mount', async () => { + it('should fetch feed counts on component mount', async () => { renderWorksheetDetails(); await waitFor( () => { - expect(mockGetFeedCounts).toHaveBeenCalledWith( + expect(mockFetchEntityTaskCountsInto).toHaveBeenCalledWith( + 'test-service.test-spreadsheet.test-worksheet', + expect.any(Function) + ); + expect(mockFetchEntityActivityCountInto).toHaveBeenCalledWith( EntityType.WORKSHEET, 'test-service.test-spreadsheet.test-worksheet', expect.any(Function) diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ContainerPage/ContainerPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/ContainerPage/ContainerPage.test.tsx index c9f9604fcdd2..88a931435a7c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/ContainerPage/ContainerPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ContainerPage/ContainerPage.test.tsx @@ -181,6 +181,8 @@ jest.mock('../../rest/storageAPI'); jest.mock('../../utils/CommonUtils', () => ({ addToRecentViewed: jest.fn(), + fetchEntityActivityCountInto: jest.fn(), + fetchEntityTaskCountsInto: jest.fn(), getEntityMissingError: jest.fn().mockImplementation(() =>
Error
), getFeedCounts: jest.fn().mockReturnValue(0), sortTagsCaseInsensitive: jest.fn().mockImplementation((tags) => tags), diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx index caaf75e3fe8e..6db5deabf229 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx @@ -1000,10 +1000,17 @@ const TableDetailsPageV1: React.FC = () => { // Still loading the entity itself — useQuery is mid-flight or hasn't started (e.g. the // FQN just changed and the new cache slot is empty). Distinct from the permission gate // above so we keep the loader spinning instead of flashing the missing-entity placeholder. - if (tableLoading || !tableDetails) { + if (tableLoading) { return ; } + // Fetch completed but no entity body — typically a 404 (invalid FQN) or a network error + // that {@code tableError} surfaced. Show the missing-entity placeholder instead of + // looping on the loader (the original page used a separate gate for this). + if (!tableDetails) { + return ; + } + return ( From ab45e7656d37747fe2adec8141b9ce5528c7a9e8 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Fri, 22 May 2026 14:58:09 -0700 Subject: [PATCH 45/62] chore(ui-checkstyle): prettier reflow on DirectoryDetails.test mock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's `lint-src` job runs organize-imports → eslint --fix → prettier --write on every changed file in the PR and fails if `git status` reports any diff after. My earlier add of `fetchEntityTaskCountsInto` to the Drive entity mocks put the arrow function arg list on three lines in DirectoryDetails; prettier prefers a single-line break-after-arrow when the param annotation fits. Reflow to match. No behavior change. Same mock surface, same 31 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../DriveService/Directory/DirectoryDetails.test.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Directory/DirectoryDetails.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Directory/DirectoryDetails.test.tsx index 34adfaaf3645..e1367415ed10 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Directory/DirectoryDetails.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Directory/DirectoryDetails.test.tsx @@ -43,9 +43,8 @@ jest.mock('../../../utils/CommonUtils', () => ({ fetchEntityActivityCountInto: ( ...args: [EntityType, string, (data: FeedCounts) => void] ) => mockFetchEntityActivityCountInto(...args), - fetchEntityTaskCountsInto: ( - ...args: [string, (data: FeedCounts) => void] - ) => mockFetchEntityTaskCountsInto(...args), + fetchEntityTaskCountsInto: (...args: [string, (data: FeedCounts) => void]) => + mockFetchEntityTaskCountsInto(...args), getEntityMissingError: jest.fn(), getFeedCounts: (...args: [EntityType, string, (data: FeedCounts) => void]) => mockGetFeedCounts(...args), From 4d2532ec701c5cde2236ff413f5e253ecb66d9fd Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Fri, 22 May 2026 15:37:00 -0700 Subject: [PATCH 46/62] fix(sonar): exclude src/test/** from main-source indexing SonarCloud's scanner fails with "File src/test/unit/test-utils.tsx can't be indexed twice" because that file is matched by both `sonar.sources=src` (via the broad `src/**/*.tsx` inclusion) and `sonar.tests=src/test/unit`. The existing exclusion list only filtered `src/**/*.test.tsx` and `src/**/*.mock.*`, so the new `test-utils.tsx` helper added for the React Query migration slipped through both filters. Add `src/test/**` to `sonar.exclusions` so the main-source scan skips the entire test infrastructure folder; `sonar.tests=src/test/unit` still picks up the same files for the test-side scan. Co-Authored-By: Claude Opus 4.7 (1M context) --- openmetadata-ui/src/main/resources/ui/sonar-project.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openmetadata-ui/src/main/resources/ui/sonar-project.properties b/openmetadata-ui/src/main/resources/ui/sonar-project.properties index e83baba47f7d..885b9d4e2b20 100644 --- a/openmetadata-ui/src/main/resources/ui/sonar-project.properties +++ b/openmetadata-ui/src/main/resources/ui/sonar-project.properties @@ -7,7 +7,7 @@ sonar.language=ts # This property is optional if sonar.modules is set. sonar.sources=src sonar.tests=src/test/unit -sonar.exclusions=src/enums/**, src/generated/**, src/cypress/**, src/interface/**, src/jsons/**, src/mocks/**, src/styles/**, src/**/*.mock.*, src/*.js, src/**/*.test.ts, src/**/*.test.tsx, src/**/*.test.js, src/**/*.test.jsx +sonar.exclusions=src/enums/**, src/generated/**, src/cypress/**, src/interface/**, src/jsons/**, src/mocks/**, src/styles/**, src/test/**, src/**/*.mock.*, src/*.js, src/**/*.test.ts, src/**/*.test.tsx, src/**/*.test.js, src/**/*.test.jsx sonar.inclusions=src/**/*.ts, src/**/*.tsx, src/**/*.js, src/**/*.jsx sonar.typescript.lcov.reportPaths=src/test/unit/coverage/lcov.info sonar.testExecutionReportPaths=test-report.xml From e0a0fa7a8687b52a663751d61053a85f953b589e Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Fri, 22 May 2026 16:19:21 -0700 Subject: [PATCH 47/62] feat(ui-perf): variable Inter font + dark-mode splash restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Variable font swap (Linear-style "one woff2 per subset")** Replace `@fontsource/inter`'s six weight-specific CSS imports (400/500/600/700/800/900) with a single `@fontsource-variable/inter`-backed declaration aliased under the existing `'Inter'` family name. The new `src/styles/inter-variable.css` re-registers each Unicode subset with the variable woff2 file, which carries the full 100–900 weight axis in one file via the woff2-variations format. Net effect on production dist: Before: ~30 inter-*.woff2 files (6 weights × ~5 subsets) After: 7 inter-*-wght-normal.woff2 files (1 file × 7 subsets) Cold-paint scenario for an English-only user collapses from 6 woff2 fetches (one per weight in the Latin subset) to 1. No code change required at the 9 `font-family: 'Inter', ...` call sites because the alias keeps the family name stable. Dropping the static `@fontsource/inter` dep cleans up the node_modules tree too. **Dark-mode pre-paint restore (Linear-style splash JS)** Add a tiny inline ` + + + diff --git a/openmetadata-ui/src/main/resources/ui/package.json b/openmetadata-ui/src/main/resources/ui/package.json index 369dfad4129b..e755b5ec3719 100644 --- a/openmetadata-ui/src/main/resources/ui/package.json +++ b/openmetadata-ui/src/main/resources/ui/package.json @@ -65,7 +65,7 @@ "@deuex-solutions/react-tour": "^1.2.6", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@fontsource/inter": "^5.1.1", + "@fontsource-variable/inter": "^5.2.8", "@fontsource/poppins": "^5.0.0", "@fontsource/source-code-pro": "^5.0.0", "@github/g-emoji-element": "^1.1.5", diff --git a/openmetadata-ui/src/main/resources/ui/src/styles/index.ts b/openmetadata-ui/src/main/resources/ui/src/styles/index.ts index b343d01f2c35..fcd52bb0e2ba 100644 --- a/openmetadata-ui/src/main/resources/ui/src/styles/index.ts +++ b/openmetadata-ui/src/main/resources/ui/src/styles/index.ts @@ -17,13 +17,11 @@ import '@fontsource/poppins/500.css'; // Font 500 import '@fontsource/poppins/600.css'; // Font 600 import '@fontsource/source-code-pro'; // Font 400 -import '@fontsource/inter'; // Font 400 -import '@fontsource/inter/400.css'; // Font 400 -import '@fontsource/inter/500.css'; // Font 500 -import '@fontsource/inter/600.css'; // Font 600 -import '@fontsource/inter/700.css'; // Font 700 -import '@fontsource/inter/800.css'; // Font 800 -import '@fontsource/inter/900.css'; // Font 900 +// Variable Inter aliased under the "Inter" family name. Loads one woff2 per +// Unicode subset covering the full 100–900 weight axis, replacing the prior +// 6 weight-specific woff2 files per subset (~30 → ~7 fetches). See the file +// header in {@link ./inter-variable.css} for context. +import './inter-variable.css'; import '@react-awesome-query-builder/antd/css/styles.css'; import 'reactflow/dist/base.css'; diff --git a/openmetadata-ui/src/main/resources/ui/src/styles/inter-variable.css b/openmetadata-ui/src/main/resources/ui/src/styles/inter-variable.css new file mode 100644 index 000000000000..70278d198598 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/styles/inter-variable.css @@ -0,0 +1,108 @@ +/* + * 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 + */ + +/* + * Re-registers Inter Variable under the existing "Inter" family name so the + * codebase's many `font-family: 'Inter', ...` references keep working without + * having to be rewritten. The @fontsource-variable/inter package ships under + * the name "Inter Variable", which would otherwise force us to update every + * Less file. Each @font-face here points at the same variable woff2 files + * that the package would have loaded under "Inter Variable" — Vite resolves + * the package-relative url() through its CSS plugin. + * + * The variable font carries the full weight axis (100–900) in a single woff2 + * per Unicode subset, replacing the 6 weight-specific files per subset that + * @fontsource/inter used to load. ~30 woff2 fetches collapse to ~7 — one per + * subset. font-display:swap matches the old behavior so cold paint still + * shows the system fallback first. + */ + +/* Cyrillic Extended */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url('@fontsource-variable/inter/files/inter-cyrillic-ext-wght-normal.woff2') + format('woff2-variations'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, + U+FE2E-FE2F; +} + +/* Cyrillic */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url('@fontsource-variable/inter/files/inter-cyrillic-wght-normal.woff2') + format('woff2-variations'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} + +/* Greek Extended */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url('@fontsource-variable/inter/files/inter-greek-ext-wght-normal.woff2') + format('woff2-variations'); + unicode-range: U+1F00-1FFF; +} + +/* Greek */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url('@fontsource-variable/inter/files/inter-greek-wght-normal.woff2') + format('woff2-variations'); + unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, + U+03A3-03FF; +} + +/* Vietnamese */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url('@fontsource-variable/inter/files/inter-vietnamese-wght-normal.woff2') + format('woff2-variations'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, + U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, + U+1EA0-1EF9, U+20AB; +} + +/* Latin Extended */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url('@fontsource-variable/inter/files/inter-latin-ext-wght-normal.woff2') + format('woff2-variations'); + unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, + U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +/* Latin (most common — loaded first by the browser when matching characters + * from this range fall on the page; preload this one in index.html). */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url('@fontsource-variable/inter/files/inter-latin-wght-normal.woff2') + format('woff2-variations'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, + U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, + U+2212, U+2215, U+FEFF, U+FFFD; +} diff --git a/openmetadata-ui/src/main/resources/ui/yarn.lock b/openmetadata-ui/src/main/resources/ui/yarn.lock index f26291d3d429..f3a15e48e132 100644 --- a/openmetadata-ui/src/main/resources/ui/yarn.lock +++ b/openmetadata-ui/src/main/resources/ui/yarn.lock @@ -1780,10 +1780,10 @@ resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.11.tgz#a269e055e40e2f45873bae9d1a2fdccbd314ea3f" integrity sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg== -"@fontsource/inter@^5.1.1": +"@fontsource-variable/inter@^5.2.8": version "5.2.8" - resolved "https://registry.yarnpkg.com/@fontsource/inter/-/inter-5.2.8.tgz#10c95d877d972c7de5bd4592309d42fb6a5e1a5b" - integrity sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg== + resolved "https://registry.yarnpkg.com/@fontsource-variable/inter/-/inter-5.2.8.tgz#29b11476f5149f6a443b4df6516e26002d87941a" + integrity sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ== "@fontsource/poppins@^5.0.0": version "5.2.7" From 77f1968330c44572d3b279bc9dde878f0185ccc8 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Fri, 22 May 2026 17:00:17 -0700 Subject: [PATCH 48/62] chore(ui): full Apache 2.0 header on inter-variable.css MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `license-header-fix` flagged the file because the abbreviated header I used omitted the standard four trailing lines ("Unless required by applicable law…" through "limitations under the License."). The tool didn't recognize the truncated form and inserted the canonical block above it, leaving the file with duplicate headers and failing the `License Header Check` CI job. Replace the truncated header with the canonical one. `yarn license-header-fix` is now a no-op on the file ("Inserted license into 0 file(s)"). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/main/resources/ui/src/styles/inter-variable.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openmetadata-ui/src/main/resources/ui/src/styles/inter-variable.css b/openmetadata-ui/src/main/resources/ui/src/styles/inter-variable.css index 70278d198598..1702ee763685 100644 --- a/openmetadata-ui/src/main/resources/ui/src/styles/inter-variable.css +++ b/openmetadata-ui/src/main/resources/ui/src/styles/inter-variable.css @@ -4,6 +4,11 @@ * 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. */ /* From 7bd189287dcd3128cca3ffd36ac53c1104824661 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Fri, 22 May 2026 18:43:53 -0700 Subject: [PATCH 49/62] feat(ui-perf): idle route prefetch + lazy Tour + drop eager image preload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Idle route prefetch** (`src/utils/idlePrefetchRoutes.ts` + `App.tsx`) After first paint, schedule `requestIdleCallback` (timeout fallback for Safari) that fires three dynamic imports: ExplorePageV1, SettingsRouter, EntityRouter. Each resolves the lazy module the router would otherwise fetch on first navigation. The Service Worker + HTTP cache pick up the hashed chunks, so the click → render lag on the user's next nav drops from ~200–500 ms (network round-trip) to ~5–10 ms (cache hit + parse). The prefetch never competes with first-paint work (idle-scheduled) and silently drops on import failure (network blip / offline). **Tour lazy-load** (`src/components/AppTour/Tour.tsx`) `@deuex-solutions/react-tour` (~50 KB raw / ~14 KB brotli) only mounts when a first-time user runs the in-app tour. Was a static top-level import; now `React.lazy` + `Suspense` so the chunk never lands in the bundle of users who never see the tour. Type import for `TourSteps` moves to `import type` to keep the type-level reference without dragging the runtime in. **Drop eager image preload** (`index.html`) ` +