From harness-claude
Optimizes Angular rendering with OnPush change detection, trackBy, virtual scrolling, deferrable views, and signals for zoneless apps. Use for janky scrolling, excessive CD cycles, deep trees, or large *ngFor lists.
npx claudepluginhub intense-visions/harness-engineering --plugin harness-claudeThis skill uses the workspace's default tool permissions.
> Optimize Angular rendering with OnPush change detection, trackBy, virtual scrolling, deferrable views, and signals for zoneless-ready apps
Provides Angular best practices for performance optimization including signals, OnPush, zoneless change detection, bundle optimization, SSR, rendering, and state management. Use when writing, reviewing, or refactoring code.
Provides expert Angular/TypeScript patterns for standalone components, signals, RxJS, NgRx state management, smart/dumb components, and performance.
Implements Angular lazy loading with loadComponent, loadChildren, preloading strategies, and @defer blocks to reduce initial bundle size and improve time-to-interactive for large apps.
Share bugs, ideas, or general feedback.
Optimize Angular rendering with OnPush change detection, trackBy, virtual scrolling, deferrable views, and signals for zoneless-ready apps
*ngFor causes memory or scroll performance issueschangeDetection: ChangeDetectionStrategy.OnPush on every component. With OnPush, Angular only checks a component when its input references change, an async pipe emits, or a signal updates — not on every browser event.trackBy with *ngFor to prevent Angular from destroying and re-creating DOM nodes when the array reference changes: *ngFor="let item of items; trackBy: trackById". The track function should return a stable unique identifier (e.g., the item's ID).@angular/cdk/scrolling CdkVirtualScrollViewport for lists with more than ~100 items. Virtual scrolling renders only the visible items, keeping DOM size constant regardless of data size.@defer (on viewport) for components below the fold — they won't load until the user scrolls to them, reducing initial bundle execution time.computed() signals or pure pipes — both memoize their results and only recompute when dependencies change.{{ computeTotal() }}) — they execute on every change detection cycle. Replace with computed() signals or @Input() derived values.setTimeout/setInterval without wrapping in NgZone.runOutsideAngular() for non-UI timers — they trigger change detection on every tick.provideExperimentalZonelessChangeDetection() in Angular 18+).// OnPush + trackBy
@Component({
selector: 'app-product-list',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<app-product-card *ngFor="let product of products(); trackBy: trackById" [product]="product" />
`,
})
export class ProductListComponent {
products = input.required<Product[]>();
trackById = (_: number, item: Product) => item.id;
}
// Virtual scrolling with CDK
import { ScrollingModule, CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
@Component({
imports: [ScrollingModule],
template: `
<cdk-virtual-scroll-viewport itemSize="72" style="height: 600px">
<div
*cdkVirtualFor="let item of items; trackBy: trackById"
class="list-item"
style="height: 72px"
>
{{ item.name }}
</div>
</cdk-virtual-scroll-viewport>
`,
})
export class VirtualListComponent {
items = input.required<Item[]>();
trackById = (_: number, i: Item) => i.id;
}
// Computed signal instead of template method call
@Component({
template: `<p>Total: {{ formattedTotal() }}</p>`,
})
export class CartComponent {
items = signal<CartItem[]>([]);
// Memoized — only recomputes when items() changes
total = computed(() => this.items().reduce((s, i) => s + i.price * i.qty, 0));
formattedTotal = computed(() =>
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(this.total())
);
}
// Running non-UI work outside Angular zone
@Injectable({ providedIn: 'root' })
export class PollingService {
private ngZone = inject(NgZone);
startPolling(callback: () => void, intervalMs: number): () => void {
let id: ReturnType<typeof setInterval>;
this.ngZone.runOutsideAngular(() => {
id = setInterval(() => {
// Run callback back inside zone to trigger CD if needed
this.ngZone.run(callback);
}, intervalMs);
});
return () => clearInterval(id);
}
}
Change detection cost model: In the default strategy, Angular traverses the entire component tree on every browser event (click, input, scroll, setTimeout, XHR). With OnPush, Angular marks a component as "dirty" only when:
@Input() reference changes (new object/array reference)async pipe emitsChangeDetectorRef.markForCheck() is called explicitlytrackBy mechanics: Without trackBy, Angular compares list items by identity. When the array reference changes (even with the same data), Angular destroys and recreates all DOM nodes — re-triggering child lifecycle hooks. trackBy returns a key; if the key matches an existing node, Angular reuses the DOM element and only updates the changed properties.
Virtual scrolling sizing: CdkVirtualScrollViewport requires itemSize (in pixels) for fixed-height items. For variable-height items, use AutoSizeVirtualScrollStrategy from CDK (experimental). The viewport must have an explicit height for scrolling to work.
NgZone.runOutsideAngular use cases:
requestAnimationFrame loops for canvas renderingsetInterval for polling when only some callbacks need UI updatesBundle performance: @defer creates a separate chunk for the deferred component. Use ng build --stats-json && npx webpack-bundle-analyzer dist/stats.json to verify chunk sizes. Set bundleBudgets in angular.json to fail the build if chunks exceed defined thresholds.
Profiling with Angular DevTools: Install the Angular DevTools Chrome extension. In the "Profiler" tab, record a change detection cycle and inspect which components checked and how long each took. Components with unnecessary check counts are candidates for OnPush or signal migration.
Memoization with pure pipes: A pure: true pipe (default) is essentially a memoized function — Angular caches the result for the same input references. For expensive formatting applied in a large *ngFor, a pure pipe avoids recomputing the format on every CD cycle.
https://angular.dev/guide/best-practices/runtime-performance