Skip to content

Commit

Permalink
feat(nuxt): allow client components within NuxtIsland (#22649)
Browse files Browse the repository at this point in the history
  • Loading branch information
huang-julien committed Dec 19, 2023
1 parent 802b3e2 commit 1b93e60
Show file tree
Hide file tree
Showing 18 changed files with 778 additions and 107 deletions.
22 changes: 22 additions & 0 deletions docs/2.guide/2.directory-structure/1.components.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,28 @@ Now you can register server-only components with the `.server` suffix and use th

Server-only components use [`<NuxtIsland>`](/docs/api/components/nuxt-island) under the hood, meaning that `lazy` prop and `#fallback` slot are both passed down to it.

#### Client components within server components

::alert{type=info}
This feature needs `experimental.componentIslands.selectiveClient` within your configuration to be true.
::

You can partially hydrate a component by setting a `nuxt-client` attribute on the component you wish to be loaded client-side.

```html [components/ServerWithClient.vue]
<template>
<div>
<HighlightedMarkdown markdown="# Headline" />
<!-- Counter will be loaded and hydrated client-side -->
<Counter nuxt-client :count="5" />
</div>
</template>
```

::alert{type=info}
This only works within a server component.
::

#### Server Component Context

When rendering a server-only or island component, `<NuxtIsland>` makes a fetch request which comes back with a `NuxtIslandResponse`. (This is an internal request if rendered on the server, or a request that you can see in the network tab if it's rendering on client-side navigation.)
Expand Down
4 changes: 4 additions & 0 deletions docs/3.api/1.components/8.nuxt-island.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,13 @@ Server only components use `<NuxtIsland>` under the hood
- **type**: `Record<string, any>`
- `source`: Remote source to call the island to render.
- **type**: `string`
- **dangerouslyLoadClientComponents**: Required to load components from a remote source.
- **type**: `boolean`
- **default**: `false`

::callout{color="blue" icon="i-ph-info-duotone"}
Remote islands need `experimental.componentIslands` to be `'local+remote'` in your `nuxt.config`.
It is strongly discouraged to enable `dangerouslyLoadClientComponents` as you can't trust a remote server's javascript.
::

## Slots
Expand Down
126 changes: 105 additions & 21 deletions packages/nuxt/src/app/components/nuxt-island.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { Fragment, Teleport, computed, createStaticVNode, createVNode, defineComponent, getCurrentInstance, h, nextTick, onMounted, ref, watch } from 'vue'
import type { Component } from 'vue'
import { Fragment, Teleport, computed, createStaticVNode, createVNode, defineComponent, getCurrentInstance, h, nextTick, onMounted, ref, toRaw, watch } from 'vue'
import { debounce } from 'perfect-debounce'
import { hash } from 'ohash'
import { appendResponseHeader } from 'h3'
import { useHead } from '@unhead/vue'
import { randomUUID } from 'uncrypto'
import { joinURL, withQuery } from 'ufo'
import type { FetchResponse } from 'ofetch'
import { join } from 'pathe'

// eslint-disable-next-line import/no-restricted-paths
import type { NuxtIslandResponse } from '../../core/runtime/nitro/renderer'
Expand All @@ -14,7 +16,7 @@ import { prerenderRoutes, useRequestEvent } from '../composables/ssr'
import { getFragmentHTML, getSlotProps } from './utils'

// @ts-expect-error virtual file
import { remoteComponentIslands } from '#build/nuxt.config.mjs'
import { remoteComponentIslands, selectiveClient } from '#build/nuxt.config.mjs'

const pKey = '_islandPromises'
const SSR_UID_RE = /nuxt-ssr-component-uid="([^"]*)"/
Expand All @@ -25,6 +27,31 @@ const SLOT_FALLBACK_RE = /<div nuxt-slot-fallback-start="([^"]*)"[^>]*><\/div>((
let id = 0
const getId = import.meta.client ? () => (id++).toString() : randomUUID

const components = import.meta.client ? new Map<string, Component>() : undefined

async function loadComponents (source = '/', paths: Record<string, string>) {
const promises = []

for (const component in paths) {
if (!(components!.has(component))) {
promises.push((async () => {
const chunkSource = join(source, paths[component])
const c = await import(/* @vite-ignore */ chunkSource).then(m => m.default || m)
components!.set(component, c)
})())
}
}
await Promise.all(promises)
}

function emptyPayload () {
return {
chunks: {},
props: {},
teleports: {}
}
}

export default defineComponent({
name: 'NuxtIsland',
props: {
Expand All @@ -44,16 +71,23 @@ export default defineComponent({
source: {
type: String,
default: () => undefined
},
dangerouslyLoadClientComponents: {
type: Boolean,
default: false
}
},
async setup (props, { slots, expose }) {
const key = ref(0)
const canLoadClientComponent = computed(() => selectiveClient && (props.dangerouslyLoadClientComponents || !props.source))
const error = ref<unknown>(null)
const config = useRuntimeConfig()
const nuxtApp = useNuxtApp()
const filteredProps = computed(() => props.props ? Object.fromEntries(Object.entries(props.props).filter(([key]) => !key.startsWith('data-v-'))) : {})
const hashId = computed(() => hash([props.name, filteredProps.value, props.context, props.source]))
const instance = getCurrentInstance()!
const event = useRequestEvent()

// TODO: remove use of `$fetch.raw` when nitro 503 issues on windows dev server are resolved
const eventFetch = import.meta.server ? event.fetch : import.meta.dev ? $fetch.raw : globalThis.fetch
const mounted = ref(false)
Expand All @@ -65,50 +99,69 @@ export default defineComponent({
key,
...(import.meta.server && import.meta.prerender)
? {}
: { params: { ...props.context, props: props.props ? JSON.stringify(props.props) : undefined } }
: { params: { ...props.context, props: props.props ? JSON.stringify(props.props) : undefined } },
result: {
chunks: result.chunks,
props: result.props,
teleports: result.teleports
}
},
...result
}
}
// needs to be non-reactive because we don't want to trigger re-renders
// at hydration, we only retrieve props/chunks/teleports from payload. See the reviver at nuxt\src\app\plugins\revive-payload.client.ts
// If not hydrating, fetchComponent() will set it
const rawPayload = nuxtApp.isHydrating ? toRaw(nuxtApp.payload.data)?.[`${props.name}_${hashId.value}`] ?? emptyPayload() : emptyPayload()

const nonReactivePayload: Pick<NuxtIslandResponse, 'chunks'| 'props' | 'teleports'> = {
chunks: rawPayload.chunks,
props: rawPayload.props,
teleports: rawPayload.teleports
}

const ssrHTML = ref<string>('')

if (import.meta.client) {
const renderedHTML = getFragmentHTML(instance.vnode?.el ?? null)?.join('') ?? ''
if (renderedHTML && nuxtApp.isHydrating) {
setPayload(`${props.name}_${hashId.value}`, {
html: getFragmentHTML(instance.vnode?.el ?? null, true)?.join('') ?? '',
state: {},
head: {
link: [],
style: []
}
})
}
ssrHTML.value = renderedHTML
ssrHTML.value = getFragmentHTML(instance.vnode?.el ?? null, true)?.join('') || ''
}

const slotProps = computed(() => getSlotProps(ssrHTML.value))
const uid = ref<string>(ssrHTML.value.match(SSR_UID_RE)?.[1] ?? getId())
const availableSlots = computed(() => [...ssrHTML.value.matchAll(SLOTNAME_RE)].map(m => m[1]))

const html = computed(() => {
const currentSlots = Object.keys(slots)
return ssrHTML.value.replace(SLOT_FALLBACK_RE, (full, slotName, content) => {
let html = ssrHTML.value

if (import.meta.client && !canLoadClientComponent.value) {
for (const [key, value] of Object.entries(nonReactivePayload.teleports || {})) {
html = html.replace(new RegExp(`<div [^>]*nuxt-ssr-client="${key}"[^>]*>`), (full) => {
return full + value
})
}
}

return html.replace(SLOT_FALLBACK_RE, (full, slotName, content) => {
// remove fallback to insert slots
if (currentSlots.includes(slotName)) {
return ''
}
return content
})
})

function setUid () {
uid.value = ssrHTML.value.match(SSR_UID_RE)?.[1] ?? getId() as string
}

const cHead = ref<Record<'link' | 'style', Array<Record<string, string>>>>({ link: [], style: [] })
useHead(cHead)

async function _fetchComponent (force = false) {
const key = `${props.name}_${hashId.value}`
if (nuxtApp.payload.data[key] && !force) { return nuxtApp.payload.data[key] }

if (nuxtApp.payload.data[key]?.html && !force) { return nuxtApp.payload.data[key] }

const url = remoteComponentIslands && props.source ? new URL(`/__nuxt_island/${key}.json`, props.source).href : `/__nuxt_island/${key}.json`

Expand All @@ -133,7 +186,7 @@ export default defineComponent({
setPayload(key, result)
return result
}
const key = ref(0)

async function fetchComponent (force = false) {
nuxtApp[pKey] = nuxtApp[pKey] || {}
if (!nuxtApp[pKey][uid.value]) {
Expand All @@ -150,6 +203,16 @@ export default defineComponent({
})
key.value++
error.value = null

if (selectiveClient && import.meta.client) {
if (canLoadClientComponent.value && res.chunks) {
await loadComponents(props.source, res.chunks)
}
nonReactivePayload.props = res.props
}
nonReactivePayload.teleports = res.teleports
nonReactivePayload.chunks = res.chunks

if (import.meta.client) {
// must await next tick for Teleport to work correctly with static node re-rendering
await nextTick()
Expand Down Expand Up @@ -178,23 +241,44 @@ export default defineComponent({
fetchComponent()
} else if (import.meta.server || !nuxtApp.isHydrating || !nuxtApp.payload.serverRendered) {
await fetchComponent()
} else if (selectiveClient && canLoadClientComponent.value && nonReactivePayload.chunks) {
await loadComponents(props.source, nonReactivePayload.chunks)
}

return () => {
if ((!html.value || error.value) && slots.fallback) {
return [slots.fallback({ error: error.value })]
if (!html.value || error.value) {
return [slots.fallback?.({ error: error.value }) ?? createVNode('div')]
}
const nodes = [createVNode(Fragment, {
key: key.value
}, [h(createStaticVNode(html.value || '<div></div>', 1))])]
if (uid.value && (mounted.value || nuxtApp.isHydrating || import.meta.server)) {

if (uid.value && (mounted.value || nuxtApp.isHydrating || import.meta.server) && html.value) {
for (const slot in slots) {
if (availableSlots.value.includes(slot)) {
nodes.push(createVNode(Teleport, { to: import.meta.client ? `[nuxt-ssr-component-uid='${uid.value}'] [nuxt-ssr-slot-name='${slot}']` : `uid=${uid.value};slot=${slot}` }, {
default: () => (slotProps.value[slot] ?? [undefined]).map((data: any) => slots[slot]?.(data))
}))
}
}
if (import.meta.server) {
for (const [id, html] of Object.entries(nonReactivePayload.teleports ?? {})) {
nodes.push(createVNode(Teleport, { to: `uid=${uid.value};client=${id}` }, {
default: () => [createStaticVNode(html, 1)]
}))
}
}
if (selectiveClient && import.meta.client && canLoadClientComponent.value) {
for (const [id, props] of Object.entries(nonReactivePayload.props ?? {})) {
const component = components!.get(id.split('-')[0])!
const vnode = createVNode(Teleport, { to: `[nuxt-ssr-component-uid='${uid.value}'] [nuxt-ssr-client="${id}"]` }, {
default: () => {
return [h(component, props)]
}
})
nodes.push(vnode)
}
}
}
return nodes
}
Expand Down
62 changes: 62 additions & 0 deletions packages/nuxt/src/app/components/nuxt-teleport-ssr-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { Component, } from 'vue'
import { Teleport, defineComponent, h } from 'vue'
import { useNuxtApp } from '../nuxt'
// @ts-expect-error virtual file
import { paths } from '#build/components-chunk'

type ExtendedComponent = Component & {
__file: string,
__name: string
}

/**
* component only used with componentsIsland
* this teleport the component in SSR only if it needs to be hydrated on client
*/
export default defineComponent({
name: 'NuxtTeleportSsrClient',
props: {
to: {
type: String,
required: true
},
nuxtClient: {
type: Boolean,
default: false
},
/**
* ONLY used in dev mode since we use build:manifest result in production
* do not pass any value in production
*/
rootDir: {
type: String,
default: null
}
},
setup (props, { slots }) {
if (!props.nuxtClient) { return () => slots.default!() }

const app = useNuxtApp()
const islandContext = app.ssrContext!.islandContext!

return () => {
const slot = slots.default!()[0]
const slotType = (slot.type as ExtendedComponent)
const name = (slotType.__name || slotType.name) as string

if (import.meta.dev) {
const path = '_nuxt/' + paths[name]
islandContext.chunks[name] = path
} else {
islandContext.chunks[name] = paths[name]
}

islandContext.propsData[props.to] = slot.props || {}

return [h('div', {
style: 'display: contents;',
'nuxt-ssr-client': props.to
}, []), h(Teleport, { to: props.to }, slot)]
}
}
})
12 changes: 10 additions & 2 deletions packages/nuxt/src/app/plugins/revive-payload.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const revivers: Record<string, (data: any) => any> = {
}

if (componentIslands) {
revivers.Island = ({ key, params }: any) => {
revivers.Island = ({ key, params, result }: any) => {
const nuxtApp = useNuxtApp()
if (!nuxtApp.isHydrating) {
nuxtApp.payload.data[key] = nuxtApp.payload.data[key] || $fetch(`/__nuxt_island/${key}.json`, {
Expand All @@ -29,7 +29,15 @@ if (componentIslands) {
return r
})
}
return null
return {
html: '',
state: {},
head: {
link: [],
style: []
},
...result
}
}
}

Expand Down

0 comments on commit 1b93e60

Please sign in to comment.