mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-25 15:15:19 +00:00
feat(nuxt): experimental server component islands (#5689)
Co-authored-by: Pooya Parsa <pooya@pi0.io>
This commit is contained in:
parent
8089ec9652
commit
ab125bd1c5
33
packages/nuxt/src/app/components/island-renderer.ts
Normal file
33
packages/nuxt/src/app/components/island-renderer.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { createBlock, defineComponent, h, Teleport } from 'vue'
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
import * as islandComponents from '#build/components.islands.mjs'
|
||||||
|
import { createError } from '#app'
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
props: {
|
||||||
|
context: {
|
||||||
|
type: Object as () => { name: string, props?: Record<string, any> },
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async setup (props) {
|
||||||
|
// TODO: https://github.com/vuejs/core/issues/6207
|
||||||
|
const component = islandComponents[props.context.name]
|
||||||
|
|
||||||
|
if (!component) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: `Island component not found: ${JSON.stringify(component)}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof component === 'object') {
|
||||||
|
await component.__asyncLoader?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => [
|
||||||
|
createBlock(Teleport as any, { to: 'nuxt-island' }, [h(component || 'span', props.context.props)])
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
67
packages/nuxt/src/app/components/nuxt-island.ts
Normal file
67
packages/nuxt/src/app/components/nuxt-island.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { defineComponent, createStaticVNode, computed, ref, watch } from 'vue'
|
||||||
|
import { debounce } from 'perfect-debounce'
|
||||||
|
import { hash } from 'ohash'
|
||||||
|
import type { MetaObject } from '@nuxt/schema'
|
||||||
|
// eslint-disable-next-line import/no-restricted-paths
|
||||||
|
import type { NuxtIslandResponse } from '../../core/runtime/nitro/renderer'
|
||||||
|
import { useHead, useNuxtApp } from '#app'
|
||||||
|
|
||||||
|
const pKey = '_islandPromises'
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'NuxtIsland',
|
||||||
|
props: {
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
type: Object,
|
||||||
|
default: () => undefined
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async setup (props) {
|
||||||
|
const nuxtApp = useNuxtApp()
|
||||||
|
const hashId = computed(() => hash([props.name, props.props, props.context]))
|
||||||
|
const html = ref<string>('')
|
||||||
|
const cHead = ref<MetaObject>({ link: [], style: [] })
|
||||||
|
useHead(cHead)
|
||||||
|
|
||||||
|
function _fetchComponent () {
|
||||||
|
// TODO: Validate response
|
||||||
|
return $fetch<NuxtIslandResponse>(`/__nuxt_island/${props.name}:${hashId.value}`, {
|
||||||
|
params: {
|
||||||
|
...props.context,
|
||||||
|
props: props.props ? JSON.stringify(props.props) : undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchComponent () {
|
||||||
|
nuxtApp[pKey] = nuxtApp[pKey] || {}
|
||||||
|
if (!nuxtApp[pKey][hashId.value]) {
|
||||||
|
nuxtApp[pKey][hashId.value] = _fetchComponent().finally(() => {
|
||||||
|
delete nuxtApp[pKey][hashId.value]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const res: NuxtIslandResponse = await nuxtApp[pKey][hashId.value]
|
||||||
|
cHead.value.link = res.head.link
|
||||||
|
cHead.value.style = res.head.style
|
||||||
|
html.value = res.html
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.server || !nuxtApp.isHydrating) {
|
||||||
|
await fetchComponent()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.client) {
|
||||||
|
watch(props, debounce(fetchComponent, 100))
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => createStaticVNode(html.value, 1)
|
||||||
|
}
|
||||||
|
})
|
@ -1,6 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<Suspense @resolve="onResolve">
|
<Suspense @resolve="onResolve">
|
||||||
<ErrorComponent v-if="error" :error="error" />
|
<ErrorComponent v-if="error" :error="error" />
|
||||||
|
<IslandRendererer v-else-if="islandContext" :context="islandContext" />
|
||||||
<AppComponent v-else />
|
<AppComponent v-else />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</template>
|
</template>
|
||||||
@ -11,6 +12,9 @@ import { callWithNuxt, isNuxtError, showError, useError, useRoute, useNuxtApp }
|
|||||||
import AppComponent from '#build/app-component.mjs'
|
import AppComponent from '#build/app-component.mjs'
|
||||||
|
|
||||||
const ErrorComponent = defineAsyncComponent(() => import('#build/error-component.mjs').then(r => r.default || r))
|
const ErrorComponent = defineAsyncComponent(() => import('#build/error-component.mjs').then(r => r.default || r))
|
||||||
|
const IslandRendererer = process.server
|
||||||
|
? defineAsyncComponent(() => import('./island-renderer').then(r => r.default || r))
|
||||||
|
: () => null
|
||||||
|
|
||||||
const nuxtApp = useNuxtApp()
|
const nuxtApp = useNuxtApp()
|
||||||
const onResolve = nuxtApp.deferHydration()
|
const onResolve = nuxtApp.deferHydration()
|
||||||
@ -32,4 +36,7 @@ onErrorCaptured((err, target, info) => {
|
|||||||
callWithNuxt(nuxtApp, showError, [err])
|
callWithNuxt(nuxtApp, showError, [err])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Component islands context
|
||||||
|
const { islandContext } = process.server && nuxtApp.ssrContext
|
||||||
</script>
|
</script>
|
||||||
|
@ -6,6 +6,8 @@ import type { RuntimeConfig, AppConfigInput } from '@nuxt/schema'
|
|||||||
import { getContext } from 'unctx'
|
import { getContext } from 'unctx'
|
||||||
import type { SSRContext } from 'vue-bundle-renderer/runtime'
|
import type { SSRContext } from 'vue-bundle-renderer/runtime'
|
||||||
import type { H3Event } from 'h3'
|
import type { H3Event } from 'h3'
|
||||||
|
// eslint-disable-next-line import/no-restricted-paths
|
||||||
|
import type { NuxtIslandContext } from '../core/runtime/nitro/renderer'
|
||||||
|
|
||||||
const nuxtAppCtx = getContext<NuxtApp>('nuxt-app')
|
const nuxtAppCtx = getContext<NuxtApp>('nuxt-app')
|
||||||
|
|
||||||
@ -50,6 +52,7 @@ export interface NuxtSSRContext extends SSRContext {
|
|||||||
payload: _NuxtApp['payload']
|
payload: _NuxtApp['payload']
|
||||||
teleports?: Record<string, string>
|
teleports?: Record<string, string>
|
||||||
renderMeta?: () => Promise<NuxtMeta> | NuxtMeta
|
renderMeta?: () => Promise<NuxtMeta> | NuxtMeta
|
||||||
|
islandContext?: NuxtIslandContext
|
||||||
}
|
}
|
||||||
|
|
||||||
interface _NuxtApp {
|
interface _NuxtApp {
|
||||||
|
@ -3,7 +3,7 @@ import { relative, resolve } from 'pathe'
|
|||||||
import { defineNuxtModule, resolveAlias, addTemplate, addPluginTemplate, updateTemplates } from '@nuxt/kit'
|
import { defineNuxtModule, resolveAlias, addTemplate, addPluginTemplate, updateTemplates } from '@nuxt/kit'
|
||||||
import type { Component, ComponentsDir, ComponentsOptions } from '@nuxt/schema'
|
import type { Component, ComponentsDir, ComponentsOptions } from '@nuxt/schema'
|
||||||
import { distDir } from '../dirs'
|
import { distDir } from '../dirs'
|
||||||
import { componentsPluginTemplate, componentsTemplate, componentsTypeTemplate } from './templates'
|
import { componentsPluginTemplate, componentsTemplate, componentsIslandsTemplate, componentsTypeTemplate } from './templates'
|
||||||
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'
|
||||||
@ -14,7 +14,7 @@ function compareDirByPathLength ({ path: pathA }: { path: string }, { path: path
|
|||||||
return pathB.split(/[\\/]/).filter(Boolean).length - pathA.split(/[\\/]/).filter(Boolean).length
|
return pathB.split(/[\\/]/).filter(Boolean).length - pathA.split(/[\\/]/).filter(Boolean).length
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_COMPONENTS_DIRS_RE = /\/components$|\/components\/global$/
|
const DEFAULT_COMPONENTS_DIRS_RE = /\/components(\/global|\/islands)?$/
|
||||||
|
|
||||||
type getComponentsT = (mode?: 'client' | 'server' | 'all') => Component[]
|
type getComponentsT = (mode?: 'client' | 'server' | 'all') => Component[]
|
||||||
|
|
||||||
@ -44,6 +44,7 @@ export default defineNuxtModule<ComponentsOptions>({
|
|||||||
}
|
}
|
||||||
if (dir === true || dir === undefined) {
|
if (dir === true || dir === undefined) {
|
||||||
return [
|
return [
|
||||||
|
{ path: resolve(cwd, 'components/islands'), island: true },
|
||||||
{ path: resolve(cwd, 'components/global'), global: true },
|
{ path: resolve(cwd, 'components/global'), global: true },
|
||||||
{ path: resolve(cwd, 'components') }
|
{ path: resolve(cwd, 'components') }
|
||||||
]
|
]
|
||||||
@ -117,6 +118,12 @@ export default defineNuxtModule<ComponentsOptions>({
|
|||||||
addTemplate({ ...componentsTemplate, filename: 'components.server.mjs', options: { getComponents, mode: 'server' } })
|
addTemplate({ ...componentsTemplate, filename: 'components.server.mjs', options: { getComponents, mode: 'server' } })
|
||||||
// components.client.mjs
|
// components.client.mjs
|
||||||
addTemplate({ ...componentsTemplate, filename: 'components.client.mjs', options: { getComponents, mode: 'client' } })
|
addTemplate({ ...componentsTemplate, filename: 'components.client.mjs', options: { getComponents, mode: 'client' } })
|
||||||
|
// components.islands.mjs
|
||||||
|
if (nuxt.options.experimental.componentIslands) {
|
||||||
|
addTemplate({ ...componentsIslandsTemplate, filename: 'components.islands.mjs', options: { getComponents } })
|
||||||
|
} else {
|
||||||
|
addTemplate({ filename: 'components.islands.mjs', getContents: () => 'export default {}' })
|
||||||
|
}
|
||||||
|
|
||||||
nuxt.hook('vite:extendConfig', (config, { isClient }) => {
|
nuxt.hook('vite:extendConfig', (config, { isClient }) => {
|
||||||
const mode = isClient ? 'client' : 'server'
|
const mode = isClient ? 'client' : 'server'
|
||||||
|
@ -62,9 +62,10 @@ export async function scanComponents (dirs: ComponentsDir[], srcDir: string): Pr
|
|||||||
*/
|
*/
|
||||||
let fileName = basename(filePath, extname(filePath))
|
let fileName = basename(filePath, extname(filePath))
|
||||||
|
|
||||||
const global = /\.(global)$/.test(fileName) || dir.global
|
const island = /\.(island)(\.global)?$/.test(fileName) || dir.island
|
||||||
const mode = (fileName.match(/(?<=\.)(client|server)(\.global)?$/)?.[1] || 'all') as 'client' | 'server' | 'all'
|
const global = /\.(global)(\.island)?$/.test(fileName) || dir.global
|
||||||
fileName = fileName.replace(/(\.(client|server))?(\.global)?$/, '')
|
const mode = island ? 'server' : (fileName.match(/(?<=\.)(client|server)(\.global|\.island)*$/)?.[1] || 'all') as 'client' | 'server' | 'all'
|
||||||
|
fileName = fileName.replace(/(\.(client|server))?(\.global|\.island)*$/, '')
|
||||||
|
|
||||||
if (fileName.toLowerCase() === 'index') {
|
if (fileName.toLowerCase() === 'index') {
|
||||||
fileName = dir.pathPrefix === false ? basename(dirname(filePath)) : '' /* inherits from path */
|
fileName = dir.pathPrefix === false ? basename(dirname(filePath)) : '' /* inherits from path */
|
||||||
@ -107,6 +108,7 @@ export async function scanComponents (dirs: ComponentsDir[], srcDir: string): Pr
|
|||||||
// inheritable from directory configuration
|
// inheritable from directory configuration
|
||||||
mode,
|
mode,
|
||||||
global,
|
global,
|
||||||
|
island,
|
||||||
prefetch: Boolean(dir.prefetch),
|
prefetch: Boolean(dir.prefetch),
|
||||||
preload: Boolean(dir.preload),
|
preload: Boolean(dir.preload),
|
||||||
// specific to the file
|
// specific to the file
|
||||||
|
@ -57,7 +57,7 @@ export const componentsTemplate: NuxtTemplate<ComponentsTemplateContext> = {
|
|||||||
imports.add('import { defineAsyncComponent } from \'vue\'')
|
imports.add('import { defineAsyncComponent } from \'vue\'')
|
||||||
|
|
||||||
let num = 0
|
let num = 0
|
||||||
const components = options.getComponents(options.mode).flatMap((c) => {
|
const components = options.getComponents(options.mode).filter(c => !c.island).flatMap((c) => {
|
||||||
const exp = c.export === 'default' ? 'c.default || c' : `c['${c.export}']`
|
const exp = c.export === 'default' ? 'c.default || c' : `c['${c.export}']`
|
||||||
const comment = createImportMagicComments(c)
|
const comment = createImportMagicComments(c)
|
||||||
|
|
||||||
@ -78,16 +78,29 @@ export const componentsTemplate: NuxtTemplate<ComponentsTemplateContext> = {
|
|||||||
return [
|
return [
|
||||||
...imports,
|
...imports,
|
||||||
...components,
|
...components,
|
||||||
`export const componentNames = ${JSON.stringify(options.getComponents().map(c => c.pascalName))}`
|
`export const componentNames = ${JSON.stringify(options.getComponents().filter(c => !c.island).map(c => c.pascalName))}`
|
||||||
].join('\n')
|
].join('\n')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const componentsIslandsTemplate: NuxtTemplate<ComponentsTemplateContext> = {
|
||||||
|
// components.islands.mjs'
|
||||||
|
getContents ({ options }) {
|
||||||
|
return options.getComponents().filter(c => c.island).map(
|
||||||
|
(c) => {
|
||||||
|
const exp = c.export === 'default' ? 'c.default || c' : `c['${c.export}']`
|
||||||
|
const comment = createImportMagicComments(c)
|
||||||
|
return `export const ${c.pascalName} = defineAsyncComponent(${genDynamicImport(c.filePath, { comment })}.then(c => ${exp}))`
|
||||||
|
}
|
||||||
|
).join('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const componentsTypeTemplate: NuxtTemplate<ComponentsTemplateContext> = {
|
export const componentsTypeTemplate: NuxtTemplate<ComponentsTemplateContext> = {
|
||||||
filename: 'components.d.ts',
|
filename: 'components.d.ts',
|
||||||
getContents: ({ options, nuxt }) => {
|
getContents: ({ options, nuxt }) => {
|
||||||
const buildDir = nuxt.options.buildDir
|
const buildDir = nuxt.options.buildDir
|
||||||
const componentTypes = options.getComponents().map(c => [
|
const componentTypes = options.getComponents().filter(c => !c.island).map(c => [
|
||||||
c.pascalName,
|
c.pascalName,
|
||||||
`typeof ${genDynamicImport(isAbsolute(c.filePath)
|
`typeof ${genDynamicImport(isAbsolute(c.filePath)
|
||||||
? relative(buildDir, c.filePath).replace(/(?<=\w)\.(?!vue)\w+$/g, '')
|
? relative(buildDir, c.filePath).replace(/(?<=\w)\.(?!vue)\w+$/g, '')
|
||||||
|
@ -116,6 +116,7 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) {
|
|||||||
'process.env.NUXT_NO_SCRIPTS': !!nuxt.options.experimental.noScripts && !nuxt.options.dev,
|
'process.env.NUXT_NO_SCRIPTS': !!nuxt.options.experimental.noScripts && !nuxt.options.dev,
|
||||||
'process.env.NUXT_INLINE_STYLES': !!nuxt.options.experimental.inlineSSRStyles,
|
'process.env.NUXT_INLINE_STYLES': !!nuxt.options.experimental.inlineSSRStyles,
|
||||||
'process.env.NUXT_PAYLOAD_EXTRACTION': !!nuxt.options.experimental.payloadExtraction,
|
'process.env.NUXT_PAYLOAD_EXTRACTION': !!nuxt.options.experimental.payloadExtraction,
|
||||||
|
'process.env.NUXT_COMPONENT_ISLANDS': !!nuxt.options.experimental.componentIslands,
|
||||||
'process.dev': nuxt.options.dev,
|
'process.dev': nuxt.options.dev,
|
||||||
__VUE_PROD_DEVTOOLS__: false
|
__VUE_PROD_DEVTOOLS__: false
|
||||||
},
|
},
|
||||||
|
@ -2,11 +2,11 @@ import { join, normalize, resolve } from 'pathe'
|
|||||||
import { createHooks, createDebugger } from 'hookable'
|
import { createHooks, createDebugger } from 'hookable'
|
||||||
import type { Nuxt, NuxtOptions, NuxtHooks } from '@nuxt/schema'
|
import type { Nuxt, NuxtOptions, NuxtHooks } from '@nuxt/schema'
|
||||||
import { loadNuxtConfig, LoadNuxtOptions, nuxtCtx, installModule, addComponent, addVitePlugin, addWebpackPlugin, tryResolveModule, addPlugin } from '@nuxt/kit'
|
import { loadNuxtConfig, LoadNuxtOptions, nuxtCtx, installModule, addComponent, addVitePlugin, addWebpackPlugin, tryResolveModule, addPlugin } from '@nuxt/kit'
|
||||||
// Temporary until finding better placement
|
|
||||||
/* eslint-disable import/no-restricted-paths */
|
/* eslint-disable import/no-restricted-paths */
|
||||||
import escapeRE from 'escape-string-regexp'
|
import escapeRE from 'escape-string-regexp'
|
||||||
import fse from 'fs-extra'
|
import fse from 'fs-extra'
|
||||||
import { withoutLeadingSlash } from 'ufo'
|
import { withoutLeadingSlash } from 'ufo'
|
||||||
|
/* eslint-disable import/no-restricted-paths */
|
||||||
import pagesModule from '../pages/module'
|
import pagesModule from '../pages/module'
|
||||||
import metaModule from '../head/module'
|
import metaModule from '../head/module'
|
||||||
import componentsModule from '../components/module'
|
import componentsModule from '../components/module'
|
||||||
@ -167,6 +167,14 @@ async function initNuxt (nuxt: Nuxt) {
|
|||||||
filePath: resolve(nuxt.options.appDir, 'components/nuxt-loading-indicator')
|
filePath: resolve(nuxt.options.appDir, 'components/nuxt-loading-indicator')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Add <NuxtIsland>
|
||||||
|
if (nuxt.options.experimental.componentIslands) {
|
||||||
|
addComponent({
|
||||||
|
name: 'NuxtIsland',
|
||||||
|
filePath: resolve(nuxt.options.appDir, 'components/nuxt-island')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Add prerender payload support
|
// Add prerender payload support
|
||||||
if (!nuxt.options.dev && nuxt.options.experimental.payloadExtraction) {
|
if (!nuxt.options.dev && nuxt.options.experimental.payloadExtraction) {
|
||||||
addPlugin(resolve(nuxt.options.appDir, 'plugins/payload.client'))
|
addPlugin(resolve(nuxt.options.appDir, 'plugins/payload.client'))
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
import { createRenderer, renderResourceHeaders } from 'vue-bundle-renderer/runtime'
|
import { createRenderer, renderResourceHeaders } from 'vue-bundle-renderer/runtime'
|
||||||
import type { RenderResponse } from 'nitropack'
|
import type { RenderResponse } from 'nitropack'
|
||||||
import type { Manifest } from 'vite'
|
import type { Manifest } from 'vite'
|
||||||
import { appendHeader, createError, getQuery, writeEarlyHints } from 'h3'
|
import { appendHeader, getQuery, H3Event, writeEarlyHints, readBody, createError } from 'h3'
|
||||||
import devalue from '@nuxt/devalue'
|
import devalue from '@nuxt/devalue'
|
||||||
|
import destr from 'destr'
|
||||||
import { joinURL } from 'ufo'
|
import { joinURL } from 'ufo'
|
||||||
import { renderToString as _renderToString } from 'vue/server-renderer'
|
import { renderToString as _renderToString } from 'vue/server-renderer'
|
||||||
import { useRuntimeConfig, useNitroApp, defineRenderHandler, getRouteRules } from '#internal/nitro'
|
import { useRuntimeConfig, useNitroApp, defineRenderHandler, getRouteRules } from '#internal/nitro'
|
||||||
|
import { hash } from 'ohash'
|
||||||
// eslint-disable-next-line import/no-restricted-paths
|
// eslint-disable-next-line import/no-restricted-paths
|
||||||
import type { NuxtApp, NuxtSSRContext } from '#app'
|
import type { NuxtApp, NuxtSSRContext } from '#app'
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@ -19,6 +21,7 @@ globalThis.__buildAssetsURL = buildAssetsURL
|
|||||||
globalThis.__publicAssetsURL = publicAssetsURL
|
globalThis.__publicAssetsURL = publicAssetsURL
|
||||||
|
|
||||||
export interface NuxtRenderHTMLContext {
|
export interface NuxtRenderHTMLContext {
|
||||||
|
island?: boolean
|
||||||
htmlAttrs: string[]
|
htmlAttrs: string[]
|
||||||
head: string[]
|
head: string[]
|
||||||
bodyAttrs: string[]
|
bodyAttrs: string[]
|
||||||
@ -27,6 +30,23 @@ export interface NuxtRenderHTMLContext {
|
|||||||
bodyAppend: string[]
|
bodyAppend: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NuxtIslandContext {
|
||||||
|
id?: string
|
||||||
|
name: string
|
||||||
|
props?: Record<string, any>
|
||||||
|
url?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NuxtIslandResponse {
|
||||||
|
id?: string
|
||||||
|
html: string
|
||||||
|
state: Record<string, any>
|
||||||
|
head: {
|
||||||
|
link: (Record<string, string>)[]
|
||||||
|
style: ({ innerHTML: string, key: string })[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface NuxtRenderResponse {
|
export interface NuxtRenderResponse {
|
||||||
body: string,
|
body: string,
|
||||||
statusCode: number,
|
statusCode: number,
|
||||||
@ -115,12 +135,33 @@ const getSPARenderer = lazyCachedFunction(async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function getIslandContext (event: H3Event): Promise<NuxtIslandContext> {
|
||||||
|
// TODO: Strict validation for url
|
||||||
|
const url = event.req.url?.substring('/__nuxt_island'.length + 1) || ''
|
||||||
|
const [componentName, hashId] = url.split('?')[0].split(':')
|
||||||
|
|
||||||
|
// TODO: Validate context
|
||||||
|
const context = event.req.method === 'GET' ? getQuery(event) : await readBody(event)
|
||||||
|
|
||||||
|
const ctx: NuxtIslandContext = {
|
||||||
|
url: '/',
|
||||||
|
...context,
|
||||||
|
id: hashId,
|
||||||
|
name: componentName,
|
||||||
|
props: destr(context.props) || {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
const PAYLOAD_CACHE = (process.env.NUXT_PAYLOAD_EXTRACTION && process.env.prerender) ? new Map() : null // TODO: Use LRU cache
|
const PAYLOAD_CACHE = (process.env.NUXT_PAYLOAD_EXTRACTION && process.env.prerender) ? new Map() : null // TODO: Use LRU cache
|
||||||
const PAYLOAD_URL_RE = /\/_payload(\.[a-zA-Z0-9]+)?.js(\?.*)?$/
|
const PAYLOAD_URL_RE = /\/_payload(\.[a-zA-Z0-9]+)?.js(\?.*)?$/
|
||||||
|
|
||||||
const PRERENDER_NO_SSR_ROUTES = new Set(['/index.html', '/200.html', '/404.html'])
|
const PRERENDER_NO_SSR_ROUTES = new Set(['/index.html', '/200.html', '/404.html'])
|
||||||
|
|
||||||
export default defineRenderHandler(async (event) => {
|
export default defineRenderHandler(async (event) => {
|
||||||
|
const nitroApp = useNitroApp()
|
||||||
|
|
||||||
// Whether we're rendering an error page
|
// Whether we're rendering an error page
|
||||||
const ssrError = event.node.req.url?.startsWith('/__nuxt_error')
|
const ssrError = event.node.req.url?.startsWith('/__nuxt_error')
|
||||||
? getQuery(event) as Exclude<NuxtApp['payload']['error'], Error>
|
? getQuery(event) as Exclude<NuxtApp['payload']['error'], Error>
|
||||||
@ -129,7 +170,13 @@ export default defineRenderHandler(async (event) => {
|
|||||||
throw createError('Cannot directly render error page!')
|
throw createError('Cannot directly render error page!')
|
||||||
}
|
}
|
||||||
|
|
||||||
let url = ssrError?.url as string || event.node.req.url!
|
// Check for island component rendering
|
||||||
|
const islandContext = (process.env.NUXT_COMPONENT_ISLANDS && event.req.url?.startsWith('/__nuxt_island'))
|
||||||
|
? await getIslandContext(event)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
// Request url
|
||||||
|
let url = ssrError?.url as string || islandContext?.url || event.node.req.url!
|
||||||
|
|
||||||
// Whether we are rendering payload route
|
// Whether we are rendering payload route
|
||||||
const isRenderingPayload = PAYLOAD_URL_RE.test(url)
|
const isRenderingPayload = PAYLOAD_URL_RE.test(url)
|
||||||
@ -156,7 +203,8 @@ export default defineRenderHandler(async (event) => {
|
|||||||
(process.env.prerender ? PRERENDER_NO_SSR_ROUTES.has(url) : false),
|
(process.env.prerender ? PRERENDER_NO_SSR_ROUTES.has(url) : false),
|
||||||
error: !!ssrError,
|
error: !!ssrError,
|
||||||
nuxt: undefined!, /* NuxtApp */
|
nuxt: undefined!, /* NuxtApp */
|
||||||
payload: (ssrError ? { error: ssrError } : {}) as NuxtSSRContext['payload']
|
payload: (ssrError ? { error: ssrError } : {}) as NuxtSSRContext['payload'],
|
||||||
|
islandContext
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whether we are prerendering route
|
// Whether we are prerendering route
|
||||||
@ -212,6 +260,7 @@ export default defineRenderHandler(async (event) => {
|
|||||||
|
|
||||||
// Create render context
|
// Create render context
|
||||||
const htmlContext: NuxtRenderHTMLContext = {
|
const htmlContext: NuxtRenderHTMLContext = {
|
||||||
|
island: Boolean(islandContext),
|
||||||
htmlAttrs: normalizeChunks([renderedMeta.htmlAttrs]),
|
htmlAttrs: normalizeChunks([renderedMeta.htmlAttrs]),
|
||||||
head: normalizeChunks([
|
head: normalizeChunks([
|
||||||
renderedMeta.headTags,
|
renderedMeta.headTags,
|
||||||
@ -226,10 +275,7 @@ export default defineRenderHandler(async (event) => {
|
|||||||
renderedMeta.bodyScriptsPrepend,
|
renderedMeta.bodyScriptsPrepend,
|
||||||
ssrContext.teleports?.body
|
ssrContext.teleports?.body
|
||||||
]),
|
]),
|
||||||
body: [
|
body: (process.env.NUXT_COMPONENT_ISLANDS && islandContext) ? [] : [_rendered.html],
|
||||||
// TODO: Rename to _rendered.body in next vue-bundle-renderer
|
|
||||||
_rendered.html
|
|
||||||
],
|
|
||||||
bodyAppend: normalizeChunks([
|
bodyAppend: normalizeChunks([
|
||||||
process.env.NUXT_NO_SCRIPTS
|
process.env.NUXT_NO_SCRIPTS
|
||||||
? undefined
|
? undefined
|
||||||
@ -244,24 +290,58 @@ export default defineRenderHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Allow hooking into the rendered result
|
// Allow hooking into the rendered result
|
||||||
const nitroApp = useNitroApp()
|
|
||||||
await nitroApp.hooks.callHook('render:html', htmlContext, { event })
|
await nitroApp.hooks.callHook('render:html', htmlContext, { event })
|
||||||
|
|
||||||
|
// Response for component islands
|
||||||
|
if (process.env.NUXT_COMPONENT_ISLANDS && islandContext) {
|
||||||
|
const _tags = htmlContext.head.flatMap(head => extractHTMLTags(head))
|
||||||
|
const head: NuxtIslandResponse['head'] = {
|
||||||
|
link: _tags.filter(tag => tag.tagName === 'link' && tag.attrs.rel === 'stylesheet' && tag.attrs.href.includes('scoped') && !tag.attrs.href.includes('pages/')).map(tag => ({
|
||||||
|
key: 'island-link-' + hash(tag.attrs.href),
|
||||||
|
...tag.attrs
|
||||||
|
})),
|
||||||
|
style: _tags.filter(tag => tag.tagName === 'style' && tag.innerHTML).map(tag => ({
|
||||||
|
key: 'island-style-' + hash(tag.innerHTML),
|
||||||
|
innerHTML: tag.innerHTML
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const islandResponse: NuxtIslandResponse = {
|
||||||
|
id: islandContext.id,
|
||||||
|
head,
|
||||||
|
html: ssrContext.teleports!['nuxt-island'].replace(/<!--.*-->/g, ''),
|
||||||
|
state: ssrContext.payload.state
|
||||||
|
}
|
||||||
|
|
||||||
|
await nitroApp.hooks.callHook('render:island', islandResponse, { event, islandContext })
|
||||||
|
|
||||||
|
const response: RenderResponse = {
|
||||||
|
body: JSON.stringify(islandResponse, null, 2),
|
||||||
|
statusCode: event.res.statusCode,
|
||||||
|
statusMessage: event.res.statusMessage,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json;charset=utf-8',
|
||||||
|
'x-powered-by': 'Nuxt'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
// Construct HTML response
|
// Construct HTML response
|
||||||
const response: RenderResponse = {
|
const response: RenderResponse = {
|
||||||
body: renderHTMLDocument(htmlContext),
|
body: renderHTMLDocument(htmlContext),
|
||||||
statusCode: event.node.res.statusCode,
|
statusCode: event.node.res.statusCode,
|
||||||
statusMessage: event.node.res.statusMessage,
|
statusMessage: event.node.res.statusMessage,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'text/html;charset=UTF-8',
|
'content-type': 'text/html;charset=utf-8',
|
||||||
'X-Powered-By': 'Nuxt'
|
'x-powered-by': 'Nuxt'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
return response
|
||||||
})
|
})
|
||||||
|
|
||||||
function lazyCachedFunction <T> (fn: () => Promise<T>): () => Promise<T> {
|
function lazyCachedFunction<T> (fn: () => Promise<T>): () => Promise<T> {
|
||||||
let res: Promise<T> | null = null
|
let res: Promise<T> | null = null
|
||||||
return () => {
|
return () => {
|
||||||
if (res === null) {
|
if (res === null) {
|
||||||
@ -291,6 +371,22 @@ function renderHTMLDocument (html: NuxtRenderHTMLContext) {
|
|||||||
</html>`
|
</html>`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TOOD: Move to external library
|
||||||
|
const HTML_TAG_RE = /<(?<tag>[a-z]+)(?<rawAttrs> [^>]*)?>(?:(?<innerHTML>[\s\S]*?)<\/\k<tag>)?/g
|
||||||
|
const HTML_TAG_ATTR_RE = /(?<name>[a-z]+)="(?<value>[^"]*)"/g
|
||||||
|
function extractHTMLTags (html: string) {
|
||||||
|
const tags: { tagName: string, attrs: Record<string, string>, innerHTML: string }[] = []
|
||||||
|
for (const tagMatch of html.matchAll(HTML_TAG_RE)) {
|
||||||
|
const attrs: Record<string, string> = {}
|
||||||
|
for (const attrMatch of tagMatch.groups!.rawAttrs?.matchAll(HTML_TAG_ATTR_RE) || []) {
|
||||||
|
attrs[attrMatch.groups!.name] = attrMatch.groups!.value
|
||||||
|
}
|
||||||
|
const innerHTML = tagMatch.groups!.innerHTML || ''
|
||||||
|
tags.push({ tagName: tagMatch.groups!.tag, attrs, innerHTML })
|
||||||
|
}
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
async function renderInlineStyles (usedModules: Set<string> | string[]) {
|
async function renderInlineStyles (usedModules: Set<string> | string[]) {
|
||||||
const styleMap = await getSSRStyles()
|
const styleMap = await getSSRStyles()
|
||||||
const inlinedStyles = new Set<string>()
|
const inlinedStyles = new Set<string>()
|
||||||
|
5
packages/nuxt/test/fixture/components/global/Glob.vue
Normal file
5
packages/nuxt/test/fixture/components/global/Glob.vue
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
Global component from folder
|
||||||
|
</div>
|
||||||
|
</template>
|
5
packages/nuxt/test/fixture/components/islands/Isle.vue
Normal file
5
packages/nuxt/test/fixture/components/islands/Isle.vue
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
Island from folder
|
||||||
|
</div>
|
||||||
|
</template>
|
@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
Global component from suffix
|
||||||
|
</div>
|
||||||
|
</template>
|
5
packages/nuxt/test/fixture/components/some.island.vue
Normal file
5
packages/nuxt/test/fixture/components/some.island.vue
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
Island defined with suffix
|
||||||
|
</div>
|
||||||
|
</template>
|
@ -11,6 +11,36 @@ vi.mock('@nuxt/kit', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const dirs: ComponentsDir[] = [
|
const dirs: ComponentsDir[] = [
|
||||||
|
{
|
||||||
|
path: rFixture('components/islands'),
|
||||||
|
enabled: true,
|
||||||
|
extensions: [
|
||||||
|
'vue'
|
||||||
|
],
|
||||||
|
pattern: '**/*.{vue,}',
|
||||||
|
ignore: [
|
||||||
|
'**/*.stories.{js,ts,jsx,tsx}',
|
||||||
|
'**/*{M,.m,-m}ixin.{js,ts,jsx,tsx}',
|
||||||
|
'**/*.d.ts'
|
||||||
|
],
|
||||||
|
transpile: false,
|
||||||
|
island: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: rFixture('components/global'),
|
||||||
|
enabled: true,
|
||||||
|
extensions: [
|
||||||
|
'vue'
|
||||||
|
],
|
||||||
|
pattern: '**/*.{vue,}',
|
||||||
|
ignore: [
|
||||||
|
'**/*.stories.{js,ts,jsx,tsx}',
|
||||||
|
'**/*{M,.m,-m}ixin.{js,ts,jsx,tsx}',
|
||||||
|
'**/*.d.ts'
|
||||||
|
],
|
||||||
|
transpile: false,
|
||||||
|
global: true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: rFixture('components'),
|
path: rFixture('components'),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@ -57,6 +87,30 @@ const dirs: ComponentsDir[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const expectedComponents = [
|
const expectedComponents = [
|
||||||
|
{
|
||||||
|
chunkName: 'components/isle-server',
|
||||||
|
export: 'default',
|
||||||
|
global: undefined,
|
||||||
|
island: true,
|
||||||
|
kebabName: 'isle',
|
||||||
|
mode: 'server',
|
||||||
|
pascalName: 'Isle',
|
||||||
|
prefetch: false,
|
||||||
|
preload: false,
|
||||||
|
shortPath: 'components/islands/Isle.vue'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chunkName: 'components/glob',
|
||||||
|
export: 'default',
|
||||||
|
global: true,
|
||||||
|
island: undefined,
|
||||||
|
kebabName: 'glob',
|
||||||
|
mode: 'all',
|
||||||
|
pascalName: 'Glob',
|
||||||
|
prefetch: false,
|
||||||
|
preload: false,
|
||||||
|
shortPath: 'components/global/Glob.vue'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
mode: 'all',
|
mode: 'all',
|
||||||
pascalName: 'HelloWorld',
|
pascalName: 'HelloWorld',
|
||||||
@ -65,6 +119,7 @@ const expectedComponents = [
|
|||||||
shortPath: 'components/HelloWorld.vue',
|
shortPath: 'components/HelloWorld.vue',
|
||||||
export: 'default',
|
export: 'default',
|
||||||
global: undefined,
|
global: undefined,
|
||||||
|
island: undefined,
|
||||||
prefetch: false,
|
prefetch: false,
|
||||||
preload: false
|
preload: false
|
||||||
},
|
},
|
||||||
@ -76,6 +131,7 @@ const expectedComponents = [
|
|||||||
shortPath: 'components/Nuxt3.client.vue',
|
shortPath: 'components/Nuxt3.client.vue',
|
||||||
export: 'default',
|
export: 'default',
|
||||||
global: undefined,
|
global: undefined,
|
||||||
|
island: undefined,
|
||||||
prefetch: false,
|
prefetch: false,
|
||||||
preload: false
|
preload: false
|
||||||
},
|
},
|
||||||
@ -87,6 +143,7 @@ const expectedComponents = [
|
|||||||
shortPath: 'components/Nuxt3.server.vue',
|
shortPath: 'components/Nuxt3.server.vue',
|
||||||
export: 'default',
|
export: 'default',
|
||||||
global: undefined,
|
global: undefined,
|
||||||
|
island: undefined,
|
||||||
prefetch: false,
|
prefetch: false,
|
||||||
preload: false
|
preload: false
|
||||||
},
|
},
|
||||||
@ -98,8 +155,33 @@ const expectedComponents = [
|
|||||||
shortPath: 'components/parent-folder/index.server.vue',
|
shortPath: 'components/parent-folder/index.server.vue',
|
||||||
export: 'default',
|
export: 'default',
|
||||||
global: undefined,
|
global: undefined,
|
||||||
|
island: undefined,
|
||||||
prefetch: false,
|
prefetch: false,
|
||||||
preload: false
|
preload: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chunkName: 'components/some-glob',
|
||||||
|
export: 'default',
|
||||||
|
global: true,
|
||||||
|
island: undefined,
|
||||||
|
kebabName: 'some-glob',
|
||||||
|
mode: 'all',
|
||||||
|
pascalName: 'SomeGlob',
|
||||||
|
prefetch: false,
|
||||||
|
preload: false,
|
||||||
|
shortPath: 'components/some-glob.global.vue'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chunkName: 'components/some-server',
|
||||||
|
export: 'default',
|
||||||
|
global: undefined,
|
||||||
|
island: true,
|
||||||
|
kebabName: 'some',
|
||||||
|
mode: 'server',
|
||||||
|
pascalName: 'Some',
|
||||||
|
prefetch: false,
|
||||||
|
preload: false,
|
||||||
|
shortPath: 'components/some.island.vue'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -95,6 +95,11 @@ export default defineUntypedSchema({
|
|||||||
*
|
*
|
||||||
* @note nginx does not support 103 Early hints in the current version.
|
* @note nginx does not support 103 Early hints in the current version.
|
||||||
*/
|
*/
|
||||||
writeEarlyHints: false
|
writeEarlyHints: false,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Experimental component islands support with <NuxtIsland> and .island.vue files.
|
||||||
|
*/
|
||||||
|
componentIslands: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -8,6 +8,7 @@ export interface Component {
|
|||||||
prefetch: boolean
|
prefetch: boolean
|
||||||
preload: boolean
|
preload: boolean
|
||||||
global?: boolean
|
global?: boolean
|
||||||
|
island?: boolean
|
||||||
mode?: 'client' | 'server' | 'all'
|
mode?: 'client' | 'server' | 'all'
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,12 +54,15 @@ export interface ScanDir {
|
|||||||
isAsync?: boolean
|
isAsync?: boolean
|
||||||
|
|
||||||
extendComponent?: (component: Component) => Promise<Component | void> | (Component | void)
|
extendComponent?: (component: Component) => Promise<Component | void> | (Component | void)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If enabled, registers components to be globally available.
|
* If enabled, registers components to be globally available.
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
global?: boolean
|
global?: boolean
|
||||||
|
/**
|
||||||
|
* If enabled, registers components as islands
|
||||||
|
*/
|
||||||
|
island?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComponentsDir extends ScanDir {
|
export interface ComponentsDir extends ScanDir {
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { joinURL } from 'ufo'
|
import { joinURL, withQuery } from 'ufo'
|
||||||
import { isWindows } from 'std-env'
|
import { isWindows } from 'std-env'
|
||||||
import { setup, fetch, $fetch, startServer, createPage, url } from '@nuxt/test-utils'
|
|
||||||
// eslint-disable-next-line import/order
|
// eslint-disable-next-line import/order
|
||||||
|
import { setup, fetch, $fetch, startServer, createPage, url } from '@nuxt/test-utils'
|
||||||
|
import type { NuxtIslandResponse } from '../packages/nuxt/src/core/runtime/nitro/renderer'
|
||||||
import { expectNoClientErrors, renderPage, withLogs } from './utils'
|
import { expectNoClientErrors, renderPage, withLogs } from './utils'
|
||||||
|
|
||||||
await setup({
|
await setup({
|
||||||
@ -774,6 +775,94 @@ describe('app config', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('component islands', () => {
|
||||||
|
it('renders components with route', async () => {
|
||||||
|
const result: NuxtIslandResponse = await $fetch('/__nuxt_island/RouteComponent?url=/foo')
|
||||||
|
|
||||||
|
if (process.env.NUXT_TEST_DEV) {
|
||||||
|
result.head.link = result.head.link.filter(l => !l.href.includes('@nuxt+ui-templates'))
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(result).toMatchInlineSnapshot(`
|
||||||
|
{
|
||||||
|
"head": {
|
||||||
|
"link": [],
|
||||||
|
"style": [],
|
||||||
|
},
|
||||||
|
"html": "<pre> Route: /foo
|
||||||
|
</pre>",
|
||||||
|
"state": {},
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders pure components', async () => {
|
||||||
|
const result: NuxtIslandResponse = await $fetch(withQuery('/__nuxt_island/PureComponent', {
|
||||||
|
props: JSON.stringify({
|
||||||
|
bool: false,
|
||||||
|
number: 3487,
|
||||||
|
str: 'something',
|
||||||
|
obj: { foo: 42, bar: false, me: 'hi' }
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (process.env.NUXT_TEST_DEV) {
|
||||||
|
result.head.link = result.head.link.filter(l => !l.href.includes('@nuxt+ui-templates'))
|
||||||
|
}
|
||||||
|
result.head.style = result.head.style.map(s => ({
|
||||||
|
...s,
|
||||||
|
innerHTML: (s.innerHTML || '').replace(/data-v-[a-z0-9]+/, 'data-v-xxxxx'),
|
||||||
|
key: s.key.replace(/-[a-zA-Z0-9]+$/, '')
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (!(process.env.NUXT_TEST_DEV || process.env.TEST_WITH_WEBPACK)) {
|
||||||
|
expect(result.head).toMatchInlineSnapshot(`
|
||||||
|
{
|
||||||
|
"link": [],
|
||||||
|
"style": [
|
||||||
|
{
|
||||||
|
"innerHTML": "pre[data-v-xxxxx]{color:blue}",
|
||||||
|
"key": "island-style",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
} else if (process.env.NUXT_TEST_DEV) {
|
||||||
|
expect(result.head).toMatchInlineSnapshot(`
|
||||||
|
{
|
||||||
|
"link": [
|
||||||
|
{
|
||||||
|
"href": "/_nuxt/components/islands/PureComponent.vue?vue&type=style&index=0&scoped=c0c0cf89&lang.css",
|
||||||
|
"key": "island-link-gH9jFOYxRw",
|
||||||
|
"rel": "stylesheet",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"style": [],
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(result.html.replace(/data-v-\w+|"|<!--.*-->/g, '')).toMatchInlineSnapshot(`
|
||||||
|
"<div > Was router enabled: true <br > Props: <pre >{
|
||||||
|
number: 3487,
|
||||||
|
str: something,
|
||||||
|
obj: {
|
||||||
|
foo: 42,
|
||||||
|
bar: false,
|
||||||
|
me: hi
|
||||||
|
},
|
||||||
|
bool: false
|
||||||
|
}</pre></div>"
|
||||||
|
`)
|
||||||
|
|
||||||
|
expect(result.state).toMatchInlineSnapshot(`
|
||||||
|
{
|
||||||
|
"$shasRouter": true,
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe.skipIf(process.env.NUXT_TEST_DEV || isWindows)('payload rendering', () => {
|
describe.skipIf(process.env.NUXT_TEST_DEV || isWindows)('payload rendering', () => {
|
||||||
it('renders a payload', async () => {
|
it('renders a payload', async () => {
|
||||||
const payload = await $fetch('/random/a/_payload.js', { responseType: 'text' })
|
const payload = await $fetch('/random/a/_payload.js', { responseType: 'text' })
|
||||||
|
26
test/fixtures/basic/components/islands/PureComponent.vue
vendored
Normal file
26
test/fixtures/basic/components/islands/PureComponent.vue
vendored
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps({
|
||||||
|
bool: Boolean,
|
||||||
|
number: Number,
|
||||||
|
str: String,
|
||||||
|
obj: Object
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasRouter = useState('hasRouter', () => !!useRouter())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
Was router enabled: {{ hasRouter }}
|
||||||
|
<br>
|
||||||
|
Props:
|
||||||
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
|
<pre v-html="JSON.stringify(props, null, 2)" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
pre {
|
||||||
|
color: blue
|
||||||
|
}
|
||||||
|
</style>
|
5
test/fixtures/basic/components/islands/RouteComponent.vue
vendored
Normal file
5
test/fixtures/basic/components/islands/RouteComponent.vue
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<pre>
|
||||||
|
Route: {{ $route.fullPath }}
|
||||||
|
</pre>
|
||||||
|
</template>
|
1
test/fixtures/basic/nuxt.config.ts
vendored
1
test/fixtures/basic/nuxt.config.ts
vendored
@ -104,6 +104,7 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
inlineSSRStyles: id => !!id && !id.includes('assets.vue'),
|
inlineSSRStyles: id => !!id && !id.includes('assets.vue'),
|
||||||
|
componentIslands: true,
|
||||||
reactivityTransform: true,
|
reactivityTransform: true,
|
||||||
treeshakeClientOnly: true,
|
treeshakeClientOnly: true,
|
||||||
payloadExtraction: true
|
payloadExtraction: true
|
||||||
|
39
test/fixtures/basic/pages/islands.vue
vendored
Normal file
39
test/fixtures/basic/pages/islands.vue
vendored
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const islandProps = ref({
|
||||||
|
bool: true,
|
||||||
|
number: 100,
|
||||||
|
str: 'helo world',
|
||||||
|
obj: { json: 'works' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const routeIslandVisible = ref(false)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
Pure island component:
|
||||||
|
<div class="box">
|
||||||
|
<NuxtIsland name="PureComponent" :props="islandProps" />
|
||||||
|
<NuxtIsland name="PureComponent" :props="islandProps" />
|
||||||
|
</div>
|
||||||
|
<button @click="islandProps.number++">
|
||||||
|
Increase
|
||||||
|
</button>
|
||||||
|
<hr>
|
||||||
|
Route island component:
|
||||||
|
<div v-if="routeIslandVisible" class="box">
|
||||||
|
<NuxtIsland name="RouteComponent" :context="{ url: '/test' }" />
|
||||||
|
</div>
|
||||||
|
<button v-else @click="routeIslandVisible = true">
|
||||||
|
Show
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.box {
|
||||||
|
border: 1px solid black;
|
||||||
|
margin: 3px;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
</style>
|
2
test/fixtures/basic/plugins/my-plugin.ts
vendored
2
test/fixtures/basic/plugins/my-plugin.ts
vendored
@ -2,7 +2,7 @@ export default defineNuxtPlugin(() => {
|
|||||||
useHead({
|
useHead({
|
||||||
titleTemplate: '%s - Fixture'
|
titleTemplate: '%s - Fixture'
|
||||||
})
|
})
|
||||||
const path = useRoute().path
|
const path = useRoute()?.path
|
||||||
return {
|
return {
|
||||||
provide: {
|
provide: {
|
||||||
myPlugin: () => 'Injected by my-plugin',
|
myPlugin: () => 'Injected by my-plugin',
|
||||||
|
Loading…
Reference in New Issue
Block a user