hydration.spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460
  1. import {
  2. createSSRApp,
  3. h,
  4. ref,
  5. nextTick,
  6. VNode,
  7. Portal,
  8. createStaticVNode,
  9. Suspense,
  10. onMounted
  11. } from '@vue/runtime-dom'
  12. import { renderToString } from '@vue/server-renderer'
  13. import { mockWarn } from '@vue/shared'
  14. function mountWithHydration(html: string, render: () => any) {
  15. const container = document.createElement('div')
  16. container.innerHTML = html
  17. const app = createSSRApp({
  18. render
  19. })
  20. return {
  21. vnode: app.mount(container).$.subTree,
  22. container
  23. }
  24. }
  25. const triggerEvent = (type: string, el: Element) => {
  26. const event = new Event(type)
  27. el.dispatchEvent(event)
  28. }
  29. describe('SSR hydration', () => {
  30. mockWarn()
  31. test('text', async () => {
  32. const msg = ref('foo')
  33. const { vnode, container } = mountWithHydration('foo', () => msg.value)
  34. expect(vnode.el).toBe(container.firstChild)
  35. expect(container.textContent).toBe('foo')
  36. msg.value = 'bar'
  37. await nextTick()
  38. expect(container.textContent).toBe('bar')
  39. })
  40. test('comment', () => {
  41. const { vnode, container } = mountWithHydration('<!---->', () => null)
  42. expect(vnode.el).toBe(container.firstChild)
  43. expect(vnode.el.nodeType).toBe(8) // comment
  44. })
  45. test('static', () => {
  46. const html = '<div><span>hello</span></div>'
  47. const { vnode, container } = mountWithHydration(html, () =>
  48. createStaticVNode(html)
  49. )
  50. expect(vnode.el).toBe(container.firstChild)
  51. expect(vnode.el.outerHTML).toBe(html)
  52. })
  53. test('element with text children', async () => {
  54. const msg = ref('foo')
  55. const { vnode, container } = mountWithHydration(
  56. '<div class="foo">foo</div>',
  57. () => h('div', { class: msg.value }, msg.value)
  58. )
  59. expect(vnode.el).toBe(container.firstChild)
  60. expect(container.firstChild!.textContent).toBe('foo')
  61. msg.value = 'bar'
  62. await nextTick()
  63. expect(container.innerHTML).toBe(`<div class="bar">bar</div>`)
  64. })
  65. test('element with elements children', async () => {
  66. const msg = ref('foo')
  67. const fn = jest.fn()
  68. const { vnode, container } = mountWithHydration(
  69. '<div><span>foo</span><span class="foo"></span></div>',
  70. () =>
  71. h('div', [
  72. h('span', msg.value),
  73. h('span', { class: msg.value, onClick: fn })
  74. ])
  75. )
  76. expect(vnode.el).toBe(container.firstChild)
  77. expect((vnode.children as VNode[])[0].el).toBe(
  78. container.firstChild!.childNodes[0]
  79. )
  80. expect((vnode.children as VNode[])[1].el).toBe(
  81. container.firstChild!.childNodes[1]
  82. )
  83. // event handler
  84. triggerEvent('click', vnode.el.querySelector('.foo'))
  85. expect(fn).toHaveBeenCalled()
  86. msg.value = 'bar'
  87. await nextTick()
  88. expect(vnode.el.innerHTML).toBe(`<span>bar</span><span class="bar"></span>`)
  89. })
  90. test('Fragment', async () => {
  91. const msg = ref('foo')
  92. const fn = jest.fn()
  93. const { vnode, container } = mountWithHydration(
  94. '<div><!--[--><span>foo</span><!--[--><span class="foo"></span><!--]--><!--]--></div>',
  95. () =>
  96. h('div', [
  97. [h('span', msg.value), [h('span', { class: msg.value, onClick: fn })]]
  98. ])
  99. )
  100. expect(vnode.el).toBe(container.firstChild)
  101. expect(vnode.el.innerHTML).toBe(
  102. `<!--[--><span>foo</span><!--[--><span class="foo"></span><!--]--><!--]-->`
  103. )
  104. // start fragment 1
  105. const fragment1 = (vnode.children as VNode[])[0]
  106. expect(fragment1.el).toBe(vnode.el.childNodes[0])
  107. const fragment1Children = fragment1.children as VNode[]
  108. // first <span>
  109. expect(fragment1Children[0].el.tagName).toBe('SPAN')
  110. expect(fragment1Children[0].el).toBe(vnode.el.childNodes[1])
  111. // start fragment 2
  112. const fragment2 = fragment1Children[1]
  113. expect(fragment2.el).toBe(vnode.el.childNodes[2])
  114. const fragment2Children = fragment2.children as VNode[]
  115. // second <span>
  116. expect(fragment2Children[0].el.tagName).toBe('SPAN')
  117. expect(fragment2Children[0].el).toBe(vnode.el.childNodes[3])
  118. // end fragment 2
  119. expect(fragment2.anchor).toBe(vnode.el.childNodes[4])
  120. // end fragment 1
  121. expect(fragment1.anchor).toBe(vnode.el.childNodes[5])
  122. // event handler
  123. triggerEvent('click', vnode.el.querySelector('.foo'))
  124. expect(fn).toHaveBeenCalled()
  125. msg.value = 'bar'
  126. await nextTick()
  127. expect(vnode.el.innerHTML).toBe(
  128. `<!--[--><span>bar</span><!--[--><span class="bar"></span><!--]--><!--]-->`
  129. )
  130. })
  131. test('Portal', async () => {
  132. const msg = ref('foo')
  133. const fn = jest.fn()
  134. const portalContainer = document.createElement('div')
  135. portalContainer.id = 'portal'
  136. portalContainer.innerHTML = `<span>foo</span><span class="foo"></span>`
  137. document.body.appendChild(portalContainer)
  138. const { vnode, container } = mountWithHydration('<!--portal-->', () =>
  139. h(Portal, { target: '#portal' }, [
  140. h('span', msg.value),
  141. h('span', { class: msg.value, onClick: fn })
  142. ])
  143. )
  144. expect(vnode.el).toBe(container.firstChild)
  145. expect((vnode.children as VNode[])[0].el).toBe(
  146. portalContainer.childNodes[0]
  147. )
  148. expect((vnode.children as VNode[])[1].el).toBe(
  149. portalContainer.childNodes[1]
  150. )
  151. // event handler
  152. triggerEvent('click', portalContainer.querySelector('.foo')!)
  153. expect(fn).toHaveBeenCalled()
  154. msg.value = 'bar'
  155. await nextTick()
  156. expect(portalContainer.innerHTML).toBe(
  157. `<span>bar</span><span class="bar"></span>`
  158. )
  159. })
  160. // compile SSR + client render fn from the same template & hydrate
  161. test('full compiler integration', async () => {
  162. const mounted: string[] = []
  163. const log = jest.fn()
  164. const toggle = ref(true)
  165. const Child = {
  166. data() {
  167. return {
  168. count: 0,
  169. text: 'hello',
  170. style: {
  171. color: 'red'
  172. }
  173. }
  174. },
  175. mounted() {
  176. mounted.push('child')
  177. },
  178. template: `
  179. <div>
  180. <span class="count" :style="style">{{ count }}</span>
  181. <button class="inc" @click="count++">inc</button>
  182. <button class="change" @click="style.color = 'green'" >change color</button>
  183. <button class="emit" @click="$emit('foo')">emit</button>
  184. <span class="text">{{ text }}</span>
  185. <input v-model="text">
  186. </div>
  187. `
  188. }
  189. const App = {
  190. setup() {
  191. return { toggle }
  192. },
  193. mounted() {
  194. mounted.push('parent')
  195. },
  196. template: `
  197. <div>
  198. <span>hello</span>
  199. <template v-if="toggle">
  200. <Child @foo="log('child')"/>
  201. <template v-if="true">
  202. <button class="parent-click" @click="log('click')">click me</button>
  203. </template>
  204. </template>
  205. <span>hello</span>
  206. </div>`,
  207. components: {
  208. Child
  209. },
  210. methods: {
  211. log
  212. }
  213. }
  214. const container = document.createElement('div')
  215. // server render
  216. container.innerHTML = await renderToString(h(App))
  217. // hydrate
  218. createSSRApp(App).mount(container)
  219. // assert interactions
  220. // 1. parent button click
  221. triggerEvent('click', container.querySelector('.parent-click')!)
  222. expect(log).toHaveBeenCalledWith('click')
  223. // 2. child inc click + text interpolation
  224. const count = container.querySelector('.count') as HTMLElement
  225. expect(count.textContent).toBe(`0`)
  226. triggerEvent('click', container.querySelector('.inc')!)
  227. await nextTick()
  228. expect(count.textContent).toBe(`1`)
  229. // 3. child color click + style binding
  230. expect(count.style.color).toBe('red')
  231. triggerEvent('click', container.querySelector('.change')!)
  232. await nextTick()
  233. expect(count.style.color).toBe('green')
  234. // 4. child event emit
  235. triggerEvent('click', container.querySelector('.emit')!)
  236. expect(log).toHaveBeenCalledWith('child')
  237. // 5. child v-model
  238. const text = container.querySelector('.text')!
  239. const input = container.querySelector('input')!
  240. expect(text.textContent).toBe('hello')
  241. input.value = 'bye'
  242. triggerEvent('input', input)
  243. await nextTick()
  244. expect(text.textContent).toBe('bye')
  245. })
  246. test('Suspense', async () => {
  247. const AsyncChild = {
  248. async setup() {
  249. const count = ref(0)
  250. return () =>
  251. h(
  252. 'span',
  253. {
  254. onClick: () => {
  255. count.value++
  256. }
  257. },
  258. count.value
  259. )
  260. }
  261. }
  262. const { vnode, container } = mountWithHydration('<span>0</span>', () =>
  263. h(Suspense, () => h(AsyncChild))
  264. )
  265. expect(vnode.el).toBe(container.firstChild)
  266. // wait for hydration to finish
  267. await new Promise(r => setTimeout(r))
  268. triggerEvent('click', container.querySelector('span')!)
  269. await nextTick()
  270. expect(container.innerHTML).toBe(`<span>1</span>`)
  271. })
  272. test('Suspense (full integration)', async () => {
  273. const mountedCalls: number[] = []
  274. const asyncDeps: Promise<any>[] = []
  275. const AsyncChild = {
  276. async setup(props: { n: number }) {
  277. const count = ref(props.n)
  278. onMounted(() => {
  279. mountedCalls.push(props.n)
  280. })
  281. const p = new Promise(r => setTimeout(r, props.n * 10))
  282. asyncDeps.push(p)
  283. await p
  284. return () =>
  285. h(
  286. 'span',
  287. {
  288. onClick: () => {
  289. count.value++
  290. }
  291. },
  292. count.value
  293. )
  294. }
  295. }
  296. const done = jest.fn()
  297. const App = {
  298. template: `
  299. <Suspense @resolve="done">
  300. <AsyncChild :n="1" />
  301. <AsyncChild :n="2" />
  302. </Suspense>`,
  303. components: {
  304. AsyncChild
  305. },
  306. methods: {
  307. done
  308. }
  309. }
  310. const container = document.createElement('div')
  311. // server render
  312. container.innerHTML = await renderToString(h(App))
  313. expect(container.innerHTML).toMatchInlineSnapshot(
  314. `"<!--[--><span>1</span><span>2</span><!--]-->"`
  315. )
  316. // reset asyncDeps from ssr
  317. asyncDeps.length = 0
  318. // hydrate
  319. createSSRApp(App).mount(container)
  320. expect(mountedCalls.length).toBe(0)
  321. expect(asyncDeps.length).toBe(2)
  322. // wait for hydration to complete
  323. await Promise.all(asyncDeps)
  324. await new Promise(r => setTimeout(r))
  325. // should flush buffered effects
  326. expect(mountedCalls).toMatchObject([1, 2])
  327. expect(container.innerHTML).toMatch(`<span>1</span><span>2</span>`)
  328. const span1 = container.querySelector('span')!
  329. triggerEvent('click', span1)
  330. await nextTick()
  331. expect(container.innerHTML).toMatch(`<span>2</span><span>2</span>`)
  332. const span2 = span1.nextSibling as Element
  333. triggerEvent('click', span2)
  334. await nextTick()
  335. expect(container.innerHTML).toMatch(`<span>2</span><span>3</span>`)
  336. })
  337. describe('mismatch handling', () => {
  338. test('text node', () => {
  339. const { container } = mountWithHydration(`foo`, () => 'bar')
  340. expect(container.textContent).toBe('bar')
  341. expect(`Hydration text mismatch`).toHaveBeenWarned()
  342. })
  343. test('element text content', () => {
  344. const { container } = mountWithHydration(`<div>foo</div>`, () =>
  345. h('div', 'bar')
  346. )
  347. expect(container.innerHTML).toBe('<div>bar</div>')
  348. expect(`Hydration text content mismatch in <div>`).toHaveBeenWarned()
  349. })
  350. test('not enough children', () => {
  351. const { container } = mountWithHydration(`<div></div>`, () =>
  352. h('div', [h('span', 'foo'), h('span', 'bar')])
  353. )
  354. expect(container.innerHTML).toBe(
  355. '<div><span>foo</span><span>bar</span></div>'
  356. )
  357. expect(`Hydration children mismatch in <div>`).toHaveBeenWarned()
  358. })
  359. test('too many children', () => {
  360. const { container } = mountWithHydration(
  361. `<div><span>foo</span><span>bar</span></div>`,
  362. () => h('div', [h('span', 'foo')])
  363. )
  364. expect(container.innerHTML).toBe('<div><span>foo</span></div>')
  365. expect(`Hydration children mismatch in <div>`).toHaveBeenWarned()
  366. })
  367. test('complete mismatch', () => {
  368. const { container } = mountWithHydration(
  369. `<div><span>foo</span><span>bar</span></div>`,
  370. () => h('div', [h('div', 'foo'), h('p', 'bar')])
  371. )
  372. expect(container.innerHTML).toBe('<div><div>foo</div><p>bar</p></div>')
  373. expect(`Hydration node mismatch`).toHaveBeenWarnedTimes(2)
  374. })
  375. test('fragment mismatch removal', () => {
  376. const { container } = mountWithHydration(
  377. `<div><!--[--><div>foo</div><div>bar</div><!--]--></div>`,
  378. () => h('div', [h('span', 'replaced')])
  379. )
  380. expect(container.innerHTML).toBe('<div><span>replaced</span></div>')
  381. expect(`Hydration node mismatch`).toHaveBeenWarned()
  382. })
  383. test('fragment not enough children', () => {
  384. const { container } = mountWithHydration(
  385. `<div><!--[--><div>foo</div><!--]--><div>baz</div></div>`,
  386. () => h('div', [[h('div', 'foo'), h('div', 'bar')], h('div', 'baz')])
  387. )
  388. expect(container.innerHTML).toBe(
  389. '<div><!--[--><div>foo</div><div>bar</div><!--]--><div>baz</div></div>'
  390. )
  391. expect(`Hydration node mismatch`).toHaveBeenWarned()
  392. })
  393. test('fragment too many children', () => {
  394. const { container } = mountWithHydration(
  395. `<div><!--[--><div>foo</div><div>bar</div><!--]--><div>baz</div></div>`,
  396. () => h('div', [[h('div', 'foo')], h('div', 'baz')])
  397. )
  398. expect(container.innerHTML).toBe(
  399. '<div><!--[--><div>foo</div><!--]--><div>baz</div></div>'
  400. )
  401. // fragment ends early and attempts to hydrate the extra <div>bar</div>
  402. // as 2nd fragment child.
  403. expect(`Hydration text content mismatch`).toHaveBeenWarned()
  404. // exccesive children removal
  405. expect(`Hydration children mismatch`).toHaveBeenWarned()
  406. })
  407. })
  408. })