Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions changelog.d/0014-observe-signal-lifetime.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand All @@ -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<any>();
(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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
19 changes: 18 additions & 1 deletion src/frontend/packages/store/src/helpers/signal-interop.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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', () => {
Expand Down
13 changes: 7 additions & 6 deletions src/frontend/packages/store/src/helpers/signal-interop.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand All @@ -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.
Expand All @@ -23,7 +24,7 @@ export function observeSignal<T>(source: Signal<T>, 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();
});
}
Expand Down
Loading