analytics-tracker.web.tsx 3.4 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889
  1. 'use client';
  2. import { Suspense, useEffect, useRef } from 'react';
  3. import { usePathname, useSearchParams } from 'next/navigation';
  4. import { useReportWebVitals } from 'next/web-vitals';
  5. import { installGlobalErrorCapture } from './global-errors';
  6. import { finishScreenLoad, recordWebVital, setHealthView } from './health';
  7. import { setTrackerVisible, startTracker, trackPageview } from './tracker';
  8. /** Next sets the new page's title shortly after the route changes. */
  9. const TITLE_DELAY_MS = 400;
  10. type WebVitalMetric = { name: string; value: number; navigationType?: string };
  11. /** One function for the page's life: next/web-vitals reports everything again to a new one. */
  12. function reportWebVital(metric: WebVitalMetric): void {
  13. recordWebVital(metric.name, metric.value, metric.navigationType);
  14. }
  15. /** Runs `callback` once the browser has painted (at once in a hidden tab, which doesn't paint). */
  16. function afterNextPaint(callback: () => void): () => void {
  17. if (document.visibilityState === 'hidden' || typeof requestAnimationFrame !== 'function') {
  18. callback();
  19. return () => {};
  20. }
  21. let timer: ReturnType<typeof setTimeout> | null = null;
  22. const frame = requestAnimationFrame(() => {
  23. timer = setTimeout(callback, 0);
  24. });
  25. return () => {
  26. cancelAnimationFrame(frame);
  27. if (timer) clearTimeout(timer);
  28. };
  29. }
  30. /**
  31. * A pageview on the first page and after every client-side navigation (including History API
  32. * URL changes). A router navigation's page, once painted, ends its `screen_load` (started in
  33. * instrumentation-client.ts).
  34. */
  35. function RouteTracker() {
  36. const pathname = usePathname();
  37. const search = useSearchParams()?.toString() ?? '';
  38. const route = `${pathname}?${search}`;
  39. const firstRoute = useRef(true);
  40. useEffect(() => {
  41. if (!route) return undefined;
  42. setHealthView(window.location.href);
  43. const cancelPaint = firstRoute.current ? null : afterNextPaint(finishScreenLoad);
  44. firstRoute.current = false;
  45. // A page left before this fires (a redirect) isn't counted.
  46. const timer = setTimeout(() => trackPageview(window.location.href, document.title), TITLE_DELAY_MS);
  47. return () => {
  48. clearTimeout(timer);
  49. cancelPaint?.();
  50. };
  51. }, [route]);
  52. return null;
  53. }
  54. /**
  55. * Sends pageviews, engaged time, errors and web vitals to UNA's analytics. Mounted once, in
  56. * the app providers.
  57. */
  58. export function AnalyticsTracker() {
  59. useReportWebVitals(reportWebVital);
  60. useEffect(() => {
  61. // already installed by instrumentation-client.ts; here in case that didn't run
  62. installGlobalErrorCapture();
  63. startTracker(document.visibilityState === 'visible');
  64. const onVisibilityChange = () => setTrackerVisible(document.visibilityState === 'visible');
  65. const onPageHide = () => setTrackerVisible(false);
  66. document.addEventListener('visibilitychange', onVisibilityChange);
  67. window.addEventListener('pagehide', onPageHide);
  68. return () => {
  69. document.removeEventListener('visibilitychange', onVisibilityChange);
  70. window.removeEventListener('pagehide', onPageHide);
  71. };
  72. }, []);
  73. // useSearchParams needs its own Suspense boundary, or prerendered routes bail out to client rendering.
  74. return (
  75. <Suspense fallback={null}>
  76. <RouteTracker />
  77. </Suspense>
  78. );
  79. }