diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md index ff3320e..ca81126 100644 --- a/docs/javascript-architecture.md +++ b/docs/javascript-architecture.md @@ -1423,6 +1423,12 @@ If one of these breaks, inspect this controller first. - `tickClockHelper(...)` - `handleSessionTimerTickHelper(...)` +Background rendering rule: +- `Timing`, `Dashboard`, and `Overlay` may rerender for live timing updates where explicitly allowed. +- decoder WebSocket `open`, `close`, and `error` events must not rerender `Settings`; they update the global connection badge instead. +- `app.js#renderViewFromBackground()` skips the one-time backend/voice refresh while a Settings control has focus or has changed. +- explicit Settings actions such as Save, Start AMMC, Stop AMMC, or Refresh may render after completing because the submitted values are already captured. + #### AMMC/backend control - `loadAmmcConfigFromBackendHelper(...)` - `saveAmmcConfigToBackendHelper(...)` @@ -2261,6 +2267,23 @@ Mitigation: - treat backend hydration and local dirty flags as runtime concerns - inspect `src/runtime_services.js` together with the affected save path +## 17.7 Settings forms replaced by decoder reconnect + +Observed failure: +- decoder IP/port fields and checkboxes reset while the operator was typing +- the operator had to enter and save values before the next refresh + +Pattern: +- an unreachable or unstable WebSocket triggers `error`, `close`, reconnect after two seconds, and `open/error` again +- each lifecycle event previously called full `renderView()` while `Settings` was active +- initial backend hydration or `voiceschanged` could also replace a newly focused form + +Mitigation: +- never include `settings` in decoder lifecycle rerender targets in `src/decoder_runtime.js` +- use `renderViewFromBackground()` for asynchronous app/bootstrap updates +- mark Settings as draft-active on `focusin`, `input`, and `change` +- keep a regression assertion in `tests/decoder_runtime.test.js` that `open/error/close` does not render Settings + ## 18. Automated Regression Tests Run: @@ -2313,6 +2336,12 @@ npm test - dirty-state protection when newer local edits occur during a request - failed-sync behavior without false version advancement +`tests/decoder_runtime.test.js` covers: +- split and pit roles never incrementing normal laps +- pit-entry attribution surviving an endurance driver/transponder change +- one WebSocket per enabled decoder role +- decoder lifecycle events not rerendering and replacing the Settings form + `tests/audio_runtime.test.js` covers: - explicit and language-based speech voice selection - practice-specific versus race-specific start/finish phrases diff --git a/src/app.js b/src/app.js index b3214ea..b7fa95f 100644 --- a/src/app.js +++ b/src/app.js @@ -2540,6 +2540,7 @@ let lastOverlayBestLapByKey = {}; let activeModalEscapeHandler = null; let settingsStorageNotice = ""; let settingsStorageNoticeIsError = false; +let settingsDraftActive = false; const backend = { available: false, lastSyncAt: null, @@ -2568,9 +2569,12 @@ init(); async function init() { document.body.classList.toggle("overlay-mode", overlayMode); + ["focusin", "input", "change"].forEach((eventName) => { + dom.view.addEventListener(eventName, markSettingsDraftActive); + }); if ("speechSynthesis" in window) { window.speechSynthesis.addEventListener("voiceschanged", () => { - if (currentView === "settings") renderView(); + if (currentView === "settings") renderViewFromBackground(); }); } seedDefaultData(); @@ -2587,7 +2591,7 @@ async function init() { Promise.allSettled([hydrateFromBackend(), loadAmmcConfigFromBackend()]).finally(() => { applyTheme(); renderNav(); - renderView(); + renderViewFromBackground(); setupLanguageControl(); updateHeaderState(); updateConnectionBadge(); @@ -2605,6 +2609,14 @@ async function init() { } } +function markSettingsDraftActive(event) { + if (currentView !== "settings") return; + const target = event.target; + if (target instanceof HTMLInputElement || target instanceof HTMLSelectElement || target instanceof HTMLTextAreaElement) { + settingsDraftActive = true; + } +} + function seedDefaultData() { if (!state.classes.length) { state.classes.push( @@ -3212,6 +3224,7 @@ function updateViewUrl(view, driverId = "", classId = "") { } function renderView() { + if (currentView === "settings") settingsDraftActive = false; clearModalEscapeHandler(); const navMeta = NAV_ITEMS.find((x) => x.id === currentView); dom.pageTitle.textContent = navMeta ? t(navMeta.titleKey) : ""; @@ -3322,6 +3335,11 @@ function renderView() { updateHeaderState(); } +function renderViewFromBackground() { + if (currentView === "settings" && settingsDraftActive) return; + renderView(); +} + function clearModalEscapeHandler() { if (activeModalEscapeHandler) { document.removeEventListener("keydown", activeModalEscapeHandler); diff --git a/src/decoder_runtime.js b/src/decoder_runtime.js index 0dac3d2..23657a2 100644 --- a/src/decoder_runtime.js +++ b/src/decoder_runtime.js @@ -162,7 +162,7 @@ function connectDecoderRole(role, deps) { state.decoder[lastErrorKey] = ""; saveState(); updateConnectionBadge(); - if (["timing", "settings", "overlay"].includes(getCurrentView())) { + if (["timing", "overlay"].includes(getCurrentView())) { renderView(); } }; @@ -207,7 +207,7 @@ function connectDecoderRole(role, deps) { }, 2000)); } - if (["timing", "settings", "overlay"].includes(getCurrentView())) { + if (["timing", "overlay"].includes(getCurrentView())) { renderView(); } }; @@ -216,7 +216,7 @@ function connectDecoderRole(role, deps) { state.decoder[lastErrorKey] = t("error.decoder_connection"); saveState(); updateConnectionBadge(); - if (["timing", "settings", "overlay"].includes(getCurrentView())) { + if (["timing", "overlay"].includes(getCurrentView())) { renderView(); } }; diff --git a/tests/decoder_runtime.test.js b/tests/decoder_runtime.test.js index 7f75d9c..a05e3e8 100644 --- a/tests/decoder_runtime.test.js +++ b/tests/decoder_runtime.test.js @@ -106,6 +106,7 @@ test("connect decoder opens one websocket per enabled role", () => { const clients = {}; const timers = {}; let disconnected = 0; + let renderCount = 0; const state = { settings: { wsUrl: "ws://server:9000", @@ -125,8 +126,8 @@ test("connect decoder opens one websocket per enabled role", () => { setWsClient: (role, client) => { clients[role] = client; }, getReconnectTimer: (role) => timers[role] || null, setReconnectTimer: (role, timer) => { timers[role] = timer; }, - getCurrentView: () => "dashboard", - renderView: () => {}, + getCurrentView: () => "settings", + renderView: () => { renderCount += 1; }, updateConnectionBadge: () => {}, processDecoderMessage: () => {}, disconnectDecoder: () => { disconnected += 1; }, @@ -138,6 +139,10 @@ test("connect decoder opens one websocket per enabled role", () => { pit_entry: "ws://server:9002", pit_exit: "ws://server:9003", }); + clients.main.onopen(); + clients.main.onerror(); + clients.main.onclose(); + assert.equal(renderCount, 0); } finally { globalThis.WebSocket = OriginalWebSocket; }