Notes on Infinite PDP Track Scrolling with Apollo Client and Shopify Flashlist

Notes on Infinite PDP Track Scrolling with Apollo Client and Shopify Flashlist

Notes on InfiniteTrack scrolling for PDP

Introduction

The following notes are meant to supplement Dragonfly MR-131 which implements infinite scrolling using Shopify’s FlashList (wrapped by a Bauhaus component) and the Apollo client/cache. This builds upon the earlier prototype as noted in: Notes on InfiniteTrack scrolling prototype using FlatList.

These notes contain some additional information the includes:

  • How the code in this MR updates and uses the Apollo client/cache and FlashList

  • How to use Reactotron to examine the cache and confirm that the queries are running correctly

What is the problem being solve?

Playlists can a large number of tracks and we desire to give the customer an experience in which they can just (seemingly) scroll infinitely and smoothly with great fluidity. There are at least two dimensions to achieving good performance: minimizing the payload size that is transferred between client and service, and rendering only what the customer sees. The initial PDP prototype attempted to pull the entire set in a single query, this will provide worse performance as the playlist size grows. Firefly currently avoids such a degradation by truncating the result size to 50 tracks…. but this is masking the problem, not solving it. In fact, one of the demo playlists (id=29018f1f4b764317b360c7bff9520f35sune … or “Athena” playlist has 220 tracks but the early PDP firefly query (as show below) was only retrieving 50 tracks.

query {
  playlist (id:"29018f1f4b764317b360c7bff9520f35sune") {
    id,
    title,
    trackCount,
    tracks{
      edges {
        node {
          id,
          title,
          images{
            width,
            height,
            url
          }

        }
      }
      edgeCount
      }
    }
  }

  WITH THE RESPONSE:

  {
  "data": {
    "playlist": {
      "id": "98f566ad-dd07-48a1-9f4f-d7d738923e1e",
      "title": "Athena",
      "trackCount": 220, <--- 220 TRACKS BUT AS SEEN BELOW ONLY 50 RETURNED
      "tracks": {
        "edges": [
          {
            "node": {
              "id": "B000SH5EGU",
              "title": "Come On! Feel the Illinoise! Part I: The World's Columbian Exposition Part II: Carl Sandburg Visits Me In A Dream",
              "images": [
                {
                  "width": 500,
                  "height": 500,
                  "url": "https://m.media-amazon.com/images/I/610i3A3ZgfL.jpg"
                },
                {
                  "width": 1400,
                  "height": 1400,
                  "url": "https://m.media-amazon.com/images/I/81qDBivALmL.jpg"
                }
              ]
            }
          },
    :
    :
    :
          {
            "node": {
              "id": "B009Y2JQ50",
              "title": "Lift Up Your Heads Ye Mighty Gates",
              "images": [
                {
                  "width": 1425,
                  "height": 1425,
                  "url": "https://m.media-amazon.com/images/I/81xj9dYuNBL.jpg"
                },
                {
                  "width": 500,
                  "height": 500,
                  "url": "https://m.media-amazon.com/images/I/61csZrYepTL.jpg"
                }
              ]
            }
          }
        ],
        "edgeCount": 50 <--- only 50 returned

      }
    }
  },
  "extensions": {}
}

So the goal of Dragonfly MR-131 was to pull in or page in the additional tracks as the user scrolls. The effort included modifications to the query to support paging and state management (for additional tracks and the cursor).

Also, in this we use the Reactotron tool (https://infinite.red/reactotron ) mostly to have a quick way to inspect the firefly request and responses and to get response times for the Firefly interaction. I had some trouble getting this to work until applying the recommended item discussed here.

Limitations of the current implementation

There are a number of limitations in the prototype that include:

  • Fixed Window: The paging window is set artificially to a single size vs. adapting to the customer scrolling behavior, network conditions, and size of playlist.

  • No spinner while loading: There is no spinner created while loading.

In action

The following are some screen shots illustrating the code via Reactotron. The query in has been modified to support a cursor and batch size (see getPlaylistQuery.ts). The initial cursor value is the empty string and the tracksLimit is hard coded to 25.

Fig 1 Also via Reactotron the response can be inspected showing the relay token value of ’24:B000THE2Q2’. Also, notice that the trackCount for the playlist and the edgeCount for the response do not match. That is, there are fewer tracks in the response than in the playlist in total. Fig 2 the token is fed into the cursor parameter for the next call and in this case the response has only 24 tracks and there is no more data.

Fig 3

Extra (or incorrect) metrics record generation

Also, at the time this was implmented one side effect of the track list update was an additional [UI_PAGE_VIEW, PLAYLIST_DETAIL] metric being emitted. This seems undesirable but seems to happen for any update including the playlist follow one. Allthough this has been I mention it to illustrate how you can use the Reactotron to inspect RN-side events. You can also download a summary of the events. Fig 4

The following dump of events illustrates the extra number of times that events are getting emitted. The older events at lower in the display and the order varies slightly with each execution. The main take away is that send metrics was called about 5 times while only scrolling through two windows of a single playlist.

[
    {
        "time": "2023-03-28T21:16:58.757Z",
        "clientId": "9c5bdf8d-ef8f-964b-ac3d-8c7d4a0c1987",
        "payload": {
            "action": "setItem"
        },
        "type": "asyncStorage.mutation"
    },
    {. // 4th metrics record generation for PDP
        "time": "2023-03-28T21:16:58.146Z",
        "clientId": "9c5bdf8d-ef8f-964b-ac3d-8c7d4a0c1987",
        "payload": {
            "name": "Metrics.sendMetrics()",
            "action": {
                "args": [
                    [
                        {
                            "eventType": "UI_PAGE_VIEW",
                            "csSessionId": "000-0000-000000",
                            "loadTimeMilliseconds": 331,
                            "locale": "en_US",
                            "pageType": "PLAYLIST_DETAIL",
                            "userAgent": "",
                            "viewType": "PORTRAIT"
                        }
                    ]
                ],
                "name": "sendMetrics",
                "path": "/Metrics"
            }
        },
        "type": "state.action.complete"
    },
    {
        "time": "2023-03-28T21:16:57.742Z",
        "clientId": "9c5bdf8d-ef8f-964b-ac3d-8c7d4a0c1987",
        "payload": {},
        "type": "api.response". // next pull of playlist tracks for Christmas playlist
    },
    {
        "time": "2023-03-28T21:16:34.740Z",
        "clientId": "9c5bdf8d-ef8f-964b-ac3d-8c7d4a0c1987",
        "payload": {
            "action": "setItem"
        },
        "type": "asyncStorage.mutation"
    },
    {. // 3rd metrics record sent
        "time": "2023-03-28T21:16:33.938Z",
        "clientId": "9c5bdf8d-ef8f-964b-ac3d-8c7d4a0c1987",
        "payload": {
            "name": "Metrics.sendMetrics()",
            "action": {
                "args": [
                    [
                        {
                            "eventType": "UI_PAGE_VIEW",
                            "csSessionId": "000-0000-000000",
                            "loadTimeMilliseconds": 173,
                            "locale": "en_US",
                            "pageType": "PLAYLIST_DETAIL",
                            "userAgent": "",
                            "viewType": "PORTRAIT"
                        }
                    ]
                ],
                "name": "sendMetrics",
                "path": "/Metrics"
            }
        },
        "type": "state.action.complete"
    },
    {
        "time": "2023-03-28T21:16:33.734Z",
        "clientId": "9c5bdf8d-ef8f-964b-ac3d-8c7d4a0c1987",
        "payload": {},
        "type": "api.response". // follow playlist update
    },
    { // 2nd metrics page load of PDP
        "time": "2023-03-28T21:16:33.688Z",
        "clientId": "9c5bdf8d-ef8f-964b-ac3d-8c7d4a0c1987",
        "payload": {
            "name": "Metrics.sendMetrics()",
            "action": {
                "args": [
                    [
                        {
                            "eventType": "UI_PAGE_VIEW",
                            "csSessionId": "000-0000-000000",
                            "loadTimeMilliseconds": 126,
                            "locale": "en_US",
                            "pageType": "PLAYLIST_DETAIL",
                            "userAgent": "",
                            "viewType": "PORTRAIT"
                        }
                    ]
                ],
                "name": "sendMetrics",
                "path": "/Metrics"
            }
        },
        "type": "state.action.complete"
    },
    {
        "time": "2023-03-28T21:16:33.267Z",
        "clientId": "9c5bdf8d-ef8f-964b-ac3d-8c7d4a0c1987",
        "payload": {},
        "type": "api.response" // get of first 25 tracks from Christmas playlist
    },
    {. // page load of PDP
        "time": "2023-03-28T21:16:33.055Z",
        "clientId": "9c5bdf8d-ef8f-964b-ac3d-8c7d4a0c1987",
        "payload": {
            "name": "Metrics.sendMetrics()",
            "action": {
                "args": [
                    [
                        {
                            "eventType": "UI_PAGE_VIEW",
                            "csSessionId": "000-0000-000000",
                            "loadTimeMilliseconds": 145,
                            "locale": "en_US",
                            "pageType": "PLAYLIST_DETAIL",
                            "userAgent": "",
                            "viewType": "PORTRAIT"
                        }
                    ]
                ],
                "name": "sendMetrics",
                "path": "/Metrics"
            }
        },
        "type": "state.action.complete"
    },
    {
        "time": "2023-03-28T21:16:33.054Z",
        "clientId": "9c5bdf8d-ef8f-964b-ac3d-8c7d4a0c1987",
        "payload": {
            "action": "setItem"
        },
        "type": "asyncStorage.mutation"
    },
    {
        "time": "2023-03-28T21:16:33.054Z",
        "clientId": "9c5bdf8d-ef8f-964b-ac3d-8c7d4a0c1987",
        "payload": {},
        "type": "log"
    },
    {
        "time": "2023-03-28T21:16:26.941Z",
        "clientId": "9c5bdf8d-ef8f-964b-ac3d-8c7d4a0c1987",
        "payload": {
            "action": "setItem"
        },
        "type": "asyncStorage.mutation"
    },
    {
        "time": "2023-03-28T21:16:25.910Z",
        "clientId": "9c5bdf8d-ef8f-964b-ac3d-8c7d4a0c1987",
        "payload": {},
        "type": "api.response"
    },
    {
        "time": "2023-03-28T21:16:25.694Z",
        "clientId": "9c5bdf8d-ef8f-964b-ac3d-8c7d4a0c1987",
        "payload": {},
        "type": "api.response"
    },
    {
        "time": "2023-03-28T21:16:25.095Z",
        "clientId": "9c5bdf8d-ef8f-964b-ac3d-8c7d4a0c1987",
        "payload": {},
        "type": "api.response"
    },
    {
        "time": "2023-03-28T21:16:25.045Z",
        "clientId": "9c5bdf8d-ef8f-964b-ac3d-8c7d4a0c1987",
        "payload": {
            "name": "Metrics.sendMetrics()",
            "action": {
                "args": [
                    [
                        {
                            "eventType": "UI_PAGE_VIEW",
                            "csSessionId": "000-0000-000000",
                            "loadTimeMilliseconds": 55,
                            "locale": "en_US",
                            "pageType": "BROWSE_HOME",
                            "userAgent": "",
                            "viewType": "PORTRAIT"
                        }
                    ]
                ],
                "name": "sendMetrics",
                "path": "/Metrics"
            }
        },
        "type": "state.action.complete"
    },
    {
        "time": "2023-03-28T21:16:24.912Z",
        "clientId": "9c5bdf8d-ef8f-964b-ac3d-8c7d4a0c1987",
        "payload": {},
        "type": "api.response"
    },
    {
        "time": "2023-03-28T21:16:24.024Z",
        "clientId": "9c5bdf8d-ef8f-964b-ac3d-8c7d4a0c1987",
        "payload": {
            "action": "setItem"
        },
        "type": "asyncStorage.mutation"
    },
    {
        "time": "2023-03-28T21:16:24.014Z",
        "clientId": "9c5bdf8d-ef8f-964b-ac3d-8c7d4a0c1987",
        "payload": {
            "name": "DeepLinking.setRoute()",
            "action": {
                "args": [
                    null
                ],
                "name": "setRoute",
                "path": "/DeepLinking"
            }
        },
        "type": "state.action.complete"
    },
    {
        "time": "2023-03-28T21:16:24.012Z",
        "clientId": "9c5bdf8d-ef8f-964b-ac3d-8c7d4a0c1987",
        "payload": {
            "name": "ROOT STORE"
        },
        "type": "display"
    },
    {
        "time": "2023-03-28T21:16:24.015Z",
        "clientId": "9c5bdf8d-ef8f-964b-ac3d-8c7d4a0c1987",
        "payload": {
            "name": "@dragonfly/main"
        },
        "type": "client.intro"
    }
]




Apollo cache notes

This MR changed from the previous one on how the track list was managed. Previously, in the first version, we used the useState() (see Notes on InfiniteTrack scrolling prototype using FlatList ) but this version uses the Apollo cache. As such it illustrates one of the more complex use-cases for the Apollo cache in that we don’t just store each response, but rather aggregate the informaion from multiple calls into a single logical object. The Apollo cache is simple to use for simple requests, but batch processing is on the more complex side. The Reactotron tool is useful to dump that cache so you can inspect it further.

The Apollo cache automatically normalizes complex objects, so being able to see how that automatic normalization unfolds is really useful.

As noted in the MR there are actually two playlist objects in the apollo cache for each playlist: one that is the original latest request and the other that has the growing set of tracks. To see this we copy the output from Reactotron and paste it into a file and then format it.

step 1: copy cache from Reactotron:

Fig 5 The original cached request is still in the cache. Notice that the trackCount is 49 but the edgeCount = 25 and the cursor token is for the next window.

"Playlist:B00JEQPLIC": {
        "__typename": "Playlist",
        "id": "B00JEQPLIC",
        "url": "https://music.amazon.com/playlists/B00JEQPLIC?ref=dm_ff_amazonmusic.ios",
        "title": "Christmas Classics",
        "trackCount": 49,
        "duration": 8213,
        "eligibility": {
            "__typename": "Eligibility",
            "playback": {
                "__typename": "PlaybackEligibility",
                "canPlayOnDemand": true,
                "shuffleType": "INLINE"

    :
    :
        "tracks({\"cursor\":\"\",\"limit\":25})": {
            "__typename": "PlaylistTrackConnection",
            "edges": [
                {
                    "__typename": "TrackEdge",    <-- normalized track representation
                    "node": {                          in cache
                        "__ref": "Track:B002T014KY"
                    }
                },
                {
                    "__typename": "TrackEdge",
                    "node": {
                        "__ref": "Track:B00FBO9I9E"
                    }
                },


                        "__typename": "TrackEdge",
                    "node": {
                        "__ref": "Track:B000THE2Q2"
                    }
                }
            ],
            "edgeCount": 25,
            "pageInfo": {
                "__typename": "PageInfo",
                "hasNextPage": true,
                "token": "24:B000THE2Q2"
            }
        }

Once paging starts another object is maintained by the code which looks similar but has an updated track count and notice the same track representation.

  "ROOT_QUERY": {
        "__typename": "Query",
        "playlist({\"id\":\"B0861VPZ6D\"})": {
            "__ref": "Playlist:B0861VPZ6D"
        },
        "playlist({\"id\":\"7d9c1fd709ce4e8dbfa37958cb3898f4sune\"})": {
            "__ref": "Playlist:b8208062-a33c-4f46-b9e3-16c6315564db"
        },
        "playlist({\"id\":\"B00JEQPLIC\"})": {
            "id": "B00JEQPLIC",
            "url": "https://music.amazon.com/playlists/B00JEQPLIC?ref=dm_ff_amazonmusic.ios",
            "title": "Christmas Classics",
            "trackCount": 49,
            "duration": 8213,
            "eligibility": {
                "__typename": "Eligibility",
                "playback": {
                    "__typename": "PlaybackEligibility",
                    "canPlayOnDemand": true,
                    "shuffleType": "INLINE"
                },
                "playbackSubscriptionTiers": [
        :
        :
        :

             "tracks({\"cursor\":\"\",\"limit\":25})": {
                "edges": [
                    {
                        "__typename": "TrackEdge",
                        "node": {
                            "__ref": "Track:B002T014KY"
                        }
                    },
                    {
                        "__typename": "TrackEdge",
                        "node": {
                            "__ref": "Track:B00FBO9I9E"
                        }
                    },
                    {
                        "__typename": "TrackEdge",
                        "node": {
                            "__ref": "Track:B00136NIJK"
                        }
                    },


                      "__typename": "TrackEdge",
                        "node": {
                            "__ref": "Track:B00136LGS0"
                        }
                    }
                ],
                "edgeCount": 49,
                "pageInfo": {
                    "__typename": "PageInfo",
                    "hasNextPage": false,
                    "token": null
                }
            }
        },

I’m not sure the Apollo cache is better than using the useState() mechanism for this use-case just to to its complexity. It did, however, have some useful mocking capabilities that I used with the cache for integration testing.

Final note

We are using the listHeaderComponent mechanism of FlashList because of the issue when embedding a virtual list within a scrollable component. Embedding a virtual list within the scrollable one just fails for FlatList (see error) but for FlashList it leads to the behavior that when the component keeps calling the onEndReached handler continually when the page loads until the cursor is completely consumed (undesirable behavior).