diff --git a/docs/specs/solid-monkeytype/roadmap.md b/docs/specs/solid-monkeytype/roadmap.md index 0d9d88404a8d..253923e82483 100644 --- a/docs/specs/solid-monkeytype/roadmap.md +++ b/docs/specs/solid-monkeytype/roadmap.md @@ -110,7 +110,7 @@ Serial; each step needs the previous one. Verify each in the browser (`__mt.chec |---|---|---|---|---|---| | [ ] | U1 | L · PR | Single-row user collection with `getUser()` and named writes (direct writes for server-side effects, optimistic actions for user changes, incl. quote ratings); convert **all writers** (result save, xp, badges, inbox count, lb memory, streak offset, quote rate, favourite quotes, edit profile, update name, unlink Discord, Discord OAuth, auth); delete the reactive mirror; `DB.getSnapshot()` becomes a read-only shim over the row | none | Writers must move in the same PR: there's no mutable object left | | [ ] | U2 | M · PR | Activity calendar: plain activity data in the user row; the calendar becomes a Solid component; delete the imperative test-activity modules | U1 (data shape) | Can share a PR with U1 | -| [ ] | U3 | M · PR | Remove the snapshot-updated auth event (PSA, last-signed-out-result modal read `getUser()`); auth becomes a signal the router reads, with the user-data load as state (loading/loaded/failed) plus `reloadUserData()` replacing the event's promise; delete the auth bus | U1 | | +| [ ] | U3 | M · PR | Remove the snapshot-updated auth event (PSA, last-signed-out-result modal read `getUser()`); PSA's `authEvent` subscription (`Psa.init`, started by boot) follows the auth signal; auth becomes a signal the router reads, with the user-data load as state (loading/loaded/failed) plus `reloadUserData()` replacing the event's promise; delete the auth bus | U1 | | | [ ] | U4 | S · commit per area | Readers move from the `DB.getSnapshot()` shim to `getUser()`, by area; the last PR deletes the shim and the snapshot code in `db.ts` | U1 | Parallel by area | | [ ] | U5 | M · PR | Tags, presets and custom themes get the `getUser()` pattern: plain getters backed by a module-level reactive mirror, replacing their live-query hooks (keep `gcTime: Infinity`). Rewrite the non-test `__nonReactive` callers as Solid code reading the getters: commandline tag/preset lists, the custom-themes check in config metadata, the tag PB helpers in `db.ts` (or they go with U4) | none | Test-page callers go with T6a/T6c | | [ ] | U6 | S · commit | Delete `__nonReactive` from every module once no caller is left | U5, T6a, T6c | Pure deletion: a caller still left means a step missed it | @@ -130,10 +130,10 @@ Serial; each step needs the previous one. Verify each in the browser (`__mt.chec |---|---|---|---|---| | [x] | L1 | M · PR | Focus mode becomes a signal; layout binds focus state; cursor hiding goes to CSS; one component owns the mouse-move exit. Done in [#8519](https://github.com/monkeytypegame/monkeytype/pull/8519): `isFocusMode`/`setFocusMode` (rAF-deferred) in states/test; `data-focused` on app, header, footer, banners; `FocusModeExit`; caret blink follows the signal; `test/focus.ts` deleted | none (before T5) | | [x] | L2 | L · PR | Funbox component owns funbox document state (body state, stylesheet link, CRT scanline); funbox functions stop touching the DOM and become engine plugin hooks, getting input as arguments instead of importing the EventLog; display tweaks via `data-ui-*` / funbox CSS. Done in [#8522](https://github.com/monkeytypegame/monkeytype/pull/8522): `components/core/Funbox.tsx` in App derives `fb-*`/`ignore-reduced-motion` and per-funbox links from `getAppliedFunboxes()` (set by `activate()` after validation, cleared by `clear()`), crt (`crtmode`, `#scanline`, global link) from `config.funbox` unless screenshotting; selectors unchanged; body classes via the `useBodyClasses` hook (Theme's `darkMode` and DevOptionsModal's debug classes moved too). `applyGlobalCSS`/`clearGlobal` and funbox's config-event subscriber deleted; crt loses `hasCssFile` (was loaded twice). Safari ≤16.5 crt check → `getFunboxUnsupportedReason` (metadata `isBlocked`, `toggleFunbox` setter, `activate()`). `handleKeydown`/`handleSpace` get a `FunboxInput` (`current()`, `forWord(i)`) from input instead of importing `events/data`. Hook shape left for T6a | none (before T5) | -| [ ] | L3 | M · PR | Monkey power as a component owning its canvas, positioned from the caret | T3 | +| [ ] | L3 | M · PR | Monkey power as a component owning its canvas, positioned from the caret; replaces App's `MonkeyPower.init` on mount (L6 stopgap) | T3 | | [ ] | L4 | M · PR | Result word highlight becomes part of words history, driven by a highlighted-range signal from the chart | none | -| [ ] | L5 | M · PR | Screenshot-mode signal; elements hide through state; capture uses the result ref | none | -| [ ] | L6 | M · PR | Boot cleanup: the ready module goes into App lifecycle / a boot module; PSA, merch banner and no-CSS detection become state plus components | none | +| [ ] | L5 | M · PR | Screenshot-mode signal; elements hide through state (incl. the static `#nocss` box); capture uses the result ref | none | +| [x] | L6 | M · PR | Boot cleanup: the ready module goes into App lifecycle / a boot module; PSA, merch banner and no-CSS detection become state plus components. Done: DOM-free `boot.ts` (called after render) awaits config then auth, sets app ready, shows merch banner, syncs server config, registers the service worker, and starts connection listeners (`initConnection`, was `onDOMReady`) and PSA (`Psa.init`, still on the auth bus until U3); App calls `MonkeyPower.init` on mount until L3 (the body transition gate went in 99ef85bb6: the first paint has the stored theme); `NoCssDetails` portals into empty mount points of the static `#nocss` box; `ready.ts` and `no-css.ts` deleted; the app has no `onDOMReady` callers left (`email-handler.html` still uses it, see CL1) | none | | [ ] | L7 | S · commit | Remove stray DOM queries: account page, leaderboard table, captcha, challenge controller header, ad/analytics controllers. Done except the leaderboard table: #8524 rewrites its scroll-to-user and should switch to DataTable's `rowSelection.scrollToActiveRow` | none | | [ ] | L8 | S · commit | Delete dead modules: result batches, character counter, discord avatar util, input indicator, unused input-validation exports, tag builder plus its spec | none | | [ ] | L9 | M · PR | Replace the sorted-table utility with DataTable | none | @@ -145,7 +145,7 @@ Serial; each step needs the previous one. Verify each in the browser (`__mt.chec | Done | ID | Size | Step | Blocked by | |---|---|---|---|---| -| [ ] | CL1 | M · PR | Delete the DOM-wrapper utility library, the ref-with-utils hook and `onDOMReady` | T7, all L, U2 | +| [ ] | CL1 | M · PR | Delete the DOM-wrapper utility library, the ref-with-utils hook and `onDOMReady` (the app has none since L6; `email-handler.html`, a separate Vite entry, still imports `qs` and `onDOMReady` from `utils/dom`, so migrate that page first) | T7, all L, U2 | | [ ] | CL2 | M · PR | DOM-query / `innerHTML` lint ban, with the server-HTML allowlist (notifications, simple modal, version history, quote-search highlight) | CL1 | | [ ] | CL3 | M · PR | **Announced removal of legacy theme selectors** (old ids, classes, `letter`): the single break for user custom CSS; delete remaining SCSS | T1, L11, L12 | | [ ] | CL4 | S · commit | Update coding standards and agent docs to describe only the final patterns | CL1 | diff --git a/frontend/__tests__/components/core/NoCssDetails.spec.tsx b/frontend/__tests__/components/core/NoCssDetails.spec.tsx new file mode 100644 index 000000000000..9642a4bfdcbc --- /dev/null +++ b/frontend/__tests__/components/core/NoCssDetails.spec.tsx @@ -0,0 +1,51 @@ +import { render } from "@solidjs/testing-library"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +import { NoCssDetails } from "../../../src/ts/components/core/NoCssDetails"; + +function text(id: string): string { + return (document.getElementById(id)?.textContent ?? "") + .replace(/\s+/g, " ") + .trim(); +} + +describe("NoCssDetails", () => { + beforeEach(() => { + document.body.innerHTML = ` +
+ + + +
`; + document.head.innerHTML = ``; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + document.head.innerHTML = ""; + }); + + it("shows Ctrl outside mac", () => { + vi.stubGlobal("navigator", { userAgent: "Mozilla/5.0 (X11; Linux)" }); + render(() => ); + + expect(text("nocss-keys")).toBe("Ctrl + Shift + R"); + }); + + it("shows Cmd on mac", () => { + vi.stubGlobal("navigator", { userAgent: "Mozilla/5.0 (Macintosh)" }); + render(() => ); + + expect(text("nocss-keys")).toBe("Cmd + Shift + R"); + }); + + it("lists only the app stylesheets and the client version", () => { + render(() => ); + + expect(text("nocss-stylesheets")).toBe( + `Requested stylesheets:${location.origin}/css/style.abc.css`, + ); + expect(text("nocss-js")).toMatch(/^Requested Javascript files:/); + expect(text("nocss-js")).toMatch(/Client version:\S+$/); + }); +}); diff --git a/frontend/__tests__/root/boot.spec.ts b/frontend/__tests__/root/boot.spec.ts new file mode 100644 index 000000000000..57d9858bd6d9 --- /dev/null +++ b/frontend/__tests__/root/boot.spec.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi } from "vitest"; + +const { config, auth } = vi.hoisted(() => ({ + config: Promise.withResolvers(), + auth: Promise.withResolvers(), +})); + +vi.mock("../../src/ts/config/lifecycle", () => ({ + configLoadPromise: config.promise, +})); +vi.mock("../../src/ts/firebase", () => ({ authPromise: auth.promise })); +vi.mock("../../src/ts/elements/merch-banner", () => ({ + showIfNotClosedBefore: vi.fn(), +})); +vi.mock("../../src/ts/elements/psa", () => ({ init: vi.fn() })); +vi.mock("../../src/ts/states/connection", () => ({ initConnection: vi.fn() })); +vi.mock("../../src/ts/ape/server-configuration", () => ({ + sync: vi.fn(async () => undefined), +})); +vi.mock("../../src/ts/utils/env", () => ({ isDevEnvironment: () => false })); + +import * as ServerConfiguration from "../../src/ts/ape/server-configuration"; +import { boot } from "../../src/ts/boot"; +import * as MerchBanner from "../../src/ts/elements/merch-banner"; +import * as Psa from "../../src/ts/elements/psa"; +import { initConnection } from "../../src/ts/states/connection"; +import { isAppReady } from "../../src/ts/states/core"; + +describe("boot", () => { + it("waits for config, then auth, before the app is ready", async () => { + const booted = boot(); + + // started before awaiting, so the first auth state change shows PSAs + expect(initConnection).toHaveBeenCalledOnce(); + expect(Psa.init).toHaveBeenCalledOnce(); + + auth.resolve(undefined); + await Promise.resolve(); + expect(isAppReady()).toBe(false); + + config.resolve(undefined); + await booted; + + expect(isAppReady()).toBe(true); + expect(MerchBanner.showIfNotClosedBefore).toHaveBeenCalledOnce(); + expect(ServerConfiguration.sync).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/src/html/warnings.html b/frontend/src/html/warnings.html index 36840c09f75a..6a4763a7baa8 100644 --- a/frontend/src/html/warnings.html +++ b/frontend/src/html/warnings.html @@ -108,53 +108,7 @@ It seems like the CSS failed to load. Please try clearing your cache to redownload the styles:
- - - Ctrl - - / - - Cmd - - + - - Shift - - + - - R - - +
You could also try to reinstall the service worker:
@@ -172,11 +126,7 @@ If that doesn't help contact support (support@monkeytype.com or discord.gg/monkeytype) - - If the website works for a bit but then this screen comes back, clear your - cache again and then on Monkeytype open the command line (esc) and search - for "Clear SW cache". - - + + diff --git a/frontend/src/ts/ready.ts b/frontend/src/ts/boot.ts similarity index 75% rename from frontend/src/ts/ready.ts rename to frontend/src/ts/boot.ts index cde35b5fe3f3..d734eae721fb 100644 --- a/frontend/src/ts/ready.ts +++ b/frontend/src/ts/boot.ts @@ -1,13 +1,18 @@ -import * as MonkeyPower from "./elements/monkey-power"; -import * as MerchBanner from "./elements/merch-banner"; import * as ServerConfiguration from "./ape/server-configuration"; import { configLoadPromise } from "./config/lifecycle"; +import * as MerchBanner from "./elements/merch-banner"; +import * as Psa from "./elements/psa"; import { authPromise } from "./firebase"; -import { onDOMReady } from "./utils/dom"; -import { isDevEnvironment } from "./utils/env"; +import { initConnection } from "./states/connection"; import { setAppReady } from "./states/core"; +import { isDevEnvironment } from "./utils/env"; + +/** App startup work that doesn't touch the DOM. Call once, right after render. */ +export async function boot(): Promise { + // before awaiting: PSAs show on the first auth state change + initConnection(); + Psa.init(); -onDOMReady(async () => { await configLoadPromise; await authPromise; @@ -17,8 +22,10 @@ onDOMReady(async () => { void ServerConfiguration.sync(); - MonkeyPower.init(); + registerServiceWorker(); +} +function registerServiceWorker(): void { if (isDevEnvironment()) { void navigator.serviceWorker .getRegistrations() @@ -44,4 +51,4 @@ onDOMReady(async () => { }); } } -}); +} diff --git a/frontend/src/ts/components/core/App.tsx b/frontend/src/ts/components/core/App.tsx index f6bbc3ae99d8..c1117847447f 100644 --- a/frontend/src/ts/components/core/App.tsx +++ b/frontend/src/ts/components/core/App.tsx @@ -3,8 +3,9 @@ import { RouterContextProvider, RouterProvider as RouteRenderer, } from "@tanstack/solid-router"; -import { JSXElement } from "solid-js"; +import { JSXElement, onMount } from "solid-js"; +import * as MonkeyPower from "../../elements/monkey-power"; import { queryClient } from "../../queries"; import { router } from "../../router/app-router"; import { getGlobalOffsetTop, isAppReady } from "../../states/core"; @@ -23,9 +24,13 @@ import { BarTimerProgress } from "../pages/test/live-stats/BarTimerProgress"; import { CustomBackground } from "./CustomBackground"; import { Font } from "./Font"; import { Funbox } from "./Funbox"; +import { NoCssDetails } from "./NoCssDetails"; import { Theme } from "./Theme"; export function App(): JSXElement { + // until monkey power owns its canvas (L3) + onMount(() => MonkeyPower.init()); + return ( @@ -66,6 +71,7 @@ export function App(): JSXElement { + ); } diff --git a/frontend/src/ts/components/core/NoCssDetails.tsx b/frontend/src/ts/components/core/NoCssDetails.tsx new file mode 100644 index 000000000000..87b0e9252e9c --- /dev/null +++ b/frontend/src/ts/components/core/NoCssDetails.tsx @@ -0,0 +1,93 @@ +import { For, JSXElement, Show } from "solid-js"; +import { Portal } from "solid-js/web"; +import { envConfig } from "virtual:env-config"; + +const keyStyle = { + padding: "1rem", + display: "inline-block", + "border-radius": "1rem", + background: "#2c2e31", +}; + +/** + * Fills the static no-CSS warning (warnings.html) with the reload shortcut for + * this platform and the requested stylesheets and scripts. Inline styles only: + * it shows when the CSS failed to load. + */ +export function NoCssDetails(): JSXElement { + const isMac = window.navigator.userAgent.toLowerCase().includes("mac"); + const stylesheets = ( + [...document.querySelectorAll("link[rel=stylesheet]")] as HTMLLinkElement[] + ) + .map((l) => l.href) + .filter((l) => /\/css\/style/gi.test(l)); + const scripts = ( + [...document.querySelectorAll("script")] as HTMLScriptElement[] + ) + .map((l) => l.src) + .filter((l) => /(\/js\/mon|\/js\/vendor)/gi.test(l)); + + return ( + <> + + + {isMac ? "Cmd" : "Ctrl"} + {" "} + + Shift + R + + + Requested stylesheets: + + {(href) => ( + <> +
+ {href} + + )} +
+
+ + Requested Javascript files: + + {(src) => ( + <> +
+ {src} + + )} +
+
+
+ Client version: +
+ {envConfig.clientVersion} +
+ + ); +} + +/** Renders into an empty mount point of the static warning markup. */ +function Mount(props: { + at: HTMLElement | null; + children: JSXElement; +}): JSXElement { + return ( + + {(el) => ( + (wrapper.style.display = "contents")} + > + {props.children} + + )} + + ); +} diff --git a/frontend/src/ts/elements/no-css.ts b/frontend/src/ts/elements/no-css.ts deleted file mode 100644 index c12d08cbe370..000000000000 --- a/frontend/src/ts/elements/no-css.ts +++ /dev/null @@ -1,97 +0,0 @@ -// queries only static markup from index.html (#nocss, links, scripts) -/* oxlint-disable monkeytype-rules/no-module-scope-dom-query */ -import { envConfig } from "virtual:env-config"; -import { qs } from "../utils/dom"; - -qs("#nocss .requestedStylesheets")?.setHtml( - `Requested stylesheets:
${( - [...document.querySelectorAll("link[rel=stylesheet")] as HTMLAnchorElement[] - ) - .map((l) => l.href) - .filter((l) => /\/css\/style/gi.test(l)) - .join("
")}`, -); - -qs("#nocss .requestedJs")?.setHtml( - `Requested Javascript files:
${( - [...document.querySelectorAll("script")] as HTMLScriptElement[] - ) - .map((l) => l.src) - .filter((l) => /(\/js\/mon|\/js\/vendor)/gi.test(l)) - .join("
")}

Client version:
${envConfig.clientVersion}`, -); - -if (window.navigator.userAgent.toLowerCase().includes("mac")) { - qs("#nocss .keys")?.setHtml(` - - Cmd - - + - - Shift - - + - - R - - `); -} else { - qs("#nocss .keys")?.setHtml(` - - Ctrl - - + - - Shift - - + - - R - - `); -} diff --git a/frontend/src/ts/elements/psa.tsx b/frontend/src/ts/elements/psa.tsx index a86b81c8f414..34173168adc3 100644 --- a/frontend/src/ts/elements/psa.tsx +++ b/frontend/src/ts/elements/psa.tsx @@ -203,8 +203,11 @@ export async function show(): Promise { }); } -authEvent.subscribe((event) => { - if (event.type === "authStateChanged") { - void show(); - } -}); +/** Shows the PSAs on every auth state change. */ +export function init(): void { + authEvent.subscribe((event) => { + if (event.type === "authStateChanged") { + void show(); + } + }); +} diff --git a/frontend/src/ts/index.tsx b/frontend/src/ts/index.tsx index 404a438d18b8..64a115cc1df5 100644 --- a/frontend/src/ts/index.tsx +++ b/frontend/src/ts/index.tsx @@ -17,21 +17,18 @@ import { Config } from "./config/store"; import * as TestTimer from "./test/test-timer"; import { toggleSmoothedBurst } from "./states/result"; import { onAuthStateChanged } from "./auth"; -import "./elements/no-css"; -import "./states/connection"; import "./test/tts"; import { addToGlobal } from "./utils/misc"; import { fetchLatestVersion } from "./utils/version"; import * as Sentry from "./sentry"; import * as Cookies from "./cookies"; -import "./elements/psa"; import { applyEngineSettings } from "./anim"; import { qs, qsa, qsr } from "./utils/dom"; import { render } from "solid-js/web"; import { App } from "./components/core/App"; -import "./ready"; import { setVersion } from "./states/core"; import { loadFromLocalStorage } from "./config/lifecycle"; +import { boot } from "./boot"; import "./input/hotkeys"; import { showModal } from "./states/modals"; @@ -64,6 +61,7 @@ if (import.meta.env.DEV) { // render before anything that might touch component-rendered DOM (eg. test page). // Renders after the static markup in (index.html) render(() => , document.body); +void boot(); applyEngineSettings(); void loadFromLocalStorage(); diff --git a/frontend/src/ts/states/connection.ts b/frontend/src/ts/states/connection.ts index 250e1f3d1e89..49ed0df372e8 100644 --- a/frontend/src/ts/states/connection.ts +++ b/frontend/src/ts/states/connection.ts @@ -1,7 +1,6 @@ import { createSignal } from "solid-js"; import { debounce } from "throttle-debounce"; import { showSuccessNotification } from "./notifications"; -import { onDOMReady } from "../utils/dom"; import { addBanner, removeBanner } from "./banners"; import { isTestActive } from "./test"; @@ -45,11 +44,11 @@ function handleConnectionChange(newState: boolean): void { throttledHandleState(); } -onDOMReady(() => { +export function initConnection(): void { window.addEventListener("online", () => handleConnectionChange(true)); window.addEventListener("offline", () => handleConnectionChange(false)); setOnline(navigator.onLine); if (!isOnline()) { showOfflineBanner(); } -}); +} diff --git a/frontend/src/ts/states/core.ts b/frontend/src/ts/states/core.ts index 5cca4c3ecbd4..42b6b452a1c3 100644 --- a/frontend/src/ts/states/core.ts +++ b/frontend/src/ts/states/core.ts @@ -23,7 +23,7 @@ export const [getThemeIndicator, setThemeIndicator] = createSignal<{ }); export const [getGlobalOffsetTop, setGlobalOffsetTop] = createSignal(0); -// #app stays hidden until the config and auth are loaded (see ready.ts) +// #app stays hidden until the config and auth are loaded (see boot.ts) export const [isAppReady, setAppReady] = createSignal(false); export const [getIsScreenshotting, setIsScreenshotting] = createSignal(false);