Corrige le churn de refs par pièce dans La Corne d'Abondance
Build and deploy / deploy (push) Successful in 37s

Le ref inline dans pieces.map() était recréé à chaque rendu, forçant
React à recycler (null puis noeud) le ref DOM de chaque pièce affichée
à chaque mise à jour du jeu (lâcher, fusion) — coût croissant avec le
nombre de pièces et la fréquence de jeu, cause probable du plantage
signalé sur iPhone après plusieurs minutes. Extrait un composant
PieceView avec un ref stable via useCallback.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Valentin ROBIN
2026-08-26 19:01:18 +02:00
parent 058b1ec140
commit 3f594768b0
+64 -30
View File
@@ -32,6 +32,54 @@ function randomTier(min: number, max: number): number {
return min + Math.floor(Math.random() * (max - min + 1)); return min + Math.floor(Math.random() * (max - min + 1));
} }
// Composant à part entière (pas juste un fragment JSX dans le .map() de
// MelonGame) uniquement pour pouvoir lui donner un ref stable via son
// propre useCallback([piece.id]) — un ref créé comme fonction fléchée
// inline dans une boucle de rendu est une NOUVELLE fonction à chaque
// rendu, ce que React interprète comme "le ref a changé" : il rappelle
// alors la fonction avec null puis avec le nœud, pour CHAQUE pièce déjà
// posée (pas seulement celle qui vient de changer), à chaque mise à jour
// de `pieces` (chaque lâcher, chaque fusion). Sur un plateau qui accumule
// des dizaines de pièces après plusieurs minutes de jeu, ce cycle inutile
// devient un vrai coût CPU répété très souvent — plausiblement la vraie
// cause du ralentissement/rechargement forcé constaté sur iPhone (la
// fuite du Set de fusion, corrigée séparément, n'était pas suffisante à
// elle seule). useCallback garde la même fonction tant que piece.id ne
// change pas, ce qu'une fonction fléchée inline ne peut pas faire.
function PieceView({
piece,
citizen,
diameter,
registerNode,
}: {
piece: Piece;
citizen: MemberRow | undefined;
diameter: number;
registerNode: (id: number, node: HTMLDivElement | null) => void;
}) {
const ref = useCallback((node: HTMLDivElement | null) => registerNode(piece.id, node), [piece.id, registerNode]);
return (
<div ref={ref} className="pointer-events-none absolute top-0 left-0">
{/* La position/rotation (transform) de ce conteneur est pilotée
impérativement à chaque frame depuis la boucle physique —
l'animation d'apparition vit sur cet enfant, jamais sur le
conteneur, pour ne pas entrer en conflit avec transform. Idem
pour animate-danger-blink (ajoutée/retirée sur ce même conteneur
par la boucle physique quand la pièce dépasse la ligne de
danger). rounded-full ici : sans ça le halo de l'animation
dessinait un carré visible autour du rond. animate-fade-in (pas
animate-reveal) : ce dernier passe par 82% puis 104% avant de se
stabiliser à 100%, ce qui faisait paraître une pièce tout juste
lâchée d'une taille différente de celle annoncée dans la
preview. */}
<div className="animate-fade-in rounded-full">
<Avatar pseudo={citizen?.pseudo ?? "?"} avatarUrl={citizen?.avatar_url ?? null} size={diameter} />
</div>
</div>
);
}
export type MelonRunTelemetry = { export type MelonRunTelemetry = {
v: 1; v: 1;
duration_ms: number; duration_ms: number;
@@ -52,6 +100,13 @@ export function MelonGame({
const previewRef = useRef<HTMLDivElement | null>(null); const previewRef = useRef<HTMLDivElement | null>(null);
const guideLineRef = useRef<HTMLDivElement | null>(null); const guideLineRef = useRef<HTMLDivElement | null>(null);
const nodeRefs = useRef(new Map<number, HTMLDivElement>()); const nodeRefs = useRef(new Map<number, HTMLDivElement>());
// Stable (jamais recréée) : passée à PieceView, qui la mémorise elle-même
// par pièce via son propre useCallback([piece.id]) — voir la remarque
// juste au-dessus de PieceView sur pourquoi ça compte.
const registerPieceNode = useCallback((id: number, node: HTMLDivElement | null) => {
if (node) nodeRefs.current.set(id, node);
else nodeRefs.current.delete(id);
}, []);
// Un palier de taille par Citoyen, pas un nombre arbitraire — voir // Un palier de taille par Citoyen, pas un nombre arbitraire — voir
// buildTierRadii. citizens[tier] (indexation directe, sans tirage) donne // buildTierRadii. citizens[tier] (indexation directe, sans tirage) donne
@@ -484,36 +539,15 @@ export function MelonGame({
className="pointer-events-none absolute top-0 bottom-0 w-px border-l border-dashed border-gold/40" className="pointer-events-none absolute top-0 bottom-0 w-px border-l border-dashed border-gold/40"
/> />
{pieces.map((piece) => { {pieces.map((piece) => (
const citizen = citizens[piece.tier]; <PieceView
const diameter = tierRadii[piece.tier] * 2; key={piece.id}
return ( piece={piece}
<div citizen={citizens[piece.tier]}
key={piece.id} diameter={tierRadii[piece.tier] * 2}
ref={(node) => { registerNode={registerPieceNode}
if (node) nodeRefs.current.set(piece.id, node); />
}} ))}
className="pointer-events-none absolute top-0 left-0"
>
{/* La position/rotation (transform) de ce conteneur est
pilotée impérativement à chaque frame depuis la boucle
physique — l'animation d'apparition vit sur cet enfant,
jamais sur le conteneur, pour ne pas entrer en conflit
avec transform. Idem pour animate-danger-blink (ajoutée/
retirée sur ce même conteneur par la boucle physique quand
la pièce dépasse la ligne de danger). rounded-full ici :
sans ça le halo de l'animation dessinait un carré visible
autour du rond. animate-fade-in (pas animate-reveal) : ce
dernier passe par 82% puis 104% avant de se stabiliser à
100%, ce qui faisait paraître une pièce tout juste lâchée
d'une taille différente de celle annoncée dans la
preview. */}
<div className="animate-fade-in rounded-full">
<Avatar pseudo={citizen?.pseudo ?? "?"} avatarUrl={citizen?.avatar_url ?? null} size={diameter} />
</div>
</div>
);
})}
<div <div
ref={(node) => { ref={(node) => {