front: stoppe les fuites de souscriptions RxJS (takeUntilDestroyed)

Toutes les souscriptions des composants passent par
takeUntilDestroyed(this.destroyRef) : browse, catalog, api-detail, upload,
category-form-modal, category-delete-modal. api-detail abandonne le pattern
manuel Subscription/ngOnDestroy au profit de DestroyRef (ngOnDestroy ne garde
que le clearPoll du setInterval).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
z3n
2026-06-26 12:49:11 +00:00
parent 34a83233f2
commit c38adf3a7c
6 changed files with 207 additions and 137 deletions

View File

@@ -3,14 +3,15 @@ import {
signal,
computed,
inject,
DestroyRef,
OnInit,
OnDestroy,
ChangeDetectionStrategy,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Subscription } from 'rxjs';
import { ApiService } from '../../core/api.service';
import { CategoryService } from '../../core/category.service';
import { ApiEntry, ApiEntryListItem, Category } from '@datacat/shared';
@@ -416,6 +417,7 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
private apiService = inject(ApiService);
private categoryService = inject(CategoryService);
private fb = inject(FormBuilder);
private destroyRef = inject(DestroyRef);
api = signal<ApiEntry | null>(null);
versions = signal<ApiEntryListItem[]>([]);
@@ -462,26 +464,29 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
});
private pollInterval: ReturnType<typeof setInterval> | null = null;
private routeSub: Subscription | null = null;
private id = '';
ngOnInit() {
this.categoryService.list().subscribe((res) => this.categories.set(res.items));
this.routeSub = this.route.paramMap.subscribe((params) => {
this.id = params.get('id') ?? '';
this.api.set(null);
this.versions.set([]);
this.error.set(null);
this.categoryBreadcrumb.set([]);
this.editingInfo.set(false);
this.editingContacts.set(false);
this.clearPoll();
this.loadApi();
});
this.categoryService
.list()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((res) => this.categories.set(res.items));
this.route.paramMap
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => {
this.id = params.get('id') ?? '';
this.api.set(null);
this.versions.set([]);
this.error.set(null);
this.categoryBreadcrumb.set([]);
this.editingInfo.set(false);
this.editingContacts.set(false);
this.clearPoll();
this.loadApi();
});
}
ngOnDestroy() {
this.routeSub?.unsubscribe();
this.clearPoll();
}
@@ -498,29 +503,38 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
onSetCurrent() {
this.settingCurrent.set(true);
this.apiService.setCurrent(this.id).subscribe({
next: (entry) => {
this.api.set(entry);
this.settingCurrent.set(false);
this.apiService.getVersions(this.id).subscribe({
next: (v) => this.versions.set(v),
error: () => {},
});
},
error: () => this.settingCurrent.set(false),
});
this.apiService
.setCurrent(this.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (entry) => {
this.api.set(entry);
this.settingCurrent.set(false);
this.apiService
.getVersions(this.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (v) => this.versions.set(v),
error: () => {},
});
},
error: () => this.settingCurrent.set(false),
});
}
onRegenerate() {
this.regenerating.set(true);
this.apiService.regenerate(this.id).subscribe({
next: (entry) => {
this.api.set(entry);
this.regenerating.set(false);
this.startPollIfPending();
},
error: () => this.regenerating.set(false),
});
this.apiService
.regenerate(this.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (entry) => {
this.api.set(entry);
this.regenerating.set(false);
this.startPollIfPending();
},
error: () => this.regenerating.set(false),
});
}
startEditInfo() {
@@ -568,10 +582,12 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
versionPatch: raw.versionPatch ?? 0,
categoryId: raw.categoryId || null,
description: raw.description || null,
}).subscribe({
next: (updated) => { this.api.set(updated); this.savingInfo.set(false); this.editingInfo.set(false); },
error: () => this.savingInfo.set(false),
});
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (updated) => { this.api.set(updated); this.savingInfo.set(false); this.editingInfo.set(false); },
error: () => this.savingInfo.set(false),
});
}
submitContacts() {
@@ -587,10 +603,12 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
contactTechnicalName: raw.contactTechnicalName ?? '',
contactTechnicalEntity: raw.contactTechnicalEntity ?? '',
contactTechnicalEmail: raw.contactTechnicalEmail ?? '',
}).subscribe({
next: (updated) => { this.api.set(updated); this.savingContacts.set(false); this.editingContacts.set(false); },
error: () => this.savingContacts.set(false),
});
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (updated) => { this.api.set(updated); this.savingContacts.set(false); this.editingContacts.set(false); },
error: () => this.savingContacts.set(false),
});
}
onDelete() {
@@ -599,16 +617,19 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
/* Calculer la cible de redirection avant la suppression */
const otherVersions = this.versions().filter((v) => v.id !== this.id);
const redirectTarget = otherVersions.find((v) => v.isCurrent) ?? otherVersions[0];
this.apiService.delete(this.id).subscribe({
next: () => {
if (redirectTarget) {
this.router.navigate(['/catalog', redirectTarget.id]);
} else {
this.router.navigate(this.backUrl());
}
},
error: () => this.deleting.set(false),
});
this.apiService
.delete(this.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
if (redirectTarget) {
this.router.navigate(['/catalog', redirectTarget.id]);
} else {
this.router.navigate(this.backUrl());
}
},
error: () => this.deleting.set(false),
});
}
badgeClass(status: string): string {
@@ -627,27 +648,36 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
private loadApi() {
this.loading.set(true);
this.apiService.get(this.id).subscribe({
next: (entry) => {
this.api.set(entry);
this.loading.set(false);
this.startPollIfPending();
this.apiService.getVersions(this.id).subscribe({
next: (v) => this.versions.set(v),
error: () => {},
});
if (entry.categoryId) {
this.categoryService.browse(entry.categoryId).subscribe({
next: (data) => this.categoryBreadcrumb.set(data.breadcrumb),
error: () => {},
});
}
},
error: (err) => {
this.error.set(err.message ?? 'Erreur lors du chargement');
this.loading.set(false);
},
});
this.apiService
.get(this.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (entry) => {
this.api.set(entry);
this.loading.set(false);
this.startPollIfPending();
this.apiService
.getVersions(this.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (v) => this.versions.set(v),
error: () => {},
});
if (entry.categoryId) {
this.categoryService
.browse(entry.categoryId)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (data) => this.categoryBreadcrumb.set(data.breadcrumb),
error: () => {},
});
}
},
error: (err) => {
this.error.set(err.message ?? 'Erreur lors du chargement');
this.loading.set(false);
},
});
}
private startPollIfPending() {
@@ -658,12 +688,15 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
}
private pollStatus() {
this.apiService.get(this.id).subscribe({
next: (entry) => {
this.api.set(entry);
if (entry.status !== 'PENDING') this.clearPoll();
},
});
this.apiService
.get(this.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (entry) => {
this.api.set(entry);
if (entry.status !== 'PENDING') this.clearPoll();
},
});
}
private clearPoll() {

View File

@@ -327,16 +327,19 @@ export class BrowseComponent implements OnInit {
loadBrowse() {
this.loading.set(true);
this.error.set(null);
this.categoryService.browse(this.categoryId()).subscribe({
next: (data) => {
this.browseData.set(data);
this.loading.set(false);
},
error: () => {
this.error.set('Erreur lors du chargement des données.');
this.loading.set(false);
},
});
this.categoryService
.browse(this.categoryId())
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (data) => {
this.browseData.set(data);
this.loading.set(false);
},
error: () => {
this.error.set('Erreur lors du chargement des données.');
this.loading.set(false);
},
});
}
openCreate() {
@@ -385,7 +388,9 @@ export class BrowseComponent implements OnInit {
forkJoin({
apis: this.apiService.list({ search: raw || undefined, page: this.searchPage(), limit: this.searchLimit }),
categories: this.categoryService.list(raw || undefined),
}).subscribe({
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: ({ apis, categories }) => {
this.searchResults.set(apis.items);
this.searchTotal.set(apis.total);

View File

@@ -4,9 +4,11 @@ import {
computed,
effect,
inject,
DestroyRef,
OnInit,
ChangeDetectionStrategy,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
@@ -154,6 +156,7 @@ import { ApiEntryListItem, ApiType } from '@datacat/shared';
})
export class CatalogComponent implements OnInit {
private apiService = inject(ApiService);
private destroyRef = inject(DestroyRef);
apis = signal<ApiEntryListItem[]>([]);
loading = signal(false);
@@ -221,6 +224,7 @@ export class CatalogComponent implements OnInit {
page: this.page(),
limit: this.limit,
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (res) => {
this.apis.set(res.items);

View File

@@ -3,10 +3,11 @@ import {
signal,
computed,
inject,
DestroyRef,
OnInit,
ChangeDetectionStrategy,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { Router, RouterLink, ActivatedRoute } from '@angular/router';
@@ -507,6 +508,7 @@ export class UploadComponent implements OnInit {
private categoryService = inject(CategoryService);
private router = inject(Router);
private route = inject(ActivatedRoute);
private destroyRef = inject(DestroyRef);
step = signal(1);
selectedFiles = signal<File[]>([]);
@@ -628,24 +630,33 @@ export class UploadComponent implements OnInit {
const preselectedCategoryId = params.get('categoryId');
const fromId = params.get('from');
this.categoryService.list().subscribe({
next: (res) => {
this.categories.set(res.items);
if (preselectedCategoryId) {
this.metaForm.patchValue({ categoryId: preselectedCategoryId });
}
},
});
this.categoryService
.list()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (res) => {
this.categories.set(res.items);
if (preselectedCategoryId) {
this.metaForm.patchValue({ categoryId: preselectedCategoryId });
}
},
});
if (fromId) {
this.apiService.get(fromId).subscribe({
next: (api) => this.prefillFromApi(api),
error: () => {},
});
this.apiService.getVersions(fromId).subscribe({
next: (versions) => this.existingVersions.set(versions),
error: () => {},
});
this.apiService
.get(fromId)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (api) => this.prefillFromApi(api),
error: () => {},
});
this.apiService
.getVersions(fromId)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (versions) => this.existingVersions.set(versions),
error: () => {},
});
}
}
@@ -748,10 +759,13 @@ export class UploadComponent implements OnInit {
formData.append('files', f);
}
this.apiService.validate(formData).subscribe({
next: (result) => {
this.validating.set(false);
this.validationDone.set(true);
this.apiService
.validate(formData)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (result) => {
this.validating.set(false);
this.validationDone.set(true);
if (!result.valid) {
this.validationErrors.set(result.errors);
/* Ne pas réactiver le type s'il est verrouillé par fromApi */
@@ -826,16 +840,19 @@ export class UploadComponent implements OnInit {
formData.append('contactTechnicalEntity', raw.contactTechnicalEntity ?? '');
formData.append('contactTechnicalEmail', raw.contactTechnicalEmail ?? '');
this.apiService.upload(formData).subscribe({
next: (entry) => {
this.uploading.set(false);
this.router.navigate(['/catalog', entry.id]);
},
error: (err) => {
this.uploading.set(false);
this.uploadError.set(err.error?.message ?? "Erreur lors de l'import");
},
});
this.apiService
.upload(formData)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (entry) => {
this.uploading.set(false);
this.router.navigate(['/catalog', entry.id]);
},
error: (err) => {
this.uploading.set(false);
this.uploadError.set(err.error?.message ?? "Erreur lors de l'import");
},
});
}
private async detectMainFileClient(files: File[]): Promise<void> {

View File

@@ -1,4 +1,5 @@
import { Component, signal, inject, ChangeDetectionStrategy, input, output } from '@angular/core';
import { Component, signal, inject, DestroyRef, ChangeDetectionStrategy, input, output } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
import { CategoryService } from '../../core/category.service';
import { CategoryWithCounts } from '@datacat/shared';
@@ -49,6 +50,7 @@ import { CategoryWithCounts } from '@datacat/shared';
})
export class CategoryDeleteModalComponent {
private categoryService = inject(CategoryService);
private destroyRef = inject(DestroyRef);
category = input.required<CategoryWithCounts>();
confirmed = output<void>();
@@ -65,15 +67,18 @@ export class CategoryDeleteModalComponent {
const cat = this.category();
this.deleteLoading.set(true);
this.deleteError.set(null);
this.categoryService.delete(cat.id).subscribe({
next: () => {
this.deleteLoading.set(false);
this.confirmed.emit();
},
error: (err: { error?: { message?: string } }) => {
this.deleteLoading.set(false);
this.deleteError.set(err?.error?.message ?? 'Erreur lors de la suppression.');
},
});
this.categoryService
.delete(cat.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.deleteLoading.set(false);
this.confirmed.emit();
},
error: (err: { error?: { message?: string } }) => {
this.deleteLoading.set(false);
this.deleteError.set(err?.error?.message ?? 'Erreur lors de la suppression.');
},
});
}
}

View File

@@ -3,11 +3,13 @@ import {
signal,
computed,
inject,
DestroyRef,
OnInit,
ChangeDetectionStrategy,
input,
output,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
import { CategoryService } from '../../core/category.service';
import { Category } from '@datacat/shared';
@@ -79,6 +81,7 @@ import { Category } from '@datacat/shared';
})
export class CategoryFormModalComponent implements OnInit {
private categoryService = inject(CategoryService);
private destroyRef = inject(DestroyRef);
mode = input<'create' | 'edit'>('create');
category = input<Category | null>(null);
@@ -133,10 +136,13 @@ export class CategoryFormModalComponent implements OnInit {
this.formDescription.set(cat.description ?? '');
} else {
this.formParentId.set(this.parentId());
this.categoryService.list().subscribe({
next: (res) => this.allCategories.set(res.items),
error: () => this.allCategories.set([]),
});
this.categoryService
.list()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (res) => this.allCategories.set(res.items),
error: () => this.allCategories.set([]),
});
}
}
@@ -159,7 +165,7 @@ export class CategoryFormModalComponent implements OnInit {
? this.categoryService.create({ name, description, parentId: this.formParentId() ?? undefined })
: this.categoryService.update(this.editingCategoryId()!, { name, description });
obs.subscribe({
obs.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
next: () => {
this.formLoading.set(false);
this.saved.emit();