ui: Fix flaky product tour startup on slow /tour render (#27820)
Some checks are pending
Java Checkstyle / java-checkstyle (push) Waiting to run
Maven Collate Tests / maven-collate-ci (push) Waiting to run
OpenMetadata Service Unit Tests / Detect Changes (push) Waiting to run
OpenMetadata Service Unit Tests / openmetadata-service-unit-tests (mysql) (push) Blocked by required conditions
OpenMetadata Service Unit Tests / openmetadata-service-unit-tests (postgresql) (push) Blocked by required conditions
OpenMetadata Service Unit Tests / k8s_operator-unit-tests (push) Blocked by required conditions
OpenMetadata Service Unit Tests / openmetadata-service-unit-tests-status (push) Blocked by required conditions

* ui: Fix flaky product tour startup on slow /tour render

* simplify code
This commit is contained in:
Harsh Vador 2026-05-03 15:10:12 +05:30 committed by GitHub
parent 5620121e50
commit a93bfcf3f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 245 additions and 47 deletions

View file

@ -189,15 +189,29 @@ test.describe(
});
test('Tour should work from welcome screen', async ({ page }) => {
await page
test.slow();
const isAlertVisible = await page
.getByTestId('whats-new-alert-card')
.locator('.whats-new-alert-close')
.click();
.isVisible();
if (isAlertVisible) {
await page
.getByTestId('whats-new-alert-card')
.locator('.whats-new-alert-close')
.click();
}
await page.getByText('Take a product tour to get started!').click();
await page.waitForURL('**/tour');
await waitForAllLoadersToDisappear(page);
await waitForAllLoadersToDisappear(page, 'entity-list-skeleton');
const isWelcomeScreenVisible = await page
.getByTestId('welcome-screen')
.isVisible();
if (isWelcomeScreenVisible) {
await page.getByTestId('welcome-screen-close-btn').click();
}
await page.locator('#feedWidgetData').waitFor();
// Since the tour steps are already tested in the first test,
// here we only validate whether the tour is loading or not.

View file

@ -14,7 +14,9 @@ import {
createContext,
FC,
ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
@ -43,7 +45,11 @@ export const TourContext = createContext({} as TourProviderContextProps);
const TourProvider: FC<Props> = ({ children }) => {
const location = useCustomLocation();
const [isTourOpen, setIsTourOpen] = useState<boolean>(false);
const isTourPage = useMemo(
() => location.pathname.includes(ROUTES.TOUR),
[location.pathname]
);
const [isTourOpen, setIsTourOpen] = useState<boolean>(isTourPage);
const [currentTourPage, setCurrentTourPage] = useState<CurrentTourPageType>(
CurrentTourPageType.MY_DATA_PAGE
);
@ -51,22 +57,30 @@ const TourProvider: FC<Props> = ({ children }) => {
useState<EntityTabs>(EntityTabs.SCHEMA);
const [searchValue, setSearchValue] = useState('');
const isTourPage = useMemo(
() => location.pathname.includes(ROUTES.TOUR),
[location.pathname]
useEffect(() => {
if (isTourPage) {
setIsTourOpen(true);
}
}, [isTourPage]);
const handleIsTourOpen = useCallback((value: boolean) => {
setIsTourOpen(value);
}, []);
const handleTourPageChange = useCallback(
(value: CurrentTourPageType) => setCurrentTourPage(value),
[]
);
const handleIsTourOpen = (value: boolean) => {
setIsTourOpen(value);
};
const handleActiveTabChange = useCallback(
(value: EntityTabs) => setActiveTabForTourDatasetPage(value),
[]
);
const handleTourPageChange = (value: CurrentTourPageType) =>
setCurrentTourPage(value);
const handleActiveTabChange = (value: EntityTabs) =>
setActiveTabForTourDatasetPage(value);
const handleUpdateTourSearch = (value: string) => setSearchValue(value);
const handleUpdateTourSearch = useCallback(
(value: string) => setSearchValue(value),
[]
);
return (
<TourContext.Provider

View file

@ -11,7 +11,7 @@
* limitations under the License.
*/
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Tour from '../../components/AppTour/Tour';
import { TOUR_SEARCH_TERM } from '../../constants/constants';
@ -22,6 +22,76 @@ import ExplorePageV1Component from '../ExplorePage/ExplorePageV1.component';
import MyDataPage from '../MyDataPage/MyDataPage.component';
import TableDetailsPageV1 from '../TableDetailsPageV1/TableDetailsPageV1';
const TOUR_FEED_WIDGET_SELECTOR = '#feedWidgetData';
const REQUIRED_STABLE_LAYOUT_FRAMES = 3;
type TourTargetRect = {
height: number;
left: number;
top: number;
width: number;
};
const getTourFeedWidgetRect = (): TourTargetRect | undefined => {
const feedWidget = document.querySelector(TOUR_FEED_WIDGET_SELECTOR);
const feedWidgetRect = feedWidget?.getBoundingClientRect();
if (!feedWidgetRect?.width || !feedWidgetRect.height) {
return;
}
return {
height: feedWidgetRect.height,
left: feedWidgetRect.left,
top: feedWidgetRect.top,
width: feedWidgetRect.width,
};
};
const isSameWidgetRect = (
currentRect?: TourTargetRect,
previousRect?: TourTargetRect
) => {
return (
currentRect?.height === previousRect?.height &&
currentRect?.left === previousRect?.left &&
currentRect?.top === previousRect?.top &&
currentRect?.width === previousRect?.width
);
};
const waitForTourFeedWidget = (onReady: () => void) => {
let animationFrameId = 0;
let stableLayoutFrames = 0;
let previousRect: TourTargetRect | undefined;
const waitForFeedWidget = () => {
const currentRect = getTourFeedWidgetRect();
if (currentRect && isSameWidgetRect(currentRect, previousRect)) {
stableLayoutFrames += 1;
} else {
stableLayoutFrames = 0;
}
previousRect = currentRect;
if (stableLayoutFrames >= REQUIRED_STABLE_LAYOUT_FRAMES) {
onReady();
return;
}
animationFrameId = window.requestAnimationFrame(waitForFeedWidget);
};
waitForFeedWidget();
return () => {
window.cancelAnimationFrame(animationFrameId);
};
};
const TourPage = () => {
const {
updateIsTourOpen,
@ -33,28 +103,24 @@ const TourPage = () => {
const { t } = useTranslation();
const [isTourReady, setIsTourReady] = useState(false);
const clearSearchTerm = () => {
const clearSearchTerm = useCallback(() => {
updateTourSearch('');
};
}, [updateTourSearch]);
useEffect(() => {
updateIsTourOpen(true);
let attempts = 0;
const maxAttempts = 60;
const waitForElement = () => {
const el = document.querySelector('#feedWidgetData');
if (el) {
let tourMountFrameId = 0;
const cancelFeedWidgetWait = waitForTourFeedWidget(() => {
updateIsTourOpen(true);
tourMountFrameId = window.requestAnimationFrame(() => {
setIsTourReady(true);
} else if (attempts < maxAttempts) {
attempts++;
setTimeout(waitForElement, 100);
}
};
});
});
waitForElement();
}, []);
return () => {
cancelFeedWidgetWait();
window.cancelAnimationFrame(tourMountFrameId);
};
}, [updateIsTourOpen]);
const currentPageComponent = useMemo(() => {
switch (currentTourPage) {
@ -72,19 +138,21 @@ const TourPage = () => {
}
}, [currentTourPage]);
const tourSteps = useMemo(
() =>
getTourSteps({
searchTerm: TOUR_SEARCH_TERM,
clearSearchTerm,
updateActiveTab,
updateTourPage,
}),
[clearSearchTerm, updateActiveTab, updateTourPage]
);
return (
<>
{currentPageComponent}
{isTourReady && (
<Tour
steps={getTourSteps({
searchTerm: TOUR_SEARCH_TERM,
clearSearchTerm,
updateActiveTab,
updateTourPage,
})}
/>
)}
{isTourReady && <Tour steps={tourSteps} />}
</>
);
};

View file

@ -10,12 +10,13 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { fireEvent, render, screen } from '@testing-library/react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { useTourProvider } from '../../context/TourProvider/TourProvider';
import { CurrentTourPageType } from '../../enums/tour.enum';
import TourPage from './TourPage.component';
const mockUseTourProvider = {
isTourOpen: true,
updateIsTourOpen: jest.fn(),
currentTourPage: CurrentTourPageType.MY_DATA_PAGE,
updateActiveTab: jest.fn(),
@ -29,6 +30,29 @@ Object.defineProperty(document, 'querySelector', {
writable: true,
});
const createReadyFeedWidget = () => {
const feedWidget = document.createElement('div');
jest.spyOn(feedWidget, 'getBoundingClientRect').mockReturnValue({
bottom: 100,
height: 100,
left: 0,
right: 100,
top: 0,
width: 100,
x: 0,
y: 0,
toJSON: jest.fn(),
});
return feedWidget;
};
const waitForTourReadyCheck = async (time = 80) => {
await act(async () => {
jest.advanceTimersByTime(time);
});
};
jest.mock('../../context/TourProvider/TourProvider', () => ({
useTourProvider: jest.fn().mockImplementation(() => mockUseTourProvider),
}));
@ -62,9 +86,11 @@ jest.mock('../../utils/TourUtils', () => ({
describe('TourPage component', () => {
beforeEach(() => {
jest.useFakeTimers();
const feedWidget = createReadyFeedWidget();
mockQuerySelector.mockImplementation((selector) => {
if (selector === '#feedWidgetData') {
return document.createElement('div');
return feedWidget;
}
return null;
@ -78,12 +104,14 @@ describe('TourPage component', () => {
it('should render correctly', async () => {
render(<TourPage />);
await waitForTourReadyCheck();
expect(await screen.findByText('Tour.component')).toBeInTheDocument();
});
it('clear search term should work correctly', async () => {
render(<TourPage />);
await waitForTourReadyCheck();
const clearBtn = await screen.findByTestId('clear-btn');
fireEvent.click(clearBtn);
@ -91,6 +119,77 @@ describe('TourPage component', () => {
expect(mockUseTourProvider.updateTourSearch).toHaveBeenCalledWith('');
});
it('should render tour when feed widget appears after initial render', async () => {
const feedWidget = createReadyFeedWidget();
mockQuerySelector.mockReturnValue(null);
render(<TourPage />);
expect(screen.queryByText('Tour.component')).not.toBeInTheDocument();
mockQuerySelector.mockImplementation((selector) => {
if (selector === '#feedWidgetData') {
return feedWidget;
}
return null;
});
await act(async () => {
document.body.appendChild(feedWidget);
});
await waitForTourReadyCheck(116);
expect(screen.getByText('Tour.component')).toBeInTheDocument();
feedWidget.remove();
});
it('should render tour when existing feed widget becomes layout ready', async () => {
const feedWidget = document.createElement('div');
const getBoundingClientRect = jest
.spyOn(feedWidget, 'getBoundingClientRect')
.mockReturnValueOnce({
bottom: 0,
height: 0,
left: 0,
right: 0,
top: 0,
width: 0,
x: 0,
y: 0,
toJSON: jest.fn(),
})
.mockReturnValue({
bottom: 100,
height: 100,
left: 0,
right: 100,
top: 0,
width: 100,
x: 0,
y: 0,
toJSON: jest.fn(),
});
mockQuerySelector.mockImplementation((selector) => {
if (selector === '#feedWidgetData') {
return feedWidget;
}
return null;
});
render(<TourPage />);
expect(screen.queryByText('Tour.component')).not.toBeInTheDocument();
await waitForTourReadyCheck();
await waitForTourReadyCheck();
expect(screen.getByText('Tour.component')).toBeInTheDocument();
expect(getBoundingClientRect).toHaveBeenCalled();
});
it('MyDataPage Component should be visible, if currentTourPage is myDataPage', async () => {
(useTourProvider as jest.Mock).mockReset();
(useTourProvider as jest.Mock).mockImplementation(() => ({
@ -98,6 +197,7 @@ describe('TourPage component', () => {
currentTourPage: CurrentTourPageType.MY_DATA_PAGE,
}));
render(<TourPage />);
await waitForTourReadyCheck();
expect(await screen.findByText('MyDataPage.component')).toBeInTheDocument();
});
@ -109,6 +209,7 @@ describe('TourPage component', () => {
currentTourPage: CurrentTourPageType.EXPLORE_PAGE,
}));
render(<TourPage />);
await waitForTourReadyCheck();
expect(
await screen.findByText('ExplorePageV1Component.component')
@ -122,6 +223,7 @@ describe('TourPage component', () => {
currentTourPage: CurrentTourPageType.DATASET_PAGE,
}));
render(<TourPage />);
await waitForTourReadyCheck();
expect(
await screen.findByText('TableDetailsPageV1.component')