From 73da203d0794e20af7aff31a0b68e1cc9bdfee91 Mon Sep 17 00:00:00 2001
From: "sentry-junior[bot]"
<264270552+sentry-junior[bot]@users.noreply.github.com>
Date: Mon, 17 Aug 2026 18:38:24 +0000
Subject: [PATCH 1/9] fix(projects): load every project page in the year list
The projects page only requested the first API page, so years with more
than 50 projects hid the rest. Walk nextCursor until the full filtered
list is loaded.
Co-Authored-By: Trevor Elkins
---
src/app/queries/projects.ts | 30 ++++++++++++++++----
test/app/routes.test.tsx | 55 +++++++++++++++++++++++++++++++++++++
2 files changed, 80 insertions(+), 5 deletions(-)
diff --git a/src/app/queries/projects.ts b/src/app/queries/projects.ts
index 1f30106..d71b078 100644
--- a/src/app/queries/projects.ts
+++ b/src/app/queries/projects.ts
@@ -38,17 +38,37 @@ export function useProjects(
group?: string,
search?: string,
) {
- const query = new URLSearchParams({year: yearId, limit: '50'});
- if (kind) query.set('kind', kind);
- if (group) query.set('group', group);
- if (search) query.set('q', search);
return useQuery({
queryKey: ['projects', yearId, kind, group, search],
- queryFn: () => apiRequest(`/projects?${query}`),
+ queryFn: () => fetchAllProjects(yearId, kind, group, search),
placeholderData: keepPreviousData,
});
}
+async function fetchAllProjects(
+ yearId: string,
+ kind?: 'project' | 'idea',
+ group?: string,
+ search?: string,
+): Promise {
+ const projects: ProjectsResponse['projects'] = [];
+ let cursor: string | undefined;
+
+ do {
+ const query = new URLSearchParams({year: yearId, limit: '50'});
+ if (kind) query.set('kind', kind);
+ if (group) query.set('group', group);
+ if (search) query.set('q', search);
+ if (cursor) query.set('cursor', cursor);
+
+ const page = await apiRequest(`/projects?${query}`);
+ projects.push(...page.projects);
+ cursor = page.nextCursor ?? undefined;
+ } while (cursor);
+
+ return {projects, nextCursor: null};
+}
+
export function useProject(projectId: string) {
return useQuery({
queryKey: ['project', projectId],
diff --git a/test/app/routes.test.tsx b/test/app/routes.test.tsx
index 24598e5..8f3571a 100644
--- a/test/app/routes.test.tsx
+++ b/test/app/routes.test.tsx
@@ -345,6 +345,61 @@ describe('clickable project routes', () => {
).toBe('true');
});
+ it('loads every project page until nextCursor is exhausted', async () => {
+ fetchMock.mockImplementation(async (input) => {
+ const url = input instanceof Request ? input.url : input.toString();
+ if (url.includes('/api/years/2026')) {
+ return json({
+ year: {
+ id: '2026',
+ votingEnabled: false,
+ submissionsClosed: false,
+ projectCount: 51,
+ ideaCount: 0,
+ groupCount: 0,
+ participantCount: 51,
+ },
+ groups: [],
+ awards: [],
+ });
+ }
+
+ const requestUrl = new URL(url, 'https://hackweek.test');
+ const cursor = requestUrl.searchParams.get('cursor');
+ if (cursor === '50') {
+ return json({
+ projects: [{...projectFixture, id: 'project-51', name: 'Project 51'}],
+ nextCursor: null,
+ });
+ }
+
+ return json({
+ projects: Array.from({length: 50}, (_, index) => ({
+ ...projectFixture,
+ id: `project-${index + 1}`,
+ name: `Project ${index + 1}`,
+ })),
+ nextCursor: '50',
+ });
+ });
+
+ renderRoute(, '/years/2026/projects', '/years/:yearId/projects');
+
+ expect(await screen.findByRole('heading', {name: 'Project 1'})).toBeTruthy();
+ expect(await screen.findByRole('heading', {name: 'Project 51'})).toBeTruthy();
+ expect(screen.getByRole('region', {name: 'project list'}).children).toHaveLength(51);
+ expect(fetchMock).toHaveBeenCalledWith(
+ expect.stringMatching(/\/api\/projects\?(?=.*year=2026)(?=.*limit=50)(?!.*cursor=)/),
+ undefined,
+ );
+ expect(fetchMock).toHaveBeenCalledWith(
+ expect.stringMatching(
+ /\/api\/projects\?(?=.*year=2026)(?=.*limit=50)(?=.*cursor=50)/,
+ ),
+ undefined,
+ );
+ });
+
it('live-updates server search without replacing the current list', async () => {
let resolveSearch!: (response: Response) => void;
const pendingSearch = new Promise((resolve) => {
From 06b11604e27146e56a34bdd2fe96d10a0f9cc9d2 Mon Sep 17 00:00:00 2001
From: "sentry-junior[bot]"
<264270552+sentry-junior[bot]@users.noreply.github.com>
Date: Mon, 17 Aug 2026 18:40:34 +0000
Subject: [PATCH 2/9] test(projects): format pagination regression coverage
---
test/app/routes.test.tsx | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/test/app/routes.test.tsx b/test/app/routes.test.tsx
index 8f3571a..16bd8e2 100644
--- a/test/app/routes.test.tsx
+++ b/test/app/routes.test.tsx
@@ -389,7 +389,9 @@ describe('clickable project routes', () => {
expect(await screen.findByRole('heading', {name: 'Project 51'})).toBeTruthy();
expect(screen.getByRole('region', {name: 'project list'}).children).toHaveLength(51);
expect(fetchMock).toHaveBeenCalledWith(
- expect.stringMatching(/\/api\/projects\?(?=.*year=2026)(?=.*limit=50)(?!.*cursor=)/),
+ expect.stringMatching(
+ /\/api\/projects\?(?=.*year=2026)(?=.*limit=50)(?!.*cursor=)/,
+ ),
undefined,
);
expect(fetchMock).toHaveBeenCalledWith(
From 561a443449786467017d4b6a23ecfeb2d99cab98 Mon Sep 17 00:00:00 2001
From: "sentry-junior[bot]"
<264270552+sentry-junior[bot]@users.noreply.github.com>
Date: Mon, 17 Aug 2026 18:48:55 +0000
Subject: [PATCH 3/9] fix(projects): raise page size to 250 and add page
controls
Allow the projects API to return up to 250 items per request so current
Hackweek years fit in one fetch. Keep next/previous controls for any
result set that still returns a next cursor.
Co-Authored-By: Trevor Elkins
---
src/app/queries/projects.ts | 39 +++++----------
src/app/routes/ProjectsPage.tsx | 87 ++++++++++++++++++++++++++++-----
src/app/styles.css | 32 ++++++++++++
src/worker/routes/projects.ts | 2 +-
test/app/routes.test.tsx | 41 +++++++++++-----
5 files changed, 151 insertions(+), 50 deletions(-)
diff --git a/src/app/queries/projects.ts b/src/app/queries/projects.ts
index d71b078..10f4261 100644
--- a/src/app/queries/projects.ts
+++ b/src/app/queries/projects.ts
@@ -32,43 +32,30 @@ export function useYear(yearId: string) {
});
}
+export const PROJECTS_PAGE_SIZE = 250;
+
export function useProjects(
yearId: string,
kind?: 'project' | 'idea',
group?: string,
search?: string,
+ cursor?: string,
) {
+ const query = new URLSearchParams({
+ year: yearId,
+ limit: String(PROJECTS_PAGE_SIZE),
+ });
+ if (kind) query.set('kind', kind);
+ if (group) query.set('group', group);
+ if (search) query.set('q', search);
+ if (cursor) query.set('cursor', cursor);
return useQuery({
- queryKey: ['projects', yearId, kind, group, search],
- queryFn: () => fetchAllProjects(yearId, kind, group, search),
+ queryKey: ['projects', yearId, kind, group, search, cursor ?? null],
+ queryFn: () => apiRequest(`/projects?${query}`),
placeholderData: keepPreviousData,
});
}
-async function fetchAllProjects(
- yearId: string,
- kind?: 'project' | 'idea',
- group?: string,
- search?: string,
-): Promise {
- const projects: ProjectsResponse['projects'] = [];
- let cursor: string | undefined;
-
- do {
- const query = new URLSearchParams({year: yearId, limit: '50'});
- if (kind) query.set('kind', kind);
- if (group) query.set('group', group);
- if (search) query.set('q', search);
- if (cursor) query.set('cursor', cursor);
-
- const page = await apiRequest(`/projects?${query}`);
- projects.push(...page.projects);
- cursor = page.nextCursor ?? undefined;
- } while (cursor);
-
- return {projects, nextCursor: null};
-}
-
export function useProject(projectId: string) {
return useQuery({
queryKey: ['project', projectId],
diff --git a/src/app/routes/ProjectsPage.tsx b/src/app/routes/ProjectsPage.tsx
index efceb57..642a568 100644
--- a/src/app/routes/ProjectsPage.tsx
+++ b/src/app/routes/ProjectsPage.tsx
@@ -35,6 +35,8 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
const [group, setGroup] = useState('');
const [searchInput, setSearchInput] = useState('');
const [search, setSearch] = useState('');
+ const [cursor, setCursor] = useState();
+ const [cursorHistory, setCursorHistory] = useState>([]);
const [view, setView] = useState(getProjectsView);
const year = useYear(yearId);
const projects = useProjects(
@@ -42,8 +44,20 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
kind,
kind === 'project' ? group || undefined : undefined,
search || undefined,
+ cursor,
);
const error = year.error ?? projects.error;
+ const pageProjects = projects.data?.projects ?? [];
+ const nextCursor = projects.data?.nextCursor ?? null;
+ const pageOffset = cursor ? Number(cursor) : 0;
+ const pageStart = pageOffset + 1;
+ const pageEnd = pageOffset + pageProjects.length;
+ const showPagination = Boolean(cursor || nextCursor);
+
+ const resetPagination = () => {
+ setCursor(undefined);
+ setCursorHistory([]);
+ };
useEffect(() => {
const timeout = window.setTimeout(() => {
@@ -52,6 +66,10 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
return () => window.clearTimeout(timeout);
}, [searchInput]);
+ useEffect(() => {
+ resetPagination();
+ }, [yearId, search]);
+
return (
{!year.data ? (
@@ -136,13 +154,19 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
@@ -153,7 +177,10 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
Group
)}
- {!projects.data?.projects.length ? (
+ {!pageProjects.length ? (
∅
No {kind === 'idea' ? 'ideas' : 'projects'} found
@@ -210,14 +237,50 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
) : (
-
- {projects.data.projects.map((project) => (
-
- ))}
-
+ <>
+
+ {pageProjects.map((project) => (
+
+ ))}
+
+ {showPagination && (
+
+ )}
+ >
)}
)}
diff --git a/src/app/styles.css b/src/app/styles.css
index 76ff0db..06cace1 100644
--- a/src/app/styles.css
+++ b/src/app/styles.css
@@ -652,6 +652,30 @@ main {
align-items: center;
justify-content: flex-end;
}
+.projectPagination {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1rem;
+ align-items: center;
+ justify-content: space-between;
+ margin-top: 1.5rem;
+ padding-top: 1rem;
+ border-top: 1px solid var(--line);
+}
+.projectPagination p {
+ margin: 0;
+ color: var(--muted);
+ font-size: 0.85rem;
+}
+.projectPagination > div {
+ display: flex;
+ gap: 0.75rem;
+ align-items: center;
+}
+.projectPagination button:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
.projectViewToggle {
display: inline-flex;
gap: 2px;
@@ -2769,6 +2793,14 @@ kbd {
width: 100%;
justify-content: space-between;
}
+ .projectPagination {
+ align-items: stretch;
+ flex-direction: column;
+ }
+ .projectPagination > div {
+ width: 100%;
+ justify-content: space-between;
+ }
.projectRow {
grid-template-areas:
'name name'
diff --git a/src/worker/routes/projects.ts b/src/worker/routes/projects.ts
index afd8d62..802f1b0 100644
--- a/src/worker/routes/projects.ts
+++ b/src/worker/routes/projects.ts
@@ -30,7 +30,7 @@ projectsRoutes.get('/', async (c) => {
throw new ServiceError('VALIDATION_FAILED', 'Kind query is invalid', 400);
}
const kind = kindQuery === 'project' || kindQuery === 'idea' ? kindQuery : undefined;
- const limit = boundedInteger(c.req.query('limit'), 24, 1, 50, 'Limit');
+ const limit = boundedInteger(c.req.query('limit'), 24, 1, 250, 'Limit');
const offset = boundedInteger(c.req.query('cursor'), 0, 0, 100_000, 'Cursor');
const search = boundedSearch(c.req.query('q'));
const response: ProjectsResponse = await listProjects(c.env.DB, {
diff --git a/test/app/routes.test.tsx b/test/app/routes.test.tsx
index 16bd8e2..0555b78 100644
--- a/test/app/routes.test.tsx
+++ b/test/app/routes.test.tsx
@@ -345,7 +345,7 @@ describe('clickable project routes', () => {
).toBe('true');
});
- it('loads every project page until nextCursor is exhausted', async () => {
+ it('requests a 250-item page and paginates with next/previous controls', async () => {
fetchMock.mockImplementation(async (input) => {
const url = input instanceof Request ? input.url : input.toString();
if (url.includes('/api/years/2026')) {
@@ -354,10 +354,10 @@ describe('clickable project routes', () => {
id: '2026',
votingEnabled: false,
submissionsClosed: false,
- projectCount: 51,
+ projectCount: 251,
ideaCount: 0,
groupCount: 0,
- participantCount: 51,
+ participantCount: 251,
},
groups: [],
awards: [],
@@ -365,38 +365,57 @@ describe('clickable project routes', () => {
}
const requestUrl = new URL(url, 'https://hackweek.test');
+ expect(requestUrl.searchParams.get('limit')).toBe('250');
const cursor = requestUrl.searchParams.get('cursor');
- if (cursor === '50') {
+ if (cursor === '250') {
return json({
- projects: [{...projectFixture, id: 'project-51', name: 'Project 51'}],
+ projects: [{...projectFixture, id: 'project-251', name: 'Project 251'}],
nextCursor: null,
});
}
return json({
- projects: Array.from({length: 50}, (_, index) => ({
+ projects: Array.from({length: 250}, (_, index) => ({
...projectFixture,
id: `project-${index + 1}`,
name: `Project ${index + 1}`,
})),
- nextCursor: '50',
+ nextCursor: '250',
});
});
renderRoute(, '/years/2026/projects', '/years/:yearId/projects');
expect(await screen.findByRole('heading', {name: 'Project 1'})).toBeTruthy();
- expect(await screen.findByRole('heading', {name: 'Project 51'})).toBeTruthy();
- expect(screen.getByRole('region', {name: 'project list'}).children).toHaveLength(51);
+ expect(screen.getByRole('region', {name: 'project list'}).children).toHaveLength(250);
+ expect(screen.getByText('showing 1–250+')).toBeTruthy();
+ expect(screen.getByRole('button', {name: 'previous'}).hasAttribute('disabled')).toBe(
+ true,
+ );
+
+ await userEvent.click(screen.getByRole('button', {name: 'next'}));
+
+ expect(await screen.findByRole('heading', {name: 'Project 251'})).toBeTruthy();
+ expect(screen.getByText('showing 251–251')).toBeTruthy();
+ expect(screen.getByRole('button', {name: 'next'}).hasAttribute('disabled')).toBe(
+ true,
+ );
+
+ await userEvent.click(screen.getByRole('button', {name: 'previous'}));
+
+ expect(await screen.findByRole('heading', {name: 'Project 1'})).toBeTruthy();
+ expect(screen.getByRole('button', {name: 'previous'}).hasAttribute('disabled')).toBe(
+ true,
+ );
expect(fetchMock).toHaveBeenCalledWith(
expect.stringMatching(
- /\/api\/projects\?(?=.*year=2026)(?=.*limit=50)(?!.*cursor=)/,
+ /\/api\/projects\?(?=.*year=2026)(?=.*limit=250)(?!.*cursor=)/,
),
undefined,
);
expect(fetchMock).toHaveBeenCalledWith(
expect.stringMatching(
- /\/api\/projects\?(?=.*year=2026)(?=.*limit=50)(?=.*cursor=50)/,
+ /\/api\/projects\?(?=.*year=2026)(?=.*limit=250)(?=.*cursor=250)/,
),
undefined,
);
From 8a8f1a64d9baea99e46fc0ae2b13829b0978baa8 Mon Sep 17 00:00:00 2001
From: "sentry-junior[bot]"
<264270552+sentry-junior[bot]@users.noreply.github.com>
Date: Mon, 17 Aug 2026 20:03:32 +0000
Subject: [PATCH 4/9] fix(projects): batch member lookups under D1 bind limit
D1 allows at most 100 bound parameters per query. listProjects loads
members with IN (...), so page sizes above 100 failed. Chunk those
lookups and cover the path with a 101-project list test.
Co-Authored-By: Trevor Elkins
---
src/worker/repositories/projects.ts | 37 ++++++++++++++++++-----------
test/projects/projects.test.ts | 31 ++++++++++++++++++++++++
2 files changed, 54 insertions(+), 14 deletions(-)
diff --git a/src/worker/repositories/projects.ts b/src/worker/repositories/projects.ts
index 5c26a97..b740ad6 100644
--- a/src/worker/repositories/projects.ts
+++ b/src/worker/repositories/projects.ts
@@ -534,24 +534,33 @@ async function assertUsersExist(db: D1Database, ids: string[]) {
}
}
+// D1 allows at most 100 bound parameters per query.
+// https://developers.cloudflare.com/d1/platform/limits/
+const D1_MAX_BOUND_PARAMETERS = 100;
+
async function membersByProjectIds(db: D1Database, ids: string[]) {
const result = new Map();
if (!ids.length) return result;
- const placeholders = ids.map(() => '?').join(',');
- const {results} = await db
- .prepare(
- `SELECT pm.project_id, u.id, u.email, u.display_name, u.avatar_url, u.is_admin
- FROM project_members pm JOIN users u ON u.id = pm.user_id
- WHERE pm.project_id IN (${placeholders})
- ORDER BY u.display_name COLLATE NOCASE, u.id`,
- )
- .bind(...ids)
- .all();
- for (const row of results) {
- const members = result.get(row.project_id) ?? [];
- members.push(mapMember(row));
- result.set(row.project_id, members);
+
+ for (let offset = 0; offset < ids.length; offset += D1_MAX_BOUND_PARAMETERS) {
+ const chunk = ids.slice(offset, offset + D1_MAX_BOUND_PARAMETERS);
+ const placeholders = chunk.map(() => '?').join(',');
+ const {results} = await db
+ .prepare(
+ `SELECT pm.project_id, u.id, u.email, u.display_name, u.avatar_url, u.is_admin
+ FROM project_members pm JOIN users u ON u.id = pm.user_id
+ WHERE pm.project_id IN (${placeholders})
+ ORDER BY u.display_name COLLATE NOCASE, u.id`,
+ )
+ .bind(...chunk)
+ .all();
+ for (const row of results) {
+ const members = result.get(row.project_id) ?? [];
+ members.push(mapMember(row));
+ result.set(row.project_id, members);
+ }
}
+
return result;
}
diff --git a/test/projects/projects.test.ts b/test/projects/projects.test.ts
index 813b40d..e73cab5 100644
--- a/test/projects/projects.test.ts
+++ b/test/projects/projects.test.ts
@@ -74,6 +74,37 @@ describe('project and history APIs', () => {
expect(page.body.projects[0].members).toBeInstanceOf(Array);
});
+ it('lists more than 100 projects without exceeding D1 bound-parameter limits', async () => {
+ const user = await env.DB.prepare('SELECT id FROM users WHERE google_subject = ?')
+ .bind(`project-member-${suffix}`)
+ .first<{id: string}>();
+ const statements = Array.from({length: 101}, (_, index) => {
+ const id = `bulk-project-${suffix}-${index}`;
+ return [
+ env.DB.prepare(
+ `INSERT INTO projects (id, source_id, year_id, creator_id, name, kind, group_id)
+ VALUES (?, ?, ?, ?, ?, 'project', ?)`,
+ ).bind(id, id, yearId, user!.id, `Bulk project ${index}`, groupId),
+ env.DB.prepare(
+ 'INSERT INTO project_members (project_id, user_id) VALUES (?, ?)',
+ ).bind(id, user!.id),
+ ];
+ }).flat();
+ await env.DB.batch(statements);
+
+ const page = await api(
+ `/projects?year=${yearId}&kind=project&limit=101`,
+ memberToken,
+ );
+
+ expect(page.status).toBe(200);
+ expect(page.body.projects).toHaveLength(101);
+ expect(page.body.nextCursor).toBeNull();
+ expect(page.body.projects[0].members).toEqual([
+ expect.objectContaining({email: `project-member-${suffix}@sentry.io`}),
+ ]);
+ });
+
it('searches titles and descriptions before pagination with relevant results first', async () => {
const exact = await createProject(memberToken, {
name: 'Signal',
From 85ad65c2ed7aaad4384829185c770f8491869b67 Mon Sep 17 00:00:00 2001
From: Daniel Griesser
Date: Tue, 18 Aug 2026 13:51:19 +0200
Subject: [PATCH 5/9] fix(projects): restore accessible pagination recovery
Keep page controls rendered when a later cursor returns no results so users can navigate back. Move focus to the loaded result start only after placeholder data is replaced, and announce loading, completed ranges, and empty pages through the paginator status.\n\nCover empty-page recovery and deferred page transitions to prevent stale placeholder focus regressions.
---
src/app/routes/ProjectsPage.tsx | 125 +++++++++++++++++++-------------
test/app/routes.test.tsx | 94 +++++++++++++++++++++---
2 files changed, 159 insertions(+), 60 deletions(-)
diff --git a/src/app/routes/ProjectsPage.tsx b/src/app/routes/ProjectsPage.tsx
index 55567fc..135dccf 100644
--- a/src/app/routes/ProjectsPage.tsx
+++ b/src/app/routes/ProjectsPage.tsx
@@ -1,4 +1,4 @@
-import {useEffect, useState} from 'react';
+import {useEffect, useRef, useState} from 'react';
import {Link, useParams} from 'wouter';
import type {BallotStatusResponse} from '../../shared/administration';
@@ -40,6 +40,8 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
const [cursor, setCursor] = useState();
const [cursorHistory, setCursorHistory] = useState>([]);
const [view, setView] = useState(getProjectsView);
+ const resultStart = useRef(null);
+ const paginationRequestPending = useRef(false);
const year = useYear(yearId);
const ballot = useBallotStatus(yearId, year.data?.year.votingEnabled ?? false);
const projects = useProjects(
@@ -57,6 +59,11 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
const pageStart = pageOffset + 1;
const pageEnd = pageOffset + pageProjects.length;
const showPagination = Boolean(cursor || nextCursor);
+ const pageStatus = projects.isPlaceholderData
+ ? 'loading page…'
+ : pageProjects.length
+ ? `showing ${pageStart}–${pageEnd}${nextCursor ? '+' : ''}`
+ : `no ${kind === 'idea' ? 'ideas' : 'projects'} found on this page`;
const resetPagination = () => {
setCursor(undefined);
@@ -74,6 +81,18 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
resetPagination();
}, [yearId, search]);
+ useEffect(() => {
+ if (
+ !paginationRequestPending.current ||
+ projects.isFetching ||
+ projects.isPlaceholderData
+ ) {
+ return;
+ }
+ paginationRequestPending.current = false;
+ resultStart.current?.focus();
+ }, [cursor, projects.isFetching, projects.isPlaceholderData]);
+
return (
{!year.data ? (
@@ -234,7 +253,12 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
)}
{!pageProjects.length ? (
-
+
∅
No {kind === 'idea' ? 'ideas' : 'projects'} found
@@ -244,55 +268,54 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
) : (
- <>
-
- {pageProjects.map((project) => (
-
- ))}
-
- {showPagination && (
-
- )}
- >
+
+ {pageProjects.map((project) => (
+
+ ))}
+
+ )}
+ {showPagination && (
+
)}
)}
diff --git a/test/app/routes.test.tsx b/test/app/routes.test.tsx
index a4af188..82362b9 100644
--- a/test/app/routes.test.tsx
+++ b/test/app/routes.test.tsx
@@ -562,7 +562,11 @@ describe('clickable project routes', () => {
).toBe('true');
});
- it('requests a 250-item page and paginates with next/previous controls', async () => {
+ it('requests a 250-item page and focuses and announces loaded pages', async () => {
+ let resolveSecondPage!: (response: Response) => void;
+ const pendingSecondPage = new Promise((resolve) => {
+ resolveSecondPage = resolve;
+ });
fetchMock.mockImplementation(async (input) => {
const url = input instanceof Request ? input.url : input.toString();
if (url.includes('/api/years/2026')) {
@@ -584,12 +588,7 @@ describe('clickable project routes', () => {
const requestUrl = new URL(url, 'https://hackweek.test');
expect(requestUrl.searchParams.get('limit')).toBe('250');
const cursor = requestUrl.searchParams.get('cursor');
- if (cursor === '250') {
- return json({
- projects: [{...projectFixture, id: 'project-251', name: 'Project 251'}],
- nextCursor: null,
- });
- }
+ if (cursor === '250') return pendingSecondPage;
return json({
projects: Array.from({length: 250}, (_, index) => ({
@@ -610,10 +609,29 @@ describe('clickable project routes', () => {
true,
);
- await userEvent.click(screen.getByRole('button', {name: 'next'}));
+ const next = screen.getByRole('button', {name: 'next'});
+ await userEvent.click(next);
+
+ const pagination = screen.getByRole('navigation', {name: 'Project pages'});
+ expect(within(pagination).getByRole('status').textContent).toBe('loading page…');
+ expect(screen.getByRole('heading', {name: 'Project 1'})).toBeTruthy();
+ expect(document.activeElement).toBe(next);
+
+ resolveSecondPage(
+ json({
+ projects: [{...projectFixture, id: 'project-251', name: 'Project 251'}],
+ nextCursor: null,
+ }),
+ );
expect(await screen.findByRole('heading', {name: 'Project 251'})).toBeTruthy();
- expect(screen.getByText('showing 251–251')).toBeTruthy();
+ expect(within(pagination).getByRole('status').textContent).toBe('showing 251–251');
+ await waitFor(() =>
+ expect(document.activeElement).toBe(
+ screen.getByRole('region', {name: 'project list'}),
+ ),
+ );
+ expect(screen.queryByRole('heading', {name: 'Project 1'})).toBeNull();
expect(screen.getByRole('button', {name: 'next'}).hasAttribute('disabled')).toBe(
true,
);
@@ -621,6 +639,12 @@ describe('clickable project routes', () => {
await userEvent.click(screen.getByRole('button', {name: 'previous'}));
expect(await screen.findByRole('heading', {name: 'Project 1'})).toBeTruthy();
+ expect(within(pagination).getByRole('status').textContent).toBe('showing 1–250+');
+ await waitFor(() =>
+ expect(document.activeElement).toBe(
+ screen.getByRole('region', {name: 'project list'}),
+ ),
+ );
expect(screen.getByRole('button', {name: 'previous'}).hasAttribute('disabled')).toBe(
true,
);
@@ -638,6 +662,58 @@ describe('clickable project routes', () => {
);
});
+ it('keeps Previous available and announces an empty later page', async () => {
+ fetchMock.mockImplementation(async (input) => {
+ const url = input instanceof Request ? input.url : input.toString();
+ if (url.includes('/api/years/2026')) {
+ return json({
+ year: {
+ id: '2026',
+ votingEnabled: false,
+ submissionsClosed: false,
+ projectCount: 1,
+ ideaCount: 0,
+ groupCount: 0,
+ participantCount: 1,
+ },
+ groups: [],
+ awards: [],
+ });
+ }
+
+ const cursor = new URL(url, 'https://hackweek.test').searchParams.get('cursor');
+ if (cursor === '250') return json({projects: [], nextCursor: null});
+ return json({projects: [projectFixture], nextCursor: '250'});
+ });
+
+ renderRoute(, '/years/2026/projects', '/years/:yearId/projects');
+
+ expect(await screen.findByRole('heading', {name: 'A small machine'})).toBeTruthy();
+ await userEvent.click(screen.getByRole('button', {name: 'next'}));
+
+ expect(await screen.findByRole('heading', {name: 'No projects found'})).toBeTruthy();
+ const emptyResults = screen.getByRole('region', {name: 'project results'});
+ const pagination = screen.getByRole('navigation', {name: 'Project pages'});
+ expect(within(pagination).getByRole('status').textContent).toBe(
+ 'no projects found on this page',
+ );
+ const previous = within(pagination).getByRole('button', {name: 'previous'});
+ expect(previous.hasAttribute('disabled')).toBe(false);
+ await waitFor(() => expect(document.activeElement).toBe(emptyResults));
+
+ await userEvent.click(previous);
+
+ expect(await screen.findByRole('heading', {name: 'A small machine'})).toBeTruthy();
+ await waitFor(() =>
+ expect(document.activeElement).toBe(
+ screen.getByRole('region', {name: 'project list'}),
+ ),
+ );
+ expect(screen.getByRole('button', {name: 'previous'}).hasAttribute('disabled')).toBe(
+ true,
+ );
+ });
+
it('live-updates server search without replacing the current list', async () => {
let resolveSearch!: (response: Response) => void;
const pendingSearch = new Promise((resolve) => {
From b972dc33c0e74c8737d0d014b23cf02ab58dec3b Mon Sep 17 00:00:00 2001
From: Daniel Griesser
Date: Tue, 18 Aug 2026 13:58:10 +0200
Subject: [PATCH 6/9] fix(projects): cancel stale pagination focus
Clear pending post-pagination focus whenever filters, year changes, or debounced search reset the cursor. This prevents replacement query results from stealing focus from the control that superseded an in-flight page request.
Add a race regression covering a kind filter change while the next page remains pending.
---
src/app/routes/ProjectsPage.tsx | 1 +
test/app/routes.test.tsx | 75 +++++++++++++++++++++++++++++++++
2 files changed, 76 insertions(+)
diff --git a/src/app/routes/ProjectsPage.tsx b/src/app/routes/ProjectsPage.tsx
index 135dccf..42b98cc 100644
--- a/src/app/routes/ProjectsPage.tsx
+++ b/src/app/routes/ProjectsPage.tsx
@@ -66,6 +66,7 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
: `no ${kind === 'idea' ? 'ideas' : 'projects'} found on this page`;
const resetPagination = () => {
+ paginationRequestPending.current = false;
setCursor(undefined);
setCursorHistory([]);
};
diff --git a/test/app/routes.test.tsx b/test/app/routes.test.tsx
index 82362b9..124965e 100644
--- a/test/app/routes.test.tsx
+++ b/test/app/routes.test.tsx
@@ -662,6 +662,81 @@ describe('clickable project routes', () => {
);
});
+ it('preserves filter focus when it supersedes a pending page fetch', async () => {
+ let resolveSecondPage!: (response: Response) => void;
+ let resolveIdeas!: (response: Response) => void;
+ const pendingSecondPage = new Promise((resolve) => {
+ resolveSecondPage = resolve;
+ });
+ const pendingIdeas = new Promise((resolve) => {
+ resolveIdeas = resolve;
+ });
+ fetchMock.mockImplementation(async (input) => {
+ const url = input instanceof Request ? input.url : input.toString();
+ if (url.includes('/api/years/2026')) {
+ return json({
+ year: {
+ id: '2026',
+ votingEnabled: false,
+ submissionsClosed: false,
+ projectCount: 251,
+ ideaCount: 1,
+ groupCount: 0,
+ participantCount: 252,
+ },
+ groups: [],
+ awards: [],
+ });
+ }
+
+ const requestUrl = new URL(url, 'https://hackweek.test');
+ if (requestUrl.searchParams.get('kind') === 'idea') return pendingIdeas;
+ if (requestUrl.searchParams.get('cursor') === '250') return pendingSecondPage;
+ return json({projects: [projectFixture], nextCursor: '250'});
+ });
+
+ renderRoute(, '/years/2026/projects', '/years/:yearId/projects');
+
+ expect(await screen.findByRole('heading', {name: 'A small machine'})).toBeTruthy();
+ await userEvent.click(screen.getByRole('button', {name: 'next'}));
+ await waitFor(() =>
+ expect(fetchMock).toHaveBeenCalledWith(
+ expect.stringContaining('cursor=250'),
+ undefined,
+ ),
+ );
+
+ const ideas = screen.getByRole('button', {name: /Ideas/});
+ await userEvent.click(ideas);
+ expect(document.activeElement).toBe(ideas);
+
+ resolveIdeas(
+ json({
+ projects: [
+ {
+ ...projectFixture,
+ id: 'idea',
+ name: 'Open signal',
+ kind: 'idea',
+ group: null,
+ members: [],
+ },
+ ],
+ nextCursor: null,
+ }),
+ );
+
+ expect(await screen.findByRole('heading', {name: 'Open signal'})).toBeTruthy();
+ await waitFor(() => expect(document.activeElement).toBe(ideas));
+
+ resolveSecondPage(
+ json({
+ projects: [{...projectFixture, id: 'project-251', name: 'Project 251'}],
+ nextCursor: null,
+ }),
+ );
+ });
+
it('keeps Previous available and announces an empty later page', async () => {
fetchMock.mockImplementation(async (input) => {
const url = input instanceof Request ? input.url : input.toString();
From 9b9134a754a4ef1f890c6ad2b1daabef4da45771 Mon Sep 17 00:00:00 2001
From: "sentry-junior[bot]"
<264270552+sentry-junior[bot]@users.noreply.github.com>
Date: Tue, 18 Aug 2026 12:07:05 +0000
Subject: [PATCH 7/9] fix(projects): preserve search focus during pagination
---
src/app/routes/ProjectsPage.tsx | 11 +++++--
test/app/routes.test.tsx | 56 +++++++++++++++++++++++++++++++++
2 files changed, 65 insertions(+), 2 deletions(-)
diff --git a/src/app/routes/ProjectsPage.tsx b/src/app/routes/ProjectsPage.tsx
index 42b98cc..6aed01d 100644
--- a/src/app/routes/ProjectsPage.tsx
+++ b/src/app/routes/ProjectsPage.tsx
@@ -156,13 +156,17 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
value={searchInput}
maxLength={100}
placeholder="Search titles and descriptions"
- onChange={(event) => setSearchInput(event.target.value)}
+ onChange={(event) => {
+ paginationRequestPending.current = false;
+ setSearchInput(event.target.value);
+ }}
/>
{search && (
)}
{projects.isFetching && (
-
+
updating…
)}
diff --git a/test/app/routes.test.tsx b/test/app/routes.test.tsx
index 124965e..dcb865c 100644
--- a/test/app/routes.test.tsx
+++ b/test/app/routes.test.tsx
@@ -737,6 +737,62 @@ describe('clickable project routes', () => {
);
});
+ it('keeps search focus when typing supersedes a pending page fetch', async () => {
+ let resolveSecondPage!: (response: Response) => void;
+ const pendingSecondPage = new Promise((resolve) => {
+ resolveSecondPage = resolve;
+ });
+ fetchMock.mockImplementation(async (input) => {
+ const url = input instanceof Request ? input.url : input.toString();
+ if (url.includes('/api/years/2026')) {
+ return json({
+ year: {
+ id: '2026',
+ votingEnabled: false,
+ submissionsClosed: false,
+ projectCount: 251,
+ ideaCount: 0,
+ groupCount: 0,
+ participantCount: 251,
+ },
+ groups: [],
+ awards: [],
+ });
+ }
+
+ const requestUrl = new URL(url, 'https://hackweek.test');
+ if (requestUrl.searchParams.get('cursor') === '250') return pendingSecondPage;
+ return json({projects: [projectFixture], nextCursor: '250'});
+ });
+
+ renderRoute(, '/years/2026/projects', '/years/:yearId/projects');
+
+ expect(await screen.findByRole('heading', {name: 'A small machine'})).toBeTruthy();
+ await userEvent.click(screen.getByRole('button', {name: 'next'}));
+ await waitFor(() =>
+ expect(fetchMock).toHaveBeenCalledWith(
+ expect.stringContaining('cursor=250'),
+ undefined,
+ ),
+ );
+
+ const searchInput = screen.getByRole('searchbox', {
+ name: 'Search projects and ideas',
+ });
+ await userEvent.type(searchInput, 's');
+ expect(document.activeElement).toBe(searchInput);
+
+ resolveSecondPage(
+ json({
+ projects: [{...projectFixture, id: 'project-251', name: 'Project 251'}],
+ nextCursor: null,
+ }),
+ );
+
+ expect(await screen.findByRole('heading', {name: 'Project 251'})).toBeTruthy();
+ await waitFor(() => expect(document.activeElement).toBe(searchInput));
+ });
+
it('keeps Previous available and announces an empty later page', async () => {
fetchMock.mockImplementation(async (input) => {
const url = input instanceof Request ? input.url : input.toString();
From 9f0044cc29ddf6381b670b3444a4a4e0567a54fc Mon Sep 17 00:00:00 2001
From: "sentry-junior[bot]"
<264270552+sentry-junior[bot]@users.noreply.github.com>
Date: Tue, 18 Aug 2026 14:19:36 +0000
Subject: [PATCH 8/9] fix(projects): raise page size to 250 and add page
controls
The projects page stopped at the first API page, so years with more than
50 projects hid the rest. Raise the API max and client page size to 250,
add previous/next controls when a next cursor exists, batch member
lookups under D1's 100-parameter bind limit, and keep search focus when
a pending page load is superseded.
Co-Authored-By: Trevor Elkins
---
src/app/queries/projects.ts | 11 +-
src/app/routes/ProjectsPage.tsx | 112 ++++++++++-
src/app/styles.css | 32 ++++
src/worker/repositories/projects.ts | 37 ++--
src/worker/routes/projects.ts | 2 +-
test/app/routes.test.tsx | 283 ++++++++++++++++++++++++++++
test/projects/projects.test.ts | 33 +++-
7 files changed, 483 insertions(+), 27 deletions(-)
diff --git a/src/app/queries/projects.ts b/src/app/queries/projects.ts
index 1f30106..10f4261 100644
--- a/src/app/queries/projects.ts
+++ b/src/app/queries/projects.ts
@@ -32,18 +32,25 @@ export function useYear(yearId: string) {
});
}
+export const PROJECTS_PAGE_SIZE = 250;
+
export function useProjects(
yearId: string,
kind?: 'project' | 'idea',
group?: string,
search?: string,
+ cursor?: string,
) {
- const query = new URLSearchParams({year: yearId, limit: '50'});
+ const query = new URLSearchParams({
+ year: yearId,
+ limit: String(PROJECTS_PAGE_SIZE),
+ });
if (kind) query.set('kind', kind);
if (group) query.set('group', group);
if (search) query.set('q', search);
+ if (cursor) query.set('cursor', cursor);
return useQuery({
- queryKey: ['projects', yearId, kind, group, search],
+ queryKey: ['projects', yearId, kind, group, search, cursor ?? null],
queryFn: () => apiRequest(`/projects?${query}`),
placeholderData: keepPreviousData,
});
diff --git a/src/app/routes/ProjectsPage.tsx b/src/app/routes/ProjectsPage.tsx
index 3f25485..6aed01d 100644
--- a/src/app/routes/ProjectsPage.tsx
+++ b/src/app/routes/ProjectsPage.tsx
@@ -1,4 +1,4 @@
-import {useEffect, useState} from 'react';
+import {useEffect, useRef, useState} from 'react';
import {Link, useParams} from 'wouter';
import type {BallotStatusResponse} from '../../shared/administration';
@@ -37,7 +37,11 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
const [group, setGroup] = useState('');
const [searchInput, setSearchInput] = useState('');
const [search, setSearch] = useState('');
+ const [cursor, setCursor] = useState();
+ const [cursorHistory, setCursorHistory] = useState>([]);
const [view, setView] = useState(getProjectsView);
+ const resultStart = useRef(null);
+ const paginationRequestPending = useRef(false);
const year = useYear(yearId);
const ballot = useBallotStatus(yearId, year.data?.year.votingEnabled ?? false);
const projects = useProjects(
@@ -45,9 +49,27 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
kind,
kind === 'project' ? group || undefined : undefined,
search || undefined,
+ cursor,
);
const error = year.error ?? projects.error;
const voteCategoriesByProject = selectedCategoriesByProject(ballot.data);
+ const pageProjects = projects.data?.projects ?? [];
+ const nextCursor = projects.data?.nextCursor ?? null;
+ const pageOffset = cursor ? Number(cursor) : 0;
+ const pageStart = pageOffset + 1;
+ const pageEnd = pageOffset + pageProjects.length;
+ const showPagination = Boolean(cursor || nextCursor);
+ const pageStatus = projects.isPlaceholderData
+ ? 'loading page…'
+ : pageProjects.length
+ ? `showing ${pageStart}–${pageEnd}${nextCursor ? '+' : ''}`
+ : `no ${kind === 'idea' ? 'ideas' : 'projects'} found on this page`;
+
+ const resetPagination = () => {
+ paginationRequestPending.current = false;
+ setCursor(undefined);
+ setCursorHistory([]);
+ };
useEffect(() => {
const timeout = window.setTimeout(() => {
@@ -56,6 +78,22 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
return () => window.clearTimeout(timeout);
}, [searchInput]);
+ useEffect(() => {
+ resetPagination();
+ }, [yearId, search]);
+
+ useEffect(() => {
+ if (
+ !paginationRequestPending.current ||
+ projects.isFetching ||
+ projects.isPlaceholderData
+ ) {
+ return;
+ }
+ paginationRequestPending.current = false;
+ resultStart.current?.focus();
+ }, [cursor, projects.isFetching, projects.isPlaceholderData]);
+
return (
{!year.data ? (
@@ -118,13 +156,17 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
value={searchInput}
maxLength={100}
placeholder="Search titles and descriptions"
- onChange={(event) => setSearchInput(event.target.value)}
+ onChange={(event) => {
+ paginationRequestPending.current = false;
+ setSearchInput(event.target.value);
+ }}
/>
{search && (
)}
{projects.isFetching && (
-
+
updating…
)}
@@ -143,13 +188,19 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
@@ -160,7 +211,10 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
Group
)}
- {!projects.data?.projects.length ? (
-
+ {!pageProjects.length ? (
+
∅
No {kind === 'idea' ? 'ideas' : 'projects'} found
@@ -220,8 +279,10 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
- {projects.data.projects.map((project) => (
+ {pageProjects.map((project) => (
)}
+ {showPagination && (
+
+ )}
)}
diff --git a/src/app/styles.css b/src/app/styles.css
index 865871d..1239ce9 100644
--- a/src/app/styles.css
+++ b/src/app/styles.css
@@ -829,6 +829,30 @@ main {
align-items: center;
justify-content: flex-end;
}
+.projectPagination {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1rem;
+ align-items: center;
+ justify-content: space-between;
+ margin-top: 1.5rem;
+ padding-top: 1rem;
+ border-top: 1px solid var(--line);
+}
+.projectPagination p {
+ margin: 0;
+ color: var(--muted);
+ font-size: 0.85rem;
+}
+.projectPagination > div {
+ display: flex;
+ gap: 0.75rem;
+ align-items: center;
+}
+.projectPagination button:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
.projectViewToggle {
display: inline-flex;
gap: 2px;
@@ -3130,6 +3154,14 @@ kbd {
width: 100%;
justify-content: space-between;
}
+ .projectPagination {
+ align-items: stretch;
+ flex-direction: column;
+ }
+ .projectPagination > div {
+ width: 100%;
+ justify-content: space-between;
+ }
.projectRow {
grid-template-areas:
'name name'
diff --git a/src/worker/repositories/projects.ts b/src/worker/repositories/projects.ts
index da3c738..d91ed97 100644
--- a/src/worker/repositories/projects.ts
+++ b/src/worker/repositories/projects.ts
@@ -535,24 +535,33 @@ async function assertUsersExist(db: D1Database, ids: string[]) {
}
}
+// D1 allows at most 100 bound parameters per query.
+// https://developers.cloudflare.com/d1/platform/limits/
+const D1_MAX_BOUND_PARAMETERS = 100;
+
async function membersByProjectIds(db: D1Database, ids: string[]) {
const result = new Map();
if (!ids.length) return result;
- const placeholders = ids.map(() => '?').join(',');
- const {results} = await db
- .prepare(
- `SELECT pm.project_id, u.id, u.email, u.display_name, u.avatar_url, u.is_admin
- FROM project_members pm JOIN users u ON u.id = pm.user_id
- WHERE pm.project_id IN (${placeholders})
- ORDER BY u.display_name COLLATE NOCASE, u.id`,
- )
- .bind(...ids)
- .all();
- for (const row of results) {
- const members = result.get(row.project_id) ?? [];
- members.push(mapMember(row));
- result.set(row.project_id, members);
+
+ for (let offset = 0; offset < ids.length; offset += D1_MAX_BOUND_PARAMETERS) {
+ const chunk = ids.slice(offset, offset + D1_MAX_BOUND_PARAMETERS);
+ const placeholders = chunk.map(() => '?').join(',');
+ const {results} = await db
+ .prepare(
+ `SELECT pm.project_id, u.id, u.email, u.display_name, u.avatar_url, u.is_admin
+ FROM project_members pm JOIN users u ON u.id = pm.user_id
+ WHERE pm.project_id IN (${placeholders})
+ ORDER BY u.display_name COLLATE NOCASE, u.id`,
+ )
+ .bind(...chunk)
+ .all();
+ for (const row of results) {
+ const members = result.get(row.project_id) ?? [];
+ members.push(mapMember(row));
+ result.set(row.project_id, members);
+ }
}
+
return result;
}
diff --git a/src/worker/routes/projects.ts b/src/worker/routes/projects.ts
index afd8d62..802f1b0 100644
--- a/src/worker/routes/projects.ts
+++ b/src/worker/routes/projects.ts
@@ -30,7 +30,7 @@ projectsRoutes.get('/', async (c) => {
throw new ServiceError('VALIDATION_FAILED', 'Kind query is invalid', 400);
}
const kind = kindQuery === 'project' || kindQuery === 'idea' ? kindQuery : undefined;
- const limit = boundedInteger(c.req.query('limit'), 24, 1, 50, 'Limit');
+ const limit = boundedInteger(c.req.query('limit'), 24, 1, 250, 'Limit');
const offset = boundedInteger(c.req.query('cursor'), 0, 0, 100_000, 'Cursor');
const search = boundedSearch(c.req.query('q'));
const response: ProjectsResponse = await listProjects(c.env.DB, {
diff --git a/test/app/routes.test.tsx b/test/app/routes.test.tsx
index a9c7b7a..dcb865c 100644
--- a/test/app/routes.test.tsx
+++ b/test/app/routes.test.tsx
@@ -562,6 +562,289 @@ describe('clickable project routes', () => {
).toBe('true');
});
+ it('requests a 250-item page and focuses and announces loaded pages', async () => {
+ let resolveSecondPage!: (response: Response) => void;
+ const pendingSecondPage = new Promise((resolve) => {
+ resolveSecondPage = resolve;
+ });
+ fetchMock.mockImplementation(async (input) => {
+ const url = input instanceof Request ? input.url : input.toString();
+ if (url.includes('/api/years/2026')) {
+ return json({
+ year: {
+ id: '2026',
+ votingEnabled: false,
+ submissionsClosed: false,
+ projectCount: 251,
+ ideaCount: 0,
+ groupCount: 0,
+ participantCount: 251,
+ },
+ groups: [],
+ awards: [],
+ });
+ }
+
+ const requestUrl = new URL(url, 'https://hackweek.test');
+ expect(requestUrl.searchParams.get('limit')).toBe('250');
+ const cursor = requestUrl.searchParams.get('cursor');
+ if (cursor === '250') return pendingSecondPage;
+
+ return json({
+ projects: Array.from({length: 250}, (_, index) => ({
+ ...projectFixture,
+ id: `project-${index + 1}`,
+ name: `Project ${index + 1}`,
+ })),
+ nextCursor: '250',
+ });
+ });
+
+ renderRoute(, '/years/2026/projects', '/years/:yearId/projects');
+
+ expect(await screen.findByRole('heading', {name: 'Project 1'})).toBeTruthy();
+ expect(screen.getByRole('region', {name: 'project list'}).children).toHaveLength(250);
+ expect(screen.getByText('showing 1–250+')).toBeTruthy();
+ expect(screen.getByRole('button', {name: 'previous'}).hasAttribute('disabled')).toBe(
+ true,
+ );
+
+ const next = screen.getByRole('button', {name: 'next'});
+ await userEvent.click(next);
+
+ const pagination = screen.getByRole('navigation', {name: 'Project pages'});
+ expect(within(pagination).getByRole('status').textContent).toBe('loading page…');
+ expect(screen.getByRole('heading', {name: 'Project 1'})).toBeTruthy();
+ expect(document.activeElement).toBe(next);
+
+ resolveSecondPage(
+ json({
+ projects: [{...projectFixture, id: 'project-251', name: 'Project 251'}],
+ nextCursor: null,
+ }),
+ );
+
+ expect(await screen.findByRole('heading', {name: 'Project 251'})).toBeTruthy();
+ expect(within(pagination).getByRole('status').textContent).toBe('showing 251–251');
+ await waitFor(() =>
+ expect(document.activeElement).toBe(
+ screen.getByRole('region', {name: 'project list'}),
+ ),
+ );
+ expect(screen.queryByRole('heading', {name: 'Project 1'})).toBeNull();
+ expect(screen.getByRole('button', {name: 'next'}).hasAttribute('disabled')).toBe(
+ true,
+ );
+
+ await userEvent.click(screen.getByRole('button', {name: 'previous'}));
+
+ expect(await screen.findByRole('heading', {name: 'Project 1'})).toBeTruthy();
+ expect(within(pagination).getByRole('status').textContent).toBe('showing 1–250+');
+ await waitFor(() =>
+ expect(document.activeElement).toBe(
+ screen.getByRole('region', {name: 'project list'}),
+ ),
+ );
+ expect(screen.getByRole('button', {name: 'previous'}).hasAttribute('disabled')).toBe(
+ true,
+ );
+ expect(fetchMock).toHaveBeenCalledWith(
+ expect.stringMatching(
+ /\/api\/projects\?(?=.*year=2026)(?=.*limit=250)(?!.*cursor=)/,
+ ),
+ undefined,
+ );
+ expect(fetchMock).toHaveBeenCalledWith(
+ expect.stringMatching(
+ /\/api\/projects\?(?=.*year=2026)(?=.*limit=250)(?=.*cursor=250)/,
+ ),
+ undefined,
+ );
+ });
+
+ it('preserves filter focus when it supersedes a pending page fetch', async () => {
+ let resolveSecondPage!: (response: Response) => void;
+ let resolveIdeas!: (response: Response) => void;
+ const pendingSecondPage = new Promise((resolve) => {
+ resolveSecondPage = resolve;
+ });
+ const pendingIdeas = new Promise((resolve) => {
+ resolveIdeas = resolve;
+ });
+ fetchMock.mockImplementation(async (input) => {
+ const url = input instanceof Request ? input.url : input.toString();
+ if (url.includes('/api/years/2026')) {
+ return json({
+ year: {
+ id: '2026',
+ votingEnabled: false,
+ submissionsClosed: false,
+ projectCount: 251,
+ ideaCount: 1,
+ groupCount: 0,
+ participantCount: 252,
+ },
+ groups: [],
+ awards: [],
+ });
+ }
+
+ const requestUrl = new URL(url, 'https://hackweek.test');
+ if (requestUrl.searchParams.get('kind') === 'idea') return pendingIdeas;
+ if (requestUrl.searchParams.get('cursor') === '250') return pendingSecondPage;
+ return json({projects: [projectFixture], nextCursor: '250'});
+ });
+
+ renderRoute(, '/years/2026/projects', '/years/:yearId/projects');
+
+ expect(await screen.findByRole('heading', {name: 'A small machine'})).toBeTruthy();
+ await userEvent.click(screen.getByRole('button', {name: 'next'}));
+ await waitFor(() =>
+ expect(fetchMock).toHaveBeenCalledWith(
+ expect.stringContaining('cursor=250'),
+ undefined,
+ ),
+ );
+
+ const ideas = screen.getByRole('button', {name: /Ideas/});
+ await userEvent.click(ideas);
+ expect(document.activeElement).toBe(ideas);
+
+ resolveIdeas(
+ json({
+ projects: [
+ {
+ ...projectFixture,
+ id: 'idea',
+ name: 'Open signal',
+ kind: 'idea',
+ group: null,
+ members: [],
+ },
+ ],
+ nextCursor: null,
+ }),
+ );
+
+ expect(await screen.findByRole('heading', {name: 'Open signal'})).toBeTruthy();
+ await waitFor(() => expect(document.activeElement).toBe(ideas));
+
+ resolveSecondPage(
+ json({
+ projects: [{...projectFixture, id: 'project-251', name: 'Project 251'}],
+ nextCursor: null,
+ }),
+ );
+ });
+
+ it('keeps search focus when typing supersedes a pending page fetch', async () => {
+ let resolveSecondPage!: (response: Response) => void;
+ const pendingSecondPage = new Promise((resolve) => {
+ resolveSecondPage = resolve;
+ });
+ fetchMock.mockImplementation(async (input) => {
+ const url = input instanceof Request ? input.url : input.toString();
+ if (url.includes('/api/years/2026')) {
+ return json({
+ year: {
+ id: '2026',
+ votingEnabled: false,
+ submissionsClosed: false,
+ projectCount: 251,
+ ideaCount: 0,
+ groupCount: 0,
+ participantCount: 251,
+ },
+ groups: [],
+ awards: [],
+ });
+ }
+
+ const requestUrl = new URL(url, 'https://hackweek.test');
+ if (requestUrl.searchParams.get('cursor') === '250') return pendingSecondPage;
+ return json({projects: [projectFixture], nextCursor: '250'});
+ });
+
+ renderRoute(, '/years/2026/projects', '/years/:yearId/projects');
+
+ expect(await screen.findByRole('heading', {name: 'A small machine'})).toBeTruthy();
+ await userEvent.click(screen.getByRole('button', {name: 'next'}));
+ await waitFor(() =>
+ expect(fetchMock).toHaveBeenCalledWith(
+ expect.stringContaining('cursor=250'),
+ undefined,
+ ),
+ );
+
+ const searchInput = screen.getByRole('searchbox', {
+ name: 'Search projects and ideas',
+ });
+ await userEvent.type(searchInput, 's');
+ expect(document.activeElement).toBe(searchInput);
+
+ resolveSecondPage(
+ json({
+ projects: [{...projectFixture, id: 'project-251', name: 'Project 251'}],
+ nextCursor: null,
+ }),
+ );
+
+ expect(await screen.findByRole('heading', {name: 'Project 251'})).toBeTruthy();
+ await waitFor(() => expect(document.activeElement).toBe(searchInput));
+ });
+
+ it('keeps Previous available and announces an empty later page', async () => {
+ fetchMock.mockImplementation(async (input) => {
+ const url = input instanceof Request ? input.url : input.toString();
+ if (url.includes('/api/years/2026')) {
+ return json({
+ year: {
+ id: '2026',
+ votingEnabled: false,
+ submissionsClosed: false,
+ projectCount: 1,
+ ideaCount: 0,
+ groupCount: 0,
+ participantCount: 1,
+ },
+ groups: [],
+ awards: [],
+ });
+ }
+
+ const cursor = new URL(url, 'https://hackweek.test').searchParams.get('cursor');
+ if (cursor === '250') return json({projects: [], nextCursor: null});
+ return json({projects: [projectFixture], nextCursor: '250'});
+ });
+
+ renderRoute(, '/years/2026/projects', '/years/:yearId/projects');
+
+ expect(await screen.findByRole('heading', {name: 'A small machine'})).toBeTruthy();
+ await userEvent.click(screen.getByRole('button', {name: 'next'}));
+
+ expect(await screen.findByRole('heading', {name: 'No projects found'})).toBeTruthy();
+ const emptyResults = screen.getByRole('region', {name: 'project results'});
+ const pagination = screen.getByRole('navigation', {name: 'Project pages'});
+ expect(within(pagination).getByRole('status').textContent).toBe(
+ 'no projects found on this page',
+ );
+ const previous = within(pagination).getByRole('button', {name: 'previous'});
+ expect(previous.hasAttribute('disabled')).toBe(false);
+ await waitFor(() => expect(document.activeElement).toBe(emptyResults));
+
+ await userEvent.click(previous);
+
+ expect(await screen.findByRole('heading', {name: 'A small machine'})).toBeTruthy();
+ await waitFor(() =>
+ expect(document.activeElement).toBe(
+ screen.getByRole('region', {name: 'project list'}),
+ ),
+ );
+ expect(screen.getByRole('button', {name: 'previous'}).hasAttribute('disabled')).toBe(
+ true,
+ );
+ });
+
it('live-updates server search without replacing the current list', async () => {
let resolveSearch!: (response: Response) => void;
const pendingSearch = new Promise((resolve) => {
diff --git a/test/projects/projects.test.ts b/test/projects/projects.test.ts
index 479f819..e0e79f8 100644
--- a/test/projects/projects.test.ts
+++ b/test/projects/projects.test.ts
@@ -13,7 +13,7 @@ let outsiderToken: string;
beforeEach(async () => {
suffix += 1;
- yearId = `project-year-${suffix}`;
+ yearId = `project-year-${String(suffix).padStart(3, '0')}`;
groupId = `group-${suffix}`;
memberToken = await createSessionCookie({
sub: `project-member-${suffix}`,
@@ -99,6 +99,37 @@ describe('project and history APIs', () => {
expect(ideaView.body.project.permissions.canVote).toBe(false);
});
+ it('lists more than 100 projects without exceeding D1 bound-parameter limits', async () => {
+ const user = await env.DB.prepare('SELECT id FROM users WHERE google_subject = ?')
+ .bind(`project-member-${suffix}`)
+ .first<{id: string}>();
+ const statements = Array.from({length: 101}, (_, index) => {
+ const id = `bulk-project-${suffix}-${index}`;
+ return [
+ env.DB.prepare(
+ `INSERT INTO projects (id, source_id, year_id, creator_id, name, kind, group_id)
+ VALUES (?, ?, ?, ?, ?, 'project', ?)`,
+ ).bind(id, id, yearId, user!.id, `Bulk project ${index}`, groupId),
+ env.DB.prepare(
+ 'INSERT INTO project_members (project_id, user_id) VALUES (?, ?)',
+ ).bind(id, user!.id),
+ ];
+ }).flat();
+ await env.DB.batch(statements);
+
+ const page = await api(
+ `/projects?year=${yearId}&kind=project&limit=101`,
+ memberToken,
+ );
+
+ expect(page.status).toBe(200);
+ expect(page.body.projects).toHaveLength(101);
+ expect(page.body.nextCursor).toBeNull();
+ expect(page.body.projects[0].members).toEqual([
+ expect.objectContaining({email: `project-member-${suffix}@sentry.io`}),
+ ]);
+ });
+
it('searches titles and descriptions before pagination with relevant results first', async () => {
const exact = await createProject(memberToken, {
name: 'Signal',
From d8bd47104009b58f8f8acacd4dbe164c99dc3728 Mon Sep 17 00:00:00 2001
From: "sentry-junior[bot]"
<264270552+sentry-junior[bot]@users.noreply.github.com>
Date: Tue, 18 Aug 2026 14:25:04 +0000
Subject: [PATCH 9/9] chore: refresh PR base against current master
Co-Authored-By: Trevor Elkins