From a4ca241b9d71d32cdb2e72421d08fd6a2ee8daed Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 30 Jul 2026 09:56:00 +0300 Subject: [PATCH] fix(webapp): tolerate partial leaderboard data in users page static props A single flaky resolver returning partial data plus an errors array made graphql-request throw and fail the whole Vercel static export. Recover the partial payload when at least one leaderboard list is present and render the failed field as an empty list under hourly ISR; total failures (no data at all) still fail the build. Co-Authored-By: Claude Fable 5 --- .../UsersLeaderboardStaticProps.spec.ts | 101 ++++++++++++++++++ packages/webapp/pages/users.tsx | 58 ++++++++-- 2 files changed, 148 insertions(+), 11 deletions(-) diff --git a/packages/webapp/__tests__/UsersLeaderboardStaticProps.spec.ts b/packages/webapp/__tests__/UsersLeaderboardStaticProps.spec.ts index 8658fb2207c..57ba25105b2 100644 --- a/packages/webapp/__tests__/UsersLeaderboardStaticProps.spec.ts +++ b/packages/webapp/__tests__/UsersLeaderboardStaticProps.spec.ts @@ -156,6 +156,107 @@ describe('leaderboard static props', () => { }); }); + it('should tolerate partial leaderboard data when a single resolver fails', async () => { + const highestReputation: UserLeaderboard[] = [ + { + score: 500, + user: { + id: 'user-1', + username: 'user-1', + name: 'User One', + image: '', + permalink: '/users/user-1', + createdAt: new Date().toISOString(), + providers: [], + balance: { amount: 0 }, + }, + }, + ]; + + const partialDataError = { + response: { + data: { + ...baseLeaderboardResponse, + highestReputation, + mostAchievementPoints: null, + }, + errors: [{ message: 'Unexpected error' }], + }, + }; + + mockRequest.mockImplementation((query: string) => { + if (query === LEADERBOARD_QUERY) { + return Promise.reject(partialDataError); + } + + if (query === POPULAR_HOT_TAKES_QUERY) { + return Promise.resolve({ popularHotTakes: [] }); + } + + if (query === HIGHEST_LEVEL_QUERY) { + return Promise.resolve({ highestLevel: [] }); + } + + return Promise.reject(new Error('Unexpected query')); + }); + + const result = await getUsersStaticProps(); + + expect(result).toMatchObject({ + props: { + highestReputation, + mostAchievementPoints: [], + }, + revalidate: 3600, + }); + }); + + it('should fail the build when the leaderboard response has no data at all', async () => { + const totalFailure = { + response: { + errors: [{ message: 'Internal server error' }], + }, + }; + + mockRequest.mockImplementation((query: string) => { + if (query === LEADERBOARD_QUERY) { + return Promise.reject(totalFailure); + } + + return Promise.reject(new Error('Unexpected query')); + }); + + await expect(getUsersStaticProps()).rejects.toBe(totalFailure); + }); + + it('should fail the build when every leaderboard field errored', async () => { + const allFieldsFailed = { + response: { + data: { + highestReputation: null, + longestStreak: null, + highestPostViews: null, + mostUpvoted: null, + mostReferrals: null, + mostReadingDays: null, + mostAchievementPoints: null, + mostVerifiedUsers: null, + }, + errors: [{ message: 'Internal server error' }], + }, + }; + + mockRequest.mockImplementation((query: string) => { + if (query === LEADERBOARD_QUERY) { + return Promise.reject(allFieldsFailed); + } + + return Promise.reject(new Error('Unexpected query')); + }); + + await expect(getUsersStaticProps()).rejects.toBe(allFieldsFailed); + }); + it('should return notFound for highest-level detail when schema is missing', async () => { mockRequest.mockRejectedValue( createMissingSchemaError( diff --git a/packages/webapp/pages/users.tsx b/packages/webapp/pages/users.tsx index e2ec4052ba8..a06753616b1 100644 --- a/packages/webapp/pages/users.tsx +++ b/packages/webapp/pages/users.tsx @@ -51,6 +51,44 @@ interface PageProps { popularHotTakes: PopularHotTakes[]; } +type LeaderboardQueryData = Omit< + PageProps, + 'highestLevel' | 'isHighestLevelSupported' | 'popularHotTakes' +>; + +const leaderboardListFields: (keyof LeaderboardQueryData)[] = [ + 'highestReputation', + 'longestStreak', + 'highestPostViews', + 'mostUpvoted', + 'mostReferrals', + 'mostReadingDays', + 'mostAchievementPoints', + 'mostVerifiedUsers', +]; + +// A single flaky resolver makes graphql-request throw even though the response +// carries data for every other leaderboard; with hourly ISR an empty section is +// better than failing the whole static export. +const fetchLeaderboards = async (): Promise> => { + try { + return await gqlClient.request(LEADERBOARD_QUERY); + } catch (err) { + const partialData = ( + err as { response?: { data?: Partial } } + )?.response?.data; + const hasUsableData = + partialData && + leaderboardListFields.some((field) => Array.isArray(partialData[field])); + + if (!hasUsableData) { + throw err; + } + + return partialData; + } +}; + const isHighestLevelSchemaMissing = (error: GraphQLError): boolean => { return ( error?.response?.errors?.some( @@ -210,9 +248,7 @@ export async function getStaticProps(): Promise< GetStaticPropsResult > { try { - const res = await gqlClient.request< - Omit - >(LEADERBOARD_QUERY); + const res = await fetchLeaderboards(); let highestLevel: UserLeaderboard[] = []; let isHighestLevelSupported = false; @@ -247,16 +283,16 @@ export async function getStaticProps(): Promise< return { props: { - highestReputation: res.highestReputation, - longestStreak: res.longestStreak, - highestPostViews: res.highestPostViews, - mostUpvoted: res.mostUpvoted, - mostReferrals: res.mostReferrals, - mostReadingDays: res.mostReadingDays, - mostAchievementPoints: res.mostAchievementPoints, + highestReputation: res.highestReputation ?? [], + longestStreak: res.longestStreak ?? [], + highestPostViews: res.highestPostViews ?? [], + mostUpvoted: res.mostUpvoted ?? [], + mostReferrals: res.mostReferrals ?? [], + mostReadingDays: res.mostReadingDays ?? [], + mostAchievementPoints: res.mostAchievementPoints ?? [], highestLevel, isHighestLevelSupported, - mostVerifiedUsers: res.mostVerifiedUsers, + mostVerifiedUsers: res.mostVerifiedUsers ?? [], popularHotTakes, }, revalidate: 3600,