Skip to content

Commit 6467ffe

Browse files
subhayu99claude
andcommitted
fix(gui): resolve ScrambleText staying stuck in gibberish state
Track active setTimeout/setInterval as refs instead of relying on a closure-captured `cancelled` boolean. When the scroll-triggered or hover animation is interrupted (fast scroll, component remount, competing hover), clearActive() now deterministically stops both timers and the cleanup function forces setDisplay(targetText) so the text always resolves to English. Also adds a top-level unmount cleanup effect to prevent leaked timers. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 4cb8a0d commit 6467ffe

1 file changed

Lines changed: 25 additions & 21 deletions

File tree

client/src/components/gui/ScrambleText.tsx

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ function randomGlyph() {
1212
interface ScrambleTextProps {
1313
text: string;
1414
className?: string;
15-
/** Delay in ms before starting the decode (useful for staggering) */
1615
delay?: number;
1716
}
1817

@@ -22,26 +21,29 @@ export default function ScrambleText({ text, className = '', delay = 0 }: Scramb
2221
const [display, setDisplay] = useState(text);
2322
const hasPlayed = useRef(false);
2423
const isHovering = useRef(false);
24+
const activeInterval = useRef<ReturnType<typeof setInterval> | null>(null);
25+
const activeTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
26+
27+
const clearActive = useCallback(() => {
28+
if (activeInterval.current) { clearInterval(activeInterval.current); activeInterval.current = null; }
29+
if (activeTimeout.current) { clearTimeout(activeTimeout.current); activeTimeout.current = null; }
30+
}, []);
2531

2632
const scramble = useCallback((targetText: string, startDelay: number) => {
27-
let cancelled = false;
33+
clearActive();
2834

2935
const chars = targetText.split('');
3036
const resolved = new Array(chars.length).fill(false);
3137
const current = chars.map((ch) => (ch === ' ' ? ' ' : randomGlyph()));
3238

33-
// Immediately show scrambled state
34-
setTimeout(() => {
35-
if (cancelled) return;
39+
activeTimeout.current = setTimeout(() => {
40+
activeTimeout.current = null;
3641
setDisplay(current.join(''));
3742

3843
let charIndex = 0;
3944
let cycleCount = 0;
4045

41-
const interval = setInterval(() => {
42-
if (cancelled) { clearInterval(interval); return; }
43-
44-
// Cycle random glyphs for unresolved characters
46+
activeInterval.current = setInterval(() => {
4547
for (let i = charIndex; i < chars.length; i++) {
4648
if (!resolved[i] && chars[i] !== ' ') {
4749
current[i] = randomGlyph();
@@ -50,13 +52,11 @@ export default function ScrambleText({ text, className = '', delay = 0 }: Scramb
5052

5153
cycleCount++;
5254
if (cycleCount >= CYCLES) {
53-
// Lock in the next character
5455
resolved[charIndex] = true;
5556
current[charIndex] = chars[charIndex];
5657
charIndex++;
5758
cycleCount = 0;
5859

59-
// Skip spaces
6060
while (charIndex < chars.length && chars[charIndex] === ' ') {
6161
resolved[charIndex] = true;
6262
current[charIndex] = ' ';
@@ -67,16 +67,18 @@ export default function ScrambleText({ text, className = '', delay = 0 }: Scramb
6767
setDisplay(current.join(''));
6868

6969
if (charIndex >= chars.length) {
70-
clearInterval(interval);
70+
clearActive();
7171
setDisplay(targetText);
7272
}
7373
}, TICK_MS);
7474
}, startDelay);
7575

76-
return () => { cancelled = true; };
77-
}, []);
76+
return () => {
77+
clearActive();
78+
setDisplay(targetText);
79+
};
80+
}, [clearActive]);
7881

79-
// Scramble on scroll into view
8082
useEffect(() => {
8183
if (isInView && !hasPlayed.current) {
8284
hasPlayed.current = true;
@@ -85,27 +87,25 @@ export default function ScrambleText({ text, className = '', delay = 0 }: Scramb
8587
}
8688
}, [isInView, text, delay, scramble]);
8789

88-
// Re-scramble on hover (ripple from center outward)
8990
const handleMouseEnter = useCallback(() => {
9091
if (isHovering.current) return;
9192
isHovering.current = true;
93+
clearActive();
9294

9395
const chars = text.split('');
9496
const current = text.split('');
9597
const mid = Math.floor(chars.length / 2);
9698

97-
// Calculate distance from center for each character
9899
const distances = chars.map((_, i) => Math.abs(i - mid));
99100
const maxDist = Math.max(...distances);
100101
const resolved = new Array(chars.length).fill(false);
101102

102-
// Mark spaces as already resolved
103103
chars.forEach((ch, i) => { if (ch === ' ') resolved[i] = true; });
104104

105105
let tick = 0;
106106
const totalTicks = (maxDist + 1) * CYCLES + CYCLES;
107107

108-
const interval = setInterval(() => {
108+
activeInterval.current = setInterval(() => {
109109
tick++;
110110

111111
for (let i = 0; i < chars.length; i++) {
@@ -122,12 +122,16 @@ export default function ScrambleText({ text, className = '', delay = 0 }: Scramb
122122
setDisplay(current.join(''));
123123

124124
if (tick >= totalTicks || resolved.every(Boolean)) {
125-
clearInterval(interval);
125+
clearActive();
126126
setDisplay(text);
127127
isHovering.current = false;
128128
}
129129
}, TICK_MS);
130-
}, [text]);
130+
}, [text, clearActive]);
131+
132+
useEffect(() => {
133+
return () => clearActive();
134+
}, [clearActive]);
131135

132136
return (
133137
<span

0 commit comments

Comments
 (0)