Skip to content

[$250] iOS - Attachments - Password input of protected PDF closes after rotating to landscape #103531

Description

@applause-bot

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:

  • There is a 1:1 chat with several attachments uploaded (images, videos, files).
  • The user has already uploaded a password-protected PDF into this chat.
  1. Open the Expensify App and log in as the user from the Precondition
  2. Go to the chat from the Precondition
  3. Tap on the password-protected PDF preview
  4. Tap on "Enter the password"
  5. While the password field is focused, rotate the device to landscape

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:

  • Android: App
  • Android: mWeb Chrome
  • iOS: App
  • iOS: mWeb Safari
  • iOS: mWeb Chrome
  • Windows: Chrome
  • MacOS: Chrome Safari

Screenshots/Videos

Bug7280661_1791416964731.Expensify_103372_Password_Protected_PDF.1.mp4

View all open jobs on GitHub

Applause Internal Information
Exported by: Svetlana Lazutkina
Bug ID: 7280661

Upwork Automation - Do Not Edit

Activity

  1. added
    BugSomething is broken. Auto assigns a BugZero manager.
    on Oct 8, 2026
  2. applause-bot commented on Oct 8, 2026

    @applause-bot
    Author

    While Applause doesn't embed attachments for this bug, here are the links for quick view:
    https://platform.applause.com/services/links/v1/external/9a766f7707aa90a499c39e41131088e029cf2307068f7f435c99df1d1d3ce7f6

  3. MelvinBot commented on Oct 8, 2026

    @MelvinBot
    Contributor

    Proposal

    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 onPageSelected with position 0. updatePage then calls Keyboard.dismiss() and setPage(0). This closes the password input and opens the first attachment. onPageSelected is the only code path that can set page 0 on 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-view from 7.0.2 to 9.0.4. Version 9 rebuilt the iOS pager on a SwiftUI TabView. Its selection is two-way bound to currentPage, and every change is sent to JS as onPageSelected, including resets caused by layout changes during rotation.
    • mWeb: The web carousel is a FlatList positioned by pixel offset (page * cellWidth). After a resize, a passive useEffect corrects 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?

    1. Native: In AttachmentCarouselView/index.native.tsx, apply onPageSelected only when it follows a user drag (use onPageScrollStateChanged) or matches a page we set ourselves. Otherwise keep the current page and don't dismiss the keyboard.
    2. Native: In Pager/index.tsx, store the intended page in a ref. When the window dimensions change, call setPageWithoutAnimation(intendedPage) so the native selection is restored after rotation.
    3. Web: Change the cellWidth effect to useLayoutEffect. While a width change is being corrected, have updatePage skip setPage and Keyboard.dismiss().

    What alternative solutions did you explore? (Optional)

    • Patch react-native-pager-view's PagerView.swift with patch-package. The patch would re-apply currentPage after a size change and stop emitting onPageSelected for 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, never 0 (AttachmentCarousel/index.tsx:78-105). That points to the native pager event as the source of page 0.
    • Pager/index.tsx:147-158 passes onPageSelected straight to the native view. initialPage is read only when the native view is created, so JS can't restore the page through props.
    • In pager-view 9.0.4, PagerView.swift uses TabView(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 updatePage also calls Keyboard.dismiss() unconditionally and clears the active attachment when no item is visible during the resize (index.tsx:94-121).

    To confirm the cause: log updatePage on 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.


    view run

  4. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on Oct 8, 2026
  5. melvin-bot commented on Oct 8, 2026

    @melvin-bot

    Triggered auto assignment to Contributor-plus team member for initial proposal review - @huult (External)

  6. 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
  7. melvin-bot commented on Oct 8, 2026

    @melvin-bot
  8. jloa-dev commented on Oct 9, 2026

    @jloa-dev

    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 emits onPageSelected events during layout recomputation and orientation changes (SwiftUI TabView container re-render).

    updatePage unconditionally executes Keyboard.dismiss() and updates the page state:

    const updatePage = useCallback(
        (newPageIndex: number) => {
            Keyboard.dismiss();
            setShouldShowArrows(true);
            // ...
            setPage(newPageIndex);
        },
        [setShouldShowArrows, attachments, setPage, onNavigate],
    );

    Because updatePage did not check whether newPageIndex === page:

    1. When the device rotates while viewing the protected PDF (at current page), the native layout pass emits onPageSelected (with position 0 or the current position).
    2. Keyboard.dismiss() is immediately triggered, which dismisses the iOS software keyboard and closes the PDF password form modal.
    3. 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?

    1. In src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.native.tsx, add an early return guard in updatePage so that when newPageIndex === page, no redundant page state transition occurs and Keyboard.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],
    );
    1. Additionally, in AttachmentCarouselPager (src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.native.tsx), ensure onPageSelected only dispatches when newPage !== 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 === page directly resolves both false-positive dismissal and unprompted navigation resets without complex touch event listeners.
    • Patching react-native-pager-view directly: Unnecessary overhead since guarding the callback in JS cleanly handles native layout change events without maintaining extra native patches.

    Tests

    1. Open a chat report containing multiple attachments including a password-protected PDF.
    2. Tap on the protected PDF to open the attachment preview modal.
    3. Tap on the password input field so the keyboard is focused.
    4. Rotate the device from portrait to landscape and back.
    5. Verify that the keyboard remains open, the password modal is intact, and the viewer stays on the current protected PDF.
    6. 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

  9. melvin-bot commented on Oct 9, 2026

    @melvin-bot

    ✅ Contributor details stored successfully. Thank you for contributing to Expensify!

  10. camesenin commented on Oct 9, 2026

    @camesenin

    Proposal

    What is the root cause of that problem?

    This issue involves two separate platform behaviors triggering unintended page transitions during device orientation changes:

    1. iOS Native (AttachmentCarouselView/index.native.tsx & Pager/index.tsx):

      • In AttachmentCarouselView/index.native.tsx, the native pager (AnimatedPagerView wrapping react-native-pager-view 9.0.4) emits onPageSelected events during device rotation layout passes when SwiftUI recomputes the view hierarchy.
      • AnimatedPagerView is initialized once with initialPage, but react-native-pager-view does 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 emits onPageSelected({nativeEvent: {position: 0}}).
      • updatePage in AttachmentCarouselView/index.native.tsx unconditionally invokes Keyboard.dismiss() and executes setPage(newPageIndex). It lacks any validation to check whether the incoming onPageSelected was 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.
    2. Mobile Web / Safari (AttachmentCarouselView/index.tsx):

      • On web, updatePage is driven by Animated.FlatList's onViewableItemsChanged.
      • When the device rotates, windowWidth and cellWidth change immediately. The resize useEffect invokes scrollRef.current.scrollToIndex({index: page, animated: false}).
      • However, during the layout transition before scrollToIndex aligns the scroll offset to the new cell dimensions, onViewableItemsChanged fires with the closest partially visible item.
      • updatePage unconditionally calls Keyboard.dismiss() and updates setPage to that closest item, closing the password input and opening the neighboring attachment.

    What changes do you think we should make in order to solve the problem?

    1. 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. usePageScrollHandler already maintains isPagerScrolling.
      • When onPageSelected fires:
        • If event.nativeEvent.position === page, do not trigger page transitions or keyboard dismissal.
        • If event.nativeEvent.position !== page but !isPagerScrolling.get(), recognize this as an unprompted native container layout reset (such as device rotation). Re-assert the current active page via pagerRef.current?.setPage(initialPage) and suppress the erroneous navigation to page 0.
      • In AttachmentCarouselView/index.native.tsx, update updatePage to only call Keyboard.dismiss() when the target page index is different from the currently active page (newPageIndex !== page).
    2. Web (src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx):

      • In AttachmentCarouselView/index.tsx, introduce a resize guard ref (isResizingRef). When cellWidth changes due to window resize or orientation rotation, set isResizingRef.current = true.
      • In updatePage, ignore viewable item updates while isResizingRef.current is active, resetting the flag once scrollToIndex completes (e.g. inside requestAnimationFrame).
      • Guard Keyboard.dismiss() so it only fires when entry.index !== page.
    3. Unit Tests:

      • Add unit tests in tests/unit/AttachmentCarouselTest.tsx verifying:
        • updatePage does 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.

    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) in updatePage: Insufficient on its own because the native pager on iOS emits position: 0 when the user was viewing attachment 2 or higher; since 0 !== 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

    1. Open a chat report with multiple attachments (images/files) and at least one password-protected PDF.
    2. Tap on the password-protected PDF to open the attachment modal.
    3. Tap "Enter the password" to open and focus the password text input (keyboard appears).
    4. Rotate the device from portrait to landscape.
    5. 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).
    6. Rotate back to portrait and verify the password prompt remains open.
    7. 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

  11. melvin-bot commented on Oct 9, 2026

    @melvin-bot

    ✅ Contributor details stored successfully. Thank you for contributing to Expensify!

  12. huult commented on Oct 9, 2026

    @huult
    Contributor
    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.

  13. 1 remaining item

  14. added
    Help WantedApply this label when an issue is open to proposals by contributors
    on Oct 9, 2026
  15. ahmdshrif commented on Oct 9, 2026

    @ahmdshrif
    Contributor

    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 onPageSelected from react-native-pager-view as a user navigation, and since the 9.x upgrade the iOS pager emits a spurious onPageSelected(0) whenever its size changes.

    1. On iOS the pager is a SwiftUI TabView(.page). In 9.x it is wrapped in a GeometryReader and 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
    2. On rotation the proxy size changes, every cell is re-framed, the collection view loses its page offset and SwiftUI writes page 0 into the selection binding, which is forwarded to JS unconditionally:
      https://github.com/callstack/react-native-pager-view/blob/v9.0.4/ios/PagerView.swift#L78-L80
    3. App passes the event straight through to the parent:
      onPageSelected={onPageSelected}
      style={styles.flex1}
      initialPage={initialPage}
    4. 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:
      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],
    5. setPage(0) flows back as initialPage, so activePageIndex becomes 0 and the PDF item loses isFocused, which is why the form never regains focus:
      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 AttachmentCarouselPager gate 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 activePageIndex and an isUserPagingRef set in onPageScrollStateChanged when pageScrollState === '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 no dragging state → 0 !== activePageIndexRef → pager snapped back to the PDF page, nothing forwarded, so Keyboard.dismiss() and setPage(0) never run and the password field keeps focus. The snap-back echoes onPageSelected(N), which equals the active page and is dropped. Regression safety: a user swipe still starts with dragging and is forwarded unchanged; arrow navigation calls updatePage before pagerRef.setPage (index.native.tsx#L65-L77) and the mount-time onPageSelected(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 sets currentPage to the value it already has, so nothing happens and onPageSelected(0) still follows; after the reset activePage was already overwritten to 0 by the onPageScroll worklet (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 cellWidth from windowWidth and the FlatList keeps its old offset after rotation, so the nearest row becomes active (

    const {windowWidth} = useWindowDimensions();
    const cellWidth = useMemo(
    () => PixelRatio.roundToNearestPixel(windowWidth - (modalStyles.marginHorizontal + modalStyles.borderWidth) * 2),
    [modalStyles.borderWidth, modalStyles.marginHorizontal, windowWidth],
    ); a scrollToIndex({index: page, animated: false}) when cellWidth changes covers it, outside this report's iOS scope.

    What alternative solutions did you explore? (Optional)

    • onLayout snap-back only (above): no-op before the reset, wrong target after it, and it does not stop updatePage(0) from dismissing the keyboard.
    • Patch react-native-pager-view (re-apply currentPage on size change, or key the TabView on the size): the binding is already 0 when it runs, so onPageSelected(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.
  16. wildan-m commented on Oct 9, 2026

    @wildan-m
    Contributor

    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, a react-native-pager-view PagerView. Every onPageSelected event coming from it is forwarded straight to updatePage:

    <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 updatePage treats it as a user navigation: it dismisses the keyboard, changes page, and calls onNavigate with the new item, which makes the modal show that attachment instead of the PDF:

    /** 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-view 9.x is a SwiftUI TabView with the page style, whose selection is bound to currentPage; any change of that selection is emitted to JS as onPageSelected. When the pager's frame changes (rotation), the underlying collection view keeps its old content offset while the page width changes, so TabView resolves the selection to a different page (in landscape the old offset maps to a lower index, which is why the first attachment opens) and emits onPageSelected without any swipe. With the keyboard up this is made worse in the version we ship (9.0.4): its GeometryReader root 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):

    "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:

    // 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?

    1. In AttachmentCarouselPager, track whether the current page change was started by the user: set a ref when onPageScrollStateChanged reports dragging, and clear it once that drag's selection has been reported (at idle if 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 imperative setPage handle (used by the arrow buttons).
    2. Wrap onPageSelected there: 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 call setPageWithoutAnimation with the current page to snap the pager back, and ignore the selection event that the snap-back itself emits. That keeps page, the active attachment and the PDF's local password state untouched, and Keyboard.dismiss() in updatePage is not triggered.
    3. Bump react-native-pager-view to 9.0.6 (the newest 9.0.x allowed by the repo's 7-day min-release-age), which stops the pager's GeometryReader root 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 key derived from the window dimensions so it is re-created at the current page after 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 the onPageSelected that fires before the remount.

  17. 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
  18. github-actions commented on Oct 9, 2026

    @github-actions
    Contributor

    🤖 ProposalPolice™ is tracking duplicate proposals for this issue using an OpenAI Conversation. This comment stores that Conversation's ID and can be safely ignored.

  19. neerajbachani commented on Oct 9, 2026

    @neerajbachani
    Contributor

    🚨 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 page is the password-protected PDF, and PDFPasswordForm is focused.

    First incorrect step: react-native-pager-view emits onPageSelected with position 0.

    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, onPageSelected arrived with position 0 while the React page was still 4. The attachment list did not change (deepEqual stayed true), and the pager did not remount. PDFPasswordForm was still mounted and focused. It unmounted only after updatePage navigated to index 0. There was no blur before that navigation.

    The native carousel forwards every selection into updatePage:

    onPageSelected={({nativeEvent: {position: newPage}}) => updatePage(newPage)}

    AttachmentCarouselPager passes that callback through:

    <AnimatedPagerView
    pageMargin={40}
    offscreenPageLimit={1}
    onPageScroll={pageScrollHandler}
    onPageSelected={onPageSelected}
    style={styles.flex1}
    initialPage={initialPage}
    animatedProps={animatedProps}
    ref={pagerRef}

    updatePage then does what the tester sees. Keyboard.dismiss() closes the password keyboard, and setPage(0) plus onNavigate switches the modal to the first attachment:

    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 page from 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-view 9.0.4:

    "react-native-pager-view": "9.0.4",

    That bump is b6c09f5b1af, merged in #100149. In 9.0.4, TabView selection is bound to props.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 the GeometryReader and ignoreSafeArea: true on the hosting controller do not stop TabView from 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 shouldUseNarrowLayout does 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 onPageSelected in 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 before updatePage. A selection whose position is already the current page also returns before updatePage, so the snap-back echo does not dismiss the keyboard.

    AttachmentCarouselPager needs to forward onPageScrollStateChanged. It already forwards onPageSelected.

                             onPageScroll={pageScrollHandler}
    +                        onPageScrollStateChanged={onPageScrollStateChanged}
                             onPageSelected={onPageSelected}

    In AttachmentCarouselView/index.native.tsx, remember a user scroll until the matching selection is handled. onPageScrollStateChanged can report idle before onPageSelected, 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 calls updatePage and then setPage:

    const cycleThroughAttachments = useCallback(
    (deltaSlide: number) => {
    if (page === undefined) {
    return;
    }
    const nextPageIndex = page + deltaSlide;
    updatePage(nextPageIndex);
    pagerRef.current?.setPage(nextPageIndex);

    updatePage writes pageRef before the native setPage, so the echo of that arrow move matches the current page and returns. A real swipe sets userScrollRef from dragging or settling, so onPageSelected still calls updatePage.

    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.tsx gains an optional callback, and AttachmentCarouselView/index.native.tsx decides 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 from updatePage on a real page change.

    What alternative solutions did you explore? (Optional)

    • Bumping react-native-pager-view from 9.0.4 to 9.0.6. 9.0.6 adds .ignoresSafeArea() on the GeometryReader and ignoreSafeArea: true on the hosting controller. I installed it and the jump still happened. onPageSelected(0) still arrived when the keyboard opened, and updatePage still dismissed the keyboard. The bump leaves .onChange(of: props.currentPage) forwarding every selection.

    • Melvin's proposal gates onPageSelected and calls setPageWithoutAnimation on 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 because updatePage ran. Returning before Keyboard.dismiss() and snapping the pager back with setPage keeps the field. The same-page echo must also return before updatePage, 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 TabView selection, 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
  20. huult commented on Oct 9, 2026

    @huult
    Contributor

    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!

  21. wildan-m commented on Oct 9, 2026

    @wildan-m
    Contributor

    Proposal updated — added compare branch with implementation.

  22. Abdulloh0109 commented on Oct 9, 2026

    @Abdulloh0109
    Contributor

    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.

    "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 a GeometryReader root with every page framed to proxy.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 our pageMargin={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 onPageScroll after rotation spurious onPageSelected
    8.0.0 position=3 offset=0.000 none
    9.0.4 position=2 offset=0.961 none
    9.0.7 position=3 offset=0.000 none

    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: onPageScroll reports position=2 with a non-zero offset, so activePage becomes the neighbour and isPagerScrolling is stuck true:

    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 onPageSelected by itself on a size change. Once the selection does move, the rest is what the tester sees: updatePage dismisses the keyboard and switches the attachment.

    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-view from 9.0.4 to 9.0.7.

    "react-native-pager-view": "9.0.7"

    That brings in both upstream fixes: ignoreSafeArea: true on the hosting controller, and .ignoresSafeArea() on the GeometryReader root. In the harness that is the whole difference between the pager ending at 2.961 and at 3.000.

    Why this layer: the wrong state is created natively, before any JS runs. Filtering events in JS can keep page from changing, but the pager would still be sitting between two attachments in landscape with isPagerScrolling stuck.

    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 in src/ (root react-native codegen 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 setting currentPage to the value it already has is a no-op.
    • patch-package the 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.
  23. dilshodmackbook-sketch commented on Oct 9, 2026

    @dilshodmackbook-sketch
    Contributor

    Proposal

    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-view 9.0.4

    "react-native-pager-view": "9.0.4",

    In 9.0.4 the SwiftUI root is a GeometryReader and every page is framed to proxy.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 offset

    https://github.com/callstack/react-native-pager-view/blob/v9.0.4/ios/PagerView.swift#L19-L38

    onPageSelected is only emitted when the currentPage binding changes

    https://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 onPageScroll from scrollViewDidScroll, and since no drag/decelerate happened, the delegate never sends the clean offset: 0 idle event

    https://github.com/callstack/react-native-pager-view/blob/v9.0.4/ios/PagerScrollDelegate.swift#L22-L51

    I 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 any onPageSelected). 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.109
    

    Same run on 9.0.6:

      2.203 STEP focus field
      4.831 STEP rotate -> landscape
      9.029 STEP rotate -> portrait
    

    So on 9.0.4 the pager is pushed off its page by rotation (it even reports pos=7 on a 6 page pager on the way back), and zero onPageSelected events 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

    const pageScrollHandler = usePageScrollHandler((e) => {
    'worklet';
    activePage.set(e.position);
    isPagerScrolling.set(e.offset !== 0);
    }, []);

    so after the rotation activePage points to the neighbour and isPagerScrolling stays true because the final offset: 0 never comes. isPagerScrolling is what disables pinch/zoom in the lightbox

    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-view from 9.0.4 to 9.0.6 (update package-lock.json and ios/Podfile.lock).

    9.0.6 puts .ignoresSafeArea() on the GeometryReader root (callstack/react-native-pager-view#1150) and 9.0.5 restores ignoreSafeArea: true on the hosting controller (callstack/react-native-pager-view#1140), so the pages are no longer re-framed for the keyboard and safe area during rotation

    https://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-age right 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 onPageSelected guard / snap-back here: in the measured flow there is no event to guard, and setPageWithoutAnimation(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 onPageSelected in JS and snap the pager back (Melvin, and a few of the proposals above). In the measured runs no onPageSelected is emitted during rotation, so the gate never runs. That's consistent with huult still reproducing it with Melvin's approach.
    • patch-package the 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.
  24. yusufdeveloper2903 commented on Oct 9, 2026

    @yusufdeveloper2903
    Contributor

    Proposal

    What is the root cause of that problem?

    On iOS the attachment carousel is react-native-pager-view, and every onPageSelected it emits is treated as a user swipe: updatePage dismisses the keyboard, sets the page and calls onNavigate with that attachment.

    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],
    );

    <AnimatedPagerView
    pageMargin={40}
    offscreenPageLimit={1}
    onPageScroll={pageScrollHandler}
    onPageSelected={onPageSelected}
    style={styles.flex1}
    initialPage={initialPage}
    animatedProps={animatedProps}
    ref={pagerRef}
    >

    We pin react-native-pager-view 9.0.4. There the iOS pager is a SwiftUI TabView(.page) inside a GeometryReader, every page is framed to proxy.size, and any change of the TabView selection is sent to JS as onPageSelected:

    https://github.com/callstack/react-native-pager-view/blob/750d7c5c850e1c4c6302f87cc453c9a88ec41116/ios/PagerView.swift#L19-L39

    https://github.com/callstack/react-native-pager-view/blob/750d7c5c850e1c4c6302f87cc453c9a88ec41116/ios/PagerView.swift#L78-L80

    Only the inner TabView ignores the safe area, so the GeometryReader root 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, updatePage opens the first attachment, and the PDF is no longer the focused item, so its viewer and the password form are swapped for a placeholder:

    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-view to 9.0.6, then regenerate package-lock.json and ios/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 the GeometryReader root, 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 of min-release-age=7 in .npmrc. Only three iOS Swift files change, so JS and Android stay the same, and swipes and arrows still use onPageSelected.

    Note 2 (mWeb Safari) is a separate web path: on rotation the web carousel's updatePage gets 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 onPageSelected events 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.
  25. phuchoang23 commented on Oct 9, 2026

    @phuchoang23
    Contributor

    Proposal

    What is the root cause of that problem?

    On iOS, react-native-pager-view 9.0.4 (upgraded in #100149) fires a spurious onPageSelected event — typically with position 0 — whenever the device rotates and the native SwiftUI TabView relayouts. The carousel's updatePage handler treats every onPageSelected as a real user navigation: it calls Keyboard.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 inside updatePage by 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 call pagerRef.current.setPage(currentPage) to programmatically restore the correct page.

    Additionally, after the restore call the pager fires another onPageSelected with 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 onPageSelected alone (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 + useEffect to restore the page — rejected because the effect runs after render, which is too late; the spurious onPageSelected fires synchronously from native before the effect has a chance to set a guard flag.
  26. neerajbachani commented on Oct 9, 2026

    @neerajbachani
    Contributor

    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. updatePage treats that as a swipe, dismisses the keyboard, and opens the first attachment. The fix returns before updatePage for that selection and snaps the pager back to the PDF. That change is only in AttachmentCarouselView/index.native.tsx. Forwarding onPageScrollStateChanged from Pager/index.tsx is an optional fallback.

    I also tested 9.0.6 on its own and the jump still reproduces, so the bump doesn't fix it.

  27. nabi-ebrahimi commented on Oct 9, 2026

    @nabi-ebrahimi
    Contributor

    Proposal

    What is the root cause of that problem?

    The iOS attachment carousel uses react-native-pager-view@9.0.4 ([package.json](

    "react-native-pager-view": "9.0.4",
    )). On iOS that pager is a SwiftUI TabView(selection: $props.currentPage), and every currentPage change is forwarded to JS as onPageSelected, 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. AttachmentCarouselPager passes onPageSelected straight through to the parent, and the parent calls updatePage(newPage) for every emitted position ([Pager/index.tsx](

    <AnimatedPagerView
    pageMargin={40}
    offscreenPageLimit={1}
    onPageScroll={pageScrollHandler}
    onPageSelected={onPageSelected}
    style={styles.flex1}
    initialPage={initialPage}
    ), [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)). updatePage() then immediately dismisses the keyboard, changes the carousel page, updates the active attachment, and calls onNavigate() (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() before pagerRef.setPage() ([index.native.tsx](
      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 when newPageIndex === page, so same-page native echoes never dismiss the keyboard.

    What alternative solutions did you explore? (Optional)

    A simple newPageIndex === page guard alone is not enough because the bad native event can be a different page, usually 0, while the protected PDF is on another index.

    Bumping react-native-pager-view to 9.0.6/9.0.7 can bring upstream safe-area improvements, but it still forwards every currentPage selection to JS through the same onPageSelected path ([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.

  28. voxturrlabs commented on Oct 9, 2026

    @voxturrlabs

    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?

    1. 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/

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

BugSomething is broken. Auto assigns a BugZero manager.DailyKSv2ExternalAdded to denote the issue can be worked on by a contributorHelp WantedApply this label when an issue is open to proposals by contributors

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions