Skip to content

Switch debounce to throttle, remove readonly#6938

Merged
ckifer merged 6 commits intomainfrom
review
Jan 29, 2026
Merged

Switch debounce to throttle, remove readonly#6938
ckifer merged 6 commits intomainfrom
review

Conversation

@PavelVanecek
Copy link
Collaborator

@PavelVanecek PavelVanecek commented Jan 28, 2026

Description

Followup to the robot review from #6924 which turned out to be correct and useful

  • the "throttle" implementation was actually "debounce" which doesn't make a whole lot difference if the timeframe is below 4ms but it was wrong so I fixed it
  • omnidoc interpreted ReadonlyArray<X> as Array<readonly X> which was wrong so I removed the readonly completely - it's irrelevant on the website anyway
  • removed a workaround for copilot bug because that bug is fixed already

Related Issue

#6924

Types of changes

  • Bug fix (non-breaking change which fixes an issue) - but we did not release that bug yet
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

Checklist:

  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • I have added tests to cover my changes.
  • I have added a storybook story or VR test, or extended an existing story or VR test to show my changes

Summary by CodeRabbit

  • Improvements

    • Event handling: middlewares now track the latest event payload and apply leading-edge execution with proper cooldowns for mouse, touch, keyboard and external events.
  • Documentation

    • Removed many readonly qualifiers across API docs and Storybook arg types for array/coordinate props.
    • Removed a guidance line about unsetting NODE_ENV.
  • Tests

    • Added tests covering throttling behavior for mouse, keyboard and external events.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 28, 2026

Walkthrough

Refactors event throttling across multiple middlewares to cache latest event payloads per event type and run a leading-edge callback, while updating omnidoc type-simplification to strip readonly and normalize parentheses. Generated Storybook/API docs had readonly qualifiers removed from many array/tuple type summaries and a minor AGENTS.md edit.

Changes

Cohort / File(s) Summary
Type simplification & tests
omnidoc/generateApiDoc.ts, omnidoc/processType.spec.ts, omnidoc/readProject.spec.ts
simplifyOneType now strips readonly modifiers, unwraps outer parentheses, and refines function detection; tests added/updated to assert readonly stripping and literal-preservation.
Event throttling middlewares
src/state/externalEventsMiddleware.ts, src/state/keyboardEventsMiddleware.ts, src/state/mouseEventsMiddleware.ts, src/state/touchEventsMiddleware.ts
Introduced per-event latest-payload caching and switched throttling to leading-edge immediate execution plus trailing execution using cached payloads; updated timeout/RAF handling and cleanup.
Throttling tests
test/state/throttling.spec.ts
Added/updated tests to validate leading-edge and trailing throttle behavior for mouse, keyboard, and external events with numeric and RAF delays.
Generated Storybook arg-types
storybook/stories/API/arg-types/*.ts (multiple files)
Removed readonly from array/tuple element type summaries across many arg-type files (e.g., Array<readonly T>Array<T>, readonly [T, U][T, U]).
Generated API docs
www/src/docs/api/*.tsx (multiple files)
Mirrored readonly removals in public API documentation/type summaries for many components and hooks (same readonly → non-readonly changes).
Docs guidance
AGENTS.md
Removed instruction to always unset NODE_ENV.

Sequence Diagram

sequenceDiagram
    actor EventSource as Event Source
    participant Middleware
    participant Cache as Latest Event Cache
    participant Callback
    participant State

    EventSource->>Middleware: emit event (payload)
    Middleware->>Cache: store/update latest payload
    Middleware->>Callback: execute immediately (leading-edge)
    Callback->>State: dispatch/update

    Note over Middleware, Cache: cooldown started (timeout / RAF)

    EventSource->>Middleware: emit event (during cooldown)
    Middleware->>Cache: update latest payload
    Note over Middleware: skip immediate execution (throttled)

    Note over Middleware: cooldown expires
    Middleware->>Cache: read latest payload
    Middleware->>Callback: execute with latest payload (trailing)
    Callback->>State: dispatch/update
    Middleware->>Cache: clear cached payload
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Suggested reviewers

  • ckifer
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately captures the two main changes: switching from debounce to throttle logic and removing readonly modifiers from types.
Description check ✅ Passed The description covers most required sections including motivation, testing approach, and change types. However, specific testing details are not fully elaborated.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

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

✨ Finishing touches
  • 📝 Generate docstrings

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
Contributor

@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

Caution

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

⚠️ Outside diff range comments (2)
src/state/keyboardEventsMiddleware.ts (1)

44-125: Throttle cooldown can get stuck when the callback exits early.

When the trailing timeout fires, callback() can return before resetting timeoutId/rafId (e.g., Enter key path, invalid key, or accessibility disabled). Because the timeout handler only clears IDs when no payload exists, this leaves timeoutId non‑null and blocks subsequent keydowns. Ensure cleanup runs on every exit.

🛠️ Suggested fix: always clear IDs via try/finally
-    const callback = () => {
-      const currentState = listenerApi.getState();
-      const accessibilityLayerIsActive = currentState.rootProps.accessibilityLayer !== false;
-      if (!accessibilityLayerIsActive) {
-        return;
-      }
-      const { keyboardInteraction } = currentState.tooltip;
-      const key = latestKeyboardActionPayload;
-      if (key !== 'ArrowRight' && key !== 'ArrowLeft' && key !== 'Enter') {
-        return;
-      }
+    const callback = () => {
+      try {
+        const currentState = listenerApi.getState();
+        const accessibilityLayerIsActive = currentState.rootProps.accessibilityLayer !== false;
+        if (!accessibilityLayerIsActive) {
+          return;
+        }
+        const { keyboardInteraction } = currentState.tooltip;
+        const key = latestKeyboardActionPayload;
+        if (key !== 'ArrowRight' && key !== 'ArrowLeft' && key !== 'Enter') {
+          return;
+        }
        ...
-      listenerApi.dispatch(
-        setKeyboardInteraction({
-          active: true,
-          activeIndex: nextIndex.toString(),
-          activeCoordinate: coordinate,
-        }),
-      );
-      rafId = null;
-      timeoutId = null;
+        listenerApi.dispatch(
+          setKeyboardInteraction({
+            active: true,
+            activeIndex: nextIndex.toString(),
+            activeCoordinate: coordinate,
+          }),
+        );
+      } finally {
+        rafId = null;
+        timeoutId = null;
+      }
     };
test/state/throttling.spec.ts (1)

159-164: Replace as any with as unknown as SyntheticEvent to match the codebase pattern.

The as any violates the coding guideline that forbids any types. While the suggested satisfies approach is cleaner, the related test file (test/state/externalEventsMiddleware.spec.ts) uses as unknown as SyntheticEvent for the same mock pattern. This is the established pattern in the codebase for typing mock events that satisfy React's SyntheticEvent interface.

Apply this change to lines 159-164 and 180-184 (and any other occurrences in this file):

  reactEvent: mockEvent as any,
+ reactEvent: mockEvent as unknown as SyntheticEvent,
🤖 Fix all issues with AI agents
In `@test/state/throttling.spec.ts`:
- Around line 88-93: The test "should execute first mousemove event immediately
(leading edge)" in throttling.spec.ts is empty — either delete this no-op test
or implement a concrete assertion; to implement, simulate a mousemove dispatch
and assert the leading-edge behavior by spying on the middleware/handler (e.g.,
use jest.spyOn(console, 'log') or spy on the externalEventsMiddleware handler)
or by asserting store state changes after the first dispatch; update the test to
call the same setup used by externalEventsMiddleware tests, dispatch the
mousemove event, and expect the spy to have been called exactly once immediately
(and optionally restore the spy).
- Around line 169-172: The test currently calls vi.runAllTimers() inside the act
callback to complete the timeout cleanup; replace that call with
vi.runOnlyPendingTimers() so only scheduled pending timers run and avoid
starting any new timers—update the act block that wraps vi.runAllTimers() to
call vi.runOnlyPendingTimers() instead (refer to the act(...) invocation and the
vi.runAllTimers() symbol in the throttling.spec.ts snippet).

Comment on lines +169 to 172
// Complete the timeout to clean up the Map
act(() => {
vi.advanceTimersByTime(100);
vi.runAllTimers();
});
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n test/state/throttling.spec.ts | sed -n '160,180p'

Repository: recharts/recharts

Length of output: 826


🏁 Script executed:

cat -n test/state/throttling.spec.ts | sed -n '140,175p'

Repository: recharts/recharts

Length of output: 1213


🏁 Script executed:

cat -n test/state/throttling.spec.ts | sed -n '1,30p'

Repository: recharts/recharts

Length of output: 1323


🏁 Script executed:

cat -n test/state/throttling.spec.ts | sed -n '26,40p'

Repository: recharts/recharts

Length of output: 578


Replace vi.runAllTimers() with vi.runOnlyPendingTimers().
This test does not use the createSelectorTestCase helper, so the guideline to avoid vi.runAllTimers() applies here. Use vi.runOnlyPendingTimers() to prevent potential runaway timers.

Suggested change
-        vi.runAllTimers();
+        vi.runOnlyPendingTimers();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Complete the timeout to clean up the Map
act(() => {
vi.advanceTimersByTime(100);
vi.runAllTimers();
});
// Complete the timeout to clean up the Map
act(() => {
vi.runOnlyPendingTimers();
});
🤖 Prompt for AI Agents
In `@test/state/throttling.spec.ts` around lines 169 - 172, The test currently
calls vi.runAllTimers() inside the act callback to complete the timeout cleanup;
replace that call with vi.runOnlyPendingTimers() so only scheduled pending
timers run and avoid starting any new timers—update the act block that wraps
vi.runAllTimers() to call vi.runOnlyPendingTimers() instead (refer to the
act(...) invocation and the vi.runAllTimers() symbol in the throttling.spec.ts
snippet).

@codecov
Copy link

codecov bot commented Jan 28, 2026

Codecov Report

❌ Patch coverage is 75.00000% with 42 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.28%. Comparing base (265bb5b) to head (52c0ebd).
⚠️ Report is 4 commits behind head on main.

Files with missing lines Patch % Lines
src/state/keyboardEventsMiddleware.ts 70.58% 20 Missing ⚠️
src/state/touchEventsMiddleware.ts 38.46% 16 Missing ⚠️
src/state/mouseEventsMiddleware.ts 73.33% 4 Missing ⚠️
src/state/externalEventsMiddleware.ts 91.66% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6938      +/-   ##
==========================================
- Coverage   94.30%   94.28%   -0.03%     
==========================================
  Files         573      573              
  Lines       56891    56938      +47     
  Branches     5254     5265      +11     
==========================================
+ Hits        53653    53683      +30     
- Misses       3229     3246      +17     
  Partials        9        9              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions
Copy link
Contributor

Staging Deployment Details

These deployments will remain available for 30 days.

To update snapshots: Comment /update-snapshots on this PR to automatically update the baseline screenshots.

Copy link
Contributor

@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: 0

Caution

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

⚠️ Outside diff range comments (2)
test/state/throttling.spec.ts (2)

142-147: Mock handler is not reset between tests.

The mockHandler is defined once at the describe block level but never reset between tests. This causes toHaveBeenCalledTimes assertions to accumulate across tests, which could lead to flaky or incorrect test results.

🐛 Proposed fix
   describe('externalEventsMiddleware', () => {
     const mockHandler = vi.fn();
     const mockEvent = {
       type: 'click',
       persist: vi.fn(),
     };

+    beforeEach(() => {
+      mockHandler.mockClear();
+    });
+
     it('should execute first event immediately (leading edge) when throttleDelay is a number', () => {

45-47: Also replace vi.runAllTimers() here.

For consistency with the coding guidelines, this instance should also use vi.runOnlyPendingTimers().

♻️ Proposed fix
       act(() => {
-        vi.runAllTimers();
+        vi.runOnlyPendingTimers();
       });
🧹 Nitpick comments (2)
src/state/keyboardEventsMiddleware.ts (1)

102-105: The finally block may clear timeoutId prematurely in the trailing-edge path.

When callback() is invoked from within the setTimeout handler (line 122), the finally block sets timeoutId = null before the outer code in lines 123-126 can check it or set it again. This appears intentional since the trailing callback should complete the throttle cycle, but the logic in lines 121-126 checks latestKeyboardActionPayload after callback() has already run.

Looking more closely: callback() at line 122 sets timeoutId = null in finally, then the code at lines 123-126 either does nothing (no new payload) or sets timeoutId = null again. This is redundant but not harmful.

♻️ Optional: Simplify the trailing-edge cleanup

The else branch at lines 123-126 is redundant since timeoutId and rafId are already cleared in the finally block of callback(). Consider simplifying:

         timeoutId = setTimeout(() => {
           if (latestKeyboardActionPayload) {
             callback();
-          } else {
-            timeoutId = null;
-            rafId = null;
           }
         }, throttleDelay);
test/state/throttling.spec.ts (1)

118-139: Keyboard throttling test covers the basic case well.

The test validates that keyboard events follow the same throttle pattern as mouse events. Consider adding a test to verify the leading-edge execution (handler called immediately on first event) similar to the externalEventsMiddleware test at line 149.

♻️ Optional: Add leading-edge verification for keyboard events

To fully test the throttle behavior, consider adding assertions that verify the first event executes immediately:

it('should execute first keydown event immediately (leading edge) when throttleDelay is a number', () => {
  // Setup store with rootProps.accessibilityLayer and tooltip state
  // to enable the keyboard handler to actually dispatch
  store.dispatch(setEventSettings({ throttleDelay: 100, throttledEvents: 'all' }));
  
  store.dispatch(keyDownAction('ArrowRight'));
  // Verify leading-edge execution occurred (check state changes)
  expect(vi.getTimerCount()).toBe(1); // Trailing timer scheduled
});

@github-actions
Copy link
Contributor

Staging Deployment Details

These deployments will remain available for 30 days.

To update snapshots: Comment /update-snapshots on this PR to automatically update the baseline screenshots.

@ckifer ckifer merged commit d5a70c1 into main Jan 29, 2026
45 of 47 checks passed
@ckifer ckifer deleted the review branch January 29, 2026 02:45
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