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

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

Merged
merged 54 commits into from
Mar 8, 2023
Merged
Show file tree
Hide file tree
Changes from 38 commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
b946983
feat(nuxt): add ClientIfFail component
huang-julien Oct 28, 2022
df05c99
test(nuxt): add basic test for clientIfFail
huang-julien Oct 15, 2022
498bfa2
fix(nuxt): fix client-if-fail reactivity
huang-julien Oct 15, 2022
461a0d9
test(basic): fix component name
huang-julien Oct 15, 2022
2e4ec63
test(basic): fix tests
huang-julien Oct 15, 2022
243791b
fix(nuxt): allow empty slot for client-if-fail
huang-julien Oct 15, 2022
7ca9d4c
fix(nuxt): pass missing attributes to client-if-fail ssr divs
huang-julien Oct 15, 2022
a5fbfc7
docs(components): add ClientIfFail docs
huang-julien Oct 15, 2022
092da30
feat(nuxt): add ssr-error event to ClientIfFail
huang-julien Oct 15, 2022
fa47262
feat(nuxt): auto uid for ClientIfFail
huang-julien Oct 15, 2022
5541a77
test(nuxt): remove uid attrs from <ClientIfFail>
huang-julien Oct 15, 2022
529bdac
docs(nuxt): update doc + add ClientIfFail api
huang-julien Oct 15, 2022
60f582d
refactor(nuxt): remove duplicated code
huang-julien Oct 15, 2022
a8ede69
refactor(nuxt): rename `<ClientIfFail>` to `<ClientFallback>`
huang-julien Oct 16, 2022
e09bda6
feat(nuxt): add runtime id for ClientFallback component
huang-julien Oct 18, 2022
acb88a9
Update packages/nuxt/src/components/client-if-fail-auto-id.ts
huang-julien Oct 16, 2022
a020962
feat(nuxt): add fallbackTag to ClientFallback
huang-julien Oct 18, 2022
c442b49
fix(nuxt): fix client-fallback-auto-id plugin
huang-julien Oct 19, 2022
cd143d7
fix(nuxt): workaround for webpack to avoid transform multiple times
huang-julien Oct 19, 2022
09dca52
fix(nuxt): fix feature to dev/prod/webpack
huang-julien Oct 20, 2022
c9ff849
style: lint
huang-julien Oct 20, 2022
0c523c1
docs: lint
huang-julien Oct 20, 2022
69ac6f0
fix: add fallbackTag + fallback + fix auto id plugin
huang-julien Oct 21, 2022
689a7ab
docs(api): correct file order
huang-julien Oct 21, 2022
c85d99b
docs: correct docs
huang-julien Oct 22, 2022
38237b8
perf(nuxt): separate ClientFallback between server and client
huang-julien Oct 24, 2022
32b44d5
fix(nuxt): fix $setup is undef on nonSFC component for client fallbac…
huang-julien Oct 24, 2022
6ebcfe2
docs(api): add default and type to ClientFallback props
huang-julien Oct 24, 2022
20da286
docs(api): set correct files order
huang-julien Oct 24, 2022
17fa922
test(client-fallback): test more compilation possibility for ClientFa…
huang-julien Oct 26, 2022
0108a38
fix(nuxt): use acorn + estree-walker to handle different type of comp…
huang-julien Oct 28, 2022
471368d
chore: remove old file added during rebase
huang-julien Oct 28, 2022
7030197
chore(deps): add acorn and estree-walker
huang-julien Oct 28, 2022
5b5c9f6
refactor(nuxt): use $props and replace directly on <ClientFallback>
huang-julien Oct 30, 2022
d3c0e69
Update client-fallback-auto-id.ts
huang-julien Oct 31, 2022
b045921
Merge remote-tracking branch 'origin/main' into feat/clientIfFail
danielroe Feb 7, 2023
9270b81
chore: remove duplicate docs
danielroe Feb 7, 2023
daa0520
style: allow import from vue shared
danielroe Feb 7, 2023
2ecfce3
Apply suggestions from code review
huang-julien Feb 8, 2023
c4d9474
Merge branch 'main' into feat/clientIfFail
danielroe Feb 8, 2023
16d8603
Merge remote-tracking branch 'origin/main' into feat/clientIfFail
danielroe Feb 20, 2023
29adba5
style: lint
danielroe Feb 20, 2023
5f6311b
Merge branch 'main' into feat/clientIfFail
danielroe Mar 2, 2023
f50bbfb
Merge remote-tracking branch 'origin/main' into feat/clientIfFail
danielroe Mar 7, 2023
ba30fb2
test: bump server modules size
danielroe Mar 7, 2023
e1212f2
refactor: split server/client part
huang-julien Mar 7, 2023
66ab1bb
refactor: rename to `<NuxtClientFallback>`
danielroe Mar 8, 2023
52d94dc
fix: add priority to component
danielroe Mar 8, 2023
8bdf79f
docs: small revisions to content
danielroe Mar 8, 2023
34122b9
test: revert some renames
danielroe Mar 8, 2023
90dfd0e
refactor: make component experimental for now
danielroe Mar 8, 2023
3b69290
Merge branch 'main' into feat/clientIfFail
danielroe Mar 8, 2023
81a4d7b
Merge remote-tracking branch 'origin/main' into feat/clientIfFail
danielroe Mar 8, 2023
f176381
test: bump bytes
danielroe Mar 8, 2023
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
18 changes: 18 additions & 0 deletions docs/2.guide/2.directory-structure/1.components.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,24 @@ The content will not be included in production builds and tree-shaken.
</template>
```

## <ClientFallback> Component

Nuxt provides the `<ClientFallback>` component to render its slot in client-side if it fails to render in ssr.
You can specify a `fallbackTag` to `<ClientFallback>` to make it render a specific tag if it fails to render in ssr.

```html{}[pages/example.vue]
<template>
<div>
<Sidebar />
<!-- this component will be rendered on client-side -->
<ClientFallback fallback-tag="span">
<Comments />
<BrokeInSSR />
</ClientFallback>
</div>
</template>
```

## Library Authors

Making Vue component libraries with automatic tree-shaking and component registration is super easy ✨
Expand Down
54 changes: 54 additions & 0 deletions docs/3.api/2.components/1.client-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
title: "<ClientFallback>"
description: "Nuxt provides `<ClientFallback>` component to render its children on client side if some of them triggers an error in SSR"
---

# `<ClientFallback>`

Nuxt provides `<ClientFallback>` component to render its children on client side if some of them triggers an error in SSR.

## Events

- **`@ssr-error`**: Event emitted when a children triggers an error in SSR. Note that this will be only triggered server-side.

```vue
<template>
<ClientFallback @ssr-error="logSomeError">
<!-- ... -->
</ClientFallback>
</template>
```

## Props

- **placeholderTag** | **fallbackTag**: Specify a fallback tag to be rendered if the slot fails to render.
- **type**: `string`
- **default**: `div`
- **placeholder** | **fallback**: Specify a 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 -->
<ClientFallback fallback-tag="span" fallback="Hello world">
<BrokeInSsr />
</ClientFallback>
</template>
```

## Slots

- **#fallback**: specify a content to be displayed server-side if the slot fails to render.

```vue
<template>
<ClientFallback>
<!-- ... -->
<template #fallback>
<!-- this will be rendered on server side if the default slot fails to render in ssr -->
<p>Hello world</p>
</template>
</ClientFallback>
</template>
```

119 changes: 119 additions & 0 deletions packages/nuxt/src/app/components/client-fallback.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { defineComponent, createElementBlock, getCurrentInstance, onErrorCaptured } from 'vue'
import { ssrRenderVNode, ssrRenderAttrs, ssrRenderSlot } from 'vue/server-renderer'
import { createBuffer } from './utils'

const clientFallback = process.server
? {
name: 'ClientFallback',
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('<!--]-->')
}
}
}
: {
name: 'ClientFallback',
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?.()
}
}
}

export default defineComponent(clientFallback)
34 changes: 34 additions & 0 deletions packages/nuxt/src/app/components/utils.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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<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
}
}
}
}
52 changes: 52 additions & 0 deletions packages/nuxt/src/components/client-fallback-auto-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
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
}

huang-julien marked this conversation as resolved.
Show resolved Hide resolved
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 (!/[cC]lient-?[fF]allback/.test(code)) { return }
huang-julien marked this conversation as resolved.
Show resolved Hide resolved

const s = new MagicString(code)
const relativeID = isAbsolute(id) ? relative(options.rootDir, id) : id
let count = 0

s.replace(/<([cC]lient-?[fF]allback)( [^>]*)?>/g, (full, name, attrs) => {
huang-julien marked this conversation as resolved.
Show resolved Hide resolved
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
}
}
}
}
})
29 changes: 29 additions & 0 deletions packages/nuxt/src/components/helpers.ts
Original file line number Diff line number Diff line change
@@ -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
}
31 changes: 2 additions & 29 deletions packages/nuxt/src/components/loader.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { pathToFileURL } from 'node:url'
import { createUnplugin } from 'unplugin'
import { parseQuery, parseURL } from 'ufo'
import type { Component, ComponentsOptions } from '@nuxt/schema'
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'

interface LoaderOptions {
getComponents (): Component[]
mode: 'server' | 'client'
Expand All @@ -16,33 +16,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 || []
Expand Down
Loading