From 1a101505c4d2cfcfcebc77b728a99dc50a2f6aac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 16 May 2026 17:33:08 +0200 Subject: [PATCH] fix(monitoring): defer web-vitals sample draw to useEffect to satisfy react-hooks/purity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 10% sampling decision in WebVitalsReporter (v1.4.33 F19) was computed inline in useMemo(), which the react-hooks/purity lint rule rejects: Math.random() is impure and the render body must be idempotent. The original useMemo() happened to be stable in practice (empty dep array) but the rule fires regardless because the expression inside the memo factory is impure. Move the draw into useEffect(() => {}, []) and stash the result in a useRef so every reportWebVitals callback in the same navigation reads the same decision. SSR seeds the ref to null, so a pre-hydration beacon (which would have no rate-limit relief value) is dropped; the first post-hydration callback finds the ref populated and decides whether to POST. Bundle and runtime cost unchanged — same one draw per mount, same all-or-none per page-load. ESLint clean. --- .../monitoring/web-vitals-reporter.tsx | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/components/monitoring/web-vitals-reporter.tsx b/src/components/monitoring/web-vitals-reporter.tsx index 093a64b94..35dd6b92e 100644 --- a/src/components/monitoring/web-vitals-reporter.tsx +++ b/src/components/monitoring/web-vitals-reporter.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo } from "react"; +import { useEffect, useRef } from "react"; import { useReportWebVitals } from "next/web-vitals"; /** @@ -38,16 +38,24 @@ const SAMPLE_RATE = 0.1; export function WebVitalsReporter() { // Decide once per mount whether this page-load reports its vitals. - // `useMemo` with an empty dep array keeps the decision stable across - // every metric callback inside the same navigation — partial sampling - // would mix LCP-without-CLS noise into the wide-event aggregates. - // `SSR` evaluates Math.random() to a stable false branch (Node), but - // the hook only fires post-hydration so the decision uses the - // browser's PRNG. - const sampled = useMemo(() => Math.random() < SAMPLE_RATE, []); + // Math.random() is impure, so we deferred the draw to `useEffect` + // (React's purity contract forbids running impure work inside + // useMemo / the render body). The ref keeps the decision stable + // across every metric callback inside the same navigation — + // partial sampling would mix LCP-without-CLS noise into the wide- + // event aggregates. SSR initialises the ref to `null` (no sample + // decision yet); the first reportWebVitals callback fires post- + // hydration once the effect has filled the slot. + const sampledRef = useRef(null); + useEffect(() => { + sampledRef.current = Math.random() < SAMPLE_RATE; + }, []); useReportWebVitals((metric) => { - if (!sampled) return; + // If the effect hasn't yet seeded the sample decision (very first + // pre-hydration measurement), drop the beacon — over-sampling at + // first paint would defeat the rate-limit relief. + if (sampledRef.current !== true) return; const body = JSON.stringify({ id: metric.id,