Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 18 additions & 2 deletions src/runtime/components/Button.vue
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,11 @@ const props = useComponentProps('button', _props)
const appConfig = useAppConfig() as Button['AppConfig']
const { orientation, size: buttonSize } = useFieldGroup<ButtonProps>(_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<Ref<boolean> | undefined>(formLoadingInjectionKey, undefined)
Expand All @@ -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
Expand Down Expand Up @@ -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
>
<ULinkBase
Expand Down
14 changes: 13 additions & 1 deletion src/runtime/components/InputMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,19 @@ const virtualizerProps = toRef(() => {

const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, size: formFieldSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField<InputProps>(_props)
const { orientation, size: fieldGroupSize } = useFieldGroup<InputProps>(_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)

Expand Down
14 changes: 13 additions & 1 deletion src/runtime/components/Select.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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<InputProps>(_props)
const { orientation, size: fieldGroupSize } = useFieldGroup<InputProps>(_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)

Expand Down
14 changes: 13 additions & 1 deletion src/runtime/components/SelectMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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<InputProps>(_props)
const { orientation, size: fieldGroupSize } = useFieldGroup<InputProps>(_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)

Expand Down
54 changes: 54 additions & 0 deletions test/bench/button-link.bench.ts
Original file line number Diff line number Diff line change
@@ -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 <button> β€” baseline DOM/reactivity cost of a trivial element
// 2. UButton (no link) β€” adds the Button + ULink component stack (ULink
// renders a plain <button> when there's no `to`)
// 3. UButton (link, `to`) β€” adds the router-link path (route resolution, RouterLink)
// (2) βˆ’ (1) = the Nuxt UI component-stack overhead; (3) βˆ’ (2) = the routing overhead.

// Declares `loading` so the re-render benches can toggle a prop on it too.
const Plain = { props: ['loading'], template: '<button type="button">x</button>' }

describe('mount', () => {
bench('plain <button>', async () => {
const wrapper = await mountSuspended(Plain)
wrapper.unmount()
})

bench('UButton (no link)', async () => {
const wrapper = await mountSuspended(Button, { props: { label: 'x' } })
wrapper.unmount()
})

bench('UButton (link)', async () => {
const wrapper = await mountSuspended(Button, { props: { label: 'x', to: '/' } })
wrapper.unmount()
})
})

// Re-render cost: a full on/off `loading` cycle per iteration (deterministic,
// ends in the initial state), re-rendering the whole subtree.
function reRenderBench(name: string, comp: any, props: Record<string, any> = {}) {
describe(`re-render: ${name}`, () => {
let wrapper: Awaited<ReturnType<typeof mountSuspended>>

bench(name, async () => {
await wrapper.setProps({ loading: true })
await wrapper.setProps({ loading: false })
}, {
async setup() {
wrapper = await mountSuspended(comp, { props })
},
teardown() {
wrapper?.unmount()
}
})
})
}

reRenderBench('plain <button>', Plain)
reRenderBench('UButton (no link)', Button, { label: 'x' })
reRenderBench('UButton (link)', Button, { label: 'x', to: '/' })
63 changes: 63 additions & 0 deletions test/bench/link-stack.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { bench, describe } from 'vitest'
import { h } from 'vue'
import { mountSuspended } from '@nuxt/test-utils/runtime'
import { Primitive } from 'reka-ui'
import Button from '../../src/runtime/components/Button.vue'
import Link from '../../src/runtime/vue/overrides/vue-router/Link.vue'
import LinkBase from '../../src/runtime/components/LinkBase.vue'

// Decomposes the no-link Button stack layer by layer so each rung's cost is
// attributable. Every case is wrapped in the same parent whose `cls` prop
// toggles, re-rendering the target subtree:
// plain <button> β€” Vue baseline
// Primitive β€” reka render primitive
// ULinkBase β€” LinkBase -> Primitive
// ULink (default slot) β€” Link -> LinkBase -> Primitive, class via tv
// ULink custom + ULinkBase β€” the exact pattern Button/NavigationMenu use
// UButton β€” full component
const CASES: [string, (cls: string) => any][] = [
['plain <button>', cls => h('button', { class: cls, type: 'button' }, 'x')],
['Primitive', cls => h(Primitive, { as: 'button', class: cls }, () => 'x')],
['ULinkBase', cls => h(LinkBase, { class: cls }, () => 'x')],
['ULink (default slot)', cls => h(Link, { class: cls }, () => 'x')],
['ULink custom + ULinkBase', cls => h(Link, { custom: true }, { default: ({ active: _active, ...slotProps }: any) => h(LinkBase, { ...slotProps, class: cls }, () => 'x') })],
['UButton', cls => h(Button, { label: 'x', class: cls })]
]

function makeParent(render: (cls: string) => any) {
return {
props: ['cls'],
setup(p: any) {
return () => render(p.cls)
}
}
}

describe('mount', () => {
for (const [name, render] of CASES) {
bench(name, async () => {
const wrapper = await mountSuspended(makeParent(render), { props: { cls: 'p-2' } })
wrapper.unmount()
})
}
})

describe('re-render', () => {
for (const [name, render] of CASES) {
describe(name, () => {
let wrapper: Awaited<ReturnType<typeof mountSuspended>>

bench(name, async () => {
await wrapper.setProps({ cls: 'p-3' })
await wrapper.setProps({ cls: 'p-2' })
}, {
async setup() {
wrapper = await mountSuspended(makeParent(render), { props: { cls: 'p-2' } })
},
teardown() {
wrapper?.unmount()
}
})
})
}
})
Loading