Apollo Client shows stale data after leaving current screen

Hi there!

I’m facing an issue when implementing Apollo Client on a React Native app.

I’m fetching a list of Listing entities from our server, using offset based pagination (with a limit of 5) to show the user a vertical list that appends new pages to the bottom.

I’ve set the necessary merge and read functions in the query’s type policy. This works fine – results are properly fetched and displayed, and whenever I fetchMore, they are properly merged, 5 by 5, and I can reach the end of the list (in this test case, a total of 12 listings) with no issues.

However, whenever I leave the screen (e.g. by clicking on a listing) and come back, only the first 5 results are displayed. I can see with a console.log() in my React component that immediately after I leave the screen, the number of Listings goes down from 12 to 5. The logs I’ve put in the read and merge functions are NOT accesed when this happens.

When I try to fetchMore, the logs ARE accessed, and I can see that the full 12 listings are returned from both functions; however, the number of Listings in data returned by the useLazyQuery hook remains fixed at only 5.

I’ve read the docs up and down and have not found a solution. I’m at my wit’s end, I do not know how to proceed and am seriously considering replacing Apollo Client with a different solution such as Tanstack Query, but I would much rather prefer not having to do so.

Any help will be appreciated. Thanks!

Hi Nico :wave: welcome to the forum! I’m guessing you’re passing the offset/limit values in variables somewhere, yes? If so, when you navigate away from and back into that component, are the variables values being reset? E.g.

  1. Last state: offset: 10, limit: 5
  2. Navigate away
  3. Return to component with paginated data
  4. New state: offset: 0, limit: 5 (?)

I apologize if these assumptions are not correct - might you be able to share some code snippets so that I can see what you see?

Hi Jeff!

I actually found more info while debugging:

When you tap a list item and go to the individual listing screen, a different query is executed. I found out that, if I remove the field id inside that query, the bug stops happening. I don’t understand why though.

query GetObjectDetails($objectId: String!) {
  object(id: $objectId) {
    product {
      name
    }
    photos {
      id
      deletedDescription
      url
      mainPhoto
      isInternal
    }
    id --> Removing this field stops the bug from happening
    name
    description
    objectStatus {
      id
      description
    }
    guid
  }
}

Both queries handle the same type of entity, the typename in the cache is __ObjectEntity. However, the ones in the list are under the __PaginatedObjectEntities umbrella, I still don’t understand why a single object would overwrite the cache for the list of objects.

I’ve just now fixed the bug by adding this to my type policies:

  ObjectEntity: {
    keyFields: false,
  },

If I instead use or [‘id’] or [‘guid’] or [‘id’, ‘guid’], it still doesn’t work, and I get the same bug as before, even though they are unique values and should be unique identifiers in the cache.

I still don’t understand WHY this works. Could you please elaborate?

I recorded a video before finding out that the second query was responsible for the issue, but it’s still good for you to understand the way in which the cache is misbehaving

Thanks for the info @nico - I watched the video and unfortunately I think I’m still missing some info that might be helpful. Your ObjectInventory component code may be relevant, as might the merge function you reference in the video.

Setting keyFields: false excludes your ObjectEntity objects from normalization, meaning that they won’t be deduplicated and stored as refs in your cache. So your object field will just contain a nested object with values that may be duplicates of values in your objects field.

My hypothesis is that, without keyFields: false, GetObjectDetails is causing a merge to run on entities in your objects field (this is expected behavior), which will then trigger a rerender of components that are watching that objects query. I wonder whether the ObjectInventory is preserving the offset value on rerender? Again, it’s tough to say without being able to inspect the code more closely. I hope that helps! Happy to keep taking a look at things, I’m sure we’ll be able to find a solution.

That’s awesome Jeff, here’s the code for both screens and for the merge funcion. Hope you can find the issue!

ObjectInventory:

const PAGINATION_LIMIT = 5

export const ObjectInventoryScreen: React.FC<RootStackScreenProps<'ObjectInventory'>> = ({ navigation }) => {
  const { strings } = useLocalization()
  const [selectedObjects, setSelectedObjects] = React.useState<ObjectEntity[]>([])
  const { onboardingDismissed: salesFlowOnboardingDismissed } = useSalesFlowOnboarding()
  const { user } = useAuth()

  const [filterSheetVisible, setFilterSheetVisible] = React.useState(false)
  const [selectedStatusFilterIds, setSelectedStatusFilterIds] = React.useState<AllowedObjectStatuses[]>([])

  const toggleStatusFilter = (filterId: AllowedObjectStatuses) => {
    if (selectedStatusFilterIds.includes(filterId)) {
      return setSelectedStatusFilterIds(selectedStatusFilterIds.filter((fid) => fid !== filterId))
    } else {
      return setSelectedStatusFilterIds([...selectedStatusFilterIds, filterId])
    }
  }

  const toggleObject = (object: ObjectEntity) => {
    if (selectedObjects.find((selectedObject) => selectedObject.id === object.id)) {
      return setSelectedObjects((selected) => selected.filter((selectedObject) => selectedObject.id !== object.id))
    } else {
      return setSelectedObjects((selected) => [...selected, object])
    }
  }

  const [
    getInventoryObjects,
    { data: objectsData, loading: objectsLoading, error: objectsError, fetchMore: fetchMoreObjects },
  ] = useGetInventoryObjectsLazyQuery({
    notifyOnNetworkStatusChange: true,
  })

  const actualObjects = objectsData?.objects.items

  useEffect(() => {
    if (user?.realId && !objectsData) {
      getInventoryObjects({
        variables: {
          userId: user.realId,
          filters: {
            status: selectedStatusFilterIds,
          },
          options: {
            limit: PAGINATION_LIMIT,
          },
        },
      })
    }
  }, [user?.realId, selectedStatusFilterIds])

  const sellableSelectedObjects = selectedObjects.filter((obj) => obj.product.eligibleForSale)

  const objectActions = [
    // {
    //   label: 'Devolución',
    //   onPress: () => console.log('Lorem'),
    //   icon: TruckSvg,
    // },
    {
      label: 'Solicitar venta',
      badgeText: sellableSelectedObjects.length,
      disabled: sellableSelectedObjects.length === 0,
      onPress: () => {
        const nextScreen: NavigatorScreenParams<SaleFlowStackParamList> = {
          screen: 'ObjectSummary',
          params: { objectsSetExternally: clone(sellableSelectedObjects) },
        }

        if (!salesFlowOnboardingDismissed) {
          navigation.navigate('SaleFlow', { screen: 'SaleFlowOnboarding', params: { followingScreen: nextScreen } })
        } else navigation.navigate('SaleFlow', nextScreen)
        setSelectedObjects([])
      },
      icon: ShoppingBag,
    },
    // {
    //   label: 'Donar',
    //   onPress: () => console.log('Lorem'),
    //   icon: null,
    // },
    // {
    //   label: 'Poner en alquiler',
    //   onPress: () => console.log('Lorem'),
    //   icon: null,
    // },
  ]

  const showActionBar = !!selectedObjects.length

  return (
    <ViewWithSafeAreaInsets style={[styles.container]}>
      <View style={styles.filterBar}>
        <Button
          icon={
            filtersOnlyOnSheet > 0
              ? () => (
                  <View style={styles.filterBadge}>
                    <Text type='body2' weight='medium' paletteColor='contrast'>
                      {filtersOnlyOnSheet}
                    </Text>
                  </View>
                )
              : 'filter-variant'
          }
          contentStyle={styles.filterButtonContent}
          onPress={() => setFilterSheetVisible(true)}
          style={styles.filterButton}>
          {strings.generic.filter}
        </Button>

        <ScrollView horizontal showsHorizontalScrollIndicator={false}>
          {filtersForMainView.map((filter) => (
            <Chip
              mode='outlined'
              compact
              key={filter.value}
              selected={!!selectedStatusFilterIds.find((sf) => sf === filter.value)}
              onPress={() => toggleStatusFilter(filter.value as AllowedObjectStatuses)}>
              {filter.label}
            </Chip>
          ))}
        </ScrollView>
      </View>
      <View style={styles.topBar}>
        <Text type='body2' weight='medium'>
          {objectsLoading
            ? `${strings.generic.loading}...`
            : actualObjects?.length
            ? strings.ObjectInventoryScreen.objectTotal(actualObjects?.length ?? 0)
            : ''}
        </Text>
      </View>

      <View style={{ flex: 1 }}>
        {objectsLoading && !actualObjects?.length ? (
          <>
            <LoadingLargeObjectCard />
            <LoadingLargeObjectCard />
            <LoadingLargeObjectCard />
            <LoadingLargeObjectCard />
          </>
        ) : null}
        {actualObjects?.length ? (
          <FlatList
            data={actualObjects}
            keyExtractor={(o) => o.id.toString()}
            ListFooterComponent={() => (objectsLoading ? <LoadingLargeObjectCard /> : null)}
            onEndReached={() => {
              const endReached = actualObjects.length >= objectsData!.objects.total
              if (objectsLoading || endReached) return

              fetchMoreObjects({
                variables: {
                  options: {
                    page: getNewPage(actualObjects.length, PAGINATION_LIMIT),
                    limit: PAGINATION_LIMIT,
                  },
                },
              })
            }}
            renderItem={({ item }) => (
              <LargeObjectCard
                object={item}
                disableAnimations
                onPress={() => navigation.navigate({ name: 'ObjectDetails', params: { objectGuid: item.guid } })}
                checked={!!selectedObjects.find((sf) => sf.id === item.id)}
                onCheck={() => toggleObject(item)}
                onLongPress={() => toggleObject(item)}
              />
            )}
          />
        ) : null}
        {!objectsLoading && !actualObjects?.length ? (
          <EmptyState title={strings.ObjectInventoryScreen.emptyState} />
        ) : null}
      </View>
      <ActionSheet actions={objectActions} visible={showActionBar} onClose={() => {}} />
      <FilterSheet
        filterCategories={FILTER_CATEGORIES}
        selectedFilterIds={selectedStatusFilterIds}
        visible={filterSheetVisible}
        onClose={() => setFilterSheetVisible(false)}
        onFilterClear={() => setSelectedStatusFilterIds([])}
        onFilterChange={(item) => toggleStatusFilter(item.value as AllowedObjectStatuses)}
      />
    </ViewWithSafeAreaInsets>
  )
}

const getNewPage = (currentLength: number, limit: number) => Math.ceil(currentLength / limit) + 1

ObjectDetails

export const ObjectDetailsScreen: React.FC<RootStackScreenProps<'ObjectDetails'>> = ({
  route: { params },
  navigation,
}) => {
  const width = Dimensions.get('window').width
  const { strings } = useLocalization()
  const [carouselIndex, setCarouselIndex] = React.useState(0)
  const [fabOpen, setFabOpen] = React.useState(false)
  const { navigate } = useNavigation()
  const isFocused = useIsFocused()
  const { onboardingDismissed: salesFlowOnboardingDismissed } = useSalesFlowOnboarding()
  const utilityStyles = useUtilityStyles()

  const { data, loading, error } = useGetObjectDetailsQuery({
    variables: { objectId: params.objectGuid },
  })

  if (error) return <EmptyState title={strings.ObjectDetailsScreen.error} />

  return (
    <ScrollView style={styles.container}>
      <View style={styles.carouselContainer}>
        {data?.object.photos && data.object.photos.length > 0 ? (
          <>
            <Carousel
              width={width}
              height={width}
              data={data?.object?.photos?.map((photo) => photo.url) ?? []}
              scrollAnimationDuration={200}
              loop={false}
              onSnapToItem={(index) => setCarouselIndex(index)}
              renderItem={({ item }) => (
                <View style={styles.carouselItem}>
                  <ImageWithLoader source={{ uri: item }} style={styles.carouselImage} />
                </View>
              )}
            />

            <View style={styles.carouselBottomInfo}>
              <View style={styles.carouselIndexBadge}>
                <Text style={styles.carouselIndexBadgeText}>{`${carouselIndex + 1}/${
                  data?.object.photos.length
                }`}</Text>
              </View>
              <ObjectStatusBadge statusId={data.object.objectStatus.description as AllowedObjectStatuses} />
            </View>
          </>
        ) : (
          <View style={[styles.noImage, { width, height: width }]}>
            <NoImageSvg />
            <Text type='caption'>{strings.ObjectDetailsScreen.photoEmptyState}</Text>
          </View>
        )}
      </View>

      <View style={styles.objectBasicInfoContainer}>
        <View style={styles.topInfo}>
          <View>
            {data?.object && !loading ? (
              <>
                <Text type='body'>ID: {data.object.id}</Text>
                <Text type='h1' weight='medium' marginTop={12}>
                  {data.object.product.name}
                </Text>
              </>
            ) : (
              <>
                <ContentLoader
                  speed={0.9}
                  width={250}
                  height={60}
                  viewBox='0 0 250 60'
                  backgroundColor='#f3f3f3'
                  foregroundColor='#ecebeb'>
                  <Rect x='0' y='0' rx='0' ry='0' width='80' height='16' key='id' />
                  <Rect x='0' y='28' rx='0' ry='0' width='100' height='28' key='title' />
                </ContentLoader>
              </>
            )}
          </View>
          <Pressable
            style={{ marginTop: 10 }}
            onPress={() => data?.object && navigate({ name: 'ObjectEdit', params: { object: data.object } })}>
            <EditSvg />
          </Pressable>
        </View>

        <View style={{ marginTop: 16 }}>
          {data && !loading ? (
            <>
              <Text type='body' marginBottom={16}>
                {data.object.name}
              </Text>
              <Text type='body2'>{data.object.description}</Text>
              {data.object.product.eligibleForSale && (
                <View style={[utilityStyles.flexRow, { marginTop: 24 }]}>
                  <ObjectPropBadge icon={<SaleBadgeSvg />} text={strings.ObjectDetailsScreen.propBadges.sellable} />
                </View>
              )}
            </>
          ) : (
            <>
              <ContentLoader speed={0.9} width={250} height={57} backgroundColor='#f3f3f3' foregroundColor='#ecebeb'>
                <Rect x='0' y='2' rx='0' ry='0' width='140' height='16' key='name' />
                <Rect x='0' y='38' rx='0' ry='0' width='120' height='14' key='description' />
              </ContentLoader>
            </>
          )}
        </View>
      </View>

      {data?.object?.product.eligibleForSale && !loading ? (
        <FABGroup
          open={fabOpen}
          visible={Boolean(isFocused)}
          handleOpenChange={setFabOpen}
          actions={[
            {
              icon: SaleBagSvg,
              label: 'Vender',
              onPress: () => {
                const nextScreen: NavigatorScreenParams<SaleFlowStackParamList> = {
                  screen: 'ObjectSummary',
                  params: { objectsSetExternally: [data!.object] },
                }

                if (!salesFlowOnboardingDismissed) {
                  navigate('SaleFlow', {
                    screen: 'SaleFlowOnboarding',
                    params: { followingScreen: nextScreen },
                  })
                } else navigate('SaleFlow', nextScreen)
              },
            },
          ]}
        />
      ) : null}
    </ScrollView>
  )
}

typePolicies

const typePolicies: StrictTypedTypePolicies = {
  Query: {
    fields: {
      objects: {
        keyArgs: ['userId', 'filters'],
        merge(existing, incoming, { args }) {
          const page: number = args?.options?.page ?? 1
          const limit: number | undefined = args?.options?.limit

          const existingItems: ObjectEntity[] = existing?.items ?? []
          const incomingItems: ObjectEntity[] = incoming?.items ?? []

          const mergedItems: ObjectEntity[] = existingItems.slice(0)

          if (page && limit) {
            const offset = (page - 1) * limit
            for (let i = 0; i < incomingItems.length; ++i) {
              mergedItems[offset + i] = incomingItems[i]
            }
          } else {
            mergedItems.push(...incomingItems)
          }

          const fullResult = {
            ...incoming,
            items: mergedItems,
          }

          return fullResult
        },
      },
    },
  },
  // ObjectEntity: {
  //   // If I use [] as my keyFields, cache gets corrupted, even though the empty array is supposed to work the same as `false` as per the docs
  //   // If I instead use or ['id'] or ['guid'] or ['id', 'guid'], it still doesn't work, and I get the same error as before, even though they are unique values
  //   keyFields: false,
  // },
}

1 Like

I think you may be right about the offset (page number in this case) not being preserved, but I don’t know what the correct practice would be in this case.

1 Like

Thanks for sharing @nico - I will dig deeper into your code as soon as I can - it’s a busy day though so it may have to wait until tomorrow. I’ll do my best to get to this asap!

1 Like

Awesome, Jeff! Thanks!

Ok thanks for sharing, I’ve explored a few different possibilities but nothing seems conclusively wrong. A few follow-up questions:

  1. In your video there was a console.log call that talked about a read function but I didn’t see that in the code you shared. What’s inside that read function? From the video it doesn’t appear that the cache is the problem but I’d like to rule out the possibility that there’s a buggy read function
  2. Following that train of thought, it seems like the issue exists in the component code somewhere. It might be in your application code or it could be something counterintuitive that Apollo Client is doing. In your video you have logs with the text number of objects in component - where in your code was that being logged and what was the variable you were logging? Was it actualObjects.length?
  3. Can you try dropping a console.log inside the if (user?.realId && !objectsData) { conditional in your useEffect call in ObjectInventory? Is that conditional being reached more frequently than you expect?

Thanks @nico!

1 Like

Hi again Jeff, will be glad to answer your questions:

  1. I’ve removed the read function as I was only using for logging. I was just console.logging the params and returning them.
  2. Indeed it was actualObjects.length
  3. That was my first instinct as well, but I found out that that conditional was only being reached on first mount and never again.

Thanks @nico. Okay I have one more avenue to try. I wonder whether you might be able to console.log the following, maybe after you declare actualObjects:

  const [
    getInventoryObjects,
    { client, data: objectsData, loading: objectsLoading, error: objectsError, fetchMore: fetchMoreObjects },
  ] = useGetInventoryObjectsLazyQuery({
    notifyOnNetworkStatusChange: true,
  })

  const actualObjects = objectsData?.objects.items
  console.log(client.readQuery({
    query: GET_INVENTORY_OBJECTS_QUERY,
    // include all relevant variables, etc.
  }));

If we’re able to get a positive result from that readQuery call then I think we might need to take a look at whether there’s a bug or documentation gap for useLazyQuery that we need to address.