Skip to content
This repository has been archived by the owner on Apr 6, 2023. It is now read-only.

Commit

Permalink
feat(nuxt): experimental server component islands (#5689)
Browse files Browse the repository at this point in the history
Co-authored-by: Pooya Parsa <[email protected]>
  • Loading branch information
danielroe and pi0 authored Nov 24, 2022
1 parent 8089ec9 commit ab125bd
Show file tree
Hide file tree
Showing 23 changed files with 533 additions and 25 deletions.
33 changes: 33 additions & 0 deletions packages/nuxt/src/app/components/island-renderer.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 67 additions & 0 deletions packages/nuxt/src/app/components/nuxt-island.ts
Original file line number Diff line number Diff line change
@@ -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)
}
})
7 changes: 7 additions & 0 deletions packages/nuxt/src/app/components/nuxt-root.vue
Original file line number Diff line number Diff line change
@@ -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>
Expand All @@ -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()
Expand All @@ -32,4 +36,7 @@ onErrorCaptured((err, target, info) => {
callWithNuxt(nuxtApp, showError, [err])
}
})
// Component islands context
const { islandContext } = process.server && nuxtApp.ssrContext
</script>
3 changes: 3 additions & 0 deletions packages/nuxt/src/app/nuxt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -50,6 +52,7 @@ export interface NuxtSSRContext extends SSRContext {
payload: _NuxtApp['payload']
teleports?: Record<string, string>
renderMeta?: () => Promise<NuxtMeta> | NuxtMeta
islandContext?: NuxtIslandContext
}

interface _NuxtApp {
Expand Down
11 changes: 9 additions & 2 deletions packages/nuxt/src/components/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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[]

Expand Down Expand Up @@ -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') }
]
Expand Down Expand Up @@ -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'
Expand Down
8 changes: 5 additions & 3 deletions packages/nuxt/src/components/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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
Expand Down
19 changes: 16 additions & 3 deletions packages/nuxt/src/components/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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, '')
Expand Down
1 change: 1 addition & 0 deletions packages/nuxt/src/core/nitro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
Expand Down
10 changes: 9 additions & 1 deletion packages/nuxt/src/core/nuxt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'))
Expand Down
Loading

0 comments on commit ab125bd

Please sign in to comment.