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/scripts/import-tree.ts b/frontend/scripts/import-tree.ts index 6ad90054044e..ce1cb6834e76 100644 --- a/frontend/scripts/import-tree.ts +++ b/frontend/scripts/import-tree.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import ts from "typescript"; const ROOT = path.resolve(import.meta.dirname, ".."); @@ -34,8 +35,11 @@ function collectTsFiles(dir: string): string[] { return results; } +const isDir = fs.statSync(resolved).isDirectory(); +const boundary = isDir ? resolved : null; + let entryPoints: string[]; -if (fs.statSync(resolved).isDirectory()) { +if (isDir) { entryPoints = collectTsFiles(resolved); } else { entryPoints = [resolved]; @@ -46,15 +50,35 @@ if (entryPoints.length === 0) { process.exit(1); } -// --- Import extraction --- +// --- Import extraction (type-aware) --- + +const tsConfig: ts.CompilerOptions = { + module: ts.ModuleKind.ESNext, + target: ts.ScriptTarget.ESNext, + jsx: ts.JsxEmit.Preserve, + sourceMap: false, + declaration: false, + isolatedModules: true, +}; -const IMPORT_RE = - /(?:import|export)\s+(?:type\s+)?(?:(?:\{[^}]*\}|[\w*]+(?:\s*,\s*\{[^}]*\})?)\s+from\s+)?["']([^"']+)["']/g; +const JS_IMPORT_RE = + /(?:import|export)\s+(?:(?:\{[^}]*\}|[\w*]+(?:\s*,\s*\{[^}]*\})?)\s+from\s+)?["']([^"']+)["']/g; function extractImports(filePath: string): string[] { const content = fs.readFileSync(filePath, "utf-8"); + let outputText: string; + try { + ({ outputText } = ts.transpileModule(content, { + compilerOptions: tsConfig, + fileName: filePath, + })); + } catch { + // Some files (e.g. declaration files) can't be transpiled — fall back to + // regex on the original source, which still strips type-only imports. + outputText = content; + } const specifiers: string[] = []; - for (const match of content.matchAll(IMPORT_RE)) { + for (const match of outputText.matchAll(JS_IMPORT_RE)) { const spec = match[1]; if (spec !== undefined) specifiers.push(spec); } @@ -190,6 +214,12 @@ function depthColor(depth: number): string { // --- Display --- +function leavesFolder(filePath: string): boolean { + if (boundary === null) return false; + if (filePath.startsWith("@monkeytype/")) return true; + return !filePath.startsWith(boundary + "/"); +} + function displayPath(filePath: string): string { if (filePath.startsWith(ROOT + "/")) { return path.relative(ROOT, filePath); @@ -210,9 +240,12 @@ function printTree( const connector = isRoot ? "" : isLast ? "└── " : "├── "; const dc = depthColor(depth); + const leaves = !isRoot && leavesFolder(filePath); + const leavesTag = leaves ? ` ${c.red}[↑]${c.reset}` : ""; + if (!info) { // leaf node (e.g. @monkeytype package) - console.log(`${c.dim}${prefix}${connector}${dp}${c.reset}`); + console.log(`${c.dim}${prefix}${connector}${dp}${c.reset}${leavesTag}`); return; } @@ -225,7 +258,7 @@ function printTree( const seen = !isRoot && printed.has(filePath); const seenTag = seen ? ` ${c.dim}[seen above]${c.reset}` : ""; console.log( - `${c.dim}${prefix}${connector}${c.reset}${nameStyle}${dp}${c.reset}${stats}${seenTag}`, + `${c.dim}${prefix}${connector}${c.reset}${nameStyle}${dp}${c.reset}${stats}${leavesTag}${seenTag}`, ); if (seen || depth >= maxDepthLimit) return; @@ -336,3 +369,21 @@ console.log( console.log( `Max depth: ${c.bold}${maxDepthSeen}${c.reset} ${c.dim}(${displayPath(maxDepthFile)})${c.reset}`, ); + +if (boundary !== null) { + const externalDirect = new Set(); + const externalTransitive = new Set(); + for (const entry of entryPoints) { + const info = cache.get(entry); + if (!info) continue; + for (const dep of info.directImports) { + if (leavesFolder(dep)) externalDirect.add(dep); + } + for (const dep of getAllReachable(entry, new Set())) { + if (leavesFolder(dep)) externalTransitive.add(dep); + } + } + console.log( + `Leaves folder ${c.red}[↑]${c.reset}: ${c.bold}${externalDirect.size}${c.reset} direct, ${c.bold}${externalTransitive.size}${c.reset} transitive`, + ); +} diff --git a/frontend/src/ts/auth.tsx b/frontend/src/ts/auth.tsx index 5fc6c39b58d5..972b61ff07d7 100644 --- a/frontend/src/ts/auth.tsx +++ b/frontend/src/ts/auth.tsx @@ -9,9 +9,9 @@ import { } from "firebase/auth"; 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, @@ -22,10 +22,7 @@ import { signInWithPopup, 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"; import { showNoticeNotification, @@ -75,28 +72,6 @@ async function getDataAndInit(): Promise { } void Sentry.setUser(snapshot.uid, snapshot.name); - if (snapshot.needsToChangeName) { - addBanner({ - level: "error", - icon: "fas fa-exclamation-triangle", - customContent: ( - <> - You need to update your account name.{" "} - {" "} - to change it and learn more about why. - - ), - important: true, - }); - } await updateConfigFromServer(); return true; @@ -131,7 +106,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 +130,7 @@ export async function onAuthStateChanged( void Sentry.clearUser(); } - AuthEvent.dispatch({ + authEvent.dispatch({ type: "authStateChanged", data: { isUserSignedIn: user !== null, loadPromise: userPromise }, }); @@ -231,7 +206,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`, { @@ -253,14 +228,11 @@ export async function signUp( name: string, email: string, password: string, + captchaToken: string, ): Promise { if (!isAuthAvailable()) { return { success: false, message: "Authentication uninitialized" }; } - const captchaToken = await showRegisterCaptchaModal(); - if (captchaToken === undefined || captchaToken === "") { - return { success: false, message: "Please complete the captcha" }; - } try { const createdAuthUser = await createUserWithEmailAndPassword( diff --git a/frontend/src/ts/commandline/lists/font-family.ts b/frontend/src/ts/commandline/lists/font-family.ts index acfc08ca9a37..231ddc5f043a 100644 --- a/frontend/src/ts/commandline/lists/font-family.ts +++ b/frontend/src/ts/commandline/lists/font-family.ts @@ -1,11 +1,12 @@ import { Command } from "../types"; import { buildCommandForConfigKey } from "../util"; import FileStorage from "../../utils/file-storage"; -import { applyFontFamily } from "../../controllers/theme-controller"; + import { updateUI } from "../../elements/settings/custom-font-picker"; import { showNoticeNotification } from "../../states/notifications"; import { Config } from "../../config/store"; import { setConfig } from "../../config/setters"; +import { applyFontFamily } from "../../ui"; const fromMeta = buildCommandForConfigKey("fontFamily"); if (fromMeta.subgroup) { 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(); }} />