fix(core-app-api): fix selector memory leak

Signed-off-by: Sanchit2662 <sanchit2662@gmail.com>
This commit is contained in:
Sanchit2662
2026-01-16 00:40:33 +05:30
parent 6742458c2f
commit 5a71e7a270
4 changed files with 70 additions and 11 deletions
@@ -0,0 +1,5 @@
---
'@backstage/core-app-api': patch
---
Fixed memory leak caused by duplicate `AppThemeSelector` instances and missing cleanup in `AppThemeSelector` and `AppLanguageSelector`. Added `dispose()` method to both selectors for proper resource cleanup.
@@ -69,20 +69,25 @@ export class AppLanguageSelector implements AppLanguageApi {
selector.setLanguage(storedLanguage);
}
selector.language$().subscribe(({ language }) => {
const subscription = selector.language$().subscribe(({ language }) => {
if (language !== window.localStorage.getItem(STORAGE_KEY)) {
window.localStorage.setItem(STORAGE_KEY, language);
}
});
window.addEventListener('storage', event => {
const storageListener = (event: StorageEvent) => {
if (event.key === STORAGE_KEY) {
const language = localStorage.getItem(STORAGE_KEY) ?? undefined;
if (language) {
selector.setLanguage(language);
}
}
});
};
window.addEventListener('storage', storageListener);
// Store cleanup references for potential disposal
selector.#storageSubscription = subscription;
selector.#storageListener = storageListener;
return selector;
}
@@ -91,6 +96,10 @@ export class AppLanguageSelector implements AppLanguageApi {
#language: string;
#subject: BehaviorSubject<{ language: string }>;
// References for cleanup when using createWithStorage
#storageSubscription?: { unsubscribe(): void };
#storageListener?: (event: StorageEvent) => void;
private constructor(languages: string[], initialLanguage: string) {
this.#languages = languages;
this.#language = initialLanguage;
@@ -126,4 +135,20 @@ export class AppLanguageSelector implements AppLanguageApi {
language$(): Observable<{ language: string }> {
return this.#subject;
}
/**
* Cleans up resources created by createWithStorage().
* Call this method when the selector is no longer needed to prevent memory leaks.
* This is particularly useful in testing scenarios or when the app is unmounted.
*/
dispose(): void {
if (this.#storageSubscription) {
this.#storageSubscription.unsubscribe();
this.#storageSubscription = undefined;
}
if (this.#storageListener) {
window.removeEventListener('storage', this.#storageListener);
this.#storageListener = undefined;
}
}
}
@@ -39,7 +39,7 @@ export class AppThemeSelector implements AppThemeApi {
selector.setActiveThemeId(initialThemeId);
selector.activeThemeId$().subscribe(themeId => {
const subscription = selector.activeThemeId$().subscribe(themeId => {
if (themeId) {
window.localStorage.setItem(STORAGE_KEY, themeId);
} else {
@@ -47,12 +47,17 @@ export class AppThemeSelector implements AppThemeApi {
}
});
window.addEventListener('storage', event => {
const storageListener = (event: StorageEvent) => {
if (event.key === STORAGE_KEY) {
const themeId = localStorage.getItem(STORAGE_KEY) ?? undefined;
selector.setActiveThemeId(themeId);
}
});
};
window.addEventListener('storage', storageListener);
// Store cleanup references for potential disposal
selector.#storageSubscription = subscription;
selector.#storageListener = storageListener;
return selector;
}
@@ -60,6 +65,10 @@ export class AppThemeSelector implements AppThemeApi {
private activeThemeId: string | undefined;
private readonly subject = new BehaviorSubject<string | undefined>(undefined);
// References for cleanup when using createWithStorage
#storageSubscription?: { unsubscribe(): void };
#storageListener?: (event: StorageEvent) => void;
constructor(private readonly themes: AppTheme[]) {}
getInstalledThemes(): AppTheme[] {
@@ -78,4 +87,20 @@ export class AppThemeSelector implements AppThemeApi {
this.activeThemeId = themeId;
this.subject.next(themeId);
}
/**
* Cleans up resources created by createWithStorage().
* Call this method when the selector is no longer needed to prevent memory leaks.
* This is particularly useful in testing scenarios or when the app is unmounted.
*/
dispose(): void {
if (this.#storageSubscription) {
this.#storageSubscription.unsubscribe();
this.#storageSubscription = undefined;
}
if (this.#storageListener) {
window.removeEventListener('storage', this.#storageListener);
this.#storageListener = undefined;
}
}
}
+9 -5
View File
@@ -171,6 +171,7 @@ export class AppManager implements BackstageApp {
private readonly defaultApis: Iterable<AnyApiFactory>;
private readonly bindRoutes: AppOptions['bindRoutes'];
private readonly appLanguageApi: AppLanguageApi;
private readonly appThemeApi: AppThemeApi;
private readonly translationResources: Array<
TranslationResource | TranslationMessages
>;
@@ -194,6 +195,9 @@ export class AppManager implements BackstageApp {
availableLanguages:
options.__experimentalTranslations?.availableLanguages,
});
// Create a single AppThemeSelector instance to be shared between
// the loading phase and the main app, avoiding duplicate event listeners
this.appThemeApi = AppThemeSelector.createWithStorage(this.themes);
this.translationResources =
options.__experimentalTranslations?.resources ?? [];
}
@@ -240,10 +244,9 @@ export class AppManager implements BackstageApp {
const Provider = ({ children }: PropsWithChildren<{}>) => {
const needsFeatureFlagRegistrationRef = useRef(true);
const appThemeApi = useMemo(
() => AppThemeSelector.createWithStorage(this.themes),
[],
);
// Use the shared AppThemeSelector instance created in the constructor
// to avoid creating duplicate event listeners and subscriptions
const appThemeApi = this.appThemeApi;
const { routing, featureFlags } = useMemo(() => {
const usesReactRouterBeta = isReactRouterBeta();
@@ -436,7 +439,8 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be
this.apiFactoryRegistry.register('static', {
api: appThemeApiRef,
deps: {},
factory: () => AppThemeSelector.createWithStorage(this.themes),
// Use the shared AppThemeSelector instance to avoid duplicate event listeners
factory: () => this.appThemeApi,
});
this.apiFactoryRegistry.register('static', {
api: configApiRef,