Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(new-ui): add image prefetch in new ui #628

Merged
29 changes: 26 additions & 3 deletions lib/static/components/modals/screenshot-accepter/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ import ScreenshotAccepterMeta from './meta';
import ScreenshotAccepterBody from './body';
import {getAcceptableImagesByStateName} from '../../../modules/selectors/tree';
import {staticImageAccepterPropType} from '../../../modules/static-image-accepter';
import {preloadImage} from '../../../modules/utils';

import './style.css';
import {AnalyticsContext} from '@/static/new-ui/providers/analytics';
import {preloadImage} from '../../../modules/utils/imageEntity';

const PRELOAD_IMAGE_COUNT = 3;

Expand Down Expand Up @@ -52,7 +52,8 @@ class ScreenshotAccepter extends Component {
stateNameImageIds,
activeImageIndex,
retryIndex,
showMeta: false
showMeta: false,
disposables: {}
};
this.topRef = React.createRef();

Expand All @@ -65,6 +66,11 @@ class ScreenshotAccepter extends Component {
this.analytics = this.context;
}

componentWillUnmount() {
Object.values(this.state.disposables)
.forEach(disposeCallback => disposeCallback());
}

componentDidUpdate() {
this.topRef.current.parentNode.scrollTo(0, 0);
}
Expand Down Expand Up @@ -223,7 +229,24 @@ class ScreenshotAccepter extends Component {
const stateNameImageId = stateNameImageIds[preloadingImagesIndex];
const {expectedImg, actualImg, diffImg} = last(this.props.imagesByStateName[stateNameImageId]);

[expectedImg, actualImg, diffImg].filter(Boolean).forEach(({path}) => preloadImage(path));
const disposables = {};

[expectedImg, actualImg, diffImg].filter(Boolean).forEach(({path}) => {
if (disposables[path]) {
return;
}

const element = preloadImage(path);

disposables[path] = () => element.remove();
});

this.setState({
disposables: {
...this.state.disposables,
...disposables
}
});
});
}

Expand Down
55 changes: 55 additions & 0 deletions lib/static/modules/utils/imageEntity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import {ImageEntity, ImageEntityCommitted, ImageEntityError, ImageEntityFail, ImageEntityStaged, ImageEntitySuccess, ImageEntityUpdated} from '../../new-ui/types/store';

// TODO: remove export when old ui is removed
export 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());
};
}
4 changes: 0 additions & 4 deletions lib/static/modules/utils/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -160,10 +160,6 @@ export function parseKeyToGroupTestsBy(key) {
return [groupSection, groupKey];
}

export function preloadImage(url) {
new Image().src = url;
}

export function getBlob(url) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
Expand Down
4 changes: 2 additions & 2 deletions lib/static/new-ui/components/SuiteTitle/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,9 @@ export function SuiteTitle(props: SuiteTitlePropsInternal): ReactNode {
</div>
<div className={styles.paginationContainer}>
<span className={styles.counter}>{props.index === -1 ? '–' : props.index + 1}/{props.totalItems}</span>
<Button view={'flat'} disabled={props.index <= 0} onClick={props.onPrevious}><Icon
<Button qa='suite-prev' view={'flat'} disabled={props.index <= 0} onClick={props.onPrevious}><Icon
data={ChevronUp}/></Button>
<Button view={'flat'} disabled={props.index < 0 || props.index === props.totalItems - 1} onClick={props.onNext}><Icon
<Button qa='suite-next' view={'flat'} disabled={props.index < 0 || props.index === props.totalItems - 1} onClick={props.onNext}><Icon
data={ChevronDown}/></Button>
</div>
</div>;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
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';
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';
Expand All @@ -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<Record<string, () => 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(disposeCallback => disposeCallback?.());
}, []);
};

export function VisualChecksPage(): ReactNode {
const dispatch = useDispatch();
Expand All @@ -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}));
Expand Down
19 changes: 19 additions & 0 deletions lib/static/new-ui/features/visual-checks/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import React from 'react';
import {addBrowserToTree, addImageToTree, addResultToTree, addSuiteToTree, mkBrowserEntity, mkEmptyTree, mkImageEntityFail, mkRealStore, mkResultEntity, mkSuiteEntityLeaf, renderWithStore} from '../../../../utils';
import proxyquire from 'proxyquire';

describe('<VisualChecksPage />', () => {
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 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;

renderWithStore(<VisualChecksPage />, 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]
);
}
});
mordvinx marked this conversation as resolved.
Show resolved Hide resolved
});
Loading