Ionic Framework Version
(Not present in v8.8.19.)
Current Behavior
In @ionic/vue 9, a class supplied by a conditional Vue binding can reappear on an Ionic component after it has correctly been removed.
syncElementClasses (new in v9) imperatively re-adds every class the component has ever received via attrs.class on every render. Vue's own patchClass normally overwrites the element's class afterwards — but Vue skips that patch when the newly computed class string is identical to the previous render's. On any such render the imperative re-add survives, and the stale class is back.
That makes it look intermittent: the class is dropped correctly on the render where the class string changes, then returns on the next render where it does not.
<ion-item :class="{ 'is-active': selected===id }">…</ion-item>Select a → b and a correctly loses is-active. Select b → c and a gets is-active back, so two items are active at once.
This is a behaviour change from v8, where the same template worked correctly.
Expected Behavior
A class supplied by a conditional binding should stay removed once the binding stops producing it, as in v8 and as for non-Ionic elements.
Steps to Reproduce
ionic start stickyClass blank --type=vue- Install
@ionic/vue@9.0.0 / @ionic/vue-router@9.0.0 / @ionic/core@9.0.0. - Render three
ion-items driven by one selection ref — three are needed, because the bug only shows on the second deselection render:
import{IonicVue,IonItem,IonList}from'@ionic/vue'import{createApp,h,ref}from'vue'import'@ionic/vue/css/core.css'constIDS=['a','b','c']constselected=ref('a')constApp={setup(){return()=>h(IonList,null,{default: ()=>IDS.map((id)=>h(IonItem,{id: `item-${id}`,class: {'is-active': selected.value===id}},{default: ()=>`item ${id}`},)),})},}createApp(App).use(IonicVue).mount('#app')constsettle=()=>newPromise((r)=>setTimeout(r,250))constsnapshot=()=>IDS.map((id)=>`${id}:${document.getElementById(`item-${id}`).classList.contains('is-active') ? 'ACTIVE' : '-'}`).join(' ')awaitsettle()console.log('mounted (a): ',snapshot())selected.value='b';awaitsettle()console.log('after selecting b:',snapshot())selected.value='c';awaitsettle()console.log('after selecting c:',snapshot())- Read the console.
Actual on 9.0.0:
mounted (a): a:ACTIVE b:- c:-
after selecting b: a:- b:ACTIVE c:- <- correctly removed
after selecting c: a:ACTIVE b:- c:ACTIVE <- 'a' is active again
Expected (and what 8.8.19 produces): exactly one active item at every step.
Only the @ionic/vue version changes between the two runs; everything else is identical.
Code Reproduction URL
No Stackblitz link, sorry — but the reproduction is fully inlined in Steps to Reproduce above and needs nothing beyond a stock ionic start … --type=vue app with that snippet pasted into a single page. The behaviour also flips purely on the @ionic/vue version, with no other change. Happy to put it on Stackblitz if that would help triage.
Ionic Info
Ionic:
Ionic CLI : 7.2.1
Ionic Framework : @ionic/vue 9.0.0
Capacitor:
Capacitor CLI : 8.5.0
@capacitor/android : 8.5.0
@capacitor/core : 8.5.0
@capacitor/ios : 8.5.0
System:
NodeJS : v26.7.0
npm : 11.19.0
OS : macOS
Additional Information
Cause
defineContainer keeps a Set of the classes Vue has supplied, and that Set is only ever added to (@ionic/vue/dist/index.js):
constclasses=newSet(getComponentClasses(attrs.class));// setup…return()=>{getComponentClasses(attrs.class).forEach((value)=>{classes.add(value);// render — never removes});…constpropsToAdd={…class: syncElementClasses(containerRef,classes),};};syncElementClasses is new in v9 and force-adds every member of that Set to the host element on each render:
constsyncElementClasses=(ref,componentClasses,defaultClasses=[])=>{if(ref?.value){constelement=ref.value;// makes sure vue classes are on the actual elementcomponentClasses.forEach((c)=>{if(!!c&&!element.classList.contains(c)){element.classList.add(c);}});}return[...Array.from(ref.value?.classList||[]), ...defaultClasses].filter((c,i,self)=>{return!componentClasses.has(c)&&self.indexOf(c)===i;});};In v8 the equivalent helper (getElementClasses) never touched the DOM — it only computed a list that was handed back to Vue, so Vue's own class patching handled both add and remove. In v9 the imperative add has no counterpart. It is normally masked because patchClass rewrites el.className wholesale straight afterwards, but Vue skips that when the computed class string is unchanged from the previous render — and then the re-added stale class survives.
Suggested fix
Track the classes from the current render rather than accumulating, and remove the ones that are no longer bound:
constnextClasses=newSet(getComponentClasses(attrs.class));if(containerRef.value){classes.forEach((value)=>{if(!!value&&!nextClasses.has(value)){containerRef.value.classList.remove(value);}});}classes.clear();nextClasses.forEach((value)=>classes.add(value));Impact
In our app this affects 68 conditional :class bindings on Ionic components across 51 files, so it cannot be worked around in templates. It failed 7 of 40 E2E shards, typically as duplicated "active"/"selected" state — e.g. two side-menu ion-items both carrying is_active after navigating through three tabs, and an ion-item that still had is_selected. Because it depends on two consecutive renders producing the same class string, it reads as flakiness rather than a deterministic bug, which made it expensive to track down.
We are currently shipping the patch above as a local pnpm patch.
Found while upgrading one app from 8.8.19 to 9.0.0; two more regressions from the same release: #31393 (getComponentClasses whitespace split, same helper) and #31394 (ion-radio value reflect).
Ionic Framework Version
(Not present in v8.8.19.)
Current Behavior
In
@ionic/vue9, a class supplied by a conditional Vue binding can reappear on an Ionic component after it has correctly been removed.syncElementClasses(new in v9) imperatively re-adds every class the component has ever received viaattrs.classon every render. Vue's ownpatchClassnormally overwrites the element's class afterwards — but Vue skips that patch when the newly computed class string is identical to the previous render's. On any such render the imperative re-add survives, and the stale class is back.That makes it look intermittent: the class is dropped correctly on the render where the class string changes, then returns on the next render where it does not.
Select a → b and
acorrectly losesis-active. Select b → c andagetsis-activeback, so two items are active at once.This is a behaviour change from v8, where the same template worked correctly.
Expected Behavior
A class supplied by a conditional binding should stay removed once the binding stops producing it, as in v8 and as for non-Ionic elements.
Steps to Reproduce
ionic start stickyClass blank --type=vue@ionic/vue@9.0.0/@ionic/vue-router@9.0.0/@ionic/core@9.0.0.ion-items driven by one selection ref — three are needed, because the bug only shows on the second deselection render:Actual on 9.0.0:
Expected (and what 8.8.19 produces): exactly one active item at every step.
Only the
@ionic/vueversion changes between the two runs; everything else is identical.Code Reproduction URL
No Stackblitz link, sorry — but the reproduction is fully inlined in Steps to Reproduce above and needs nothing beyond a stock
ionic start … --type=vueapp with that snippet pasted into a single page. The behaviour also flips purely on the@ionic/vueversion, with no other change. Happy to put it on Stackblitz if that would help triage.Ionic Info
Additional Information
Cause
defineContainerkeeps aSetof the classes Vue has supplied, and that Set is only ever added to (@ionic/vue/dist/index.js):syncElementClassesis new in v9 and force-adds every member of that Set to the host element on each render:In v8 the equivalent helper (
getElementClasses) never touched the DOM — it only computed a list that was handed back to Vue, so Vue's own class patching handled both add and remove. In v9 the imperative add has no counterpart. It is normally masked becausepatchClassrewritesel.classNamewholesale straight afterwards, but Vue skips that when the computed class string is unchanged from the previous render — and then the re-added stale class survives.Suggested fix
Track the classes from the current render rather than accumulating, and remove the ones that are no longer bound:
Impact
In our app this affects 68 conditional
:classbindings on Ionic components across 51 files, so it cannot be worked around in templates. It failed 7 of 40 E2E shards, typically as duplicated "active"/"selected" state — e.g. two side-menuion-items both carryingis_activeafter navigating through three tabs, and anion-itemthat still hadis_selected. Because it depends on two consecutive renders producing the same class string, it reads as flakiness rather than a deterministic bug, which made it expensive to track down.We are currently shipping the patch above as a local
pnpm patch.Found while upgrading one app from 8.8.19 to 9.0.0; two more regressions from the same release: #31393 (
getComponentClasseswhitespace split, same helper) and #31394 (ion-radiovalue reflect).