Skip to content

Commit d057ffc

Browse files
authored
feat(new-ui): add image prefetch in new ui (#628)
* feat(new-ui): add image prefetch in new ui * fix(preload): fixed preloading function implementation * feat(new-ui): add ref image prefetch in new ui * feat(components): add data-qa tags for test purproses * test(components): VisualChecsPage basic preload test * refactor(style): rename variable * feat(preload): replace image preloading function in old ui * refactor(tests): remove unused variable * refactor(prefetch): global link cache added * refactor(preload): components use global cache * fix(tests): fix stubs --------- Co-authored-by: = <=>
1 parent 8a14d30 commit d057ffc

File tree

8 files changed

+219
-11
lines changed

8 files changed

+219
-11
lines changed

lib/static/components/modals/screenshot-accepter/index.jsx

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ import ScreenshotAccepterMeta from './meta';
1010
import ScreenshotAccepterBody from './body';
1111
import {getAcceptableImagesByStateName} from '../../../modules/selectors/tree';
1212
import {staticImageAccepterPropType} from '../../../modules/static-image-accepter';
13-
import {preloadImage} from '../../../modules/utils';
1413

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

1818
const PRELOAD_IMAGE_COUNT = 3;
1919

@@ -52,7 +52,8 @@ class ScreenshotAccepter extends Component {
5252
stateNameImageIds,
5353
activeImageIndex,
5454
retryIndex,
55-
showMeta: false
55+
showMeta: false,
56+
disposables: {}
5657
};
5758
this.topRef = React.createRef();
5859

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

69+
componentWillUnmount() {
70+
Object.values(this.state.disposables)
71+
.forEach(disposeCallback => disposeCallback());
72+
}
73+
6874
componentDidUpdate() {
6975
this.topRef.current.parentNode.scrollTo(0, 0);
7076
}
@@ -223,7 +229,20 @@ class ScreenshotAccepter extends Component {
223229
const stateNameImageId = stateNameImageIds[preloadingImagesIndex];
224230
const {expectedImg, actualImg, diffImg} = last(this.props.imagesByStateName[stateNameImageId]);
225231

226-
[expectedImg, actualImg, diffImg].filter(Boolean).forEach(({path}) => preloadImage(path));
232+
const disposables = {};
233+
234+
[expectedImg, actualImg, diffImg].filter(Boolean).forEach(({path}) => {
235+
const disposeCallback = preloadImage(path);
236+
237+
disposables[path] = disposeCallback;
238+
});
239+
240+
this.setState({
241+
disposables: {
242+
...this.state.disposables,
243+
...disposables
244+
}
245+
});
227246
});
228247
}
229248

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import {ImageEntity, ImageEntityCommitted, ImageEntityError, ImageEntityFail, ImageEntityStaged, ImageEntitySuccess, ImageEntityUpdated} from '../../new-ui/types/store';
2+
3+
class LinkCache {
4+
private static cache: Record<string, HTMLLinkElement | undefined> = {};
5+
6+
private static createPreloadLink(url: string): HTMLLinkElement {
7+
const link = document.createElement('link');
8+
9+
link.rel = 'preload';
10+
link.as = 'image';
11+
link.href = url;
12+
13+
document.head.appendChild(link);
14+
15+
return link;
16+
}
17+
18+
static register(url: string): void {
19+
if (LinkCache.cache[url]) {
20+
return;
21+
}
22+
23+
LinkCache.cache[url] = LinkCache.createPreloadLink(url);
24+
}
25+
26+
static dispose(url: string): void {
27+
LinkCache.cache[url]?.remove();
28+
29+
delete LinkCache.cache[url];
30+
}
31+
}
32+
33+
// TODO: remove export when old ui is removed
34+
export function preloadImage(url: string): () => void {
35+
LinkCache.register(url);
36+
37+
return () => LinkCache.dispose(url);
38+
}
39+
40+
function hasExpectedImage(image: ImageEntity): image is ImageEntityFail | ImageEntitySuccess | ImageEntityUpdated {
41+
return Object.hasOwn(image, 'expectedImg');
42+
}
43+
44+
function hasActualImage(image: ImageEntity): image is ImageEntityFail | ImageEntityCommitted | ImageEntityError | ImageEntityStaged {
45+
return Object.hasOwn(image, 'actualImg');
46+
}
47+
48+
function hasDiffImage(image: ImageEntity): image is ImageEntityFail {
49+
return Object.hasOwn(image, 'diffImg');
50+
}
51+
52+
function hasRefImage(image: ImageEntity): image is ImageEntityFail {
53+
return Object.hasOwn(image, 'refImg');
54+
}
55+
56+
export function preloadImageEntity(image: ImageEntity): () => void {
57+
const disposeCallbacks: (() => void)[] = [];
58+
59+
if (hasExpectedImage(image)) {
60+
disposeCallbacks.push(preloadImage(image.expectedImg.path));
61+
}
62+
63+
if (hasActualImage(image)) {
64+
disposeCallbacks.push(preloadImage(image.actualImg.path));
65+
}
66+
67+
if (hasDiffImage(image)) {
68+
disposeCallbacks.push(preloadImage(image.diffImg.path));
69+
}
70+
71+
if (hasRefImage(image)) {
72+
disposeCallbacks.push(preloadImage(image.refImg.path));
73+
}
74+
75+
return () => disposeCallbacks.forEach(dispose => dispose());
76+
}

lib/static/modules/utils/index.js

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -160,10 +160,6 @@ export function parseKeyToGroupTestsBy(key) {
160160
return [groupSection, groupKey];
161161
}
162162

163-
export function preloadImage(url) {
164-
new Image().src = url;
165-
}
166-
167163
export function getBlob(url) {
168164
return new Promise((resolve, reject) => {
169165
const xhr = new XMLHttpRequest();

lib/static/new-ui/components/SuiteTitle/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,9 @@ export function SuiteTitle(props: SuiteTitlePropsInternal): ReactNode {
4949
</div>
5050
<div className={styles.paginationContainer}>
5151
<span className={styles.counter}>{props.index === -1 ? '–' : props.index + 1}/{props.totalItems}</span>
52-
<Button view={'flat'} disabled={props.index <= 0} onClick={props.onPrevious}><Icon
52+
<Button qa='suite-prev' view={'flat'} disabled={props.index <= 0} onClick={props.onPrevious}><Icon
5353
data={ChevronUp}/></Button>
54-
<Button view={'flat'} disabled={props.index < 0 || props.index === props.totalItems - 1} onClick={props.onNext}><Icon
54+
<Button qa='suite-next' view={'flat'} disabled={props.index < 0 || props.index === props.totalItems - 1} onClick={props.onNext}><Icon
5555
data={ChevronDown}/></Button>
5656
</div>
5757
</div>;

lib/static/new-ui/features/visual-checks/components/VisualChecksPage/index.tsx

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import {ArrowUturnCcwLeft, Check} from '@gravity-ui/icons';
22
import {Button, Divider, Icon, Select} from '@gravity-ui/uikit';
33
import classNames from 'classnames';
4-
import React, {ReactNode} from 'react';
4+
import React, {ReactNode, useEffect, useRef} from 'react';
55
import {useDispatch, useSelector} from 'react-redux';
66

77
import {SplitViewLayout} from '@/static/new-ui/components/SplitViewLayout';
88
import {UiCard} from '@/static/new-ui/components/Card/UiCard';
99
import {
1010
getCurrentImage,
1111
getCurrentNamedImage,
12+
getImagesByNamedImageIds,
1213
getVisibleNamedImageIds
1314
} from '@/static/new-ui/features/visual-checks/selectors';
1415
import {SuiteTitle} from '@/static/new-ui/components/SuiteTitle';
@@ -28,6 +29,32 @@ import {
2829
} from '@/static/new-ui/features/visual-checks/components/VisualChecksPage/AssertViewResultSkeleton';
2930
import {thunkAcceptImages, thunkRevertImages} from '@/static/modules/actions/screenshots';
3031
import {useAnalytics} from '@/static/new-ui/hooks/useAnalytics';
32+
import {preloadImageEntity} from '../../../../../modules/utils/imageEntity';
33+
34+
export const PRELOAD_IMAGES_COUNT = 3;
35+
36+
const usePreloadImages = (
37+
currentNamedImageIndex: number,
38+
visibleNamedImageIds: string[]): void => {
39+
const preloaded = useRef<Record<string, () => void | undefined>>({});
40+
41+
const namedImageIdsToPreload: string[] = visibleNamedImageIds.slice(
42+
Math.max(0, currentNamedImageIndex - 1 - PRELOAD_IMAGES_COUNT),
43+
Math.min(visibleNamedImageIds.length, currentNamedImageIndex + 1 + PRELOAD_IMAGES_COUNT)
44+
);
45+
46+
const imagesToPreload = useSelector((state) => getImagesByNamedImageIds(state, namedImageIdsToPreload));
47+
48+
useEffect(() => {
49+
imagesToPreload.forEach(image => {
50+
preloaded.current[image.id] = preloadImageEntity(image);
51+
});
52+
}, [currentNamedImageIndex]);
53+
54+
useEffect(() => () => {
55+
Object.values(preloaded.current).forEach(disposeCallback => disposeCallback?.());
56+
}, []);
57+
};
3158

3259
export function VisualChecksPage(): ReactNode {
3360
const dispatch = useDispatch();
@@ -41,6 +68,8 @@ export function VisualChecksPage(): ReactNode {
4168
const onPreviousImageHandler = (): void => void dispatch(visualChecksPageSetCurrentNamedImage(visibleNamedImageIds[currentNamedImageIndex - 1]));
4269
const onNextImageHandler = (): void => void dispatch(visualChecksPageSetCurrentNamedImage(visibleNamedImageIds[currentNamedImageIndex + 1]));
4370

71+
usePreloadImages(currentNamedImageIndex, visibleNamedImageIds);
72+
4473
const diffMode = useSelector(state => state.view.diffMode);
4574
const onChangeHandler = (diffModeId: DiffModeId): void => {
4675
dispatch(setDiffMode({diffModeId}));

lib/static/new-ui/features/visual-checks/selectors.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,25 @@ export const getCurrentImage = (state: State): ImageEntity | null => {
111111
return getImages(state)[currentImageId];
112112
};
113113

114+
export const getImagesByNamedImageIds = (state: State, names: string[]): ImageEntity[] => {
115+
const results: ImageEntity[] = [];
116+
117+
const images = getImages(state);
118+
const namedImages = getNamedImages(state);
119+
120+
for (const name of names) {
121+
const namedImage = namedImages[name];
122+
123+
if (!namedImage) {
124+
continue;
125+
}
126+
127+
results.push(...namedImage.imageIds.map(id => images[id]));
128+
}
129+
130+
return results;
131+
};
132+
114133
export const getVisibleNamedImageIds = createSelector([getNamedImages], (namedImages): string[] => {
115134
return Object.values(namedImages).map(namedImage => namedImage.id);
116135
});

test/unit/lib/static/components/modals/screenshot-accepter/index.jsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,10 @@ describe('<ScreenshotAccepter/>', () => {
4343

4444
preloadImageStub = sandbox.stub();
4545

46+
preloadImageStub.returns(() => void 0);
47+
4648
ScreenshotAccepter = proxyquire('lib/static/components/modals/screenshot-accepter', {
47-
'../../../modules/utils': {preloadImage: preloadImageStub}
49+
'../../../modules/utils/imageEntity': {preloadImage: preloadImageStub}
4850
}).default;
4951
});
5052

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import React from 'react';
2+
import {addBrowserToTree, addImageToTree, addResultToTree, addSuiteToTree, mkBrowserEntity, mkEmptyTree, mkImageEntityFail, mkRealStore, mkResultEntity, mkSuiteEntityLeaf, renderWithStore} from '../../../../utils';
3+
import proxyquire from 'proxyquire';
4+
5+
describe('<VisualChecksPage />', () => {
6+
const sandbox = sinon.sandbox.create();
7+
8+
const prepareTestStore = () => {
9+
const tree = mkEmptyTree();
10+
11+
const suite = mkSuiteEntityLeaf(`test-1`);
12+
addSuiteToTree({tree, suite});
13+
14+
const browser = mkBrowserEntity(`bro-1`, {parentId: suite.id});
15+
addBrowserToTree({tree, browser});
16+
17+
const result = mkResultEntity(`res-1`, {parentId: browser.id});
18+
addResultToTree({tree, result});
19+
20+
for (const i of Array.from({length: 10}).map((_, i) => i + 1)) {
21+
const image = mkImageEntityFail(`img-${i}`, {parentId: result.id});
22+
addImageToTree({tree, image});
23+
}
24+
25+
const store = mkRealStore({
26+
initialState: {
27+
app: {
28+
isInitialized: true
29+
},
30+
tree
31+
}
32+
});
33+
34+
return store;
35+
};
36+
37+
let store;
38+
let preloadImageEntityStub;
39+
40+
beforeEach(() => {
41+
preloadImageEntityStub = sandbox.stub();
42+
43+
store = prepareTestStore();
44+
45+
const VisualChecksPage = proxyquire('lib/static/new-ui/features/visual-checks/components/VisualChecksPage', {
46+
'../../../../../modules/utils/imageEntity': {preloadImageEntity: preloadImageEntityStub}
47+
}).VisualChecksPage;
48+
49+
renderWithStore(<VisualChecksPage />, store);
50+
});
51+
52+
afterEach(() => {
53+
sandbox.restore();
54+
});
55+
56+
it('should preload current and 3 adjacent images on mount', async () => {
57+
const state = store.getState();
58+
const orderedImages = Object.values(state.tree.images.byId);
59+
60+
for (let i = 0; i < 3; i++) {
61+
assert.calledWith(
62+
preloadImageEntityStub,
63+
orderedImages[i]
64+
);
65+
}
66+
});
67+
});

0 commit comments

Comments
 (0)