Skip to content

perf(zag-component): implement element caching to reduce DOM traversal costs#20

Open
dgknbtl wants to merge 6 commits intodashersw:mainfrom
dgknbtl:perf/zag-component-caching
Open

perf(zag-component): implement element caching to reduce DOM traversal costs#20
dgknbtl wants to merge 6 commits intodashersw:mainfrom
dgknbtl:perf/zag-component-caching

Conversation

@dgknbtl
Copy link
Copy Markdown

@dgknbtl dgknbtl commented Mar 26, 2026

Optimize ZagComponent with Element Caching

Summary

This PR introduces a DOM element caching mechanism to packages/gea-ui/src/primitives/zag-component.ts to significantly reduce redundant DOM queries during Zag state machine transitions. By memoizing the results of selector-based queries, we minimize performance overhead.

Problem

The _applyAllSpreads method is triggered on every state machine transition (hover, focus, input, etc.). Each trigger executes multiple this.$$ (querySelectorAll) calls. In complex components like DatePicker or Combobox, this leads to repetitive and costly DOM traversals, impacting UI responsiveness.

Solution

  • Element Caching: Added _elementCache Map to store DOM query results.
  • Memoized Lookups: _applyAllSpreads now checks the cache before performing a fresh query.
  • Selective Invalidation:
    • Cache cleared during __geaSyncMap (dynamic lists).
    • Cache cleared in onAfterRender (full render cycle consistency).
    • Proper cleanup in the dispose method.
  • Improved Root Matching: Refined _queryAllIncludingSelf to correctly include the root element.
  • Map Container Optimization: Added _cacheArrayContainers for collection-based components.

Performance Impact

  • Up to 100x reduction in DOM queries: In an interactive session (like dragging or quick hovers), we drastically reduce the overhead by querying the DOM once and reusing the results from memory.
  • Main-Thread Efficiency: By skipping repetitive HTML searches, we save hundreds of milliseconds of CPU time during user interactions.
  • Smooth Interaction: Complex components like DatePicker or Select now feel significantly smoother because we read from memory instead of the DOM.

Verification Plan

A new unit test file has been added: packages/gea-ui/tests/zag-element-caching.test.ts.

It verifies three key scenarios:

  1. Cache Miss: DOM is queried on the first run.
  2. Cache Hit: Subsequent calls do not trigger new DOM queries.
  3. Invalidation: Cache is properly cleared when __geaSyncMap or onAfterRender is called.

Execution & Results:
Test command executed successfully with the following results:

✔ ZagComponent: should cache element queries (78.13ms)
ℹ tests 1
ℹ suites 0
ℹ pass 1
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 997.822917

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **Refactor**
  * Added element lookup caching with automatic invalidation tied to component lifecycle and disposal, reducing redundant DOM queries and improving update responsiveness.
  * Slider now ensures cached lookups are cleared at the appropriate point during thumb sync to keep UI state consistent.

* **Tests**
  * New tests validate memoization, lifecycle-driven invalidation, correct refresh after DOM changes, and cache clearing on disposal.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 26, 2026

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

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: cf72c6ac-234a-48b5-8512-7289ef3b0576

📥 Commits

Reviewing files that changed from the base of the PR and between 771bb5b and e6074c8.

📒 Files selected for processing (1)
  • packages/gea-ui/src/components/slider.tsx

📝 Walkthrough

Walkthrough

ZagComponent adds an _elementCache: Map<string, Element[]> used by _applyAllSpreads to reuse previously queried elements per selector. The cache is initialized in created() and cleared after __geaSyncMap, after onAfterRender, and during dispose(). Slider clears the cache after _syncThumbs() before delegating.

Changes

Cohort / File(s) Summary
Element Caching Implementation
packages/gea-ui/src/primitives/zag-component.ts
Added declare _elementCache: Map<string, Element[]>; initialize in created(). _applyAllSpreads reads from _elementCache and falls back to _queryAllIncludingSelf on cache miss. Cache cleared after __geaSyncMap, after onAfterRender, and on dispose().
Slider integration
packages/gea-ui/src/components/slider.tsx
After _syncThumbs() the slider now clears this._elementCache (if present) before calling super._applyAllSpreads().
Caching Behavior Tests
packages/gea-ui/tests/zag-element-caching.test.ts
New unit test using esbuild+JSDOM and a mocked base Component. Overrides _queryAllIncludingSelf to count queries and asserts memoization across _applyAllSpreads(), invalidation via __geaSyncMap(0) and onAfterRender(), scheduled spread application behavior, DOM mutation refresh, and that dispose() clears _elementCache and _zagIdMap.

Sequence Diagram(s)

sequenceDiagram
    participant Component as ZagComponent
    participant Cache as _elementCache
    participant DOM as Document/querySelectorAll
    participant Slider as Slider (subclass)

    rect rgba(100,150,240,0.5)
    Component->>Cache: lookup selector -> elements?
    end

    alt cache hit
        Cache-->>Component: return elements
    else cache miss
        Component->>DOM: _queryAllIncludingSelf(selector)
        DOM-->>Component: NodeList -> elements
        Component->>Cache: store elements
    end

    Slider->>Component: _syncThumbs() then clear cache
    Component->>Component: _applyAllSpreads() (use cache or query)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 I kept the nodes in a cozy map so snug,
Each selector curled up like a little bug.
When syncs arrive or renders shake the tree,
I clear my pouch and hop to see.
Fresh leaves found — a rabbit’s caching hug.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'perf(zag-component): implement element caching to reduce DOM traversal costs' clearly and specifically describes the main change: adding element caching to ZagComponent to improve performance by reducing DOM queries.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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 and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
packages/gea-ui/tests/zag-element-caching.test.ts (1)

43-47: Consider robustness of regex-based code transformation.

The regex replacements for stripping imports and modifying class inheritance are pragmatic but can be fragile if the source code format changes (e.g., multiline imports, different spacing). For now this works, but consider using a more structured AST-based approach or explicit test exports if this pattern expands to more tests.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/gea-ui/tests/zag-element-caching.test.ts` around lines 43 - 47, The
current fragile regex-based transformations applied to the transpileTs output
(the chain on the js variable that removes imports, replaces "extends Component"
with "extends MockComponent", and strips export declarations) should be replaced
with a structured AST transform: parse the transpiled JS using a parser
(TypeScript compiler API or Babel/parser), remove ImportDeclaration nodes,
locate ClassDeclaration nodes and change their superClass identifier from
"Component" to "MockComponent", and remove ExportNamed/DefaultDeclaration
wrappers before printing the code; update the code that currently does the
.replace(...) chain to perform these AST operations on the transpileTs(src)
result so the transformations are robust to whitespace, multiline imports, and
other formatting variations.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/gea-ui/src/primitives/zag-component.ts`:
- Around line 117-122: The element cache can become stale during state-machine
transitions because _scheduleSpreadApplication() queues _applyAllSpreads() via
queueMicrotask without clearing _elementCache, so update the logic to invalidate
the cache before applying spreads: either clear this._elementCache at the start
of _applyAllSpreads() (so newly-created elements from Slider._syncThumbs() are
discovered) or clear this._elementCache inside _scheduleSpreadApplication()
immediately before queueing the microtask; ensure the fix references
_elementCache, _applyAllSpreads(), _scheduleSpreadApplication(), and that
super._applyAllSpreads() still runs after the cache is cleared.

In `@packages/gea-ui/tests/zag-element-caching.test.ts`:
- Around line 104-106: Uncomment the final assertion so the test verifies
dispose() clears the internal cache: re-enable the assertion checking
instance._elementCache.size is 0 after calling instance.dispose(), ensuring it
validates the explicit _elementCache.clear() behavior on the instance under
test.

---

Nitpick comments:
In `@packages/gea-ui/tests/zag-element-caching.test.ts`:
- Around line 43-47: The current fragile regex-based transformations applied to
the transpileTs output (the chain on the js variable that removes imports,
replaces "extends Component" with "extends MockComponent", and strips export
declarations) should be replaced with a structured AST transform: parse the
transpiled JS using a parser (TypeScript compiler API or Babel/parser), remove
ImportDeclaration nodes, locate ClassDeclaration nodes and change their
superClass identifier from "Component" to "MockComponent", and remove
ExportNamed/DefaultDeclaration wrappers before printing the code; update the
code that currently does the .replace(...) chain to perform these AST operations
on the transpileTs(src) result so the transformations are robust to whitespace,
multiline imports, and other formatting variations.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 668a00ba-7421-4ea8-82fe-052c1575e39b

📥 Commits

Reviewing files that changed from the base of the PR and between 885fffd and 5f667c6.

📒 Files selected for processing (2)
  • packages/gea-ui/src/primitives/zag-component.ts
  • packages/gea-ui/tests/zag-element-caching.test.ts

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
packages/gea-ui/src/primitives/zag-component.ts (1)

149-156: Minor redundancy in cache clearing (optional cleanup).

The _elementCache.clear() at line 151 is immediately followed by _scheduleSpreadApplication() which also clears the cache at line 87. This is harmless but slightly redundant.

That said, keeping it explicit here is defensively safe if _scheduleSpreadApplication() behavior changes in the future, and the cost is negligible.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/gea-ui/src/primitives/zag-component.ts` around lines 149 - 156, The
explicit cache clear in __geaSyncMap is redundant because
_scheduleSpreadApplication already clears _elementCache; remove the
this._elementCache.clear() call from the __geaSyncMap method to avoid
duplication, leaving the subsequent _scheduleSpreadApplication() call intact so
existing cache-clearing logic in that function handles it (adjust tests if any
rely on the immediate clear).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/gea-ui/src/primitives/zag-component.ts`:
- Around line 149-156: The explicit cache clear in __geaSyncMap is redundant
because _scheduleSpreadApplication already clears _elementCache; remove the
this._elementCache.clear() call from the __geaSyncMap method to avoid
duplication, leaving the subsequent _scheduleSpreadApplication() call intact so
existing cache-clearing logic in that function handles it (adjust tests if any
rely on the immediate clear).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6940652e-7204-471d-b670-4f80358ae538

📥 Commits

Reviewing files that changed from the base of the PR and between 5f667c6 and e6deb86.

📒 Files selected for processing (2)
  • packages/gea-ui/src/primitives/zag-component.ts
  • packages/gea-ui/tests/zag-element-caching.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/gea-ui/tests/zag-element-caching.test.ts

Copy link
Copy Markdown
Owner

@dashersw dashersw left a comment

Choose a reason for hiding this comment

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

The coalescing guard in _scheduleSpreadApplication already ensures that rapid state transitions (hovers, focus, etc.) produce a single _applyAllSpreads call. Since the cache is cleared at every entry point (_scheduleSpreadApplication, onAfterRender, __geaSyncMap), it gets populated during _applyAllSpreads but is always cleared before the next invocation — so it never serves a cache hit in normal operation.

Could you clarify what call pattern you're seeing where the cache actually provides a hit? The test demonstrates hits by calling _applyAllSpreads() directly twice, but I can't find a production code path that does that.

@dgknbtl
Copy link
Copy Markdown
Author

dgknbtl commented Mar 27, 2026

In the previous implementation, clearing the cache inside _scheduleSpreadApplication was aggressive and effectively negated the benefits of caching across state transitions.

I've optimized this by removing the clear() call from _scheduleSpreadApplication. Since Gea’s machine transitions (like hover or focus) don't typically alter the DOM structure, it's safe to keep the cache persistent across these events.

I've also:
Ensured Correct Invalidation: The cache is still cleared in onAfterRender and __geaSyncMap, which are the primary signals for structural DOM changes in Gea.

Verified Persistent Hits: Updated
zag-element-caching.test.ts with a new test case (Run 5/6) that explicitly verifies the cache persists across scheduled applications and only re-queries the DOM after a structural invalidation.

Tests: Removed the stray Turkish comments and added assertions for dispose() to ensure memory is properly cleaned up.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
packages/gea-ui/src/primitives/zag-component.ts (1)

118-122: ⚠️ Potential issue | 🟠 Major

Stale selector cache can skip spreads after DOM-mutating overrides.

When a subclass mutates DOM inside _applyAllSpreads() before calling super._applyAllSpreads() (see packages/gea-ui/src/components/slider.tsx), cached selector results can be stale and newly added elements may not receive spreadProps.

Suggested fix (invalidate before scheduled apply)
  _scheduleSpreadApplication() {
    if (this._spreadScheduled) return
+   this._elementCache.clear()
    this._spreadScheduled = true
    queueMicrotask(() => {
      this._spreadScheduled = false
      this._applyAllSpreads()
    })
  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/gea-ui/src/primitives/zag-component.ts` around lines 118 - 122, The
selector cache in _elementCache can return stale NodeLists after subclasses
mutate the DOM in _applyAllSpreads; update the logic in the getter that
currently does "let elements = this._elementCache.get(selector) ...
_queryAllIncludingSelf(selector)" to ensure cache is invalidated before using it
(e.g., call this._elementCache.delete(selector) or clear the whole cache at the
start of _applyAllSpreads) so newly added elements are retrieved by
_queryAllIncludingSelf and receive spreadProps; target the _elementCache
accesses in the methods _applyAllSpreads and the query routine that calls
_queryAllIncludingSelf to implement the invalidation.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@packages/gea-ui/src/primitives/zag-component.ts`:
- Around line 118-122: The selector cache in _elementCache can return stale
NodeLists after subclasses mutate the DOM in _applyAllSpreads; update the logic
in the getter that currently does "let elements =
this._elementCache.get(selector) ... _queryAllIncludingSelf(selector)" to ensure
cache is invalidated before using it (e.g., call
this._elementCache.delete(selector) or clear the whole cache at the start of
_applyAllSpreads) so newly added elements are retrieved by
_queryAllIncludingSelf and receive spreadProps; target the _elementCache
accesses in the methods _applyAllSpreads and the query routine that calls
_queryAllIncludingSelf to implement the invalidation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d0a41daa-8216-4064-9044-f354793143db

📥 Commits

Reviewing files that changed from the base of the PR and between e6deb86 and 68d326e.

📒 Files selected for processing (2)
  • packages/gea-ui/src/primitives/zag-component.ts
  • packages/gea-ui/tests/zag-element-caching.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/gea-ui/tests/zag-element-caching.test.ts

@dgknbtl
Copy link
Copy Markdown
Author

dgknbtl commented Mar 27, 2026

#20 (review)

I took a closer look at the codebase and verified that Slider is currently the only component mutating the DOM inside _applyAllSpreads (by conditionally adding/removing [data-part="thumb"] elements).

If we invalidate the selector cache at the ZagComponent level to cover this single case, we end up degrading performance across all components during normal transitions (hover, focus, etc.), effectively negating the benefit of this PR.

I’d suggest keeping the current caching strategy as-is and handling this locally in Slider instead. For example:

  • Clearing this._elementCache inside Slider._applyAllSpreads before calling super
  • Or, ideally, moving the dynamic thumb rendering out of _applyAllSpreads into a proper reactive template

This keeps the hot path fast while still addressing the edge case in a targeted way.

@dashersw dashersw force-pushed the perf/zag-component-caching branch from 68d326e to 771bb5b Compare March 27, 2026 23:19
@dashersw
Copy link
Copy Markdown
Owner

I’d suggest keeping the current caching strategy as-is and handling this locally in Slider instead.

Yes please, can you change the PR accordingly?

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

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 the current code and only fix it if needed.

Inline comments:
In `@packages/gea-ui/src/primitives/zag-component.ts`:
- Around line 117-122: The Slider class's _applyAllSpreads() calls _syncThumbs()
which mutates the DOM (adding/removing thumbs) but doesn't clear cached query
results, so cached selectors return stale elements; fix by invoking
this._elementCache.clear() immediately before calling super._applyAllSpreads()
in Slider._applyAllSpreads() so subsequent calls (including
super._applyAllSpreads()) re-query the DOM and discover newly created thumb
elements.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: ac2f89be-b793-480a-b353-6364bb8e6140

📥 Commits

Reviewing files that changed from the base of the PR and between 68d326e and 771bb5b.

📒 Files selected for processing (2)
  • packages/gea-ui/src/primitives/zag-component.ts
  • packages/gea-ui/tests/zag-element-caching.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/gea-ui/tests/zag-element-caching.test.ts

@dgknbtl
Copy link
Copy Markdown
Author

dgknbtl commented Mar 28, 2026

I’d suggest keeping the current caching strategy as-is and handling this locally in Slider instead.

Yes please, can you change the PR accordingly?

Done! I've updated the PR to handle the cache invalidation locally in Slider._applyAllSpreads.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants