graphql-relay-js icon indicating copy to clipboard operation
graphql-relay-js copied to clipboard

pageInfo.hasPreviousPage is always false if you are paging forwards

Open gallagher-stem opened this issue 8 years ago • 20 comments

https://github.com/graphql/graphql-relay-js/blob/7422cfe8ccd1c2cd3923c7e1d5f8b7533c0e3fdd/src/connection/arrayconnection.js#L120

If my connection args don't have the 'last' parameter, then hasPreviousPage will always be false. Same problem if you are paging backwards and don't have the 'first' parameter in your connection args: hasNextPage will always be false.

But I can't have a 'last' parameter if I am paging forward using 'first' and 'after'. And I can't have a 'first' parameter if I am paging backwards using 'last' and 'before'. Babel-relay-plugin will throw an error on transpile.

So if I am paging forwards, I will always be told I have no previous pages, even when I do. And if I am paging backwards I will always be told I have no next pages, even when I do.

This has gotta be a bug. It kinda ruins bi-directional paging.

Can't we just make paging easier and let us pass first and last and before and after all as connection args (some of them as null depending on which way you are paging) without babel-relay-plugin blowing up?

gallagher-stem avatar Jan 07 '16 01:01 gallagher-stem

I know this seems counter-intuitive to you as it did to me as well, but that is actually how it is supposed to work...

hasPreviousPage is only meaningful when last is included, as it is always false otherwise. hasNextPage is only meaningful when first is included, as it is always false otherwise. When both first and last are included, both of the fields are set according to the above algorithms, but their meaning as it relates to pagination becomes unclear. This is among the reasons that pagination with both first and last is discouraged.

https://facebook.github.io/relay/graphql/connections.htm#sec-undefined.PageInfo.Fields

Sorry I couldn't use markdown in the post I emailed.
I also posted about this, was embarrassing hehe. https://github.com/graphql/graphql-relay-js/issues/55

MattMcFarland avatar Jan 07 '16 01:01 MattMcFarland

Thanks for the response, Matt.

So its the actual Relay spec that has a bug. :(

In any case, it makes no sense, and ruins bi-directional paging.

Since the maintainers of this repo are the same guys maintaining the spec, I'm gonna leave this open, as it really needs to be addressed.

gallagher-stem avatar Jan 07 '16 01:01 gallagher-stem

Yeah, this is good feedback.

We went with this approach because it's easy to envision a backend system (and we had some at Facebook) where it was be prohibitively expensive to compute hasPreviousPage when paginating forward (or vice versa). The cursors that we were using for pagination made it very efficient to skip straight to the item in question, but trying to traverse backwards to see if there was anything before it would have been costly. Hence, we didn't assign meaning to hasPreviousPage, because we had no way to provide an accurate value there.

Your feedback is spot on, though; this definitely messes with bidirectional pagination. One way forward would be to modify the Relay specification and allow, though not require, hasPreviousPage to have the expected meaning. Connections where hasPreviousPage is easy to compute can then include it, where cases where it is expensive can continue returning false. Clients can then enable the bidirectional pagination behavior you're looking to add only if they ever see hasPreviousPage be true when paginating forward.

Thoughts?

dschafer avatar Jan 07 '16 04:01 dschafer

Thanks for the reply, Dan. Loving the Relay.

tl;dr: connectionFromArray should assume that its got an entire dataset, so it can be honest about hasNextPage/hasPreviousPage. connectionFromArraySlice should assume that its got only part of a larger data set, so it cant be honest about hasPrevious page when its going forward (or vice-versa).

At the end of the day, to make good on the Relay goal of making data fetching/caching as easy and opaque as possible (which is an awesome goal), paging just "needs to work". And that means supporting the commonly understood ideas of what paging in apps looks like, which is pretty much either infinite scroll (forward-only) or next/prev (bi-directional) with/without page numbers.

We can check off forward-only because that works great! ;)

I was messing with arrayconnection.js and it was pretty simple to stop taking last/first into account when calculating and comparing the array bounds and the current cursor location. Relay on the client thwarted me, however, because it still overrides the values for hasNextPage/hasPreviousPage on the client based on whether first/last is being used.

I guess I don't understand why connectionFromArray can't give back honest values from hasNextPage/hasPreviousPage. Its assumed that we are passing in the whole list of data as the array. Why not be honest with the hasNextPage/hasPreviousPage?

connectionFromArraySlice can and should be used when we are using forward-only paging on large data-sets. The comments in the code come out and say as much. It kinda looks like we just found an easy way to have connectionFromArray delegate its work to connectionFromArraySlice. And connectionFromArraySlice correctly assumes it cant honestly answer if there is a previous page when going forward on what it has been told is just a slice of a larger array.

Maybe some renaming would make things clearer. connectionFromArray becomes bidirectionalPagingConnection (or entireDataSetConnection or something) and connectionFromArraySlice becomes forwardOnlyPagingConnection (or partialDataSetConnection or something).

And then change connectionArgs validation. In fact, Im not convinced we need both 'first' and 'last' if we can just make some assumptions. Instead of first/last we just have 'count', because thats what both those are: just a count of records to return. Then if we have count without before or after, we assume its going forward from the start (which I think is the 99%+ use case). If we want to start out going backward from the end, you pass a 'before: end' (or before: '' or something). Other than that, before and after cursors work the same, and args validation just changes to not allow both before and after together.

And then change the Relay spec to reflect these changes. ;)

(And then figure out page numbers, which once we decide that connectionFromArray is meant for entire datasets, is an easy prop to add to pageInfo and connectionArgs).

To maybe help out anyone currently wrangling with this same issue, here is a link to a gist that shows how I am hacking together next/prev paging in Relay. It was heavily informed by this fine gentleman's efforts with the same problem.

Anyway, thanks for the listen. I'm having so much fun getting into Relay and I love what it does and what its going to do. Rock on, rockstars!

gallagher-stem avatar Jan 07 '16 19:01 gallagher-stem

I am trying to get hasNextPage with connectionFromPromisedArray but it does not work (returns false). first: 5 i am passing, there are more rows in database. It seems like it will work only with connectionFromArraySlice because graphql-relay will get all data set and slice it. I read spec but still do not understand how it expected to work

artyomtrityak avatar Apr 05 '16 21:04 artyomtrityak

Ugh, this issue is so deeply annoying when having a hard requirement to do windowed pagination due to limited space. Even with a server returning hasPreviousPage as true (because it can actually calculate that), hasPreviousPage gets passed on to components as false. Are there any decent examples of anyone doing a workaround that supports windowed pagination? I could care less about jumping to pages, but accurate previous & next links is vital.

bruce avatar Apr 18 '16 18:04 bruce

The pageType and hasNextPage/hasPrevPage is declared in connection.js, but the logic appears to be in arrayconnection.js https://github.com/graphql/graphql-relay-js/blob/997e06993ed04bfc38ef4809a645d12c27c321b8/src/connection/arrayconnection.js#L66-L126

This code here looks suspect(startOffset, endOffset, lowerBound, and upperBound):

  return {
    edges,
    pageInfo: {
      startCursor: firstEdge ? firstEdge.cursor : null,
      endCursor: lastEdge ? lastEdge.cursor : null,
      hasPreviousPage:
        typeof last === 'number' ? startOffset > lowerBound : false,
      hasNextPage:
        typeof first === 'number' ? endOffset < upperBound : false,
    },
  };

Specifically , the startOffset and endOffset are not set unless you use "first" or "last": The source code below is found in the same file, slightly above.

  if (typeof first === 'number') {
    endOffset = Math.min(
      endOffset,
      startOffset + first
    );
  }
  if (typeof last === 'number') {
    startOffset = Math.max(
      startOffset,
      endOffset - last
    );
  }

The upperBound and lowerBound are set like so:

  var lowerBound = after ? (afterOffset + 1) : 0;
  var upperBound = before ? beforeOffset : arrayLength;

Looks like you need to set after, before, first, and last and you will get dual pagination to work. I haven't tested but looking at the source code I can't see why not!

MattMcFarland avatar Apr 18 '16 19:04 MattMcFarland

This is really annoying - I had to implement my own methods, even though I can generate these values properly in the GraphQL response:

const idxPrefix = 'idx---';

const fromBase64 = (encoded: string): string => 
  Buffer.from(encoded, 'base64').toString('utf8');

const indexFromCursor = (cursor: string): number =>
  parseInt(fromBase64(cursor).replace(idxPrefix, ''), 10);

const hasPreviousPage = (startCursor: string): boolean => 
  indexFromCursor(startCursor) > 0;

const hasNextPage = (endCursor: string): boolean => 
  (indexFromCursor(endCursor) + 1) % limit === 0;

I have a feeling that this will be a continued problem, or people will choose to not adopt Connections / Edges, and instead just paginate via hints from parameters in a Route.

staylor avatar Nov 30 '16 09:11 staylor

The other workaround is to put state of graphql query in the url

Page 1
/list
Page 2
/list?after={endCursor1}
Page 3
/list?after={endCursor2}
Page 4
/list?after={endCursor3}

Going forward works well as it is, to go backward simply go back in history, i.e. window.history.go(-1)

If ?after= is not present, Previous button can be hidden

thomasloh avatar Jan 20 '17 19:01 thomasloh

Any status on resolving this?

ivosabev avatar Feb 06 '17 15:02 ivosabev

@ivosabev: I'm not aware of anybody actively working on this, but given Dan's comment (quoting below) I think it would be reasonable to accept a PR that modified the spec:

Your feedback is spot on, though; this definitely messes with bidirectional pagination. One way forward would be to modify the Relay specification and allow, though not require, hasPreviousPage to have the expected meaning. Connections where hasPreviousPage is easy to compute can then include it, where cases where it is expensive can continue returning false. Clients can then enable the bidirectional pagination behavior you're looking to add only if they ever see hasPreviousPage be true when paginating forward.

wincent avatar Feb 14 '17 00:02 wincent

Any update or work around on this issue? @gallagher-stem comment seems like a good solution,

du5rte avatar May 31 '17 12:05 du5rte

I have PRed a change to the spec to permit setting these values properly so that at least other implementations can do so without breaking technical compliance: https://github.com/facebook/relay/pull/2079

eapache avatar Sep 01 '17 18:09 eapache

Are PRs for this still welcome or should this issue be closed as won't fix?

richardscarrott avatar Oct 17 '18 17:10 richardscarrott

Any plans to change this still?

longfellowone avatar May 18 '19 00:05 longfellowone

According to spec:

hasPreviousPage is used to indicate whether more edges exist prior to the set defined by the clients arguments. If the client is paginating with last/before, then the server must return true if prior edges exist, otherwise false. If the client is paginating with first/after, then the client may return true if edges prior to after exist, if it can do so efficiently, otherwise may return false. More formally: Some code

That should mean hasPreviousPage can be true, when you know there are edges before the current dataset.

As of right now, I'm using something similar to @staylor

venikx avatar Feb 06 '20 10:02 venikx

@staylor I know this was a while back now but your hasNextPage would only work if the total number of items wasn't exactly divisible by the limit?

const idxPrefix = 'idx---';

const fromBase64 = (encoded) => 
  atob(encoded);

const indexFromCursor = (cursor) =>
  parseInt(fromBase64(cursor).replace(idxPrefix, ''), 10);

const hasPreviousPage = (startCursor) => 
  indexFromCursor(startCursor) > 0;

const hasNextPage = (endCursor, arrayLength) => 
  (indexFromCursor(endCursor) + 1) % limit === 0;

// Assuming 12 items in the array and pages (limit) of 3
console.log(hasNextPage(btoa('idx---2'))); // true
console.log(hasNextPage(btoa('idx---5'))); // true
console.log(hasNextPage(btoa('idx---8'))); // true
console.log(hasNextPage(btoa('idx---11'))); // true, should be false

Aside, to be fair I don't expect you thought anybody would copy paste it like I did 😆

I'm instead computing it based on the array length:

const idxPrefix = 'idx---';

const fromBase64 = (encoded) => 
  atob(encoded);

const indexFromCursor = (cursor) =>
  parseInt(fromBase64(cursor).replace(idxPrefix, ''), 10);

const hasPreviousPage = (startCursor) => 
  indexFromCursor(startCursor) > 0;

const hasNextPage = (endCursor, arrayLength) => 
  (indexFromCursor(endCursor) + 1) < arrayLength;

// Assuming 12 items in the array and pages (limit) of 3
console.log(hasNextPage(btoa('idx---2'), 12)); // true
console.log(hasNextPage(btoa('idx---5'), 12)); // true
console.log(hasNextPage(btoa('idx---8'), 12)); // true
console.log(hasNextPage(btoa('idx---11', 12))); // false

richardscarrott avatar Mar 10 '20 18:03 richardscarrott

Any updates? Really hope we can fix this...

dzcpy avatar Oct 14 '21 08:10 dzcpy

I know this seems counter-intuitive to you as it did to me as well, but that is actually how it is supposed to work...

hasPreviousPage is only meaningful when last is included, as it is always false otherwise. hasNextPage is only meaningful when first is included, as it is always false otherwise. When both first and last are included, both of the fields are set according to the above algorithms, but their meaning as it relates to pagination becomes unclear. This is among the reasons that pagination with both first and last is discouraged.

https://facebook.github.io/relay/graphql/connections.htm#sec-undefined.PageInfo.Fields

Sorry I couldn't use markdown in the post I emailed. I also posted about this, was embarrassing hehe. #55

That's not true. It always tells you if it has previous page, but not when using connectionFromArraySlice

aligatorr89 avatar Aug 31 '23 13:08 aligatorr89

Any updates? Really hope we can fix this...

I solved it with this (I think)

+ connection.pageInfo.hasPreviousPage = connection.edges.length > 0 && offset > 0 && offset < total;

aligatorr89 avatar Aug 31 '23 15:08 aligatorr89