Skip to content

Commit f5fe3de

Browse files
authored
refactor: convert observables to events (@Miodec) (monkeytypegame#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.
1 parent e2378e9 commit f5fe3de

71 files changed

Lines changed: 364 additions & 416 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 65 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,81 @@
11
import { createRoot } from "solid-js";
2-
import { describe, expect, it } from "vitest";
2+
import { describe, expect, it, vi } from "vitest";
33
import { createEvent } from "../../src/ts/hooks/createEvent";
44

55
describe("createEvent", () => {
6-
it("initial value is 0", () => {
7-
createRoot((dispose) => {
8-
const [event] = createEvent();
9-
expect(event()).toBe(0);
10-
dispose();
11-
});
6+
it("dispatch notifies subscribers", () => {
7+
const event = createEvent<string>();
8+
const fn = vi.fn();
9+
event.subscribe(fn);
10+
event.dispatch("hello");
11+
expect(fn).toHaveBeenCalledWith("hello");
1212
});
1313

14-
it("dispatch increments the value by 1", () => {
15-
createRoot((dispose) => {
16-
const [event, dispatch] = createEvent();
17-
dispatch();
18-
expect(event()).toBe(1);
19-
dispose();
20-
});
14+
it("dispatch notifies multiple subscribers", () => {
15+
const event = createEvent<number>();
16+
const fn1 = vi.fn();
17+
const fn2 = vi.fn();
18+
event.subscribe(fn1);
19+
event.subscribe(fn2);
20+
event.dispatch(42);
21+
expect(fn1).toHaveBeenCalledWith(42);
22+
expect(fn2).toHaveBeenCalledWith(42);
2123
});
2224

23-
it("each dispatch increments independently", () => {
24-
createRoot((dispose) => {
25-
const [event, dispatch] = createEvent();
26-
dispatch();
27-
dispatch();
28-
dispatch();
29-
expect(event()).toBe(3);
30-
dispose();
31-
});
25+
it("dispatch with no type arg requires no arguments", () => {
26+
const event = createEvent();
27+
const fn = vi.fn();
28+
event.subscribe(fn);
29+
event.dispatch();
30+
expect(fn).toHaveBeenCalledTimes(1);
31+
});
32+
33+
it("subscribe returns an unsubscribe function", () => {
34+
const event = createEvent<string>();
35+
const fn = vi.fn();
36+
const unsub = event.subscribe(fn);
37+
event.dispatch("a");
38+
unsub();
39+
event.dispatch("b");
40+
expect(fn).toHaveBeenCalledTimes(1);
41+
expect(fn).toHaveBeenCalledWith("a");
3242
});
3343

3444
it("two independent events do not share state", () => {
45+
const eventA = createEvent<string>();
46+
const eventB = createEvent<string>();
47+
const fnA = vi.fn();
48+
const fnB = vi.fn();
49+
eventA.subscribe(fnA);
50+
eventB.subscribe(fnB);
51+
eventA.dispatch("a");
52+
expect(fnA).toHaveBeenCalledWith("a");
53+
expect(fnB).not.toHaveBeenCalled();
54+
});
55+
56+
it("useListener auto-unsubscribes on dispose", () => {
57+
const event = createEvent<string>();
58+
const fn = vi.fn();
3559
createRoot((dispose) => {
36-
const [eventA, dispatchA] = createEvent();
37-
const [eventB, dispatchB] = createEvent();
38-
dispatchA();
39-
dispatchA();
40-
dispatchB();
41-
expect(eventA()).toBe(2);
42-
expect(eventB()).toBe(1);
60+
event.useListener(fn);
61+
event.dispatch("inside");
4362
dispose();
4463
});
64+
event.dispatch("outside");
65+
expect(fn).toHaveBeenCalledTimes(1);
66+
expect(fn).toHaveBeenCalledWith("inside");
67+
});
68+
69+
it("subscriber errors do not prevent other subscribers from running", () => {
70+
const event = createEvent<string>();
71+
const fn1 = vi.fn(() => {
72+
throw new Error("oops");
73+
});
74+
const fn2 = vi.fn();
75+
event.subscribe(fn1);
76+
event.subscribe(fn2);
77+
event.dispatch("test");
78+
expect(fn1).toHaveBeenCalled();
79+
expect(fn2).toHaveBeenCalled();
4580
});
4681
});

frontend/__tests__/root/config.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
} from "@monkeytype/schemas/configs";
1313
import * as FunboxValidation from "../../src/ts/config/funbox-validation";
1414
import * as ConfigValidation from "../../src/ts/config/validation";
15-
import * as ConfigEvent from "../../src/ts/observables/config-event";
15+
import { configEvent } from "../../src/ts/events/config";
1616
import * as ApeConfig from "../../src/ts/ape/config";
1717
import * as Notifications from "../../src/ts/states/notifications";
1818
const { replaceConfig, getConfig } = __testing;
@@ -33,7 +33,7 @@ describe("Config", () => {
3333
ConfigValidation,
3434
"isConfigValueValid",
3535
);
36-
const dispatchConfigEventMock = vi.spyOn(ConfigEvent, "dispatch");
36+
const dispatchConfigEventMock = vi.spyOn(configEvent, "dispatch");
3737
const dbSaveConfigMock = vi.spyOn(ApeConfig, "saveConfig");
3838
const notificationAddMock = vi.spyOn(
3939
Notifications,

frontend/src/ts/auth.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import Ape from "./ape";
1212
import { showRegisterCaptchaModal } from "./components/modals/RegisterCaptchaModal";
1313
import { updateFromServer as updateConfigFromServer } from "./config/remote";
1414
import * as DB from "./db";
15+
import { authEvent } from "./events/auth";
1516
import {
1617
isAuthAvailable,
1718
getAuthenticatedUser,
@@ -23,7 +24,6 @@ import {
2324
resetIgnoreAuthCallback,
2425
} from "./firebase";
2526
import { showPopup } from "./modals/simple-modals-base";
26-
import * as AuthEvent from "./observables/auth-event";
2727
import * as Sentry from "./sentry";
2828
import { addBanner } from "./states/banners";
2929
import { showLoaderBar, hideLoaderBar } from "./states/loader-bar";
@@ -131,7 +131,7 @@ export async function loadUser(_user: UserType): Promise<void> {
131131
signOut();
132132
return;
133133
}
134-
AuthEvent.dispatch({ type: "snapshotUpdated", data: { isInitial: true } });
134+
authEvent.dispatch({ type: "snapshotUpdated", data: { isInitial: true } });
135135
}
136136

137137
export async function onAuthStateChanged(
@@ -155,7 +155,7 @@ export async function onAuthStateChanged(
155155
void Sentry.clearUser();
156156
}
157157

158-
AuthEvent.dispatch({
158+
authEvent.dispatch({
159159
type: "authStateChanged",
160160
data: { isUserSignedIn: user !== null, loadPromise: userPromise },
161161
});
@@ -231,7 +231,7 @@ async function addAuthProvider(
231231
await linkWithPopup(user, provider);
232232
hideLoaderBar();
233233
showSuccessNotification(`${providerName} authentication added`);
234-
AuthEvent.dispatch({ type: "authConfigUpdated" });
234+
authEvent.dispatch({ type: "authConfigUpdated" });
235235
} catch (error) {
236236
hideLoaderBar();
237237
showErrorNotification(`Failed to add ${providerName} authentication`, {

frontend/src/ts/commandline/lists/themes.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import * as ThemeController from "../../controllers/theme-controller";
55
import { Command, CommandsSubgroup } from "../types";
66
import { ThemesList, ThemeWithName } from "../../constants/themes";
77
import { not } from "@monkeytype/util/predicates";
8-
import * as ConfigEvent from "../../observables/config-event";
8+
import { configEvent } from "../../events/config";
99
import * as getErrorMessage from "../../utils/error";
1010

1111
const isFavorite = (theme: ThemeWithName): boolean =>
@@ -77,7 +77,7 @@ export function update(themes: ThemeWithName[]): void {
7777
}
7878

7979
// subscribe to theme-related config events to update the theme command list
80-
ConfigEvent.subscribe(({ key }) => {
80+
configEvent.subscribe(({ key }) => {
8181
if (key === "favThemes") {
8282
// update themes list when favorites change
8383
try {

frontend/src/ts/components/layout/header/AccountXpBar.tsx

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { XpBreakdown } from "@monkeytype/schemas/results";
22
import { isSafeNumber } from "@monkeytype/util/numbers";
33
import {
4-
createMemo,
54
createSignal,
65
For,
76
JSXElement,
@@ -14,7 +13,7 @@ import { createSignalWithSetters } from "../../../hooks/createSignalWithSetters"
1413
import { createEffectOn } from "../../../hooks/effects";
1514
import { getFocus } from "../../../states/core";
1615
import {
17-
getSkipBreakdownEvent,
16+
skipBreakdownEvent,
1817
getXpBarData,
1918
setAnimatedLevel,
2019
} from "../../../states/header";
@@ -38,11 +37,11 @@ export function AccountXpBar(): JSXElement {
3837
const [getBarAnimationDuration, setBarAnimationDuration] = createSignal(0);
3938
const [getBarAnimationEase, setBarAnimationEase] = createSignal("out(5)");
4039

41-
const [getAnimationEvent, fireAnimationEvent] = createEvent();
40+
const animationEvent = createEvent();
4241
const [getTotal, { setTotal }] = createSignalWithSetters(0)({
4342
setTotal: (set, value: number) => {
4443
set(value);
45-
fireAnimationEvent();
44+
animationEvent.dispatch();
4645
},
4746
});
4847

@@ -54,23 +53,29 @@ export function AccountXpBar(): JSXElement {
5453
let skipped = false;
5554
let runId = 0;
5655

57-
const flashAnimation = createMemo(() => {
58-
getAnimationEvent(); // trigger on every total update, even if value unchanged
56+
const [flashAnimation, setFlashAnimation] = createSignal({
57+
scale: [1, 1],
58+
rotate: [0, 0],
59+
duration: 2000,
60+
ease: "out(5)",
61+
});
62+
63+
animationEvent.useListener(() => {
5964
const rand = (Math.random() * 2 - 1) / 4;
6065
const rand2 = (Math.random() + 1) / 2;
61-
return {
66+
setFlashAnimation({
6267
scale: [1 + 0.5 * rand2, 1],
6368
rotate: [10 * rand, 0],
6469
duration: 2000,
6570
ease: "out(5)",
66-
};
71+
});
6772
});
6873

6974
const addItem = (label: string, amount: number | string): void => {
7075
setBreakdownItems((items) => [...items, { label, amount }]);
7176
};
7277

73-
createEffectOn(getSkipBreakdownEvent, async () => {
78+
skipBreakdownEvent.useListener(async () => {
7479
if (skipped || !canSkip) return;
7580

7681
const myId = runId; // capture before first await

frontend/src/ts/components/layout/header/Logo.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { JSXElement } from "solid-js";
22

33
import {
4-
dispatchRestartTest,
4+
restartTestEvent,
55
getActivePage,
66
getFocus,
77
} from "../../../states/core";
@@ -21,7 +21,7 @@ export function Logo(): JSXElement {
2121
}}
2222
data-ui-element="logo"
2323
onClick={() => {
24-
if (getActivePage() === "test") dispatchRestartTest();
24+
if (getActivePage() === "test") restartTestEvent.dispatch();
2525
}}
2626
>
2727
<svg

frontend/src/ts/components/layout/header/Nav.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { createMemo, JSXElement, Show } from "solid-js";
44
import { createEffectOn } from "../../../hooks/effects";
55
import { getServerConfigurationQueryOptions } from "../../../queries/server-configuration";
66
import {
7-
dispatchRestartTest,
7+
restartTestEvent,
88
getActivePage,
99
getFocus,
1010
} from "../../../states/core";
@@ -80,7 +80,7 @@ export function Nav(): JSXElement {
8080
"data-nav-item": "test",
8181
}}
8282
onClick={() => {
83-
if (getActivePage() === "test") dispatchRestartTest();
83+
if (getActivePage() === "test") restartTestEvent.dispatch();
8484
}}
8585
/>
8686
<Button

frontend/src/ts/components/pages/profile/ProfileSearchPage.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import { UserNameSchema } from "@monkeytype/schemas/users";
22
import { createForm } from "@tanstack/solid-form";
33
import { createEffect, createSignal, JSXElement, Show } from "solid-js";
44

5+
import { navigationEvent } from "../../../events/navigation";
56
import { useRefWithUtils } from "../../../hooks/useRefWithUtils";
6-
import * as NavigationEvent from "../../../observables/navigation-event";
77
import { queryClient } from "../../../queries";
88
import { getUserProfile } from "../../../queries/profile";
99
import { getActivePage } from "../../../states/core";
@@ -27,7 +27,10 @@ export function ProfileSearchPage(): JSXElement {
2727
onSubmit: async ({ value }) => {
2828
setEditable(false);
2929
try {
30-
NavigationEvent.dispatch(`/profile/${value.username}`, {});
30+
navigationEvent.dispatch({
31+
url: `/profile/${value.username}`,
32+
options: {},
33+
});
3134
} finally {
3235
setEditable(true);
3336
}

frontend/src/ts/config/lifecycle.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
} from "./persistence";
1212
import { Config, setConfigStore } from "./store";
1313
import { getDefaultConfig } from "../constants/default-config";
14-
import * as ConfigEvent from "../observables/config-event";
14+
import { configEvent } from "../events/config";
1515
import { migrateConfig } from "./utils";
1616
import { promiseWithResolvers, typedKeys } from "../utils/misc";
1717
import { setConfig } from "./setters";
@@ -76,7 +76,7 @@ export async function applyConfig(
7676
//migrate old values if needed, remove additional keys and merge with default config
7777
const fullConfig: ConfigSchemas.Config = migrateConfig(partialConfig);
7878

79-
ConfigEvent.dispatch({ key: "fullConfigChange" });
79+
configEvent.dispatch({ key: "fullConfigChange" });
8080

8181
const defaultConfig = getDefaultConfig();
8282
for (const key of typedKeys(fullConfig)) {
@@ -107,7 +107,7 @@ export async function applyConfig(
107107
saveToLocalStorage(key);
108108
}
109109

110-
ConfigEvent.dispatch({ key: "fullConfigChangeFinished" });
110+
configEvent.dispatch({ key: "fullConfigChangeFinished" });
111111
setConfigStore(reconcile(Config));
112112
}
113113

frontend/src/ts/config/setters.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { ZodType as ZodSchema } from "zod";
33
import { saveToLocalStorage } from "../config/persistence";
44
import { configMetadata, ConfigMetadataObject } from "./metadata";
55
import { isConfigValueValid } from "./validation";
6-
import * as ConfigEvent from "../observables/config-event";
6+
import { configEvent } from "../events/config";
77
import { showNoticeNotification } from "../states/notifications";
88
import {
99
canSetConfigWithCurrentFunboxes,
@@ -124,7 +124,7 @@ export function setConfig<T extends keyof ConfigSchemas.Config>(
124124
if (!options?.nosave) saveToLocalStorage(key, options?.nosave);
125125

126126
// @ts-expect-error i can't figure this out
127-
ConfigEvent.dispatch({
127+
configEvent.dispatch({
128128
key: key,
129129
newValue: value,
130130
nosave: options?.nosave ?? false,
@@ -195,7 +195,7 @@ export function toggleFunbox(funbox: FunboxName, nosave?: boolean): boolean {
195195

196196
Config.funbox = newConfig;
197197
saveToLocalStorage("funbox", nosave);
198-
ConfigEvent.dispatch({
198+
configEvent.dispatch({
199199
key: "funbox",
200200
newValue: Config.funbox,
201201
nosave,

0 commit comments

Comments
 (0)