From 9b09b4d1127e3bbdbd5d97edd2309ad7f40463f9 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Sun, 30 Jul 2023 21:46:16 +0300 Subject: [PATCH] feat(nuxt): render all head tags on server with `unhead` (#22179) --- packages/nuxt/src/app/nuxt.ts | 12 +- packages/nuxt/src/core/nitro.ts | 8 - .../nuxt/src/core/runtime/nitro/renderer.ts | 208 +++++++++++------- .../nuxt/src/head/runtime/plugins/unhead.ts | 23 +- test/bundle.test.ts | 4 +- 5 files changed, 132 insertions(+), 123 deletions(-) diff --git a/packages/nuxt/src/app/nuxt.ts b/packages/nuxt/src/app/nuxt.ts index fe8aed8f05..2c0bf250fc 100644 --- a/packages/nuxt/src/app/nuxt.ts +++ b/packages/nuxt/src/app/nuxt.ts @@ -10,6 +10,7 @@ import type { H3Event } from 'h3' import type { AppConfig, AppConfigInput, RuntimeConfig } from 'nuxt/schema' import type { RenderResponse } from 'nitropack' +import type { MergeHead, VueHeadClient } from '@unhead/vue' // eslint-disable-next-line import/no-restricted-paths import type { NuxtIslandContext } from '../core/runtime/nitro/renderer' import type { RouteMiddleware } from '../../app' @@ -18,15 +19,6 @@ import type { AsyncDataRequestStatus } from '../app/composables/asyncData' const nuxtAppCtx = /* #__PURE__ */ getContext('nuxt-app') -type NuxtMeta = { - htmlAttrs?: string - headAttrs?: string - bodyAttrs?: string - headTags?: string - bodyScriptsPrepend?: string - bodyScripts?: string -} - type HookResult = Promise | void type AppRenderedContext = { ssrContext: NuxtApp['ssrContext'], renderResult: null | Awaited['renderToString']>> } @@ -59,10 +51,10 @@ export interface NuxtSSRContext extends SSRContext { error?: boolean nuxt: _NuxtApp payload: NuxtPayload + head: VueHeadClient /** This is used solely to render runtime config with SPA renderer. */ config?: Pick teleports?: Record - renderMeta?: () => Promise | NuxtMeta islandContext?: NuxtIslandContext /** @internal */ _renderResponse?: Partial diff --git a/packages/nuxt/src/core/nitro.ts b/packages/nuxt/src/core/nitro.ts index 812d92c3ef..50176f5e5f 100644 --- a/packages/nuxt/src/core/nitro.ts +++ b/packages/nuxt/src/core/nitro.ts @@ -8,8 +8,6 @@ import escapeRE from 'escape-string-regexp' import { defu } from 'defu' import fsExtra from 'fs-extra' import { dynamicEventHandler } from 'h3' -import { createHeadCore } from '@unhead/vue' -import { renderSSRHead } from '@unhead/ssr' import type { Nuxt } from 'nuxt/schema' // @ts-expect-error TODO: add legacy type support for subpath imports import { template as defaultSpaLoadingTemplate } from '@nuxt/ui-templates/templates/spa-loading-icon.mjs' @@ -205,12 +203,6 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) { // Resolve user-provided paths nitroConfig.srcDir = resolve(nuxt.options.rootDir, nuxt.options.srcDir, nitroConfig.srcDir!) - // Add head chunk for SPA renders - const head = createHeadCore() - head.push(nuxt.options.app.head) - const headChunk = await renderSSRHead(head) - nitroConfig.virtual!['#head-static'] = `export default ${JSON.stringify(headChunk)}` - // Add fallback server for `ssr: false` if (!nuxt.options.ssr) { nitroConfig.virtual!['#build/dist/server/server.mjs'] = 'export default () => {}' diff --git a/packages/nuxt/src/core/runtime/nitro/renderer.ts b/packages/nuxt/src/core/runtime/nitro/renderer.ts index 840e01281a..da2e70764d 100644 --- a/packages/nuxt/src/core/runtime/nitro/renderer.ts +++ b/packages/nuxt/src/core/runtime/nitro/renderer.ts @@ -1,4 +1,10 @@ -import { createRenderer, renderResourceHeaders } from 'vue-bundle-renderer/runtime' +import { + createRenderer, + getPrefetchLinks, + getPreloadLinks, + getRequestDependencies, + renderResourceHeaders +} from 'vue-bundle-renderer/runtime' import type { RenderResponse } from 'nitropack' import type { Manifest } from 'vite' import type { H3Event } from 'h3' @@ -9,14 +15,17 @@ import destr from 'destr' import { joinURL, withoutTrailingSlash } from 'ufo' import { renderToString as _renderToString } from 'vue/server-renderer' import { hash } from 'ohash' +import { renderSSRHead } from '@unhead/ssr' import { defineRenderHandler, getRouteRules, useRuntimeConfig } from '#internal/nitro' import { useNitroApp } from '#internal/nitro/app' +import type { Link, Script } from '@unhead/vue' +import { createServerHead } from '@unhead/vue' // eslint-disable-next-line import/no-restricted-paths import type { NuxtPayload, NuxtSSRContext } from '#app/nuxt' // @ts-expect-error virtual file -import { appRootId, appRootTag } from '#internal/nuxt.config.mjs' +import { appHead, appRootId, appRootTag } from '#internal/nuxt.config.mjs' // @ts-expect-error virtual file import { buildAssetsURL, publicAssetsURL } from '#paths' @@ -71,9 +80,6 @@ const getEntryIds: () => Promise = () => getClientManifest().then(r => r._globalCSS ).map(r => r.src!)) -// @ts-expect-error virtual file -const getStaticRenderedHead = (): Promise => import('#head-static').then(r => r.default || r) - // @ts-expect-error file will be produced after app build const getServerEntry = () => import('#build/dist/server/server.mjs').then(r => r.default || r) @@ -140,7 +146,6 @@ const getSPARenderer = lazyCachedFunction(async () => { public: config.public, app: config.app } - ssrContext!.renderMeta = ssrContext!.renderMeta ?? getStaticRenderedHead return Promise.resolve(result) } @@ -221,6 +226,9 @@ export default defineRenderHandler(async (event): Promise + ({ rel: 'stylesheet', href: renderer.rendererContext.buildAssetsURL(resource.file) }) + ), + style: inlinedStyles + }) + + // 4. Scripts + if (!routeOptions.experimentalNoScripts) { + head.push({ + script: Object.values(scripts).map(resource => (` + - `` + const payload: Script = { + type: 'application/json', + id: opts.id, + innerHTML: contents, + 'data-ssr': !(process.env.NUXT_NO_SSR || opts.ssrContext.noSSR) + } + if (opts.src) { + payload['data-src'] = opts.src + } + return [ + payload, + { + innerHTML: `window.__NUXT__={};window.__NUXT__.config=${uneval(opts.ssrContext.config)}` + } + ] } -function renderPayloadScript (opts: { ssrContext: NuxtSSRContext, data?: any, src?: string }) { +function renderPayloadScript (opts: { ssrContext: NuxtSSRContext, data?: any, src?: string }): Script[] { opts.data.config = opts.ssrContext.config const _PAYLOAD_EXTRACTION = process.env.prerender && process.env.NUXT_PAYLOAD_EXTRACTION && !opts.ssrContext.noSSR if (_PAYLOAD_EXTRACTION) { - return `` + return [ + { + type: 'module', + innerHTML: `import p from "${opts.src}";window.__NUXT__={...p,...(${devalue(opts.data)})` + } + ] } - return `` + return [ + { + innerHTML: `window.__NUXT__=${devalue(opts.data)}` + } + ] } function splitPayload (ssrContext: NuxtSSRContext) { diff --git a/packages/nuxt/src/head/runtime/plugins/unhead.ts b/packages/nuxt/src/head/runtime/plugins/unhead.ts index 3fd5e0c330..f16413b1b9 100644 --- a/packages/nuxt/src/head/runtime/plugins/unhead.ts +++ b/packages/nuxt/src/head/runtime/plugins/unhead.ts @@ -1,16 +1,11 @@ -import { createHead as createClientHead, createServerHead } from '@unhead/vue' -import { renderSSRHead } from '@unhead/ssr' +import { createHead as createClientHead } from '@unhead/vue' import { defineNuxtPlugin } from '#app/nuxt' -// @ts-expect-error untyped -import { appHead } from '#build/nuxt.config.mjs' export default defineNuxtPlugin({ name: 'nuxt:head', setup (nuxtApp) { - const createHead = process.server ? createServerHead : createClientHead - const head = createHead() - head.push(appHead) - + const head = process.server ? nuxtApp.ssrContext!.head : createClientHead() + // nuxt.config appHead is set server-side within the renderer nuxtApp.vueApp.use(head) if (process.client) { @@ -28,17 +23,5 @@ export default defineNuxtPlugin({ // unpause the DOM once the mount suspense is resolved nuxtApp.hooks.hook('app:suspense:resolve', unpauseDom) } - - if (process.server) { - nuxtApp.ssrContext!.renderMeta = async () => { - const meta = await renderSSRHead(head) - return { - ...meta, - bodyScriptsPrepend: meta.bodyTagsOpen, - // resolves naming difference with NuxtMeta and Unhead - bodyScripts: meta.bodyTags - } - } - } } }) diff --git a/test/bundle.test.ts b/test/bundle.test.ts index 20cad7138d..3c6f55bac6 100644 --- a/test/bundle.test.ts +++ b/test/bundle.test.ts @@ -19,7 +19,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM for (const outputDir of ['.output', '.output-inline']) { it('default client bundle size', async () => { const clientStats = await analyzeSizes('**/*.js', join(rootDir, outputDir, 'public')) - expect.soft(roundToKilobytes(clientStats.totalBytes)).toMatchInlineSnapshot('"97.5k"') + expect.soft(roundToKilobytes(clientStats.totalBytes)).toMatchInlineSnapshot('"97.3k"') expect(clientStats.files.map(f => f.replace(/\..*\.js/, '.js'))).toMatchInlineSnapshot(` [ "_nuxt/entry.js", @@ -32,7 +32,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM const serverDir = join(rootDir, '.output/server') const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir) - expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot('"64.5k"') + expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot('"64.2k"') const modules = await analyzeSizes('node_modules/**/*', serverDir) expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot('"2330k"')