perf(nuxt): avoid making client-only component setup async (#28334)

This commit is contained in:
Julien Huang 2024-08-12 10:37:43 +02:00 committed by GitHub
parent c28be6553c
commit d21bd84439
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 60 additions and 21 deletions

View File

@ -1,5 +1,6 @@
import { cloneVNode, createElementBlock, createStaticVNode, defineComponent, getCurrentInstance, h, onMounted, provide, ref } from 'vue' import { cloneVNode, createElementBlock, createStaticVNode, defineComponent, getCurrentInstance, h, onMounted, provide, ref } from 'vue'
import type { ComponentInternalInstance, ComponentOptions, InjectionKey } from 'vue' import type { ComponentInternalInstance, ComponentOptions, InjectionKey } from 'vue'
import { isPromise } from '@vue/shared'
import { useNuxtApp } from '../nuxt' import { useNuxtApp } from '../nuxt'
import { getFragmentHTML } from './utils' import { getFragmentHTML } from './utils'
@ -42,9 +43,10 @@ export function createClientOnly<T extends ComponentOptions> (component: T) {
const clone = { ...component } const clone = { ...component }
if (clone.render) { if (clone.render) {
// override the component render (non script setup component) // override the component render (non script setup component) or dev mode
clone.render = (ctx: any, cache: any, $props: any, $setup: any, $data: any, $options: any) => { clone.render = (ctx: any, cache: any, $props: any, $setup: any, $data: any, $options: any) => {
if ($setup.mounted$ ?? ctx.mounted$) { // import.meta.client for server-side treeshakking
if (import.meta.client && ($setup.mounted$ ?? ctx.mounted$)) {
const res = component.render?.bind(ctx)(ctx, cache, $props, $setup, $data, $options) const res = component.render?.bind(ctx)(ctx, cache, $props, $setup, $data, $options)
return (res.children === null || typeof res.children === 'string') return (res.children === null || typeof res.children === 'string')
? cloneVNode(res) ? cloneVNode(res)
@ -63,33 +65,39 @@ export function createClientOnly<T extends ComponentOptions> (component: T) {
} }
clone.setup = (props, ctx) => { clone.setup = (props, ctx) => {
const nuxtApp = useNuxtApp()
const mounted$ = ref(import.meta.client && nuxtApp.isHydrating === false)
const instance = getCurrentInstance()! const instance = getCurrentInstance()!
const attrs = { ...instance.attrs } if (import.meta.server || nuxtApp.isHydrating) {
const attrs = { ...instance.attrs }
// remove existing directives during hydration
const directives = extractDirectives(instance)
// prevent attrs inheritance since a staticVNode is rendered before hydration
for (const key in attrs) {
delete instance.attrs[key]
}
// remove existing directives during hydration onMounted(() => {
const directives = extractDirectives(instance) Object.assign(instance.attrs, attrs)
// prevent attrs inheritance since a staticVNode is rendered before hydration instance.vnode.dirs = directives
for (const key in attrs) { })
delete instance.attrs[key]
} }
const mounted$ = ref(false)
onMounted(() => { onMounted(() => {
Object.assign(instance.attrs, attrs)
instance.vnode.dirs = directives
mounted$.value = true mounted$.value = true
}) })
const setupState = component.setup?.(props, ctx) || {}
return Promise.resolve(component.setup?.(props, ctx) || {}) if (isPromise(setupState)) {
.then((setupState) => { return Promise.resolve(setupState).then((setupState) => {
if (typeof setupState !== 'function') { if (typeof setupState !== 'function') {
setupState = setupState || {} setupState = setupState || {}
setupState.mounted$ = mounted$ setupState.mounted$ = mounted$
return setupState return setupState
} }
return (...args: any[]) => { return (...args: any[]) => {
if (mounted$.value) { if (import.meta.client && (mounted$.value || !nuxtApp.isHydrating)) {
const res = setupState(...args) const res = setupState(...args)
return (res.children === null || typeof res.children === 'string') return (res.children === null || typeof res.children === 'string')
? cloneVNode(res) ? cloneVNode(res)
@ -100,6 +108,20 @@ export function createClientOnly<T extends ComponentOptions> (component: T) {
} }
} }
}) })
} else {
if (typeof setupState === 'function') {
return (...args: any[]) => {
if (mounted$.value) {
return h(setupState(...args), ctx.attrs)
}
const fragment = getFragmentHTML(instance?.vnode.el ?? null) ?? ['<div></div>']
return import.meta.client
? createStaticVNode(fragment.join(''), fragment.length) :
h('div', ctx.attrs)
}
}
return Object.assign(setupState, { mounted$ })
}
} }
cache.set(component, clone) cache.set(component, clone)

View File

@ -392,12 +392,14 @@ describe('pages', () => {
expect(await page.locator('.client-only-script button').innerHTML()).toContain('2') expect(await page.locator('.client-only-script button').innerHTML()).toContain('2')
expect(await page.locator('.string-stateful-script').innerHTML()).toContain('1') expect(await page.locator('.string-stateful-script').innerHTML()).toContain('1')
expect(await page.locator('.string-stateful').innerHTML()).toContain('1') expect(await page.locator('.string-stateful').innerHTML()).toContain('1')
const waitForConsoleLog = page.waitForEvent('console', consoleLog => consoleLog.text() === 'has $el')
// ensure directives are reactive // ensure directives are reactive
await page.locator('button#show-all').click() await page.locator('button#show-all').click()
await Promise.all(hiddenSelectors.map(selector => page.locator(selector).isVisible())) await Promise.all(hiddenSelectors.map(selector => page.locator(selector).isVisible()))
.then(results => results.forEach(isVisible => expect(isVisible).toBeTruthy())) .then(results => results.forEach(isVisible => expect(isVisible).toBeTruthy()))
await waitForConsoleLog
expect(pageErrors).toEqual([]) expect(pageErrors).toEqual([])
await page.close() await page.close()
// don't expect any errors or warning on client-side navigation // don't expect any errors or warning on client-side navigation

View File

@ -0,0 +1,16 @@
<template>
<div>
<ClientSetupScript
ref="clientSetupScript"
class="client-only-script-setup"
foo="hello"
/>
</div>
</template>
<script setup lang="ts">
const clientSetupScript = ref<{ $el: HTMLElement }>()
onMounted(() => {
console.log(clientSetupScript.value?.$el as HTMLElement ? 'has $el' : 'no $el')
})
</script>

View File

@ -59,6 +59,7 @@
class="no-state-hidden" class="no-state-hidden"
/> />
<WrapClientComponent v-if="show" />
<button <button
class="test-ref-1" class="test-ref-1"
@click="stringStatefulComp.add" @click="stringStatefulComp.add"
@ -94,16 +95,14 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Ref } from 'vue'
// bypass client import protection to ensure this is treeshaken from .client components // bypass client import protection to ensure this is treeshaken from .client components
import BreaksServer from '~~/components/BreaksServer.client' import BreaksServer from '~~/components/BreaksServer.client'
type Comp = Ref<{ add: () => void }> type Comp = { add: () => void }
const stringStatefulComp = ref<Comp>(null)
const stringStatefulComp = ref(null) as any as Comp const stringStatefulScriptComp = ref<Comp>(null)
const stringStatefulScriptComp = ref(null) as any as Comp const clientScript = ref<Comp>(null)
const clientScript = ref(null) as any as Comp const clientSetupScript = ref<Comp>(null)
const clientSetupScript = ref(null) as any as Comp
const BreakServerComponent = defineAsyncComponent(() => { const BreakServerComponent = defineAsyncComponent(() => {
return import('./../components/BreaksServer.client') return import('./../components/BreaksServer.client')
}) })