feat(nuxt): allow server islands to manipulate head (#27987)

This commit is contained in:
Julien Huang 2024-08-22 14:05:39 +02:00 committed by Daniel Roe
parent e4569bbdc6
commit 3f1db54b80
No known key found for this signature in database
GPG Key ID: 3714AB03996F442B
11 changed files with 375 additions and 930 deletions

View File

@ -57,6 +57,7 @@
"@types/node": "20.16.1",
"@types/semver": "7.5.8",
"@unhead/schema": "1.10.0",
"@unhead/vue": "^1.10.0",
"@vitejs/plugin-vue": "5.1.2",
"@vitest/coverage-v8": "2.0.5",
"@vue/test-utils": "2.4.6",

View File

@ -1,6 +1,7 @@
import type { defineAsyncComponent } from 'vue'
import { createVNode, defineComponent, onErrorCaptured } from 'vue'
import { injectHead } from '@unhead/vue'
import { createError } from '../composables/error'
// @ts-expect-error virtual file
@ -14,6 +15,10 @@ export default defineComponent({
},
},
setup (props) {
// reset head - we don't want to have any head tags from plugin or anywhere else.
const head = injectHead()
head.headEntries().splice(0, head.headEntries().length)
const component = islandComponents[props.context.name] as ReturnType<typeof defineAsyncComponent>
if (!component) {

View File

@ -3,7 +3,7 @@ import { Fragment, Teleport, computed, createStaticVNode, createVNode, defineCom
import { debounce } from 'perfect-debounce'
import { hash } from 'ohash'
import { appendResponseHeader } from 'h3'
import { useHead } from '@unhead/vue'
import { injectHead } from '@unhead/vue'
import { randomUUID } from 'uncrypto'
import { joinURL, withQuery } from 'ufo'
import type { FetchResponse } from 'ofetch'
@ -96,7 +96,7 @@ export default defineComponent({
if (result.props) { toRevive.props = result.props }
if (result.slots) { toRevive.slots = result.slots }
if (result.components) { toRevive.components = result.components }
if (result.head) { toRevive.head = result.head }
nuxtApp.payload.data[key] = {
__nuxt_island: {
key,
@ -158,8 +158,7 @@ export default defineComponent({
return html
})
const cHead = ref<Record<'link' | 'style', Array<Record<string, string>>>>({ link: [], style: [] })
useHead(cHead)
const head = injectHead()
async function _fetchComponent (force = false) {
const key = `${props.name}_${hashId.value}`
@ -199,8 +198,7 @@ export default defineComponent({
}
try {
const res: NuxtIslandResponse = await nuxtApp[pKey][uid.value]
cHead.value.link = res.head.link
cHead.value.style = res.head.style
ssrHTML.value = res.html.replaceAll(DATA_ISLAND_UID_RE, `data-island-uid="${uid.value}"`)
key.value++
error.value = null
@ -248,6 +246,13 @@ export default defineComponent({
await loadComponents(props.source, payloads.components)
}
if (import.meta.server || nuxtApp.isHydrating) {
// re-push head into active head instance
(nuxtApp.payload.data[`${props.name}_${hashId.value}`] as NuxtIslandResponse)?.head?.forEach((h) => {
head.push(h)
})
}
return (_ctx: any, _cache: any) => {
if (!html.value || error.value) {
return [slots.fallback?.({ error: error.value }) ?? createVNode('div')]

View File

@ -31,11 +31,6 @@ if (componentIslands) {
}
return {
html: '',
state: {},
head: {
link: [],
style: [],
},
...result,
}
}

View File

@ -16,11 +16,10 @@ import { stringify, uneval } from 'devalue'
import destr from 'destr'
import { getQuery as getURLQuery, joinURL, withoutTrailingSlash } from 'ufo'
import { renderToString as _renderToString } from 'vue/server-renderer'
import { hash } from 'ohash'
import { propsToString, renderSSRHead } from '@unhead/ssr'
import type { HeadEntryOptions } from '@unhead/schema'
import type { Head, HeadEntryOptions } from '@unhead/schema'
import type { Link, Script, Style } from '@unhead/vue'
import { createServerHead } from '@unhead/vue'
import { createServerHead, resolveUnrefHeadInput } from '@unhead/vue'
import { defineRenderHandler, getRouteRules, useRuntimeConfig, useStorage } from '#internal/nitro'
import { useNitroApp } from '#internal/nitro/app'
@ -80,10 +79,7 @@ export interface NuxtIslandContext {
export interface NuxtIslandResponse {
id?: string
html: string
head: {
link: (Record<string, string>)[]
style: ({ innerHTML: string, key: string })[]
}
head: Head[]
props?: Record<string, Record<string, any>>
components?: Record<string, NuxtIslandClientResponse>
slots?: Record<string, NuxtIslandSlotResponse>
@ -289,6 +285,7 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse
const head = createServerHead({
plugins: unheadPlugins,
})
// needed for hash hydration plugin to work
const headEntryOptions: HeadEntryOptions = { mode: 'server' }
if (!isRenderingIsland) {
@ -395,7 +392,9 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse
}
// 2. Styles
head.push({ style: inlinedStyles })
if (inlinedStyles.length) {
head.push({ style: inlinedStyles })
}
if (!isRenderingIsland || import.meta.dev) {
const link: Link[] = []
for (const style in styles) {
@ -412,7 +411,9 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse
link.push({ rel: 'stylesheet', href: renderer.rendererContext.buildAssetsURL(resource.file) })
}
}
head.push({ link }, headEntryOptions)
if (link.length) {
head.push({ link }, headEntryOptions)
}
}
if (!NO_SCRIPTS && !isRenderingIsland) {
@ -478,20 +479,9 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse
// Response for component islands
if (isRenderingIsland && islandContext) {
const islandHead: NuxtIslandResponse['head'] = {
link: [],
style: [],
}
for (const tag of await head.resolveTags()) {
if (tag.tag === 'link') {
islandHead.link.push({ key: 'island-link-' + hash(tag.props), ...tag.props })
} else if (tag.tag === 'style' && tag.innerHTML) {
islandHead.style.push({ key: 'island-style-' + hash(tag.innerHTML), innerHTML: tag.innerHTML })
}
}
const islandResponse: NuxtIslandResponse = {
id: islandContext.id,
head: islandHead,
head: (head.headEntries().map(h => resolveUnrefHeadInput(h.input) as Head)),
html: getServerComponentHTML(htmlContext.body),
components: getClientIslandResponse(ssrContext),
slots: getSlotIslandResponse(ssrContext),

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,8 @@ import { join, normalize } from 'pathe'
import { $fetch, createPage, fetch, isDev, setup, startServer, url, useTestContext } from '@nuxt/test-utils/e2e'
import { $fetchComponent } from '@nuxt/test-utils/experimental'
import { expectNoClientErrors, expectWithPolling, gotoPath, isRenderingJson, parseData, parsePayload, renderPage } from './utils'
import { resolveUnrefHeadInput } from '@unhead/vue'
import { expectNoClientErrors, expectWithPolling, gotoPath, isRenderingJson, parseData, parsePayload, renderPage, resolveHead } from './utils'
import type { NuxtIslandResponse } from '#app'
@ -135,7 +136,7 @@ describe('pages', () => {
// should apply attributes to client-only components
expect(html).toContain('<div style="color:red;" class="client-only"></div>')
// should render server-only components
expect(html.replace(/ data-island-uid="[^"]*"/, '')).toContain('<div class="server-only" style="background-color:gray;"> server-only component <div> server-only component child (non-server-only) </div></div>')
expect(html.replaceAll(/ data-island-uid="[^"]*"/g, '')).toContain('<div class="server-only" style="background-color:gray;"> server-only component <div> server-only component child (non-server-only) </div></div>')
// should register global components automatically
expect(html).toContain('global component registered automatically')
expect(html).toContain('global component via suffix')
@ -1932,6 +1933,12 @@ describe('server components/islands', () => {
await page.close()
})
it('/server-page', async () => {
const html = await $fetch<string>('/server-page')
// test island head
expect(html).toContain('<meta name="author" content="Nuxt">')
})
it.skipIf(isDev)('should allow server-only components to set prerender hints', async () => {
// @ts-expect-error ssssh! untyped secret property
const publicDir = useTestContext().nuxt._nitro.options.output.publicDir
@ -2141,15 +2148,15 @@ describe('component islands', () => {
result.html = result.html.replace(/ data-island-uid="[^"]*"/g, '')
if (isDev()) {
result.head.link = result.head.link.filter(l => !l.href!.includes('@nuxt+ui-templates') && (l.href!.startsWith('_nuxt/components/islands/') && l.href!.includes('_nuxt/components/islands/RouteComponent')))
result.head = resolveHead(result.head).map(h => ({
...h,
link: h.link?.filter(l => typeof l.href !== 'string' || (!l.href.includes('_nuxt/components/islands/RouteComponent') && !l.href.includes('PureComponent') /* TODO: fix dev bug triggered by previous fetch of /islands */)),
})).filter(h => Object.values(h).some(h => !Array.isArray(h) || h.length))
}
expect(result).toMatchInlineSnapshot(`
{
"head": {
"link": [],
"style": [],
},
"head": [],
"html": "<pre data-island-uid> Route: /foo
</pre>",
}
@ -2163,15 +2170,15 @@ describe('component islands', () => {
}),
}))
if (isDev()) {
result.head.link = result.head.link.filter(l => !l.href!.includes('@nuxt+ui-templates') && (l.href!.startsWith('_nuxt/components/islands/') && l.href!.includes('_nuxt/components/islands/LongAsyncComponent')))
result.head = resolveHead(result.head).map(h => ({
...h,
link: h.link?.filter(l => typeof l.href !== 'string' || (!l.href.includes('_nuxt/components/islands/LongAsyncComponent') && !l.href.includes('PureComponent') /* TODO: fix dev bug triggered by previous fetch of /islands */)),
})).filter(h => Object.values(h).some(h => !Array.isArray(h) || h.length))
}
result.html = result.html.replaceAll(/ (data-island-uid|data-island-component)="([^"]*)"/g, '')
expect(result).toMatchInlineSnapshot(`
{
"head": {
"link": [],
"style": [],
},
"head": [],
"html": "<div data-island-uid><div> count is above 2 </div><!--[--><div style="display: contents;" data-island-uid data-island-slot="default"><!--teleport start--><!--teleport end--></div><!--]--> that was very long ... <div id="long-async-component-count">3</div> <!--[--><div style="display: contents;" data-island-uid data-island-slot="test"><!--teleport start--><!--teleport end--></div><!--]--><p>hello world !!!</p><!--[--><div style="display: contents;" data-island-uid data-island-slot="hello"><!--teleport start--><!--teleport end--></div><!--teleport start--><!--teleport end--><!--]--><!--[--><div style="display: contents;" data-island-uid data-island-slot="fallback"><!--teleport start--><!--teleport end--></div><!--teleport start--><!--teleport end--><!--]--></div>",
"slots": {
"default": {
@ -2221,7 +2228,10 @@ describe('component islands', () => {
}),
}))
if (isDev()) {
result.head.link = result.head.link.filter(l => !l.href!.includes('@nuxt+ui-templates') && (l.href!.startsWith('_nuxt/components/islands/') && l.href!.includes('_nuxt/components/islands/AsyncServerComponent')))
result.head = result.head.map(h => ({
...h,
link: h.link?.filter(l => typeof l.href === 'string' && !l.href.includes('PureComponent') /* TODO: fix dev bug triggered by previous fetch of /islands */ && (!l.href.startsWith('_nuxt/components/islands/') || l.href.includes('AsyncServerComponent'))),
})).filter(h => Object.values(h).some(h => !Array.isArray(h) || h.length))
}
result.props = {}
result.components = {}
@ -2231,10 +2241,7 @@ describe('component islands', () => {
expect(result).toMatchInlineSnapshot(`
{
"components": {},
"head": {
"link": [],
"style": [],
},
"head": [],
"html": "<div data-island-uid> This is a .server (20ms) async component that was very long ... <div id="async-server-component-count">2</div><div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><!--[--><div style="display: contents;" data-island-uid data-island-slot="default"><!--teleport start--><!--teleport end--></div><!--]--></div>",
"props": {},
"slots": {},
@ -2246,7 +2253,10 @@ describe('component islands', () => {
it('render server component with selective client hydration', async () => {
const result = await $fetch<NuxtIslandResponse>('/__nuxt_island/ServerWithClient')
if (isDev()) {
result.head.link = result.head.link.filter(l => !l.href!.includes('@nuxt+ui-templates') && (l.href!.startsWith('_nuxt/components/islands/') && l.href!.includes('_nuxt/components/islands/AsyncServerComponent')))
result.head = resolveHead(result.head).map(h => ({
...h,
link: h.link?.filter(l => typeof l.href !== 'string' || (!l.href.includes('_nuxt/components/islands/LongAsyncComponent') && !l.href.includes('PureComponent') /* TODO: fix dev bug triggered by previous fetch of /islands */)),
})).filter(h => Object.values(h).some(h => !Array.isArray(h) || h.length))
}
const { components } = result
result.components = {}
@ -2258,10 +2268,7 @@ describe('component islands', () => {
expect(result).toMatchInlineSnapshot(`
{
"components": {},
"head": {
"link": [],
"style": [],
},
"head": [],
"html": "<div data-island-uid> ServerWithClient.server.vue : <p>count: 0</p> This component should not be preloaded <div><!--[--><div>a</div><div>b</div><div>c</div><!--]--></div> This is not interactive <div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><div class="interactive-component-wrapper" style="border:solid 1px red;"> The component below is not a slot but declared as interactive <!--[--><div style="display: contents;" data-island-uid data-island-component="Counter"></div><!--teleport start--><!--teleport end--><!--]--></div></div>",
"slots": {},
}
@ -2269,10 +2276,10 @@ describe('component islands', () => {
expect(teleportsEntries).toHaveLength(1)
expect(teleportsEntries[0]![0].startsWith('Counter-')).toBeTruthy()
expect(teleportsEntries[0]![1].props).toMatchInlineSnapshot(`
{
"multiplier": 1,
}
`)
{
"multiplier": 1,
}
`)
expect(teleportsEntries[0]![1].html).toMatchInlineSnapshot(`"<div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><!--teleport anchor-->"`)
})
}
@ -2290,41 +2297,52 @@ describe('component islands', () => {
if (isDev()) {
const fixtureDir = normalize(fileURLToPath(new URL('./fixtures/basic', import.meta.url)))
for (const link of result.head.link) {
link.href = link.href!.replace(fixtureDir, '/<rootDir>').replaceAll('//', '/')
link.key = link.key!.replace(/-[a-z0-9]+$/i, '')
for (const head of result.head) {
for (const key in head) {
if (key === 'link') {
head[key] = head[key]?.map((h) => {
if (h.href) {
h.href = resolveUnrefHeadInput(h.href).replace(fixtureDir, '/<rootDir>').replaceAll('//', '/')
}
return h
})
}
}
}
result.head.link.sort((a, b) => b.href!.localeCompare(a.href!))
}
// TODO: fix rendering of styles in webpack
if (!isDev() && !isWebpack) {
expect(normaliseIslandResult(result).head).toMatchInlineSnapshot(`
{
"link": [],
"style": [
{
"innerHTML": "pre[data-v-xxxxx]{color:blue}",
"key": "island-style",
},
],
}
[
{
"style": [
{
"innerHTML": "pre[data-v-xxxxx]{color:blue}",
},
],
},
]
`)
} else if (isDev() && !isWebpack) {
// TODO: resolve dev bug triggered by earlier fetch of /vueuse-head page
// https://github.com/nuxt/nuxt/blob/main/packages/nuxt/src/core/runtime/nitro/renderer.ts#L139
result.head.link = result.head.link.filter(h => !h.href!.includes('SharedComponent'))
result.head = resolveHead(result.head).map(h => ({
...h,
link: h.link?.filter(l => typeof l.href !== 'string' || !l.href.includes('SharedComponent')),
})).filter(h => Object.values(h).some(h => !Array.isArray(h) || h.length))
expect(result.head).toMatchInlineSnapshot(`
{
"link": [
{
"href": "/_nuxt/components/islands/PureComponent.vue?vue&type=style&index=0&scoped=c0c0cf89&lang.css",
"key": "island-link",
"rel": "stylesheet",
},
],
"style": [],
}
[
{
"link": [
{
"href": "/_nuxt/components/islands/PureComponent.vue?vue&type=style&index=0&scoped=c0c0cf89&lang.css",
"rel": "stylesheet",
},
],
},
]
`)
}
@ -2671,14 +2689,21 @@ describe('Node.js compatibility for client-side', () => {
function normaliseIslandResult (result: NuxtIslandResponse) {
return {
...result,
head: {
...result.head,
style: result.head.style.map(s => ({
...s,
innerHTML: (s.innerHTML || '').replace(/data-v-[a-z0-9]+/, 'data-v-xxxxx').replace(/\.[a-zA-Z0-9]+\.svg/, '.svg'),
key: s.key.replace(/-[a-z0-9]+$/i, ''),
})),
},
head: result.head.map((h) => {
if (h.style) {
for (const style of h.style) {
if (typeof style !== 'string') {
if (style.innerHTML) {
style.innerHTML = (style.innerHTML as string).replace(/data-v-[a-z0-9]+/g, 'data-v-xxxxx')
}
if (style.key) {
style.key = style.key.replace(/-[a-z0-9]+$/i, '')
}
}
}
return h
}
}),
}
}

View File

@ -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(`"210k"`)
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"209k"`)
const modules = await analyzeSizes('node_modules/**/*', serverDir)
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"1351k"`)
@ -72,7 +72,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
const serverDir = join(rootDir, '.output-inline/server')
const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"534k"`)
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"533k"`)
const modules = await analyzeSizes('node_modules/**/*', serverDir)
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"82.1k"`)

View File

@ -79,6 +79,11 @@
style="color: red;"
class="client-only"
/>
<NuxtIsland
ref="island"
name="AsyncServerComponent"
:props="{ count: 34 }"
/>
<ServerOnlyComponent
class="server-only"
style="background-color: gray;"
@ -96,9 +101,10 @@
import { setupDevtoolsPlugin } from '@vue/devtools-api'
import { useRuntimeConfig } from '#imports'
import { importedRE, importedValue } from '~/some-exports'
import type { NuxtIsland, ServerOnlyComponent } from '#build/components'
setupDevtoolsPlugin({}, () => {}) as any
const island = ref<InstanceType<typeof ServerOnlyComponent>>()
const config = useRuntimeConfig()
const someValue = useState('val', () => 1)
@ -107,7 +113,30 @@ const NestedCounter = resolveComponent('NestedCounter')
if (!NestedCounter) {
throw new Error('Component not found')
}
useHead({
meta: [
{
name: 'author',
content: 'Nuxt',
key: 'testkey',
},
{
name: 'author',
content: 'Nuxt',
key: 'testkey',
},
],
script: [
{
innerHTML: 'console.log("my script")',
key: 'my-script',
},
{
innerHTML: 'console.log("my script")',
key: 'my-script',
},
],
}, { key: 'testkey' })
definePageMeta({
alias: '/some-alias',
other: ref('test'),

View File

@ -8,3 +8,15 @@
</NuxtLink>
</div>
</template>
<script setup lang="ts">
useHead({
title: 'Server Page',
meta: [
{
name: 'author',
content: 'Nuxt',
},
],
})
</script>

View File

@ -5,6 +5,8 @@ import { parse } from 'devalue'
import { reactive, ref, shallowReactive, shallowRef } from 'vue'
import { createError } from 'h3'
import { getBrowser, url, useTestContext } from '@nuxt/test-utils/e2e'
import type { Head } from '@unhead/vue'
import { resolveUnrefHeadInput } from '@unhead/vue'
export const isRenderingJson = process.env.TEST_PAYLOAD !== 'js'
@ -126,3 +128,7 @@ export function parseData (html: string) {
attrs: _attrs,
}
}
export function resolveHead (head: Head[]) {
return head.map(i => resolveUnrefHeadInput(i) as Head)
}