diff --git a/eslint-local-rules/disallowSideEffects.js b/eslint-local-rules/disallowSideEffects.js index b9e7390326..2ade75eb10 100644 --- a/eslint-local-rules/disallowSideEffects.js +++ b/eslint-local-rules/disallowSideEffects.js @@ -35,6 +35,7 @@ const pathsWithSideEffect = new Set([ const packagesWithoutSideEffect = new Set([ '@datadog/browser-core', '@datadog/browser-rum-core', + '@datadog/browser-rum-react/internal', 'react', 'react-router-dom', 'vue', diff --git a/packages/rum-nextjs/package.json b/packages/rum-nextjs/package.json index af0a91c39f..defb831a66 100644 --- a/packages/rum-nextjs/package.json +++ b/packages/rum-nextjs/package.json @@ -11,7 +11,8 @@ }, "dependencies": { "@datadog/browser-core": "6.31.0", - "@datadog/browser-rum-core": "6.31.0" + "@datadog/browser-rum-core": "6.31.0", + "@datadog/browser-rum-react": "6.31.0" }, "peerDependencies": { "next": ">=13.0.0", @@ -36,6 +37,7 @@ "devDependencies": { "@types/react": "19.2.11", "next": "15.5.10", - "react": "19.2.4" + "react": "19.2.4", + "react-dom": "19.2.4" } } diff --git a/packages/rum-nextjs/src/domain/error/errorBoundary.spec.tsx b/packages/rum-nextjs/src/domain/error/errorBoundary.spec.tsx new file mode 100644 index 0000000000..78e527fc0b --- /dev/null +++ b/packages/rum-nextjs/src/domain/error/errorBoundary.spec.tsx @@ -0,0 +1,102 @@ +import React, { act } from 'react' + +import { + disableJasmineUncaughtExceptionTracking, + ignoreConsoleLogs, + registerCleanupTask, +} from '@datadog/browser-core/test' +import type { RumInitConfiguration, RumPublicApi } from '@datadog/browser-rum-core' +import { appendComponent } from '../../../../rum-react/test/appendComponent' +import { initReactOldBrowsersSupport } from '../../../../rum-react/test/reactOldBrowsersSupport' +import { nextjsPlugin, resetNextjsPlugin } from '../nextjsPlugin' +import type { ErrorBoundaryFallback } from './errorBoundary' +import { ErrorBoundary } from './errorBoundary' + +type FallbackFunctionComponent = Extract any> + +function initializeNextjsPlugin() { + const addErrorSpy = jasmine.createSpy() + const publicApi = { startView: jasmine.createSpy() } as unknown as RumPublicApi + const plugin = nextjsPlugin() + plugin.onInit({ publicApi, initConfiguration: {} as RumInitConfiguration }) + plugin.onRumStart({ addError: addErrorSpy }) + return { addErrorSpy } +} + +describe('NextjsErrorBoundary', () => { + beforeEach(() => { + ignoreConsoleLogs('error', 'Error: error') + disableJasmineUncaughtExceptionTracking() + initReactOldBrowsersSupport() + resetNextjsPlugin() + registerCleanupTask(() => resetNextjsPlugin()) + }) + + it('renders children', () => { + const container = appendComponent( null}>bar) + expect(container.innerHTML).toBe('bar') + }) + + it('renders the fallback when an error occurs', () => { + const fallbackSpy = jasmine.createSpy().and.returnValue('fallback') + const ComponentSpy = jasmine.createSpy().and.throwError(new Error('error')) + const container = appendComponent( + + + + ) + + expect(fallbackSpy).toHaveBeenCalled() + fallbackSpy.calls.all().forEach(({ args }) => { + expect(args[0]).toEqual({ + error: new Error('error'), + resetError: jasmine.any(Function), + }) + }) + expect(container.innerHTML).toBe('fallback') + }) + + it('resets the error when resetError is called', () => { + const fallbackSpy = jasmine.createSpy().and.returnValue('fallback') + const ComponentSpy = jasmine.createSpy().and.throwError(new Error('error')) + const container = appendComponent( + + + + ) + + ComponentSpy.and.returnValue('bar') + + const { resetError } = fallbackSpy.calls.mostRecent().args[0] + act(() => { + resetError() + }) + + expect(container.innerHTML).toBe('bar') + }) + + it('reports the error through addNextjsError', () => { + const { addErrorSpy } = initializeNextjsPlugin() + const originalError = new Error('error') + const ComponentSpy = jasmine.createSpy().and.throwError(originalError) + ;(ComponentSpy as any).displayName = 'ComponentSpy' + + appendComponent( + null}> + + + ) + + expect(addErrorSpy).toHaveBeenCalledOnceWith( + jasmine.objectContaining({ + error: originalError, + handlingStack: jasmine.any(String), + startClocks: jasmine.any(Object), + context: jasmine.objectContaining({ + framework: 'nextjs', + }), + componentStack: jasmine.stringContaining('ComponentSpy'), + }) + ) + }) +}) diff --git a/packages/rum-nextjs/src/domain/error/errorBoundary.ts b/packages/rum-nextjs/src/domain/error/errorBoundary.ts new file mode 100644 index 0000000000..67ad094d54 --- /dev/null +++ b/packages/rum-nextjs/src/domain/error/errorBoundary.ts @@ -0,0 +1,22 @@ +'use client' +import { createErrorBoundary } from '@datadog/browser-rum-react/internal' +export type { ErrorBoundaryFallback, ErrorBoundaryProps } from '@datadog/browser-rum-react/internal' +import { addNextjsError } from './addNextjsError' + +/** + * ErrorBoundary component to report React errors to Datadog using the Next.js error context. + * + * For more advanced error handling, you can use the {@link addNextjsError} function. + * + * @category Error + * @example + * ```ts + * import { ErrorBoundary } from '@datadog/browser-rum-nextjs' + * + * null}> + * + * + * ``` + */ +// eslint-disable-next-line local-rules/disallow-side-effects +export const ErrorBoundary = createErrorBoundary(addNextjsError) diff --git a/packages/rum-nextjs/src/entries/main.ts b/packages/rum-nextjs/src/entries/main.ts index d6bdb2237f..70f1d03311 100644 --- a/packages/rum-nextjs/src/entries/main.ts +++ b/packages/rum-nextjs/src/entries/main.ts @@ -3,3 +3,5 @@ export type { NextjsPlugin } from '../domain/nextjsPlugin' export { DatadogAppRouter } from '../domain/nextJSRouter/datadogAppRouter' export { DatadogPagesRouter } from '../domain/nextJSRouter/datadogPagesRouter' export { addNextjsError } from '../domain/error/addNextjsError' +export { ErrorBoundary } from '../domain/error/errorBoundary' +export type { ErrorBoundaryFallback, ErrorBoundaryProps } from '../domain/error/errorBoundary' diff --git a/packages/rum-react/internal/internal.d.ts b/packages/rum-react/internal/internal.d.ts new file mode 100644 index 0000000000..90ac61d6bc --- /dev/null +++ b/packages/rum-react/internal/internal.d.ts @@ -0,0 +1 @@ +export * from '../src/entries/internal' diff --git a/packages/rum-react/internal/package.json b/packages/rum-react/internal/package.json index aecaa32b8f..d482178205 100644 --- a/packages/rum-react/internal/package.json +++ b/packages/rum-react/internal/package.json @@ -3,5 +3,5 @@ "private": true, "main": "../cjs/entries/internal.js", "module": "../esm/entries/internal.js", - "types": "../cjs/entries/internal.d.ts" + "types": "./internal.d.ts" } diff --git a/test/apps/nextjs/app/error-test/error.tsx b/test/apps/nextjs/app/error-test/error.tsx index 95e5f41353..072ebbdbbf 100644 --- a/test/apps/nextjs/app/error-test/error.tsx +++ b/test/apps/nextjs/app/error-test/error.tsx @@ -17,7 +17,7 @@ export default function ErrorBoundary({ error, reset }: { error: Error & { diges {error.digest &&

Digest: {error.digest}

}
- Go to Home + Back to Home ) } diff --git a/test/apps/nextjs/app/error-test/page.tsx b/test/apps/nextjs/app/error-test/page.tsx index 70b1a7de53..d3fd22c26f 100644 --- a/test/apps/nextjs/app/error-test/page.tsx +++ b/test/apps/nextjs/app/error-test/page.tsx @@ -14,7 +14,7 @@ export default function ErrorTestPage() { return (
- ← Back to Home + Back to Home

Error Test

+ ) +} + +const ErrorFallback: ErrorBoundaryFallback = ({ error, resetError }) => ( +
+

{error.message}

+ +
+) + +export default function ErrorTestPage() { + return ( +
+ ← Back to Home +

Error Test

+ + + +
+ ) +} diff --git a/test/apps/nextjs/pages/pages-router/index.tsx b/test/apps/nextjs/pages/pages-router/index.tsx index cc03d59a47..2d31b81838 100644 --- a/test/apps/nextjs/pages/pages-router/index.tsx +++ b/test/apps/nextjs/pages/pages-router/index.tsx @@ -11,6 +11,9 @@ export default function HomePage() {
  • Go to Guides 123
  • +
  • + Go to Error Test +
  • ) diff --git a/test/apps/nextjs/pages/pages-router/user/[id].tsx b/test/apps/nextjs/pages/pages-router/user/[id].tsx index 54efa0f286..ef36c9180e 100644 --- a/test/apps/nextjs/pages/pages-router/user/[id].tsx +++ b/test/apps/nextjs/pages/pages-router/user/[id].tsx @@ -10,9 +10,15 @@ export default function UserPage() { ← Back to Home

    User {id}

    This is a dynamic route testing view name normalization.

    - Go to User 999 - Change query params - Go to Section +
    + Go to User 999 +
    +
    + Change query params +
    +
    + Go to Section +
    ) } diff --git a/test/apps/nextjs/yarn.lock b/test/apps/nextjs/yarn.lock index 36a16509cb..fca5505def 100644 --- a/test/apps/nextjs/yarn.lock +++ b/test/apps/nextjs/yarn.lock @@ -14,19 +14,20 @@ __metadata: "@datadog/browser-rum-core@file:../../../packages/rum-core/package.tgz::locator=nextjs%40workspace%3A.": version: 6.31.0 - resolution: "@datadog/browser-rum-core@file:../../../packages/rum-core/package.tgz#../../../packages/rum-core/package.tgz::hash=55eb2b&locator=nextjs%40workspace%3A." + resolution: "@datadog/browser-rum-core@file:../../../packages/rum-core/package.tgz#../../../packages/rum-core/package.tgz::hash=cad31e&locator=nextjs%40workspace%3A." dependencies: "@datadog/browser-core": "npm:6.31.0" - checksum: 10c0/11b93076a410ef527c70d94d7b3203a3a24e05b665f40ce9b1c2227a30ee7efd99613e02a139be7f94ee1a95ca48cb2bf83e8b6d9f3d5253f7bf37d89f1ea338 + checksum: 10c0/f57c5d9fdbc7243f373afba6912bb75a90f3b65ab036750a858aa7776ed30b8921e456eff32a02e7dc021c192cd7bc220ea7b124459eacb7685c5c5ba0cca1c5 languageName: node linkType: hard "@datadog/browser-rum-nextjs@file:../../../packages/rum-nextjs/package.tgz::locator=nextjs%40workspace%3A.": version: 0.0.0 - resolution: "@datadog/browser-rum-nextjs@file:../../../packages/rum-nextjs/package.tgz#../../../packages/rum-nextjs/package.tgz::hash=72ad74&locator=nextjs%40workspace%3A." + resolution: "@datadog/browser-rum-nextjs@file:../../../packages/rum-nextjs/package.tgz#../../../packages/rum-nextjs/package.tgz::hash=528334&locator=nextjs%40workspace%3A." dependencies: "@datadog/browser-core": "npm:6.31.0" "@datadog/browser-rum-core": "npm:6.31.0" + "@datadog/browser-rum-react": "npm:6.31.0" peerDependencies: next: ">=13.0.0" react: ">=18.0.0" @@ -35,13 +36,13 @@ __metadata: optional: true react: optional: true - checksum: 10c0/4b71f2d2e40522120685bfb2457b17febae3f339ca19bc84d58a35f47da1c7a6a49ec13eaec8d62a7f8d61c4d4611293ff7053e338490a74b6f58263e1bc84e6 + checksum: 10c0/08d0f50b13a5e2a32b40e14cb9b7c9e78fabc728bfeb7f172ba1aa0e232989c4ab9a2e6fc9a1194d02aeec4ad259a9448cb3745268bea7c9dcb302d7aae90e50 languageName: node linkType: hard "@datadog/browser-rum-react@file:../../../packages/rum-react/package.tgz::locator=nextjs%40workspace%3A.": version: 6.31.0 - resolution: "@datadog/browser-rum-react@file:../../../packages/rum-react/package.tgz#../../../packages/rum-react/package.tgz::hash=ff7bfc&locator=nextjs%40workspace%3A." + resolution: "@datadog/browser-rum-react@file:../../../packages/rum-react/package.tgz#../../../packages/rum-react/package.tgz::hash=1edc15&locator=nextjs%40workspace%3A." dependencies: "@datadog/browser-core": "npm:6.31.0" "@datadog/browser-rum-core": "npm:6.31.0" @@ -60,13 +61,13 @@ __metadata: optional: true react-router-dom: optional: true - checksum: 10c0/6d2a5f34727c9763abd67aaf9d2ba501e9fd38e8d754a15672e929dc4a37c93c85cdce08ce307122b9d055c688dfee0147315f3679fa377620318b4ab84f00bc + checksum: 10c0/e3ed7ae929dd29836b3f2d7adb910dc9e2fdc6cf6fce81e77e950ed5cd060bb9810c30b8eb8772cf9436203f5c08380b1879454aac856509224bc3a511c9af61 languageName: node linkType: hard "@datadog/browser-rum@file:../../../packages/rum/package.tgz::locator=nextjs%40workspace%3A.": version: 6.31.0 - resolution: "@datadog/browser-rum@file:../../../packages/rum/package.tgz#../../../packages/rum/package.tgz::hash=b23186&locator=nextjs%40workspace%3A." + resolution: "@datadog/browser-rum@file:../../../packages/rum/package.tgz#../../../packages/rum/package.tgz::hash=c2aa74&locator=nextjs%40workspace%3A." dependencies: "@datadog/browser-core": "npm:6.31.0" "@datadog/browser-rum-core": "npm:6.31.0" @@ -75,7 +76,7 @@ __metadata: peerDependenciesMeta: "@datadog/browser-logs": optional: true - checksum: 10c0/5eeaf5251ce0207c45d6782b3e219f8366fbce0b60bddd8fa35473a3dfc84a97fc6e2fb686033d9c985ebade414ef432cd18f17e753c694c469a3e9a6ac4aad5 + checksum: 10c0/135a2cdf999ea9505e08031ae7069f4d6a236a88e6203ef7105c784a77ae23a23fbabc648d793305dcb4b960cbe4a535fab2d688748efb5824137597f4de84b5 languageName: node linkType: hard @@ -415,18 +416,18 @@ __metadata: linkType: hard "baseline-browser-mapping@npm:^2.8.3": - version: 2.10.7 - resolution: "baseline-browser-mapping@npm:2.10.7" + version: 2.10.8 + resolution: "baseline-browser-mapping@npm:2.10.8" bin: baseline-browser-mapping: dist/cli.cjs - checksum: 10c0/fe2988088ede5a2dc7936f0a718d4b500b2a28a7ee3e11e2a3844a9444dd217a95a070c00508d8130da73c4fe576d677b21844bc078f6cd7867fb0e1be60caf0 + checksum: 10c0/a77882e8ac37a900a9a0757b9bf4e50f407829ed17ee7630273ab5da0ae10d9c64ed4c5a1e03afb3490e39713440092417ded4bb856eeb9bd44856eacbd97497 languageName: node linkType: hard "caniuse-lite@npm:^1.0.30001579": - version: 1.0.30001778 - resolution: "caniuse-lite@npm:1.0.30001778" - checksum: 10c0/830042e0a6af0796d3da4d9575f60966b92308c5504577993b618dd196c835d023dbd725fa8b47c33b74c487d75ce01ee3ebd6e7a078714989513110e8ff80e5 + version: 1.0.30001780 + resolution: "caniuse-lite@npm:1.0.30001780" + checksum: 10c0/8a88f39758a228852d6f3ac92362ecb7694b1b2b022f194d8dfe59123ad40a5de6202bf2dff0fe316bb3d5ca9caf316c22056e0da693459c3be2771cde4f4bf9 languageName: node linkType: hard diff --git a/test/e2e/playwright.base.config.ts b/test/e2e/playwright.base.config.ts index b1d1782661..ec285821cb 100644 --- a/test/e2e/playwright.base.config.ts +++ b/test/e2e/playwright.base.config.ts @@ -48,7 +48,7 @@ export const config: Config = { name: 'nextjs app router', stdout: 'pipe' as const, cwd: path.join(__dirname, '../apps/nextjs'), - command: isLocal ? 'yarn dev' : 'yarn start', + command: 'yarn start', wait: { stdout: /- Local:\s+http:\/\/localhost:(?\d+)/, }, diff --git a/test/e2e/scenario/nextjsPlugin.scenario.ts b/test/e2e/scenario/nextjsPlugin.scenario.ts index 0f718e0560..395e695c71 100644 --- a/test/e2e/scenario/nextjsPlugin.scenario.ts +++ b/test/e2e/scenario/nextjsPlugin.scenario.ts @@ -14,6 +14,7 @@ const routerConfigs = [ router: 'pages' as const, viewPrefix: '/pages-router', homeUrlPattern: /\/pages-router(\?|$)/, + clientErrorMessage: 'Pages Router error from NextjsErrorBoundary', }, ] @@ -200,69 +201,77 @@ test.describe('nextjs - router', () => { }) test.describe('nextjs - errors', () => { - const { name, viewPrefix, clientErrorMessage, router } = routerConfigs[0] - - test.describe(name, () => { - createTest('should report client-side error') - .withRum() - .withNextjsApp(router) - .run(async ({ page, flushEvents, intakeRegistry, withBrowserLogs }) => { - await page.click('text=Go to Error Test') - await page.waitForURL(`**${viewPrefix}/error-test`) + routerConfigs.forEach(({ name, router, viewPrefix, clientErrorMessage }) => { + test.describe(name, () => { + createTest('should report client-side error') + .withRum() + .withNextjsApp(router) + .run(async ({ page, flushEvents, intakeRegistry, withBrowserLogs, browserName }) => { + test.skip( + browserName === 'firefox', + 'firefox is sending the errors in two separate batches, however the last batch is delayed making the test setup to miss it' + ) + await page.click('text=Go to Error Test') + await page.waitForURL(`**${viewPrefix}/error-test`) - await page.click('[data-testid="trigger-error"]') - await page.waitForSelector('[data-testid="error-boundary"]') + await page.click('[data-testid="trigger-error"]') + await page.waitForSelector('[data-testid="error-boundary"]') - await flushEvents() + await flushEvents() - // React StrictMode double-fires useEffect in dev mode, so we may get 2 errors - const customErrors = intakeRegistry.rumErrorEvents.filter((e) => e.error.source === 'custom') - expect(customErrors.length).toBeGreaterThanOrEqual(1) - expect(customErrors[0].error.message).toBe(clientErrorMessage) - expect(customErrors[0].error.handling_stack).toBeDefined() + const customErrors = intakeRegistry.rumErrorEvents.filter((e) => e.error.source === 'custom') + expect(customErrors).toHaveLength(1) + expect(customErrors[0].error.message).toBe(clientErrorMessage) + expect(customErrors[0].error.handling_stack).toBeDefined() + expect(customErrors[0].context).toMatchObject({ framework: 'nextjs' }) - withBrowserLogs((browserLogs) => { - expect(browserLogs.length).toBeGreaterThan(0) + withBrowserLogs((browserLogs) => { + expect(browserLogs.length).toBeGreaterThan(0) + }) }) - }) - - createTest('should report a server error with digest via addNextjsError') - .withRum() - .withNextjsApp(router) - .run(async ({ page, flushEvents, intakeRegistry, withBrowserLogs }) => { - await page.click('text=Go to Server Error') - await page.waitForSelector('[data-testid="error-boundary"]') - await flushEvents() + if (router === 'app') { + createTest('should report a server error with digest via addNextjsError') + .withRum() + .withNextjsApp(router) + .run(async ({ page, flushEvents, intakeRegistry, withBrowserLogs }) => { + await page.click('text=Go to Server Error') + await page.waitForSelector('[data-testid="error-boundary"]') - // React StrictMode double-fires useEffect in dev mode, so we may get 2 errors - const customErrors = intakeRegistry.rumErrorEvents.filter((e) => e.error.source === 'custom') - expect(customErrors.length).toBeGreaterThanOrEqual(1) - expect(customErrors[0].error.handling_stack).toBeDefined() - expect((customErrors[0].context?.nextjs as { digest: string }).digest).toBeDefined() + await flushEvents() - withBrowserLogs((browserLogs) => { - expect(browserLogs.length).toBeGreaterThan(0) - }) - }) + const customErrors = intakeRegistry.rumErrorEvents.filter((e) => e.error.source === 'custom') + expect(customErrors).toHaveLength(1) + expect(customErrors[0].error.handling_stack).toBeDefined() + expect(customErrors[0].context).toMatchObject({ + framework: 'nextjs', + nextjs: { digest: expect.any(String) }, + }) + + withBrowserLogs((browserLogs) => { + expect(browserLogs.length).toBeGreaterThan(0) + }) + }) - createTest('should report global error via global-error.tsx') - .withRum() - .withNextjsApp(router) - .run(async ({ page, flushEvents, intakeRegistry, withBrowserLogs }) => { - await page.click('text=Go to Global Error') - await page.waitForSelector('[data-testid="global-error-boundary"]') + createTest('should report global error via global-error.tsx') + .withRum() + .withNextjsApp(router) + .run(async ({ page, flushEvents, intakeRegistry, withBrowserLogs }) => { + await page.click('text=Go to Global Error') + await page.waitForSelector('[data-testid="global-error-boundary"]') - await flushEvents() + await flushEvents() - // React StrictMode double-fires useEffect in dev mode, so we may get 2 errors - const customErrors = intakeRegistry.rumErrorEvents.filter((e) => e.error.source === 'custom') - expect(customErrors.length).toBeGreaterThanOrEqual(1) - expect(customErrors[0].error.handling_stack).toBeDefined() + const customErrors = intakeRegistry.rumErrorEvents.filter((e) => e.error.source === 'custom') + expect(customErrors).toHaveLength(1) + expect(customErrors[0].error.handling_stack).toBeDefined() + expect(customErrors[0].context).toMatchObject({ framework: 'nextjs' }) - withBrowserLogs((browserLogs) => { - expect(browserLogs.length).toBeGreaterThan(0) - }) - }) + withBrowserLogs((browserLogs) => { + expect(browserLogs.length).toBeGreaterThan(0) + }) + }) + } + }) }) }) diff --git a/yarn.lock b/yarn.lock index 1ed94c2ad0..fb9c7cf1f4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -307,9 +307,11 @@ __metadata: dependencies: "@datadog/browser-core": "npm:6.31.0" "@datadog/browser-rum-core": "npm:6.31.0" + "@datadog/browser-rum-react": "npm:6.31.0" "@types/react": "npm:19.2.11" next: "npm:15.5.10" react: "npm:19.2.4" + react-dom: "npm:19.2.4" peerDependencies: next: ">=13.0.0" react: ">=18.0.0" @@ -321,7 +323,7 @@ __metadata: languageName: unknown linkType: soft -"@datadog/browser-rum-react@workspace:packages/rum-react": +"@datadog/browser-rum-react@npm:6.31.0, @datadog/browser-rum-react@workspace:packages/rum-react": version: 0.0.0-use.local resolution: "@datadog/browser-rum-react@workspace:packages/rum-react" dependencies: