Why FlatList's Failure Callback Didn't Catch This Crash
A photo viewer can receive fewer images than its caller expects. If the caller remembers index 9 from twelve images, then passes only five usable images to the viewer, that position is no longer valid. Installing onScrollToIndexFailed does not make the invalid call safe.
Find the item in the current data, validate its index, then scroll.
This is Day 5 / Lists lesson 3 of 3, the module finale. Part 1 covered initial rendering and row updates; Part 2 completed pagination, refresh and retry.
The video uses sample diagrams, English synthetic narration and timed English/Arabic subtitles. I reviewed the code below, but it was not executed or device-tested. The illustrated gallery is not a newly reproduced device crash.
The source fix and what it actually guarantees
HM-tasks2 commit b933bc81d27ad8a87d499b8ae76f1ef6495c4c65, authored by Ibrahim Fathi on 2026-08-27 with a recorded AI co-author, changes src/components/ImageViewer/index.tsx and adds helpers.ts. Its commit explanation identifies the mismatch between caller indices and the filtered URL list.
The diff clamps indices, omits the FlatList for empty data, and cancels the delayed scroll when the derived bounds become obsolete. It also separates open-animation reset from the scroll effect. Its helper returns zero for an empty list, so the separate empty guard is essential: zero is not a valid index into zero items. The committed helper tests were read as evidence; they were not rerun here.
This fix prevents the specific invalid positional requests it guards. Clamping is not an identity-preserving selection policy. In our sample [P02, P05, P10, P11, P12], clamping 9 to 4 selects P12. The requested P10 is at index 2. An old index can also remain numerically valid while referring to the wrong item after reordering.
The ID-based approach and bounded measurement recovery below are teaching additions. The production diff does not implement them. No source application was modified.
Two paths in React Native 0.83.2
The project's package.json at that commit specifies React Native 0.83.2. In its VirtualizedList.scrollToIndex, the nonnegative-index, nonempty-data and upper-bound checks run before the measurement-failure branch. Violating them throws synchronously; onScrollToIndexFailed is not a general exception catcher.
For an in-range target beyond the measured region, without getItemLayout, that later branch calls onScrollToIndexFailed if supplied, then returns. The callback itself is invoked synchronously in this implementation, even though content measurement happens over time. This distinction corrects the source comment's loose description of an "async" measurement path. Tagged React Native 0.83.2 source.
Bounds and layout are separate questions:
| Question | Outcome |
|---|---|
| Does the requested item exist in the current data? | Resolve its ID and validate the resulting index |
| Is that item's position known? | Use accurate geometry or bounded measurement recovery |
| Is the current dataset empty? | Show empty state; send no scroll request |
| Is the ID absent? | Show unavailable; do not select a substitute |
Match the lookup to the rendered array
Filter the data once, preserving stable unique IDs alongside URLs, then give that same array to the list and the lookup. Do not compute an index in allPhotos and pass it to a list rendering usablePhotos.
const usablePhotos = allPhotos.filter(photo => Boolean(photo.uri));
const index = usablePhotos.findIndex(photo => photo.id === selectedId);
if (!Number.isInteger(index) || index < 0 || index >= usablePhotos.length) {
showUnavailable();
return;
}
// This FlatList must be rendering usablePhotos at the time of the command.
list.current?.scrollToIndex({index});
For deferred work, checking only when the timer is created is insufficient. Re-resolve the ID immediately before each attempt, and cancel the operation when a newer selection or dataset takes its place. The complete example keeps a snapshot of committed list data, invalidates pending work on array replacement, and lets the user select again after the gallery changes. Use immutable data and stable references for unchanged arrays; constructing a new array on every parent render will deliberately cancel pending work more often.
Reviewed integration example
This example uses a single-column vertical list with variable caption heights. It has no list header, separators, inversion or horizontal RTL offset calculations. A valid-but-unmeasured target gets one estimated move and one retry. A second measurement failure stops with a manual-scroll message.
import React, {useCallback, useLayoutEffect, useRef, useState} from 'react';
import {Button, FlatList, Image, Text, View} from 'react-native';
import type {FlatListProps} from 'react-native';
export type Photo = {id: string; uri: string; caption: string};
export function isValidIndex(index: number, length: number): boolean {
return Number.isInteger(index) && index >= 0 && index < length;
}
export function indexForId(photos: readonly Photo[], id: string): number | null {
const index = photos.findIndex(photo => photo.id === id);
return isValidIndex(index, photos.length) ? index : null;
}
type Jump = {id: string; index: number; retries: number; failed: boolean};
type Failure = Parameters<NonNullable<FlatListProps<Photo>['onScrollToIndexFailed']>>[0];
const keyExtractor = (photo: Photo) => photo.id;
function usePhotoJump(photos: readonly Photo[], selectedId: string | null) {
const list = useRef<FlatList<Photo>>(null);
const current = useRef(photos);
const mounted = useRef(false);
const pending = useRef<Jump | null>(null);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [notice, setNotice] = useState('');
const cancel = useCallback(() => {
if (timer.current !== null) clearTimeout(timer.current);
timer.current = null;
pending.current = null;
}, []);
useLayoutEffect(() => {
mounted.current = true;
return () => { mounted.current = false; cancel(); };
}, [cancel]);
// Use committed data, matching this FlatList's props. No in-place mutation.
useLayoutEffect(() => {
const previousJump = pending.current;
cancel();
current.current = photos;
if (previousJump) setNotice(indexForId(photos, previousJump.id) === null ?
'That photo is unavailable.' : 'The gallery changed. Select the photo again.');
}, [photos, cancel]);
useLayoutEffect(() => { cancel(); }, [selectedId, cancel]);
const attempt = useCallback((jump: Jump) => {
if (!mounted.current || pending.current !== jump) return;
const index = indexForId(current.current, jump.id);
if (index === null) {
cancel();
setNotice(current.current.length ? 'That photo is unavailable.' : 'No photos.');
return;
}
if (!list.current) {
cancel();
setNotice('The list is not ready. Try again.');
return;
}
jump.index = index;
jump.failed = false;
list.current.scrollToIndex({index, animated: false});
// RN 0.83.2 invokes its measurement-failure callback synchronously.
// A returned command is not confirmation of native scroll completion.
if (pending.current === jump && !jump.failed) cancel();
}, [cancel]);
const onScrollToIndexFailed = useCallback((info: Failure) => {
const jump = pending.current;
if (!mounted.current || !jump || info.index !== jump.index) return;
jump.failed = true;
const index = indexForId(current.current, jump.id);
const average = info.averageItemLength;
if (index === null || jump.retries >= 1 || !Number.isFinite(average) || average <= 0) {
cancel();
setNotice(index === null ? 'That photo is unavailable.' :
'Could not locate the photo precisely. Scroll manually.');
return;
}
const estimate = average * index;
if (!Number.isFinite(estimate)) {
cancel();
setNotice('Could not estimate the position. Scroll manually.');
return;
}
jump.retries += 1;
list.current?.scrollToOffset({offset: Math.max(0, estimate), animated: false});
// One scheduling delay, not a promise that layout completes in 200 ms.
timer.current = setTimeout(() => {
timer.current = null;
attempt(jump); // Resolves the ID and validates current bounds again.
}, 200);
}, [attempt, cancel]);
const showPhoto = useCallback((id: string) => {
cancel(); // New selection supersedes the previous jump.
if (!mounted.current) return;
setNotice('');
const jump: Jump = {id, index: -1, retries: 0, failed: false};
pending.current = jump;
attempt(jump);
}, [attempt, cancel]);
return {list, notice, showPhoto, onScrollToIndexFailed};
}
// Teaching integration: variable-height vertical rows, one column, no header,
// separators or inverted/RTL horizontal offsets. Stable, unique IDs required.
export function SafePhotoList({photos, selectedId}: {
photos: readonly Photo[]; selectedId: string | null;
}) {
const jump = usePhotoJump(photos, selectedId);
return <View style={{flex: 1}}>
{selectedId !== null && <Button title="Show selected photo"
onPress={() => jump.showPhoto(selectedId)} />}
{!!jump.notice && <Text accessibilityRole="alert">{jump.notice}</Text>}
{photos.length === 0 ? <Text>No photos.</Text> : <FlatList
ref={jump.list}
data={photos}
keyExtractor={keyExtractor}
renderItem={({item}) => <View>
<Image source={{uri: item.uri}} style={{height: 180}} resizeMode="contain" />
<Text>{item.caption}</Text>
</View>}
onScrollToIndexFailed={jump.onScrollToIndexFailed}
/>}
</View>;
}
// Separate fixed-width gallery option, only when every page really has this
// extent, with no separator/header offset. Recompute when actual width changes.
export function fixedPageLayout(width: number) {
if (!Number.isFinite(width) || width <= 0) throw new Error('Invalid page width');
return (_data: ArrayLike<Photo> | null | undefined, index: number) => ({
length: width, offset: width * index, index,
});
}
A missing target is rejected before any scroll command for that selection. Measurement recovery is different: it may move to an approximate location before deciding to stop. The code does not promise that a fallback leaves the exact original offset untouched.
The 200 ms delay is a bounded scheduling choice. It does not prove that layout will finish within 200 ms. A long distance, complex images or a busy device may require manual scrolling. Request identity prevents an obsolete timer from acting after new selection or data. There is no unbounded retry loop, and native scroll completion is not inferred merely from the method returning.
Translate the example UI strings and apply app styling during integration. Stable unique IDs, valid image URIs, actual image layouts and behavior on target devices still need verification.
Use layout hints only when geometry is known
getItemLayout can bypass measurement when accurate item lengths and offsets are known. Equal-width pages can use their actual page width; separators must be included in offset calculations. Recompute when layout changes. Do not supply a guessed constant for variable-size rows. FlatList 0.83 layout documentation.
Liana's src/components/ImageViewer/index.tsx:147-154 provides a contextual example: page length is screenW, offset is screenW * index, and rendered page containers use screenW. The relevant lines have team attribution (dabd51dc); individual authorship is not claimed. Its initialScrollIndex is context. It is no evidence of a complete bounds fix.
The fixed-width helper at the end of the teaching file is a separate option. I do not supply it to the variable-height list above. It assumes no additional header/separator offset. For an initial jump, derive and validate initialScrollIndex from the same dataset and supply the required accurate layout implementation. The teaching component avoids that extra startup path and handles selection explicitly. FlatList initialScrollIndex.
The measurement callback exposes the requested index, average length and highest measured index. It supports recovery for unmeasured content; it cannot repair a missing item. VirtualizedList 0.83 failure callback.
Verification to run in the consuming app
Run these checks in your app. I have not run them on a device:
| Scenario | Expected behavior |
|---|---|
| Twelve images become five; P10 remains | P10 resolves to current index 2 rather than old index 9 |
| Same-length reorder | Requested ID resolves to its new position |
| Selected ID is removed | Unavailable message; no substitute selected |
| All image URLs are filtered out | Empty state; no FlatList scroll command |
| Negative, NaN, fractional or oversized supplied index | Generic guard rejects it; do not rely on the callback |
| Valid distant target lacks measurements | At most one scheduled retry, then manual fallback |
| Data or selected ID changes during delay | Pending jump cancelled; no old-timer navigation |
| Screen unmounts during delay | Timer cleared; no later command or state update |
| Width, font scale or language changes | Real geometry and layout assumptions verified again |
Also check platforms, accessibility, image-loading layout changes and any horizontal RTL implementation separately. This example does not establish a universal RTL offset formula or a guarantee against all native scrolling errors.
Lists is complete
The three lessons cover initial rendering and row updates, reliable pagination, and safe navigation to list items. No separate pagination or rendering follow-up is promised.
Next: My App Froze Without Crashing: Two Modals at Once, Day 6, a standalone production story. Its video link will be added when published.
Comments