Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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 @@ -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 () => {
Expand Down Expand Up @@ -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 () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,12 +173,25 @@
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 (
Expand Down Expand Up @@ -510,7 +523,7 @@

return false;
}),
page.waitForTimeout(10000),

Check warning on line 526 in openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts

View workflow job for this annotation

GitHub Actions / lint-playwright

Unexpected use of page.waitForTimeout()
]);

await redirectToHomePage(page);
Expand Down
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,110 @@
/*
* 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 (
<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,86 @@
/*
* 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 — 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
* 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.
*
* 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
* 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);
// 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(() => {
if (activeTab !== tabKey || fetchedRef.current) {
return;
}
fetchedRef.current = true;
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. `fetcherRef` keeps the latest closure available.
}, [activeTab, tabKey]);
Comment thread
gitar-bot[bot] marked this conversation as resolved.
Comment on lines +83 to +85
Comment on lines +44 to +85
}
Loading
Loading