diff --git a/docs/2.guide/2.directory-structure/1.components.md b/docs/2.guide/2.directory-structure/1.components.md
index f4d5168f85..1d9859ab15 100644
--- a/docs/2.guide/2.directory-structure/1.components.md
+++ b/docs/2.guide/2.directory-structure/1.components.md
@@ -296,8 +296,8 @@ Now you can register server-only components with the `.server` suffix and use th
```
-::alert{type=warning}
-Slots are not supported by server components in their current state of development.
+::alert{type=info}
+Slots can be interactive and are wrapped within a `
` with `display: contents;`
::
### Paired with a `.client` component
diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json
index b0c76d0524..8554792ac3 100644
--- a/packages/nuxt/package.json
+++ b/packages/nuxt/package.json
@@ -91,6 +91,7 @@
"strip-literal": "^1.0.1",
"ufo": "^1.1.2",
"ultrahtml": "^1.2.0",
+ "uncrypto": "^0.1.2",
"unctx": "^2.3.0",
"unenv": "^1.4.1",
"unimport": "^3.0.6",
diff --git a/packages/nuxt/src/app/components/island-renderer.ts b/packages/nuxt/src/app/components/island-renderer.ts
index bb4a8e9e65..07f5409803 100644
--- a/packages/nuxt/src/app/components/island-renderer.ts
+++ b/packages/nuxt/src/app/components/island-renderer.ts
@@ -21,6 +21,7 @@ export default defineComponent({
statusMessage: `Island component not found: ${JSON.stringify(component)}`
})
}
- return () => createVNode(component || 'span', props.context.props)
+
+ return () => createVNode(component || 'span', { ...props.context.props, 'nuxt-ssr-component-uid': '' })
}
})
diff --git a/packages/nuxt/src/app/components/nuxt-island.ts b/packages/nuxt/src/app/components/nuxt-island.ts
index adfe5552ef..d3a077520f 100644
--- a/packages/nuxt/src/app/components/nuxt-island.ts
+++ b/packages/nuxt/src/app/components/nuxt-island.ts
@@ -1,16 +1,20 @@
-import type { RendererNode, Slots } from 'vue'
-import { computed, createStaticVNode, defineComponent, getCurrentInstance, h, ref, watch } from 'vue'
+import { Fragment, Teleport, computed, createStaticVNode, createVNode, defineComponent, getCurrentInstance, h, nextTick, onMounted, ref, 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'
// eslint-disable-next-line import/no-restricted-paths
import type { NuxtIslandResponse } from '../../core/runtime/nitro/renderer'
+import { getFragmentHTML, getSlotProps } from './utils'
import { useNuxtApp } from '#app/nuxt'
import { useRequestEvent } from '#app/composables/ssr'
const pKey = '_islandPromises'
+const SSR_UID_RE = /nuxt-ssr-component-uid="([^"]*)"/
+const UID_ATTR = /nuxt-ssr-component-uid(="([^"]*)")?/
+const SLOTNAME_RE = /nuxt-ssr-slot-name="([^"]*)"/g
+const SLOT_FALLBACK_RE = /
]*><\/div>(((?!
]*>)[\s\S])*)
]*><\/div>/g
export default defineComponent({
name: 'NuxtIsland',
@@ -28,15 +32,37 @@ export default defineComponent({
default: () => ({})
}
},
- async setup (props) {
+ async setup (props, { slots }) {
const nuxtApp = useNuxtApp()
const hashId = computed(() => hash([props.name, props.props, props.context]))
const instance = getCurrentInstance()!
const event = useRequestEvent()
+ const mounted = ref(false)
+ onMounted(() => { mounted.value = true })
+ const ssrHTML = ref
(process.client ? getFragmentHTML(instance.vnode?.el ?? null).join('') ?? '
' : '
')
+ const uid = ref(ssrHTML.value.match(SSR_UID_RE)?.[1] ?? randomUUID())
+ const availableSlots = computed(() => {
+ return [...ssrHTML.value.matchAll(SLOTNAME_RE)].map(m => m[1])
+ })
- const html = ref(process.client ? getFragmentHTML(instance?.vnode?.el).join('') ?? '
' : '
')
+ const html = computed(() => {
+ const currentSlots = Object.keys(slots)
+ return ssrHTML.value.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] ?? randomUUID() as string
+ }
const cHead = ref>>>({ link: [], style: [] })
useHead(cHead)
+ const slotProps = computed(() => {
+ return getSlotProps(ssrHTML.value)
+ })
function _fetchComponent () {
const url = `/__nuxt_island/${props.name}:${hashId.value}`
@@ -55,16 +81,23 @@ export default defineComponent({
const key = ref(0)
async function fetchComponent () {
nuxtApp[pKey] = nuxtApp[pKey] || {}
- if (!nuxtApp[pKey][hashId.value]) {
- nuxtApp[pKey][hashId.value] = _fetchComponent().finally(() => {
- delete nuxtApp[pKey]![hashId.value]
+ if (!nuxtApp[pKey][uid.value]) {
+ nuxtApp[pKey][uid.value] = _fetchComponent().finally(() => {
+ delete nuxtApp[pKey]![uid.value]
})
}
- const res: NuxtIslandResponse = await nuxtApp[pKey][hashId.value]
+ const res: NuxtIslandResponse = await nuxtApp[pKey][uid.value]
cHead.value.link = res.head.link
cHead.value.style = res.head.style
- html.value = res.html
+ ssrHTML.value = res.html.replace(UID_ATTR, () => {
+ return `nuxt-ssr-component-uid="${randomUUID()}"`
+ })
key.value++
+ if (process.client) {
+ // must await next tick for Teleport to work correctly with static node re-rendering
+ await nextTick()
+ }
+ setUid()
}
if (process.client) {
@@ -74,40 +107,21 @@ export default defineComponent({
if (process.server || !nuxtApp.isHydrating) {
await fetchComponent()
}
- return () => h((_, { slots }) => (slots as Slots).default?.(), { key: key.value }, {
- default: () => [createStaticVNode(html.value, 1)]
- })
+
+ return () => {
+ const nodes = [createVNode(Fragment, {
+ key: key.value
+ }, [h(createStaticVNode(html.value, 1))])]
+ if (uid.value && (mounted.value || nuxtApp.isHydrating || process.server)) {
+ for (const slot in slots) {
+ if (availableSlots.value.includes(slot)) {
+ nodes.push(createVNode(Teleport, { to: process.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))
+ }))
+ }
+ }
+ }
+ return nodes
+ }
}
})
-
-// TODO refactor with https://github.com/nuxt/nuxt/pull/19231
-function getFragmentHTML (element: RendererNode | null) {
- if (element) {
- if (element.nodeName === '#comment' && element.nodeValue === '[') {
- return getFragmentChildren(element)
- }
- return [element.outerHTML]
- }
- return []
-}
-
-function getFragmentChildren (element: RendererNode | null, blocks: string[] = []) {
- if (element && element.nodeName) {
- if (isEndFragment(element)) {
- return blocks
- } else if (!isStartFragment(element)) {
- blocks.push(element.outerHTML)
- }
-
- getFragmentChildren(element.nextSibling, blocks)
- }
- return blocks
-}
-
-function isStartFragment (element: RendererNode) {
- return element.nodeName === '#comment' && element.nodeValue === '['
-}
-
-function isEndFragment (element: RendererNode) {
- return element.nodeName === '#comment' && element.nodeValue === ']'
-}
diff --git a/packages/nuxt/src/app/components/utils.ts b/packages/nuxt/src/app/components/utils.ts
index 8fe1ec3c2c..386a81f0c9 100644
--- a/packages/nuxt/src/app/components/utils.ts
+++ b/packages/nuxt/src/app/components/utils.ts
@@ -1,7 +1,8 @@
import { h } from 'vue'
-import type { Component } from 'vue'
+import type { Component, RendererNode } from 'vue'
// eslint-disable-next-line
-import { isString, isPromise, isArray } from '@vue/shared'
+import { isString, isPromise, isArray, isObject } from '@vue/shared'
+import destr from 'destr'
/**
* Internal utility
@@ -44,3 +45,99 @@ export function createBuffer () {
}
}
}
+
+const TRANSLATE_RE = /&(nbsp|amp|quot|lt|gt);/g
+const NUMSTR_RE = /(\d+);/gi
+export function decodeHtmlEntities (html: string) {
+ const translateDict = {
+ nbsp: ' ',
+ amp: '&',
+ quot: '"',
+ lt: '<',
+ gt: '>'
+ } as const
+ return html.replace(TRANSLATE_RE, function (_, entity: keyof typeof translateDict) {
+ return translateDict[entity]
+ }).replace(NUMSTR_RE, function (_, numStr: string) {
+ const num = parseInt(numStr, 10)
+ return String.fromCharCode(num)
+ })
+}
+
+/**
+ * helper for NuxtIsland to generate a correct array for scoped data
+ */
+export function vforToArray (source: any): any[] {
+ if (isArray(source)) {
+ return source
+ } else if (isString(source)) {
+ return source.split('')
+ } else if (typeof source === 'number') {
+ if (process.dev && !Number.isInteger(source)) {
+ console.warn(`The v-for range expect an integer value but got ${source}.`)
+ }
+ const array = []
+ for (let i = 0; i < source; i++) {
+ array[i] = i
+ }
+ return array
+ } else if (isObject(source)) {
+ if (source[Symbol.iterator as any]) {
+ return Array.from(source as Iterable, item =>
+ item
+ )
+ } else {
+ const keys = Object.keys(source)
+ const array = new Array(keys.length)
+ for (let i = 0, l = keys.length; i < l; i++) {
+ const key = keys[i]
+ array[i] = source[key]
+ }
+ return array
+ }
+ }
+ return []
+}
+
+export function getFragmentHTML (element: RendererNode | null) {
+ if (element) {
+ if (element.nodeName === '#comment' && element.nodeValue === '[') {
+ return getFragmentChildren(element)
+ }
+ return [element.outerHTML]
+ }
+ return []
+}
+
+function getFragmentChildren (element: RendererNode | null, blocks: string[] = []) {
+ if (element && element.nodeName) {
+ if (isEndFragment(element)) {
+ return blocks
+ } else if (!isStartFragment(element)) {
+ blocks.push(element.outerHTML)
+ }
+
+ getFragmentChildren(element.nextSibling, blocks)
+ }
+ return blocks
+}
+
+function isStartFragment (element: RendererNode) {
+ return element.nodeName === '#comment' && element.nodeValue === '['
+}
+
+function isEndFragment (element: RendererNode) {
+ return element.nodeName === '#comment' && element.nodeValue === ']'
+}
+const SLOT_PROPS_RE = /]*nuxt-ssr-slot-name="([^"]*)" nuxt-ssr-slot-data="([^"]*)"[^/|>]*>/g
+
+export function getSlotProps (html: string) {
+ const slotsDivs = html.matchAll(SLOT_PROPS_RE)
+ const data:Record
= {}
+ for (const slot of slotsDivs) {
+ const [_, slotName, json] = slot
+ const slotData = destr(decodeHtmlEntities(json))
+ data[slotName] = slotData
+ }
+ return data
+}
diff --git a/packages/nuxt/src/components/islandsTransform.ts b/packages/nuxt/src/components/islandsTransform.ts
new file mode 100644
index 0000000000..c6da0fff4e
--- /dev/null
+++ b/packages/nuxt/src/components/islandsTransform.ts
@@ -0,0 +1,101 @@
+import { pathToFileURL } from 'node:url'
+import type { Component } from '@nuxt/schema'
+import { parseURL } from 'ufo'
+import { createUnplugin } from 'unplugin'
+import MagicString from 'magic-string'
+import { ELEMENT_NODE, parse, walk } from 'ultrahtml'
+
+interface ServerOnlyComponentTransformPluginOptions {
+ getComponents: () => Component[]
+}
+
+const SCRIPT_RE = /
diff --git a/test/fixtures/basic/pages/index.vue b/test/fixtures/basic/pages/index.vue
index 2b31da6380..c971c966d7 100644
--- a/test/fixtures/basic/pages/index.vue
+++ b/test/fixtures/basic/pages/index.vue
@@ -23,6 +23,9 @@
Link
+
+ islands
+
Chunk error
diff --git a/test/fixtures/basic/pages/islands.vue b/test/fixtures/basic/pages/islands.vue
index 3e6051e454..df8d35af54 100644
--- a/test/fixtures/basic/pages/islands.vue
+++ b/test/fixtures/basic/pages/islands.vue
@@ -6,8 +6,9 @@ const islandProps = ref({
obj: { json: 'works' }
})
+const showIslandSlot = ref(false)
const routeIslandVisible = ref(false)
-
+const testCount = ref(0)
const count = ref(0)
@@ -31,13 +32,46 @@ const count = ref(0)
async .server component
-
+
+
+ Slot with in .server component
+
+
- Async island component (20ms):
-
-
- add +1 to count
+ Async component (1000ms):
+
+
+ Interactive testing slot
+
+
+
+
+
+ Slot with name test - scoped data {{ scoped }}
+
+
+
+
+ Slot with name hello - scoped data {{ scoped }}
+
+
+
+
+ add +1 to count
+
+
+
+
+
Island with props mounted client side
+
+ Show Interactive island
+
+
+ Interactive testing slot post SSR
+
+
+