From c709e395dba70b9ecf38352b0e1f8f723ca43878 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Wed, 22 Apr 2026 07:58:18 -0500 Subject: [PATCH 1/3] fix: Prevent stale draft restore after message send (#571) When a message is sent, clearElementValue clears the textarea and __liveDrafts. But on a Blazor re-render, restoreDraftsAndFocus could overwrite the empty textarea with a stale draft from draftBySession if the timing was wrong. Fix: Mark inputs as 'recently sent' via __recentlySentIds when clearElementValue runs. restoreDraftsAndFocus checks this flag and skips restoring non-empty drafts into recently-cleared inputs. The flag is one-shot (cleared after first skip) to allow future legitimate draft restores. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot/wwwroot/index.html | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/PolyPilot/wwwroot/index.html b/PolyPilot/wwwroot/index.html index 2b14de7e2..8cd1a4b2c 100644 --- a/PolyPilot/wwwroot/index.html +++ b/PolyPilot/wwwroot/index.html @@ -423,6 +423,9 @@ el.value = ''; if (window.__liveDrafts) delete window.__liveDrafts[elementId]; el.__lastRestoredDraft = ''; + // Mark this input as recently cleared by a send — prevents stale draft restore + window.__recentlySentIds = window.__recentlySentIds || Object.create(null); + window.__recentlySentIds[elementId] = true; el.style.height = 'auto'; // Remove slash command ghost overlay var ghost = document.getElementById(elementId + '--slash-ghost'); @@ -824,6 +827,15 @@ var desired = drafts[id] || ''; var current = (typeof el.value === 'string') ? el.value : ''; var lastRestored = (typeof el.__lastRestoredDraft === 'string') ? el.__lastRestoredDraft : ''; + + // Skip restore if this input was recently cleared by a send (prevents re-filling with stale draft) + if (window.__recentlySentIds && window.__recentlySentIds[id]) { + // Only skip for a limited window — clear the flag after first skip + delete window.__recentlySentIds[id]; + if (desired && !current) continue; // Don't restore stale text into a cleared input + } + + // Skip restore if user has typed diverged content since last restore var hasDivergedUserText = current.length > 0 && current !== desired && current !== lastRestored; if (hasDivergedUserText) continue; if (current !== desired) { From e1a0724e1955b28e12e5baade0777866f2ec65e8 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Wed, 22 Apr 2026 08:26:12 -0500 Subject: [PATCH 2/3] Fix flag-consumption ordering: delete inside continue branch Move the delete of __recentlySentIds[id] to only execute when the from being consumed prematurely on renders where the condition isn't true, leaving the textarea vulnerable to stale draft restore on the next re-render. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot/wwwroot/index.html | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/PolyPilot/wwwroot/index.html b/PolyPilot/wwwroot/index.html index 8cd1a4b2c..6290bc6e5 100644 --- a/PolyPilot/wwwroot/index.html +++ b/PolyPilot/wwwroot/index.html @@ -830,9 +830,13 @@ // Skip restore if this input was recently cleared by a send (prevents re-filling with stale draft) if (window.__recentlySentIds && window.__recentlySentIds[id]) { - // Only skip for a limited window — clear the flag after first skip + if (desired && !current) { + // Only consume the flag when we actually skip — protects against next re-render too + delete window.__recentlySentIds[id]; + continue; // Don't restore stale text into a cleared input + } + // If desired is empty or current has content, flag isn't needed — clean up delete window.__recentlySentIds[id]; - if (desired && !current) continue; // Don't restore stale text into a cleared input } // Skip restore if user has typed diverged content since last restore From 70a2beb3ad348f545a673ce3f8f7b871298ccd57 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Wed, 22 Apr 2026 08:36:18 -0500 Subject: [PATCH 3/3] Fix multi-render race and memory leak from 3-model review - Remove delete from skip branch: flag now survives across multiple stale re-renders until C# draft clears or user types (closes the race window where render #2 restores stale text) - Store Date.now() instead of true, sweep entries >5s old at the top of restoreDraftsAndFocus to prevent unbounded growth from session switches Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot/wwwroot/index.html | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/PolyPilot/wwwroot/index.html b/PolyPilot/wwwroot/index.html index 6290bc6e5..b9cf2b8f2 100644 --- a/PolyPilot/wwwroot/index.html +++ b/PolyPilot/wwwroot/index.html @@ -423,9 +423,9 @@ el.value = ''; if (window.__liveDrafts) delete window.__liveDrafts[elementId]; el.__lastRestoredDraft = ''; - // Mark this input as recently cleared by a send — prevents stale draft restore + // Mark this input as recently cleared — prevents stale draft restore on re-render window.__recentlySentIds = window.__recentlySentIds || Object.create(null); - window.__recentlySentIds[elementId] = true; + window.__recentlySentIds[elementId] = Date.now(); el.style.height = 'auto'; // Remove slash command ghost overlay var ghost = document.getElementById(elementId + '--slash-ghost'); @@ -821,6 +821,13 @@ } } } + // Sweep stale __recentlySentIds entries (5s TTL prevents unbounded growth) + if (window.__recentlySentIds) { + var now = Date.now(); + for (var k in window.__recentlySentIds) { + if (now - window.__recentlySentIds[k] > 5000) delete window.__recentlySentIds[k]; + } + } for (var id in drafts) { var el = document.getElementById(id); if (!el) continue; @@ -828,14 +835,12 @@ var current = (typeof el.value === 'string') ? el.value : ''; var lastRestored = (typeof el.__lastRestoredDraft === 'string') ? el.__lastRestoredDraft : ''; - // Skip restore if this input was recently cleared by a send (prevents re-filling with stale draft) + // Skip restore if this input was recently cleared (prevents re-filling with stale draft) if (window.__recentlySentIds && window.__recentlySentIds[id]) { if (desired && !current) { - // Only consume the flag when we actually skip — protects against next re-render too - delete window.__recentlySentIds[id]; - continue; // Don't restore stale text into a cleared input + continue; // Don't restore stale text into a cleared input (flag survives for next re-render) } - // If desired is empty or current has content, flag isn't needed — clean up + // Danger window over: desired is empty or user has typed — flag no longer needed delete window.__recentlySentIds[id]; }