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 GitHub
parent f602062442
commit 8730dde90b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 166 additions and 100 deletions

View File

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

View File

@ -1,6 +1,7 @@
import type { defineAsyncComponent } from 'vue' import type { defineAsyncComponent } from 'vue'
import { createVNode, defineComponent, onErrorCaptured } from 'vue' import { createVNode, defineComponent, onErrorCaptured } from 'vue'
import { injectHead } from '@unhead/vue'
import { createError } from '../composables/error' import { createError } from '../composables/error'
// @ts-expect-error virtual file // @ts-expect-error virtual file
@ -14,6 +15,10 @@ export default defineComponent({
}, },
}, },
setup (props) { 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> const component = islandComponents[props.context.name] as ReturnType<typeof defineAsyncComponent>
if (!component) { if (!component) {

View File

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

View File

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

View File

@ -16,11 +16,10 @@ import { stringify, uneval } from 'devalue'
import destr from 'destr' import destr from 'destr'
import { getQuery as getURLQuery, joinURL, withoutTrailingSlash } from 'ufo' import { getQuery as getURLQuery, 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 { propsToString, renderSSRHead } from '@unhead/ssr' 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 type { Link, Script, Style } from '@unhead/vue'
import { createServerHead } from '@unhead/vue' import { createServerHead, resolveUnrefHeadInput } from '@unhead/vue'
import { defineRenderHandler, getRouteRules, useNitroApp, useRuntimeConfig, useStorage } from 'nitro/runtime' import { defineRenderHandler, getRouteRules, useNitroApp, useRuntimeConfig, useStorage } from 'nitro/runtime'
@ -79,10 +78,7 @@ export interface NuxtIslandContext {
export interface NuxtIslandResponse { export interface NuxtIslandResponse {
id?: string id?: string
html: string html: string
head: { head: Head[]
link: (Record<string, string>)[]
style: ({ innerHTML: string, key: string })[]
}
props?: Record<string, Record<string, any>> props?: Record<string, Record<string, any>>
components?: Record<string, NuxtIslandClientResponse> components?: Record<string, NuxtIslandClientResponse>
slots?: Record<string, NuxtIslandSlotResponse> slots?: Record<string, NuxtIslandSlotResponse>
@ -288,6 +284,7 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse
const head = createServerHead({ const head = createServerHead({
plugins: unheadPlugins, plugins: unheadPlugins,
}) })
// needed for hash hydration plugin to work // needed for hash hydration plugin to work
const headEntryOptions: HeadEntryOptions = { mode: 'server' } const headEntryOptions: HeadEntryOptions = { mode: 'server' }
if (!isRenderingIsland) { if (!isRenderingIsland) {
@ -394,7 +391,9 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse
} }
// 2. Styles // 2. Styles
head.push({ style: inlinedStyles }) if (inlinedStyles.length) {
head.push({ style: inlinedStyles })
}
if (!isRenderingIsland || import.meta.dev) { if (!isRenderingIsland || import.meta.dev) {
const link: Link[] = [] const link: Link[] = []
for (const style in styles) { for (const style in styles) {
@ -411,7 +410,9 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse
link.push({ rel: 'stylesheet', href: renderer.rendererContext.buildAssetsURL(resource.file) }) 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) { if (!NO_SCRIPTS && !isRenderingIsland) {
@ -460,20 +461,9 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse
// Response for component islands // Response for component islands
if (isRenderingIsland && islandContext) { 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 = { const islandResponse: NuxtIslandResponse = {
id: islandContext.id, id: islandContext.id,
head: islandHead, head: (head.headEntries().map(h => resolveUnrefHeadInput(h.input) as Head)),
html: getServerComponentHTML(_rendered.html), html: getServerComponentHTML(_rendered.html),
components: getClientIslandResponse(ssrContext), components: getClientIslandResponse(ssrContext),
slots: getSlotIslandResponse(ssrContext), slots: getSlotIslandResponse(ssrContext),

View File

@ -62,6 +62,9 @@ importers:
'@unhead/schema': '@unhead/schema':
specifier: 1.10.0 specifier: 1.10.0
version: 1.10.0 version: 1.10.0
'@unhead/vue':
specifier: ^1.10.0
version: 1.10.0(vue@3.4.38(typescript@5.5.4))
'@vitejs/plugin-vue': '@vitejs/plugin-vue':
specifier: 5.1.2 specifier: 5.1.2
version: 5.1.2(vite@5.4.2(@types/node@20.16.1)(sass@1.69.4)(terser@5.27.0))(vue@3.4.38(typescript@5.5.4)) version: 5.1.2(vite@5.4.2(@types/node@20.16.1)(sass@1.69.4)(terser@5.27.0))(vue@3.4.38(typescript@5.5.4))
@ -6785,9 +6788,6 @@ packages:
uc.micro@2.1.0: uc.micro@2.1.0:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
ufo@1.5.3:
resolution: {integrity: sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==}
ufo@1.5.4: ufo@1.5.4:
resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==} resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==}
@ -12617,7 +12617,7 @@ snapshots:
serve-placeholder: 2.0.2 serve-placeholder: 2.0.2
serve-static: 1.15.0 serve-static: 1.15.0
std-env: 3.7.0 std-env: 3.7.0
ufo: 1.5.3 ufo: 1.5.4
uncrypto: 0.1.3 uncrypto: 0.1.3
unctx: 2.3.1 unctx: 2.3.1
unenv: 1.10.0 unenv: 1.10.0
@ -14175,8 +14175,6 @@ snapshots:
uc.micro@2.1.0: {} uc.micro@2.1.0: {}
ufo@1.5.3: {}
ufo@1.5.4: {} ufo@1.5.4: {}
uglify-js@3.17.4: {} uglify-js@3.17.4: {}

View File

@ -7,7 +7,8 @@ import { join, normalize } from 'pathe'
import { $fetch as _$fetch, createPage, fetch, isDev, setup, startServer, url, useTestContext } from '@nuxt/test-utils/e2e' import { $fetch as _$fetch, createPage, fetch, isDev, setup, startServer, url, useTestContext } from '@nuxt/test-utils/e2e'
import { $fetchComponent } from '@nuxt/test-utils/experimental' 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' import type { NuxtIslandResponse } from '#app'
@ -137,7 +138,7 @@ describe('pages', () => {
// should apply attributes to client-only components // should apply attributes to client-only components
expect(html).toContain('<div style="color:red;" class="client-only"></div>') expect(html).toContain('<div style="color:red;" class="client-only"></div>')
// should render server-only components // 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 // should register global components automatically
expect(html).toContain('global component registered automatically') expect(html).toContain('global component registered automatically')
expect(html).toContain('global component via suffix') expect(html).toContain('global component via suffix')
@ -1929,6 +1930,12 @@ describe('server components/islands', () => {
await page.close() 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 () => { it.skipIf(isDev)('should allow server-only components to set prerender hints', async () => {
// @ts-expect-error ssssh! untyped secret property // @ts-expect-error ssssh! untyped secret property
const publicDir = useTestContext().nuxt._nitro.options.output.publicDir const publicDir = useTestContext().nuxt._nitro.options.output.publicDir
@ -2138,15 +2145,15 @@ describe('component islands', () => {
result.html = result.html.replace(/ data-island-uid="[^"]*"/g, '') result.html = result.html.replace(/ data-island-uid="[^"]*"/g, '')
if (isDev()) { 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(` expect(result).toMatchInlineSnapshot(`
{ {
"head": { "head": [],
"link": [],
"style": [],
},
"html": "<pre data-island-uid> Route: /foo "html": "<pre data-island-uid> Route: /foo
</pre>", </pre>",
} }
@ -2160,15 +2167,15 @@ describe('component islands', () => {
}), }),
})) }))
if (isDev()) { 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, '') result.html = result.html.replaceAll(/ (data-island-uid|data-island-component)="([^"]*)"/g, '')
expect(result).toMatchInlineSnapshot(` expect(result).toMatchInlineSnapshot(`
{ {
"head": { "head": [],
"link": [],
"style": [],
},
"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>", "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": { "slots": {
"default": { "default": {
@ -2218,7 +2225,10 @@ describe('component islands', () => {
}), }),
})) }))
if (isDev()) { 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.props = {}
result.components = {} result.components = {}
@ -2228,10 +2238,7 @@ describe('component islands', () => {
expect(result).toMatchInlineSnapshot(` expect(result).toMatchInlineSnapshot(`
{ {
"components": {}, "components": {},
"head": { "head": [],
"link": [],
"style": [],
},
"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>", "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": {}, "props": {},
"slots": {}, "slots": {},
@ -2243,7 +2250,10 @@ describe('component islands', () => {
it('render server component with selective client hydration', async () => { it('render server component with selective client hydration', async () => {
const result = await $fetch<NuxtIslandResponse>('/__nuxt_island/ServerWithClient') const result = await $fetch<NuxtIslandResponse>('/__nuxt_island/ServerWithClient')
if (isDev()) { 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 const { components } = result
result.components = {} result.components = {}
@ -2255,10 +2265,7 @@ describe('component islands', () => {
expect(result).toMatchInlineSnapshot(` expect(result).toMatchInlineSnapshot(`
{ {
"components": {}, "components": {},
"head": { "head": [],
"link": [],
"style": [],
},
"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>", "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": {}, "slots": {},
} }
@ -2266,10 +2273,10 @@ describe('component islands', () => {
expect(teleportsEntries).toHaveLength(1) expect(teleportsEntries).toHaveLength(1)
expect(teleportsEntries[0]![0].startsWith('Counter-')).toBeTruthy() expect(teleportsEntries[0]![0].startsWith('Counter-')).toBeTruthy()
expect(teleportsEntries[0]![1].props).toMatchInlineSnapshot(` 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-->"`) expect(teleportsEntries[0]![1].html).toMatchInlineSnapshot(`"<div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><!--teleport anchor-->"`)
}) })
} }
@ -2287,41 +2294,52 @@ describe('component islands', () => {
if (isDev()) { if (isDev()) {
const fixtureDir = normalize(fileURLToPath(new URL('./fixtures/basic', import.meta.url))) const fixtureDir = normalize(fileURLToPath(new URL('./fixtures/basic', import.meta.url)))
for (const link of result.head.link) { for (const head of result.head) {
link.href = link.href!.replace(fixtureDir, '/<rootDir>').replaceAll('//', '/') for (const key in head) {
link.key = link.key!.replace(/-[a-z0-9]+$/i, '') 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 // TODO: fix rendering of styles in webpack
if (!isDev() && !isWebpack) { if (!isDev() && !isWebpack) {
expect(normaliseIslandResult(result).head).toMatchInlineSnapshot(` expect(normaliseIslandResult(result).head).toMatchInlineSnapshot(`
{ [
"link": [], {
"style": [ "style": [
{ {
"innerHTML": "pre[data-v-xxxxx]{color:blue}", "innerHTML": "pre[data-v-xxxxx]{color:blue}",
"key": "island-style", },
}, ],
], },
} ]
`) `)
} else if (isDev() && !isWebpack) { } else if (isDev() && !isWebpack) {
// TODO: resolve dev bug triggered by earlier fetch of /vueuse-head page // 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 // 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(` expect(result.head).toMatchInlineSnapshot(`
{ [
"link": [ {
{ "link": [
"href": "/_nuxt/components/islands/PureComponent.vue?vue&type=style&index=0&scoped=c0c0cf89&lang.css", {
"key": "island-link", "href": "/_nuxt/components/islands/PureComponent.vue?vue&type=style&index=0&scoped=c0c0cf89&lang.css",
"rel": "stylesheet", "rel": "stylesheet",
}, },
], ],
"style": [], },
} ]
`) `)
} }
@ -2668,14 +2686,21 @@ describe('Node.js compatibility for client-side', () => {
function normaliseIslandResult (result: NuxtIslandResponse) { function normaliseIslandResult (result: NuxtIslandResponse) {
return { return {
...result, ...result,
head: { head: result.head.map((h) => {
...result.head, if (h.style) {
style: result.head.style.map(s => ({ for (const style of h.style) {
...s, if (typeof style !== 'string') {
innerHTML: (s.innerHTML || '').replace(/data-v-[a-z0-9]+/, 'data-v-xxxxx').replace(/\.[a-zA-Z0-9]+\.svg/, '.svg'), if (style.innerHTML) {
key: s.key.replace(/-[a-z0-9]+$/i, ''), 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 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(`"211k"`) expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"205k"`)
const modules = await analyzeSizes('node_modules/**/*', serverDir) const modules = await analyzeSizes('node_modules/**/*', serverDir)
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"1355k"`) expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"1355k"`)
@ -73,7 +73,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
const serverDir = join(rootDir, '.output-inline/server') const serverDir = join(rootDir, '.output-inline/server')
const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir) const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"535k"`) expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"529k"`)
const modules = await analyzeSizes('node_modules/**/*', serverDir) const modules = await analyzeSizes('node_modules/**/*', serverDir)
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"86.1k"`) expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"86.1k"`)

View File

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

View File

@ -8,3 +8,15 @@
</NuxtLink> </NuxtLink>
</div> </div>
</template> </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 { reactive, ref, shallowReactive, shallowRef } from 'vue'
import { createError } from 'h3' import { createError } from 'h3'
import { getBrowser, url, useTestContext } from '@nuxt/test-utils/e2e' 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' export const isRenderingJson = process.env.TEST_PAYLOAD !== 'js'
@ -126,3 +128,7 @@ export function parseData (html: string) {
attrs: _attrs, attrs: _attrs,
} }
} }
export function resolveHead (head: Head[]) {
return head.map(i => resolveUnrefHeadInput(i) as Head)
}