fix(stage-ui): keep stores off disposed Pinias (#3201)

This commit is contained in:
Neko
2026-10-28 23:50:24 +08:00
committed by GitHub
parent e943c70d79
commit 927d1facc1
3 changed files with 47 additions and 9 deletions
@@ -276,3 +276,39 @@ describe('issue #3124 restores the active Character after a restart', () => {
expect(localStorage.getItem(activeCardIdKey)).toBeNull()
})
})
// ROOT CAUSE:
//
// `initialize()` looked up the module stores after an `await`, through the active Pinia.
// A stray action of a disposed Pinia could make that one active, so CI failed with `Pinia destroyed`.
//
// We fixed this by passing the card store's own Pinia to these lookups.
describe('airi card store keeps to its own Pinia', () => {
afterEach(() => {
localStorage.clear()
})
it('initializes while another Pinia becomes active', async () => {
const stale = createPinia()
createApp({}).use(stale)
disposePinia(stale)
const pinia = createPinia()
// Stands in for that stray action: every action of this Pinia hands the active Pinia back to the stale one.
pinia.use(({ store }) => {
store.$onAction(({ after, onError }) => {
after(() => setActivePinia(stale))
onError(() => setActivePinia(stale))
})
})
createApp({}).use(pinia).use(PiniaColada)
setActivePinia(pinia)
const cardStore = useAiriCardStore()
const initialized = cardStore.initialize()
setActivePinia(stale)
await expect(initialized).resolves.toBeUndefined()
expect(cardStore.cards.has('default')).toBe(true)
disposePinia(pinia)
})
})
@@ -226,10 +226,10 @@ export const useAiriCardStore = defineStore('airi-card', () => {
function useRuntimeModuleStores() {
return {
artistry: useArtistryStore(),
consciousness: useConsciousnessStore(),
speech: useSpeechStore(),
vision: useVisionStore(),
artistry: useArtistryStore(pinia),
consciousness: useConsciousnessStore(pinia),
speech: useSpeechStore(pinia),
vision: useVisionStore(pinia),
}
}
@@ -353,7 +353,7 @@ export const useAiriCardStore = defineStore('airi-card', () => {
/** Loads the effective auth-owned voice catalog after card setup finishes. */
async function loadAuthenticatedSpeechVoices(): Promise<void> {
const { speech } = useRuntimeModuleStores()
const provider = useProviderConfigStore().providers[speech.activeSpeechProvider]
const provider = useProviderConfigStore(pinia).providers[speech.activeSpeechProvider]
if (provider?.configuredBy !== 'authentication')
return
@@ -647,14 +647,14 @@ export const useAiriCardStore = defineStore('airi-card', () => {
*/
async function selectActiveCardVisionProvider(provider: string) {
await pendingAuthenticationSetup
const vision = useVisionStore()
const vision = useVisionStore(pinia)
vision.enabled = true
if (vision.activeProvider === provider)
return true
vision.activeProvider = provider
vision.resetModelSelection()
await vision.loadModelsForProvider(provider)
const model = useProviderStore().getDefaultModelForProvider(provider) ?? ''
const model = useProviderStore(pinia).getDefaultModelForProvider(provider) ?? ''
return await updateActiveCardVision({ provider, model })
}
@@ -1383,7 +1383,7 @@ export const useAiriCardStore = defineStore('airi-card', () => {
),
},
}
const providers = useProviderConfigStore().providers
const providers = useProviderConfigStore(pinia).providers
for (const module of ['consciousness', 'vision', 'speech'] as const) {
const provider = providers[resolved[module].provider]
// Logout disables authenticated providers without deleting card choices.
@@ -1422,7 +1422,7 @@ export const useAiriCardStore = defineStore('airi-card', () => {
rememberInheritedSettings()
// The runtime holds a mix of two cards until the write completes. See followInheritedSettings.
appliedModules = undefined
const artistry = useArtistryStore()
const artistry = useArtistryStore(pinia)
artistry.resetToGlobal()
@@ -42,6 +42,8 @@ export const useSettingsStageModel = defineStore('settings-stage-model', () => {
let disposed = false
onScopeDispose(() => {
disposed = true
// Cancel a pending update. Its next store action would make this disposed Pinia active again.
stageModelUpdateSequence++
})
const stageModelSelected = computed<string>({
get: () => stageModelSelectedState.value,