Skip to content

Commit 7467987

Browse files
fix: intro-screen issues
1 parent ed45693 commit 7467987

3 files changed

Lines changed: 102 additions & 0 deletions

File tree

packages/pluggableWidgets/intro-screen-native/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
1010

1111
- We fixed an issue where reopening a page containing the intro screen would show the first slide instead of the slide referenced by the active slide attribute, and would report a slide change that the user did not make.
1212
- We fixed an issue where the initial slide was scrolled to with an animation, which briefly left the slide content unavailable to screen readers.
13+
- We fixed an issue where moving to a slide with the next, previous or pagination controls could jump back to the first slide and report an extra slide change.
1314

1415
## [4.4.1] - 2026-6-10
1516

packages/pluggableWidgets/intro-screen-native/src/SwipeableContainer.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ export const SwipeableContainer = (props: SwipeableContainerProps): ReactElement
7676
const flashList = useRef<FlashListRef<any>>(null);
7777
const hasAppliedInitialScroll = useRef(false);
7878
const isUserDragging = useRef(false);
79+
// Slide a programmatic scroll is currently heading for, or null when the list is
80+
// wherever the user left it. Used to recognise momentum events that contradict the
81+
// scroll we just asked for; see onMomentumScrollEnd.
82+
const pendingScrollTarget = useRef<number | null>(null);
7983

8084
const rtlSafeIndex = useCallback(
8185
(i: number): number => (isAndroidRTL ? props.slides.length - 1 - i : i),
@@ -86,6 +90,7 @@ export const SwipeableContainer = (props: SwipeableContainerProps): ReactElement
8690
(pageNum: number, animated = true) => {
8791
setActiveIndex(pageNum);
8892
if (flashList && flashList.current) {
93+
pendingScrollTarget.current = pageNum;
8994
flashList.current.scrollToOffset({
9095
offset: rtlSafeIndex(pageNum) * width,
9196
animated
@@ -324,6 +329,10 @@ export const SwipeableContainer = (props: SwipeableContainerProps): ReactElement
324329

325330
const onScrollBeginDrag = useCallback(() => {
326331
isUserDragging.current = true;
332+
// The user takes over from here, so any programmatic scroll still in flight no
333+
// longer describes where the list is heading. Clearing the target also keeps it
334+
// from latching when a scroll never reports a momentum end of its own.
335+
pendingScrollTarget.current = null;
327336
}, []);
328337

329338
const onMomentumScrollEnd = useCallback(
@@ -336,6 +345,20 @@ export const SwipeableContainer = (props: SwipeableContainerProps): ReactElement
336345
}
337346
const offset = event.nativeEvent.contentOffset.x;
338347
const newIndex = rtlSafeIndex(Math.round(offset / width));
348+
349+
// While a programmatic scroll is in flight the list can report a momentum end
350+
// for the position it is leaving rather than the one it is heading to. Trusting
351+
// that offset rewrites activeIndex to a slide that is not on screen, and the
352+
// drag flag cannot tell the two apart because a fling reports momentum without
353+
// a fresh drag. Anything that disagrees with the requested slide is stale.
354+
const pendingTarget = pendingScrollTarget.current;
355+
if (pendingTarget !== null) {
356+
if (newIndex === pendingTarget) {
357+
pendingScrollTarget.current = null;
358+
}
359+
return;
360+
}
361+
339362
if (newIndex === activeIndex) {
340363
return;
341364
}

packages/pluggableWidgets/intro-screen-native/src/__tests__/SwipeableContainer.remount.spec.tsx

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,84 @@ describe("SwipeableContainer remount behaviour", () => {
123123
expect(activeSlide.setValue).toHaveBeenCalledWith(new Big(3));
124124
});
125125

126+
it("keeps the slide Next moved to when a stale momentum event reports the old offset", () => {
127+
// Reproduces the Android e2e failure: after remounting on slide 2 the widget scrolls
128+
// to slide 3, then the list reports a momentum end for the offset it was leaving.
129+
const { getByTestId, activeSlide, onSlideChange } = setup(2);
130+
131+
// A drag that settles without a momentum event of its own — the initial positioning
132+
// scroll produces one on device — leaves the widget believing a drag is in progress.
133+
act(() => {
134+
fireEvent(getByTestId("intro"), "scrollBeginDrag", {});
135+
});
136+
137+
fireEvent.press(getByTestId("intro$buttonNext"));
138+
expect(activeSlide.setValue).toHaveBeenLastCalledWith(new Big(3));
139+
140+
act(() => {
141+
fireEvent(getByTestId("intro"), "momentumScrollEnd", {
142+
nativeEvent: { contentOffset: { x: 0 } }
143+
});
144+
});
145+
146+
// The stale offset must not drag the attribute back to slide 1 or report a change.
147+
expect(activeSlide.setValue).toHaveBeenLastCalledWith(new Big(3));
148+
expect(onSlideChange).toHaveBeenCalledTimes(1);
149+
expect(onSlideChange).toHaveBeenCalledWith(2, 1);
150+
151+
// Slide 3 is the last slide, so the pagination still offers Done rather than
152+
// falling back to the first-slide buttons.
153+
expect(getByTestId("intro$buttonDone")).toBeTruthy();
154+
155+
// Previous moves relative to the slide that is actually on screen.
156+
fireEvent.press(getByTestId("intro$buttonPrevious"));
157+
expect(activeSlide.setValue).toHaveBeenLastCalledWith(new Big(2));
158+
});
159+
160+
it("still reports a user swipe after a programmatic scroll has settled", () => {
161+
const { getByTestId, activeSlide, onSlideChange } = setup(2);
162+
163+
fireEvent.press(getByTestId("intro$buttonNext"));
164+
onSlideChange.mockClear();
165+
166+
// The programmatic scroll to slide 3 lands.
167+
act(() => {
168+
fireEvent(getByTestId("intro"), "momentumScrollEnd", {
169+
nativeEvent: { contentOffset: { x: 2 * WIDTH } }
170+
});
171+
});
172+
173+
// The user then swipes back to slide 2, which must be reported.
174+
act(() => {
175+
fireEvent(getByTestId("intro"), "scrollBeginDrag", {});
176+
fireEvent(getByTestId("intro"), "momentumScrollEnd", {
177+
nativeEvent: { contentOffset: { x: WIDTH } }
178+
});
179+
});
180+
181+
expect(onSlideChange).toHaveBeenCalledWith(1, 2);
182+
expect(activeSlide.setValue).toHaveBeenLastCalledWith(new Big(2));
183+
});
184+
185+
it("reports a user swipe that interrupts an in-flight programmatic scroll", () => {
186+
const { getByTestId, activeSlide, onSlideChange } = setup(2);
187+
188+
fireEvent.press(getByTestId("intro$buttonNext"));
189+
onSlideChange.mockClear();
190+
191+
// The user grabs the list before the scroll to slide 3 settles and swipes back to
192+
// slide 1. Taking over invalidates the pending target, so this must be reported.
193+
act(() => {
194+
fireEvent(getByTestId("intro"), "scrollBeginDrag", {});
195+
fireEvent(getByTestId("intro"), "momentumScrollEnd", {
196+
nativeEvent: { contentOffset: { x: 0 } }
197+
});
198+
});
199+
200+
expect(onSlideChange).toHaveBeenCalledWith(0, 2);
201+
expect(activeSlide.setValue).toHaveBeenLastCalledWith(new Big(1));
202+
});
203+
126204
it("ignores a stale programmatic scroll that reports the pre-scroll offset", () => {
127205
const { getByTestId, activeSlide, onSlideChange } = setup(2);
128206

0 commit comments

Comments
 (0)