From 7501e67b55df3e896cc61cef8012728b0ef2ea77 Mon Sep 17 00:00:00 2001 From: Jean-Claude Date: Sat, 4 Jul 2026 03:15:20 +0100 Subject: [PATCH] Fix live toast stale callback race (#271) Co-authored-by: Jean-Claude <273834277+jjoanna2-debug@users.noreply.github.com> --- skill/scripts/live-browser.js | 27 +++++++++++++++---------- tests/live-browser-regression.test.mjs | 28 ++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index bd4d56e36..3ac73b572 100644 --- a/skill/scripts/live-browser.js +++ b/skill/scripts/live-browser.js @@ -7928,7 +7928,7 @@ void main() { const barTopFromBottom = barRect && barRect.height > 0 ? Math.max(16, window.innerHeight - barRect.top + 12) : 16; - toastEl = el('div', { + const currentToast = el('div', { position: 'fixed', bottom: barTopFromBottom + 'px', left: '50%', transform: 'translateX(-50%) translateY(8px)', background: C.ink, color: C.white, @@ -7938,19 +7938,24 @@ void main() { transition: 'opacity 0.25s ' + EASE + ', transform 0.25s ' + EASE, pointerEvents: 'none', maxWidth: '420px', textAlign: 'center', }); - toastEl.id = PREFIX + '-toast'; - toastEl.textContent = message; - uiAppend(toastEl); + toastEl = currentToast; + currentToast.id = PREFIX + '-toast'; + currentToast.textContent = message; + uiAppend(currentToast); requestAnimationFrame(() => { - toastEl.style.opacity = '1'; - toastEl.style.transform = 'translateX(-50%) translateY(0)'; + if (toastEl !== currentToast) return; + currentToast.style.opacity = '1'; + currentToast.style.transform = 'translateX(-50%) translateY(0)'; }); setTimeout(() => { - if (toastEl) { - toastEl.style.opacity = '0'; - toastEl.style.transform = 'translateX(-50%) translateY(8px)'; - setTimeout(() => { if (toastEl) { toastEl.remove(); toastEl = null; } }, 250); - } + if (toastEl !== currentToast) return; + currentToast.style.opacity = '0'; + currentToast.style.transform = 'translateX(-50%) translateY(8px)'; + setTimeout(() => { + if (toastEl !== currentToast) return; + currentToast.remove(); + toastEl = null; + }, 250); }, duration); } diff --git a/tests/live-browser-regression.test.mjs b/tests/live-browser-regression.test.mjs index e68e0768d..015abbc8e 100644 --- a/tests/live-browser-regression.test.mjs +++ b/tests/live-browser-regression.test.mjs @@ -528,6 +528,34 @@ describe('live-browser.js regression guards', () => { ); }); + it('toast enter and dismiss timers only touch the current toast element', () => { + assert.match( + SOURCE, + /function showToast\(message, duration\)[\s\S]{0,1200}?const currentToast = el\('div'/, + 'showToast must capture the created toast in a local so delayed callbacks cannot dereference or remove a stale global toastEl', + ); + assert.match( + SOURCE, + /requestAnimationFrame\(\(\) => \{[\s\S]{0,80}?if \(toastEl !== currentToast\) return;[\s\S]{0,120}?currentToast\.style\.opacity = '1';/, + 'toast enter rAF must no-op when dismissToast or a newer toast replaced toastEl before the frame fires', + ); + assert.match( + SOURCE, + /setTimeout\(\(\) => \{[\s\S]{0,80}?if \(toastEl !== currentToast\) return;[\s\S]{0,120}?currentToast\.style\.opacity = '0';/, + 'toast auto-dismiss timer must not animate a newer toast created after this timer was scheduled', + ); + assert.match( + SOURCE, + /setTimeout\(\(\) => \{[\s\S]{0,80}?if \(toastEl !== currentToast\) return;[\s\S]{0,80}?currentToast\.remove\(\);[\s\S]{0,80}?toastEl = null;/, + 'toast removal timer must only remove and clear the same toast it scheduled', + ); + assert.doesNotMatch( + SOURCE, + /requestAnimationFrame\(\(\) => \{\s*toastEl\.style/, + 'toast enter rAF must not read toastEl.style directly after dismissToast can null it', + ); + }); + it('insert mode UI and generate payload guards', () => { assert.match(SOURCE, /function toggleInsert\(\)/, 'global bar must expose insert toggle'); assert.match(SOURCE, /PREFIX \+ '-insert-toggle'/, 'insert toggle needs stable id');