O código que quebra quando Angular fica zoneless

Zoneless expõe caminhos de código que alteram estado lido no template sem passar por uma notificação do Angular. Stable desde o Angular v20.2 (agosto/2025) e default em projetos novos a partir da v21 (novembro/2025), zoneless transforma um bug silencioso num alvo visível de review.

Comece pela notificação, não pelo nome da API

Um review de zoneless começa com uma pergunta: quando esse código muda algo que o template lê, qual caminho de notificação agenda o Angular?

O guia oficial de zoneless lista os caminhos de notificação: markForCheck, AsyncPipe, ComponentRef.setInput, atualização de signal lido no template, listeners de host ou template e anexar uma view marcada como suja. Código que altera estado de template fora desses caminhos vira o primeiro alvo de review.

Não remova todo NgZone.run() ou runOutsideAngular() mecanicamente. O alvo mais preciso do review são APIs de estabilidade como NgZone onStable, onUnstable, onMicrotaskEmpty ou lógica que trata isStable como fonte da verdade.

Alex Rickabaugh (Angular team) colocou a implicação no JS Party episódio 310: 'To turn off Zone is an application-wide decision, because Zone either as a library kind of has to be wrapping your application or not.' Ou seja, zoneless não é flag por componente. É uma postura que o app inteiro assume, e é por isso que o review começa pelo caminho de notificação, não pela superfície da API.

PadrãoPor que quebraPrimeira correção a revisar
Timer muda um campo simplesO campo muda, mas Angular pode não ser agendadoUsar um signal lido no template ou chamar markForCheck
Subscription manual muda um campoA subscription pode estar correta no lifecycle e ainda invisível para change detectionUsar AsyncPipe, toSignal ou markForCheck explícito
Status de reactive form dirige UIO modelo do form emite observables, mas nem toda leitura no template é reagendada automaticamenteLigar statusChanges ou valueChanges a um signal, ou marcar a view
Callback de terceiro atualiza estado de UIO callback pode rodar fora dos caminhos de notificação do AngularCriar um adapter que atualiza um signal ou marca a view
Teste chama detectChanges() depois de toda mutaçãoO teste pode esconder notificações que faltariam em produçãoUsar TestBed zoneless e esperar o Angular estabilizar

Timer pode estar correto no lifecycle e invisível na UI

Este timer faz cleanup, mas isso não torna o código compatível com zoneless. O problema não é o intervalo. O problema é que um campo simples muda e nada avisa o Angular que uma leitura no template ficou desatualizada.

Se o template lê a hora atual, transforme o valor em signal, a menos que exista um motivo forte para manter campo simples. A dependência fica explícita e a exigência de cleanup continua visível.

Timer com atualização implícitats
export class ClockComponent {
  private readonly destroyRef = inject(DestroyRef);

  now = new Date();

  ngOnInit() {
    const intervalId = window.setInterval(() => {
      this.now = new Date();
    }, 1000);

    this.destroyRef.onDestroy(() => {
      window.clearInterval(intervalId);
    });
  }
}
Timer com signal e cleanupts
export class ClockComponent {
  private readonly destroyRef = inject(DestroyRef);

  readonly now = signal(new Date());

  ngOnInit() {
    const intervalId = window.setInterval(() => {
      this.now.set(new Date());
    }, 1000);

    this.destroyRef.onDestroy(() => {
      window.clearInterval(intervalId);
    });
  }
}

Subscription segura não significa atualização visível

takeUntilDestroyed resolve um problema de lifecycle. Sozinho, ele não notifica o Angular que um campo simples atribuído dentro da subscription deve atualizar o template.

Para valores lidos no template, o ponto de consumo mais limpo costuma ser AsyncPipe ou toSignal. Se um campo simples precisa ficar porque o componente adapta código legado, o markForCheck() deve aparecer perto da atribuição.

Subscription segura, update invisívelts
export class OrdersBadgeComponent {
  private readonly orders = inject(OrdersService);
  private readonly destroyRef = inject(DestroyRef);

  count = 0;

  ngOnInit() {
    this.orders.count$
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe((count) => {
        this.count = count;
      });
  }
}
Borda com signal para leitura no templatets
export class OrdersBadgeComponent {
  private readonly orders = inject(OrdersService);

  readonly count = toSignal(this.orders.count$, {
    initialValue: 0,
  });
}
Fallback quando o campo simples precisa ficarts
export class OrdersBadgeComponent {
  private readonly orders = inject(OrdersService);
  private readonly cdr = inject(ChangeDetectorRef);
  private readonly destroyRef = inject(DestroyRef);

  count = 0;

  ngOnInit() {
    this.orders.count$
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe((count) => {
        this.count = count;
        this.cdr.markForCheck();
      });
  }
}

Reactive forms precisam de uma ponte explícita

Reactive forms continuam sendo uma escolha madura. A cautela com zoneless é mais específica: se o template deriva UI de status, valid, errors ou validação customizada, deixe o caminho de notificação visível.

Converta para signal apenas o observable do form que o template precisa. O modelo continua sendo form, e o template ganha um valor rastreado.

Status do form exposto como Signals para o templatets
export class CheckoutFormComponent {
  private readonly fb = inject(NonNullableFormBuilder);

  readonly form = this.fb.group({
    email: '',
  });

  readonly status = toSignal(
    this.form.statusChanges.pipe(startWith(this.form.status)),
    { initialValue: this.form.status }
  );

  readonly canSubmit = computed(() => this.status() === 'VALID');
}

Callbacks de terceiros precisam de adapter

Charts, editors, mapas, drag-and-drop e overlays customizados são bons alvos de spike porque costumam misturar DOM manual, timers, observers e callbacks que o componente Angular não desenhou.

Não espalhe markForCheck() em callbacks. Crie um adapter pequeno: o evento de terceiro entra em um lugar, atualiza um signal ou marca a view explicitamente, e registra teardown.

Callback de terceiro adaptado com signalts
export class SalesChartHostComponent {
  private readonly chart = inject(SalesChartAdapter);
  private readonly destroyRef = inject(DestroyRef);

  readonly selectedPoint = signal<ChartPoint | null>(null);

  ngAfterViewInit() {
    const unsubscribe = this.chart.onPointClick((point) => {
      this.selectedPoint.set(point);
    });

    this.destroyRef.onDestroy(unsubscribe);
  }
}

Testes não devem esconder notificações ausentes

Uma suíte de testes pode fazer zoneless parecer mais seguro do que é se toda asserção força outro fixture.detectChanges(). Isso prova que o template renderiza depois de uma change detection manual, não que o código de produção notificou o Angular.

O spike precisa de testes focados que configuram comportamento zoneless e usam whenStable() depois da ação. Se isso falhar, o componente mudou um valor de template sem passar por um caminho de notificação.

O Angular team foi explícito sobre a direção na RFC #66779: zoneless 'has created a requirement that components explicitly use ChangeDetectionStrategy.OnPush or at least be OnPush compatible.' Testes que escondem esse requisito deixam o código de produção passar do spike sem ajuste.

Smoke test com TestBed zonelessts
@Component({
  selector: 'app-counter-badge',
  standalone: true,
  template: `<span>{{ count() }}</span>`,
})
class CounterBadgeComponent {
  readonly count = signal(0);

  setCountForTest(count: number) {
    this.count.set(count);
  }
}

beforeEach(() => {
  TestBed.configureTestingModule({
    imports: [CounterBadgeComponent],
    providers: [provideZonelessChangeDetection()],
  });
});

it('waits for Angular instead of forcing a second detectChanges', async () => {
  const fixture = TestBed.createComponent(CounterBadgeComponent);
  fixture.detectChanges();

  fixture.componentInstance.setCountForTest(3);
  await fixture.whenStable();

  expect(fixture.nativeElement.textContent).toContain('3');
});

A saída deve ser uma lista de correções, não um veredito

Não encerre um spike zoneless com 'funciona' ou 'não funciona'. Termine com uma tabela curta que nomeie o padrão quebrado, o caminho de notificação necessário e se a correção mora no componente, em um adapter compartilhado ou em upgrade de dependência.

Essa é a diferença entre adotar um default moderno do Angular e começar uma campanha arriscada de migração.

AchadoEvidênciaDecisão
Timer muda campo simples no resumo de checkoutTemplate só atualiza depois de detectChanges() forçado no testeConverter para signal porque o valor é estado de template
Callback de ponto no chart atualiza estado do componenteClick atualiza dados, mas o label selecionado fica desatualizadoMover callback para adapter do chart e atualizar um signal
Botão de submit depende de form.statusStatus muda, disabled state não refresca no spikeConectar statusChanges com toSignal
Biblioteca usa NgZone.onStableCallback nunca roda na branch zonelessTrocar por render hook ou esperar atualização da biblioteca

Artefato para reaproveitar

Checklist de review de código para zoneless

  • Procure timers, subscriptions manuais, leituras de status de form, callbacks de terceiros e APIs de estabilidade de NgZone.
  • Para todo valor lido no template, identifique o caminho de notificação: signal, AsyncPipe, input, evento de template ou markForCheck.
  • Não trate todo NgZone.run() ou runOutsideAngular() como bug; foque APIs de estabilidade e mutações escondidas lidas no template.
  • Separe correções de lifecycle e correções de change detection no code review.
  • Adicione pelo menos um smoke test com TestBed zoneless que não force detectChanges() depois da ação testada.
  • Termine o spike com lista de correções e decisão de adoção, não só com build passando.

Fontes consultadas

Modern Angular Playbook

Este artigo é um play.

O Playbook do Angular Moderno reúne o diagnóstico, a matriz de adoção, onze jogadas e o plano de 30 dias. Grátis, em inglês e português.

Você recebe os dois PDFs por email, pela lista do Dojo IA.

Abrir playbook →

André Ramosdisponível para vagas remotas, UTC−3Entre em contato →

Leia também

Nota · 5 min

Um checklist curto antes de testar zoneless

Um checklist de prontidão para testar zoneless em uma aplicação Angular existente sem confundir spike de compatibilidade com plano de rollout.

Ler artigo →

Erro · 8 min

NG0100 ExpressionChangedAfterItHasBeenCheckedError: por que ele aparece no Angular zoneless

Por que o NG0100 ExpressionChangedAfterItHasBeenCheckedError aparece depois de ir pra zoneless ou Angular 22, o que ele significa agora, a armadilha do fixture.detectChanges() no teste, e os fixes conforme de onde veio a mudança.

Ler artigo →

Guia · 6 min

Quando migrar uma suíte Angular para Vitest

Vitest virou o test runner padrão do Angular CLI, mas migrar uma suíte existente ainda é experimental: quais specs migrar primeiro, o que o fakeAsync quebra, e como manter o CI confiável.

Ler artigo →