diff --git a/packages/next/src/views/Document/DocumentView.spec.ts b/packages/next/src/views/Document/DocumentView.spec.ts new file mode 100644 index 00000000000..81af6c33f21 --- /dev/null +++ b/packages/next/src/views/Document/DocumentView.spec.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from 'vitest' + +/** + * Unit tests for DocumentView error handling. + * + * These tests verify that DocumentView correctly propagates unhandled errors + * instead of silently returning undefined (which causes blank edit views). + * + * See: https://github.com/payloadcms/payload/issues/15712 + */ + +// We test the error-handling logic by importing and exercising DocumentView +// with mocked dependencies. The key behavior: any error that is not +// 'NEXT_REDIRECT' or 'not-found' must be re-thrown. + +// Mock next/navigation +vi.mock('next/navigation.js', () => ({ + notFound: vi.fn(() => { + throw new Error('NEXT_NOT_FOUND') + }), + redirect: vi.fn(() => { + throw new Error('NEXT_REDIRECT') + }), +})) + +// Minimal mock for payload's logError +vi.mock('payload', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + logError: vi.fn(), + } +}) + +describe('DocumentView error handling', () => { + it('should re-throw errors that are not NEXT_REDIRECT or not-found', async () => { + // Dynamically import after mocks are set up + const { DocumentView } = await import('./index.js') + + const cloudflareError = new Error('Cannot access cookies in edge runtime') + + // Create minimal props that will cause renderDocument to throw + const mockProps = { + initPageResult: { + req: { + payload: { config: {} }, + }, + }, + } as any + + // Mock the module internals - renderDocument will throw our test error + // We achieve this by passing props that cause the function to fail + // at the very first destructuring step + await expect(DocumentView(mockProps)).rejects.toThrow() + }) + + it('should propagate NEXT_REDIRECT errors', async () => { + const { DocumentView } = await import('./index.js') + + const mockProps = { + initPageResult: { + req: { + payload: { config: {} }, + }, + }, + } as any + + // DocumentView should throw (not return undefined) for any error + const result = DocumentView(mockProps) + await expect(result).rejects.toBeDefined() + }) + + it('should never return undefined (which causes blank Suspense boundaries)', async () => { + const { DocumentView } = await import('./index.js') + + const mockProps = { + initPageResult: { + req: { + payload: { config: {} }, + }, + }, + } as any + + // The function should either return a valid ReactNode or throw. + // It should NEVER return undefined. + try { + const result = await DocumentView(mockProps) + // If it doesn't throw, the result must be defined + expect(result).toBeDefined() + } catch { + // Throwing is acceptable - that's the fix working correctly + } + }) +}) diff --git a/packages/next/src/views/Document/index.tsx b/packages/next/src/views/Document/index.tsx index 3e790c5c8cc..d206e88d33b 100644 --- a/packages/next/src/views/Document/index.tsx +++ b/packages/next/src/views/Document/index.tsx @@ -475,5 +475,13 @@ export async function DocumentView(props: AdminViewServerProps) { if (error.message === 'not-found') { notFound() } + + // Re-throw unhandled errors so Next.js error boundaries can catch them. + // Without this, the function returns `undefined` and React renders an + // empty Suspense boundary — producing a blank edit view with no visible + // error. This is especially common on edge runtimes (e.g. Cloudflare + // Workers) where auth-context or streaming failures produce errors that + // are neither 'not-found' nor 'NEXT_REDIRECT'. + throw error } }