diff --git a/src/runtime/components/Button.vue b/src/runtime/components/Button.vue index b3b5e36828..4ca2824ffc 100644 --- a/src/runtime/components/Button.vue +++ b/src/runtime/components/Button.vue @@ -68,7 +68,11 @@ const props = useComponentProps('button', _props) const appConfig = useAppConfig() as Button['AppConfig'] const { orientation, size: buttonSize } = useFieldGroup(_props) +// Memoized: `omit` iterates every forwarded key through three proxy layers +// (useForwardProps -> reactivePick -> useComponentProps), so doing it inline in +// the template re-paid that walk on every render. const linkProps = useForwardProps(pickLinkProps(props)) +const forwardedLinkProps = computed(() => omit(linkProps.value, ['type', 'disabled', 'onClick'])) const loadingAutoState = ref(false) const formLoading = inject | undefined>(formLoadingInjectionKey, undefined) @@ -87,8 +91,20 @@ const isLoading = computed(() => { return props.loading || (props.loadingAuto && (loadingAutoState.value || (formLoading?.value && props.type === 'submit'))) }) +// Pass only the props the composable reads: a `{ ...props }` spread would walk +// every prop through the `useComponentProps` proxy and subscribe this computed +// (and `ui`, which reads `isLeading`/`isTrailing`) to all of them, re-running +// the whole tv pipeline on unrelated prop changes like `class`. const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons( - computed(() => ({ ...props, loading: isLoading.value })) + computed(() => ({ + icon: props.icon, + leading: props.leading, + leadingIcon: props.leadingIcon, + trailing: props.trailing, + trailingIcon: props.trailingIcon, + loading: isLoading.value, + loadingIcon: props.loadingIcon + })) ) // eslint-disable-next-line vue/no-dupe-keys @@ -124,7 +140,7 @@ const ui = computed(() => tv({ v-slot="{ active, ...slotProps }" :type="props.type" :disabled="props.disabled || isLoading" - v-bind="omit(linkProps, ['type', 'disabled', 'onClick'])" + v-bind="forwardedLinkProps" custom > { const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, size: formFieldSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField(_props) const { orientation, size: fieldGroupSize } = useFieldGroup(_props) -const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(toRef(() => defu(props, { trailingIcon: appConfig.ui.icons.chevronDown }))) +// Pass only the props the composable reads: `defu(props, ...)` copied every prop +// through the `useComponentProps` proxy and subscribed this computed (and `ui`, +// which reads `isLeading`/`isTrailing`) to all of them, re-running the whole tv +// pipeline on unrelated prop changes. +const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(computed(() => ({ + icon: props.icon, + leading: props.leading, + leadingIcon: props.leadingIcon, + trailing: props.trailing, + trailingIcon: props.trailingIcon !== undefined ? props.trailingIcon : appConfig.ui.icons.chevronDown, + loading: props.loading, + loadingIcon: props.loadingIcon +}))) const inputSize = computed(() => fieldGroupSize.value || formFieldSize.value) diff --git a/src/runtime/components/Select.vue b/src/runtime/components/Select.vue index a1c8b724ba..7281b9ae1e 100644 --- a/src/runtime/components/Select.vue +++ b/src/runtime/components/Select.vue @@ -192,7 +192,19 @@ const arrowProps = toRef(() => defu(props.arrow, { rounded: true }) as SelectArr const { emitFormChange, emitFormInput, emitFormBlur, emitFormFocus, size: formFieldSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField(_props) const { orientation, size: fieldGroupSize } = useFieldGroup(_props) -const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(toRef(() => defu(props, { trailingIcon: appConfig.ui.icons.chevronDown }))) +// Pass only the props the composable reads: `defu(props, ...)` copied every prop +// through the `useComponentProps` proxy and subscribed this computed (and `ui`, +// which reads `isLeading`/`isTrailing`) to all of them, re-running the whole tv +// pipeline on unrelated prop changes. +const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(computed(() => ({ + icon: props.icon, + leading: props.leading, + leadingIcon: props.leadingIcon, + trailing: props.trailing, + trailingIcon: props.trailingIcon !== undefined ? props.trailingIcon : appConfig.ui.icons.chevronDown, + loading: props.loading, + loadingIcon: props.loadingIcon +}))) const selectSize = computed(() => fieldGroupSize.value || formFieldSize.value) diff --git a/src/runtime/components/SelectMenu.vue b/src/runtime/components/SelectMenu.vue index cd37311b73..93433f26cc 100644 --- a/src/runtime/components/SelectMenu.vue +++ b/src/runtime/components/SelectMenu.vue @@ -292,7 +292,19 @@ const searchInputProps = toRef(() => defu(props.searchInput, { placeholder: t('s const { emitFormBlur, emitFormFocus, emitFormInput, emitFormChange, size: formFieldSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField(_props) const { orientation, size: fieldGroupSize } = useFieldGroup(_props) -const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(toRef(() => defu(props, { trailingIcon: appConfig.ui.icons.chevronDown }))) +// Pass only the props the composable reads: `defu(props, ...)` copied every prop +// through the `useComponentProps` proxy and subscribed this computed (and `ui`, +// which reads `isLeading`/`isTrailing`) to all of them, re-running the whole tv +// pipeline on unrelated prop changes. +const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(computed(() => ({ + icon: props.icon, + leading: props.leading, + leadingIcon: props.leadingIcon, + trailing: props.trailing, + trailingIcon: props.trailingIcon !== undefined ? props.trailingIcon : appConfig.ui.icons.chevronDown, + loading: props.loading, + loadingIcon: props.loadingIcon +}))) const selectSize = computed(() => fieldGroupSize.value || formFieldSize.value) diff --git a/test/bench/button-link.bench.ts b/test/bench/button-link.bench.ts new file mode 100644 index 0000000000..d2acaa3329 --- /dev/null +++ b/test/bench/button-link.bench.ts @@ -0,0 +1,54 @@ +import { bench, describe } from 'vitest' +import { mountSuspended } from '@nuxt/test-utils/runtime' +import Button from '../../src/runtime/components/Button.vue' + +// Where does a Button's render cost come from? Three rungs isolate it: +// 1. plain ' } + +describe('mount', () => { + bench('plain