From 48fab74cca79f54c333832f425e5c2d07344a7ee Mon Sep 17 00:00:00 2001 From: Petr Konecny Date: Sat, 10 Oct 2026 18:18:44 +0200 Subject: [PATCH 1/2] fix: stop worklets capturing react elements The pan gesture, the scroll reaction and the infinite-scroll reaction read from props and the children array inside worklets. Reanimated 4 copies everything a worklet captures to the UI thread and throws on React elements in development (Cannot copy value of type FiberNode), so the carousel crashed on mount. They now capture only the callbacks and the slide count. Co-Authored-By: Claude Opus 5.5 --- src/components/AnimatedPagedView/index.tsx | 13 ++++++++----- src/hooks/useInfiniteScroll.ts | 8 +++++--- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/components/AnimatedPagedView/index.tsx b/src/components/AnimatedPagedView/index.tsx index 1d1c915..ebcf824 100644 --- a/src/components/AnimatedPagedView/index.tsx +++ b/src/components/AnimatedPagedView/index.tsx @@ -31,20 +31,23 @@ export const AnimatedPagedView = forwardRef { 'worklet' - return clamp(value, 0, (childrenArray.length - 1) * slideWidth) + return clamp(value, 0, (slideCount - 1) * slideWidth) }, - [childrenArray.length, slideWidth], + [slideCount, slideWidth], ) const gesture = Gesture.Pan() .minDistance(MIN_DISTANCE_THRESHOLD) .onStart(() => { context.value = { x: translateX.value } - runOnJS(props.onScrollBeginDrag)() + runOnJS(onScrollBeginDrag)() }) .onUpdate((event) => { translateX.value = clampValue(context.value.x - event.translationX) @@ -72,9 +75,9 @@ export const AnimatedPagedView = forwardRef translateX.value, (value) => { - props.onScroll?.(value) + onScroll?.(value) }, - [props.onScroll], + [onScroll], ) useImperativeHandle( diff --git a/src/hooks/useInfiniteScroll.ts b/src/hooks/useInfiniteScroll.ts index 869c3f5..f90d5d9 100644 --- a/src/hooks/useInfiniteScroll.ts +++ b/src/hooks/useInfiniteScroll.ts @@ -26,6 +26,8 @@ export const useInfiniteScroll = ({ () => [childrenArray[childrenArray.length - 1], ...childrenArray, childrenArray[0]], [childrenArray], ) + // Worklets must not capture the array of React elements, only its length. + const paddedLength = paddedChildrenArray.length // This handles the infinite scrolling useAnimatedReaction( () => scrollValue.value, @@ -34,16 +36,16 @@ export const useInfiniteScroll = ({ const activeIndex = customRound(offset, ROUNDING_PRECISION) // if we are at the last index we need to switch to the second one without animation // second one because the first one is a clone of the last one - if (activeIndex >= paddedChildrenArray.length - 1) { + if (activeIndex >= paddedLength - 1) { goToPage(1) } // if we are at the first index we need to switch to the next to last one without animation // next to last one because the last one is a clone of the first one if (activeIndex <= 0.01) { - goToPage(paddedChildrenArray.length - 2, 0) + goToPage(paddedLength - 2, 0) } }, - [childrenArray.length, goToPage, paddedChildrenArray.length, slideWidth, scrollValue], + [goToPage, paddedLength, slideWidth, scrollValue], ) return useMemo( From f085dfd5e1f125109d04fd224251110fb8110626 Mon Sep 17 00:00:00 2001 From: Petr Konecny Date: Sat, 10 Oct 2026 18:18:44 +0200 Subject: [PATCH 2/2] perf: add renderWindow to mount only slides near the current one Every slide stayed mounted, so a long carousel paid for all of them on mount and ran every slide's animated styles on each frame. With renderWindow, slides further away render as empty slots of the same width. It's opt-in, since unmounting drops slide state such as video playback. With infinite scroll the window wraps around, so the slide a clone jumps to is already mounted. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + src/components/HeroCarousel/index.tsx | 26 ++++++++- src/components/HeroCarouselSlide/index.tsx | 8 +-- src/context/CarouselContext/index.tsx | 5 ++ src/utils/__tests__/isInRenderWindow.test.ts | 56 ++++++++++++++++++++ src/utils/isInRenderWindow.ts | 29 ++++++++++ 6 files changed, 119 insertions(+), 6 deletions(-) create mode 100644 src/utils/__tests__/isInRenderWindow.test.ts create mode 100644 src/utils/isInRenderWindow.ts diff --git a/README.md b/README.md index 5c027ac..a3ad33f 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,7 @@ The context provider that must wrap your carousel components. **All carousel con | `disableAutoScroll` | `boolean` | `false` | Disable automatic scrolling | | `disableInfiniteScroll` | `boolean` | `false` | Disable infinite scrolling (shows first/last slide boundaries) | | `autoScrollAnimation` | `(to: number, duration: number) => number` | `withTiming` | Custom animation function for auto-scroll transitions | +| `renderWindow` | `number` | all slides | Slides kept mounted on each side of the current one; for long carousels | | `children` | `React.ReactNode` | Required | Carousel content (should contain HeroCarousel component) | #### `HeroCarousel` diff --git a/src/components/HeroCarousel/index.tsx b/src/components/HeroCarousel/index.tsx index 8665fca..1a22e66 100644 --- a/src/components/HeroCarousel/index.tsx +++ b/src/components/HeroCarousel/index.tsx @@ -1,4 +1,5 @@ -import React from 'react' +import React, { useState } from 'react' +import { runOnJS, useAnimatedReaction } from 'react-native-reanimated' import { CarouselContextProvider, useCarouselContext } from '../../context/CarouselContext' import { HeroCarouselSlide } from '../HeroCarouselSlide' @@ -7,6 +8,7 @@ import { useAutoScroll } from '../../hooks/useAutoScroll' import { useInfiniteScroll } from '../../hooks/useInfiniteScroll' import { DEFAULT_INTERVAL } from './index.preset' import { ItemAnimatedView } from '../ItemAnimatedView' +import { isInRenderWindow } from '../../utils/isInRenderWindow' export type HeroCarouselProps = { children: React.ReactNode[] @@ -24,6 +26,7 @@ const HeroCarousel = ({ children }: HeroCarouselProps) => { interval, disableAutoScroll, autoScrollAnimation, + renderWindow, } = useCarouselContext() const { paddedChildrenArray } = useInfiniteScroll({ children, @@ -33,6 +36,16 @@ const HeroCarousel = ({ children }: HeroCarouselProps) => { disabled: disableInfiniteScroll, }) + // Follows the page React-side only when the rounded page changes, not on every frame. + const [activeIndex, setActiveIndex] = useState(() => Math.round(scrollValue.value)) + + useAnimatedReaction( + () => Math.round(scrollValue.value), + (index, previous) => { + if (index !== previous) runOnJS(setActiveIndex)(index) + }, + ) + const autoScrollEnabled = !userInteracted const { runAutoScroll } = useAutoScroll({ @@ -66,7 +79,16 @@ const HeroCarousel = ({ children }: HeroCarouselProps) => { runAutoScroll={runAutoScroll} goToPage={goToPage} > - {child} + {isInRenderWindow({ + index, + activeIndex, + slideCount: children.length, + renderWindow, + isInfinite: !disableInfiniteScroll, + }) + ? child + : // An empty slot of the same width keeps every slide's position. + null} ))} diff --git a/src/components/HeroCarouselSlide/index.tsx b/src/components/HeroCarouselSlide/index.tsx index 0a6d09c..5f4c73f 100644 --- a/src/components/HeroCarouselSlide/index.tsx +++ b/src/components/HeroCarouselSlide/index.tsx @@ -1,10 +1,10 @@ import { View } from 'react-native' import { ItemContext } from '../../context/ItemContext' import { useAutoScroll } from '../../hooks/useAutoScroll' -import { useMemo } from 'react' +import { memo, useMemo } from 'react' import { useManualScroll } from '../../hooks/useManualScroll' -export const HeroCarouselSlide = ({ +export const HeroCarouselSlide = memo(function HeroCarouselSlide({ children, width, index, @@ -18,7 +18,7 @@ export const HeroCarouselSlide = ({ total: number runAutoScroll: ReturnType['runAutoScroll'] goToPage: ReturnType['goToPage'] -}) => { +}) { return ( ) -} +}) diff --git a/src/context/CarouselContext/index.tsx b/src/context/CarouselContext/index.tsx index 8fac90f..92e95fa 100644 --- a/src/context/CarouselContext/index.tsx +++ b/src/context/CarouselContext/index.tsx @@ -39,6 +39,8 @@ type ContextProps = { interval?: number | ((index: number) => number) disableAutoScroll?: boolean autoScrollAnimation?: (to: number, duration: number) => number + /** Slides kept mounted on each side of the current one. Mounts every slide when omitted. */ + renderWindow?: number } export const CarouselContextProvider = ({ @@ -48,6 +50,7 @@ export const CarouselContextProvider = ({ interval = DEFAULT_INTERVAL, disableAutoScroll = false, autoScrollAnimation = DEFAULT_ANIMATION, + renderWindow, ...rest }: ContextProps) => { const userInteracted = useUserInteracted() @@ -69,6 +72,7 @@ export const CarouselContextProvider = ({ interval, disableAutoScroll, autoScrollAnimation, + renderWindow, }), [ manualScroll, @@ -78,6 +82,7 @@ export const CarouselContextProvider = ({ interval, disableAutoScroll, autoScrollAnimation, + renderWindow, ], )} > diff --git a/src/utils/__tests__/isInRenderWindow.test.ts b/src/utils/__tests__/isInRenderWindow.test.ts new file mode 100644 index 0000000..718d2cc --- /dev/null +++ b/src/utils/__tests__/isInRenderWindow.test.ts @@ -0,0 +1,56 @@ +import { isInRenderWindow } from '../isInRenderWindow' + +describe('isInRenderWindow', () => { + it('mounts every slide without a render window', () => { + expect( + isInRenderWindow({ + index: 40, + activeIndex: 0, + slideCount: 50, + renderWindow: undefined, + isInfinite: false, + }), + ).toBe(true) + }) + + it('mounts slides within the window around the current one', () => { + const check = (index: number) => + isInRenderWindow({ + index, + activeIndex: 10, + slideCount: 50, + renderWindow: 2, + isInfinite: false, + }) + + expect([7, 8, 9, 10, 11, 12, 13].map(check)).toEqual([ + false, + true, + true, + true, + true, + true, + false, + ]) + }) + + // With infinite scroll and 10 slides, rendered index 0 clones slide 9, 1-10 are the real slides + // and 11 clones slide 0. + const infinite = (index: number, activeIndex: number) => + isInRenderWindow({ index, activeIndex, slideCount: 10, renderWindow: 2, isInfinite: true }) + + it('keeps the first real slide mounted while on the clone at the end', () => { + expect(infinite(1, 11)).toBe(true) + }) + + it('keeps the last real slide mounted while on the clone at the start', () => { + expect(infinite(10, 0)).toBe(true) + }) + + it('wraps the window around the ends', () => { + // On the first real slide, the last two real slides are its neighbours to the left. + expect(infinite(10, 1)).toBe(true) + expect(infinite(9, 1)).toBe(true) + expect(infinite(8, 1)).toBe(false) + }) +}) diff --git a/src/utils/isInRenderWindow.ts b/src/utils/isInRenderWindow.ts new file mode 100644 index 0000000..f661599 --- /dev/null +++ b/src/utils/isInRenderWindow.ts @@ -0,0 +1,29 @@ +/** + * Whether the slide at `index` stays mounted while `activeIndex` is the current one. + * Both are indexes into the rendered slides. With infinite scroll those start and end with a + * clone, so the distance is measured between the real slides and wraps around: the slide a clone + * jumps to is then always mounted. + */ +export const isInRenderWindow = ({ + index, + activeIndex, + slideCount, + renderWindow, + isInfinite, +}: { + index: number + activeIndex: number + /** Number of real slides, clones excluded. */ + slideCount: number + /** Slides kept mounted on each side of the current one. Everything is mounted when omitted. */ + renderWindow: number | undefined + isInfinite: boolean +}) => { + if (renderWindow === undefined) return true + if (!isInfinite) return Math.abs(index - activeIndex) <= renderWindow + + const toReal = (renderedIndex: number) => (renderedIndex - 1 + slideCount) % slideCount + const distance = Math.abs(toReal(index) - toReal(activeIndex)) + + return Math.min(distance, slideCount - distance) <= renderWindow +}