When a live feed re-emits a full array on every tick, naively swapping the whole array forces Angular's `@for` block to re-evaluate every row even if only one changed, and worse, breaks referential equality for child `OnPush` components even when their data is identical. `RowReconciler.merge` performs a keyed diff: unchanged rows keep their original object reference (`existing`, not `next`), so `OnPush` child components skip re-rendering entirely; only rows whose `version` actually advanced get replaced. Combined with `track row.id`, Angular's reconciliation avoids destroying and recreating DOM nodes for rows that merely shifted position.
interface Row { id: string; version: number; label: string; }@Injectable({ providedIn: 'root' })export class RowReconciler {merge(current: readonly Row[], incoming: readonly Row[]): Row[] {const incomingById = new Map(incoming.map((row) => [row.id, row]));const merged: Row[] = [];for (const existing of current) {const next = incomingById.get(existing.id);if (!next) continue;merged.push(next.version === existing.version ? existing : next);incomingById.delete(existing.id);}for (const remaining of incoming) {if (incomingById.has(remaining.id)) {merged.push(remaining);}}return merged;}}@Component({selector: 'app-row-list',standalone: true,template: `@for (row of rows(); track row.id) {<app-row [data]="row" />}`,})export class RowListComponent {private readonly reconciler = inject(RowReconciler);private readonly source = inject(RowFeed).stream();readonly rows = toSignal(this.source.pipe(scan((acc, incoming) => this.reconciler.merge(acc, incoming), [] as Row[])),{ initialValue: [] },);}