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
69 changes: 68 additions & 1 deletion src/runtime/utils/tv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,13 +104,60 @@ 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): boolean {
if (value === undefined || value === null) {
return true
}
const type = typeof value
if (type === 'string' || type === 'number' || type === 'boolean') {
return true
}
if (Array.isArray(value)) {
return value.every(isMemoizable)
}
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>) {
const memo = new Map<string, Map<string, string>>()

return new Proxy(slots, {
get(target, key: string) {
const slot = target[key]
Expand All @@ -121,7 +168,27 @@ 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)
} else if (cache.size > 500) {
// Pathological dynamic inputs (e.g. per-row generated classes):
// reset rather than grow unbounded.
cache.clear()
}

let result = cache.get(cacheKey)
if (result === undefined) {
result = slot(slotProps) as string
cache.set(cacheKey, result)
}
return result
}
return applyReplacer(replacer, slotProps, () => slot({ ...slotProps, class: undefined, className: undefined }))
}
Expand Down
66 changes: 66 additions & 0 deletions test/utils/tv.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,69 @@ 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) })()

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 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('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