feat(nuxt): add <NuxtClientFallback> component (#8216)

This commit is contained in:
Julien Huang 2023-03-08 22:13:06 +01:00 committed by GitHub
parent c4222b1f6e
commit 1729d2e42f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 500 additions and 31 deletions

View File

@ -342,6 +342,28 @@ The content will not be included in production builds and tree-shaken.
</template> </template>
``` ```
## <NuxtClientFallback> Component
::StabilityEdge
Nuxt provides the `<NuxtClientFallback>` component to render its content on the client if any of its children trigger an error in SSR.
You can specify a `fallbackTag` to make it render a specific tag if it fails to render on the server.
```html{}[pages/example.vue]
<template>
<div>
<Sidebar />
<!-- this component will be rendered on client-side -->
<NuxtClientFallback fallback-tag="span">
<Comments />
<BrokeInSSR />
</NuxtClientFallback>
</div>
</template>
```
::
## Library Authors ## Library Authors
Making Vue component libraries with automatic tree-shaking and component registration is super easy ✨ Making Vue component libraries with automatic tree-shaking and component registration is super easy ✨

View File

@ -0,0 +1,60 @@
---
title: "<NuxtClientFallback>"
description: "Nuxt provides `<NuxtClientFallback>` component to render its content on the client if any of its children trigger an error in SSR"
---
# `<NuxtClientFallback>`
Nuxt provides a `<NuxtClientFallback>` component to render its content on the client if any of its children trigger an error in SSR.
::alert{type=warning}
This component is experimental and in order to use it you must enable the `experimental.clientFallback` option in your `nuxt.config`.
::
:StabilityEdge{title=NuxtClientFallback}
## Events
- **`@ssr-error`**: Event emitted when a child triggers an error in SSR. Note that this will only be triggered on the server.
```vue
<template>
<NuxtClientFallback @ssr-error="logSomeError">
<!-- ... -->
</NuxtClientFallback>
</template>
```
## Props
- **placeholderTag** | **fallbackTag**: Specify a fallback tag to be rendered if the slot fails to render.
- **type**: `string`
- **default**: `div`
- **placeholder** | **fallback**: Specify fallback content to be rendered if the slot fails to render.
- **type**: `string`
```vue
<template>
<!-- render <span>Hello world</span> server-side if the default slot fails to render -->
<NuxtClientFallback fallback-tag="span" fallback="Hello world">
<BrokeInSsr />
</NuxtClientFallback>
</template>
```
## Slots
- **#fallback**: specify content to be displayed server-side if the slot fails to render.
```vue
<template>
<NuxtClientFallback>
<!-- ... -->
<template #fallback>
<!-- this will be rendered on server side if the default slot fails to render in ssr -->
<p>Hello world</p>
</template>
</NuxtClientFallback>
</template>
```

View File

@ -0,0 +1,46 @@
import { defineComponent, createElementBlock } from 'vue'
export default defineComponent({
name: 'NuxtClientFallback',
inheritAttrs: false,
props: {
uid: {
type: String
},
fallbackTag: {
type: String,
default: () => 'div'
},
fallback: {
type: String,
default: () => ''
},
placeholder: {
type: String
},
placeholderTag: {
type: String
}
},
emits: ['ssr-error'],
setup (props, ctx) {
const mounted = ref(false)
const ssrFailed = useState(`${props.uid}`)
if (ssrFailed.value) {
onMounted(() => { mounted.value = true })
}
return () => {
if (mounted.value) { return ctx.slots.default?.() }
if (ssrFailed.value) {
const slot = ctx.slots.placeholder || ctx.slots.fallback
if (slot) { return slot() }
const fallbackStr = props.placeholder || props.fallback
const fallbackTag = props.placeholderTag || props.fallbackTag
return createElementBlock(fallbackTag, null, fallbackStr)
}
return ctx.slots.default?.()
}
}
})

View File

@ -0,0 +1,74 @@
import { defineComponent, getCurrentInstance, onErrorCaptured } from 'vue'
import { ssrRenderVNode, ssrRenderAttrs, ssrRenderSlot } from 'vue/server-renderer'
import { createBuffer } from './utils'
const NuxtClientFallbackServer = defineComponent({
name: 'NuxtClientFallback',
inheritAttrs: false,
props: {
uid: {
type: String
},
fallbackTag: {
type: String,
default: () => 'div'
},
fallback: {
type: String,
default: () => ''
},
placeholder: {
type: String
},
placeholderTag: {
type: String
}
},
emits: ['ssr-error'],
setup (props, ctx) {
const vm = getCurrentInstance()
const ssrFailed = ref(false)
onErrorCaptured(() => {
useState(`${props.uid}`, () => true)
ssrFailed.value = true
ctx.emit('ssr-error')
return false
})
try {
const defaultSlot = ctx.slots.default?.()
const ssrVNodes = createBuffer()
for (let i = 0; i < defaultSlot.length; i++) {
ssrRenderVNode(ssrVNodes.push, defaultSlot[i], vm)
}
return { ssrFailed, ssrVNodes }
} catch {
// catch in dev
useState(`${props.uid}`, () => true)
ctx.emit('ssr-error')
return { ssrFailed: true, ssrVNodes: [] }
}
},
ssrRender (ctx, push, parent) {
if (ctx.ssrFailed) {
const { fallback, placeholder } = ctx.$slots
if (fallback || placeholder) {
ssrRenderSlot(ctx.$slots, fallback ? 'fallback' : 'placeholder', {}, null, push, parent)
} else {
const content = ctx.placeholder || ctx.fallback
const tag = ctx.placeholderTag || ctx.fallbackTag
push(`<${tag}${ssrRenderAttrs(ctx.$attrs)}>${content}</${tag}>`)
}
} else {
// push Fragment markup
push('<!--[-->')
push(ctx.ssrVNodes.getBuffer())
push('<!--]-->')
}
}
})
export default NuxtClientFallbackServer

View File

@ -1,5 +1,7 @@
import { defineComponent, h } from 'vue' import { defineComponent, h } from 'vue'
import type { Component } from 'vue' import type { Component } from 'vue'
// eslint-disable-next-line
import { isString, isPromise, isArray } from '@vue/shared'
const Fragment = defineComponent({ const Fragment = defineComponent({
name: 'FragmentWrapper', name: 'FragmentWrapper',
@ -16,3 +18,35 @@ const Fragment = defineComponent({
export const _wrapIf = (component: Component, props: any, slots: any) => { export const _wrapIf = (component: Component, props: any, slots: any) => {
return { default: () => props ? h(component, props === true ? {} : props, slots) : h(Fragment, {}, slots) } return { default: () => props ? h(component, props === true ? {} : props, slots) : h(Fragment, {}, slots) }
} }
// eslint-disable-next-line no-use-before-define
export type SSRBuffer = SSRBufferItem[] & { hasAsync?: boolean }
export type SSRBufferItem = string | SSRBuffer | Promise<SSRBuffer>
/**
* create buffer retrieved from @vue/server-renderer
*
* @see https://github.com/vuejs/core/blob/9617dd4b2abc07a5dc40de6e5b759e851b4d0da1/packages/server-renderer/src/render.ts#L57
* @private
*/
export function createBuffer () {
let appendable = false
const buffer: SSRBuffer = []
return {
getBuffer (): SSRBuffer {
return buffer
},
push (item: SSRBufferItem) {
const isStringItem = isString(item)
if (appendable && isStringItem) {
buffer[buffer.length - 1] += item as string
} else {
buffer.push(item)
}
appendable = isStringItem
if (isPromise(item) || (isArray(item) && item.hasAsync)) {
buffer.hasAsync = true
}
}
}
}

View File

@ -0,0 +1,53 @@
import { createUnplugin } from 'unplugin'
import type { ComponentsOptions } from '@nuxt/schema'
import MagicString from 'magic-string'
import { isAbsolute, relative } from 'pathe'
import { hash } from 'ohash'
import { isVueTemplate } from './helpers'
interface LoaderOptions {
sourcemap?: boolean
transform?: ComponentsOptions['transform'],
rootDir: string
}
const CLIENT_FALLBACK_RE = /<(NuxtClientFallback|nuxt-client-fallback)( [^>]*)?>/
const CLIENT_FALLBACK_GLOBAL_RE = /<(NuxtClientFallback|nuxt-client-fallback)( [^>]*)?>/g
export const clientFallbackAutoIdPlugin = createUnplugin((options: LoaderOptions) => {
const exclude = options.transform?.exclude || []
const include = options.transform?.include || []
return {
name: 'nuxt:client-fallback-auto-id',
enforce: 'pre',
transformInclude (id) {
if (exclude.some(pattern => id.match(pattern))) {
return false
}
if (include.some(pattern => id.match(pattern))) {
return true
}
return isVueTemplate(id)
},
transform (code, id) {
if (!CLIENT_FALLBACK_RE.test(code)) { return }
const s = new MagicString(code)
const relativeID = isAbsolute(id) ? relative(options.rootDir, id) : id
let count = 0
s.replace(CLIENT_FALLBACK_GLOBAL_RE, (full, name, attrs) => {
count++
if (/ :?uid=/g.test(attrs)) { return full }
return `<${name} :uid="'${hash(relativeID)}' + JSON.stringify($props) + '${count}'" ${attrs ?? ''}>`
})
if (s.hasChanged()) {
return {
code: s.toString(),
map: options.sourcemap
? s.generateMap({ source: id, includeContent: true })
: undefined
}
}
}
}
})

View File

@ -0,0 +1,29 @@
import { pathToFileURL } from 'node:url'
import { parseQuery, parseURL } from 'ufo'
export function isVueTemplate (id: string) {
// Bare `.vue` file (in Vite)
if (id.endsWith('.vue')) {
return true
}
const { search } = parseURL(decodeURIComponent(pathToFileURL(id).href))
if (!search) {
return false
}
const query = parseQuery(search)
// Macro
if (query.macro) {
return true
}
// Non-Vue or Styles
if (!('vue' in query) || query.type === 'style') {
return false
}
// Query `?vue&type=template` (in Webpack or external template)
return true
}

View File

@ -1,11 +1,10 @@
import { pathToFileURL } from 'node:url'
import { createUnplugin } from 'unplugin' import { createUnplugin } from 'unplugin'
import { parseQuery, parseURL } from 'ufo'
import { genDynamicImport, genImport } from 'knitwork' import { genDynamicImport, genImport } from 'knitwork'
import MagicString from 'magic-string' import MagicString from 'magic-string'
import { pascalCase } from 'scule' import { pascalCase } from 'scule'
import { resolve } from 'pathe' import { resolve } from 'pathe'
import { distDir } from '../dirs' import { distDir } from '../dirs'
import { isVueTemplate } from './helpers'
import type { Component, ComponentsOptions } from 'nuxt/schema' import type { Component, ComponentsOptions } from 'nuxt/schema'
interface LoaderOptions { interface LoaderOptions {
@ -16,33 +15,6 @@ interface LoaderOptions {
experimentalComponentIslands?: boolean experimentalComponentIslands?: boolean
} }
function isVueTemplate (id: string) {
// Bare `.vue` file (in Vite)
if (id.endsWith('.vue')) {
return true
}
const { search } = parseURL(decodeURIComponent(pathToFileURL(id).href))
if (!search) {
return false
}
const query = parseQuery(search)
// Macro
if (query.macro) {
return true
}
// Non-Vue or Styles
if (!('vue' in query) || query.type === 'style') {
return false
}
// Query `?vue&type=template` (in webpack or external template)
return true
}
export const loaderPlugin = createUnplugin((options: LoaderOptions) => { export const loaderPlugin = createUnplugin((options: LoaderOptions) => {
const exclude = options.transform?.exclude || [] const exclude = options.transform?.exclude || []
const include = options.transform?.include || [] const include = options.transform?.include || []
@ -86,7 +58,7 @@ export const loaderPlugin = createUnplugin((options: LoaderOptions) => {
return identifier return identifier
} }
const isClientOnly = component.mode === 'client' const isClientOnly = component.mode === 'client' && component.pascalName !== 'NuxtClientFallback'
if (isClientOnly) { if (isClientOnly) {
imports.add(genImport('#app/components/client-only', [{ name: 'createClientOnly' }])) imports.add(genImport('#app/components/client-only', [{ name: 'createClientOnly' }]))
identifier += '_client' identifier += '_client'

View File

@ -2,6 +2,7 @@ import { statSync } from 'node:fs'
import { relative, resolve } from 'pathe' import { relative, resolve } from 'pathe'
import { defineNuxtModule, resolveAlias, addTemplate, addPluginTemplate, updateTemplates } from '@nuxt/kit' import { defineNuxtModule, resolveAlias, addTemplate, addPluginTemplate, updateTemplates } from '@nuxt/kit'
import { distDir } from '../dirs' import { distDir } from '../dirs'
import { clientFallbackAutoIdPlugin } from './client-fallback-auto-id'
import { componentsPluginTemplate, componentsTemplate, componentsIslandsTemplate, 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'
@ -198,6 +199,10 @@ export default defineNuxtModule<ComponentsOptions>({
getComponents getComponents
})) }))
} }
config.plugins.push(clientFallbackAutoIdPlugin.vite({
sourcemap: nuxt.options.sourcemap[mode],
rootDir: nuxt.options.rootDir
}))
config.plugins.push(loaderPlugin.vite({ config.plugins.push(loaderPlugin.vite({
sourcemap: nuxt.options.sourcemap[mode], sourcemap: nuxt.options.sourcemap[mode],
getComponents, getComponents,
@ -216,6 +221,10 @@ export default defineNuxtModule<ComponentsOptions>({
getComponents getComponents
})) }))
} }
config.plugins.push(clientFallbackAutoIdPlugin.webpack({
sourcemap: nuxt.options.sourcemap[mode],
rootDir: nuxt.options.rootDir
}))
config.plugins.push(loaderPlugin.webpack({ config.plugins.push(loaderPlugin.webpack({
sourcemap: nuxt.options.sourcemap[mode], sourcemap: nuxt.options.sourcemap[mode],
getComponents, getComponents,

View File

@ -220,6 +220,23 @@ async function initNuxt (nuxt: Nuxt) {
filePath: resolve(nuxt.options.appDir, 'components/nuxt-loading-indicator') filePath: resolve(nuxt.options.appDir, 'components/nuxt-loading-indicator')
}) })
// Add <NuxtClientFallback>
if (nuxt.options.experimental.clientFallback) {
addComponent({
name: 'NuxtClientFallback',
priority: 10, // built-in that we do not expect the user to override
filePath: resolve(nuxt.options.appDir, 'components/client-fallback.client'),
mode: 'client'
})
addComponent({
name: 'NuxtClientFallback',
priority: 10, // built-in that we do not expect the user to override
filePath: resolve(nuxt.options.appDir, 'components/client-fallback.server'),
mode: 'server'
})
}
// Add <NuxtIsland> // Add <NuxtIsland>
if (nuxt.options.experimental.componentIslands) { if (nuxt.options.experimental.componentIslands) {
addComponent({ addComponent({

View File

@ -127,6 +127,12 @@ export default defineUntypedSchema({
} }
}, },
/**
* Whether to enable the experimental `<NuxtClientFallback>` component for rendering content on the client
* if there's an error in SSR.
*/
clientFallback: false,
/** Enable cross-origin prefetch using the Speculation Rules API. */ /** Enable cross-origin prefetch using the Speculation Rules API. */
crossOriginPrefetch: false, crossOriginPrefetch: false,

View File

@ -280,6 +280,37 @@ describe('pages', () => {
expect(html).not.toContain('client only script') expect(html).not.toContain('client only script')
await expectNoClientErrors('/client-only-explicit-import') await expectNoClientErrors('/client-only-explicit-import')
}) })
it('client-fallback', async () => {
const classes = [
'clientfallback-non-stateful-setup',
'clientfallback-non-stateful',
'clientfallback-stateful-setup',
'clientfallback-stateful'
]
const html = await $fetch('/client-fallback')
// ensure failed components are not rendered server-side
expect(html).not.toContain('This breaks in server-side setup.')
classes.forEach(c => expect(html).not.toContain(c))
// ensure not failed component not be rendered
expect(html).not.toContain('Sugar Counter 12 x 0 = 0')
// ensure NuxtClientFallback is being rendered with its fallback tag and attributes
expect(html).toContain('<span class="break-in-ssr">this failed to render</span>')
// ensure Fallback slot is being rendered server side
expect(html).toContain('Hello world !')
// ensure not failed component are correctly rendered
expect(html).not.toContain('<p></p>')
expect(html).toContain('hi')
await expectNoClientErrors('/client-fallback')
const page = await createPage('/client-fallback')
await page.waitForLoadState('networkidle')
// ensure components reactivity once mounted
await page.locator('#increment-count').click()
expect(await page.locator('#sugar-counter').innerHTML()).toContain('Sugar Counter 12 x 1 = 12')
})
}) })
describe('nuxt links', () => { describe('nuxt links', () => {

View File

@ -40,7 +40,7 @@ describe.skipIf(isWindows)('minimal nuxt application', () => {
it('default server bundle size', async () => { it('default server bundle size', async () => {
stats.server = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir) stats.server = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
expect(stats.server.totalBytes).toBeLessThan(94400) expect(stats.server.totalBytes).toBeLessThan(94450)
const modules = await analyzeSizes('node_modules/**/*', serverDir) const modules = await analyzeSizes('node_modules/**/*', serverDir)
expect(modules.totalBytes).toBeLessThan(2713000) expect(modules.totalBytes).toBeLessThan(2713000)

View File

@ -0,0 +1,10 @@
<script setup>
// break server-side
const data = window.__NUXT__
</script>
<template>
<div>
This breaks in server-side setup. {{ data.serverRendered }}
</div>
</template>

View File

@ -0,0 +1,14 @@
<template>
<div>
non stateful
<NuxtClientFallback>
<BreakInSetup class="clientfallback-non-stateful" />
</NuxtClientFallback>
</div>
</template>
<script>
export default defineNuxtComponent({
name: 'ClientFallbackStateful'
})
</script>

View File

@ -0,0 +1,8 @@
<template>
<div>
non stateful setup
<NuxtClientFallback>
<BreakInSetup class="clientfallback-non-stateful-setup" />
</NuxtClientFallback>
</div>
</template>

View File

@ -0,0 +1,20 @@
<template>
<div>
stateful test {{ state }}
<NuxtClientFallback>
<BreakInSetup class="clientfallback-stateful" />
</NuxtClientFallback>
</div>
</template>
<script>
export default defineNuxtComponent({
name: 'ClientFallbackStateful',
setup () {
const state = ref(0)
return {
state
}
}
})
</script>

View File

@ -0,0 +1,13 @@
<template>
<div>
stateful setup {{ state }}
<NuxtClientFallback>
<BreakInSetup class="clientfallback-stateful-setup" />
</NuxtClientFallback>
</div>
</template>
<script setup>
const state = ref(1)
</script>

View File

@ -173,6 +173,7 @@ export default defineNuxtConfig({
} }
}, },
experimental: { experimental: {
clientFallback: true,
restoreState: true, restoreState: true,
inlineSSRStyles: id => !!id && !id.includes('assets.vue'), inlineSSRStyles: id => !!id && !id.includes('assets.vue'),
componentIslands: true, componentIslands: true,

View File

@ -0,0 +1,50 @@
<template>
<div>
Hello World
<div id="locator-for-playwright">
<!-- single child -->
<NuxtClientFallback fallback-tag="span" class="break-in-ssr" fallback="this failed to render">
<BreakInSetup />
</NuxtClientFallback>
<!-- multi child -->
<NuxtClientFallback>
<BreakInSetup class="broke-in-ssr" />
<BreakInSetup />
</NuxtClientFallback>
<!-- don't render if one child fails in ssr -->
<NuxtClientFallback>
<BreakInSetup />
<SugarCounter id="sugar-counter" :multiplier="multiplier" />
</NuxtClientFallback>
<!-- nested children fails -->
<NuxtClientFallback>
<div>
<BreakInSetup />
</div>
<SugarCounter :multiplier="multiplier" />
</NuxtClientFallback>
<!-- should be rendered -->
<NuxtClientFallback fallback-tag="p">
<FunctionalComponent />
</NuxtClientFallback>
<!-- fallback -->
<NuxtClientFallback>
<BreakInSetup />
<template #fallback>
<div>Hello world !</div>
</template>
</NuxtClientFallback>
<ClientFallbackStateful />
<ClientFallbackStatefulSetup />
<ClientFallbackNonStatefulSetup />
<ClientFallbackNonStateful />
</div>
<button id="increment-count" @click="multiplier++">
increment count
</button>
</div>
</template>
<script setup>
const multiplier = ref(0)
</script>