DEV Community

gioelevalori
gioelevalori

Posted on AI-assisted

What I learned making an Angular component library work without zone.js

LiberaUI demo: accent colors, dark theme, data table and multi-select

I maintain LiberaUI, a small MIT-licensed Angular component library. When Angular made zoneless change detection a first-class option, I assumed my components would just work. They didn't, and the bugs were quiet ones: no errors, just a screen that stopped updating.

Here are the three patterns that broke, how I found them, and the tests that now keep them fixed.

1. Values set from code never appeared

Every form control in the library implements ControlValueAccessor. With zone.js this was enough:

writeValue(value: string | null) {
  this.value = value;
}
Enter fullscreen mode Exit fullscreen mode

Without zone.js, control.setValue('x') called writeValue, the field changed, and the input on screen stayed empty. Nothing told Angular the view was dirty, because nothing in a template had triggered the change.

The fix is one line, and it works on every Angular version the library supports:

private readonly changeDetector = inject(ChangeDetectorRef);

writeValue(value: string | null) {
  this.value = value;
  this.changeDetector.markForCheck();
}
Enter fullscreen mode Exit fullscreen mode

The same applies to setDisabledState. A form that disables itself after a request was silently ignoring it.

2. Anything after a timer or a promise

A copy button showed "Copied!" after await navigator.clipboard.writeText(...), and an entrance animation flipped its state inside setTimeout. Both changed a plain field after the click handler had finished, so in a zoneless app the text never appeared and the animated content stayed at opacity: 0.

For state that changes over time, a signal is the cleanest fix, because reading it in the template is enough for Angular to schedule an update:

readonly state = signal('');

private activate() {
  setTimeout(() => this.state.set(`${this.animation}-in`));
}
Enter fullscreen mode Exit fullscreen mode

3. Tests that passed for the wrong reason

The original tests called fixture.detectChanges() after every action, which hides exactly this class of bug. The regression tests now run without zone.js and only wait for the app to settle:

TestBed.configureTestingModule({
  imports: [HostComponent],
  // provideExperimentalZonelessChangeDetection() before Angular 20
  providers: [provideZonelessChangeDetection()],
});

fixture.componentInstance.control.setValue('updated');
await fixture.whenStable();
expect(input.value).toBe('updated');
Enter fullscreen mode Exit fullscreen mode

When I first ran them, 8 of 8 form controls failed. That number is the reason I'd recommend writing these tests before you trust a library, including mine.

A note on accessibility

The same discipline paid off for keyboard and screen reader behavior. The menu follows the WAI-ARIA menu button pattern (arrow keys, Home/End, type-ahead, Escape returns focus), the data table supports multi-column sorting with Shift + Enter, and the breadcrumb marks the current page with aria-current while drawing separators in CSS so they are not read aloud. Each of these has a regression test, and the keyboard ones dispatch real key events.

Try it

If you maintain components of your own, I'd be curious which zoneless bugs you ran into. And if you try LiberaUI, issues and feedback are very welcome.

Top comments (0)