Background:
I am working on an Angular 20.3.10 application where I use signals for managing the state of a collection of toast messages. I have SSR and Zoneless enabled and have a web worker registered with plans to learn about PWA.
The Problem:
I have a @for loop in my template that iterates over a signal. The signal is updated correctly when I remove an item, and I have verified this using console.log and an effect() statement. However, the @for loop does not remove the corresponding DOM element when the signal is truncated.
I am using the same signal to run CSS animations which I can see are working. I also tried using ChangeDetectorRef and explicitly marking the component for check after the signal is updated, but the issue persists.
Here is some relevant code:
Toast Service
export interface Toast {
id: number;
message: string;
}
@Injectable({
providedIn: 'root',
})
export class ToastService {
private readonly toastCollection = signal<Toast[]>([]);
private nextToastId = 0;
getToastCollectionSignal() {
return this.toastCollection.asReadonly();
}
addToast(message: string): void {
const toast: Toast = { id: this.nextToastId++, message };
this.toastCollection.update((toasts) => [...toasts, toast]);
}
removeToast(id: number): void {
this.toastCollection.update((toasts) =>
toasts.filter((toast) => toast.id !== id)
);
}
}
Toast Template
<div class="fixed top-0 right-0 z-50 flex flex-col flex-nowrap p-4">
@for (toast of this.toasts(); track toast.id) {
<aside
animate.leave="toast-dismiss"
(animationstart)="onAnimationStart(toast.state, toast.id)"
(animationend)="onAnimationEnd(toast.state, toast.id)"
(mouseout)="mouseOutToast(toast.id)"
(mouseover)="mouseOverToast(toast.id)"
[class]="
mergeClasses(
'toast-item mt-0 mb-2 flex min-h-16 max-w-64 min-w-64 flex-col rounded-md
border-l-4 border-solid bg-background-600 px-2 shadow-md shadow-background-800',
toast.typeDecoration,
toast.state === ToastState.SHIFT_UP && 'toast-shift',
toast.state === ToastState.EXPAND && 'toast-expand',
toast.state === ToastState.DISMISS && 'toast-dismiss'
)
"
>
<div class="inline-flex w-full flex-row justify-between">
<header class="font-semibold">{{ toast.title }}</header>
<button (click)="dismissToast(toast.id)">
<small>✖</small>
</button>
</div>
<span
class="overflow-y-hidden text-sm text-pretty hover:overflow-y-scroll"
>
{{ toast.message }}
</span>
</aside>
}
</div>
Toast Component
@Component({
selector: 'app-toast',
templateUrl: './toast.html',
styleUrls: ['./toast.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ToastComponent {
private toastService = inject(ToastService);
private cdRef = inject(ChangeDetectorRef);
toasts = computed(() => this.toastService.getToastCollectionSignal()());
addToast(): void {
this.toastService.addToast('New Toast Message');
}
removeToast(id: number): void {
this.toastService.removeToast(id);
this.cdRef.markForCheck(); // Tried this, but it doesn't help
}
}
I've used a lot of console.log statements to follow the code execution and everything seems fine. I'm inclined to believe the issue lies in my lack of understanding when it comes to Zoneless Angular and change detection. However, the fact that the animations are working as expected must mean that the DOM is being updated since I'm using CSS native animations (at least I think that's how that works).
The call to remove the dismissed toast actually occurs inside onAnimationEnd() if that helps.
toastCollectionSignal = this.toastCollection.asReadonly(). this way signal instanse will be the same rather than new on each iteration and it should workthis.toasts()? Please try justtoast()withoutthis.(animationstart)with(animate.leave)