Вы загружаете два элемента, нажимаете «Далее» и снова видите последний элемент предыдущей страницы. В другой раз при переходе между страницами один элемент пропадает. Представим небольшой список задач, который меняется во время просмотра: код должен определять, что значит «дальше», когда строки меняют положение.

Читайте фильтр, сортировку и выбор страницы вместе

Список сортируется по createdAt по убыванию, затем по уникальному числовому id, тоже по убыванию. В учебном примере время задано небольшими целыми числами. Шесть элементов open идут в порядке 6, 5, 4, 3, 2, 1; у элементов 5 и 4 одинаковое время — 50. Есть также элемент 0 со статусом done и временем 70.

Что читать Какое правило искать
selected Фильтрация по статусу до выбора страницы
compare Сначала более новое время; при равном времени — больший ID
offsetPage / cursorPage Какие строки попадают на следующую страницу
Вызывающий код Сохранение nextCursor, добавление актуальных результатов, сброс при изменении запроса

Начните с действия «Далее» и проследите его аргументы до функции выборки. Если читать только финальный срез массива, легко упустить фильтр и порядок, определяющие смысл позиций.

Посмотрите, как один offset указывает на разные строки

Эта реализация пропускает заданное число строк, которые подходят под фильтр в текущий момент:

const compare = (a, b) => b.createdAt - a.createdAt || b.id - a.id;
const selected = (rows, status) =>
  rows.filter((row) => row.status === status).sort(compare);

function offsetPage(rows, offset, take = 2, status = 'open') {
  return selected(rows, status).slice(offset, offset + take);
}

Первая страница содержит 6 и 5. Если между вызовами внести одно изменение, запрос второй страницы с offset = 2 даст разные результаты:

Изменение после первой страницы Текущий порядок подходящих строк Вторая страница Результат
Нет 6, 5, 4, 3, 2, 1 4, 3 Ожидаемое продолжение
Добавлен 7 со временем 70 7, 6, 5, 4, 3, 2, 1 5, 4 5 повторяется
Удалён 6 5, 4, 3, 2, 1 3, 2 4 пропущен

Каждый случай начинается с исходных данных. Число пропускаемых строк остаётся прежним, но их позиции меняются. Удаление повторяющихся ID в интерфейсе может скрыть повтор, но не вернёт пропущенный элемент.

Сравнивайте полный ключ сортировки

Курсор хранит ключ последнего возвращённого элемента. Условие только по времени, например createdAt < cursor.createdAt, потеряет элемент 4 после элемента 5: у обоих время 50. Замена на <= снова допустит предыдущий элемент.

Сохраните оба значения (createdAt, id) и используйте то же сравнение, что и при сортировке:

function cursorPage(rows, after = null, take = 2, status = 'open') {
  const items = selected(rows, status)
    .filter((row) => after === null || compare(row, after) > 0)
    .slice(0, take);
  const last = items.at(-1);
  return {
    items,
    nextCursor: last ? { createdAt: last.createdAt, id: last.id } : null,
  };
}

При таком порядке по убыванию compare(row, after) > 0 означает, что строка находится строго после сохранённой позиции. При равном времени порядок определяет id. После описанных выше добавления или удаления следующая страница содержит 4, 3.

Объект nextCursor копирует значения ключа, а не ищет предыдущую строку заново. Поэтому даже после удаления элемента 5 можно продолжить с сохранённого ключа. Пустая страница возвращает null. В этом примере может потребоваться дополнительный запрос, чтобы обнаружить конец списка.

Запустите изменения в отдельных тестах

Скачайте pagination-reading.mjs и запустите в Node.js 22 или новее:

node --test pagination-reading.mjs

Все десять тестов проходят. Три теста с префиксом reproduce: намеренно проверяют ошибочные результаты: повтор с offset, пропуск с offset и пропуск при сравнении курсора только по времени. Остальные семь проверяют добавление, удаление перед курсором, удаление исходной строки курсора, полный обход с одинаковым временем и пустой последней страницей, новую строку после курсора, фильтрацию до ограничения размера и сброс фильтра.

Тесты используют новые массивы и синхронные изменения между вызовами. Сервис, учётная запись, таймеры и установка пакетов не нужны.

Прежде чем считать исправление готовым, прочитайте вызывающий код

Если сменить фильтр на done, сохранив старый курсор, пример вернёт пустую страницу: элемент 0 расположен до этого курсора. Начав изменённый запрос с after = null, получим элемент 0. При смене фильтра или сортировки вызывающий код должен одновременно сбрасывать накопленные строки и курсор.

Также проверьте, где хранится nextCursor и когда он используется. Здесь null после пустого ответа означает остановку; если сразу передать его обратно в функцию, снова откроется первая страница. Ищите повторные запросы, ответы в обратном порядке и двукратное добавление одного ответа. Эти сценарии интерфейса не входят в синхронный пример; устаревшие ответы отдельно разобраны в статье о смене выбора при асинхронном чтении.

Определите, как показывать изменения читателю

Курсор не создаёт снимок данных. После чтения первой страницы добавьте элемент 8 со временем 49: следующая страница станет 4, 8. Новая строка находится после сохранённой позиции и поэтому появляется в уже начатом обходе. Новая строка перед курсором будет видна после обновления списка.

Проверенное продолжение предполагает уникальные ID и неизменные ключи сортировки у оставшихся элементов. Изменение ключа или статуса и удаление ещё не прочитанного элемента требуют явного правила продукта; выгрузке с фиксированным содержимым может понадобиться отдельный механизм снимков. Пример с массивом не проверяет изоляцию базы данных, индексы, передачу данных или кодирование курсора. Полная фильтрация и сортировка при каждом вызове также не подходят как решение для больших объёмов данных.

При чтении своей реализации сопоставьте фильтр, сравнение, сохранённый ключ и сброс в вызывающем коде. В Yenpo можно открыть связанные файлы и пройти по ссылкам на код, чтобы проверить каждый шаг.