diff --git a/packages/rum-react/src/domain/performance/addDurationVital.ts b/packages/rum-react/src/domain/performance/addDurationVital.ts
new file mode 100644
index 0000000000..91ca505152
--- /dev/null
+++ b/packages/rum-react/src/domain/performance/addDurationVital.ts
@@ -0,0 +1,8 @@
+import type { RumPublicApi } from '@datadog/browser-rum-core'
+import { onReactPluginInit } from '../reactPlugin'
+
+export const addDurationVital: RumPublicApi['addDurationVital'] = (name, options) => {
+ onReactPluginInit((_, rumPublicApi) => {
+ rumPublicApi.addDurationVital(name, options)
+ })
+}
diff --git a/packages/rum-react/src/domain/performance/index.ts b/packages/rum-react/src/domain/performance/index.ts
new file mode 100644
index 0000000000..06fdb9e674
--- /dev/null
+++ b/packages/rum-react/src/domain/performance/index.ts
@@ -0,0 +1,2 @@
+// eslint-disable-next-line camelcase
+export { UNSTABLE_ReactComponentTracker } from './reactComponentTracker'
diff --git a/packages/rum-react/src/domain/performance/reactComponentTracker.spec.tsx b/packages/rum-react/src/domain/performance/reactComponentTracker.spec.tsx
new file mode 100644
index 0000000000..14f9df8245
--- /dev/null
+++ b/packages/rum-react/src/domain/performance/reactComponentTracker.spec.tsx
@@ -0,0 +1,108 @@
+import React, { useEffect, useLayoutEffect } from 'react'
+import { flushSync } from 'react-dom'
+import { appendComponent } from '../../../test/appendComponent'
+import { initializeReactPlugin } from '../../../test/initializeReactPlugin'
+import type { Clock } from '../../../../core/test'
+import { mockClock, registerCleanupTask } from '../../../../core/test'
+// eslint-disable-next-line camelcase
+import { UNSTABLE_ReactComponentTracker } from './reactComponentTracker'
+
+const RENDER_DURATION = 100
+const EFFECT_DURATION = 101
+const LAYOUT_EFFECT_DURATION = 102
+const TOTAL_DURATION = RENDER_DURATION + EFFECT_DURATION + LAYOUT_EFFECT_DURATION
+
+function ChildComponent({ clock }: { clock: Clock }) {
+ clock.tick(RENDER_DURATION)
+ useEffect(() => clock.tick(EFFECT_DURATION))
+ useLayoutEffect(() => clock.tick(LAYOUT_EFFECT_DURATION))
+ return null
+}
+
+describe('UNSTABLE_ReactComponentTracker', () => {
+ let clock: Clock
+
+ beforeEach(() => {
+ clock = mockClock()
+ registerCleanupTask(() => clock.cleanup())
+ })
+
+ it('should call addDurationVital after the component rendering', () => {
+ const addDurationVitalSpy = jasmine.createSpy()
+ initializeReactPlugin({
+ publicApi: {
+ addDurationVital: addDurationVitalSpy,
+ },
+ })
+
+ appendComponent(
+ // eslint-disable-next-line camelcase
+
+
+
+ )
+
+ expect(addDurationVitalSpy).toHaveBeenCalledTimes(1)
+ const [name, options] = addDurationVitalSpy.calls.mostRecent().args
+ expect(name).toBe('reactComponentRender')
+ expect(options).toEqual({
+ description: 'ChildComponent',
+ startTime: clock.timeStamp(0),
+ duration: TOTAL_DURATION,
+ context: {
+ is_first_render: true,
+ render_phase_duration: RENDER_DURATION,
+ effect_phase_duration: EFFECT_DURATION,
+ layout_effect_phase_duration: LAYOUT_EFFECT_DURATION,
+ framework: 'react',
+ },
+ })
+ })
+
+ it('should call addDurationVital on rerender', () => {
+ const addDurationVitalSpy = jasmine.createSpy()
+ initializeReactPlugin({
+ publicApi: {
+ addDurationVital: addDurationVitalSpy,
+ },
+ })
+
+ let forceUpdate: () => void
+
+ function App() {
+ const [, setState] = React.useState(0)
+ forceUpdate = () => setState((prev) => prev + 1)
+ return (
+ <>
+ {/* eslint-disable-next-line camelcase */}
+
+
+
+ >
+ )
+ }
+
+ appendComponent()
+
+ clock.tick(1)
+
+ flushSync(() => {
+ forceUpdate!()
+ })
+
+ expect(addDurationVitalSpy).toHaveBeenCalledTimes(2)
+ const options = addDurationVitalSpy.calls.mostRecent().args[1]
+ expect(options).toEqual({
+ description: 'ChildComponent',
+ startTime: clock.timeStamp(TOTAL_DURATION + 1),
+ duration: TOTAL_DURATION,
+ context: {
+ is_first_render: false,
+ render_phase_duration: RENDER_DURATION,
+ effect_phase_duration: EFFECT_DURATION,
+ layout_effect_phase_duration: LAYOUT_EFFECT_DURATION,
+ framework: 'react',
+ },
+ })
+ })
+})
diff --git a/packages/rum-react/src/domain/performance/reactComponentTracker.tsx b/packages/rum-react/src/domain/performance/reactComponentTracker.tsx
new file mode 100644
index 0000000000..a070e01ff9
--- /dev/null
+++ b/packages/rum-react/src/domain/performance/reactComponentTracker.tsx
@@ -0,0 +1,78 @@
+import * as React from 'react'
+import { createTimer } from './timer'
+import { addDurationVital } from './addDurationVital'
+
+// eslint-disable-next-line
+export const UNSTABLE_ReactComponentTracker = ({
+ name: componentName,
+ children,
+}: {
+ name: string
+ children?: React.ReactNode
+}) => {
+ const isFirstRender = React.useRef(true)
+
+ const renderTimer = createTimer()
+ const effectTimer = createTimer()
+ const layoutEffectTimer = createTimer()
+
+ const onEffectEnd = () => {
+ const renderDuration = renderTimer.getDuration() ?? 0
+ const effectDuration = effectTimer.getDuration() ?? 0
+ const layoutEffectDuration = layoutEffectTimer.getDuration() ?? 0
+
+ const totalRenderTime = renderDuration + effectDuration + layoutEffectDuration
+
+ addDurationVital('reactComponentRender', {
+ description: componentName,
+ startTime: renderTimer.getStartTime()!, // note: renderTimer should have been started at this point, so getStartTime should not return undefined
+ duration: totalRenderTime,
+ context: {
+ is_first_render: isFirstRender.current,
+ render_phase_duration: renderDuration,
+ effect_phase_duration: effectDuration,
+ layout_effect_phase_duration: layoutEffectDuration,
+ framework: 'react',
+ },
+ })
+
+ isFirstRender.current = false
+ }
+
+ // In react, children are rendered sequentially in the order they are defined. that's why we can
+ // measure perf timings of a component by starting recordings in the component above and stopping
+ // them in the component below.
+ return (
+ <>
+
+ {children}
+ {
+ effectTimer.stopTimer()
+ onEffectEnd()
+ }}
+ />
+ >
+ )
+}
+
+function LifeCycle({
+ onRender,
+ onLayoutEffect,
+ onEffect,
+}: {
+ onRender: () => void
+ onLayoutEffect: () => void
+ onEffect: () => void
+}) {
+ onRender()
+ React.useLayoutEffect(onLayoutEffect)
+ React.useEffect(onEffect)
+ return null
+}
diff --git a/packages/rum-react/src/domain/performance/timer.spec.ts b/packages/rum-react/src/domain/performance/timer.spec.ts
new file mode 100644
index 0000000000..fd60e7f106
--- /dev/null
+++ b/packages/rum-react/src/domain/performance/timer.spec.ts
@@ -0,0 +1,16 @@
+import { mockClock, registerCleanupTask } from '@datadog/browser-core/test'
+import type { Duration } from '@datadog/browser-core'
+import { createTimer } from './timer'
+
+describe('createTimer', () => {
+ it('is able to measure time', () => {
+ const clock = mockClock()
+ registerCleanupTask(clock.cleanup)
+
+ const timer = createTimer()
+ timer.startTimer()
+ clock.tick(1000)
+ timer.stopTimer()
+ expect(timer.getDuration()).toBe(1000 as Duration)
+ })
+})
diff --git a/packages/rum-react/src/domain/performance/timer.ts b/packages/rum-react/src/domain/performance/timer.ts
new file mode 100644
index 0000000000..32fecfb0eb
--- /dev/null
+++ b/packages/rum-react/src/domain/performance/timer.ts
@@ -0,0 +1,32 @@
+import type { Duration, RelativeTime, TimeStamp } from '@datadog/browser-core'
+import { elapsed, relativeNow, timeStampNow } from '@datadog/browser-core'
+
+export function createTimer() {
+ let duration: Duration | undefined
+ let startTime: TimeStamp | undefined
+ let highPrecisionStartTime: RelativeTime | undefined
+
+ return {
+ startTimer(this: void) {
+ // timeStampNow uses Date.now() internally, which is not high precision, but this is what is
+ // used for other events, so we use it here as well.
+ startTime = timeStampNow()
+
+ // relativeNow uses performance.now() which is higher precision than Date.now(), so we use for
+ // the duration
+ highPrecisionStartTime = relativeNow()
+ },
+
+ stopTimer(this: void) {
+ duration = elapsed(highPrecisionStartTime!, relativeNow())
+ },
+
+ getDuration(this: void) {
+ return duration
+ },
+
+ getStartTime(this: void) {
+ return startTime
+ },
+ }
+}
diff --git a/packages/rum-react/src/entries/main.ts b/packages/rum-react/src/entries/main.ts
index dd221ccccd..b188d536c4 100644
--- a/packages/rum-react/src/entries/main.ts
+++ b/packages/rum-react/src/entries/main.ts
@@ -1,2 +1,4 @@
export { ErrorBoundary, addReactError } from '../domain/error'
export { reactPlugin } from '../domain/reactPlugin'
+// eslint-disable-next-line camelcase
+export { UNSTABLE_ReactComponentTracker } from '../domain/performance'
diff --git a/sandbox/react-app/main.tsx b/sandbox/react-app/main.tsx
index 8824d49ff4..07188e9b33 100644
--- a/sandbox/react-app/main.tsx
+++ b/sandbox/react-app/main.tsx
@@ -3,7 +3,7 @@ import React, { useEffect, useState } from 'react'
import ReactDOM from 'react-dom/client'
import { datadogRum } from '@datadog/browser-rum'
import { createBrowserRouter } from '@datadog/browser-rum-react/react-router-v6'
-import { reactPlugin, ErrorBoundary } from '@datadog/browser-rum-react'
+import { reactPlugin, ErrorBoundary, UNSTABLE_ReactComponentTracker } from '@datadog/browser-rum-react'
datadogRum.init({
applicationId: 'xxx',
@@ -66,7 +66,11 @@ function HomePage() {
function UserPage() {
const { id } = useParams()
- return User {id}
+ return (
+
+ User {id}
+
+ )
}
function WildCardPage() {
diff --git a/webpack.base.js b/webpack.base.js
index be8744cf74..ae6461f206 100644
--- a/webpack.base.js
+++ b/webpack.base.js
@@ -36,7 +36,7 @@ module.exports = ({ entry, mode, filename, types, keepBuildEnvVariables, plugins
},
resolve: {
- extensions: ['.ts', '.js'],
+ extensions: ['.ts', '.js', '.tsx'],
plugins: [new TsconfigPathsPlugin({ configFile: tsconfigPath })],
alias: {
// The default "pako.esm.js" build is not transpiled to es5