From cf18ec45078e586ac1925622bcd9b3797dce9f4b Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Mon, 19 Oct 2026 08:57:03 -0700 Subject: [PATCH 1/3] fix(helm): stop upgrade setup on page leave The upgrade target can resolve after the user leaves the upgrade page. The constructor subscription was never ended, so a late value set up the version list and loaded versions on a destroyed component. End it with the component (takeUntilDestroyed). The spec now swaps the component's own HelmReleaseHelperService, so the mock is the one the component uses. --- .../upgrade-release.component.spec.ts | 22 ++++++++++++++++++- .../upgrade-release.component.ts | 4 +++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/upgrade-release/upgrade-release.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/upgrade-release/upgrade-release.component.spec.ts index 95cf33e808..bde07adb6e 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/workloads/upgrade-release/upgrade-release.component.spec.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/upgrade-release/upgrade-release.component.spec.ts @@ -6,7 +6,7 @@ import { provideZonelessChangeDetection } from '@angular/core'; import { provideNoopAnimations } from '@angular/platform-browser/animations'; import { ActivatedRoute } from '@angular/router'; import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { EMPTY, of } from 'rxjs'; +import { EMPTY, of, Subject } from 'rxjs'; import { TabNavService } from '@stratosui/core'; import { TEST_CATALOGUE_ENTITIES, EntityCatalogTestModule, EntityCatalogHelper, EntityCatalogHelpers } from '@stratosui/store'; @@ -84,6 +84,9 @@ describe('UpgradeReleaseComponent', () => { ] }); + // The component provides its own HelmReleaseHelperService; swap that one too. + TestBed.overrideProvider(HelmReleaseHelperService, { useValue: mockHelmReleaseHelper }); + // Initialize EntityCatalogHelper for Angular 20 compatibility const helper = TestBed.inject(EntityCatalogHelper); EntityCatalogHelpers.SetEntityCatalogHelper(helper); @@ -106,4 +109,21 @@ describe('UpgradeReleaseComponent', () => { it('version step starts invalid until a version is selected', () => { expect(component.versionStepHandle.valid()).toBe(false); }); + + // The upgrade target can resolve after the user has left the page. The + // late value must not set up a version list on the destroyed component. + it('ignores an upgrade target that resolves after destroy', () => { + const chart$ = new Subject(); + (mockHelmReleaseHelper.hasUpgrade as any).mockReturnValue(chart$); + const late = TestBed.createComponent(UpgradeReleaseComponent); + late.destroy(); + + chart$.next({ + upgrade: { name: 'chart', repo: { name: 'repo' } }, + release: { chart: { metadata: { version: '1.0.0' } } }, + monocularEndpointId: 'monocular', + }); + + expect(late.componentInstance.listConfig()).toBeUndefined(); + }); }); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/upgrade-release/upgrade-release.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/upgrade-release/upgrade-release.component.ts index e37d3c0bfb..f021939f3c 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/workloads/upgrade-release/upgrade-release.component.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/upgrade-release/upgrade-release.component.ts @@ -7,6 +7,7 @@ import { ViewChild, WritableSignal, } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; import { format, formatDistance } from 'date-fns'; import { combineLatest, firstValueFrom, Observable } from 'rxjs'; @@ -133,7 +134,8 @@ export class UpgradeReleaseComponent { this.helper.hasUpgrade(true).pipe( filter(c => !!c), - take(1) + take(1), + takeUntilDestroyed(), ).subscribe(chart => { if (!chart) { return; From a8c337c3d97a8d7ff5c01247ca84cb77285217e1 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Mon, 19 Oct 2026 08:57:03 -0700 Subject: [PATCH 2/3] fix(store): keep observeSignal alive past views With a component's injector, observeSignal made a view effect. It ended with that view and left the observable silent, never complete, so a subscriber that outlived the view (a stepper promise, a snackbar action) would hang. Create the effect on the injector's EnvironmentInjector, so it lives exactly as long as the subscription. --- .../store/src/helpers/signal-interop.spec.ts | 19 ++++++++++++++++++- .../store/src/helpers/signal-interop.ts | 13 +++++++------ 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/frontend/packages/store/src/helpers/signal-interop.spec.ts b/src/frontend/packages/store/src/helpers/signal-interop.spec.ts index 95ad30bdb7..f3ad9fa9fe 100644 --- a/src/frontend/packages/store/src/helpers/signal-interop.spec.ts +++ b/src/frontend/packages/store/src/helpers/signal-interop.spec.ts @@ -1,4 +1,4 @@ -import { computed, Injector, signal } from '@angular/core'; +import { Component, computed, Injector, signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { firstValueFrom, of, Subject } from 'rxjs'; import { take, toArray } from 'rxjs/operators'; @@ -92,6 +92,23 @@ describe('signal-interop', () => { expect(second).toEqual(['a', 'b']); sub2.unsubscribe(); }); + + it('keeps emitting after the view of a component injector is destroyed', () => { + @Component({ template: '' }) + class HostComponent {} + const fixture = TestBed.createComponent(HostComponent); + const source = signal(1); + const values: number[] = []; + + const sub = observeSignal(source, fixture.componentRef.injector).subscribe(v => values.push(v)); + TestBed.tick(); + fixture.destroy(); + source.set(2); + TestBed.tick(); + + expect(values).toEqual([1, 2]); + sub.unsubscribe(); + }); }); describe('combineLatestOrEmpty', () => { diff --git a/src/frontend/packages/store/src/helpers/signal-interop.ts b/src/frontend/packages/store/src/helpers/signal-interop.ts index 8e027201e5..5e37ae12f2 100644 --- a/src/frontend/packages/store/src/helpers/signal-interop.ts +++ b/src/frontend/packages/store/src/helpers/signal-interop.ts @@ -1,4 +1,4 @@ -import { effect, Injector, signal, Signal, untracked, ValueEqualityFn, WritableSignal } from '@angular/core'; +import { effect, EnvironmentInjector, Injector, signal, Signal, untracked, ValueEqualityFn, WritableSignal } from '@angular/core'; import { BehaviorSubject, combineLatest, Observable, of } from 'rxjs'; /** @@ -10,10 +10,11 @@ import { BehaviorSubject, combineLatest, Observable, of } from 'rxjs'; * it safe with a long-lived injector such as the root one, where * toObservable() would leave an effect running forever per call. * - * A component's injector makes the effect a view effect: it ends with that - * view, manualCleanup or not, and the observable then goes silent without - * completing. Pass an EnvironmentInjector when the subscriber can outlive - * the view. + * The effect is created on the injector's EnvironmentInjector. With a + * component's injector it would be a view effect, end with that view, and + * leave the observable silent without completing. So the effect lives as + * long as the subscription; a subscriber that must stop with a view + * unsubscribes itself (takeUntilDestroyed). * * Timing matches toObservable(): the first value comes from the effect's * first run, not synchronously on subscribe. @@ -23,7 +24,7 @@ export function observeSignal(source: Signal, injector: Injector): Observa const ref = effect(() => { const value = source(); untracked(() => subscriber.next(value)); - }, { injector, manualCleanup: true }); + }, { injector: injector.get(EnvironmentInjector), manualCleanup: true }); return () => ref.destroy(); }); } From eadfd32371cc4dd6a3e0ab01897783147812c3ff Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Mon, 19 Oct 2026 15:15:02 -0700 Subject: [PATCH 3/3] docs(changelog): note the observeSignal lifetime Release note for the signal helper and Helm upgrade fixes. --- changelog.d/0014-observe-signal-lifetime.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 changelog.d/0014-observe-signal-lifetime.md diff --git a/changelog.d/0014-observe-signal-lifetime.md b/changelog.d/0014-observe-signal-lifetime.md new file mode 100644 index 0000000000..6ef596b0ba --- /dev/null +++ b/changelog.d/0014-observe-signal-lifetime.md @@ -0,0 +1,8 @@ +[Maintainability] +- The store's signal-to-observable helper (`observeSignal`) now lives + exactly as long as its subscription, whatever injector it is given. + With a component's injector it used to end with that view and leave + any later subscriber waiting forever. No current caller outlives its + view; this removes the trap for the next one. +- The Helm upgrade page ends its upgrade-target subscription when the + page closes, so a late answer no longer sets up a page that is gone.