Skip to content

[iOS] pager content getting cropped on version 8.0.4 #1099

Description

@kothawade29

Environment

react-native-pager-view: 8.0.4 (regression from 8.0.2)
react-native: 0.81.1
Architecture: Fabric (New Architecture)
Platform: iOS
Device: iPhone 16 Pro, portrait (reproduces on any device with a home-indicator safe area)
Tab bar: custom React Native bottom tab (not the system UITabBar)

Description

Regression in 8.0.4, introduced by #1085.

After upgrading 8.0.2 → 8.0.4, page content is cropped at the bottom on first render and only corrects after the first scroll.

Root cause (confirmed with native logging inside PageChildViewController):

propagateSafeArea() walks up to the nearest ancestor with a non-zero safe area and copies its safeAreaInsets into additionalSafeAreaInsets. The picked source is consistently the same view (_UIHostingView<_ViewList_View>), but its safeAreaInsets.bottom is not stable during layout — it flickers 49 → 24.33 while the hosting view's height settles through intermediate passes (730 → 681 → 656.66).

propagateSafeArea() samples this value mid-relayout and pins the transient 24.33, cropping the content. The 0.5pt guard doesn't help, since 49 → 24.33 is a large delta (the guard only prevents infinite layout loops, not incorrect values). A manual scroll forces a clean relayout back to 49, which is why the crop disappears after scrolling.

Trigger: the initial layout pass on first mount, and again whenever the page is relaid out. The inset is read while the hosting view's layout is still settling, so an intermediate value gets applied.

Expected: content uses the correct, stable bottom inset (49) on first render.
Actual: content is cropped using a transient inset (24.33) until the first scroll.

Reproducible Demo

Fabric app with a PagerView whose page contains a scrollable list.
The page is rendered inside a view whose height changes during initial layout (below a collapsing header or header height changes during layout).
On first load, the bottom of the content is cut off. Scroll once and it corrects.

Image

Activity

  1. kyunss commented on Aug 13, 2026

    @kyunss

    Same root cause, but we hit a second failure mode that I think makes the design flaw clearer: the injected inset isn't just transient — when the ancestor that nearestNonZeroSafeAreaInsets() lands on is a scroll container, the value is wrong by construction and tracks the scroll offset.

    Setup

    PagerView (tabs)
      └─ vertical ScrollView
          └─ PagerView (image pager, ~500pt tall card mid-screen)
              └─ page: <Image />
    

    react-native-pager-view 8.0.4 · react-native 0.84.1 · Fabric · iOS 26.1

    Symptom

    Scrolling the outer vertical ScrollView, the image inside the pager stops moving and appears pinned to the screen while everything around it (card border, overlays, page indicator — all siblings outside <PagerView>) scrolls normally. Once the value saturates or resolves to a different ancestor, the image snaps back and starts scrolling again.

    Why

    Walking up from PageChildViewController.view, every nearby ancestor sits inside the safe area, so the loop keeps going until it reaches the outer ScrollView's content view. That view's top edge is above the safe-area boundary by exactly the scroll offset, so its safeAreaInsets.top grows 1:1 as you scroll.

    Because safeAreaInsets is derived from on-screen geometry, viewSafeAreaInsetsDidChange fires continuously while scrolling, so additionalSafeAreaInsets.top is re-applied every pass:

    container scrolls up by N  →  page content pushed down by N  →  net zero  →  content looks frozen
    

    No layout-timing flicker involved here — the value is stable, it's just not the page's inset. The 0.5pt guard is irrelevant for the same reason you noted.

    Verified

    Three independent changes each remove the symptom:

    1. Making propagateSafeArea() a no-op (patched locally)
    2. Replacing <PagerView> with a paging FlatList (never enters this code path)
    3. Downgrading to 8.0.3, which uses UIHostingController(rootView:, ignoreSafeArea: true) instead

    Affected versions

    ios/Extensions.swift is byte-identical in 8.0.4, 8.0.5 and 9.0.0 — so 9.0.0 does not fix this.

    Suggestion

    An ancestor's safeAreaInsets can't stand in for the page's own: it's computed against that view's bounds and position, so it means something different for every ancestor, and it changes as the ancestor moves. Two directions that avoid both failure modes:

    • compute the page view's own overlap with the window's unsafe regions (view.convert(...) against window.safeAreaLayoutGuide) instead of copying an ancestor's value, or
    • restore the 8.0.3 ignoreSafeArea: true path — disableSafeArea() is still defined in Extensions.swift but no longer called — and address [iOS] New Arch: safe area insets are stripped from PagerView children #1090 without re-injection.

    Happy to test a patch against the nested-pager-in-ScrollView case if that helps.

  2. troZee commented on Aug 13, 2026

    @troZee
    Collaborator

    Hello @kyunss 👋
    Thank you for that analysis. Could you provide a PR for that with the example inside this repository?

  3. troZee commented on Aug 14, 2026

    @troZee
    Collaborator

    I am not able to reproduce that using the below example

    import React from 'react';
    import { Image, ScrollView, StyleSheet, Text, View } from 'react-native';
    import PagerView from 'react-native-pager-view';
    
    import { IMAGE_URIS } from './utils';
    
    const CARD_HEIGHT = 500;
    
    /**
     * Reproduces https://github.com/callstack/react-native-pager-view/issues/1099
     *
     * On iOS with Fabric and pager-view >= 8.0.4, scroll the first tab vertically.
     * The image in the nested pager can appear pinned while the card border and page
     * indicator continue moving. This happens because the page controller copies the
     * outer ScrollView content view's changing safe-area inset.
     */
    export function Issue1099SafeAreaRepro() {
      return (
        <PagerView style={styles.tabs} initialPage={0} testID="issue-1099-tabs">
          <ScrollView
            key="scrollable-tab"
            style={styles.tab}
            contentContainerStyle={styles.scrollContent}
            testID="issue-1099-outer-scroll-view"
          >
            <Text style={styles.heading}>Issue #1099: nested pager safe area</Text>
            <Text style={styles.description}>
              Scroll this tab. On affected iOS Fabric builds, the image may stop
              moving with its card temporarily while the card border and indicator
              continue scrolling.
            </Text>
    
            <View style={styles.topSpacer} />
    
            <View style={styles.card} testID="issue-1099-card">
              <PagerView
                style={styles.imagePager}
                initialPage={0}
                testID="issue-1099-image-pager"
              >
                {IMAGE_URIS.slice(0, 3).map((uri, index) => (
                  <View
                    key={uri}
                    collapsable={false}
                    style={styles.imagePage}
                    testID={`issue-1099-image-page-${index}`}
                  >
                    <View
                      collapsable={false}
                      style={{
                        width: '100%',
                        height: '100%',
                        backgroundColor: 'red',
                        justifyContent: 'space-between',
                      }}
                    >
                      <Text>TOP</Text>
                      <Text>BOTTOM</Text>
                    </View>
                  </View>
                ))}
              </PagerView>
              <View style={styles.indicator} testID="issue-1099-page-indicator">
                <Text style={styles.indicatorText}>Swipe images horizontally</Text>
              </View>
            </View>
    
            {Array.from({ length: 8 }, (_, index) => (
              <View key={index} style={styles.row}>
                <Text>Scrollable content {index + 1}</Text>
              </View>
            ))}
          </ScrollView>
    
          <View key="second-tab" style={styles.secondTab} collapsable={false}>
            <Text>Second tab</Text>
          </View>
        </PagerView>
      );
    }
    
    const styles = StyleSheet.create({
      tabs: {
        flex: 1,
      },
      tab: {
        flex: 1,
        backgroundColor: '#f5f5f5',
      },
      scrollContent: {
        padding: 16,
        paddingBottom: 48,
      },
      heading: {
        fontSize: 20,
        fontWeight: '600',
      },
      description: {
        marginTop: 8,
        color: '#444',
        lineHeight: 20,
      },
      topSpacer: {
        height: 180,
      },
      card: {
        height: CARD_HEIGHT,
    
        borderWidth: 3,
        borderColor: '#5b5bd6',
        backgroundColor: 'white',
      },
      imagePager: {
        height: CARD_HEIGHT - 48,
      },
      imagePage: {
        flex: 1,
      },
      image: {
        width: '100%',
        height: '100%',
      },
      indicator: {
        height: 42,
        alignItems: 'center',
        justifyContent: 'center',
        backgroundColor: '#e5e5ff',
      },
      indicatorText: {
        fontWeight: '600',
      },
      row: {
        height: 120,
        marginTop: 16,
        alignItems: 'center',
        justifyContent: 'center',
        borderRadius: 12,
        backgroundColor: 'white',
      },
      secondTab: {
        flex: 1,
        alignItems: 'center',
        justifyContent: 'center',
      },
    });
    
    
  4. curry-888 commented on Aug 31, 2026

    @curry-888

    Another failure mode of the same PageChildViewController.propagateSafeArea() change, with a drop-in example for example/src/.

    I think this one isolates the design problem: the injected inset does not merely carry a wrong value, it moves the page content. Since RepresentableView became a UIViewControllerRepresentable, setting additionalSafeAreaInsets on that controller makes SwiftUI lay the representable out inside the safe area — the hosted RN view is pushed down until its top sits on the safe-area boundary, while anything that is a sibling of <PagerView> stays where it is.

    Environment

    • react-native-pager-view: 8.0.5 — correct on 8.0.3, broken from 8.0.4 (9.0.0–9.0.2 ship the same Extensions.swift)
    • react-native: 0.86.3 (Expo SDK 57)
    • Architecture: Fabric / New Architecture
    • iOS 26.3, iPhone 17 Pro simulator (any device with a non-zero top inset)

    Steps to reproduce

    1. Save the file below as example/src/Issue1099PageOffsetRepro.tsx.
    2. In example/src/App.tsx, import it and add one entry to ghIssues:
      import { Issue1099PageOffsetRepro } from './Issue1099PageOffsetRepro';
      
      const ghIssues: Example[] = [
        // …
        {
          component: Issue1099PageOffsetRepro,
          name: 'Issue #1099 Page Offset Repro',
        },
      ];
    3. cd example/ios && pod install, then build and run on iOS with the New Architecture.
    4. Open Issue [iOS] pager content getting cropped on version 8.0.4 #1099 Page Offset Repro from the list.

    The screen draws two 44pt bars, both at top: 0 of the same container. The magenta one is a sibling of <PagerView>; the lime one is the first child of page 0. They must form a single continuous stripe.

    Expected (8.0.3): the two halves meet, and [magenta|outside][lime|inside] reads as one 44pt stripe across the top of the screen.

    Actual (8.0.5): the magenta half stays at y = 0 while the lime half sits at y = 62, exactly the status-bar inset.

    react-native-pager-view outside bar inside bar offset
    8.0.3 y = 0 y = 0 0 ✅
    8.0.5 y = 0 y = 62 62 ❌

    If it does not reproduce, check these

    These are the things that silently mask it — I hit most of them while narrowing it down:

    • The screen must have no header. This is the big one. The example app's screens are pushed with a header, and under an opaque header the pager already starts below the safe-area boundary, so there is nothing left to inset and the bars line up even on 8.0.5. The repro file hides the header itself via navigation.setOptions({ headerShown: false }) — please keep that useLayoutEffect. (A headerTransparent: true screen reproduces it too, and that is how we originally hit it.)
    • Rebuild natively when switching versions. yarn add react-native-pager-view@8.0.3 + a JS reload proves nothing; it needs pod install and a fresh native build.
    • iOS + Fabric. The example app can run either stack; the JS/native stack toggle in the header does not matter, but the New Architecture does.
    • A device with a non-zero top safe-area inset. The offset is that inset, so on a device reporting 0 there is nothing to see.
    • Don't try to measure it from JS. onLayout and measureInWindow both report y: 0 for the page root — the shift only exists in UIKit. Compare the two bars visually or diff screenshots.

    The page lands on the safe-area boundary, wherever the pager starts

    Same app, same 8.0.5 build, moving only the pager container's top edge. Measured on a screen with a transparent native header, where the enclosing react-native-screens screen reports a 100.33pt top inset:

    pager frame top lime bar lands at offset
    0 100.33 100.33
    40 100.33 60.33
    60 100.33 40.33
    120 120 0

    So the trigger is precisely the PagerView's frame overlapping the inset region — which a full-screen pager always does, and which is also why the last row (pager pushed below the boundary) looks fine.

    Why it is easy to miss

    In the most common layout — pager fills the screen, all chrome inside it — a uniform inset just looks like correct safe-area handling. It only becomes visible when something outside the pager has to line up with something inside it.

    That is exactly how the "collapsible header + sticky tab bar + one scroll view per page" pattern works (bluesky's PagerWithHeader and everything derived from it): the header and the sticky tab bar are absolutely positioned siblings of <PagerView> at top: 0, and each page's scroll view offsets its own content with contentContainerStyle={{ paddingTop: headerHeight }}. Both sides are expressed in the pager container's coordinate space, so insetting only the pages leaves a permanent gap between the sticky tab bar and the page content — 100pt on our screens, with no error and nothing else on the screen looking wrong.

    Suggestion

    The goal of #1085 / #1086 — scroll views inside pages seeing real safeAreaInsets — does not require the page host to participate in layout. Some directions (I have only verified the diagnosis, not a fix):

    • keep RepresentableView as a UIViewRepresentable and surface the insets without a view controller, so nothing gets re-laid-out;
    • or keep the controller but stop SwiftUI from insetting the representable itself;
    • or add an opt-out prop — a pager whose page offsets are computed in JS never wants the inset.

    Happy to turn the file below into a PR if that helps.


    // example/src/Issue1099PageOffsetRepro.tsx
    import * as React from 'react';
    import { StyleSheet, Text, View } from 'react-native';
    import { useNavigation } from '@react-navigation/native';
    import PagerView from 'react-native-pager-view';
    
    /**
     * Repro for the page-offset failure mode of
     * https://github.com/callstack/react-native-pager-view/issues/1099
     *
     * The magenta bar and the PagerView are SIBLINGS in the same container, and
     * both bars are placed at `top: 0` of that container, so they must line up
     * into a single continuous stripe:  [magenta|outside][lime|inside]
     *
     *   <= 8.0.3        one continuous stripe
     *   >= 8.0.4 (iOS)  the lime half is pushed down onto the safe-area boundary
     *
     * The header is hidden ON PURPOSE, and it is load-bearing for the repro: the
     * bug only appears when the PagerView's own frame overlaps the safe-area inset
     * region. Under a normal (opaque) navigation header the pager already starts
     * below the boundary, there is nothing left to inset, and the bars line up even
     * on a broken version. Do not drop the `useLayoutEffect` below.
     *
     * Also note: `onLayout` and `measureInWindow` both keep reporting `y: 0` for
     * the page root. The offset is applied by SwiftUI while laying out the
     * `UIViewControllerRepresentable`, so it does not exist as far as JS is
     * concerned — which is why this repro is visual rather than numeric.
     */
    export function Issue1099PageOffsetRepro() {
      const navigation = useNavigation();
    
      React.useLayoutEffect(() => {
        navigation.setOptions({ headerShown: false });
      }, [navigation]);
    
      return (
        <View style={styles.container}>
          {/* OUTSIDE the pager — absolute, top: 0 of the shared container */}
          <View style={[styles.bar, styles.outside]}>
            <Text style={styles.label}>outside</Text>
          </View>
    
          <PagerView style={styles.pager} initialPage={0}>
            <View key="0" style={styles.page}>
              {/* INSIDE the pager — first child, top of page 0 */}
              <View style={[styles.bar, styles.inside]}>
                <Text style={styles.label}>inside</Text>
              </View>
            </View>
            <View key="1" style={styles.page} />
          </PagerView>
        </View>
      );
    }
    
    const styles = StyleSheet.create({
      container: { flex: 1, backgroundColor: '#ddd' },
      pager: { flex: 1 },
      page: { flex: 1 },
      bar: {
        position: 'absolute',
        top: 0,
        width: '50%',
        height: 44,
        alignItems: 'center',
        justifyContent: 'center',
      },
      outside: { left: 0, backgroundColor: 'magenta', zIndex: 10 },
      inside: { left: '50%', backgroundColor: 'lime' },
      label: { fontSize: 13, fontWeight: '600', color: '#000' },
    });
  5. troZee commented on Aug 31, 2026

    @troZee
    Collaborator

    @curry-888
    Thank you for the explanation. Please go ahead and provide a PR for that

  6. removed
    Resolution: Needs ReproThis issue could be improved with a demo to reproduce the issue.
    on Aug 31, 2026
  7. seantanys commented on Sep 28, 2026

    @seantanys
    Contributor

    Hi @troZee , another failure mode with the same symptom (page cropped at the bottom, onLayout still full height), but a different cause from propagateSafeArea(): SwiftUI keyboard avoidance on the GeometryReader root. Still present on 9.0.5, so #1140 does not cover it.

    What happens

    With the keyboard open, every page is shrunk by the keyboard overlap. The lower part of the page is clipped and stops receiving touches. It shows when the pager sits in a container that follows the keyboard with a transform (e.g. a chat composer in KeyboardStickyView); a full-screen pager hides it, because the cropped area is under the keyboard.

    Before (9.0.5) After (patched)
    pager-before-fix.mp4
    pager-after-fix.mp4

    Why

    Fix

           }
         }
    +    .ignoresSafeArea()
         .onAppear {

    Verified with a local patch on 9.0.5 (iOS simulator, New Architecture, via react-native-tab-view 4.3.1): pages keep React Native's height with the keyboard open. The inner modifier on the TabView stays, so the vertical layout is unchanged.

    Happy to open a PR with an example under example/src/gh-issues/ and a Maestro flow under .maestro/issues/.

  8. troZee commented on Sep 28, 2026

    @troZee
    Collaborator

    @seantanys
    Thank you for the explanation. Please provide a PR and we can start a discussion there

  9. seantanys commented on Sep 29, 2026

    @seantanys
    Contributor

    @seantanys Thank you for the explanation. Please provide a PR and we can start a discussion there

    Not a problem, I have created a PR #1150 , cheers!

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions