diff --git a/lib/static/modules/utils/imageEntity.ts b/lib/static/modules/utils/imageEntity.ts new file mode 100644 index 00000000..9353047d --- /dev/null +++ b/lib/static/modules/utils/imageEntity.ts @@ -0,0 +1,54 @@ +import {ImageEntity, ImageEntityCommitted, ImageEntityError, ImageEntityFail, ImageEntityStaged, ImageEntitySuccess, ImageEntityUpdated} from '../../new-ui/types/store'; + +function preloadImage(url: string): HTMLElement { + const link = document.createElement('link'); + + link.rel = 'preload'; + link.as = 'image'; + link.href = url; + link.onload; + + document.head.appendChild(link); + + return link; +} + +function hasExpectedImage(image: ImageEntity): image is ImageEntityFail | ImageEntitySuccess | ImageEntityUpdated { + return Object.hasOwn(image, 'expectedImg'); +} + +function hasActualImage(image: ImageEntity): image is ImageEntityFail | ImageEntityCommitted | ImageEntityError | ImageEntityStaged { + return Object.hasOwn(image, 'actualImg'); +} + +function hasDiffImage(image: ImageEntity): image is ImageEntityFail { + return Object.hasOwn(image, 'diffImg'); +} + +function hasRefImage(image: ImageEntity): image is ImageEntityFail { + return Object.hasOwn(image, 'refImg'); +} + +export function preloadImageEntity(image: ImageEntity): () => void { + const elements: HTMLElement[] = []; + + if (hasExpectedImage(image)) { + elements.push(preloadImage(image.expectedImg.path)); + } + + if (hasActualImage(image)) { + elements.push(preloadImage(image.actualImg.path)); + } + + if (hasDiffImage(image)) { + elements.push(preloadImage(image.diffImg.path)); + } + + if (hasRefImage(image)) { + elements.push(preloadImage(image.refImg.path)); + } + + return (): void => { + elements.forEach(element => element.remove()); + }; +} diff --git a/lib/static/modules/utils/index.js b/lib/static/modules/utils/index.js index b68b3997..ebd5b358 100644 --- a/lib/static/modules/utils/index.js +++ b/lib/static/modules/utils/index.js @@ -160,6 +160,10 @@ export function parseKeyToGroupTestsBy(key) { return [groupSection, groupKey]; } +/** @deprecated - this is just not working. + * @link https://stackoverflow.com/questions/3646036/preloading-images-with-javascript + * @see preloadImage from lib/static/modules/utils/imageEntity.ts instead + */ export function preloadImage(url) { new Image().src = url; } diff --git a/lib/static/new-ui/components/SuiteTitle/index.tsx b/lib/static/new-ui/components/SuiteTitle/index.tsx index e65d6f61..139b5a40 100644 --- a/lib/static/new-ui/components/SuiteTitle/index.tsx +++ b/lib/static/new-ui/components/SuiteTitle/index.tsx @@ -49,9 +49,9 @@ export function SuiteTitle(props: SuiteTitlePropsInternal): ReactNode {
{props.index === -1 ? '–' : props.index + 1}/{props.totalItems} - -
; diff --git a/lib/static/new-ui/features/visual-checks/components/VisualChecksPage/index.tsx b/lib/static/new-ui/features/visual-checks/components/VisualChecksPage/index.tsx index 3cbf830f..f2aea92b 100644 --- a/lib/static/new-ui/features/visual-checks/components/VisualChecksPage/index.tsx +++ b/lib/static/new-ui/features/visual-checks/components/VisualChecksPage/index.tsx @@ -1,7 +1,7 @@ import {ArrowUturnCcwLeft, Check} from '@gravity-ui/icons'; import {Button, Divider, Icon, Select} from '@gravity-ui/uikit'; import classNames from 'classnames'; -import React, {ReactNode} from 'react'; +import React, {ReactNode, useEffect, useRef} from 'react'; import {useDispatch, useSelector} from 'react-redux'; import {SplitViewLayout} from '@/static/new-ui/components/SplitViewLayout'; @@ -9,6 +9,7 @@ import {UiCard} from '@/static/new-ui/components/Card/UiCard'; import { getCurrentImage, getCurrentNamedImage, + getImagesByNamedImageIds, getVisibleNamedImageIds } from '@/static/new-ui/features/visual-checks/selectors'; import {SuiteTitle} from '@/static/new-ui/components/SuiteTitle'; @@ -28,6 +29,36 @@ import { } from '@/static/new-ui/features/visual-checks/components/VisualChecksPage/AssertViewResultSkeleton'; import {thunkAcceptImages, thunkRevertImages} from '@/static/modules/actions/screenshots'; import {useAnalytics} from '@/static/new-ui/hooks/useAnalytics'; +import {preloadImageEntity} from '../../../../../modules/utils/imageEntity'; + +export const PRELOAD_IMAGES_COUNT = 3; + +const usePreloadImages = ( + currentNamedImageIndex: number, + visibleNamedImageIds: string[]): void => { + const preloaded = useRef void | undefined>>({}); + + const namedImageIdsToPreload: string[] = visibleNamedImageIds.slice( + Math.max(0, currentNamedImageIndex - 1 - PRELOAD_IMAGES_COUNT), + Math.min(visibleNamedImageIds.length, currentNamedImageIndex + 1 + PRELOAD_IMAGES_COUNT) + ); + + const imagesToPreload = useSelector((state) => getImagesByNamedImageIds(state, namedImageIdsToPreload)); + + useEffect(() => { + imagesToPreload.forEach(image => { + if (preloaded.current[image.id]) { + return; + } + + preloaded.current[image.id] = preloadImageEntity(image); + }); + }, [currentNamedImageIndex]); + + useEffect(() => () => { + Object.values(preloaded.current).forEach(preload => preload?.()); + }, []); +}; export function VisualChecksPage(): ReactNode { const dispatch = useDispatch(); @@ -41,6 +72,8 @@ export function VisualChecksPage(): ReactNode { const onPreviousImageHandler = (): void => void dispatch(visualChecksPageSetCurrentNamedImage(visibleNamedImageIds[currentNamedImageIndex - 1])); const onNextImageHandler = (): void => void dispatch(visualChecksPageSetCurrentNamedImage(visibleNamedImageIds[currentNamedImageIndex + 1])); + usePreloadImages(currentNamedImageIndex, visibleNamedImageIds); + const diffMode = useSelector(state => state.view.diffMode); const onChangeHandler = (diffModeId: DiffModeId): void => { dispatch(setDiffMode({diffModeId})); diff --git a/lib/static/new-ui/features/visual-checks/selectors.ts b/lib/static/new-ui/features/visual-checks/selectors.ts index 24fa0742..75a78f1a 100644 --- a/lib/static/new-ui/features/visual-checks/selectors.ts +++ b/lib/static/new-ui/features/visual-checks/selectors.ts @@ -111,6 +111,25 @@ export const getCurrentImage = (state: State): ImageEntity | null => { return getImages(state)[currentImageId]; }; +export const getImagesByNamedImageIds = (state: State, names: string[]): ImageEntity[] => { + const results: ImageEntity[] = []; + + const images = getImages(state); + const namedImages = getNamedImages(state); + + for (const name of names) { + const namedImage = namedImages[name]; + + if (!namedImage) { + continue; + } + + results.push(...namedImage.imageIds.map(id => images[id])); + } + + return results; +}; + export const getVisibleNamedImageIds = createSelector([getNamedImages], (namedImages): string[] => { return Object.values(namedImages).map(namedImage => namedImage.id); }); diff --git a/test/unit/lib/static/new-ui/features/visual-checks/components/VisualChecksPage.jsx b/test/unit/lib/static/new-ui/features/visual-checks/components/VisualChecksPage.jsx new file mode 100644 index 00000000..24ba3686 --- /dev/null +++ b/test/unit/lib/static/new-ui/features/visual-checks/components/VisualChecksPage.jsx @@ -0,0 +1,68 @@ +import React from 'react'; +import {addBrowserToTree, addImageToTree, addResultToTree, addSuiteToTree, mkBrowserEntity, mkEmptyTree, mkImageEntityFail, mkRealStore, mkResultEntity, mkSuiteEntityLeaf, renderWithStore} from '../../../../utils'; +import proxyquire from 'proxyquire'; + +describe('', () => { + const sandbox = sinon.sandbox.create(); + + const prepareTestStore = () => { + const tree = mkEmptyTree(); + + const suite = mkSuiteEntityLeaf(`test-1`); + addSuiteToTree({tree, suite}); + + const browser = mkBrowserEntity(`bro-1`, {parentId: suite.id}); + addBrowserToTree({tree, browser}); + + const result = mkResultEntity(`res-1`, {parentId: browser.id}); + addResultToTree({tree, result}); + + for (const i of Array.from({length: 10}).map((_, i) => i + 1)) { + const image = mkImageEntityFail(`img-${i}`, {parentId: result.id}); + addImageToTree({tree, image}); + } + + const store = mkRealStore({ + initialState: { + app: { + isInitialized: true + }, + tree + } + }); + + return store; + }; + + let store; + let component; + let preloadImageEntityStub; + + beforeEach(() => { + preloadImageEntityStub = sandbox.stub(); + + store = prepareTestStore(); + + const VisualChecksPage = proxyquire('lib/static/new-ui/features/visual-checks/components/VisualChecksPage', { + '../../../../../modules/utils/imageEntity': {preloadImageEntity: preloadImageEntityStub} + }).VisualChecksPage; + + component = renderWithStore(, store); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should preload current and 3 adjacent images on mount', async () => { + const state = store.getState(); + const orderedImages = Object.values(state.tree.images.byId); + + for (let i = 0; i < 3; i++) { + assert.calledWith( + preloadImageEntityStub, + orderedImages[i] + ); + } + }); +});