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 = () => {