Angular @defer avancé : triggers et prefetch

Front-end Mezgani said
Angular Defer Lazy Loading Performance Angular 19
Angular @defer avancé : triggers et prefetch

Maîtrisez les triggers avancés de @defer dans Angular 19+ : on viewport, interaction, hover, timer, idle + prefetch et tests unitaires.

@defer basique vs avancé : la différence

Angular 17 a introduit @defer comme une révolution du chargement différé. Vous connaissez peut-être la forme basique — charger un composant quand il devient visible. Mais @defer va bien plus loin avec une grammaire complète de déclencheurs, de préchargement et de gestion d'états.

Voici la forme basique que beaucoup connaissent :

// @defer basique — charge quand l'élément arrive dans la fenêtre
@defer {
  <app-tableau-statistiques></app-tableau-statistiques>
}
// Problème : aucun état de chargement, aucun fallback, déclenchement immédiat

Et voici la forme complète et expressive que nous allons maîtriser :

// @defer complet — toute la grammaire avancée
@defer (on viewport; prefetch on idle) {
  <!-- Composant principal — chargé quand visible dans la fenêtre -->
  <app-tableau-statistiques [projetId]="projetActif()"></app-tableau-statistiques>

} @placeholder (minimum 200ms) {
  <!-- Affiché AVANT le déclenchement — toujours léger -->
  <div class="placeholder-container rounded bg-light" style="height: 300px">
    <p class="text-center text-muted pt-5">Tableau de bord</p>
  </div>

} @loading (minimum 500ms; after 100ms) {
  <!-- Affiché PENDANT le téléchargement du chunk -->
  <div class="placeholder-glow">
    <div class="placeholder col-12 mb-2" style="height: 40px"></div>
    <div class="placeholder col-8 mb-2" style="height: 200px"></div>
    <div class="placeholder col-4" style="height: 30px"></div>
  </div>

} @error {
  <!-- Affiché si le chunk échoue à se charger -->
  <div class="alert alert-warning d-flex align-items-center gap-2">
    <i class="bi bi-wifi-off"></i>
    <span>Impossible de charger ce bloc. Vérifiez votre connexion.</span>
  </div>
}
@defer est une instruction de compilation : Contrairement au lazy loading des routes qui utilise l'import dynamique JavaScript, @defer est traité par le compilateur Angular. Il découpe automatiquement le composant différé en un chunk séparé. Vous n'écrivez aucun import() manuel.
Fonctionnalité @defer basique @defer avancé
Déclencheur Immédiat (on idle par défaut) 6 triggers + condition signal
Préchargement Non prefetch on + trigger indépendant
États visuels Aucun @placeholder + @loading + @error
Durée minimale Non applicable minimum sur @placeholder et @loading
Délai d'affichage Non applicable after sur @loading

Les triggers on : 6 déclencheurs disponibles

Le mot-clé on définit l'événement qui déclenche le chargement du chunk. Angular propose six déclencheurs couvrant tous les scénarios d'interaction.

on viewport — chargement au défilement

Le plus utilisé. Le composant se charge quand le @placeholder entre dans la zone visible de l'écran (détecté via IntersectionObserver).

// Cas d'usage : tableau de bord de projet — sections bas de page chargées au scroll
@Component({
  selector: 'app-projet-detail',
  standalone: true,
  template: `
    <!-- Section toujours visible — chargée immédiatement -->
    <div class="project-header">
      <h1>{{ projet().nom }}</h1>
      <p class="text-muted">Chef de projet : {{ projet().responsable }}</p>
    </div>

    <!-- Timeline des jalons — chargée quand on scrolle jusqu'à elle -->
    @defer (on viewport) {
      <app-timeline-jalons [jalons]="projet().jalons"></app-timeline-jalons>
    } @placeholder {
      <div class="bg-light rounded p-4 text-center text-muted" style="height: 250px">
        <p class="pt-4">Timeline des jalons</p>
      </div>
    } @loading (minimum 400ms) {
      <div class="placeholder-glow p-2">
        @for (i of [1,2,3,4]; track i) {
          <div class="d-flex align-items-center gap-3 mb-3">
            <div class="placeholder rounded-circle" style="width:40px;height:40px;flex-shrink:0"></div>
            <div class="flex-grow-1">
              <div class="placeholder col-6 mb-1" style="height:14px"></div>
              <div class="placeholder col-4" style="height:12px"></div>
            </div>
          </div>
        }
      </div>
    }

    <!-- Graphique burndown — visible seulement si le projet est en cours -->
    @defer (on viewport) {
      <app-graphique-burndown [sprints]="projet().sprints"></app-graphique-burndown>
    } @placeholder {
      <div class="bg-light rounded" style="height: 300px"></div>
    }
  `
})
export class ProjetDetailComponent {
  projet = input.required<Projet>();
}

on interaction — chargement au clic ou focus

// Éditeur de description de tâche — chargé uniquement si l'utilisateur clique
@defer (on interaction) {
  <app-editeur-markdown
    [contenu]="tache().description"
    (changement)="sauvegarderDescription($event)">
  </app-editeur-markdown>
} @placeholder {
  <!-- Le placeholder lui-même reçoit le clic/focus qui déclenche @defer -->
  <div class="border rounded p-3 cursor-pointer text-muted bg-white"
       style="min-height: 100px">
    @if (tache().description) {
      <p>{{ tache().description | slice:0:150 }}...</p>
    } @else {
      <p>Cliquez pour ajouter une description...</p>
    }
  </div>
} @loading {
  <div class="d-flex align-items-center gap-2 p-3 text-muted">
    <div class="spinner-border spinner-border-sm"></div>
    <span>Chargement de l'éditeur...</span>
  </div>
}

on hover — chargement au survol

// Preview riche d'un membre de l'équipe au survol de son avatar
@defer (on hover) {
  <app-carte-membre-detail [membreId]="membre.id"></app-carte-membre-detail>
} @placeholder {
  <!-- L'avatar léger est toujours affiché — @defer surveille son survol -->
  <div class="avatar-sm" [title]="membre.nom">
    <img [src]="membre.avatarMiniature" [alt]="membre.nom"
         class="rounded-circle" width="36" height="36">
  </div>
}
<!-- Résultat : la carte détaillée (photo haute-res, bio, stats)
     ne se charge que si l'utilisateur survole l'avatar -->

on timer(N) — chargement après un délai

// Widget de chat d'assistance — apparu 4 secondes après l'arrivée sur la page
// Laisse l'utilisateur commencer à lire avant d'afficher un widget intrusif
@defer (on timer(4000)) {
  <app-widget-support-chat></app-widget-support-chat>
} @placeholder {
  <!-- Espace réservé invisible pendant les 4 premières secondes -->
  <div></div>
} @loading {
  <!-- Apparition discrète en bas à droite -->
  <div class="position-fixed bottom-0 end-0 m-3">
    <div class="spinner-border text-primary spinner-border-sm"></div>
  </div>
}

on idle — chargement pendant l'inactivité

// Suggestions de tâches similaires — chargées quand le navigateur est inactif
// requestIdleCallback est utilisé en interne par Angular
@defer (on idle) {
  <app-suggestions-taches [tacheActuelle]="tache()"></app-suggestions-taches>
} @placeholder {
  <div class="border-top mt-4 pt-4">
    <h6 class="text-muted">Tâches similaires</h6>
    <div class="placeholder-glow">
      <div class="placeholder col-12 mb-2" style="height: 40px"></div>
      <div class="placeholder col-12 mb-2" style="height: 40px"></div>
    </div>
  </div>
}

on immediate — chargement au plus tôt

// Composant non critique mais prioritaire — chargé dès que possible
// Contrairement au défaut (on idle), ne attend pas l'inactivité
@defer (on immediate) {
  <app-bandeau-notifications></app-bandeau-notifications>
}
// Cas d'usage : notifications, alertes système non critiques pour le LCP
// mais à afficher rapidement tout de même

Le trigger when : condition par signal

when est le trigger le plus flexible : il déclenche le chargement dès qu'une expression devient vraie. C'est le seul trigger piloté par votre logique applicative plutôt que par un événement utilisateur.

// Onglet "Rapports" — le composant se charge quand l'onglet est sélectionné
@Component({
  selector: 'app-navigation-projet',
  standalone: true,
  template: `
    <ul class="nav nav-tabs mb-3">
      <li class="nav-item">
        <button class="nav-link" [class.active]="ongletActif() === 'apercu'"
                (click)="ongletActif.set('apercu')">Aperçu</button>
      </li>
      <li class="nav-item">
        <button class="nav-link" [class.active]="ongletActif() === 'taches'"
                (click)="ongletActif.set('taches')">Tâches</button>
      </li>
      <li class="nav-item">
        <button class="nav-link" [class.active]="ongletActif() === 'rapports'"
                (click)="ongletActif.set('rapports')">Rapports</button>
      </li>
    </ul>

    <div class="tab-content">

      @if (ongletActif() === 'apercu') {
        <app-apercu-projet></app-apercu-projet>
      }

      @if (ongletActif() === 'taches') {
        <app-liste-taches></app-liste-taches>
      }

      <!-- Rapports : chargés uniquement quand cet onglet est sélectionné -->
      <!-- "when" évalue la condition à chaque changement du signal -->
      @defer (when ongletActif() === 'rapports') {
        <app-rapports-avances [projetId]="projetId()"></app-rapports-avances>
      } @placeholder {
        <!-- Vide — l'onglet n'est pas actif -->
        <div></div>
      } @loading (minimum 600ms) {
        <div class="text-center py-5">
          <div class="spinner-border text-primary mb-3"></div>
          <p class="text-muted">Génération des rapports...</p>
        </div>
      }

    </div>
  `
})
export class NavigationProjetComponent {
  ongletActif = signal<'apercu' | 'taches' | 'rapports'>('apercu');
  projetId    = input.required<number>();
}

when avec rôle utilisateur

// Panneau d'administration — chargé uniquement si l'utilisateur est admin
// Zéro code admin téléchargé pour les utilisateurs non-admins
@Component({
  template: `
    <!-- Contenu principal — tout le monde -->
    <app-contenu-principal></app-contenu-principal>

    <!-- Panneau admin — chargé uniquement pour les admins -->
    @defer (when estAdmin()) {
      <app-panneau-administration></app-panneau-administration>
    }
  `
})
export class PageProjetComponent {
  private auth = inject(AuthService);
  // computed() dérive le statut admin depuis le service d'auth
  estAdmin = computed(() => this.auth.utilisateur()?.roles.includes('ADMIN') ?? false);
}
when ne se redéclenche pas : Une fois que la condition devient true, le composant se charge et when ne surveille plus la condition. Si la condition repasse à false ensuite (ex: déconnexion admin), le composant reste affiché. Pour un affichage conditionnel dynamique, combinez @defer avec @if.

prefetch : précharger avant d'afficher

prefetch est l'arme secrète de @defer. Il permet de télécharger le chunk en arrière-plan avant même que le déclencheur d'affichage ne soit activé. Résultat : quand l'utilisateur déclenche l'affichage, le composant est déjà là — affichage instantané.

// Scénario : formulaire de création de tâche
// On précharge pendant que le nav est idle, on affiche au clic sur le bouton

@Component({
  template: `
    <button class="btn btn-primary" #btnNouvelleTache
            (click)="afficherFormulaire.set(true)">
      + Nouvelle tâche
    </button>

    @defer (
      on interaction(btnNouvelleTache);
      prefetch on idle
    ) {
      <!-- Ce composant est préchargé quand le navigateur est idle -->
      <!-- Il s'affiche instantanément au clic sur le bouton -->
      <app-formulaire-nouvelle-tache
        [projetId]="projetId()"
        (cree)="onTacheCree($event)"
        (annule)="afficherFormulaire.set(false)">
      </app-formulaire-nouvelle-tache>

    } @loading (minimum 200ms) {
      <div class="text-muted py-3">Chargement du formulaire...</div>
    }
  `
})
export class EnteteProjetComponent {
  projetId         = input.required<number>();
  afficherFormulaire = signal(false);
  private tachesService = inject(TachesService);

  onTacheCree(tache: Tache) {
    this.tachesService.ajouter(tache);
    this.afficherFormulaire.set(false);
  }
}

prefetch on hover — précharger au survol

// Menu de navigation — préchargement au survol du lien
// La page sera prête avant même le clic
@Component({
  template: `
    <nav class="navbar">
      <a routerLink="/projets" #lienProjets>Projets</a>

      <!-- Précharger le panneau de résumé projets au survol du lien de navigation -->
      @defer (
        on hover(lienProjets);
        prefetch on hover(lienProjets)
      ) {
        <div class="dropdown-preview card shadow-lg">
          <app-resume-projets-rapide></app-resume-projets-rapide>
        </div>
      } @placeholder {
        <div></div>
      }
    </nav>
  `
})
export class NavbarComponent {}

Tous les triggers disponibles pour prefetch

Syntaxe prefetch Précharge quand... Idéal combiné avec...
prefetch on idle Le navigateur est inactif on interaction ou on hover
prefetch on hover L'utilisateur survole le placeholder on interaction (clic)
prefetch on viewport Le placeholder est visible on interaction
prefetch on timer(N) Après N millisecondes on interaction
prefetch when condition Une condition signal devient true Toute combinaison

@placeholder, @loading et @error

Ces trois blocs gèrent les états visuels du cycle de vie d'un @defer. Chacun a un rôle précis et des options qui permettent d'affiner l'expérience utilisateur.

@placeholder — l'état initial

// @placeholder est affiché AVANT que le trigger ne se déclenche
// Il doit être LÉGER — son code est inclus dans le bundle principal

@defer (on viewport) {
  <app-tableau-membres-equipe [equipe]="equipe()"></app-tableau-membres-equipe>

} @placeholder (minimum 300ms) {
  <!--
    minimum 300ms : le placeholder reste visible au moins 300ms
    même si le trigger se déclenche immédiatement
    Evite un flash visuel si l'élément est déjà dans le viewport au chargement
  -->
  <div class="card">
    <div class="card-header">Équipe du projet</div>
    <div class="card-body placeholder-glow">
      @for (i of [1,2,3]; track i) {
        <div class="d-flex align-items-center gap-3 mb-3">
          <div class="placeholder rounded-circle bg-secondary"
               style="width:44px;height:44px;flex-shrink:0"></div>
          <div class="flex-grow-1">
            <div class="placeholder col-7 mb-1" style="height:14px"></div>
            <div class="placeholder col-4" style="height:12px"></div>
          </div>
          <div class="placeholder col-2" style="height:28px"></div>
        </div>
      }
    </div>
  </div>
}

@loading — pendant le téléchargement

// @loading s'affiche pendant le téléchargement du chunk JavaScript
// Deux options : minimum et after

@defer (on interaction) {
  <app-editeur-diagramme-gantt [projetId]="projetId()"></app-editeur-diagramme-gantt>

} @placeholder {
  <button class="btn btn-outline-primary w-100">
    📊 Ouvrir l'éditeur Gantt
  </button>

} @loading (after 100ms; minimum 500ms) {
  <!--
    after 100ms  : n'affiche @loading que si le chargement dure plus de 100ms
                   (évite le flash pour les connexions rapides)
    minimum 500ms : si affiché, visible au moins 500ms
                   (évite un flash trop rapide)
  -->
  <div class="d-flex flex-column align-items-center py-5 gap-3">
    <div class="spinner-border text-primary" style="width: 3rem; height: 3rem"></div>
    <div>
      <p class="text-center fw-bold mb-1">Chargement de l'éditeur Gantt</p>
      <p class="text-center text-muted small">
        Première ouverture — environ 2 secondes
      </p>
    </div>
  </div>
}

@error — gestion des échecs de chargement

// @error s'affiche si le téléchargement du chunk échoue
// Causes : perte de réseau, CDN indisponible, fichier 404

@defer (on viewport) {
  <app-carte-interactivite-kanban [colonnes]="colonnes()"></app-carte-interactivite-kanban>

} @placeholder {
  <div class="kanban-placeholder bg-light rounded" style="height: 400px"></div>

} @loading (minimum 300ms) {
  <div class="text-center py-5">
    <div class="spinner-border text-primary"></div>
  </div>

} @error {
  <div class="alert alert-warning">
    <div class="d-flex align-items-start gap-3">
      <i class="bi bi-exclamation-triangle-fill fs-5 mt-1"></i>
      <div>
        <strong>Le tableau Kanban n'a pas pu se charger.</strong>
        <p class="mb-2 small">Vérifiez votre connexion internet et réessayez.</p>
        <button class="btn btn-sm btn-outline-warning"
                (click)="$event.target.closest('[defer]')?.retry()">
          Réessayer
        </button>
      </div>
    </div>
  </div>
}
Règle des blocs @defer : @placeholder et ses éléments doivent être légers et statiques — ils sont inclus dans le bundle principal. N'y mettez pas de composants complexes. @loading et @error peuvent être plus riches car ils s'affichent seulement à la demande.

Combiner plusieurs triggers

La vraie puissance de @defer vient de la combinaison de triggers. On peut spécifier plusieurs déclencheurs d'affichage (le premier qui se déclenche gagne) et un déclencheur de préchargement distinct.

// Syntaxe de combinaison : séparer les triggers par ";"
// Logique "OU" : le premier trigger qui se déclenche active le chargement

// Exemple 1 : charger si viewport OU si timer dépasse 8 secondes
// (pour les utilisateurs qui scrollent lentement)
@defer (on viewport; on timer(8000); prefetch on idle) {
  <app-section-temoignages [projetId]="projetId()"></app-section-temoignages>
} @placeholder {
  <div class="bg-light rounded p-4" style="min-height: 200px"></div>
}
// Exemple 2 : combinaison avancée pour un éditeur collaboratif lourd
// - Précharger quand le navigateur est idle (silencieux)
// - Afficher quand l'utilisateur clique sur "Éditer"
@Component({
  template: `
    <div class="tache-card card mb-3">
      <div class="card-body">
        <div class="d-flex justify-content-between">
          <h6>{{ tache().titre }}</h6>
          <button #btnEditer class="btn btn-sm btn-outline-primary">
            ✏️ Éditer
          </button>
        </div>

        @defer (
          on interaction(btnEditer);
          prefetch on hover(btnEditer)
        ) {
          <!-- Chargé au clic — mais déjà préchargé si l'user a survolé le bouton -->
          <app-editeur-tache-inline
            [tache]="tache()"
            (sauvegarde)="onSauvegarde($event)"
            (annule)="onAnnule()">
          </app-editeur-tache-inline>

        } @loading (after 80ms; minimum 300ms) {
          <div class="p-2 text-muted small">
            <span class="spinner-border spinner-border-sm me-2"></span>
            Ouverture de l'éditeur...
          </div>
        } @error {
          <div class="alert alert-danger py-2 small">
            Impossible d'ouvrir l'éditeur.
          </div>
        }

      </div>
    </div>
  `
})
export class CarteTacheComponent {
  tache = input.required<Tache>();
  private tachesService = inject(TachesService);
  onSauvegarde(t: Tache) { this.tachesService.mettrAJour(t); }
  onAnnule() {}
}
// Exemple 3 : combinaison when + prefetch on idle
// Bandeau de cookies RGPD — visible uniquement si pas encore accepté
// et préchargé dès que le navigateur est inactif
@defer (
  when !consentementDonne();
  prefetch on idle
) {
  <app-bandeau-cookies-rgpd
    (accepte)="enregistrerConsentement(true)"
    (refuse)="enregistrerConsentement(false)">
  </app-bandeau-cookies-rgpd>
}

Cas d'usage réels en production

Voici une application de gestion de projet complète qui utilise @defer de façon stratégique sur sa page principale. Chaque décision est justifiée par un objectif de performance.

// page-tableau-de-bord.component.ts — orchestration complète de @defer
@Component({
  selector: 'app-tableau-de-bord',
  standalone: true,
  template: `
    <!-- ZONE 1 : Hero section — chargée immédiatement (LCP critique) -->
    <div class="dashboard-hero">
      <h1>Bonjour, {{ utilisateur().prenom }} 👋</h1>
      <div class="row g-3">
        <!-- 4 métriques clés — toujours visibles, dans le bundle principal -->
        @for (metric of metriquesRapides(); track metric.label) {
          <div class="col-md-3">
            <div class="card text-center p-3">
              <span class="display-6 fw-bold" [class]="metric.couleur">{{ metric.valeur }}</span>
              <small class="text-muted">{{ metric.label }}</small>
            </div>
          </div>
        }
      </div>
    </div>

    <div class="row mt-4 g-4">

      <!-- ZONE 2 : Liste des projets actifs — chargée immédiatement (fold supérieur) -->
      <div class="col-lg-8">
        <app-liste-projets-actifs [projets]="projetsActifs()"></app-liste-projets-actifs>
      </div>

      <!-- ZONE 3 : Activité récente — viewport + prefetch idle -->
      <!-- Visible dans le fold sur grand écran, mais lazy sur mobile -->
      <div class="col-lg-4">
        @defer (on viewport; prefetch on idle) {
          <app-flux-activite-recente [equipeId]="equipeId()"></app-flux-activite-recente>
        } @placeholder (minimum 200ms) {
          <div class="card" style="height: 400px">
            <div class="card-header">Activité récente</div>
            <div class="card-body placeholder-glow">
              @for (i of [1,2,3,4,5]; track i) {
                <div class="placeholder col-12 mb-3" style="height: 60px"></div>
              }
            </div>
          </div>
        }
      </div>

    </div>

    <!-- ZONE 4 : Graphiques analytiques — en dessous du fold, lourds -->
    <div class="row mt-4 g-4">

      <div class="col-md-6">
        @defer (on viewport; prefetch on idle) {
          <app-graphique-progression-sprints [equipeId]="equipeId()"></app-graphique-progression-sprints>
        } @placeholder {
          <div class="card bg-light" style="height: 280px"></div>
        } @loading (after 100ms; minimum 400ms) {
          <div class="card" style="height: 280px">
            <div class="card-body d-flex align-items-center justify-content-center">
              <div class="spinner-border text-primary"></div>
            </div>
          </div>
        }
      </div>

      <div class="col-md-6">
        @defer (on viewport; prefetch on idle) {
          <app-graphique-charge-equipe [equipeId]="equipeId()"></app-graphique-charge-equipe>
        } @placeholder {
          <div class="card bg-light" style="height: 280px"></div>
        } @loading (after 100ms; minimum 400ms) {
          <div class="card" style="height: 280px">
            <div class="card-body d-flex align-items-center justify-content-center">
              <div class="spinner-border text-primary"></div>
            </div>
          </div>
        }
      </div>

    </div>

    <!-- ZONE 5 : Widget de support — timer 5s -->
    @defer (on timer(5000); prefetch on idle) {
      <app-widget-support></app-widget-support>
    }
  `
})
export class TableauDeBordComponent {
  private auth         = inject(AuthService);
  private projetsService = inject(ProjetsService);

  utilisateur     = this.auth.utilisateur;
  equipeId        = computed(() => this.utilisateur()?.equipeId ?? 0);
  projetsActifs   = this.projetsService.actifs;
  metriquesRapides = computed(() => [
    { label: 'Tâches actives',   valeur: 12, couleur: 'text-primary' },
    { label: 'Sprints en cours', valeur: 3,  couleur: 'text-success' },
    { label: 'PR en attente',    valeur: 7,  couleur: 'text-warning' },
    { label: 'Bugs ouverts',     valeur: 4,  couleur: 'text-danger'  },
  ]);
}
Impact sur les Core Web Vitals : Cette architecture réduit le bundle initial (main.js) de 40 à 60% en déplaçant les composants analytiques lourds (Chart.js, graphiques) dans des chunks lazy. Le LCP (Largest Contentful Paint) s'améliore car le navigateur n'a plus à parser 300 Ko de JS avant de rendre la page.

Tester @defer et bonnes pratiques

Tester les blocs @defer dans les tests unitaires

// Angular fournit des helpers pour tester chaque état de @defer
import { TestBed, ComponentFixture } from '@angular/core/testing';
import { DeferBlockState } from '@angular/core/testing';

describe('TableauDeBordComponent', () => {
  let fixture: ComponentFixture<TableauDeBordComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [TableauDeBordComponent]
    }).compileComponents();
    fixture = TestBed.createComponent(TableauDeBordComponent);
  });

  it('devrait afficher le placeholder par défaut', async () => {
    fixture.detectChanges();
    // Les blocs @defer sont en état "placeholder" par défaut dans les tests
    const placeholder = fixture.nativeElement.querySelector('.kanban-placeholder');
    expect(placeholder).toBeTruthy();
  });

  it('devrait afficher le composant une fois chargé', async () => {
    fixture.detectChanges();

    // Récupérer le premier bloc @defer (index 0)
    const deferBlocks = await fixture.getDeferBlocks();

    // Forcer le rendu de l'état principal (comme si le trigger s'était déclenché)
    await deferBlocks[0].render(DeferBlockState.Complete);

    fixture.detectChanges();
    // Vérifier que le vrai composant est maintenant rendu
    const composant = fixture.nativeElement.querySelector('app-carte-interactivite-kanban');
    expect(composant).toBeTruthy();
  });

  it('devrait afficher le spinner pendant le chargement', async () => {
    fixture.detectChanges();
    const deferBlocks = await fixture.getDeferBlocks();

    // Forcer l'état "loading"
    await deferBlocks[0].render(DeferBlockState.Loading);
    fixture.detectChanges();

    const spinner = fixture.nativeElement.querySelector('.spinner-border');
    expect(spinner).toBeTruthy();
  });

  it('devrait afficher un message d\'erreur si le chunk échoue', async () => {
    fixture.detectChanges();
    const deferBlocks = await fixture.getDeferBlocks();

    // Simuler un échec de chargement
    await deferBlocks[0].render(DeferBlockState.Error);
    fixture.detectChanges();

    const erreur = fixture.nativeElement.querySelector('.alert-warning');
    expect(erreur).toBeTruthy();
  });
});

Checklist @defer avancé

  • Chaque @defer a un @placeholder — jamais de blanc avant le chargement
  • Le contenu de @placeholder est léger (pas de composants complexes)
  • @loading utilise after 100ms pour éviter le flash sur connexions rapides
  • @loading utilise minimum 300ms+ pour éviter le flash sur connexions très rapides
  • Tous les blocs @defer de composants critiques ont un @error
  • Les composants dans @defer ne sont pas listés dans imports[] du composant parent
  • prefetch on idle ajouté dès qu'un déclencheur manuel est utilisé (interaction, hover)
  • Les 4 états DeferBlockState sont testés pour chaque bloc @defer critique
  • Les composants lourds (+30 Ko) sont systématiquement dans un @defer
  • source-map-explorer vérifié après l'ajout de @defer pour confirmer la séparation en chunk
Résumé : @defer est l'outil le plus puissant d'Angular pour le chargement progressif au sein d'une page. La combinaison on viewport + prefetch on idle couvre 80% des cas : les composants bas de page sont préchargés discrètement et s'affichent instantanément quand l'utilisateur scrolle. Ajoutez after 100ms; minimum 300ms sur @loading pour une expérience fluide quelle que soit la vitesse de connexion.

Partager