Repository navigation
bug: a removed conditional :class reappears on Ionic components in Vue #31392
Description
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
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'
const IDS = ['a', 'b', 'c']
const selected = ref('a')
const App = {
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')
const settle = () => new Promise((r) => setTimeout(r, 250))
const snapshot = () => IDS
.map((id) => `${id}:${document.getElementById(`item-${id}`).classList.contains('is-active') ? 'ACTIVE' : '-'}`)
.join(' ')
await settle()
console.log('mounted (a): ', snapshot())
selected.value = 'b'; await settle()
console.log('after selecting b:', snapshot())
selected.value = 'c'; await settle()
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):
const classes = new Set(getComponentClasses(attrs.class)); // setup
…
return () => {
getComponentClasses(attrs.class).forEach((value) => {
classes.add(value); // render — never removes
});
…
const propsToAdd = {
…
class: syncElementClasses(containerRef, classes),
};
};syncElementClasses is new in v9 and force-adds every member of that Set to the host element on each render:
const syncElementClasses = (ref, componentClasses, defaultClasses = []) => {
if (ref?.value) {
const element = ref.value;
// makes sure vue classes are on the actual element
componentClasses.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:
const nextClasses = new Set(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).
Activity
thetaPC commentedon Aug 20, 2026
Thank you for reporting the issue! I was able to reproduce it.
The Vue wrapper keeps a list of every class it has ever seen on a component and re-applies all of them to the element on each render. Vue normally overwrites that a moment later, which is what removes the old class, but it skips that overwrite when the class list has not changed since the last render. On those renders the re-applied class is left behind.
One note on the fix: the code you patched is bundled into
@ionic/vuefrom@stencil/vue-output-target, so the real change has to land there and come back to us in a version bump. It is tracked upstream as stenciljs/output-targets#835 if you want to follow along. Your local patch is the right workaround in the meantime.We'll get to this as soon as we can.
ShaneK commentedon Aug 25, 2026
Weird that a cross-repo reference would close this. The dep still needs a version deploy and the version needs to be updated in framework
chore(deps): update stencil/core to 4.44.2 and vue-output-target to 0…
ShaneK commentedon Aug 26, 2026
Thanks for your report! This has been fixed in v9.0.1!
ionitron-bot commentedon Sep 25, 2026
Thanks for the issue! This issue is being locked to prevent comments that are not relevant to the original issue. If this is still an issue with the latest version of Ionic, please create a new issue and ensure the template is fully filled out.