From ef66e181a3aa4ea77da74beb41abe4e737e876c6 Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Wed, 15 Jul 2026 16:27:14 +0200 Subject: [PATCH 1/7] 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/7] 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/7] 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 })) }) From 3cb40a29e33e875aa86d4a3126434147525af3f4 Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Thu, 30 Jul 2026 15:38:02 +0200 Subject: [PATCH 4/7] fix(tv): bail memoization on non-finite numbers --- src/runtime/utils/tv.ts | 7 ++++++- test/utils/tv.spec.ts | 8 ++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/runtime/utils/tv.ts b/src/runtime/utils/tv.ts index 9e14d5e664..6b71b21f4f 100644 --- a/src/runtime/utils/tv.ts +++ b/src/runtime/utils/tv.ts @@ -114,9 +114,14 @@ function isMemoizable(value: unknown): boolean { return true } const type = typeof value - if (type === 'string' || type === 'number' || type === 'boolean') { + 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)) { return value.every(isMemoizable) } diff --git a/test/utils/tv.spec.ts b/test/utils/tv.spec.ts index 2442180375..acc6fe6431 100644 --- a/test/utils/tv.spec.ts +++ b/test/utils/tv.spec.ts @@ -164,6 +164,14 @@ describe('tv slot memoization', () => { 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... From b74244ddd0892fedc725c8eb2602c6f979016f20 Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Fri, 31 Jul 2026 10:44:20 +0200 Subject: [PATCH 5/7] fix(tv): cache slot results that resolve to no classes `tv` returns `undefined` for a slot whose chain resolves to nothing (two on `navigation-menu`), so it can't double as the miss sentinel. Those slots re-ran on every call: 1,875 -> 60,416 ops/s on a 100-call loop. --- src/runtime/utils/tv.ts | 8 ++++++-- test/utils/tv.spec.ts | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/runtime/utils/tv.ts b/src/runtime/utils/tv.ts index 6b71b21f4f..f52a3322c9 100644 --- a/src/runtime/utils/tv.ts +++ b/src/runtime/utils/tv.ts @@ -161,7 +161,9 @@ function memoKey(slotProps: Record): string | undefined { * `app.config.ui` change) or variant-prop recompute starts fresh. */ function wrapSlots(slots: Record, directives?: Record) { - const memo = new Map>() + // `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>() return new Proxy(slots, { get(target, key: string) { @@ -189,7 +191,9 @@ function wrapSlots(slots: Record, directives?: Record { 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' }) From 03a5208bf50d14957de3cfc5e85544e484543381 Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Fri, 31 Jul 2026 12:58:54 +0200 Subject: [PATCH 6/7] fix(tv): bound array recursion and cap the slot cache at 500 --- src/runtime/utils/tv.ts | 24 ++++++++++++++++++------ test/utils/tv.spec.ts | 19 +++++++++++++++++++ 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/runtime/utils/tv.ts b/src/runtime/utils/tv.ts index f52a3322c9..033c46b1c3 100644 --- a/src/runtime/utils/tv.ts +++ b/src/runtime/utils/tv.ts @@ -109,7 +109,7 @@ function applyReplacer(replacer: SlotClassReplacer, slotProps: Record= 4) { + return false + } + for (const item of value) { + if (!isMemoizable(item, depth + 1)) { + return false + } + } + return true } return false } @@ -184,16 +195,17 @@ function wrapSlots(slots: Record, directives?: Record 500) { - // Pathological dynamic inputs (e.g. per-row generated classes): - // reset rather than grow unbounded. - cache.clear() } 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) } diff --git a/test/utils/tv.spec.ts b/test/utils/tv.spec.ts index 835307d8bb..48af2c5fbf 100644 --- a/test/utils/tv.spec.ts +++ b/test/utils/tv.spec.ts @@ -229,6 +229,25 @@ describe('tv slot memoization', () => { 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() + for (let i = 0; i < 600; i++) { + ui.base({ class: `w-[${i}px]` }) + } + // Entry 501 resets rather than growing, and lookups still resolve after it. + expect(ui.base({ class: 'w-[599px]' })).toContain('w-[599px]') + expect(ui.base({ class: 'w-[0px]' })).toContain('w-[0px]') + }) + 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 From ca22f60d340538b2e17c078fad924617ce1b3cf3 Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Fri, 31 Jul 2026 13:03:47 +0200 Subject: [PATCH 7/7] test(tv): assert the bounded cache actually evicts --- test/utils/tv.spec.ts | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/test/utils/tv.spec.ts b/test/utils/tv.spec.ts index 48af2c5fbf..c88fe4771a 100644 --- a/test/utils/tv.spec.ts +++ b/test/utils/tv.spec.ts @@ -240,12 +240,28 @@ describe('tv slot memoization', () => { 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({ class: `w-[${i}px]` }) + ui.base(props(i)) } - // Entry 501 resets rather than growing, and lookups still resolve after it. - expect(ui.base({ class: 'w-[599px]' })).toContain('w-[599px]') - expect(ui.base({ class: 'w-[0px]' })).toContain('w-[0px]') + + // 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) }) it('does not key inputs carrying inherited enumerable props as plain ones', () => {