From 5a71e7a2703d0087f7852535181a1e47497b5a82 Mon Sep 17 00:00:00 2001 From: Sanchit2662 Date: Fri, 16 Jan 2026 00:40:33 +0530 Subject: [PATCH 1/2] fix(core-app-api): fix selector memory leak Signed-off-by: Sanchit2662 --- .../fix-theme-language-selector-leak.md | 5 +++ .../AppLanguageApi/AppLanguageSelector.ts | 31 +++++++++++++++++-- .../AppThemeApi/AppThemeSelector.ts | 31 +++++++++++++++++-- packages/core-app-api/src/app/AppManager.tsx | 14 ++++++--- 4 files changed, 70 insertions(+), 11 deletions(-) create mode 100644 .changeset/fix-theme-language-selector-leak.md diff --git a/.changeset/fix-theme-language-selector-leak.md b/.changeset/fix-theme-language-selector-leak.md new file mode 100644 index 0000000000..426127b687 --- /dev/null +++ b/.changeset/fix-theme-language-selector-leak.md @@ -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. diff --git a/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.ts b/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.ts index 04dcfe9ebb..e0da4f9b87 100644 --- a/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.ts +++ b/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.ts @@ -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; + } + } } diff --git a/packages/core-app-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts b/packages/core-app-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts index b0303cf29a..ef0c2e337b 100644 --- a/packages/core-app-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts +++ b/packages/core-app-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts @@ -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(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; + } + } } diff --git a/packages/core-app-api/src/app/AppManager.tsx b/packages/core-app-api/src/app/AppManager.tsx index 98067267b5..bfaeb2f2db 100644 --- a/packages/core-app-api/src/app/AppManager.tsx +++ b/packages/core-app-api/src/app/AppManager.tsx @@ -171,6 +171,7 @@ export class AppManager implements BackstageApp { private readonly defaultApis: Iterable; 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, From 6bb2f2149dc359557cc852bc6e903e5502d70858 Mon Sep 17 00:00:00 2001 From: Sanchit2662 Date: Fri, 16 Jan 2026 01:32:54 +0530 Subject: [PATCH 2/2] chore: update API reports Signed-off-by: Sanchit2662 --- .changeset/backend-process-listener-cleanup.md | 5 +++++ packages/core-app-api/report.api.md | 1 + 2 files changed, 6 insertions(+) create mode 100644 .changeset/backend-process-listener-cleanup.md diff --git a/.changeset/backend-process-listener-cleanup.md b/.changeset/backend-process-listener-cleanup.md new file mode 100644 index 0000000000..eb4dc52937 --- /dev/null +++ b/.changeset/backend-process-listener-cleanup.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-app-api': patch +--- + +Fixed memory leak by properly cleaning up process event listeners on backend shutdown. diff --git a/packages/core-app-api/report.api.md b/packages/core-app-api/report.api.md index e039394946..51bdb676ce 100644 --- a/packages/core-app-api/report.api.md +++ b/packages/core-app-api/report.api.md @@ -276,6 +276,7 @@ export class AppThemeSelector implements AppThemeApi { activeThemeId$(): Observable; // (undocumented) static createWithStorage(themes: AppTheme[]): AppThemeSelector; + dispose(): void; // (undocumented) getActiveThemeId(): string | undefined; // (undocumented)