mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-11 00:23:53 +00:00
feat(nuxt): allow server islands to manipulate head (#27987)
This commit is contained in:
parent
f602062442
commit
8730dde90b
@ -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",
|
||||||
|
@ -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) {
|
||||||
|
@ -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')]
|
||||||
|
@ -31,11 +31,6 @@ if (componentIslands) {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
html: '',
|
html: '',
|
||||||
state: {},
|
|
||||||
head: {
|
|
||||||
link: [],
|
|
||||||
style: [],
|
|
||||||
},
|
|
||||||
...result,
|
...result,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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),
|
||||||
|
@ -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: {}
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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"`)
|
||||||
|
33
test/fixtures/basic/pages/index.vue
vendored
33
test/fixtures/basic/pages/index.vue
vendored
@ -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'),
|
||||||
|
12
test/fixtures/basic/pages/server-page.server.vue
vendored
12
test/fixtures/basic/pages/server-page.server.vue
vendored
@ -8,3 +8,15 @@
|
|||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
useHead({
|
||||||
|
title: 'Server Page',
|
||||||
|
meta: [
|
||||||
|
{
|
||||||
|
name: 'author',
|
||||||
|
content: 'Nuxt',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user