diff --git a/docs/2.guide/2.directory-structure/1.components.md b/docs/2.guide/2.directory-structure/1.components.md index f4d5168f85..1d9859ab15 100644 --- a/docs/2.guide/2.directory-structure/1.components.md +++ b/docs/2.guide/2.directory-structure/1.components.md @@ -296,8 +296,8 @@ Now you can register server-only components with the `.server` suffix and use th ``` -::alert{type=warning} -Slots are not supported by server components in their current state of development. +::alert{type=info} +Slots can be interactive and are wrapped within a `
` with `display: contents;` :: ### Paired with a `.client` component diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index b0c76d0524..8554792ac3 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -91,6 +91,7 @@ "strip-literal": "^1.0.1", "ufo": "^1.1.2", "ultrahtml": "^1.2.0", + "uncrypto": "^0.1.2", "unctx": "^2.3.0", "unenv": "^1.4.1", "unimport": "^3.0.6", diff --git a/packages/nuxt/src/app/components/island-renderer.ts b/packages/nuxt/src/app/components/island-renderer.ts index bb4a8e9e65..07f5409803 100644 --- a/packages/nuxt/src/app/components/island-renderer.ts +++ b/packages/nuxt/src/app/components/island-renderer.ts @@ -21,6 +21,7 @@ export default defineComponent({ statusMessage: `Island component not found: ${JSON.stringify(component)}` }) } - return () => createVNode(component || 'span', props.context.props) + + return () => createVNode(component || 'span', { ...props.context.props, 'nuxt-ssr-component-uid': '' }) } }) diff --git a/packages/nuxt/src/app/components/nuxt-island.ts b/packages/nuxt/src/app/components/nuxt-island.ts index adfe5552ef..d3a077520f 100644 --- a/packages/nuxt/src/app/components/nuxt-island.ts +++ b/packages/nuxt/src/app/components/nuxt-island.ts @@ -1,16 +1,20 @@ -import type { RendererNode, Slots } from 'vue' -import { computed, createStaticVNode, defineComponent, getCurrentInstance, h, ref, watch } from 'vue' +import { Fragment, Teleport, computed, createStaticVNode, createVNode, defineComponent, getCurrentInstance, h, nextTick, onMounted, ref, watch } from 'vue' import { debounce } from 'perfect-debounce' import { hash } from 'ohash' import { appendResponseHeader } from 'h3' import { useHead } from '@unhead/vue' - +import { randomUUID } from 'uncrypto' // eslint-disable-next-line import/no-restricted-paths import type { NuxtIslandResponse } from '../../core/runtime/nitro/renderer' +import { getFragmentHTML, getSlotProps } from './utils' import { useNuxtApp } from '#app/nuxt' import { useRequestEvent } from '#app/composables/ssr' const pKey = '_islandPromises' +const SSR_UID_RE = /nuxt-ssr-component-uid="([^"]*)"/ +const UID_ATTR = /nuxt-ssr-component-uid(="([^"]*)")?/ +const SLOTNAME_RE = /nuxt-ssr-slot-name="([^"]*)"/g +const SLOT_FALLBACK_RE = /
]*><\/div>(((?!
]*>)[\s\S])*)
]*><\/div>/g export default defineComponent({ name: 'NuxtIsland', @@ -28,15 +32,37 @@ export default defineComponent({ default: () => ({}) } }, - async setup (props) { + async setup (props, { slots }) { const nuxtApp = useNuxtApp() const hashId = computed(() => hash([props.name, props.props, props.context])) const instance = getCurrentInstance()! const event = useRequestEvent() + const mounted = ref(false) + onMounted(() => { mounted.value = true }) + const ssrHTML = ref(process.client ? getFragmentHTML(instance.vnode?.el ?? null).join('') ?? '
' : '
') + const uid = ref(ssrHTML.value.match(SSR_UID_RE)?.[1] ?? randomUUID()) + const availableSlots = computed(() => { + return [...ssrHTML.value.matchAll(SLOTNAME_RE)].map(m => m[1]) + }) - const html = ref(process.client ? getFragmentHTML(instance?.vnode?.el).join('') ?? '
' : '
') + const html = computed(() => { + const currentSlots = Object.keys(slots) + return ssrHTML.value.replace(SLOT_FALLBACK_RE, (full, slotName, content) => { + // remove fallback to insert slots + if (currentSlots.includes(slotName)) { + return '' + } + return content + }) + }) + function setUid () { + uid.value = ssrHTML.value.match(SSR_UID_RE)?.[1] ?? randomUUID() as string + } const cHead = ref>>>({ link: [], style: [] }) useHead(cHead) + const slotProps = computed(() => { + return getSlotProps(ssrHTML.value) + }) function _fetchComponent () { const url = `/__nuxt_island/${props.name}:${hashId.value}` @@ -55,16 +81,23 @@ export default defineComponent({ const key = ref(0) async function fetchComponent () { nuxtApp[pKey] = nuxtApp[pKey] || {} - if (!nuxtApp[pKey][hashId.value]) { - nuxtApp[pKey][hashId.value] = _fetchComponent().finally(() => { - delete nuxtApp[pKey]![hashId.value] + if (!nuxtApp[pKey][uid.value]) { + nuxtApp[pKey][uid.value] = _fetchComponent().finally(() => { + delete nuxtApp[pKey]![uid.value] }) } - const res: NuxtIslandResponse = await nuxtApp[pKey][hashId.value] + const res: NuxtIslandResponse = await nuxtApp[pKey][uid.value] cHead.value.link = res.head.link cHead.value.style = res.head.style - html.value = res.html + ssrHTML.value = res.html.replace(UID_ATTR, () => { + return `nuxt-ssr-component-uid="${randomUUID()}"` + }) key.value++ + if (process.client) { + // must await next tick for Teleport to work correctly with static node re-rendering + await nextTick() + } + setUid() } if (process.client) { @@ -74,40 +107,21 @@ export default defineComponent({ if (process.server || !nuxtApp.isHydrating) { await fetchComponent() } - return () => h((_, { slots }) => (slots as Slots).default?.(), { key: key.value }, { - default: () => [createStaticVNode(html.value, 1)] - }) + + return () => { + const nodes = [createVNode(Fragment, { + key: key.value + }, [h(createStaticVNode(html.value, 1))])] + if (uid.value && (mounted.value || nuxtApp.isHydrating || process.server)) { + for (const slot in slots) { + if (availableSlots.value.includes(slot)) { + nodes.push(createVNode(Teleport, { to: process.client ? `[nuxt-ssr-component-uid='${uid.value}'] [nuxt-ssr-slot-name='${slot}']` : `uid=${uid.value};slot=${slot}` }, { + default: () => (slotProps.value[slot] ?? [undefined]).map((data: any) => slots[slot]?.(data)) + })) + } + } + } + return nodes + } } }) - -// TODO refactor with https://github.com/nuxt/nuxt/pull/19231 -function getFragmentHTML (element: RendererNode | null) { - if (element) { - if (element.nodeName === '#comment' && element.nodeValue === '[') { - return getFragmentChildren(element) - } - return [element.outerHTML] - } - return [] -} - -function getFragmentChildren (element: RendererNode | null, blocks: string[] = []) { - if (element && element.nodeName) { - if (isEndFragment(element)) { - return blocks - } else if (!isStartFragment(element)) { - blocks.push(element.outerHTML) - } - - getFragmentChildren(element.nextSibling, blocks) - } - return blocks -} - -function isStartFragment (element: RendererNode) { - return element.nodeName === '#comment' && element.nodeValue === '[' -} - -function isEndFragment (element: RendererNode) { - return element.nodeName === '#comment' && element.nodeValue === ']' -} diff --git a/packages/nuxt/src/app/components/utils.ts b/packages/nuxt/src/app/components/utils.ts index 8fe1ec3c2c..386a81f0c9 100644 --- a/packages/nuxt/src/app/components/utils.ts +++ b/packages/nuxt/src/app/components/utils.ts @@ -1,7 +1,8 @@ import { h } from 'vue' -import type { Component } from 'vue' +import type { Component, RendererNode } from 'vue' // eslint-disable-next-line -import { isString, isPromise, isArray } from '@vue/shared' +import { isString, isPromise, isArray, isObject } from '@vue/shared' +import destr from 'destr' /** * Internal utility @@ -44,3 +45,99 @@ export function createBuffer () { } } } + +const TRANSLATE_RE = /&(nbsp|amp|quot|lt|gt);/g +const NUMSTR_RE = /&#(\d+);/gi +export function decodeHtmlEntities (html: string) { + const translateDict = { + nbsp: ' ', + amp: '&', + quot: '"', + lt: '<', + gt: '>' + } as const + return html.replace(TRANSLATE_RE, function (_, entity: keyof typeof translateDict) { + return translateDict[entity] + }).replace(NUMSTR_RE, function (_, numStr: string) { + const num = parseInt(numStr, 10) + return String.fromCharCode(num) + }) +} + +/** + * helper for NuxtIsland to generate a correct array for scoped data + */ +export function vforToArray (source: any): any[] { + if (isArray(source)) { + return source + } else if (isString(source)) { + return source.split('') + } else if (typeof source === 'number') { + if (process.dev && !Number.isInteger(source)) { + console.warn(`The v-for range expect an integer value but got ${source}.`) + } + const array = [] + for (let i = 0; i < source; i++) { + array[i] = i + } + return array + } else if (isObject(source)) { + if (source[Symbol.iterator as any]) { + return Array.from(source as Iterable, item => + item + ) + } else { + const keys = Object.keys(source) + const array = new Array(keys.length) + for (let i = 0, l = keys.length; i < l; i++) { + const key = keys[i] + array[i] = source[key] + } + return array + } + } + return [] +} + +export function getFragmentHTML (element: RendererNode | null) { + if (element) { + if (element.nodeName === '#comment' && element.nodeValue === '[') { + return getFragmentChildren(element) + } + return [element.outerHTML] + } + return [] +} + +function getFragmentChildren (element: RendererNode | null, blocks: string[] = []) { + if (element && element.nodeName) { + if (isEndFragment(element)) { + return blocks + } else if (!isStartFragment(element)) { + blocks.push(element.outerHTML) + } + + getFragmentChildren(element.nextSibling, blocks) + } + return blocks +} + +function isStartFragment (element: RendererNode) { + return element.nodeName === '#comment' && element.nodeValue === '[' +} + +function isEndFragment (element: RendererNode) { + return element.nodeName === '#comment' && element.nodeValue === ']' +} +const SLOT_PROPS_RE = /]*nuxt-ssr-slot-name="([^"]*)" nuxt-ssr-slot-data="([^"]*)"[^/|>]*>/g + +export function getSlotProps (html: string) { + const slotsDivs = html.matchAll(SLOT_PROPS_RE) + const data:Record = {} + for (const slot of slotsDivs) { + const [_, slotName, json] = slot + const slotData = destr(decodeHtmlEntities(json)) + data[slotName] = slotData + } + return data +} diff --git a/packages/nuxt/src/components/islandsTransform.ts b/packages/nuxt/src/components/islandsTransform.ts new file mode 100644 index 0000000000..c6da0fff4e --- /dev/null +++ b/packages/nuxt/src/components/islandsTransform.ts @@ -0,0 +1,101 @@ +import { pathToFileURL } from 'node:url' +import type { Component } from '@nuxt/schema' +import { parseURL } from 'ufo' +import { createUnplugin } from 'unplugin' +import MagicString from 'magic-string' +import { ELEMENT_NODE, parse, walk } from 'ultrahtml' + +interface ServerOnlyComponentTransformPluginOptions { + getComponents: () => Component[] +} + +const SCRIPT_RE = /]*>/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('([\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, `
`) + } else { + s.overwrite(loc[0].start, loc[0].end, `
`) + s.overwrite(loc[1].start, loc[1].end, '
') + + if (children.length > 1) { + // need to wrap instead of applying v-for on each child + const wrapperTag = `
` + s.appendRight(loc[0].end, `
${wrapperTag}`) + s.appendLeft(loc[1].start, '
') + } 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, `
`) + s.appendLeft(loc[1].start, '
') + } + } + } + }) + + 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, 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})))"` + } +} diff --git a/packages/nuxt/src/components/module.ts b/packages/nuxt/src/components/module.ts index 4882bfc961..6450d1fe6b 100644 --- a/packages/nuxt/src/components/module.ts +++ b/packages/nuxt/src/components/module.ts @@ -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({ 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({ 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 + })) }) }) } diff --git a/packages/nuxt/src/components/runtime/server-component.ts b/packages/nuxt/src/components/runtime/server-component.ts index cdd9e69d47..cd48972051 100644 --- a/packages/nuxt/src/components/runtime/server-component.ts +++ b/packages/nuxt/src/components/runtime/server-component.ts @@ -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>(((?!
]*>)[\s\S])*)
]*><\/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 + } } }) diff --git a/packages/nuxt/src/core/runtime/nitro/renderer.ts b/packages/nuxt/src/core/runtime/nitro/renderer.ts index 068ea6fef0..ab47538628 100644 --- a/packages/nuxt/src/core/runtime/nitro/renderer.ts +++ b/packages/nuxt/src/core/runtime/nitro/renderer.ts @@ -154,7 +154,8 @@ async function getIslandContext (event: H3Event): Promise { ...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]*>((?!nuxt-ssr-slot-name="${slot}"|nuxt-ssr-component-uid)[\\s\\S])*
]*nuxt-ssr-slot-name="${slot}"[^>]*>`), (full) => { + return full + teleports[key] + }) + } + return html +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 23876b2306..c78844eee4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/test/basic.test.ts b/test/basic.test.ts index 0b69b0d026..c7ac6c52bc 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -88,7 +88,7 @@ describe('pages', () => { // should apply attributes to client-only components expect(html).toContain('
') // should render server-only components - expect(html).toContain('
server-only component
') + expect(html.replace(/ nuxt-ssr-component-uid="[^"]*"/, '')).toContain('
server-only component
') // 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": "
    Route: /foo
+        "html": "
    Route: /foo
         
", "state": {}, } @@ -1241,7 +1258,7 @@ describe('component islands', () => { "link": [], "style": [], }, - "html": "
that was very long ...
3

hello world !!!

", + "html": "
count is above 2
that was very long ...
3

hello world !!!

fallback slot -- index: 0
fallback slot -- index: 1
fallback slot -- index: 2
fall slot -- index: 0
wonderful fallback
back slot -- index: 1
wonderful fallback
", "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": "
This is a .server (20ms) async component that was very long ...
2
", - "state": {}, - } - `) + { + "head": { + "link": [], + "style": [], + }, + "html": "
This is a .server (20ms) async component that was very long ...
2
", + "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(` - "
Was router enabled: true
Props:
{
-      number: 3487,
-      str: something,
-      obj: {
-        foo: 42,
-        bar: false,
-        me: hi
-      },
-      bool: false
-    }
" - `) + "
Was router enabled: true
Props:
{
+        number: 3487,
+        str: something,
+        obj: {
+          foo: 42,
+          bar: false,
+          me: hi
+        },
+        bool: false
+      }
" + `) 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": "
server-only component
", - } - `) - expect(data.data).toMatchObject({ hey: { baz: 'qux', diff --git a/test/fixtures/basic/components/AsyncServerComponent.server.vue b/test/fixtures/basic/components/AsyncServerComponent.server.vue index 6bbee5b9ab..221224efc4 100644 --- a/test/fixtures/basic/components/AsyncServerComponent.server.vue +++ b/test/fixtures/basic/components/AsyncServerComponent.server.vue @@ -5,6 +5,7 @@
{{ count }}
+
diff --git a/test/fixtures/basic/components/islands/LongAsyncComponent.vue b/test/fixtures/basic/components/islands/LongAsyncComponent.vue index b5e9c0e985..59cefd9a04 100644 --- a/test/fixtures/basic/components/islands/LongAsyncComponent.vue +++ b/test/fixtures/basic/components/islands/LongAsyncComponent.vue @@ -1,10 +1,29 @@ @@ -12,6 +31,5 @@ defineProps<{ count: number }>() - const { data } = await useFetch('/api/very-long-request') diff --git a/test/fixtures/basic/pages/index.vue b/test/fixtures/basic/pages/index.vue index 2b31da6380..c971c966d7 100644 --- a/test/fixtures/basic/pages/index.vue +++ b/test/fixtures/basic/pages/index.vue @@ -23,6 +23,9 @@ Link + + islands + Chunk error diff --git a/test/fixtures/basic/pages/islands.vue b/test/fixtures/basic/pages/islands.vue index 3e6051e454..df8d35af54 100644 --- a/test/fixtures/basic/pages/islands.vue +++ b/test/fixtures/basic/pages/islands.vue @@ -6,8 +6,9 @@ const islandProps = ref({ obj: { json: 'works' } }) +const showIslandSlot = ref(false) const routeIslandVisible = ref(false) - +const testCount = ref(0) const count = ref(0) @@ -31,13 +32,46 @@ const count = ref(0)

async .server component

- + +
+ Slot with in .server component +
+
- Async island component (20ms): - - +
+
+
+

Island with props mounted client side

+ +
+ +
Interactive testing slot post SSR
+ +
+