diff --git a/docs/2.guide/2.directory-structure/1.components.md b/docs/2.guide/2.directory-structure/1.components.md index eab70584693..99d40bed3f6 100644 --- a/docs/2.guide/2.directory-structure/1.components.md +++ b/docs/2.guide/2.directory-structure/1.components.md @@ -342,6 +342,28 @@ The content will not be included in production builds and tree-shaken. ``` +## Component + +::StabilityEdge + +Nuxt provides the `` 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] + +``` + +:: + ## Library Authors Making Vue component libraries with automatic tree-shaking and component registration is super easy ✨ diff --git a/docs/3.api/2.components/1.nuxt-client-fallback.md b/docs/3.api/2.components/1.nuxt-client-fallback.md new file mode 100644 index 00000000000..6c1ef63e64f --- /dev/null +++ b/docs/3.api/2.components/1.nuxt-client-fallback.md @@ -0,0 +1,60 @@ +--- +title: "" +description: "Nuxt provides `` component to render its content on the client if any of its children trigger an error in SSR" +--- + +# `` + +Nuxt provides a `` 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 + + ``` + +## 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 + +``` + +## Slots + +- **#fallback**: specify content to be displayed server-side if the slot fails to render. + +```vue + +``` + \ No newline at end of file diff --git a/packages/nuxt/src/app/components/client-fallback.client.mjs b/packages/nuxt/src/app/components/client-fallback.client.mjs new file mode 100644 index 00000000000..3c3e7df0327 --- /dev/null +++ b/packages/nuxt/src/app/components/client-fallback.client.mjs @@ -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?.() + } + } +}) diff --git a/packages/nuxt/src/app/components/client-fallback.server.mjs b/packages/nuxt/src/app/components/client-fallback.server.mjs new file mode 100644 index 00000000000..03df998cc92 --- /dev/null +++ b/packages/nuxt/src/app/components/client-fallback.server.mjs @@ -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}`) + } + } else { + // push Fragment markup + push('') + push(ctx.ssrVNodes.getBuffer()) + push('') + } + } +}) + +export default NuxtClientFallbackServer diff --git a/packages/nuxt/src/app/components/utils.ts b/packages/nuxt/src/app/components/utils.ts index 589890b466f..5865558278e 100644 --- a/packages/nuxt/src/app/components/utils.ts +++ b/packages/nuxt/src/app/components/utils.ts @@ -1,5 +1,7 @@ import { defineComponent, h } from 'vue' import type { Component } from 'vue' +// eslint-disable-next-line +import { isString, isPromise, isArray } from '@vue/shared' const Fragment = defineComponent({ name: 'FragmentWrapper', @@ -16,3 +18,35 @@ const Fragment = defineComponent({ export const _wrapIf = (component: Component, props: any, slots: any) => { 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 + +/** + * 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 + } + } + } +} diff --git a/packages/nuxt/src/components/client-fallback-auto-id.ts b/packages/nuxt/src/components/client-fallback-auto-id.ts new file mode 100644 index 00000000000..370122be15c --- /dev/null +++ b/packages/nuxt/src/components/client-fallback-auto-id.ts @@ -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 + } + } + } + } +}) diff --git a/packages/nuxt/src/components/helpers.ts b/packages/nuxt/src/components/helpers.ts new file mode 100644 index 00000000000..ebd4ccf5fe9 --- /dev/null +++ b/packages/nuxt/src/components/helpers.ts @@ -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 +} diff --git a/packages/nuxt/src/components/loader.ts b/packages/nuxt/src/components/loader.ts index 7372ac478b8..951a2d1c907 100644 --- a/packages/nuxt/src/components/loader.ts +++ b/packages/nuxt/src/components/loader.ts @@ -1,11 +1,10 @@ -import { pathToFileURL } from 'node:url' import { createUnplugin } from 'unplugin' -import { parseQuery, parseURL } from 'ufo' import { genDynamicImport, genImport } from 'knitwork' import MagicString from 'magic-string' import { pascalCase } from 'scule' import { resolve } from 'pathe' import { distDir } from '../dirs' +import { isVueTemplate } from './helpers' import type { Component, ComponentsOptions } from 'nuxt/schema' interface LoaderOptions { @@ -16,33 +15,6 @@ interface LoaderOptions { 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) => { const exclude = options.transform?.exclude || [] const include = options.transform?.include || [] @@ -86,7 +58,7 @@ export const loaderPlugin = createUnplugin((options: LoaderOptions) => { return identifier } - const isClientOnly = component.mode === 'client' + const isClientOnly = component.mode === 'client' && component.pascalName !== 'NuxtClientFallback' if (isClientOnly) { imports.add(genImport('#app/components/client-only', [{ name: 'createClientOnly' }])) identifier += '_client' diff --git a/packages/nuxt/src/components/module.ts b/packages/nuxt/src/components/module.ts index 71472e66d62..716b611b916 100644 --- a/packages/nuxt/src/components/module.ts +++ b/packages/nuxt/src/components/module.ts @@ -2,6 +2,7 @@ import { statSync } from 'node:fs' import { relative, resolve } from 'pathe' import { defineNuxtModule, resolveAlias, addTemplate, addPluginTemplate, updateTemplates } from '@nuxt/kit' import { distDir } from '../dirs' +import { clientFallbackAutoIdPlugin } from './client-fallback-auto-id' import { componentsPluginTemplate, componentsTemplate, componentsIslandsTemplate, componentsTypeTemplate } from './templates' import { scanComponents } from './scan' import { loaderPlugin } from './loader' @@ -198,6 +199,10 @@ export default defineNuxtModule({ getComponents })) } + config.plugins.push(clientFallbackAutoIdPlugin.vite({ + sourcemap: nuxt.options.sourcemap[mode], + rootDir: nuxt.options.rootDir + })) config.plugins.push(loaderPlugin.vite({ sourcemap: nuxt.options.sourcemap[mode], getComponents, @@ -216,6 +221,10 @@ export default defineNuxtModule({ getComponents })) } + config.plugins.push(clientFallbackAutoIdPlugin.webpack({ + sourcemap: nuxt.options.sourcemap[mode], + rootDir: nuxt.options.rootDir + })) config.plugins.push(loaderPlugin.webpack({ sourcemap: nuxt.options.sourcemap[mode], getComponents, diff --git a/packages/nuxt/src/core/nuxt.ts b/packages/nuxt/src/core/nuxt.ts index c09ac1ad645..2e7423100bd 100644 --- a/packages/nuxt/src/core/nuxt.ts +++ b/packages/nuxt/src/core/nuxt.ts @@ -220,6 +220,23 @@ async function initNuxt (nuxt: Nuxt) { filePath: resolve(nuxt.options.appDir, 'components/nuxt-loading-indicator') }) + // Add + 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 if (nuxt.options.experimental.componentIslands) { addComponent({ diff --git a/packages/schema/src/config/experimental.ts b/packages/schema/src/config/experimental.ts index b9f23e5aee6..30438eab2e0 100644 --- a/packages/schema/src/config/experimental.ts +++ b/packages/schema/src/config/experimental.ts @@ -127,6 +127,12 @@ export default defineUntypedSchema({ } }, + /** + * Whether to enable the experimental `` 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. */ crossOriginPrefetch: false, diff --git a/test/basic.test.ts b/test/basic.test.ts index 132051fb74e..ea6c08a5d29 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -280,6 +280,37 @@ describe('pages', () => { expect(html).not.toContain('client only script') 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('this failed to render') + // ensure Fallback slot is being rendered server side + expect(html).toContain('Hello world !') + + // ensure not failed component are correctly rendered + expect(html).not.toContain('

') + 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', () => { diff --git a/test/bundle.test.ts b/test/bundle.test.ts index 0592a4a7944..f3314554584 100644 --- a/test/bundle.test.ts +++ b/test/bundle.test.ts @@ -40,7 +40,7 @@ describe.skipIf(isWindows)('minimal nuxt application', () => { it('default server bundle size', async () => { 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) expect(modules.totalBytes).toBeLessThan(2713000) diff --git a/test/fixtures/basic/components/BreakInSetup.vue b/test/fixtures/basic/components/BreakInSetup.vue new file mode 100644 index 00000000000..8eef1408d05 --- /dev/null +++ b/test/fixtures/basic/components/BreakInSetup.vue @@ -0,0 +1,10 @@ + + + diff --git a/test/fixtures/basic/components/clientFallback/NonStateful.vue b/test/fixtures/basic/components/clientFallback/NonStateful.vue new file mode 100644 index 00000000000..20a8fe84dda --- /dev/null +++ b/test/fixtures/basic/components/clientFallback/NonStateful.vue @@ -0,0 +1,14 @@ + + + diff --git a/test/fixtures/basic/components/clientFallback/NonStatefulSetup.vue b/test/fixtures/basic/components/clientFallback/NonStatefulSetup.vue new file mode 100644 index 00000000000..49f2f4f7794 --- /dev/null +++ b/test/fixtures/basic/components/clientFallback/NonStatefulSetup.vue @@ -0,0 +1,8 @@ + diff --git a/test/fixtures/basic/components/clientFallback/Stateful.vue b/test/fixtures/basic/components/clientFallback/Stateful.vue new file mode 100644 index 00000000000..26c40b990db --- /dev/null +++ b/test/fixtures/basic/components/clientFallback/Stateful.vue @@ -0,0 +1,20 @@ + + + diff --git a/test/fixtures/basic/components/clientFallback/StatefulSetup.vue b/test/fixtures/basic/components/clientFallback/StatefulSetup.vue new file mode 100644 index 00000000000..1643e2007e8 --- /dev/null +++ b/test/fixtures/basic/components/clientFallback/StatefulSetup.vue @@ -0,0 +1,13 @@ + + + + diff --git a/test/fixtures/basic/nuxt.config.ts b/test/fixtures/basic/nuxt.config.ts index c249f3bfc67..22b03ef2164 100644 --- a/test/fixtures/basic/nuxt.config.ts +++ b/test/fixtures/basic/nuxt.config.ts @@ -173,6 +173,7 @@ export default defineNuxtConfig({ } }, experimental: { + clientFallback: true, restoreState: true, inlineSSRStyles: id => !!id && !id.includes('assets.vue'), componentIslands: true, diff --git a/test/fixtures/basic/pages/client-fallback.vue b/test/fixtures/basic/pages/client-fallback.vue new file mode 100644 index 00000000000..c2b0fce213b --- /dev/null +++ b/test/fixtures/basic/pages/client-fallback.vue @@ -0,0 +1,50 @@ + + +