Skip to content

Commit abf4e47

Browse files
committed
fix: add session storage caching for branding logo and favicon
Problem: - Logo loader makes multiple 404 HEAD requests on every page load while detecting the correct image format (jpg, jpeg, png, gif, webp, svg) - Favicon update is delayed until cluster config API responds Solution: - Cache detected logo format path in session storage to skip format detection on subsequent page loads - Cache favicon in session storage and load it immediately during preload phase before React bootstrap starts - Move favicon update logic from bootstrap to brandingLoader with exported updateFavicon function for bootstrap to cache new values This eliminates redundant network requests and provides immediate logo and favicon display on subsequent page loads within the same session. Signed-off-by: Oleksii Orel <oorel@redhat.com>
1 parent 237c13e commit abf4e47

File tree

7 files changed

+453
-45
lines changed

7 files changed

+453
-45
lines changed

packages/dashboard-frontend/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
<meta name="viewport" content="width=device-width, initial-scale=1.0">
1919
<meta http-equiv="X-UA-Compatible" content="ie=edge">
2020
<title>Dashboard</title>
21-
<link rel="shortcut icon" href="/dashboard/assets/branding/favicon.ico" id="dashboardFavicon">
21+
<link rel="shortcut icon" href="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7">
2222
<link rel="stylesheet" type="text/css" href="/dashboard/assets/branding/branding.css">
2323
<link rel="manifest" crossorigin="use-credentials" href="/dashboard/assets/branding/manifest.json"/>
2424
</head>
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
/*
2+
* Copyright (c) 2018-2025 Red Hat, Inc.
3+
* This program and the accompanying materials are made
4+
* available under the terms of the Eclipse Public License 2.0
5+
* which is available at https://www.eclipse.org/legal/epl-2.0/
6+
*
7+
* SPDX-License-Identifier: EPL-2.0
8+
*
9+
* Contributors:
10+
* Red Hat, Inc. - initial API and implementation
11+
*/
12+
13+
/* eslint-disable @typescript-eslint/no-explicit-any */
14+
15+
describe('brandingLoader', () => {
16+
// These keys must match the constants in brandingLoader.ts and SessionStorageKey enum
17+
const BRANDING_LOGO_PATH_KEY = 'branding-logo-path';
18+
const BRANDING_FAVICON_KEY = 'branding-favicon';
19+
20+
let mockSessionStorage: Record<string, string>;
21+
let mockFetch: jest.Mock;
22+
let mockImgElement: { src: string };
23+
let mockFaviconElement: { setAttribute: jest.Mock };
24+
let mockQuerySelector: jest.Mock;
25+
26+
beforeEach(() => {
27+
// Reset mocks
28+
mockSessionStorage = {};
29+
mockImgElement = { src: '' };
30+
mockFaviconElement = { setAttribute: jest.fn() };
31+
32+
// Mock sessionStorage directly on window
33+
Object.defineProperty(window, 'sessionStorage', {
34+
value: {
35+
getItem: jest.fn((key: string) => mockSessionStorage[key] || null),
36+
setItem: jest.fn((key: string, value: string) => {
37+
mockSessionStorage[key] = value;
38+
}),
39+
removeItem: jest.fn((key: string) => {
40+
delete mockSessionStorage[key];
41+
}),
42+
},
43+
writable: true,
44+
});
45+
46+
// Mock fetch
47+
mockFetch = jest.fn();
48+
global.fetch = mockFetch;
49+
50+
// Mock document.querySelector to return appropriate elements based on selector
51+
mockQuerySelector = jest.fn().mockImplementation((selector: string) => {
52+
if (selector === '.ide-page-loader-content img') {
53+
return mockImgElement;
54+
}
55+
if (selector === 'link[rel="shortcut icon"]') {
56+
return mockFaviconElement;
57+
}
58+
return null;
59+
});
60+
Object.defineProperty(document, 'querySelector', {
61+
value: mockQuerySelector,
62+
writable: true,
63+
});
64+
65+
// Mock document.readyState
66+
Object.defineProperty(document, 'readyState', {
67+
value: 'complete',
68+
writable: true,
69+
});
70+
71+
// Reset modules to reload brandingLoader
72+
jest.resetModules();
73+
});
74+
75+
afterEach(() => {
76+
jest.clearAllMocks();
77+
});
78+
79+
describe('logo loading with cache', () => {
80+
it('should use cached logo path if available', async () => {
81+
mockSessionStorage[BRANDING_LOGO_PATH_KEY] = '/dashboard/assets/branding/loader.png';
82+
83+
// Import module to trigger initialization
84+
await import('../brandingLoader');
85+
86+
// Should not make any fetch requests
87+
expect(mockFetch).not.toHaveBeenCalled();
88+
89+
// Should set the cached path
90+
expect(mockImgElement.src).toBe('/dashboard/assets/branding/loader.png');
91+
});
92+
93+
it('should detect format with parallel requests and cache when found', async () => {
94+
// Mock fetch to return 404 for jpg, jpeg, png and 200 for gif (and svg)
95+
mockFetch.mockImplementation((url: string) => {
96+
if (url.includes('loader.gif')) {
97+
return Promise.resolve({ ok: true });
98+
}
99+
if (url.includes('loader.svg')) {
100+
return Promise.resolve({ ok: true });
101+
}
102+
return Promise.resolve({ ok: false });
103+
});
104+
105+
// Import module to trigger initialization
106+
await import('../brandingLoader');
107+
108+
// Wait for async operations
109+
await new Promise(resolve => setTimeout(resolve, 50));
110+
111+
// Should have sent parallel requests for all formats including svg
112+
expect(mockFetch).toHaveBeenCalledWith('/dashboard/assets/branding/loader.jpg', {
113+
method: 'HEAD',
114+
});
115+
expect(mockFetch).toHaveBeenCalledWith('/dashboard/assets/branding/loader.jpeg', {
116+
method: 'HEAD',
117+
});
118+
expect(mockFetch).toHaveBeenCalledWith('/dashboard/assets/branding/loader.png', {
119+
method: 'HEAD',
120+
});
121+
expect(mockFetch).toHaveBeenCalledWith('/dashboard/assets/branding/loader.gif', {
122+
method: 'HEAD',
123+
});
124+
expect(mockFetch).toHaveBeenCalledWith('/dashboard/assets/branding/loader.webp', {
125+
method: 'HEAD',
126+
});
127+
expect(mockFetch).toHaveBeenCalledWith('/dashboard/assets/branding/loader.svg', {
128+
method: 'HEAD',
129+
});
130+
131+
// Should have cached the successful path (gif comes before svg in priority order)
132+
expect(mockSessionStorage[BRANDING_LOGO_PATH_KEY]).toBe(
133+
'/dashboard/assets/branding/loader.gif',
134+
);
135+
136+
// Should have set the src
137+
expect(mockImgElement.src).toBe('/dashboard/assets/branding/loader.gif');
138+
});
139+
140+
it('should use SVG when no other format is found (all checked in parallel)', async () => {
141+
// Mock fetch to return 404 for all formats except svg
142+
mockFetch.mockImplementation((url: string) => {
143+
if (url.includes('loader.svg')) {
144+
return Promise.resolve({ ok: true });
145+
}
146+
return Promise.resolve({ ok: false });
147+
});
148+
149+
// Import module to trigger initialization
150+
await import('../brandingLoader');
151+
152+
// Wait for async operations
153+
await new Promise(resolve => setTimeout(resolve, 50));
154+
155+
// Should have checked all formats in parallel including SVG
156+
expect(mockFetch).toHaveBeenCalledWith('/dashboard/assets/branding/loader.svg', {
157+
method: 'HEAD',
158+
});
159+
160+
// Should have cached the SVG path
161+
expect(mockSessionStorage[BRANDING_LOGO_PATH_KEY]).toBe(
162+
'/dashboard/assets/branding/loader.svg',
163+
);
164+
165+
// Should have set the src to SVG
166+
expect(mockImgElement.src).toBe('/dashboard/assets/branding/loader.svg');
167+
});
168+
169+
it('should not make requests when image element is not found', async () => {
170+
mockQuerySelector.mockImplementation((selector: string) => {
171+
if (selector === 'link[rel="shortcut icon"]') {
172+
return mockFaviconElement;
173+
}
174+
return null;
175+
});
176+
177+
// Import module to trigger initialization
178+
await import('../brandingLoader');
179+
180+
// Wait for async operations
181+
await new Promise(resolve => setTimeout(resolve, 50));
182+
183+
// Should not make any fetch requests for logo
184+
expect(mockFetch).not.toHaveBeenCalled();
185+
});
186+
});
187+
188+
describe('favicon loading with cache', () => {
189+
it('should load cached favicon immediately', async () => {
190+
const cachedHref = 'data:image/png;base64,testdata';
191+
mockSessionStorage[BRANDING_FAVICON_KEY] = cachedHref;
192+
193+
// Import module to trigger initialization
194+
await import('../brandingLoader');
195+
196+
// Should have updated favicon element
197+
expect(mockFaviconElement.setAttribute).toHaveBeenCalledWith('href', cachedHref);
198+
});
199+
200+
it('should not update favicon if no cache exists', async () => {
201+
// Import module to trigger initialization
202+
await import('../brandingLoader');
203+
204+
// Should not have updated favicon element (from cached source)
205+
expect(mockFaviconElement.setAttribute).not.toHaveBeenCalled();
206+
});
207+
});
208+
209+
describe('updateFavicon export', () => {
210+
it('should cache and update favicon when dashboardFavicon is provided', async () => {
211+
const { updateFavicon } = await import('../brandingLoader');
212+
213+
await updateFavicon({ base64data: 'base64data', mediatype: 'image/png' });
214+
215+
// Should cache the favicon
216+
expect(mockSessionStorage[BRANDING_FAVICON_KEY]).toBe('data:image/png;base64,base64data');
217+
218+
// Should update favicon element
219+
expect(mockFaviconElement.setAttribute).toHaveBeenCalledWith(
220+
'href',
221+
'data:image/png;base64,base64data',
222+
);
223+
});
224+
225+
it('should fetch default favicon.ico when dashboardFavicon is undefined', async () => {
226+
const mockBlob = new Blob(['test'], { type: 'image/x-icon' });
227+
const mockDataUrl = 'data:image/x-icon;base64,dGVzdA==';
228+
229+
mockFetch.mockResolvedValue({
230+
ok: true,
231+
blob: () => Promise.resolve(mockBlob),
232+
});
233+
234+
// Mock FileReader
235+
const mockFileReader = {
236+
readAsDataURL: jest.fn(),
237+
onloadend: null as (() => void) | null,
238+
result: mockDataUrl,
239+
};
240+
(global as any).FileReader = jest.fn(() => mockFileReader);
241+
242+
const { updateFavicon } = await import('../brandingLoader');
243+
244+
const updatePromise = updateFavicon(undefined);
245+
246+
// Wait for fetch to be called
247+
await new Promise(resolve => setTimeout(resolve, 10));
248+
249+
// Trigger FileReader onloadend
250+
if (mockFileReader.onloadend) {
251+
mockFileReader.onloadend();
252+
}
253+
254+
await updatePromise;
255+
256+
// Should have fetched favicon.ico
257+
expect(mockFetch).toHaveBeenCalledWith('/dashboard/assets/branding/favicon.ico');
258+
259+
// Should cache the fetched favicon
260+
expect(mockSessionStorage[BRANDING_FAVICON_KEY]).toBe(mockDataUrl);
261+
262+
// Should update favicon element
263+
expect(mockFaviconElement.setAttribute).toHaveBeenCalledWith('href', mockDataUrl);
264+
});
265+
266+
it('should not update favicon when fetch fails and dashboardFavicon is undefined', async () => {
267+
mockFetch.mockResolvedValue({
268+
ok: false,
269+
});
270+
271+
const { updateFavicon } = await import('../brandingLoader');
272+
273+
await updateFavicon(undefined);
274+
275+
// Should have tried to fetch favicon.ico
276+
expect(mockFetch).toHaveBeenCalledWith('/dashboard/assets/branding/favicon.ico');
277+
278+
// Should not cache or update anything
279+
expect(mockSessionStorage[BRANDING_FAVICON_KEY]).toBeUndefined();
280+
expect(mockFaviconElement.setAttribute).not.toHaveBeenCalled();
281+
});
282+
});
283+
});

0 commit comments

Comments
 (0)