ssr-template.spec.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577
  1. import Vue from '../../dist/vue.runtime.common.js'
  2. import { compileWithWebpack } from './compile-with-webpack'
  3. import { createRenderer } from '../../packages/vue-server-renderer'
  4. import VueSSRClientPlugin from '../../packages/vue-server-renderer/client-plugin'
  5. import { createRenderer as createBundleRenderer } from './ssr-bundle-render.spec.js'
  6. const defaultTemplate = `<html><head></head><body><!--vue-ssr-outlet--></body></html>`
  7. const interpolateTemplate = `<html><head><title>{{ title }}</title></head><body><!--vue-ssr-outlet-->{{{ snippet }}}</body></html>`
  8. function generateClientManifest (file, cb) {
  9. compileWithWebpack(file, {
  10. output: {
  11. path: '/',
  12. publicPath: '/',
  13. filename: '[name].js'
  14. },
  15. optimization: {
  16. runtimeChunk: {
  17. name: 'manifest'
  18. }
  19. },
  20. plugins: [
  21. new VueSSRClientPlugin()
  22. ]
  23. }, fs => {
  24. cb(JSON.parse(fs.readFileSync('/vue-ssr-client-manifest.json', 'utf-8')))
  25. })
  26. }
  27. function createRendererWithManifest (file, options, cb) {
  28. if (typeof options === 'function') {
  29. cb = options
  30. options = null
  31. }
  32. generateClientManifest(file, clientManifest => {
  33. createBundleRenderer(file, Object.assign({
  34. asBundle: true,
  35. template: defaultTemplate,
  36. clientManifest
  37. }, options), cb)
  38. })
  39. }
  40. describe('SSR: template option', () => {
  41. it('renderToString', done => {
  42. const renderer = createRenderer({
  43. template: defaultTemplate
  44. })
  45. const context = {
  46. head: '<meta name="viewport" content="width=device-width">',
  47. styles: '<style>h1 { color: red }</style>',
  48. state: { a: 1 }
  49. }
  50. renderer.renderToString(new Vue({
  51. template: '<div>hi</div>'
  52. }), context, (err, res) => {
  53. expect(err).toBeNull()
  54. expect(res).toContain(
  55. `<html><head>${context.head}${context.styles}</head><body>` +
  56. `<div data-server-rendered="true">hi</div>` +
  57. `<script>window.__INITIAL_STATE__={"a":1}</script>` +
  58. `</body></html>`
  59. )
  60. done()
  61. })
  62. })
  63. it('renderToString with interpolation', done => {
  64. const renderer = createRenderer({
  65. template: interpolateTemplate
  66. })
  67. const context = {
  68. title: '<script>hacks</script>',
  69. snippet: '<div>foo</div>',
  70. head: '<meta name="viewport" content="width=device-width">',
  71. styles: '<style>h1 { color: red }</style>',
  72. state: { a: 1 }
  73. }
  74. renderer.renderToString(new Vue({
  75. template: '<div>hi</div>'
  76. }), context, (err, res) => {
  77. expect(err).toBeNull()
  78. expect(res).toContain(
  79. `<html><head>` +
  80. // double mustache should be escaped
  81. `<title>&lt;script&gt;hacks&lt;/script&gt;</title>` +
  82. `${context.head}${context.styles}</head><body>` +
  83. `<div data-server-rendered="true">hi</div>` +
  84. `<script>window.__INITIAL_STATE__={"a":1}</script>` +
  85. // triple should be raw
  86. `<div>foo</div>` +
  87. `</body></html>`
  88. )
  89. done()
  90. })
  91. })
  92. it('renderToString with interpolation and context.rendered', done => {
  93. const renderer = createRenderer({
  94. template: interpolateTemplate
  95. })
  96. const context = {
  97. title: '<script>hacks</script>',
  98. snippet: '<div>foo</div>',
  99. head: '<meta name="viewport" content="width=device-width">',
  100. styles: '<style>h1 { color: red }</style>',
  101. state: { a: 0 },
  102. rendered: context => {
  103. context.state.a = 1
  104. }
  105. }
  106. renderer.renderToString(new Vue({
  107. template: '<div>hi</div>'
  108. }), context, (err, res) => {
  109. expect(err).toBeNull()
  110. expect(res).toContain(
  111. `<html><head>` +
  112. // double mustache should be escaped
  113. `<title>&lt;script&gt;hacks&lt;/script&gt;</title>` +
  114. `${context.head}${context.styles}</head><body>` +
  115. `<div data-server-rendered="true">hi</div>` +
  116. `<script>window.__INITIAL_STATE__={"a":1}</script>` +
  117. // triple should be raw
  118. `<div>foo</div>` +
  119. `</body></html>`
  120. )
  121. done()
  122. })
  123. })
  124. it('renderToString w/ template function', done => {
  125. const renderer = createRenderer({
  126. template: (content, context) => `<html><head>${context.head}</head>${content}</html>`
  127. })
  128. const context = {
  129. head: '<meta name="viewport" content="width=device-width">'
  130. }
  131. renderer.renderToString(new Vue({
  132. template: '<div>hi</div>'
  133. }), context, (err, res) => {
  134. expect(err).toBeNull()
  135. expect(res).toContain(`<html><head>${context.head}</head><div data-server-rendered="true">hi</div></html>`)
  136. done()
  137. })
  138. })
  139. it('renderToString w/ template function returning Promise', done => {
  140. const renderer = createRenderer({
  141. template: (content, context) => new Promise((resolve) => {
  142. setTimeout(() => {
  143. resolve(`<html><head>${context.head}</head>${content}</html>`)
  144. }, 0)
  145. })
  146. })
  147. const context = {
  148. head: '<meta name="viewport" content="width=device-width">'
  149. }
  150. renderer.renderToString(new Vue({
  151. template: '<div>hi</div>'
  152. }), context, (err, res) => {
  153. expect(err).toBeNull()
  154. expect(res).toContain(`<html><head>${context.head}</head><div data-server-rendered="true">hi</div></html>`)
  155. done()
  156. })
  157. })
  158. it('renderToString w/ template function returning Promise w/ rejection', done => {
  159. const renderer = createRenderer({
  160. template: () => new Promise((resolve, reject) => {
  161. setTimeout(() => {
  162. reject(new Error(`foo`))
  163. }, 0)
  164. })
  165. })
  166. const context = {
  167. head: '<meta name="viewport" content="width=device-width">'
  168. }
  169. renderer.renderToString(new Vue({
  170. template: '<div>hi</div>'
  171. }), context, (err, res) => {
  172. expect(err.message).toBe(`foo`)
  173. expect(res).toBeUndefined()
  174. done()
  175. })
  176. })
  177. it('renderToStream', done => {
  178. const renderer = createRenderer({
  179. template: defaultTemplate
  180. })
  181. const context = {
  182. head: '<meta name="viewport" content="width=device-width">',
  183. styles: '<style>h1 { color: red }</style>',
  184. state: { a: 1 }
  185. }
  186. const stream = renderer.renderToStream(new Vue({
  187. template: '<div>hi</div>'
  188. }), context)
  189. let res = ''
  190. stream.on('data', chunk => {
  191. res += chunk
  192. })
  193. stream.on('end', () => {
  194. expect(res).toContain(
  195. `<html><head>${context.head}${context.styles}</head><body>` +
  196. `<div data-server-rendered="true">hi</div>` +
  197. `<script>window.__INITIAL_STATE__={"a":1}</script>` +
  198. `</body></html>`
  199. )
  200. done()
  201. })
  202. })
  203. it('renderToStream with interpolation', done => {
  204. const renderer = createRenderer({
  205. template: interpolateTemplate
  206. })
  207. const context = {
  208. title: '<script>hacks</script>',
  209. snippet: '<div>foo</div>',
  210. head: '<meta name="viewport" content="width=device-width">',
  211. styles: '<style>h1 { color: red }</style>',
  212. state: { a: 1 }
  213. }
  214. const stream = renderer.renderToStream(new Vue({
  215. template: '<div>hi</div>'
  216. }), context)
  217. let res = ''
  218. stream.on('data', chunk => {
  219. res += chunk
  220. })
  221. stream.on('end', () => {
  222. expect(res).toContain(
  223. `<html><head>` +
  224. // double mustache should be escaped
  225. `<title>&lt;script&gt;hacks&lt;/script&gt;</title>` +
  226. `${context.head}${context.styles}</head><body>` +
  227. `<div data-server-rendered="true">hi</div>` +
  228. `<script>window.__INITIAL_STATE__={"a":1}</script>` +
  229. // triple should be raw
  230. `<div>foo</div>` +
  231. `</body></html>`
  232. )
  233. done()
  234. })
  235. })
  236. it('renderToStream with interpolation and context.rendered', done => {
  237. const renderer = createRenderer({
  238. template: interpolateTemplate
  239. })
  240. const context = {
  241. title: '<script>hacks</script>',
  242. snippet: '<div>foo</div>',
  243. head: '<meta name="viewport" content="width=device-width">',
  244. styles: '<style>h1 { color: red }</style>',
  245. state: { a: 0 },
  246. rendered: context => {
  247. context.state.a = 1
  248. }
  249. }
  250. const stream = renderer.renderToStream(new Vue({
  251. template: '<div>hi</div>'
  252. }), context)
  253. let res = ''
  254. stream.on('data', chunk => {
  255. res += chunk
  256. })
  257. stream.on('end', () => {
  258. expect(res).toContain(
  259. `<html><head>` +
  260. // double mustache should be escaped
  261. `<title>&lt;script&gt;hacks&lt;/script&gt;</title>` +
  262. `${context.head}${context.styles}</head><body>` +
  263. `<div data-server-rendered="true">hi</div>` +
  264. `<script>window.__INITIAL_STATE__={"a":1}</script>` +
  265. // triple should be raw
  266. `<div>foo</div>` +
  267. `</body></html>`
  268. )
  269. done()
  270. })
  271. })
  272. it('bundleRenderer + renderToString', done => {
  273. createBundleRenderer('app.js', {
  274. asBundle: true,
  275. template: defaultTemplate
  276. }, renderer => {
  277. const context = {
  278. head: '<meta name="viewport" content="width=device-width">',
  279. styles: '<style>h1 { color: red }</style>',
  280. state: { a: 1 },
  281. url: '/test'
  282. }
  283. renderer.renderToString(context, (err, res) => {
  284. expect(err).toBeNull()
  285. expect(res).toContain(
  286. `<html><head>${context.head}${context.styles}</head><body>` +
  287. `<div data-server-rendered="true">/test</div>` +
  288. `<script>window.__INITIAL_STATE__={"a":1}</script>` +
  289. `</body></html>`
  290. )
  291. expect(context.msg).toBe('hello')
  292. done()
  293. })
  294. })
  295. })
  296. it('bundleRenderer + renderToStream', done => {
  297. createBundleRenderer('app.js', {
  298. asBundle: true,
  299. template: defaultTemplate
  300. }, renderer => {
  301. const context = {
  302. head: '<meta name="viewport" content="width=device-width">',
  303. styles: '<style>h1 { color: red }</style>',
  304. state: { a: 1 },
  305. url: '/test'
  306. }
  307. const stream = renderer.renderToStream(context)
  308. let res = ''
  309. stream.on('data', chunk => {
  310. res += chunk.toString()
  311. })
  312. stream.on('end', () => {
  313. expect(res).toContain(
  314. `<html><head>${context.head}${context.styles}</head><body>` +
  315. `<div data-server-rendered="true">/test</div>` +
  316. `<script>window.__INITIAL_STATE__={"a":1}</script>` +
  317. `</body></html>`
  318. )
  319. expect(context.msg).toBe('hello')
  320. done()
  321. })
  322. })
  323. })
  324. const expectedHTMLWithManifest = (options = {}) =>
  325. `<html><head>` +
  326. // used chunks should have preload
  327. `<link rel="preload" href="/manifest.js" as="script">` +
  328. `<link rel="preload" href="/main.js" as="script">` +
  329. `<link rel="preload" href="/0.js" as="script">` +
  330. `<link rel="preload" href="/test.css" as="style">` +
  331. // images and fonts are only preloaded when explicitly asked for
  332. (options.preloadOtherAssets ? `<link rel="preload" href="/test.png" as="image">` : ``) +
  333. (options.preloadOtherAssets ? `<link rel="preload" href="/test.woff2" as="font" type="font/woff2" crossorigin>` : ``) +
  334. // unused chunks should have prefetch
  335. (options.noPrefetch ? `` : `<link rel="prefetch" href="/1.js">`) +
  336. // css assets should be loaded
  337. `<link rel="stylesheet" href="/test.css">` +
  338. `</head><body>` +
  339. `<div data-server-rendered="true"><div>async test.woff2 test.png</div></div>` +
  340. // state should be inlined before scripts
  341. `<script>window.${options.stateKey || '__INITIAL_STATE__'}={"a":1}</script>` +
  342. // manifest chunk should be first
  343. `<script src="/manifest.js" defer></script>` +
  344. // async chunks should be before main chunk
  345. `<script src="/0.js" defer></script>` +
  346. `<script src="/main.js" defer></script>` +
  347. `</body></html>`
  348. createClientManifestAssertions(true)
  349. createClientManifestAssertions(false)
  350. function createClientManifestAssertions (runInNewContext) {
  351. it('bundleRenderer + renderToString + clientManifest ()', done => {
  352. createRendererWithManifest('split.js', { runInNewContext }, renderer => {
  353. renderer.renderToString({ state: { a: 1 }}, (err, res) => {
  354. expect(err).toBeNull()
  355. expect(res).toContain(expectedHTMLWithManifest())
  356. done()
  357. })
  358. })
  359. })
  360. it('bundleRenderer + renderToStream + clientManifest + shouldPreload', done => {
  361. createRendererWithManifest('split.js', {
  362. runInNewContext,
  363. shouldPreload: (file, type) => {
  364. if (type === 'image' || type === 'script' || type === 'font' || type === 'style') {
  365. return true
  366. }
  367. }
  368. }, renderer => {
  369. const stream = renderer.renderToStream({ state: { a: 1 }})
  370. let res = ''
  371. stream.on('data', chunk => {
  372. res += chunk.toString()
  373. })
  374. stream.on('end', () => {
  375. expect(res).toContain(expectedHTMLWithManifest({
  376. preloadOtherAssets: true
  377. }))
  378. done()
  379. })
  380. })
  381. })
  382. it('bundleRenderer + renderToStream + clientManifest + shouldPrefetch', done => {
  383. createRendererWithManifest('split.js', {
  384. runInNewContext,
  385. shouldPrefetch: (file, type) => {
  386. if (type === 'script') {
  387. return false
  388. }
  389. }
  390. }, renderer => {
  391. const stream = renderer.renderToStream({ state: { a: 1 }})
  392. let res = ''
  393. stream.on('data', chunk => {
  394. res += chunk.toString()
  395. })
  396. stream.on('end', () => {
  397. expect(res).toContain(expectedHTMLWithManifest({
  398. noPrefetch: true
  399. }))
  400. done()
  401. })
  402. })
  403. })
  404. it('bundleRenderer + renderToString + clientManifest + inject: false', done => {
  405. createRendererWithManifest('split.js', {
  406. runInNewContext,
  407. template: `<html>` +
  408. `<head>{{{ renderResourceHints() }}}{{{ renderStyles() }}}</head>` +
  409. `<body><!--vue-ssr-outlet-->{{{ renderState({ windowKey: '__FOO__', contextKey: 'foo' }) }}}{{{ renderScripts() }}}</body>` +
  410. `</html>`,
  411. inject: false
  412. }, renderer => {
  413. const context = { foo: { a: 1 }}
  414. renderer.renderToString(context, (err, res) => {
  415. expect(err).toBeNull()
  416. expect(res).toContain(expectedHTMLWithManifest({
  417. stateKey: '__FOO__'
  418. }))
  419. done()
  420. })
  421. })
  422. })
  423. it('bundleRenderer + renderToString + clientManifest + no template', done => {
  424. createRendererWithManifest('split.js', {
  425. runInNewContext,
  426. template: null
  427. }, renderer => {
  428. const context = { foo: { a: 1 }}
  429. renderer.renderToString(context, (err, res) => {
  430. expect(err).toBeNull()
  431. const customOutput =
  432. `<html><head>${
  433. context.renderResourceHints() +
  434. context.renderStyles()
  435. }</head><body>${
  436. res +
  437. context.renderState({
  438. windowKey: '__FOO__',
  439. contextKey: 'foo'
  440. }) +
  441. context.renderScripts()
  442. }</body></html>`
  443. expect(customOutput).toContain(expectedHTMLWithManifest({
  444. stateKey: '__FOO__'
  445. }))
  446. done()
  447. })
  448. })
  449. })
  450. it('whitespace insensitive interpolation', done => {
  451. const interpolateTemplate = `<html><head><title>{{title}}</title></head><body><!--vue-ssr-outlet-->{{{snippet}}}</body></html>`
  452. const renderer = createRenderer({
  453. template: interpolateTemplate
  454. })
  455. const context = {
  456. title: '<script>hacks</script>',
  457. snippet: '<div>foo</div>',
  458. head: '<meta name="viewport" content="width=device-width">',
  459. styles: '<style>h1 { color: red }</style>',
  460. state: { a: 1 }
  461. }
  462. renderer.renderToString(new Vue({
  463. template: '<div>hi</div>'
  464. }), context, (err, res) => {
  465. expect(err).toBeNull()
  466. expect(res).toContain(
  467. `<html><head>` +
  468. // double mustache should be escaped
  469. `<title>&lt;script&gt;hacks&lt;/script&gt;</title>` +
  470. `${context.head}${context.styles}</head><body>` +
  471. `<div data-server-rendered="true">hi</div>` +
  472. `<script>window.__INITIAL_STATE__={"a":1}</script>` +
  473. // triple should be raw
  474. `<div>foo</div>` +
  475. `</body></html>`
  476. )
  477. done()
  478. })
  479. })
  480. it('renderToString + nonce', done => {
  481. const interpolateTemplate = `<html><head><title>hello</title></head><body><!--vue-ssr-outlet--></body></html>`
  482. const renderer = createRenderer({
  483. template: interpolateTemplate
  484. })
  485. const context = {
  486. state: { a: 1 },
  487. nonce: '4AEemGb0xJptoIGFP3Nd'
  488. }
  489. renderer.renderToString(new Vue({
  490. template: '<div>hi</div>'
  491. }), context, (err, res) => {
  492. expect(err).toBeNull()
  493. expect(res).toContain(
  494. `<html><head>` +
  495. `<title>hello</title>` +
  496. `</head><body>` +
  497. `<div data-server-rendered="true">hi</div>` +
  498. `<script nonce="4AEemGb0xJptoIGFP3Nd">window.__INITIAL_STATE__={"a":1}</script>` +
  499. `</body></html>`
  500. )
  501. done()
  502. })
  503. })
  504. it('renderToString + custom serializer', done => {
  505. const expected = `{"foo":123}`
  506. const renderer = createRenderer({
  507. template: defaultTemplate,
  508. serializer: () => expected
  509. })
  510. const context = {
  511. state: { a: 1 }
  512. }
  513. renderer.renderToString(new Vue({
  514. template: '<div>hi</div>'
  515. }), context, (err, res) => {
  516. expect(err).toBeNull()
  517. expect(res).toContain(
  518. `<script>window.__INITIAL_STATE__=${expected}</script>`
  519. )
  520. done()
  521. })
  522. })
  523. }
  524. })