Skip to content

fix(ios): Upcoming Trips selection never updates after initial render#1899

Draft
Copilot wants to merge 2 commits intomainfrom
copilot/fix-upcoming-trips-selection-ios
Draft

fix(ios): Upcoming Trips selection never updates after initial render#1899
Copilot wants to merge 2 commits intomainfrom
copilot/fix-upcoming-trips-selection-ios

Conversation

Copy link
Contributor

Copilot AI commented Mar 9, 2026

On iOS, tapping trips in the Upcoming Trips screen had no visual effect — the first trip stayed highlighted indefinitely. Android was unaffected.

Root cause: Three compounding issues caused List (NativeWindUI SectionList) to skip re-rendering items when selectedTrip changed:

  • No extraData — list had no signal that external selection state changed
  • Index-based keyExtractor — unstable keys cause incorrect cell reuse on iOS
  • Inline renderItem without useCallback — stale closure risk on selectedTrip

Fix (apps/expo/app/(app)/upcoming-trips.tsx):

  • Add extraData={selectedTrip?.id} so the list re-renders items on selection change
  • Add id: trip.id to data items; switch keyExtractor to (item) => item.id
  • Wrap renderItem in useCallback([upcomingTrips, selectedTrip, t]) — same pattern as conversations.tsx
  • Move renderItem before the early return (Rules of Hooks compliance)
// Before
<List
  data={upcomingTrips.map((trip) => ({ title: trip.name, subTitle: ... }))}
  keyExtractor={(_, index) => index.toString()}
  renderItem={(info) => { ... }}
/>

// After
<List
  data={upcomingTrips.map((trip) => ({ id: trip.id, title: trip.name, subTitle: ... }))}
  keyExtractor={(item) => item.id}
  extraData={selectedTrip?.id}
  renderItem={renderItem}  // useCallback([upcomingTrips, selectedTrip, t])
/>
Original prompt

This section details on the original issue you should resolve

<issue_title>iOS – Upcoming Trips Screen Fails to Update Selection</issue_title>
<issue_description>Description:
On iOS devices, in the Upcoming Trips screen:

  • The first trip is automatically selected when the screen loads.
  • Attempting to select other trips does not change the selection — the first trip remains highlighted.
  • On Android, selecting other trips works correctly.

Steps to Reproduce:

  1. Open the app on iOS
  2. Navigate to Upcoming Trips
  3. Observe that the first trip is automatically selected
  4. Tap on any other trip
  5. Observe that the selection does not change

Screenshots / Screen Recordings:

ScreenRecording_02-25-2026.19-21-00_1.mov

Expected Result:
Selecting any trip should update the selection and display the corresponding trip details.

Actual Result:
The first trip remains selected; tapping other trips has no effect.

Environment:

  • Device: iPhone (13 Pro)

Additional Notes:

  • iOS-specific issue</issue_description>

Comments on the Issue (you are @copilot in this section)

@andrew-bierman @copilot Assigned for POC development. Please investigate and create a proof of concept implementation.

💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

Summary by CodeRabbit

  • Refactor
    • Optimized the upcoming trips list rendering performance through improved memoization and more efficient list item handling to ensure smoother interactions when selecting trips.

On iOS, the List component (NativeWindUI/SectionList) was not
re-rendering items when selectedTrip state changed because:
- No extraData prop to signal external state changes
- keyExtractor used array indices instead of stable trip IDs
- renderItem was an inline closure without useCallback

Fix:
- Add id field to data objects and use item.id in keyExtractor
- Add extraData={selectedTrip?.id} to force re-renders on selection
- Wrap renderItem in useCallback with [upcomingTrips, selectedTrip, t]
- Move renderItem before early return (React Rules of Hooks)

Co-authored-by: andrew-bierman <94939237+andrew-bierman@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix Upcoming Trips screen selection issue on iOS fix(ios): Upcoming Trips selection never updates after initial render Mar 9, 2026
@andrew-bierman
Copy link
Collaborator

@coderabbitai Please review this PR for code quality, best practices, and potential issues.

@coderabbitai
Copy link

coderabbitai bot commented Mar 9, 2026

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Fetch the PR diff
gh pr diff 1899 --repo PackRat-AI/PackRat

echo "---FILE CONTENT---"
# Find the file
fd "upcoming-trips.tsx" --exec cat {}

Length of output: 10446


@andrew-bierman: Here's a focused review of PR #1899.


Overall Assessment

The three-pronged fix (extraData, stable keyExtractor, useCallback for renderItem) correctly targets the iOS re-render skipping bug. The approach matches the pattern used in conversations.tsx. A few code quality issues remain though:


🔴 Issue 1 — renderItem still uses index-based trip lookup (regression risk)

Even though the keyExtractor now uses item.id, the renderItem callback still retrieves the trip by index:

const trip = upcomingTrips[info.index];   // ← still index-based
assertDefined(trip);

If upcomingTrips and data ever get out of sync (e.g., during an async trip load or sort), this silently renders the wrong trip or throws. Since item.id is now available, look up by ID instead:

const renderItem = useCallback(
  (info: { item: { id: string; title: string; subTitle: string }; index: number }) => {
    const trip = upcomingTrips.find((t) => t.id === info.item.id);  // ← stable
    assertDefined(trip);
    // ...
  },
  [upcomingTrips, selectedTrip, t],
);

🟡 Issue 2 — upcomingTrips and data not memoized

upcomingTrips is recomputed on every render:

const upcomingTrips = trips.filter(
  (t) => !!t.startDate && new Date(t.startDate).getTime() > Date.now(),
);

This produces a new array reference each render, causing useCallback to invalidate and useEffect to re-fire more than necessary. The data map also creates a new array every render. Both should be wrapped in useMemo:

const upcomingTrips = useMemo(
  () => trips.filter((t) => !!t.startDate && new Date(t.startDate).getTime() > Date.now()),
  [trips],
);

const listData = useMemo(
  () =>
    upcomingTrips.map((trip) => ({
      id: trip.id,
      title: trip.name,
      subTitle: `${trip.location?.name ?? t('trips.unknown')}${formatDate(trip.startDate)} to ${formatDate(trip.endDate)}`,
    })),
  [upcomingTrips, t],
);

🟡 Issue 3 — Hardcoded untranslated strings

Several strings in the Trip Summary section bypass the translation system:

{selectedTrip.location?.name ?? 'No location'}   // ← hardcoded
<Text>DATES</Text>                                 // ← hardcoded
<Text>PACK</Text>                                  //  hardcoded
{selectedPack ? `${selectedPack.items.length} items` : 'No pack assigned'}  // ← hardcoded

These should be replaced with t('...') calls (with corresponding keys added to the i18n files), or at minimum be extracted to constants so they're easy to find and fix later.


🟡 Issue 4 — Stale selectedTrip not cleared when it leaves upcomingTrips

If a selectedTrip passes its startDate (i.e., it's no longer "upcoming"), it won't be removed from state. The current useEffect only sets an initial trip; it never clears or re-validates selectedTrip against the current upcomingTrips list:

useEffect(() => {
  if (!selectedTrip && upcomingTrips.length > 0) {   // only handles undefined case
    setSelectedTrip(upcomingTrips[0]);
  }
}, [upcomingTrips, selectedTrip]);

Consider also handling the invalidation case:

useEffect(() => {
  if (upcomingTrips.length === 0) return;
  if (!selectedTrip || !upcomingTrips.some((t) => t.id === selectedTrip.id)) {
    setSelectedTrip(upcomingTrips[0]);
  }
}, [upcomingTrips, selectedTrip]);

✅ What's done well

  • extraData={selectedTrip?.id} — minimal and correct signal to SectionList.
  • Moving renderItem above the early return correctly satisfies Rules of Hooks.
  • useCallback dependency array [upcomingTrips, selectedTrip, t] is accurate.
  • The pattern is consistent with conversations.tsx.

🧠 Learnings used
Learnt from: CR
Repo: PackRat-AI/PackRat PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-01T10:08:42.658Z
Learning: Applies to apps/expo/app/**/*.{ts,tsx} : When modifying the mobile app, update apps/expo/app/ for screen changes using Expo Router
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai
Copy link

coderabbitai bot commented Mar 9, 2026

📝 Walkthrough

Walkthrough

The upcoming trips list component was refactored to fix iOS-specific selection issues by extracting rendering logic into a memoized callback using useCallback, switching from index-based to ID-based key extraction, and adding extraData to trigger rerenders on selection state changes.

Changes

Cohort / File(s) Summary
List Rendering Optimization
apps/expo/app/(app)/upcoming-trips.tsx
Extracted per-item rendering into a memoized renderItem callback with dependencies on upcomingTrips, selectedTrip, and t. Added ID-based keyExtractor and extraData={selectedTrip?.id} to ensure list updates when selection changes.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 A trips list hopped and skipped on iOS shores,
Selection stuck like doors that wouldn't open more,
But now with memoized renders and IDs so true,
Each tap finds its way, the selection breaks through! 🎉

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main fix: resolving iOS-specific selection state update issues in the Upcoming Trips screen.
Linked Issues check ✅ Passed The PR directly addresses the core iOS selection re-render bug described in issue #1851 with three targeted fixes: extraData prop, stable id-based keyExtractor, and memoized renderItem.
Out of Scope Changes check ✅ Passed All code changes are scoped to fixing the iOS selection re-render issue; no unrelated modifications detected in the changeset.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch copilot/fix-upcoming-trips-selection-ios

Comment @coderabbitai help to get the list of available commands and usage tips.

@andrew-bierman
Copy link
Collaborator

@copilot CodeRabbit has reviewed this PR and provided feedback. Please address the review comments and push updates. Once you've made the changes, the monitor will ask CodeRabbit to re-review.

@andrew-bierman
Copy link
Collaborator

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Mar 9, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (4)
apps/expo/app/(app)/upcoming-trips.tsx (4)

122-146: Index-based lookup risks desynchronization with list data.

The renderItem callback retrieves the trip via upcomingTrips[info.index], but the List data is mapped separately with its own id field. If the arrays ever diverge (e.g., due to a race condition or future refactoring), the selected trip could mismatch the displayed item.

Since info.item.id is already available, look up the trip by ID for a more robust approach:

♻️ Proposed fix
  const renderItem = useCallback(
    (info: { item: { id: string; title: string; subTitle: string }; index: number }) => {
-     const trip = upcomingTrips[info.index];
-     assertDefined(trip);
+     const trip = upcomingTrips.find((t) => t.id === info.item.id);
+     if (!trip) return null;

      const { status, completion } = getTripStatus(trip, t);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/expo/app/`(app)/upcoming-trips.tsx around lines 122 - 146, renderItem
currently fetches the trip by index (upcomingTrips[info.index]), which can
desynchronize from the list item; instead locate the trip by id using
info.item.id to ensure the rendered data matches selection. Update renderItem to
find the trip from upcomingTrips by matching trip.id === info.item.id (still
call getTripStatus(trip, t), setSelectedTrip(trip), and compare selectedTrip?.id
to trip.id) so lookups are ID-based and robust against reordering or divergence.

110-112: Memoize upcomingTrips to stabilize the useCallback dependency.

upcomingTrips is recomputed on every render, which invalidates the useCallback memoization even when trips hasn't changed. Wrap it in useMemo to preserve referential stability:

♻️ Proposed fix
+ import { useCallback, useEffect, useMemo, useState } from 'react';
- import { useCallback, useEffect, useState } from 'react';
- const upcomingTrips = trips.filter(
-   (t) => !!t.startDate && new Date(t.startDate).getTime() > Date.now(),
- );
+ const upcomingTrips = useMemo(
+   () => trips.filter((t) => !!t.startDate && new Date(t.startDate).getTime() > Date.now()),
+   [trips],
+ );

Also applies to: 122-146

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/expo/app/`(app)/upcoming-trips.tsx around lines 110 - 112, upcomingTrips
is being recomputed on every render which breaks referential stability for the
dependent useCallback; wrap the filter into a useMemo that depends on trips
(e.g. const upcomingTrips = useMemo(() => trips.filter(...), [trips])) so
upcomingTrips identity is stable and the callback memoization remains effective;
apply the same useMemo pattern to the other similar filtered arrays referenced
by the useCallback in the same component.

190-208: Hardcoded strings bypass translations.

Several user-facing strings in the Trip Summary section are not internationalized:

  • Line 190: 'No location'
  • Line 197: 'DATES'
  • Line 205: 'PACK'
  • Line 208: 'No pack assigned'

Consider replacing these with t('...') calls for consistency with the rest of the component.

🌐 Proposed fix
              <Text variant="subhead" className="mt-1 text-muted-foreground">
-               {selectedTrip.location?.name ?? 'No location'}
+               {selectedTrip.location?.name ?? t('trips.noLocation')}
              </Text>
                <Text variant="footnote" className="text-muted-foreground">
-                 DATES
+                 {t('trips.dates')}
                </Text>
                <Text variant="footnote" className="text-muted-foreground">
-                 PACK
+                 {t('trips.pack')}
                </Text>
                <Text variant="subhead" className="mt-1">
-                 {selectedPack ? `${selectedPack.items.length} items` : 'No pack assigned'}
+                 {selectedPack ? t('trips.itemCount', { count: selectedPack.items.length }) : t('trips.noPackAssigned')}
                </Text>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/expo/app/`(app)/upcoming-trips.tsx around lines 190 - 208, Replace
hardcoded UI strings in the Trip Summary with translation calls: wrap 'No
location', 'DATES', 'PACK', and 'No pack assigned' with the i18n function (e.g.,
t('No location'), t('DATES'), t('PACK'), t('No pack assigned')) where these
appear in the JSX that renders selectedTrip.location, the DATES label and range
(uses formatDate), and the PACK label and selectedPack fallback; ensure the
translation function (t) is imported/available in this file and use the same
keys/style as other strings in this component.

116-120: Consider invalidating selectedTrip when it leaves upcomingTrips.

The current effect only sets an initial selection. If the selected trip's start date passes while the screen is open, selectedTrip becomes stale (no longer in upcomingTrips). Consider re-validating:

♻️ Proposed fix
  useEffect(() => {
-   if (!selectedTrip && upcomingTrips.length > 0) {
+   const isSelectedValid = selectedTrip && upcomingTrips.some((t) => t.id === selectedTrip.id);
+   if (!isSelectedValid && upcomingTrips.length > 0) {
      setSelectedTrip(upcomingTrips[0]);
    }
  }, [upcomingTrips, selectedTrip]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/expo/app/`(app)/upcoming-trips.tsx around lines 116 - 120, The effect
that currently only initializes selectedTrip should also invalidate it when it
no longer appears in upcomingTrips: inside the useEffect that watches
upcomingTrips and selectedTrip (the one calling setSelectedTrip), add a branch
that if selectedTrip is non-null and not found in upcomingTrips (compare by the
trip id or unique key), then call setSelectedTrip(upcomingTrips.length ?
upcomingTrips[0] : null) so the selection is updated to the next available trip
or cleared; keep the existing initialization behavior for when selectedTrip is
null and upcomingTrips has items.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@apps/expo/app/`(app)/upcoming-trips.tsx:
- Around line 122-146: renderItem currently fetches the trip by index
(upcomingTrips[info.index]), which can desynchronize from the list item; instead
locate the trip by id using info.item.id to ensure the rendered data matches
selection. Update renderItem to find the trip from upcomingTrips by matching
trip.id === info.item.id (still call getTripStatus(trip, t),
setSelectedTrip(trip), and compare selectedTrip?.id to trip.id) so lookups are
ID-based and robust against reordering or divergence.
- Around line 110-112: upcomingTrips is being recomputed on every render which
breaks referential stability for the dependent useCallback; wrap the filter into
a useMemo that depends on trips (e.g. const upcomingTrips = useMemo(() =>
trips.filter(...), [trips])) so upcomingTrips identity is stable and the
callback memoization remains effective; apply the same useMemo pattern to the
other similar filtered arrays referenced by the useCallback in the same
component.
- Around line 190-208: Replace hardcoded UI strings in the Trip Summary with
translation calls: wrap 'No location', 'DATES', 'PACK', and 'No pack assigned'
with the i18n function (e.g., t('No location'), t('DATES'), t('PACK'), t('No
pack assigned')) where these appear in the JSX that renders
selectedTrip.location, the DATES label and range (uses formatDate), and the PACK
label and selectedPack fallback; ensure the translation function (t) is
imported/available in this file and use the same keys/style as other strings in
this component.
- Around line 116-120: The effect that currently only initializes selectedTrip
should also invalidate it when it no longer appears in upcomingTrips: inside the
useEffect that watches upcomingTrips and selectedTrip (the one calling
setSelectedTrip), add a branch that if selectedTrip is non-null and not found in
upcomingTrips (compare by the trip id or unique key), then call
setSelectedTrip(upcomingTrips.length ? upcomingTrips[0] : null) so the selection
is updated to the next available trip or cleared; keep the existing
initialization behavior for when selectedTrip is null and upcomingTrips has
items.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 86c4bbb2-af58-4feb-bdc2-6560add62eb8

📥 Commits

Reviewing files that changed from the base of the PR and between 3f94763 and ad217f9.

📒 Files selected for processing (1)
  • apps/expo/app/(app)/upcoming-trips.tsx

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

iOS – Upcoming Trips Screen Fails to Update Selection

2 participants