Skip to content

Commit dee286e

Browse files
committed
fix: History Location state incorrectly using Node typing
feat: ThemeProvider for updating theme based on application state instead of purely in callbacks fix: Issues with rendered themes sometimes becoming out of sync, hopefully fix: Selecting no theme not working
1 parent 059e45b commit dee286e

File tree

13 files changed

+335
-287
lines changed

13 files changed

+335
-287
lines changed

src/renderer/components/AppLoader.tsx

Lines changed: 34 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
1-
import { updatePreferences } from '@renderer/store/preferences/slice';
2-
import store from '@renderer/store/store';
1+
import { useAppDispatch } from '@renderer/hooks/useAppSelector';
2+
import { setMainState } from '@renderer/store/main/slice';
3+
import { setPreferences, updatePreferences } from '@renderer/store/preferences/slice';
4+
import store, { AppDispatch } from '@renderer/store/store';
35
import { logFactory } from '@renderer/util/logging';
46
import { SocketClient } from '@shared/back/SocketClient';
57
import { BackIn, BackOut } from '@shared/back/types';
68
import { InitRendererData } from '@shared/IPC';
79
import { LogLevel } from '@shared/Log/interface';
8-
import { setTheme } from '@shared/Theme';
910
import { createErrorProxy } from '@shared/Util';
1011
import EventEmitter from 'node:events';
1112
import * as path from 'node:path';
1213
import { ReactNode, useState } from 'react';
1314
import { Spinner } from './Spinner';
15+
import { ThemeProvider } from './ThemeProvider';
1416

1517
type AppLoaderProps = {
1618
children: ReactNode,
@@ -30,7 +32,7 @@ async function waitForConnection(host: string): Promise<WebSocket> {
3032
}
3133
}
3234

33-
const onInit = async (data: InitRendererData) => {
35+
const onInit = async (data: InitRendererData, dispatch: AppDispatch) => {
3436
// Store value(s)
3537
window.Shared.isBackRemote = data.isBackRemote;
3638
window.Shared.backUrl = new URL(data.host);
@@ -60,6 +62,10 @@ const onInit = async (data: InitRendererData) => {
6062
const initData = await window.Shared.back.request(BackIn.GET_RENDERER_INIT_DATA);
6163
if (initData) {
6264
window.Shared.initialPreferences = initData.preferences;
65+
window.Shared.initialThemes = initData.themes;
66+
// Set some things early so Theme provider works
67+
dispatch(setMainState({ themeList: initData.themes }));
68+
dispatch(setPreferences(initData.preferences));
6369
window.Shared.config = {
6470
data: initData.config,
6571
// @FIXTHIS This should take if this is installed into account
@@ -71,12 +77,7 @@ const onInit = async (data: InitRendererData) => {
7177
window.Shared.customVersion = initData.customVersion;
7278
window.Shared.initialLang = initData.language;
7379
window.Shared.initialLangList = initData.languages;
74-
window.Shared.initialThemes = initData.themes;
7580
window.Shared.initialLocaleCode = initData.localeCode;
76-
if (window.Shared.initialPreferences.currentTheme) {
77-
const theme = window.Shared.initialThemes.find(t => t.id === window.Shared.initialPreferences.currentTheme);
78-
if (theme) { setTheme(theme); }
79-
}
8081
} else {
8182
throw 'No data given by host?';
8283
}
@@ -177,6 +178,7 @@ export function AppLoader(props: AppLoaderProps) {
177178
const [isInitDone, setIsInitDone] = useState(false);
178179
const [initError, setInitError] = useState<string>();
179180
const [showSpinner, setShowSpinner] = useState(false);
181+
const dispatch = useAppDispatch();
180182

181183
if (!loaderInit) {
182184
setLoaderInit(true);
@@ -185,7 +187,7 @@ export function AppLoader(props: AppLoaderProps) {
185187
setShowSpinner(true);
186188
}, 800);
187189
// Run initialization script
188-
onInit(props.data)
190+
onInit(props.data, dispatch)
189191
.then(() => {
190192
setIsInitDone(true);
191193
})
@@ -200,26 +202,32 @@ export function AppLoader(props: AppLoaderProps) {
200202
</div>;
201203
}
202204

203-
if (!isInitDone) {
204-
return <div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
205-
{ showSpinner && (
206-
<div className='splash-screen'>
207-
<div className='splash-screen__logo'>
208-
<Spinner/>
209-
</div>
210-
<div className='splash-screen__status-block'>
211-
<div className='splash-screen__status-header'>
212-
Connecting...
205+
if (loaderInit && !isInitDone) {
206+
return (
207+
<ThemeProvider>
208+
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
209+
{ showSpinner && (
210+
<div className='splash-screen'>
211+
<div className='splash-screen__logo'>
212+
<Spinner/>
213+
</div>
214+
<div className='splash-screen__status-block'>
215+
<div className='splash-screen__status-header'>
216+
Connecting...
217+
</div>
218+
</div>
213219
</div>
214-
</div>
220+
)}
215221
</div>
216-
)}
217-
</div>;
222+
</ThemeProvider>
223+
);
218224
}
219225

220226
return (
221-
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
222-
{props.children}
223-
</div>
227+
<ThemeProvider>
228+
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
229+
{props.children}
230+
</div>
231+
</ThemeProvider>
224232
);
225233
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { initialMainState, updateThemeCss } from '@renderer/store/main/slice';
2+
import { initialPreferencesState, updatePreferences } from '@renderer/store/preferences/slice';
3+
import { getFileServerURL } from '@shared/Util';
4+
import { renderWithProviders } from '@test/redux';
5+
import { useTestServer } from '@test/useTestServer';
6+
import { ITheme } from 'flashpoint-launcher';
7+
import { act } from 'react';
8+
import uuid from 'uuid';
9+
import { afterEach, describe, expect, it } from 'vitest';
10+
import { ThemeProvider } from './ThemeProvider';
11+
12+
describe('ThemeProvider', () => {
13+
useTestServer();
14+
15+
afterEach(() => {
16+
// Clean up theme elements after test
17+
const themeElement = document.head.querySelector('[data-theme="true"]');
18+
if (themeElement) {
19+
themeElement.remove();
20+
}
21+
});
22+
23+
it('update dom based on state change', async () => {
24+
const themeList = [
25+
createMockTheme(),
26+
createMockTheme(),
27+
];
28+
29+
const { container, store } = renderWithProviders(<ThemeProvider/>, {
30+
preloadedState: {
31+
main: {
32+
...initialMainState(),
33+
themeList,
34+
},
35+
preferences: {
36+
...initialPreferencesState(),
37+
currentTheme: themeList[0].id
38+
}
39+
}
40+
});
41+
42+
// Check theme element was created
43+
const themeElement = container.ownerDocument.querySelector('[data-theme="true"]') as HTMLLinkElement;
44+
expect(themeElement).toBeInTheDocument();
45+
expect(themeElement.tagName).toBe('LINK');
46+
expect(themeElement.getAttribute('type')).toBe('text/css');
47+
expect(themeElement.getAttribute('rel')).toBe('stylesheet');
48+
const expectedUrl = `${getFileServerURL()}/Themes/${themeList[0].id}/${themeList[0].entryPath}?v=0`;
49+
expect(themeElement.getAttribute('href')).toBe(expectedUrl);
50+
51+
// Check theme element is updated with new current theme
52+
act(() => {
53+
store.dispatch(updatePreferences({
54+
currentTheme: themeList[1].id
55+
}));
56+
});
57+
58+
const updatedElement = container.ownerDocument.querySelector('[data-theme="true"]') as HTMLLinkElement;
59+
const newExpectedUrl = `${getFileServerURL()}/Themes/${themeList[1].id}/${themeList[1].entryPath}?v=0`;
60+
expect(updatedElement.getAttribute('href')).toBe(newExpectedUrl);
61+
62+
// Check theme element is updated with new version number
63+
act(() => {
64+
store.dispatch(updateThemeCss());
65+
});
66+
67+
const updatedVerElement = container.ownerDocument.querySelector('[data-theme="true"]') as HTMLLinkElement;
68+
const newExpectedVerUrl = `${getFileServerURL()}/Themes/${themeList[1].id}/${themeList[1].entryPath}?v=1`;
69+
expect(updatedVerElement.getAttribute('href')).toBe(newExpectedVerUrl);
70+
71+
// Check theme element href is unset when no theme selected
72+
act(() => {
73+
store.dispatch(updatePreferences({
74+
currentTheme: undefined
75+
}));
76+
});
77+
78+
const elementAfterUnset = container.ownerDocument.querySelector('[data-theme="true"]') as HTMLLinkElement;
79+
expect(elementAfterUnset).toBeInTheDocument();
80+
expect(elementAfterUnset.getAttribute('href')).toBeNull();
81+
});
82+
});
83+
84+
function createMockTheme(): ITheme {
85+
const id = uuid();
86+
return {
87+
id,
88+
themePath: 'unused',
89+
entryPath: id + '.css',
90+
meta: {},
91+
files: []
92+
};
93+
}
94+
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { useAppSelector } from '@renderer/hooks/useAppSelector';
2+
import { getFileServerURL } from '@shared/Util';
3+
import { ITheme } from 'flashpoint-launcher';
4+
import { PropsWithChildren, useState } from 'react';
5+
6+
const globalThemeAttribute = 'data-theme';
7+
8+
type ThemeProviderProps = PropsWithChildren;
9+
10+
export function ThemeProvider({ children }: ThemeProviderProps) {
11+
const currentThemeVersion = useAppSelector(state => state.main.themeVersion);
12+
const availableThemes = useAppSelector(state => state.main.themeList);
13+
const selectedTheme = useAppSelector(state => state.preferences.currentTheme);
14+
const [currentTheme, setCurrentTheme] = useState(selectedTheme);
15+
const [themeVersion, setThemeVersion] = useState(currentThemeVersion);
16+
const [firstRender, setFirstRender] = useState(true);
17+
18+
// Must update DOM on very first render if we have a theme
19+
if (firstRender) {
20+
setFirstRender(false);
21+
const theme = availableThemes.find(t => t.id === currentTheme);
22+
if (theme) {
23+
updateDom(theme, themeVersion);
24+
}
25+
}
26+
27+
// Update DOM if the theme changes
28+
if (selectedTheme !== currentTheme) {
29+
const newTheme = availableThemes.find(t => t.id === selectedTheme);
30+
setCurrentTheme(selectedTheme);
31+
updateDom(newTheme, themeVersion);
32+
}
33+
34+
// Update DOM if the theme is invalidated (version changes)
35+
if (themeVersion !== currentThemeVersion) {
36+
setThemeVersion(currentThemeVersion);
37+
const theme = availableThemes.find(t => t.id === currentTheme);
38+
updateDom(theme, currentThemeVersion);
39+
}
40+
41+
return children;
42+
}
43+
44+
/**
45+
* Set the theme data of the "global" theme style element.
46+
*
47+
* @param theme Theme to apply on top of the default
48+
*/
49+
function updateDom(theme: ITheme | undefined, version: number): void {
50+
let element = findThemeGlobal();
51+
if (!element) {
52+
element = createThemeElement();
53+
element.setAttribute(globalThemeAttribute, 'true');
54+
if (document.head) { document.head.appendChild(element); }
55+
}
56+
if (theme) {
57+
const url = `${getFileServerURL()}/Themes/${theme.id}/${theme.entryPath}?v=${version}`;
58+
if (element.getAttribute('href') !== url) {
59+
element.setAttribute('href', url);
60+
}
61+
}
62+
else { element.removeAttribute('href'); }
63+
}
64+
65+
66+
/** Find the "global" theme style element. */
67+
function findThemeGlobal(): HTMLElement | undefined {
68+
// Go through all children of <head>
69+
if (document.head) {
70+
const children = document.head.children;
71+
for (let i = children.length; i >= 0; i--) {
72+
const child = children.item(i) as HTMLElement;
73+
if (child) {
74+
// Check if the child has the unique "global theme element" attribute
75+
const attribute = child.getAttribute(globalThemeAttribute);
76+
if (attribute) { return child; }
77+
}
78+
}
79+
}
80+
}
81+
82+
/** Create an element that themes can be "applied" to. */
83+
function createThemeElement(): HTMLElement {
84+
const element = document.createElement('link');
85+
element.setAttribute('type', 'text/css');
86+
element.setAttribute('rel', 'stylesheet');
87+
return element;
88+
}

src/renderer/components/app.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { setDownloaderState, updateDownloaderStatus, updateDownloaderTask, updat
99
import { setFpfssUser } from '@renderer/store/fpfss/slice';
1010
import { pushHistory } from '@renderer/store/history/slice';
1111
import { addLogEntries, setEntries } from '@renderer/store/logs/slice';
12-
import { addCustomRoute, addGameSidebarComponent, addLoaded, addNewExtension, cancelDialog, changeService, createDialog, openDynamicPage, removeCustomRoute, removeExtension, removeGameSidebarComponent, removeService, setExtOrderablesFromCallback, setMainState, setUnrecoverableError, setUpdateInfo, updateDialog, updateDialogField, updateMetadataSource } from '@renderer/store/main/slice';
12+
import { addCustomRoute, addGameSidebarComponent, addLoaded, addNewExtension, cancelDialog, changeService, createDialog, openDynamicPage, removeCustomRoute, removeExtension, removeGameSidebarComponent, removeService, setExtOrderablesFromCallback, setMainState, setUnrecoverableError, setUpdateInfo, updateDialog, updateDialogField, updateMetadataSource, updateThemeCss } from '@renderer/store/main/slice';
1313
import { setExtState, setPreferences, updatePreferences } from '@renderer/store/preferences/slice';
1414
import { addData, createViews, GENERAL_VIEW_ID, resetDropdownData, updateGame } from '@renderer/store/search/slice';
1515
import store, { AppDispatch, RootState } from '@renderer/store/store';
@@ -20,7 +20,6 @@ import * as extUtils from '@renderer/util/ext';
2020
import { BackIn, BackInit, BackOut, FpfssUser } from '@shared/back/types';
2121
import { APP_TITLE } from '@shared/constants';
2222
import { Paths } from '@shared/Paths';
23-
import { setTheme } from '@shared/Theme';
2423
import { getFileServerURL, sizeToString } from '@shared/Util';
2524
import {
2625
Playlist
@@ -698,7 +697,7 @@ function registerWebsocketListeners(dispatch: AppDispatch) {
698697
});
699698

700699
window.Shared.back.register(BackOut.THEME_CHANGE, (event, theme) => {
701-
setTheme(theme);
700+
dispatch(updateThemeCss());
702701
});
703702

704703
window.Shared.back.register(BackOut.THEME_LIST_CHANGE, (event, data) => {

src/renderer/components/pages/ConfigPage.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import { BackIn } from '@shared/back/types';
1010
import { ScreenshotPreviewMode } from '@shared/BrowsePageLayout';
1111
import { autoCode } from '@shared/lang';
1212
import { Paths } from '@shared/Paths';
13-
import { setTheme } from '@shared/Theme';
1413
import { deepCopy } from '@shared/Util';
1514
import * as Coerce from '@shared/utils/Coerce';
1615
import { formatString } from '@shared/utils/StringFormatter';
@@ -366,7 +365,10 @@ export function ConfigPage() {
366365
currentTheme: selectedTheme.id,
367366
currentLogoSet: logoSetId
368367
}));
369-
setTheme(selectedTheme);
368+
} else if (value === '') {
369+
dispatch(updatePreferences({
370+
currentTheme: value
371+
}));
370372
}
371373
};
372374

src/renderer/store/curate/slice.test.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { setCurrentCuration, sortCurations } from '@renderer/store/curate/slice';
1+
import { initialCurateState, setCurrentCuration, sortCurations } from '@renderer/store/curate/slice';
22
import { mockCuration } from '@test/mocks/curate';
33
import { useTestServer } from '@test/useTestServer';
44
import { describe, expect, it } from 'vitest';
@@ -18,14 +18,8 @@ describe('Curate Redux Store', () => {
1818

1919
const store = setupStore({
2020
curate: {
21-
curations,
22-
loaded: true,
23-
groups: [],
24-
collapsedGroups: [],
25-
current: '',
26-
selected: [],
27-
lastSelected: '',
28-
curationTemplates: []
21+
...initialCurateState(),
22+
curations
2923
}
3024
});
3125

0 commit comments

Comments
 (0)