feat(nuxt): experimental server component islands (#5689)

Co-authored-by: Pooya Parsa <pooya@pi0.io>
This commit is contained in:
Daniel Roe 2022-11-24 12:24:14 +00:00 committed by GitHub
parent 8089ec9652
commit ab125bd1c5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 533 additions and 25 deletions

View 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)])
]
}
})

View 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)
}
})

View File

@ -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>

View File

@ -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 {

View File

@ -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'

View File

@ -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

View 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, '')

View File

@ -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
}, },

View File

@ -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'))

View File

@ -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>()

View File

@ -0,0 +1,5 @@
<template>
<div>
Global component from folder
</div>
</template>

View File

@ -0,0 +1,5 @@
<template>
<div>
Island from folder
</div>
</template>

View File

@ -0,0 +1,5 @@
<template>
<div>
Global component from suffix
</div>
</template>

View File

@ -0,0 +1,5 @@
<template>
<div>
Island defined with suffix
</div>
</template>

View File

@ -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'
} }
] ]

View File

@ -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
} }
}) })

View File

@ -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 {

View File

@ -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' })

View 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>

View File

@ -0,0 +1,5 @@
<template>
<pre>
Route: {{ $route.fullPath }}
</pre>
</template>

View File

@ -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
View 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>

View File

@ -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',