From de9b0db2028e9daebc06dc49e59f96df51a53ae4 Mon Sep 17 00:00:00 2001 From: Oren Geva Date: Wed, 8 Apr 2026 19:17:52 +0300 Subject: [PATCH 1/9] perf: move role/image resolution into PilotMarkerItem, memoize filter+map --- app/components/vatsimMapView/PilotMarkers.jsx | 66 +++++++++++-------- 1 file changed, 40 insertions(+), 26 deletions(-) diff --git a/app/components/vatsimMapView/PilotMarkers.jsx b/app/components/vatsimMapView/PilotMarkers.jsx index 051649c..15fd25c 100644 --- a/app/components/vatsimMapView/PilotMarkers.jsx +++ b/app/components/vatsimMapView/PilotMarkers.jsx @@ -1,11 +1,11 @@ import {Marker} from 'react-native-maps'; import {Image, Platform} from 'react-native'; -import React, {useCallback, useRef, useEffect} from 'react'; +import React, {useCallback, useRef, useEffect, useMemo} from 'react'; import {useDispatch, useSelector} from 'react-redux'; import allActions from '../../redux/actions'; import {markNewSelection} from '../detailPanel/DetailPanelProvider'; import {mapIcons, getPilotMarkerRole} from '../../common/iconsHelper'; -import {getMarkerImage, getCacheVersion} from '../../common/aircraftIconService'; +import {getMarkerImage} from '../../common/aircraftIconService'; import {getZoomBand, GROUND_SPEED_THRESHOLD} from '../../common/consts'; const isAndroid = Platform.OS === 'android'; @@ -19,16 +19,31 @@ const coordKey = isAndroid ? (lat, lng) => `${Math.round(lat * 100)}_${Math.round(lng * 100)}` : () => ''; +// Equality: skip re-render if position, heading, cid, aircraft type, and role +// inputs haven't changed. Role and image are now resolved inside the item, so +// we compare the inputs that drive them (cid, myCid, friendCids, aircraftType). export const pilotMarkerItemPropsEqual = (prev, next) => prev.pilot.key === next.pilot.key && prev.pilot.latitude === next.pilot.latitude && prev.pilot.longitude === next.pilot.longitude && prev.pilot.heading === next.pilot.heading && - prev.pilotImage === next.pilotImage && - prev.pilotRole === next.pilotRole && + prev.pilot.cid === next.pilot.cid && + prev.pilot.flight_plan?.aircraft === next.pilot.flight_plan?.aircraft && + prev.myCid === next.myCid && + prev.friendCids === next.friendCids && + prev.iconCacheVersion === next.iconCacheVersion && prev.onPress === next.onPress; -const PilotMarkerItem = React.memo(({pilot, pilotImage, pilotImageSize, onPress, pilotRole: _pilotRole}) => { +const defaultImageSize = isAndroid ? 64 : 32; + +// Role and image are resolved here, inside the memo boundary. +// They only run when pilotMarkerItemPropsEqual returns false. +const PilotMarkerItem = React.memo(({pilot, myCid, friendCids, iconCacheVersion: _v, onPress}) => { + const role = getPilotMarkerRole(pilot, myCid, friendCids); + const entry = getMarkerImage(pilot.flight_plan?.aircraft || null, role); + const pilotImage = entry ? entry.image : (pilot.image || mapIcons.B737); + const pilotImageSize = entry ? entry.sizeDp : (pilot.image ? pilot.imageSize : defaultImageSize); + return isAndroid ? ( state.app.selectedClient); const pilots = useSelector(state => state.vatsimLiveData.clients.pilots); const myCid = useSelector(state => state.app.myCid); const friendCids = useSelector(state => state.app.friendCids); // Re-render when the icon cache is rebuilt (theme change) so role colors update - useSelector(state => state.app.iconCacheVersion); + const iconCacheVersion = useSelector(state => state.app.iconCacheVersion); const dispatch = useDispatch(); const selectedClientRef = useRef(selectedClient); useEffect(() => { selectedClientRef.current = selectedClient; }, [selectedClient]); + const onPress = useCallback((pilot) => { - if(selectedClientRef.current && pilot.callsign == selectedClientRef.current.callsign) { + if (selectedClientRef.current && pilot.callsign === selectedClientRef.current.callsign) { dispatch(allActions.appActions.clientSelected(null)); } else { markNewSelection(); @@ -85,37 +99,37 @@ const PilotMarkers = React.memo(function PilotMarkers({zoomLevel}) { const zoomBand = getZoomBand(zoomLevel); - return pilots - .filter(pilot => { + const markers = useMemo(() => { + const filtered = pilots.filter(pilot => { const groundspeed = Number(pilot.groundspeed); const hasValidGroundspeed = Number.isFinite(groundspeed); - return ( zoomBand === 'airport' || pilot.callsign === selectedClient?.callsign || !hasValidGroundspeed || groundspeed > GROUND_SPEED_THRESHOLD ); - }) - .map(pilot => { - const role = getPilotMarkerRole(pilot, myCid, friendCids); - const entry = getMarkerImage(pilot.flight_plan?.aircraft || null, role); - const pilotImage = entry ? entry.image : (pilot.image || mapIcons.B737); - const pilotImageSize = entry ? entry.sizeDp : (pilot.image ? pilot.imageSize : defaultImageSize); + }); + return filtered.map(pilot => { const markerKey = isAndroid ? `${pilot.key}_${coordKey(pilot.latitude, pilot.longitude)}` : pilot.key; - return ; + return ( + + ); }); + }, [pilots, zoomBand, selectedClient?.callsign, myCid, friendCids, iconCacheVersion, onPress]); + + return markers; }); export default PilotMarkers; From 6c5aa823620f35bbbe3e17754bc802395468d3a8 Mon Sep 17 00:00:00 2001 From: Oren Geva Date: Wed, 8 Apr 2026 19:20:42 +0300 Subject: [PATCH 2/9] perf: memoize AirportMarkers loop to skip recompute when inputs unchanged --- .../vatsimMapView/AirportMarkers.jsx | 272 +++++++++--------- 1 file changed, 139 insertions(+), 133 deletions(-) diff --git a/app/components/vatsimMapView/AirportMarkers.jsx b/app/components/vatsimMapView/AirportMarkers.jsx index 7c37187..fdfc3b9 100644 --- a/app/components/vatsimMapView/AirportMarkers.jsx +++ b/app/components/vatsimMapView/AirportMarkers.jsx @@ -1,6 +1,6 @@ import {Circle, Marker, Polygon} from 'react-native-maps'; import {Image, Platform, StyleSheet} from 'react-native'; -import React, {useCallback, useRef} from 'react'; +import React, {useCallback, useMemo, useRef} from 'react'; import {useDispatch, useSelector} from 'react-redux'; import allActions from '../../redux/actions'; import {APP, APP_RADIUS} from '../../common/consts'; @@ -67,74 +67,113 @@ const AirportMarkers = React.memo(function AirportMarkers({visible = true, zoomL const traconPolygonCacheRef = useRef(new Map()); const appCircleCacheRef = useRef(new Map()); const staleTallyRef = useRef(new Map()); - const airportMarkers = []; - const visibleTraconKeys = new Set(); - const visibleCircleKeys = new Set(); const onPress = useCallback((airport) => { markNewSelection(); dispatch(allActions.appActions.clientSelected(airport)); }, [dispatch]); - const renderedTracons = new Set(); - const zoomBand = getZoomBand(zoomLevel); - const renderedStaffedIcaos = new Set(); + // eslint-disable-next-line react-hooks/exhaustive-deps + const markers = useMemo(() => { + const airportMarkers = []; + const visibleTraconKeys = new Set(); + const visibleCircleKeys = new Set(); - for (const icao in airportAtc) { - const airport = getAirportByCode(icao, airports); + const renderedTracons = new Set(); + const zoomBand = getZoomBand(zoomLevel); + const renderedStaffedIcaos = new Set(); - if (airport != null && airportAtc[airport.icao] && airportAtc[airport.icao].length > 0) { - airportAtc[airport.icao].forEach(atc => { - if (atc.facility === APP) { - const callsignPrefix = atc.callsign.split('_')[0]; - const callsignSuffix = atc.callsign.split('_').pop(); - const tracon = lookupTracon(traconBoundaryLookup, callsignPrefix, callsignSuffix); - if (tracon) { - const traconKey = tracon.id; - if (!renderedTracons.has(traconKey)) { - renderedTracons.add(traconKey); - tracon.polygons.forEach((poly, i) => { - const overlayKey = `${traconKey}-polygon-${i}`; - traconPolygonCacheRef.current.set(overlayKey, { - coordinates: poly.coordinates, - holes: poly.holes, - airport, + for (const icao in airportAtc) { + const airport = getAirportByCode(icao, airports); + + if (airport != null && airportAtc[airport.icao] && airportAtc[airport.icao].length > 0) { + airportAtc[airport.icao].forEach(atc => { + if (atc.facility === APP) { + const callsignPrefix = atc.callsign.split('_')[0]; + const callsignSuffix = atc.callsign.split('_').pop(); + const tracon = lookupTracon(traconBoundaryLookup, callsignPrefix, callsignSuffix); + if (tracon) { + const traconKey = tracon.id; + if (!renderedTracons.has(traconKey)) { + renderedTracons.add(traconKey); + tracon.polygons.forEach((poly, i) => { + const overlayKey = `${traconKey}-polygon-${i}`; + traconPolygonCacheRef.current.set(overlayKey, { + coordinates: poly.coordinates, + holes: poly.holes, + airport, + }); + visibleTraconKeys.add(overlayKey); }); - visibleTraconKeys.add(overlayKey); + } + } else { + const circleKey = `${atc.callsign}-app-circle`; + appCircleCacheRef.current.set(circleKey, { + center: {latitude: atc.latitude, longitude: atc.longitude}, + title: atc.callsign, }); + visibleCircleKeys.add(circleKey); } + } + }); + + renderedStaffedIcaos.add(airport.icao); + + if (visible) { + const traffic = trafficCounts ? trafficCounts[airport.icao] : null; + const useViewMarker = zoomBand === 'continental' || zoomBand === 'regional' || zoomBand === 'local' || zoomBand === 'airport'; + if (useViewMarker) { + airportMarkers.push( + + ); } else { - const circleKey = `${atc.callsign}-app-circle`; - appCircleCacheRef.current.set(circleKey, { - center: {latitude: atc.latitude, longitude: atc.longitude}, - title: atc.callsign, - }); - visibleCircleKeys.add(circleKey); + const markerImage = getStaffedMarkerImage(airport.icao, zoomBand, activeTheme, null); + airportMarkers.push( + + ); } } - }); + } + } - renderedStaffedIcaos.add(airport.icao); + // Render unstaffed airports with traffic (hidden at global zoom) + if (visible && zoomBand !== 'global' && trafficCounts) { + const useViewMarker = zoomBand === 'continental' || zoomBand === 'regional' || zoomBand === 'local' || zoomBand === 'airport'; + for (const icao in trafficCounts) { + if (renderedStaffedIcaos.has(icao)) continue; + const airport = getAirportByCode(icao, airports); + if (!airport) continue; + const traffic = trafficCounts[icao]; + if (!traffic || (traffic.departures === 0 && traffic.arrivals === 0)) continue; - if (visible) { - const traffic = trafficCounts ? trafficCounts[airport.icao] : null; - const useViewMarker = zoomBand === 'continental' || zoomBand === 'regional' || zoomBand === 'local' || zoomBand === 'airport'; if (useViewMarker) { airportMarkers.push( ); } else { - const markerImage = getStaffedMarkerImage(airport.icao, zoomBand, activeTheme, null); + const markerImage = getTrafficMarkerImage(icao, traffic.departures, traffic.arrivals, zoomBand, activeTheme); airportMarkers.push( - ); - } else { - const markerImage = getTrafficMarkerImage(icao, traffic.departures, traffic.arrivals, zoomBand, activeTheme); - airportMarkers.push( - - ); - } - } - } - // Render cached TRACON polygons, evict stale ones - traconPolygonCacheRef.current.forEach((overlay, overlayKey) => { - if (visibleTraconKeys.has(overlayKey)) { - staleTallyRef.current.delete(overlayKey); - airportMarkers.push( - onPress(overlay.airport)} - /> - ); - } else { - const tally = (staleTallyRef.current.get(overlayKey) || 0) + 1; - if (tally > STALE_EVICT_THRESHOLD) { - traconPolygonCacheRef.current.delete(overlayKey); + // Render cached TRACON polygons, evict stale ones + traconPolygonCacheRef.current.forEach((overlay, overlayKey) => { + if (visibleTraconKeys.has(overlayKey)) { staleTallyRef.current.delete(overlayKey); - } else { - staleTallyRef.current.set(overlayKey, tally); airportMarkers.push( onPress(overlay.airport)} /> ); + } else { + const tally = (staleTallyRef.current.get(overlayKey) || 0) + 1; + if (tally > STALE_EVICT_THRESHOLD) { + traconPolygonCacheRef.current.delete(overlayKey); + staleTallyRef.current.delete(overlayKey); + } else { + staleTallyRef.current.set(overlayKey, tally); + airportMarkers.push( + + ); + } } - } - }); + }); - // Render cached APP circles, evict stale ones - appCircleCacheRef.current.forEach((circle, circleKey) => { - if (visibleCircleKeys.has(circleKey)) { - staleTallyRef.current.delete(circleKey); - airportMarkers.push( - - ); - } else { - const tally = (staleTallyRef.current.get(circleKey) || 0) + 1; - if (tally > STALE_EVICT_THRESHOLD) { - appCircleCacheRef.current.delete(circleKey); + // Render cached APP circles, evict stale ones + appCircleCacheRef.current.forEach((circle, circleKey) => { + if (visibleCircleKeys.has(circleKey)) { staleTallyRef.current.delete(circleKey); - } else { - staleTallyRef.current.set(circleKey, tally); airportMarkers.push( ); + } else { + const tally = (staleTallyRef.current.get(circleKey) || 0) + 1; + if (tally > STALE_EVICT_THRESHOLD) { + appCircleCacheRef.current.delete(circleKey); + staleTallyRef.current.delete(circleKey); + } else { + staleTallyRef.current.set(circleKey, tally); + airportMarkers.push( + + ); + } } - } - }); + }); + + return airportMarkers; + }, [airportAtc, airports, traconBoundaryLookup, trafficCounts, zoomLevel, visible, activeTheme, onPress]); - return <>{airportMarkers}; + return <>{markers}; }); export default AirportMarkers; From 5cd8a5efa8f2ed63f6a5d9d10936e333fea0881c Mon Sep 17 00:00:00 2001 From: Oren Geva Date: Wed, 8 Apr 2026 19:21:39 +0300 Subject: [PATCH 3/9] fix: move eslint-disable comment to immediately precede useMemo dep array --- app/components/vatsimMapView/AirportMarkers.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/vatsimMapView/AirportMarkers.jsx b/app/components/vatsimMapView/AirportMarkers.jsx index fdfc3b9..20501e3 100644 --- a/app/components/vatsimMapView/AirportMarkers.jsx +++ b/app/components/vatsimMapView/AirportMarkers.jsx @@ -73,7 +73,6 @@ const AirportMarkers = React.memo(function AirportMarkers({visible = true, zoomL dispatch(allActions.appActions.clientSelected(airport)); }, [dispatch]); - // eslint-disable-next-line react-hooks/exhaustive-deps const markers = useMemo(() => { const airportMarkers = []; const visibleTraconKeys = new Set(); @@ -261,6 +260,7 @@ const AirportMarkers = React.memo(function AirportMarkers({visible = true, zoomL }); return airportMarkers; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [airportAtc, airports, traconBoundaryLookup, trafficCounts, zoomLevel, visible, activeTheme, onPress]); return <>{markers}; From 82a482410ea77108952844b9d6bb854f96c45bc5 Mon Sep 17 00:00:00 2001 From: Oren Geva Date: Wed, 8 Apr 2026 19:23:18 +0300 Subject: [PATCH 4/9] perf: add custom equality to LocalAirportMarker to skip renders on same atcList --- .../vatsimMapView/LocalAirportMarker.jsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/app/components/vatsimMapView/LocalAirportMarker.jsx b/app/components/vatsimMapView/LocalAirportMarker.jsx index ab122f2..35b8248 100644 --- a/app/components/vatsimMapView/LocalAirportMarker.jsx +++ b/app/components/vatsimMapView/LocalAirportMarker.jsx @@ -15,6 +15,23 @@ const DOT_SIZE = 10; const DOT_GAP = 3; const BADGE_TEXT_COLOR = '#FFFFFF'; +const localAirportMarkerEqual = (prev, next) => { + if (prev.airport.icao !== next.airport.icao) return false; + if (prev.trafficInfo?.departures !== next.trafficInfo?.departures) return false; + if (prev.trafficInfo?.arrivals !== next.trafficInfo?.arrivals) return false; + if (prev.activeTheme.atc.airportDot !== next.activeTheme.atc.airportDot) return false; + if (prev.activeTheme.atc.airportDotUnstaffed !== next.activeTheme.atc.airportDotUnstaffed) return false; + if (prev.onPress !== next.onPress) return false; + if ((prev.atcList?.length ?? 0) !== (next.atcList?.length ?? 0)) return false; + if (prev.atcList && next.atcList) { + for (let i = 0; i < prev.atcList.length; i++) { + if (prev.atcList[i].callsign !== next.atcList[i].callsign) return false; + if (prev.atcList[i].facility !== next.atcList[i].facility) return false; + } + } + return true; +}; + const LocalAirportMarker = React.memo(function LocalAirportMarker({ airport, atcList, @@ -123,7 +140,7 @@ const LocalAirportMarker = React.memo(function LocalAirportMarker({ ); -}); +}, localAirportMarkerEqual); export default LocalAirportMarker; From da0c407da70aad1d15f0f164e42188c7a79ac788 Mon Sep 17 00:00:00 2001 From: Oren Geva Date: Wed, 8 Apr 2026 19:23:24 +0300 Subject: [PATCH 5/9] perf: useCallback onPress in CTRPolygons to stabilize native prop reference --- app/components/vatsimMapView/CTRPolygons.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/components/vatsimMapView/CTRPolygons.jsx b/app/components/vatsimMapView/CTRPolygons.jsx index 046f58b..dc81442 100644 --- a/app/components/vatsimMapView/CTRPolygons.jsx +++ b/app/components/vatsimMapView/CTRPolygons.jsx @@ -1,6 +1,6 @@ import {Marker, Polygon} from 'react-native-maps'; import {Text} from 'react-native'; -import React, {useRef} from 'react'; +import React, {useRef, useCallback} from 'react'; import {useTheme} from '../../common/ThemeProvider'; import {EXCLUDED_CALLSIGNS} from '../../common/consts'; import {useDispatch, useSelector} from 'react-redux'; @@ -77,10 +77,10 @@ const CTRPolygons = React.memo(function CTRPolygons({visible = true}) { const polygons = []; const activeKeys = new Set(); - let onPress = (client) => { + const onPress = useCallback((client) => { markNewSelection(); dispatch(allActions.appActions.clientSelected(client)); - }; + }, [dispatch]); const getAirspaceCoordinates = client => { let isOceanic = false; From d9d32bbe0cee3157423f2cbe9e172daa40c7a066 Mon Sep 17 00:00:00 2001 From: Oren Geva Date: Wed, 8 Apr 2026 19:24:44 +0300 Subject: [PATCH 6/9] fix: compare activeTheme.atc reference in LocalAirportMarker equality to cover badge colors --- app/components/vatsimMapView/LocalAirportMarker.jsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/components/vatsimMapView/LocalAirportMarker.jsx b/app/components/vatsimMapView/LocalAirportMarker.jsx index 35b8248..9326fb3 100644 --- a/app/components/vatsimMapView/LocalAirportMarker.jsx +++ b/app/components/vatsimMapView/LocalAirportMarker.jsx @@ -19,8 +19,7 @@ const localAirportMarkerEqual = (prev, next) => { if (prev.airport.icao !== next.airport.icao) return false; if (prev.trafficInfo?.departures !== next.trafficInfo?.departures) return false; if (prev.trafficInfo?.arrivals !== next.trafficInfo?.arrivals) return false; - if (prev.activeTheme.atc.airportDot !== next.activeTheme.atc.airportDot) return false; - if (prev.activeTheme.atc.airportDotUnstaffed !== next.activeTheme.atc.airportDotUnstaffed) return false; + if (prev.activeTheme.atc !== next.activeTheme.atc) return false; if (prev.onPress !== next.onPress) return false; if ((prev.atcList?.length ?? 0) !== (next.atcList?.length ?? 0)) return false; if (prev.atcList && next.atcList) { From 4a9a4f76b64cedc0b3817d40ec3a0965b8b755ac Mon Sep 17 00:00:00 2001 From: Oren Geva Date: Wed, 8 Apr 2026 19:25:19 +0300 Subject: [PATCH 7/9] chore: delete unused ClusteredPilotMarkers dead code --- .../vatsimMapView/ClusteredPilotMarkers.jsx | 194 ------------------ 1 file changed, 194 deletions(-) delete mode 100644 app/components/vatsimMapView/ClusteredPilotMarkers.jsx diff --git a/app/components/vatsimMapView/ClusteredPilotMarkers.jsx b/app/components/vatsimMapView/ClusteredPilotMarkers.jsx deleted file mode 100644 index dc33185..0000000 --- a/app/components/vatsimMapView/ClusteredPilotMarkers.jsx +++ /dev/null @@ -1,194 +0,0 @@ -import React, {useCallback, useMemo} from 'react'; -import {View, Text, Image, StyleSheet, Platform} from 'react-native'; -import { Marker } from 'react-native-maps'; -import { useDispatch, useSelector } from 'react-redux'; -import ClusterMapView from 'react-native-map-clustering'; -import allActions from '../../redux/actions'; -import {mapIcons} from '../../common/iconsHelper'; -import {useTheme} from '../../common/ThemeProvider'; - -const isAndroid = Platform.OS === 'android'; -const defaultImageSize = isAndroid ? 64 : 32; - -// Custom cluster marker component -const ClusterMarker = ({pointCount, coordinate, onPress, clusterBadgeStyle, clusterTextStyle}) => { - const points = pointCount; - - return ( - - - - - {points} - - - - - ); -}; - -// Custom renderer for individual pilot markers -const PilotMarker = ({pilot, onPress}) => { - const pilotImage = pilot.image || mapIcons.B737; - const pilotImageSize = pilot.image ? pilot.imageSize : defaultImageSize; - const pilotImageStyle = useMemo(() => ({ - height: pilotImageSize, - width: pilotImageSize, - transform: [{rotate: `${pilot.heading}deg`}], - }), [pilot.heading, pilotImageSize]); - - return isAndroid ? ( - - ) : ( - - - - ); -}; - -const ClusteredPilotMarkers = () => { - const dispatch = useDispatch(); - const selectedClient = useSelector(state => state.app.selectedClient); - const pilots = useSelector(state => state.vatsimLiveData.clients.pilots); - const {activeTheme} = useTheme(); - const clusterColor = activeTheme.accent.primary + 'CC'; - const clusterBadgeStyle = useMemo(() => ({ - backgroundColor: clusterColor, - borderColor: activeTheme.surface.border, - }), [activeTheme.surface.border, clusterColor]); - const clusterTextStyle = useMemo(() => ({ - color: activeTheme.text.primary, - }), [activeTheme.text.primary]); - - // Convert pilots to GeoJSON features for clustering - const pilotFeatures = useMemo(() => { - return pilots.map(pilot => ({ - type: 'Feature', - properties: { - id: pilot.callsign, - pilot: pilot, - }, - geometry: { - type: 'Point', - coordinates: [pilot.longitude, pilot.latitude], - }, - })); - }, [pilots]); - - const onMarkerPress = useCallback((pilot) => { - if (selectedClient && pilot.callsign === selectedClient.callsign) { - dispatch(allActions.appActions.clientSelected(null)); - } else { - dispatch(allActions.appActions.clientSelected(pilot)); - } - }, [selectedClient, dispatch]); - - // Custom render cluster function - const renderCluster = useCallback((cluster) => { - const { id, geometry, onPress, properties } = cluster; - const points = properties.point_count; - - // For clusters with only one point, render the individual marker - if (points === 1) { - const pilot = properties.cluster - ? properties.cluster[0].properties.pilot - : properties.pilot; - - return ( - onMarkerPress(pilot)} - /> - ); - } - - // For clusters with multiple points, render a cluster marker - return ( - - ); - }, [onMarkerPress, clusterBadgeStyle, clusterTextStyle]); - - if (!pilots.length) return null; - - return ( - { - // Handle cluster press if needed - }} - /> - ); -}; - -const styles = StyleSheet.create({ - mapStyle: { - flex: 1, - }, - clusterContainer: { - width: 40, - height: 40, - alignItems: 'center', - justifyContent: 'center', - }, - clusterBackground: { - width: 30, - height: 30, - borderRadius: 15, - alignItems: 'center', - justifyContent: 'center', - borderWidth: 2, - borderColor: 'white', - }, - clusterText: { - color: 'white', - fontWeight: 'bold', - fontSize: 12, - }, -}); - -export default ClusteredPilotMarkers; From 76d38b8801c41f0eb261c5b7867d32dc571ccc6f Mon Sep 17 00:00:00 2001 From: Oren Geva Date: Wed, 8 Apr 2026 19:29:43 +0300 Subject: [PATCH 8/9] test: update PilotMarkers equality tests to match new prop API (myCid, iconCacheVersion) --- __tests__/PilotMarkers.test.js | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/__tests__/PilotMarkers.test.js b/__tests__/PilotMarkers.test.js index 4a90ffb..9c9077a 100644 --- a/__tests__/PilotMarkers.test.js +++ b/__tests__/PilotMarkers.test.js @@ -12,7 +12,7 @@ import PilotMarkers, {pilotMarkerItemPropsEqual} from '../app/components/vatsimM // Build a minimal Redux store matching the app shape const makeStore = (pilots = [], selectedClient = null, myCid = '', friendCids = []) => { return createStore(() => ({ - app: { selectedClient, myCid, friendCids }, + app: { selectedClient, myCid, friendCids, iconCacheVersion: 1 }, vatsimLiveData: { clients: { pilots }, }, @@ -193,21 +193,31 @@ describe('PilotMarkers role coloring', () => { }); }); -describe('PilotMarkerItem memo with pilotRole', () => { - it('returns false when pilotRole changes', () => { +describe('PilotMarkerItem memo equality', () => { + it('returns false when myCid changes (role input changed)', () => { const pilot = makePilot(); const onPress = jest.fn(); - const base = { pilot, pilotImage: pilot.image, pilotImageSize: pilot.imageSize, onPress, pilotRole: 'other' }; + const base = { pilot, myCid: '', friendCids: [], iconCacheVersion: 1, onPress }; expect(pilotMarkerItemPropsEqual( - { ...base, pilotRole: 'other' }, - { ...base, pilotRole: 'me' } + { ...base, myCid: '' }, + { ...base, myCid: String(pilot.cid) } )).toBe(false); }); - it('returns true when pilotRole is same', () => { + it('returns false when iconCacheVersion changes (theme changed)', () => { const pilot = makePilot(); const onPress = jest.fn(); - const base = { pilot, pilotImage: pilot.image, pilotImageSize: pilot.imageSize, onPress, pilotRole: 'friend' }; + const base = { pilot, myCid: '', friendCids: [], iconCacheVersion: 1, onPress }; + expect(pilotMarkerItemPropsEqual( + { ...base, iconCacheVersion: 1 }, + { ...base, iconCacheVersion: 2 } + )).toBe(false); + }); + + it('returns true when all inputs are identical', () => { + const pilot = makePilot(); + const onPress = jest.fn(); + const base = { pilot, myCid: '', friendCids: [], iconCacheVersion: 1, onPress }; expect(pilotMarkerItemPropsEqual(base, base)).toBe(true); }); }); From 8abca001f697fdcb41ea12347b600c6e5ec5abfc Mon Sep 17 00:00:00 2001 From: Oren Geva Date: Wed, 8 Apr 2026 19:39:17 +0300 Subject: [PATCH 9/9] fix: extract named selector to suppress Redux selector stability warning --- app/components/vatsimMapView/PilotMarkers.jsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/components/vatsimMapView/PilotMarkers.jsx b/app/components/vatsimMapView/PilotMarkers.jsx index 15fd25c..53d7bf5 100644 --- a/app/components/vatsimMapView/PilotMarkers.jsx +++ b/app/components/vatsimMapView/PilotMarkers.jsx @@ -10,6 +10,8 @@ import {getZoomBand, GROUND_SPEED_THRESHOLD} from '../../common/consts'; const isAndroid = Platform.OS === 'android'; +const selectIconCacheVersion = state => state.app.iconCacheVersion; + // ANDROID WORKAROUND: Native Google Maps markers can leave ghost bitmaps at // old positions when react-native-maps updates coordinates with // tracksViewChanges={false}. Including a coarse coordinate hash in the React @@ -80,7 +82,7 @@ const PilotMarkers = React.memo(function PilotMarkers({zoomLevel}) { const myCid = useSelector(state => state.app.myCid); const friendCids = useSelector(state => state.app.friendCids); // Re-render when the icon cache is rebuilt (theme change) so role colors update - const iconCacheVersion = useSelector(state => state.app.iconCacheVersion); + const iconCacheVersion = useSelector(selectIconCacheVersion); const dispatch = useDispatch(); const selectedClientRef = useRef(selectedClient);