Skip to content

bug: a removed conditional :class reappears on Ionic components in Vue #31392

Description

@maximilianschmid

Ionic Framework Version

  • v9.x
  • Nightly

(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

  1. ionic start stickyClass blank --type=vue
  2. Install @ionic/vue@9.0.0 / @ionic/vue-router@9.0.0 / @ionic/core@9.0.0.
  3. 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())
  1. 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));// setupreturn()=>{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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions