Skip to content

perf(components): memoize tv slot invocations with simple args - #6737

Merged
benjamincanac merged 11 commits into
v4from
perf/tv-slot-memo
Jul 31, 2026
Merged

perf(components): memoize tv slot invocations with simple args#6737
benjamincanac merged 11 commits into
v4from
perf/tv-slot-memo

Conversation

@benjamincanac

@benjamincanac benjamincanac commented Jul 15, 2026

Copy link
Copy Markdown
Member

🔗 Linked issue

Relates to #6293

❓ Type of change

  • 👌 Enhancement (improving an existing functionality)

📚 Description

Every render re-invokes the tv slot functions (ui.base(), ui.label(), ui.td(), ...), re-running variant resolution and twMerge even when the arguments are identical to the previous call. tailwind-variants caches nothing across invocations.

This memoizes slot invocations in the tv wrapper when the arguments are fully serializable: primitives and arrays of primitives, on a plain object. Anything else, clsx-style object classes and the (defaults) => classes replacer functions from #6562, bails to the existing uncached path, so those semantics are untouched (covered by test/utils/tv.spec.ts). JSON.stringify is used as the cache key, and its dropping of undefined values matches tv's own treatment of undefined variants. Non-finite numbers bail because NaN serializes to null but resolves differently. The cache lives on the invocation result, so an app.config.ui change or a variant-prop recompute starts fresh, and each slot's cache is capped at 500 entries to guard against pathological dynamic inputs.

Where it pays off is a slot called many times with repeating arguments. A 100-call ui.td() loop goes from 5,197 to 52,684 ops/s, and ui.link() on navigation-menu from 1,533 to 42,094 ops/s.

At component level that translates to one scenario: a Table re-render (200 rows x 5 columns, new data identity) goes from 12.2 to 13.7 ops/s, about 12%. Button mount, Button re-render and Table mount are unchanged. A Button makes roughly six slot calls per render, which is noise against a render that costs hundreds of microseconds, so the memo has nothing to amortize there.

The cost, for completeness: when the arguments never repeat, building the key is pure overhead. A 100-call loop with all-distinct classes is about 4% slower, and a 600-call one that runs past the 500-entry cap about 8% slower. That is the shape a Table with per-cell meta.class.td hits.

Output classes are byte-identical: the full suite passes with zero snapshot changes.

📝 Checklist

  • I have linked an issue or discussion.
  • I have updated the documentation accordingly.

@github-actions github-actions Bot added the v4 #4488 label Jul 15, 2026
@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

wrapSlots now memoizes slot results when no replacer is present and slotProps are safely cacheable. It generates JSON-based keys, distinguishes non-finite numbers and non-plain objects, stores results per slot, reuses cached results, and clears caches above 500 entries. Tests cover cache reuse, argument isolation, key ordering, variant handling, and non-cacheable inputs.

Estimated code review effort: 3 (Moderate) | ~20 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the main change: memoizing tv slot invocations for performance.
Description check ✅ Passed The description directly explains the memoization behavior, supported arguments, cache limits, performance impact, and test coverage.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/tv-slot-memo

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pkg-pr-new

pkg-pr-new Bot commented Jul 15, 2026

Copy link
Copy Markdown
npm i https://pkg.pr.new/@nuxt/ui@6737

commit: ae572ca

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/utils/tv.spec.ts`:
- Around line 162-165: Rename the test case around build to clarify that it
verifies identical output for reordered keys, not memoization cache reuse; leave
the assertion and implementation unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3b50c4c8-f271-457c-b88c-ebc679c4ffdb

📥 Commits

Reviewing files that changed from the base of the PR and between ef66e18 and 05272d7.

📒 Files selected for processing (2)
  • src/runtime/utils/tv.ts
  • test/utils/tv.spec.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/runtime/utils/tv.ts

Comment thread test/utils/tv.spec.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/runtime/utils/tv.ts (2)

140-149: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not key only enumerable properties.

JSON.stringify({ class: 'hidden' }) becomes {}, and Object.defineProperty({}, 'class', { enumerable: false, value: 'hidden' }) has no enumerable keys, so memoKey can return the same cache entry for an empty-like object and a { class: 'hidden' } object. Object.keys also omits accessor-based entries while slot resolution can read/getter-dependent properties, so non-enumerable/accessor descriptors should reject memoization before caching.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/runtime/utils/tv.ts` around lines 140 - 149, The memoization key logic
around isMemoizable currently inspects only enumerable keys, allowing
non-enumerable and accessor properties to collide with empty objects. Replace
the Object.keys-based inspection with own-property descriptor inspection,
rejecting any non-enumerable or accessor-based property before JSON.stringify;
preserve the existing undefined-value serialization semantics for eligible data
properties.

181-195: 🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

Enforce the cache limit before inserting the new entry.

With cache.size > 500, a cache that already has 500 entries will insert a 501st entry on the next miss. Move the stale-cache reset into the miss path and use >= 500 before cache.set so the cache stays bounded.

Proposed fix
-          } 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) {
+            if (cache.size >= 500) {
+              cache.clear()
+            }
             result = slot(slotProps) as string
             cache.set(cacheKey, result)
           }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/runtime/utils/tv.ts` around lines 181 - 195, Update the cache handling
around the result lookup in the memoized slot flow: move the cache reset into
the result-miss path, and before inserting a new result in cache.set, clear the
cache when its size is at least 500. Preserve existing cache reuse for hits and
ensure the newly computed entry is inserted afterward.
🧹 Nitpick comments (1)
test/utils/tv.spec.ts (1)

139-145: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Make the repeated-call test observe cache reuse.

toBe on primitive strings only verifies equal output; this test also passes if the slot is recomputed on every call. Spy on the underlying resolver or add an invocation counter, then assert the second identical call does not invoke it again.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/utils/tv.spec.ts` around lines 139 - 145, Update the
repeated-identical-args test around ui.base to observe cache reuse rather than
only string equality: spy on or count invocations of the underlying resolver,
call ui.base with the same arguments twice, and assert the resolver runs only
once while preserving the existing output assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime/utils/tv.ts`:
- Around line 125-126: Update isMemoizable to track arrays currently being
traversed and return false when an array repeats on the active recursion stack,
preventing cyclic arrays from recursing indefinitely. Remove each array from the
active set after traversal so shared but non-cyclic arrays remain memoizable,
while preserving the existing uncached path for rejected values.

---

Outside diff comments:
In `@src/runtime/utils/tv.ts`:
- Around line 140-149: The memoization key logic around isMemoizable currently
inspects only enumerable keys, allowing non-enumerable and accessor properties
to collide with empty objects. Replace the Object.keys-based inspection with
own-property descriptor inspection, rejecting any non-enumerable or
accessor-based property before JSON.stringify; preserve the existing
undefined-value serialization semantics for eligible data properties.
- Around line 181-195: Update the cache handling around the result lookup in the
memoized slot flow: move the cache reset into the result-miss path, and before
inserting a new result in cache.set, clear the cache when its size is at least
500. Preserve existing cache reuse for hits and ensure the newly computed entry
is inserted afterward.

---

Nitpick comments:
In `@test/utils/tv.spec.ts`:
- Around line 139-145: Update the repeated-identical-args test around ui.base to
observe cache reuse rather than only string equality: spy on or count
invocations of the underlying resolver, call ui.base with the same arguments
twice, and assert the resolver runs only once while preserving the existing
output assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2a942854-5516-473d-b47e-40497ed2b0a5

📥 Commits

Reviewing files that changed from the base of the PR and between 6dfb300 and 3cb40a2.

📒 Files selected for processing (2)
  • src/runtime/utils/tv.ts
  • test/utils/tv.spec.ts

Comment thread src/runtime/utils/tv.ts Outdated
`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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/utils/tv.spec.ts`:
- Around line 241-249: Update the “keeps each slot cache bounded” test to
observe getter-read counts rather than only validating rendered class strings.
Verify that accessing entry 599 does not recompute it, while accessing entry 0
after the cache reset recomputes it, preserving the existing bounded-cache
scenario and output assertions as needed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 51b1b7ed-a435-4107-927f-a845e0bddce6

📥 Commits

Reviewing files that changed from the base of the PR and between 3cb40a2 and 03a5208.

📒 Files selected for processing (2)
  • src/runtime/utils/tv.ts
  • test/utils/tv.spec.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/runtime/utils/tv.ts

Comment thread test/utils/tv.spec.ts
@codspeed-hq

codspeed-hq Bot commented Jul 31, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by ×2.9

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 3 improved benchmarks
✅ 28 untouched benchmarks

Performance Changes

Benchmark BASE HEAD Efficiency
td x100 (string class) 3,906.3 µs 239 µs ×16
UButton 5.7 ms 4.7 ms +20.78%
td x100 (array class) 2.5 ms 2.1 ms +18.69%

Tip

Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.


Comparing perf/tv-slot-memo (ae572ca) with v4 (5afbd5c)

Open in CodSpeed

@benjamincanac benjamincanac changed the title perf(tv): memoize slot invocations with simple args perf(theme): memoize slot invocations with simple args Jul 31, 2026
@benjamincanac

Copy link
Copy Markdown
Member Author

@codspeedbot explain why this is faster

@codspeed-hq

codspeed-hq Bot commented Jul 31, 2026

Copy link
Copy Markdown

@codspeedbot explain why this is faster

Here's what the profiles say, comparing head (4b896ce) against base v4 (5afbd5c).

The whole tailwind-variants slot-resolution subtree disappears from the profile.

On td x100 (string class), base spends 97.9% of the benchmark inside the tv slot function (tailwind-variants/dist/chunk-RZF76H2U.js:222), broken down as:

Frame Base self time Share
cnMerge inner (index.js:32) → twMerge 1.4 ms 36.7%
getCompleteProps 996 µs 25.5%
tv slot fn itself 659 µs 16.9%
processClassesRecursively (tailwind-merge) 205 µs 5.3%
getVariantValue 188 µs 4.8%

In head, none of those frames appear at all. The only frames left are memoKey (128 µs, i.e. JSON.stringify + the isMemoizable scan), the wrapper closure, findReplacer and the two Map lookups — 239 µs total, hence the ×16. So the win is not "tv got faster", it's that 99 of the 100 calls never reach tv: variant resolution, compound-variant flattening and twMerge run exactly once per distinct argument set.

Why the component-level number is what it is. On re-render::UButton, the tv slot subtree accounted for 1.3 ms of the 5.7 ms base run (23.2%)getVariantClassNamesBySlotKey alone was 881 µs (15.5%), compound-variant resolution 395 µs (7%). In head that subtree is gone (getCompleteProps isn't even present in the profile), and the benchmark drops 5.7 ms → 4.7 ms. Saving ~1 ms out of a 1.3 ms subtree lines up almost exactly with the reported +20.8%: a re-render calls the same six Button slots with the same arguments, so every call after the first is a cache hit, and what's left is key construction.

That also explains the 28 untouched benchmarks: mount benchmarks pay each slot call on a cold cache, so there is nothing to amortize, and factory-build benchmarks (tv({ extend: … })) don't touch the memo path at all.

Two caveats on reading the report:

  • The report flags that base and head ran on different CPU models (EPYC 7763 → 9V74). The mechanism above is visible in the flamegraphs independently of that, and I cross-checked it with a local walltime run of test/bench/tv.bench.ts on the same machine: td x100 goes from 421 µs → 34 µs (string class, ×12.3) and 423 µs → 43 µs (array class, ×9.8) per 100 calls.
  • Given that, the reported +18.7% on td x100 (array class) looks understated: its head profile shows the tv frames fully gone, same as the string variant, and the remaining cost is attributed to the wrapper closure rather than to memoKey. Walltime puts the real gain near ×10, with the array variant ~1.3× behind the string variant purely because serializing [undefined, 'p-2'] into the key costs more than a plain string.
Summary
  • Warming up the performance wizard...
  • Analyzing the speedup — Explained the gain from flamegraph evidence and a local walltime cross-check
  • Handling comment — Comment handling finished successfully

Was this response helpful? React with 👍 or 👎 to help us improve CodSpeedBot.

@benjamincanac benjamincanac changed the title perf(theme): memoize slot invocations with simple args perf(components): memoize tv slot invocations with simple args Jul 31, 2026
@benjamincanac
benjamincanac merged commit 4200e80 into v4 Jul 31, 2026
26 checks passed
@benjamincanac
benjamincanac deleted the perf/tv-slot-memo branch July 31, 2026 14:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v4 #4488

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant