mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-22 05:35:13 +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>
|
||||
<Suspense @resolve="onResolve">
|
||||
<ErrorComponent v-if="error" :error="error" />
|
||||
<IslandRendererer v-else-if="islandContext" :context="islandContext" />
|
||||
<AppComponent v-else />
|
||||
</Suspense>
|
||||
</template>
|
||||
@ -11,6 +12,9 @@ import { callWithNuxt, isNuxtError, showError, useError, useRoute, useNuxtApp }
|
||||
import AppComponent from '#build/app-component.mjs'
|
||||
|
||||
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 onResolve = nuxtApp.deferHydration()
|
||||
@ -32,4 +36,7 @@ onErrorCaptured((err, target, info) => {
|
||||
callWithNuxt(nuxtApp, showError, [err])
|
||||
}
|
||||
})
|
||||
|
||||
// Component islands context
|
||||
const { islandContext } = process.server && nuxtApp.ssrContext
|
||||
</script>
|
||||
|
@ -6,6 +6,8 @@ import type { RuntimeConfig, AppConfigInput } from '@nuxt/schema'
|
||||
import { getContext } from 'unctx'
|
||||
import type { SSRContext } from 'vue-bundle-renderer/runtime'
|
||||
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')
|
||||
|
||||
@ -50,6 +52,7 @@ export interface NuxtSSRContext extends SSRContext {
|
||||
payload: _NuxtApp['payload']
|
||||
teleports?: Record<string, string>
|
||||
renderMeta?: () => Promise<NuxtMeta> | NuxtMeta
|
||||
islandContext?: NuxtIslandContext
|
||||
}
|
||||
|
||||
interface _NuxtApp {
|
||||
|
@ -3,7 +3,7 @@ import { relative, resolve } from 'pathe'
|
||||
import { defineNuxtModule, resolveAlias, addTemplate, addPluginTemplate, updateTemplates } from '@nuxt/kit'
|
||||
import type { Component, ComponentsDir, ComponentsOptions } from '@nuxt/schema'
|
||||
import { distDir } from '../dirs'
|
||||
import { componentsPluginTemplate, componentsTemplate, componentsTypeTemplate } from './templates'
|
||||
import { componentsPluginTemplate, componentsTemplate, componentsIslandsTemplate, componentsTypeTemplate } from './templates'
|
||||
import { scanComponents } from './scan'
|
||||
import { loaderPlugin } from './loader'
|
||||
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
|
||||
}
|
||||
|
||||
const DEFAULT_COMPONENTS_DIRS_RE = /\/components$|\/components\/global$/
|
||||
const DEFAULT_COMPONENTS_DIRS_RE = /\/components(\/global|\/islands)?$/
|
||||
|
||||
type getComponentsT = (mode?: 'client' | 'server' | 'all') => Component[]
|
||||
|
||||
@ -44,6 +44,7 @@ export default defineNuxtModule<ComponentsOptions>({
|
||||
}
|
||||
if (dir === true || dir === undefined) {
|
||||
return [
|
||||
{ path: resolve(cwd, 'components/islands'), island: true },
|
||||
{ path: resolve(cwd, 'components/global'), global: true },
|
||||
{ path: resolve(cwd, 'components') }
|
||||
]
|
||||
@ -117,6 +118,12 @@ export default defineNuxtModule<ComponentsOptions>({
|
||||
addTemplate({ ...componentsTemplate, filename: 'components.server.mjs', options: { getComponents, mode: 'server' } })
|
||||
// components.client.mjs
|
||||
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 }) => {
|
||||
const mode = isClient ? 'client' : 'server'
|
||||
|
@ -62,9 +62,10 @@ export async function scanComponents (dirs: ComponentsDir[], srcDir: string): Pr
|
||||
*/
|
||||
let fileName = basename(filePath, extname(filePath))
|
||||
|
||||
const global = /\.(global)$/.test(fileName) || dir.global
|
||||
const mode = (fileName.match(/(?<=\.)(client|server)(\.global)?$/)?.[1] || 'all') as 'client' | 'server' | 'all'
|
||||
fileName = fileName.replace(/(\.(client|server))?(\.global)?$/, '')
|
||||
const island = /\.(island)(\.global)?$/.test(fileName) || dir.island
|
||||
const global = /\.(global)(\.island)?$/.test(fileName) || dir.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') {
|
||||
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
|
||||
mode,
|
||||
global,
|
||||
island,
|
||||
prefetch: Boolean(dir.prefetch),
|
||||
preload: Boolean(dir.preload),
|
||||
// specific to the file
|
||||
|
@ -57,7 +57,7 @@ export const componentsTemplate: NuxtTemplate<ComponentsTemplateContext> = {
|
||||
imports.add('import { defineAsyncComponent } from \'vue\'')
|
||||
|
||||
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 comment = createImportMagicComments(c)
|
||||
|
||||
@ -78,16 +78,29 @@ export const componentsTemplate: NuxtTemplate<ComponentsTemplateContext> = {
|
||||
return [
|
||||
...imports,
|
||||
...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')
|
||||
}
|
||||
}
|
||||
|
||||
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> = {
|
||||
filename: 'components.d.ts',
|
||||
getContents: ({ options, nuxt }) => {
|
||||
const buildDir = nuxt.options.buildDir
|
||||
const componentTypes = options.getComponents().map(c => [
|
||||
const componentTypes = options.getComponents().filter(c => !c.island).map(c => [
|
||||
c.pascalName,
|
||||
`typeof ${genDynamicImport(isAbsolute(c.filePath)
|
||||
? 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_INLINE_STYLES': !!nuxt.options.experimental.inlineSSRStyles,
|
||||
'process.env.NUXT_PAYLOAD_EXTRACTION': !!nuxt.options.experimental.payloadExtraction,
|
||||
'process.env.NUXT_COMPONENT_ISLANDS': !!nuxt.options.experimental.componentIslands,
|
||||
'process.dev': nuxt.options.dev,
|
||||
__VUE_PROD_DEVTOOLS__: false
|
||||
},
|
||||
|
@ -2,11 +2,11 @@ import { join, normalize, resolve } from 'pathe'
|
||||
import { createHooks, createDebugger } from 'hookable'
|
||||
import type { Nuxt, NuxtOptions, NuxtHooks } from '@nuxt/schema'
|
||||
import { loadNuxtConfig, LoadNuxtOptions, nuxtCtx, installModule, addComponent, addVitePlugin, addWebpackPlugin, tryResolveModule, addPlugin } from '@nuxt/kit'
|
||||
// Temporary until finding better placement
|
||||
/* eslint-disable import/no-restricted-paths */
|
||||
import escapeRE from 'escape-string-regexp'
|
||||
import fse from 'fs-extra'
|
||||
import { withoutLeadingSlash } from 'ufo'
|
||||
/* eslint-disable import/no-restricted-paths */
|
||||
import pagesModule from '../pages/module'
|
||||
import metaModule from '../head/module'
|
||||
import componentsModule from '../components/module'
|
||||
@ -167,6 +167,14 @@ async function initNuxt (nuxt: Nuxt) {
|
||||
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
|
||||
if (!nuxt.options.dev && nuxt.options.experimental.payloadExtraction) {
|
||||
addPlugin(resolve(nuxt.options.appDir, 'plugins/payload.client'))
|
||||
|
@ -1,11 +1,13 @@
|
||||
import { createRenderer, renderResourceHeaders } from 'vue-bundle-renderer/runtime'
|
||||
import type { RenderResponse } from 'nitropack'
|
||||
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 destr from 'destr'
|
||||
import { joinURL } from 'ufo'
|
||||
import { renderToString as _renderToString } from 'vue/server-renderer'
|
||||
import { useRuntimeConfig, useNitroApp, defineRenderHandler, getRouteRules } from '#internal/nitro'
|
||||
import { hash } from 'ohash'
|
||||
// eslint-disable-next-line import/no-restricted-paths
|
||||
import type { NuxtApp, NuxtSSRContext } from '#app'
|
||||
// @ts-ignore
|
||||
@ -19,6 +21,7 @@ globalThis.__buildAssetsURL = buildAssetsURL
|
||||
globalThis.__publicAssetsURL = publicAssetsURL
|
||||
|
||||
export interface NuxtRenderHTMLContext {
|
||||
island?: boolean
|
||||
htmlAttrs: string[]
|
||||
head: string[]
|
||||
bodyAttrs: string[]
|
||||
@ -27,6 +30,23 @@ export interface NuxtRenderHTMLContext {
|
||||
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 {
|
||||
body: string,
|
||||
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_URL_RE = /\/_payload(\.[a-zA-Z0-9]+)?.js(\?.*)?$/
|
||||
|
||||
const PRERENDER_NO_SSR_ROUTES = new Set(['/index.html', '/200.html', '/404.html'])
|
||||
|
||||
export default defineRenderHandler(async (event) => {
|
||||
const nitroApp = useNitroApp()
|
||||
|
||||
// Whether we're rendering an error page
|
||||
const ssrError = event.node.req.url?.startsWith('/__nuxt_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!')
|
||||
}
|
||||
|
||||
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
|
||||
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),
|
||||
error: !!ssrError,
|
||||
nuxt: undefined!, /* NuxtApp */
|
||||
payload: (ssrError ? { error: ssrError } : {}) as NuxtSSRContext['payload']
|
||||
payload: (ssrError ? { error: ssrError } : {}) as NuxtSSRContext['payload'],
|
||||
islandContext
|
||||
}
|
||||
|
||||
// Whether we are prerendering route
|
||||
@ -212,6 +260,7 @@ export default defineRenderHandler(async (event) => {
|
||||
|
||||
// Create render context
|
||||
const htmlContext: NuxtRenderHTMLContext = {
|
||||
island: Boolean(islandContext),
|
||||
htmlAttrs: normalizeChunks([renderedMeta.htmlAttrs]),
|
||||
head: normalizeChunks([
|
||||
renderedMeta.headTags,
|
||||
@ -226,10 +275,7 @@ export default defineRenderHandler(async (event) => {
|
||||
renderedMeta.bodyScriptsPrepend,
|
||||
ssrContext.teleports?.body
|
||||
]),
|
||||
body: [
|
||||
// TODO: Rename to _rendered.body in next vue-bundle-renderer
|
||||
_rendered.html
|
||||
],
|
||||
body: (process.env.NUXT_COMPONENT_ISLANDS && islandContext) ? [] : [_rendered.html],
|
||||
bodyAppend: normalizeChunks([
|
||||
process.env.NUXT_NO_SCRIPTS
|
||||
? undefined
|
||||
@ -244,17 +290,51 @@ export default defineRenderHandler(async (event) => {
|
||||
}
|
||||
|
||||
// Allow hooking into the rendered result
|
||||
const nitroApp = useNitroApp()
|
||||
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
|
||||
const response: RenderResponse = {
|
||||
body: renderHTMLDocument(htmlContext),
|
||||
statusCode: event.node.res.statusCode,
|
||||
statusMessage: event.node.res.statusMessage,
|
||||
headers: {
|
||||
'Content-Type': 'text/html;charset=UTF-8',
|
||||
'X-Powered-By': 'Nuxt'
|
||||
'content-type': 'text/html;charset=utf-8',
|
||||
'x-powered-by': 'Nuxt'
|
||||
}
|
||||
}
|
||||
|
||||
@ -291,6 +371,22 @@ function renderHTMLDocument (html: NuxtRenderHTMLContext) {
|
||||
</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[]) {
|
||||
const styleMap = await getSSRStyles()
|
||||
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[] = [
|
||||
{
|
||||
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'),
|
||||
enabled: true,
|
||||
@ -57,6 +87,30 @@ const dirs: ComponentsDir[] = [
|
||||
]
|
||||
|
||||
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',
|
||||
pascalName: 'HelloWorld',
|
||||
@ -65,6 +119,7 @@ const expectedComponents = [
|
||||
shortPath: 'components/HelloWorld.vue',
|
||||
export: 'default',
|
||||
global: undefined,
|
||||
island: undefined,
|
||||
prefetch: false,
|
||||
preload: false
|
||||
},
|
||||
@ -76,6 +131,7 @@ const expectedComponents = [
|
||||
shortPath: 'components/Nuxt3.client.vue',
|
||||
export: 'default',
|
||||
global: undefined,
|
||||
island: undefined,
|
||||
prefetch: false,
|
||||
preload: false
|
||||
},
|
||||
@ -87,6 +143,7 @@ const expectedComponents = [
|
||||
shortPath: 'components/Nuxt3.server.vue',
|
||||
export: 'default',
|
||||
global: undefined,
|
||||
island: undefined,
|
||||
prefetch: false,
|
||||
preload: false
|
||||
},
|
||||
@ -98,8 +155,33 @@ const expectedComponents = [
|
||||
shortPath: 'components/parent-folder/index.server.vue',
|
||||
export: 'default',
|
||||
global: undefined,
|
||||
island: undefined,
|
||||
prefetch: 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.
|
||||
*/
|
||||
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
|
||||
preload: boolean
|
||||
global?: boolean
|
||||
island?: boolean
|
||||
mode?: 'client' | 'server' | 'all'
|
||||
}
|
||||
|
||||
@ -53,12 +54,15 @@ export interface ScanDir {
|
||||
isAsync?: boolean
|
||||
|
||||
extendComponent?: (component: Component) => Promise<Component | void> | (Component | void)
|
||||
|
||||
/**
|
||||
* If enabled, registers components to be globally available.
|
||||
*
|
||||
*/
|
||||
global?: boolean
|
||||
/**
|
||||
* If enabled, registers components as islands
|
||||
*/
|
||||
island?: boolean
|
||||
}
|
||||
|
||||
export interface ComponentsDir extends ScanDir {
|
||||
|
@ -1,9 +1,10 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { joinURL } from 'ufo'
|
||||
import { joinURL, withQuery } from 'ufo'
|
||||
import { isWindows } from 'std-env'
|
||||
import { setup, fetch, $fetch, startServer, createPage, url } from '@nuxt/test-utils'
|
||||
// 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'
|
||||
|
||||
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', () => {
|
||||
it('renders a payload', async () => {
|
||||
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: {
|
||||
inlineSSRStyles: id => !!id && !id.includes('assets.vue'),
|
||||
componentIslands: true,
|
||||
reactivityTransform: true,
|
||||
treeshakeClientOnly: 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({
|
||||
titleTemplate: '%s - Fixture'
|
||||
})
|
||||
const path = useRoute().path
|
||||
const path = useRoute()?.path
|
||||
return {
|
||||
provide: {
|
||||
myPlugin: () => 'Injected by my-plugin',
|
||||
|
Loading…
Reference in New Issue
Block a user