Skip to content
Merged
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@
"test": "vitest",
"test:vue": "vitest --project vue",
"test:nuxt": "vitest --project nuxt",
"bench": "vitest bench --project vue",
"release": "release-it"
},
"dependencies": {
Expand Down
88 changes: 88 additions & 0 deletions test/bench/components.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { bench, describe } from 'vitest'
import { mountSuspended } from '@nuxt/test-utils/runtime'
import Button from '../../src/runtime/components/Button.vue'
import Table from '../../src/runtime/components/Table.vue'
import type { TableColumn } from '../../src/runtime/components/Table.vue'

// A toolbar of many buttons (the #6293 shape): each Button builds its own tv
// factory on mount, and re-renders on every editor transaction. Labels only —
// icons resolve via an async fetch that's orthogonal to the tv cost measured here.
const Toolbar = {
components: { UButton: Button },
template: `<div><UButton v-for="i in 25" :key="i" :label="'B' + i" /></div>`
}

describe('Button mount', () => {
bench('toolbar of 25', async () => {
const wrapper = await mountSuspended(Toolbar)
wrapper.unmount()
})
})

// Toggling a variant prop re-runs the component's `ui` computed and re-renders
// its subtree. Each iteration performs a full on/off cycle so the work measured
// is deterministic and always ends back in the initial `false` state.
describe('Button re-render (variant prop change)', () => {
let wrapper: Awaited<ReturnType<typeof mountSuspended>>

bench('toggle loading', async () => {
await wrapper.setProps({ loading: true })
await wrapper.setProps({ loading: false })
}, {
async setup() {
wrapper = await mountSuspended(Button, { props: { label: 'Button' } })
},
teardown() {
wrapper?.unmount()
}
})
})

type Row = { id: number, name: string, email: string, amount: number, status: string }

function makeData(n: number): Row[] {
return Array.from({ length: n }, (_, i) => ({
id: i,
name: `Name ${i}`,
email: `user${i}@example.com`,
amount: i * 3,
status: i % 2 ? 'success' : 'failed'
}))
}

const columns: TableColumn<Row>[] = [
{ accessorKey: 'id', header: '#' },
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'email', header: 'Email' },
{ accessorKey: 'amount', header: 'Amount' },
{ accessorKey: 'status', header: 'Status' }
]

// `mountSuspended` doesn't infer the Table's row generic, so the mounted prop
// expects `TableColumn<unknown, unknown>[]` — cast the checked columns at use.
const tableProps = (data: Row[]) => ({ data, columns: columns as unknown as TableColumn<unknown, unknown>[] })

// The per-cell `ui.td()` path at scale (200 rows x 5 columns = 1000 cells).
describe('Table mount (200 x 5)', () => {
const data = makeData(200)

bench('mount', async () => {
const wrapper = await mountSuspended(Table, { props: tableProps(data) })
wrapper.unmount()
})
})

describe('Table re-render (new data identity)', () => {
let wrapper: Awaited<ReturnType<typeof mountSuspended>>

bench('set data', async () => {
await wrapper.setProps({ data: makeData(200) })
}, {
async setup() {
wrapper = await mountSuspended(Table, { props: tableProps(makeData(200)) })
},
teardown() {
wrapper?.unmount()
}
})
})
89 changes: 89 additions & 0 deletions test/bench/tv.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { bench, describe } from 'vitest'
import { tv } from '../../src/runtime/utils/tv'
import buttonTheme from '#build/ui/button'
import navigationMenuTheme from '#build/ui/navigation-menu'
import tableTheme from '#build/ui/table'

// Representative invocation props for each component (mirrors the objects the
// components pass to `tv(...)(...)` at runtime).
const buttonProps = {
color: 'primary',
variant: 'solid',
size: 'md',
loading: false,
block: false,
square: false,
leading: false,
trailing: false
} as const
const tableProps = {
sticky: false,
loading: false,
loadingColor: 'primary',
loadingAnimation: 'carousel',
externalScroll: false
} as const

// Building the factory is the expensive step (deep-merges the whole variant
// matrix, joins every slot, flattens compound variants). Today it happens inside
// each component's `computed`, so every variant-prop change re-runs it.
describe('factory build', () => {
bench('button (~6 slots)', () => {
tv({ extend: buttonTheme })
})

bench('table (~13 slots)', () => {
tv({ extend: tableTheme })
})

bench('navigation-menu (~31 slots)', () => {
tv({ extend: navigationMenuTheme })
})
})

// Invoking a prebuilt factory is the cheap step — this is all a variant-prop
// change should cost once the build is hoisted out of the invocation computed.
describe('invocation (prebuilt factory)', () => {
const buttonFactory = tv({ extend: buttonTheme })
const tableFactory = tv({ extend: tableTheme })

bench('button', () => {
buttonFactory(buttonProps)
})

bench('table', () => {
tableFactory(tableProps)
})
})

// Build and invoke fused in one call, as every component does today. The gap
// between this and "invocation (prebuilt factory)" is the overhead saved by
// hoisting factory construction out of the per-variant-prop recomputation.
describe('build + invoke (current fused pattern)', () => {
bench('button', () => {
tv({ extend: buttonTheme })(buttonProps)
})

bench('table', () => {
tv({ extend: tableTheme })(tableProps)
})
})

// Table renders call `ui.td(...)` once per cell through the `wrapSlots` proxy.
// This models a 100-cell render, exercising the per-access wrapper-closure
// allocation and the replacer scan on every slot call.
describe('slot invocation', () => {
const tableUi = tv({ extend: tableTheme })(tableProps)

bench('td x100 (string class)', () => {
for (let i = 0; i < 100; i++) {
tableUi.td({ class: 'p-2', pinned: false })
}
})

bench('td x100 (array class)', () => {
for (let i = 0; i < 100; i++) {
tableUi.td({ class: [undefined, 'p-2'], pinned: false })
}
})
})
4 changes: 4 additions & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ export default defineConfig({
name: 'nuxt',
dir: './test',
include: ['components/**/**.spec.ts', 'composables/**.spec.ts', 'utils/**/**.spec.ts'],
// Benchmarks run in the `vue` project only (happy-dom, faster); keep them
// out of the nuxt project so a bare `vitest bench` doesn't double-run them.
benchmark: { include: [] },
environment: 'nuxt',
environmentOptions: {
nuxt: {
Expand All @@ -44,6 +47,7 @@ export default defineConfig({
environment: 'happy-dom',
dir: './test',
include: ['components/**.spec.ts', 'composables/**.spec.ts', 'utils/**/**.spec.ts'],
benchmark: { include: ['bench/**/*.bench.ts'] },
setupFiles: ['./test/utils/setup.ts']
},
plugins: [
Expand Down
Loading