diff --git a/docs/2.guide/2.directory-structure/1.components.md b/docs/2.guide/2.directory-structure/1.components.md index 655cb98bd9..1addc266c2 100644 --- a/docs/2.guide/2.directory-structure/1.components.md +++ b/docs/2.guide/2.directory-structure/1.components.md @@ -318,7 +318,7 @@ You can partially hydrate a component by setting a `nuxt-client` attribute on th ``` ::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 diff --git a/packages/nuxt/src/app/components/nuxt-island.ts b/packages/nuxt/src/app/components/nuxt-island.ts index f1a6e20005..7191dcf747 100644 --- a/packages/nuxt/src/app/components/nuxt-island.ts +++ b/packages/nuxt/src/app/components/nuxt-island.ts @@ -260,20 +260,24 @@ export default defineComponent({ } if (import.meta.server) { 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}` }, { - default: () => [createStaticVNode(html, 1)] + default: () => [createStaticVNode(replaced, 1)] })) } - } - if (selectiveClient && import.meta.client && canLoadClientComponent.value) { + } else if (selectiveClient && import.meta.client && canLoadClientComponent.value) { for (const [id, info] of Object.entries(payloads.components ?? {})) { - const { props } = info + const { props, slots } = info const component = components!.get(id)! // 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}"]` }, { default: () => { - return [h(component, props)] + return [h(component, props, Object.fromEntries(Object.entries(slots || {}).map(([k, v]) => ([k, () => createStaticVNode(`
${v}
`, 1) + ]))))] } }) teleports.push(vnode) diff --git a/packages/nuxt/src/app/components/nuxt-teleport-island-component.ts b/packages/nuxt/src/app/components/nuxt-teleport-island-component.ts index 34a5dcca91..75adbdfaf4 100644 --- a/packages/nuxt/src/app/components/nuxt-teleport-island-component.ts +++ b/packages/nuxt/src/app/components/nuxt-teleport-island-component.ts @@ -1,5 +1,5 @@ -import type { Component } from 'vue' -import { Teleport, defineComponent, h } from 'vue' +import type { Component, InjectionKey } from 'vue' +import { Teleport, defineComponent, h, inject, provide } from 'vue' import { useNuxtApp } from '../nuxt' // @ts-expect-error virtual file import { paths } from '#build/components-chunk' @@ -9,6 +9,8 @@ type ExtendedComponent = Component & { __name: string } +export const NuxtTeleportIslandSymbol = Symbol('NuxtTeleportIslandComponent') as InjectionKey + /** * component only used with componentsIsland * 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 }) { 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! return () => { diff --git a/packages/nuxt/src/app/components/nuxt-teleport-island-slot.ts b/packages/nuxt/src/app/components/nuxt-teleport-island-slot.ts index 6001f26097..7fb7287c45 100644 --- a/packages/nuxt/src/app/components/nuxt-teleport-island-slot.ts +++ b/packages/nuxt/src/app/components/nuxt-teleport-island-slot.ts @@ -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 { NuxtTeleportIslandSymbol } from './nuxt-teleport-island-component' + /** * component only used within islands for slot teleport */ @@ -9,37 +11,50 @@ export default defineComponent({ name: 'NuxtTeleportIslandSlot', props: { name: { - type: String, - required: true + type: String, + required: true }, /** * must be an array to handle v-for */ props: { - type: Object as () => Array + type: Object as () => Array } }, setup (props, { slots }) { const nuxtApp = useNuxtApp() const islandContext = nuxtApp.ssrContext?.islandContext - - if(!islandContext) { - return () => slots.default?.() + if (!islandContext) { + return () => slots.default?.()[0] } + const componentName = inject(NuxtTeleportIslandSymbol, false) islandContext.slots[props.name] = { - props: (props.props || []) as unknown[] + props: (props.props || []) as unknown[] } return () => { - const vnodes = [h('div', { - style: 'display: contents;', - 'data-island-uid': '', - 'data-island-slot': props.name, - })] + const vnodes: VNode[] = [] + + if (nuxtApp.ssrContext?.islandContext && slots.default) { + 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) { - vnodes.push(h(Teleport, { to: `island-fallback=${props.name}`}, slots.fallback())) + vnodes.push(h(Teleport, { to: `island-fallback=${props.name}` }, slots.fallback())) } return vnodes diff --git a/packages/nuxt/src/components/islandsTransform.ts b/packages/nuxt/src/components/islandsTransform.ts index 0c98ceb7f8..6692277a9c 100644 --- a/packages/nuxt/src/components/islandsTransform.ts +++ b/packages/nuxt/src/components/islandsTransform.ts @@ -21,7 +21,7 @@ interface ServerOnlyComponentTransformPluginOptions { /** * allow using `nuxt-client` attribute on components */ - selectiveClient?: boolean + selectiveClient?: boolean | 'deep' } interface ComponentChunkOptions { @@ -47,6 +47,7 @@ export const islandsTransform = createUnplugin((options: ServerOnlyComponentTran enforce: 'pre', transformInclude (id) { if (!isVue(id)) { return false } + if (options.selectiveClient === 'deep') { return true } const components = options.getComponents() const islands = components.filter(component => diff --git a/packages/nuxt/src/core/runtime/nitro/renderer.ts b/packages/nuxt/src/core/runtime/nitro/renderer.ts index 39cc1931a5..3d262a1e3d 100644 --- a/packages/nuxt/src/core/runtime/nitro/renderer.ts +++ b/packages/nuxt/src/core/runtime/nitro/renderer.ts @@ -70,6 +70,7 @@ export interface NuxtIslandClientResponse { html: string props: unknown chunk: string + slots?: Record } export interface NuxtIslandResponse { @@ -629,6 +630,7 @@ function getServerComponentHTML (body: string[]): string { const SSR_SLOT_TELEPORT_MARKER = /^uid=([^;]*);slot=(.*)$/ const SSR_CLIENT_TELEPORT_MARKER = /^uid=([^;]*);client=(.*)$/ +const SSR_CLIENT_SLOT_MARKER = /^island-slot=(?:[^;]*);(.*)$/ function getSlotIslandResponse (ssrContext: NuxtSSRContext): NuxtIslandResponse['slots'] { if (!ssrContext.islandContext) { return {} } @@ -636,7 +638,7 @@ function getSlotIslandResponse (ssrContext: NuxtSSRContext): NuxtIslandResponse[ for (const slot in ssrContext.islandContext.slots) { response[slot] = { ...ssrContext.islandContext.slots[slot], - fallback: ssrContext.teleports?.[`island-fallback=${slot}`] + fallback: ssrContext.teleports?.[`island-fallback=${slot}`], } } return response @@ -645,16 +647,33 @@ function getSlotIslandResponse (ssrContext: NuxtSSRContext): NuxtIslandResponse[ function getClientIslandResponse (ssrContext: NuxtSSRContext): NuxtIslandResponse['components'] { if (!ssrContext.islandContext) { return {} } const response: NuxtIslandResponse['components'] = {} + for (const clientUid in ssrContext.islandContext.components) { const html = ssrContext.teleports?.[clientUid] || '' response[clientUid] = { ...ssrContext.islandContext.components[clientUid], html, + slots: getComponentSlotTeleport(ssrContext.teleports ?? {}) } } return response } +function getComponentSlotTeleport (teleports: Record) { + const entries = Object.entries(teleports) + const slots: Record = {} + + 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) { const { teleports, islandContext } = ssrContext diff --git a/packages/nuxt/src/core/templates.ts b/packages/nuxt/src/core/templates.ts index 9fbcdb79bb..5a820ac2b1 100644 --- a/packages/nuxt/src/core/templates.ts +++ b/packages/nuxt/src/core/templates.ts @@ -386,7 +386,7 @@ export const nuxtConfigTemplate: NuxtTemplate = { `export const cookieStore = ${!!ctx.nuxt.options.experimental.cookieStore}`, `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 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 devRootDir = ${ctx.nuxt.options.dev ? JSON.stringify(ctx.nuxt.options.rootDir) : 'null'}`, `export const nuxtLinkDefaults = ${JSON.stringify(ctx.nuxt.options.experimental.defaults.nuxtLink)}`, diff --git a/test/basic.test.ts b/test/basic.test.ts index 065639d32b..879dda2155 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -1610,6 +1610,12 @@ describe('server components/islands', () => { // test islands wrapped with client-only 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) { // test client component interactivity 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]}"`) if (isWebpack) { - expect(text).toMatchInlineSnapshot('" End page
This is a .server (20ms) async component that was very long ...
42
Sugar Counter 12 x 1 = 12
This is a .server (20ms) async component that was very long ...
42
Sugar Counter 12 x 1 = 12
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 bellow is not a slot but declared as interactive
Sugar Counter 12 x 1 = 12
"') + expect(text).toMatchInlineSnapshot(`" End page
This is a .server (20ms) async component that was very long ...
42
Sugar Counter 12 x 1 = 12
This is a .server (20ms) async component that was very long ...
42
Sugar Counter 12 x 1 = 12
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 bellow is not a slot but declared as interactive
Sugar Counter 12 x 1 = 12
"`) } else { - expect(text).toMatchInlineSnapshot(`" End page
This is a .server (20ms) async component that was very long ...
42
Sugar Counter 12 x 1 = 12
This is a .server (20ms) async component that was very long ...
42
Sugar Counter 12 x 1 = 12
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 bellow is not a slot but declared as interactive
"`) + expect(text).toMatchInlineSnapshot(`" End page
This is a .server (20ms) async component that was very long ...
42
Sugar Counter 12 x 1 = 12
This is a .server (20ms) async component that was very long ...
42
Sugar Counter 12 x 1 = 12
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 bellow is not a slot but declared as interactive
"`) } expect(text).toContain('async component that was very long') @@ -1928,7 +1934,7 @@ describe('component islands', () => { "link": [], "style": [], }, - "html": "
count is above 2
that was very long ...
3

hello world !!!

", + "html": "
count is above 2
that was very long ...
3

hello world !!!

", "slots": { "default": { "props": [], @@ -1992,7 +1998,7 @@ describe('component islands', () => { "link": [], "style": [], }, - "html": "
This is a .server (20ms) async component that was very long ...
2
Sugar Counter 12 x 1 = 12
", + "html": "
This is a .server (20ms) async component that was very long ...
2
Sugar Counter 12 x 1 = 12
", "props": {}, "slots": {}, "state": {}, diff --git a/test/fixtures/basic/components/CounterWithNuxtClient.vue b/test/fixtures/basic/components/CounterWithNuxtClient.vue new file mode 100644 index 0000000000..c16d3d5ada --- /dev/null +++ b/test/fixtures/basic/components/CounterWithNuxtClient.vue @@ -0,0 +1,9 @@ + diff --git a/test/fixtures/basic/components/ServerWithNestedClient.server.vue b/test/fixtures/basic/components/ServerWithNestedClient.server.vue new file mode 100644 index 0000000000..3121e03eea --- /dev/null +++ b/test/fixtures/basic/components/ServerWithNestedClient.server.vue @@ -0,0 +1,5 @@ + diff --git a/test/fixtures/basic/nuxt.config.ts b/test/fixtures/basic/nuxt.config.ts index eb931240f6..46dd69a46c 100644 --- a/test/fixtures/basic/nuxt.config.ts +++ b/test/fixtures/basic/nuxt.config.ts @@ -211,7 +211,7 @@ export default defineNuxtConfig({ restoreState: true, clientNodeCompat: true, componentIslands: { - selectiveClient: true + selectiveClient: 'deep' }, treeshakeClientOnly: true, asyncContext: process.env.TEST_CONTEXT === 'async', diff --git a/test/fixtures/basic/pages/islands.vue b/test/fixtures/basic/pages/islands.vue index 4e2b59e2c2..1bb6483518 100644 --- a/test/fixtures/basic/pages/islands.vue +++ b/test/fixtures/basic/pages/islands.vue @@ -110,6 +110,7 @@ const count = ref(0) + diff --git a/test/nuxt/nuxt-island.test.ts b/test/nuxt/nuxt-island.test.ts index 6ef437d728..5a40833c32 100644 --- a/test/nuxt/nuxt-island.test.ts +++ b/test/nuxt/nuxt-island.test.ts @@ -26,7 +26,7 @@ vi.mock('vue', async (original) => { const consoleError = vi.spyOn(console, 'error') const consoleWarn = vi.spyOn(console, 'warn') -function expectNoConsoleIssue() { +function expectNoConsoleIssue () { expect(consoleError).not.toHaveBeenCalled() expect(consoleWarn).not.toHaveBeenCalled() } @@ -93,7 +93,7 @@ describe('runtime server component', () => { link: [], style: [] }, - json() { + json () { return this } } @@ -123,7 +123,7 @@ describe('client components', () => { vi.doMock(mockPath, () => ({ default: { name: 'ClientComponent', - setup() { + setup () { return () => h('div', 'client component') } } @@ -145,7 +145,7 @@ describe('client components', () => { chunk: mockPath } }, - json() { + json () { return this } } @@ -184,14 +184,14 @@ describe('client components', () => { style: [] }, components: {}, - json() { + json () { return this } })) await wrapper.vm.$.exposed!.refresh() await nextTick() - expect(wrapper.html()).toMatchInlineSnapshot( ` + expect(wrapper.html()).toMatchInlineSnapshot(` "
hello
fallback
@@ -202,10 +202,10 @@ describe('client components', () => { expectNoConsoleIssue() }) - + it('should not replace nested client components data-island-uid', async () => { const componentId = 'Client-12345' - + const stubFetch = vi.fn(() => { return { id: '1234', @@ -215,7 +215,7 @@ describe('client components', () => { link: [], style: [] }, - json() { + json () { return this } } @@ -238,4 +238,66 @@ describe('client components', () => { vi.mocked(fetch).mockReset() expectNoConsoleIssue() }) -}) \ No newline at end of file + + 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: `
hello
`, + state: {}, + head: { + link: [], + style: [] + }, + components: { + [componentId]: { + html: '
fallback
', + props: {}, + chunk: mockPath, + slots: { + default: '
slot in client component
' + } + } + }, + json () { + return this + } + } + }) + + vi.stubGlobal('fetch', stubFetch) + const wrapper = await mountSuspended(NuxtIsland, { + props: { + name: 'NuxtClientWithSlot', + }, + attachTo: 'body' + }) + expect(fetch).toHaveBeenCalledOnce() + expect(wrapper.html()).toMatchInlineSnapshot(` + "
hello
+
+
+
slot in client component
+
+
+
+
+ + " + `) + + expectNoConsoleIssue() + }) +})