fix(editor): leading-edge fire + max-wait cap on Monaco debounce

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.
This commit is contained in:
Diego Imbert
2026-06-09 12:30:01 +02:00
parent cd746e366f
commit 41241dd1fd
+38 -3
View File
@@ -136,7 +136,17 @@
lineNumbersMinChars?: number
files?: Record<string, { code: string; readonly?: boolean }> | 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())