Skip to content

Commit eb074d0

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 eb074d0

File tree

7 files changed

+452
-45
lines changed

7 files changed

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

0 commit comments

Comments
 (0)