Fix live toast stale callback race (#271)

Co-authored-by: Jean-Claude <273834277+jjoanna2-debug@users.noreply.github.com>
This commit is contained in:
Jean-Claude
2026-07-03 19:15:20 -07:00
committed by GitHub
co-authored by Jean-Claude
parent 9798bb7235
commit 7501e67b55
2 changed files with 44 additions and 11 deletions
+16 -11
View File
@@ -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);
}
+28
View File
@@ -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');