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:
@@ -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
|
Vérifié en exécution : `9772466671438` saisi depuis « Ajouter un ouvrage » nomme toujours
|
||||||
« Médor » et propose d'ouvrir sa fiche.
|
« 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.
|
||||||
|
|||||||
@@ -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)
|
## 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
|
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
|
de suite — alors que deux images dont l'URL a été collée à la main (Fnac) s'affichent sans
|
||||||
attendre.
|
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
|
l'image s'affichera en ligne et jamais hors-ligne (exactement le défaut corrigé le
|
||||||
2026-08-20).
|
2026-08-20).
|
||||||
|
|
||||||
- **O2. Des étiquettes par numéro : les articles à la une.** ⚠️ **À trancher d'abord : sur la
|
- **O2. Des étiquettes par numéro : les articles à la une.** ✅ **Tranché avec l'utilisateur le
|
||||||
revue ou sur le numéro ?** L'énoncé dit « par revue », mais « les articles à la une » change à
|
2026-08-20 : sur le NUMÉRO** (`NumeroRevue`), pas sur la revue — les articles à la une changent
|
||||||
chaque parution — donc `NumeroRevue`, sauf intention contraire. Posé sur `Revue`, ce serait
|
à chaque parution ; posés sur `Revue`, ils décriraient une ligne éditoriale, pas un sommaire.
|
||||||
une description de la ligne éditoriale, pas un sommaire.
|
|
||||||
|
|
||||||
Le modèle existe déjà à côté : `Theme` / `LivreTheme` (nom, `NomNormalise`, index unique,
|
Le modèle existe déjà à côté : `Theme` / `LivreTheme` (nom, `NomNormalise`, index unique,
|
||||||
n-n). Deux façons de faire, à choisir :
|
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
|
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.
|
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.**
|
✅ **Séparateur tranché le 2026-08-20 : le point-virgule.** Un titre d'article contient souvent
|
||||||
Divergence à assumer explicitement ou à écarter : un titre d'article contient souvent une
|
une virgule (« Ukraine, deux ans après »), qui couperait le titre en deux. ⚠️ **Reste une
|
||||||
virgule (« Ukraine, deux ans après »), ce qui justifie le point-virgule ici — mais deux
|
question ouverte** : les thèmes de livres se saisissent à la virgule — faut-il les aligner sur
|
||||||
champs voisins qui se saisissent différemment se confondent. Trancher avec l'utilisateur.
|
`;` pour que deux champs voisins ne se saisissent pas différemment, ou assumer la divergence
|
||||||
Le reste suit les règles déjà tenues : dédoublonnage avant résolution, `NormalisationTexte`,
|
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.
|
premier libellé saisi = forme affichée.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@using MaBibli.Client.Services
|
@using MaBibli.Client.Services
|
||||||
@inject CacheHorsLigne Cache
|
@inject CacheHorsLigne Cache
|
||||||
@inject EtatReseau Reseau
|
@inject EtatReseau Reseau
|
||||||
|
@implements IAsyncDisposable
|
||||||
@*
|
@*
|
||||||
Couverture d'un livre, avec substitut.
|
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
|
seulement l'image. Sans ça, cliquer une vignette cassée agrandissait quand même une image
|
||||||
cassée (A4, IDEES.md).
|
cassée (A4, IDEES.md).
|
||||||
|
|
||||||
Cache hors-ligne (A5, IDEES.md) : EN LIGNE, l'affichage utilise l'URL réseau telle quelle —
|
Cache des couvertures : le cache IndexedDB est consulté D'ABORD, en ligne comme hors ligne.
|
||||||
rien ne doit ralentir la voie rapide — et une mise en cache démarre en tâche de fond,
|
Une image déjà rangée sur l'appareil s'affiche sans aucune requête réseau — c'est ce qui rend
|
||||||
sans attendre son résultat, pour que la même couverture reste visible hors-ligne. HORS
|
un rechargement instantané sur téléphone, là où covers.openlibrary.org impose deux
|
||||||
LIGNE, on tente d'abord le cache : l'URL réseau échouerait de toute façon.
|
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
|
⚠️ 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.
|
de s'y engager, comme pour l'AOT — voir IDEES.md.
|
||||||
*@
|
*@
|
||||||
@@ -94,11 +104,11 @@
|
|||||||
private bool ADesPixels => !string.IsNullOrWhiteSpace(Url);
|
private bool ADesPixels => !string.IsNullOrWhiteSpace(Url);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// URL réellement posée en <c>src</c> : l'URL réseau en ligne (voie rapide, inchangée), le
|
/// URL réellement posée en <c>src</c> : le blob mis en cache dès qu'il existe — en ligne
|
||||||
/// blob mis en cache hors-ligne quand il existe, sinon l'URL réseau telle quelle (qui échoue
|
/// comme hors ligne —, sinon l'URL réseau telle quelle (qui échoue simplement hors-ligne,
|
||||||
/// simplement, comme avant cette fonctionnalité).
|
/// exactement comme avant cette fonctionnalité).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private string? UrlAffichee => !Reseau.EnLigne && _urlCachee is not null ? _urlCachee : Url;
|
private string? UrlAffichee => _urlCachee ?? Url;
|
||||||
|
|
||||||
private string Initiale =>
|
private string Initiale =>
|
||||||
string.IsNullOrWhiteSpace(Titre) ? "?" : Titre.Trim()[..1].ToUpperInvariant();
|
string.IsNullOrWhiteSpace(Titre) ? "?" : Titre.Trim()[..1].ToUpperInvariant();
|
||||||
@@ -131,21 +141,38 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
_urlResoluePour = jeton;
|
_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 à
|
// Rien en cache : le réseau sert l'image (UrlAffichee retombe sur Url), et la mise
|
||||||
// ce cache, elle reste aussi rapide qu'avant.
|
// 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!);
|
_ = Cache.MettreEnCacheCouvertureAsync(Url!);
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
_urlCachee = await Cache.LireCouvertureCacheeAsync(Url!);
|
|
||||||
StateHasChanged();
|
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)
|
protected override async Task OnAfterRenderAsync(bool premierRendu)
|
||||||
{
|
{
|
||||||
if (_aFocaliser)
|
if (_aFocaliser)
|
||||||
|
|||||||
@@ -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()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
if (_module is null)
|
if (_module is null)
|
||||||
|
|||||||
@@ -154,3 +154,15 @@ export async function couvertureLire(url) {
|
|||||||
return null;
|
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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user