diff --git a/CLAUDE.md b/CLAUDE.md index 5703be1..9a3e25e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2548,3 +2548,57 @@ jamais « créer une revue », c'est toujours « ranger un numéro ». Vérifié en exécution : `9772466671438` saisi depuis « Ajouter un ouvrage » nomme toujours « Médor » et propose d'ouvrir sa fiche. + +## Les couvertures viennent du cache AUSSI en ligne — règle A5 renversée (2026-08-20) + +Constat d'usage : sur téléphone, après un rechargement, les vignettes n'apparaissent pas tout de +suite — alors que deux couvertures dont l'URL avait été collée à la main (CDN de la Fnac) +s'affichaient sans attendre. + +⚠️ **L'hypothèse naturelle était fausse, et c'est ce qu'il faut retenir** : « garder l'URL en base +pour charger plus vite » ne pouvait rien donner — `Livre.CoverUrl` **est** en base depuis +toujours, il n'y avait aucune résolution à supprimer. La comparaison avec la Fnac désignait la +vraie cause : `covers.openlibrary.org` répond **302, deux fois**, avant d'aboutir sur +`archive.org` (déjà mesuré au lot du relais), là où une URL de CDN va droit au fichier. Trois +allers-retours par vignette, dix vignettes à l'écran, sur un lien mobile. + +Seconde cause, dans notre code : `Couverture.razor` ne lisait le magasin IndexedDB `couvertures` +que **hors ligne**. En ligne, chaque rechargement repartait du réseau pour une image déjà rangée +sur l'appareil. + +**Décidé avec l'utilisateur : le cache est consulté d'abord, en ligne comme hors ligne. Le réseau +n'est sollicité que si rien n'est en cache.** + +⚠️ **Ceci renverse la règle du lot A5** — « en ligne, l'affichage utilise l'URL réseau telle +quelle : rien ne doit ralentir la voie rapide ». Elle n'était pas absurde, elle était mal +calibrée : ce qu'on protégeait était une requête réseau avec redirections, ce qu'on refusait +était une lecture IndexedDB locale. La seconde est largement plus rapide que la première. Le +sens du mot « rapide » avait été supposé, pas mesuré. + +Le fire-and-forget de mise en cache subsiste, mais **seulement quand le cache est vide** : rien +ne sert de le relancer pour une image déjà rangée (`couvertureMettreEnCache` court-circuitait +déjà sur son `getKey`, c'est maintenant explicite côté C#). + +### ⚠️ Le corollaire à ne pas oublier : révoquer les `blob:` + +`URL.createObjectURL` retient son Blob en mémoire **jusqu'à révocation ou fermeture de la page**. +Tant que le cache ne servait qu'hors-ligne, la fuite restait bornée à une consultation +ponctuelle ; avec une vignette par carte sur tous les écrans, elle ne l'est plus. `Couverture` +implémente donc `IAsyncDisposable` et libère aussi lorsqu'elle change d'URL +(`couvertureLiberer` → `URL.revokeObjectURL`). + +### Vérifié en exécution + +| Point | Résultat | +|---|---| +| `src` des vignettes au chargement | **`blob:`** pour les deux couvertures | +| Requêtes vers `editions-ambre.fr`, `openlibrary.org`, `/api/couvertures` | **aucune** | +| Contenu réellement servi par le `blob:` | `image/jpeg`, 15 777 o, **500×500** | +| URL d'objet après navigation vers un autre écran | **révoquée** (fetch rejeté) | + +479 tests au vert. + +⚠️ `naturalWidth` reste à 0 dans le navigateur d'automatisation : la fenêtre ne compose pas +d'image, donc `loading="lazy"` ne déclenche jamais le décodage. Ce n'est pas un défaut de la +couverture — le blob a été décodé à la main par `createImageBitmap` pour le prouver. Ne pas +partir en chasse là-dessus. diff --git a/IDEES.md b/IDEES.md index ac19fdc..5d48473 100644 --- a/IDEES.md +++ b/IDEES.md @@ -326,6 +326,11 @@ Non actés : ce fichier ne fait pas autorité, `CLAUDE.md` reste la référence. ## Lot N — Les couvertures lentes au rechargement (téléphone) +✅ **N1 traité le 2026-08-20** : le cache IndexedDB est consulté d'abord, en ligne comme hors +ligne — voir `CLAUDE.md`, section « Les couvertures viennent du cache AUSSI en ligne ». N2 +(stocker l'URL finale) reste ouvert, et n'est plus urgent : une couverture déjà vue ne repasse +plus par les redirections. + Constat d'usage : après un rechargement sur téléphone, les couvertures n'apparaissent pas tout de suite — alors que deux images dont l'URL a été collée à la main (Fnac) s'affichent sans attendre. @@ -366,10 +371,9 @@ Pistes, dans l'ordre où elles se testent : l'image s'affichera en ligne et jamais hors-ligne (exactement le défaut corrigé le 2026-08-20). -- **O2. Des étiquettes par numéro : les articles à la une.** ⚠️ **À trancher d'abord : sur la - revue ou sur le numéro ?** L'énoncé dit « par revue », mais « les articles à la une » change à - chaque parution — donc `NumeroRevue`, sauf intention contraire. Posé sur `Revue`, ce serait - une description de la ligne éditoriale, pas un sommaire. +- **O2. Des étiquettes par numéro : les articles à la une.** ✅ **Tranché avec l'utilisateur le + 2026-08-20 : sur le NUMÉRO** (`NumeroRevue`), pas sur la revue — les articles à la une changent + à chaque parution ; posés sur `Revue`, ils décriraient une ligne éditoriale, pas un sommaire. Le modèle existe déjà à côté : `Theme` / `LivreTheme` (nom, `NomNormalise`, index unique, n-n). Deux façons de faire, à choisir : @@ -382,9 +386,10 @@ Pistes, dans l'ordre où elles se testent : Un titre d'article étant unique à sa parution, il ne se réutilise pas : **la table à part semble la bonne**, mais c'est une décision à acter, pas un détail d'implémentation. - ⚠️ **Le séparateur demandé est `;`, alors que les thèmes de livres utilisent la virgule.** - Divergence à assumer explicitement ou à écarter : un titre d'article contient souvent une - virgule (« Ukraine, deux ans après »), ce qui justifie le point-virgule ici — mais deux - champs voisins qui se saisissent différemment se confondent. Trancher avec l'utilisateur. - Le reste suit les règles déjà tenues : dédoublonnage avant résolution, `NormalisationTexte`, + ✅ **Séparateur tranché le 2026-08-20 : le point-virgule.** Un titre d'article contient souvent + une virgule (« Ukraine, deux ans après »), qui couperait le titre en deux. ⚠️ **Reste une + question ouverte** : les thèmes de livres se saisissent à la virgule — faut-il les aligner sur + `;` pour que deux champs voisins ne se saisissent pas différemment, ou assumer la divergence + parce qu'un thème (« dark fantasy ») ne contient jamais de virgule ? À poser à l'utilisateur + avant de coder. Le reste suit les règles déjà tenues : dédoublonnage avant résolution, `NormalisationTexte`, premier libellé saisi = forme affichée. diff --git a/MaBibli.Client/Composants/Couverture.razor b/MaBibli.Client/Composants/Couverture.razor index 8914692..155d734 100644 --- a/MaBibli.Client/Composants/Couverture.razor +++ b/MaBibli.Client/Composants/Couverture.razor @@ -1,6 +1,7 @@ @using MaBibli.Client.Services @inject CacheHorsLigne Cache @inject EtatReseau Reseau +@implements IAsyncDisposable @* Couverture d'un livre, avec substitut. @@ -20,10 +21,19 @@ seulement l'image. Sans ça, cliquer une vignette cassée agrandissait quand même une image cassée (A4, IDEES.md). - Cache hors-ligne (A5, IDEES.md) : EN LIGNE, l'affichage utilise l'URL réseau telle quelle — - rien ne doit ralentir la voie rapide — et une mise en cache démarre en tâche de fond, - sans attendre son résultat, pour que la même couverture reste visible hors-ligne. HORS - LIGNE, on tente d'abord le cache : l'URL réseau échouerait de toute façon. + Cache des couvertures : le cache IndexedDB est consulté D'ABORD, en ligne comme hors ligne. + Une image déjà rangée sur l'appareil s'affiche sans aucune requête réseau — c'est ce qui rend + un rechargement instantané sur téléphone, là où covers.openlibrary.org impose deux + redirections avant d'aboutir sur archive.org. Le réseau n'est sollicité QUE si rien n'est en + cache, et la mise en cache démarre alors en tâche de fond. + + ⚠️ Ceci REMPLACE la règle d'origine (A5) « en ligne, rien ne doit ralentir la voie rapide », + qui réservait le cache au hors-ligne. La lecture IndexedDB précède désormais l'affichage : + elle est locale et sans réseau, donc largement plus rapide que ce qu'elle remplace. + + ⚠️ Corollaire : l'URL d'objet doit être LIBÉRÉE quand le composant disparaît ou change + d'image (Dispose / changement d'URL). Un blob: retient son Blob en mémoire jusque-là, et il y + a maintenant une vignette par carte sur tous les écrans. ⚠️ L'allègement (redimensionner, convertir en WebP) n'est PAS fait ici : à mesurer avant de s'y engager, comme pour l'AOT — voir IDEES.md. *@ @@ -94,11 +104,11 @@ private bool ADesPixels => !string.IsNullOrWhiteSpace(Url); /// - /// URL réellement posée en src : l'URL réseau en ligne (voie rapide, inchangée), le - /// blob mis en cache hors-ligne quand il existe, sinon l'URL réseau telle quelle (qui échoue - /// simplement, comme avant cette fonctionnalité). + /// URL réellement posée en src : le blob mis en cache dès qu'il existe — en ligne + /// comme hors ligne —, sinon l'URL réseau telle quelle (qui échoue simplement hors-ligne, + /// exactement comme avant cette fonctionnalité). /// - private string? UrlAffichee => !Reseau.EnLigne && _urlCachee is not null ? _urlCachee : Url; + private string? UrlAffichee => _urlCachee ?? Url; private string Initiale => string.IsNullOrWhiteSpace(Titre) ? "?" : Titre.Trim()[..1].ToUpperInvariant(); @@ -131,21 +141,38 @@ } _urlResoluePour = jeton; - _urlCachee = null; + await LibererAsync(); - if (Reseau.EnLigne) + // Le cache d'abord, EN LIGNE COMME HORS LIGNE : une image déjà sur l'appareil s'affiche + // sans requête, sans redirection, et sans réveiller la radio du téléphone. + _urlCachee = await Cache.LireCouvertureCacheeAsync(Url!); + + if (_urlCachee is null && Reseau.EnLigne) { - // Tâche de fond, jamais attendue : la voie d'affichage en ligne ne doit rien à - // ce cache, elle reste aussi rapide qu'avant. + // Rien en cache : le réseau sert l'image (UrlAffichee retombe sur Url), et la mise + // en cache démarre en tâche de fond pour que le prochain chargement soit local. + // ⚠️ Jamais attendue : elle ne doit pas retarder un affichage qui va avoir lieu. _ = Cache.MettreEnCacheCouvertureAsync(Url!); } - else - { - _urlCachee = await Cache.LireCouvertureCacheeAsync(Url!); - StateHasChanged(); - } + + StateHasChanged(); } + /// Révoque l'URL d'objet en cours, s'il y en a une. + private async Task LibererAsync() + { + if (_urlCachee is null) + { + return; + } + + var aLiberer = _urlCachee; + _urlCachee = null; + await Cache.LibererCouvertureAsync(aLiberer); + } + + public async ValueTask DisposeAsync() => await LibererAsync(); + protected override async Task OnAfterRenderAsync(bool premierRendu) { if (_aFocaliser) diff --git a/MaBibli.Client/Services/CacheHorsLigne.cs b/MaBibli.Client/Services/CacheHorsLigne.cs index 42de125..c18d8f2 100644 --- a/MaBibli.Client/Services/CacheHorsLigne.cs +++ b/MaBibli.Client/Services/CacheHorsLigne.cs @@ -191,6 +191,27 @@ public sealed class CacheHorsLigne(IJSRuntime js) : IAsyncDisposable } } + /// + /// Libère une URL d'objet obtenue par . + /// + /// + /// ⚠️ Nécessaire depuis que le cache sert aussi en ligne : une URL blob: retient son + /// Blob en mémoire tant qu'elle n'est pas révoquée. Avec une vignette par carte sur tous les + /// écrans, ne pas libérer ferait enfler la mémoire de l'onglet au fil de la navigation. + /// + public async Task LibererCouvertureAsync(string url) + { + try + { + var module = await ModuleAsync(); + await module.InvokeVoidAsync("couvertureLiberer", url); + } + catch (Exception) + { + // Voir MettreEnCacheCouvertureAsync : rien à propager. + } + } + public async ValueTask DisposeAsync() { if (_module is null) diff --git a/MaBibli.Client/wwwroot/js/cache-hors-ligne.js b/MaBibli.Client/wwwroot/js/cache-hors-ligne.js index 38ec462..c39e75c 100644 --- a/MaBibli.Client/wwwroot/js/cache-hors-ligne.js +++ b/MaBibli.Client/wwwroot/js/cache-hors-ligne.js @@ -154,3 +154,15 @@ export async function couvertureLire(url) { return null; } } + +// Libère une URL d'objet rendue par `couvertureLire`. ⚠️ Indispensable depuis que le cache sert +// AUSSI en ligne : chaque `createObjectURL` retient son Blob en mémoire jusqu'à ce qu'on le +// révoque ou que la page se ferme. Hors-ligne seul, la fuite restait bornée à une consultation +// ponctuelle ; sur toutes les vignettes de tous les écrans, elle ne l'est plus. +export function couvertureLiberer(url) { + try { + if (url) URL.revokeObjectURL(url); + } catch (e) { + // Rien à faire : libérer est un agrément, jamais une condition de bon fonctionnement. + } +}