feat(nuxt): add experimental.headNext unhead integration (#22620)

This commit is contained in:
Harlan Wilton 2023-08-14 22:33:00 +03:00 committed by GitHub
parent bb83ab5b3f
commit d50a416304
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 91 additions and 66 deletions

View File

@ -58,9 +58,9 @@
"@nuxt/telemetry": "^2.4.1", "@nuxt/telemetry": "^2.4.1",
"@nuxt/ui-templates": "^1.3.1", "@nuxt/ui-templates": "^1.3.1",
"@nuxt/vite-builder": "workspace:../vite", "@nuxt/vite-builder": "workspace:../vite",
"@unhead/ssr": "^1.2.2", "@unhead/ssr": "^1.3.2",
"@unhead/vue": "^1.2.2", "@unhead/vue": "^1.3.2",
"@unhead/dom": "^1.2.2", "@unhead/dom": "^1.3.2",
"@vue/shared": "^3.3.4", "@vue/shared": "^3.3.4",
"acorn": "8.10.0", "acorn": "8.10.0",
"c12": "^1.4.2", "c12": "^1.4.2",

View File

@ -17,12 +17,15 @@ import { joinURL, withoutTrailingSlash } from 'ufo'
import { renderToString as _renderToString } from 'vue/server-renderer' import { renderToString as _renderToString } from 'vue/server-renderer'
import { hash } from 'ohash' import { hash } from 'ohash'
import { renderSSRHead } from '@unhead/ssr' import { renderSSRHead } from '@unhead/ssr'
import type { HeadEntryOptions } from '@unhead/schema'
import { defineRenderHandler, getRouteRules, useRuntimeConfig, useStorage } from '#internal/nitro' import { defineRenderHandler, getRouteRules, useRuntimeConfig, useStorage } from '#internal/nitro'
import { useNitroApp } from '#internal/nitro/app' import { useNitroApp } from '#internal/nitro/app'
import type { Link, Script } from '@unhead/vue' import type { Link, Script } from '@unhead/vue'
import { createServerHead } from '@unhead/vue' import { createServerHead } from '@unhead/vue'
// @ts-expect-error virtual file
import unheadPlugins from '#internal/unhead-plugins.mjs'
// eslint-disable-next-line import/no-restricted-paths // eslint-disable-next-line import/no-restricted-paths
import type { NuxtPayload, NuxtSSRContext } from '#app/nuxt' import type { NuxtPayload, NuxtSSRContext } from '#app/nuxt'
// @ts-expect-error virtual file // @ts-expect-error virtual file
@ -239,8 +242,12 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse
// Get route options (currently to apply `ssr: false`) // Get route options (currently to apply `ssr: false`)
const routeOptions = getRouteRules(event) const routeOptions = getRouteRules(event)
const head = createServerHead() const head = createServerHead({
head.push(appHead) plugins: unheadPlugins
})
// needed for hash hydration plugin to work
const headEntryOptions: HeadEntryOptions = { mode: 'server' }
head.push(appHead, headEntryOptions)
// Initialize ssr context // Initialize ssr context
const ssrContext: NuxtSSRContext = { const ssrContext: NuxtSSRContext = {
@ -336,7 +343,7 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse
? { rel: 'preload', as: 'fetch', crossorigin: 'anonymous', href: payloadURL } ? { rel: 'preload', as: 'fetch', crossorigin: 'anonymous', href: payloadURL }
: { rel: 'modulepreload', href: payloadURL } : { rel: 'modulepreload', href: payloadURL }
] ]
}) }, headEntryOptions)
} }
// 2. Styles // 2. Styles
@ -346,17 +353,17 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse
({ rel: 'stylesheet', href: renderer.rendererContext.buildAssetsURL(resource.file) }) ({ rel: 'stylesheet', href: renderer.rendererContext.buildAssetsURL(resource.file) })
), ),
style: inlinedStyles style: inlinedStyles
}) }, headEntryOptions)
if (!NO_SCRIPTS) { if (!NO_SCRIPTS) {
// 3. Resource Hints // 3. Resource Hints
// TODO: add priorities based on Capo // TODO: add priorities based on Capo
head.push({ head.push({
link: getPreloadLinks(ssrContext, renderer.rendererContext) as Link[] link: getPreloadLinks(ssrContext, renderer.rendererContext) as Link[]
}) }, headEntryOptions)
head.push({ head.push({
link: getPrefetchLinks(ssrContext, renderer.rendererContext) as Link[] link: getPrefetchLinks(ssrContext, renderer.rendererContext) as Link[]
}) }, headEntryOptions)
// 4. Payloads // 4. Payloads
head.push({ head.push({
script: _PAYLOAD_EXTRACTION script: _PAYLOAD_EXTRACTION
@ -367,6 +374,7 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse
? renderPayloadJsonScript({ id: '__NUXT_DATA__', ssrContext, data: ssrContext.payload }) ? renderPayloadJsonScript({ id: '__NUXT_DATA__', ssrContext, data: ssrContext.payload })
: renderPayloadScript({ ssrContext, data: ssrContext.payload }) : renderPayloadScript({ ssrContext, data: ssrContext.payload })
}, { }, {
...headEntryOptions,
// this should come before another end of body scripts // this should come before another end of body scripts
tagPosition: 'bodyClose', tagPosition: 'bodyClose',
tagPriority: 'high' tagPriority: 'high'
@ -382,7 +390,7 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse
defer: resource.module ? null : true, defer: resource.module ? null : true,
crossorigin: '' crossorigin: ''
})) }))
}) }, headEntryOptions)
} }
// remove certain tags for nuxt islands // remove certain tags for nuxt islands

View File

@ -1,5 +1,5 @@
import { resolve } from 'pathe' import { resolve } from 'pathe'
import { addComponent, addImportsSources, addPlugin, defineNuxtModule, tryResolveModule } from '@nuxt/kit' import { addComponent, addImportsSources, addPlugin, addTemplate, defineNuxtModule, tryResolveModule } from '@nuxt/kit'
import { distDir } from '../dirs' import { distDir } from '../dirs'
const components = ['NoScript', 'Link', 'Base', 'Title', 'Meta', 'Style', 'Head', 'Html', 'Body'] const components = ['NoScript', 'Link', 'Base', 'Title', 'Meta', 'Style', 'Head', 'Html', 'Body']
@ -54,9 +54,26 @@ export default defineNuxtModule({
addPlugin({ src: resolve(runtimeDir, 'plugins/vueuse-head-polyfill') }) addPlugin({ src: resolve(runtimeDir, 'plugins/vueuse-head-polyfill') })
} }
if (nuxt.options.experimental.headCapoPlugin) { addTemplate({
addPlugin({ src: resolve(runtimeDir, 'plugins/capo') }) filename: 'unhead-plugins.mjs',
} getContents () {
if (!nuxt.options.experimental.headNext) {
return 'export default []'
}
// TODO don't use HashHydrationPlugin for SPA
return `import { CapoPlugin, HashHydrationPlugin } from '@unhead/vue'
const plugins = [HashHydrationPlugin()];
if (process.server) {
plugins.push(CapoPlugin({ track: true }));
}
export default plugins;`
}
})
// template is only exposed in nuxt context, expose in nitro context as well
nuxt.hooks.hook('nitro:config', (config) => {
config.virtual!['#internal/unhead-plugins.mjs'] = () => nuxt.vfs['#build/unhead-plugins']
})
// Add library-specific plugin // Add library-specific plugin
addPlugin({ src: resolve(runtimeDir, 'plugins/unhead') }) addPlugin({ src: resolve(runtimeDir, 'plugins/unhead') })

View File

@ -1,9 +0,0 @@
import { CapoPlugin } from '@unhead/vue'
import { defineNuxtPlugin } from '#app/nuxt'
export default defineNuxtPlugin({
name: 'nuxt:head:capo',
setup (nuxtApp) {
nuxtApp.vueApp._context.provides.usehead.use(CapoPlugin({ track: true }))
}
})

View File

@ -2,10 +2,17 @@ import { createHead as createClientHead } from '@unhead/vue'
import { renderDOMHead } from '@unhead/dom' import { renderDOMHead } from '@unhead/dom'
import { defineNuxtPlugin } from '#app/nuxt' import { defineNuxtPlugin } from '#app/nuxt'
// @ts-expect-error virtual file
import unheadPlugins from '#build/unhead-plugins.mjs'
export default defineNuxtPlugin({ export default defineNuxtPlugin({
name: 'nuxt:head', name: 'nuxt:head',
setup (nuxtApp) { setup (nuxtApp) {
const head = import.meta.server ? nuxtApp.ssrContext!.head : createClientHead() const head = import.meta.server
? nuxtApp.ssrContext!.head
: createClientHead({
plugins: unheadPlugins
})
// nuxt.config appHead is set server-side within the renderer // nuxt.config appHead is set server-side within the renderer
nuxtApp.vueApp.use(head) nuxtApp.vueApp.use(head)

View File

@ -30,7 +30,7 @@
"@types/file-loader": "5.0.1", "@types/file-loader": "5.0.1",
"@types/pug": "2.0.6", "@types/pug": "2.0.6",
"@types/sass-loader": "8.0.5", "@types/sass-loader": "8.0.5",
"@unhead/schema": "1.2.2", "@unhead/schema": "1.3.2",
"@vitejs/plugin-vue": "4.2.3", "@vitejs/plugin-vue": "4.2.3",
"@vitejs/plugin-vue-jsx": "3.0.1", "@vitejs/plugin-vue-jsx": "3.0.1",
"@vue/compiler-core": "3.3.4", "@vue/compiler-core": "3.3.4",

View File

@ -232,10 +232,12 @@ export default defineUntypedSchema({
asyncContext: false, asyncContext: false,
/** /**
* Add the capo.js head plugin in order to render tags in of the head in a more performant way. * Use new experimental head optimisations:
* - Add the capo.js head plugin in order to render tags in of the head in a more performant way.
* - Uses the hash hydration plugin to reduce initial hydration
* *
* @see https://rviscomi.github.io/capo.js/user/rules/ * @see https://github.com/nuxt/nuxt/discussions/22632
*/ */
headCapoPlugin: false headNext: false
} }
}) })

View File

@ -365,14 +365,14 @@ importers:
specifier: ^14.18.0 || >=16.10.0 specifier: ^14.18.0 || >=16.10.0
version: 18.17.5 version: 18.17.5
'@unhead/dom': '@unhead/dom':
specifier: ^1.2.2 specifier: ^1.3.2
version: 1.2.2 version: 1.3.2
'@unhead/ssr': '@unhead/ssr':
specifier: ^1.2.2 specifier: ^1.3.2
version: 1.2.2 version: 1.3.2
'@unhead/vue': '@unhead/vue':
specifier: ^1.2.2 specifier: ^1.3.2
version: 1.2.2(vue@3.3.4) version: 1.3.2(vue@3.3.4)
'@vue/shared': '@vue/shared':
specifier: ^3.3.4 specifier: ^3.3.4
version: 3.3.4 version: 3.3.4
@ -577,8 +577,8 @@ importers:
specifier: 8.0.5 specifier: 8.0.5
version: 8.0.5 version: 8.0.5
'@unhead/schema': '@unhead/schema':
specifier: 1.2.2 specifier: 1.3.2
version: 1.2.2 version: 1.3.2
'@vitejs/plugin-vue': '@vitejs/plugin-vue':
specifier: 4.2.3 specifier: 4.2.3
version: 4.2.3(vite@4.4.9)(vue@3.3.4) version: 4.2.3(vite@4.4.9)(vue@3.3.4)
@ -3239,41 +3239,41 @@ packages:
eslint-visitor-keys: 3.4.3 eslint-visitor-keys: 3.4.3
dev: true dev: true
/@unhead/dom@1.2.2: /@unhead/dom@1.3.2:
resolution: {integrity: sha512-ohganmg4i1Dd4wwQ2A9oLWEkJNpJRoERJNmFgzmScw9Vi3zMqoS4gPIofT20zUR5rhyyAsFojuDPojJ5vKcmqw==} resolution: {integrity: sha512-iShW0eKzS4TvL0ATtmFNyRdx4JxFKiksoUSDAgkPrMaI8EhYtryU5IL0i5TVySSk4kIjMfLgd8uElOAfUHpTsQ==}
dependencies: dependencies:
'@unhead/schema': 1.2.2 '@unhead/schema': 1.3.2
'@unhead/shared': 1.2.2 '@unhead/shared': 1.3.2
dev: false dev: false
/@unhead/schema@1.2.2: /@unhead/schema@1.3.2:
resolution: {integrity: sha512-cGtNvadL76eGl7QxGjWHZxFqLv9a2VrmRpeEb1d7sm0cvnN0bWngdXDTdUyXzn7RVv/Um+/yae6eiT6A+pyQOw==} resolution: {integrity: sha512-RiJUPipN6wntwpJvHBS8+84/eQyQdnznWTY+AC3woWTUfPHz7M4Hzu6jEkdnzpTX77HQMIIuu734vohmo+L91A==}
dependencies: dependencies:
hookable: 5.5.3 hookable: 5.5.3
zhead: 2.0.10 zhead: 2.0.10
/@unhead/shared@1.2.2: /@unhead/shared@1.3.2:
resolution: {integrity: sha512-bWRjRyVzFsunih9GbHctvS8Aenj6KBe5ycql1JE4LawBL/NRYvCYUCPpdK5poVOqjYr0yDAf9m4JGaM2HwpVLw==} resolution: {integrity: sha512-omOLfVnSkCpiIgikGKkgW6dzs+2jncAXtmPb+/IkFSkevbEfzyQlciDL12h9ChetRXjcWBZhu+OCCw0oY8W/Nw==}
dependencies: dependencies:
'@unhead/schema': 1.2.2 '@unhead/schema': 1.3.2
dev: false dev: false
/@unhead/ssr@1.2.2: /@unhead/ssr@1.3.2:
resolution: {integrity: sha512-mpWSNNbrQFJZolAfdVInPPiSGUva08bK9UbNV1zgDScUz+p+FnRg4cj77X+PpVeJ0+KPgjXfOsI8VQKYt+buYA==} resolution: {integrity: sha512-ygbvJcJoN6wzGZnRfc3QmBtHp1awxEy2IIPbLyqgNWmkDElUfC14xM2BvNAEybSTR/EeX8Sf492CGw+5I0RqPw==}
dependencies: dependencies:
'@unhead/schema': 1.2.2 '@unhead/schema': 1.3.2
'@unhead/shared': 1.2.2 '@unhead/shared': 1.3.2
dev: false dev: false
/@unhead/vue@1.2.2(vue@3.3.4): /@unhead/vue@1.3.2(vue@3.3.4):
resolution: {integrity: sha512-AxOmY5JPn4fS34ovaivPnqg2my+InIkZDNSxCKfRkmbBtstFre/Fyf0d92Qfx0u8PJiSRPOjthEHx5vKDgTEJQ==} resolution: {integrity: sha512-pM5SbKTTTzLroVHGfqBkDA8WCVk1qVWBe8sia5Rw1pSntp99VgDawJkiCdJJbyh/bOUOvCuAgUVHH1IRpB8QmQ==}
peerDependencies: peerDependencies:
vue: '>=2.7 || >=3' vue: '>=2.7 || >=3'
dependencies: dependencies:
'@unhead/schema': 1.2.2 '@unhead/schema': 1.3.2
'@unhead/shared': 1.2.2 '@unhead/shared': 1.3.2
hookable: 5.5.3 hookable: 5.5.3
unhead: 1.2.2 unhead: 1.3.2
vue: 3.3.4 vue: 3.3.4
dev: false dev: false
@ -7183,7 +7183,7 @@ packages:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
/jstransformer@1.0.0: /jstransformer@1.0.0:
resolution: {integrity: sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A==} resolution: {integrity: sha1-7Yvwkh4vPx7U1cGkT2hwntJHIsM=}
dependencies: dependencies:
is-promise: 2.2.2 is-promise: 2.2.2
promise: 7.3.1 promise: 7.3.1
@ -10316,7 +10316,7 @@ packages:
engines: {node: '>=0.6'} engines: {node: '>=0.6'}
/token-stream@1.0.0: /token-stream@1.0.0:
resolution: {integrity: sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg==} resolution: {integrity: sha1-zCAOqyYT9BZtJ/+a/HylbUnfbrQ=}
dev: false dev: false
/totalist@1.1.0: /totalist@1.1.0:
@ -10532,12 +10532,12 @@ packages:
node-fetch-native: 1.2.0 node-fetch-native: 1.2.0
pathe: 1.1.1 pathe: 1.1.1
/unhead@1.2.2: /unhead@1.3.2:
resolution: {integrity: sha512-9wDuiso7YWNe0BTA5NGsHR0dtqn0YrL/5+NumfuXDxxYykavc6N27pzZxTXiuvVHbod8tFicsxA6pC9WhQvzqg==} resolution: {integrity: sha512-s4qW/Rcp6OD4GRBreAQYRD4B1ch7zhVt57IGUIGdn6xwT0tHJucHBv2GbqdpaTLmZcUOdblBIt2HXdOlbW2YHg==}
dependencies: dependencies:
'@unhead/dom': 1.2.2 '@unhead/dom': 1.3.2
'@unhead/schema': 1.2.2 '@unhead/schema': 1.3.2
'@unhead/shared': 1.2.2 '@unhead/shared': 1.3.2
hookable: 5.5.3 hookable: 5.5.3
dev: false dev: false

View File

@ -19,7 +19,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
for (const outputDir of ['.output', '.output-inline']) { for (const outputDir of ['.output', '.output-inline']) {
it('default client bundle size', async () => { it('default client bundle size', async () => {
const clientStats = await analyzeSizes('**/*.js', join(rootDir, outputDir, 'public')) const clientStats = await analyzeSizes('**/*.js', join(rootDir, outputDir, 'public'))
expect.soft(roundToKilobytes(clientStats.totalBytes)).toMatchInlineSnapshot('"97.4k"') expect.soft(roundToKilobytes(clientStats.totalBytes)).toMatchInlineSnapshot('"95.0k"')
expect(clientStats.files.map(f => f.replace(/\..*\.js/, '.js'))).toMatchInlineSnapshot(` expect(clientStats.files.map(f => f.replace(/\..*\.js/, '.js'))).toMatchInlineSnapshot(`
[ [
"_nuxt/entry.js", "_nuxt/entry.js",
@ -32,10 +32,10 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
const serverDir = join(rootDir, '.output/server') const serverDir = join(rootDir, '.output/server')
const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir) const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot('"64.5k"') expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot('"64.6k"')
const modules = await analyzeSizes('node_modules/**/*', serverDir) const modules = await analyzeSizes('node_modules/**/*', serverDir)
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot('"2342k"') expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot('"2335k"')
const packages = modules.files const packages = modules.files
.filter(m => m.endsWith('package.json')) .filter(m => m.endsWith('package.json'))
@ -95,7 +95,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot('"370k"') expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot('"370k"')
const modules = await analyzeSizes('node_modules/**/*', serverDir) const modules = await analyzeSizes('node_modules/**/*', serverDir)
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot('"604k"') expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot('"597k"')
const packages = modules.files const packages = modules.files
.filter(m => m.endsWith('package.json')) .filter(m => m.endsWith('package.json'))

View File

@ -195,7 +195,7 @@ export default defineNuxtConfig({
treeshakeClientOnly: true, treeshakeClientOnly: true,
payloadExtraction: true, payloadExtraction: true,
asyncContext: process.env.TEST_CONTEXT === 'async', asyncContext: process.env.TEST_CONTEXT === 'async',
headCapoPlugin: true headNext: true
}, },
appConfig: { appConfig: {
fromNuxtConfig: true, fromNuxtConfig: true,