From f5fe3ded357dc985b6b5d52387f072a947596465 Mon Sep 17 00:00:00 2001 From: Jack Date: Wed, 18 Mar 2026 22:29:17 +0100 Subject: [PATCH 1/7] refactor: convert observables to events (@miodec) (#7680) Convert observables to events using a new hook which supports vanilla and solid. Replace existing hook with this new universal one. Convert star imports to named imports. Update all usage. --- frontend/__tests__/hooks/createEvent.spec.ts | 95 +++++++++++++------ frontend/__tests__/root/config.spec.ts | 4 +- frontend/src/ts/auth.tsx | 8 +- frontend/src/ts/commandline/lists/themes.ts | 4 +- .../components/layout/header/AccountXpBar.tsx | 23 +++-- .../src/ts/components/layout/header/Logo.tsx | 4 +- .../src/ts/components/layout/header/Nav.tsx | 4 +- .../pages/profile/ProfileSearchPage.tsx | 7 +- frontend/src/ts/config/lifecycle.ts | 6 +- frontend/src/ts/config/setters.ts | 6 +- frontend/src/ts/controllers/ad-controller.ts | 4 +- .../ts/controllers/challenge-controller.ts | 4 +- .../src/ts/controllers/chart-controller.ts | 4 +- .../src/ts/controllers/quotes-controller.ts | 4 +- .../src/ts/controllers/route-controller.ts | 16 ++-- .../src/ts/controllers/sound-controller.ts | 4 +- frontend/src/ts/controllers/tag-controller.ts | 4 +- .../src/ts/controllers/theme-controller.ts | 4 +- frontend/src/ts/controllers/url-handler.tsx | 4 +- frontend/src/ts/db.ts | 6 +- .../src/ts/elements/account/result-filters.ts | 4 +- .../ts/elements/custom-background-filter.ts | 4 +- frontend/src/ts/elements/keymap.ts | 8 +- frontend/src/ts/elements/modes-notice.ts | 6 +- frontend/src/ts/elements/psa.tsx | 4 +- .../src/ts/elements/settings/theme-picker.ts | 4 +- frontend/src/ts/events/auth.ts | 11 +++ frontend/src/ts/events/config.ts | 18 ++++ frontend/src/ts/events/connection.ts | 12 +++ frontend/src/ts/events/google-sign-up.ts | 9 ++ frontend/src/ts/events/keymap.ts | 17 ++++ frontend/src/ts/events/navigation.ts | 17 ++++ frontend/src/ts/events/timer.ts | 9 ++ frontend/src/ts/events/tts.ts | 3 + frontend/src/ts/firebase.ts | 4 +- frontend/src/ts/hooks/createEvent.ts | 68 ++++++++----- frontend/src/ts/input/handlers/insert-text.ts | 4 +- frontend/src/ts/legacy-states/connection.ts | 4 +- frontend/src/ts/modals/google-sign-up.ts | 4 +- .../src/ts/modals/last-signed-out-result.ts | 4 +- frontend/src/ts/observables/auth-event.ts | 26 ----- frontend/src/ts/observables/config-event.ts | 34 ------- .../src/ts/observables/connection-event.ts | 27 ------ .../ts/observables/google-sign-up-event.ts | 26 ----- frontend/src/ts/observables/keymap-event.ts | 33 ------- .../src/ts/observables/navigation-event.ts | 28 ------ frontend/src/ts/observables/timer-event.ts | 18 ---- frontend/src/ts/observables/tts-event.ts | 18 ---- frontend/src/ts/pages/account-settings.ts | 4 +- frontend/src/ts/pages/account.ts | 4 +- frontend/src/ts/pages/friends.ts | 4 +- frontend/src/ts/pages/settings.ts | 10 +- frontend/src/ts/states/core.ts | 2 +- frontend/src/ts/states/header.ts | 2 +- frontend/src/ts/test/caret.ts | 4 +- .../src/ts/test/funbox/funbox-functions.ts | 8 +- frontend/src/ts/test/funbox/funbox.ts | 4 +- frontend/src/ts/test/live-acc.ts | 4 +- frontend/src/ts/test/live-burst.ts | 4 +- frontend/src/ts/test/live-speed.ts | 4 +- frontend/src/ts/test/monkey.ts | 4 +- frontend/src/ts/test/pace-caret.ts | 4 +- frontend/src/ts/test/practise-words.ts | 4 +- frontend/src/ts/test/result.ts | 4 +- frontend/src/ts/test/test-config.ts | 8 +- frontend/src/ts/test/test-logic.ts | 21 ++-- frontend/src/ts/test/test-timer.ts | 14 +-- frontend/src/ts/test/test-ui.ts | 12 +-- frontend/src/ts/test/timer-progress.ts | 4 +- frontend/src/ts/test/tts.ts | 8 +- frontend/src/ts/ui.ts | 4 +- 71 files changed, 364 insertions(+), 416 deletions(-) create mode 100644 frontend/src/ts/events/auth.ts create mode 100644 frontend/src/ts/events/config.ts create mode 100644 frontend/src/ts/events/connection.ts create mode 100644 frontend/src/ts/events/google-sign-up.ts create mode 100644 frontend/src/ts/events/keymap.ts create mode 100644 frontend/src/ts/events/navigation.ts create mode 100644 frontend/src/ts/events/timer.ts create mode 100644 frontend/src/ts/events/tts.ts delete mode 100644 frontend/src/ts/observables/auth-event.ts delete mode 100644 frontend/src/ts/observables/config-event.ts delete mode 100644 frontend/src/ts/observables/connection-event.ts delete mode 100644 frontend/src/ts/observables/google-sign-up-event.ts delete mode 100644 frontend/src/ts/observables/keymap-event.ts delete mode 100644 frontend/src/ts/observables/navigation-event.ts delete mode 100644 frontend/src/ts/observables/timer-event.ts delete mode 100644 frontend/src/ts/observables/tts-event.ts diff --git a/frontend/__tests__/hooks/createEvent.spec.ts b/frontend/__tests__/hooks/createEvent.spec.ts index af5a28518a2f..d4455d0f128f 100644 --- a/frontend/__tests__/hooks/createEvent.spec.ts +++ b/frontend/__tests__/hooks/createEvent.spec.ts @@ -1,46 +1,81 @@ import { createRoot } from "solid-js"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createEvent } from "../../src/ts/hooks/createEvent"; describe("createEvent", () => { - it("initial value is 0", () => { - createRoot((dispose) => { - const [event] = createEvent(); - expect(event()).toBe(0); - dispose(); - }); + it("dispatch notifies subscribers", () => { + const event = createEvent(); + const fn = vi.fn(); + event.subscribe(fn); + event.dispatch("hello"); + expect(fn).toHaveBeenCalledWith("hello"); }); - it("dispatch increments the value by 1", () => { - createRoot((dispose) => { - const [event, dispatch] = createEvent(); - dispatch(); - expect(event()).toBe(1); - dispose(); - }); + it("dispatch notifies multiple subscribers", () => { + const event = createEvent(); + const fn1 = vi.fn(); + const fn2 = vi.fn(); + event.subscribe(fn1); + event.subscribe(fn2); + event.dispatch(42); + expect(fn1).toHaveBeenCalledWith(42); + expect(fn2).toHaveBeenCalledWith(42); }); - it("each dispatch increments independently", () => { - createRoot((dispose) => { - const [event, dispatch] = createEvent(); - dispatch(); - dispatch(); - dispatch(); - expect(event()).toBe(3); - dispose(); - }); + it("dispatch with no type arg requires no arguments", () => { + const event = createEvent(); + const fn = vi.fn(); + event.subscribe(fn); + event.dispatch(); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("subscribe returns an unsubscribe function", () => { + const event = createEvent(); + const fn = vi.fn(); + const unsub = event.subscribe(fn); + event.dispatch("a"); + unsub(); + event.dispatch("b"); + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalledWith("a"); }); it("two independent events do not share state", () => { + const eventA = createEvent(); + const eventB = createEvent(); + const fnA = vi.fn(); + const fnB = vi.fn(); + eventA.subscribe(fnA); + eventB.subscribe(fnB); + eventA.dispatch("a"); + expect(fnA).toHaveBeenCalledWith("a"); + expect(fnB).not.toHaveBeenCalled(); + }); + + it("useListener auto-unsubscribes on dispose", () => { + const event = createEvent(); + const fn = vi.fn(); createRoot((dispose) => { - const [eventA, dispatchA] = createEvent(); - const [eventB, dispatchB] = createEvent(); - dispatchA(); - dispatchA(); - dispatchB(); - expect(eventA()).toBe(2); - expect(eventB()).toBe(1); + event.useListener(fn); + event.dispatch("inside"); dispose(); }); + event.dispatch("outside"); + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalledWith("inside"); + }); + + it("subscriber errors do not prevent other subscribers from running", () => { + const event = createEvent(); + const fn1 = vi.fn(() => { + throw new Error("oops"); + }); + const fn2 = vi.fn(); + event.subscribe(fn1); + event.subscribe(fn2); + event.dispatch("test"); + expect(fn1).toHaveBeenCalled(); + expect(fn2).toHaveBeenCalled(); }); }); diff --git a/frontend/__tests__/root/config.spec.ts b/frontend/__tests__/root/config.spec.ts index 0f89fb6aa4d6..d8c881e5588f 100644 --- a/frontend/__tests__/root/config.spec.ts +++ b/frontend/__tests__/root/config.spec.ts @@ -12,7 +12,7 @@ import { } from "@monkeytype/schemas/configs"; import * as FunboxValidation from "../../src/ts/config/funbox-validation"; import * as ConfigValidation from "../../src/ts/config/validation"; -import * as ConfigEvent from "../../src/ts/observables/config-event"; +import { configEvent } from "../../src/ts/events/config"; import * as ApeConfig from "../../src/ts/ape/config"; import * as Notifications from "../../src/ts/states/notifications"; const { replaceConfig, getConfig } = __testing; @@ -33,7 +33,7 @@ describe("Config", () => { ConfigValidation, "isConfigValueValid", ); - const dispatchConfigEventMock = vi.spyOn(ConfigEvent, "dispatch"); + const dispatchConfigEventMock = vi.spyOn(configEvent, "dispatch"); const dbSaveConfigMock = vi.spyOn(ApeConfig, "saveConfig"); const notificationAddMock = vi.spyOn( Notifications, diff --git a/frontend/src/ts/auth.tsx b/frontend/src/ts/auth.tsx index 5fc6c39b58d5..39d411e64fd3 100644 --- a/frontend/src/ts/auth.tsx +++ b/frontend/src/ts/auth.tsx @@ -12,6 +12,7 @@ import Ape from "./ape"; import { showRegisterCaptchaModal } from "./components/modals/RegisterCaptchaModal"; import { updateFromServer as updateConfigFromServer } from "./config/remote"; import * as DB from "./db"; +import { authEvent } from "./events/auth"; import { isAuthAvailable, getAuthenticatedUser, @@ -23,7 +24,6 @@ import { resetIgnoreAuthCallback, } from "./firebase"; import { showPopup } from "./modals/simple-modals-base"; -import * as AuthEvent from "./observables/auth-event"; import * as Sentry from "./sentry"; import { addBanner } from "./states/banners"; import { showLoaderBar, hideLoaderBar } from "./states/loader-bar"; @@ -131,7 +131,7 @@ export async function loadUser(_user: UserType): Promise { signOut(); return; } - AuthEvent.dispatch({ type: "snapshotUpdated", data: { isInitial: true } }); + authEvent.dispatch({ type: "snapshotUpdated", data: { isInitial: true } }); } export async function onAuthStateChanged( @@ -155,7 +155,7 @@ export async function onAuthStateChanged( void Sentry.clearUser(); } - AuthEvent.dispatch({ + authEvent.dispatch({ type: "authStateChanged", data: { isUserSignedIn: user !== null, loadPromise: userPromise }, }); @@ -231,7 +231,7 @@ async function addAuthProvider( await linkWithPopup(user, provider); hideLoaderBar(); showSuccessNotification(`${providerName} authentication added`); - AuthEvent.dispatch({ type: "authConfigUpdated" }); + authEvent.dispatch({ type: "authConfigUpdated" }); } catch (error) { hideLoaderBar(); showErrorNotification(`Failed to add ${providerName} authentication`, { diff --git a/frontend/src/ts/commandline/lists/themes.ts b/frontend/src/ts/commandline/lists/themes.ts index 0b4874f72bdb..394ca2c29821 100644 --- a/frontend/src/ts/commandline/lists/themes.ts +++ b/frontend/src/ts/commandline/lists/themes.ts @@ -5,7 +5,7 @@ import * as ThemeController from "../../controllers/theme-controller"; import { Command, CommandsSubgroup } from "../types"; import { ThemesList, ThemeWithName } from "../../constants/themes"; import { not } from "@monkeytype/util/predicates"; -import * as ConfigEvent from "../../observables/config-event"; +import { configEvent } from "../../events/config"; import * as getErrorMessage from "../../utils/error"; const isFavorite = (theme: ThemeWithName): boolean => @@ -77,7 +77,7 @@ export function update(themes: ThemeWithName[]): void { } // subscribe to theme-related config events to update the theme command list -ConfigEvent.subscribe(({ key }) => { +configEvent.subscribe(({ key }) => { if (key === "favThemes") { // update themes list when favorites change try { diff --git a/frontend/src/ts/components/layout/header/AccountXpBar.tsx b/frontend/src/ts/components/layout/header/AccountXpBar.tsx index 0e95cd5e84e7..06988455bb60 100644 --- a/frontend/src/ts/components/layout/header/AccountXpBar.tsx +++ b/frontend/src/ts/components/layout/header/AccountXpBar.tsx @@ -1,7 +1,6 @@ import { XpBreakdown } from "@monkeytype/schemas/results"; import { isSafeNumber } from "@monkeytype/util/numbers"; import { - createMemo, createSignal, For, JSXElement, @@ -14,7 +13,7 @@ import { createSignalWithSetters } from "../../../hooks/createSignalWithSetters" import { createEffectOn } from "../../../hooks/effects"; import { getFocus } from "../../../states/core"; import { - getSkipBreakdownEvent, + skipBreakdownEvent, getXpBarData, setAnimatedLevel, } from "../../../states/header"; @@ -38,11 +37,11 @@ export function AccountXpBar(): JSXElement { const [getBarAnimationDuration, setBarAnimationDuration] = createSignal(0); const [getBarAnimationEase, setBarAnimationEase] = createSignal("out(5)"); - const [getAnimationEvent, fireAnimationEvent] = createEvent(); + const animationEvent = createEvent(); const [getTotal, { setTotal }] = createSignalWithSetters(0)({ setTotal: (set, value: number) => { set(value); - fireAnimationEvent(); + animationEvent.dispatch(); }, }); @@ -54,23 +53,29 @@ export function AccountXpBar(): JSXElement { let skipped = false; let runId = 0; - const flashAnimation = createMemo(() => { - getAnimationEvent(); // trigger on every total update, even if value unchanged + const [flashAnimation, setFlashAnimation] = createSignal({ + scale: [1, 1], + rotate: [0, 0], + duration: 2000, + ease: "out(5)", + }); + + animationEvent.useListener(() => { const rand = (Math.random() * 2 - 1) / 4; const rand2 = (Math.random() + 1) / 2; - return { + setFlashAnimation({ scale: [1 + 0.5 * rand2, 1], rotate: [10 * rand, 0], duration: 2000, ease: "out(5)", - }; + }); }); const addItem = (label: string, amount: number | string): void => { setBreakdownItems((items) => [...items, { label, amount }]); }; - createEffectOn(getSkipBreakdownEvent, async () => { + skipBreakdownEvent.useListener(async () => { if (skipped || !canSkip) return; const myId = runId; // capture before first await diff --git a/frontend/src/ts/components/layout/header/Logo.tsx b/frontend/src/ts/components/layout/header/Logo.tsx index bc838e360b53..41127879c8c2 100644 --- a/frontend/src/ts/components/layout/header/Logo.tsx +++ b/frontend/src/ts/components/layout/header/Logo.tsx @@ -1,7 +1,7 @@ import { JSXElement } from "solid-js"; import { - dispatchRestartTest, + restartTestEvent, getActivePage, getFocus, } from "../../../states/core"; @@ -21,7 +21,7 @@ export function Logo(): JSXElement { }} data-ui-element="logo" onClick={() => { - if (getActivePage() === "test") dispatchRestartTest(); + if (getActivePage() === "test") restartTestEvent.dispatch(); }} > { - if (getActivePage() === "test") dispatchRestartTest(); + if (getActivePage() === "test") restartTestEvent.dispatch(); }} />