️(frontend) optimize re-renders of the CarouselLayout

Apply the same pattern as previous layouts: push state and useSize
subscriptions down into child components.

The CarouselLayout now only re-renders when maxVisibles or
orientation actually change, instead of on every size update.
This commit is contained in:
lebaudantoine
2026-07-22 19:38:17 +02:00
committed by aleb_the_flash
parent ef05c0ab19
commit eced9891c6
@@ -3,6 +3,7 @@ import { getScrollBarWidth } from '@livekit/components-core'
import * as React from 'react' import * as React from 'react'
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react' import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver' import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
import { useCallback, useEffect, useLayoutEffect } from 'react'
const MIN_HEIGHT = 130 const MIN_HEIGHT = 130
const MIN_WIDTH = 140 const MIN_WIDTH = 140
@@ -10,6 +11,72 @@ const MIN_VISIBLE_TILES = 1
const ASPECT_RATIO = 16 / 10 const ASPECT_RATIO = 16 / 10
const ASPECT_RATIO_INVERT = (1 - ASPECT_RATIO) * -1 const ASPECT_RATIO_INVERT = (1 - ASPECT_RATIO) * -1
type CarouselOrientation = 'vertical' | 'horizontal'
interface CarouselLayoutState {
orientation: CarouselOrientation
maxVisibleTiles: number
}
interface CarouselLayoutObserverProps {
asideEl: React.RefObject<HTMLDivElement>
orientation?: CarouselOrientation
onLayoutChange: (layout: CarouselLayoutState) => void
}
const CarouselLayoutObserver = ({
orientation,
asideEl,
onLayoutChange,
}: CarouselLayoutObserverProps) => {
const { width, height } = useSize(asideEl)
// Hysteresis memory: avoids flapping between N and N+1 tiles when the
// container size hovers around a breakpoint. A ref (not state) because
// updating it must not trigger a re-render.
const prevTilesRef = React.useRef(0)
const carouselOrientation: CarouselOrientation =
orientation ?? (height >= width ? 'vertical' : 'horizontal')
const tileSpan =
carouselOrientation === 'vertical'
? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT)
: Math.max(height * ASPECT_RATIO, MIN_WIDTH)
const scrollBarWidth = getScrollBarWidth()
const availableSpan =
(carouselOrientation === 'vertical' ? height : width) - scrollBarWidth
const tilesThatFit = Math.max(availableSpan / tileSpan, MIN_VISIBLE_TILES)
let maxVisibleTiles: number
if (Math.abs(tilesThatFit - prevTilesRef.current) < 0.5) {
// Within the dead zone: keep the previous count.
maxVisibleTiles = Math.round(prevTilesRef.current)
} else {
maxVisibleTiles = Math.round(tilesThatFit)
prevTilesRef.current = tilesThatFit
}
// Apply cosmetic layout output straight to the DOM.
useLayoutEffect(() => {
const el = asideEl.current
if (!el) return
el.dataset.lkOrientation = carouselOrientation
el.style.setProperty('--lk-max-visible-tiles', maxVisibleTiles.toString())
}, [asideEl, carouselOrientation, maxVisibleTiles])
// Report upward only what the parent actually needs for
// `useVisualStableUpdate` (and only when it changes — see parent handler).
useEffect(() => {
onLayoutChange({ orientation: carouselOrientation, maxVisibleTiles })
}, [carouselOrientation, maxVisibleTiles, onLayoutChange])
return null
}
/** @public */ /** @public */
export interface CarouselLayoutProps extends React.HTMLAttributes<HTMLMediaElement> { export interface CarouselLayoutProps extends React.HTMLAttributes<HTMLMediaElement> {
tracks: TrackReferenceOrPlaceholder[] tracks: TrackReferenceOrPlaceholder[]
@@ -40,52 +107,42 @@ export function CarouselLayout({
...props ...props
}: CarouselLayoutProps) { }: CarouselLayoutProps) {
const asideEl = React.useRef<HTMLDivElement>(null) const asideEl = React.useRef<HTMLDivElement>(null)
const [prevTiles, setPrevTiles] = React.useState(0)
const { width, height } = useSize(asideEl)
const carouselOrientation = orientation
? orientation
: height >= width
? 'vertical'
: 'horizontal'
const tileSpan = const [layout, setLayout] = React.useState<CarouselLayoutState>({
carouselOrientation === 'vertical' orientation: orientation ?? 'vertical',
? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT) maxVisibleTiles: MIN_VISIBLE_TILES,
: Math.max(height * ASPECT_RATIO, MIN_WIDTH) })
const scrollBarWidth = getScrollBarWidth()
const tilesThatFit = // Stable callback + identity check: the parent only re-renders when the
carouselOrientation === 'vertical' // derived layout genuinely changed, not on every resize tick.
? Math.max((height - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES) const handleLayoutChange = useCallback((next: CarouselLayoutState) => {
: Math.max((width - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES) setLayout((prev) =>
prev.orientation === next.orientation &&
prev.maxVisibleTiles === next.maxVisibleTiles
? prev
: next
)
}, [])
let maxVisibleTiles = Math.round(tilesThatFit) const sortedTiles = useVisualStableUpdate(tracks, layout.maxVisibleTiles)
if (Math.abs(tilesThatFit - prevTiles) < 0.5) {
maxVisibleTiles = Math.round(prevTiles)
} else if (prevTiles !== tilesThatFit) {
setPrevTiles(tilesThatFit)
}
const sortedTiles = useVisualStableUpdate(tracks, maxVisibleTiles)
React.useLayoutEffect(() => {
if (asideEl.current) {
asideEl.current.dataset.lkOrientation = carouselOrientation
asideEl.current.style.setProperty(
'--lk-max-visible-tiles',
maxVisibleTiles.toString()
)
}
}, [maxVisibleTiles, carouselOrientation])
return ( return (
<aside <>
key={carouselOrientation} <CarouselLayoutObserver
className="lk-carousel" asideEl={asideEl}
ref={asideEl} orientation={orientation}
{...props} onLayoutChange={handleLayoutChange}
> />
<TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop> {/* `key` intentionally remounts the container when orientation flips, */}
</aside> {/* which resets scroll position and re-runs the observer measurement. */}
<aside
key={layout.orientation}
className="lk-carousel"
ref={asideEl}
{...props}
>
<TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop>
</aside>
</>
) )
} }