mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-22 13:45:18 +00:00
feat(nuxt): allow client components within NuxtIsland
(#22649)
This commit is contained in:
parent
802b3e28c1
commit
1b93e604d3
@ -295,6 +295,28 @@ Now you can register server-only components with the `.server` suffix and use th
|
|||||||
|
|
||||||
Server-only components use [`<NuxtIsland>`](/docs/api/components/nuxt-island) under the hood, meaning that `lazy` prop and `#fallback` slot are both passed down to it.
|
Server-only components use [`<NuxtIsland>`](/docs/api/components/nuxt-island) under the hood, meaning that `lazy` prop and `#fallback` slot are both passed down to it.
|
||||||
|
|
||||||
|
#### Client components within server components
|
||||||
|
|
||||||
|
::alert{type=info}
|
||||||
|
This feature needs `experimental.componentIslands.selectiveClient` within your configuration to be true.
|
||||||
|
::
|
||||||
|
|
||||||
|
You can partially hydrate a component by setting a `nuxt-client` attribute on the component you wish to be loaded client-side.
|
||||||
|
|
||||||
|
```html [components/ServerWithClient.vue]
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<HighlightedMarkdown markdown="# Headline" />
|
||||||
|
<!-- Counter will be loaded and hydrated client-side -->
|
||||||
|
<Counter nuxt-client :count="5" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
::alert{type=info}
|
||||||
|
This only works within a server component.
|
||||||
|
::
|
||||||
|
|
||||||
#### Server Component Context
|
#### Server Component Context
|
||||||
|
|
||||||
When rendering a server-only or island component, `<NuxtIsland>` makes a fetch request which comes back with a `NuxtIslandResponse`. (This is an internal request if rendered on the server, or a request that you can see in the network tab if it's rendering on client-side navigation.)
|
When rendering a server-only or island component, `<NuxtIsland>` makes a fetch request which comes back with a `NuxtIslandResponse`. (This is an internal request if rendered on the server, or a request that you can see in the network tab if it's rendering on client-side navigation.)
|
||||||
|
@ -36,9 +36,13 @@ Server only components use `<NuxtIsland>` under the hood
|
|||||||
- **type**: `Record<string, any>`
|
- **type**: `Record<string, any>`
|
||||||
- `source`: Remote source to call the island to render.
|
- `source`: Remote source to call the island to render.
|
||||||
- **type**: `string`
|
- **type**: `string`
|
||||||
|
- **dangerouslyLoadClientComponents**: Required to load components from a remote source.
|
||||||
|
- **type**: `boolean`
|
||||||
|
- **default**: `false`
|
||||||
|
|
||||||
::callout{color="blue" icon="i-ph-info-duotone"}
|
::callout{color="blue" icon="i-ph-info-duotone"}
|
||||||
Remote islands need `experimental.componentIslands` to be `'local+remote'` in your `nuxt.config`.
|
Remote islands need `experimental.componentIslands` to be `'local+remote'` in your `nuxt.config`.
|
||||||
|
It is strongly discouraged to enable `dangerouslyLoadClientComponents` as you can't trust a remote server's javascript.
|
||||||
::
|
::
|
||||||
|
|
||||||
## Slots
|
## Slots
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { Fragment, Teleport, computed, createStaticVNode, createVNode, defineComponent, getCurrentInstance, h, nextTick, onMounted, ref, watch } from 'vue'
|
import type { Component } from 'vue'
|
||||||
|
import { Fragment, Teleport, computed, createStaticVNode, createVNode, defineComponent, getCurrentInstance, h, nextTick, onMounted, ref, toRaw, 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'
|
||||||
@ -6,6 +7,7 @@ import { useHead } from '@unhead/vue'
|
|||||||
import { randomUUID } from 'uncrypto'
|
import { randomUUID } from 'uncrypto'
|
||||||
import { joinURL, withQuery } from 'ufo'
|
import { joinURL, withQuery } from 'ufo'
|
||||||
import type { FetchResponse } from 'ofetch'
|
import type { FetchResponse } from 'ofetch'
|
||||||
|
import { join } from 'pathe'
|
||||||
|
|
||||||
// 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'
|
||||||
@ -14,7 +16,7 @@ import { prerenderRoutes, useRequestEvent } from '../composables/ssr'
|
|||||||
import { getFragmentHTML, getSlotProps } from './utils'
|
import { getFragmentHTML, getSlotProps } from './utils'
|
||||||
|
|
||||||
// @ts-expect-error virtual file
|
// @ts-expect-error virtual file
|
||||||
import { remoteComponentIslands } from '#build/nuxt.config.mjs'
|
import { remoteComponentIslands, selectiveClient } from '#build/nuxt.config.mjs'
|
||||||
|
|
||||||
const pKey = '_islandPromises'
|
const pKey = '_islandPromises'
|
||||||
const SSR_UID_RE = /nuxt-ssr-component-uid="([^"]*)"/
|
const SSR_UID_RE = /nuxt-ssr-component-uid="([^"]*)"/
|
||||||
@ -25,6 +27,31 @@ const SLOT_FALLBACK_RE = /<div nuxt-slot-fallback-start="([^"]*)"[^>]*><\/div>((
|
|||||||
let id = 0
|
let id = 0
|
||||||
const getId = import.meta.client ? () => (id++).toString() : randomUUID
|
const getId = import.meta.client ? () => (id++).toString() : randomUUID
|
||||||
|
|
||||||
|
const components = import.meta.client ? new Map<string, Component>() : undefined
|
||||||
|
|
||||||
|
async function loadComponents (source = '/', paths: Record<string, string>) {
|
||||||
|
const promises = []
|
||||||
|
|
||||||
|
for (const component in paths) {
|
||||||
|
if (!(components!.has(component))) {
|
||||||
|
promises.push((async () => {
|
||||||
|
const chunkSource = join(source, paths[component])
|
||||||
|
const c = await import(/* @vite-ignore */ chunkSource).then(m => m.default || m)
|
||||||
|
components!.set(component, c)
|
||||||
|
})())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(promises)
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyPayload () {
|
||||||
|
return {
|
||||||
|
chunks: {},
|
||||||
|
props: {},
|
||||||
|
teleports: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'NuxtIsland',
|
name: 'NuxtIsland',
|
||||||
props: {
|
props: {
|
||||||
@ -44,9 +71,15 @@ export default defineComponent({
|
|||||||
source: {
|
source: {
|
||||||
type: String,
|
type: String,
|
||||||
default: () => undefined
|
default: () => undefined
|
||||||
|
},
|
||||||
|
dangerouslyLoadClientComponents: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async setup (props, { slots, expose }) {
|
async setup (props, { slots, expose }) {
|
||||||
|
const key = ref(0)
|
||||||
|
const canLoadClientComponent = computed(() => selectiveClient && (props.dangerouslyLoadClientComponents || !props.source))
|
||||||
const error = ref<unknown>(null)
|
const error = ref<unknown>(null)
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const nuxtApp = useNuxtApp()
|
const nuxtApp = useNuxtApp()
|
||||||
@ -54,6 +87,7 @@ export default defineComponent({
|
|||||||
const hashId = computed(() => hash([props.name, filteredProps.value, props.context, props.source]))
|
const hashId = computed(() => hash([props.name, filteredProps.value, props.context, props.source]))
|
||||||
const instance = getCurrentInstance()!
|
const instance = getCurrentInstance()!
|
||||||
const event = useRequestEvent()
|
const event = useRequestEvent()
|
||||||
|
|
||||||
// TODO: remove use of `$fetch.raw` when nitro 503 issues on windows dev server are resolved
|
// TODO: remove use of `$fetch.raw` when nitro 503 issues on windows dev server are resolved
|
||||||
const eventFetch = import.meta.server ? event.fetch : import.meta.dev ? $fetch.raw : globalThis.fetch
|
const eventFetch = import.meta.server ? event.fetch : import.meta.dev ? $fetch.raw : globalThis.fetch
|
||||||
const mounted = ref(false)
|
const mounted = ref(false)
|
||||||
@ -65,34 +99,50 @@ export default defineComponent({
|
|||||||
key,
|
key,
|
||||||
...(import.meta.server && import.meta.prerender)
|
...(import.meta.server && import.meta.prerender)
|
||||||
? {}
|
? {}
|
||||||
: { params: { ...props.context, props: props.props ? JSON.stringify(props.props) : undefined } }
|
: { params: { ...props.context, props: props.props ? JSON.stringify(props.props) : undefined } },
|
||||||
|
result: {
|
||||||
|
chunks: result.chunks,
|
||||||
|
props: result.props,
|
||||||
|
teleports: result.teleports
|
||||||
|
}
|
||||||
},
|
},
|
||||||
...result
|
...result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// needs to be non-reactive because we don't want to trigger re-renders
|
||||||
|
// at hydration, we only retrieve props/chunks/teleports from payload. See the reviver at nuxt\src\app\plugins\revive-payload.client.ts
|
||||||
|
// If not hydrating, fetchComponent() will set it
|
||||||
|
const rawPayload = nuxtApp.isHydrating ? toRaw(nuxtApp.payload.data)?.[`${props.name}_${hashId.value}`] ?? emptyPayload() : emptyPayload()
|
||||||
|
|
||||||
|
const nonReactivePayload: Pick<NuxtIslandResponse, 'chunks'| 'props' | 'teleports'> = {
|
||||||
|
chunks: rawPayload.chunks,
|
||||||
|
props: rawPayload.props,
|
||||||
|
teleports: rawPayload.teleports
|
||||||
|
}
|
||||||
|
|
||||||
const ssrHTML = ref<string>('')
|
const ssrHTML = ref<string>('')
|
||||||
|
|
||||||
if (import.meta.client) {
|
if (import.meta.client) {
|
||||||
const renderedHTML = getFragmentHTML(instance.vnode?.el ?? null)?.join('') ?? ''
|
ssrHTML.value = getFragmentHTML(instance.vnode?.el ?? null, true)?.join('') || ''
|
||||||
if (renderedHTML && nuxtApp.isHydrating) {
|
|
||||||
setPayload(`${props.name}_${hashId.value}`, {
|
|
||||||
html: getFragmentHTML(instance.vnode?.el ?? null, true)?.join('') ?? '',
|
|
||||||
state: {},
|
|
||||||
head: {
|
|
||||||
link: [],
|
|
||||||
style: []
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
ssrHTML.value = renderedHTML
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const slotProps = computed(() => getSlotProps(ssrHTML.value))
|
const slotProps = computed(() => getSlotProps(ssrHTML.value))
|
||||||
const uid = ref<string>(ssrHTML.value.match(SSR_UID_RE)?.[1] ?? getId())
|
const uid = ref<string>(ssrHTML.value.match(SSR_UID_RE)?.[1] ?? getId())
|
||||||
const availableSlots = computed(() => [...ssrHTML.value.matchAll(SLOTNAME_RE)].map(m => m[1]))
|
const availableSlots = computed(() => [...ssrHTML.value.matchAll(SLOTNAME_RE)].map(m => m[1]))
|
||||||
|
|
||||||
const html = computed(() => {
|
const html = computed(() => {
|
||||||
const currentSlots = Object.keys(slots)
|
const currentSlots = Object.keys(slots)
|
||||||
return ssrHTML.value.replace(SLOT_FALLBACK_RE, (full, slotName, content) => {
|
let html = ssrHTML.value
|
||||||
|
|
||||||
|
if (import.meta.client && !canLoadClientComponent.value) {
|
||||||
|
for (const [key, value] of Object.entries(nonReactivePayload.teleports || {})) {
|
||||||
|
html = html.replace(new RegExp(`<div [^>]*nuxt-ssr-client="${key}"[^>]*>`), (full) => {
|
||||||
|
return full + value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return html.replace(SLOT_FALLBACK_RE, (full, slotName, content) => {
|
||||||
// remove fallback to insert slots
|
// remove fallback to insert slots
|
||||||
if (currentSlots.includes(slotName)) {
|
if (currentSlots.includes(slotName)) {
|
||||||
return ''
|
return ''
|
||||||
@ -100,15 +150,18 @@ export default defineComponent({
|
|||||||
return content
|
return content
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
function setUid () {
|
function setUid () {
|
||||||
uid.value = ssrHTML.value.match(SSR_UID_RE)?.[1] ?? getId() as string
|
uid.value = ssrHTML.value.match(SSR_UID_RE)?.[1] ?? getId() 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)
|
||||||
|
|
||||||
async function _fetchComponent (force = false) {
|
async function _fetchComponent (force = false) {
|
||||||
const key = `${props.name}_${hashId.value}`
|
const key = `${props.name}_${hashId.value}`
|
||||||
if (nuxtApp.payload.data[key] && !force) { return nuxtApp.payload.data[key] }
|
|
||||||
|
if (nuxtApp.payload.data[key]?.html && !force) { return nuxtApp.payload.data[key] }
|
||||||
|
|
||||||
const url = remoteComponentIslands && props.source ? new URL(`/__nuxt_island/${key}.json`, props.source).href : `/__nuxt_island/${key}.json`
|
const url = remoteComponentIslands && props.source ? new URL(`/__nuxt_island/${key}.json`, props.source).href : `/__nuxt_island/${key}.json`
|
||||||
|
|
||||||
@ -133,7 +186,7 @@ export default defineComponent({
|
|||||||
setPayload(key, result)
|
setPayload(key, result)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
const key = ref(0)
|
|
||||||
async function fetchComponent (force = false) {
|
async function fetchComponent (force = false) {
|
||||||
nuxtApp[pKey] = nuxtApp[pKey] || {}
|
nuxtApp[pKey] = nuxtApp[pKey] || {}
|
||||||
if (!nuxtApp[pKey][uid.value]) {
|
if (!nuxtApp[pKey][uid.value]) {
|
||||||
@ -150,6 +203,16 @@ export default defineComponent({
|
|||||||
})
|
})
|
||||||
key.value++
|
key.value++
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
|
if (selectiveClient && import.meta.client) {
|
||||||
|
if (canLoadClientComponent.value && res.chunks) {
|
||||||
|
await loadComponents(props.source, res.chunks)
|
||||||
|
}
|
||||||
|
nonReactivePayload.props = res.props
|
||||||
|
}
|
||||||
|
nonReactivePayload.teleports = res.teleports
|
||||||
|
nonReactivePayload.chunks = res.chunks
|
||||||
|
|
||||||
if (import.meta.client) {
|
if (import.meta.client) {
|
||||||
// must await next tick for Teleport to work correctly with static node re-rendering
|
// must await next tick for Teleport to work correctly with static node re-rendering
|
||||||
await nextTick()
|
await nextTick()
|
||||||
@ -178,16 +241,19 @@ export default defineComponent({
|
|||||||
fetchComponent()
|
fetchComponent()
|
||||||
} else if (import.meta.server || !nuxtApp.isHydrating || !nuxtApp.payload.serverRendered) {
|
} else if (import.meta.server || !nuxtApp.isHydrating || !nuxtApp.payload.serverRendered) {
|
||||||
await fetchComponent()
|
await fetchComponent()
|
||||||
|
} else if (selectiveClient && canLoadClientComponent.value && nonReactivePayload.chunks) {
|
||||||
|
await loadComponents(props.source, nonReactivePayload.chunks)
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if ((!html.value || error.value) && slots.fallback) {
|
if (!html.value || error.value) {
|
||||||
return [slots.fallback({ error: error.value })]
|
return [slots.fallback?.({ error: error.value }) ?? createVNode('div')]
|
||||||
}
|
}
|
||||||
const nodes = [createVNode(Fragment, {
|
const nodes = [createVNode(Fragment, {
|
||||||
key: key.value
|
key: key.value
|
||||||
}, [h(createStaticVNode(html.value || '<div></div>', 1))])]
|
}, [h(createStaticVNode(html.value || '<div></div>', 1))])]
|
||||||
if (uid.value && (mounted.value || nuxtApp.isHydrating || import.meta.server)) {
|
|
||||||
|
if (uid.value && (mounted.value || nuxtApp.isHydrating || import.meta.server) && html.value) {
|
||||||
for (const slot in slots) {
|
for (const slot in slots) {
|
||||||
if (availableSlots.value.includes(slot)) {
|
if (availableSlots.value.includes(slot)) {
|
||||||
nodes.push(createVNode(Teleport, { to: import.meta.client ? `[nuxt-ssr-component-uid='${uid.value}'] [nuxt-ssr-slot-name='${slot}']` : `uid=${uid.value};slot=${slot}` }, {
|
nodes.push(createVNode(Teleport, { to: import.meta.client ? `[nuxt-ssr-component-uid='${uid.value}'] [nuxt-ssr-slot-name='${slot}']` : `uid=${uid.value};slot=${slot}` }, {
|
||||||
@ -195,6 +261,24 @@ export default defineComponent({
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (import.meta.server) {
|
||||||
|
for (const [id, html] of Object.entries(nonReactivePayload.teleports ?? {})) {
|
||||||
|
nodes.push(createVNode(Teleport, { to: `uid=${uid.value};client=${id}` }, {
|
||||||
|
default: () => [createStaticVNode(html, 1)]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (selectiveClient && import.meta.client && canLoadClientComponent.value) {
|
||||||
|
for (const [id, props] of Object.entries(nonReactivePayload.props ?? {})) {
|
||||||
|
const component = components!.get(id.split('-')[0])!
|
||||||
|
const vnode = createVNode(Teleport, { to: `[nuxt-ssr-component-uid='${uid.value}'] [nuxt-ssr-client="${id}"]` }, {
|
||||||
|
default: () => {
|
||||||
|
return [h(component, props)]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
nodes.push(vnode)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nodes
|
return nodes
|
||||||
}
|
}
|
||||||
|
62
packages/nuxt/src/app/components/nuxt-teleport-ssr-client.ts
Normal file
62
packages/nuxt/src/app/components/nuxt-teleport-ssr-client.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import type { Component, } from 'vue'
|
||||||
|
import { Teleport, defineComponent, h } from 'vue'
|
||||||
|
import { useNuxtApp } from '../nuxt'
|
||||||
|
// @ts-expect-error virtual file
|
||||||
|
import { paths } from '#build/components-chunk'
|
||||||
|
|
||||||
|
type ExtendedComponent = Component & {
|
||||||
|
__file: string,
|
||||||
|
__name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* component only used with componentsIsland
|
||||||
|
* this teleport the component in SSR only if it needs to be hydrated on client
|
||||||
|
*/
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'NuxtTeleportSsrClient',
|
||||||
|
props: {
|
||||||
|
to: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
nuxtClient: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* ONLY used in dev mode since we use build:manifest result in production
|
||||||
|
* do not pass any value in production
|
||||||
|
*/
|
||||||
|
rootDir: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setup (props, { slots }) {
|
||||||
|
if (!props.nuxtClient) { return () => slots.default!() }
|
||||||
|
|
||||||
|
const app = useNuxtApp()
|
||||||
|
const islandContext = app.ssrContext!.islandContext!
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const slot = slots.default!()[0]
|
||||||
|
const slotType = (slot.type as ExtendedComponent)
|
||||||
|
const name = (slotType.__name || slotType.name) as string
|
||||||
|
|
||||||
|
if (import.meta.dev) {
|
||||||
|
const path = '_nuxt/' + paths[name]
|
||||||
|
islandContext.chunks[name] = path
|
||||||
|
} else {
|
||||||
|
islandContext.chunks[name] = paths[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
islandContext.propsData[props.to] = slot.props || {}
|
||||||
|
|
||||||
|
return [h('div', {
|
||||||
|
style: 'display: contents;',
|
||||||
|
'nuxt-ssr-client': props.to
|
||||||
|
}, []), h(Teleport, { to: props.to }, slot)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
@ -18,7 +18,7 @@ const revivers: Record<string, (data: any) => any> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (componentIslands) {
|
if (componentIslands) {
|
||||||
revivers.Island = ({ key, params }: any) => {
|
revivers.Island = ({ key, params, result }: any) => {
|
||||||
const nuxtApp = useNuxtApp()
|
const nuxtApp = useNuxtApp()
|
||||||
if (!nuxtApp.isHydrating) {
|
if (!nuxtApp.isHydrating) {
|
||||||
nuxtApp.payload.data[key] = nuxtApp.payload.data[key] || $fetch(`/__nuxt_island/${key}.json`, {
|
nuxtApp.payload.data[key] = nuxtApp.payload.data[key] || $fetch(`/__nuxt_island/${key}.json`, {
|
||||||
@ -29,7 +29,15 @@ if (componentIslands) {
|
|||||||
return r
|
return r
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return null
|
return {
|
||||||
|
html: '',
|
||||||
|
state: {},
|
||||||
|
head: {
|
||||||
|
link: [],
|
||||||
|
style: []
|
||||||
|
},
|
||||||
|
...result
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,27 +1,49 @@
|
|||||||
import { pathToFileURL } from 'node:url'
|
import { pathToFileURL } from 'node:url'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import fs from 'node:fs'
|
||||||
import type { Component } from '@nuxt/schema'
|
import type { Component } from '@nuxt/schema'
|
||||||
import { parseURL } from 'ufo'
|
import { parseURL } from 'ufo'
|
||||||
import { createUnplugin } from 'unplugin'
|
import { createUnplugin } from 'unplugin'
|
||||||
import MagicString from 'magic-string'
|
import MagicString from 'magic-string'
|
||||||
import { ELEMENT_NODE, parse, walk } from 'ultrahtml'
|
import { ELEMENT_NODE, parse, walk } from 'ultrahtml'
|
||||||
|
import { hash } from 'ohash'
|
||||||
|
import { resolvePath } from '@nuxt/kit'
|
||||||
import { isVue } from '../core/utils'
|
import { isVue } from '../core/utils'
|
||||||
|
|
||||||
interface ServerOnlyComponentTransformPluginOptions {
|
interface ServerOnlyComponentTransformPluginOptions {
|
||||||
getComponents: () => Component[]
|
getComponents: () => Component[]
|
||||||
|
/**
|
||||||
|
* passed down to `NuxtTeleportSsrClient`
|
||||||
|
* should be done only in dev mode as we use build:manifest result in production
|
||||||
|
*/
|
||||||
|
rootDir?: string
|
||||||
|
isDev?: boolean
|
||||||
|
/**
|
||||||
|
* allow using `nuxt-client` attribute on components
|
||||||
|
*/
|
||||||
|
selectiveClient?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComponentChunkOptions {
|
||||||
|
getComponents: () => Component[]
|
||||||
|
buildDir: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const SCRIPT_RE = /<script[^>]*>/g
|
const SCRIPT_RE = /<script[^>]*>/g
|
||||||
const HAS_SLOT_RE = /<slot[^>]*>/
|
const HAS_SLOT_OR_CLIENT_RE = /(<slot[^>]*>)|(nuxt-client)/
|
||||||
const TEMPLATE_RE = /<template>([\s\S]*)<\/template>/
|
const TEMPLATE_RE = /<template>([\s\S]*)<\/template>/
|
||||||
|
const NUXTCLIENT_ATTR_RE = /\snuxt-client(="[^"]*")?/g
|
||||||
|
|
||||||
export const islandsTransform = createUnplugin((options: ServerOnlyComponentTransformPluginOptions) => {
|
export const islandsTransform = createUnplugin((options: ServerOnlyComponentTransformPluginOptions, meta) => {
|
||||||
|
const isVite = meta.framework === 'vite'
|
||||||
|
const { isDev, rootDir } = options
|
||||||
return {
|
return {
|
||||||
name: 'server-only-component-transform',
|
name: 'server-only-component-transform',
|
||||||
enforce: 'pre',
|
enforce: 'pre',
|
||||||
transformInclude (id) {
|
transformInclude (id) {
|
||||||
if (!isVue(id)) { return false }
|
if (!isVue(id)) { return false }
|
||||||
|
|
||||||
const components = options.getComponents()
|
const components = options.getComponents()
|
||||||
|
|
||||||
const islands = components.filter(component =>
|
const islands = components.filter(component =>
|
||||||
component.island || (component.mode === 'server' && !components.some(c => c.pascalName === component.pascalName && c.mode === 'client'))
|
component.island || (component.mode === 'server' && !components.some(c => c.pascalName === component.pascalName && c.mode === 'client'))
|
||||||
)
|
)
|
||||||
@ -29,19 +51,22 @@ export const islandsTransform = createUnplugin((options: ServerOnlyComponentTran
|
|||||||
return islands.some(c => c.filePath === pathname)
|
return islands.some(c => c.filePath === pathname)
|
||||||
},
|
},
|
||||||
async transform (code, id) {
|
async transform (code, id) {
|
||||||
if (!HAS_SLOT_RE.test(code)) { return }
|
if (!HAS_SLOT_OR_CLIENT_RE.test(code)) { return }
|
||||||
const template = code.match(TEMPLATE_RE)
|
const template = code.match(TEMPLATE_RE)
|
||||||
if (!template) { return }
|
if (!template) { return }
|
||||||
const startingIndex = template.index || 0
|
const startingIndex = template.index || 0
|
||||||
const s = new MagicString(code)
|
const s = new MagicString(code)
|
||||||
|
|
||||||
s.replace(SCRIPT_RE, (full) => {
|
s.replace(SCRIPT_RE, (full) => {
|
||||||
return full + '\nimport { vforToArray as __vforToArray } from \'#app/components/utils\''
|
return full + '\nimport { vforToArray as __vforToArray } from \'#app/components/utils\'' + '\nimport NuxtTeleportSsrClient from \'#app/components/nuxt-teleport-ssr-client\''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let hasNuxtClient = false
|
||||||
|
|
||||||
const ast = parse(template[0])
|
const ast = parse(template[0])
|
||||||
await walk(ast, (node) => {
|
await walk(ast, (node) => {
|
||||||
if (node.type === ELEMENT_NODE && node.name === 'slot') {
|
if (node.type === ELEMENT_NODE) {
|
||||||
|
if (node.name === 'slot') {
|
||||||
const { attributes, children, loc, isSelfClosingTag } = node
|
const { attributes, children, loc, isSelfClosingTag } = node
|
||||||
const slotName = attributes.name ?? 'default'
|
const slotName = attributes.name ?? 'default'
|
||||||
let vfor: [string, string] | undefined
|
let vfor: [string, string] | undefined
|
||||||
@ -78,9 +103,25 @@ export const islandsTransform = createUnplugin((options: ServerOnlyComponentTran
|
|||||||
s.appendLeft(startingIndex + loc[1].start, '<div nuxt-slot-fallback-end/>')
|
s.appendLeft(startingIndex + loc[1].start, '<div nuxt-slot-fallback-end/>')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (options.selectiveClient && ('nuxt-client' in node.attributes || ':nuxt-client' in node.attributes)) {
|
||||||
|
hasNuxtClient = true
|
||||||
|
const attributeValue = node.attributes[':nuxt-client'] || node.attributes['nuxt-client'] || 'true'
|
||||||
|
if (isVite) {
|
||||||
|
// handle granular interactivity
|
||||||
|
const htmlCode = code.slice(startingIndex + node.loc[0].start, startingIndex + node.loc[1].end)
|
||||||
|
const uid = hash(id + node.loc[0].start + node.loc[0].end)
|
||||||
|
|
||||||
|
s.overwrite(startingIndex + node.loc[0].start, startingIndex + node.loc[1].end, `<NuxtTeleportSsrClient to="${node.name}-${uid}" ${rootDir && isDev ? `root-dir="${rootDir}"` : ''} :nuxt-client="${attributeValue}">${htmlCode.replaceAll(NUXTCLIENT_ATTR_RE, '')}</NuxtTeleportSsrClient>`)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (!isVite && hasNuxtClient) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn(`nuxt-client attribute and client components within islands is only supported with Vite. file: ${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
if (s.hasChanged()) {
|
if (s.hasChanged()) {
|
||||||
return {
|
return {
|
||||||
code: s.toString(),
|
code: s.toString(),
|
||||||
@ -105,3 +146,48 @@ function getBindings (bindings: Record<string, string>, vfor?: [string, string])
|
|||||||
return `:nuxt-ssr-slot-data="JSON.stringify(__vforToArray(${vfor[1]}).map(${vfor[0]} => (${data})))"`
|
return `:nuxt-ssr-slot-data="JSON.stringify(__vforToArray(${vfor[1]}).map(${vfor[0]} => (${data})))"`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const componentsChunkPlugin = createUnplugin((options: ComponentChunkOptions) => {
|
||||||
|
const { buildDir } = options
|
||||||
|
return {
|
||||||
|
name: 'componentsChunkPlugin',
|
||||||
|
vite: {
|
||||||
|
async config (config) {
|
||||||
|
const components = options.getComponents()
|
||||||
|
config.build = config.build || {}
|
||||||
|
config.build.rollupOptions = config.build.rollupOptions || {}
|
||||||
|
config.build.rollupOptions.output = config.build.rollupOptions.output || {}
|
||||||
|
config.build.rollupOptions.input = config.build.rollupOptions.input || {}
|
||||||
|
// don't use 'strict', this would create another "facade" chunk for the entry file, causing the ssr styles to not detect everything
|
||||||
|
config.build.rollupOptions.preserveEntrySignatures = 'allow-extension'
|
||||||
|
for (const component of components) {
|
||||||
|
if (component.mode === 'client' || component.mode === 'all') {
|
||||||
|
(config.build.rollupOptions.input as Record<string, string>)[component.pascalName] = await resolvePath(component.filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async generateBundle (_opts, bundle) {
|
||||||
|
const components = options.getComponents().filter(c => c.mode === 'client' || c.mode === 'all')
|
||||||
|
const pathAssociation: Record<string, string> = {}
|
||||||
|
for (const [chunkPath, chunkInfo] of Object.entries(bundle)) {
|
||||||
|
if (chunkInfo.type !== 'chunk') { continue }
|
||||||
|
|
||||||
|
for (const component of components) {
|
||||||
|
if (chunkInfo.facadeModuleId && chunkInfo.exports.length > 0) {
|
||||||
|
const { pathname } = parseURL(decodeURIComponent(pathToFileURL(chunkInfo.facadeModuleId).href))
|
||||||
|
const isPath = await resolvePath(component.filePath) === pathname
|
||||||
|
if (isPath) {
|
||||||
|
// avoid importing the component chunk in all pages
|
||||||
|
chunkInfo.isEntry = false
|
||||||
|
pathAssociation[component.pascalName] = chunkPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(join(buildDir, 'components-chunk.mjs'), `export const paths = ${JSON.stringify(pathAssociation, null, 2)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { statSync } from 'node:fs'
|
import fs, { statSync } from 'node:fs'
|
||||||
import { normalize, relative, resolve } from 'pathe'
|
import { join, normalize, relative, resolve } from 'pathe'
|
||||||
import { addPluginTemplate, addTemplate, addVitePlugin, addWebpackPlugin, defineNuxtModule, logger, resolveAlias, updateTemplates } from '@nuxt/kit'
|
import { addPluginTemplate, addTemplate, addVitePlugin, addWebpackPlugin, defineNuxtModule, logger, resolveAlias, updateTemplates } from '@nuxt/kit'
|
||||||
import type { Component, ComponentsDir, ComponentsOptions } from 'nuxt/schema'
|
import type { Component, ComponentsDir, ComponentsOptions } from 'nuxt/schema'
|
||||||
|
|
||||||
@ -9,7 +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 { componentsChunkPlugin, 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'
|
||||||
@ -226,10 +226,35 @@ export default defineNuxtModule<ComponentsOptions>({
|
|||||||
experimentalComponentIslands: !!nuxt.options.experimental.componentIslands
|
experimentalComponentIslands: !!nuxt.options.experimental.componentIslands
|
||||||
}))
|
}))
|
||||||
|
|
||||||
if (isServer && nuxt.options.experimental.componentIslands) {
|
if (nuxt.options.experimental.componentIslands) {
|
||||||
config.plugins.push(islandsTransform.vite({
|
const selectiveClient = typeof nuxt.options.experimental.componentIslands === 'object' && nuxt.options.experimental.componentIslands.selectiveClient
|
||||||
getComponents
|
|
||||||
|
if (isClient && selectiveClient) {
|
||||||
|
fs.writeFileSync(join(nuxt.options.buildDir, 'components-chunk.mjs'), 'export const paths = {}')
|
||||||
|
if(!nuxt.options.dev) {
|
||||||
|
config.plugins.push(componentsChunkPlugin.vite({
|
||||||
|
getComponents,
|
||||||
|
buildDir: nuxt.options.buildDir
|
||||||
}))
|
}))
|
||||||
|
} else {
|
||||||
|
fs.writeFileSync(join(nuxt.options.buildDir, 'components-chunk.mjs'),`export const paths = ${JSON.stringify(
|
||||||
|
getComponents().filter(c => c.mode === 'client' || c.mode === 'all').reduce((acc, c) => {
|
||||||
|
if(c.filePath.endsWith('.vue') || c.filePath.endsWith('.js') || c.filePath.endsWith('.ts')) return Object.assign(acc, {[c.pascalName]: `/@fs/${c.filePath}`})
|
||||||
|
const filePath = fs.existsSync( `${c.filePath}.vue`) ? `${c.filePath}.vue` : fs.existsSync( `${c.filePath}.js`) ? `${c.filePath}.js` : `${c.filePath}.ts`
|
||||||
|
return Object.assign(acc, {[c.pascalName]: `/@fs/${filePath}`})
|
||||||
|
}, {} as Record<string, string>)
|
||||||
|
)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isServer) {
|
||||||
|
config.plugins.push(islandsTransform.vite({
|
||||||
|
getComponents,
|
||||||
|
rootDir: nuxt.options.rootDir,
|
||||||
|
isDev: nuxt.options.dev,
|
||||||
|
selectiveClient
|
||||||
|
}))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!isServer && nuxt.options.experimental.componentIslands) {
|
if (!isServer && nuxt.options.experimental.componentIslands) {
|
||||||
config.plugins.push({
|
config.plugins.push({
|
||||||
@ -270,10 +295,14 @@ export default defineNuxtModule<ComponentsOptions>({
|
|||||||
experimentalComponentIslands: !!nuxt.options.experimental.componentIslands
|
experimentalComponentIslands: !!nuxt.options.experimental.componentIslands
|
||||||
}))
|
}))
|
||||||
|
|
||||||
if (nuxt.options.experimental.componentIslands && mode === 'server') {
|
if (nuxt.options.experimental.componentIslands) {
|
||||||
|
if (mode === 'server') {
|
||||||
config.plugins.push(islandsTransform.webpack({
|
config.plugins.push(islandsTransform.webpack({
|
||||||
getComponents
|
getComponents
|
||||||
}))
|
}))
|
||||||
|
} else {
|
||||||
|
fs.writeFileSync(join(nuxt.options.buildDir, 'components-chunk.mjs'), 'export const paths = {}')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -58,6 +58,10 @@ export interface NuxtIslandContext {
|
|||||||
name: string
|
name: string
|
||||||
props?: Record<string, any>
|
props?: Record<string, any>
|
||||||
url?: string
|
url?: string
|
||||||
|
// chunks to load components
|
||||||
|
chunks: Record<string, string>
|
||||||
|
// props to be sent back
|
||||||
|
propsData: Record<string, any>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NuxtIslandResponse {
|
export interface NuxtIslandResponse {
|
||||||
@ -68,6 +72,9 @@ export interface NuxtIslandResponse {
|
|||||||
link: (Record<string, string>)[]
|
link: (Record<string, string>)[]
|
||||||
style: ({ innerHTML: string, key: string })[]
|
style: ({ innerHTML: string, key: string })[]
|
||||||
}
|
}
|
||||||
|
chunks?: Record<string, string>
|
||||||
|
props?: Record<string, Record<string, any>>
|
||||||
|
teleports?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NuxtRenderResponse {
|
export interface NuxtRenderResponse {
|
||||||
@ -188,7 +195,10 @@ async function getIslandContext (event: H3Event): Promise<NuxtIslandContext> {
|
|||||||
id: hashId,
|
id: hashId,
|
||||||
name: componentName,
|
name: componentName,
|
||||||
props: destr(context.props) || {},
|
props: destr(context.props) || {},
|
||||||
uid: destr(context.uid) || undefined
|
uid: destr(context.uid) || undefined,
|
||||||
|
chunks: {},
|
||||||
|
propsData: {},
|
||||||
|
teleports: {}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ctx
|
return ctx
|
||||||
@ -406,7 +416,7 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse
|
|||||||
head: normalizeChunks([headTags, ssrContext.styles]),
|
head: normalizeChunks([headTags, ssrContext.styles]),
|
||||||
bodyAttrs: [bodyAttrs],
|
bodyAttrs: [bodyAttrs],
|
||||||
bodyPrepend: normalizeChunks([bodyTagsOpen, ssrContext.teleports?.body]),
|
bodyPrepend: normalizeChunks([bodyTagsOpen, ssrContext.teleports?.body]),
|
||||||
body: [process.env.NUXT_COMPONENT_ISLANDS ? replaceServerOnlyComponentsSlots(ssrContext, _rendered.html) : _rendered.html],
|
body: [process.env.NUXT_COMPONENT_ISLANDS ? replaceClientTeleport(ssrContext, replaceServerOnlyComponentsSlots(ssrContext, _rendered.html)) : _rendered.html],
|
||||||
bodyAppend: [bodyTags]
|
bodyAppend: [bodyTags]
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -431,7 +441,10 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse
|
|||||||
id: islandContext.id,
|
id: islandContext.id,
|
||||||
head: islandHead,
|
head: islandHead,
|
||||||
html: getServerComponentHTML(htmlContext.body),
|
html: getServerComponentHTML(htmlContext.body),
|
||||||
state: ssrContext.payload.state
|
state: ssrContext.payload.state,
|
||||||
|
chunks: islandContext.chunks,
|
||||||
|
props: islandContext.propsData,
|
||||||
|
teleports: ssrContext.teleports || {}
|
||||||
}
|
}
|
||||||
|
|
||||||
await nitroApp.hooks.callHook('render:island', islandResponse, { event, islandContext })
|
await nitroApp.hooks.callHook('render:island', islandResponse, { event, islandContext })
|
||||||
@ -577,6 +590,7 @@ function getServerComponentHTML (body: string[]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SSR_TELEPORT_MARKER = /^uid=([^;]*);slot=(.*)$/
|
const SSR_TELEPORT_MARKER = /^uid=([^;]*);slot=(.*)$/
|
||||||
|
const SSR_CLIENT_TELEPORT_MARKER = /^uid=([^;]*);client=(.*)$/
|
||||||
function replaceServerOnlyComponentsSlots (ssrContext: NuxtSSRContext, html: string): string {
|
function replaceServerOnlyComponentsSlots (ssrContext: NuxtSSRContext, html: string): string {
|
||||||
const { teleports, islandContext } = ssrContext
|
const { teleports, islandContext } = ssrContext
|
||||||
if (islandContext || !teleports) { return html }
|
if (islandContext || !teleports) { return html }
|
||||||
@ -591,3 +605,21 @@ function replaceServerOnlyComponentsSlots (ssrContext: NuxtSSRContext, html: str
|
|||||||
}
|
}
|
||||||
return html
|
return html
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO merge with replaceServerOnlyComponentsSlots once slots are refactored
|
||||||
|
function replaceClientTeleport (ssrContext: NuxtSSRContext, html: string) {
|
||||||
|
const { teleports, islandContext } = ssrContext
|
||||||
|
|
||||||
|
if (islandContext || !teleports) { return html }
|
||||||
|
for (const key in teleports) {
|
||||||
|
const match = key.match(SSR_CLIENT_TELEPORT_MARKER)
|
||||||
|
if (!match) { continue }
|
||||||
|
const [, uid, clientId] = match
|
||||||
|
if (!uid || !clientId) { continue }
|
||||||
|
html = html.replace(new RegExp(`<div [^>]*nuxt-ssr-component-uid="${uid}"[^>]*>((?!nuxt-ssr-client="${clientId}"|nuxt-ssr-component-uid)[\\s\\S])*<div [^>]*nuxt-ssr-client="${clientId}"[^>]*>`), (full) => {
|
||||||
|
|
||||||
|
return full + teleports[key]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
@ -378,7 +378,8 @@ export const nuxtConfigTemplate = {
|
|||||||
`export const componentIslands = ${!!ctx.nuxt.options.experimental.componentIslands}`,
|
`export const componentIslands = ${!!ctx.nuxt.options.experimental.componentIslands}`,
|
||||||
`export const payloadExtraction = ${!!ctx.nuxt.options.experimental.payloadExtraction}`,
|
`export const payloadExtraction = ${!!ctx.nuxt.options.experimental.payloadExtraction}`,
|
||||||
`export const appManifest = ${!!ctx.nuxt.options.experimental.appManifest}`,
|
`export const appManifest = ${!!ctx.nuxt.options.experimental.appManifest}`,
|
||||||
`export const remoteComponentIslands = ${ctx.nuxt.options.experimental.componentIslands === 'local+remote'}`,
|
`export const remoteComponentIslands = ${typeof ctx.nuxt.options.experimental.componentIslands === 'object' && ctx.nuxt.options.experimental.componentIslands.remoteIsland}`,
|
||||||
|
`export const selectiveClient = ${typeof ctx.nuxt.options.experimental.componentIslands === 'object' && ctx.nuxt.options.experimental.componentIslands.selectiveClient}`,
|
||||||
`export const devPagesDir = ${ctx.nuxt.options.dev ? JSON.stringify(ctx.nuxt.options.dir.pages) : 'null'}`,
|
`export const devPagesDir = ${ctx.nuxt.options.dev ? JSON.stringify(ctx.nuxt.options.dir.pages) : 'null'}`,
|
||||||
`export const devRootDir = ${ctx.nuxt.options.dev ? JSON.stringify(ctx.nuxt.options.rootDir) : 'null'}`,
|
`export const devRootDir = ${ctx.nuxt.options.dev ? JSON.stringify(ctx.nuxt.options.rootDir) : 'null'}`,
|
||||||
`export const nuxtLinkDefaults = ${JSON.stringify(ctx.nuxt.options.experimental.defaults.nuxtLink)}`,
|
`export const nuxtLinkDefaults = ${JSON.stringify(ctx.nuxt.options.experimental.defaults.nuxtLink)}`,
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import type { Plugin } from 'vite'
|
import type { Plugin } from 'vite'
|
||||||
import type { Component } from '@nuxt/schema'
|
import type { Component } from '@nuxt/schema'
|
||||||
|
import type { UnpluginOptions } from 'unplugin'
|
||||||
import { islandsTransform } from '../src/components/islandsTransform'
|
import { islandsTransform } from '../src/components/islandsTransform'
|
||||||
import { normalizeLineEndings } from './utils'
|
import { normalizeLineEndings } from './utils'
|
||||||
|
|
||||||
@ -17,12 +18,25 @@ const getComponents = () => [{
|
|||||||
preload: false
|
preload: false
|
||||||
}] as Component[]
|
}] as Component[]
|
||||||
|
|
||||||
const pluginVite = islandsTransform.raw({
|
const pluginWebpack = islandsTransform.raw({
|
||||||
getComponents
|
getComponents,
|
||||||
|
selectiveClient: true
|
||||||
|
}, { framework: 'webpack', webpack: { compiler: {} as any } })
|
||||||
|
|
||||||
|
const viteTransform = async (source: string, id: string, isDev = false, selectiveClient = false) => {
|
||||||
|
const vitePlugin = islandsTransform.raw({
|
||||||
|
getComponents,
|
||||||
|
rootDir: '/root',
|
||||||
|
isDev,
|
||||||
|
selectiveClient
|
||||||
}, { framework: 'vite' }) as Plugin
|
}, { framework: 'vite' }) as Plugin
|
||||||
|
|
||||||
const viteTransform = async (source: string, id: string) => {
|
const result = await (vitePlugin.transform! as Function)(source, id)
|
||||||
const result = await (pluginVite.transform! as Function)(source, id)
|
return typeof result === 'string' ? result : result?.code
|
||||||
|
}
|
||||||
|
|
||||||
|
const webpackTransform = async (source: string, id: string) => {
|
||||||
|
const result = await ((pluginWebpack as UnpluginOptions).transform! as Function)(source, id)
|
||||||
return typeof result === 'string' ? result : result?.code
|
return typeof result === 'string' ? result : result?.code
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,6 +71,7 @@ describe('islandTransform - server and island components', () => {
|
|||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { vforToArray as __vforToArray } from '#app/components/utils'
|
import { vforToArray as __vforToArray } from '#app/components/utils'
|
||||||
|
import NuxtTeleportSsrClient from '#app/components/nuxt-teleport-ssr-client'
|
||||||
const someData = 'some data'
|
const someData = 'some data'
|
||||||
|
|
||||||
</script>"
|
</script>"
|
||||||
@ -87,6 +102,7 @@ describe('islandTransform - server and island components', () => {
|
|||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { vforToArray as __vforToArray } from '#app/components/utils'
|
import { vforToArray as __vforToArray } from '#app/components/utils'
|
||||||
|
import NuxtTeleportSsrClient from '#app/components/nuxt-teleport-ssr-client'
|
||||||
const someData = 'some data'
|
const someData = 'some data'
|
||||||
|
|
||||||
</script>"
|
</script>"
|
||||||
@ -150,6 +166,7 @@ describe('islandTransform - server and island components', () => {
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { vforToArray as __vforToArray } from '#app/components/utils'
|
import { vforToArray as __vforToArray } from '#app/components/utils'
|
||||||
|
import NuxtTeleportSsrClient from '#app/components/nuxt-teleport-ssr-client'
|
||||||
export interface Props {
|
export interface Props {
|
||||||
count?: number;
|
count?: number;
|
||||||
}
|
}
|
||||||
@ -161,4 +178,189 @@ describe('islandTransform - server and island components', () => {
|
|||||||
`)
|
`)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('nuxt-client', () => {
|
||||||
|
describe('vite', () => {
|
||||||
|
it('test transform with vite in dev', async () => {
|
||||||
|
const result = await viteTransform(`<template>
|
||||||
|
<div>
|
||||||
|
<!-- should not be wrapped by NuxtTeleportSsrClient -->
|
||||||
|
<HelloWorld />
|
||||||
|
<!-- should be wrapped by NuxtTeleportSsrClient with a rootDir attr -->
|
||||||
|
<HelloWorld nuxt-client />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import HelloWorld from './HelloWorld.vue'
|
||||||
|
</script>
|
||||||
|
`, 'hello.server.vue', true, true)
|
||||||
|
|
||||||
|
expect(normalizeLineEndings(result)).toMatchInlineSnapshot(`
|
||||||
|
"<template>
|
||||||
|
<div>
|
||||||
|
<!-- should not be wrapped by NuxtTeleportSsrClient -->
|
||||||
|
<HelloWorld />
|
||||||
|
<!-- should be wrapped by NuxtTeleportSsrClient with a rootDir attr -->
|
||||||
|
<NuxtTeleportSsrClient to=\\"HelloWorld-PIVollAJCe\\" root-dir=\\"/root\\" :nuxt-client=\\"true\\"><HelloWorld /></NuxtTeleportSsrClient>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang=\\"ts\\">
|
||||||
|
import { vforToArray as __vforToArray } from '#app/components/utils'
|
||||||
|
import NuxtTeleportSsrClient from '#app/components/nuxt-teleport-ssr-client'
|
||||||
|
import HelloWorld from './HelloWorld.vue'
|
||||||
|
</script>
|
||||||
|
"
|
||||||
|
`)
|
||||||
|
// root-dir prop should never be used in production
|
||||||
|
expect(result).toContain('root-dir="/root"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('test transform with vite in prod', async () => {
|
||||||
|
const result = await viteTransform(`<template>
|
||||||
|
<div>
|
||||||
|
<HelloWorld />
|
||||||
|
<HelloWorld nuxt-client />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import HelloWorld from './HelloWorld.vue'
|
||||||
|
</script>
|
||||||
|
`, 'hello.server.vue', false, true)
|
||||||
|
|
||||||
|
expect(normalizeLineEndings(result)).toMatchInlineSnapshot(`
|
||||||
|
"<template>
|
||||||
|
<div>
|
||||||
|
<HelloWorld />
|
||||||
|
<NuxtTeleportSsrClient to=\\"HelloWorld-CyH3UXLuYA\\" :nuxt-client=\\"true\\"><HelloWorld /></NuxtTeleportSsrClient>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang=\\"ts\\">
|
||||||
|
import { vforToArray as __vforToArray } from '#app/components/utils'
|
||||||
|
import NuxtTeleportSsrClient from '#app/components/nuxt-teleport-ssr-client'
|
||||||
|
import HelloWorld from './HelloWorld.vue'
|
||||||
|
</script>
|
||||||
|
"
|
||||||
|
`)
|
||||||
|
|
||||||
|
// root-dir prop should never be used in production
|
||||||
|
expect(result).not.toContain('root-dir="')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('test dynamic nuxt-client', async () => {
|
||||||
|
const result = await viteTransform(`<template>
|
||||||
|
<div>
|
||||||
|
<HelloWorld />
|
||||||
|
<HelloWorld :nuxt-client="nuxtClient" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import HelloWorld from './HelloWorld.vue'
|
||||||
|
|
||||||
|
const nuxtClient = false
|
||||||
|
</script>
|
||||||
|
`, 'hello.server.vue', false, true)
|
||||||
|
|
||||||
|
expect(normalizeLineEndings(result)).toMatchInlineSnapshot(`
|
||||||
|
"<template>
|
||||||
|
<div>
|
||||||
|
<HelloWorld />
|
||||||
|
<NuxtTeleportSsrClient to=\\"HelloWorld-eo0XycWCUV\\" :nuxt-client=\\"nuxtClient\\"><HelloWorld :nuxt-client=\\"nuxtClient\\" /></NuxtTeleportSsrClient>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang=\\"ts\\">
|
||||||
|
import { vforToArray as __vforToArray } from '#app/components/utils'
|
||||||
|
import NuxtTeleportSsrClient from '#app/components/nuxt-teleport-ssr-client'
|
||||||
|
import HelloWorld from './HelloWorld.vue'
|
||||||
|
|
||||||
|
const nuxtClient = false
|
||||||
|
</script>
|
||||||
|
"
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not transform if disabled', async () => {
|
||||||
|
const result = await viteTransform(`<template>
|
||||||
|
<div>
|
||||||
|
<HelloWorld />
|
||||||
|
<HelloWorld :nuxt-client="nuxtClient" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import HelloWorld from './HelloWorld.vue'
|
||||||
|
|
||||||
|
const nuxtClient = false
|
||||||
|
</script>
|
||||||
|
`, 'hello.server.vue', false, false)
|
||||||
|
|
||||||
|
expect(normalizeLineEndings(result)).toMatchInlineSnapshot(`
|
||||||
|
"<template>
|
||||||
|
<div>
|
||||||
|
<HelloWorld />
|
||||||
|
<HelloWorld :nuxt-client=\\"nuxtClient\\" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang=\\"ts\\">
|
||||||
|
import { vforToArray as __vforToArray } from '#app/components/utils'
|
||||||
|
import NuxtTeleportSsrClient from '#app/components/nuxt-teleport-ssr-client'
|
||||||
|
import HelloWorld from './HelloWorld.vue'
|
||||||
|
|
||||||
|
const nuxtClient = false
|
||||||
|
</script>
|
||||||
|
"
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('webpack', () => {
|
||||||
|
it('test transform with webpack', async () => {
|
||||||
|
const spyOnWarn = vi.spyOn(console, 'warn')
|
||||||
|
const result = await webpackTransform(`<template>
|
||||||
|
<div>
|
||||||
|
<!-- should not be wrapped by NuxtTeleportSsrClient -->
|
||||||
|
<HelloWorld />
|
||||||
|
|
||||||
|
<!-- should be not wrapped by NuxtTeleportSsrClient for now -->
|
||||||
|
<HelloWorld nuxt-client />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import HelloWorld from './HelloWorld.vue'
|
||||||
|
|
||||||
|
const someData = 'some data'
|
||||||
|
</script>
|
||||||
|
`, 'hello.server.vue')
|
||||||
|
expect(normalizeLineEndings(result)).toMatchInlineSnapshot(`
|
||||||
|
"<template>
|
||||||
|
<div>
|
||||||
|
<!-- should not be wrapped by NuxtTeleportSsrClient -->
|
||||||
|
<HelloWorld />
|
||||||
|
|
||||||
|
<!-- should be not wrapped by NuxtTeleportSsrClient for now -->
|
||||||
|
<HelloWorld nuxt-client />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang=\\"ts\\">
|
||||||
|
import { vforToArray as __vforToArray } from '#app/components/utils'
|
||||||
|
import NuxtTeleportSsrClient from '#app/components/nuxt-teleport-ssr-client'
|
||||||
|
import HelloWorld from './HelloWorld.vue'
|
||||||
|
|
||||||
|
const someData = 'some data'
|
||||||
|
</script>
|
||||||
|
"
|
||||||
|
`)
|
||||||
|
|
||||||
|
expect(spyOnWarn).toHaveBeenCalledWith('nuxt-client attribute and client components within islands is only supported with Vite. file: hello.server.vue')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@ -132,13 +132,17 @@ export default defineUntypedSchema({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Experimental component islands support with <NuxtIsland> and .island.vue files.
|
* Experimental component islands support with <NuxtIsland> and .island.vue files.
|
||||||
* @type {true | 'local' | 'local+remote' | false}
|
* @type {true | 'local' | 'local+remote' | Partial<{ remoteIsland: boolean, selectiveClient: boolean }> | false}
|
||||||
*/
|
*/
|
||||||
componentIslands: {
|
componentIslands: {
|
||||||
$resolve: (val) => {
|
$resolve: (val) => {
|
||||||
if (typeof val === 'string') { return val }
|
if (val === 'local+remote') {
|
||||||
if (val === true) { return 'local' }
|
return { remoteIsland: true }
|
||||||
return false
|
}
|
||||||
|
if (val === 'local') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return val ?? false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -7,6 +7,7 @@ import { join, normalize } from 'pathe'
|
|||||||
import { $fetch, createPage, fetch, isDev, setup, startServer, url, useTestContext } from '@nuxt/test-utils/e2e'
|
import { $fetch, createPage, fetch, isDev, setup, startServer, url, useTestContext } from '@nuxt/test-utils/e2e'
|
||||||
import { $fetchComponent } from '@nuxt/test-utils/experimental'
|
import { $fetchComponent } from '@nuxt/test-utils/experimental'
|
||||||
|
|
||||||
|
import type { ConsoleMessage } from 'playwright-core'
|
||||||
import type { NuxtIslandResponse } from '../packages/nuxt/src/core/runtime/nitro/renderer'
|
import type { NuxtIslandResponse } from '../packages/nuxt/src/core/runtime/nitro/renderer'
|
||||||
import { expectNoClientErrors, expectWithPolling, gotoPath, isRenderingJson, parseData, parsePayload, renderPage } from './utils'
|
import { expectNoClientErrors, expectWithPolling, gotoPath, isRenderingJson, parseData, parsePayload, renderPage } from './utils'
|
||||||
|
|
||||||
@ -1483,16 +1484,27 @@ describe('server components/islands', () => {
|
|||||||
await page.locator('#show-island').click()
|
await page.locator('#show-island').click()
|
||||||
expect(await page.locator('#island-mounted-client-side').innerHTML()).toContain('Interactive testing slot post SSR')
|
expect(await page.locator('#island-mounted-client-side').innerHTML()).toContain('Interactive testing slot post SSR')
|
||||||
|
|
||||||
|
if (!isWebpack) {
|
||||||
|
// test client component interactivity
|
||||||
|
expect(await page.locator('.interactive-component-wrapper').innerHTML()).toContain('Sugar Counter 12')
|
||||||
|
await page.locator('.interactive-component-wrapper button').click()
|
||||||
|
expect(await page.locator('.interactive-component-wrapper').innerHTML()).toContain('Sugar Counter 13')
|
||||||
|
}
|
||||||
|
|
||||||
await page.close()
|
await page.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('lazy server components', async () => {
|
it('lazy server components', async () => {
|
||||||
|
const logs: ConsoleMessage[] = []
|
||||||
|
|
||||||
const { page } = await renderPage('/server-components/lazy/start')
|
const { page } = await renderPage('/server-components/lazy/start')
|
||||||
|
|
||||||
|
page.on('console', (msg) => { if (msg.type() === 'error') { logs.push(msg) } })
|
||||||
await page.waitForLoadState('networkidle')
|
await page.waitForLoadState('networkidle')
|
||||||
await page.getByText('Go to page with lazy server component').click()
|
await page.getByText('Go to page with lazy server component').click()
|
||||||
|
|
||||||
const text = await page.innerText('pre')
|
const text = await page.innerText('pre')
|
||||||
expect(text).toMatchInlineSnapshot(`" End page <pre></pre><section id="fallback"> Loading server component </section><section id="no-fallback"><div></div></section>"`)
|
expect(text).toMatchInlineSnapshot('" End page <pre></pre><section id="fallback"> Loading server component </section><section id="no-fallback"><div></div></section><div></div>"')
|
||||||
expect(text).not.toContain('async component that was very long')
|
expect(text).not.toContain('async component that was very long')
|
||||||
expect(text).toContain('Loading server component')
|
expect(text).toContain('Loading server component')
|
||||||
|
|
||||||
@ -1502,6 +1514,26 @@ describe('server components/islands', () => {
|
|||||||
await page.waitForFunction(() => (document.querySelector('#no-fallback') as HTMLElement)?.innerText?.includes('async component'))
|
await page.waitForFunction(() => (document.querySelector('#no-fallback') as HTMLElement)?.innerText?.includes('async component'))
|
||||||
await page.waitForFunction(() => (document.querySelector('#fallback') as HTMLElement)?.innerText?.includes('async component'))
|
await page.waitForFunction(() => (document.querySelector('#fallback') as HTMLElement)?.innerText?.includes('async component'))
|
||||||
|
|
||||||
|
// test navigating back and forth for lazy <ServerWithClient> component (should not trigger any issue)
|
||||||
|
await page.goBack({ waitUntil: 'networkidle' })
|
||||||
|
await page.getByText('Go to page with lazy server component').click()
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
|
||||||
|
expect(logs).toHaveLength(0)
|
||||||
|
|
||||||
|
await page.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not preload ComponentWithRef', async () => {
|
||||||
|
// should not add <ComponentWithRef> to the modulepreload list since it is used only server side
|
||||||
|
const { page } = await renderPage('/islands')
|
||||||
|
const links = await page.locator('link').all()
|
||||||
|
for (const link of links) {
|
||||||
|
if (await link.getAttribute('rel') === 'modulepreload') {
|
||||||
|
expect(await link.getAttribute('href')).not.toContain('ComponentWithRef')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await page.close()
|
await page.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -1510,8 +1542,13 @@ describe('server components/islands', () => {
|
|||||||
await page.waitForLoadState('networkidle')
|
await page.waitForLoadState('networkidle')
|
||||||
await page.getByText('Go to page without lazy server component').click()
|
await page.getByText('Go to page without lazy server component').click()
|
||||||
|
|
||||||
const text = await page.innerText('pre')
|
const text = (await page.innerText('pre')).replace(/nuxt-ssr-client="([^"]*)"/g, (_, content) => `nuxt-ssr-client="${content.split('-')[0]}"`)
|
||||||
expect(text).toMatchInlineSnapshot(`" End page <pre></pre><section id="fallback"><div nuxt-ssr-component-uid="2"> This is a .server (20ms) async component that was very long ... <div id="async-server-component-count">42</div><div style="display:contents;" nuxt-ssr-slot-name="default"></div></div></section><section id="no-fallback"><div nuxt-ssr-component-uid="3"> This is a .server (20ms) async component that was very long ... <div id="async-server-component-count">42</div><div style="display:contents;" nuxt-ssr-slot-name="default"></div></div></section>"`)
|
|
||||||
|
if (isWebpack) {
|
||||||
|
expect(text).toMatchInlineSnapshot('" End page <pre></pre><section id="fallback"><div nuxt-ssr-component-uid="4"> This is a .server (20ms) async component that was very long ... <div id="async-server-component-count">42</div><div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><div style="display:contents;" nuxt-ssr-slot-name="default"></div></div></section><section id="no-fallback"><div nuxt-ssr-component-uid="5"> This is a .server (20ms) async component that was very long ... <div id="async-server-component-count">42</div><div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><div style="display:contents;" nuxt-ssr-slot-name="default"></div></div></section><div nuxt-ssr-component-uid="3"> ServerWithClient.server.vue : <p>count: 0</p> This component should not be preloaded <div><!--[--><div>a</div><div>b</div><div>c</div><!--]--></div> This is not interactive <div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><div class="interactive-component-wrapper" style="border:solid 1px red;"> The component bellow is not a slot but declared as interactive <div class="sugar-counter" nuxt-client=""> Sugar Counter 12 x 1 = 12 <button> Inc </button></div></div></div>"')
|
||||||
|
} else {
|
||||||
|
expect(text).toMatchInlineSnapshot('" End page <pre></pre><section id="fallback"><div nuxt-ssr-component-uid="4"> This is a .server (20ms) async component that was very long ... <div id="async-server-component-count">42</div><div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><div style="display:contents;" nuxt-ssr-slot-name="default"></div></div></section><section id="no-fallback"><div nuxt-ssr-component-uid="5"> This is a .server (20ms) async component that was very long ... <div id="async-server-component-count">42</div><div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><div style="display:contents;" nuxt-ssr-slot-name="default"></div></div></section><div nuxt-ssr-component-uid="3"> ServerWithClient.server.vue : <p>count: 0</p> This component should not be preloaded <div><!--[--><div>a</div><div>b</div><div>c</div><!--]--></div> This is not interactive <div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><div class="interactive-component-wrapper" style="border:solid 1px red;"> The component bellow is not a slot but declared as interactive <!--[--><div style="display: contents;" nuxt-ssr-client="Counter"></div><!--teleport start--><!--teleport end--><!--]--></div></div>"')
|
||||||
|
}
|
||||||
expect(text).toContain('async component that was very long')
|
expect(text).toContain('async component that was very long')
|
||||||
|
|
||||||
// Wait for all pending micro ticks to be cleared
|
// Wait for all pending micro ticks to be cleared
|
||||||
@ -1728,13 +1765,16 @@ describe('component islands', () => {
|
|||||||
|
|
||||||
expect(result).toMatchInlineSnapshot(`
|
expect(result).toMatchInlineSnapshot(`
|
||||||
{
|
{
|
||||||
|
"chunks": {},
|
||||||
"head": {
|
"head": {
|
||||||
"link": [],
|
"link": [],
|
||||||
"style": [],
|
"style": [],
|
||||||
},
|
},
|
||||||
"html": "<pre nuxt-ssr-component-uid> Route: /foo
|
"html": "<pre nuxt-ssr-component-uid> Route: /foo
|
||||||
</pre>",
|
</pre>",
|
||||||
|
"props": {},
|
||||||
"state": {},
|
"state": {},
|
||||||
|
"teleports": {},
|
||||||
}
|
}
|
||||||
`)
|
`)
|
||||||
})
|
})
|
||||||
@ -1750,12 +1790,15 @@ describe('component islands', () => {
|
|||||||
}
|
}
|
||||||
expect(result).toMatchInlineSnapshot(`
|
expect(result).toMatchInlineSnapshot(`
|
||||||
{
|
{
|
||||||
|
"chunks": {},
|
||||||
"head": {
|
"head": {
|
||||||
"link": [],
|
"link": [],
|
||||||
"style": [],
|
"style": [],
|
||||||
},
|
},
|
||||||
"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>",
|
"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>",
|
||||||
|
"props": {},
|
||||||
"state": {},
|
"state": {},
|
||||||
|
"teleports": {},
|
||||||
}
|
}
|
||||||
`)
|
`)
|
||||||
})
|
})
|
||||||
@ -1769,18 +1812,69 @@ describe('component islands', () => {
|
|||||||
if (isDev()) {
|
if (isDev()) {
|
||||||
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')))
|
||||||
}
|
}
|
||||||
|
result.props = {}
|
||||||
|
result.teleports = {}
|
||||||
|
result.chunks = {}
|
||||||
|
result.html = result.html.replace(/ nuxt-ssr-client="([^"]*)"/g, (_, content) => `'nuxt-ssr-client="${content.split('-')[0]}"`)
|
||||||
|
|
||||||
expect(result).toMatchInlineSnapshot(`
|
expect(result).toMatchInlineSnapshot(`
|
||||||
{
|
{
|
||||||
|
"chunks": {},
|
||||||
"head": {
|
"head": {
|
||||||
"link": [],
|
"link": [],
|
||||||
"style": [],
|
"style": [],
|
||||||
},
|
},
|
||||||
"html": "<div nuxt-ssr-component-uid> This is a .server (20ms) async component that was very long ... <div id="async-server-component-count">2</div><div style="display:contents;" nuxt-ssr-slot-name="default"></div></div>",
|
"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 class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><div style="display:contents;" nuxt-ssr-slot-name="default"></div></div>",
|
||||||
|
"props": {},
|
||||||
"state": {},
|
"state": {},
|
||||||
|
"teleports": {},
|
||||||
}
|
}
|
||||||
`)
|
`)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (!isWebpack) {
|
||||||
|
it('render server component with selective client hydration', async () => {
|
||||||
|
const result: NuxtIslandResponse = await $fetch('/__nuxt_island/ServerWithClient')
|
||||||
|
if (isDev()) {
|
||||||
|
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')))
|
||||||
|
}
|
||||||
|
const { props, teleports, chunks } = result
|
||||||
|
result.props = {}
|
||||||
|
result.teleports = {}
|
||||||
|
result.chunks = {}
|
||||||
|
result.html = result.html.replace(/ nuxt-ssr-client="([^"]*)"/g, (_, content) => `'nuxt-ssr-client="${content.split('-')[0]}"`)
|
||||||
|
|
||||||
|
const propsEntries = Object.entries(props || {})
|
||||||
|
const teleportsEntries = Object.entries(teleports || {})
|
||||||
|
const chunksEntries = Object.entries(chunks || {})
|
||||||
|
|
||||||
|
expect(result).toMatchInlineSnapshot(`
|
||||||
|
{
|
||||||
|
"chunks": {},
|
||||||
|
"head": {
|
||||||
|
"link": [],
|
||||||
|
"style": [],
|
||||||
|
},
|
||||||
|
"html": "<div nuxt-ssr-component-uid> ServerWithClient.server.vue : <p>count: 0</p> This component should not be preloaded <div><!--[--><div>a</div><div>b</div><div>c</div><!--]--></div> This is not interactive <div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><div class="interactive-component-wrapper" style="border:solid 1px red;"> The component bellow is not a slot but declared as interactive <!--[--><div style="display: contents;"'nuxt-ssr-client="Counter"></div><!--teleport start--><!--teleport end--><!--]--></div></div>",
|
||||||
|
"props": {},
|
||||||
|
"state": {},
|
||||||
|
"teleports": {},
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
expect(propsEntries).toHaveLength(1)
|
||||||
|
expect(teleportsEntries).toHaveLength(1)
|
||||||
|
expect(propsEntries[0][0].startsWith('Counter-')).toBeTruthy()
|
||||||
|
expect(teleportsEntries[0][0].startsWith('Counter-')).toBeTruthy()
|
||||||
|
expect(chunksEntries[0][0]).toBe('Counter')
|
||||||
|
expect(propsEntries[0][1]).toMatchInlineSnapshot(`
|
||||||
|
{
|
||||||
|
"multiplier": 1,
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
expect(teleportsEntries[0][1]).toMatchInlineSnapshot('"<div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><!--teleport anchor-->"')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
it('renders pure components', async () => {
|
it('renders pure components', async () => {
|
||||||
const result: NuxtIslandResponse = await $fetch(withQuery('/__nuxt_island/PureComponent.json', {
|
const result: NuxtIslandResponse = await $fetch(withQuery('/__nuxt_island/PureComponent.json', {
|
||||||
props: JSON.stringify({
|
props: JSON.stringify({
|
||||||
@ -1874,6 +1968,13 @@ describe('component islands', () => {
|
|||||||
await page.locator('#first-sugar-counter button').click()
|
await page.locator('#first-sugar-counter button').click()
|
||||||
expect(await page.locator('#first-sugar-counter').innerHTML()).toContain('Sugar Counter 13')
|
expect(await page.locator('#first-sugar-counter').innerHTML()).toContain('Sugar Counter 13')
|
||||||
|
|
||||||
|
if (!isWebpack) {
|
||||||
|
// test client component interactivity
|
||||||
|
expect(await page.locator('.interactive-component-wrapper').innerHTML()).toContain('Sugar Counter 12')
|
||||||
|
await page.locator('.interactive-component-wrapper button').click()
|
||||||
|
expect(await page.locator('.interactive-component-wrapper').innerHTML()).toContain('Sugar Counter 13')
|
||||||
|
}
|
||||||
|
|
||||||
await page.close()
|
await page.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -5,6 +5,8 @@
|
|||||||
<div id="async-server-component-count">
|
<div id="async-server-component-count">
|
||||||
{{ count }}
|
{{ count }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Counter :multiplier="1" />
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
28
test/fixtures/basic/components/ServerWithClient.server.vue
vendored
Normal file
28
test/fixtures/basic/components/ServerWithClient.server.vue
vendored
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
ServerWithClient.server.vue :
|
||||||
|
<p>count: {{ count }}</p>
|
||||||
|
|
||||||
|
This component should not be preloaded
|
||||||
|
<ComponentWithRef />
|
||||||
|
|
||||||
|
This is not interactive
|
||||||
|
<Counter :multiplier="1" />
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="interactive-component-wrapper"
|
||||||
|
style="border: solid 1px red;"
|
||||||
|
>
|
||||||
|
The component bellow is not a slot but declared as interactive
|
||||||
|
|
||||||
|
<Counter
|
||||||
|
nuxt-client
|
||||||
|
:multiplier="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const count = ref(0)
|
||||||
|
</script>
|
4
test/fixtures/basic/nuxt.config.ts
vendored
4
test/fixtures/basic/nuxt.config.ts
vendored
@ -198,7 +198,9 @@ export default defineNuxtConfig({
|
|||||||
clientFallback: true,
|
clientFallback: true,
|
||||||
restoreState: true,
|
restoreState: true,
|
||||||
inlineSSRStyles: id => !!id && !id.includes('assets.vue'),
|
inlineSSRStyles: id => !!id && !id.includes('assets.vue'),
|
||||||
componentIslands: true,
|
componentIslands: {
|
||||||
|
selectiveClient: true
|
||||||
|
},
|
||||||
treeshakeClientOnly: true,
|
treeshakeClientOnly: true,
|
||||||
asyncContext: process.env.TEST_CONTEXT === 'async',
|
asyncContext: process.env.TEST_CONTEXT === 'async',
|
||||||
appManifest: process.env.TEST_MANIFEST !== 'manifest-off',
|
appManifest: process.env.TEST_MANIFEST !== 'manifest-off',
|
||||||
|
1
test/fixtures/basic/pages/islands.vue
vendored
1
test/fixtures/basic/pages/islands.vue
vendored
@ -105,6 +105,7 @@ const count = ref(0)
|
|||||||
</NuxtIsland>
|
</NuxtIsland>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ServerWithClient />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -31,5 +31,7 @@ const lazy = useRoute().query.lazy === 'true'
|
|||||||
:count="42"
|
:count="42"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<ServerWithClient :lazy="lazy" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -9,7 +9,8 @@ vi.mock('#build/nuxt.config.mjs', async (original) => {
|
|||||||
return {
|
return {
|
||||||
// @ts-expect-error virtual file
|
// @ts-expect-error virtual file
|
||||||
...(await original()),
|
...(await original()),
|
||||||
remoteComponentIslands: true
|
remoteComponentIslands: true,
|
||||||
|
selectiveClient: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user