From 41241dd1fd7a04d5531e4bf0acf66a6683ad7092 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Tue, 9 Jun 2026 12:30:01 +0200 Subject: [PATCH] fix(editor): leading-edge fire + max-wait cap on Monaco debounce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Editor debounced `onDidChangeModelContent` purely on the trailing edge — every keystroke rescheduled a 500ms timer, and uninterrupted typing held the bindable `code` prop stale until a pause. Stacked behind our 1.5s autosave debouncer that meant our clock didn't even start ticking until 500ms after the user paused, and the `code` binding never updated mid-burst for downstream consumers (lint, live preview, change listeners). Switch to leading + trailing + max-wait: * First keystroke of a burst fires `updateCode` synchronously, then stamps a wall-clock chain start. * Each subsequent keystroke (re)arms a trailing timer at `min(now + changeTimeout, chainStart + maxChangeTimeout)` — the cap is what makes continuous typing materialize at least once per maxChangeTimeout window instead of indefinitely. * When the trailing fires it resets the chain so the next keystroke after a pause is a fresh leading fire. New prop `maxChangeTimeout` (default 1000ms) sits next to the existing `changeTimeout` (default 500ms). Dispose path clears the chain stamp alongside the timer. --- frontend/src/lib/components/Editor.svelte | 41 +++++++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/components/Editor.svelte b/frontend/src/lib/components/Editor.svelte index 698e799aad..6e409b3365 100644 --- a/frontend/src/lib/components/Editor.svelte +++ b/frontend/src/lib/components/Editor.svelte @@ -136,7 +136,17 @@ lineNumbersMinChars?: number files?: Record | undefined extraLib?: string | undefined + /** Trailing debounce window (ms) on Monaco's onDidChangeModelContent. + * Each keystroke schedules (or reschedules) an `updateCode` call this + * far in the future. */ changeTimeout?: number + /** Hard ceiling (ms) on how long `updateCode` can be deferred while + * the user is typing continuously — measured from the FIRST + * keystroke of the burst (the leading fire). Without this cap, + * uninterrupted typing would hold the bindable `code` prop stale + * indefinitely and downstream consumers (autosave, lint, live + * preview) would never see the latest text. */ + maxChangeTimeout?: number loadAsync?: boolean key?: string | undefined class?: string | undefined @@ -177,6 +187,7 @@ files = {}, extraLib = undefined, changeTimeout = 500, + maxChangeTimeout = 1000, loadAsync = false, key = undefined, class: clazz = undefined, @@ -1327,6 +1338,10 @@ } let timeoutModel: number | undefined = undefined + /** Wall-clock start (ms) of the current debounce chain. Reset whenever + * the trailing fire lands — so a typing burst → pause → typing burst + * gets a fresh leading fire instead of inheriting the previous cap. */ + let changeChainStart: number | undefined = undefined async function loadMonaco() { setMonacoTypescriptOptions() console.log('path', uri) @@ -1433,10 +1448,29 @@ let ataModel: number | undefined = undefined editor?.onDidChangeModelContent((event) => { - timeoutModel && clearTimeout(timeoutModel) - timeoutModel = setTimeout(() => { + // Leading fire on the first keystroke of a burst: every + // downstream consumer (autosave's 1.5s debouncer, the + // `bind:code` chain, change listeners) sees text within the + // same tick instead of after `changeTimeout` ms of silence. + // Subsequent keystrokes within the burst are trailing-only + // (debounced by `changeTimeout`), with a hard ceiling at + // `chainStart + maxChangeTimeout` so continuous typing still + // materializes at least once per `maxChangeTimeout` window. + const now = Date.now() + if (changeChainStart === undefined) { updateCode() - }, changeTimeout) + changeChainStart = now + } + timeoutModel && clearTimeout(timeoutModel) + const fireAt = Math.min(now + changeTimeout, changeChainStart + maxChangeTimeout) + timeoutModel = setTimeout( + () => { + updateCode() + timeoutModel = undefined + changeChainStart = undefined + }, + Math.max(0, fireAt - now) + ) ataModel && clearTimeout(ataModel) ataModel = setTimeout(() => { @@ -1788,6 +1822,7 @@ resultCollectionCompletor && resultCollectionCompletor.dispose() preprocessorCompletor && preprocessorCompletor.dispose() timeoutModel && clearTimeout(timeoutModel) + changeChainStart = undefined loadTimeout && clearTimeout(loadTimeout) aiChatEditorHandler?.clear() absolutePathExtraLibs.forEach((d) => d.dispose())