apollographql / apollo-client

:rocket:  A fully-featured, production ready caching GraphQL client for every UI framework and GraphQL server.
https://apollographql.com/client
MIT License
19.38k stars 2.66k forks source link

What kind of Apollo Client Type Policy for infinite scrolling and paged search both? #8468

Open simeonkerkola opened 3 years ago

simeonkerkola commented 3 years ago

I recently updated the apollo client package in my work to use version 3, and some things I can’t quite grasp just yet. Like defining type policy globally per query name. Here’s my issue, at work we have a search query, an offset based one, and it’s used in very different UI contexts. We have views that are table like with lots of paged data, and then we have infinite scroll type of feeds.

I have found that a single type policy for query search does not really quite support this. Here is our type policies:

typePolicies: {
  Query: {
    fields: {
      search: {
        keyArgs: (args, context) => {
          const keyArgs = ['ascendingSort', 'input', 'sortField', 'size'];
          if (context.field?.alias?.value === 'pagedSearch') {
            keyArgs.push('from');
          }
          return keyArgs;
        },
        merge: (existing, incoming, { args }) => {
          const start = args?.from || 0;
          if (!existing?.hits?.length) return incoming;

          const { hits, total, maxScore } = incoming;

          const mergedHits = existing?.hits ? existing?.hits?.slice(0) : [];
          hits.forEach((hit, i) => {
            mergedHits[start + i] = hit;
          });

          return {
            ...existing,
            hits: mergedHits,
            total,
            maxScore,
          };
        },
      },
    },
  },
};

The hacky part here is that we are using field alias:

pagedSearch: search(input: $input from: $from) for search query and pushing offset variable to the keyArgs of paged queries, so user gets fresh results when they change the table’s page.

if (context.field?.alias?.value === 'pagedSearch') { keyArgs.push('from'); } This does get the job done for now, but I want to know how would one implement a single type policy for these type of queries?

I have asked this question on stack overflow a while a go, but no answers yet https://stackoverflow.com/questions/68157953/what-kind-of-apollo-client-type-policy-for-infinite-scrolling-and-paged-results

benjamn commented 3 years ago

@sssmi I appreciate your clever use of a dynamic function for keyArgs, but I suspect you may get further by disabling keyArgs with keyArgs: false, and relying instead on a read function (along with your merge function) to interpret the args dynamically.

Here's some intermediate/advanced documentation about how I/we think about keyArgs: https://www.apollographql.com/docs/react/pagination/key-args/

More specifically, I would guess your input argument is the only one that really belongs in the keyArgs array, because it's convenient to keep data for different input strings completely separate. All the other arguments can be processed by read and merge—for example, your merge function can return/store data in whatever order is most convenient internally, and later the read function can dynamically sort that data according to the given ascendingSort and sortField arguments, providing different views of the same underlying data, without making network requests.

SkinyMonkey commented 3 years ago

By the way : I feel like when an alias is setup on a query, apollo should use it instead of the original query name.

sanderkooger commented 2 years ago

@benjamin, Working on a simulair thing.

We have an interface with sliders (like netflix) and we have the merge part workign using offset pagination in combination with some keyargs ( which are very unintuetive to say the least)

We all our items live in an item table in the DB, they are differentieted by item_type, show_data, Date_added. and we query with offset and limit.

Like described, we have pagination working as in a slider fetches more data when it hits the end. However, if i navigate from tha page and navigate back with cache still there.... it displays all items in one go, as in all of the data that has been fetched before.

Shoudl we use keyargs, or should we build some sort of custom read function.

@onair-lena you might want to track this thread ;)

molinx commented 2 years ago

We have a similar problem. Depending on the viewport, we are using an infinite scroll (with fetchMore) or pages, using the exact same query.

  1. Although not the best solution, @sssmi hack is working for us. We've used a silly directive (@include(if: true)) instead of an alias, to keep the same data shape for both cases.

  2. We've also tried defining a read function as suggested. But, how can we know if we are in the paginated or infinite scroll case?

typePolicies: {
  Query: {
    fields: {
      search: {
        keyArgs: ['query'],
        merge(existing, incoming, { args: { offset }}) {
          // similar to https://github.com/apollographql/apollo-client/blob/5d0c881a4c20c5c48842d9a8d246d26a917eccf9/src/utilities/policies/pagination.ts#L33
        },
        read(existing, { args: { offset, limit }}) {
          // how do we know here if we need to return the whole list or slice it?
        },
      },
    },
  },
};
  1. The best approach we found is with the @connection directive. We've defined these two queries:
    
    query searchPaginated($query: String!, $offset: Int){
    search(
    query: $query
    offset: $offset
    limit: 10
    ) @connection(key: "searchPaginated", filter: ["query", "offset"]) {
    ...Result
    }
    }

query searchInfinite($query: String!, $offset: Int) { search( query: $query offset: $offset limit: 10 ) @connection(key: "searchInfinite", filter: ["query"]) { ...Result } }

and in `typePolicies` only the `merge` function, so the `fetchMore` is working properly:
```ts
typePolicies: {
  Query: {
    fields: {
      search: {
        merge(existing, incoming, { args: { offset }}) {
          // similar to https://github.com/apollographql/apollo-client/blob/5d0c881a4c20c5c48842d9a8d246d26a917eccf9/src/utilities/policies/pagination.ts#L33
        },
      },
    },
  },
};
benjamn commented 2 years ago

I honestly think returning everything from the read function (ignoring offset and limit) is the most flexible option for both infinite scroll and paginated navigation, because then you can use application/UI-level logic to slice the big array into chunks for pagination (in a part of your code where you probably have enough context to make that choice), and you don't have to keep updating the offset and limit of the original query each time you fetch new data, and both queries can consume the same underlying list of data (rather than maintaining separate lists in the cache).

This difference between paginated and non-paginated read functions is discussed here in the docs. I'm specifically recommending the non-paginated approach.


If you're not sold on the one-big-list philosophy, and you want to keep using @connection, keep reading for some additional thoughts…

Since keyArgs is intended to replace @connection, I would hope you can just use keyArgs, but I admit @connection can be useful to inject arbitrary key information, and is conveniently already understood by most GraphQL tooling (unlike other directives or arguments you might make up).

While InMemoryCache does support @connection in the absence of keyArgs, prior to AC3.5, there was a pitfall where providing keyArgs would cause @connection to be ignored (see #8659). Thanks to #8678 (released in v3.5), it's now possible to include information from any directives (or variables) in your keyArgs array:

new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        search: {
          keyArgs: ["query", "@connection", ["key"]],
          merge(...) {...},
        },
      },
    },
  },
})

This configuration will produce field keys like search:{"query":"...","@connection":{"key":"searchInfinite"}}, so the @connection(key) information is handled sort of like an argument passed to the field. You should not need to pass a filter argument to @connection, since that's what the other strings in keyArgs represent.

peirisyasara commented 1 year ago

i tried to use @connection directive as below and it returns me an error saying "Variable "$key" is never used in operation" . Can someone help with this?

query searchPaginated($query: String!, $offset: Int, $key: String = "key"){ search( query: $query offset: $offset limit: 10 ) @connection(key:$key) { ...Result } }

DaltheCow commented 1 year ago

Any resolution to this? Why can't Apollo be updated to give access to the name of the custom query, seems like pretty important functionality for uses cases like what @simeonkerkola is doing. If an Apollo user ever wants to store data in the cache differently for any two custom queries then it more or less seems like that behavior is not supported by Apollo, because the only workable solution is hacking something together with @connection. This is basic functionality.