Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions examples/app-vitest-full/components/GenericStateComponent.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<script setup lang="ts">
const { isPositive } = useCounter()
const { data } = useLazyAsyncData(async () => {
return isPositive() ? 'positive' : 'zero-or-negative'
})
</script>

<template>
<div v-if="data === 'positive'">
Positive count
</div>
<div v-else>
Zero or negative count
</div>
</template>
15 changes: 15 additions & 0 deletions examples/app-vitest-full/components/WatcherComponent.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<script setup lang="ts">
const { title } = defineProps<{ title: string }>()

const testState = useState('testState')

watch(testState, (newTestState) => {
console.log(`Test state has changed in component ${title}: `, newTestState)
})
</script>

<template>
<div>
{{ title }}
</div>
</template>
12 changes: 12 additions & 0 deletions examples/app-vitest-full/composables/useCounter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export const useCounter = () => {
const count = ref(0)

function isPositive() {
return count.value > 0
}

return {
count,
isPositive,
}
}
82 changes: 81 additions & 1 deletion examples/app-vitest-full/tests/nuxt/mount-suspended.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'

import { mountSuspended } from '@nuxt/test-utils/runtime'
import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime'
import { satisfies } from 'semver'
import { version as nuxtVersion } from 'nuxt/package.json'

Expand All @@ -25,11 +25,13 @@ import ComponentWithAttrs from '~/components/ComponentWithAttrs.vue'
import ComponentWithReservedProp from '~/components/ComponentWithReservedProp.vue'
import ComponentWithReservedState from '~/components/ComponentWithReservedState.vue'
import ComponentWithImports from '~/components/ComponentWithImports.vue'
import GenericStateComponent from '~/components/GenericStateComponent.vue'

import { BoundAttrs } from '#components'
import DirectiveComponent from '~/components/DirectiveComponent.vue'
import CustomComponent from '~/components/CustomComponent.vue'
import WrapperElement from '~/components/WrapperElement.vue'
import WatcherComponent from '~/components/WatcherComponent.vue'

const formats = {
ExportDefaultComponent,
Expand Down Expand Up @@ -454,3 +456,81 @@ it('element should be changed', async () => {

expect(component.element.tagName).toBe('SPAN')
})

describe('composable state isolation', () => {
const { useCounterMock } = vi.hoisted(() => {
return {
useCounterMock: vi.fn(() => {
return {
isPositive: (): boolean => false,
}
}),
}
})

mockNuxtImport('useCounter', () => {
return useCounterMock
})

it('shows zero or negative state by default', async () => {
const component = await mountSuspended(GenericStateComponent)
expect(component.text()).toMatchInlineSnapshot('"Zero or negative count"')
})

it('shows positive state when counter is positive', async () => {
useCounterMock.mockRestore()
useCounterMock.mockImplementation(() => ({
isPositive: () => true,
}))
const component = await mountSuspended(GenericStateComponent)
expect(component.text()).toMatchInlineSnapshot('"Positive count"')
})
})

describe('watcher cleanup validation', () => {
let watcherCallCount = 0
beforeEach(() => {
watcherCallCount = 0
// Mock console.log to count watcher calls
vi.spyOn(console, 'log').mockImplementation((message) => {
if (typeof message === 'string' && message.includes('Test state has changed')) {
watcherCallCount++
}
})
})

afterEach(() => {
vi.restoreAllMocks()
})

it('mounts component in test 1', async () => {
await mountSuspended(WatcherComponent, {
props: {
title: 'Component 1',
},
})

expect(watcherCallCount).toBe(0) // No state changes yet
})

it('mounts component in test 2 and validates watcher cleanup', async () => {
await mountSuspended(WatcherComponent, {
props: {
title: 'Component 2',
},
})

// Reset counter after mounting
watcherCallCount = 0

// Change the state - this should only trigger Component 2's watcher
const state = useState('testState')
state.value = 'new state'

await nextTick()

// Before the fix: would see 2 watcher calls (Component 1 and Component 2)
// After the fix: should only see 1 watcher call (Component 2 only)
expect(watcherCallCount).toBe(1)
})
})
38 changes: 34 additions & 4 deletions src/runtime-utils/mount.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { mount } from '@vue/test-utils'
import type { ComponentMountingOptions } from '@vue/test-utils'
import { Suspense, h, isReadonly, nextTick, reactive, unref, getCurrentInstance } from 'vue'
import { Suspense, h, isReadonly, nextTick, reactive, unref, getCurrentInstance, effectScope } from 'vue'
import type { ComponentInternalInstance, DefineComponent, SetupContext } from 'vue'
import { defu } from 'defu'
import type { RouteLocationRaw } from 'vue-router'
Expand Down Expand Up @@ -57,6 +57,11 @@ export async function mountSuspended<T>(
..._options
} = options || {}

// cleanup previously mounted test wrappers
for (const fn of globalThis.__cleanup || []) {
fn()
}

const vueApp = tryUseNuxtApp()?.vueApp
// @ts-expect-error untyped global __unctx__
|| globalThis.__unctx__.get('nuxt-app').tryUse().vueApp
Expand Down Expand Up @@ -104,13 +109,26 @@ export async function mountSuspended<T>(
}

let passedProps: Record<string, unknown>
let componentScope: ReturnType<typeof effectScope> | null = null

const wrappedSetup = async (props: Record<string, unknown>, setupContext: SetupContext): Promise<unknown> => {
interceptEmitOnCurrentInstance()

passedProps = props

if (setup) {
const result = await setup(props, setupContext)
// Create a new effect scope for the component's setup
componentScope = effectScope()

// Add component scope cleanup to global cleanup
globalThis.__cleanup ||= []
globalThis.__cleanup.push(() => {
componentScope?.stop()
})

const result = await componentScope.run(async () => {
return await setup(props, setupContext)
})
setupState = result && typeof result === 'object' ? result : {}
return result
}
Expand All @@ -122,10 +140,18 @@ export async function mountSuspended<T>(
{
setup: (props: Record<string, unknown>, ctx: SetupContext) => {
setupContext = ctx
return NuxtRoot.setup(props, {

const scope = effectScope()

globalThis.__cleanup ||= []
globalThis.__cleanup.push(() => {
scope.stop()
})

return scope.run(() => NuxtRoot.setup(props, {
...ctx,
expose: () => {},
})
}))
},
render: (renderContext: Record<string, unknown>) =>
h(
Expand Down Expand Up @@ -276,3 +302,7 @@ function wrappedMountedWrapper<T>(wrapper: ReturnType<typeof mount<T>> & { setup

return proxy
}

declare global {
var __cleanup: Array<() => void> | undefined
}
Loading