From 02e92d0f3b191eec7916f1759e48a1a2b5d766b0 Mon Sep 17 00:00:00 2001 From: Miodec Date: Mon, 22 Sep 2025 13:10:50 +0200 Subject: [PATCH 1/2] impr: add debounced animation frame util !nuf --- .../src/ts/utils/debounced-animation-frame.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 frontend/src/ts/utils/debounced-animation-frame.ts diff --git a/frontend/src/ts/utils/debounced-animation-frame.ts b/frontend/src/ts/utils/debounced-animation-frame.ts new file mode 100644 index 000000000000..afb60c3e9c33 --- /dev/null +++ b/frontend/src/ts/utils/debounced-animation-frame.ts @@ -0,0 +1,21 @@ +const pendingFrames = new Map(); + +export function requestDebouncedAnimationFrame( + frameId: string, + callback: () => void +): void { + cancelIfPending(frameId); + const frame = requestAnimationFrame(() => { + pendingFrames.delete(frameId); + callback(); + }); + pendingFrames.set(frameId, frame); +} + +function cancelIfPending(frameId: string): void { + const pending = pendingFrames.get(frameId); + if (pending !== undefined) { + cancelAnimationFrame(pending); + pendingFrames.delete(frameId); + } +} From 1bf03e8dc26b0742a59032b1bd9b9ce28dccb019 Mon Sep 17 00:00:00 2001 From: Miodec Date: Mon, 22 Sep 2025 13:13:01 +0200 Subject: [PATCH 2/2] refactor: use new util function --- frontend/src/ts/test/focus.ts | 41 ++++++++++++++++----------------- frontend/src/ts/test/test-ui.ts | 9 +++----- 2 files changed, 23 insertions(+), 27 deletions(-) diff --git a/frontend/src/ts/test/focus.ts b/frontend/src/ts/test/focus.ts index 35b655bcabad..7b9842e2f124 100644 --- a/frontend/src/ts/test/focus.ts +++ b/frontend/src/ts/test/focus.ts @@ -4,6 +4,7 @@ import * as LiveBurst from "./live-burst"; import * as LiveAcc from "./live-acc"; import * as TimerProgress from "./timer-progress"; import * as PageTransition from "../states/page-transition"; +import { requestDebouncedAnimationFrame } from "../utils/debounced-animation-frame"; const unfocusPx = 3; let state = false; @@ -41,13 +42,13 @@ function initializeCache(): void { // with cursor is a special case that is only used on the initial page load // to avoid the cursor being invisible and confusing the user export function set(value: boolean, withCursor = false): void { - initializeCache(); + requestDebouncedAnimationFrame("focus.set", () => { + initializeCache(); - if (value && !state) { - state = true; + if (value && !state) { + state = true; - // batch DOM operations for better performance - requestAnimationFrame(() => { + // batch DOM operations for better performance if (cache.focus) { for (const el of cache.focus) { el.classList.add("focus"); @@ -58,17 +59,15 @@ export function set(value: boolean, withCursor = false): void { el.style.cursor = "none"; } } - }); - Caret.stopAnimation(); - LiveSpeed.show(); - LiveBurst.show(); - LiveAcc.show(); - TimerProgress.show(); - } else if (!value && state) { - state = false; + Caret.stopAnimation(); + LiveSpeed.show(); + LiveBurst.show(); + LiveAcc.show(); + TimerProgress.show(); + } else if (!value && state) { + state = false; - requestAnimationFrame(() => { if (cache.focus) { for (const el of cache.focus) { el.classList.remove("focus"); @@ -79,14 +78,14 @@ export function set(value: boolean, withCursor = false): void { el.style.cursor = ""; } } - }); - Caret.startAnimation(); - LiveSpeed.hide(); - LiveBurst.hide(); - LiveAcc.hide(); - TimerProgress.hide(); - } + Caret.startAnimation(); + LiveSpeed.hide(); + LiveBurst.hide(); + LiveAcc.hide(); + TimerProgress.hide(); + } + }); } $(document).on("mousemove", function (event) { diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index 0e3f478b2893..778c5711f030 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -23,6 +23,7 @@ import { TimerColor, TimerOpacity } from "@monkeytype/schemas/configs"; import { convertRemToPixels } from "../utils/numbers"; import { findSingleActiveFunboxWithFunction } from "./funbox/list"; import * as TestState from "./test-state"; +import { requestDebouncedAnimationFrame } from "../utils/debounced-animation-frame"; const debouncedZipfCheck = debounce(250, async () => { const supports = await JSONData.checkIfLanguageSupportsZipf(Config.language); @@ -491,13 +492,9 @@ export function appendEmptyWordElement( `
` ); } -let updateWordsInputPositionAnimationFrameId: null | number = null; + export function updateWordsInputPosition(): void { - if (updateWordsInputPositionAnimationFrameId !== null) { - cancelAnimationFrame(updateWordsInputPositionAnimationFrameId); - } - updateWordsInputPositionAnimationFrameId = requestAnimationFrame(() => { - updateWordsInputPositionAnimationFrameId = null; + requestDebouncedAnimationFrame("test-ui.updateWordsInputPosition", () => { if (ActivePage.get() !== "test") return; const isTestRightToLeft = TestState.isDirectionReversed ? !TestState.isLanguageRightToLeft