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

feat(nuxt): experimental server component islands #5689

Merged
merged 90 commits into from
Nov 24, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
90 commits
Select commit Hold shift + click to select a range
6e3d7e9
refactor: split out renderMeta into separate function
danielroe Jun 28, 2022
488d3ba
feat: support rendering individual components
danielroe Jun 28, 2022
aa98041
feat: render styles/scripts and extract type
danielroe Jul 1, 2022
109d48a
fix: workaround upstream vue issue
danielroe Jul 1, 2022
7547e03
fix: suppress vue-router warning
danielroe Jul 1, 2022
fdb09e2
feat: support POST requests as well
danielroe Jul 1, 2022
12d2592
refactor: use `destr` to parse
danielroe Jul 1, 2022
be7ab20
refactor: clarity
danielroe Jul 1, 2022
4759e41
fix: remove setHeader for `application/json`
danielroe Jul 1, 2022
8d6e23a
refactor: styles & scripts
danielroe Jul 1, 2022
4a1db3b
feat: add `isIndividualRender` composable
danielroe Jul 7, 2022
b1c32db
feat: support passing url in `customRender` payload
danielroe Jul 7, 2022
11c8e14
test: add test suite for server rendering individual components
danielroe Jul 7, 2022
f832468
test: improve snapshot readability
danielroe Jul 7, 2022
63978b4
chore: revert changes
danielroe Jul 7, 2022
be889e8
fix: disable universal router too
danielroe Jul 7, 2022
685fc4b
refactor: remove `isIndividualRender` and detect directly from app flag
danielroe Jul 8, 2022
08b8274
fix: use based on url
danielroe Jul 8, 2022
5696fd8
test: update state snapshots
danielroe Jul 8, 2022
9bb09f7
Merge branch 'main' into feat/selective-rendering
pi0 Jul 14, 2022
f180149
revert back json header
pi0 Jul 14, 2022
4a8e5f9
fix: always repond json in custom render mode
pi0 Jul 14, 2022
cc273c3
refactor: rename to isolated
pi0 Jul 14, 2022
206469f
Merge remote-tracking branch 'origin/main' into feat/selective-rendering
danielroe Jul 22, 2022
eb889fa
Merge remote-tracking branch 'origin/main' into feat/selective-rendering
danielroe Aug 1, 2022
0efce9a
refactor: only render island components (via `~/components/islands` o…
danielroe Aug 1, 2022
e712bd2
test: update components fixture for global + islands components
danielroe Aug 1, 2022
4fe0bf5
feat: strip server-only component code from client build
danielroe Aug 1, 2022
192ff02
Merge remote-tracking branch 'origin/main' into feat/selective-rendering
danielroe Aug 3, 2022
0a96bb5
Merge remote-tracking branch 'origin/main' into feat/selective-rendering
danielroe Aug 7, 2022
9ff0f7d
Merge branch 'main' into feat/selective-rendering
danielroe Aug 17, 2022
bf458c1
refactor: return `RenderResponse` for components too, remove nuxt hoo…
danielroe Aug 17, 2022
ea7e9ba
Merge branch 'main' into feat/selective-rendering
pi0 Aug 17, 2022
54300ff
isolated ~> island
pi0 Aug 17, 2022
e18942a
refactor: use single render endpoint
pi0 Aug 17, 2022
33858ed
Merge branch 'main' into feat/selective-rendering
pi0 Aug 18, 2022
9e18a88
fix lint and update test
pi0 Aug 18, 2022
8b8fa7a
update
pi0 Aug 18, 2022
d610fbb
update types
pi0 Aug 18, 2022
1cb6406
remove server-components plugin
pi0 Aug 18, 2022
b41bb84
add render:island hook
pi0 Aug 18, 2022
66d61fa
improved url format for caching and prerendering
pi0 Aug 18, 2022
6f8a2d9
handle 404 components
pi0 Aug 18, 2022
5372a87
feat: `NuxtIsland` component
pi0 Aug 18, 2022
7cad463
chore: remove unused imports
pi0 Aug 18, 2022
752dfc0
nullify island renderer for client-side
pi0 Aug 18, 2022
2891b0b
improve url matching
pi0 Aug 18, 2022
71a0c88
fix hashId matching
pi0 Aug 18, 2022
d5b8c1e
update tests
pi0 Aug 18, 2022
8994268
use NuxtIslandContext for nuxt app
pi0 Aug 18, 2022
3956bfb
refactor: reduce git diff
pi0 Aug 18, 2022
1101eba
fix: add name to hashId to make it always unique
pi0 Aug 18, 2022
340f858
update snapshot test
pi0 Aug 18, 2022
811c193
fix: split component islands
pi0 Aug 18, 2022
5d15708
nuxt-island: test client side and handle parallel requests to same
pi0 Aug 18, 2022
82e919c
remove comments from snapshot
pi0 Aug 18, 2022
b2fcd74
Merge branch 'main' into feat/selective-rendering
pi0 Aug 18, 2022
c2a723f
refactor: move tag parsing to the server renderer
pi0 Aug 18, 2022
4d10638
fix: strip all comments
pi0 Aug 18, 2022
b363043
update test
pi0 Aug 18, 2022
4a8fb3d
fix tag parsing and compress
pi0 Aug 18, 2022
22dfbcd
use computed object for head
pi0 Aug 18, 2022
aa7e07a
eslint is sometimes not funny either
pi0 Aug 18, 2022
d41fd7a
update fixture
pi0 Aug 18, 2022
dc7db7f
chore: typo
danielroe Sep 26, 2022
410beda
Merge remote-tracking branch 'origin/main' into feat/selective-rendering
danielroe Sep 26, 2022
2dce064
fix: respect island url
danielroe Sep 26, 2022
bbb12e4
test: remove hashes
danielroe Sep 26, 2022
ae18933
style: revert indent
danielroe Sep 26, 2022
1d98c28
test: add support for extracting style/title tags
danielroe Sep 26, 2022
96d1648
fix: type assertion
danielroe Sep 26, 2022
dd09120
style: reorder import
danielroe Sep 26, 2022
734bfcc
style: move comment
danielroe Sep 26, 2022
bd3e3d4
Merge remote-tracking branch 'origin/main' into feat/selective-rendering
danielroe Sep 26, 2022
7cb15e7
test: sort after map
danielroe Sep 26, 2022
d7f810d
Merge remote-tracking branch 'origin/main' into feat/selective-rendering
danielroe Sep 26, 2022
3334dbd
Merge remote-tracking branch 'origin/main' into feat/selective-rendering
danielroe Oct 9, 2022
de7d997
test: update snapshot
danielroe Oct 9, 2022
ee495e3
Merge branch 'main' into feat/selective-rendering
pi0 Nov 11, 2022
33c2658
small fixes
pi0 Nov 11, 2022
7c66c41
feat: implement style inlining with unique keys
pi0 Nov 11, 2022
2484081
debug test
pi0 Nov 11, 2022
3be020c
enable behind `experimental.componentIslands`
pi0 Nov 11, 2022
b25019b
Merge branch 'main' into feat/selective-rendering
pi0 Nov 15, 2022
24ea58d
try to normalize head for dev
pi0 Nov 15, 2022
73f6bb5
add fallback for components.islands.mjs when disabled
pi0 Nov 15, 2022
dfaaf6d
Merge branch 'main' into feat/selective-rendering
pi0 Nov 15, 2022
e1148a4
rest: mask data-v- id
pi0 Nov 15, 2022
3529f0c
Merge branch 'main' into feat/selective-rendering
pi0 Nov 24, 2022
3a65a8d
fix missing key type
pi0 Nov 24, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
pi0 marked this conversation as resolved.
Show resolved Hide resolved
}
})
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