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}
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

View File

@ -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)

View File

@ -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<false | string>
/**
* 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 () => {

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 { 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<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

View File

@ -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 =>

View File

@ -70,6 +70,7 @@ export interface NuxtIslandClientResponse {
html: string
props: unknown
chunk: string
slots?: Record<string, string>
}
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<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

View File

@ -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)}`,

View File

@ -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 <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')
@ -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": [],
@ -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": {},

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,
clientNodeCompat: true,
componentIslands: {
selectiveClient: true
selectiveClient: 'deep'
},
treeshakeClientOnly: true,
asyncContext: process.env.TEST_CONTEXT === 'async',

View File

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

View File

@ -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(`
"<div data-island-uid="3">hello<div>
<div>fallback</div>
</div>
@ -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()
})
})
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()
})
})