Files
pmomusic/Blackboard/Done/Frontend_Review.md
Eric Coissac 78c37e1732 🔖 move Frontent Review to Done
- Move `Frontend_Review.md` from Todo/Blackboard to Done/
- Reflects task completion status
2026-04-06 08:56:54 +02:00

23 KiB

** Ce travail devra être réalisé en suivant scrupuleusement les consignes listées dans le fichier @Rules_optimal.md **

Vue d'ensemble

Le frontend de PMOMusic est une application Vue 3 + TypeScript avec Pinia, organisée autour de composables réactifs, d'un client SSE centralisé et d'un cache API à plusieurs niveaux. L'architecture générale est solide : séparation claire composables/services/vues, typage strict (strict: true dans tsconfig.app.json), reconnexion SSE avec backoff exponentiel.

Cette revue documente les bugs avérés, les fragilités de conception et les axes d'amélioration relevés lors d'une lecture complète des fichiers pmoapp/webapp/src/.


Bugs

1. apiCache.ts:130-133 — Mutation globale du TTL non réentrante

Problème : la méthode fetch() accepte un ttl optionnel par appel. Pour l'appliquer, elle modifie this.options.ttl globalement avant d'appeler this.set(), puis le restaure :

// apiCache.ts:130-133
if (ttl) {
  const originalTtl = this.options.ttl;
  this.options.ttl = ttl;          // (A) modification globale
  this.set(endpoint, data, params);
  this.options.ttl = originalTtl;  // (B) restauration
}

Cause : fetcher() est await-é (ligne 128) avant ce bloc. Pendant cet await, d'autres microtasks peuvent s'intercaler et appeler isFresh() ou set(), qui lisent this.options.ttl. Si deux appels fetch() avec des ttl différents sont en vol simultanément, la restauration de (B) peut effacer la valeur posée par le second appel concurrent, ou (A) peut lire un TTL modifié par un autre appel.

Solution : passer le ttl directement à set() comme paramètre, sans modifier l'état partagé :

// Dans set() : ajouter un paramètre ttl optionnel
set<T>(endpoint: string, data: T, params?: ..., etag?: string, ttl?: number): void {
  const key = this.makeKey(endpoint, params);
  this.cache.set(key, {
    data,
    timestamp: Date.now(),
    ttl: ttl ?? this.options.ttl,  // TTL par entrée, pas global
    etag,
  });
  this.notifySubscribers(key, data);
}

// Dans isFresh() : lire le ttl de l'entrée
private isFresh(key: string): boolean {
  const entry = this.cache.get(key);
  if (!entry) return false;
  return Date.now() - entry.timestamp < (entry.ttl ?? this.options.ttl);
}

// Dans fetch() : supprimer le bloc de mutation globale
this.set(endpoint, data, params, undefined, ttl);

2. useRenderers.ts:151 + 222 — Réassignation post-switch écrase le nouvel objet

Problème : le handler onRendererEvent termine par une ligne inconditionnelle :

// useRenderers.ts:222
snapshotState.snapshots.set(rendererId, snapshot);

Cette ligne s'exécute pour tous les types d'événements après le switch, y compris pour position_changed et metadata_changed qui ont déjà créé et stocké un nouvel objet dans le Map à l'intérieur du switch :

// position_changed — ligne 151 : stocke newSnapshot
snapshotState.snapshots.set(rendererId, newSnapshot);
break;
// → puis ligne 222 écrase avec snapshot (proxy d'origine)

// metadata_changed — ligne 176 : stocke un spread
snapshotState.snapshots.set(rendererId, { ...snapshot, state: { ...snapshot.state } });
break;
// → puis ligne 222 écrase avec snapshot (proxy d'origine)

Cause : break sort du switch mais pas de la fonction. La ligne 222 est atteinte dans tous les cas.

Conséquence : les objets créés pour forcer la détection de changement par Vue sont immédiatement écrasés. Le mécanisme de réactivité fonctionne malgré tout (le proxy muté est re-stocké), mais la logique est trompeuse et fragile : si Vue venait à optimiser la détection d'identité des objets réactifs, cette redondance deviendrait un bug visible.

Solution : supprimer la ligne 222 et s'assurer que chaque branche du switch stocke explicitement son résultat dans la Map. Les branches volume_changed, mute_changed et binding_changed qui mutent directement snapshot doivent aussi créer un nouvel objet :

case "volume_changed":
  snapshotState.snapshots.set(rendererId, {
    ...snapshot,
    state: { ...snapshot.state, volume: event.volume },
  });
  break;

case "mute_changed":
  snapshotState.snapshots.set(rendererId, {
    ...snapshot,
    state: { ...snapshot.state, mute: event.mute },
  });
  break;
// idem pour binding_changed, stream_state_changed

Supprimer la ligne 222. Chaque case devient responsable de son stockage, ce qui élimine aussi le besoin de toRaw().


3. useRenderers.ts:122as any sur transport_state

Problème :

// useRenderers.ts:122
snapshot.state.transport_state = event.state as any;

Cause : event.state est typé string (type SSE générique), alors que transport_state est une union littérale ("PLAYING" | "PAUSED" | "STOPPED" | ...). Le cast as any contourne la vérification de type.

Conséquence : si le backend envoie une valeur non prévue (ex. "TRANSITIONING"), elle sera stockée sans validation. Les composants qui comparent transport_state === "PLAYING" ne matcheront pas et l'UI restera muette.

Solution : définir un guard de type ou une assertion dans types.ts :

// services/pmocontrol/types.ts
export type TransportState = "PLAYING" | "PAUSED" | "STOPPED" | "NO_MEDIA" | "TRANSITIONING";

export function isTransportState(s: string): s is TransportState {
  return ["PLAYING", "PAUSED", "STOPPED", "NO_MEDIA", "TRANSITIONING"].includes(s);
}
// useRenderers.ts — case state_changed
case "state_changed":
  if (isTransportState(event.state)) {
    snapshot.state.transport_state = event.state;
  } else {
    console.warn(`[useRenderers] transport_state inconnu: ${event.state}`);
  }
  break;

4. apiCache.ts:181-200invalidate() supprime silencieusement les subscriptions actives

Problème :

// apiCache.ts:197-200
keysToDelete.forEach(key => {
  this.cache.delete(key);
  this.subscriptions.delete(key);  // ← subscriptions perdues sans notification
});

Cause : lors d'une invalidation (ex. après un SSE event), les callbacks enregistrés via subscribe() sont supprimés de la Map. Les composants ne sont pas notifiés de la suppression et ne reçoivent plus les futures mises à jour même après un refetch.

Conséquence : un composant qui a appelé apiCache.subscribe(...) et qui survit à une invalidation devient « sourd » sans le savoir.

Solution : conserver les subscriptions lors d'une invalidation — seule la donnée en cache est périmée, pas les abonnés :

invalidate(pattern: string): void {
  const keysToDelete: string[] = [];
  // ... construction de keysToDelete inchangée ...
  keysToDelete.forEach(key => {
    this.cache.delete(key);
    // NE PAS supprimer this.subscriptions.get(key)
    // Les abonnés seront notifiés lors du prochain set()
  });
}

Si l'on veut notifier les abonnés d'une invalidation (pour qu'ils affichent un état de chargement), ajouter un callback optionnel onInvalidate dans l'interface de subscription.


Fragilités de conception

5. useRenderers.ts:7,143-151toRaw() comme contournement de réactivité Vue

Problème : le code utilise toRaw() pour extraire l'objet brut d'un proxy Vue avant de faire un spread, afin que la copie ne contienne pas de getters réactifs qui pointent vers l'objet original :

// useRenderers.ts:143-151
const rawState = toRaw(snapshot.state);
const newState = { ...rawState };
const newSnapshot = { ...snapshot, state: newState };
snapshotState.snapshots.set(rendererId, newSnapshot);

Cause : les Maps imbriquées dans un objet reactive() ont un comportement de réactivité peu prévisible dans Vue 3. Vue ne détecte pas les mutations d'éléments d'une Map réactive si la référence de la Map elle-même ne change pas.

Solution recommandée : remplacer reactive(new Map()) par shallowRef(new Map()) pour les Maps qui contiennent des données complexes. La réactivité se déclenche en remplaçant la Map entière (ou en forçant un triggerRef) :

// Au lieu de :
const snapshotState = reactive<RendererSnapshotState>({ snapshots: reactive(new Map()), ... });

// Utiliser :
const snapshots = shallowRef(new Map<string, FullRendererSnapshot>());

// Pour déclencher la réactivité après mutation :
snapshots.value = new Map(snapshots.value);  // ou triggerRef(snapshots)

Cela rend la propagation de réactivité explicite et élimine le besoin de toRaw().


6. useRenderers.ts:552-570 — Timer de debounce non nettoyé dans useRenderer()

Problème : useRenderer() crée un timer de debounce local qui n'est jamais nettoyé si le composant parent est démonté :

// useRenderers.ts:553-566
let refreshDebounceTimer: ReturnType<typeof setTimeout> | null = null;
const REFRESH_DEBOUNCE_MS = 500;

async function refresh(force = true) {
  if (refreshDebounceTimer !== null) return;
  refreshDebounceTimer = setTimeout(() => {
    refreshDebounceTimer = null;
  }, REFRESH_DEBOUNCE_MS);
  await Promise.all([...]);
}

Cause : pas d'appel à clearTimeout dans un onUnmounted. Si le composant est démonté pendant les 500 ms du debounce, le timer continue de s'exécuter.

Conséquence : fuite mémoire potentielle ; dans des cas extrêmes (navigation rapide), le callback peut tenter de déclencher un fetch sur un composant déjà démonté.

Solution :

import { onUnmounted } from 'vue';

// Dans useRenderer() :
onUnmounted(() => {
  if (refreshDebounceTimer !== null) {
    clearTimeout(refreshDebounceTimer);
    refreshDebounceTimer = null;
  }
});

7. useRenderers.ts:45-46 — Singleton SSE initialisé par flag de module non réinitialisable

Problème :

// useRenderers.ts:45-46
let sseInitialized = false;
function ensureSSEInitialized() {
  if (sseInitialized) return;
  // ...
  sseInitialized = true;
}

Cause : ce flag de module est persistant pour toute la durée de vie de la page. Si la connexion SSE est perdue puis rétablie avec un nouvel objet PMOControlSSE, le handler onRendererEvent précédent peut ne plus être actif, mais sseInitialized empêche sa re-enregistration.

Conséquence : après une déconnexion et reconnexion SSE, les événements renderer peuvent ne plus être reçus par useRenderers jusqu'à un rechargement de page.

Solution : exposer une fonction resetSSE() qui remet sseInitialized = false et la connecter à l'événement de reconnexion du service SSE. Alternativement, utiliser le pattern provide/inject ou un store Pinia pour gérer le cycle de vie SSE explicitement, en lieu et place du flag de module.


Qualité du code

8. useTabs.ts — Deep watch déclenchant une sérialisation localStorage à chaque mutation

Problème : le watch qui persiste l'état des onglets utilise { deep: true } sur un tableau qui peut contenir jusqu'à 12 entrées avec des métadonnées :

// useTabs.ts:349-355
watch(
  () => [state.tabs, state.activeTabId, state.tabHistory],
  () => { saveToLocalStorage(); },
  { deep: true },
);

Cause : { deep: true } traverse récursivement toutes les propriétés observées. saveToLocalStorage() appelle JSON.stringify sur l'ensemble des tabs à chaque mutation, même mineure (ex. changement de activeTabId).

Solution : surveiller les propriétés individuellement et sérialiser uniquement ce qui change, ou utiliser un computed pour construire la clé de changement :

// Watch séparés, sans deep
watch(() => state.activeTabId, saveToLocalStorage);
watch(() => state.tabHistory.length, saveToLocalStorage);
watch(
  () => state.tabs.map(t => t.id + t.type + (t.metadata?.rendererId ?? '')).join('|'),
  saveToLocalStorage,
);

9. UnifiedControlView.vue:40-49 — Swipe : clientX final au lieu de la position initiale

Problème :

// UnifiedControlView.vue:40-49
useSwipe(viewRef, {
  threshold: 50,
  onSwipeEnd(_e: TouchEvent, swipeDirection: string) {
    if (swipeDirection === "right" && !drawerOpen.value) {
      const touch = _e.changedTouches[0];
      if (touch && touch.clientX < 50) {  // ← position finale du doigt
        drawerOpen.value = true;
      }
    }
  },
});

Cause : onSwipeEnd reçoit l'événement touchend. Dans changedTouches, clientX est la position finale du doigt (après le swipe), pas la position initiale. Un swipe commençant à x=30 et terminant à x=150 a clientX=150 dans touchend — la condition < 50 ne sera jamais vraie pour un swipe horizontal significatif.

Conséquence : le geste de swipe depuis le bord gauche ne fonctionne probablement pas sur les appareils tactiles.

Solution : capturer la position initiale dans onSwipeStart :

const swipeStartX = ref(0);

useSwipe(viewRef, {
  threshold: 50,
  onSwipeStart(e: TouchEvent) {
    swipeStartX.value = e.touches[0]?.clientX ?? 0;
  },
  onSwipeEnd(_e: TouchEvent, swipeDirection: string) {
    if (swipeDirection === "right" && !drawerOpen.value && swipeStartX.value < 50) {
      drawerOpen.value = true;
    }
  },
});

10. api.ts:50 — Réponse JSON non validée avant le cast TypeScript

Problème :

// api.ts:50
return response.json();  // retour typé T par inférence, sans validation

Cause : response.json() retourne Promise<any>. TypeScript accepte le retour car la méthode request<T> promet Promise<T>, mais aucune validation de structure n'est effectuée.

Conséquence : si le backend renvoie un schéma légèrement différent (champ renommé, type changé), le bug se manifestera loin du point d'appel avec un message cryptique. En développement avec plusieurs instances en parallèle (udn_prefix différent), une requête dirigée vers la mauvaise instance peut retourner un format inattendu.

Solution pragmatique : ajouter une validation légère avec un type guard pour les réponses critiques, ou au minimum loguer la réponse brute en mode développement :

private async request<T>(path: string, options: RequestInit = {}): Promise<T> {
  // ...
  const data = await response.json();
  if (import.meta.env.DEV && data == null) {
    console.warn(`[PMOControlAPI] Réponse vide pour ${path}`);
  }
  return data as T;
}

Pour les endpoints critiques (getRendererFullSnapshot, getRenderers), envisager un schéma de validation Zod ou une assertion runtime minimale.


Accessibilité et feedback utilisateur

11. Absence de labels ARIA sur les contrôles transport

Les composants TransportControls.vue et VolumeControl.vue contiennent des boutons iconiques (play, pause, stop, volume) sans attributs aria-label. Les lecteurs d'écran ne peuvent pas identifier la fonction de ces contrôles.

Correction minimale :

<!-- TransportControls.vue -->
<button @click="play" aria-label="Lecture">
  <PlayIcon />
</button>
<button @click="pause" aria-label="Pause">
  <PauseIcon />
</button>

12. Erreurs réseau silencieuses sans feedback utilisateur

Plusieurs appels critiques sont lancés en fire-and-forget sans propagation vers l'UI :

// useRenderers.ts:86
void fetchRenderers(true);   // erreur loggée en console uniquement

// useRenderers.ts:89
void fetchRendererSnapshot(rendererId, { force: true });  // idem

Le store ui.ts dispose d'un système de notifications toast (addNotification). Les erreurs de réseau devraient y être propagées pour informer l'utilisateur :

import { useUIStore } from '@/stores/ui';

const uiStore = useUIStore();

// Dans le handler SSE :
try {
  await fetchRenderers(true);
} catch {
  uiStore.addNotification({
    message: 'Impossible de rafraîchir la liste des renderers',
    type: 'error',
  });
}

Points forts à conserver

Ces patterns sont bien conçus et ne doivent pas être modifiés dans les corrections ci-dessus :

  • Déduplication des requêtes en vol (apiCache.ts:100-112) : évite les appels réseau redondants quand plusieurs composants demandent la même ressource simultanément.
  • Watch sélectif sur les IDs (UnifiedControlView.vue:139-150) : calcule une clé synthétique ids.join(',') au lieu d'un deep watch sur le tableau de renderers.
  • Backoff exponentiel SSE (sse.ts) : reconnexion progressive 1s→2s→4s→8s→16s→30s avec cap. Implémentation robuste.
  • Fetch batch contrôlé (useRenderers.ts:351-383) : fetchBatchSnapshots() avec concurrence limitée (défaut 3) et délai inter-batches. Évite de saturer le réseau au démarrage.
  • filterRenderers par UDN (UnifiedControlView.vue:107-120) : filtre les WebRenderers étrangers en comparant le UDN normalisé. Logique correcte avec gestion du préfixe uuid:.

État d'avancement — corrections appliquées (commit 2026-04-06)

Les 12 points de la revue ont été traités. Le tableau ci-dessous récapitule ce qui a été fait et ce qui reste à finir.

# Problème État
1 apiCache — TTL mutation globale Corrigé (ttl par entrée dans CacheEntry, isFresh() lit entry.ttl)
2 useRenderers — double réassignation post-switch Corrigé (chaque case responsable, ligne 222 supprimée)
3 useRenderersas any sur transport_state Corrigé (isTransportState guard dans types.ts)
4 apiCacheinvalidate() détruisait les subscriptions Corrigé (this.subscriptions.delete supprimé)
5 useRendererstoRaw() / reactive(Map) fragile Migré vers shallowRef + helpers triggerSnapshotReactivity() / triggerLoadingReactivity()
6 useRenderer() — timer debounce non nettoyé Corrigé (onUnmounted + clearTimeout)
7 Singleton SSE non réinitialisable Corrigé (resetSSE() exposé dans le retour de useRenderers())
8 useTabs — deep watch coûteux Corrigé (watches séparés sans deep: true)
9 Swipe — clientX final au lieu d'initial Corrigé (swipeStartX capturé dans onSwipeStart)
10 api.ts — JSON non validé Corrigé (log dev-mode pour réponse nulle)
11 Absence de labels ARIA Corrigé (4 boutons transport + bouton mute + slider volume)
12 Erreurs réseau silencieuses Corrigé (uiStore.notifyError() dans fetchRenderers et fetchRendererSnapshot)

Tâches restantes — Toutes corrigées (commit 2026-04-06)

# Problème résiduel État
A state_changed — mutation directe sans trigger réactivité Corrigé (spread + triggerSnapshotReactivity())
B queue_refreshing/queue_updatedqueueRefreshingIds sans trigger Corrigé (triggerQueueReactivity() ajoutée et appelée)
C toRaw import obsolète, position_changed simplifié Corrigé (toRaw supprimé de l'import, spread direct)

A. useRenderers.tsstate_changed : mutation directe sans déclenchement de réactivité

Problème : avec la migration vers shallowRef, les objets dans la Map ne sont plus des proxies Vue. La mutation directe de snapshot.state.transport_state ne déclenche aucune réactivité — les composants ne se mettront pas à jour quand l'état de transport change :

// useRenderers.ts — case state_changed (code actuel)
case "state_changed":
  if (isTransportState(event.state)) {
    snapshot.state.transport_state = event.state;  // ← mutation directe, pas de trigger
  }
  break;

Solution : créer un nouvel objet, comme pour volume_changed et mute_changed :

case "state_changed":
  if (isTransportState(event.state)) {
    snapshots.value.set(rendererId, {
      ...snapshot,
      state: { ...snapshot.state, transport_state: event.state },
    });
  } else {
    console.warn(`[useRenderers] transport_state inconnu: ${event.state}`);
  }
  break;

B. useRenderers.tsqueue_refreshing / queue_updated : mutations de queueRefreshingIds sans trigger

Problème : queueRefreshingIds est un shallowRef<Set>. Les appels .add() et .delete() sur .value ne déclenchent pas la réactivité de shallowRef :

// useRenderers.ts — case queue_refreshing (code actuel)
case "queue_refreshing":
  queueRefreshingIds.value.add(rendererId);   // ← pas de trigger
  break;

case "queue_updated":
  queueRefreshingIds.value.delete(rendererId); // ← pas de trigger
  break;

Le composable isQueueRefreshing(id) retourne queueRefreshingIds.value.has(id). Sans trigger, les templates qui dépendent de cette valeur ne se recalculeront pas.

Solution : ajouter une fonction triggerQueueReactivity() analogue à triggerLoadingReactivity() et l'appeler après chaque mutation :

function triggerQueueReactivity() {
  queueRefreshingIds.value = new Set(queueRefreshingIds.value);
}

// Dans le switch :
case "queue_refreshing":
  queueRefreshingIds.value.add(rendererId);
  triggerQueueReactivity();
  break;

case "queue_updated":
  snapshot.state.queue_len = event.queue_length;
  queueRefreshingIds.value.delete(rendererId);
  triggerQueueReactivity();
  void fetchRendererSnapshot(rendererId, { force: true });
  break;

C. useRenderers.ts — import toRaw et commentaire obsolètes

Problème : avec shallowRef, les objets stockés dans snapshots.value sont de simples objets JavaScript (jamais des proxies Vue). L'appel toRaw(snapshot.state) dans position_changed est devenu un no-op, et le commentaire qui le justifie est trompeur :

// useRenderers.ts:160-163 (commentaire et import obsolètes)
// IMPORTANT: Utiliser toRaw() pour obtenir l'objet brut non-réactif avant de copier
// sinon Vue copie les getters réactifs qui continuent à pointer vers l'objet d'origine
const rawState = toRaw(snapshot.state);
const newState = { ...rawState };

Solution : supprimer le toRaw(), simplifier en spread direct, retirer toRaw de l'import ligne 7 :

// Remplacer :
import { ref, shallowRef, computed, toRaw, type Ref, onUnmounted } from "vue";

// Par :
import { ref, shallowRef, computed, type Ref, onUnmounted } from "vue";

// Dans position_changed :
const newSnapshot = {
  ...snapshot,
  state: {
    ...snapshot.state,
    position_ms: positionMs ?? 0,
    duration_ms: durationMs,
  },
};
snapshots.value.set(rendererId, newSnapshot);

Cette simplification rend aussi le cas position_changed cohérent avec les autres cases (volume_changed, mute_changed, etc.) qui construisent directement l'objet final sans passer par une variable intermédiaire.