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
90 changes: 89 additions & 1 deletion src/runtime/utils/tv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,13 +104,78 @@ function applyReplacer(replacer: SlotClassReplacer, slotProps: Record<string, an
return cnMerge(replacer(resolveDefaults()), ...plainClasses(slotProps.class), ...plainClasses(slotProps.className))(config) ?? ''
}

/**
* A slot invocation is memoizable only when its output is fully determined by a
* serializable key: primitives and arrays of primitives. Objects (clsx-style
* class maps) and functions (replacers) bail to the uncached path.
*/
function isMemoizable(value: unknown, depth = 0): boolean {
if (value === undefined || value === null) {
return true
}
const type = typeof value
if (type === 'string' || type === 'boolean') {
return true
}
// `JSON.stringify` turns NaN/Infinity into `null`, colliding with real `null`
// keys that tv resolves differently (default variant vs `key || "false"` lookup).
if (type === 'number') {
return Number.isFinite(value)
}
if (Array.isArray(value)) {
// Stop a few levels down rather than recurse without bound: a cyclic array
// reaching a variant would blow the stack, where tv itself resolves it. The
// arrays components pass (`[props.ui?.td, ...]`) are one or two deep.
if (depth >= 4) {
return false
}
for (const item of value) {
if (!isMemoizable(item, depth + 1)) {
return false
}
}
return true
}
return false
}

function memoKey(slotProps: Record<string, any>): string | undefined {
// Only plain objects: an exotic prototype could carry inherited enumerable
// props that tv would read but `JSON.stringify` would drop from the key,
// making two different inputs share one cache entry.
const proto = Object.getPrototypeOf(slotProps)
if (proto !== Object.prototype && proto !== null) {
return undefined
}

// `Object.keys` matches exactly what `JSON.stringify` serializes (own
// enumerable keys), so everything the key omits is also never inspected here.
for (const key of Object.keys(slotProps)) {
if (!isMemoizable(slotProps[key])) {
return undefined
}
}
// `JSON.stringify` drops `undefined`-valued keys, matching tv's semantics
// (an undefined variant is the same as an absent one).
return JSON.stringify(slotProps)
}

/**
* Wrap the slot functions returned by `tv()` so a replacer (from `:ui` / `class`
* at call time, or from `app.config.ui` at construction time) drops the slot's
* baked-in default chain and returns only its replacement. Without a replacer the
* original slot function runs untouched, so the common merge path is unaffected.
*
* Repeated invocations with identical simple args (re-renders, table cells) are
* memoized per slot: variant resolution + twMerge only run once per distinct
* input. The cache lives on the invocation result, so a factory rebuild (e.g.
* `app.config.ui` change) or variant-prop recompute starts fresh.
*/
function wrapSlots(slots: Record<string, any>, directives?: Record<string, SlotClassReplacer>) {
// `undefined` is a real slot result: `tv` returns it (not `''`) for a slot
// whose chain resolves to no classes, so it can't double as a miss sentinel.
const memo = new Map<string, Map<string, string | undefined>>()

return new Proxy(slots, {
get(target, key: string) {
const slot = target[key]
Expand All @@ -121,7 +186,30 @@ function wrapSlots(slots: Record<string, any>, directives?: Record<string, SlotC
return (slotProps: Record<string, any> = {}) => {
const replacer = findReplacer(slotProps.class) ?? findReplacer(slotProps.className) ?? directives?.[key]
if (!replacer) {
return slot(slotProps)
const cacheKey = memoKey(slotProps)
if (cacheKey === undefined) {
return slot(slotProps)
}

let cache = memo.get(key)
if (!cache) {
cache = new Map()
memo.set(key, cache)
}

let result = cache.get(cacheKey)
// The extra `has` only runs for the rare slot that resolves to no
// classes, so the hot path stays a single lookup.
if (result === undefined && !cache.has(cacheKey)) {
if (cache.size >= 500) {
// Pathological dynamic inputs (e.g. per-row generated classes):
// reset rather than grow unbounded.
cache.clear()
}
result = slot(slotProps) as string
cache.set(cacheKey, result)
}
return result
}
return applyReplacer(replacer, slotProps, () => slot({ ...slotProps, class: undefined, className: undefined }))
}
Expand Down
150 changes: 150 additions & 0 deletions test/utils/tv.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,153 @@ describe('tv class replace (slotless component)', () => {
expect(ui()).toBe('block')
})
})

describe('tv slot memoization', () => {
const theme = {
slots: { base: 'inline-flex text-sm', label: 'truncate' },
variants: {
active: {
true: { base: 'font-bold' },
false: { base: 'font-light' }
}
}
}

const build = () => tvt({ extend: tvt(theme) })()

// Counts how often the `active` variant is read. Building the cache key reads
// it a fixed number of times per call, so a call served from the cache reads
// it strictly fewer times than one that runs the slot, without either test
// having to pin the exact counts.
function countingProps(active: boolean) {
const counter = { reads: 0 }
const props = {
get active() {
counter.reads++
return active
}
}
return [props, counter] as const
}

it('runs the slot once for repeated identical args', () => {
const ui = build()
const [props, counter] = countingProps(true)

expect(ui.base(props)).toContain('font-bold')
const miss = counter.reads

counter.reads = 0
expect(ui.base(props)).toContain('font-bold')
expect(counter.reads).toBeLessThan(miss)
})

it('caches a slot whose chain resolves to no classes', () => {
// `tv` returns `undefined` for such a slot rather than `''`, so it can't
// double as the "not cached yet" sentinel (`navigation-menu` has two).
const ui = tvt({ extend: tvt({ slots: { base: '' }, variants: { active: { true: {}, false: {} } } }) })()
const [props, counter] = countingProps(true)

expect(ui.base(props)).toBeUndefined()
const miss = counter.reads

counter.reads = 0
expect(ui.base(props)).toBeUndefined()
expect(counter.reads).toBeLessThan(miss)
})

it('returns correct output for repeated identical args', () => {
const ui = build()
const first = ui.base({ active: true, class: 'p-2' })
expect(ui.base({ active: true, class: 'p-2' })).toBe(first)
expect(first).toContain('font-bold')
expect(first).toContain('p-2')
})

it('never shares entries across distinct args', () => {
const ui = build()
expect(ui.base({ active: true })).toContain('font-bold')
expect(ui.base({ active: false })).toContain('font-light')
expect(ui.base({ active: true, class: 'p-2' })).toContain('p-2')
expect(ui.base({ active: true })).not.toContain('p-2')
// String and array class forms resolve to the same output independently.
expect(ui.base({ class: ['p-2', undefined] })).toContain('p-2')
})

it('treats an `undefined`-valued key the same as an absent one', () => {
const ui = build()
expect(ui.base({ active: undefined, class: 'p-2' })).toBe(ui.base({ class: 'p-2' }))
})

it('returns identical output for reordered keys (a cache miss, not a shared entry)', () => {
const ui = build()
expect(ui.base({ active: true, class: 'p-2' })).toBe(ui.base({ class: 'p-2', active: true }))
})

it('does not share entries between NaN and null variant values', () => {
const ui = tvt({ extend: tvt(theme), defaultVariants: { active: true } })()
// Both serialize to `"null"`, but tv resolves `null` to the default variant
// while NaN falls through the `key || "false"` lookup.
expect(ui.base({ active: Number.NaN })).toContain('font-light')
expect(ui.base({ active: null })).toContain('font-bold')
})

it('does not poison the cache through clsx object classes', () => {
const ui = build()
// Object classes bail out of the memo but still resolve...
expect(ui.label({ class: { 'font-bold': true, 'opacity-50': false } })).toBe('truncate font-bold')
// ...and cached plain calls before/after stay independent.
expect(ui.label({})).toBe('truncate')
expect(ui.label({ class: { 'font-bold': false } })).toBe('truncate')
})

it('does not poison the cache through replacers', () => {
const ui = build()
expect(ui.label({ class: 'p-2' })).toBe('truncate p-2')
expect(ui.label({ class: () => 'block' })).toBe('block')
expect(ui.label({ class: 'p-2' })).toBe('truncate p-2')
})

it('falls back to the uncached path for a cyclic array', () => {
const ui = build()
const cyclic: any[] = ['p-2']
cyclic.push(cyclic)
// tv resolves this to the default variant (`String(cyclic)` matches no key),
// so keying it must stop recursing rather than blow the stack.
expect(() => ui.base({ active: cyclic })).not.toThrow()
})

it('keeps each slot cache bounded', () => {
const ui = build()
const counter = { reads: 0 }
const props = (i: number) => ({
class: `w-[${i}px]`,
get active() {
counter.reads++
return true
}
})

for (let i = 0; i < 600; i++) {
ui.base(props(i))
}

// The 501st distinct key resets the cache, so it now holds 500 onwards.
counter.reads = 0
expect(ui.base(props(599))).toContain('w-[599px]')
const hit = counter.reads

// Entry 0 went with the reset and has to be resolved again.
counter.reads = 0
expect(ui.base(props(0))).toContain('w-[0px]')
expect(counter.reads).toBeGreaterThan(hit)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it('does not key inputs carrying inherited enumerable props as plain ones', () => {
const ui = build()
// Inherited `class` is read by tv but invisible to `JSON.stringify`: without
// the plain-object guard this would cache a `font-bold` result under `{}`.
expect(ui.label(Object.create({ class: 'font-bold' }))).toBe('truncate font-bold')
expect(ui.label({})).toBe('truncate')
})
})
Loading