By Prateek MishraSoftware Engineering

I Debugged Every PrimeNG v21 Surprise So You Don’t Have To

7 breaking changes the migration guide never mentions, found while upgrading 3 production Angular apps.

If you’re upgrading PrimeNG from 16 to 21, the migration guide is going to let you down.

Not on the big stuff. The renames and removed components are all documented. It lets you down on the changes that compile cleanly and break at runtime: flipped defaults, rewired click handling, new validation rules nobody announced.

I hit every one of these in production code. Every fix came from the source, not the docs.

This article is that list. Each entry tells you what changed, what breaks, and the fix that worked for us.

Before you read

  • Verified against: primeng 16.9.1 and 21.1.10, with @primeuix/styles 2.0.3. I re-checked the source-level cause of each item in the installed packages while writing this. Timings and repro details come from our production app.
  • Not repeated here: anything the official guides already cover. If you haven’t read my version-by-version guide yet, start there: Migrating PrimeNG from v16 to v21: A Version-by-Version Guide.

Table of Contents

  1. Why a green build proves nothing
  2. Theming and the CSS cascade
  3. Defaults that flipped without a note
  4. Overlays, popovers and dialogs
  5. Form controls
  6. Signals, templates and wrapper components
  7. Performance and unit tests

1. Why a green build proves nothing

Most of this list compiles cleanly. That is the whole problem. Three things hide it.

1.1 strictTemplates is probably off

Apps that started life on Angular 16 often run with strictTemplates: false. Under that setting, all of these compile:

  • A signal input read by its bare name ([disabled]="required" instead of required())
  • An invalid string for an enum-typed input
  • An unknown attribute directive
  • A misspelled #ref
  • Any CSS class that was renamed

Fix: turn on strictTemplates once, on a throwaway branch. It lists most of the signal bugs in section 6 in a single pass.

1.2 Old selectors still work

Re-cased components keep their old selector as an alias. p-autoComplete, p-fileUpload, p-multiSelect and p-datePicker all still compile, so NG8001 won’t point you at old usages. Grep for them instead.

1.3 Menu modules stopped re-exporting RouterModule

In v16, MenuModule, TieredMenuModule, BreadcrumbModule and StepsModule all exported RouterModule. PaginatorModule exported FormsModule, and SplitButtonModule exported TieredMenuModule. In v21, each module exports only its own component plus SharedModule.

If a feature module got routerLink or <router-outlet> through one of these, they now go silently inert. The router still reports NavigationEnd, but the routed component is never constructed.

Fix: import RouterModule and FormsModule explicitly wherever a PrimeNG module used to supply them.

2. Theming and the CSS cascade

This is where most of our visual bugs came from. PrimeNG moved from a static theme file to runtime-injected CSS built from design tokens, and that changes who wins in the cascade.

2.1 The root class moved onto the host element

What changed: .p-message, .p-accordionpanel, .p-tabs, .p-select and others now sit on the component’s host element, merged with your own class.

What breaks: descendant selectors like .my-banner .p-message stop matching, because both classes are now on the same element.

Fix: make them compound: .my-banner.p-message.

2.2 The p-element class is gone

In v21, only TreeTable still emits p-element. Any selector or theme scoped under .p-element now misses every other component.

2.3 Overlay text renders blurry

What changed: .p-overlay-content now has will-change: transform.

What breaks: Chrome rasterises the layer at the sub-pixel offset of the first animation frame and never re-rasterises it. Option text in dropdowns looks soft on some screens.

Fix:

.p-overlay .p-overlay-content {
  will-change: auto;
}

The two-class selector matters: PrimeNG injects its CSS unlayered after your styles, so a single-class override loses the tie.

3. Defaults that flipped without a note

No error, no warning. The component just behaves differently than it did in v16.

3.1 Dropdown focus defaults reversed

What changed: on Select, MultiSelect and AutoComplete:

  • focusOnHover went from false to true (AutoComplete didn’t have it in v16; it now defaults to true)
  • autoOptionFocus went from true to false
  • MultiSelect autofocusFilter went from true to false

What breaks: the hover highlight stays on an option after the mouse leaves. Combined with the selected option, users see two highlighted rows and ask which one is picked. The first option is no longer pre-focused, and the MultiSelect filter no longer takes focus when the panel opens.

Fix: there is no global config for these. Set [focusOnHover]="false" [autoOptionFocus]="true" per component, or once in a shared wrapper.

3.2 Every p-button asks for focus

What changed: the Button template binds [pAutoFocus]="autofocus || buttonProps?.autofocus". The AutoFocus directive only removes the attribute when the value is strictly false. With autofocus unset, every button renders autofocus="true".

What breaks: Popover and Dialog both default focusOnShow to true. Open one, and about 5 ms later focus jumps from the trigger to the first button inside. Alert-style action buttons can scroll the page to themselves.

Fix: [focusOnShow]="false" on the popover or dialog, or [buttonProps]="{ autofocus: false }" on the button. Quick audit on any page: document.querySelectorAll('button[autofocus]').length.

3.3 Empty string and 0 now mean true

What changed: more than 250 component inputs (disabled, readonly, required, rounded, loading …) now use Angular’s booleanAttribute transform. v16 had none.

What breaks: booleanAttribute treats '' and 0 as true and only the string 'false' as false. So [disabled]="cond ? x : ''" now disables the control.

Fix: bind real booleans (!!expr). Grep for '' inside [disabled], [readonly] and [required] bindings.

3.4 DynamicDialog defaults are inverted

What changed: closable is now opt-in, and draggable and resizable are on unless you pass false. v16 was the opposite on all three.

What breaks: dialogs open with no X button, and users can drag and resize them.

Fix: spread your defaults into every open() call:

this.dialogService.open(MyComponent, {
  closable: true,
  draggable: false,
  resizable: false,
  ...config,
});

3.5 Multi-select lists no longer need Ctrl/Cmd

What changed: metaKeySelection flipped from true to false on Tree, PickList and OrderList.

What breaks: each click now toggles an item instead of replacing the selection. Users who click to select one item end up with several. Note that the v17 CHANGELOG claims these all default to true; the installed source says otherwise.

Fix: set [metaKeySelection]="true" where you want the v16 behaviour.

3.6 Select stopped auto-selecting the first option on init

What changed: v16 Dropdown had autoDisplayFirst = true. In ngOnInit, with an empty model, it selected the first option and fired ngModelChange. Removing autoDisplayFirst in v20 is documented. Losing that init-time ngModelChange is not.

What breaks: load-time side effects wired to (ngModelChange) quietly stop running.

Fix: move init logic out of the change handler and into the code that loads the data.

3.7 p-message lost its severity icon

What changed: v16 picked the icon from severity. v21 shows an icon only if you pass icon="pi pi-…". The message also animates in and out by default.

Fix: pass icon explicitly. To keep it static, set .p-message-enter-active, .p-message-leave-active { animation: none; }.

3.8 Accordion icons changed direction

v16 showed chevron-right when collapsed and chevron-down when expanded. v21 shows chevron-down when collapsed and chevron-up when expanded. If you restyled the icon with a rotation, rotate on .p-accordionheader[data-p-active] .p-icon instead of on the toggle-icon class.

4. Overlays, popovers and dialogs

Overlays now animate through a new motion layer, and Popover replaced OverlayPanel. Both changes left sharp edges.

4.1 Popover swallows the first outside click

What changed: v21 Popover sets an internal selfClick flag on content mousedown, not only on click. Only the document click listener clears it.

What breaks: if anything inside the popover calls $event.stopPropagation() on click, that click never reaches the document. The flag stays true, and the next outside click is treated as an inside click. The popover refuses to close.

Fix: stop mousedown too, wherever you stop click:

<button (click)="select(item); $event.stopPropagation()"
        (mousedown)="$event.stopPropagation()">

4.2 Popover lost top: 0; left: 0

What changed: v16 OverlayPanel’s CSS set top: 0; left: 0. v21 Popover’s CSS doesn’t.

What breaks: with appendTo="body" near the right edge, the panel briefly overflows during positioning. A scrollbar appears, the layout shifts, and the panel lands about 15 px short of its target.

Fix: add top: 0; left: 0 to the popover’s styleClass.

4.3 The flip marker is never removed

What changed: when there’s no room below, align() adds both the p-popover-flipped class and a data-p-popover-flipped attribute. Nothing removes either. The arrow position also moved from --overlayArrowLeft to --p-popover-arrow-left.

What breaks: reuse one popover for different triggers, and once it flips it stays flipped.

Fix: clear both markers before re-aligning against a new target.

4.4 The overlay’s old animation outputs never fire

What changed: p-overlay still declares onAnimationStart and onAnimationDone, but nothing emits them. The new lifecycle goes through onBeforeEnter and onAfterLeave.

What breaks: if you forked a Select, MultiSelect or AutoComplete template, your copy still binds the old outputs. You lose scroll-to-selected, filter focus, and Select never emits onHide.

Fix: bind (onBeforeEnter)="onOverlayBeforeEnter($event)" and (onAfterLeave)="onOverlayAfterLeave($event)" in forked templates.

4.5 Panels linger after an outside click

What changed: the overlay binds its outside-click listener after the enter animation, and Overlay.hide() doesn’t call markForCheck().

What breaks: in a zone-based app with OnPush hosts, a MultiSelect panel can stay on screen after an outside click until the next event triggers change detection.

Fix:

overlayOptions = {
  onHide: () => this.zone.run(() => this.cdr.markForCheck()),
};

4.6 DynamicDialog returns null the second time

What changed: from v20, DialogService.open() refuses to open a second dialog of the same component type unless you pass duplicate: true. It returns null instead. The return type is DynamicDialogRef | null, but most code calls .onClose on it directly.

What breaks: if a dialog closes during the same render that opened it, its leave animation is skipped and its ref is never cleaned up. Every later open() of that component returns null. You get dialogs that work exactly once, and save spinners that hang with no API call.

Fix: null-check every ref, defer any self-close with setTimeout, and pass duplicate: true where stacking is intended.

4.7 A focused tab eats every keydown

What changed: p-tab’s keydown handler calls event.stopPropagation() at the end of the switch, for every key, including ones it doesn’t handle.

What breaks: window- or document-level keyboard handlers (arrow-key scrolling, shortcuts) go dead after the user clicks a tab.

Fix: listen in the capture phase and forward when event.target.closest('[role="tab"]') matches.

5. Form controls

5.1 DatePicker blanks valid-looking dates

What changed: when formatting a value, DatePicker now checks the full timestamp against minDate and maxDate. Anything outside renders as an empty string. Day cells in the calendar are still checked per day.

What breaks: with maxDate = new Date(), a range ending at end-of-today (23:59:59) is later than “now”. The field shows blank, even though the user just picked that day from the calendar.

Fix: day-align your bounds (startOf('day') for minDate, endOf('day') for maxDate). And parse API dates with a date library, not new Date('YYYY-MM-DD'), which gives UTC midnight.

5.2 The DatePicker popup is as wide as the host

What changed: the overlay’s min-width is now set to host.offsetWidth, and the host is a stretching root.

What breaks: put a 200 px input in a flex row and the calendar opens 600 px wide.

Fix: constrain the host’s width, or override min-width on the panel.

5.3 Select shows a clear icon for an empty string

What changed: hasSelectedOption() is now modelValue() !== undefined.

What breaks: a FormControl('') with showClear shows the ”×” on an empty field.

Fix: initialise optional selects with null, not ''.

5.4 Checkbox toggles twice inside clickable rows

What changed: v21 Checkbox lays a real <input type="checkbox"> over the box and updates on its change event. v16 used a click handler on the root.

What breaks: a checkbox inside a row that toggles on click now fires both, so it flips and flips back. .p-focus is no longer set either; the focus ring uses :has(.p-checkbox-input:focus-visible).

Fix: add (click)="$event.preventDefault()" to the checkbox when the row owns the toggle.

5.5 Checkbox and RadioButton lost label

What changed: v16 had label and labelStyleClass inputs on both. v21 has neither.

What breaks: a static label="Yes" is now just an HTML attribute. It compiles and renders no text.

Fix: use a real <label for="…"> next to the control.

5.6 RadioButton’s binary now does something

What changed: v21 RadioButton has a real binary input. In v16 it didn’t exist, so binary="true" on a radio was inert markup.

What breaks: that leftover attribute now checks every radio in the group at once.

Fix: grep for binary on p-radioButton / p-radiobutton and delete it.

5.7 FileUpload basic mode moved the file name

What changed: in v16 basic mode, the button label switched to the chosen file’s name. In v21 the button keeps chooseLabel, and the name appears in a separate <span> next to it. With no file, that span shows “No file chosen”. The button also gains a ”+” icon.

What breaks: layouts sized for one button now have a button plus a text span. Anything that read the file name off the button is wrong.

Fix: to keep the old look, pass an empty #filelabel template and bind chooseLabel to the file name yourself.

6. Signals, templates and wrapper components

If your team wraps PrimeNG components (a shared app-select that extends Select, say), this section is where the hours go. Inheritance in v21 brings along far more than it did in v16.

6.1 Table’s row expansion template was renamed

What changed: the pTemplate keys rowexpansion and frozenrowexpansion are now expandedrow and frozenexpandedrow.

What breaks: unknown template names are ignored. Row expansion renders nothing, with no error.

Fix: grep -rn rowexpansion src.

6.2 pTemplate and #ref fill opposite fields per component

What changed: every template slot now has two fields, xTemplate and _xTemplate. On Table, pTemplate="header" fills headerTemplate. On Select, pTemplate="item" fills _itemTemplate, and #item fills itemTemplate.

What breaks: a forked template that reads only one of the two fields works for one style of consumer and silently drops the other.

Fix: in every forked template, read xTemplate || _xTemplate for every slot.

6.3 Base-class inputs became signals. Never assign to them.

What changed: the shared base input class now declares disabled, required, name, min, max, maxlength, invalid, size, variant and fluid as signal inputs. appendTo is a signal on Select, MultiSelect, AutoComplete and DatePicker.

What breaks: two ways.

  • A wrapper that forwards [appendTo]="appendTo" passes a function, not a value. The overlay falls back to absolute positioning and renders far from its field.
  • Assigning to one replaces the signal. The common v18-era alias, @Input('size') set customSize(v) { this.size = v; }, does exactly that, and the base’s own size() calls then throw. Same for this.select.disabled = true through @ViewChild.

Fix: read signals with (), and derive instead of assigning:

customSize = computed(() => this.size() ?? 'normal');

6.4 PrimeNG icons are attribute components now

What changed: built-in icons now use selectors like svg[data-p-icon="spinner"], and their only input is spin.

What breaks: a forked template with <SpinnerIcon [styleClass]="…"> renders nothing. Under a lenient schema, there’s no error.

Fix: <svg data-p-icon="spinner" class="…"></svg>.

6.5 Most form controls dropped the style input

What changed: Select, MultiSelect, AutoComplete, DatePicker and Checkbox no longer have a style input. A $style member exists, but it is the theme bundle, not inline styles.

What breaks: [ngStyle]="$style" in a forked template compiles and silently drops the consumer’s styles.

Fix: apply inline styles on the host, or use class.

6.6 BaseComponent owns every lifecycle hook

What changed: BaseComponent implements all eight Angular lifecycle hooks. ngOnInit loads the component’s CSS and sets its scoping attribute. Each ngXxx then calls a protected onXxx method.

What breaks: a subclass that overrides ngOnInit or ngOnDestroy without calling super skips style loading and theme-listener cleanup. Nothing errors.

Fix: override onInit, onChanges, onAfterViewInit and onDestroy instead.

6.7 Host bindings, host listeners and content queries are inherited

What changed: v21 components put root classes, p-disabled, p-focus, data-p-* and click listeners on the host. A subclass inherits all of it, even if it replaces the template.

What breaks:

  • Double borders and stacked disabled opacity (0.6 × 0.6) on wrapper hosts.
  • A (click) in your template that duplicates the inherited host listener fires twice. The panel opens and closes, or a switch never toggles.
  • Base classes declare @ContentChild('loadingicon') loadingIconTemplate. A subclass @Input() loadingIconTemplate inherits that query, which resets your input to undefined after content init.

Fix: scope theme rules with :not(app-select). Before adding any @Input to a subclass, check the base’s queries metadata in the .mjs file, and alias the input if the name collides.

7. Performance and unit tests

7.1 $style is rebuilt on every read

What changed: in v21, BaseComponent.$style is a getter that spreads a fresh object on every call, recursing up the parent-instance chain. Host bindings read it on every change-detection pass.

What breaks: on a table with many inline controls, we measured this getter at 43% of self time during a range change. A full change-detection pass went from 5.4 ms to 13.4 ms.

Fix: PrimeNG 22 caches it (_$styleCache in the 22.x source). On 21, you can patch the getter with a cache before bootstrap.

7.2 Every control’s host bindings got heavier

What changed: each control now computes cn(cx('root'), styleClass), sx('root') and data-p attributes on every pass. We measured roughly 48 µs per Select and 100 µs per DatePicker.

What breaks: patterns that were harmless on v16 become tick storms: in-zone setTimeouts in ngAfterViewInit, scroll and wheel listeners, and template layout reads like el.clientWidth < el.scrollWidth. We saw around 100 ticks per scroll step.

Fix: run listeners and timers with runOutsideAngular. For truncation tooltips, use Tooltip’s new showOnEllipsis instead of reading layout in the template.

7.3 Scroller init() is deferred

What changed: Scroller.init() now does its sizing work inside setTimeout(…, 1).

What breaks: calling setInitialState() then init() on a live scroller leaves a one-tick window. A scroll event in that window computes a NaN range, and the virtual list goes blank until the next scroll. It shows up mostly on Windows or with smooth scrolling off; launch Chrome with --disable-smooth-scrolling to reproduce.

Fix: don’t reset the state of a live scroller.

7.4 Config mocks must expose signals

What changed: components read config.pt(), config.unstyled(), config.ptOptions() and config.overlayAppendTo() as signals.

What breaks: a hand-written PrimeNG config mock with plain properties makes every spec that renders a styled component throw config.pt is not a function.

Fix: return signal(…) for each of those four in your test mock. The real default for overlayAppendTo is signal('self').

What I’d do differently next time

Three habits would have caught most of this list before QA did.

  1. Read the installed source, not just the guides. For any component you style or extend, open its .mjs in node_modules/primeng/fesm2022 and diff it against the old version. The defaults, class maps and host bindings are all in one file.
  2. Turn on strictTemplates before you start. Fix what it finds on a branch, even if you turn it back off.
  3. Test behaviour, not compilation. Open every overlay, close it with an outside click, tab through every dialog, and pick today’s date in every bounded date field.