feat(nuxt): full scoped slots support for server components (#20284)

This commit is contained in:
Julien Huang 2023-05-16 00:43:53 +02:00 committed by GitHub
parent 1aec0e5039
commit 70c5ec86d2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 490 additions and 100 deletions

View File

@ -296,8 +296,8 @@ Now you can register server-only components with the `.server` suffix and use th
</template> </template>
``` ```
::alert{type=warning} ::alert{type=info}
Slots are not supported by server components in their current state of development. Slots can be interactive and are wrapped within a `<div>` with `display: contents;`
:: ::
### Paired with a `.client` component ### Paired with a `.client` component

View File

@ -91,6 +91,7 @@
"strip-literal": "^1.0.1", "strip-literal": "^1.0.1",
"ufo": "^1.1.2", "ufo": "^1.1.2",
"ultrahtml": "^1.2.0", "ultrahtml": "^1.2.0",
"uncrypto": "^0.1.2",
"unctx": "^2.3.0", "unctx": "^2.3.0",
"unenv": "^1.4.1", "unenv": "^1.4.1",
"unimport": "^3.0.6", "unimport": "^3.0.6",

View File

@ -21,6 +21,7 @@ export default defineComponent({
statusMessage: `Island component not found: ${JSON.stringify(component)}` 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': '' })
} }
}) })

View File

@ -1,16 +1,20 @@
import type { RendererNode, Slots } from 'vue' import { Fragment, Teleport, computed, createStaticVNode, createVNode, defineComponent, getCurrentInstance, h, nextTick, onMounted, ref, watch } from 'vue'
import { computed, createStaticVNode, defineComponent, getCurrentInstance, h, ref, watch } from 'vue'
import { debounce } from 'perfect-debounce' import { debounce } from 'perfect-debounce'
import { hash } from 'ohash' import { hash } from 'ohash'
import { appendResponseHeader } from 'h3' import { appendResponseHeader } from 'h3'
import { useHead } from '@unhead/vue' import { useHead } from '@unhead/vue'
import { randomUUID } from 'uncrypto'
// eslint-disable-next-line import/no-restricted-paths // eslint-disable-next-line import/no-restricted-paths
import type { NuxtIslandResponse } from '../../core/runtime/nitro/renderer' import type { NuxtIslandResponse } from '../../core/runtime/nitro/renderer'
import { getFragmentHTML, getSlotProps } from './utils'
import { useNuxtApp } from '#app/nuxt' import { useNuxtApp } from '#app/nuxt'
import { useRequestEvent } from '#app/composables/ssr' import { useRequestEvent } from '#app/composables/ssr'
const pKey = '_islandPromises' 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({ export default defineComponent({
name: 'NuxtIsland', name: 'NuxtIsland',
@ -28,15 +32,37 @@ export default defineComponent({
default: () => ({}) default: () => ({})
} }
}, },
async setup (props) { async setup (props, { slots }) {
const nuxtApp = useNuxtApp() const nuxtApp = useNuxtApp()
const hashId = computed(() => hash([props.name, props.props, props.context])) const hashId = computed(() => hash([props.name, props.props, props.context]))
const instance = getCurrentInstance()! const instance = getCurrentInstance()!
const event = useRequestEvent() 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: [] }) const cHead = ref<Record<'link' | 'style', Array<Record<string, string>>>>({ link: [], style: [] })
useHead(cHead) useHead(cHead)
const slotProps = computed(() => {
return getSlotProps(ssrHTML.value)
})
function _fetchComponent () { function _fetchComponent () {
const url = `/__nuxt_island/${props.name}:${hashId.value}` const url = `/__nuxt_island/${props.name}:${hashId.value}`
@ -55,16 +81,23 @@ export default defineComponent({
const key = ref(0) const key = ref(0)
async function fetchComponent () { async function fetchComponent () {
nuxtApp[pKey] = nuxtApp[pKey] || {} nuxtApp[pKey] = nuxtApp[pKey] || {}
if (!nuxtApp[pKey][hashId.value]) { if (!nuxtApp[pKey][uid.value]) {
nuxtApp[pKey][hashId.value] = _fetchComponent().finally(() => { nuxtApp[pKey][uid.value] = _fetchComponent().finally(() => {
delete nuxtApp[pKey]![hashId.value] 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.link = res.head.link
cHead.value.style = res.head.style 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++ 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) { if (process.client) {
@ -74,40 +107,21 @@ export default defineComponent({
if (process.server || !nuxtApp.isHydrating) { if (process.server || !nuxtApp.isHydrating) {
await fetchComponent() 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 === ']'
}

View File

@ -1,7 +1,8 @@
import { h } from 'vue' import { h } from 'vue'
import type { Component } from 'vue' import type { Component, RendererNode } from 'vue'
// eslint-disable-next-line // 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 * 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
}

View 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})))"`
}
}

View File

@ -9,6 +9,7 @@ import { componentNamesTemplate, componentsIslandsTemplate, componentsPluginTemp
import { scanComponents } from './scan' import { scanComponents } from './scan'
import { loaderPlugin } from './loader' import { loaderPlugin } from './loader'
import { TreeShakeTemplatePlugin } from './tree-shake' import { TreeShakeTemplatePlugin } from './tree-shake'
import { islandsTransform } from './islandsTransform'
import { createTransformPlugin } from './transform' import { createTransformPlugin } from './transform'
const isPureObjectOrString = (val: any) => (!Array.isArray(val) && typeof val === 'object') || typeof val === 'string' 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, transform: typeof nuxt.options.components === 'object' && !Array.isArray(nuxt.options.components) ? nuxt.options.components.transform : undefined,
experimentalComponentIslands: nuxt.options.experimental.componentIslands experimentalComponentIslands: nuxt.options.experimental.componentIslands
})) }))
config.plugins.push(islandsTransform.vite({
getComponents
}))
}) })
nuxt.hook('webpack:config', (configs) => { nuxt.hook('webpack:config', (configs) => {
configs.forEach((config) => { 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, transform: typeof nuxt.options.components === 'object' && !Array.isArray(nuxt.options.components) ? nuxt.options.components.transform : undefined,
experimentalComponentIslands: nuxt.options.experimental.componentIslands experimentalComponentIslands: nuxt.options.experimental.componentIslands
})) }))
config.plugins.push(islandsTransform.webpack({
getComponents
}))
}) })
}) })
} }

View File

@ -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 { debounce } from 'perfect-debounce'
import { hash } from 'ohash' import { hash } from 'ohash'
import { appendResponseHeader } from 'h3' import { appendResponseHeader } from 'h3'
import { useHead } from '@unhead/vue' import { useHead } from '@unhead/vue'
import { randomUUID } from 'uncrypto'
import type { NuxtIslandResponse } from '../../core/runtime/nitro/renderer' import type { NuxtIslandResponse } from '../../core/runtime/nitro/renderer'
import { useNuxtApp } from '#app/nuxt' import { useNuxtApp } from '#app/nuxt'
import { useRequestEvent } from '#app/composables/ssr' import { useRequestEvent } from '#app/composables/ssr'
import { useAsyncData } from '#app/composables/asyncData' import { useAsyncData } from '#app/composables/asyncData'
import { getFragmentHTML, getSlotProps } from '#app/components/utils'
const pKey = '_islandPromises' 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) => { export const createServerComponent = (name: string) => {
return defineComponent({ return defineComponent({
name, name,
inheritAttrs: false, inheritAttrs: false,
setup (_props, { attrs }) { setup (_props, { attrs, slots }) {
return () => h(NuxtServerComponent, { return () => h(NuxtServerComponent, {
name, name,
props: attrs props: attrs
}) }, slots)
} }
}) })
} }
@ -40,9 +46,14 @@ const NuxtServerComponent = defineComponent({
default: () => ({}) 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 nuxtApp = useNuxtApp()
const mounted = ref(false)
const key = ref(0) const key = ref(0)
onMounted(() => { mounted.value = true })
const hashId = computed(() => hash([props.name, props.props, props.context])) const hashId = computed(() => hash([props.name, props.props, props.context]))
const event = useRequestEvent() const event = useRequestEvent()
@ -96,11 +107,57 @@ const NuxtServerComponent = defineComponent({
watch(props, debounce(async () => { watch(props, debounce(async () => {
await res.execute() await res.execute()
key.value++ key.value++
if (process.client) {
// must await next tick for Teleport to work correctly with static node re-rendering
await nextTick()
}
setUid()
}, 100)) }, 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 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
}
} }
}) })

View File

@ -154,7 +154,8 @@ async function getIslandContext (event: H3Event): Promise<NuxtIslandContext> {
...context, ...context,
id: hashId, id: hashId,
name: componentName, name: componentName,
props: destr(context.props) || {} props: destr(context.props) || {},
uid: destr(context.uid) || undefined
} }
return ctx return ctx
@ -309,7 +310,7 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse
renderedMeta.bodyScriptsPrepend, renderedMeta.bodyScriptsPrepend,
ssrContext.teleports?.body ssrContext.teleports?.body
]), ]),
body: [_rendered.html], body: [process.env.NUXT_COMPONENT_ISLANDS ? replaceServerOnlyComponentsSlots(ssrContext, _rendered.html) : _rendered.html],
bodyAppend: normalizeChunks([ bodyAppend: normalizeChunks([
NO_SCRIPTS NO_SCRIPTS
? undefined ? undefined
@ -491,3 +492,19 @@ function getServerComponentHTML (body: string[]): string {
const match = body[0].match(ROOT_NODE_REGEX) const match = body[0].match(ROOT_NODE_REGEX)
return match ? match[1] : body[0] 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
}

View File

@ -438,6 +438,9 @@ importers:
ultrahtml: ultrahtml:
specifier: ^1.2.0 specifier: ^1.2.0
version: 1.2.0 version: 1.2.0
uncrypto:
specifier: ^0.1.2
version: 0.1.2
unctx: unctx:
specifier: ^2.3.0 specifier: ^2.3.0
version: 2.3.0 version: 2.3.0

View File

@ -88,7 +88,7 @@ describe('pages', () => {
// should apply attributes to client-only components // should apply attributes to client-only components
expect(html).toContain('<div style="color:red;" class="client-only"></div>') expect(html).toContain('<div style="color:red;" class="client-only"></div>')
// should render server-only components // 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 // should register global components automatically
expect(html).toContain('global component registered automatically') expect(html).toContain('global component registered automatically')
expect(html).toContain('global component via suffix') expect(html).toContain('global component via suffix')
@ -358,8 +358,14 @@ describe('pages', () => {
await page.locator('#increase-pure-component').click() await page.locator('#increase-pure-component').click()
await page.waitForResponse(response => response.url().includes('/__nuxt_island/') && response.status() === 200) await page.waitForResponse(response => response.url().includes('/__nuxt_island/') && response.status() === 200)
await page.waitForLoadState('networkidle') 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,') 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([ await Promise.all([
page.waitForResponse(response => response.url().includes('/__nuxt_island/LongAsyncComponent') && response.status() === 200), page.waitForResponse(response => response.url().includes('/__nuxt_island/LongAsyncComponent') && response.status() === 200),
page.waitForResponse(response => response.url().includes('/__nuxt_island/AsyncServerComponent') && 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') await page.waitForLoadState('networkidle')
expect(await page.locator('#async-server-component-count').innerHTML()).toContain(('1')) expect(await page.locator('#async-server-component-count').innerHTML()).toContain(('1'))
expect(await page.locator('#long-async-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() await page.close()
}) })
@ -1219,7 +1236,7 @@ describe('component islands', () => {
"link": [], "link": [],
"style": [], "style": [],
}, },
"html": "<pre> Route: /foo "html": "<pre nuxt-ssr-component-uid> Route: /foo
</pre>", </pre>",
"state": {}, "state": {},
} }
@ -1241,7 +1258,7 @@ describe('component islands', () => {
"link": [], "link": [],
"style": [], "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=\\"[{&quot;count&quot;:3}]\\"></div><p>hello world !!!</p><div style=\\"display:contents;\\" nuxt-ssr-slot-name=\\"hello\\" nuxt-ssr-slot-data=\\"[{&quot;t&quot;:0},{&quot;t&quot;:1},{&quot;t&quot;: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=\\"[{&quot;t&quot;:&quot;fall&quot;},{&quot;t&quot;:&quot;back&quot;}]\\"><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": {}, "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'))) 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(` expect(result).toMatchInlineSnapshot(`
{ {
"head": { "head": {
"link": [], "link": [],
"style": [], "style": [],
}, },
"html": "<div> This is a .server (20ms) async component that was very long ... <div id=\\"async-server-component-count\\">2</div></div>", "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": {}, "state": {},
} }
`) `)
}) })
it('renders pure components', async () => { it('renders pure components', async () => {
@ -1277,6 +1294,7 @@ describe('component islands', () => {
obj: { foo: 42, bar: false, me: 'hi' } obj: { foo: 42, bar: false, me: 'hi' }
}) })
})) }))
result.html = result.html.replace(/ nuxt-ssr-component-uid="([^"]*)"/g, '')
if (isDev()) { if (isDev()) {
result.head.link = result.head.link.filter(l => !l.href.includes('@nuxt+ui-templates')) 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) { } else if (isDev() && !isWebpack) {
expect(result.head).toMatchInlineSnapshot(` expect(result.head).toMatchInlineSnapshot(`
{ {
@ -1321,17 +1339,17 @@ describe('component islands', () => {
} }
expect(result.html.replace(/data-v-\w+|"|<!--.*-->/g, '')).toMatchInlineSnapshot(` expect(result.html.replace(/data-v-\w+|"|<!--.*-->/g, '')).toMatchInlineSnapshot(`
"<div > Was router enabled: true <br > Props: <pre >{ "<div nuxt-ssr-component-uid > Was router enabled: true <br > Props: <pre >{
number: 3487, number: 3487,
str: something, str: something,
obj: { obj: {
foo: 42, foo: 42,
bar: false, bar: false,
me: hi me: hi
}, },
bool: false bool: false
}</pre></div>" }</pre></div>"
`) `)
expect(result.state).toMatchInlineSnapshot(` 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', () => { describe.runIf(isDev() && !isWebpack)('vite plugins', () => {
@ -1357,17 +1402,6 @@ describe.skipIf(isDev() || isWindows || !isRenderingJson)('payload rendering', (
const data = parsePayload(payload) const data = parsePayload(payload)
expect(typeof data.prerenderedAt).toEqual('number') 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({ expect(data.data).toMatchObject({
hey: { hey: {
baz: 'qux', baz: 'qux',

View File

@ -5,6 +5,7 @@
<div id="async-server-component-count"> <div id="async-server-component-count">
{{ count }} {{ count }}
</div> </div>
<slot />
</div> </div>
</template> </template>

View File

@ -1,10 +1,29 @@
<template> <template>
<div> <div>
<div v-if="count > 2">
count is above 2
</div>
<slot />
{{ data }} {{ data }}
<div id="long-async-component-count"> <div id="long-async-component-count">
{{ count }} {{ count }}
</div> </div>
<slot name="test" :count="count" />
<p>hello world !!!</p> <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> </div>
</template> </template>
@ -12,6 +31,5 @@
defineProps<{ defineProps<{
count: number count: number
}>() }>()
const { data } = await useFetch('/api/very-long-request') const { data } = await useFetch('/api/very-long-request')
</script> </script>

View File

@ -23,6 +23,9 @@
<NuxtLink to="/"> <NuxtLink to="/">
Link Link
</NuxtLink> </NuxtLink>
<NuxtLink id="islands" to="/islands">
islands
</NuxtLink>
<NuxtLink to="/chunk-error" :prefetch="false"> <NuxtLink to="/chunk-error" :prefetch="false">
Chunk error Chunk error
</NuxtLink> </NuxtLink>

View File

@ -6,8 +6,9 @@ const islandProps = ref({
obj: { json: 'works' } obj: { json: 'works' }
}) })
const showIslandSlot = ref(false)
const routeIslandVisible = ref(false) const routeIslandVisible = ref(false)
const testCount = ref(0)
const count = ref(0) const count = ref(0)
</script> </script>
@ -31,13 +32,46 @@ const count = ref(0)
</button> </button>
<p>async .server component</p> <p>async .server component</p>
<AsyncServerComponent :count="count" /> <AsyncServerComponent :count="count">
<div id="slot-in-server">
Slot with in .server component
</div>
</AsyncServerComponent>
<div> <div>
Async island component (20ms): Async component (1000ms):
<NuxtIsland name="LongAsyncComponent" :props="{ count }" /> <div>
<button id="count-async-server-long-async" @click="count++"> <NuxtIsland name="LongAsyncComponent" :props="{ count }">
add +1 to 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> </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>
</div> </div>
</template> </template>