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(); }} />