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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,30 +16,81 @@
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;
import org.openmetadata.service.monitoring.RequestLatencyContext;
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.
*
* <p>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.
*
* <p>{@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);

Comment on lines +35 to +60
Comment on lines +42 to +60
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
Comment thread
gitar-bot[bot] marked this conversation as resolved.
Outdated

return (
<div className={className} ref={ref}>
{hasBeenVisible ? children : placeholder ?? null}
</div>
);
};

export default DeferredWidget;
Original file line number Diff line number Diff line change
@@ -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<void>,
resetDeps: ReadonlyArray<unknown> = []
): 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]);
Comment thread
gitar-bot[bot] marked this conversation as resolved.
Comment on lines +83 to +85
Comment on lines +44 to +85
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
<div data-grid={widget} key={widget.i}>
{getWidgetFromKey({
widgetConfig: widget,
currentLayout: layout,
})}
<DeferredWidget>
{getWidgetFromKey({
widgetConfig: widget,
currentLayout: layout,
})}
</DeferredWidget>
Comment thread
gitar-bot[bot] marked this conversation as resolved.
Outdated
</div>
)),
[layout, isAnnouncementLoading, announcements]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
]);
Comment on lines +789 to +806

useSub(
'updateDetails',
(suggestion: Suggestion) => {
Expand Down
Loading
Loading