diff --git a/docs/specs/solid-monkeytype/roadmap.md b/docs/specs/solid-monkeytype/roadmap.md index b0885eb8352a..ae4defb845ca 100644 --- a/docs/specs/solid-monkeytype/roadmap.md +++ b/docs/specs/solid-monkeytype/roadmap.md @@ -112,8 +112,8 @@ Serial; each step needs the previous one. Verify each in the browser (`__mt.chec | [ ] | 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()`); 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 | +| [x] | 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 | Done in [#8564](https://github.com/monkeytypegame/monkeytype/pull/8564): a module-level live query per collection behind plain getters, plus a session download status (`getTagsStatus()` etc.: loading/loaded/failed) and `reloadCustomThemes()`. Tags keep `__nonReactive` for `test-logic` and `result-pb` | +| [ ] | 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. Since U5 only the tags module (test-page callers) and `states/test`/results keep one | ## Track EP: engine prep (small, parallel) diff --git a/docs/specs/solid-monkeytype/spec.md b/docs/specs/solid-monkeytype/spec.md index e4bb07a5e83f..f4e9a32753b7 100644 --- a/docs/specs/solid-monkeytype/spec.md +++ b/docs/specs/solid-monkeytype/spec.md @@ -142,6 +142,9 @@ Users should see no behaviour change, except where a bug at today's seams disapp - **Auth**: auth state is a signal (unknown, signed in, signed out), which the router's auth context and UI read. The user-data load sits next to it as state (loading, loaded, failed with an error) plus a `reloadUserData()` command; the router's auth context reads it for the blocking user-data step (user row, tags, presets) and its retry, instead of receiving a promise through an event. The auth bus (both its auth-state-changed and snapshot-updated events) is deleted. - **Server data**: Query for read-only server data, DB collections for user-owned lists (results, tags, presets, connections, ape keys, inbox, custom themes, filter presets) and the single-row user collection above. - Small collections (tags, presets, custom themes) use the same read pattern as the user: each module exports plain getters (eg. `getTags()`, `getActiveTags()`) backed by a module-level reactive mirror, reactive in components and synchronous elsewhere. They replace both `__nonReactive` and the separate live-query hooks for these collections, are documented as "returns only what's loaded, never fetches", and keep `gcTime: Infinity`. + - The mirror is a module-level live query (`useLiveQuery` in a root that lives as long as the page). Getters read its `.state` (single-row getters like `getTag(id)` track only their row) and never call the query itself, which suspends: the getters never suspend. + - Since nothing suspends, each collection exports its session download status (`getTagsStatus()`, `getPresetsStatus()`, `getCustomThemesStatus()`: loading, loaded or failed, like the user-data load), from `createSessionDownload`; UI that needs a loading state switches on that status, not `Suspense`. + - A collection's rows outlive sign out (the user query reset doesn't clear them), so the mirror, the status and the blocking `waitFor…Ready` calls wait for the download since the current sign in, which the module starts on sign in. A failed download settles the wait (sign in doesn't hang) and sets the status to failed; UI with a loading state shows the failure with a retry (`reloadCustomThemes()`, like `reloadUserData()`). - `__nonReactive` exists only for old-style code. It is not replaced one-for-one: each caller is rewritten as Solid code (or moved into the engine) as part of its own step, and `__nonReactive` is deleted once the last caller is gone. Reading a getter outside a tracking scope, as the engine does at finish, is ordinary Solid usage, not an escape hatch. Sign-in already waits for tags and presets inside the blocking user-data step, so they're loaded before any test can start; no extra blocking data is added. - Large reads (all results) use the async `*Once` form and stay off hot paths. - **UI state**: module-level signals and stores grouped by feature in the states area, created with the existing helpers. No plain `let` state that UI reads. Engine state is not here (see below). @@ -272,6 +275,11 @@ From commandline metadata (C5): generated config commands use the settings label - Renamed options: key tips, out of focus warning and caps lock warning show/hide (were on/off); error sound 4 "missed punch" (was "punch miss"); layout "default" (was "off"); random theme "favorite" (was "fav"); pace caret "tag pb" (was "tagPb"). - Kept commandline labels: blind mode on/off (settings shows a blank "on" for button height), quote length, sound volume, font family and keymap layout (no settings label of their own to share). +From user data (U5): + +- The settings custom themes list shows a loading circle until the custom themes load, and "Failed to load custom themes" with a retry button if the download fails (before: an empty list either way). Custom themes aren't part of the blocking sign-in step. +- After signing out and back in, tags, presets and custom themes show nothing (and the sign-in wait for tags and presets holds) until the new session's download lands; before, the previous session's rows showed, reported as loaded. + ## Settled decisions (grilling, 2026-10-24) | # | Decision | @@ -303,7 +311,7 @@ From commandline metadata (C5): generated config commands use the settings label | Q25 | U1 adds the collection and converts all writers; a read-only `DB.getSnapshot()` shim stays until readers move, then is deleted | | Q26 | Quote ratings stay in the user row as an optimistic action; #7717 closed | | Q27 | One `getUser()` read: reactive in components, synchronous elsewhere | -| Q28 | Same one-getter pattern for tags, presets and custom themes (replaces their live-query hooks); results stay async. `__nonReactive` goes by rewriting its old-style callers, then deleting it | +| Q28 | Same one-getter pattern for tags, presets and custom themes (replaces their live-query hooks); results stay async. `__nonReactive` goes by rewriting its old-style callers, then deleting it. Amended in U5: the mirror is a module-level live query, plus a session download status per collection (loading/loaded/failed) since the getters never suspend | | Q29 | Auth bus deleted entirely; the user-data load is state (loading/loaded/failed) plus `reloadUserData()` beside the auth signal, read by the router | EventLog (grilling, 2026-10-26): diff --git a/frontend/__tests__/collections/custom-themes.spec.ts b/frontend/__tests__/collections/custom-themes.spec.ts index 30ca40a81937..78dad58a26eb 100644 --- a/frontend/__tests__/collections/custom-themes.spec.ts +++ b/frontend/__tests__/collections/custom-themes.spec.ts @@ -1,21 +1,40 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createEffect, createRoot } from "solid-js"; vi.mock("../../src/ts/ape", () => ({ default: { users: { getCustomThemes: vi.fn(async () => ({ status: 200, - body: { data: [] }, + body: { + data: [ + { _id: "c2", name: "zebra", colors: [] }, + { _id: "c1", name: "mine", colors: [] }, + ], + }, })), + deleteCustomTheme: vi.fn(async () => ({ status: 200, body: {} })), }, }, })); import Ape from "../../src/ts/ape"; -import "../../src/ts/collections/custom-themes"; +import { + getCustomThemesStatus, + reloadCustomThemes, + deleteCustomTheme, + getCustomTheme, + getCustomThemes, +} from "../../src/ts/collections/custom-themes"; import { queryClient } from "../../src/ts/queries/index"; import { setUserId } from "../../src/ts/states/core"; +const isLoaded = (): boolean => getCustomThemesStatus() === "loaded"; +const hasFailed = (): boolean => getCustomThemesStatus() === "failed"; + +const names = (themes: { name: string }[]): string[] => + themes.map((it) => it.name); + /** Lets a queued effect update run (a set signal flushes on the next turn). */ async function flush(): Promise { await new Promise((resolve) => setTimeout(resolve, 0)); @@ -65,4 +84,93 @@ describe("customThemes collection", () => { await waitFor(hasDownloaded, "the customThemes query to fetch"); expect(Ape.users.getCustomThemes).toHaveBeenCalled(); }); + + it("is empty while signed out", () => { + expect(getCustomThemes()).toEqual([]); + }); + + it("returns the loaded themes sorted by name", async () => { + setUserId("uid"); + await waitFor(() => getCustomThemes().length === 2, "the themes to load"); + expect(names(getCustomThemes())).toEqual(["mine", "zebra"]); + expect(getCustomTheme("c2")?.name).toEqual("zebra"); + }); + + it("reads a delete synchronously and re-runs effects", async () => { + setUserId("uid"); + await waitFor(() => getCustomThemes().length === 2, "the themes to load"); + const seen: string[][] = []; + const dispose = createRoot((dispose) => { + createEffect(() => seen.push(names(getCustomThemes()))); + return dispose; + }); + + const deleted = deleteCustomTheme({ themeId: "c2" }); + + expect(getCustomTheme("c2")).toBeUndefined(); + expect(seen.at(-1)).toEqual(["mine"]); + await deleted; + dispose(); + }); + + it("empties on sign out", async () => { + setUserId("uid"); + await waitFor(() => getCustomThemes().length > 0, "the themes to load"); + setUserId(null); + expect(getCustomThemes()).toEqual([]); + }); + + it("reports loaded only once signed in and downloaded", async () => { + expect(getCustomThemesStatus()).toBe("loading"); + setUserId("uid"); + await waitFor(isLoaded, "the themes to load"); + setUserId(null); + expect(getCustomThemesStatus()).toBe("loading"); + }); + + it("drops the previous user's themes until the new ones load", async () => { + setUserId("uid"); + await waitFor(isLoaded, "the themes to load"); + setUserId(null); + + let release = (): void => undefined; + vi.mocked(Ape.users.getCustomThemes).mockImplementationOnce( + async () => + new Promise((resolve) => { + release = () => + resolve({ + status: 200, + body: { data: [{ _id: "c3", name: "other", colors: [] }] }, + } as never); + }), + ); + setUserId("uid2"); + await flush(); + + expect(getCustomThemes()).toEqual([]); + expect(getCustomThemesStatus()).toBe("loading"); + + release(); + await waitFor(isLoaded, "the new themes to load"); + expect(names(getCustomThemes())).toEqual(["other"]); + }); + + it("reports a failed download, and loads on reload", async () => { + // no retries: the failure is final at once + queryClient.setQueryDefaults(["user", "customThemes"], { retry: false }); + vi.mocked(Ape.users.getCustomThemes).mockRejectedValueOnce( + new Error("offline"), + ); + setUserId("uid4"); + await waitFor(hasFailed, "the download to fail"); + + expect(getCustomThemesStatus()).toBe("failed"); + + reloadCustomThemes(); + expect(getCustomThemesStatus()).toBe("loading"); + await waitFor(isLoaded, "the themes to load on reload"); + expect(getCustomThemesStatus()).not.toBe("failed"); + expect(getCustomThemes()).toHaveLength(2); + queryClient.setQueryDefaults(["user", "customThemes"], {}); + }); }); diff --git a/frontend/__tests__/collections/presets.spec.ts b/frontend/__tests__/collections/presets.spec.ts index 8aa86ba09371..0d7ed24ffee2 100644 --- a/frontend/__tests__/collections/presets.spec.ts +++ b/frontend/__tests__/collections/presets.spec.ts @@ -1,28 +1,35 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createEffect, createRoot } from "solid-js"; vi.mock("../../src/ts/ape", () => ({ default: { presets: { get: vi.fn(async () => ({ status: 200, - body: { data: [{ _id: "p1", name: "my_preset", config: {} }] }, + body: { + data: [ + { _id: "p2", name: "zebra", config: {} }, + { _id: "p1", name: "my_preset", config: {} }, + ], + }, })), + delete: vi.fn(async () => ({ status: 200, body: {} })), }, }, })); import { - __nonReactive, + getPresetsStatus, + deletePreset, + getPreset, + getPresets, waitForPresetsReady, } from "../../src/ts/collections/presets"; import { setUserId } from "../../src/ts/states/core"; -/** - * The commandline reads presets through `__nonReactive`, which doesn't - * subscribe. Loaded only by `waitForPresetsReady` in the login flow, the - * collection has no subscriber outside the settings page, so it must not be - * garbage collected (TanStack DB's default collection gcTime is 5 min). - */ +const names = (presets: { name: string }[]): string[] => + presets.map((it) => it.name); + describe("presets collection", () => { beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); @@ -30,19 +37,64 @@ describe("presets collection", () => { afterEach(() => { vi.useRealTimers(); + setUserId(null); + }); + + it("is empty while signed out", () => { + expect(getPresets()).toEqual([]); }); - it("keeps its data with no subscriber", async () => { + it("returns the loaded presets sorted by name", async () => { + setUserId("uid"); + await waitForPresetsReady(); + expect(names(getPresets())).toEqual(["my preset", "zebra"]); + expect(getPreset("p2")?.name).toEqual("zebra"); + }); + + /** + * The commandline reads presets outside a tracking scope. Loaded only by + * `waitForPresetsReady` in the login flow, the collection must not be + * garbage collected (TanStack DB's default collection gcTime is 5 min). + */ + it("keeps its data over time", async () => { setUserId("uid"); await waitForPresetsReady(); - expect(__nonReactive.getPresets().map((it) => it.name)).toEqual([ - "my preset", - ]); await vi.advanceTimersByTimeAsync(10 * 60_000); - expect(__nonReactive.getPresets().map((it) => it.name)).toEqual([ - "my preset", - ]); + expect(names(getPresets())).toEqual(["my preset", "zebra"]); + }); + + it("reads a delete synchronously and re-runs effects", async () => { + setUserId("uid"); + await waitForPresetsReady(); + const seen: string[][] = []; + const dispose = createRoot((dispose) => { + createEffect(() => seen.push(names(getPresets()))); + return dispose; + }); + + const deleted = deletePreset({ presetId: "p2" }); + + expect(getPreset("p2")).toBeUndefined(); + expect(seen.at(-1)).toEqual(["my preset"]); + await deleted; + dispose(); + }); + + it("empties on sign out", async () => { + setUserId("uid"); + await waitForPresetsReady(); + setUserId(null); + expect(getPresets()).toEqual([]); + }); + + it("reports loaded only once signed in and downloaded", async () => { + expect(getPresetsStatus()).toBe("loading"); + setUserId("uid"); + await waitForPresetsReady(); + expect(getPresetsStatus()).toBe("loaded"); + setUserId(null); + expect(getPresetsStatus()).toBe("loading"); }); }); diff --git a/frontend/__tests__/collections/tags.spec.ts b/frontend/__tests__/collections/tags.spec.ts index 77e8d97a7cdb..a3cd1d787441 100644 --- a/frontend/__tests__/collections/tags.spec.ts +++ b/frontend/__tests__/collections/tags.spec.ts @@ -1,20 +1,28 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createRoot, createEffect } from "solid-js"; vi.mock("../../src/ts/ape/user", () => ({ fetchUserFromApi: vi.fn(async () => ({ - tags: [{ _id: "t1", name: "my_tag", personalBests: {} }], + tags: [ + { _id: "t2", name: "zebra", personalBests: {} }, + { _id: "t1", name: "my_tag", personalBests: {} }, + ], })), })); -import { __nonReactive, waitForTagsReady } from "../../src/ts/collections/tags"; +import { + getTagsStatus, + getActiveTags, + getTag, + getTags, + toggleTagActive, + waitForTagsReady, +} from "../../src/ts/collections/tags"; +import { fetchUserFromApi } from "../../src/ts/ape/user"; import { setUserId } from "../../src/ts/states/core"; -/** - * The commandline and result saving read tags through `__nonReactive`, which - * doesn't subscribe. Loaded only by `waitForTagsReady` in the login flow, the - * collection must not be garbage collected while nothing subscribes - * (TanStack DB's default collection gcTime is 5 min). - */ +const names = (tags: { name: string }[]): string[] => tags.map((it) => it.name); + describe("tags collection", () => { beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); @@ -22,15 +30,129 @@ describe("tags collection", () => { afterEach(() => { vi.useRealTimers(); + setUserId(null); + }); + + it("is empty while signed out", () => { + expect(getTags()).toEqual([]); + }); + + it("returns the loaded tags sorted by name", async () => { + setUserId("uid"); + await waitForTagsReady(); + expect(names(getTags())).toEqual(["my tag", "zebra"]); + expect(getTag("t2")?.name).toEqual("zebra"); }); - it("keeps its data with no subscriber", async () => { + /** + * The commandline and result saving read tags outside a tracking scope. + * Loaded only by `waitForTagsReady` in the login flow, the collection must + * not be garbage collected (TanStack DB's default collection gcTime is 5 min). + */ + it("keeps its data over time", async () => { setUserId("uid"); await waitForTagsReady(); - expect(__nonReactive.getTags().map((it) => it.name)).toEqual(["my tag"]); await vi.advanceTimersByTimeAsync(10 * 60_000); - expect(__nonReactive.getTags().map((it) => it.name)).toEqual(["my tag"]); + expect(names(getTags())).toEqual(["my tag", "zebra"]); + }); + + it("reads a toggle synchronously and re-runs effects", async () => { + setUserId("uid"); + await waitForTagsReady(); + const seen: string[][] = []; + const dispose = createRoot((dispose) => { + createEffect(() => seen.push(names(getActiveTags()))); + return dispose; + }); + + void toggleTagActive({ tagId: "t1", noSave: true }); + + expect(getTag("t1")?.active).toBe(true); + expect(seen.at(-1)).toEqual(["my tag"]); + dispose(); + void toggleTagActive({ tagId: "t1", noSave: true }); + }); + + it("empties on sign out", async () => { + setUserId("uid"); + await waitForTagsReady(); + setUserId(null); + expect(getTags()).toEqual([]); + }); + + it("reports loaded only once signed in and downloaded", async () => { + expect(getTagsStatus()).toBe("loading"); + setUserId("uid"); + await waitForTagsReady(); + expect(getTagsStatus()).toBe("loaded"); + setUserId(null); + expect(getTagsStatus()).toBe("loading"); + }); + + it("re-runs a single-tag reader only for its own tag", async () => { + setUserId("uid"); + await waitForTagsReady(); + let runs = 0; + const dispose = createRoot((dispose) => { + createEffect(() => { + getTag("t2"); + runs++; + }); + return dispose; + }); + + void toggleTagActive({ tagId: "t1", noSave: true }); + void toggleTagActive({ tagId: "t1", noSave: true }); + expect(runs).toBe(1); + + void toggleTagActive({ tagId: "t2", noSave: true }); + expect(runs).toBe(2); + dispose(); + void toggleTagActive({ tagId: "t2", noSave: true }); + }); + + it("drops the previous user's tags on sign out", async () => { + setUserId("uid"); + await waitForTagsReady(); + setUserId(null); + + let release = (): void => undefined; + vi.mocked(fetchUserFromApi).mockImplementationOnce( + async () => + new Promise((resolve) => { + release = () => + resolve({ + tags: [{ _id: "t3", name: "other", personalBests: {} }], + } as never); + }), + ); + setUserId("uid2"); + await vi.advanceTimersByTimeAsync(0); + + expect(getTags()).toEqual([]); + expect(getTagsStatus()).toBe("loading"); + + release(); + await waitForTagsReady(); + expect(names(getTags())).toEqual(["other"]); + expect(getTagsStatus()).toBe("loaded"); + }); + + it("doesn't hang sign in when the download fails", async () => { + // the first try and the query's 3 retries + for (let i = 0; i < 4; i++) { + vi.mocked(fetchUserFromApi).mockRejectedValueOnce(new Error("offline")); + } + setUserId("uid3"); + + const ready = waitForTagsReady(); + // the retries back off 1s, 2s, 4s + await vi.advanceTimersByTimeAsync(10_000); + await ready; + + expect(getTagsStatus()).toBe("failed"); + expect(getTags()).toEqual([]); }); }); diff --git a/frontend/__tests__/components/pages/test/result/ResultChart.spec.tsx b/frontend/__tests__/components/pages/test/result/ResultChart.spec.tsx index d3fcc67b4d36..91704731ee91 100644 --- a/frontend/__tests__/components/pages/test/result/ResultChart.spec.tsx +++ b/frontend/__tests__/components/pages/test/result/ResultChart.spec.tsx @@ -1,29 +1,18 @@ // static json (layouts...) read from disk, the test state module loads some import "../../../../__harness__/mock-static"; import { render } from "@solidjs/testing-library"; -import { Suspense } from "solid-js"; import { afterEach, describe, expect, it, vi } from "vitest"; -// tags() suspends until the tags load (never, here), tags.state fills in when they do +// the tag getter returns only loaded tags, they fill in when they load const tagNames = vi.hoisted(() => ({ set: undefined as ((id: string, name: string) => void) | undefined, })); vi.mock("../../../../../src/ts/collections/tags", async () => { - const { createResource } = await import("solid-js"); const { createStore } = await import("solid-js/store"); + const [names, setNames] = createStore>({}); + tagNames.set = (id, name) => setNames(id, { name }); return { - useTagsLiveQuery: () => { - const [names, setNames] = createStore>( - {}, - ); - tagNames.set = (id, name) => setNames(id, { name }); - const [pending] = createResource( - async () => new Promise(() => undefined), - ); - return Object.assign(() => pending(), { - state: { get: (id: string) => names[id] }, - }); - }, + getTag: (id: string) => names[id], }; }); vi.mock("../../../../../src/ts/db", () => ({ @@ -63,23 +52,20 @@ describe("ResultChart", () => { setResultState("tags", [{ id: "tag1", isPb: false, chartPb: 100 }]); setResultShown(true); - const { queryByTestId, getByTestId, queryByText } = render(() => ( - suspended}> - - + const { queryByTestId, getByTestId } = render(() => ( + )); - expect(queryByText("suspended")).toBeNull(); expect(queryByTestId("chart")).not.toBeNull(); expect(getByTestId("chart")).not.toHaveTextContent("PB"); diff --git a/frontend/__tests__/controllers/preset-controller.spec.ts b/frontend/__tests__/controllers/preset-controller.spec.ts index 05ab3c362db0..b56aa4621154 100644 --- a/frontend/__tests__/controllers/preset-controller.spec.ts +++ b/frontend/__tests__/controllers/preset-controller.spec.ts @@ -21,7 +21,7 @@ describe("PresetController", () => { // })); const dbGetSnapshotMock = vi.spyOn(DB, "getUserData"); - const getPresetMock = vi.spyOn(Presets.__nonReactive, "getPreset"); + const getPresetMock = vi.spyOn(Presets, "getPreset"); const configApplyMock = vi.spyOn(Lifecycle, "applyConfig"); const configSaveFullConfigMock = vi.spyOn( Persistence, diff --git a/frontend/src/ts/auth.tsx b/frontend/src/ts/auth.tsx index b70690724d58..6ff579d02252 100644 --- a/frontend/src/ts/auth.tsx +++ b/frontend/src/ts/auth.tsx @@ -173,7 +173,8 @@ async function getDataAndInit(): Promise { try { console.log("getting account data"); const snapshot = await DB.initSnapshot(); - //TODO: preload collections for now, remove when __nonReactive is removed from collections + // blocking user data: tags and presets are loaded before any test can + // start, so their getters (which never fetch) are ready for it await waitForPresetsReady(); await waitForTagsReady(); diff --git a/frontend/src/ts/collections/custom-themes.ts b/frontend/src/ts/collections/custom-themes.ts index 4a855fa8e76f..e7d5ba35d2d5 100644 --- a/frontend/src/ts/collections/custom-themes.ts +++ b/frontend/src/ts/collections/custom-themes.ts @@ -7,10 +7,13 @@ import { } from "@tanstack/solid-db"; import { createRoot } from "solid-js"; import Ape from "../ape"; -import { createEffectOn } from "../hooks/effects"; import { queryClient } from "../queries"; import { baseKey } from "../queries/utils/keys"; import { tempId, keepForever } from "./utils/misc"; +import { + createSessionDownload, + type SessionDownloadStatus, +} from "./utils/session-download"; import { isAuthenticated } from "../states/core"; export type CustomThemeItem = CustomTheme; @@ -19,15 +22,6 @@ const queryKeys = { root: () => [...baseKey("customThemes", { isUserSpecific: true })], }; -// oxlint-disable-next-line typescript/explicit-function-return-type -export function useCustomThemesLiveQuery() { - return useLiveQuery((q) => { - return q - .from({ customTheme: customThemesCollection }) - .orderBy(({ customTheme }) => customTheme.name, "asc"); - }); -} - const customThemesCollection = createCollection({ ...queryCollectionOptions({ ...keepForever, @@ -51,14 +45,19 @@ const customThemesCollection = createCollection({ ...keepForever, }); -// Its query is created disabled: startSync runs at module load, before the -// auth state resolves, and the query never re-evaluates `enabled` on its own. -// Kick the download when the user signs in, like the other user collections. -createRoot(() => - createEffectOn(isAuthenticated, (hasUser) => { - if (hasUser) { - void customThemesCollection.utils.refetch(); - } +// a fresh download per sign in: the rows outlive a sign out, so until it +// lands they may be the previous session's +const download = createSessionDownload(queryKeys.root(), async () => + customThemesCollection.utils.refetch(), +); + +// The module-level mirror the getters read: a live query owned by a root that +// lives as long as the page. Read `.state` and `.status` only: calling +// `customThemesQuery()` suspends +const customThemesQuery = createRoot(() => + useLiveQuery((q) => { + if (download.status() !== "loaded") return undefined; + return q.from({ customTheme: customThemesCollection }); }), ); @@ -149,14 +148,38 @@ const actions = { // --- Public API --- -function getCustomThemes(): CustomThemeItem[] { - return [...customThemesCollection.values()].sort((a, b) => +/** + * The loaded custom themes, sorted by name. Reactive in components and + * effects, a plain read elsewhere. Returns only what's loaded, never fetches. + */ +export function getCustomThemes(): CustomThemeItem[] { + return [...customThemesQuery.state.values()].sort((a, b) => a.name.localeCompare(b.name), ); } -function getCustomTheme(id: string): CustomThemeItem | undefined { - return customThemesCollection.get(id); +/** A loaded custom theme by id. Reactive like `getCustomThemes`. */ +export function getCustomTheme(id: string): CustomThemeItem | undefined { + return customThemesQuery.state.get(id); +} + +/** + * Whether the custom themes have loaded in this session: `loading` (also while signed + * out), `loaded` once the getters return them, or `failed`. + * Unlike tags and presets, sign in doesn't wait for them. Reactive. + */ +export function getCustomThemesStatus(): SessionDownloadStatus { + const status = download.status(); + // the getters' live query catches up a tick after the download + if (status === "loaded" && customThemesQuery.status !== "ready") { + return "loading"; + } + return status; +} + +/** Downloads the custom themes again, eg. after a failure. */ +export function reloadCustomThemes(): void { + download.reload(); } export async function addCustomTheme( @@ -179,11 +202,3 @@ export async function deleteCustomTheme( const transaction = actions.deleteCustomTheme(params); await transaction.when("settled"); } - -/** - * Used for non reactive access. Do not use in Solid components. - */ -export const __nonReactive = { - getCustomThemes, - getCustomTheme, -}; diff --git a/frontend/src/ts/collections/presets.ts b/frontend/src/ts/collections/presets.ts index 4585a81c4fc3..d80e907b83db 100644 --- a/frontend/src/ts/collections/presets.ts +++ b/frontend/src/ts/collections/presets.ts @@ -10,6 +10,11 @@ import { queryClient } from "../queries"; import { baseKey } from "../queries/utils/keys"; import { ConfigGroupName } from "@monkeytype/schemas/configs"; import { tempId, keepForever } from "./utils/misc"; +import { + createSessionDownload, + type SessionDownloadStatus, +} from "./utils/session-download"; +import { createRoot } from "solid-js"; import { isAuthenticated } from "../states/core"; import { replaceUnderscoresWithSpaces } from "../utils/strings"; @@ -19,16 +24,6 @@ const queryKeys = { root: () => [...baseKey("presets", { isUserSpecific: true })], }; -// oxlint-disable-next-line typescript/explicit-function-return-type -export function usePresetsLiveQuery() { - return useLiveQuery((q) => { - if (!isAuthenticated()) return undefined; - return q - .from({ preset: presetsCollection }) - .orderBy(({ preset }) => preset.name, "asc"); - }); -} - const presetsCollection = createCollection({ ...queryCollectionOptions({ ...keepForever, @@ -53,6 +48,22 @@ const presetsCollection = createCollection({ ...keepForever, }); +// a fresh download per sign in: the rows outlive a sign out, so until it +// lands they may be the previous session's +const download = createSessionDownload(queryKeys.root(), async () => + presetsCollection.utils.refetch(), +); + +// The module-level mirror the getters read: a live query owned by a root that +// lives as long as the page. Read `.state` and `.status` only: calling +// `presetsQuery()` suspends +const presetsQuery = createRoot(() => + useLiveQuery((q) => { + if (download.status() !== "loaded") return undefined; + return q.from({ preset: presetsCollection }); + }), +); + type ActionType = { addPreset: { name: string; @@ -163,17 +174,38 @@ const actions = { // --- Public API --- export async function waitForPresetsReady(): Promise { + // starts the sync on the first sign in; the download since this sign in + // may still be on its way after that (the rows outlive a sign out) await presetsCollection.stateWhenReady(); + await download.whenSettled(); } -function getPresets(): PresetItem[] { - return [...presetsCollection.values()].sort((a, b) => +/** + * The loaded presets, sorted by name. Reactive in components and effects, a + * plain read elsewhere. Returns only what's loaded, never fetches. + */ +export function getPresets(): PresetItem[] { + return [...presetsQuery.state.values()].sort((a, b) => a.name.localeCompare(b.name), ); } -function getPreset(id: string): PresetItem | undefined { - return presetsCollection.get(id); +/** A loaded preset by id. Reactive like `getPresets`, never fetches. */ +export function getPreset(id: string): PresetItem | undefined { + return presetsQuery.state.get(id); +} + +/** + * Whether the presets have loaded in this session: `loading` (also while signed + * out), `loaded` once the getters return them, or `failed`. Reactive. + */ +export function getPresetsStatus(): SessionDownloadStatus { + const status = download.status(); + // the getters' live query catches up a tick after the download + if (status === "loaded" && presetsQuery.status !== "ready") { + return "loading"; + } + return status; } export async function addPreset( @@ -196,11 +228,3 @@ export async function deletePreset( const transaction = actions.deletePreset(params); await transaction.when("settled"); } - -/** - * Used for non reactive access. Do not use in Solid components. - */ -export const __nonReactive = { - getPresets, - getPreset, -}; diff --git a/frontend/src/ts/collections/results.ts b/frontend/src/ts/collections/results.ts index 1dfc8ba7148c..f0484108ad43 100644 --- a/frontend/src/ts/collections/results.ts +++ b/frontend/src/ts/collections/results.ts @@ -34,7 +34,7 @@ import { getTagsOnce, reconcileLocalTagPB, saveLocalTagPB, - useActiveTagsLiveQuery, + getActiveTags, } from "./tags"; import { blockingDownloadOptions, @@ -611,8 +611,6 @@ export function useUserAverage10LiveQuery(options: { }; }); - const activeTagsQuery = useActiveTagsLiveQuery(); - const average = useLiveQuery((q) => { //disable query if (!isAuthenticated()) return undefined; @@ -622,7 +620,7 @@ export function useUserAverage10LiveQuery(options: { .from({ //we use sub-query to filter first and then aggregate last10: buildSettingsResultsQuery(settingsFilter(), { - tagIds: activeTagsQuery().map((it) => it._id), + tagIds: getActiveTags().map((it) => it._id), }) .orderBy(({ r }) => r.timestamp, "desc") .limit(10), diff --git a/frontend/src/ts/collections/tags.ts b/frontend/src/ts/collections/tags.ts index 4dc5685ac4b9..5e949f74aa33 100644 --- a/frontend/src/ts/collections/tags.ts +++ b/frontend/src/ts/collections/tags.ts @@ -23,6 +23,11 @@ import { import { Difficulty } from "@monkeytype/schemas/configs"; import { Language } from "@monkeytype/schemas/languages"; import { isTempId, tempId, keepForever } from "./utils/misc"; +import { + createSessionDownload, + type SessionDownloadStatus, +} from "./utils/session-download"; +import { createRoot } from "solid-js"; import { fetchUserFromApi } from "../ape/user"; import { updateTagsInFilterStorage } from "../states/result-filters"; import { isAuthenticated } from "../states/core"; @@ -60,16 +65,6 @@ const tagsCollection = createCollection({ ...keepForever, }); -// oxlint-disable-next-line typescript/explicit-function-return-type -export function useTagsLiveQuery() { - return useLiveQuery((q) => { - if (!isAuthenticated()) return undefined; - return q - .from({ tag: tagsCollection }) - .orderBy(({ tag }) => tag.name, "asc"); - }); -} - // oxlint-disable-next-line typescript/explicit-function-return-type export async function getActiveTagsOnce() { return queryOnce((q) => { @@ -87,16 +82,21 @@ export async function getTagsOnce() { }); } -// oxlint-disable-next-line typescript/explicit-function-return-type -export function useActiveTagsLiveQuery() { - return useLiveQuery((q) => { - if (!isAuthenticated()) return undefined; - return q - .from({ tag: tagsCollection }) - .where(({ tag }) => eq(tag.active, true)) - .orderBy(({ tag }) => tag.name, "asc"); - }); -} +// a fresh download per sign in: the rows outlive a sign out, so until it +// lands they may be the previous session's +const download = createSessionDownload(queryKeys.root(), async () => + tagsCollection.utils.refetch(), +); + +// The module-level mirror the getters read: a live query owned by a root that +// lives as long as the page. Read `.state` and `.status` only: calling +// `tagsQuery()` suspends +const tagsQuery = createRoot(() => + useLiveQuery((q) => { + if (download.status() !== "loaded") return undefined; + return q.from({ tag: tagsCollection }); + }), +); type ActionType = { insertTag: { @@ -268,7 +268,10 @@ const actions = { // --- Public API --- export async function waitForTagsReady(): Promise { + // starts the sync on the first sign in; the download since this sign in + // may still be on its way after that (the rows outlive a sign out) await tagsCollection.stateWhenReady(); + await download.whenSettled(); } export async function insertTag( @@ -320,17 +323,36 @@ export async function clearActiveTags( await transaction.when("settled"); } -function getTags(): TagItem[] { - return [...tagsCollection.values()].sort((a, b) => +/** + * The loaded tags, sorted by name. Reactive in components and effects, a plain + * read elsewhere. Returns only what's loaded, never fetches. + */ +export function getTags(): TagItem[] { + return [...tagsQuery.state.values()].sort((a, b) => a.name.localeCompare(b.name), ); } -function getTag(id: string): TagItem | undefined { - return tagsCollection.get(id); +/** A loaded tag by id. Reactive like `getTags`, never fetches. */ +export function getTag(id: string): TagItem | undefined { + return tagsQuery.state.get(id); +} + +/** + * Whether the tags have loaded in this session: `loading` (also while signed + * out), `loaded` once the getters return them, or `failed`. Reactive. + */ +export function getTagsStatus(): SessionDownloadStatus { + const status = download.status(); + // the getters' live query catches up a tick after the download + if (status === "loaded" && tagsQuery.status !== "ready") { + return "loading"; + } + return status; } -function getActiveTags(): TagItem[] { +/** The active loaded tags, sorted by name. Reactive like `getTags`. */ +export function getActiveTags(): TagItem[] { return getTags().filter((tag) => tag.active); } @@ -563,7 +585,7 @@ export function getActiveTagsPB( } /** - * Used for non reactive access. Do not use in Solid components. + * Kept for the test-page callers until T6a/T6c; use the getters above. */ export const __nonReactive = { getTags, diff --git a/frontend/src/ts/collections/utils/session-download.ts b/frontend/src/ts/collections/utils/session-download.ts new file mode 100644 index 000000000000..7a8e2e573346 --- /dev/null +++ b/frontend/src/ts/collections/utils/session-download.ts @@ -0,0 +1,84 @@ +import { hashKey, type QueryKey } from "@tanstack/solid-query"; +import { createRoot, createSignal, onCleanup } from "solid-js"; +import { createEffectOn } from "../../hooks/effects"; +import { queryClient } from "../../queries"; +import { isAuthenticated } from "../../states/core"; + +/** `loading` also while signed out: there's no session to have loaded. */ +export type SessionDownloadStatus = "loading" | "loaded" | "failed"; + +/** + * A user collection's download in the current session (since the last sign + * in). + * + * A collection keeps its rows (and its ready status) after sign out: the + * `["user"]` query reset on sign out only sets the query back to pending. So + * on the next sign in the collection looks ready with the previous session's + * rows until the new download lands. Gate reads on `status()` instead. + * + * Starts the download on sign in, unless it already ran: once sign out reset + * the query, nothing else starts it (its `enabled` isn't re-evaluated, and + * the collection's sync only fetches on its first start). + * + * The status changes once the query settles, after the collection applied the + * rows (the query notifies its observers, the collection among them, before + * the cache listeners). + */ +export function createSessionDownload( + queryKey: QueryKey, + refetch: () => Promise, +): { + /** `failed` only after the query's retries. Reactive. */ + status: () => SessionDownloadStatus; + /** Downloads again; `loading` until it settles. */ + reload: () => void; + /** Resolves once the session's download loaded or failed. */ + whenSettled: () => Promise; +} { + const queryHash = hashKey(queryKey); + const [status, setStatus] = createSignal("loading"); + + const queryStatus = (): SessionDownloadStatus => { + const query = queryClient.getQueryCache().get(queryHash); + if (query === undefined || query.state.status === "pending") { + return "loading"; + } + if (query.state.status === "success") return "loaded"; + // a reload after a failure keeps the error status while it fetches + return query.state.fetchStatus === "idle" ? "failed" : "loading"; + }; + + // a root so the effect is owned, it lives as long as the page + createRoot(() => + createEffectOn(isAuthenticated, (signedIn) => { + setStatus("loading"); + if (!signedIn) return; + setStatus(queryStatus()); + // a collection whose sync hasn't started has nothing to refetch: its + // first sync start downloads + if (status() === "loading") void refetch(); + const unsubscribe = queryClient.getQueryCache().subscribe((event) => { + if (event.query.queryHash === queryHash) setStatus(queryStatus()); + }); + onCleanup(unsubscribe); + }), + ); + + const whenSettled = async (): Promise => + new Promise((resolve) => { + createRoot((dispose) => + createEffectOn(status, (current) => { + if (current === "loading") return; + dispose(); + resolve(); + }), + ); + }); + + const reload = (): void => { + setStatus("loading"); + void refetch(); + }; + + return { status, reload, whenSettled }; +} diff --git a/frontend/src/ts/commandline/lists/custom-themes-list.ts b/frontend/src/ts/commandline/lists/custom-themes-list.ts index 4e09ec1f7eab..e867bc53b03a 100644 --- a/frontend/src/ts/commandline/lists/custom-themes-list.ts +++ b/frontend/src/ts/commandline/lists/custom-themes-list.ts @@ -1,6 +1,6 @@ import { setConfig } from "../../config/setters"; import { isAuthenticated } from "../../states/core"; -import * as CustomThemes from "../../collections/custom-themes"; +import { getCustomThemes } from "../../collections/custom-themes"; import * as ThemeController from "../../controllers/theme-controller"; import { Command, CommandsSubgroup } from "../types"; @@ -30,7 +30,7 @@ export function update(): void { subgroup.list = []; - const customThemes = CustomThemes.__nonReactive.getCustomThemes(); + const customThemes = getCustomThemes(); for (const theme of customThemes) { subgroup.list.push({ id: `setCustomThemeId${theme._id}`, diff --git a/frontend/src/ts/commandline/lists/presets.ts b/frontend/src/ts/commandline/lists/presets.ts index db7c53346cbc..ea3d15bca956 100644 --- a/frontend/src/ts/commandline/lists/presets.ts +++ b/frontend/src/ts/commandline/lists/presets.ts @@ -1,7 +1,7 @@ import * as PresetController from "../../controllers/preset-controller"; import { isAuthenticated } from "../../states/core"; import { Command, CommandsSubgroup } from "../types"; -import { __nonReactive } from "../../collections/presets"; +import { getPresets } from "../../collections/presets"; import { showModal } from "../../states/modals"; const subgroup: CommandsSubgroup = { @@ -26,7 +26,7 @@ const commands: Command[] = [ ]; function update(): void { - const presets = __nonReactive.getPresets(); + const presets = getPresets(); subgroup.list = []; if (presets.length === 0) return; presets.forEach((preset) => { diff --git a/frontend/src/ts/commandline/lists/tags.ts b/frontend/src/ts/commandline/lists/tags.ts index a70e5e48536b..4ae45f47bbfa 100644 --- a/frontend/src/ts/commandline/lists/tags.ts +++ b/frontend/src/ts/commandline/lists/tags.ts @@ -1,7 +1,8 @@ import { clearActiveTags, toggleTagActive, - __nonReactive, + getTag, + getTags, } from "../../collections/tags"; import { Config } from "../../config/store"; import * as PaceCaret from "../../test/pace-caret"; @@ -30,7 +31,7 @@ const commands: Command[] = [ ]; function update(): void { - const tags = __nonReactive.getTags(); + const tags = getTags(); subgroup.list = []; if (tags.length > 0) { @@ -57,7 +58,7 @@ function update(): void { display: tag.name, sticky: true, active: () => { - return __nonReactive.getTag(tag._id)?.active ?? false; + return getTag(tag._id)?.active ?? false; }, exec: async (): Promise => { await toggleTagActive({ tagId: tag._id }); diff --git a/frontend/src/ts/components/layout/footer/ThemeIndicator.tsx b/frontend/src/ts/components/layout/footer/ThemeIndicator.tsx index f531d9a924b5..cb01b65e3eeb 100644 --- a/frontend/src/ts/components/layout/footer/ThemeIndicator.tsx +++ b/frontend/src/ts/components/layout/footer/ThemeIndicator.tsx @@ -1,6 +1,6 @@ import { JSXElement, Show } from "solid-js"; -import { useCustomThemesLiveQuery } from "../../../collections/custom-themes"; +import { getCustomThemes } from "../../../collections/custom-themes"; import { setConfig } from "../../../config/setters"; import { Config } from "../../../config/store"; import { showCommandline } from "../../../states/commandline"; @@ -9,15 +9,13 @@ import { showNoticeNotification } from "../../../states/notifications"; import { Fa } from "../../common/Fa"; export function ThemeIndicator(): JSXElement { - const themes = useCustomThemesLiveQuery(); - const handleClick = (e: MouseEvent): void => { if (e.shiftKey) { if (Config.customTheme) { setConfig("customTheme", false); return; } - if (isAuthenticated() && themes().length < 1) { + if (isAuthenticated() && getCustomThemes().length < 1) { showNoticeNotification("No custom themes!"); return; } diff --git a/frontend/src/ts/components/modals/EditResultTagsModal.tsx b/frontend/src/ts/components/modals/EditResultTagsModal.tsx index 74f63327960e..7c8b02ad2d50 100644 --- a/frontend/src/ts/components/modals/EditResultTagsModal.tsx +++ b/frontend/src/ts/components/modals/EditResultTagsModal.tsx @@ -1,7 +1,7 @@ import { createEffect, createSignal, For } from "solid-js"; import { updateTags } from "../../collections/results"; -import { useTagsLiveQuery } from "../../collections/tags"; +import { getTags } from "../../collections/tags"; import { getSelectedResult } from "../../states/edit-result-tags"; import { hideLoaderBar, showLoaderBar } from "../../states/loader-bar"; import { hideModal } from "../../states/modals"; @@ -20,11 +20,9 @@ export function EditResultTagsModal() { new Set([]), ); - const tags = useTagsLiveQuery(); - createEffect(() => { const resultTags = getSelectedResult()?.tags ?? []; - const knownTagIds = new Set(tags().map((tag) => tag._id)); + const knownTagIds = new Set(getTags().map((tag) => tag._id)); const filtered = resultTags.filter((id) => knownTagIds.has(id)); setSelectedTagIds(new Set(filtered)); }); @@ -36,7 +34,7 @@ export function EditResultTagsModal() { modalClass="max-w-xl" >
- + {(tag) => (
- )} - - + return { + status: "success", + message: "Preset deleted", + }; + }, + }); + }} + /> + + )} +