diff --git a/.changeset/prerender-retry-and-fail-on-error.md b/.changeset/prerender-retry-and-fail-on-error.md new file mode 100644 index 00000000000..f3f63c6696f --- /dev/null +++ b/.changeset/prerender-retry-and-fail-on-error.md @@ -0,0 +1,5 @@ +--- +'@tanstack/start-plugin-core': patch +--- + +Fix prerendering so that `retryCount` actually retries a failed page, and a page that still fails with `failOnError` enabled now fails the build instead of exiting successfully. diff --git a/packages/start-plugin-core/src/prerender.ts b/packages/start-plugin-core/src/prerender.ts index 2fbd9e74b9c..0977fb71866 100644 --- a/packages/start-plugin-core/src/prerender.ts +++ b/packages/start-plugin-core/src/prerender.ts @@ -86,6 +86,7 @@ export async function prerender({ const seen = new Set() const prerendered = new Set() const retriesByPath = new Map() + const errors: Array = [] const concurrency = startConfig.prerender?.concurrency ?? os.cpus().length logger.info(`Concurrency: ${concurrency}`) const queue = new Queue({ concurrency }) @@ -106,6 +107,16 @@ export async function prerender({ await queue.start() + if (errors.length > 0) { + if (errors.length === 1) { + throw errors[0] + } + throw new AggregateError( + errors, + `Prerendering failed for ${errors.length} pages`, + ) + } + return Array.from(prerendered) function addCrawlPageTask(page: Page) { @@ -113,7 +124,7 @@ export async function prerender({ seen.add(page.path) - if (page.fromCrawl) { + if (page.fromCrawl && !startConfig.pages.includes(page)) { startConfig.pages.push(page) } @@ -219,9 +230,10 @@ export async function prerender({ ) await new Promise((resolve) => setTimeout(resolve, retryDelay)) retriesByPath.set(page.path, retries + 1) + seen.delete(page.path) addCrawlPageTask(page) } else if (prerenderOptions.failOnError ?? true) { - throw error + errors.push(error) } } }) diff --git a/packages/start-plugin-core/tests/prerender-retry.test.ts b/packages/start-plugin-core/tests/prerender-retry.test.ts new file mode 100644 index 00000000000..b5658ae23ae --- /dev/null +++ b/packages/start-plugin-core/tests/prerender-retry.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it, vi } from 'vitest' +import { prerender } from '../src/prerender' + +vi.mock('../src/utils', async () => { + const actual = await vi.importActual('../src/utils') + return { + ...actual, + createLogger: () => ({ info: () => {}, warn: () => {}, error: () => {} }), + } +}) + +// Mock fs to prevent actual file system operations +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + return { + ...actual, + promises: { + ...actual.promises, + mkdir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + }, + } +}) + +function okResponse() { + return new Response('', { + status: 200, + headers: { 'content-type': 'text/html' }, + }) +} + +function failResponse() { + return new Response('boom', { status: 500 }) +} + +function makeStartConfig( + pagePath: string, + prerenderOverrides: Record, +) { + return { + prerender: { + enabled: true, + autoStaticPathsDiscovery: false, + concurrency: 1, + crawlLinks: false, + retryDelay: 0, + ...prerenderOverrides, + }, + pages: [{ path: pagePath }], + router: { basepath: '' }, + spa: { + enabled: false, + prerender: { + outputPath: '/_shell', + crawlLinks: false, + retryCount: 0, + enabled: true, + }, + }, + } as any +} + +describe('prerender retry and failOnError', () => { + it('retries a failing page up to retryCount times until it succeeds', async () => { + const request = vi + .fn() + .mockResolvedValueOnce(failResponse()) + .mockResolvedValueOnce(failResponse()) + .mockResolvedValue(okResponse()) + const handler = { getClientOutputDirectory: () => '/client', request } + const startConfig = makeStartConfig('/flaky', { + retryCount: 2, + failOnError: true, + }) + + await expect(prerender({ startConfig, handler })).resolves.not.toThrow() + // 1 initial attempt + 2 retries, succeeding on the third + expect(request).toHaveBeenCalledTimes(3) + }) + + it('fails the build when a page fails and failOnError is set', async () => { + const request = vi.fn().mockResolvedValue(failResponse()) + const handler = { getClientOutputDirectory: () => '/client', request } + const startConfig = makeStartConfig('/broken', { + retryCount: 0, + failOnError: true, + }) + + await expect(prerender({ startConfig, handler })).rejects.toThrow( + /Failed to fetch/, + ) + expect(request).toHaveBeenCalledTimes(1) + }) + + it('retries then fails the build when the page never recovers', async () => { + const request = vi.fn().mockResolvedValue(failResponse()) + const handler = { getClientOutputDirectory: () => '/client', request } + const startConfig = makeStartConfig('/broken', { + retryCount: 2, + failOnError: true, + }) + + await expect(prerender({ startConfig, handler })).rejects.toThrow( + /Failed to fetch/, + ) + // 1 initial attempt + 2 retries before giving up + expect(request).toHaveBeenCalledTimes(3) + }) + + it('does not fail the build when failOnError is disabled', async () => { + const request = vi.fn().mockResolvedValue(failResponse()) + const handler = { getClientOutputDirectory: () => '/client', request } + const startConfig = makeStartConfig('/broken', { + retryCount: 0, + failOnError: false, + }) + + await expect(prerender({ startConfig, handler })).resolves.not.toThrow() + }) + + it('records a retried crawled page only once', async () => { + let childAttempts = 0 + const request = vi.fn((path: string) => { + if (path.includes('child')) { + childAttempts++ + return Promise.resolve( + childAttempts === 1 ? failResponse() : okResponse(), + ) + } + return Promise.resolve( + new Response('child', { + status: 200, + headers: { 'content-type': 'text/html' }, + }), + ) + }) + const handler = { getClientOutputDirectory: () => '/client', request } + const startConfig = makeStartConfig('/', { + crawlLinks: true, + retryCount: 1, + failOnError: false, + }) + + await prerender({ startConfig, handler }) + + // The crawled page fails once and is retried, but must be recorded once. + const childEntries = startConfig.pages.filter( + (page: { path: string }) => page.path === '/child', + ) + expect(childEntries).toHaveLength(1) + }) + + it('aggregates multiple page failures into an AggregateError', async () => { + const request = vi.fn().mockResolvedValue(failResponse()) + const handler = { getClientOutputDirectory: () => '/client', request } + const startConfig = makeStartConfig('/a', { + retryCount: 0, + failOnError: true, + }) + startConfig.pages = [{ path: '/a' }, { path: '/b' }] + + let error: unknown + try { + await prerender({ startConfig, handler }) + } catch (e) { + error = e + } + + expect(error).toBeInstanceOf(AggregateError) + if (error instanceof AggregateError) { + expect(error.errors).toHaveLength(2) + } + }) +})