import { createSSRApp, h, ref, nextTick, VNode, Portal, createStaticVNode, Suspense, onMounted } from '@vue/runtime-dom' import { renderToString } from '@vue/server-renderer' import { mockWarn } from '@vue/shared' function mountWithHydration(html: string, render: () => any) { const container = document.createElement('div') container.innerHTML = html const app = createSSRApp({ render }) return { vnode: app.mount(container).$.subTree, container } } const triggerEvent = (type: string, el: Element) => { const event = new Event(type) el.dispatchEvent(event) } describe('SSR hydration', () => { mockWarn() test('text', async () => { const msg = ref('foo') const { vnode, container } = mountWithHydration('foo', () => msg.value) expect(vnode.el).toBe(container.firstChild) expect(container.textContent).toBe('foo') msg.value = 'bar' await nextTick() expect(container.textContent).toBe('bar') }) test('comment', () => { const { vnode, container } = mountWithHydration('', () => null) expect(vnode.el).toBe(container.firstChild) expect(vnode.el.nodeType).toBe(8) // comment }) test('static', () => { const html = '
hello
' const { vnode, container } = mountWithHydration(html, () => createStaticVNode(html) ) expect(vnode.el).toBe(container.firstChild) expect(vnode.el.outerHTML).toBe(html) }) test('element with text children', async () => { const msg = ref('foo') const { vnode, container } = mountWithHydration( '
foo
', () => h('div', { class: msg.value }, msg.value) ) expect(vnode.el).toBe(container.firstChild) expect(container.firstChild!.textContent).toBe('foo') msg.value = 'bar' await nextTick() expect(container.innerHTML).toBe(`
bar
`) }) test('element with elements children', async () => { const msg = ref('foo') const fn = jest.fn() const { vnode, container } = mountWithHydration( '
foo
', () => h('div', [ h('span', msg.value), h('span', { class: msg.value, onClick: fn }) ]) ) expect(vnode.el).toBe(container.firstChild) expect((vnode.children as VNode[])[0].el).toBe( container.firstChild!.childNodes[0] ) expect((vnode.children as VNode[])[1].el).toBe( container.firstChild!.childNodes[1] ) // event handler triggerEvent('click', vnode.el.querySelector('.foo')) expect(fn).toHaveBeenCalled() msg.value = 'bar' await nextTick() expect(vnode.el.innerHTML).toBe(`bar`) }) test('Fragment', async () => { const msg = ref('foo') const fn = jest.fn() const { vnode, container } = mountWithHydration( '
foo
', () => h('div', [ [h('span', msg.value), [h('span', { class: msg.value, onClick: fn })]] ]) ) expect(vnode.el).toBe(container.firstChild) // should remove anchors in dev mode expect(vnode.el.innerHTML).toBe(`foo`) // start fragment 1 const fragment1 = (vnode.children as VNode[])[0] expect(fragment1.el).toBe(vnode.el.childNodes[0]) const fragment1Children = fragment1.children as VNode[] // first expect(fragment1Children[0].el.tagName).toBe('SPAN') expect(fragment1Children[0].el).toBe(vnode.el.childNodes[1]) // start fragment 2 const fragment2 = fragment1Children[1] expect(fragment2.el).toBe(vnode.el.childNodes[2]) const fragment2Children = fragment2.children as VNode[] // second expect(fragment2Children[0].el.tagName).toBe('SPAN') expect(fragment2Children[0].el).toBe(vnode.el.childNodes[3]) // end fragment 2 expect(fragment2.anchor).toBe(vnode.el.childNodes[4]) // end fragment 1 expect(fragment1.anchor).toBe(vnode.el.childNodes[5]) // event handler triggerEvent('click', vnode.el.querySelector('.foo')) expect(fn).toHaveBeenCalled() msg.value = 'bar' await nextTick() expect(vnode.el.innerHTML).toBe(`bar`) }) test('Portal', async () => { const msg = ref('foo') const fn = jest.fn() const portalContainer = document.createElement('div') portalContainer.id = 'portal' portalContainer.innerHTML = `foo` document.body.appendChild(portalContainer) const { vnode, container } = mountWithHydration('', () => h(Portal, { target: '#portal' }, [ h('span', msg.value), h('span', { class: msg.value, onClick: fn }) ]) ) expect(vnode.el).toBe(container.firstChild) expect((vnode.children as VNode[])[0].el).toBe( portalContainer.childNodes[0] ) expect((vnode.children as VNode[])[1].el).toBe( portalContainer.childNodes[1] ) // event handler triggerEvent('click', portalContainer.querySelector('.foo')!) expect(fn).toHaveBeenCalled() msg.value = 'bar' await nextTick() expect(portalContainer.innerHTML).toBe( `bar` ) }) // compile SSR + client render fn from the same template & hydrate test('full compiler integration', async () => { const mounted: string[] = [] const log = jest.fn() const toggle = ref(true) const Child = { data() { return { count: 0, text: 'hello', style: { color: 'red' } } }, mounted() { mounted.push('child') }, template: `
{{ count }} {{ text }}
` } const App = { setup() { return { toggle } }, mounted() { mounted.push('parent') }, template: `
hello hello
`, components: { Child }, methods: { log } } const container = document.createElement('div') // server render container.innerHTML = await renderToString(h(App)) // hydrate createSSRApp(App).mount(container) // assert interactions // 1. parent button click triggerEvent('click', container.querySelector('.parent-click')!) expect(log).toHaveBeenCalledWith('click') // 2. child inc click + text interpolation const count = container.querySelector('.count') as HTMLElement expect(count.textContent).toBe(`0`) triggerEvent('click', container.querySelector('.inc')!) await nextTick() expect(count.textContent).toBe(`1`) // 3. child color click + style binding expect(count.style.color).toBe('red') triggerEvent('click', container.querySelector('.change')!) await nextTick() expect(count.style.color).toBe('green') // 4. child event emit triggerEvent('click', container.querySelector('.emit')!) expect(log).toHaveBeenCalledWith('child') // 5. child v-model const text = container.querySelector('.text')! const input = container.querySelector('input')! expect(text.textContent).toBe('hello') input.value = 'bye' triggerEvent('input', input) await nextTick() expect(text.textContent).toBe('bye') }) test('Suspense', async () => { const AsyncChild = { async setup() { const count = ref(0) return () => h( 'span', { onClick: () => { count.value++ } }, count.value ) } } const { vnode, container } = mountWithHydration('0', () => h(Suspense, () => h(AsyncChild)) ) expect(vnode.el).toBe(container.firstChild) // wait for hydration to finish await new Promise(r => setTimeout(r)) triggerEvent('click', container.querySelector('span')!) await nextTick() expect(container.innerHTML).toBe(`1`) }) test('Suspense (full integration)', async () => { const mountedCalls: number[] = [] const asyncDeps: Promise[] = [] const AsyncChild = { async setup(props: { n: number }) { const count = ref(props.n) onMounted(() => { mountedCalls.push(props.n) }) const p = new Promise(r => setTimeout(r, props.n * 10)) asyncDeps.push(p) await p return () => h( 'span', { onClick: () => { count.value++ } }, count.value ) } } const done = jest.fn() const App = { template: ` `, components: { AsyncChild }, methods: { done } } const container = document.createElement('div') // server render container.innerHTML = await renderToString(h(App)) expect(container.innerHTML).toMatchInlineSnapshot( `"12"` ) // reset asyncDeps from ssr asyncDeps.length = 0 // hydrate createSSRApp(App).mount(container) expect(mountedCalls.length).toBe(0) expect(asyncDeps.length).toBe(2) // wait for hydration to complete await Promise.all(asyncDeps) await new Promise(r => setTimeout(r)) // should flush buffered effects expect(mountedCalls).toMatchObject([1, 2]) // should have removed fragment markers expect(container.innerHTML).toMatch(`12`) const span1 = container.querySelector('span')! triggerEvent('click', span1) await nextTick() expect(container.innerHTML).toMatch(`22`) const span2 = span1.nextSibling as Element triggerEvent('click', span2) await nextTick() expect(container.innerHTML).toMatch(`23`) }) describe('mismatch handling', () => { test('text node', () => { const { container } = mountWithHydration(`foo`, () => 'bar') expect(container.textContent).toBe('bar') expect(`Hydration text mismatch`).toHaveBeenWarned() }) test('element text content', () => { const { container } = mountWithHydration(`
foo
`, () => h('div', 'bar') ) expect(container.innerHTML).toBe('
bar
') expect(`Hydration text content mismatch in
`).toHaveBeenWarned() }) test('not enough children', () => { const { container } = mountWithHydration(`
`, () => h('div', [h('span', 'foo'), h('span', 'bar')]) ) expect(container.innerHTML).toBe( '
foobar
' ) expect(`Hydration children mismatch in
`).toHaveBeenWarned() }) test('too many children', () => { const { container } = mountWithHydration( `
foobar
`, () => h('div', [h('span', 'foo')]) ) expect(container.innerHTML).toBe('
foo
') expect(`Hydration children mismatch in
`).toHaveBeenWarned() }) test('complete mismatch', () => { const { container } = mountWithHydration( `
foobar
`, () => h('div', [h('div', 'foo'), h('p', 'bar')]) ) expect(container.innerHTML).toBe('
foo

bar

') expect(`Hydration node mismatch`).toHaveBeenWarnedTimes(2) }) }) })