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]
+ );
+ }
+ });
+});