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..02564f1f
--- /dev/null
+++ b/packages/page-practice/lib/practice/Presenter.test.tsx
@@ -0,0 +1,62 @@
+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";
+
+// ---------------------------------------------------------------------------
+// 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.
+
+const faker = new ResultFaker();
+
+test("transient blur (ad refresh) does not reset the lesson", 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");
+ 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();
+ });
+ await act(async () => {
+ textarea!.blur();
+ textarea!.focus();
+ });
+
+ // The lesson text should be unchanged — the transient blur must not have
+ // triggered a lesson reset.
+ includes(r.container.textContent!, "abcdefghij");
+
+ r.unmount();
+});
diff --git a/packages/page-practice/lib/practice/Presenter.tsx b/packages/page-practice/lib/practice/Presenter.tsx
index b1edb38d..26dee7e7 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,18 @@ 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.
+ if (this.#blurTimer != null) {
+ clearTimeout(this.#blurTimer);
+ }
+ this.#blurTimer = setTimeout(() => {
+ this.#blurTimer = null;
+ this.props.onResetLesson();
+ }, 300);
};
handleChangeView = () => {