# Infinite query with max pages

> An infinite query that pages in both directions from the middle of the data and keeps a window of at most three pages.

An infinite query that starts in the middle of a hundred projects, pages
forward and backward, and holds at most three pages: `maxPages: 3` drops the
page at the far end whenever a fourth comes in, so the window slides instead
of growing. The page function is told which way each fetch extends the
window. Use it where a list can be entered anywhere and scrolled both ways
while memory stays bounded: a chat history opened at an unread message, a
log viewer jumped to a timestamp, a long timeline of sensor readings.

Live demo: [Infinite query with max pages](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/max-pages), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/max_pages)). Pages in both directions, with a window of three.

## What to try

- The screen opens on the page at cursor 30: `pageParams=30`, and both
  `hasPreviousPage` and `hasNextPage` are true.
- Press *Load next* twice for `pageParams=30,40,50`, then once more: the
  window slides to `40,50,60`, still `pages=3`, and *Project 30* is gone from
  the rows. `lastPage` names the cursor and `forward`.
- Press *Load previous*: a *loading previous* pill shows while
  `isFetchingPreviousPage=true`, the window slides back to `30,40,50`, and
  `lastPage` says `backward`.
- Press *Refetch*: the pages in the window are fetched again, first to last,
  and nothing outside it: `lastPage` ends on the window's last cursor with
  `forward`, and each row's *fetched* time is renewed.
- Keep paging one way: at cursor 90 `hasNextPage=false` and *Load next* is
  disabled; at cursor 0 the same happens to *Load previous*.

## The code

Both directions read their cursor off the page the backend sent, and
`maxPages` caps the window. The page function's context carries `pageParam`,
`signal` and `direction`:

[`examples/showcase/lib/features/max_pages/max_pages_screen.dart`, lines 71–89](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/max_pages/max_pages_screen.dart#L71-L89):

```dart
InfiniteQueryObserverOptions<ProjectSlice, int> projectsWindowQuery(
  ShowcaseApi api, {
  void Function(int cursor, FetchDirection direction)? onPage,
}) =>
    InfiniteQueryObserverOptions<ProjectSlice, int>(
      queryKey: projectsWindowKey,
      initialPageParam: startCursor,
      pageFn: (context) {
        onPage?.call(context.pageParam, context.direction);
        return api.projectsFrom(
          context.pageParam,
          limit: pageSize,
          signal: context.signal,
        );
      },
      getNextPageParam: (page, _, __, ___) => page.nextId,
      getPreviousPageParam: (page, _, __, ___) => page.previousId,
      maxPages: windowSize,
    );
```

The screen holds the query as an `InfiniteQueryController`, created in
`initState` and disposed with the state, and rebuilds through a
`ListenableBuilder`:

[`examples/showcase/lib/features/max_pages/max_pages_screen.dart`, lines 113–119](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/max_pages/max_pages_screen.dart#L113-L119):

```dart
_window = InfiniteQueryController<ProjectSlice, int, ProjectWindow>(
  QueryClientProvider.read(context),
  projectsWindowQuery(
    api,
    onPage: (cursor, direction) => _lastPage = '$cursor ${direction.name}',
  ),
);
```

Which end is loading is on the controller, not on the sealed result, so the
card's pill switches over both:

[`examples/showcase/lib/features/max_pages/max_pages_screen.dart`, lines 163–169](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/max_pages/max_pages_screen.dart#L163-L169):

```dart
trailing: switch (result) {
  QueryResult(isLoading: true) => const Pill('loading'),
  _ when window.isFetchingPreviousPage => const Pill('loading previous'),
  _ when window.isFetchingNextPage => const Pill('loading next'),
  _ when window.isRefetching => const Pill('refreshing'),
  _ => const SizedBox.shrink(),
},
```

<details>
<summary>The whole screen</summary>

[`examples/showcase/lib/features/max_pages/max_pages_screen.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/max_pages/max_pages_screen.dart):

```dart
/// Upstream's `infinite-query-with-max-pages` example: an infinite query that
/// pages in both directions from a cursor in the middle of the data, holding
/// a window of at most three pages. `maxPages: 3` drops the page at the far
/// end whenever a fourth comes in, so paging forward slides the window and
/// paging back slides it again; a refetch re-requests exactly the pages the
/// window holds, first to last, and every row's `fetched` stamp moves.
///
/// The query is an `InfiniteQueryController` created in `initState` and read
/// through a `ListenableBuilder`; the paging half — `hasNextPage`,
/// `isFetchingPreviousPage`, `fetchNextPage` — lives on the controller, not
/// on the sealed result. The page function receives an `InfinitePageContext`,
/// whose `direction` says which end of the window a fetch extends: the screen
/// notes the last one as `lastPage=<cursor> <direction>`. A refetch walks
/// the window first to last, every page `forward`.
///
/// Proofs (widget tests in `test/features/max_pages_test.dart`, end-to-end in
/// `e2e/tests/max_pages.spec.ts`): the screen starts on the page at cursor 30
/// with both directions available; two `Load next` make `pageParams=30,40,50`
/// and a third slides the window to `40,50,60` — still three pages, rows
/// 30–39 gone, rows 60–69 there, one request per cursor; `Load previous`
/// from there slides it back to `30,40,50` with one new request for 30;
/// `Refetch` sends one request per page in the window, bumps `fetches` by one
/// and renews every row's `fetched` stamp; cursor 90 ends the forward
/// direction (`hasNextPage=false`, button disabled) and cursor 0 the backward
/// one; and `lastPage` says `40 forward` after `Load next`, `20 backward`
/// after `Load previous`, and the window's last cursor `forward` after a
/// refetch.
library;

import 'package:flutter/material.dart';
import 'package:query_kit_flutter/query_kit_flutter.dart';

import '../../shared/api.dart';
import '../../shared/chrome.dart';
import '../../shared/debug_strip.dart';
import '../../shared/fact_group.dart';
import '../../shared/feature.dart';
import '../../shared/feature_scaffold.dart';
import '../../shared/models.dart';
import '../../shared/scope.dart';

const Feature maxPagesFeature = Feature(
  id: 'max-pages',
  title: 'Infinite query with max pages',
  summary: 'Pages in both directions, with a window of three.',
  upstream: 'infinite-query-with-max-pages',
);

/// The window's cache entry — its own key, so the pagination and load-more
/// screens' project entries are untouched by what happens here.
QueryKey get projectsWindowKey =>
    QueryKey(const <Object?>['projects', 'window']);

/// Ten rows per page; the backend has a hundred, ids 0 to 99.
const int pageSize = 10;

/// Where the window starts: in the middle, so there is a page on either side
/// from the first frame on.
const int startCursor = 30;

/// How many pages the window keeps.
const int windowSize = 3;

typedef ProjectWindow = InfiniteData<ProjectSlice, int>;

/// The window's options. The cursors come back with every slice, so the
/// paging functions read them off the page rather than counting. [onPage]
/// hears each page fetch with what the page function was told about it: the
/// cursor and the direction — `forward` for `fetchNextPage`, the first page
/// and every page of a refetch, `backward` for `fetchPreviousPage`.
InfiniteQueryObserverOptions<ProjectSlice, int> projectsWindowQuery(
  ShowcaseApi api, {
  void Function(int cursor, FetchDirection direction)? onPage,
}) =>
    InfiniteQueryObserverOptions<ProjectSlice, int>(
      queryKey: projectsWindowKey,
      initialPageParam: startCursor,
      pageFn: (context) {
        onPage?.call(context.pageParam, context.direction);
        return api.projectsFrom(
          context.pageParam,
          limit: pageSize,
          signal: context.signal,
        );
      },
      getNextPageParam: (page, _, __, ___) => page.nextId,
      getPreviousPageParam: (page, _, __, ___) => page.previousId,
      maxPages: windowSize,
    );

class MaxPagesScreen extends StatefulWidget {
  const MaxPagesScreen({super.key});

  @override
  State<MaxPagesScreen> createState() => _MaxPagesScreenState();
}

class _MaxPagesScreenState extends State<MaxPagesScreen> {
  late final InfiniteQueryController<ProjectSlice, int, ProjectWindow> _window;

  /// The last page fetch the page function was asked for, as
  /// `<cursor> <direction>`. Written from inside the page function — before
  /// the request goes out — and read by the rebuild its answer causes, so no
  /// `setState` is needed for it.
  String _lastPage = 'none';

  @override
  void initState() {
    super.initState();
    // Neither lookup subscribes: the api and the client are fixed for the
    // life of the app, and a subscribing lookup is not allowed here anyway.
    final api = context.getInheritedWidgetOfExactType<ShowcaseScope>()!.api;
    _window = InfiniteQueryController<ProjectSlice, int, ProjectWindow>(
      QueryClientProvider.read(context),
      projectsWindowQuery(
        api,
        onPage: (cursor, direction) => _lastPage = '$cursor ${direction.name}',
      ),
    );
  }

  @override
  void dispose() {
    _window.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => FeatureScaffold(
        feature: maxPagesFeature,
        children: <Widget>[
          ListenableBuilder(
            listenable: _window,
            builder: (context, _) =>
                _WindowCard(window: _window, lastPage: _lastPage),
          ),
          QueryDebugStrip(queryKey: projectsWindowKey, label: 'projects'),
        ],
      );
}

/// The window: its facts, the three buttons, and the rows it holds.
class _WindowCard extends StatelessWidget {
  const _WindowCard({required this.window, required this.lastPage});

  final InfiniteQueryController<ProjectSlice, int, ProjectWindow> window;

  /// `<cursor> <direction>` of the last page fetch, off the page context.
  final String lastPage;

  @override
  Widget build(BuildContext context) {
    final result = window.value;
    final data = result.dataOrNull;
    final pages = data?.pages ?? const <ProjectSlice>[];
    final pageParams = data?.pageParams ?? const <int>[];
    final rows = <Project>[
      for (final page in pages) ...page.items,
    ];

    return SectionCard(
      title: '$pageSize projects per page, $windowSize pages at most',
      trailing: switch (result) {
        QueryResult(isLoading: true) => const Pill('loading'),
        _ when window.isFetchingPreviousPage => const Pill('loading previous'),
        _ when window.isFetchingNextPage => const Pill('loading next'),
        _ when window.isRefetching => const Pill('refreshing'),
        _ => const SizedBox.shrink(),
      },
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: <Widget>[
          // Explicit child nodes: a row of buttons and texts folds into one
          // semantics node otherwise, and every `key=value` here is read as
          // an exact text.
          SemanticsGroup(
            child: Wrap(
              spacing: 12,
              runSpacing: 4,
              children: <Widget>[
                _Fact('pages=${pages.length}'),
                _Fact('pageParams=${pageParams.join(',')}'),
                _Fact('hasPreviousPage=${window.hasPreviousPage}'),
                _Fact('hasNextPage=${window.hasNextPage}'),
                _Fact(
                    'isFetchingPreviousPage=${window.isFetchingPreviousPage}'),
                _Fact('isFetchingNextPage=${window.isFetchingNextPage}'),
                _Fact('lastPage=$lastPage'),
              ],
            ),
          ),
          const SizedBox(height: 12),
          SemanticsGroup(
            child: Wrap(
              spacing: 8,
              runSpacing: 8,
              children: <Widget>[
                FilledButton.tonalIcon(
                  onPressed:
                      window.hasPreviousPage && !window.isFetchingPreviousPage
                          ? () => window.fetchPreviousPage().ignore()
                          : null,
                  icon: const Icon(Icons.arrow_upward),
                  label: const Text('Load previous'),
                ),
                FilledButton.tonalIcon(
                  onPressed: window.hasNextPage && !window.isFetchingNextPage
                      ? () => window.fetchNextPage().ignore()
                      : null,
                  icon: const Icon(Icons.arrow_downward),
                  label: const Text('Load next'),
                ),
                OutlinedButton.icon(
                  onPressed: result.isFetching
                      ? null
                      : () => window.refetch().ignore(),
                  icon: const Icon(Icons.refresh),
                  label: const Text('Refetch'),
                ),
              ],
            ),
          ),
          const SizedBox(height: 12),
          switch (result) {
            QueryPending() => const Column(
                crossAxisAlignment: CrossAxisAlignment.stretch,
                children: <Widget>[
                  SkeletonBox(height: 20),
                  SizedBox(height: 4),
                  SkeletonBox(height: 20),
                  SizedBox(height: 4),
                  SkeletonBox(height: 20),
                ],
              ),
            QueryError(:final error, staleData: null) =>
              Notice('$error', error: true),
            _ => Column(
                crossAxisAlignment: CrossAxisAlignment.stretch,
                children: <Widget>[
                  if (result case QueryError(:final error)) ...<Widget>[
                    Notice('Fetch failed: $error', error: true),
                    const SizedBox(height: 8),
                  ],
                  _Rows(rows: rows),
                ],
              ),
          },
        ],
      ),
    );
  }
}

class _Fact extends StatelessWidget {
  const _Fact(this.text);

  final String text;

  @override
  Widget build(BuildContext context) => Text(
        text,
        style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
      );
}

/// The rows of every page in the window, in a scroller of their own: the
/// screen's scaffold builds its children lazily, and the strip under thirty
/// rows would otherwise never be built.
///
/// Not a lazy list. Thirty rows are nothing, and a lazy list whose row count
/// grows under a fixed row height keeps its old scroll extent until the next
/// scroll — the rows the new page brought would be unreachable for one
/// frame. With every row in the tree the far end of the window is always
/// there to be found.
class _Rows extends StatelessWidget {
  const _Rows({required this.rows});

  final List<Project> rows;

  @override
  Widget build(BuildContext context) {
    final scheme = Theme.of(context).colorScheme;
    return Container(
      key: const ValueKey<String>('project-rows'),
      height: 240,
      decoration: BoxDecoration(
        border: Border.all(color: scheme.outlineVariant),
        borderRadius: BorderRadius.circular(8),
      ),
      child: SingleChildScrollView(
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: <Widget>[
            for (final project in rows)
              SizedBox(
                height: 40,
                child: Padding(
                  padding: const EdgeInsets.symmetric(horizontal: 12),
                  child: Row(
                    children: <Widget>[
                      Expanded(child: Text(project.name)),
                      Text(
                        'fetched ${hhmmss(project.fetchedAt)}',
                        style: Theme.of(context).textTheme.bodySmall,
                      ),
                    ],
                  ),
                ),
              ),
          ],
        ),
      ),
    );
  }
}
```

</details>

## Related

- Guides: [Infinite queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/infinite-queries.md)
- Upstream: TanStack's React [`infinite-query-with-max-pages`](https://github.com/TanStack/query/tree/main/examples/react/infinite-query-with-max-pages) example
- Tested by `test/features/max_pages_test.dart` (widget) and `e2e/tests/max_pages.spec.ts` (browser)
- [View the feature on GitHub](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/max_pages)
