diff --git a/packages/nuxt/src/app/components/island-renderer.ts b/packages/nuxt/src/app/components/island-renderer.ts new file mode 100644 index 0000000000..ae4642efaa --- /dev/null +++ b/packages/nuxt/src/app/components/island-renderer.ts @@ -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 }, + 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)]) + ] + } +}) diff --git a/packages/nuxt/src/app/components/nuxt-island.ts b/packages/nuxt/src/app/components/nuxt-island.ts new file mode 100644 index 0000000000..32faf805c2 --- /dev/null +++ b/packages/nuxt/src/app/components/nuxt-island.ts @@ -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('') + const cHead = ref({ link: [], style: [] }) + useHead(cHead) + + function _fetchComponent () { + // TODO: Validate response + return $fetch(`/__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) + } +}) diff --git a/packages/nuxt/src/app/components/nuxt-root.vue b/packages/nuxt/src/app/components/nuxt-root.vue index e703fef657..3cc5167472 100644 --- a/packages/nuxt/src/app/components/nuxt-root.vue +++ b/packages/nuxt/src/app/components/nuxt-root.vue @@ -1,6 +1,7 @@ @@ -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 diff --git a/packages/nuxt/src/app/nuxt.ts b/packages/nuxt/src/app/nuxt.ts index d7a4d8e534..a6bd2aa22d 100644 --- a/packages/nuxt/src/app/nuxt.ts +++ b/packages/nuxt/src/app/nuxt.ts @@ -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('nuxt-app') @@ -50,6 +52,7 @@ export interface NuxtSSRContext extends SSRContext { payload: _NuxtApp['payload'] teleports?: Record renderMeta?: () => Promise | NuxtMeta + islandContext?: NuxtIslandContext } interface _NuxtApp { diff --git a/packages/nuxt/src/components/module.ts b/packages/nuxt/src/components/module.ts index 5f078f502b..493e65dcf9 100644 --- a/packages/nuxt/src/components/module.ts +++ b/packages/nuxt/src/components/module.ts @@ -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({ } 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({ 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' diff --git a/packages/nuxt/src/components/scan.ts b/packages/nuxt/src/components/scan.ts index 4cad3c587d..3b85cbd0fa 100644 --- a/packages/nuxt/src/components/scan.ts +++ b/packages/nuxt/src/components/scan.ts @@ -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 diff --git a/packages/nuxt/src/components/templates.ts b/packages/nuxt/src/components/templates.ts index 00b3356dbf..ee5cbf2892 100644 --- a/packages/nuxt/src/components/templates.ts +++ b/packages/nuxt/src/components/templates.ts @@ -57,7 +57,7 @@ export const componentsTemplate: NuxtTemplate = { 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 = { 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 = { + // 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 = { 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, '') diff --git a/packages/nuxt/src/core/nitro.ts b/packages/nuxt/src/core/nitro.ts index 43e918464c..825e267e57 100644 --- a/packages/nuxt/src/core/nitro.ts +++ b/packages/nuxt/src/core/nitro.ts @@ -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 }, diff --git a/packages/nuxt/src/core/nuxt.ts b/packages/nuxt/src/core/nuxt.ts index 67462b743c..274e047a59 100644 --- a/packages/nuxt/src/core/nuxt.ts +++ b/packages/nuxt/src/core/nuxt.ts @@ -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 + 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')) diff --git a/packages/nuxt/src/core/runtime/nitro/renderer.ts b/packages/nuxt/src/core/runtime/nitro/renderer.ts index 0b01f1b2e0..b68ca1cabb 100644 --- a/packages/nuxt/src/core/runtime/nitro/renderer.ts +++ b/packages/nuxt/src/core/runtime/nitro/renderer.ts @@ -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 + url?: string +} + +export interface NuxtIslandResponse { + id?: string + html: string + state: Record + head: { + link: (Record)[] + 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 { + // 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 @@ -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,24 +290,58 @@ 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' } } return response }) -function lazyCachedFunction (fn: () => Promise): () => Promise { +function lazyCachedFunction (fn: () => Promise): () => Promise { let res: Promise | null = null return () => { if (res === null) { @@ -291,6 +371,22 @@ function renderHTMLDocument (html: NuxtRenderHTMLContext) { ` } +// TOOD: Move to external library +const HTML_TAG_RE = /<(?[a-z]+)(? [^>]*)?>(?:(?[\s\S]*?)<\/\k)?/g +const HTML_TAG_ATTR_RE = /(?[a-z]+)="(?[^"]*)"/g +function extractHTMLTags (html: string) { + const tags: { tagName: string, attrs: Record, innerHTML: string }[] = [] + for (const tagMatch of html.matchAll(HTML_TAG_RE)) { + const attrs: Record = {} + 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[]) { const styleMap = await getSSRStyles() const inlinedStyles = new Set() diff --git a/packages/nuxt/test/fixture/components/global/Glob.vue b/packages/nuxt/test/fixture/components/global/Glob.vue new file mode 100644 index 0000000000..76d4486c40 --- /dev/null +++ b/packages/nuxt/test/fixture/components/global/Glob.vue @@ -0,0 +1,5 @@ + diff --git a/packages/nuxt/test/fixture/components/islands/Isle.vue b/packages/nuxt/test/fixture/components/islands/Isle.vue new file mode 100644 index 0000000000..48b2f5e7bd --- /dev/null +++ b/packages/nuxt/test/fixture/components/islands/Isle.vue @@ -0,0 +1,5 @@ + diff --git a/packages/nuxt/test/fixture/components/some-glob.global.vue b/packages/nuxt/test/fixture/components/some-glob.global.vue new file mode 100644 index 0000000000..02137b9175 --- /dev/null +++ b/packages/nuxt/test/fixture/components/some-glob.global.vue @@ -0,0 +1,5 @@ + diff --git a/packages/nuxt/test/fixture/components/some.island.vue b/packages/nuxt/test/fixture/components/some.island.vue new file mode 100644 index 0000000000..4ed36d3f49 --- /dev/null +++ b/packages/nuxt/test/fixture/components/some.island.vue @@ -0,0 +1,5 @@ + diff --git a/packages/nuxt/test/scan-components.test.ts b/packages/nuxt/test/scan-components.test.ts index 8e4fd12b7d..ee5000a371 100644 --- a/packages/nuxt/test/scan-components.test.ts +++ b/packages/nuxt/test/scan-components.test.ts @@ -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' } ] diff --git a/packages/schema/src/config/experimental.ts b/packages/schema/src/config/experimental.ts index c7096ca04a..8d0e86557b 100644 --- a/packages/schema/src/config/experimental.ts +++ b/packages/schema/src/config/experimental.ts @@ -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 and .island.vue files. + */ + componentIslands: false } }) diff --git a/packages/schema/src/types/components.ts b/packages/schema/src/types/components.ts index bce1c330cb..8291547f45 100644 --- a/packages/schema/src/types/components.ts +++ b/packages/schema/src/types/components.ts @@ -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) - /** * If enabled, registers components to be globally available. * */ global?: boolean + /** + * If enabled, registers components as islands + */ + island?: boolean } export interface ComponentsDir extends ScanDir { diff --git a/test/basic.test.ts b/test/basic.test.ts index f43b730538..401edb66b5 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -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": "
    Route: /foo
+        
", + "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(` + "
Was router enabled: true
Props:
{
+      number: 3487,
+      str: something,
+      obj: {
+        foo: 42,
+        bar: false,
+        me: hi
+      },
+      bool: false
+    }
" + `) + + 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' }) diff --git a/test/fixtures/basic/components/islands/PureComponent.vue b/test/fixtures/basic/components/islands/PureComponent.vue new file mode 100644 index 0000000000..61a3941ced --- /dev/null +++ b/test/fixtures/basic/components/islands/PureComponent.vue @@ -0,0 +1,26 @@ + + + + + diff --git a/test/fixtures/basic/components/islands/RouteComponent.vue b/test/fixtures/basic/components/islands/RouteComponent.vue new file mode 100644 index 0000000000..a5f3defe1a --- /dev/null +++ b/test/fixtures/basic/components/islands/RouteComponent.vue @@ -0,0 +1,5 @@ + diff --git a/test/fixtures/basic/nuxt.config.ts b/test/fixtures/basic/nuxt.config.ts index 5553166e34..70e64e9049 100644 --- a/test/fixtures/basic/nuxt.config.ts +++ b/test/fixtures/basic/nuxt.config.ts @@ -104,6 +104,7 @@ export default defineNuxtConfig({ }, experimental: { inlineSSRStyles: id => !!id && !id.includes('assets.vue'), + componentIslands: true, reactivityTransform: true, treeshakeClientOnly: true, payloadExtraction: true diff --git a/test/fixtures/basic/pages/islands.vue b/test/fixtures/basic/pages/islands.vue new file mode 100644 index 0000000000..ca229bc8a4 --- /dev/null +++ b/test/fixtures/basic/pages/islands.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/test/fixtures/basic/plugins/my-plugin.ts b/test/fixtures/basic/plugins/my-plugin.ts index 9f59aea022..627d9db911 100644 --- a/test/fixtures/basic/plugins/my-plugin.ts +++ b/test/fixtures/basic/plugins/my-plugin.ts @@ -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',