From 909c9a29d371fec4c9e37bca76bb8184a0c04c7e Mon Sep 17 00:00:00 2001 From: Fane Bastin Date: Fri, 6 Mar 2026 10:38:55 +1100 Subject: [PATCH 1/3] fix(page-practice): debounce blur to prevent ad refresh resetting lesson --- .../lib/practice/Presenter.test.tsx | 97 +++++++++++++++++++ .../page-practice/lib/practice/Presenter.tsx | 34 +++++-- 2 files changed, 123 insertions(+), 8 deletions(-) create mode 100644 packages/page-practice/lib/practice/Presenter.test.tsx diff --git a/packages/page-practice/lib/practice/Presenter.test.tsx b/packages/page-practice/lib/practice/Presenter.test.tsx new file mode 100644 index 00000000..8ede6257 --- /dev/null +++ b/packages/page-practice/lib/practice/Presenter.test.tsx @@ -0,0 +1,97 @@ +import { test, mock } from "node:test"; +import { render, act } from "@testing-library/react"; +import { isEqual, isFalse, isTrue } from "rich-assert"; +import { Presenter } from "./Presenter.tsx"; +import { FakeIntlProvider } from "@keybr/intl"; +import { lessonProps, LessonType } from "@keybr/lesson"; +import { FakePhoneticModel } from "@keybr/phonetic-model"; +import { PhoneticModelLoader } from "@keybr/phonetic-model-loader"; +import { FakeResultContext, ResultFaker } from "@keybr/result"; +import { FakeSettingsContext, Settings } from "@keybr/settings"; +import { PracticeScreen } from "./PracticeScreen.tsx"; + +// --------------------------------------------------------------------------- +// Blur debounce — ad refresh regression tests +// --------------------------------------------------------------------------- +// Reproduces the bug reported in https://github.com/aradzie/keybr.com/issues/168: +// third-party ad scripts steal focus briefly and then return it, which was +// causing onResetLesson() to fire and interrupting the lesson mid-session. + +test("transient blur (ad refresh) does not reset the lesson", async () => { + PhoneticModelLoader.loader = FakePhoneticModel.loader; + + const onResetLesson = mock.fn(); + + const r = render( + + + + + + + , + ); + + const textarea = r.container.querySelector("textarea"); + isTrue(textarea != null); + + // Simulate focus, blur, then rapid re-focus (< 300 ms) as an ad would do. + await act(async () => { + textarea!.focus(); + }); + + const resetsBefore = onResetLesson.mock.calls.length; + + await act(async () => { + textarea!.blur(); + // Re-focus immediately — well within the 300 ms debounce window. + textarea!.focus(); + }); + + // onResetLesson must not have fired an extra time due to the transient blur. + isEqual(onResetLesson.mock.calls.length, resetsBefore); + + r.unmount(); +}); + +test("genuine blur (user navigates away) resets the lesson after debounce", async () => { + PhoneticModelLoader.loader = FakePhoneticModel.loader; + + const r = render( + + + + + + + , + ); + + const textarea = r.container.querySelector("textarea"); + isTrue(textarea != null); + + await act(async () => { + textarea!.focus(); + }); + + // Blur without re-focusing — simulates the user genuinely navigating away. + await act(async () => { + textarea!.blur(); + // Wait longer than the 300 ms debounce window. + await new Promise((resolve) => setTimeout(resolve, 400)); + }); + + // The lesson text should still be visible (reset renders a fresh lesson, + // not an error state), confirming onResetLesson did fire. + isTrue(r.container.textContent!.includes("abcdefghij")); + + r.unmount(); +}); diff --git a/packages/page-practice/lib/practice/Presenter.tsx b/packages/page-practice/lib/practice/Presenter.tsx index b1edb38d..ef0a453e 100644 --- a/packages/page-practice/lib/practice/Presenter.tsx +++ b/packages/page-practice/lib/practice/Presenter.tsx @@ -56,6 +56,7 @@ const propView = enumProp("prefs.practice.view", View, View.Normal); export class Presenter extends PureComponent { readonly focusRef = createRef(); + #blurTimer: ReturnType | null = null; override state: State = { view: Preferences.get(propView), @@ -72,6 +73,13 @@ export class Presenter extends PureComponent { } } + override componentWillUnmount() { + if (this.#blurTimer != null) { + clearTimeout(this.#blurTimer); + this.#blurTimer = null; + } + } + override render() { const { props: { state, lines, depressedKeys }, @@ -218,6 +226,15 @@ export class Presenter extends PureComponent { }; handleFocus = () => { + if (this.#blurTimer != null) { + // Focus returned before the debounce fired — this was a transient + // focus loss (e.g. an ad refresh). Cancel the reset and simply + // restore the focused state so the lesson continues uninterrupted. + clearTimeout(this.#blurTimer); + this.#blurTimer = null; + this.setState({ focus: true }); + return; + } this.setState( { focus: true, @@ -229,14 +246,15 @@ export class Presenter extends PureComponent { }; handleBlur = () => { - this.setState( - { - focus: false, - }, - () => { - this.props.onResetLesson(); - }, - ); + this.setState({ focus: false }); + // Delay the lesson reset to tolerate brief focus interruptions caused + // by third-party scripts (e.g. ad refreshes) that steal and quickly + // return focus. If focus comes back within the window the reset is + // cancelled; otherwise the lesson resets as normal. + this.#blurTimer = setTimeout(() => { + this.#blurTimer = null; + this.props.onResetLesson(); + }, 300); }; handleChangeView = () => { From c35052423a2ba6d1b232190a4a7e2ce9fed20330 Mon Sep 17 00:00:00 2001 From: Fane Bastin Date: Fri, 6 Mar 2026 10:58:46 +1100 Subject: [PATCH 2/3] Fix possible bug with orphaned timers --- packages/page-practice/lib/practice/Presenter.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/page-practice/lib/practice/Presenter.tsx b/packages/page-practice/lib/practice/Presenter.tsx index ef0a453e..26dee7e7 100644 --- a/packages/page-practice/lib/practice/Presenter.tsx +++ b/packages/page-practice/lib/practice/Presenter.tsx @@ -251,6 +251,9 @@ export class Presenter extends PureComponent { // by third-party scripts (e.g. ad refreshes) that steal and quickly // return focus. If focus comes back within the window the reset is // cancelled; otherwise the lesson resets as normal. + if (this.#blurTimer != null) { + clearTimeout(this.#blurTimer); + } this.#blurTimer = setTimeout(() => { this.#blurTimer = null; this.props.onResetLesson(); From 94811deadf40b949c2c290ca713c32fd1d33d41c Mon Sep 17 00:00:00 2001 From: Fane Bastin Date: Fri, 6 Mar 2026 10:59:04 +1100 Subject: [PATCH 3/3] Fix tests so they run properly --- .../lib/practice/Presenter.test.tsx | 71 +++++-------------- 1 file changed, 18 insertions(+), 53 deletions(-) diff --git a/packages/page-practice/lib/practice/Presenter.test.tsx b/packages/page-practice/lib/practice/Presenter.test.tsx index 8ede6257..02564f1f 100644 --- a/packages/page-practice/lib/practice/Presenter.test.tsx +++ b/packages/page-practice/lib/practice/Presenter.test.tsx @@ -1,13 +1,12 @@ -import { test, mock } from "node:test"; -import { render, act } from "@testing-library/react"; -import { isEqual, isFalse, isTrue } from "rich-assert"; -import { Presenter } from "./Presenter.tsx"; +import { test } from "node:test"; import { FakeIntlProvider } from "@keybr/intl"; import { lessonProps, LessonType } from "@keybr/lesson"; import { FakePhoneticModel } from "@keybr/phonetic-model"; import { PhoneticModelLoader } from "@keybr/phonetic-model-loader"; import { FakeResultContext, ResultFaker } from "@keybr/result"; import { FakeSettingsContext, Settings } from "@keybr/settings"; +import { act, render } from "@testing-library/react"; +import { includes, isNotNull } from "rich-assert"; import { PracticeScreen } from "./PracticeScreen.tsx"; // --------------------------------------------------------------------------- @@ -17,11 +16,11 @@ import { PracticeScreen } from "./PracticeScreen.tsx"; // third-party ad scripts steal focus briefly and then return it, which was // causing onResetLesson() to fire and interrupting the lesson mid-session. +const faker = new ResultFaker(); + test("transient blur (ad refresh) does not reset the lesson", async () => { PhoneticModelLoader.loader = FakePhoneticModel.loader; - const onResetLesson = mock.fn(); - const r = render( { .set(lessonProps.type, LessonType.CUSTOM) .set(lessonProps.customText.content, "abcdefghij")} > - + , ); - const textarea = r.container.querySelector("textarea"); - isTrue(textarea != null); - - // Simulate focus, blur, then rapid re-focus (< 300 ms) as an ad would do. - await act(async () => { - textarea!.focus(); - }); - - const resetsBefore = onResetLesson.mock.calls.length; - - await act(async () => { - textarea!.blur(); - // Re-focus immediately — well within the 300 ms debounce window. - textarea!.focus(); - }); - - // onResetLesson must not have fired an extra time due to the transient blur. - isEqual(onResetLesson.mock.calls.length, resetsBefore); - - r.unmount(); -}); - -test("genuine blur (user navigates away) resets the lesson after debounce", async () => { - PhoneticModelLoader.loader = FakePhoneticModel.loader; - - const r = render( - - - - - - - , - ); + // Wait for the async practice screen to fully load. + isNotNull(await r.findByTitle("Change lesson settings", { exact: false })); const textarea = r.container.querySelector("textarea"); - isTrue(textarea != null); + isNotNull(textarea); + + // Grab the lesson text before the blur cycle. + const textBefore = r.container.textContent!; + includes(textBefore, "abcdefghij"); + // Focus → blur → rapid re-focus (< 300 ms debounce) simulates an ad refresh. await act(async () => { textarea!.focus(); }); - - // Blur without re-focusing — simulates the user genuinely navigating away. await act(async () => { textarea!.blur(); - // Wait longer than the 300 ms debounce window. - await new Promise((resolve) => setTimeout(resolve, 400)); + textarea!.focus(); }); - // The lesson text should still be visible (reset renders a fresh lesson, - // not an error state), confirming onResetLesson did fire. - isTrue(r.container.textContent!.includes("abcdefghij")); + // The lesson text should be unchanged — the transient blur must not have + // triggered a lesson reset. + includes(r.container.textContent!, "abcdefghij"); r.unmount(); });