From f32aaab05fbfbe927ca0cefe547ae656aa5e662d Mon Sep 17 00:00:00 2001 From: Shannon Anahata Date: Wed, 29 Jul 2026 10:36:27 -0700 Subject: [PATCH 1/2] fix(docs): Handle missing MDX source files correctly in Edge runtime Replace instanceof Error checks with duck-typed isExpectedMdxError() utility that works across Edge runtime VM context boundaries. The instanceof check fails when Error objects cross VM boundaries, causing expected 'file not found' errors to produce 500s instead of 404s. Changes: - Extract error classification into shared src/mdxErrors.ts - Update both catch blocks in page.tsx to use isExpectedMdxError() - Update include.tsx to use isExpectedMdxError() with typed catch - Add diagnostic logging on unexpected errors (re-throw path) - Add unit tests for isExpectedMdxError() covering all error shapes - Add error contract tests for getFileBySlug in mdx.spec.ts Fixes DOCS-9WN --- app/[[...path]]/page.tsx | 35 +++++++++-------- src/components/include.tsx | 5 ++- src/mdx.spec.ts | 70 +++++++++++++++++++++++++++++++++- src/mdxErrors.spec.ts | 78 ++++++++++++++++++++++++++++++++++++++ src/mdxErrors.ts | 27 +++++++++++++ 5 files changed, 195 insertions(+), 20 deletions(-) create mode 100644 src/mdxErrors.spec.ts create mode 100644 src/mdxErrors.ts diff --git a/app/[[...path]]/page.tsx b/app/[[...path]]/page.tsx index be718ae1b95c68..ae178b651824d8 100644 --- a/app/[[...path]]/page.tsx +++ b/app/[[...path]]/page.tsx @@ -23,6 +23,7 @@ import { } from 'sentry-docs/docTree'; import {getMDXComponent} from 'sentry-docs/getMDXComponent'; import {isDeveloperDocs} from 'sentry-docs/isDeveloperDocs'; +import {isExpectedMdxError} from 'sentry-docs/mdxErrors'; import { getDevDocsFrontMatter, getDocsFrontMatter, @@ -162,14 +163,8 @@ export default async function Page(props: {params: Promise<{path?: string[]}>}) // This can happen when serverless function is invoked at runtime but docs files // aren't in the bundle, or MDX compilation is attempted at Vercel runtime. // Note: This error handling is duplicated for regular docs below - keep them in sync. - const errorCode = e && typeof e === 'object' && 'code' in e ? e.code : null; - const isExpectedError = - errorCode === 'ENOENT' || - errorCode === 'MDX_RUNTIME_ERROR' || - (e instanceof Error && e.message.includes('Failed to find a valid source file')); - if (isExpectedError) { - // Log as warning for visibility without flooding errors - // Users are served static pages from CDN - this is an infrastructure edge case + if (isExpectedMdxError(e)) { + const errorCode = e && typeof e === 'object' && 'code' in e ? e.code : null; Sentry.logger.warn('MDX file not found at runtime, returning 404', { path: params.path?.join('/'), errorCode, @@ -177,6 +172,13 @@ export default async function Page(props: {params: Promise<{path?: string[]}>}) }); return notFound(); } + // TODO(diagnostic): remove once Edge runtime error shape is confirmed + const errorCode = e && typeof e === 'object' && 'code' in e ? e.code : null; + Sentry.logger.error('Unexpected error loading MDX, re-throwing as 500', { + path: params.path?.join('/'), + errorCode, + hasMessage: typeof (e as any)?.message === 'string', + }); throw e; } const {mdxSource, frontMatter} = doc; @@ -237,14 +239,8 @@ export default async function Page(props: {params: Promise<{path?: string[]}>}) // This can happen when serverless function is invoked at runtime but docs files // aren't in the bundle, or MDX compilation is attempted at Vercel runtime. // Note: This error handling is duplicated for developer docs above - keep them in sync. - const errorCode = e && typeof e === 'object' && 'code' in e ? e.code : null; - const isExpectedError = - errorCode === 'ENOENT' || - errorCode === 'MDX_RUNTIME_ERROR' || - (e instanceof Error && e.message.includes('Failed to find a valid source file')); - if (isExpectedError) { - // Log as warning for visibility without flooding errors - // Users are served static pages from CDN - this is an infrastructure edge case + if (isExpectedMdxError(e)) { + const errorCode = e && typeof e === 'object' && 'code' in e ? e.code : null; Sentry.logger.warn('MDX file not found at runtime, returning 404', { path: pageNode.path, errorCode, @@ -252,6 +248,13 @@ export default async function Page(props: {params: Promise<{path?: string[]}>}) }); return notFound(); } + // TODO(diagnostic): remove once Edge runtime error shape is confirmed + const errorCode = e && typeof e === 'object' && 'code' in e ? e.code : null; + Sentry.logger.error('Unexpected error loading MDX, re-throwing as 500', { + path: pageNode.path, + errorCode, + hasMessage: typeof (e as any)?.message === 'string', + }); throw e; } const {mdxSource, frontMatter} = doc; diff --git a/src/components/include.tsx b/src/components/include.tsx index fb4846c6ea4b9f..c73fd21d94a6a7 100644 --- a/src/components/include.tsx +++ b/src/components/include.tsx @@ -1,6 +1,7 @@ import {useMemo} from 'react'; import {getMDXComponent} from 'sentry-docs/getMDXComponent'; import {getFileBySlugWithCache} from 'sentry-docs/mdx'; +import {isExpectedMdxError} from 'sentry-docs/mdxErrors'; import {mdxComponents} from 'sentry-docs/mdxComponents'; import {PlatformContent} from './platformContent'; @@ -21,9 +22,9 @@ export async function Include({name}: Props) { } try { doc = await getFileBySlugWithCache(`includes/${name}`); - } catch (e) { + } catch (e: unknown) { // Handle file not found (ENOENT) and runtime MDX compilation attempts gracefully - if (e.code === 'ENOENT' || e.code === 'MDX_RUNTIME_ERROR') { + if (isExpectedMdxError(e)) { return null; } throw e; diff --git a/src/mdx.spec.ts b/src/mdx.spec.ts index fffbb1b0b80834..79c929e4f177b1 100644 --- a/src/mdx.spec.ts +++ b/src/mdx.spec.ts @@ -1,6 +1,12 @@ -import {describe, expect, test} from 'vitest'; +import {afterEach, describe, expect, test, vi} from 'vitest'; -import {addVersionToFilePath, getVersionedIndexPath, getVersionsFromDoc} from './mdx'; +import { + addVersionToFilePath, + getFileBySlug, + getVersionedIndexPath, + getVersionsFromDoc, +} from './mdx'; +import {isExpectedMdxError} from './mdxErrors'; import {FrontMatter} from './types'; const mockFm: FrontMatter[] = [ @@ -117,4 +123,64 @@ describe('mdx', () => { ); }); }); + + describe('getFileBySlug error contracts', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + test('throws error with .code = ENOENT for non-existent slug', async () => { + let thrown: unknown; + try { + await getFileBySlug('nonexistent/path/that/does/not/exist'); + } catch (e) { + thrown = e; + } + expect(thrown).toBeDefined(); + expect(thrown).toHaveProperty('code', 'ENOENT'); + expect(thrown).toHaveProperty('message'); + expect((thrown as Error).message).toContain('Failed to find a valid source file'); + }); + + test('ENOENT error is recognized by isExpectedMdxError', async () => { + let thrown: unknown; + try { + await getFileBySlug('nonexistent/path/that/does/not/exist'); + } catch (e) { + thrown = e; + } + expect(thrown).toBeDefined(); + expect(isExpectedMdxError(thrown)).toBe(true); + }); + + test('throws error with .code = MDX_RUNTIME_ERROR in Vercel runtime', async () => { + vi.stubEnv('VERCEL', '1'); + vi.stubEnv('NODE_ENV', 'production'); + delete process.env.CI; + + let thrown: unknown; + try { + await getFileBySlug('any/slug'); + } catch (e) { + thrown = e; + } + expect(thrown).toBeDefined(); + expect(thrown).toHaveProperty('code', 'MDX_RUNTIME_ERROR'); + }); + + test('MDX_RUNTIME_ERROR is recognized by isExpectedMdxError', async () => { + vi.stubEnv('VERCEL', '1'); + vi.stubEnv('NODE_ENV', 'production'); + delete process.env.CI; + + let thrown: unknown; + try { + await getFileBySlug('any/slug'); + } catch (e) { + thrown = e; + } + expect(thrown).toBeDefined(); + expect(isExpectedMdxError(thrown)).toBe(true); + }); + }); }); diff --git a/src/mdxErrors.spec.ts b/src/mdxErrors.spec.ts new file mode 100644 index 00000000000000..e4ea45630d1447 --- /dev/null +++ b/src/mdxErrors.spec.ts @@ -0,0 +1,78 @@ +import {describe, expect, test} from 'vitest'; + +import {isExpectedMdxError} from './mdxErrors'; + +describe('isExpectedMdxError', () => { + describe('returns true for expected errors', () => { + test('Error with .code = ENOENT', () => { + const error = Object.assign(new Error('file not found'), {code: 'ENOENT'}); + expect(isExpectedMdxError(error)).toBe(true); + }); + + test('Error with .code = MDX_RUNTIME_ERROR', () => { + const error = Object.assign(new Error('runtime error'), { + code: 'MDX_RUNTIME_ERROR', + }); + expect(isExpectedMdxError(error)).toBe(true); + }); + + test('plain object with .code = ENOENT (Edge runtime simulation)', () => { + const error = {code: 'ENOENT', message: 'some error'}; + expect(isExpectedMdxError(error)).toBe(true); + }); + + test('plain object with .code = MDX_RUNTIME_ERROR (Edge runtime simulation)', () => { + const error = {code: 'MDX_RUNTIME_ERROR', message: 'runtime error'}; + expect(isExpectedMdxError(error)).toBe(true); + }); + + test('plain object with expected message but no .code (Edge runtime fallback)', () => { + const error = { + message: 'Failed to find a valid source file for slug "docs/changelog"', + }; + expect(isExpectedMdxError(error)).toBe(true); + }); + + test('Error with expected message but no .code', () => { + const error = new Error( + 'Failed to find a valid source file for slug "docs/changelog"' + ); + expect(isExpectedMdxError(error)).toBe(true); + }); + }); + + describe('returns false for unexpected errors', () => { + test('unrelated Error', () => { + expect(isExpectedMdxError(new Error('Connection refused'))).toBe(false); + }); + + test('Error with unrelated .code', () => { + const error = Object.assign(new Error('permission denied'), {code: 'EPERM'}); + expect(isExpectedMdxError(error)).toBe(false); + }); + + test('plain object with wrong message and no .code', () => { + expect(isExpectedMdxError({message: 'Something else went wrong'})).toBe(false); + }); + + test('string throw', () => { + expect(isExpectedMdxError('some string error')).toBe(false); + }); + + test('null', () => { + expect(isExpectedMdxError(null)).toBe(false); + }); + + test('undefined', () => { + expect(isExpectedMdxError(undefined)).toBe(false); + }); + + test('number', () => { + expect(isExpectedMdxError(42)).toBe(false); + }); + + test('empty object', () => { + expect(isExpectedMdxError({})).toBe(false); + }); + }); +}); diff --git a/src/mdxErrors.ts b/src/mdxErrors.ts new file mode 100644 index 00000000000000..cf051f006bf0f1 --- /dev/null +++ b/src/mdxErrors.ts @@ -0,0 +1,27 @@ +/** + * Determines whether a caught error represents an expected "page not found" + * condition that should result in a 404, rather than an unhandled 500. + * + * Uses duck-typing (.code property check) rather than instanceof, because + * instanceof Error returns false across Edge runtime VM context boundaries. + * Falls back to message-string matching for resilience in case .code is + * stripped during serialization. + */ +export function isExpectedMdxError(e: unknown): boolean { + const errorCode = + e && typeof e === 'object' && 'code' in e ? (e as {code: unknown}).code : null; + + if (errorCode === 'ENOENT' || errorCode === 'MDX_RUNTIME_ERROR') { + return true; + } + + // Fallback: duck-type the message for Edge runtime where .code may be stripped + if ( + typeof (e as any)?.message === 'string' && + (e as any).message.includes('Failed to find a valid source file') + ) { + return true; + } + + return false; +} From f2ac886f10d9b82e7e439194277b7f14934e00b1 Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:12:10 +0000 Subject: [PATCH 2/2] [getsentry/action-github-commit] Auto commit --- app/[[...path]]/page.tsx | 2 +- src/components/include.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/[[...path]]/page.tsx b/app/[[...path]]/page.tsx index ae178b651824d8..ebbc4d455076f0 100644 --- a/app/[[...path]]/page.tsx +++ b/app/[[...path]]/page.tsx @@ -23,7 +23,6 @@ import { } from 'sentry-docs/docTree'; import {getMDXComponent} from 'sentry-docs/getMDXComponent'; import {isDeveloperDocs} from 'sentry-docs/isDeveloperDocs'; -import {isExpectedMdxError} from 'sentry-docs/mdxErrors'; import { getDevDocsFrontMatter, getDocsFrontMatter, @@ -31,6 +30,7 @@ import { getVersionsFromDoc, } from 'sentry-docs/mdx'; import {mdxComponents} from 'sentry-docs/mdxComponents'; +import {isExpectedMdxError} from 'sentry-docs/mdxErrors'; import {PageType} from 'sentry-docs/metrics'; import {setServerContext} from 'sentry-docs/serverContext'; import {PaginationNavNode} from 'sentry-docs/types/paginationNavNode'; diff --git a/src/components/include.tsx b/src/components/include.tsx index c73fd21d94a6a7..34eb66aa6435fd 100644 --- a/src/components/include.tsx +++ b/src/components/include.tsx @@ -1,8 +1,8 @@ import {useMemo} from 'react'; import {getMDXComponent} from 'sentry-docs/getMDXComponent'; import {getFileBySlugWithCache} from 'sentry-docs/mdx'; -import {isExpectedMdxError} from 'sentry-docs/mdxErrors'; import {mdxComponents} from 'sentry-docs/mdxComponents'; +import {isExpectedMdxError} from 'sentry-docs/mdxErrors'; import {PlatformContent} from './platformContent';