mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-22 05:35:13 +00:00
feat(nuxt): full scoped slots support for server components (#20284)
This commit is contained in:
parent
1aec0e5039
commit
70c5ec86d2
@ -296,8 +296,8 @@ Now you can register server-only components with the `.server` suffix and use th
|
||||
</template>
|
||||
```
|
||||
|
||||
::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 `<div>` with `display: contents;`
|
||||
::
|
||||
|
||||
### Paired with a `.client` component
|
||||
|
@ -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",
|
||||
|
@ -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': '' })
|
||||
}
|
||||
})
|
||||
|
@ -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 nuxt-slot-fallback-start="([^"]*)"[^>]*><\/div>(((?!<div nuxt-slot-fallback-end[^>]*>)[\s\S])*)<div nuxt-slot-fallback-end[^>]*><\/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<string>(process.client ? getFragmentHTML(instance.vnode?.el ?? null).join('') ?? '<div></div>' : '<div></div>')
|
||||
const uid = ref<string>(ssrHTML.value.match(SSR_UID_RE)?.[1] ?? randomUUID())
|
||||
const availableSlots = computed(() => {
|
||||
return [...ssrHTML.value.matchAll(SLOTNAME_RE)].map(m => m[1])
|
||||
})
|
||||
|
||||
const html = ref<string>(process.client ? getFragmentHTML(instance?.vnode?.el).join('') ?? '<div></div>' : '<div></div>')
|
||||
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<Record<'link' | 'style', Array<Record<string, string>>>>({ 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 === ']'
|
||||
}
|
||||
|
@ -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<any>, 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 = /<div[^>]*nuxt-ssr-slot-name="([^"]*)" nuxt-ssr-slot-data="([^"]*)"[^/|>]*>/g
|
||||
|
||||
export function getSlotProps (html: string) {
|
||||
const slotsDivs = html.matchAll(SLOT_PROPS_RE)
|
||||
const data:Record<string, any> = {}
|
||||
for (const slot of slotsDivs) {
|
||||
const [_, slotName, json] = slot
|
||||
const slotData = destr(decodeHtmlEntities(json))
|
||||
data[slotName] = slotData
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
101
packages/nuxt/src/components/islandsTransform.ts
Normal file
101
packages/nuxt/src/components/islandsTransform.ts
Normal file
@ -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 = /<script[^>]*>/g
|
||||
|
||||
export const islandsTransform = createUnplugin((options: ServerOnlyComponentTransformPluginOptions) => {
|
||||
return {
|
||||
name: 'server-only-component-transform',
|
||||
enforce: 'pre',
|
||||
transformInclude (id) {
|
||||
const components = options.getComponents()
|
||||
const islands = components.filter(component =>
|
||||
component.island || (component.mode === 'server' && !components.some(c => c.pascalName === component.pascalName && c.mode === 'client'))
|
||||
)
|
||||
const { pathname } = parseURL(decodeURIComponent(pathToFileURL(id).href))
|
||||
return islands.some(c => c.filePath === pathname)
|
||||
},
|
||||
async transform (code, id) {
|
||||
if (!code.includes('<slot ')) { return }
|
||||
const template = code.match(/<template>([\s\S]*)<\/template>/)
|
||||
if (!template) { return }
|
||||
const s = new MagicString(code)
|
||||
|
||||
s.replace(SCRIPT_RE, (full) => {
|
||||
return full + '\nimport { vforToArray as __vforToArray } from \'#app/components/utils\''
|
||||
})
|
||||
|
||||
const ast = parse(template[0])
|
||||
await walk(ast, (node) => {
|
||||
if (node.type === ELEMENT_NODE && node.name === 'slot') {
|
||||
const { attributes, children, loc, isSelfClosingTag } = node
|
||||
const slotName = attributes.name ?? 'default'
|
||||
let vfor: [string, string] | undefined
|
||||
if (attributes['v-for']) {
|
||||
vfor = attributes['v-for'].split(' in ').map((v: string) => v.trim()) as [string, string]
|
||||
delete attributes['v-for']
|
||||
}
|
||||
if (attributes.name) { delete attributes.name }
|
||||
if (attributes['v-bind']) {
|
||||
attributes._bind = attributes['v-bind']
|
||||
delete attributes['v-bind']
|
||||
}
|
||||
const bindings = getBindings(attributes, vfor)
|
||||
|
||||
if (isSelfClosingTag) {
|
||||
s.overwrite(loc[0].start, loc[0].end, `<div style="display: contents;" nuxt-ssr-slot-name="${slotName}" ${bindings}/>`)
|
||||
} else {
|
||||
s.overwrite(loc[0].start, loc[0].end, `<div style="display: contents;" nuxt-ssr-slot-name="${slotName}" ${bindings}>`)
|
||||
s.overwrite(loc[1].start, loc[1].end, '</div>')
|
||||
|
||||
if (children.length > 1) {
|
||||
// need to wrap instead of applying v-for on each child
|
||||
const wrapperTag = `<div ${vfor ? `v-for="${vfor[0]} in ${vfor[1]}"` : ''} style="display: contents;">`
|
||||
s.appendRight(loc[0].end, `<div nuxt-slot-fallback-start="${slotName}"/>${wrapperTag}`)
|
||||
s.appendLeft(loc[1].start, '</div><div nuxt-slot-fallback-end/>')
|
||||
} else if (children.length === 1) {
|
||||
if (vfor && children[0].type === ELEMENT_NODE) {
|
||||
const { loc, name, attributes, isSelfClosingTag } = children[0]
|
||||
const attrs = Object.entries(attributes).map(([attr, val]) => `${attr}="${val}"`).join(' ')
|
||||
s.overwrite(loc[0].start, loc[0].end, `<${name} v-for="${vfor[0]} in ${vfor[1]}" ${attrs} ${isSelfClosingTag ? '/' : ''}>`)
|
||||
}
|
||||
|
||||
s.appendRight(loc[0].end, `<div nuxt-slot-fallback-start="${slotName}"/>`)
|
||||
s.appendLeft(loc[1].start, '<div nuxt-slot-fallback-end/>')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (s.hasChanged()) {
|
||||
return {
|
||||
code: s.toString(),
|
||||
map: s.generateMap({ source: id, includeContent: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function isBinding (attr: string): boolean {
|
||||
return attr.startsWith(':')
|
||||
}
|
||||
|
||||
function getBindings (bindings: Record<string, string>, vfor?: [string, string]): string {
|
||||
if (Object.keys(bindings).length === 0) { return '' }
|
||||
const content = Object.entries(bindings).filter(b => b[0] !== '_bind').map(([name, value]) => isBinding(name) ? `${name.slice(1)}: ${value}` : `${name}: \`${value}\``).join(',')
|
||||
const data = bindings._bind ? `mergeProps(${bindings._bind}, { ${content} })` : `{ ${content} }`
|
||||
if (!vfor) {
|
||||
return `:nuxt-ssr-slot-data="JSON.stringify([${data}])"`
|
||||
} else {
|
||||
return `:nuxt-ssr-slot-data="JSON.stringify(__vforToArray(${vfor[1]}).map(${vfor[0]} => (${data})))"`
|
||||
}
|
||||
}
|
@ -9,6 +9,7 @@ import { componentNamesTemplate, componentsIslandsTemplate, componentsPluginTemp
|
||||
import { scanComponents } from './scan'
|
||||
import { loaderPlugin } from './loader'
|
||||
import { TreeShakeTemplatePlugin } from './tree-shake'
|
||||
import { islandsTransform } from './islandsTransform'
|
||||
import { createTransformPlugin } from './transform'
|
||||
|
||||
const isPureObjectOrString = (val: any) => (!Array.isArray(val) && typeof val === 'object') || typeof val === 'string'
|
||||
@ -220,6 +221,10 @@ export default defineNuxtModule<ComponentsOptions>({
|
||||
transform: typeof nuxt.options.components === 'object' && !Array.isArray(nuxt.options.components) ? nuxt.options.components.transform : undefined,
|
||||
experimentalComponentIslands: nuxt.options.experimental.componentIslands
|
||||
}))
|
||||
|
||||
config.plugins.push(islandsTransform.vite({
|
||||
getComponents
|
||||
}))
|
||||
})
|
||||
nuxt.hook('webpack:config', (configs) => {
|
||||
configs.forEach((config) => {
|
||||
@ -242,6 +247,10 @@ export default defineNuxtModule<ComponentsOptions>({
|
||||
transform: typeof nuxt.options.components === 'object' && !Array.isArray(nuxt.options.components) ? nuxt.options.components.transform : undefined,
|
||||
experimentalComponentIslands: nuxt.options.experimental.componentIslands
|
||||
}))
|
||||
|
||||
config.plugins.push(islandsTransform.webpack({
|
||||
getComponents
|
||||
}))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
@ -1,25 +1,31 @@
|
||||
import { Fragment, computed, createStaticVNode, createVNode, defineComponent, 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'
|
||||
import type { NuxtIslandResponse } from '../../core/runtime/nitro/renderer'
|
||||
import { useNuxtApp } from '#app/nuxt'
|
||||
import { useRequestEvent } from '#app/composables/ssr'
|
||||
import { useAsyncData } from '#app/composables/asyncData'
|
||||
import { getFragmentHTML, getSlotProps } from '#app/components/utils'
|
||||
|
||||
const pKey = '_islandPromises'
|
||||
const UID_ATTR = /nuxt-ssr-component-uid(="([^"]*)")?/
|
||||
const SLOTNAME_RE = /nuxt-ssr-slot-name="([^"]*)"/g
|
||||
const SLOT_FALLBACK_RE = /<div nuxt-slot-fallback-start="([^"]*)"[^>]*><\/div>(((?!<div nuxt-slot-fallback-end[^>]*>)[\s\S])*)<div nuxt-slot-fallback-end[^>]*><\/div>/g
|
||||
const SSR_UID_RE = /nuxt-ssr-component-uid="([^"]*)"/
|
||||
|
||||
export const createServerComponent = (name: string) => {
|
||||
return defineComponent({
|
||||
name,
|
||||
inheritAttrs: false,
|
||||
setup (_props, { attrs }) {
|
||||
setup (_props, { attrs, slots }) {
|
||||
return () => h(NuxtServerComponent, {
|
||||
name,
|
||||
props: attrs
|
||||
})
|
||||
}, slots)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -40,9 +46,14 @@ const NuxtServerComponent = defineComponent({
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
async setup (props) {
|
||||
async setup (props, { slots }) {
|
||||
const instance = getCurrentInstance()!
|
||||
const uid = ref(getFragmentHTML(instance.vnode?.el)[0]?.match(SSR_UID_RE)?.[1] ?? randomUUID())
|
||||
|
||||
const nuxtApp = useNuxtApp()
|
||||
const mounted = ref(false)
|
||||
const key = ref(0)
|
||||
onMounted(() => { mounted.value = true })
|
||||
const hashId = computed(() => hash([props.name, props.props, props.context]))
|
||||
|
||||
const event = useRequestEvent()
|
||||
@ -96,11 +107,57 @@ const NuxtServerComponent = defineComponent({
|
||||
watch(props, debounce(async () => {
|
||||
await res.execute()
|
||||
key.value++
|
||||
if (process.client) {
|
||||
// must await next tick for Teleport to work correctly with static node re-rendering
|
||||
await nextTick()
|
||||
}
|
||||
setUid()
|
||||
}, 100))
|
||||
}
|
||||
|
||||
const slotProps = computed(() => {
|
||||
return getSlotProps(res.data.value!.html)
|
||||
})
|
||||
const availableSlots = computed(() => {
|
||||
return [...res.data.value!.html.matchAll(SLOTNAME_RE)].map(m => m[1])
|
||||
})
|
||||
|
||||
const html = computed(() => {
|
||||
const currentSlots = Object.keys(slots)
|
||||
return res.data.value!.html
|
||||
.replace(UID_ATTR, () => `nuxt-ssr-component-uid="${randomUUID()}"`)
|
||||
.replace(SLOT_FALLBACK_RE, (full, slotName, content) => {
|
||||
// remove fallback to insert slots
|
||||
if (currentSlots.includes(slotName)) {
|
||||
return ''
|
||||
}
|
||||
return content
|
||||
})
|
||||
})
|
||||
function setUid () {
|
||||
uid.value = html.value.match(SSR_UID_RE)?.[1] ?? randomUUID() as string
|
||||
}
|
||||
|
||||
await res
|
||||
|
||||
return () => createVNode(Fragment, { key: key.value }, [createStaticVNode(res.data.value!.html, 1)])
|
||||
if (process.server || !nuxtApp.isHydrating) {
|
||||
setUid()
|
||||
}
|
||||
|
||||
return () => {
|
||||
const nodes = [createVNode(Fragment, {
|
||||
key: key.value
|
||||
}, [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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
@ -154,7 +154,8 @@ async function getIslandContext (event: H3Event): Promise<NuxtIslandContext> {
|
||||
...context,
|
||||
id: hashId,
|
||||
name: componentName,
|
||||
props: destr(context.props) || {}
|
||||
props: destr(context.props) || {},
|
||||
uid: destr(context.uid) || undefined
|
||||
}
|
||||
|
||||
return ctx
|
||||
@ -309,7 +310,7 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse
|
||||
renderedMeta.bodyScriptsPrepend,
|
||||
ssrContext.teleports?.body
|
||||
]),
|
||||
body: [_rendered.html],
|
||||
body: [process.env.NUXT_COMPONENT_ISLANDS ? replaceServerOnlyComponentsSlots(ssrContext, _rendered.html) : _rendered.html],
|
||||
bodyAppend: normalizeChunks([
|
||||
NO_SCRIPTS
|
||||
? undefined
|
||||
@ -491,3 +492,19 @@ function getServerComponentHTML (body: string[]): string {
|
||||
const match = body[0].match(ROOT_NODE_REGEX)
|
||||
return match ? match[1] : body[0]
|
||||
}
|
||||
|
||||
const SSR_TELEPORT_MARKER = /^uid=([^;]*);slot=(.*)$/
|
||||
function replaceServerOnlyComponentsSlots (ssrContext: NuxtSSRContext, html: string): string {
|
||||
const { teleports, islandContext } = ssrContext
|
||||
if (islandContext || !teleports) { return html }
|
||||
for (const key in teleports) {
|
||||
const match = key.match(SSR_TELEPORT_MARKER)
|
||||
if (!match) { continue }
|
||||
const [, uid, slot] = match
|
||||
if (!uid || !slot) { continue }
|
||||
html = html.replace(new RegExp(`<div nuxt-ssr-component-uid="${uid}"[^>]*>((?!nuxt-ssr-slot-name="${slot}"|nuxt-ssr-component-uid)[\\s\\S])*<div [^>]*nuxt-ssr-slot-name="${slot}"[^>]*>`), (full) => {
|
||||
return full + teleports[key]
|
||||
})
|
||||
}
|
||||
return html
|
||||
}
|
||||
|
@ -438,6 +438,9 @@ importers:
|
||||
ultrahtml:
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0
|
||||
uncrypto:
|
||||
specifier: ^0.1.2
|
||||
version: 0.1.2
|
||||
unctx:
|
||||
specifier: ^2.3.0
|
||||
version: 2.3.0
|
||||
|
@ -88,7 +88,7 @@ describe('pages', () => {
|
||||
// should apply attributes to client-only components
|
||||
expect(html).toContain('<div style="color:red;" class="client-only"></div>')
|
||||
// should render server-only components
|
||||
expect(html).toContain('<div class="server-only" style="background-color:gray;"> server-only component </div>')
|
||||
expect(html.replace(/ nuxt-ssr-component-uid="[^"]*"/, '')).toContain('<div class="server-only" style="background-color:gray;"> server-only component </div>')
|
||||
// should register global components automatically
|
||||
expect(html).toContain('global component registered automatically')
|
||||
expect(html).toContain('global component via suffix')
|
||||
@ -358,8 +358,14 @@ describe('pages', () => {
|
||||
await page.locator('#increase-pure-component').click()
|
||||
await page.waitForResponse(response => response.url().includes('/__nuxt_island/') && response.status() === 200)
|
||||
await page.waitForLoadState('networkidle')
|
||||
expect(await page.locator('#slot-in-server').first().innerHTML()).toContain('Slot with in .server component')
|
||||
expect(await page.locator('#test-slot').first().innerHTML()).toContain('Slot with name test')
|
||||
|
||||
// test fallback slot with v-for
|
||||
expect(await page.locator('.fallback-slot-content').all()).toHaveLength(2)
|
||||
// test islands update
|
||||
expect(await page.locator('.box').innerHTML()).toContain('"number": 101,')
|
||||
await page.locator('#count-async-server-long-async').click()
|
||||
await page.locator('#update-server-components').click()
|
||||
await Promise.all([
|
||||
page.waitForResponse(response => response.url().includes('/__nuxt_island/LongAsyncComponent') && response.status() === 200),
|
||||
page.waitForResponse(response => response.url().includes('/__nuxt_island/AsyncServerComponent') && response.status() === 200)
|
||||
@ -367,6 +373,17 @@ describe('pages', () => {
|
||||
await page.waitForLoadState('networkidle')
|
||||
expect(await page.locator('#async-server-component-count').innerHTML()).toContain(('1'))
|
||||
expect(await page.locator('#long-async-component-count').innerHTML()).toContain('1')
|
||||
|
||||
// test islands slots interactivity
|
||||
await page.locator('#first-sugar-counter button').click()
|
||||
expect(await page.locator('#first-sugar-counter').innerHTML()).toContain('Sugar Counter 13')
|
||||
|
||||
// test islands mounted client side with slot
|
||||
await page.locator('#show-island').click()
|
||||
await page.waitForResponse(response => response.url().includes('/__nuxt_island/') && response.status() === 200)
|
||||
await page.waitForLoadState('networkidle')
|
||||
expect(await page.locator('#island-mounted-client-side').innerHTML()).toContain('Interactive testing slot post SSR')
|
||||
|
||||
await page.close()
|
||||
})
|
||||
|
||||
@ -1219,7 +1236,7 @@ describe('component islands', () => {
|
||||
"link": [],
|
||||
"style": [],
|
||||
},
|
||||
"html": "<pre> Route: /foo
|
||||
"html": "<pre nuxt-ssr-component-uid> Route: /foo
|
||||
</pre>",
|
||||
"state": {},
|
||||
}
|
||||
@ -1241,7 +1258,7 @@ describe('component islands', () => {
|
||||
"link": [],
|
||||
"style": [],
|
||||
},
|
||||
"html": "<div>that was very long ... <div id=\\"long-async-component-count\\">3</div><p>hello world !!!</p></div>",
|
||||
"html": "<div nuxt-ssr-component-uid><div> count is above 2 </div><div style=\\"display:contents;\\" nuxt-ssr-slot-name=\\"default\\"></div> that was very long ... <div id=\\"long-async-component-count\\">3</div><div style=\\"display:contents;\\" nuxt-ssr-slot-name=\\"test\\" nuxt-ssr-slot-data=\\"[{"count":3}]\\"></div><p>hello world !!!</p><div style=\\"display:contents;\\" nuxt-ssr-slot-name=\\"hello\\" nuxt-ssr-slot-data=\\"[{"t":0},{"t":1},{"t":2}]\\"><div nuxt-slot-fallback-start=\\"hello\\"></div><!--[--><div style=\\"display:contents;\\"><div> fallback slot -- index: 0</div></div><div style=\\"display:contents;\\"><div> fallback slot -- index: 1</div></div><div style=\\"display:contents;\\"><div> fallback slot -- index: 2</div></div><!--]--><div nuxt-slot-fallback-end></div></div><div style=\\"display:contents;\\" nuxt-ssr-slot-name=\\"fallback\\" nuxt-ssr-slot-data=\\"[{"t":"fall"},{"t":"back"}]\\"><div nuxt-slot-fallback-start=\\"fallback\\"></div><!--[--><div style=\\"display:contents;\\"><div>fall slot -- index: 0</div><div class=\\"fallback-slot-content\\"> wonderful fallback </div></div><div style=\\"display:contents;\\"><div>back slot -- index: 1</div><div class=\\"fallback-slot-content\\"> wonderful fallback </div></div><!--]--><div nuxt-slot-fallback-end></div></div></div>",
|
||||
"state": {},
|
||||
}
|
||||
`)
|
||||
@ -1257,15 +1274,15 @@ describe('component islands', () => {
|
||||
result.head.link = result.head.link.filter(l => !l.href.includes('@nuxt+ui-templates') && (l.href.startsWith('_nuxt/components/islands/') && l.href.includes('_nuxt/components/islands/AsyncServerComponent')))
|
||||
}
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
{
|
||||
"head": {
|
||||
"link": [],
|
||||
"style": [],
|
||||
},
|
||||
"html": "<div> This is a .server (20ms) async component that was very long ... <div id=\\"async-server-component-count\\">2</div></div>",
|
||||
"state": {},
|
||||
}
|
||||
`)
|
||||
{
|
||||
"head": {
|
||||
"link": [],
|
||||
"style": [],
|
||||
},
|
||||
"html": "<div nuxt-ssr-component-uid> This is a .server (20ms) async component that was very long ... <div id=\\"async-server-component-count\\">2</div><div style=\\"display:contents;\\" nuxt-ssr-slot-name=\\"default\\"></div></div>",
|
||||
"state": {},
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('renders pure components', async () => {
|
||||
@ -1277,6 +1294,7 @@ describe('component islands', () => {
|
||||
obj: { foo: 42, bar: false, me: 'hi' }
|
||||
})
|
||||
}))
|
||||
result.html = result.html.replace(/ nuxt-ssr-component-uid="([^"]*)"/g, '')
|
||||
|
||||
if (isDev()) {
|
||||
result.head.link = result.head.link.filter(l => !l.href.includes('@nuxt+ui-templates'))
|
||||
@ -1304,7 +1322,7 @@ describe('component islands', () => {
|
||||
},
|
||||
],
|
||||
}
|
||||
`)
|
||||
`)
|
||||
} else if (isDev() && !isWebpack) {
|
||||
expect(result.head).toMatchInlineSnapshot(`
|
||||
{
|
||||
@ -1321,17 +1339,17 @@ describe('component islands', () => {
|
||||
}
|
||||
|
||||
expect(result.html.replace(/data-v-\w+|"|<!--.*-->/g, '')).toMatchInlineSnapshot(`
|
||||
"<div > Was router enabled: true <br > Props: <pre >{
|
||||
number: 3487,
|
||||
str: something,
|
||||
obj: {
|
||||
foo: 42,
|
||||
bar: false,
|
||||
me: hi
|
||||
},
|
||||
bool: false
|
||||
}</pre></div>"
|
||||
`)
|
||||
"<div nuxt-ssr-component-uid > Was router enabled: true <br > Props: <pre >{
|
||||
number: 3487,
|
||||
str: something,
|
||||
obj: {
|
||||
foo: 42,
|
||||
bar: false,
|
||||
me: hi
|
||||
},
|
||||
bool: false
|
||||
}</pre></div>"
|
||||
`)
|
||||
|
||||
expect(result.state).toMatchInlineSnapshot(`
|
||||
{
|
||||
@ -1339,6 +1357,33 @@ describe('component islands', () => {
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('test client-side navigation', async () => {
|
||||
const page = await createPage('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.click('#islands')
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.locator('#increase-pure-component').click()
|
||||
await page.waitForResponse(response => response.url().includes('/__nuxt_island/') && response.status() === 200)
|
||||
await page.waitForLoadState('networkidle')
|
||||
expect(await page.locator('#slot-in-server').first().innerHTML()).toContain('Slot with in .server component')
|
||||
expect(await page.locator('#test-slot').first().innerHTML()).toContain('Slot with name test')
|
||||
|
||||
// test islands update
|
||||
expect(await page.locator('.box').innerHTML()).toContain('"number": 101,')
|
||||
await page.locator('#update-server-components').click()
|
||||
await Promise.all([
|
||||
page.waitForResponse(response => response.url().includes('/__nuxt_island/LongAsyncComponent') && response.status() === 200),
|
||||
page.waitForResponse(response => response.url().includes('/__nuxt_island/AsyncServerComponent') && response.status() === 200)
|
||||
])
|
||||
await page.waitForLoadState('networkidle')
|
||||
expect(await page.locator('#async-server-component-count').innerHTML()).toContain(('1'))
|
||||
expect(await page.locator('#long-async-component-count').innerHTML()).toContain('1')
|
||||
|
||||
// test islands slots interactivity
|
||||
await page.locator('#first-sugar-counter button').click()
|
||||
expect(await page.locator('#first-sugar-counter').innerHTML()).toContain('Sugar Counter 13')
|
||||
})
|
||||
})
|
||||
|
||||
describe.runIf(isDev() && !isWebpack)('vite plugins', () => {
|
||||
@ -1357,17 +1402,6 @@ describe.skipIf(isDev() || isWindows || !isRenderingJson)('payload rendering', (
|
||||
const data = parsePayload(payload)
|
||||
expect(typeof data.prerenderedAt).toEqual('number')
|
||||
|
||||
const [_key, serverData] = Object.entries(data.data).find(([key]) => key.startsWith('ServerOnlyComponent'))!
|
||||
expect(serverData).toMatchInlineSnapshot(`
|
||||
{
|
||||
"head": {
|
||||
"link": [],
|
||||
"style": [],
|
||||
},
|
||||
"html": "<div> server-only component </div>",
|
||||
}
|
||||
`)
|
||||
|
||||
expect(data.data).toMatchObject({
|
||||
hey: {
|
||||
baz: 'qux',
|
||||
|
@ -5,6 +5,7 @@
|
||||
<div id="async-server-component-count">
|
||||
{{ count }}
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -1,10 +1,29 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="count > 2">
|
||||
count is above 2
|
||||
</div>
|
||||
<slot />
|
||||
{{ data }}
|
||||
<div id="long-async-component-count">
|
||||
{{ count }}
|
||||
</div>
|
||||
<slot name="test" :count="count" />
|
||||
<p>hello world !!!</p>
|
||||
<slot v-for="(t, index) in 3" name="hello" :t="t">
|
||||
<div :key="t">
|
||||
fallback slot -- index: {{ index }}
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
<slot v-for="(t, index) in ['fall', 'back']" name="fallback" :t="t">
|
||||
<div :key="t">
|
||||
{{ t }} slot -- index: {{ index }}
|
||||
</div>
|
||||
<div :key="t" class="fallback-slot-content">
|
||||
wonderful fallback
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -12,6 +31,5 @@
|
||||
defineProps<{
|
||||
count: number
|
||||
}>()
|
||||
|
||||
const { data } = await useFetch('/api/very-long-request')
|
||||
</script>
|
||||
|
3
test/fixtures/basic/pages/index.vue
vendored
3
test/fixtures/basic/pages/index.vue
vendored
@ -23,6 +23,9 @@
|
||||
<NuxtLink to="/">
|
||||
Link
|
||||
</NuxtLink>
|
||||
<NuxtLink id="islands" to="/islands">
|
||||
islands
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/chunk-error" :prefetch="false">
|
||||
Chunk error
|
||||
</NuxtLink>
|
||||
|
46
test/fixtures/basic/pages/islands.vue
vendored
46
test/fixtures/basic/pages/islands.vue
vendored
@ -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)
|
||||
</script>
|
||||
|
||||
@ -31,13 +32,46 @@ const count = ref(0)
|
||||
</button>
|
||||
|
||||
<p>async .server component</p>
|
||||
<AsyncServerComponent :count="count" />
|
||||
<AsyncServerComponent :count="count">
|
||||
<div id="slot-in-server">
|
||||
Slot with in .server component
|
||||
</div>
|
||||
</AsyncServerComponent>
|
||||
<div>
|
||||
Async island component (20ms):
|
||||
<NuxtIsland name="LongAsyncComponent" :props="{ count }" />
|
||||
<button id="count-async-server-long-async" @click="count++">
|
||||
add +1 to count
|
||||
Async component (1000ms):
|
||||
<div>
|
||||
<NuxtIsland name="LongAsyncComponent" :props="{ count }">
|
||||
<div>Interactive testing slot</div>
|
||||
<div id="first-sugar-counter">
|
||||
<SugarCounter :multiplier="testCount" />
|
||||
</div>
|
||||
<template #test="scoped">
|
||||
<div id="test-slot">
|
||||
Slot with name test - scoped data {{ scoped }}
|
||||
</div>
|
||||
</template>
|
||||
<template #hello="scoped">
|
||||
<div id="test-slot">
|
||||
Slot with name hello - scoped data {{ scoped }}
|
||||
</div>
|
||||
</template>
|
||||
</NuxtIsland>
|
||||
<button id="update-server-components" @click="count++">
|
||||
add +1 to count
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p>Island with props mounted client side</p>
|
||||
<button id="show-island" @click="showIslandSlot = true">
|
||||
Show Interactive island
|
||||
</button>
|
||||
<div id="island-mounted-client-side">
|
||||
<NuxtIsland v-if="showIslandSlot" name="LongAsyncComponent" :props="{ count }">
|
||||
<div>Interactive testing slot post SSR</div>
|
||||
<SugarCounter :multiplier="testCount" />
|
||||
</NuxtIsland>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
Loading…
Reference in New Issue
Block a user