diff --git a/packages/react/src/web-vitals.tsx b/packages/react/src/web-vitals.tsx new file mode 100644 index 0000000..8e58ca9 --- /dev/null +++ b/packages/react/src/web-vitals.tsx @@ -0,0 +1,42 @@ +'use client'; +import { Logger } from '@axiomhq/logging'; +import * as React from 'react'; +import { onLCP, onFID, onCLS, onINP, onFCP, onTTFB } from 'web-vitals'; +import type { Metric } from 'web-vitals'; + +export function useReportWebVitals(reportWebVitalsFn: (metric: Metric) => void) { + const ref = React.useRef(reportWebVitalsFn); + + ref.current = reportWebVitalsFn; + + React.useEffect(() => { + onCLS(ref.current); + onFID(ref.current); + onLCP(ref.current); + onINP(ref.current); + onFCP(ref.current); + onTTFB(ref.current); + }, []); +} + +const transformWebVitalsMetric = (metric: Metric): Record => { + return { + webVital: metric, + _time: new Date().getTime(), + source: 'web-vital', + path: window.location.pathname, + }; +}; + +export const createWebVitalsComponent = (logger: Logger) => { + const reportWebVitals = (metric: Metric) => { + logger.raw(transformWebVitalsMetric(metric)); + logger.flush(); + }; + + return () => { + useReportWebVitals(reportWebVitals); + + return <>; + }; +}; diff --git a/packages/react/test/unit/web-vitals.test.tsx b/packages/react/test/unit/web-vitals.test.tsx new file mode 100644 index 0000000..322de88 --- /dev/null +++ b/packages/react/test/unit/web-vitals.test.tsx @@ -0,0 +1,121 @@ +import * as React from 'react'; +import { renderHook, render } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { useReportWebVitals, createWebVitalsComponent } from '../../src/web-vitals'; +import { Logger } from '@axiomhq/logging'; +import * as webVitals from 'web-vitals'; + +// Mock all web-vitals functions +vi.mock('web-vitals', () => ({ + onCLS: vi.fn(), + onFID: vi.fn(), + onLCP: vi.fn(), + onINP: vi.fn(), + onFCP: vi.fn(), + onTTFB: vi.fn(), +})); + +describe('Web Vitals', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset window location + Object.defineProperty(window, 'location', { + value: { pathname: '/test-path' }, + writable: true, + }); + }); + + describe('useReportWebVitals', () => { + it('should register all web vitals metrics', () => { + const reportFn = vi.fn(); + renderHook(() => useReportWebVitals(reportFn)); + + expect(webVitals.onCLS).toHaveBeenCalled(); + expect(webVitals.onFID).toHaveBeenCalled(); + expect(webVitals.onLCP).toHaveBeenCalled(); + expect(webVitals.onINP).toHaveBeenCalled(); + expect(webVitals.onFCP).toHaveBeenCalled(); + expect(webVitals.onTTFB).toHaveBeenCalled(); + }); + + it('should pass the report function to all web vitals', () => { + const reportFn = vi.fn(); + renderHook(() => useReportWebVitals(reportFn)); + + const calls = [ + webVitals.onCLS, + webVitals.onFID, + webVitals.onLCP, + webVitals.onINP, + webVitals.onFCP, + webVitals.onTTFB, + ]; + + calls.forEach((call) => { + expect(call).toHaveBeenCalledWith(expect.any(Function)); + }); + }); + }); + + describe('createWebVitalsComponent', () => { + it('should create a component that uses web vitals reporting', () => { + const mockLogger = { + raw: vi.fn(), + flush: vi.fn(), + } as unknown as Logger; + + const WebVitals = createWebVitalsComponent(mockLogger); + render(); + + // Verify that all web vitals are registered + expect(webVitals.onCLS).toHaveBeenCalled(); + expect(webVitals.onFID).toHaveBeenCalled(); + expect(webVitals.onLCP).toHaveBeenCalled(); + expect(webVitals.onINP).toHaveBeenCalled(); + expect(webVitals.onFCP).toHaveBeenCalled(); + expect(webVitals.onTTFB).toHaveBeenCalled(); + }); + + it('should log and flush metrics when reported', () => { + const mockLogger = { + raw: vi.fn(), + flush: vi.fn(), + } as unknown as Logger; + + const WebVitals = createWebVitalsComponent(mockLogger); + render(); + + // Simulate a web vital metric being reported + const mockMetric = { + name: 'CLS', + value: 0.1, + id: 'test', + }; + + // Get the callback passed to onCLS and call it + const onCLSCallback = vi.mocked(webVitals.onCLS).mock.calls[0][0] as Function; + onCLSCallback(mockMetric); + + // Verify the metric was logged and flushed + expect(mockLogger.raw).toHaveBeenCalledWith({ + webVital: mockMetric, + _time: expect.any(Number), + source: 'web-vital', + path: '/test-path', + }); + expect(mockLogger.flush).toHaveBeenCalled(); + }); + + it('should render an empty fragment', () => { + const mockLogger = { + raw: vi.fn(), + flush: vi.fn(), + } as unknown as Logger; + + const WebVitals = createWebVitalsComponent(mockLogger); + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + }); +});