Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/lib/pagination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ export async function paginate<T>(
items.push(item);
}

// Guard against infinite loop when API returns empty items with non-null nextToken.
// Without this, a filtered list returning zero results hangs indefinitely.
if (page.items.length === 0) break;

if (page.nextToken === null) break;
cursor = page.nextToken;
}
Expand Down
83 changes: 83 additions & 0 deletions test/pagination.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, it, expect, vi } from 'vitest';
import { paginate, type Page, type FetchPage } from '../src/lib/pagination.js';

describe('paginate', () => {
it('collects all items across pages until nextToken is null', async () => {
const pages: Page<string>[] = [
{ items: ['a', 'b'], nextToken: 'cursor1' },
{ items: ['c'], nextToken: null },
];
let callIndex = 0;
const fetchPage: FetchPage<string> = async () => pages[callIndex++]!;

const result = await paginate(fetchPage);

expect(result.items).toEqual(['a', 'b', 'c']);
expect(result.nextToken).toBeNull();
});

it('breaks out of loop when API returns empty items with non-null nextToken', async () => {
// This is the bug from issue #35: API returns { items: [], nextToken: 'cursor' }
// Without the fix, paginate() would loop forever requesting the same empty page.
const fetchPage: FetchPage<string> = vi.fn(
async (): Promise<Page<string>> => ({
items: [],
nextToken: 'cursor',
}),
);

const result = await paginate(fetchPage);

expect(result.items).toEqual([]);
// nextToken should reflect what the server returned, not null
expect(result.nextToken).toBe('cursor');
// Must have only fetched once — no infinite loop
expect(fetchPage).toHaveBeenCalledTimes(1);
});

it('respects maxItems cap', async () => {
const pages: Page<string>[] = [
{ items: ['a', 'b', 'c'], nextToken: 'cursor1' },
{ items: ['d', 'e'], nextToken: null },
];
let callIndex = 0;
const fetchPage: FetchPage<string> = async () => pages[callIndex++]!;

const result = await paginate(fetchPage, { maxItems: 2 });

expect(result.items).toEqual(['a', 'b']);
});

it('handles single page with null nextToken', async () => {
const fetchPage: FetchPage<string> = async () => ({
items: ['x'],
nextToken: null,
});

const result = await paginate(fetchPage);

expect(result.items).toEqual(['x']);
expect(result.nextToken).toBeNull();
});

it('handles consecutive empty pages then data', async () => {
// Edge case: first page empty (with token), second page has data
const pages: Page<string>[] = [
{ items: [], nextToken: 'cursor1' },
{ items: ['a'], nextToken: null },
];
let callIndex = 0;
const fetchPage: FetchPage<string> = vi.fn(
async (): Promise<Page<string>> => pages[callIndex++]!,
);

const result = await paginate(fetchPage);

// With the fix, the first empty page breaks the loop immediately.
// This is correct behavior — if the first page is empty, there's
// no reason to keep fetching.
expect(result.items).toEqual([]);
expect(result.nextToken).toBe('cursor1');
expect(fetchPage).toHaveBeenCalledTimes(1);
});
});