Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions packages/page-practice/lib/practice/Presenter.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<FakeIntlProvider>
<FakeSettingsContext
initialSettings={new Settings()
.set(lessonProps.type, LessonType.CUSTOM)
.set(lessonProps.customText.content, "abcdefghij")}
>
<FakeResultContext initialResults={faker.nextResultList(0)}>
<PracticeScreen />
</FakeResultContext>
</FakeSettingsContext>
</FakeIntlProvider>,
);

// 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();
});
37 changes: 29 additions & 8 deletions packages/page-practice/lib/practice/Presenter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const propView = enumProp("prefs.practice.view", View, View.Normal);

export class Presenter extends PureComponent<Props, State> {
readonly focusRef = createRef<Focusable>();
#blurTimer: ReturnType<typeof setTimeout> | null = null;

override state: State = {
view: Preferences.get(propView),
Expand All @@ -72,6 +73,13 @@ export class Presenter extends PureComponent<Props, State> {
}
}

override componentWillUnmount() {
if (this.#blurTimer != null) {
clearTimeout(this.#blurTimer);
this.#blurTimer = null;
}
}

override render() {
const {
props: { state, lines, depressedKeys },
Expand Down Expand Up @@ -218,6 +226,15 @@ export class Presenter extends PureComponent<Props, State> {
};

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,
Expand All @@ -229,14 +246,18 @@ export class Presenter extends PureComponent<Props, State> {
};

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