Skip to content

perf(react-sdk): reduce re-renders and event-listener churn#2273

Open
oliverlaz wants to merge 1 commit into
mainfrom
perf/react-sdk-audit-fixes
Open

perf(react-sdk): reduce re-renders and event-listener churn#2273
oliverlaz wants to merge 1 commit into
mainfrom
perf/react-sdk-audit-fixes

Conversation

@oliverlaz

@oliverlaz oliverlaz commented Jun 8, 2026

Copy link
Copy Markdown
Member

💡 Overview

Performance and hygiene fixes for @stream-io/video-react-sdk, from a structured performance audit. Three focus areas: re-render reduction on hot paths, event-listener hygiene, and one use-after-cleanup correctness fix. No public API changes.

📝 Implementation notes

  • React.memo on hot-path components (ParticipantView, Audio, CallParticipantListingItem): the participants array emits a fresh reference on every speaking/mute/connection-quality tick, so these rows/tiles reconciled on every emission even when their own props were unchanged. Memoizing stops the cascade (ParticipantView is wrapped as memo(forwardRef(...))).
  • Passive scroll listeners (useScrollPosition): both handlers only read scroll geometry and never call preventDefault, so { passive: true } lets the browser composite scroll frames without waiting on JS. Listener removal is unaffected.
  • useDragToScroll dependencies: the sole caller passes an inline options object, so the effect re-subscribed all four pointer listeners on every render. Now depends on destructured primitives (enabled, decay, minVelocity, dragThreshold). The pointermove listener stays non-passive (it calls preventDefault).
  • AudioVolumeIndicator: replaced a ~12.5 Hz setState (driving one CSS transform) with a direct ref write; the SCSS now sets a scaleX(0) resting state.
  • Participant search (CallParticipantsList): the per-keystroke normalizeString(p.name) over all participants plus new RegExp(userInput) is replaced with a component-scoped, lazily-created normalized-name cache and an includes() substring match (also removes a latent ReDoS / invalid-pattern smell). The cache is released when the list unmounts, so user-controlled names are not retained across calls.
  • SearchInput: the escape-key handler is stabilized with useEffectEvent, so the keydown listener is no longer re-subscribed when exitSearch identity changes.
  • Video: hoisted the static { position: 'absolute' } style object so it does not defeat memoized custom placeholders.
  • BaseVideo (correctness fix): the Safari/Firefox setTimeout(..., 0) that re-assigns srcObject and calls play() was never cleared. A stream/element change or unmount within the same tick fired it on an element the cleanup had just nulled, producing play() interrupted / AbortError noise. The timeout is now cleared in cleanup.

Verified: ESLint and tsc --noEmit clean on the touched files.

🎫 Ticket: https://linear.app/stream/issue/XYZ-123

📑 Docs: N/A (no public API change)

Summary by CodeRabbit

  • New Features

    • Improved search functionality with more efficient substring matching for participant names and user IDs.
  • Bug Fixes

    • Fixed timeout cleanup in video streaming to prevent issues after component unmount.
    • Enhanced audio volume indicator with proper resting state styling.
  • Performance

    • Optimized component rendering with memoization to reduce unnecessary re-renders.
    • Improved scroll event handling efficiency with passive listeners.

Apply React SDK performance-audit fixes:
- memoize hot-path components (ParticipantView, Audio,
  CallParticipantListingItem) to stop re-render cascades when the
  participants array re-emits on every speaking/mute tick
- mark useScrollPosition scroll listeners passive (handlers never
  preventDefault)
- depend on destructured primitives in useDragToScroll so pointer
  listeners are not re-subscribed on every render
- drive the device-settings audio level via a ref instead of ~12.5Hz
  state updates
- cache diacritic-normalized participant names per component instance and
  match with includes() instead of building a RegExp from user input
- stabilize the SearchInput escape handler with useEffectEvent
- hoist the static absolute-position style object in Video

Also clear the Safari/Firefox srcObject setTimeout in BaseVideo on
cleanup to close a use-after-cleanup play() race.
@changeset-bot

changeset-bot Bot commented Jun 8, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 2656b13

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR introduces performance optimizations across the React SDK by memoizing high-frequency render components, refactoring search algorithms to avoid regex overhead with caching, switching audio visualization from state to ref-based DOM updates, improving timer and event cleanup, and refining hook dependencies.

Changes

React SDK Performance Optimizations

Layer / File(s) Summary
Component memoization for re-render prevention
packages/react-sdk/src/components/CallParticipantsList/CallParticipantListingItem.tsx, packages/react-sdk/src/core/components/Audio/Audio.tsx, packages/react-sdk/src/core/components/ParticipantView/ParticipantView.tsx
CallParticipantListingItem, Audio, and ParticipantView are wrapped with memo() to prevent unnecessary re-renders; CallParticipantListingItem and Audio also set explicit displayName properties.
Search and filter algorithm optimizations
packages/react-sdk/src/components/CallParticipantsList/CallParticipantsList.tsx, packages/react-sdk/src/components/Search/SearchInput.tsx
Active user search uses normalized substring matching backed by a useRef cache to avoid regex; blocked user search uses lowercased substring matching on user IDs; SearchInput stabilizes the exitSearch callback with useEffectEvent to reduce effect dependency scope.
Audio visualization state to DOM update refactor
packages/react-sdk/src/components/DeviceSettings/AudioVolumeIndicator.tsx, packages/styling/src/DeviceSettings/DeviceSettings-layout.scss
AudioVolumeIndicator switches from React state-based audioLevel to useRef-backed direct DOM transform: scaleX(...) updates; SCSS establishes resting state with transform: scaleX(0) on the bar.
Video element and timer cleanup improvements
packages/react-sdk/src/core/components/Video/BaseVideo.tsx, packages/react-sdk/src/core/components/Video/Video.tsx
BaseVideo tracks and clears setTimeout ID during effect cleanup to prevent pending stream assignments after unmount; Video extracts the shared ABSOLUTE_POSITION_STYLE constant to eliminate repeated inline { position: 'absolute' } style objects.
Hook optimizations: dependencies and event listener setup
packages/react-sdk/src/hooks/useDragToScroll.ts, packages/react-sdk/src/hooks/useScrollPosition.ts
useDragToScroll destructures options (enabled, decay, minVelocity, dragThreshold) with defaults and tracks them individually in the effect dependency array; useScrollPosition attaches scroll listeners with { passive: true } for vertical and horizontal scroll events.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Suggested reviewers

  • jdimovska
  • greenfrvr

Poem

🐰 A hop through performance gains, refactored with care,
Memoized components glide without wear,
Search strings now sing, no regex in sight,
Refs paint the audio bars, DOM updates done right,
Cleanup and style constants—swift optimization's delight!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main changes: performance optimizations through reduced re-renders and event-listener improvements.
Description check ✅ Passed The description includes a comprehensive overview and detailed implementation notes explaining each change, matching the required template sections.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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.

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

✨ 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/react-sdk-audit-fixes

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.

@oliverlaz oliverlaz requested a review from jdimovska June 8, 2026 13:12

@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 (1)
packages/react-sdk/src/core/components/Video/BaseVideo.tsx (1)

20-41: ⚠️ Potential issue | 🟡 Minor

Use useLayoutEffect in BaseVideo for media stream (srcObject) binding.

packages/react-sdk/src/core/components/Video/BaseVideo.tsx currently uses useEffect to set videoElement.srcObject/playback; changing it to useLayoutEffect avoids paint with stale media state (and aligns with Video.tsx, which already uses useLayoutEffect).

Proposed change
-import { ComponentPropsWithRef, forwardRef, useEffect, useState } from 'react';
+import { ComponentPropsWithRef, forwardRef, useLayoutEffect, useState } from 'react';
...
-    useEffect(() => {
+    useLayoutEffect(() => {
       if (!videoElement || !stream) return;
       if (stream === videoElement.srcObject) return;
...
-    }, [stream, videoElement]);
+    }, [stream, videoElement]);
🤖 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 `@packages/react-sdk/src/core/components/Video/BaseVideo.tsx` around lines 20 -
41, The BaseVideo component uses useEffect to assign videoElement.srcObject,
play(), and cleanup which can cause a paint with stale media; replace the
existing useEffect hook in BaseVideo.tsx with useLayoutEffect so the srcObject
assignment, Safari/Firefox timeout+play logic, and cleanup (clearTimeout, pause,
srcObject = null) run synchronously before paint; keep the existing timeout
logic, error handling for play(), and the dependency array ([stream,
videoElement]) unchanged so behavior matches Video.tsx while preventing visual
glitches.

Source: Coding guidelines

🤖 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 `@packages/styling/src/DeviceSettings/DeviceSettings-layout.scss`:
- Around line 132-133: Add a blank line before the inline double-slash comment
preceding the transform rule to satisfy
scss/double-slash-comment-empty-line-before: insert an empty line above the line
"// resting state; the live level is written inline by AudioVolumeIndicator" in
DeviceSettings-layout.scss so the comment is separated from the previous rule,
leaving the "transform: scaleX(0);" declaration unchanged.

---

Outside diff comments:
In `@packages/react-sdk/src/core/components/Video/BaseVideo.tsx`:
- Around line 20-41: The BaseVideo component uses useEffect to assign
videoElement.srcObject, play(), and cleanup which can cause a paint with stale
media; replace the existing useEffect hook in BaseVideo.tsx with useLayoutEffect
so the srcObject assignment, Safari/Firefox timeout+play logic, and cleanup
(clearTimeout, pause, srcObject = null) run synchronously before paint; keep the
existing timeout logic, error handling for play(), and the dependency array
([stream, videoElement]) unchanged so behavior matches Video.tsx while
preventing visual glitches.
🪄 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: 846c2c5e-bbcc-4d7d-bf6e-ddb52e057156

📥 Commits

Reviewing files that changed from the base of the PR and between 4e6691c and 2656b13.

📒 Files selected for processing (11)
  • packages/react-sdk/src/components/CallParticipantsList/CallParticipantListingItem.tsx
  • packages/react-sdk/src/components/CallParticipantsList/CallParticipantsList.tsx
  • packages/react-sdk/src/components/DeviceSettings/AudioVolumeIndicator.tsx
  • packages/react-sdk/src/components/Search/SearchInput.tsx
  • packages/react-sdk/src/core/components/Audio/Audio.tsx
  • packages/react-sdk/src/core/components/ParticipantView/ParticipantView.tsx
  • packages/react-sdk/src/core/components/Video/BaseVideo.tsx
  • packages/react-sdk/src/core/components/Video/Video.tsx
  • packages/react-sdk/src/hooks/useDragToScroll.ts
  • packages/react-sdk/src/hooks/useScrollPosition.ts
  • packages/styling/src/DeviceSettings/DeviceSettings-layout.scss

Comment thread packages/styling/src/DeviceSettings/DeviceSettings-layout.scss
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