Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 22 commits into from
Mar 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b78dffe
feat(nuxt): allow declaring nuxt-client in all components
huang-julien Jan 19, 2024
11611a4
Merge branch 'main' into feat/gloal_nuxt_client
huang-julien Jan 20, 2024
f37ce9e
test: add fixtures test
huang-julien Jan 20, 2024
8ccbedb
fix: allow slot to be passed within client components
huang-julien Jan 26, 2024
6aad978
Merge remote-tracking branch 'origin/main' into feat/gloal_nuxt_client
huang-julien Jan 30, 2024
003bd5d
fix: hydration
huang-julien Jan 30, 2024
812df48
test: add runtime tests
huang-julien Jan 30, 2024
952c8b0
test: update snapshot
huang-julien Feb 1, 2024
e164b59
test: wrap vite specific test with condition
huang-julien Feb 1, 2024
60704c8
chore: lint
huang-julien Feb 1, 2024
246d559
[autofix.ci] apply automated fixes
autofix-ci[bot] Feb 1, 2024
81dd462
chore: typecheck
huang-julien Feb 1, 2024
01d91ca
Merge branch 'feat/gloal_nuxt_client' of https://github.com/nuxt/nuxt…
huang-julien Feb 1, 2024
222726c
docs: update the info alert
huang-julien Feb 2, 2024
8951003
Merge branch 'main' into feat/gloal_nuxt_client
huang-julien Feb 2, 2024
597bf48
Merge branch 'main' into feat/gloal_nuxt_client
huang-julien Feb 11, 2024
ab3eb97
Merge branch 'main' into feat/gloal_nuxt_client
huang-julien Feb 20, 2024
4ab2f99
style: lint
danielroe Feb 26, 2024
61a9de3
Merge remote-tracking branch 'origin/main' into feat/gloal_nuxt_client
danielroe Feb 26, 2024
02f79bb
fix: use typed injection key
danielroe Feb 27, 2024
6d119ba
Merge branch 'main' into feat/gloal_nuxt_client
huang-julien Mar 6, 2024
72ff29e
Merge branch 'main' into feat/gloal_nuxt_client
danielroe Mar 6, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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 @@ -1542,6 +1542,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 @@ -1599,9 +1605,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 @@ -1860,7 +1866,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 @@ -1924,7 +1930,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