** Ce travail devra être réalisé en suivant scrupuleusement les consignes listées dans le fichier [@Rules_optimal.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/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 : ```typescript // 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é : ```typescript // Dans set() : ajouter un paramètre ttl optionnel set(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 : ```typescript // 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 : ```typescript // 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 : ```typescript 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:122` — `as any` sur `transport_state` **Problème** : ```typescript // 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` : ```typescript // 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); } ``` ```typescript // 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-200` — `invalidate()` supprime silencieusement les subscriptions actives **Problème** : ```typescript // 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 : ```typescript 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-151` — `toRaw()` 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 : ```typescript // 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`) : ```typescript // Au lieu de : const snapshotState = reactive({ snapshots: reactive(new Map()), ... }); // Utiliser : const snapshots = shallowRef(new Map()); // 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é : ```typescript // useRenderers.ts:553-566 let refreshDebounceTimer: ReturnType | 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** : ```typescript 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** : ```typescript // 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 : ```typescript // 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 : ```typescript // 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** : ```typescript // 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` : ```typescript 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** : ```typescript // api.ts:50 return response.json(); // retour typé T par inférence, sans validation ``` **Cause** : `response.json()` retourne `Promise`. TypeScript accepte le retour car la méthode `request` promet `Promise`, 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 : ```typescript private async request(path: string, options: RequestInit = {}): Promise { // ... 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** : ```html ``` --- ### 12. Erreurs réseau silencieuses sans feedback utilisateur Plusieurs appels critiques sont lancés en fire-and-forget sans propagation vers l'UI : ```typescript // 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 : ```typescript 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 | `useRenderers` — `as any` sur `transport_state` | ✅ Corrigé (`isTransportState` guard dans `types.ts`) | | 4 | `apiCache` — `invalidate()` détruisait les subscriptions | ✅ Corrigé (`this.subscriptions.delete` supprimé) | | 5 | `useRenderers` — `toRaw()` / `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_updated` — `queueRefreshingIds` 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.ts` — `state_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 : ```typescript // 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` : ```typescript 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.ts` — `queue_refreshing` / `queue_updated` : mutations de `queueRefreshingIds` sans trigger **Problème** : `queueRefreshingIds` est un `shallowRef`. Les appels `.add()` et `.delete()` sur `.value` ne déclenchent pas la réactivité de `shallowRef` : ```typescript // 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 : ```typescript 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 : ```typescript // 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 : ```typescript // 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.