fix(mobile): size onboarding slides to the modal sheet on iPad (#26919)

* fix(mobile): size onboarding slides from the modal viewport

* fix(mobile): ignore zero-width onboarding viewport layouts

* test(mobile): model slide progress in onboarding width test
This commit is contained in:
Jinwoo Hong
2026-10-09 14:38:04 -04:00
committed by GitHub
parent 5da51ea6b2
commit 3eea6a5368
3 changed files with 109 additions and 30 deletions
+37 -23
View File
@@ -1,5 +1,5 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import { Animated, BackHandler, Text, useWindowDimensions, View } from 'react-native'
import { Animated, BackHandler, Text, View, type LayoutChangeEvent } from 'react-native'
import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'
import { SafeAreaView } from 'react-native-safe-area-context'
import { OrcaLogo } from '../src/components/OrcaLogo'
@@ -46,7 +46,9 @@ function MobileOnboardingFlow({
}) {
const router = useRouter()
const steps = useMemo(() => parseMobileOnboardingSteps(rawSteps), [rawSteps])
const { width } = useWindowDimensions()
// Why: an iPad modal is a centred sheet narrower than the window, so slides must
// follow the viewport; null holds the pager back until the first measurement.
const [width, setWidth] = useState<number | null>(null)
const [activeIndex, setActiveIndex] = useState(0)
const [busyChoice, setBusyChoice] = useState<MobileOnboardingBusyChoice>(null)
const [error, setError] = useState<string | null>(null)
@@ -132,7 +134,17 @@ function MobileOnboardingFlow({
[advanceOrContinue]
)
const translateX = useMemo(() => Animated.multiply(slideProgress, -width), [slideProgress, width])
const translateX = useMemo(
() => Animated.multiply(slideProgress, -(width ?? 0)),
[slideProgress, width]
)
const onViewportLayout = useCallback((event: LayoutChangeEvent) => {
const measured = event.nativeEvent.layout.width
// Why: a transient zero-width pass must not collapse the slides to nothing.
if (measured > 0) {
setWidth(measured)
}
}, [])
return (
<SafeAreaView style={styles.container}>
@@ -157,26 +169,28 @@ function MobileOnboardingFlow({
) : null}
</View>
<View style={styles.carouselViewport}>
<Animated.View
style={[
styles.carouselTrack,
{ width: width * steps.length, transform: [{ translateX }] }
]}
>
{steps.map((step, index) => (
<MobileOnboardingPage
key={step}
step={step}
width={width}
active={index === activeIndex}
busyChoice={busyChoice}
error={error}
onSessionChoice={(view) => void chooseSessionView(view)}
onNotificationChoice={(choice) => void chooseNotifications(choice)}
/>
))}
</Animated.View>
<View style={styles.carouselViewport} onLayout={onViewportLayout}>
{width === null ? null : (
<Animated.View
style={[
styles.carouselTrack,
{ width: width * steps.length, transform: [{ translateX }] }
]}
>
{steps.map((step, index) => (
<MobileOnboardingPage
key={step}
step={step}
width={width}
active={index === activeIndex}
busyChoice={busyChoice}
error={error}
onSessionChoice={(view) => void chooseSessionView(view)}
onNotificationChoice={(choice) => void chooseNotifications(choice)}
/>
))}
</Animated.View>
)}
</View>
</SafeAreaView>
)
@@ -117,12 +117,19 @@ async function connectedHost() {
client.sendRequest.mockClear()
return client
}
// The onboarding pager renders once its viewport reports a width.
function layoutOnboarding() {
renderer!.root
.findAll((node) => node.props.onLayout !== undefined)[0]
?.props.onLayout({ nativeEvent: { layout: { width: 390 } } })
}
async function choose(entry: string) {
await act(async () => {
renderer = create(
createElement(entry === 'settings' ? NotificationsScreen : MobileOnboardingScreen)
)
})
await act(async () => layoutOnboarding())
await act(async () => {
if (entry === 'settings') {
renderer!.root.findByType('Switch').props.onValueChange(true)
@@ -183,6 +190,7 @@ it('remembers Not now without registering and does not ask again', async () => {
await act(async () => {
renderer = create(createElement(MobileOnboardingScreen))
})
await act(async () => layoutOnboarding())
await act(async () => {
renderer!.root.findByType('Page').props.onNotificationChoice('skip')
})
@@ -6,6 +6,7 @@ import MobileOnboardingScreen from '../../app/mobile-onboarding'
const mocks = vi.hoisted(() => ({
params: { hostId: 'paired-host', steps: 'session-view,notifications' },
replace: vi.fn(),
windowWidth: 390,
reducedMotionEnabled: false,
animatedTiming: vi.fn(),
ensureNotificationPermissions: vi.fn(),
@@ -19,9 +20,12 @@ vi.mock('react-native', () => ({
isReduceMotionEnabled: vi.fn(() => Promise.resolve(mocks.reducedMotionEnabled))
},
Animated: {
Value: class {},
Value: class {
constructor(public current: number) {}
},
View: 'AnimatedView',
multiply: vi.fn(() => 0),
// Lazy like a real Animated node, so translateX tracks later timing updates.
multiply: (value: { current: number }, factor: number) => ({ value, factor }),
timing: mocks.animatedTiming
},
BackHandler: {
@@ -30,7 +34,7 @@ vi.mock('react-native', () => ({
StyleSheet: { create: (styles: unknown) => styles },
Text: 'Text',
View: 'View',
useWindowDimensions: () => ({ width: 390, height: 844 })
useWindowDimensions: () => ({ width: mocks.windowWidth, height: 844 })
}))
vi.mock('expo-router', () => ({
@@ -58,10 +62,16 @@ describe('MobileOnboardingScreen', () => {
beforeEach(() => {
mocks.params = { hostId: 'paired-host', steps: 'session-view,notifications' }
mocks.replace.mockReset()
mocks.windowWidth = 390
mocks.reducedMotionEnabled = false
mocks.animatedTiming.mockReset().mockReturnValue({
start: (callback: (result: { finished: boolean }) => void) => callback({ finished: true })
})
mocks.animatedTiming
.mockReset()
.mockImplementation((value: { current: number }, config: { toValue: number }) => ({
start: (callback: (result: { finished: boolean }) => void) => {
value.current = config.toValue
callback({ finished: true })
}
}))
mocks.ensureNotificationPermissions.mockReset().mockResolvedValue(true)
mocks.saveDefaultSessionView.mockReset().mockResolvedValue(undefined)
mocks.setRemotePushEnabled.mockReset().mockResolvedValue(undefined)
@@ -73,7 +83,7 @@ describe('MobileOnboardingScreen', () => {
vi.restoreAllMocks()
})
async function renderScreen() {
async function renderScreen(viewportWidth = 390) {
const consoleError = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] !== 'string' || !args[0].includes('react-test-renderer is deprecated')) {
throw new Error(String(args[0]))
@@ -82,9 +92,29 @@ describe('MobileOnboardingScreen', () => {
await act(async () => {
renderer = create(createElement(MobileOnboardingScreen))
})
await layoutViewport(viewportWidth)
consoleError.mockRestore()
}
async function layoutViewport(width: number) {
const viewport = renderer!.root.findAll((node) => node.props.onLayout !== undefined)[0]
await act(async () => viewport?.props.onLayout({ nativeEvent: { layout: { width } } }))
}
function trackStyle() {
return renderer!.root.findByType('AnimatedView').props.style[1]
}
function trackWidth(): number {
return trackStyle().width
}
function translateX(): number {
const { value, factor } = trackStyle().transform[0].translateX
// + 0 normalises -0 on the first slide.
return value.current * factor + 0
}
function pages() {
return renderer!.root.findAllByType('MobileOnboardingPage')
}
@@ -159,6 +189,7 @@ describe('MobileOnboardingScreen', () => {
mocks.params = { hostId: 'paired-host', steps: 'notifications' }
await act(async () => renderer!.update(createElement(MobileOnboardingScreen)))
await layoutViewport(390)
expect(pages()).toHaveLength(1)
expect(pages()[0].props).toMatchObject({ step: 'notifications', active: true })
@@ -186,4 +217,30 @@ describe('MobileOnboardingScreen', () => {
expect(pages()[1].props).toMatchObject({ active: true, busyChoice: null })
})
it('sizes slides from the modal viewport rather than the wider window', async () => {
// iPad presents the modal as a centred sheet narrower than the window.
mocks.windowWidth = 1024
await renderScreen(540)
expect(pages().map((page) => page.props.width)).toEqual([540, 540])
expect(trackWidth()).toBe(1080)
expect(translateX()).toBe(0)
await act(async () => pages()[0].props.onSessionChoice('chat'))
expect(translateX()).toBe(-540)
await layoutViewport(700)
expect(pages().map((page) => page.props.width)).toEqual([700, 700])
expect(trackWidth()).toBe(1400)
expect(translateX()).toBe(-700)
})
it('keeps the pager unrendered until the viewport reports a real width', async () => {
await renderScreen(0)
expect(pages()).toHaveLength(0)
await layoutViewport(540)
expect(pages().map((page) => page.props.width)).toEqual([540, 540])
})
})