Mert Özen Her Satırda Daha İleri
Geliştirici Araçları Debugging ve Problem Çözme — #07

Angular Uygulamasında Bug Avı: Signal, Change Detection, RxJS ve HTTP Katmanı

Mert Özen 8 Kas 2026 11 dk 11 görüntülenme
Angular Uygulamasında Bug Avı: Signal, Change Detection, RxJS ve HTTP Katmanı

Angular'da bug'ı UI, state, HTTP ve API zincirinde ortadan bölerek daraltırız. Gerçek Vitest çıktılarıyla: yerinde değiştirilen array yüzünden güncellenmeyen computed, await sonrası takip edilmeyen signal, Angular 22'nin varsayılan OnPush'unda ekranın güncellenmemesi ve gizlenen NG0100, catchError yüzünden ölen arama stream'i, switchMap iptalleri ve interceptor sırası.

Sürüm notu: Bu yazıdaki kodlar Angular 22.2, RxJS 7.8 ve Vitest 5.1 ile derlenip denenmiştir; bütün çıktılar ng test koşularından alınmıştır.

Önce Tahmin Et

Bookstore'un sepet rozeti bir computed signal'dan besleniyor. Kullanıcı iki kitap ekliyor: biri 1 adet, diğeri 2 adet. Kodda items.update(items => { items.push(item); return items; }) yazıyor. Rozette kaç yazar: 3 mü, 0 mı, yoksa ilk eklemeden sonraki 1 mi? İkinci soru: arama kutusu bir kez HTTP (Hypertext Transfer Protocol) 500 aldıktan sonra kullanıcı yeni bir kelime yazıyor. Yeni request gider mi? Cevapları yazının içinde gerçek test çıktılarıyla göreceğiz; tahminini bir kenara yaz.

Yöntemi Frontend'e Taşımak: UI → State → HTTP → API Zinciri

Serinin ilk yazısındaki döngü burada da aynı: gözlem, hipotez, deney. Frontend'de fark şu ki hata dört katmandan birinde doğar ve hepsi ekranda aynı görünür: "rozet güncellenmiyor". UI (User Interface) yanlış şeyi mi gösteriyor, state yanlış mı, HTTP isteği hiç gitmedi mi, yoksa API (Application Programming Interface) yanlış veri mi döndü? Serinin 03. yazısındaki binary search mantığını bu zincire uygularsın: önce ortadan böl.

Ortadaki ilk soru hep aynı: veri ağdan geldi mi? Network panelinde isteği ve yanıtını görüyorsan sorun API'de değil, yukarıda. İkinci soru: state doğru mu? Development build'de Console'da bir element seçip ng.getComponent($0) yazarak component instance'ına ulaşabilir, signal'ları çağırarak değerlerini okuyabilirsin. State doğru ama ekran yanlışsa sorun change detection'da; state yanlışsa sorun state'i değiştiren kodda. Panellerin kendisini ilerideki Browser DevTools serisinde (03 Sources, 04 Network, 09 Angular DevTools) ayrıntılı anlatıyoruz; burada yöntem ve vakalar var.

Bir ipucu daha: debugger'da her şeyi tek tek takip etmek yerine şüphelendiğin davranışı küçük bir Vitest testine çevir. Serinin 02. yazısındaki "önce kırmızı test" kuralı frontend'de de geçerli, üstelik Angular'da test yazmak çoğu zaman tarayıcıda tıklamaktan hızlı. Bu yazıdaki her vaka böyle bir testle yakalandı.

Signal Değişti Ama computed Değişmedi: Aynı Referans Tuzağı

Ticket: "Sepete ekliyorum, rozet sıfırda kalıyor. Sayfayı yenileyince düzeliyor." Store şöyle:

@Injectable({ providedIn: 'root' })
export class CartStore {
  readonly items = signal<CartItem[]>([]);
  readonly count = computed(() => this.items().reduce((sum, i) => sum + i.quantity, 0));

  // HATA: mevcut array'i değiştirip aynı referansı döndürüyor
  addBuggy(item: CartItem): void {
    this.items.update(items => {
      items.push(item);
      return items;
    });
  }

  add(item: CartItem): void {
    this.items.update(items => [...items, item]);
  }
}

Hipotezi test eden spec iki kitap ekleyip hem array uzunluğunu hem de count'u yazdırıyor. Gerçek çıktı:

stdout | src/app/cart/cart-store.spec.ts > CartStore > addBuggy: count after two adds
count before: 0
items.length: 2 count: 0

FAIL  src/app/cart/cart-store.spec.ts > CartStore > addBuggy: count after two adds
AssertionError: expected +0 to be 3 // Object.is equality

Array'de iki element var ama count hâlâ 0. Sebep signal'ın eşitlik kontrolü: varsayılan olarak yeni değer eskisiyle Object.is ile karşılaştırılır. push aynı array'i değiştirip aynı referansı döndürdüğü için signal "değişmedim" der, count ilk okunduğu andaki 0'ı cache'ten vermeye devam eder. Sayfa yenilenince düzelmesinin sebebi de bu: yeni store, yeni array.

Düzeltme add method'unda: her değişiklikte yeni array. Aynı testin add versiyonu items.length: 2 count: 3 yazdırıp geçiyor. Bu bug'ın debugging dersi şu: "state doğru mu?" sorusunu sorarken yalnız array'e bakmak yetmedi; array doğruydu. Asıl soru "state değişikliği bildirildi mi?" olmalıydı.

Effect Neyi Takip Ediyor? await Sonrasında Okunan Signal

İkinci vaka daha sinsi. Sepet her değiştiğinde sunucuyla senkronize edilecek; geliştirici önce müşteri tercihlerini yükleyen bir await eklemiş:

effect(async () => {
  const id = customerId();
  await Promise.resolve(); // ör. await this.loadPreferences(id)
  runs.push(`sync customer=${id} cart=${cartCount()}`);
});

Test sırasıyla sepeti 1'e, sonra 2'ye çıkarıyor, en sonda müşteriyi 7'den 8'e değiştiriyor ve her adımda TestBed.tick() çağırıyor. Effect kaç kez çalıştı?

[ 'sync customer=7 cart=0', 'sync customer=8 cart=2' ]

Sepet iki kez değişti, effect bir kez bile tetiklenmedi. Effect yalnız kendi senkron çalışması sırasında okunan signal'ları bağımlılık olarak kaydeder. await sonrasındaki kod ayrı bir microtask'ta çalışır; o anda okunan cartCount() hiçbir effect'e bağlanmaz. Aynı kural koşullu okuma için de geçerli: if (loggedIn()) { ... cartCount() ... } yazarsan kullanıcı giriş yapmamışken sepet değişiklikleri effect'i tetiklemez, çünkü son çalışmada cartCount hiç okunmadı.

Düzeltme, takip edilecek her signal'ı fonksiyonun başında, await'ten önce okumak; asenkron işi de signal okumayan ayrı bir method'a taşımak. Teşhis için en hızlı deney, effect'in ilk satırına bir console.log koyup hangi değişikliklerde çalıştığını saymak.

Ekran Güncellenmiyor: Angular 22'de Zoneless ve Varsayılan OnPush

Angular 21'den beri yeni uygulamalar zoneless, yani Zone.js yok. Angular 22'de bir adım daha atıldı: strategy belirtmeyen component'ler artık OnPush. Eski Default değeri Eager adını aldı ve deprecated; @angular/core 22.2.5'in tip tanımında açıkça "OnPush is enabled by default" yazıyor. Pratik sonucu şu: Angular'a haber vermeden değişen bir field ekranı güncellemez.

@Component({
  selector: 'app-book-stock',
  template: `<span class="stock">{{ stock ?? 'loading' }}</span>`,
})
export class BookStock implements OnInit {
  stock: number | null = null;

  ngOnInit(): void {
    // Angular'ın haberdar olmadığı bir callback (ör. üçüncü parti bir SDK)
    setTimeout(() => {
      this.stock = 5;
    }, 0);
  }
}

Aynı component'in signal'lı ikizi stock = signal<number | null>(null) kullanıp timer içinde stock.set(5) çağırıyor. İkisini de render edip timer'ı bekleyen testin çıktısı:

plain field : loading
signal      : 5

Field'ın değeri 5 oldu ama ekran "loading" gösteriyor. Zoneless dokümanına göre change detection'ı tetikleyenler belli: template'te okunan bir signal'ın güncellenmesi, markForCheck (AsyncPipe bunu otomatik çağırır), ComponentRef.setInput ve template ya da host listener'ları. Düz bir field ataması bunlardan biri değil. Hızlı teşhis sorusu: "Bu değişiklik Angular'a nasıl haber veriyor?" Cevap veremiyorsan bug'ı buldun. Aynı tuzak reactive form'larda da var: setValue ve patchValue tek başlarına change detection planlamaz.

Debugging sırasında bir yanılgı daha: ekranda başka bir signal güncellenirse aynı component yeniden kontrol edilir ve düz field da "tesadüfen" görünür hâle gelir. İlk denememizde iki değeri aynı component'e koyduğumuzda ikisi de 5 göründü; bug'ı gizleyen buydu. Bu yüzden hipotezi test ederken değişkenleri ayır.

NG0100 Neden Bazen Hiç Görünmüyor?

NG0100 (ExpressionChangedAfterItHasBeenCheckedError), development modunda change detection'dan sonra yapılan ek kontrolün bir binding'in değiştiğini fark etmesidir. Klasik sebep, ngAfterViewInit içinde template'te kullanılan bir değeri değiştirmek:

@Component({
  selector: 'app-order-summary',
  template: `<h2>{{ heading }}</h2>`,
})
export class OrderSummary implements AfterViewInit {
  heading = 'Order';

  ngAfterViewInit(): void {
    this.heading = 'Order #1042';
  }
}

Bu component'i fixture.detectChanges() ile render eden test hata vermedi ve h2 içinde "Order" kaldı. Ekran yanlış, uyarı yok. Aynı component'e changeDetection: ChangeDetectionStrategy.Eager verince hata geri geliyor. Tip tanımındaki açıklama sebebi söylüyor: normal change detection'dan sonra çalışan kontrol OnPush component'lerde çalışmıyor. OnPush varsayılan olunca bu kontrolün kapsamı da daraldı.

Gizli kalan hataları ortaya çıkarmak için development ortamında provideCheckNoChangesConfig({ exhaustive: true }) kullanılabilir (zoneless dokümanında developer preview olarak geçiyor). Teste bu provider'ı ekleyince gerçek çıktı:

caught: Error: NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: 'Order'. Current value: 'Order #1042'. Expression location: _OrderSummary component. Find more at https://v22.angular.dev/errors/NG0100

Mesaj ihtiyacın olan her şeyi veriyor: eski değer, yeni değer ve component'in adı. Hata mesajını okuma alışkanlığını serinin 04. yazısında konuştuk; NG0xxx hata kodlarının açıklamaları angular.dev/errors altında. Düzeltme dokümandaki öneriyle aynı: değeri ngOnInit'te ya da constructor'da kur, ya da bir signal'a çevir.

RxJS: tap ile Akışı Gözlemek, Ölen Stream ve İptal Edilen Request

Arama kutusu ticket'ı: "Bir kere hata alınca arama tamamen duruyor, sayfayı yenilemek gerekiyor." RxJS (Reactive Extensions for JavaScript) kodunda catchError dış stream'de duruyor:

// HATA: catchError dış stream'de
readonly resultsBuggy: Observable<BookDto[]> = this.terms.pipe(
  switchMap(term => this.http.get<BookDto[]>('/api/books', { params: { q: term } })),
  catchError(() => of([])),
);

HttpTestingController ile ilk isteğe 500 dönüp ardından yeni bir kelime gönderen test:

seen: [ 'next(0)', 'complete' ] | open requests for dune: 0

Akış bir boş sonuç verip complete oldu. catchError hatayı yakaladı ama hata dış stream'e kadar ulaştığı için o stream sona erdi; terms'e gelen sonraki kelimeleri dinleyen kimse kalmadı. Kullanıcı hiçbir hata görmüyor, Network panelinde de yeni istek yok. "İstek gitmiyor" belirtisinin sebebi HTTP katmanında değil, operatörün yerinde.

Düzeltilmiş hâlde hata her request'in kendi pipe'ında yakalanıyor ve tap'in RxJS 7.3'ten beri gelen unsubscribe callback'i ile iptaller de görünür oluyor:

readonly results: Observable<BookDto[]> = this.terms.pipe(
  tap(term => console.log(`[search] term=${term}`)),
  switchMap(term =>
    this.http.get<BookDto[]>('/api/books', { params: { q: term } }).pipe(
      tap({
        next: books => console.log(`[search] ${term} -> ${books.length} books`),
        error: err => console.log(`[search] ${term} -> error ${err.status}`),
        unsubscribe: () => console.log(`[search] ${term} -> cancelled by switchMap`),
      }),
      catchError(() => of([])),
    ),
  ),
);
[search] term=du
[search] term=dun
[search] du -> cancelled by switchMap
first request cancelled: true
[search] dun -> error 500
[search] term=dune
[search] dune -> 1 books
seen: [ 'next(0)', 'next(1)' ]

Bu log iki şeyi birden gösteriyor. "dun" araması 500 aldı ama akış yaşıyor; "dune" sonuç getirdi. Ayrıca "du" isteği, "dun" geldiği anda switchMap tarafından iptal edildi. Network panelinde bu istekleri "(canceled)" olarak görürsün ve bu bir bug değil, switchMap'in işi. Ama aynı operatörü sipariş kaydeden bir POST için kullanırsan, kullanıcı iki kez tıkladığında ilk istek tarayıcı tarafında iptal edilir; sunucu onu çoktan almış olabilir. Yazma işlemlerinde concatMap ya da exhaustMap düşün.

Interceptor Sırası: Kim Önce Görür?

Bir başka ticket: "Log'larda Authorization header'ı hiç görünmüyor, token gitmiyor olabilir mi?" Token gidiyor; sorun interceptor sırasında. İki interceptor, her biri isteği ve yanıt event'lerini yazıyor:

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  console.log('auth    -> request');
  const authed = req.clone({ setHeaders: { Authorization: 'Bearer test-token' } });
  return next(authed).pipe(tap(e => console.log(`auth    <- ${HttpEventType[e.type]}`)));
};

export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
  console.log(`logging -> ${req.method} ${req.url} auth=${req.headers.has('Authorization')}`);
  return next(req).pipe(tap(e => console.log(`logging <- ${HttpEventType[e.type]}`)));
};

withInterceptors([loggingInterceptor, authInterceptor]) ve ters sırayla iki koşu:

--- logging-first
logging -> GET /api/orders/1042 auth=false
auth    -> request
auth    <- Sent
logging <- Sent
auth    <- Response
logging <- Response
--- auth-first
auth    -> request
logging -> GET /api/orders/1042 auth=true
logging <- Sent
auth    <- Sent
logging <- Response
auth    <- Response

Request array sırasıyla, response ters sırayla geçiyor. Logging önce gelince header'ı eklenmeden görüyor: log yalan söylemiyor, sadece erken bakıyor. Çıktıdaki ikinci ders: interceptor'daki tap tek bir yanıt değil her HttpEvent'i görür. İlk tetiklenen Sent event'idir; "yanıt geldi" diye süre ölçen ya da body okuyan bir interceptor e.type === HttpEventType.Response kontrolü yapmazsa iki kez çalışır. Retry interceptor'ı da sıraya duyarlıdır: auth'tan önce gelirse her denemede auth interceptor yeniden çalışır (token yenilenebilir), sonra gelirse ilk denemedeki header aynen tekrar gönderilir.

Üretimde Dikkat

Production build'de ng global'leri ve NG0100 gibi development kontrolleri yoktur; hata kodları kısa mesajlarla gelir. Bu yüzden frontend'de üretim teşhisi büyük ölçüde senin bıraktığın izlere dayanır. provideBrowserGlobalErrorListeners() yakalanmamış hataları Angular'ın ErrorHandler'ına yönlendirir; kendi ErrorHandler'ın bunları sunucuya, request'in trace ID'siyle birlikte göndermeli. Böylece serinin 08. yazısındaki gibi tek bir trace ID ile tarayıcıdan API'ye kadar zaman çizelgesi kurabilirsin.

Source map'leri production'a nasıl koyduğun da önemli. Herkese açık source map kodunu açık eder; hiç olmaması stack trace'leri okunmaz kılar. Yaygın çözüm source map'i üretip yalnız hata izleme aracına yüklemek. tap ile koyduğun log'ları da üretime taşıma; bir debug flag'iyle açılan tek bir logging operatörü yaz, kullanıcı verisini (e-posta, adres, token) log'a yazma.

Son olarak, zoneless ve OnPush'a geçen eski bir uygulamada "bazen güncellenmiyor" şikâyetleri bir anda artarsa development ortamında bir süre provideCheckNoChangesConfig({ exhaustive: true, interval: 1000 }) açık tut. Haber vermeden değişen binding'leri NG0100 ile tek tek gösterir; listeyi bitirince kapat.

Sık Düşülen Tuzaklar

Signal içindeki array ya da object'i yerinde değiştirmek. push, splice, item.quantity++ signal'a değişiklik bildirmez; her zaman yeni referans üret. Effect içinde await'ten sonra signal okumak da aynı sessizlikte bozulur.

Ekran güncellenmeyince ChangeDetectorRef.detectChanges() serpiştirmek. Belirtiyi bastırır, sebebi bırakır. Değeri signal'a çevirmek ya da Observable'ı AsyncPipe veya toSignal ile bağlamak kalıcı çözümdür.

catchError'ı dış stream'e koyup "hatayı yakaladım" sanmak, subscribe'a error callback'i vermeyip hatayı RxJS'in global raporlamasına bırakmak. Hatanın nereye gittiğini bilmiyorsan, tap({ error }) ile her operatörden sonra bakarak daralt.

Network'te "(canceled)" görüp backend'e bug açmak. Önce switchMap ve route değişikliklerini kontrol et; iptal çoğu zaman bilinçli. Ve son olarak, development'ta görünmeyen NG0100'ü "yok" saymak: OnPush varsayılanıyla kontrolün kapsamı daraldı.

Küçük Bir Deneme

★☆☆ CartStore'a increaseQuantity(bookId) ekle. Önce items.update(items => { items.find(...)!.quantity++; return items; }) ile yaz, count'un değişmediğini gösteren kırmızı testi gör, sonra yeni array ve yeni object üreterek düzelt.

★★☆ BookSearch'te switchMap yerine mergeMap kullan ve "du", "dun", "dune" isteklerini ters sırayla yanıtla (flush sırası). Ekranda hangi sonuç kalıyor? Aynı testi switchMap ile tekrarla ve farkı log'dan açıkla.

★★★ Bir uygulamanın bütün component'lerini provideCheckNoChangesConfig({ exhaustive: true }) ile test ortamında çalıştıran bir setup file yaz ve mevcut test paketini koş. Çıkan her NG0100 için hangi değerin Angular'a haber vermeden değiştiğini not et.

Bu yazıdaki bütün bug'ları development ortamında, istediğimiz anda tekrarlayabildik. Bir sonraki yazıda bu lüksü kaybediyoruz: hata yalnız production'da, yalnız bazı kullanıcılarda oluyor ve elimizde log'lardan başka bir şey yok. Correlation ID ve trace ID ile bir request'i servisler arasında izleyip zaman çizelgesi kurmayı göreceğiz.