feat(nuxt): allow using nuxt-client in all components (#25479)

This commit is contained in:
Julien Huang 2024-03-06 16:26:19 +01:00 committed by GitHub
parent a80bdd1e59
commit 6d93014c52
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 169 additions and 43 deletions

View File

@ -318,7 +318,7 @@ You can partially hydrate a component by setting a `nuxt-client` attribute on th
``` ```
::alert{type=info} ::alert{type=info}
This only works within a server component. Slots for client components are not available yet. This only works within a server component. Slots for client components are working only with `experimental.componentIsland.selectiveClient` set to `'deep'` and since they are rendered server-side, they are not interactive once client-side.
:: ::
#### Server Component Context #### Server Component Context

View File

@ -260,20 +260,24 @@ export default defineComponent({
} }
if (import.meta.server) { if (import.meta.server) {
for (const [id, info] of Object.entries(payloads.components ?? {})) { for (const [id, info] of Object.entries(payloads.components ?? {})) {
const { html } = info const { html, slots } = info
let replaced = html.replaceAll('data-island-uid', `data-island-uid="${uid.value}"`)
for (const slot in slots) {
replaced = replaced.replaceAll(`data-island-slot="${slot}">`, (full) => full + slots[slot])
}
teleports.push(createVNode(Teleport, { to: `uid=${uid.value};client=${id}` }, { teleports.push(createVNode(Teleport, { to: `uid=${uid.value};client=${id}` }, {
default: () => [createStaticVNode(html, 1)] default: () => [createStaticVNode(replaced, 1)]
})) }))
} }
} } else if (selectiveClient && import.meta.client && canLoadClientComponent.value) {
if (selectiveClient && import.meta.client && canLoadClientComponent.value) {
for (const [id, info] of Object.entries(payloads.components ?? {})) { for (const [id, info] of Object.entries(payloads.components ?? {})) {
const { props } = info const { props, slots } = info
const component = components!.get(id)! const component = components!.get(id)!
// use different selectors for even and odd teleportKey to force trigger the teleport // use different selectors for even and odd teleportKey to force trigger the teleport
const vnode = createVNode(Teleport, { to: `${isKeyOdd ? 'div' : ''}[data-island-uid='${uid.value}'][data-island-component="${id}"]` }, { const vnode = createVNode(Teleport, { to: `${isKeyOdd ? 'div' : ''}[data-island-uid='${uid.value}'][data-island-component="${id}"]` }, {
default: () => { default: () => {
return [h(component, props)] return [h(component, props, Object.fromEntries(Object.entries(slots || {}).map(([k, v]) => ([k, () => createStaticVNode(`<div style="display: contents" data-island-uid data-island-slot="${k}">${v}</div>`, 1)
]))))]
} }
}) })
teleports.push(vnode) teleports.push(vnode)

View File

@ -1,5 +1,5 @@
import type { Component } from 'vue' import type { Component, InjectionKey } from 'vue'
import { Teleport, defineComponent, h } from 'vue' import { Teleport, defineComponent, h, inject, provide } from 'vue'
import { useNuxtApp } from '../nuxt' import { useNuxtApp } from '../nuxt'
// @ts-expect-error virtual file // @ts-expect-error virtual file
import { paths } from '#build/components-chunk' import { paths } from '#build/components-chunk'
@ -9,6 +9,8 @@ type ExtendedComponent = Component & {
__name: string __name: string
} }
export const NuxtTeleportIslandSymbol = Symbol('NuxtTeleportIslandComponent') as InjectionKey<false | string>
/** /**
* component only used with componentsIsland * component only used with componentsIsland
* this teleport the component in SSR only if it needs to be hydrated on client * this teleport the component in SSR only if it needs to be hydrated on client
@ -37,8 +39,10 @@ export default defineComponent({
setup (props, { slots }) { setup (props, { slots }) {
const nuxtApp = useNuxtApp() const nuxtApp = useNuxtApp()
if (!nuxtApp.ssrContext?.islandContext || !props.nuxtClient) { return () => slots.default!() } // if there's already a teleport parent, we don't need to teleport or to render the wrapped component client side
if (!nuxtApp.ssrContext?.islandContext || !props.nuxtClient || inject(NuxtTeleportIslandSymbol, false)) { return () => slots.default?.() }
provide(NuxtTeleportIslandSymbol, props.to)
const islandContext = nuxtApp.ssrContext!.islandContext! const islandContext = nuxtApp.ssrContext!.islandContext!
return () => { return () => {

View File

@ -1,6 +1,8 @@
import { Teleport, defineComponent, h } from 'vue' import type { VNode } from 'vue'
import { Teleport, createVNode, defineComponent, h, inject } from 'vue'
import { useNuxtApp } from '../nuxt' import { useNuxtApp } from '../nuxt'
import { NuxtTeleportIslandSymbol } from './nuxt-teleport-island-component'
/** /**
* component only used within islands for slot teleport * component only used within islands for slot teleport
*/ */
@ -9,37 +11,50 @@ export default defineComponent({
name: 'NuxtTeleportIslandSlot', name: 'NuxtTeleportIslandSlot',
props: { props: {
name: { name: {
type: String, type: String,
required: true required: true
}, },
/** /**
* must be an array to handle v-for * must be an array to handle v-for
*/ */
props: { props: {
type: Object as () => Array<any> type: Object as () => Array<any>
} }
}, },
setup (props, { slots }) { setup (props, { slots }) {
const nuxtApp = useNuxtApp() const nuxtApp = useNuxtApp()
const islandContext = nuxtApp.ssrContext?.islandContext const islandContext = nuxtApp.ssrContext?.islandContext
if (!islandContext) {
if(!islandContext) { return () => slots.default?.()[0]
return () => slots.default?.()
} }
const componentName = inject(NuxtTeleportIslandSymbol, false)
islandContext.slots[props.name] = { islandContext.slots[props.name] = {
props: (props.props || []) as unknown[] props: (props.props || []) as unknown[]
} }
return () => { return () => {
const vnodes = [h('div', { const vnodes: VNode[] = []
style: 'display: contents;',
'data-island-uid': '', if (nuxtApp.ssrContext?.islandContext && slots.default) {
'data-island-slot': props.name, vnodes.push(h('div', {
})] style: 'display: contents;',
'data-island-uid': '',
'data-island-slot': props.name,
}, {
// Teleport in slot to not be hydrated client-side with the staticVNode
default: () => [createVNode(Teleport, { to: `island-slot=${componentName};${props.name}` }, slots.default?.())]
}))
} else {
vnodes.push(h('div', {
style: 'display: contents;',
'data-island-uid': '',
'data-island-slot': props.name,
}))
}
if (slots.fallback) { if (slots.fallback) {
vnodes.push(h(Teleport, { to: `island-fallback=${props.name}`}, slots.fallback())) vnodes.push(h(Teleport, { to: `island-fallback=${props.name}` }, slots.fallback()))
} }
return vnodes return vnodes

View File

@ -21,7 +21,7 @@ interface ServerOnlyComponentTransformPluginOptions {
/** /**
* allow using `nuxt-client` attribute on components * allow using `nuxt-client` attribute on components
*/ */
selectiveClient?: boolean selectiveClient?: boolean | 'deep'
} }
interface ComponentChunkOptions { interface ComponentChunkOptions {
@ -47,6 +47,7 @@ export const islandsTransform = createUnplugin((options: ServerOnlyComponentTran
enforce: 'pre', enforce: 'pre',
transformInclude (id) { transformInclude (id) {
if (!isVue(id)) { return false } if (!isVue(id)) { return false }
if (options.selectiveClient === 'deep') { return true }
const components = options.getComponents() const components = options.getComponents()
const islands = components.filter(component => const islands = components.filter(component =>

View File

@ -70,6 +70,7 @@ export interface NuxtIslandClientResponse {
html: string html: string
props: unknown props: unknown
chunk: string chunk: string
slots?: Record<string, string>
} }
export interface NuxtIslandResponse { export interface NuxtIslandResponse {
@ -629,6 +630,7 @@ function getServerComponentHTML (body: string[]): string {
const SSR_SLOT_TELEPORT_MARKER = /^uid=([^;]*);slot=(.*)$/ const SSR_SLOT_TELEPORT_MARKER = /^uid=([^;]*);slot=(.*)$/
const SSR_CLIENT_TELEPORT_MARKER = /^uid=([^;]*);client=(.*)$/ const SSR_CLIENT_TELEPORT_MARKER = /^uid=([^;]*);client=(.*)$/
const SSR_CLIENT_SLOT_MARKER = /^island-slot=(?:[^;]*);(.*)$/
function getSlotIslandResponse (ssrContext: NuxtSSRContext): NuxtIslandResponse['slots'] { function getSlotIslandResponse (ssrContext: NuxtSSRContext): NuxtIslandResponse['slots'] {
if (!ssrContext.islandContext) { return {} } if (!ssrContext.islandContext) { return {} }
@ -636,7 +638,7 @@ function getSlotIslandResponse (ssrContext: NuxtSSRContext): NuxtIslandResponse[
for (const slot in ssrContext.islandContext.slots) { for (const slot in ssrContext.islandContext.slots) {
response[slot] = { response[slot] = {
...ssrContext.islandContext.slots[slot], ...ssrContext.islandContext.slots[slot],
fallback: ssrContext.teleports?.[`island-fallback=${slot}`] fallback: ssrContext.teleports?.[`island-fallback=${slot}`],
} }
} }
return response return response
@ -645,16 +647,33 @@ function getSlotIslandResponse (ssrContext: NuxtSSRContext): NuxtIslandResponse[
function getClientIslandResponse (ssrContext: NuxtSSRContext): NuxtIslandResponse['components'] { function getClientIslandResponse (ssrContext: NuxtSSRContext): NuxtIslandResponse['components'] {
if (!ssrContext.islandContext) { return {} } if (!ssrContext.islandContext) { return {} }
const response: NuxtIslandResponse['components'] = {} const response: NuxtIslandResponse['components'] = {}
for (const clientUid in ssrContext.islandContext.components) { for (const clientUid in ssrContext.islandContext.components) {
const html = ssrContext.teleports?.[clientUid] || '' const html = ssrContext.teleports?.[clientUid] || ''
response[clientUid] = { response[clientUid] = {
...ssrContext.islandContext.components[clientUid], ...ssrContext.islandContext.components[clientUid],
html, html,
slots: getComponentSlotTeleport(ssrContext.teleports ?? {})
} }
} }
return response return response
} }
function getComponentSlotTeleport (teleports: Record<string, string>) {
const entries = Object.entries(teleports)
const slots: Record<string, string> = {}
for (const [key, value] of entries) {
const match = key.match(SSR_CLIENT_SLOT_MARKER)
if (match) {
const [, slot] = match
if (!slot) { continue }
slots[slot] = value
}
}
return slots
}
function replaceIslandTeleports (ssrContext: NuxtSSRContext, html: string) { function replaceIslandTeleports (ssrContext: NuxtSSRContext, html: string) {
const { teleports, islandContext } = ssrContext const { teleports, islandContext } = ssrContext

View File

@ -386,7 +386,7 @@ export const nuxtConfigTemplate: NuxtTemplate = {
`export const cookieStore = ${!!ctx.nuxt.options.experimental.cookieStore}`, `export const cookieStore = ${!!ctx.nuxt.options.experimental.cookieStore}`,
`export const appManifest = ${!!ctx.nuxt.options.experimental.appManifest}`, `export const appManifest = ${!!ctx.nuxt.options.experimental.appManifest}`,
`export const remoteComponentIslands = ${typeof ctx.nuxt.options.experimental.componentIslands === 'object' && ctx.nuxt.options.experimental.componentIslands.remoteIsland}`, `export const remoteComponentIslands = ${typeof ctx.nuxt.options.experimental.componentIslands === 'object' && ctx.nuxt.options.experimental.componentIslands.remoteIsland}`,
`export const selectiveClient = ${typeof ctx.nuxt.options.experimental.componentIslands === 'object' && ctx.nuxt.options.experimental.componentIslands.selectiveClient}`, `export const selectiveClient = ${typeof ctx.nuxt.options.experimental.componentIslands === 'object' && Boolean(ctx.nuxt.options.experimental.componentIslands.selectiveClient)}`,
`export const devPagesDir = ${ctx.nuxt.options.dev ? JSON.stringify(ctx.nuxt.options.dir.pages) : 'null'}`, `export const devPagesDir = ${ctx.nuxt.options.dev ? JSON.stringify(ctx.nuxt.options.dir.pages) : 'null'}`,
`export const devRootDir = ${ctx.nuxt.options.dev ? JSON.stringify(ctx.nuxt.options.rootDir) : 'null'}`, `export const devRootDir = ${ctx.nuxt.options.dev ? JSON.stringify(ctx.nuxt.options.rootDir) : 'null'}`,
`export const nuxtLinkDefaults = ${JSON.stringify(ctx.nuxt.options.experimental.defaults.nuxtLink)}`, `export const nuxtLinkDefaults = ${JSON.stringify(ctx.nuxt.options.experimental.defaults.nuxtLink)}`,

View File

@ -1610,6 +1610,12 @@ describe('server components/islands', () => {
// test islands wrapped with client-only // test islands wrapped with client-only
expect(await page.locator('#wrapped-client-only').innerHTML()).toContain('Was router enabled') expect(await page.locator('#wrapped-client-only').innerHTML()).toContain('Was router enabled')
if (!isWebpack) {
// test nested client components
await page.locator('.server-with-nested-client button').click()
expect(await page.locator('.server-with-nested-client .sugar-counter').innerHTML()).toContain('Sugar Counter 13 x 1 = 13')
}
if (!isWebpack) { if (!isWebpack) {
// test client component interactivity // test client component interactivity
expect(await page.locator('.interactive-component-wrapper').innerHTML()).toContain('Sugar Counter 12') expect(await page.locator('.interactive-component-wrapper').innerHTML()).toContain('Sugar Counter 12')
@ -1667,9 +1673,9 @@ describe('server components/islands', () => {
const text = (await page.innerText('pre')).replaceAll(/ data-island-uid="([^"]*)"/g, '').replace(/data-island-component="([^"]*)"/g, (_, content) => `data-island-component="${content.split('-')[0]}"`) const text = (await page.innerText('pre')).replaceAll(/ data-island-uid="([^"]*)"/g, '').replace(/data-island-component="([^"]*)"/g, (_, content) => `data-island-component="${content.split('-')[0]}"`)
if (isWebpack) { if (isWebpack) {
expect(text).toMatchInlineSnapshot('" End page <pre></pre><section id="fallback"><div> This is a .server (20ms) async component that was very long ... <div id="async-server-component-count">42</div><div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><!--[--><div style="display: contents;" data-island-slot="default"></div><!--]--></div></section><section id="no-fallback"><div> This is a .server (20ms) async component that was very long ... <div id="async-server-component-count">42</div><div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><!--[--><div style="display: contents;" data-island-slot="default"></div><!--]--></div></section><div> 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 bellow is not a slot but declared as interactive <div class="sugar-counter" nuxt-client=""> Sugar Counter 12 x 1 = 12 <button> Inc </button></div></div></div>"') expect(text).toMatchInlineSnapshot(`" End page <pre></pre><section id="fallback"><div> This is a .server (20ms) async component that was very long ... <div id="async-server-component-count">42</div><div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><!--[--><div style="display: contents;" data-island-slot="default"><!--teleport start--><!--teleport end--></div><!--]--></div></section><section id="no-fallback"><div> This is a .server (20ms) async component that was very long ... <div id="async-server-component-count">42</div><div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><!--[--><div style="display: contents;" data-island-slot="default"><!--teleport start--><!--teleport end--></div><!--]--></div></section><div> 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 bellow is not a slot but declared as interactive <div class="sugar-counter" nuxt-client=""> Sugar Counter 12 x 1 = 12 <button> Inc </button></div></div></div>"`)
} else { } else {
expect(text).toMatchInlineSnapshot(`" End page <pre></pre><section id="fallback"><div> This is a .server (20ms) async component that was very long ... <div id="async-server-component-count">42</div><div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><!--[--><div style="display: contents;" data-island-slot="default"></div><!--]--></div></section><section id="no-fallback"><div> This is a .server (20ms) async component that was very long ... <div id="async-server-component-count">42</div><div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><!--[--><div style="display: contents;" data-island-slot="default"></div><!--]--></div></section><div> 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 bellow is not a slot but declared as interactive <!--[--><div style="display: contents;" data-island-component="Counter"></div><!--teleport start--><!--teleport end--><!--]--></div></div>"`) expect(text).toMatchInlineSnapshot(`" End page <pre></pre><section id="fallback"><div> This is a .server (20ms) async component that was very long ... <div id="async-server-component-count">42</div><div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><!--[--><div style="display: contents;" data-island-slot="default"><!--teleport start--><!--teleport end--></div><!--]--></div></section><section id="no-fallback"><div> This is a .server (20ms) async component that was very long ... <div id="async-server-component-count">42</div><div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><!--[--><div style="display: contents;" data-island-slot="default"><!--teleport start--><!--teleport end--></div><!--]--></div></section><div> 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 bellow is not a slot but declared as interactive <!--[--><div style="display: contents;" data-island-component="Counter"></div><!--teleport start--><!--teleport end--><!--]--></div></div>"`)
} }
expect(text).toContain('async component that was very long') expect(text).toContain('async component that was very long')
@ -1928,7 +1934,7 @@ describe('component islands', () => {
"link": [], "link": [],
"style": [], "style": [],
}, },
"html": "<div data-island-uid><div> count is above 2 </div><!--[--><div style="display: contents;" data-island-uid data-island-slot="default"></div><!--]--> that was very long ... <div id="long-async-component-count">3</div> <!--[--><div style="display: contents;" data-island-uid data-island-slot="test"></div><!--]--><p>hello world !!!</p><!--[--><div style="display: contents;" data-island-uid data-island-slot="hello"></div><!--teleport start--><!--teleport end--><!--]--><!--[--><div style="display: contents;" data-island-uid data-island-slot="fallback"></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": {
"props": [], "props": [],
@ -1992,7 +1998,7 @@ describe('component islands', () => {
"link": [], "link": [],
"style": [], "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"></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": {},
"state": {}, "state": {},

View File

@ -0,0 +1,9 @@
<template>
<div>
this is a normal component within a server component
<Counter
nuxt-client
:multiplier="1"
/>
</div>
</template>

View File

@ -0,0 +1,5 @@
<template>
<div class="server-with-nested-client">
<CounterWithNuxtClient />
</div>
</template>

View File

@ -211,7 +211,7 @@ export default defineNuxtConfig({
restoreState: true, restoreState: true,
clientNodeCompat: true, clientNodeCompat: true,
componentIslands: { componentIslands: {
selectiveClient: true selectiveClient: 'deep'
}, },
treeshakeClientOnly: true, treeshakeClientOnly: true,
asyncContext: process.env.TEST_CONTEXT === 'async', asyncContext: process.env.TEST_CONTEXT === 'async',

View File

@ -110,6 +110,7 @@ const count = ref(0)
</div> </div>
</div> </div>
<ServerWithClient /> <ServerWithClient />
<ServerWithNestedClient />
</div> </div>
</template> </template>

View File

@ -26,7 +26,7 @@ vi.mock('vue', async (original) => {
const consoleError = vi.spyOn(console, 'error') const consoleError = vi.spyOn(console, 'error')
const consoleWarn = vi.spyOn(console, 'warn') const consoleWarn = vi.spyOn(console, 'warn')
function expectNoConsoleIssue() { function expectNoConsoleIssue () {
expect(consoleError).not.toHaveBeenCalled() expect(consoleError).not.toHaveBeenCalled()
expect(consoleWarn).not.toHaveBeenCalled() expect(consoleWarn).not.toHaveBeenCalled()
} }
@ -93,7 +93,7 @@ describe('runtime server component', () => {
link: [], link: [],
style: [] style: []
}, },
json() { json () {
return this return this
} }
} }
@ -123,7 +123,7 @@ describe('client components', () => {
vi.doMock(mockPath, () => ({ vi.doMock(mockPath, () => ({
default: { default: {
name: 'ClientComponent', name: 'ClientComponent',
setup() { setup () {
return () => h('div', 'client component') return () => h('div', 'client component')
} }
} }
@ -145,7 +145,7 @@ describe('client components', () => {
chunk: mockPath chunk: mockPath
} }
}, },
json() { json () {
return this return this
} }
} }
@ -184,14 +184,14 @@ describe('client components', () => {
style: [] style: []
}, },
components: {}, components: {},
json() { json () {
return this return this
} }
})) }))
await wrapper.vm.$.exposed!.refresh() await wrapper.vm.$.exposed!.refresh()
await nextTick() await nextTick()
expect(wrapper.html()).toMatchInlineSnapshot( ` expect(wrapper.html()).toMatchInlineSnapshot(`
"<div data-island-uid="3">hello<div> "<div data-island-uid="3">hello<div>
<div>fallback</div> <div>fallback</div>
</div> </div>
@ -202,10 +202,10 @@ describe('client components', () => {
expectNoConsoleIssue() expectNoConsoleIssue()
}) })
it('should not replace nested client components data-island-uid', async () => { it('should not replace nested client components data-island-uid', async () => {
const componentId = 'Client-12345' const componentId = 'Client-12345'
const stubFetch = vi.fn(() => { const stubFetch = vi.fn(() => {
return { return {
id: '1234', id: '1234',
@ -215,7 +215,7 @@ describe('client components', () => {
link: [], link: [],
style: [] style: []
}, },
json() { json () {
return this return this
} }
} }
@ -238,4 +238,66 @@ describe('client components', () => {
vi.mocked(fetch).mockReset() vi.mocked(fetch).mockReset()
expectNoConsoleIssue() expectNoConsoleIssue()
}) })
})
it('pass a slot to a client components within islands', async () => {
const mockPath = '/nuxt-client-with-slot.js'
const componentId = 'ClientWithSlot-12345'
vi.doMock(mockPath, () => ({
default: {
name: 'ClientWithSlot',
setup (_, { slots }) {
return () => h('div', { class: "client-component" }, slots.default())
}
}
}))
const stubFetch = vi.fn(() => {
return {
id: '123',
html: `<div data-island-uid>hello<div data-island-uid data-island-component="${componentId}"></div></div>`,
state: {},
head: {
link: [],
style: []
},
components: {
[componentId]: {
html: '<div>fallback</div>',
props: {},
chunk: mockPath,
slots: {
default: '<div>slot in client component</div>'
}
}
},
json () {
return this
}
}
})
vi.stubGlobal('fetch', stubFetch)
const wrapper = await mountSuspended(NuxtIsland, {
props: {
name: 'NuxtClientWithSlot',
},
attachTo: 'body'
})
expect(fetch).toHaveBeenCalledOnce()
expect(wrapper.html()).toMatchInlineSnapshot(`
"<div data-island-uid="5">hello<div data-island-uid="5" data-island-component="ClientWithSlot-12345">
<div class="client-component">
<div style="display: contents" data-island-uid="" data-island-slot="default">
<div>slot in client component</div>
</div>
</div>
</div>
</div>
<!--teleport start-->
<!--teleport end-->"
`)
expectNoConsoleIssue()
})
})