From e1b4e72750e4baa88855cd821bc138c3238cc133 Mon Sep 17 00:00:00 2001 From: Miodec Date: Tue, 10 Nov 2026 12:15:29 +0100 Subject: [PATCH 1/9] refactor: tags, presets and custom themes read through plain getters (U5) (@miodec) Each collection module owns a module-level live query (in a root that lives as long as the page) and exports plain getters reading its `.state`: reactive in components and effects, synchronous elsewhere, never suspending, returning only what's loaded. Single-row getters track only their row. A loaded status (`areTagsLoaded`, `arePresetsLoaded`, `areCustomThemesLoaded`) replaces Suspense for loading states; the settings custom themes list shows a loading circle until they load. Replaces the live-query hooks and the non-test `__nonReactive` callers (commandline lists, config metadata, theme and preset controllers, preset modals). `__nonReactive` is gone from presets and custom themes; tags keep it for the test-page callers until T6a/T6c. Co-Authored-By: Claude Opus 5.5 --- .../collections/custom-themes.spec.ts | 62 ++++++- .../__tests__/collections/presets.spec.ts | 82 +++++++-- frontend/__tests__/collections/tags.spec.ts | 100 +++++++++-- .../pages/test/result/ResultChart.spec.tsx | 46 ++--- .../controllers/preset-controller.spec.ts | 2 +- frontend/src/ts/auth.tsx | 3 +- frontend/src/ts/collections/custom-themes.ts | 48 ++--- frontend/src/ts/collections/presets.ts | 47 ++--- frontend/src/ts/collections/results.ts | 6 +- frontend/src/ts/collections/tags.ts | 51 +++--- .../commandline/lists/custom-themes-list.ts | 4 +- frontend/src/ts/commandline/lists/presets.ts | 4 +- frontend/src/ts/commandline/lists/tags.ts | 7 +- .../layout/footer/ThemeIndicator.tsx | 6 +- .../components/modals/EditResultTagsModal.tsx | 8 +- .../modals/preset/EditPresetModal.tsx | 7 +- .../modals/preset/preset-modal-utils.ts | 6 +- .../ts/components/pages/account/Charts.tsx | 5 +- .../ts/components/pages/account/Filters.tsx | 17 +- .../src/ts/components/pages/account/Table.tsx | 6 +- .../pages/settings/custom-setting/Presets.tsx | 95 +++++----- .../pages/settings/custom-setting/Tags.tsx | 170 +++++++++--------- .../pages/settings/custom-setting/Theme.tsx | 20 +-- .../test/modes-notice/TestModesNotice.tsx | 10 +- .../pages/test/result/ResultChart.tsx | 6 +- .../test/result/ResultSecondaryStats.tsx | 5 +- frontend/src/ts/config/metadata.tsx | 4 +- .../src/ts/controllers/preset-controller.ts | 4 +- .../src/ts/controllers/theme-controller.ts | 20 +-- frontend/src/ts/db.ts | 1 - 30 files changed, 502 insertions(+), 350 deletions(-) diff --git a/frontend/__tests__/collections/custom-themes.spec.ts b/frontend/__tests__/collections/custom-themes.spec.ts index 30ca40a81937..62d87cfb69fb 100644 --- a/frontend/__tests__/collections/custom-themes.spec.ts +++ b/frontend/__tests__/collections/custom-themes.spec.ts @@ -1,21 +1,36 @@ 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 { + areCustomThemesLoaded, + deleteCustomTheme, + getCustomTheme, + getCustomThemes, +} from "../../src/ts/collections/custom-themes"; import { queryClient } from "../../src/ts/queries/index"; import { setUserId } from "../../src/ts/states/core"; +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 +80,47 @@ 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(areCustomThemesLoaded()).toBe(false); + setUserId("uid"); + await waitFor(areCustomThemesLoaded, "the themes to load"); + setUserId(null); + expect(areCustomThemesLoaded()).toBe(false); + }); }); diff --git a/frontend/__tests__/collections/presets.spec.ts b/frontend/__tests__/collections/presets.spec.ts index 8aa86ba09371..4feb4ffdafbf 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, + arePresetsLoaded, + 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 ready", async () => { + expect(arePresetsLoaded()).toBe(false); + setUserId("uid"); + await waitForPresetsReady(); + expect(arePresetsLoaded()).toBe(true); + setUserId(null); + expect(arePresetsLoaded()).toBe(false); }); }); diff --git a/frontend/__tests__/collections/tags.spec.ts b/frontend/__tests__/collections/tags.spec.ts index 77e8d97a7cdb..6dd44e9cfba7 100644 --- a/frontend/__tests__/collections/tags.spec.ts +++ b/frontend/__tests__/collections/tags.spec.ts @@ -1,20 +1,27 @@ 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 { + areTagsLoaded, + getActiveTags, + getTag, + getTags, + toggleTagActive, + waitForTagsReady, +} from "../../src/ts/collections/tags"; 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 +29,86 @@ 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 ready", async () => { + expect(areTagsLoaded()).toBe(false); + setUserId("uid"); + await waitForTagsReady(); + expect(areTagsLoaded()).toBe(true); + setUserId(null); + expect(areTagsLoaded()).toBe(false); + }); + + 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 }); }); }); 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..903bfbba2410 100644 --- a/frontend/src/ts/collections/custom-themes.ts +++ b/frontend/src/ts/collections/custom-themes.ts @@ -19,15 +19,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, @@ -62,6 +53,16 @@ createRoot(() => }), ); +// 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 (!isAuthenticated()) return undefined; + return q.from({ customTheme: customThemesCollection }); + }), +); + type ActionType = { addCustomTheme: { name: string; @@ -149,14 +150,27 @@ 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 (false while loading, failed or signed + * out). Unlike tags and presets, sign in doesn't wait for them. + */ +export function areCustomThemesLoaded(): boolean { + return customThemesQuery.status === "ready"; } export async function addCustomTheme( @@ -179,11 +193,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..042573b0e8d0 100644 --- a/frontend/src/ts/collections/presets.ts +++ b/frontend/src/ts/collections/presets.ts @@ -10,6 +10,7 @@ import { queryClient } from "../queries"; import { baseKey } from "../queries/utils/keys"; import { ConfigGroupName } from "@monkeytype/schemas/configs"; import { tempId, keepForever } from "./utils/misc"; +import { createRoot } from "solid-js"; import { isAuthenticated } from "../states/core"; import { replaceUnderscoresWithSpaces } from "../utils/strings"; @@ -19,16 +20,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 +44,16 @@ const presetsCollection = createCollection({ ...keepForever, }); +// 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 (!isAuthenticated()) return undefined; + return q.from({ preset: presetsCollection }); + }), +); + type ActionType = { addPreset: { name: string; @@ -166,14 +167,24 @@ export async function waitForPresetsReady(): Promise { await presetsCollection.stateWhenReady(); } -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 (false while loading, failed or signed out). */ +export function arePresetsLoaded(): boolean { + return presetsQuery.status === "ready"; } export async function addPreset( @@ -196,11 +207,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..792a7db682ed 100644 --- a/frontend/src/ts/collections/tags.ts +++ b/frontend/src/ts/collections/tags.ts @@ -23,6 +23,7 @@ import { import { Difficulty } from "@monkeytype/schemas/configs"; import { Language } from "@monkeytype/schemas/languages"; import { isTempId, tempId, keepForever } from "./utils/misc"; +import { createRoot } from "solid-js"; import { fetchUserFromApi } from "../ape/user"; import { updateTagsInFilterStorage } from "../states/result-filters"; import { isAuthenticated } from "../states/core"; @@ -60,15 +61,15 @@ const tagsCollection = createCollection({ ...keepForever, }); -// oxlint-disable-next-line typescript/explicit-function-return-type -export function useTagsLiveQuery() { - return useLiveQuery((q) => { +// 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 (!isAuthenticated()) return undefined; - return q - .from({ tag: tagsCollection }) - .orderBy(({ tag }) => tag.name, "asc"); - }); -} + return q.from({ tag: tagsCollection }); + }), +); // oxlint-disable-next-line typescript/explicit-function-return-type export async function getActiveTagsOnce() { @@ -87,17 +88,6 @@ 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"); - }); -} - type ActionType = { insertTag: { name: string; @@ -320,17 +310,28 @@ 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 (false while loading, failed or signed out). */ +export function areTagsLoaded(): boolean { + return tagsQuery.status === "ready"; } -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 +564,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/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", + }; + }, + }); + }} + /> + + )} +