Mesurer le temps d'affichage des interactions et de la navigation logicielle

Les métriques standards de peinture et de réactivité laissent un écart de mesure après le chargement initial de la page :

  • LargestContentfulPaint (largest-contentful-paint) ne mesure que le chargement initial ("hard") du document et cesse d'émettre des entrées dès que l'utilisateur interagit pour la première fois avec la page.
  • PerformanceEventTiming et event/Interaction to Next Paint (INP) mesurent la réactivité des entrées jusqu'au frame suivant (comme un état actif de bouton ou un indicateur de chargement), mais ne suivent pas les peintures asynchrones ultérieures, telles que le nouveau contenu affiché après la résolution d'un fetch() ou la fin d'une transition de routage côté client.
  • Il peut être difficile d'observer les changements de route dans une application monopage (SPA) en dehors du code du framework. Le simple fait de surveiller les mises à jour d'URL peut entraîner une surestimation des modifications d'URL qui ne sont pas liées à des changements de route (par exemple, les modifications de liens d'ancrage ou les "redirections" multiples pour un seul changement de route).

Les types d'entrées de performances InteractionContentfulPaint (interaction-contentful-paint) et PerformanceSoftNavigation (soft-navigation) comblent cette lacune. Ils propagent la causalité des interactions entre les tâches asynchrones (fetch, await, setTimeout) pour attribuer les nouveaux affichages de contenu et les transitions de routage des applications monopages (SPA) directement à l'interaction utilisateur qui les a déclenchés.

Mise en œuvre

1. Observer les Contentful Paints déclenchés par les interactions

Utilisez un PerformanceObserver avec type: 'interaction-contentful-paint' et buffered: true pour observer les Largest Contentful Paint progressivement plus grands attribuables à toute interaction utilisateur.

Chaque entrée InteractionContentfulPaint fournit les informations suivantes :

  • interactionId : identifiant unique de l'interaction utilisateur à l'origine de l'événement (correspondant à PerformanceEventTiming.interactionId et PerformanceSoftNavigation.interactionId).
  • startTime : code temporel du début de l'interaction utilisateur à l'origine de la navigation, par rapport à l'origine temporelle de la navigation initiale.
  • paintTime et presentationTime : fin de la phase d'affichage et dessin des pixels peints à l'écran.
  • duration : temps écoulé entre le début de l'interaction et la présentation de l'affichage (presentationTime - startTime).
  • largestContentfulPaint : objet LargestContentfulPaint d'instantané contenant des détails sur le plus grand élément peint pour cette interaction jusqu'à présent (element, size, url, id, renderTime, loadTime).
/**
 * Observes contentful paints caused by user interactions and invokes a callback
 * whenever an interaction produces a larger contentful paint.
 *
 * @param {(entry: PerformanceEntry) => void} onInteractionPaint
 */
export function observeInteractionPaints(onInteractionPaint) {
  // Feature-detect support before calling observe() so unsupported browsers
  // do not throw or log console warnings.
  if (
    typeof PerformanceObserver === 'undefined' ||
    !PerformanceObserver.supportedEntryTypes?.includes('interaction-contentful-paint')
  ) {
    return null;
  }

  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      // entry.duration is already calculated as (entry.presentationTime - entry.startTime),
      // giving the total time from the user interaction to this contentful paint.
      onInteractionPaint(entry);
    }
  });

  // MANDATORY: Pass buffered: true to capture interaction paints that occurred
  // before this observer was registered.
  observer.observe({ type: 'interaction-contentful-paint', buffered: true });
  return observer;
}

2. Mesurer le Largest Contentful Paint (LCP) pour les navigations douces dans les SPA

Une navigation logicielle est émise par le navigateur lorsqu'une interaction utilisateur de confiance entraîne à la fois un changement d'URL visible et un affichage de contenu. Pour calculer le LCP pour chaque navigation réactive :

  1. Observez les entrées 'soft-navigation' et 'interaction-contentful-paint' avec buffered: true.
  2. OBLIGATOIRE : lorsqu'une entrée 'soft-navigation' est reçue, amorcez son candidat LCP initial en appelant softNavEntry.getLargestInteractionContentfulPaint(). Si l'application a peint du contenu avant de mettre à jour l'URL (par exemple, en affichant une vue, puis en appelant history.pushState()), ces premières entrées interaction-contentful-paint ont été émises avant l'entrée soft-navigation et portent toujours l'navigationId précédent.
  3. OBLIGATOIRE : Corrélez les entrées 'interaction-contentful-paint' suivantes à la navigation logicielle active en faisant correspondre entry.interactionId === softNavEntry.interactionId, NE FAITES PAS correspondre par entry.navigationId. L'utilisation de interactionId vous permet d'inclure les peintures qui ont eu lieu avant la mise à jour de l'URL et d'exclure les peintures des interactions ultérieures non liées sur la nouvelle route.
/**
 * Tracks FCP and LCP for each client-side soft navigation and reports finalized
 * route metrics when the user navigates to a new route or hides the page.
 *
 * @param {(report: {
 *   url: string,
 *   navigationId: number,
 *   interactionId: number,
 *   fcpDuration: number,
 *   lcpDuration: number | null,
 *   lcpElement: Element | null,
 *   lcpSize: number | null
 * }) => void} onSoftNavReport
 */
export function trackSoftNavigationLCP(onSoftNavReport) {
  const supported =
    typeof PerformanceObserver !== 'undefined' &&
    PerformanceObserver.supportedEntryTypes?.includes('soft-navigation') &&
    PerformanceObserver.supportedEntryTypes?.includes('interaction-contentful-paint');

  if (!supported) {
    return null;
  }

  let activeSoftNav = null;
  let activeLcpEntry = null;

  function finalizeCurrentSoftNav() {
    if (!activeSoftNav) return;

    onSoftNavReport({
      // activeSoftNav.name holds the destination URL of the soft navigation.
      url: activeSoftNav.name,
      navigationId: activeSoftNav.navigationId,
      interactionId: activeSoftNav.interactionId,
      // activeSoftNav.duration is (activeSoftNav.presentationTime - activeSoftNav.startTime),
      // which represents First Contentful Paint (FCP) relative to interaction start.
      fcpDuration: activeSoftNav.duration,
      // activeLcpEntry.duration is (activeLcpEntry.presentationTime - activeSoftNav.startTime),
      // representing the soft navigation's LCP duration relative to interaction start.
      lcpDuration: activeLcpEntry ? activeLcpEntry.duration : null,
      lcpElement: activeLcpEntry?.largestContentfulPaint?.element ?? null,
      lcpSize: activeLcpEntry?.largestContentfulPaint?.size ?? null,
    });

    activeSoftNav = null;
    activeLcpEntry = null;
  }

  const softNavObserver = new PerformanceObserver((list) => {
    for (const softNavEntry of list.getEntries()) {
      // Finalize the previous route's metrics before resetting state for the new route.
      finalizeCurrentSoftNav();

      activeSoftNav = softNavEntry;
      // MANDATORY: Seed the LCP candidate with getLargestInteractionContentfulPaint()
      // to capture any attributed contentful paints that occurred before the URL updated.
      activeLcpEntry = softNavEntry.getLargestInteractionContentfulPaint();
    }
  });

  const icpObserver = new PerformanceObserver((list) => {
    for (const icpEntry of list.getEntries()) {
      // MANDATORY: Match by interactionId (not navigationId) so only paints caused
      // by the soft navigation's initiating interaction update the route's LCP.
      if (activeSoftNav && icpEntry.interactionId === activeSoftNav.interactionId) {
        activeLcpEntry = icpEntry;
      }
    }
  });

  softNavObserver.observe({ type: 'soft-navigation', buffered: true });
  icpObserver.observe({ type: 'interaction-contentful-paint', buffered: true });

  // Finalize the active route's LCP when the page is backgrounded or unloaded.
  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'hidden') {
      finalizeCurrentSoftNav();
    }
  });

  return { softNavObserver, icpObserver, finalizeCurrentSoftNav };
}

3. Mesurer les métriques Core Web Vitals pour les navigations douces avec web-vitals

Si votre objectif est de signaler les métriques Core Web Vitals standards (LCP, CLS et INP) pour les navigations complètes et partielles à un point de terminaison d'analyse, utilisez la bibliothèque web-vitals (v6.0.0 ou ultérieure) avec { reportSoftNavs: true } plutôt que de segmenter manuellement chaque chronologie de métrique.

import { onLCP, onFCP, onCLS, onINP } from 'web-vitals';

function sendToAnalytics(metric) {
  // Example-only endpoint; replace with your real telemetry URL.
  navigator.sendBeacon(
    '/analytics',
    JSON.stringify({
      name: metric.name,
      value: metric.value,
      navigationType: metric.navigationType,
      navigationURL: metric.navigationURL,
    }),
  );
}

// Pass { reportSoftNavs: true } to report metrics per soft navigation in addition
// to the initial hard navigation.
onLCP(sendToAnalytics, { reportSoftNavs: true });
onFCP(sendToAnalytics, { reportSoftNavs: true });
onCLS(sendToAnalytics, { reportSoftNavs: true });
onINP(sendToAnalytics, { reportSoftNavs: true });

Bonnes pratiques

  • À FAIRE : Détectez les fonctionnalités 'interaction-contentful-paint' et 'soft-navigation' à l'aide de PerformanceObserver.supportedEntryTypes?.includes(...) avant d'appeler observer.observe().
  • À FAIRE : Transmettez { buffered: true } lorsque vous observez 'interaction-contentful-paint' et 'soft-navigation' afin de ne pas manquer les entrées précoces si le code d'analyse s'initialise de manière asynchrone.
  • À FAIRE : Appelez softNavEntry.getLargestInteractionContentfulPaint() lors du traitement d'une nouvelle entrée soft-navigation afin que les peintures effectuées avant la mise à jour de l'URL soient incluses dans le LCP.
  • FAITES correspondre les entrées interaction-contentful-paint à une navigation douce à l'aide de interactionId, et non de navigationId.
  • À FAIRE : Utilisez entry.duration (ou entry.presentationTime - entry.startTime) pour mesurer la latence d'affichage de l'interaction, plutôt que de signaler directement entry.presentationTime. Tous les codes temporels PerformanceEntry sont mesurés à partir de l'origine du temps de chargement initial de la page, et non à partir du début de la navigation logicielle.
  • NE VOUS FIEZ PAS à performance.getEntriesByType('soft-navigation') pour surveiller les applications monopages de longue durée, car le tampon du navigateur est limité aux 50 premières entrées de navigation logicielle.

Stratégies de remplacement

Si votre cible Baseline inclut des navigateurs qui ne sont pas compatibles avec InteractionContentfulPaint ou PerformanceSoftNavigation, traitez ces API comme une amélioration progressive pour le Real User Monitoring (RUM) :

  1. Détection des fonctionnalités et dégradation élégante : protégez l'enregistrement de l'observateur avec PerformanceObserver.supportedEntryTypes?.includes('interaction-contentful-paint') et PerformanceObserver.supportedEntryTypes?.includes('soft-navigation'). Dans les navigateurs non compatibles, ignorez l'enregistrement des observateurs pour que votre application s'exécute sans générer d'erreurs.
  2. Maintenez les observateurs de navigation standard actifs : continuez à observer les entrées 'largest-contentful-paint', 'event' et 'layout-shift' standards dans tous les navigateurs. Étant donné que LargestContentfulPaint est finalisé lors de la première interaction de l'utilisateur, que la navigation logicielle soit prise en charge ou non, la mesure des navigations logicielles dans les navigateurs compatibles ne modifie ni n'interrompt les mesures initiales du LCP lors du chargement matériel.