Repository navigation
[$250] iOS - Attachments - Password input of protected PDF closes after rotating to landscape #103531
Description
Activity
- addedDailyKSv2KSv2BugSomething is broken. Auto assigns a BugZero manager.Something is broken. Auto assigns a BugZero manager.
on Oct 8, 2026 While Applause doesn't embed attachments for this bug, here are the links for quick view:
https://platform.applause.com/services/links/v1/external/9a766f7707aa90a499c39e41131088e029cf2307068f7f435c99df1d1d3ce7f6Proposal
Confidence: medium. The code path is confirmed, but I didn't reproduce this on an iOS device. Web testing can't simulate an iOS rotation, so I skipped it.
What is the root cause of that problem?
When the device rotates, the carousel receives a page-change event that it didn't ask for, and it applies that event without checking it.
- iOS app: The native pager sends
onPageSelectedwith position0.updatePagethen callsKeyboard.dismiss()andsetPage(0). This closes the password input and opens the first attachment.onPageSelectedis the only code path that can set page0on native. - Likely regression: fix: Android - Split - Percentage input has to be focused twice to be able to edit it (merged 2026-09-14) bumped
react-native-pager-viewfrom 7.0.2 to 9.0.4. Version 9 rebuilt the iOS pager on a SwiftUITabView. Its selection is two-way bound tocurrentPage, and every change is sent to JS asonPageSelected, including resets caused by layout changes during rotation. - mWeb: The web carousel is a
FlatListpositioned by pixel offset (page * cellWidth). After a resize, a passiveuseEffectcorrects the scroll position. Before it runs, viewability callbacks can read the stale offset and select a neighbouring page. This matches the "closest attachment" note.
The password form isn't required by either mechanism, so rotating on any attachment probably triggers this too. I haven't verified that.
What changes do you think we should make in order to solve the problem?
- Native: In
AttachmentCarouselView/index.native.tsx, applyonPageSelectedonly when it follows a user drag (useonPageScrollStateChanged) or matches a page we set ourselves. Otherwise keep the current page and don't dismiss the keyboard. - Native: In
Pager/index.tsx, store the intended page in a ref. When the window dimensions change, callsetPageWithoutAnimation(intendedPage)so the native selection is restored after rotation. - Web: Change the
cellWidtheffect touseLayoutEffect. While a width change is being corrected, haveupdatePageskipsetPageandKeyboard.dismiss().
What alternative solutions did you explore? (Optional)
- Patch
react-native-pager-view'sPagerView.swiftwithpatch-package. The patch would re-applycurrentPageafter a size change and stop emittingonPageSelectedfor layout-only changes. Report the bug upstream too. This is cleaner long term but adds a patch to maintain. - Revert the pager-view bump. This would bring back the Android bug that the bump fixed.
Investigation details
- The parent carousel's attachment matching returns either the matching index or
-1, never0(AttachmentCarousel/index.tsx:78-105). That points to the native pager event as the source of page0. Pager/index.tsx:147-158passesonPageSelectedstraight to the native view.initialPageis read only when the native view is created, so JS can't restore the page through props.- In pager-view 9.0.4,
PagerView.swiftusesTabView(selection: $props.currentPage),.id(props.children.count), and.onChange(of: props.currentPage) { onPageSelected(...) }. A selection reset or a rebuilt view during rotation goes straight to JS. - Landscape usually flips
shouldUseNarrowLayout, which changes the PDF and password-form layout (PDFView/index.native.tsx:159-175). This could be what makes the native pager rebuild during rotation. I haven't verified this. - Web
updatePagealso callsKeyboard.dismiss()unconditionally and clears the active attachment when no item is visible during the resize (index.tsx:94-121).
To confirm the cause: log
updatePageon the iOS simulator and rotate on a plain image and on the password form. Also check whether this reproduces on a build from before 2026-09-14.
Next Steps for Contributor+ team:
To accept:@MelvinBot implement [this](https://github.com/Expensify/App/issues/103531)to create a draft PR.
To refine:@MelvinBot <your feedback>
To reject: Explain why you are rejecting Melvin's proposal.
- iOS app: The native pager sends
- addedExternalAdded to denote the issue can be worked on by a contributorAdded to denote the issue can be worked on by a contributor
on Oct 8, 2026 Triggered auto assignment to Contributor-plus team member for initial proposal review - @huult (
External)- changed the title
[-]iOS - Attachments - Password input of protected PDF closes after rotating to landscape[/-][+][$175] iOS - Attachments - Password input of protected PDF closes after rotating to landscape[/+]on Oct 8, 2026 Job added to Upwork: https://www.upwork.com/jobs/~022108314160360869510
Proposal
Please re-state the problem that we are trying to solve in this issue.
On iOS, when viewing a password-protected PDF attachment, rotating the device from portrait to landscape closes the password input and soft keyboard, and the carousel resets or navigates unexpectedly to another attachment.
What is the root cause of that problem?
In
src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.native.tsx, the native pager emitsonPageSelectedevents during layout recomputation and orientation changes (SwiftUITabViewcontainer re-render).updatePageunconditionally executesKeyboard.dismiss()and updates the page state:const updatePage = useCallback( (newPageIndex: number) => { Keyboard.dismiss(); setShouldShowArrows(true); // ... setPage(newPageIndex); }, [setShouldShowArrows, attachments, setPage, onNavigate], );
Because
updatePagedid not check whethernewPageIndex === page:- When the device rotates while viewing the protected PDF (at current
page), the native layout pass emitsonPageSelected(with position 0 or the current position). Keyboard.dismiss()is immediately triggered, which dismisses the iOS software keyboard and closes the PDF password form modal.- If the pager emitted 0 unprompted during orientation re-measurement,
setPage(0)switches the active attachment back to the first item in the report instead of preserving the currently viewed attachment.
What changes do you think we should make in order to solve the problem?
- In
src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.native.tsx, add an early return guard inupdatePageso that whennewPageIndex === page, no redundant page state transition occurs andKeyboard.dismiss()is never called:
const updatePage = useCallback( (newPageIndex: number) => { if (newPageIndex === page) { return; } Keyboard.dismiss(); setShouldShowArrows(true); const item = attachments.at(newPageIndex); setPage(newPageIndex); if (newPageIndex >= 0 && item) { setActiveAttachmentID(item.attachmentID ?? item.source); if (onNavigate) { onNavigate(item); } } }, [setShouldShowArrows, attachments, setPage, onNavigate, page], );
- Additionally, in
AttachmentCarouselPager(src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.native.tsx), ensureonPageSelectedonly dispatches whennewPage !== page:
onPageSelected={({nativeEvent: {position: newPage}}) => { if (newPage !== page) { updatePage(newPage); } }}
This prevents keyboard dismissal on device rotation and preserves the user's active password input screen seamlessly across orientations.
What alternative solutions did you explore? (Optional)
- Dismissing keyboard only on manual touch gestures: Redundant since checking
newPageIndex === pagedirectly resolves both false-positive dismissal and unprompted navigation resets without complex touch event listeners. - Patching
react-native-pager-viewdirectly: Unnecessary overhead since guarding the callback in JS cleanly handles native layout change events without maintaining extra native patches.
Tests
- Open a chat report containing multiple attachments including a password-protected PDF.
- Tap on the protected PDF to open the attachment preview modal.
- Tap on the password input field so the keyboard is focused.
- Rotate the device from portrait to landscape and back.
- Verify that the keyboard remains open, the password modal is intact, and the viewer stays on the current protected PDF.
- Swipe left and right between attachments to confirm normal navigation still works and dismisses the keyboard as intended.
Contributor details
Your Expensify account email: jloa.dev@gmail.com
Upwork Profile Link: https://www.upwork.com/freelancers/~0163955a2ba22190aa- When the device rotates while viewing the protected PDF (at current
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
Proposal
What is the root cause of that problem?
This issue involves two separate platform behaviors triggering unintended page transitions during device orientation changes:
-
iOS Native (
AttachmentCarouselView/index.native.tsx&Pager/index.tsx):- In
AttachmentCarouselView/index.native.tsx, the native pager (AnimatedPagerViewwrappingreact-native-pager-view9.0.4) emitsonPageSelectedevents during device rotation layout passes when SwiftUI recomputes the view hierarchy. AnimatedPagerViewis initialized once withinitialPage, butreact-native-pager-viewdoes not continuously bind page selection to JS props. When the native iOS container rebuilds during rotation, its internal selection state resets to index 0 and emitsonPageSelected({nativeEvent: {position: 0}}).updatePageinAttachmentCarouselView/index.native.tsxunconditionally invokesKeyboard.dismiss()and executessetPage(newPageIndex). It lacks any validation to check whether the incomingonPageSelectedwas initiated by an active user gesture (isPagerScrolling) or represents an unprompted native layout reset.- As a result, when rotating the device while entering a password for an attachment (e.g. index 2), the native pager emits 0,
updatePage(0)dismisses the software keyboard, and the carousel navigates back to index 0 (the first attachment in the chat), unmounting the password form component and resetting its local state.
- In
-
Mobile Web / Safari (
AttachmentCarouselView/index.tsx):- On web,
updatePageis driven byAnimated.FlatList'sonViewableItemsChanged. - When the device rotates,
windowWidthandcellWidthchange immediately. The resizeuseEffectinvokesscrollRef.current.scrollToIndex({index: page, animated: false}). - However, during the layout transition before
scrollToIndexaligns the scroll offset to the new cell dimensions,onViewableItemsChangedfires with the closest partially visible item. updatePageunconditionally callsKeyboard.dismiss()and updatessetPageto that closest item, closing the password input and opening the neighboring attachment.
- On web,
What changes do you think we should make in order to solve the problem?
-
Native (
src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.native.tsx&src/components/Attachments/AttachmentCarousel/Pager/index.tsx):- In
AttachmentCarouselPager(src/components/Attachments/AttachmentCarousel/Pager/index.tsx), track whether the page selection is driven by an active user gesture.usePageScrollHandleralready maintainsisPagerScrolling. - When
onPageSelectedfires:- If
event.nativeEvent.position === page, do not trigger page transitions or keyboard dismissal. - If
event.nativeEvent.position !== pagebut!isPagerScrolling.get(), recognize this as an unprompted native container layout reset (such as device rotation). Re-assert the current active page viapagerRef.current?.setPage(initialPage)and suppress the erroneous navigation to page 0.
- If
- In
AttachmentCarouselView/index.native.tsx, updateupdatePageto only callKeyboard.dismiss()when the target page index is different from the currently active page (newPageIndex !== page).
- In
-
Web (
src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx):- In
AttachmentCarouselView/index.tsx, introduce a resize guard ref (isResizingRef). WhencellWidthchanges due to window resize or orientation rotation, setisResizingRef.current = true. - In
updatePage, ignore viewable item updates whileisResizingRef.currentis active, resetting the flag oncescrollToIndexcompletes (e.g. insiderequestAnimationFrame). - Guard
Keyboard.dismiss()so it only fires whenentry.index !== page.
- In
-
Unit Tests:
- Add unit tests in
tests/unit/AttachmentCarouselTest.tsxverifying:updatePagedoes not dismiss the keyboard or emit navigation events when called with the current page index.- Layout/dimension changes preserve the active attachment page index without triggering unprompted navigation callbacks.
- Add unit tests in
What alternative solutions did you explore? (Optional)
- Dismissing keyboard only on blur: Does not solve the unwanted page switch to attachment 0 on native or adjacent attachments on web.
- Checking only
if (newPageIndex === page)inupdatePage: Insufficient on its own because the native pager on iOS emitsposition: 0when the user was viewing attachment 2 or higher; since0 !== 2, that simple check fails to prevent the carousel from switching to the first attachment. Validating gesture state (isPagerScrolling) or suppressing layout-shift events during rotation is essential.
Tests
- Open a chat report with multiple attachments (images/files) and at least one password-protected PDF.
- Tap on the password-protected PDF to open the attachment modal.
- Tap "Enter the password" to open and focus the password text input (keyboard appears).
- Rotate the device from portrait to landscape.
- Verify that the keyboard remains open, the password modal is intact, and the viewer stays on the protected PDF (does not jump to the first attachment).
- Rotate back to portrait and verify the password prompt remains open.
- Swipe left/right between attachments and verify normal swipe navigation functions correctly and dismisses the keyboard as intended.
Contributor details
Your Expensify account email: camesenin@gmail.com
Upwork Profile Link: https://www.upwork.com/freelancers/~0170d6b02e9440e604-
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
ScreenRecording_10-09-2026.12-41-57_1.mp4.mov
Melvin’s proposal doesn’t fix the issue, so I’ll add the Help Wanted label.
1 remaining item
- addedHelp WantedApply this label when an issue is open to proposals by contributorsApply this label when an issue is open to proposals by contributors
on Oct 9, 2026 Proposal
Please re-state the problem that we are trying to solve in this issue.
On iOS, with the password form of a protected PDF open in the attachment carousel, rotating to landscape dismisses the keyboard/form and the carousel jumps to the first attachment of the chat instead of staying on the PDF.
What is the root cause of that problem?
The native carousel treats every
onPageSelectedfromreact-native-pager-viewas a user navigation, and since the 9.x upgrade the iOS pager emits a spuriousonPageSelected(0)whenever its size changes.- On iOS the pager is a SwiftUI
TabView(.page). In 9.x it is wrapped in aGeometryReaderand every page gets an explicit.frame(width: proxy.size.width, height: proxy.size.height)(added for vertical paging):
https://github.com/callstack/react-native-pager-view/blob/v9.0.4/ios/PagerView.swift#L20-L24
In 8.0.0, used until PR#100149(b6c09f5b1af, merged 2026-09-14), pages had no proxy-driven frame:
https://github.com/callstack/react-native-pager-view/blob/v8.0.0/ios/PagerView.swift#L12-L15 - On rotation the proxy size changes, every cell is re-framed, the collection view loses its page offset and SwiftUI writes page
0into theselectionbinding, which is forwarded to JS unconditionally:
https://github.com/callstack/react-native-pager-view/blob/v9.0.4/ios/PagerView.swift#L78-L80 - App passes the event straight through to the parent:
App/src/components/Attachments/AttachmentCarousel/Pager/index.tsx
Lines 151 to 153 in b9626f2
onPageSelected={onPageSelected} style={styles.flex1} initialPage={initialPage} updatePage(0)then does exactly what the tester sees:Keyboard.dismiss()closes the password keyboard,setPage(0)/onNavigate(first)switch the modal to the first attachment:
App/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.native.tsx
Lines 44 to 59 in b9626f2
const updatePage = useCallback( (newPageIndex: number) => { Keyboard.dismiss(); setShouldShowArrows(true); const item = attachments.at(newPageIndex); setPage(newPageIndex); if (newPageIndex >= 0 && item) { setActiveAttachmentID(item.attachmentID ?? item.source); if (onNavigate) { onNavigate(item); } } }, [setShouldShowArrows, attachments, setPage, onNavigate], setPage(0)flows back asinitialPage, soactivePageIndexbecomes 0 and the PDF item losesisFocused, which is why the form never regains focus:
App/src/components/Attachments/AttachmentCarousel/Pager/index.tsx
Lines 74 to 77 in b9626f2
useEffect(() => { setActivePageIndex(initialPage); activePage.set(initialPage);
This matches every detail: iOS App only (Android's ViewPager2 keeps its item across rotation), "the first attachment opens" (page 0, not a neighbour), and in the video the keyboard disappears and the first attachment's spinner appears the moment the rotation ends. Nothing in App touches the pager on rotation, and 9.0.5-9.0.7 do not change this path, so a library bump does not fix it.
What changes do you think we should make in order to solve the problem?
Make
AttachmentCarouselPagergate page-selection events: forward only selections the user swiped to, and put the pager back when the native layer moved on its own.In
Pager/index.tsx:- keep a ref mirror of
activePageIndexand anisUserPagingRefset inonPageScrollStateChangedwhenpageScrollState === 'dragging'(every swipe starts with this state; a layout reset never does); - replace
onPageSelected={onPageSelected}with a local handler:
const handlePageSelected = (e) => { const {position} = e.nativeEvent; if (position === activePageIndexRef.current) { isUserPagingRef.current = false; return; } if (!isUserPagingRef.current) { // native re-layout (rotation) moved the pager; restore the page the App is on pagerRef.current?.setPageWithoutAnimation(activePageIndexRef.current); return; } isUserPagingRef.current = false; onPageSelected?.(e); };
Repro trace: rotation → native emits
onPageSelected(0)with nodraggingstate →0 !== activePageIndexRef→ pager snapped back to the PDF page, nothing forwarded, soKeyboard.dismiss()andsetPage(0)never run and the password field keeps focus. The snap-back echoesonPageSelected(N), which equals the active page and is dropped. Regression safety: a user swipe still starts withdraggingand is forwarded unchanged; arrow navigation callsupdatePagebeforepagerRef.setPage(index.native.tsx#L65-L77) and the mount-timeonPageSelected(initialPage)already matches the active page, so dropping those echoes changes nothing.Why the obvious one-liner does not work: snapping in
onLayout(setPageWithoutAnimation(activePage.get())when the width changes) fails in both orderings. Before the SwiftUI reset it setscurrentPageto the value it already has, so nothing happens andonPageSelected(0)still follows; after the resetactivePagewas already overwritten to 0 by theonPageScrollworklet (Pager/index.tsx#L68-L73), so it snaps to the wrong page. The guard has to sit on the event itself.Secondary (optional): mWeb Safari (Note 2) is a different mechanism: the web carousel derives
cellWidthfromwindowWidthand the FlatList keeps its old offset after rotation, so the nearest row becomes active (); aApp/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx
Lines 86 to 90 in b9626f2
const {windowWidth} = useWindowDimensions(); const cellWidth = useMemo( () => PixelRatio.roundToNearestPixel(windowWidth - (modalStyles.marginHorizontal + modalStyles.borderWidth) * 2), [modalStyles.borderWidth, modalStyles.marginHorizontal, windowWidth], scrollToIndex({index: page, animated: false})whencellWidthchanges covers it, outside this report's iOS scope.What alternative solutions did you explore? (Optional)
onLayoutsnap-back only (above): no-op before the reset, wrong target after it, and it does not stopupdatePage(0)from dismissing the keyboard.- Patch
react-native-pager-view(re-applycurrentPageon size change, or key theTabViewon the size): the binding is already 0 when it runs, soonPageSelected(0)is still emitted, and re-keying drops the text input's first-responder status. Worth an upstream report, but App should not wait on it.
- On iOS the pager is a SwiftUI
Proposal
Please re-state the problem that we are trying to solve in this issue.
On iOS, while the password field of a password-protected PDF is focused in the attachment carousel, rotating the device to landscape closes the password form and switches the carousel to a different attachment (the first one in the chat).
What is the root cause of that problem?
On native, the carousel is
AttachmentCarouselPager, areact-native-pager-viewPagerView. EveryonPageSelectedevent coming from it is forwarded straight toupdatePage:App/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.native.tsx
Lines 104 to 114 in bca2d46
<AttachmentCarouselPager items={attachments} initialPage={page} onAttachmentError={onAttachmentError} activeAttachmentID={activeAttachmentID} setShouldShowArrows={setShouldShowArrows} onPageSelected={({nativeEvent: {position: newPage}}) => updatePage(newPage)} onSwipeDown={onSwipeDown} ref={pagerRef} reportID={report?.reportID} /> and
updatePagetreats it as a user navigation: it dismisses the keyboard, changespage, and callsonNavigatewith the new item, which makes the modal show that attachment instead of the PDF:App/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.native.tsx
Lines 43 to 60 in bca2d46
/** Updates the page state when the user navigates between attachments */ const updatePage = useCallback( (newPageIndex: number) => { Keyboard.dismiss(); setShouldShowArrows(true); const item = attachments.at(newPageIndex); setPage(newPageIndex); if (newPageIndex >= 0 && item) { setActiveAttachmentID(item.attachmentID ?? item.source); if (onNavigate) { onNavigate(item); } } }, [setShouldShowArrows, attachments, setPage, onNavigate], ); On iOS,
react-native-pager-view9.x is a SwiftUITabViewwith the page style, whoseselectionis bound tocurrentPage; any change of that selection is emitted to JS asonPageSelected. When the pager's frame changes (rotation), the underlying collection view keeps its old content offset while the page width changes, soTabViewresolves the selection to a different page (in landscape the old offset maps to a lower index, which is why the first attachment opens) and emitsonPageSelectedwithout any swipe. With the keyboard up this is made worse in the version we ship (9.0.4): itsGeometryReaderroot still honours the keyboard and safe-area regions, so every page is re-framed several times while the keyboard and safe area change during the rotation (fixed upstream in 9.0.5 and 9.0.6, callstack/react-native-pager-view#1140 and #1150):Line 207 in bca2d46
"react-native-pager-view": "9.0.4", Nothing on our side distinguishes a layout-induced selection change from a real swipe. The web carousel has an explicit guard that re-aligns the list to the current page whenever the cell width changes:
App/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx
Lines 237 to 246 in bca2d46
// Scroll position is affected when window width is resized, so we readjust it on width changes useEffect(() => { if (attachments.length === 0 || scrollRef.current == null) { return; } scrollRef.current.scrollToIndex({index: page, animated: false}); // The hook is not supposed to run on page change, so we keep the page out of the dependencies // eslint-disable-next-line react-hooks/exhaustive-deps }, [cellWidth]); but the native view has no equivalent, so the spurious selection is accepted and the PDF (and its password form) is replaced.
What changes do you think we should make in order to solve the problem?
- In
AttachmentCarouselPager, track whether the current page change was started by the user: set a ref whenonPageScrollStateChangedreportsdragging, and clear it once that drag's selection has been reported (atidleif the page was already selected during the drag, otherwise when the selection arrives, since iOS can report it after the pager settles). Also record the page we request programmatically in the imperativesetPagehandle (used by the arrow buttons). - Wrap
onPageSelectedthere: if the selected position equals the page the carousel is already on, or the change came from a user drag, or it matches the page we requested programmatically, forward it as today. Otherwise the selection was caused by a layout change (rotation, keyboard), so do not forward it; instead callsetPageWithoutAnimationwith the current page to snap the pager back, and ignore the selection event that the snap-back itself emits. That keepspage, the active attachment and the PDF's local password state untouched, andKeyboard.dismiss()inupdatePageis not triggered. - Bump
react-native-pager-viewto 9.0.6 (the newest 9.0.x allowed by the repo's 7-daymin-release-age), which stops the pager'sGeometryReaderroot from reacting to the keyboard and safe area, so pages are no longer re-framed while the keyboard is open. The guard in steps 1-2 is still needed because a plain rotation changes the frame regardless.
Compare branch: https://github.com/Expensify/App/compare/main...wildan-m:App:wildan/103531-pager-ignore-layout-page-change?expand=1
What alternative solutions did you explore? (Optional)
Remount the pager with a
keyderived from the window dimensions so it is re-created at the currentpageafter rotation. It keeps the right attachment, but it remounts every carousel item, so the PDF's typed password and focus are lost, and it still cannot stop theonPageSelectedthat fires before the remount.- In
- changed the title
[-][$175] iOS - Attachments - Password input of protected PDF closes after rotating to landscape[/-][+][$250] iOS - Attachments - Password input of protected PDF closes after rotating to landscape[/+]on Oct 9, 2026 🤖 ProposalPolice™ is tracking duplicate proposals for this issue using an OpenAI Conversation. This comment stores that Conversation's ID and can be safely ignored.
🚨 Edited by proposal-police: This proposal was edited at 2026-10-09 08:26:54 UTC.
Proposal
Please re-state the problem that we are trying to solve in this issue.
On iOS, opening a password-protected PDF in the chat attachment carousel and focusing the password field closes the password input and opens the first attachment in the chat. The reported gesture is rotating to landscape with that field focused. The carousel should stay on that PDF, with the password field still focused. This replaces my earlier proposal on this issue.
What is the root cause of that problem?
The iOS pager selects page 0 by itself when the keyboard opens. The carousel treats that event as a swipe: it dismisses the keyboard and navigates to the first attachment.
Last correct step: the carousel
pageis the password-protected PDF, andPDFPasswordFormis focused.First incorrect step:
react-native-pager-viewemitsonPageSelectedwith position0.A simulator trace of the repro showed the order. The PDF was page 4. The window stayed 402 by 874. About 50ms after the keyboard was shown,
onPageSelectedarrived with position0while the React page was still 4. The attachment list did not change (deepEqualstayed true), and the pager did not remount.PDFPasswordFormwas still mounted and focused. It unmounted only afterupdatePagenavigated to index 0. There was no blur before that navigation.The native carousel forwards every selection into
updatePage:App/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.native.tsx
Line 110 in a3b5785
onPageSelected={({nativeEvent: {position: newPage}}) => updatePage(newPage)} AttachmentCarouselPagerpasses that callback through:App/src/components/Attachments/AttachmentCarousel/Pager/index.tsx
Lines 147 to 155 in a3b5785
<AnimatedPagerView pageMargin={40} offscreenPageLimit={1} onPageScroll={pageScrollHandler} onPageSelected={onPageSelected} style={styles.flex1} initialPage={initialPage} animatedProps={animatedProps} ref={pagerRef} updatePagethen does what the tester sees.Keyboard.dismiss()closes the password keyboard, andsetPage(0)plusonNavigateswitches the modal to the first attachment:App/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.native.tsx
Lines 44 to 60 in a3b5785
const updatePage = useCallback( (newPageIndex: number) => { Keyboard.dismiss(); setShouldShowArrows(true); const item = attachments.at(newPageIndex); setPage(newPageIndex); if (newPageIndex >= 0 && item) { setActiveAttachmentID(item.attachmentID ?? item.source); if (onNavigate) { onNavigate(item); } } }, [setShouldShowArrows, attachments, setPage, onNavigate], ); The parent carousel sets
pagefrom attachment matching. That effect's dependencies are report data, not the window size or the keyboard:}, [reportActions, parentReportActions, compareImage, attachments, setDownloadButtonVisibility, onNavigate, accountID, type, report, isReportArchived]); Why the pager emits 0, and why the password field has to be focused. The app is on
react-native-pager-view9.0.4:Line 205 in a3b5785
"react-native-pager-view": "9.0.4", That bump is b6c09f5b1af, merged in #100149. In 9.0.4,
TabViewselection is bound toprops.currentPage, and every change is forwarded to JavaScript:https://github.com/callstack/react-native-pager-view/blob/v9.0.4/ios/PagerView.swift#L78-L80
9.0.6 still forwards every selection the same way. Its
.ignoresSafeArea()on theGeometryReaderandignoreSafeArea: trueon the hosting controller do not stopTabViewfrom writing page 0 when the keyboard opens:https://github.com/callstack/react-native-pager-view/blob/v9.0.6/ios/PagerView.swift#L81-L83
I installed 9.0.6 and reproduced the jump. The keyboard opening was enough. The React window size did not change. Rotation with the keyboard up hits this same event, because that gesture also changes the pager's selection without a user drag.
A phone stays on the narrow layout through the rotation, so
shouldUseNarrowLayoutdoes not rebuild the pager:const isSmallScreenWidth = windowWidth <= variables.mobileResponsiveWidthBreakpoint || isInLandscapeMode; Android does not compile
PagerView.swift, so this selection event is iOS only. The issue checks iOS App only.What changes do you think we should make in order to solve the problem?
Handle
onPageSelectedin the native carousel. Accept it only when the user dragged or the pager is settling. A selection that arrives any other way, including the keyboard writing page 0, snaps the native pager back to the current page and returns beforeupdatePage. A selection whose position is already the current page also returns beforeupdatePage, so the snap-back echo does not dismiss the keyboard.AttachmentCarouselPagerneeds to forwardonPageScrollStateChanged. It already forwardsonPageSelected.onPageScroll={pageScrollHandler} + onPageScrollStateChanged={onPageScrollStateChanged} onPageSelected={onPageSelected}In
AttachmentCarouselView/index.native.tsx, remember a user scroll until the matching selection is handled.onPageScrollStateChangedcan reportidlebeforeonPageSelected, so the flag has to stay set across that gap.const pagerRef = useRef<AttachmentCarouselPagerHandle>(null); + const pageRef = useRef(page); + pageRef.current = page; + const userScrollRef = useRef(false); const updatePage = useCallback( (newPageIndex: number) => { + pageRef.current = newPageIndex; Keyboard.dismiss();
onPageScrollStateChanged={({nativeEvent: {pageScrollState}}) => { if (pageScrollState === 'dragging' || pageScrollState === 'settling') { userScrollRef.current = true; } }} onPageSelected={({nativeEvent: {position: newPage}}) => { const currentPage = pageRef.current ?? null; const fromUserScroll = userScrollRef.current; if (currentPage == null || newPage === currentPage) { return; } userScrollRef.current = false; if (!fromUserScroll) { pagerRef.current?.setPage(currentPage); return; } updatePage(newPage); }}
Arrow taps stay on
cycleThroughAttachments, which already callsupdatePageand thensetPage:App/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.native.tsx
Lines 66 to 73 in a3b5785
const cycleThroughAttachments = useCallback( (deltaSlide: number) => { if (page === undefined) { return; } const nextPageIndex = page + deltaSlide; updatePage(nextPageIndex); pagerRef.current?.setPage(nextPageIndex); updatePagewritespageRefbefore the nativesetPage, so the echo of that arrow move matches the current page and returns. A real swipe setsuserScrollReffromdraggingorsettling, soonPageSelectedstill callsupdatePage.This covers the keyboard opening on the password field and a rotation while that field is focused. Both are pager selections with no user drag. I confirmed on the iOS simulator that focusing the password field keeps that PDF open and keeps the field focused.
Blast radius. Two files:
Pager/index.tsxgains an optional callback, andAttachmentCarouselView/index.native.tsxdecides which selections count. Web uses the other carousel view. Android does not emit this SwiftUI selection. Swipes and arrow buttons still dismiss the keyboard, because that dismissal still runs fromupdatePageon a real page change.What alternative solutions did you explore? (Optional)
-
Bumping
react-native-pager-viewfrom 9.0.4 to 9.0.6. 9.0.6 adds.ignoresSafeArea()on theGeometryReaderandignoreSafeArea: trueon the hosting controller. I installed it and the jump still happened.onPageSelected(0)still arrived when the keyboard opened, andupdatePagestill dismissed the keyboard. The bump leaves.onChange(of: props.currentPage)forwarding every selection. -
Melvin's proposal gates
onPageSelectedand callssetPageWithoutAnimationon a window-size change. huult reproduced the bug with that approach. The captured trace had no window-size change. The keyboard opened at 402 by 874, and the selection event followed. A guard that only runs on a dimension change does not see that path. -
My earlier snap-back comment assumed the password field had already resigned before JavaScript ran. The trace showed the opposite. The form was still focused when
onPageSelected(0)arrived, and it unmounted only becauseupdatePageran. Returning beforeKeyboard.dismiss()and snapping the pager back withsetPagekeeps the field. The same-page echo must also return beforeupdatePage, or that echo dismisses the keyboard. -
Reverting to 8.0.0 brings back the Android split-percentage focus bug that #100149 fixed by moving to 9.0.4.
What specific scenarios should we cover in automated tests?
The failure is an iOS
TabViewselection, so a JS unit test cannot open the keyboard inside it. These are the device checks:- With several attachments and a password-protected PDF, focusing the password field keeps that PDF open, keeps the field focused, and keeps the keyboard up.
- With that field focused, rotating to landscape and back keeps the same PDF and the same focused field.
- A swipe to the next attachment, and a tap of either arrow button, each move exactly one attachment and dismiss the keyboard.
- Focusing a text input on another screen that uses this pager, then rotating, keeps the input focused and keeps the pager on its current page.
Videos
Before:
Screen.Recording.2026-10-09.at.12.42.06.PM.online-video-cutter.com.mp4
After:
Screen.Recording.2026-10-09.at.1.52.49.PM.online-video-cutter.com.mp4
-
Hi Contributors, I checked this ticket and found that the issue only occurs on a physical device. However, if you can reproduce it on a simulator, that’s fine too!
Could you please reproduce the issue first and share a video showing both the issue before the fix and the result after applying your proposed solution? This will help confirm that you can reproduce the issue and that your proposal actually fixes it.
I’ll prioritize reviewing proposals that include a video demonstrating successful reproduction and a working fix.
Thanks!
Reacted by Neeraj BachaniProposal updated — added compare branch with implementation.
Proposal
What is the root cause of that problem?
The pixels in the video are rendered by
AttachmentCarouselPager(react-native-pager-view), and the regression is in the iOS layout of the version we ship, 9.0.4. It is not an App-side event-handling bug.Line 207 in b9626f2
"react-native-pager-view": "9.0.4", Two corrections to the thread first, because they change the fix. 8.0.0 (what we had before #100149) was already a SwiftUI
TabView, so "v9 rebuilt the pager on SwiftUI" is not the difference: https://github.com/callstack/react-native-pager-view/blob/v8.0.0/ios/PagerView.swift#L12-L13. What 9.0.2+ added is aGeometryReaderroot with every page framed toproxy.size:https://github.com/callstack/react-native-pager-view/blob/v9.0.4/ios/PagerView.swift#L20-L24
In 9.0.4 that proxy still honours the safe area and the keyboard region, so pages are framed smaller than the collection view's page size. Upstream describes exactly this in callstack/react-native-pager-view#1140 (9.0.5) and callstack/react-native-pager-view#1150 (9.0.6). On rotation both the safe area and the keyboard frame change, the cells are re-framed, and the pager is left parked between two pages.
I didn't want to argue this from reading Swift, so I measured it. I compiled the library's unmodified iOS sources (
PagerView.swift,PagerViewProvider.swift,PagerScrollDelegate.swift,Extensions.swift) into a small UIKit host: 6 pages, page 3 selected with a focused text field, software keyboard up, -20 horizontal margin like ourpageMargin={40}, frames applied asynchronously after rotation the way Fabric commits them. iPhone 17 Pro simulator, iOS 26.5, rotate portrait to landscape, log every delegate callback:pager-view last onPageScrollafter rotationspurious onPageSelected8.0.0 position=3 offset=0.000none 9.0.4 position=2 offset=0.961none 9.0.7 position=3 offset=0.000none 9.0.4 drifted on 3/3 runs, and it drifts with or without the keyboard. 8.0.0 and 9.0.7 never did.
Two things follow. On 9.0.4 the pager really is knocked off its page by rotation, and that state reaches JS:
onPageScrollreportsposition=2with a non-zero offset, soactivePagebecomes the neighbour andisPagerScrollingis stucktrue:App/src/components/Attachments/AttachmentCarousel/Pager/index.tsx
Lines 68 to 73 in b9626f2
const pageScrollHandler = usePageScrollHandler((e) => { 'worklet'; activePage.set(e.position); isPagerScrolling.set(e.offset !== 0); }, []); And in none of those runs did the native pager emit
onPageSelectedby itself on a size change. Once the selection does move, the rest is what the tester sees:updatePagedismisses the keyboard and switches the attachment.App/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.native.tsx
Lines 44 to 59 in b9626f2
const updatePage = useCallback( (newPageIndex: number) => { Keyboard.dismiss(); setShouldShowArrows(true); const item = attachments.at(newPageIndex); setPage(newPageIndex); if (newPageIndex >= 0 && item) { setActiveAttachmentID(item.attachmentID ?? item.source); if (onNavigate) { onNavigate(item); } } }, [setShouldShowArrows, attachments, setPage, onNavigate], Limits of what I verified: this is a native harness on a simulator, not the App on a physical device. It reproduces the drift, but not the final selection change, and I have not run the full flow on a device build.
What changes do you think we should make in order to solve the problem?
Fix it at the dependency layer: bump
react-native-pager-viewfrom 9.0.4 to 9.0.7."react-native-pager-view": "9.0.7"
That brings in both upstream fixes:
ignoreSafeArea: trueon the hosting controller, and.ignoresSafeArea()on theGeometryReaderroot. In the harness that is the whole difference between the pager ending at2.961and at3.000.Why this layer: the wrong state is created natively, before any JS runs. Filtering events in JS can keep
pagefrom changing, but the pager would still be sitting between two attachments in landscape withisPagerScrollingstuck.Blast radius is small. I diffed the 9.0.4 and 9.0.7 npm tarballs: outside
lib/, the changes are three iOS Swift files and an import-style change insrc/(rootreact-nativecodegen imports, which RN 0.86 exports). There are no Android source changes, so the Android fix from #100149 is untouched.Verification: there is no JS change, so Jest has nothing to assert. The evidence is the harness matrix above. In the PR I would record before/after on a device build for this exact flow, plus rotation on a plain image, swipe and arrow navigation, and the split percentage input from #100149 on Android.
What alternative solutions did you explore? (Optional)
- Re-assert the page from JS when the pager width changes (
setPageWithoutAnimation(page)in an effect). Rejected: in my runs the native selection was still 3 while the offset was wrong, so settingcurrentPageto the value it already has is a no-op. patch-packagethe two upstream hunks onto 9.0.4. Rejected: the bump has no Android source changes to avoid, so a patch only adds maintenance.- Go back to 8.0.0. It doesn't drift, but it brings back the Android bug fix: Android - Split - Percentage input has to be focused twice to be able to edit it #100149 fixed.
- The event-gating approach already posted would leave the drift above in place. I'd only add an App-side guard if the bug still reproduces on 9.0.7 in a device build.
- Re-assert the page from JS when the pager width changes (
dilshodmackbook-sketch commented
on Oct 9, 2026 ContributorMore actionsProposal
Please re-state the problem that we are trying to solve in this issue.
On iOS, rotating the device while the password field of a protected PDF is focused closes the password input and the carousel shows a different attachment instead of staying on the PDF.
What is the root cause of that problem?
The page change happens entirely inside the native pager. JS never gets told about it, which is why gating
onPageSelected(Melvin's proposal that huult tested) has no effect.We ship
react-native-pager-view9.0.4Line 205 in a3b5785
"react-native-pager-view": "9.0.4", In 9.0.4 the SwiftUI root is a
GeometryReaderand every page is framed toproxy.size. The reader still honours the keyboard and safe area regions, so while rotating with the keyboard up the pages get re-framed several times while the page-style collection view keeps its old content offsethttps://github.com/callstack/react-native-pager-view/blob/v9.0.4/ios/PagerView.swift#L19-L38
onPageSelectedis only emitted when thecurrentPagebinding changeshttps://github.com/callstack/react-native-pager-view/blob/v9.0.4/ios/PagerView.swift#L78-L80
but here the binding doesn't change, only the scroll offset does. The only thing JS receives is
onPageScrollfromscrollViewDidScroll, and since no drag/decelerate happened, the delegate never sends the cleanoffset: 0idle eventI didn't want to guess this from reading Swift, so I built the unmodified pager-view iOS sources into a small UIKit host on the simulator (iPhone 17 Pro, iOS 26.5, software keyboard): 6 pages, page index 3 has a focused secure text field, then the app rotates to landscape and back. The host mirrors
updatePage(dismiss keyboard + change page on anyonPageSelected). Log for 9.0.4:2.225 STEP focus field 4.851 STEP rotate -> landscape 5.049 onPageScroll pos=2.0 off=0.818 5.058 onPageScroll pos=2.0 off=0.959 9.050 STEP rotate -> portrait 9.065 onPageScroll pos=3.0 off=0.030 9.069 onPageScroll pos=7.0 off=0.109Same run on 9.0.6:
2.203 STEP focus field 4.831 STEP rotate -> landscape 9.029 STEP rotate -> portraitSo on 9.0.4 the pager is pushed off its page by rotation (it even reports
pos=7on a 6 page pager on the way back), and zeroonPageSelectedevents fire in either run. On a device the offset ends up further away and the page holding the focused field scrolls out, so the field resigns first responder and the tester sees another attachment. App state still says we're on the PDF.The App side makes it worse in one more way. Our scroll worklet takes that drift as real paging
App/src/components/Attachments/AttachmentCarousel/Pager/index.tsx
Lines 68 to 73 in a3b5785
const pageScrollHandler = usePageScrollHandler((e) => { 'worklet'; activePage.set(e.position); isPagerScrolling.set(e.offset !== 0); }, []); so after the rotation
activePagepoints to the neighbour andisPagerScrollingstaystruebecause the finaloffset: 0never comes.isPagerScrollingis what disables pinch/zoom in the lightboxApp/src/components/Lightbox/index.tsx
Line 218 in a3b5785
shouldDisableTransformationGestures={isPagerScrolling} so on 9.0.4 an image attachment can't be zoomed after a rotation either, same root cause.
What changes do you think we should make in order to solve the problem?
Bump
react-native-pager-viewfrom 9.0.4 to 9.0.6 (updatepackage-lock.jsonandios/Podfile.lock).9.0.6 puts
.ignoresSafeArea()on theGeometryReaderroot (callstack/react-native-pager-view#1150) and 9.0.5 restoresignoreSafeArea: trueon the hosting controller (callstack/react-native-pager-view#1140), so the pages are no longer re-framed for the keyboard and safe area during rotationhttps://github.com/callstack/react-native-pager-view/blob/v9.0.6/ios/PagerView.swift#L56-L59
The v9.0.4...v9.0.6 diff only touches 3 iOS Swift files, no JS and no Android, so the Android fix from #100149 stays as is. 9.0.6 is the newest version allowed by our
min-release-ageright now (9.0.7 only adds the vertical edge effect fix, which we don't need).No JS change is needed. I'd specifically not add an
onPageSelectedguard / snap-back here: in the measured flow there is no event to guard, andsetPageWithoutAnimation(currentPage)sets the binding to the value it already has, so SwiftUI doesn't re-scroll.In the PR I'll record before/after on a device build for: the exact issue flow, rotating on a plain image (and pinch zoom after it), swipe + arrow navigation after rotating, and the split percentage input from #100149 on Android.
What specific scenarios should we cover in automated tests?
The bug lives in the native SwiftUI
TabView, so a Jest test can't rotate it. Manual/device checks are listed above.What alternative solutions did you explore? (Optional)
- Gate
onPageSelectedin JS and snap the pager back (Melvin, and a few of the proposals above). In the measured runs noonPageSelectedis emitted during rotation, so the gate never runs. That's consistent with huult still reproducing it with Melvin's approach. patch-packagethe two upstream hunks on 9.0.4. Same Swift change, plus a patch to maintain.- Revert to 8.0.0. Doesn't drift, but brings back the Android bug fix: Android - Split - Percentage input has to be focused twice to be able to edit it #100149 fixed.
- Gate
Proposal
What is the root cause of that problem?
On iOS the attachment carousel is
react-native-pager-view, and everyonPageSelectedit emits is treated as a user swipe:updatePagedismisses the keyboard, sets the page and callsonNavigatewith that attachment.App/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.native.tsx
Lines 44 to 60 in a3b5785
const updatePage = useCallback( (newPageIndex: number) => { Keyboard.dismiss(); setShouldShowArrows(true); const item = attachments.at(newPageIndex); setPage(newPageIndex); if (newPageIndex >= 0 && item) { setActiveAttachmentID(item.attachmentID ?? item.source); if (onNavigate) { onNavigate(item); } } }, [setShouldShowArrows, attachments, setPage, onNavigate], ); App/src/components/Attachments/AttachmentCarousel/Pager/index.tsx
Lines 147 to 156 in a3b5785
<AnimatedPagerView pageMargin={40} offscreenPageLimit={1} onPageScroll={pageScrollHandler} onPageSelected={onPageSelected} style={styles.flex1} initialPage={initialPage} animatedProps={animatedProps} ref={pagerRef} > We pin
react-native-pager-view9.0.4. There the iOS pager is a SwiftUITabView(.page)inside aGeometryReader, every page is framed toproxy.size, and any change of theTabViewselection is sent to JS asonPageSelected:Only the inner
TabViewignores the safe area, so theGeometryReaderroot still follows the keyboard region. Focusing the password field opens the keyboard and every page is reframed to the shrunk size. Rotating in that state makes the pager select page 0 by itself,updatePageopens the first attachment, and the PDF is no longer the focused item, so its viewer and the password form are swapped for a placeholder:App/src/components/Attachments/AttachmentView/index.tsx
Lines 268 to 276 in a3b5785
if (isFocused === false) { return ( <DefaultAttachmentView fileName={file?.name} shouldShowLoadingSpinnerIcon containerStyles={containerStyles} /> ); } I reproduced it on dev iOS with five attachments in a DM (the protected PDF third) and watched the pager:
- form open, keyboard hidden: the page cell is
402x714; keyboard shown:402x413 - keyboard shown, rotate: the pager emits
onPageSelected(0)with no drag, and the first image opens - keyboard dismissed (form still open), rotate: no
onPageSelected, the PDF form stays
What changes do you think we should make in order to solve the problem?
Bump
react-native-pager-viewto 9.0.6, then regeneratepackage-lock.jsonandios/Podfile.lock:- "react-native-pager-view": "9.0.4", + "react-native-pager-view": "9.0.6",
9.0.6 (callstack/react-native-pager-view#1150) adds
.ignoresSafeArea()to theGeometryReaderroot, so pages no longer shrink for the keyboard; 9.0.5 (#1140) keeps the safe area off the hosting view. 9.0.7 cannot be installed until 10-15 because ofmin-release-age=7in.npmrc. Only three iOS Swift files change, so JS and Android stay the same, and swipes and arrows still useonPageSelected.Note 2 (mWeb Safari) is a separate web path: on rotation the web carousel's
updatePagegets an empty viewable list and clears the active attachment, even without the keyboard. I can add that change to the same PR if mWeb is in scope.What alternative solutions did you explore? (Optional)
- Ignore
onPageSelectedevents that did not start with a drag and snap the pager back: pages stay framed to the shrunk size under the keyboard, and it adds refs to hide one symptom.
- form open, keyboard hidden: the page cell is
Proposal
What is the root cause of that problem?
On iOS,
react-native-pager-view9.0.4 (upgraded in #100149) fires a spuriousonPageSelectedevent — typically with position0— whenever the device rotates and the native SwiftUITabViewrelayouts. The carousel'supdatePagehandler treats everyonPageSelectedas a real user navigation: it callsKeyboard.dismiss()(closing the password input) and sets the page to 0 (jumping to the first attachment).Simply gating the JS callback is not enough because the native pager has already moved to page 0 visually — the carousel must also be programmatically restored to the correct page.
What changes do you think we should make in order to solve the problem?
In
AttachmentCarouselView/index.native.tsx, detect rotation insideupdatePageby comparing the current window dimensions (Dimensions.get('window')) against the last known dimensions stored in a ref. When a dimension change is detected (rotation), skip all side effects (Keyboard.dismiss,setPage,onNavigate) and instead callpagerRef.current.setPage(currentPage)to programmatically restore the correct page.Additionally, after the restore call the pager fires another
onPageSelectedwith the now-correct index. Guard against that by returning early when the incoming page index equals the current page, preventing an unnecessary keyboard dismissal.What alternative solutions did you explore? (Optional)
- Gating
onPageSelectedalone (Melvin's approach) — the C+ reviewer confirmed this does not fix the visual jump because the native pager has already moved to page 0 before JS is notified. - Listening for dimension changes via
useWindowDimensions+useEffectto restore the page — rejected because the effect runs after render, which is too late; the spuriousonPageSelectedfires synchronously from native before the effect has a chance to set a guard flag.
- Gating
I'll prioritize reviewing proposals that include a video demonstrating successful reproduction and a working fix.
@huult I've updated my proposal and added the before/after videos.
The iOS pager emits
onPageSelected(0)when the password keyboard opens.updatePagetreats that as a swipe, dismisses the keyboard, and opens the first attachment. The fix returns beforeupdatePagefor that selection and snaps the pager back to the PDF. That change is only inAttachmentCarouselView/index.native.tsx. ForwardingonPageScrollStateChangedfromPager/index.tsxis an optional fallback.I also tested 9.0.6 on its own and the jump still reproduces, so the bump doesn't fix it.
Proposal
What is the root cause of that problem?
The iOS attachment carousel uses
react-native-pager-view@9.0.4([package.json]()). On iOS that pager is a SwiftUILine 207 in 4bb6832
"react-native-pager-view": "9.0.4", TabView(selection: $props.currentPage), and everycurrentPagechange is forwarded to JS asonPageSelected, whether it came from a real swipe or from native layout/keyboard/orientation changes ([PagerView.swift](https://github.com/callstack/react-native-pager-view/blob/750d7c5c850e1c4c6302f87cc453c9a88ec41116/ios/PagerView.swift#L19-L24), [onChange forwarding](https://github.com/callstack/react-native-pager-view/blob/750d7c5c850e1c4c6302f87cc453c9a88ec41116/ios/PagerView.swift#L78-L80)).App currently trusts every one of those events.
AttachmentCarouselPagerpassesonPageSelectedstraight through to the parent, and the parent callsupdatePage(newPage)for every emitted position ([Pager/index.tsx](), [index.native.tsx](https://github.com/Expensify/App/blob/4bb683286b0b877945f9e95fa512cd199f23a852/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/[index.native.tsx](https://github.com/Expensify/App/blob/4bb683286b0b877945f9e95fa512cd199f23a852/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.native.tsx#L44-L60)#L104-L113)).App/src/components/Attachments/AttachmentCarousel/Pager/index.tsx
Lines 147 to 153 in 4bb6832
<AnimatedPagerView pageMargin={40} offscreenPageLimit={1} onPageScroll={pageScrollHandler} onPageSelected={onPageSelected} style={styles.flex1} initialPage={initialPage} updatePage()then immediately dismisses the keyboard, changes the carousel page, updates the active attachment, and callsonNavigate()(index.native.tsx).So when rotation/focused-keyboard relayout makes the native iOS pager select another page, App treats that as a user navigation. In the reported iOS App case this becomes
updatePage(0), which closes the password keyboard and switches the modal to the first attachment instead of keeping the protected PDF focused.What changes do you think we should make in order to solve the problem?
Guard the native pager selection boundary in
AttachmentCarouselPager.Track whether a selection was initiated by the user via
onPageScrollStateChanged(dragging/settling) and keep a ref of the current App page. The pager-view API already exposes this event ([PagerViewNativeComponent.ts](https://github.com/callstack/react-native-pager-view/blob/750d7c5c850e1c4c6302f87cc453c9a88ec41116/src/PagerViewNativeComponent.ts#L36-L38)).Then wrap
onPageSelected:- If the selected position is already the current App page, return without calling
updatePage(). - If it came from a user drag, forward it normally so swiping still changes attachments and dismisses the keyboard.
- If it matches a page requested programmatically by the arrow buttons, treat it as an echo because arrow navigation already calls
updatePage()beforepagerRef.setPage()([index.native.tsx]()).App/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.native.tsx
Lines 66 to 74 in 4bb6832
const cycleThroughAttachments = useCallback( (deltaSlide: number) => { if (page === undefined) { return; } const nextPageIndex = page + deltaSlide; updatePage(nextPageIndex); pagerRef.current?.setPage(nextPageIndex); - Otherwise it is a native layout/keyboard/orientation selection. Do not forward it; call
setPageWithoutAnimation(currentAppPage)on the underlying pager to snap native state back and keep the password field mounted/focused.
As a small safety net, also make
updatePage()no-op whennewPageIndex === page, so same-page native echoes never dismiss the keyboard.What alternative solutions did you explore? (Optional)
A simple
newPageIndex === pageguard alone is not enough because the bad native event can be a different page, usually0, while the protected PDF is on another index.Bumping
react-native-pager-viewto9.0.6/9.0.7can bring upstream safe-area improvements, but it still forwards everycurrentPageselection to JS through the sameonPageSelectedpath ([v9.0.6 PagerView.swift](https://github.com/callstack/react-native-pager-view/blob/50992206cdd788ce93cfdaa5cb7ec40b90605bf0/ios/PagerView.swift#L81-L83)). The App still needs to reject non-user native selections at the carousel boundary.- If the selected position is already the current App page, return without calling
Proposal
Upgrade pager-view to 9.0.7, and stop page changes the user didn't make from moving the attachment carousel
Please re-state the problem that we are trying to solve in this issue.
On iOS, with the password form of a protected PDF open in the attachment carousel, rotating the device closes the form and the carousel jumps to
another attachment (the first one in the app, a nearby one on mWeb).What is the root cause of that problem?
The carousel lets the pager decide which page is showing, and it believes every page change the pager reports.
- AttachmentCarouselPager passes onPageSelected straight to updatePage, which treats it as a user navigation
(AttachmentCarouselView/index.native.tsx#L44-L60):
const updatePage = (newPageIndex: number) => {
Keyboard.dismiss(); // closes the password keyboard
setPage(newPageIndex); // jumps to another attachment
onNavigate?.(item);
};- We ship react-native-pager-view 9.0.4 (package.json#L207). On iOS its pager re-lays out its pages when the screen size, safe area or keyboard
changes, which all happen during rotation. That leaves it between pages, and it reports a page change nobody made. Upstream fixed that layout
in 9.0.5 and 9.0.6. - After that, setPage(0) flows back into the pager as initialPage, the PDF loses isFocused, and the password form unmounts.
So there are two causes: a layout bug in the library version we ship, and a carousel that trusts any page change the pager reports.
What changes do you think we should make in order to solve the problem?
- Upgrade react-native-pager-view to 9.0.7, a patch release that includes the upstream layout fixes:
"react-native-pager-view": "9.0.7"
2. Treat the pager's page as set by the App. Only accept a page change after a user swipe or a change we started ourselves. Otherwise snap the
native pager back:const isUserDragRef = useRef(false);
const onPageScrollStateChanged = (e) => {
const {pageScrollState} = e.nativeEvent;
if (pageScrollState === 'dragging') isUserDragRef.current = true;
if (pageScrollState === 'idle') isUserDragRef.current = false;
};const handlePageSelected = (e) => {
const {position} = e.nativeEvent;
if (!isUserDragRef.current && !isProgrammaticChangeRef.current) {
pagerRef.current?.setPageWithoutAnimation(activePageIndex); // undo the layout jump
return;
}
onPageSelected(e);
};isProgrammaticChangeRef is set by the arrow buttons and the imperative setPage handle, so those keep working.
3. Don't dismiss the keyboard for a same-page event:if (newPageIndex === page) {
return;
}
4. mWeb: in AttachmentCarouselView/index.tsx, switch the effect that realigns the list after cellWidth changes to useLayoutEffect. Ignore onViewableItemsChanged until that realignment finishes, so a stale scroll position can't pick a neighbouring page.
5. Tests:- an onPageSelected with no drag restores the page and doesn't call onNavigate or Keyboard.dismiss
- an onPageSelected after dragging navigates as it does today
- arrow and setPage navigation still works
- a same-page event is ignored
Manual: on a physical iPhone and on mWeb Safari, rotate with the password form focused, rotate on an image, a video and a PDF, and swipe after rotating.
What alternative solutions did you explore? (Optional)
- Only upgrading pager-view: fixes today's cause, but any future spurious page event still navigates and dismisses the keyboard.
- Only ignoring the event in JS: the native pager stays on the wrong page while the App thinks it's on the PDF, so the next swipe starts from the wrong place.
Contributor details
Expensify account email: systemadmin@voxturrlabs.com
Upwork Profile Link: https://www.upwork.com/freelancers/~010efadac9ff3c3d2a/- AttachmentCarouselPager passes onPageSelected straight to updatePage, which treats it as a user navigation
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsNo status
If you haven’t already, check out our contributing guidelines for onboarding. To join our Slack channel, fill out this form.
Version Number: v9.5.5-0
Reproducible in staging?: Yes
Reproducible in production?: Yes
If this was caught during regression testing, add the test name, ID and link from BrowserStack: https://test-management.browserstack.com/projects/2219752/test-runs/76493747/folder/13176710/41236945/1815288238
Email or phone of affected tester (no customers): gd868gd@gmail.com
Issue reported by: Applause Internal Team
Bug source: Exploratory - Significant User Experience Deterioration
Device used: iPhone 15 Pro / iOS 27.0.1
App Component: Chat Report View
Action Performed:
Preconditions:
Expected Result:
The user should stay on the same screen.
Actual Result:
The password input closes, and another attachment opens up.
Note 1: In the App, the first attachment in the chat opens up.
Note 2: In mWeb (Safari), the closest attachment in the chat opens up.
Workaround:
Unknown
Platforms:
Screenshots/Videos
Bug7280661_1791416964731.Expensify_103372_Password_Protected_PDF.1.mp4
View all open jobs on GitHub
Upwork Automation - Do Not Edit