From 8730dde90b39b43cf12fc670110df1c255fc2b9b Mon Sep 17 00:00:00 2001 From: Julien Huang Date: Thu, 22 Aug 2024 14:05:39 +0200 Subject: [PATCH] feat(nuxt): allow server islands to manipulate head (#27987) --- package.json | 1 + .../src/app/components/island-renderer.ts | 5 + .../nuxt/src/app/components/nuxt-island.ts | 17 ++- .../src/app/plugins/revive-payload.client.ts | 5 - .../nuxt/src/core/runtime/nitro/renderer.ts | 32 ++-- pnpm-lock.yaml | 10 +- test/basic.test.ts | 141 +++++++++++------- test/bundle.test.ts | 4 +- test/fixtures/basic/pages/index.vue | 33 +++- .../basic/pages/server-page.server.vue | 12 ++ test/utils.ts | 6 + 11 files changed, 166 insertions(+), 100 deletions(-) diff --git a/package.json b/package.json index 34ec0aec8a..3f98bb17c7 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,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", diff --git a/packages/nuxt/src/app/components/island-renderer.ts b/packages/nuxt/src/app/components/island-renderer.ts index 59e767f867..ebbca6fd43 100644 --- a/packages/nuxt/src/app/components/island-renderer.ts +++ b/packages/nuxt/src/app/components/island-renderer.ts @@ -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 if (!component) { diff --git a/packages/nuxt/src/app/components/nuxt-island.ts b/packages/nuxt/src/app/components/nuxt-island.ts index 93832a1eaf..f849e6b99c 100644 --- a/packages/nuxt/src/app/components/nuxt-island.ts +++ b/packages/nuxt/src/app/components/nuxt-island.ts @@ -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>>>({ 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')] diff --git a/packages/nuxt/src/app/plugins/revive-payload.client.ts b/packages/nuxt/src/app/plugins/revive-payload.client.ts index 24452437b6..3ab2f211df 100644 --- a/packages/nuxt/src/app/plugins/revive-payload.client.ts +++ b/packages/nuxt/src/app/plugins/revive-payload.client.ts @@ -31,11 +31,6 @@ if (componentIslands) { } return { html: '', - state: {}, - head: { - link: [], - style: [], - }, ...result, } } diff --git a/packages/nuxt/src/core/runtime/nitro/renderer.ts b/packages/nuxt/src/core/runtime/nitro/renderer.ts index e79b40db25..4d41ca0d5e 100644 --- a/packages/nuxt/src/core/runtime/nitro/renderer.ts +++ b/packages/nuxt/src/core/runtime/nitro/renderer.ts @@ -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, useNitroApp, useRuntimeConfig, useStorage } from 'nitro/runtime' @@ -79,10 +78,7 @@ export interface NuxtIslandContext { export interface NuxtIslandResponse { id?: string html: string - head: { - link: (Record)[] - style: ({ innerHTML: string, key: string })[] - } + head: Head[] props?: Record> components?: Record slots?: Record @@ -288,6 +284,7 @@ export default defineRenderHandler(async (event): Promise resolveUnrefHeadInput(h.input) as Head)), html: getServerComponentHTML(_rendered.html), components: getClientIslandResponse(ssrContext), slots: getSlotIslandResponse(ssrContext), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b43e832e3..2d88344f00 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: '@unhead/schema': specifier: 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': 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)) @@ -6785,9 +6788,6 @@ packages: uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} - ufo@1.5.3: - resolution: {integrity: sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==} - ufo@1.5.4: resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==} @@ -12617,7 +12617,7 @@ snapshots: serve-placeholder: 2.0.2 serve-static: 1.15.0 std-env: 3.7.0 - ufo: 1.5.3 + ufo: 1.5.4 uncrypto: 0.1.3 unctx: 2.3.1 unenv: 1.10.0 @@ -14175,8 +14175,6 @@ snapshots: uc.micro@2.1.0: {} - ufo@1.5.3: {} - ufo@1.5.4: {} uglify-js@3.17.4: {} diff --git a/test/basic.test.ts b/test/basic.test.ts index a5318dfb0a..a0f440756d 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -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 { $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' @@ -137,7 +138,7 @@ describe('pages', () => { // should apply attributes to client-only components expect(html).toContain('
') // should render server-only components - expect(html.replace(/ data-island-uid="[^"]*"/, '')).toContain('
server-only component
server-only component child (non-server-only)
') + expect(html.replaceAll(/ data-island-uid="[^"]*"/g, '')).toContain('
server-only component
server-only component child (non-server-only)
') // should register global components automatically expect(html).toContain('global component registered automatically') expect(html).toContain('global component via suffix') @@ -1929,6 +1930,12 @@ describe('server components/islands', () => { await page.close() }) + it('/server-page', async () => { + const html = await $fetch('/server-page') + // test island head + expect(html).toContain('') + }) + 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 @@ -2138,15 +2145,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": "
    Route: /foo
         
", } @@ -2160,15 +2167,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": "
count is above 2
that was very long ...
3

hello world !!!

", "slots": { "default": { @@ -2218,7 +2225,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 = {} @@ -2228,10 +2238,7 @@ describe('component islands', () => { expect(result).toMatchInlineSnapshot(` { "components": {}, - "head": { - "link": [], - "style": [], - }, + "head": [], "html": "
This is a .server (20ms) async component that was very long ...
2
Sugar Counter 12 x 1 = 12
", "props": {}, "slots": {}, @@ -2243,7 +2250,10 @@ describe('component islands', () => { it('render server component with selective client hydration', async () => { const result = await $fetch('/__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 = {} @@ -2255,10 +2265,7 @@ describe('component islands', () => { expect(result).toMatchInlineSnapshot(` { "components": {}, - "head": { - "link": [], - "style": [], - }, + "head": [], "html": "
ServerWithClient.server.vue :

count: 0

This component should not be preloaded
a
b
c
This is not interactive
Sugar Counter 12 x 1 = 12
The component below is not a slot but declared as interactive
", "slots": {}, } @@ -2266,10 +2273,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(`"
Sugar Counter 12 x 1 = 12
"`) }) } @@ -2287,41 +2294,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, '/').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, '/').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", + }, + ], + }, + ] `) } @@ -2668,14 +2686,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 + } + }), } } diff --git a/test/bundle.test.ts b/test/bundle.test.ts index c389796c66..d61144460f 100644 --- a/test/bundle.test.ts +++ b/test/bundle.test.ts @@ -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(`"211k"`) + expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"205k"`) const modules = await analyzeSizes('node_modules/**/*', serverDir) 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 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) expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"86.1k"`) diff --git a/test/fixtures/basic/pages/index.vue b/test/fixtures/basic/pages/index.vue index a2390d9fb6..5c59090757 100644 --- a/test/fixtures/basic/pages/index.vue +++ b/test/fixtures/basic/pages/index.vue @@ -79,6 +79,11 @@ style="color: red;" class="client-only" /> + {}) as any - +const island = ref>() 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'), diff --git a/test/fixtures/basic/pages/server-page.server.vue b/test/fixtures/basic/pages/server-page.server.vue index 398f9958d2..b14956da08 100644 --- a/test/fixtures/basic/pages/server-page.server.vue +++ b/test/fixtures/basic/pages/server-page.server.vue @@ -8,3 +8,15 @@ + + diff --git a/test/utils.ts b/test/utils.ts index 3e21fb0c27..49a8587be0 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -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) +}