Skip to content

Commit b0ac2ba

Browse files
fix(paginate): break loop on empty items with non-null nextToken
paginate() in pagination.ts entered an infinite loop when the API returned { items: [], nextToken: 'cursor' }. The loop only checked nextToken !== null, never whether items was empty. Any filtered list command returning zero results would hang indefinitely. Add an early-exit guard: if the page contains zero items, break regardless of nextToken. Includes 5 unit tests covering the bug reproduction, maxItems cap, single-page, and consecutive empty pages. Closes #35 Signed-off-by: Aldo Rizona <aldorizona10@gmail.com>
1 parent 15e95de commit b0ac2ba

2 files changed

Lines changed: 83 additions & 0 deletions

File tree

src/lib/pagination.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,10 @@ export async function paginate<T>(
106106
items.push(item);
107107
}
108108

109+
// Guard against infinite loop when API returns empty items with non-null nextToken.
110+
// Without this, a filtered list returning zero results hangs indefinitely.
111+
if (page.items.length === 0) break;
112+
109113
if (page.nextToken === null) break;
110114
cursor = page.nextToken;
111115
}

test/pagination.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { paginate, type Page, type FetchPage } from '../src/lib/pagination.js';
3+
4+
describe('paginate', () => {
5+
it('collects all items across pages until nextToken is null', async () => {
6+
const pages: Page<string>[] = [
7+
{ items: ['a', 'b'], nextToken: 'cursor1' },
8+
{ items: ['c'], nextToken: null },
9+
];
10+
let callIndex = 0;
11+
const fetchPage: FetchPage<string> = async () => pages[callIndex++]!;
12+
13+
const result = await paginate(fetchPage);
14+
15+
expect(result.items).toEqual(['a', 'b', 'c']);
16+
expect(result.nextToken).toBeNull();
17+
});
18+
19+
it('breaks out of loop when API returns empty items with non-null nextToken', async () => {
20+
// This is the bug from issue #35: API returns { items: [], nextToken: 'cursor' }
21+
// Without the fix, paginate() would loop forever requesting the same empty page.
22+
const fetchPage: FetchPage<string> = vi.fn(async (): Promise<Page<string>> => ({
23+
items: [],
24+
nextToken: 'cursor',
25+
}));
26+
27+
const result = await paginate(fetchPage);
28+
29+
expect(result.items).toEqual([]);
30+
// nextToken should reflect what the server returned, not null
31+
expect(result.nextToken).toBe('cursor');
32+
// Must have only fetched once — no infinite loop
33+
expect(fetchPage).toHaveBeenCalledTimes(1);
34+
});
35+
36+
it('respects maxItems cap', async () => {
37+
const pages: Page<string>[] = [
38+
{ items: ['a', 'b', 'c'], nextToken: 'cursor1' },
39+
{ items: ['d', 'e'], nextToken: null },
40+
];
41+
let callIndex = 0;
42+
const fetchPage: FetchPage<string> = async () => pages[callIndex++]!;
43+
44+
const result = await paginate(fetchPage, { maxItems: 2 });
45+
46+
expect(result.items).toEqual(['a', 'b']);
47+
});
48+
49+
it('handles single page with null nextToken', async () => {
50+
const fetchPage: FetchPage<string> = async () => ({
51+
items: ['x'],
52+
nextToken: null,
53+
});
54+
55+
const result = await paginate(fetchPage);
56+
57+
expect(result.items).toEqual(['x']);
58+
expect(result.nextToken).toBeNull();
59+
});
60+
61+
it('handles consecutive empty pages then data', async () => {
62+
// Edge case: first page empty (with token), second page has data
63+
const pages: Page<string>[] = [
64+
{ items: [], nextToken: 'cursor1' },
65+
{ items: ['a'], nextToken: null },
66+
];
67+
let callIndex = 0;
68+
const fetchPage: FetchPage<string> = vi.fn(async (): Promise<Page<string>> => pages[callIndex++]!);
69+
70+
const result = await paginate(fetchPage);
71+
72+
// With the fix, the first empty page breaks the loop immediately.
73+
// This is correct behavior — if the first page is empty, there's
74+
// no reason to keep fetching.
75+
expect(result.items).toEqual([]);
76+
expect(result.nextToken).toBe('cursor1');
77+
expect(fetchPage).toHaveBeenCalledTimes(1);
78+
});
79+
});

0 commit comments

Comments
 (0)