Skip to content

feat: add lazy page mounting - #1215

Open
krozniata wants to merge 2 commits into
mainfrom
feat/lazy-pager
Open

krozniata wants to merge 2 commits into
mainfrom
feat/lazy-pager

Conversation

@krozniata

@krozniata krozniata commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

Summary

This PR adds lazy, lazyPreloadDistance and renderLazyPlaceholder to PagerView

With lazy, a page mounts once the pager is within lazyPreloadDistance pages of it (default 1) and stays mounted after that. Pages are tracked by key, so adding or removing pages doesn't remount the others

Test Plan

  1. Open "Lazy PagerView Example"
  2. Change the preload distance, swipe and use the buttons.
  3. The mounted counter and page map show what's mounted

e2e tests pass on iOS and Android

What's required for testing (prerequisites)?

What are the steps to reproduce (after prerequisites)?

Compatibility

OS Implemented
iOS ✅❌
Android ✅❌

Checklist

  • I have tested this on a device and a simulator
  • I added the documentation in README.md
  • I updated the typed files (TS and Flow)

@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

🟢 e2e: 53 passed

All 53 tests in 21 files
Test Time
🟢 tests/basic-pager.e2e.ts · 3 passed 1m 12s
🟢 basic pager › in LTR › pages horizontally 27.0s
🟢 basic pager › in LTR › pages vertically 16.0s
🟢 basic pager › in RTL › pages horizontally with the reversed gesture 28.9s
🟢 tests/issues/issue-1083-modal-set-page.e2e.ts · 1 passed 14.0s
🟢 setPage called behind a native-stack modal lands on the requested page 14.0s
🟢 tests/issues/issue-1098-nested-pager.e2e.ts · 1 passed 11.5s
🟢 outer pager stays responsive after its page count changes around a nested pager 11.5s
🟢 tests/issues/issue-1154-page-change-focus.e2e.ts · 7 passed 1m 23s
🟢 leaving a page with setPage blurs its input 12.2s
🟢 setPage with an input focused lands on the target page 11.6s
🟢 leaving a page with a swipe blurs its input 10.5s
🟢 an input focused on the new page keeps focus 12.0s
🟢 an input focused on the new page keeps focus going back 13.2s
🟢 a swipe keeps the focus the new page takes from onPageSelected 12.1s
🟢 setPage keeps the focus the new page takes from onPageSelected 11.4s
🟢 tests/issues/issue-1156-nested-horizontal-scroll.e2e.ts · 3 passed 31.7s
🟢 a horizontal ScrollView in a page scrolls instead of the pager 10.4s
🟢 the pager takes the swipe once the ScrollView is at its edge 11.4s
🟢 a swipe outside the ScrollView changes the page 9.8s
🟢 tests/issues/issue-1196-set-page-after-add.e2e.ts · 6 passed 1m 6s
🟢 setPageWithoutAnimation selects a page added in the same commit 9.8s
🟢 setPage animates to a page added in the same commit 10.5s
🟢 setPage from useEffect animates to a page added in the same commit 9.8s
🟢 setPageWithoutAnimation selects a page added to an empty pager in the same commit 10.6s
🟢 an index that is not there yet is ignored, not applied later 12.1s
🟢 pages that come back report the page the pager lands on once 13.5s
🟢 tests/issues/issue-1199-scroll-state.e2e.ts · 2 passed 20.3s
🟢 setPage reports settling, then idle once the page has stopped 10.6s
🟢 a swipe reports settling when the finger lifts, then idle once the page has stopped 9.7s
🟢 tests/issues/issue-1205-diagonal-swipe.e2e.ts · 2 passed 20.1s
🟢 a slanted, mostly vertical drag scrolls the list 10.0s
🟢 a slanted, mostly horizontal drag changes the page 10.1s
🟢 tests/issues/issue-219-empty-pager.e2e.ts · 3 passed 34.1s
🟢 a pager that mounts empty shows its first page when pages arrive 9.9s
🟢 a pager that loses all its pages shows them again when they come back 13.4s
🟢 setPage works as soon as pages arrive in a pager that mounted empty 10.8s
🟢 tests/issues/issue-424-swipe-cancels-press.e2e.ts · 3 passed 39.9s
🟢 a tap on a button in a page presses it 11.5s
🟢 a swipe that starts on a button in a page cancels its touch 11.8s
🟢 a swipe while the JS thread is busy does not press the button 16.6s
🟢 tests/issues/issue-965-pages-before-current.e2e.ts · 6 passed 1m 15s
🟢 inserting a page before the current one keeps it visible 12.6s
🟢 inserting a page at the current index keeps the current page 12.3s
🟢 removing a page before the current one keeps it visible 12.2s
🟢 removing pages up to the current index keeps it visible 12.2s
🟢 moving the current page keeps it visible 12.7s
🟢 moving a page next to the current one keeps it rendered 13.4s
🟢 tests/issues/pr-1194-remount-initial-page.e2e.ts · 1 passed 15.4s
🟢 a pager remounted after its screen was covered starts on its own initialPage 15.4s
🟢 tests/keyboard-dismiss-mode.e2e.ts · 3 passed 25.5s
🟢 on-drag: dragging the pager blurs the input 8.3s
🟢 on-drag: setPage keeps the input focused 8.4s
🟢 none: dragging the pager keeps the input focused 8.7s
🟢 tests/lazy-pager-view.e2e.ts · 4 passed 32.4s
🟢 setPage mounts the target and its neighbours only 7.2s
🟢 swiping mounts the next neighbour 8.2s
🟢 preload distance 0 mounts only the pages visited 7.2s
🟢 a visited page keeps its scroll position 9.8s
🟢 tests/material-top-bar.e2e.ts · 1 passed 11.9s
🟢 material top tabs survive a native-stack push and pop 11.9s
🟢 tests/native-driver-events.e2e.ts · 2 passed 12.8s
🟢 native driver events › setPage through the Animated ref drives the native value 6.8s
🟢 native driver events › a swipe drives the native value 6.0s
🟢 tests/nested-pager-view.e2e.ts · 1 passed 10.4s
🟢 nested horizontal and vertical pagers scroll independently 10.4s
🟢 tests/on-page-selected.e2e.ts · 1 passed 13.8s
🟢 onPageSelected fires an alert for every page change 13.8s
🟢 tests/scrollable-pager-view.e2e.ts · 1 passed 15.5s
🟢 pager inside a ScrollView keeps its page across vertical scrolls 15.5s
🟢 tests/smoke.e2e.ts · 1 passed 6.9s
🟢 horizontal pager pages with the panel buttons 6.9s
🟢 tests/tab-view-inside-scroll-view.e2e.ts · 1 passed 12.1s
🟢 TabView inside a ScrollView swipes tabs and scrolls the page 12.1s

e2e 0.21.3 · 10m 27s · android · run artifacts

@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

🟢 e2e: 1 flaky, 43 passed

⚠️ 1 flaky test passed on a retry

⚠️ keyboard avoidance does not shrink the page under the keyboard
tests/support/app.ts:32

LOCATOR_NOT_FOUND at step 4 of 4: screen.scrollUntilVisible getByTestId("Issue #1096 Keyboard Shrink Repro"), after 1m 18s

target did not become visible while scrolling: getByTestId("Issue #1096 Keyboard Shrink Repro")

  • Asked for: test id "Issue #1096 Keyboard Shrink Repro"
  • Waited: 1m 18s
  • Screen: com.pagerviewexample / PagerView

Evidence: log

All 44 tests in 17 files
Test Time
⚠️ tests/issues/issue-1096-keyboard-shrink.e2e.ts · 1 flaky 39.1s
⚠️ keyboard avoidance does not shrink the page under the keyboard (1 failed attempt first) 39.1s
🟢 tests/basic-pager.e2e.ts · 3 passed 1m 52s
🟢 basic pager › in LTR › pages horizontally 36.9s
🟢 basic pager › in LTR › pages vertically 29.9s
🟢 basic pager › in RTL › pages horizontally with the reversed gesture 45.1s
🟢 tests/issues/issue-1050-back-gesture.e2e.ts · 8 passed 3m 18s
🟢 back gesture over the pager › in LTR › a back swipe on the first page pops the screen 19.4s
🟢 back gesture over the pager › in LTR › a back swipe on a later page goes to the previous page 17.1s
🟢 back gesture over the pager › in LTR › a forward swipe on the first page still goes to the next page 12.4s
🟢 back gesture over the pager › in RTL › a back swipe on the first page pops the screen 39.5s
🟢 back gesture over the pager › in RTL › a back swipe on a later page goes to the previous page 38.0s
🟢 back gesture over the pager › in RTL › a forward swipe on the first page still goes to the next page 43.7s
🟢 back gesture over a pager in a nested stack › a back swipe on the first page pops the outer stack 15.0s
🟢 back gesture over a pager in a nested stack › a forward swipe on the first page still goes to the next page 12.6s
🟢 tests/issues/issue-1083-modal-set-page.e2e.ts · 1 passed 16.2s
🟢 setPage called behind a native-stack modal lands on the requested page 16.2s
🟢 tests/issues/issue-1098-nested-pager.e2e.ts · 1 passed 22.7s
🟢 outer pager stays responsive after its page count changes around a nested pager 22.7s
🟢 tests/issues/issue-1142-search-bar-inset.e2e.ts · 1 passed 23.4s
🟢 FlatList inside the pager gets the search bar safe-area insets 23.4s
🟢 tests/issues/issue-1154-page-change-focus.e2e.ts · 13 passed 4m 7s
🟢 leaving a page with setPage blurs its input 15.6s
🟢 setPage with an input focused lands on the target page 15.8s
🟢 a setPage two pages away lands on the far page 18.8s
🟢 a setPage repeated during the slide changes nothing 14.7s
🟢 leaving a page with a swipe blurs its input 15.3s
🟢 an input focused on the new page keeps focus 15.4s
🟢 a swipe keeps the focus the new page takes from onPageSelected 29.6s
🟢 a setPage that interrupts another lands on the page asked for last 20.5s
🟢 a setPage that interrupts another still lands on the later page 19.0s
🟢 a setPage redirected once the focus has landed blurs it 19.4s
🟢 two setPage calls that cancel out land on the first page 15.4s
🟢 right to left › leaving a page with a swipe blurs its input 33.1s
🟢 a page that grows with the keyboard still lands on the target page 14.4s
🟢 tests/issues/pr-1194-remount-initial-page.e2e.ts · 1 passed 18.3s
🟢 a pager remounted after its screen was covered starts on its own initialPage 18.3s
🟢 tests/keyboard-dismiss-mode.e2e.ts · 3 passed 37.6s
🟢 on-drag: dragging the pager blurs the input 13.4s
🟢 on-drag: setPage keeps the input focused 10.7s
🟢 none: dragging the pager keeps the input focused 13.5s
🟢 tests/lazy-pager-view.e2e.ts · 4 passed 1m 3s
🟢 setPage mounts the target and its neighbours only 12.8s
🟢 swiping mounts the next neighbour 16.6s
🟢 preload distance 0 mounts only the pages visited 13.1s
🟢 a visited page keeps its scroll position 20.8s
🟢 tests/material-top-bar.e2e.ts · 1 passed 21.4s
🟢 material top tabs survive a native-stack push and pop 21.4s
🟢 tests/native-driver-events.e2e.ts · 2 passed 21.8s
🟢 native driver events › setPage through the Animated ref drives the native value 11.6s
🟢 native driver events › a swipe drives the native value 10.2s
🟢 tests/nested-pager-view.e2e.ts · 1 passed 15.4s
🟢 nested horizontal and vertical pagers scroll independently 15.4s
🟢 tests/on-page-selected.e2e.ts · 1 passed 15.8s
🟢 onPageSelected fires an alert for every page change 15.8s
🟢 tests/scrollable-pager-view.e2e.ts · 1 passed 23.9s
🟢 pager inside a ScrollView keeps its page across vertical scrolls 23.9s
🟢 tests/smoke.e2e.ts · 1 passed 10.4s
🟢 horizontal pager pages with the panel buttons 10.4s
🟢 tests/tab-view-inside-scroll-view.e2e.ts · 1 passed 25.9s
🟢 TabView inside a ScrollView swipes tabs and scrolls the page 25.9s

e2e 0.21.3 · 16m 45s · ios · run artifacts

@krozniata
krozniata marked this pull request as ready for review October 30, 2026 08:14
@apex-sentinel-staging

apex-sentinel-staging Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Apex Sentinel

Verdict: approve

Since the last review: 0 new · 4 still open · 13 no longer raised

4 finding(s): 2 minor, 2 nit. Verdict: approve.

🟡 Minor (2)

Where Finding
src/useLazyPages.tsx:12 Children without an element key collapse to the same pageKey "undefined" and share one mounted slot
src/useLazyPages.tsx:22 useLazyPages never updates current when the pager is moved without an onPageSelected event, leaving the visible page as a placeholder
🔵 Nit (2)
Where Finding
src/PagerView.tsx:149 PagerView now always registers JS onPageScroll/onPageSelected handlers, forcing per-frame JS events for every caller (and breaking native-driver-only Reanimated usage)
src/useLazyPages.tsx:21 lazy is read only at first render for current (useState(initialPage)) and mountedKeys, so toggling lazy from …

Reply under an inline comment with @apex-sentinel-staging note ... or @apex-sentinel-staging pivot ... to re-check that finding.
Comment @apex-sentinel-staging review on this conversation to run a full review.

Earlier reviews

Review of 1dab22bc · time not recorded

Apex Sentinel

Verdict: request_changes

Since the last review: 10 new · 0 still open · 7 no longer raised

10 finding(s): 1 major, 2 minor, 7 nit. Verdict: request_changes. reached max rounds (1).

Required fixes

  • Lazy mounting tracks pages only on the JS side: an unmounted page can become the native current page and then nothing mounts around it

🟠 Major (1)

Where Finding
src/useLazyPages.tsx:58 🆕 Lazy mounting tracks pages only on the JS side: an unmounted page can become the native current page and then nothing mounts around it

🟡 Minor (2)

Where Finding
src/useLazyPages.tsx:12 🆕 Stale pages leak into mountedKeys, and pageKey can alias two different pages, so a newly added page renders another page's already-mounted content
src/useLazyPages.tsx:29 🆕 mountedKeys only ever grows: keys of removed pages are retained for the pager's lifetime
🔵 Nit (7)
Where Finding
src/PagerView.tsx:165 🆕 The new onPageSelected wrapper is installed unconditionally
src/useLazyPages.tsx:12 🆕 pageKey folds every keyless child into the single key "undefined"
src/useLazyPages.tsx:26 🆕 lazyPreloadDistance beyond the native offscreenPageLimit unmounts a page the native pager is currently showing
src/useLazyPages.tsx:27 🆕 lazyPreloadDistance is clamped for the effective window but the example's range map uses the raw prop, so the UI can disagree with what mounts
src/useLazyPages.tsx:40 🆕 State is set during render inside useLazyPages without a guard on the cause changing, so any re-render with a changed children/preloadDistance re-derives mountedKeys
src/useLazyPages.tsx:47 🆕 Placeholder <View> reuses the page's key, so React can reuse the instance when the page mounts
src/useLazyPages.tsx:53 🆕 renderLazyPlaceholder returns content for a page index, but that content is wrapped in a plain View whose only style is the page margin

@apex-sentinel-staging apex-sentinel-staging Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Apex Sentinel: 7 new findings on a4ef014. The verdict and the full list are in the summary comment.

Comment thread src/useLazyPages.tsx Resolved
Comment thread src/PagerView.tsx Resolved
Comment thread src/PagerView.tsx
});

// Methods go on the native view, not a wrapper, which Animated needs.
useImperativeHandle<PagerViewRef | null, PagerViewRef | null>(

@apex-sentinel-staging apex-sentinel-staging Bot Oct 30, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No longer raised on b0040e6.

🟠 Major · useImperativeHandle recreates the ref handle only when setCurrent changes, so a null handle on mount is never replaced

useImperativeHandle's dependency list was changed from [] to [setCurrent], but setCurrent is lazy ? setCurrent : undefined (src/useLazyPages.tsx:57) — a stable value that only flips if the lazy prop itself flips. The create function (src/PagerView.tsx:110-141) reads nativeRef.current and returns null when it is still null. React only re-runs the create function when a dependency changes, so if the imperative handle is created before the native view ref is attached (or before nativeRef.current exists — the same situation the if (!instance) return null guard exists for), the caller's ref is set to null and stays null for the component's whole lifetime: every ref.current?.setPage(...) silently does nothing, and lazy mounting never advances because setCurrent is never called.

Suggested change
useImperativeHandle<PagerViewRef | null, PagerViewRef | null>(
useImperativeHandle<PagerViewRef | null, PagerViewRef | null>(
ref,
() => {
const instance = nativeRef.current;
if (!instance) {
return null;
}
return Object.assign(instance, {
setPage: (selectedPage: number) => {
setCurrentRef.current?.(selectedPage);
if (nativeRef.current) {
PagerViewNativeCommands.setPage(nativeRef.current, selectedPage);
}
},
setPageWithoutAnimation: (selectedPage: number) => {
setCurrentRef.current?.(selectedPage);
if (nativeRef.current) {
PagerViewNativeCommands.setPageWithoutAnimation(
nativeRef.current,
selectedPage
);
}
},
setScrollEnabled: (scrollEnabled: boolean) => {
if (nativeRef.current) {
PagerViewNativeCommands.setScrollEnabledImperatively(
nativeRef.current,
scrollEnabled
);
}
},
pagerView: instance,
});
},
[setCurrent]
);

Suggested change

Make the imperative handle independent of setCurrent's identity: read nativeRef.current inside each method (as setScrollEnabled already does) instead of capturing instance/returning null at creation time, and call setCurrent through a ref so the deps list can go back to [].

Details

This is also inconsistent: onPageSelected can advance current (src/PagerView.tsx:165-170), so a pager whose ref never materialises still mounts pages on swipes but not on setPage.

The fix is to keep the handle always live (e.g. read nativeRef.current lazily inside each method, as setScrollEnabled already does, instead of capturing instance at creation time), and not to rely on the identity of setCurrent to refresh it.

Also raised here

Also raised at this location by senior-developer: Imperative handle memoized on an effectively constant dependency and returns null when the native ref is not yet attached — The handle factory reads nativeRef.current and returns null when it is not populated yet. Its dependency array is [setCurrent], and setCurrent is lazy ? setCurrent : undefined — a React state setter, i.e. a permanently stable identity — so in practice the factory runs exactly once per lazy toggle and the null result is never recomputed. That makes the component depend on an unstated invariant: the host ref for PagerViewNativeComponent must already be attached when useLazyPages' setter-driven re-render re-runs the hook; if a render ever produces the handle before the native view attaches (React Native re-mounts, an Animated.createAnimatedComponent wrapper as in usePagerView().AnimatedPagerView, or a future change that makes a lazy mount delay the host view), ref.current.setPage is null for the rest of the pager's life because nothing in the dependency array changes to re-run the factory. …

persona: senior-developer, javascript

Reply @apex-sentinel-staging note ... to add context, or @apex-sentinel-staging pivot ... if this is wrong — I will re-check the cited code.

Useful? Reply @apex-sentinel-staging like, or @apex-sentinel-staging dislike <why> if it is not.

Comment thread src/PagerView.tsx Resolved
Comment thread src/useLazyPages.tsx Resolved
Comment thread example/src/LazyPagerViewExample.tsx Resolved
Comment thread src/useLazyPages.tsx Resolved
Base automatically changed from feat/pager-function-comp to main November 2, 2026 09:37
Copilot AI lite review requested due to automatic review settings November 2, 2026 10:38

Copilot AI 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.

🟡 Changes recommended

A moderate finding in src/useLazyPages.tsx shows that mountedKeys is never pruned, so keys from removed pages can be reused and mount immediately even when far from the current page.

1 open issue
What changed in this PR

This PR adds a lazy mode to PagerView. With lazy on, a page's content mounts only when the pager gets within lazyPreloadDistance pages of it, and it stays mounted afterward. Pages are tracked by key, so adding or removing pages does not remount the others. The change also adds a README section, an example screen, and e2e tests.

Changes:

  • Adds lazy, lazyPreloadDistance (default 1), and renderLazyPlaceholder props to PagerView, backed by a new useLazyPages hook that tracks which pages are mounted.
  • Wires setPage and page selection into the hook so jumps mount the target page and its neighbours.
  • Adds a "Lazy PagerView Example" screen and e2e tests that check the mounted count and scroll position.
File Description
src/​useLazyPages.tsx New hook that decides which pages are mounted, based on the current position and preload distance.
src/​PagerView.tsx Adds the lazy props and connects scroll, selection, and setPage to the hook.
README.md Documents the lazy props and explains placeholders and offscreenPageLimit.
example/​src/​LazyPagerViewExample.tsx New example screen that shows the mounted count, the page map, and preload controls.
example/​src/​App.tsx Registers the new example in the example list.
e2e/​tests/​lazy-pager-view.e2e.ts New e2e tests for mounting on jump and swipe, preload distance 0, and scroll position.
.gitignore Ignores the example/dist/ build output.

Review effort: Lite

Comment thread src/useLazyPages.tsx Resolved

@apex-sentinel-staging apex-sentinel-staging Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Apex Sentinel: 10 new findings on 1dab22b. The verdict and the full list are in the summary comment.

Comment thread src/useLazyPages.tsx
renderPlaceholder?: (index: number) => ReactNode;
};

const pageKey = (child: ReactNode) =>

@apex-sentinel-staging apex-sentinel-staging Bot Nov 2, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No longer raised on b0040e6.

🆕 🟡 Minor · Stale pages leak into mountedKeys, and pageKey can alias two different pages, so a newly added page renders another page's already-mounted content

mountNear (useLazyPages.tsx:30-38) only ever unions into the set; nothing ever prunes keys that are no longer in pages. Combined with pageKey collapsing everything to a string (useLazyPages.tsx:12-13), pages.map (line 50) matches a different child element against a key left over from an earlier child. Concretely, with keyless children Children.toArray assigns positional keys .0, .1, …, so removing the first page of [A,B,C] re-keys the list to [.0=B, .1=C] and B renders immediately because .0 is still marked mounted from A — the two elements share one identity string while React sees two different elements. The README ("Pages are tracked by key, so adding or removing pages doesn't remount the others", README.md:131) states the guarantee without the precondition that keys be unique and stable, and pages whose keys are positional (the usePagerView pages array, src/usePagerView.ts:49-62, which removePage shrinks from the end) is exactly the shape where the invariant silently holds or silently breaks depending on which end pages are removed from. The unbounded set itself is a small leak of string keys that never shrink as pages churn.

Suggested change

Note in the README Lazy pages section that children need unique, stable keys for the "adding or removing pages doesn't remount the others" guarantee, and prune mountedKeys against the current pages keys so removed pages do not pin their keys.

Details

persona: senior-developer

Reply @apex-sentinel-staging note ... to add context, or @apex-sentinel-staging pivot ... if this is wrong — I will re-check the cited code.

Useful? Reply @apex-sentinel-staging like, or @apex-sentinel-staging dislike <why> if it is not.

Comment thread src/useLazyPages.tsx
const pages = lazy ? Children.toArray(children) : [];
const distance = Math.max(preloadDistance, 0) + 1;

// `position` is fractional while scrolling, so both pages on screen count.

@apex-sentinel-staging apex-sentinel-staging Bot Nov 2, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No longer raised on b0040e6.

🆕 🟡 Minor · mountedKeys only ever grows: keys of removed pages are retained for the pager's lifetime

mountNear builds next as a union of mounted and the nearby keys (useLazyPages.tsx:30-38) and nothing ever removes a key. onSelect/setCurrent move current, but a key whose page has left children (e.g. usePagerView's removePage, or any list that shrinks) stays in mountedKeys forever. The set is also ReadonlySet<string> state, so every mount event allocates a fresh Set copy. For a pager with a dynamically shrinking page list this is unbounded retention keyed on stale element keys, and it makes the documented promise "Pages are tracked by key, so adding or removing pages doesn't remount the others" (README.md:132) only half true: re-adding a page whose key is still in the set mounts it immediately with no placeholder, while a freshly added page gets one — the two directions are asymmetric for reasons a caller cannot see from the docs.

Suggested change

Prune keys that are no longer present in children when computing the next mounted set (or intersect with the current page keys), and note the mount-on-re-add behaviour in the Lazy pages docs section.

Details

persona: javascript

Reply @apex-sentinel-staging note ... to add context, or @apex-sentinel-staging pivot ... if this is wrong — I will re-check the cited code.

Useful? Reply @apex-sentinel-staging like, or @apex-sentinel-staging dislike <why> if it is not.

Comment thread src/useLazyPages.tsx
renderPlaceholder?: (index: number) => ReactNode;
};

const pageKey = (child: ReactNode) =>

@apex-sentinel-staging apex-sentinel-staging Bot Nov 2, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No longer raised on b0040e6.

🆕 🔵 Nit · pageKey folds every keyless child into the single key "undefined"

Children.toArray guarantees keys only for valid elements; a raw string/number child (which <PagerView>child</PagerView> accepts as children) yields pageKey === 'undefined', so all such pages share one entry in mountedKeys and mount/unmount together. Pages are documented as needing a key (README "Pages are tracked by key"), so this is reachable only by misuse — falling back to the index, e.g. ?? String(index), would make the degenerate case behave per-page instead of as one shared page.

Suggested change
const pageKey = (child: ReactNode) =>
const pageKey = (child: ReactNode, index: number) =>
String(isValidElement(child) ? (child.key ?? index) : index);

Suggested change

Fall back to the page index in pageKey so a child without a key is still tracked as its own page.

Details

persona: maintainability

Reply @apex-sentinel-staging note ... to add context, or @apex-sentinel-staging pivot ... if this is wrong — I will re-check the cited code.

Useful? Reply @apex-sentinel-staging like, or @apex-sentinel-staging dislike <why> if it is not.

Comment thread src/useLazyPages.tsx
const [mountedKeys, setMountedKeys] = useState<ReadonlySet<string>>(
() => new Set()
);
const pages = lazy ? Children.toArray(children) : [];

@apex-sentinel-staging apex-sentinel-staging Bot Nov 2, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No longer raised on b0040e6.

🆕 🔵 Nit · lazyPreloadDistance beyond the native offscreenPageLimit unmounts a page the native pager is currently showing

useLazyPages.tsx:27 derives distance purely from preloadDistance, and PagerView passes the children prop straight through to the native component, so a page that leaves the mounted window is replaced in place by <View>{renderPlaceholder?.(index)}</View> (useLazyPages.tsx:49-55) — the child's content is swapped while the native pager may still be compositing it. Android's offscreenPageLimit defaults to 1 (src/PagerViewNativeComponent.ts:26 has no explicit default documented here, README.md:109 says "Defaults to 1", and the README's own Lazy section at 154 stresses it is "unrelated" to lazy). So <PagerView lazy lazyPreloadDistance={0} offscreenPageLimit={1}> — a configuration the README never warns against, and the lazy example does not use — unmounts the neighbour page that the pager keeps composed for the transition: the user dragging one page sees real content on one side and a placeholder on the other, then the content pops in on settle. Nothing in useLazyPages or PagerView ties distance to offscreenPageLimit or takes the max of the two.

Suggested change

Take the effective distance as max(lazyPreloadDistance, offscreenPageLimit ?? 1) in PagerView before calling useLazyPages, or document that lazyPreloadDistance must be >= offscreenPageLimit on Android.

Details

persona: senior-developer

Reply @apex-sentinel-staging note ... to add context, or @apex-sentinel-staging pivot ... if this is wrong — I will re-check the cited code.

Useful? Reply @apex-sentinel-staging like, or @apex-sentinel-staging dislike <why> if it is not.

Comment thread src/useLazyPages.tsx
() => new Set()
);
const pages = lazy ? Children.toArray(children) : [];
const distance = Math.max(preloadDistance, 0) + 1;

@apex-sentinel-staging apex-sentinel-staging Bot Nov 2, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No longer raised on b0040e6.

🆕 🔵 Nit · lazyPreloadDistance is clamped for the effective window but the example's range map uses the raw prop, so the UI can disagree with what mounts

useLazyPages computes the window as Math.max(preloadDistance, 0) + 1 (useLazyPages.tsx:27) and the hook itself takes no lower/upper validation of the prop: PagerViewProps.lazyPreloadDistance is a plain number with a default of 1 (PagerView.tsx:45,92). A caller passing a negative value gets distance = 1 (current page only, i.e. silently the same as 0) rather than an error, and a caller passing a fractional value such as 1.5 gets a window of 2 (an asymmetric, silently-rounded range). The public prop is documented in README.md:103 as "how many pages on each side … Defaults to 1", which neither of those behaviours matches. There is no validation or rounding at the prop boundary — the coercion is buried inside the +1 expression, so the documented semantics and the implemented window can diverge for any value that is not a non-negative integer.

Suggested change
const distance = Math.max(preloadDistance, 0) + 1;
const distance = Math.max(Math.round(preloadDistance), 0) + 1;

Suggested change

Normalise lazyPreloadDistance once at the public boundary (Math.max(0, Math.round(n))) so the documented "pages on each side" matches the implemented window for negative and fractional inputs, and say so in the prop JSDoc.

Details

persona: javascript

Reply @apex-sentinel-staging note ... to add context, or @apex-sentinel-staging pivot ... if this is wrong — I will re-check the cited code.

Useful? Reply @apex-sentinel-staging like, or @apex-sentinel-staging dislike <why> if it is not.

Comment thread src/useLazyPages.tsx
}
}

return {

@apex-sentinel-staging apex-sentinel-staging Bot Nov 2, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No longer raised on b0040e6.

🆕 🔵 Nit · Placeholder <View> reuses the page's key, so React can reuse the instance when the page mounts

useLazyPages returns a fresh children array on every PagerView render, and for the default non-lazy path that array is props.children itself. That array identity is now what childrenWithOverriddenStyle (src/utils.tsx:8, src/utils.ios.tsx:8) iterates with Children.map, and the result feeds the native children prop of RNCViewPager. Two consequences:

Suggested change
return {
<View key={`placeholder:${pageKey(page)}`}>
{renderPlaceholder?.(index)}
</View>

Suggested change

Give the placeholder a key distinct from the page's key (or a non-View wrapper type) so React cannot reuse the placeholder instance as the mounted page.

Details
  1. Children.map skips nil/false/undefined children. A consumer using {cond && <Page/>} or pages.map(...).filter(...) style children already got the page rendered, but the array this hook hands over now comes from useLazyPages rather than being the raw props.children, so any conditional child is dropped from the pager rather than just rendering nothing — the same skipping existed before, so this half is not new; the new half is below.

  2. More concretely: in lazy mode a page is replaced in place by <View key={pageKey(page)}>{renderPlaceholder?.(index)}</View> (src/useLazyPages.tsx:53). childrenWithOverriddenStyle clones that element with style: [element.props.style, StyleSheet.absoluteFill] and React.cloneElement on a <View> whose only child is the placeholder output. When the page mounts, React reconciles placeholder-View against the user's page element. For a page whose element type is View (the documented shape: "It is important all children are <View>s", src/PagerView.tsx:74), the type and key match, so React reuses the instance and the page's subtree mounts into the still-present placeholder children — which childrenWithOverriddenStyle also re-wraps in the absoluteFill wrapper. The result is that placeholder content and page content coexist in one reused wrapper for the transition, and anything renderLazyPlaceholder returns with its own state/animation (the README example returns <ActivityIndicator />) keeps running inside the mounted page instead of unmounting.

persona: senior-developer

Reply @apex-sentinel-staging note ... to add context, or @apex-sentinel-staging pivot ... if this is wrong — I will re-check the cited code.

Useful? Reply @apex-sentinel-staging like, or @apex-sentinel-staging dislike <why> if it is not.

Comment thread src/useLazyPages.tsx
mountedKeys.has(pageKey(page)) ? (
page
) : (
<View key={pageKey(page)}>{renderPlaceholder?.(index)}</View>

@apex-sentinel-staging apex-sentinel-staging Bot Nov 2, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No longer raised on b0040e6.

🆕 🔵 Nit · renderLazyPlaceholder returns content for a page index, but that content is wrapped in a plain View whose only style is the page margin

For an unmounted page the hook returns <View key={...}>{renderPlaceholder?.(index)}</View>, and childrenWithOverriddenStyle then overwrites that View's style with { marginHorizontal: pageMargin } (src/utils.tsx:47) — no flex: 1. The README example (renderLazyPlaceholder={() => <View style={{flex: 1, justifyContent: 'center'}}><ActivityIndicator /></View>}) only fills the pager because the outer wrapper never stretches, so with pageMargin set the placeholder is additionally shifted sideways and vertically centered over a zero-flex container, which can read as an empty page. Documented behavior ("what a page shows before its content is mounted") therefore depends on the consumer adding a style the wrapper cannot carry. Either give the wrapper {flex: 1} and merge rather than overwrite in childrenWithOverriddenStyle, or say in the renderLazyPlaceholder jsdoc that the returned node is wrapped in a flex-less View.

Suggested change
<View key={pageKey(page)}>{renderPlaceholder?.(index)}</View>
<View key={pageKey(page)} style={{ flex: 1 }}>
{renderPlaceholder?.(index)}
</View>

Suggested change

Make the placeholder wrapper flex: 1 (or merge styles instead of overwriting them in childrenWithOverriddenStyle) so a placeholder that uses flex: 1 actually fills the page, and note the wrapper in the prop's doc comment.

Details

persona: maintainability

Reply @apex-sentinel-staging note ... to add context, or @apex-sentinel-staging pivot ... if this is wrong — I will re-check the cited code.

Useful? Reply @apex-sentinel-staging like, or @apex-sentinel-staging dislike <why> if it is not.

@apex-sentinel-staging

Copy link
Copy Markdown

Apex Sentinel could not review commit 7a766d4a. No review ran, so there are no findings; this is not a verdict on the change.

Apex Sentinel could not read or check out this pull request, which is usually temporary (GitHub was slow, or the branch moved while the review waited). If it keeps failing, tell whoever runs Sentinel for this repository, or check that the App still has access to it. Push a new commit or comment @apex-sentinel-staging review to try again.

Copilot AI 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.

🔵 Needs a closer look

A moderate finding in src/useLazyPages.tsx shows the selected page is only tracked while lazy is enabled, so turning it on after the user has moved can leave a stale index and a placeholder on the visible page until the user scrolls again.

No open issues 🎉

1 previously missed issue

In code that hasn't changed since last review

Medium severity Track selected page before lazy loading is enabled

src/​useLazyPages.tsx:72

current only updates while lazy is on: setCurrent is undefined and onSelect ignores selections otherwise. If lazy is turned on after the user has moved, mountNear starts from a stale index. The visible page can then show its placeholder until the user drags again. Track the selected page unconditionally, and keep the lazy check only around the mounting in onScroll. This adds a state update, and so a re-render of PagerView, on every page change, including for pagers that don't use lazy. If that cost matters, store the page in a ref instead.

1 resolved since last review

Review effort: Lite

@krozniata
krozniata requested a review from troZee November 4, 2026 09:17

@apex-sentinel-staging apex-sentinel-staging Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Apex Sentinel: 3 findings moved to a new line on b0040e6. The verdict and the full list are in the summary comment.

Comment thread src/useLazyPages.tsx
children,
renderPlaceholder,
}: LazyPagesOptions) {
const [current, setCurrent] = useState(initialPage);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Minor · useLazyPages never updates current when the pager is moved without an onPageSelected event, leaving the visible page as a placeholder

current is only ever changed by setCurrent (imperative setPage) and onSelect (useLazyPages.tsx:61-72); the mount-range recomputation during render (lines 44-49) uses that value. Two reachable sequences leave it stale:

Suggested change
const [current, setCurrent] = useState(initialPage);
const [current, setCurrent] = useState(initialPage);
const lastNotified = useRef(initialPage);
const [mountedKeys, setMountedKeys] = useState<ReadonlySet<string>>(
() => new Set()
);
const pages = lazy ? Children.toArray(children) : [];
const keys = pages.map(pageKey);
const distance = Math.max(preloadDistance, 0) + 1;

Suggested change

Re-anchor current when it can drift: derive the active position from onPageScroll (the hook already receives position/offset) instead of only from onPageSelected/setCurrent, or track the lazy prop transition and recompute the mount range against the pager's actual position when lazy flips to true.

Details
  1. A caller toggles lazy to true after mount while the pager sits on page N (e.g. a useState(false) flipped after the first screen). current is still initialPage, so mountNear mounts the pages around initialPage only; the page actually on screen renders renderLazyPlaceholder and keeps rendering it until some later onPageSelected event arrives, which never happens while the user stays on the page — so a permanently blank/placeholder screen with lazyPreloadDistance={0} (where the current page is not in the mount range). Nothing re-syncs current, because initialPage is read only for the initial useState value and onPageScroll-driven mounting is skipped (it is gated on isDragging).
  2. A programmatic page change that does not fire onPageSelected (a native setCurrentItem without an event, or an event dropped because the JS thread was busy) permanently desynchronises current from the real position, so every later lazy mount window is computed around the wrong page — the visible page can be replaced by a placeholder on the next onSelect-driven remount of the range.

The hook should derive the position from the events it already receives (it sees onPageScroll positions in onScroll) or at least re-anchor when lazy turns on, instead of trusting that onPageSelected always fires.

persona: senior-developer

Reply @apex-sentinel-staging note ... to add context, or @apex-sentinel-staging pivot ... if this is wrong — I will re-check the cited code.

Useful? Reply @apex-sentinel-staging like, or @apex-sentinel-staging dislike <why> if it is not.

Comment thread src/PagerView.tsx
[setCurrent]
);

const onPageScroll = (

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Nit · PagerView now always registers JS onPageScroll/onPageSelected handlers, forcing per-frame JS events for every caller (and breaking native-driver-only Reanimated usage)

To feed useLazyPages, PagerView unconditionally passes onPageScroll and onPageSelected to the native component (src/PagerView.tsx:192-194), even when the consumer passed neither prop. In React Native, whether a native view dispatches an event across the bridge/JSI is decided by whether the handler prop is non-null, so after this change every mount of PagerView — lazy or not, and lazy defaults to false — starts emitting onPageScroll on every scroll frame and onPageSelected to the JS thread. Callers that previously rendered <PagerView style={{flex:1}}> with no event handlers (or with Animated.event(..., {useNativeDriver: true}) only, as the Reanimated guide in docs/docs/docs/guides/reanimated.md documents) pay a per-frame JS event dispatch they did not pay before, i.e. a performance regression for the entire library, exactly in the scenario native-driver events exist to avoid. The handlers also run even when lazy === false, where onScroll/onSelect are no-ops, so the cost buys nothing for non-lazy users. Gate the added handlers on lazy so that, without lazy, the props stay exactly props.onPageScroll/props.onPageSelected as before.

Suggested change
const onPageScroll = (
layoutDirection={deduceLayoutDirection(props.layoutDirection)}
{...(lazy
? {
onPageScroll: (e: ReactNative.NativeSyntheticEvent<OnPageScrollEventData>) => {
props.onPageScroll?.(e);
onScroll(
e.nativeEvent.position,
e.nativeEvent.offset,
isScrolling.current
);
},
onPageSelected: (e: ReactNative.NativeSyntheticEvent<OnPageSelectedEventData>) => {
props.onPageSelected?.(e);
onSelect(e.nativeEvent.position);
},
}
: {})}
onPageScrollStateChanged={onPageScrollStateChanged}

Suggested change

Only attach the wrapping onPageScroll/onPageSelected handlers when lazy is enabled, and note in the README Lazy pages section that lazy requires JS-thread delivery of onPageScroll, so it should not be combined with a UI-thread-only (Reanimated useEvent / useNativeDriver) event handler.

Details

Also raised here

Also raised at this location by javascript: onPageScroll / onPageSelected are now always registered natively, even for callers that pass no handler — Before this PR the native component received exactly the handlers the app passed: the {...props} spread carried onPageScroll / onPageSelected, so a caller that passed neither left those registration names unset and the native side never emitted them (the view managers register them at android/src/main/java/com/reactnativepagerview/PagerViewViewManager.kt:128-130, and iOS emits through eventEmitter->onPageScroll(...) at ios/RNCPagerViewComponentView.mm:111-130). Failure mode: every existing <PagerView> that passes no scroll handler now has both events registered natively, so the pager dispatches an onPageScroll event across the bridge on every scroll frame of every swipe and React re-renders PagerView for each of them — for callers that never observe scroll events on the JS thread (e.g. those that only drive native-driver animations). …

Also raised at this location by maintainability: onScroll runs on every native onPageScroll event while dragging — many per second — and each call allocates a new Set… — onScroll runs on every native onPageScroll event while dragging — many per second — and each call allocates a new Set over all page keys and schedules a PagerView state update (src/useLazyPages.tsx:60-65 via src/PagerView.tsx:146-152). With 20+ pages and a heavy page subtree this is the JS-thread work the README itself warns about; throttle to the integer position (skip the update when Math.round/position+offset rounds to the same page as last time) so the Set is only rebuilt when the mounted range would actually change.

persona: senior-developer, javascript, maintainability

Reply @apex-sentinel-staging note ... to add context, or @apex-sentinel-staging pivot ... if this is wrong — I will re-check the cited code.

Useful? Reply @apex-sentinel-staging like, or @apex-sentinel-staging dislike <why> if it is not.

Comment thread src/useLazyPages.tsx
initialPage,
children,
renderPlaceholder,
}: LazyPagesOptions) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Nit · lazy is read only at first render for current (useState(initialPage)) and mountedKeys, so toggling lazy from …

lazy is read only at first render for current (useState(initialPage)) and mountedKeys, so toggling lazy from false to true on an existing pager starts from the initial page's range rather than the page the user is on, and the previously-rendered real children are replaced by placeholders. If toggling isn't supported, say so in the LazyProps doc comments; otherwise sync current from onPageSelected on the first lazy render.

Details

persona: maintainability

Reply @apex-sentinel-staging note ... to add context, or @apex-sentinel-staging pivot ... if this is wrong — I will re-check the cited code.

Useful? Reply @apex-sentinel-staging like, or @apex-sentinel-staging dislike <why> if it is not.

This branch has not been deployed

No deployments
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