Avoid calling undefined umami.track, add safe checks for Performance APIs, and clean up load listeners to prevent .call() crashes in Chrome.
356 lines
11 KiB
TypeScript
356 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect } from 'react';
|
|
import { trackWebVitals, trackPerformance } from './analytics';
|
|
|
|
// Web Vitals types
|
|
interface Metric {
|
|
name: string;
|
|
value: number;
|
|
delta: number;
|
|
id: string;
|
|
}
|
|
|
|
// Simple Web Vitals implementation (since we don't want to add external dependencies)
|
|
const getCLS = (onPerfEntry: (metric: Metric) => void) => {
|
|
if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null;
|
|
|
|
try {
|
|
let clsValue = 0;
|
|
let sessionValue = 0;
|
|
let sessionEntries: PerformanceEntry[] = [];
|
|
|
|
const observer = new PerformanceObserver((list) => {
|
|
try {
|
|
for (const entry of list.getEntries()) {
|
|
if (!(entry as PerformanceEntry & { hadRecentInput?: boolean }).hadRecentInput) {
|
|
const firstSessionEntry = sessionEntries[0];
|
|
const lastSessionEntry = sessionEntries[sessionEntries.length - 1];
|
|
|
|
if (sessionValue && entry.startTime - lastSessionEntry.startTime < 1000 && entry.startTime - firstSessionEntry.startTime < 5000) {
|
|
sessionValue += (entry as PerformanceEntry & { value?: number }).value || 0;
|
|
sessionEntries.push(entry);
|
|
} else {
|
|
sessionValue = (entry as PerformanceEntry & { value?: number }).value || 0;
|
|
sessionEntries = [entry];
|
|
}
|
|
|
|
if (sessionValue > clsValue) {
|
|
clsValue = sessionValue;
|
|
onPerfEntry({
|
|
name: 'CLS',
|
|
value: clsValue,
|
|
delta: clsValue,
|
|
id: `cls-${Date.now()}`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Silently fail - CLS tracking is not critical
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.warn('CLS tracking error:', error);
|
|
}
|
|
}
|
|
});
|
|
|
|
observer.observe({ type: 'layout-shift', buffered: true });
|
|
return observer;
|
|
} catch (error) {
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.warn('CLS observer initialization failed:', error);
|
|
}
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const getFID = (onPerfEntry: (metric: Metric) => void) => {
|
|
if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null;
|
|
|
|
try {
|
|
const observer = new PerformanceObserver((list) => {
|
|
try {
|
|
for (const entry of list.getEntries()) {
|
|
const processingStart = (entry as PerformanceEntry & { processingStart?: number }).processingStart;
|
|
if (processingStart !== undefined) {
|
|
onPerfEntry({
|
|
name: 'FID',
|
|
value: processingStart - entry.startTime,
|
|
delta: processingStart - entry.startTime,
|
|
id: `fid-${Date.now()}`,
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.warn('FID tracking error:', error);
|
|
}
|
|
}
|
|
});
|
|
|
|
observer.observe({ type: 'first-input', buffered: true });
|
|
return observer;
|
|
} catch (error) {
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.warn('FID observer initialization failed:', error);
|
|
}
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const getFCP = (onPerfEntry: (metric: Metric) => void) => {
|
|
if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null;
|
|
|
|
try {
|
|
const observer = new PerformanceObserver((list) => {
|
|
try {
|
|
for (const entry of list.getEntries()) {
|
|
if (entry.name === 'first-contentful-paint') {
|
|
onPerfEntry({
|
|
name: 'FCP',
|
|
value: entry.startTime,
|
|
delta: entry.startTime,
|
|
id: `fcp-${Date.now()}`,
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.warn('FCP tracking error:', error);
|
|
}
|
|
}
|
|
});
|
|
|
|
observer.observe({ type: 'paint', buffered: true });
|
|
return observer;
|
|
} catch (error) {
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.warn('FCP observer initialization failed:', error);
|
|
}
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const getLCP = (onPerfEntry: (metric: Metric) => void) => {
|
|
if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null;
|
|
|
|
try {
|
|
const observer = new PerformanceObserver((list) => {
|
|
try {
|
|
const entries = list.getEntries();
|
|
const lastEntry = entries[entries.length - 1];
|
|
|
|
if (lastEntry) {
|
|
onPerfEntry({
|
|
name: 'LCP',
|
|
value: lastEntry.startTime,
|
|
delta: lastEntry.startTime,
|
|
id: `lcp-${Date.now()}`,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.warn('LCP tracking error:', error);
|
|
}
|
|
}
|
|
});
|
|
|
|
observer.observe({ type: 'largest-contentful-paint', buffered: true });
|
|
return observer;
|
|
} catch (error) {
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.warn('LCP observer initialization failed:', error);
|
|
}
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const getTTFB = (onPerfEntry: (metric: Metric) => void) => {
|
|
if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null;
|
|
|
|
try {
|
|
const observer = new PerformanceObserver((list) => {
|
|
try {
|
|
for (const entry of list.getEntries()) {
|
|
if (entry.entryType === 'navigation') {
|
|
const navEntry = entry as PerformanceNavigationTiming;
|
|
if (navEntry.responseStart && navEntry.fetchStart) {
|
|
onPerfEntry({
|
|
name: 'TTFB',
|
|
value: navEntry.responseStart - navEntry.fetchStart,
|
|
delta: navEntry.responseStart - navEntry.fetchStart,
|
|
id: `ttfb-${Date.now()}`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.warn('TTFB tracking error:', error);
|
|
}
|
|
}
|
|
});
|
|
|
|
observer.observe({ type: 'navigation', buffered: true });
|
|
return observer;
|
|
} catch (error) {
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.warn('TTFB observer initialization failed:', error);
|
|
}
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// Custom hook for Web Vitals tracking
|
|
export const useWebVitals = () => {
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
|
|
// Wrap everything in try-catch to prevent errors from breaking the app
|
|
try {
|
|
const safeNow = () => {
|
|
if (typeof performance !== "undefined" && typeof performance.now === "function") {
|
|
return performance.now();
|
|
}
|
|
return Date.now();
|
|
};
|
|
|
|
// Store web vitals for batch sending
|
|
const webVitals: Record<string, number> = {};
|
|
const path = window.location.pathname;
|
|
const projectMatch = path.match(/\/projects\/([^\/]+)/);
|
|
const projectId = projectMatch ? projectMatch[1] : null;
|
|
const observers: PerformanceObserver[] = [];
|
|
|
|
const sendWebVitals = async () => {
|
|
if (Object.keys(webVitals).length >= 3) { // Wait for at least FCP, LCP, CLS
|
|
try {
|
|
await fetch('/api/analytics/track', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
type: 'performance',
|
|
projectId: projectId,
|
|
page: path,
|
|
performance: {
|
|
fcp: webVitals.FCP || 0,
|
|
lcp: webVitals.LCP || 0,
|
|
cls: webVitals.CLS || 0,
|
|
fid: webVitals.FID || 0,
|
|
ttfb: webVitals.TTFB || 0,
|
|
loadTime: safeNow()
|
|
}
|
|
})
|
|
});
|
|
} catch (error) {
|
|
// Silently fail
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.error('Error sending web vitals:', error);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// Track Core Web Vitals
|
|
const clsObserver = getCLS((metric) => {
|
|
webVitals.CLS = metric.value;
|
|
trackWebVitals({
|
|
...metric,
|
|
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
|
|
url: window.location.pathname,
|
|
});
|
|
sendWebVitals();
|
|
});
|
|
if (clsObserver) observers.push(clsObserver);
|
|
|
|
const fidObserver = getFID((metric) => {
|
|
webVitals.FID = metric.value;
|
|
trackWebVitals({
|
|
...metric,
|
|
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
|
|
url: window.location.pathname,
|
|
});
|
|
sendWebVitals();
|
|
});
|
|
if (fidObserver) observers.push(fidObserver);
|
|
|
|
const fcpObserver = getFCP((metric) => {
|
|
webVitals.FCP = metric.value;
|
|
trackWebVitals({
|
|
...metric,
|
|
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
|
|
url: window.location.pathname,
|
|
});
|
|
sendWebVitals();
|
|
});
|
|
if (fcpObserver) observers.push(fcpObserver);
|
|
|
|
const lcpObserver = getLCP((metric) => {
|
|
webVitals.LCP = metric.value;
|
|
trackWebVitals({
|
|
...metric,
|
|
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
|
|
url: window.location.pathname,
|
|
});
|
|
sendWebVitals();
|
|
});
|
|
if (lcpObserver) observers.push(lcpObserver);
|
|
|
|
const ttfbObserver = getTTFB((metric) => {
|
|
webVitals.TTFB = metric.value;
|
|
trackWebVitals({
|
|
...metric,
|
|
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
|
|
url: window.location.pathname,
|
|
});
|
|
sendWebVitals();
|
|
});
|
|
if (ttfbObserver) observers.push(ttfbObserver);
|
|
|
|
// Track page load performance
|
|
const handleLoad = () => {
|
|
setTimeout(() => {
|
|
trackPerformance({
|
|
name: 'page-load-complete',
|
|
value: safeNow(),
|
|
url: window.location.pathname,
|
|
timestamp: Date.now(),
|
|
userAgent: navigator.userAgent,
|
|
});
|
|
}, 0);
|
|
};
|
|
|
|
if (document.readyState === 'complete') {
|
|
handleLoad();
|
|
} else {
|
|
window.addEventListener('load', handleLoad);
|
|
}
|
|
|
|
return () => {
|
|
// Cleanup all observers
|
|
observers.forEach(observer => {
|
|
try {
|
|
observer.disconnect();
|
|
} catch {
|
|
// Silently fail
|
|
}
|
|
});
|
|
try {
|
|
window.removeEventListener('load', handleLoad);
|
|
} catch {
|
|
// Silently fail
|
|
}
|
|
};
|
|
} catch (error) {
|
|
// If Web Vitals initialization fails, don't break the app
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.warn('Web Vitals initialization failed:', error);
|
|
}
|
|
// Return empty cleanup function
|
|
return () => {};
|
|
}
|
|
}, []);
|
|
};
|