fix(leaderboard): fly-down landed at (0,0) when polling re-rendered list

Wenn das 5s-Polling während der Fly-Animation feuerte, hat
fillLeaderboardContainer den destEl-Eintrag aus dem DOM entfernt.
getBoundingClientRect() lieferte dann {0,0,0,0} und der Ghost flog
sichtbar in die linke obere Ecke.

Fix:
- flyAnimationActive-Flag suspendiert updateLeaderboardDisplay
  während der Animation; ausstehendes Update wird im cleanup
  nachgeholt
- Defensive isConnected/Rect-Checks vor jedem Schritt;
  bei Orphan wird sauber abgebrochen statt nach (0,0) zu fliegen
- Cleanup in eine Funktion ausgelagert für mehrere Exit-Pfade

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Carsten Graf
2026-05-03 20:30:50 +02:00
parent 5beced0041
commit f57442c906

View File

@@ -465,11 +465,22 @@
}
// Leaderboard Funktionen
// Während der Fly-Down-Animation darf das Leaderboard NICHT neu
// gerendert werden — sonst wird destEl aus dem DOM entfernt und
// getBoundingClientRect() liefert {0,0,0,0}; der Ghost fliegt dann
// sichtbar in die linke obere Ecke.
let flyAnimationActive = false;
let leaderboardUpdatePending = false;
async function loadLeaderboard() {
try {
const response = await fetch("/api/leaderboard");
leaderboardData = await response.json();
updateLeaderboardDisplay();
if (flyAnimationActive) {
leaderboardUpdatePending = true;
} else {
updateLeaderboardDisplay();
}
} catch (error) {
console.error("Fehler beim Laden des Leaderboards:", error);
}
@@ -552,9 +563,14 @@
function flyDownFromSnapshot(srcSnap, destEl) {
if (!srcSnap || !destEl) return;
if (!destEl.isConnected) return;
const dstRect = destEl.getBoundingClientRect();
if (dstRect.width === 0 || dstRect.height === 0) return;
// Polling-getriebenes Re-Rendering des Leaderboards während der
// Animation unterdrücken (sonst wird destEl orphaned).
flyAnimationActive = true;
// ---- Phase 1: bestehende Einträge nach unten "schieben" ----
// Wir verstecken den (bereits gerenderten) neuen Top-Eintrag und
// setzen die Geschwister visuell an die Position, die sie VOR
@@ -595,7 +611,39 @@
// ---- Phase 2: nach dem Slide den Ghost einfliegen lassen ----
const flyMs = 800;
// Sauberes Aufräumen — wird in jedem Exit-Pfad aufgerufen
const cleanup = (ghost) => {
if (ghost) ghost.remove();
if (destEl.isConnected) destEl.style.visibility = "";
siblings.forEach((s) => {
if (!s.isConnected) return;
s.style.transition = "";
s.style.transform = "";
});
flyAnimationActive = false;
// Während Animation aufgelaufenes Update jetzt nachholen
if (leaderboardUpdatePending) {
leaderboardUpdatePending = false;
updateLeaderboardDisplay();
}
};
setTimeout(() => {
// Wenn destEl in der Zwischenzeit aus dem DOM verschwand
// (z. B. Modus-Wechsel), Animation abbrechen statt nach (0,0)
// zu fliegen.
if (!destEl.isConnected) {
cleanup(null);
return;
}
const destTimeSpanCheck = destEl.querySelector(".time") || destEl;
const preRect = destTimeSpanCheck.getBoundingClientRect();
if (preRect.width === 0 || preRect.height === 0) {
cleanup(null);
return;
}
const ghost = document.createElement("div");
ghost.textContent = srcSnap.text;
ghost.style.position = "fixed";
@@ -630,7 +678,7 @@
// Reflow erzwingen, damit die Anfangsposition wirkt
ghost.getBoundingClientRect();
// Endwerte: aktuelle Position des .time-Spans im Eintrag
// Endwerte aus erneuter Messung (preRect war Vorab-Check)
const destTimeSpan = destEl.querySelector(".time") || destEl;
const destTimeRect = destTimeSpan.getBoundingClientRect();
const destStyle = window.getComputedStyle(destTimeSpan);
@@ -643,15 +691,7 @@
ghost.style.color = destStyle.color;
ghost.style.textShadow = "0 0 6px rgba(0, 255, 136, 0.55)";
setTimeout(() => {
ghost.remove();
destEl.style.visibility = "";
// Sibling-Transforms aufräumen (sie sind eh schon bei 0)
siblings.forEach((s) => {
s.style.transition = "";
s.style.transform = "";
});
}, flyMs + 20);
setTimeout(() => cleanup(ghost), flyMs + 20);
}, slideMs);
}