Skip to content

A directive for page-number pagination: lists paged by page/perPage with only hasNextPage to trust (AniList's schema has no cursors) #49

Open

Description

@shergin

Summary

We are building Yakitate, a native macOS client for AniList, on Baton. AniList's GraphQL API has no cursor connections at all: every list is Page(page: Int, perPage: Int) with a pageInfo whose only trustworthy field is hasNextPage, and nested connections borrow Relay's edges/nodes/pageInfo but page by number. Baton's @connection cannot be used, and the terminology's Page entry says such a list is "composed in the UI from plain operations until the watch list promotes a directive for it". Requested: that directive, for lists paged by number or offset, with hasNextPage as the only page-info field it trusts.

Current behavior

  • @connection(key:) reads through Relay's handle key and merges pages by cursor (docs/terminology.md, "Connection, edge, node"; docs/decisions/connections-own-their-edges.md). A page without a cursor replaces, so two number-paged requests cannot merge.
  • The terminology's Page entry (docs/terminology.md, "Lists"): "a list fetched by page number or offset, as the sample API does. Not a connection; composed in the UI from plain operations until the watch list promotes a directive for it."
  • So each page is its own operation value today (SeasonQuery(page: 2) is a different root from SeasonQuery(page: 1)), and the view concatenates the pages' lists and reads hasNextPage from the last one. batonc 0.16.0 validates and generates this without complaint.

What the schema looks like

Measured against https://graphql.anilist.co on 2026-10-18 (one unauthenticated introspection and a handful of small queries):

  • Page(page, perPage) { pageInfo { total perPage currentPage lastPage hasNextPage } media(...) ... }, with 18 list fields on Page (media, mediaList, airingSchedules, characters, staff, notifications, reviews, recommendations, ...). Only one data field may be used per Page.
  • Nested: Media.characters(sort:, role:, page:, perPage:) { edges { role node { ... } } nodes pageInfo }, Media.airingSchedule(notYetAired:, page:, perPage:), and so on. No cursor anywhere.
  • perPage is capped at 50 silently (a request for 100 came back as 50). page * perPage above 5,000 is HTTP 400.
  • The docs say total and lastPage "are not currently accurate. You should only rely on hasNextPage"; total came back as exactly 5000 for two unrelated queries. The lead developer confirmed on 2026-03-19 that this still holds.
  • Page has no id. The schema has no interfaces and no Subscription type; three unions.

Requested behavior

A directive on a number-paged list field that gives a lens the same convenience a connection lens has: the merged elements, hasNext, isLoadingNext, and a loadNext() that runs the next page with the owner's variables. Design points, offered as questions rather than answers:

  • The word. Relay has none for offset pages and neither does the GraphQL specification, so it should not be mistaken for @connection. Something like @paged is one candidate; the name is yours.
  • The key. A page's storage key already carries its arguments (media(page:2,perPage:50,...)), so pages never share records. The merged client-side list needs a key of its own and a decision on whether it owns or references the pages' records, as the connections decision made for cursors.
  • The flags. hasNextPage is the only field to trust; total, lastPage and currentPage are not. Whether isLoadingNext is a client field as for connections.
  • The refresh. A refetch of page 1 should replace the merged list, as a cursorless connection fetch does; whether a page in the middle can be refetched alone.
  • The nested case. Media.characters(page:, perPage:) under a root resolved by a lookup.

Why this is needed

Number-paged lists are every list on this schema: the current season, search, browse with 71 filters, and on each media page its characters, staff, reviews, recommendations and airing schedule. Composing each in the UI is a page-holder per list, and a refresh batch (Page.media(id_in:), this schema's nodes(ids:)) lands in records the composed lists already reference, which works but is nothing the lens can describe. We will build the first milestones on the composition and report what it costs (lines per paged list, requests per page turn, re-renders on a page turn) on this issue.

Reproduction

  • SDL: run the standard introspection query against https://graphql.anilist.co and print it (graphql-js's printSchema or a short script); 183 named types.
  • baton.json:
{
  "schema": "schema.graphql",
  "identity": { "default": ["id"] },
  "lookups": [{ "field": "Query.Media", "type": "Media", "argument": "id" }]
}
  • A paged operation:
query SeasonQuery($page: Int!, $season: MediaSeason, $seasonYear: Int) @cacheExpiration(seconds: 1800) {
  Page(page: $page, perPage: 50) {
    pageInfo { hasNextPage currentPage }
    media(season: $season, seasonYear: $seasonYear, type: ANIME, sort: [POPULARITY_DESC]) {
      id
      ...MediaCard_media
    }
  }
}

batonc validate and generate pass; the lookup appears in the plan; root accessors come out lower-camel (page, media). We can attach the SDL and the full trial document set on request.

Activity

  1. changed the title [-]A directive for page-number pagination: AniList's Page(page:, perPage:) lists are the second real schema without cursors[/-] [+]A directive for page-number pagination: lists paged by page/perPage with only hasNextPage to trust (AniList's schema has no cursors)[/+] on Oct 18, 2026
  2. shergin commented on Oct 18, 2026

    @shergin
    OwnerAuthor

    Evidence from the first paged screens, 2026-10-18.

    Two operations in the app page different list fields under the same container: AiringWeekQuery selects Page(page: $page, perPage: 50) { pageInfo { hasNextPage } airingSchedules(...) } and SeasonQuery selects Page(page: $page, perPage: 50) { pageInfo { hasNextPage } media(...) }. Page has no identity, so the store keys it by its path and arguments, and both operations' page 1 resolve to one record, Query.Page(page:1,perPage:50). The two list fields sit in that record under their own storage keys, but pageInfo is one slot: after the season's page 1 committed hasNextPage: true, the week query's empty page 1 read true too and the app asked for week pages 2 and 3 it did not need (three requests against a budget of thirty a minute).

    The app now gives each list family its own perPage (50, 48, 24) so their containers differ, which works and is a smell. For the directive this says the client-side page should be keyed by the list field and its arguments (airingSchedules(mediaId_in:..., page:1)), not by the container the server wraps it in, and its page info should live with that key rather than with Page. Relay's connection handler has the same shape: the client record hangs off the field, not off a wrapper.

  3. shergin commented on Oct 19, 2026

    @shergin
    OwnerAuthor

    Decided on 2026-10-18.

    The answer: a directive of Baton's own, on the list field

    @paged(key:, page:) goes on the list field, inside the field that takes the page, its container. Relay's @connection means cursors and the GraphQL specification has no pagination, so the word is ours.

    query SeasonQuery($page: Int!, $season: MediaSeason, $seasonYear: Int) {
      Page(page: $page, perPage: 50) {
        pageInfo { hasNextPage }
        media(season: $season, seasonYear: $seasonYear, type: ANIME, sort: [POPULARITY_DESC])
          @paged(key: "SeasonQuery_media", page: $page) {
          id
          ...MediaCard_media
        }
      }
    }
    
    fragment MediaCharacters_media on Media {
      characters(sort: [ROLE], page: $page, perPage: 25) {
        pageInfo { hasNextPage }
        edges @paged(key: "MediaCharacters_edges", page: $page) { role node { id name } }
      }
    }

    The key

    The pages merge into a client record of the container's type on the container's parent: the root for Query.Page, the Media entity for Media.characters. It is keyed __<key>_paged(...) with the container's arguments except the page and the list field's arguments, so two lists under one wrapper, two seasons of one list and two page sizes never share it, and its page info is its own record. This answers the comment above: AiringWeekQuery and SeasonQuery shared one Query.Page(page:1,perPage:50) and its one pageInfo slot; under @paged each list has its own record and its own flag, whatever its perPage.

    The merge

    The first page replaces the merged list, the next appends, a page already merged rewrites its elements in place, and any other page is ignored. Elements are referenced, not copied, each record once, nulls dropped. The record's other fields and its page info follow the page that replaces or is the last.

    The lens

    The container's lens gains hasNext, which reads pageInfo.hasNextPage and nothing else (not total, lastPage or currentPage); isLoadingNext, a client field as for connections; and loadNext(), which runs the operation that owns the lens with its variables and the next page number. No fragment needs @refetchable, which matters here: Relay's front end could not grant one to a fragment on Media on a schema without Node. A refetch of the owner replaces the list.

    Declared as Baton's

    The directive is declared beside @cacheExpiration in the compiler's schema extension, so Relay's front end checks its arguments and a server never sees it. It is the third directive that is not Relay's. The generated-code format rises to 21 in Swift and 5 in Kotlin.

    Not chosen

    • @connection with a page argument: a second meaning under Relay's word. If Relay or the GraphQL specification gains a word for a list paged by number, that word replaces ours.
    • The directive on the container, or the key by the container: the comment above shows the container is shared and the key is the list's.
    • Reading total, lastPage or currentPage, or a configurable page-info shape: the one field AniList trusts is Relay's word. The Rick and Morty API's info { next } keeps reading one page per operation value until a real screen needs a second reading of the flag.
    • Offsets now: no real schema asks; when one does, the directive takes an offset: argument beside page:.
    • hasPrevious and loadPrevious(): a list paged by number starts at its first page.
    • Edge directives into a paged list: not until a real write must insert into or remove from one.
    • Copying the elements: they are referenced, as the connections decision found for edges.

    The record is docs/decisions/a-paged-list-merges-its-pages-by-number.md. The compiler, both runtimes, the fixtures and the docs land in one pull request; after it merges, a second comment here will say what to write.

  4. shergin commented on Oct 19, 2026

    @shergin
    OwnerAuthor

    Landed on master in #56, for the next release.

    What to write

    query SeasonQuery($page: Int!, $season: MediaSeason, $seasonYear: Int) {
      Page(page: $page, perPage: 50) {
        pageInfo { hasNextPage }
        media(season: $season, seasonYear: $seasonYear, type: ANIME, sort: [POPULARITY_DESC])
          @paged(key: "SeasonQuery_media", page: $page) {
          id
          ...MediaCard_media
        }
      }
    }
    • $page is a variable of the query, declared Int! (or Int with a default that is not null), and it is not a fragment argument.
    • pageInfo { hasNextPage } is selected beside the list; the compiler refuses the directive without it, and says so at the directive.
    • key ends in _ and the list field's name, as a connection's key does.
    • The same shape works on an entity: characters(sort: [ROLE], page: $page, perPage: 25) { pageInfo { hasNextPage } edges @paged(key: "MediaCharacters_edges", page: $page) { role node { ... } } } in a fragment on Media, with $page a variable of the query that spreads it. No @refetchable is needed.

    What the view reads

    The lens over Page reads media as the merged list, and gains hasNext, isLoadingNext and loadNext() (Swift async throws, Kotlin suspend). loadNext() runs the same query with the next page number and the query's other variables, so SeasonQuery(page: 1, season: WINTER, seasonYear: 2026) asks for page 2 of the same season. A refetch of the query replaces the list with its first page; an expiration does the same through revalidation. hasNext reads the merged page info's hasNextPage and nothing else.

    What the store holds

    The pages merge under client:root:__SeasonQuery_media_paged(perPage:50,season:"WINTER",seasonYear:2026,sort:["POPULARITY_DESC"],type:"ANIME"), with its own page info, apart from the server's Page(page:1,perPage:50) record. The merge copies into it only the fields the operation selected of the container, so the AiringWeekQuery and SeasonQuery lists share nothing: the per-list perPage is no longer needed, and the two queries may use the same page size. Every list is one request per page turn, and a page already merged that is fetched again (a hand-run SeasonQuery(page: 2)) rewrites its own elements in place.

    Held by the cases tests/paged-notes-1 to -3, paged-characters-1 (two lists under one server page), friends-1 and friends-2 (a list on an entity), and the script scripts/paged.json (the second page merged, the shared page keeping each list's own flag, a page rewritten in place, a page past the end ignored, a refetch that replaces), in spec/; the merge is in docs/decisions/a-paged-list-merges-its-pages-by-number.md and the terminology's "Paged list". Generated-code format 21 in Swift and 5 in Kotlin: rebuild with the release's compiler.

    One thing the build settled beyond the decision: Relay's generated refetch query of a @refetchable fragment declares a global $page as Int, by the schema's argument type; the compiler accepts that for the derived query alone, since the lens that refetches passes the page it read, so a @paged list inside a @refetchable fragment on Query compiles.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions