Sert les couvertures depuis IndexedDB aussi en ligne, et libère les blob:

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mathieu
2026-08-20 23:09:17 +02:00
co-authored by Claude Opus 5
parent 70f6844a95
commit bdcf97a3e6
5 changed files with 145 additions and 26 deletions
+54
View File
@@ -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.
+14 -9
View File
@@ -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.
+42 -15
View File
@@ -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);
/// <summary>
/// URL réellement posée en <c>src</c> : 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 <c>src</c> : 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é).
/// </summary>
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();
}
/// <summary>Révoque l'URL d'objet en cours, s'il y en a une.</summary>
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)
+21
View File
@@ -191,6 +191,27 @@ public sealed class CacheHorsLigne(IJSRuntime js) : IAsyncDisposable
}
}
/// <summary>
/// Libère une URL d'objet obtenue par <see cref="LireCouvertureCacheeAsync"/>.
/// </summary>
/// <remarks>
/// ⚠️ Nécessaire depuis que le cache sert aussi en ligne : une URL <c>blob:</c> 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.
/// </remarks>
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)
@@ -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.
}
}