From bc41e4fa58e306895fa7115cd682e0fc9cb92811 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Thu, 26 Feb 2026 20:32:16 +0100 Subject: [PATCH 1/7] chore: add signal for authentication state / user id (@fehmer) (#7539) --- frontend/src/ts/firebase.ts | 3 +++ frontend/src/ts/signals/core.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/frontend/src/ts/firebase.ts b/frontend/src/ts/firebase.ts index 85b562673397..f2d5c74db7c8 100644 --- a/frontend/src/ts/firebase.ts +++ b/frontend/src/ts/firebase.ts @@ -35,6 +35,7 @@ import { import { tryCatch } from "@monkeytype/util/trycatch"; import { dispatch as dispatchSignUpEvent } from "./observables/google-sign-up-event"; import { addBanner } from "./stores/banners"; +import { setUserId } from "./signals/core"; let app: FirebaseApp | undefined; let Auth: AuthType | undefined; @@ -76,6 +77,7 @@ export async function init(callback: ReadyCallback): Promise { onAuthStateChanged(Auth, async (user) => { if (!ignoreAuthCallback) { await callback(true, user); + setUserId(user?.uid ?? null); } }); } catch (e) { @@ -83,6 +85,7 @@ export async function init(callback: ReadyCallback): Promise { Auth = undefined; console.error("Firebase failed to initialize", e); await callback(false, null); + setUserId(null); if (isDevEnvironment()) { addBanner({ level: "notice", diff --git a/frontend/src/ts/signals/core.ts b/frontend/src/ts/signals/core.ts index f3c6127b08e0..175691f4316d 100644 --- a/frontend/src/ts/signals/core.ts +++ b/frontend/src/ts/signals/core.ts @@ -29,3 +29,6 @@ export const [getCommandlineSubgroup, setCommandlineSubgroup] = createSignal< export const [getFocus, setFocus] = createSignal(false); export const [getGlobalOffsetTop, setGlobalOffsetTop] = createSignal(0); export const [getIsScreenshotting, setIsScreenshotting] = createSignal(false); + +export const [getUserId, setUserId] = createSignal(null); +export const isLoggedIn = () => getUserId() !== null; From 8ad931506e46ef483069b812b72b82804fc27746 Mon Sep 17 00:00:00 2001 From: Miodec Date: Thu, 26 Feb 2026 21:16:48 +0100 Subject: [PATCH 2/7] chore: disable tailwind class sorting on save --- .oxfmtrc-editor.json | 37 ++++++++++++++++++++++++++++++++++ backend/.vscode/settings.json | 3 +++ frontend/.vscode/settings.json | 3 +++ monkeytype.code-workspace | 1 + packages/.vscode/settings.json | 3 +++ 5 files changed, 47 insertions(+) create mode 100644 .oxfmtrc-editor.json create mode 100644 backend/.vscode/settings.json create mode 100644 frontend/.vscode/settings.json create mode 100644 packages/.vscode/settings.json diff --git a/.oxfmtrc-editor.json b/.oxfmtrc-editor.json new file mode 100644 index 000000000000..b7fb86cd64de --- /dev/null +++ b/.oxfmtrc-editor.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://raw.githubusercontent.com/oxc-project/oxc/refs/heads/main/npm/oxfmt/configuration_schema.json", + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "htmlWhitespaceSensitivity": "ignore", + "endOfLine": "lf", + "trailingComma": "all", + "ignorePatterns": [ + "pnpm-lock.yaml", + "node_modules", + ".turbo", + "dist", + "build", + "logs", + "coverage", + "*.md" + ], + "overrides": [ + { + "files": ["**/*.tsx"], + "options": { + "experimentalSortImports": { + "groups": [ + "type-import", + ["value-builtin", "value-external"], + "type-internal", + "value-internal", + ["type-parent", "type-sibling", "type-index"], + ["value-parent", "value-sibling", "value-index"], + "unknown" + ] + } + } + } + ] +} diff --git a/backend/.vscode/settings.json b/backend/.vscode/settings.json new file mode 100644 index 000000000000..02015d68e590 --- /dev/null +++ b/backend/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "oxc.fmt.configPath": "../.oxfmtrc-editor.json" +} diff --git a/frontend/.vscode/settings.json b/frontend/.vscode/settings.json new file mode 100644 index 000000000000..02015d68e590 --- /dev/null +++ b/frontend/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "oxc.fmt.configPath": "../.oxfmtrc-editor.json" +} diff --git a/monkeytype.code-workspace b/monkeytype.code-workspace index 7fd0101d54a9..335d042b84a0 100644 --- a/monkeytype.code-workspace +++ b/monkeytype.code-workspace @@ -45,6 +45,7 @@ "vitest.maximumConfigs": 10, "oxc.typeAware": true, "typescript.format.enable": false, + "oxc.fmt.configPath": ".oxfmtrc-editor.json", "[json]": { "editor.defaultFormatter": "oxc.oxc-vscode", }, diff --git a/packages/.vscode/settings.json b/packages/.vscode/settings.json new file mode 100644 index 000000000000..02015d68e590 --- /dev/null +++ b/packages/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "oxc.fmt.configPath": "../.oxfmtrc-editor.json" +} From fe24e91e9204fadb87fe3716cc9f7f1df7eeca81 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Thu, 26 Feb 2026 21:35:00 +0100 Subject: [PATCH 3/7] chore: add ValidatedInput component (@fehmer) (#7541) --- .../src/ts/components/ui/ValidatedInput.tsx | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 frontend/src/ts/components/ui/ValidatedInput.tsx diff --git a/frontend/src/ts/components/ui/ValidatedInput.tsx b/frontend/src/ts/components/ui/ValidatedInput.tsx new file mode 100644 index 000000000000..05865863b52c --- /dev/null +++ b/frontend/src/ts/components/ui/ValidatedInput.tsx @@ -0,0 +1,56 @@ +import { + splitProps, + createEffect, + JSXElement, + onCleanup, + onMount, +} from "solid-js"; + +import { + ValidatedHtmlInputElement, + ValidationOptions, +} from "../../elements/input-validation"; +import { useRefWithUtils } from "../../hooks/useRefWithUtils"; + +export function ValidatedInput( + props: ValidationOptions & { + value?: string; + placeholder?: string; + class?: string; + onInput?: (value: T) => void; + }, +): JSXElement { + // Refs are assigned by SolidJS via the ref attribute + const [inputRef, inputEl] = useRefWithUtils(); + + let validatedInput: ValidatedHtmlInputElement | undefined; + + createEffect(() => { + validatedInput?.setValue(props.value ?? null); + }); + + onMount(() => { + const element = inputEl(); + if (element === undefined) return; + + const [_, others] = splitProps(props, ["value", "class", "placeholder"]); + validatedInput = new ValidatedHtmlInputElement( + element, + others as ValidationOptions, + ); + + validatedInput.setValue(props.value ?? null); + }); + + onCleanup(() => validatedInput?.remove()); + return ( + props.onInput?.(e.target.value as T)} + /> + ); +} From 3e700e35f0c89ead3dee31b5ad3c45a71df19427 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Thu, 26 Feb 2026 22:28:29 +0100 Subject: [PATCH 4/7] chore: add localStorage backed store (@fehmer) (#7540) Co-authored-by: Miodec --- .../utils/local-storage-with-schema.spec.ts | 11 +++ frontend/src/ts/hooks/useLocalStorageStore.ts | 81 +++++++++++++++++++ .../src/ts/utils/local-storage-with-schema.ts | 9 ++- 3 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 frontend/src/ts/hooks/useLocalStorageStore.ts diff --git a/frontend/__tests__/utils/local-storage-with-schema.spec.ts b/frontend/__tests__/utils/local-storage-with-schema.spec.ts index 112776c50aa2..369f4d90b9ef 100644 --- a/frontend/__tests__/utils/local-storage-with-schema.spec.ts +++ b/frontend/__tests__/utils/local-storage-with-schema.spec.ts @@ -75,6 +75,7 @@ describe("local-storage-with-schema.ts", () => { const update = { ...defaultObject, fontSize: 5 }; ls.set(update); + getItemMock.mockReset(); expect(ls.get()).toStrictEqual(update); @@ -83,6 +84,7 @@ describe("local-storage-with-schema.ts", () => { it("should get last valid value if schema is incorrect", () => { ls.set(defaultObject); + getItemMock.mockReset(); ls.set({ hi: "hello" } as any); @@ -91,6 +93,15 @@ describe("local-storage-with-schema.ts", () => { expect(setItemMock).toHaveBeenCalledOnce(); expect(getItemMock).not.toHaveBeenCalled(); }); + + it("should not set if value has not changed", () => { + ls.set(defaultObject); + setItemMock.mockReset(); + + ls.set(defaultObject); + + expect(setItemMock).not.toHaveBeenCalled(); + }); }); describe("get", () => { diff --git a/frontend/src/ts/hooks/useLocalStorageStore.ts b/frontend/src/ts/hooks/useLocalStorageStore.ts new file mode 100644 index 000000000000..36b1d3704236 --- /dev/null +++ b/frontend/src/ts/hooks/useLocalStorageStore.ts @@ -0,0 +1,81 @@ +import { createEffect, onCleanup } from "solid-js"; +import { createStore, reconcile, SetStoreFunction } from "solid-js/store"; +import { LocalStorageWithSchema } from "../utils/local-storage-with-schema"; + +export type UseLocalStorageStoreOptions = { + key: LocalStorageWithSchema["key"]; + schema: LocalStorageWithSchema["schema"]; + fallback: LocalStorageWithSchema["fallback"]; + migrate?: LocalStorageWithSchema["migrate"]; + /** + * Whether to sync changes across tabs/windows using storage events. + * @default true + */ + syncAcrossTabs?: boolean; +}; + +/** + * SolidJS hook for reactive localStorage with Zod schema validation. + * Wraps LocalStorageWithSchema in a reactive SolidJS store. + * + * @example + * ```tsx + * const [state, setState] = useLocalStorageStore({ + * key: "myKey", + * schema: z.object({value:z.string()}), + * fallback: {value:"default"}, + * }); + * + * return
setState("value", "new value")}>{state.value}
; + * ``` + */ +export function useLocalStorageStore( + options: UseLocalStorageStoreOptions, +): [T, SetStoreFunction] { + const { key, schema, fallback, migrate, syncAcrossTabs = true } = options; + + // Create the underlying localStorage manager + const storage = new LocalStorageWithSchema({ + key, + schema, + fallback, + migrate, + }); + + // Create store with initial value from storage + const [value, setValue] = createStore(structuredClone(storage.get())); + + // Guard to prevent redundant persist during cross-tab sync + let isSyncing = false; + + // Persist entire store to localStorage whenever it changes + createEffect(() => { + if (!isSyncing) { + storage.set(value); + } + }); + + // Sync changes across tabs/windows + if (syncAcrossTabs) { + const handleStorageChange = (e: StorageEvent): void => { + if (e.key === key && e.newValue !== null) { + console.debug(`LS ${key} Storage event detected from another tab`); + try { + const parsed = schema.parse(JSON.parse(e.newValue)); + isSyncing = true; + setValue(reconcile(parsed)); + isSyncing = false; + } catch (error) { + console.error(`LS ${key} Failed to parse storage event value`, error); + } + } + }; + + window.addEventListener("storage", handleStorageChange); + onCleanup(() => { + window.removeEventListener("storage", handleStorageChange); + }); + } + + return [value, setValue]; +} diff --git a/frontend/src/ts/utils/local-storage-with-schema.ts b/frontend/src/ts/utils/local-storage-with-schema.ts index 00365b510115..a52a4b41b86a 100644 --- a/frontend/src/ts/utils/local-storage-with-schema.ts +++ b/frontend/src/ts/utils/local-storage-with-schema.ts @@ -91,9 +91,12 @@ export class LocalStorageWithSchema { try { console.debug(`LS ${this.key} Parsing to set in localStorage`); const parsed = this.schema.parse(data); - console.debug(`LS ${this.key} Setting in localStorage`); - window.localStorage.setItem(this.key, JSON.stringify(parsed)); - this.cache = parsed; + const newValue = JSON.stringify(parsed); + if (newValue !== JSON.stringify(this.cache)) { + console.debug(`LS ${this.key} Setting in localStorage`); + window.localStorage.setItem(this.key, newValue); + this.cache = parsed; + } return true; } catch (e) { let message = "Unknown error occurred"; From eaf129e5887f9cf0565b3ffdd573f8e585e46335 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Thu, 26 Feb 2026 22:28:45 +0100 Subject: [PATCH 5/7] chore: add test for ValidatedInput (@fehmer) (#7543) --- .../components/ui/ValidatedInput.spec.tsx | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 frontend/__tests__/components/ui/ValidatedInput.spec.tsx diff --git a/frontend/__tests__/components/ui/ValidatedInput.spec.tsx b/frontend/__tests__/components/ui/ValidatedInput.spec.tsx new file mode 100644 index 000000000000..90f48a74ac67 --- /dev/null +++ b/frontend/__tests__/components/ui/ValidatedInput.spec.tsx @@ -0,0 +1,82 @@ +import { render, waitFor } from "@solidjs/testing-library"; +import userEvent from "@testing-library/user-event"; +import { createSignal } from "solid-js"; +import { describe, expect, it, vi } from "vitest"; +import { z } from "zod"; + +import { ValidatedInput } from "../../../src/ts/components/ui/ValidatedInput"; + +vi.mock("../../../src/ts/config", () => ({})); + +describe("ValidatedInput", () => { + it("renders with valid initial value", async () => { + const schema = z.string().min(4); + const { container } = render(() => ( + + )); + + await waitFor(() => container.querySelector(".inputAndIndicator") !== null); + + const wrapper = container.querySelector(".inputAndIndicator"); + const input = container.querySelector("input"); + console.log(container?.innerHTML); + expect(wrapper).toHaveClass("inputAndIndicator"); + expect(wrapper).toHaveAttribute("data-indicator-status", "success"); + expect(input).toHaveValue("Kevin"); + + const indicator = wrapper?.querySelector("div.indicator:not(.hidden)"); + expect(indicator).toBeInTheDocument(); + expect(indicator).toHaveAttribute("data-option-id", "success"); + expect(indicator?.querySelector("i")).toHaveClass("fa-check"); + }); + + it("renders with invalid initial value", async () => { + const schema = z.string().min(4); + const { container } = render(() => ( + + )); + + await waitFor(() => container.querySelector(".inputAndIndicator") !== null); + + const wrapper = container.querySelector(".inputAndIndicator"); + const input = container.querySelector("input"); + console.log(container?.innerHTML); + expect(wrapper).toHaveClass("inputAndIndicator"); + expect(wrapper).toHaveAttribute("data-indicator-status", "failed"); + expect(input).toHaveValue("Bob"); + + const indicator = wrapper?.querySelector("div.indicator:not(.hidden)"); + expect(indicator).toBeInTheDocument(); + expect(indicator).toHaveAttribute("data-option-id", "failed"); + expect(indicator?.querySelector("i")).toHaveClass("fa-times"); + }); + + it("updates callback", async () => { + const [value, setValue] = createSignal("Bob"); + const schema = z.string().min(4); + const { container } = render(() => ( + + )); + + await waitFor(() => container.querySelector(".inputAndIndicator") !== null); + console.log(container.innerHTML); + const input = container.querySelector("input") as HTMLInputElement; + expect(container.querySelector(".inputAndIndicator")).toHaveAttribute( + "data-indicator-status", + "failed", + ); + + await userEvent.type(input, "ington"); + + expect(value()).toEqual("Bobington"); + expect(container.querySelector(".inputAndIndicator")).toHaveAttribute( + "data-indicator-status", + "success", + ); + }); +}); From d94d1807b0afb3e3d634c13afe85ee55b1ec58d3 Mon Sep 17 00:00:00 2001 From: Leonabcd123 <156839416+Leonabcd123@users.noreply.github.com> Date: Thu, 26 Feb 2026 23:29:35 +0200 Subject: [PATCH 6/7] docs: update programming languages order (@Leonabcd123) (#7542) ### Description HTML is 7.6% of the code, SCSS + CSS is 7%. --- docs/CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 1915930207fb..ff0eb0954a0f 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -13,7 +13,7 @@ ## Getting Started -When contributing to Monkeytype, it's good to know our best practices, tips, and tricks. First, Monkeytype is written in ~~JavaScript~~ TypeScript, CSS, and HTML (in order of language usage within the project); thus, we assume you are comfortable with these languages or have basic knowledge of them. Our backend is in NodeJS and we use MongoDB to store our user data. Firebase is used for authentication. Redis is used to store ephemeral data (daily leaderboards, jobs via BullMQ, OAuth state parameters). Furthermore, we use Prettier to format our code. +When contributing to Monkeytype, it's good to know our best practices, tips, and tricks. First, Monkeytype is written in ~~JavaScript~~ TypeScript, HTML, and CSS (in order of language usage within the project); thus, we assume you are comfortable with these languages or have basic knowledge of them. Our backend is in NodeJS and we use MongoDB to store our user data. Firebase is used for authentication. Redis is used to store ephemeral data (daily leaderboards, jobs via BullMQ, OAuth state parameters). Furthermore, we use Prettier to format our code. ## How to Contribute From 3230ee684e755ce74d5a02c5cebd9dc24843145e Mon Sep 17 00:00:00 2001 From: Miodec Date: Thu, 26 Feb 2026 22:28:17 +0100 Subject: [PATCH 7/7] fix: useLocalStorage not removing event listener !nuf --- frontend/src/ts/hooks/useLocalStorage.ts | 33 ++++++++++-------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/frontend/src/ts/hooks/useLocalStorage.ts b/frontend/src/ts/hooks/useLocalStorage.ts index ce8413215fb1..f17a1d705753 100644 --- a/frontend/src/ts/hooks/useLocalStorage.ts +++ b/frontend/src/ts/hooks/useLocalStorage.ts @@ -1,4 +1,4 @@ -import { createSignal, createEffect, Accessor, Setter } from "solid-js"; +import { createSignal, Accessor, Setter, onCleanup } from "solid-js"; import { LocalStorageWithSchema } from "../utils/local-storage-with-schema"; export type UseLocalStorageOptions = { @@ -60,26 +60,21 @@ export function useLocalStorage( // Sync changes across tabs/windows if (syncAcrossTabs) { - createEffect(() => { - const handleStorageChange = (e: StorageEvent): void => { - if (e.key === key && e.newValue !== null) { - console.debug(`LS ${key} Storage event detected from another tab`); - try { - const parsed = schema.parse(JSON.parse(e.newValue)); - setValueInternal(() => parsed); - } catch (error) { - console.error( - `LS ${key} Failed to parse storage event value`, - error, - ); - } + const handleStorageChange = (e: StorageEvent): void => { + if (e.key === key && e.newValue !== null) { + console.debug(`LS ${key} Storage event detected from another tab`); + try { + const parsed = schema.parse(JSON.parse(e.newValue)); + setValueInternal(() => parsed); + } catch (error) { + console.error(`LS ${key} Failed to parse storage event value`, error); } - }; + } + }; - window.addEventListener("storage", handleStorageChange); - return () => { - window.removeEventListener("storage", handleStorageChange); - }; + window.addEventListener("storage", handleStorageChange); + onCleanup(() => { + window.removeEventListener("storage", handleStorageChange); }); }