import type { HMRRuntime } from '../src/hmr'
import '../src/hmr'
import type { ComponentOptions, InternalRenderFunction } from '../src/component'
import {
type TestElement,
h,
nextTick,
nodeOps,
ref,
render,
serializeInner,
triggerEvent,
} from '@vue/runtime-test'
import * as runtimeTest from '@vue/runtime-test'
import { createApp, registerRuntimeCompiler } from '@vue/runtime-test'
import { baseCompile } from '@vue/compiler-core'
declare var __VUE_HMR_RUNTIME__: HMRRuntime
const { createRecord, rerender, reload } = __VUE_HMR_RUNTIME__
registerRuntimeCompiler(compileToFunction)
function compileToFunction(template: string) {
const { code } = baseCompile(template, { hoistStatic: true, hmr: true })
const render = new Function('Vue', code)(
runtimeTest,
) as InternalRenderFunction
render._rc = true // isRuntimeCompiled
return render
}
const timeout = (n: number = 0) => new Promise(r => setTimeout(r, n))
describe('hot module replacement', () => {
test('inject global runtime', () => {
expect(createRecord).toBeDefined()
expect(rerender).toBeDefined()
expect(reload).toBeDefined()
})
test('createRecord', () => {
expect(createRecord('test1', {})).toBe(true)
// if id has already been created, should return false
expect(createRecord('test1', {})).toBe(false)
})
test('rerender', async () => {
const root = nodeOps.createElement('div')
const parentId = 'test2-parent'
const childId = 'test2-child'
const Child: ComponentOptions = {
__hmrId: childId,
render: compileToFunction(`
`),
}
createRecord(childId, Child)
const Parent: ComponentOptions = {
__hmrId: parentId,
data() {
return { count: 0 }
},
components: { Child },
render: compileToFunction(
`{{ count }}{{ count }}
`,
),
}
createRecord(parentId, Parent)
render(h(Parent), root)
expect(serializeInner(root)).toBe(``)
// Perform some state change. This change should be preserved after the
// re-render!
triggerEvent(root.children[0] as TestElement, 'click')
await nextTick()
expect(serializeInner(root)).toBe(``)
// // Update text while preserving state
rerender(
parentId,
compileToFunction(
`{{ count }}!{{ count }}
`,
),
)
expect(serializeInner(root)).toBe(``)
// Should force child update on slot content change
rerender(
parentId,
compileToFunction(
`{{ count }}!{{ count }}!
`,
),
)
expect(serializeInner(root)).toBe(``)
// Should force update element children despite block optimization
rerender(
parentId,
compileToFunction(
`{{ count }}{{ count }}
{{ count }}!
`,
),
)
expect(serializeInner(root)).toBe(``)
// Should force update child slot elements
rerender(
parentId,
compileToFunction(
`
{{ count }}
`,
),
)
expect(serializeInner(root)).toBe(``)
})
test('reload', async () => {
const root = nodeOps.createElement('div')
const childId = 'test3-child'
const unmountSpy = vi.fn()
const mountSpy = vi.fn()
const Child: ComponentOptions = {
__hmrId: childId,
data() {
return { count: 0 }
},
unmounted: unmountSpy,
render: compileToFunction(`{{ count }}
`),
}
createRecord(childId, Child)
const Parent: ComponentOptions = {
render: () => h(Child),
}
render(h(Parent), root)
expect(serializeInner(root)).toBe(`0
`)
reload(childId, {
__hmrId: childId,
data() {
return { count: 1 }
},
mounted: mountSpy,
render: compileToFunction(`{{ count }}
`),
})
await nextTick()
expect(serializeInner(root)).toBe(`1
`)
expect(unmountSpy).toHaveBeenCalledTimes(1)
expect(mountSpy).toHaveBeenCalledTimes(1)
})
// #7042
test('reload KeepAlive slot', async () => {
const root = nodeOps.createElement('div')
const childId = 'test-child-keep-alive'
const unmountSpy = vi.fn()
const mountSpy = vi.fn()
const activeSpy = vi.fn()
const deactiveSpy = vi.fn()
const Child: ComponentOptions = {
__hmrId: childId,
data() {
return { count: 0 }
},
unmounted: unmountSpy,
render: compileToFunction(`{{ count }}
`),
}
createRecord(childId, Child)
const Parent: ComponentOptions = {
components: { Child },
data() {
return { toggle: true }
},
render: compileToFunction(
`
`,
),
}
render(h(Parent), root)
expect(serializeInner(root)).toBe(`0
`)
reload(childId, {
__hmrId: childId,
data() {
return { count: 1 }
},
mounted: mountSpy,
unmounted: unmountSpy,
activated: activeSpy,
deactivated: deactiveSpy,
render: compileToFunction(`{{ count }}
`),
})
await nextTick()
expect(serializeInner(root)).toBe(`1
`)
expect(unmountSpy).toHaveBeenCalledTimes(1)
expect(mountSpy).toHaveBeenCalledTimes(1)
expect(activeSpy).toHaveBeenCalledTimes(1)
expect(deactiveSpy).toHaveBeenCalledTimes(0)
// should not unmount when toggling
triggerEvent(root.children[1] as TestElement, 'click')
await nextTick()
expect(unmountSpy).toHaveBeenCalledTimes(1)
expect(mountSpy).toHaveBeenCalledTimes(1)
expect(activeSpy).toHaveBeenCalledTimes(1)
expect(deactiveSpy).toHaveBeenCalledTimes(1)
// should not mount when toggling
triggerEvent(root.children[1] as TestElement, 'click')
await nextTick()
expect(unmountSpy).toHaveBeenCalledTimes(1)
expect(mountSpy).toHaveBeenCalledTimes(1)
expect(activeSpy).toHaveBeenCalledTimes(2)
expect(deactiveSpy).toHaveBeenCalledTimes(1)
})
// #7121
test('reload KeepAlive slot in Transition', async () => {
const root = nodeOps.createElement('div')
const childId = 'test-transition-keep-alive-reload'
const unmountSpy = vi.fn()
const mountSpy = vi.fn()
const activeSpy = vi.fn()
const deactiveSpy = vi.fn()
const Child: ComponentOptions = {
__hmrId: childId,
data() {
return { count: 0 }
},
unmounted: unmountSpy,
render: compileToFunction(`{{ count }}
`),
}
createRecord(childId, Child)
const Parent: ComponentOptions = {
components: { Child },
data() {
return { toggle: true }
},
render: compileToFunction(
`
`,
),
}
render(h(Parent), root)
expect(serializeInner(root)).toBe(`0
`)
reload(childId, {
__hmrId: childId,
data() {
return { count: 1 }
},
mounted: mountSpy,
unmounted: unmountSpy,
activated: activeSpy,
deactivated: deactiveSpy,
render: compileToFunction(`{{ count }}
`),
})
await nextTick()
expect(serializeInner(root)).toBe(`1
`)
expect(unmountSpy).toHaveBeenCalledTimes(1)
expect(mountSpy).toHaveBeenCalledTimes(1)
expect(activeSpy).toHaveBeenCalledTimes(1)
expect(deactiveSpy).toHaveBeenCalledTimes(0)
// should not unmount when toggling
triggerEvent(root.children[1] as TestElement, 'click')
await nextTick()
expect(serializeInner(root)).toBe(` `)
expect(unmountSpy).toHaveBeenCalledTimes(1)
expect(mountSpy).toHaveBeenCalledTimes(1)
expect(activeSpy).toHaveBeenCalledTimes(1)
expect(deactiveSpy).toHaveBeenCalledTimes(1)
// should not mount when toggling
triggerEvent(root.children[1] as TestElement, 'click')
await nextTick()
expect(serializeInner(root)).toBe(`1
`)
expect(unmountSpy).toHaveBeenCalledTimes(1)
expect(mountSpy).toHaveBeenCalledTimes(1)
expect(activeSpy).toHaveBeenCalledTimes(2)
expect(deactiveSpy).toHaveBeenCalledTimes(1)
})
test('reload KeepAlive slot in Transition with out-in', async () => {
const root = nodeOps.createElement('div')
const childId = 'test-transition-keep-alive-reload-with-out-in'
const unmountSpy = vi.fn()
const mountSpy = vi.fn()
const activeSpy = vi.fn()
const deactiveSpy = vi.fn()
const Child: ComponentOptions = {
__hmrId: childId,
name: 'original',
data() {
return { count: 0 }
},
unmounted: unmountSpy,
render: compileToFunction(`{{ count }}
`),
}
createRecord(childId, Child)
const Parent: ComponentOptions = {
components: { Child },
data() {
return { toggle: true }
},
methods: {
// @ts-expect-error
onLeave(_, done) {
setTimeout(done, 0)
},
},
render: compileToFunction(
`
`,
),
}
render(h(Parent), root)
expect(serializeInner(root)).toBe(`0
`)
reload(childId, {
__hmrId: childId,
name: 'updated',
data() {
return { count: 1 }
},
mounted: mountSpy,
unmounted: unmountSpy,
activated: activeSpy,
deactivated: deactiveSpy,
render: compileToFunction(`{{ count }}
`),
})
await nextTick()
await new Promise(r => setTimeout(r, 0))
expect(serializeInner(root)).toBe(`1
`)
expect(unmountSpy).toHaveBeenCalledTimes(1)
expect(mountSpy).toHaveBeenCalledTimes(1)
expect(activeSpy).toHaveBeenCalledTimes(1)
expect(deactiveSpy).toHaveBeenCalledTimes(0)
// should not unmount when toggling
triggerEvent(root.children[1] as TestElement, 'click')
await nextTick()
await new Promise(r => setTimeout(r, 0))
expect(serializeInner(root)).toBe(` `)
expect(unmountSpy).toHaveBeenCalledTimes(1)
expect(mountSpy).toHaveBeenCalledTimes(1)
expect(activeSpy).toHaveBeenCalledTimes(1)
expect(deactiveSpy).toHaveBeenCalledTimes(1)
// should not mount when toggling
triggerEvent(root.children[1] as TestElement, 'click')
await nextTick()
expect(serializeInner(root)).toBe(`1
`)
expect(unmountSpy).toHaveBeenCalledTimes(1)
expect(mountSpy).toHaveBeenCalledTimes(1)
expect(activeSpy).toHaveBeenCalledTimes(2)
expect(deactiveSpy).toHaveBeenCalledTimes(1)
})
test('reload class component', async () => {
const root = nodeOps.createElement('div')
const childId = 'test4-child'
const unmountSpy = vi.fn()
const mountSpy = vi.fn()
class Child {
static __vccOpts: ComponentOptions = {
__hmrId: childId,
data() {
return { count: 0 }
},
unmounted: unmountSpy,
render: compileToFunction(`{{ count }}
`),
}
}
createRecord(childId, Child)
const Parent: ComponentOptions = {
render: () => h(Child),
}
render(h(Parent), root)
expect(serializeInner(root)).toBe(`0
`)
class UpdatedChild {
static __vccOpts: ComponentOptions = {
__hmrId: childId,
data() {
return { count: 1 }
},
mounted: mountSpy,
render: compileToFunction(`{{ count }}
`),
}
}
reload(childId, UpdatedChild)
await nextTick()
expect(serializeInner(root)).toBe(`1
`)
expect(unmountSpy).toHaveBeenCalledTimes(1)
expect(mountSpy).toHaveBeenCalledTimes(1)
})
// #6930
test('reload: avoid infinite recursion', async () => {
const root = nodeOps.createElement('div')
const childId = 'test-child-6930'
const unmountSpy = vi.fn()
const mountSpy = vi.fn()
const Child: ComponentOptions = {
__hmrId: childId,
data() {
return { count: 0 }
},
expose: ['count'],
unmounted: unmountSpy,
render: compileToFunction(`{{ count }}
`),
}
createRecord(childId, Child)
const Parent: ComponentOptions = {
setup() {
const com1 = ref()
const changeRef1 = (value: any) => (com1.value = value)
const com2 = ref()
const changeRef2 = (value: any) => (com2.value = value)
return () => [
h(Child, { ref: changeRef1 }),
h(Child, { ref: changeRef2 }),
com1.value?.count,
]
},
}
render(h(Parent), root)
await nextTick()
expect(serializeInner(root)).toBe(`0
0
0`)
reload(childId, {
__hmrId: childId,
data() {
return { count: 1 }
},
mounted: mountSpy,
render: compileToFunction(`{{ count }}
`),
})
await nextTick()
expect(serializeInner(root)).toBe(`1
1
1`)
expect(unmountSpy).toHaveBeenCalledTimes(2)
expect(mountSpy).toHaveBeenCalledTimes(2)
})
// #1156 - static nodes should retain DOM element reference across updates
// when HMR is active
test('static el reference', async () => {
const root = nodeOps.createElement('div')
const id = 'test-static-el'
const template = ``
const Comp: ComponentOptions = {
__hmrId: id,
data() {
return { count: 0 }
},
render: compileToFunction(template),
}
createRecord(id, Comp)
render(h(Comp), root)
expect(serializeInner(root)).toBe(
``,
)
// 1. click to trigger update
triggerEvent((root as any).children[0].children[1], 'click')
await nextTick()
expect(serializeInner(root)).toBe(
``,
)
// 2. trigger HMR
rerender(
id,
compileToFunction(template.replace(`1
++ `,
)
})
// #1157 - component should force full props update when HMR is active
test('force update child component w/ static props', () => {
const root = nodeOps.createElement('div')
const parentId = 'test-force-props-parent'
const childId = 'test-force-props-child'
const Child: ComponentOptions = {
__hmrId: childId,
props: {
msg: String,
},
render: compileToFunction(`{{ msg }}
`),
}
createRecord(childId, Child)
const Parent: ComponentOptions = {
__hmrId: parentId,
components: { Child },
render: compileToFunction(` `),
}
createRecord(parentId, Parent)
render(h(Parent), root)
expect(serializeInner(root)).toBe(`foo
`)
rerender(parentId, compileToFunction(` `))
expect(serializeInner(root)).toBe(`bar
`)
})
// #1305 - component should remove class
test('remove static class from parent', () => {
const root = nodeOps.createElement('div')
const parentId = 'test-force-class-parent'
const childId = 'test-force-class-child'
const Child: ComponentOptions = {
__hmrId: childId,
render: compileToFunction(`child
`),
}
createRecord(childId, Child)
const Parent: ComponentOptions = {
__hmrId: parentId,
components: { Child },
render: compileToFunction(` `),
}
createRecord(parentId, Parent)
render(h(Parent), root)
expect(serializeInner(root)).toBe(`child
`)
rerender(parentId, compileToFunction(` `))
expect(serializeInner(root)).toBe(`child
`)
})
test('rerender if any parent in the parent chain', () => {
const root = nodeOps.createElement('div')
const parent = 'test-force-props-parent-'
const childId = 'test-force-props-child'
const numberOfParents = 5
const Child: ComponentOptions = {
__hmrId: childId,
render: compileToFunction(`child
`),
}
createRecord(childId, Child)
const components: ComponentOptions[] = []
for (let i = 0; i < numberOfParents; i++) {
const parentId = `${parent}${i}`
const parentComp: ComponentOptions = {
__hmrId: parentId,
}
components.push(parentComp)
if (i === 0) {
parentComp.render = compileToFunction(` `)
parentComp.components = {
Child,
}
} else {
parentComp.render = compileToFunction(` `)
parentComp.components = {
Parent: components[i - 1],
}
}
createRecord(parentId, parentComp)
}
const last = components[components.length - 1]
render(h(last), root)
expect(serializeInner(root)).toBe(`child
`)
rerender(last.__hmrId!, compileToFunction(` `))
expect(serializeInner(root)).toBe(`child
`)
})
// #3302
test('rerender with Teleport', () => {
const root = nodeOps.createElement('div')
const target = nodeOps.createElement('div')
const parentId = 'parent-teleport'
const Child: ComponentOptions = {
data() {
return {
// style is used to ensure that the div tag will be tracked by Teleport
style: {},
target,
}
},
render: compileToFunction(`
`),
}
const Parent: ComponentOptions = {
__hmrId: parentId,
components: { Child },
render: compileToFunction(`
1
`),
}
createRecord(parentId, Parent)
render(h(Parent), root)
expect(serializeInner(root)).toBe(
``,
)
expect(serializeInner(target)).toBe(``)
rerender(
parentId,
compileToFunction(`
1
2
`),
)
expect(serializeInner(root)).toBe(
``,
)
expect(serializeInner(target)).toBe(
``,
)
})
// #4174
test('with global mixins', async () => {
const childId = 'hmr-global-mixin'
const createSpy1 = vi.fn()
const createSpy2 = vi.fn()
const Child: ComponentOptions = {
__hmrId: childId,
created: createSpy1,
render() {
return h('div')
},
}
createRecord(childId, Child)
const Parent: ComponentOptions = {
render: () => h(Child),
}
const app = createApp(Parent)
app.mixin({})
const root = nodeOps.createElement('div')
app.mount(root)
expect(createSpy1).toHaveBeenCalledTimes(1)
expect(createSpy2).toHaveBeenCalledTimes(0)
reload(childId, {
__hmrId: childId,
created: createSpy2,
render() {
return h('div')
},
})
await nextTick()
expect(createSpy1).toHaveBeenCalledTimes(1)
expect(createSpy2).toHaveBeenCalledTimes(1)
})
// #4757
test('rerender for component that has no active instance yet', () => {
const id = 'no-active-instance-rerender'
const Foo: ComponentOptions = {
__hmrId: id,
render: () => 'foo',
}
createRecord(id, Foo)
rerender(id, () => 'bar')
const root = nodeOps.createElement('div')
render(h(Foo), root)
expect(serializeInner(root)).toBe('bar')
})
test('reload for component that has no active instance yet', () => {
const id = 'no-active-instance-reload'
const Foo: ComponentOptions = {
__hmrId: id,
render: () => 'foo',
}
createRecord(id, Foo)
reload(id, {
__hmrId: id,
render: () => 'bar',
})
const root = nodeOps.createElement('div')
render(h(Foo), root)
expect(serializeInner(root)).toBe('bar')
})
// #7155 - force HMR on slots content update
test('force update slot content change', () => {
const root = nodeOps.createElement('div')
const parentId = 'test-force-computed-parent'
const childId = 'test-force-computed-child'
const Child: ComponentOptions = {
__hmrId: childId,
computed: {
slotContent() {
return this.$slots.default?.()
},
},
render: compileToFunction(` `),
}
createRecord(childId, Child)
const Parent: ComponentOptions = {
__hmrId: parentId,
components: { Child },
render: compileToFunction(`1 `),
}
createRecord(parentId, Parent)
render(h(Parent), root)
expect(serializeInner(root)).toBe(`1`)
rerender(parentId, compileToFunction(`2 `))
expect(serializeInner(root)).toBe(`2`)
})
// #6978, #7138, #7114
test('hoisted children array inside v-for', () => {
const root = nodeOps.createElement('div')
const appId = 'test-app-id'
const App: ComponentOptions = {
__hmrId: appId,
render: compileToFunction(
`
2
3
`,
),
}
createRecord(appId, App)
render(h(App), root)
expect(serializeInner(root)).toBe(
`2
3
`,
)
// move the 3
into the 1
rerender(
appId,
compileToFunction(
`
2
`,
),
)
expect(serializeInner(root)).toBe(
`2
`,
)
})
// #11248
test('reload async component with multiple instances', async () => {
const root = nodeOps.createElement('div')
const childId = 'test-child-id'
const Child: ComponentOptions = {
__hmrId: childId,
data() {
return { count: 0 }
},
render: compileToFunction(`{{ count }}
`),
}
const Comp = runtimeTest.defineAsyncComponent(() => Promise.resolve(Child))
const appId = 'test-app-id'
const App: ComponentOptions = {
__hmrId: appId,
render: () => [h(Comp), h(Comp)],
}
createRecord(appId, App)
render(h(App), root)
await timeout()
expect(serializeInner(root)).toBe(`0
0
`)
// change count to 1
reload(childId, {
__hmrId: childId,
data() {
return { count: 1 }
},
render: compileToFunction(`{{ count }}
`),
})
await timeout()
expect(serializeInner(root)).toBe(`1
1
`)
})
test('reload async child wrapped in Suspense + KeepAlive', async () => {
const id = 'async-child-reload'
const AsyncChild: ComponentOptions = {
__hmrId: id,
async setup() {
await nextTick()
return () => 'foo'
},
}
createRecord(id, AsyncChild)
const appId = 'test-app-id'
const App: ComponentOptions = {
__hmrId: appId,
components: { AsyncChild },
render: compileToFunction(`
`),
}
const root = nodeOps.createElement('div')
render(h(App), root)
expect(serializeInner(root)).toBe('
')
await timeout()
expect(serializeInner(root)).toBe('foo
')
reload(id, {
__hmrId: id,
async setup() {
await nextTick()
return () => 'bar'
},
})
await timeout()
expect(serializeInner(root)).toBe('bar
')
})
test('multi reload child wrapped in Suspense + KeepAlive', async () => {
const id = 'test-child-reload-3'
const Child: ComponentOptions = {
__hmrId: id,
setup() {
const count = ref(0)
return { count }
},
render: compileToFunction(`{{ count }}
`),
}
createRecord(id, Child)
const appId = 'test-app-id'
const App: ComponentOptions = {
__hmrId: appId,
components: { Child },
render: compileToFunction(`
`),
}
const root = nodeOps.createElement('div')
render(h(App), root)
expect(serializeInner(root)).toBe('0
')
await timeout()
reload(id, {
__hmrId: id,
setup() {
const count = ref(1)
return { count }
},
render: compileToFunction(`{{ count }}
`),
})
await timeout()
expect(serializeInner(root)).toBe('1
')
reload(id, {
__hmrId: id,
setup() {
const count = ref(2)
return { count }
},
render: compileToFunction(`{{ count }}
`),
})
await timeout()
expect(serializeInner(root)).toBe('2
')
})
test('rerender for nested component', () => {
const id = 'child-nested-rerender'
const Foo: ComponentOptions = {
__hmrId: id,
render() {
return this.$slots.default()
},
}
createRecord(id, Foo)
const parentId = 'parent-nested-rerender'
const Parent: ComponentOptions = {
__hmrId: parentId,
render() {
return h(Foo, null, {
default: () => this.$slots.default(),
_: 3 /* FORWARDED */,
})
},
}
const appId = 'app-nested-rerender'
const App: ComponentOptions = {
__hmrId: appId,
render: () =>
h(Parent, null, {
default: () => [
h(Foo, null, {
default: () => ['foo'],
}),
],
}),
}
createRecord(parentId, App)
const root = nodeOps.createElement('div')
render(h(App), root)
expect(serializeInner(root)).toBe('foo')
rerender(id, () => 'bar')
expect(serializeInner(root)).toBe('bar')
})
// https://github.com/vitejs/vite-plugin-vue/issues/599
// Both Outer and Inner are reloaded when './server.js' changes
test('reload nested components from single update', async () => {
const innerId = 'nested-reload-inner'
const outerId = 'nested-reload-outer'
let Inner = {
__hmrId: innerId,
render() {
return h('div', 'foo')
},
}
let Outer = {
__hmrId: outerId,
render() {
return h(Inner)
},
}
createRecord(innerId, Inner)
createRecord(outerId, Outer)
const App = {
render: () => h(Outer),
}
const root = nodeOps.createElement('div')
render(h(App), root)
expect(serializeInner(root)).toBe('foo
')
Inner = {
__hmrId: innerId,
render() {
return h('div', 'bar')
},
}
Outer = {
__hmrId: outerId,
render() {
return h(Inner)
},
}
// trigger reload for both Outer and Inner
reload(outerId, Outer)
reload(innerId, Inner)
await nextTick()
expect(serializeInner(root)).toBe('bar
')
})
// #14127
test('update cached text nodes', async () => {
const root = nodeOps.createElement('div')
const appId = 'test-cached-text-nodes'
const App: ComponentOptions = {
__hmrId: appId,
data() {
return {
count: 0,
}
},
render: compileToFunction(
`{{count}}
++
static text`,
),
}
createRecord(appId, App)
render(h(App), root)
expect(serializeInner(root)).toBe(`0 ++ static text`)
// trigger count update
triggerEvent((root as any).children[2], 'click')
await nextTick()
expect(serializeInner(root)).toBe(`1 ++ static text`)
// trigger HMR update
rerender(
appId,
compileToFunction(
`{{count}}
++
static text updated`,
),
)
expect(serializeInner(root)).toBe(
`1 ++ static text updated`,
)
// trigger HMR update again
rerender(
appId,
compileToFunction(
`{{count}}
++
static text updated2`,
),
)
expect(serializeInner(root)).toBe(
`1 ++ static text updated2`,
)
})
})