Corrige le churn de refs par pièce dans La Corne d'Abondance
Build and deploy / deploy (push) Successful in 37s
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:
@@ -32,6 +32,54 @@ function randomTier(min: number, max: number): number {
|
||||
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 = {
|
||||
v: 1;
|
||||
duration_ms: number;
|
||||
@@ -52,6 +100,13 @@ export function MelonGame({
|
||||
const previewRef = useRef<HTMLDivElement | null>(null);
|
||||
const guideLineRef = useRef<HTMLDivElement | null>(null);
|
||||
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
|
||||
// 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"
|
||||
/>
|
||||
|
||||
{pieces.map((piece) => {
|
||||
const citizen = citizens[piece.tier];
|
||||
const diameter = tierRadii[piece.tier] * 2;
|
||||
return (
|
||||
<div
|
||||
key={piece.id}
|
||||
ref={(node) => {
|
||||
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>
|
||||
);
|
||||
})}
|
||||
{pieces.map((piece) => (
|
||||
<PieceView
|
||||
key={piece.id}
|
||||
piece={piece}
|
||||
citizen={citizens[piece.tier]}
|
||||
diameter={tierRadii[piece.tier] * 2}
|
||||
registerNode={registerPieceNode}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div
|
||||
ref={(node) => {
|
||||
|
||||
Reference in New Issue
Block a user