mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-29 09:02:03 +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>
|
</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
|
||||||
|
@ -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",
|
||||||
|
@ -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': '' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -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 === ']'
|
|
||||||
}
|
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
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 { 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
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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=\\"[{"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": {},
|
"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',
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
3
test/fixtures/basic/pages/index.vue
vendored
3
test/fixtures/basic/pages/index.vue
vendored
@ -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>
|
||||||
|
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' }
|
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>
|
||||||
|
Loading…
Reference in New Issue
Block a user