Skip to content

Commit 7ea56ef

Browse files
nabramovitznorman-abramovitz
authored andcommitted
Destroy prior kubernetes list-config effects on re-entry
The kubernetes signal-config services (namespaces, pods, nodes, node-pods, namespace-pods, analysis-reports, services, helm-releases, monocular-charts) are providedIn:'root' singletons, but their host tabs call initialize() from the component constructor on every mount. The name-filter effect() was created uncaptured, so each navigation stacked a live effect on the root injector, recomputing the filter predicate N times per keystroke after N visits. Capture the EffectRef and destroy the prior one at the top of initialize(). Mirrors the existing capture+destroy pattern already used elsewhere (e.g. CfAppsSignalConfigService.resolverEffect).
1 parent d74a588 commit 7ea56ef

10 files changed

Lines changed: 85 additions & 16 deletions

File tree

src/frontend/packages/kubernetes/src/helm/list-types/monocular-charts-signal-config.service.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Injectable, Injector, Signal, WritableSignal, computed, effect, inject, runInInjectionContext, signal } from '@angular/core';
1+
import { EffectRef, Injectable, Injector, Signal, WritableSignal, computed, effect, inject, runInInjectionContext, signal } from '@angular/core';
22

33
import { ListStateStore, SignalListSort, naturalCompare } from '@stratosui/core';
44

@@ -107,6 +107,10 @@ export class MonocularChartsSignalConfigService {
107107

108108
view!: KubeViewPipeline<MonocularChart>;
109109

110+
// Captured so a re-entry (root singleton, but initialize() runs per mount)
111+
// destroys the prior filter effect instead of stacking one per navigation.
112+
private filterEffect?: EffectRef;
113+
110114
errors(): Signal<StratosError[]> {
111115
return this.helmData.errors();
112116
}
@@ -125,8 +129,9 @@ export class MonocularChartsSignalConfigService {
125129
this._sortExtractors.asReadonly(),
126130
);
127131

132+
this.filterEffect?.destroy();
128133
runInInjectionContext(this.injector, () => {
129-
effect(() => {
134+
this.filterEffect = effect(() => {
130135
const q = this.nameFilter().trim().toLowerCase();
131136
const repo = this.repositoryFilter();
132137
const isHubFilter = repo === 'Artifact Hub';

src/frontend/packages/kubernetes/src/kubernetes/list-types/analysis-reports/analysis-reports-signal-config.service.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Injectable, Injector, Signal, WritableSignal, computed, effect, inject, runInInjectionContext, signal } from '@angular/core';
1+
import { EffectRef, Injectable, Injector, Signal, WritableSignal, computed, effect, inject, runInInjectionContext, signal } from '@angular/core';
22
import { firstValueFrom } from 'rxjs';
33

44
import { ListStateStore, SignalListSort } from '@stratosui/core';
@@ -129,6 +129,10 @@ export class AnalysisReportsSignalConfigService {
129129

130130
view!: KubeViewPipeline<AnalysisReport>;
131131

132+
// Captured so a re-entry (root singleton, but initialize() runs per mount)
133+
// destroys the prior filter effect instead of stacking one per navigation.
134+
private filterEffect?: EffectRef;
135+
132136
errors(): Signal<StratosError[]> {
133137
return this.analysisData.errors();
134138
}
@@ -154,8 +158,9 @@ export class AnalysisReportsSignalConfigService {
154158
this._sortExtractors.asReadonly(),
155159
);
156160

161+
this.filterEffect?.destroy();
157162
runInInjectionContext(this.injector, () => {
158-
effect(() => {
163+
this.filterEffect = effect(() => {
159164
const q = this.nameFilter().trim().toLowerCase();
160165
this.filter.set((r: AnalysisReport) => {
161166
if (!q) return true;

src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespace-pods/kubernetes-namespace-pods-signal-config.service.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
EffectRef,
23
Injectable,
34
Injector,
45
Signal,
@@ -119,6 +120,10 @@ export class KubernetesNamespacePodsSignalConfigService {
119120

120121
view!: KubePodViewPipeline;
121122

123+
// Captured so a re-entry (root singleton, but initialize() runs per mount)
124+
// destroys the prior filter effect instead of stacking one per navigation.
125+
private filterEffect?: EffectRef;
126+
122127
errors(): Signal<StratosError[]> {
123128
return this.podData.errors();
124129
}
@@ -142,8 +147,9 @@ export class KubernetesNamespacePodsSignalConfigService {
142147
this._sortExtractors.asReadonly(),
143148
);
144149

150+
this.filterEffect?.destroy();
145151
runInInjectionContext(this.injector, () => {
146-
effect(() => {
152+
this.filterEffect = effect(() => {
147153
const q = this.nameFilter().trim().toLowerCase();
148154
this.filter.set((p: KubePod) => {
149155
if (!q) return true;

src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespaces/kubernetes-namespaces-signal-config.service.spec.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { provideHttpClient } from '@angular/common/http';
22
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
33
import { provideZonelessChangeDetection } from '@angular/core';
44
import { TestBed } from '@angular/core/testing';
5-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
5+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
66

77
import { KubeEndpointDataRegistry } from '../../../services/endpoint-data/kube-endpoint-data.registry';
88
import { KubeNamespaceDataService } from '../../../services/domain-data/kube-namespace-data.service';
@@ -123,6 +123,26 @@ describe('KubernetesNamespacesSignalConfigService', () => {
123123
expect(svc.pageIndex()).toBe(0);
124124
});
125125

126+
it('initialize does not stack a filter effect on re-entry', () => {
127+
// Regression: this service is a root singleton, but the host tab calls
128+
// initialize() from its constructor on every mount. The filter effect
129+
// was created uncaptured, so each navigation left a live effect on the
130+
// root injector — N visits meant the filter predicate was recomputed N
131+
// times per keystroke. The fix captures the EffectRef and destroys the
132+
// prior one on re-entry.
133+
svc.initialize(KUBE_GUID);
134+
svc.initialize(KUBE_GUID);
135+
TestBed.tick();
136+
137+
const setSpy = vi.spyOn(svc.filter, 'set');
138+
svc.nameFilter.set('abc');
139+
TestBed.tick();
140+
141+
// One live effect → one filter recompute. Without the fix the two
142+
// stacked effects would each fire, yielding 2.
143+
expect(setSpy).toHaveBeenCalledTimes(1);
144+
});
145+
126146
it('refresh re-fetches namespaces only', async () => {
127147
svc.initialize(KUBE_GUID);
128148
const loading = svc.loadAll();

src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespaces/kubernetes-namespaces-signal-config.service.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Injectable, Injector, Signal, WritableSignal, computed, effect, inject, runInInjectionContext, signal } from '@angular/core';
1+
import { EffectRef, Injectable, Injector, Signal, WritableSignal, computed, effect, inject, runInInjectionContext, signal } from '@angular/core';
22

33
import { ListStateStore, SignalListSort } from '@stratosui/core';
44

@@ -60,6 +60,11 @@ export class KubernetesNamespacesSignalConfigService {
6060

6161
view!: KubeViewPipeline<KubeNamespace>;
6262

63+
// Captured so a re-entry (this is a root singleton, but components call
64+
// initialize() on every mount) destroys the prior filter effect instead
65+
// of stacking one live effect per navigation on the root injector.
66+
private filterEffect?: EffectRef;
67+
6368
// Tristate visibility — when the kube version / namespace fetch is
6469
// listed in the endpoint service's `unavailable`, we surface that to
6570
// the consumer so the page can render "Not Available" rather than a
@@ -87,8 +92,9 @@ export class KubernetesNamespacesSignalConfigService {
8792
this._sortExtractors.asReadonly(),
8893
);
8994

95+
this.filterEffect?.destroy();
9096
runInInjectionContext(this.injector, () => {
91-
effect(() => {
97+
this.filterEffect = effect(() => {
9298
const q = this.nameFilter().trim().toLowerCase();
9399
this.filter.set((ns: KubeNamespace) => {
94100
if (!q) return true;

src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-node-pods/kubernetes-node-pods-signal-config.service.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
EffectRef,
23
Injectable,
34
Injector,
45
Signal,
@@ -119,6 +120,10 @@ export class KubernetesNodePodsSignalConfigService {
119120

120121
view!: KubePodViewPipeline;
121122

123+
// Captured so a re-entry (root singleton, but initialize() runs per mount)
124+
// destroys the prior filter effect instead of stacking one per navigation.
125+
private filterEffect?: EffectRef;
126+
122127
errors(): Signal<StratosError[]> {
123128
return this.podData.errors();
124129
}
@@ -142,8 +147,9 @@ export class KubernetesNodePodsSignalConfigService {
142147
this._sortExtractors.asReadonly(),
143148
);
144149

150+
this.filterEffect?.destroy();
145151
runInInjectionContext(this.injector, () => {
146-
effect(() => {
152+
this.filterEffect = effect(() => {
147153
const q = this.nameFilter().trim().toLowerCase();
148154
this.filter.set((p: KubePod) => {
149155
if (!q) return true;

src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-nodes-signal-config.service.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Injectable, Injector, Signal, WritableSignal, computed, effect, inject, runInInjectionContext, signal } from '@angular/core';
1+
import { EffectRef, Injectable, Injector, Signal, WritableSignal, computed, effect, inject, runInInjectionContext, signal } from '@angular/core';
22

33
import { ListStateStore, SignalListSort } from '@stratosui/core';
44

@@ -54,6 +54,10 @@ export class KubernetesNodesSignalConfigService {
5454

5555
view!: KubeViewPipeline<KubeNode>;
5656

57+
// Captured so a re-entry (root singleton, but initialize() runs per mount)
58+
// destroys the prior filter effect instead of stacking one per navigation.
59+
private filterEffect?: EffectRef;
60+
5761
errors(): Signal<StratosError[]> {
5862
const svc = this.kubeGuid ? this.registry.getService(this.kubeGuid) : null;
5963
return computed(() => [...(svc?.errors() ?? []), ...this.nodeData.errors()()]);
@@ -77,8 +81,9 @@ export class KubernetesNodesSignalConfigService {
7781
this._sortExtractors.asReadonly(),
7882
);
7983

84+
this.filterEffect?.destroy();
8085
runInInjectionContext(this.injector, () => {
81-
effect(() => {
86+
this.filterEffect = effect(() => {
8287
const q = this.nameFilter().trim().toLowerCase();
8388
this.filter.set((n: KubeNode) => {
8489
if (!q) return true;

src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pods-signal-config.service.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
EffectRef,
23
Injectable,
34
Injector,
45
Signal,
@@ -130,6 +131,10 @@ export class KubernetesPodsSignalConfigService {
130131

131132
view!: KubePodViewPipeline;
132133

134+
// Captured so a re-entry (root singleton, but initialize() runs per mount)
135+
// destroys the prior filter effect instead of stacking one per navigation.
136+
private filterEffect?: EffectRef;
137+
133138
errors(): Signal<StratosError[]> {
134139
return this.podData.errors();
135140
}
@@ -152,8 +157,9 @@ export class KubernetesPodsSignalConfigService {
152157
this._sortExtractors.asReadonly(),
153158
);
154159

160+
this.filterEffect?.destroy();
155161
runInInjectionContext(this.injector, () => {
156-
effect(() => {
162+
this.filterEffect = effect(() => {
157163
const q = this.nameFilter().trim().toLowerCase();
158164
this.filter.set((p: KubePod) => {
159165
if (!q) return true;

src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-services/kubernetes-services-signal-config.service.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Injectable, Injector, Signal, WritableSignal, computed, effect, inject, runInInjectionContext, signal } from '@angular/core';
1+
import { EffectRef, Injectable, Injector, Signal, WritableSignal, computed, effect, inject, runInInjectionContext, signal } from '@angular/core';
22

33
import { ListStateStore, SignalListSort } from '@stratosui/core';
44

@@ -51,6 +51,10 @@ export class KubernetesServicesSignalConfigService {
5151

5252
view!: KubeViewPipeline<KubeService>;
5353

54+
// Captured so a re-entry (root singleton, but initialize() runs per mount)
55+
// destroys the prior filter effect instead of stacking one per navigation.
56+
private filterEffect?: EffectRef;
57+
5458
errors(): Signal<StratosError[]> {
5559
const svc = this.kubeGuid ? this.registry.getService(this.kubeGuid) : null;
5660
return computed(() => [...(svc?.errors() ?? []), ...this.serviceData.errors()()]);
@@ -74,8 +78,9 @@ export class KubernetesServicesSignalConfigService {
7478
this._sortExtractors.asReadonly(),
7579
);
7680

81+
this.filterEffect?.destroy();
7782
runInInjectionContext(this.injector, () => {
78-
effect(() => {
83+
this.filterEffect = effect(() => {
7984
const q = this.nameFilter().trim().toLowerCase();
8085
this.filter.set((s: KubeService) => {
8186
if (!q) return true;

src/frontend/packages/kubernetes/src/kubernetes/workloads/list-types/helm-releases-signal-config.service.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Injectable, Injector, Signal, WritableSignal, computed, effect, inject, runInInjectionContext, signal } from '@angular/core';
1+
import { EffectRef, Injectable, Injector, Signal, WritableSignal, computed, effect, inject, runInInjectionContext, signal } from '@angular/core';
22

33
import { ListStateStore, SignalListSort } from '@stratosui/core';
44

@@ -118,6 +118,10 @@ export class HelmReleasesSignalConfigService {
118118

119119
view!: KubeViewPipeline<HelmRelease>;
120120

121+
// Captured so a re-entry (root singleton, but initialize() runs per mount)
122+
// destroys the prior filter effect instead of stacking one per navigation.
123+
private filterEffect?: EffectRef;
124+
121125
errors(): Signal<StratosError[]> {
122126
return this.helmData.errors();
123127
}
@@ -136,8 +140,9 @@ export class HelmReleasesSignalConfigService {
136140
this._sortExtractors.asReadonly(),
137141
);
138142

143+
this.filterEffect?.destroy();
139144
runInInjectionContext(this.injector, () => {
140-
effect(() => {
145+
this.filterEffect = effect(() => {
141146
const q = this.nameFilter().trim().toLowerCase();
142147
const kubeId = this.kubeIdFilter();
143148
const ns = this.namespaceFilter();

0 commit comments

Comments
 (0)