diff --git a/src/runtime/utils/tv.ts b/src/runtime/utils/tv.ts index bce1cfd44f..033c46b1c3 100644 --- a/src/runtime/utils/tv.ts +++ b/src/runtime/utils/tv.ts @@ -104,13 +104,78 @@ 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 +} + +function memoKey(slotProps: Record): 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, directives?: Record) { + // `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) { const slot = target[key] @@ -121,7 +186,30 @@ 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) + } + + 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 })) } diff --git a/test/utils/tv.spec.ts b/test/utils/tv.spec.ts index 7ad5ba34db..c88fe4771a 100644 --- a/test/utils/tv.spec.ts +++ b/test/utils/tv.spec.ts @@ -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) + }) + + 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') + }) +})