From ef66e181a3aa4ea77da74beb41abe4e737e876c6 Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Wed, 15 Jul 2026 16:27:14 +0200 Subject: [PATCH 1/3] perf(tv): memoize slot invocations with simple args --- src/runtime/utils/tv.ts | 59 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/src/runtime/utils/tv.ts b/src/runtime/utils/tv.ts index bce1cfd44f..bbd38b59f4 100644 --- a/src/runtime/utils/tv.ts +++ b/src/runtime/utils/tv.ts @@ -104,13 +104,50 @@ function applyReplacer(replacer: SlotClassReplacer, slotProps: Record): string | undefined { + for (const key in 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, directives?: Record) { + const memo = new Map>() + return new Proxy(slots, { get(target, key: string) { const slot = target[key] @@ -121,7 +158,27 @@ function wrapSlots(slots: Record, directives?: Record = {}) => { 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 })) } From 05272d77c682bc663198fc95224cb1c8ed4c5b73 Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Wed, 15 Jul 2026 16:51:38 +0200 Subject: [PATCH 2/3] fix(tv): restrict slot memoization to plain objects --- src/runtime/utils/tv.ts | 12 +++++++- test/utils/tv.spec.ts | 66 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/src/runtime/utils/tv.ts b/src/runtime/utils/tv.ts index bbd38b59f4..9e14d5e664 100644 --- a/src/runtime/utils/tv.ts +++ b/src/runtime/utils/tv.ts @@ -124,7 +124,17 @@ function isMemoizable(value: unknown): boolean { } function memoKey(slotProps: Record): string | undefined { - for (const key in slotProps) { + // 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 } diff --git a/test/utils/tv.spec.ts b/test/utils/tv.spec.ts index 7ad5ba34db..921e6ac2db 100644 --- a/test/utils/tv.spec.ts +++ b/test/utils/tv.spec.ts @@ -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('is insensitive to key order', () => { + 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') + }) +}) From 6dfb300abdf447aecebe097a37fe94ea61568f8a Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Wed, 15 Jul 2026 16:58:40 +0200 Subject: [PATCH 3/3] test(tv): clarify key-order memoization test name --- test/utils/tv.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/utils/tv.spec.ts b/test/utils/tv.spec.ts index 921e6ac2db..2442180375 100644 --- a/test/utils/tv.spec.ts +++ b/test/utils/tv.spec.ts @@ -159,7 +159,7 @@ describe('tv slot memoization', () => { expect(ui.base({ active: undefined, class: 'p-2' })).toBe(ui.base({ class: 'p-2' })) }) - it('is insensitive to key order', () => { + 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 })) })