Skip to content

Commit

Permalink
feat(nuxt): allow using nuxt-client in all components (#25479)
Browse files Browse the repository at this point in the history
  • Loading branch information
huang-julien committed Mar 6, 2024
1 parent a80bdd1 commit 6d93014
Show file tree
Hide file tree
Showing 13 changed files with 169 additions and 43 deletions.
2 changes: 1 addition & 1 deletion docs/2.guide/2.directory-structure/1.components.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 10 additions & 6 deletions packages/nuxt/src/app/components/nuxt-island.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(`<div style="display: contents" data-island-uid data-island-slot="${k}">${v}</div>`, 1)
]))))]
}
})
teleports.push(vnode)
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -9,6 +9,8 @@ type ExtendedComponent = Component & {
__name: string
}

export const NuxtTeleportIslandSymbol = Symbol('NuxtTeleportIslandComponent') as InjectionKey<false | string>

/**
* component only used with componentsIsland
* this teleport the component in SSR only if it needs to be hydrated on client
Expand Down Expand Up @@ -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 () => {
Expand Down
45 changes: 30 additions & 15 deletions packages/nuxt/src/app/components/nuxt-teleport-island-slot.ts
Original file line number Diff line number Diff line change
@@ -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
*/
Expand All @@ -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<any>
type: Object as () => Array<any>
}
},
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
Expand Down
3 changes: 2 additions & 1 deletion packages/nuxt/src/components/islandsTransform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ interface ServerOnlyComponentTransformPluginOptions {
/**
* allow using `nuxt-client` attribute on components
*/
selectiveClient?: boolean
selectiveClient?: boolean | 'deep'
}

interface ComponentChunkOptions {
Expand All @@ -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 =>
Expand Down
21 changes: 20 additions & 1 deletion packages/nuxt/src/core/runtime/nitro/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export interface NuxtIslandClientResponse {
html: string
props: unknown
chunk: string
slots?: Record<string, string>
}

export interface NuxtIslandResponse {
Expand Down Expand Up @@ -629,14 +630,15 @@ 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 {} }
const response: NuxtIslandResponse['slots'] = {}
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
Expand All @@ -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<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) {
const { teleports, islandContext } = ssrContext

Expand Down
2 changes: 1 addition & 1 deletion packages/nuxt/src/core/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`,
Expand Down
14 changes: 10 additions & 4 deletions test/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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 <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 {
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')

Expand Down Expand Up @@ -1928,7 +1934,7 @@ describe('component islands', () => {
"link": [],
"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": {
"default": {
"props": [],
Expand Down Expand Up @@ -1992,7 +1998,7 @@ describe('component islands', () => {
"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"></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": {},
"slots": {},
"state": {},
Expand Down
9 changes: 9 additions & 0 deletions test/fixtures/basic/components/CounterWithNuxtClient.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<template>
<div>
this is a normal component within a server component
<Counter
nuxt-client
:multiplier="1"
/>
</div>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<template>
<div class="server-with-nested-client">
<CounterWithNuxtClient />
</div>
</template>
2 changes: 1 addition & 1 deletion test/fixtures/basic/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ export default defineNuxtConfig({
restoreState: true,
clientNodeCompat: true,
componentIslands: {
selectiveClient: true
selectiveClient: 'deep'
},
treeshakeClientOnly: true,
asyncContext: process.env.TEST_CONTEXT === 'async',
Expand Down
1 change: 1 addition & 0 deletions test/fixtures/basic/pages/islands.vue
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ const count = ref(0)
</div>
</div>
<ServerWithClient />
<ServerWithNestedClient />
</div>
</template>

Expand Down

0 comments on commit 6d93014

Please sign in to comment.