From ea1d2bdf38fdf640c10dbd8a14e50506ed426487 Mon Sep 17 00:00:00 2001
From: Christian Fehmer
Date: Sat, 17 Jan 2026 10:36:14 +0100
Subject: [PATCH 01/15] refactor: convert connections to store (@fehmer)
---
.../src/ts/components/pages/AboutPage.tsx | 26 ++++
frontend/src/ts/firebase.ts | 3 +
frontend/src/ts/signals/connections.ts | 25 ++++
frontend/src/ts/signals/user.ts | 3 +
.../ts/signals/util/resourceBackedStore.ts | 133 ++++++++++++++++++
5 files changed, 190 insertions(+)
create mode 100644 frontend/src/ts/signals/connections.ts
create mode 100644 frontend/src/ts/signals/user.ts
create mode 100644 frontend/src/ts/signals/util/resourceBackedStore.ts
diff --git a/frontend/src/ts/components/pages/AboutPage.tsx b/frontend/src/ts/components/pages/AboutPage.tsx
index ec73dd894481..80b5a2c1683c 100644
--- a/frontend/src/ts/components/pages/AboutPage.tsx
+++ b/frontend/src/ts/components/pages/AboutPage.tsx
@@ -11,6 +11,8 @@ import { intervalToDuration } from "date-fns";
import { getNumberWithMagnitude, numberWithSpaces } from "../../utils/numbers";
import { ChartJs } from "../common/ChartJs";
import { getThemeColors } from "../../signals/theme";
+import { connections } from "../../signals/connections";
+import { isAuthenticated } from "../../signals/user";
export function AboutPage(): JSXElement {
const isOpen = (): boolean => getActivePage() === "about";
@@ -35,6 +37,30 @@ export function AboutPage(): JSXElement {
return (
+ Connections {connections.store.length}
+
+
+
+
+
+ {(data) => (
+
+ {(connection) => (
+
+ {connection.initiatorName} to {connection.receiverName}
+
+ )}
+
+ )}
+
+
+
Created with love by Miodec.
diff --git a/frontend/src/ts/firebase.ts b/frontend/src/ts/firebase.ts
index dd2a6354201b..e043ce667cc1 100644
--- a/frontend/src/ts/firebase.ts
+++ b/frontend/src/ts/firebase.ts
@@ -35,6 +35,7 @@ import {
} from "firebase/analytics";
import { tryCatch } from "@monkeytype/util/trycatch";
import { dispatch as dispatchSignUpEvent } from "./observables/google-sign-up-event";
+import { setAuthenticated } from "./signals/user";
let app: FirebaseApp | undefined;
let Auth: AuthType | undefined;
@@ -74,6 +75,7 @@ export async function init(callback: ReadyCallback): Promise
{
await setPersistence(rememberMe, false);
onAuthStateChanged(Auth, async (user) => {
+ setAuthenticated(user !== undefined && user !== null);
if (!ignoreAuthCallback) {
await callback(true, user);
}
@@ -82,6 +84,7 @@ export async function init(callback: ReadyCallback): Promise {
app = undefined;
Auth = undefined;
console.error("Firebase failed to initialize", e);
+ setAuthenticated(false);
await callback(false, null);
if (isDevEnvironment()) {
Notifications.addPSA(
diff --git a/frontend/src/ts/signals/connections.ts b/frontend/src/ts/signals/connections.ts
new file mode 100644
index 000000000000..e4b12dfed1d9
--- /dev/null
+++ b/frontend/src/ts/signals/connections.ts
@@ -0,0 +1,25 @@
+import { Connection } from "@monkeytype/schemas/connections";
+import { createResourceBackedStore } from "./util/resourceBackedStore";
+import Ape from "../ape/";
+import { createEffect } from "solid-js";
+import { isAuthenticated } from "./user";
+
+export const connections = createResourceBackedStore(async () => {
+ const response = await Ape.connections.get();
+
+ if (response.status !== 200) {
+ throw new Error(response.body.message);
+ }
+ return response.body.data;
+}, []);
+
+createEffect(() => {
+ const authenticated = isAuthenticated();
+ console.log("### isAuthenticated: ", authenticated);
+ if (!authenticated) {
+ connections.reset();
+ }
+});
+
+//from legacy code
+//await connections.ready;
diff --git a/frontend/src/ts/signals/user.ts b/frontend/src/ts/signals/user.ts
new file mode 100644
index 000000000000..0f24388d36c3
--- /dev/null
+++ b/frontend/src/ts/signals/user.ts
@@ -0,0 +1,3 @@
+import { createSignal } from "solid-js";
+
+export const [isAuthenticated, setAuthenticated] = createSignal(false);
diff --git a/frontend/src/ts/signals/util/resourceBackedStore.ts b/frontend/src/ts/signals/util/resourceBackedStore.ts
new file mode 100644
index 000000000000..046883f11d6a
--- /dev/null
+++ b/frontend/src/ts/signals/util/resourceBackedStore.ts
@@ -0,0 +1,133 @@
+import { createSignal, createResource, createEffect } from "solid-js";
+import { createStore, Store } from "solid-js/store";
+import type { Accessor, Resource } from "solid-js";
+
+export type ResourceBackedStore = {
+ /**
+ * signal
+ */
+ shouldLoad: Accessor;
+
+ /**
+ * request store to be loaded
+ */
+ load: () => void;
+
+ /**
+ * request store to be reloaded
+ */
+ reload: () => void;
+
+ /**
+ * reset the resource + store
+ */
+ reset: () => void;
+
+ /**
+ * resource to be used to get loading and error states
+ */
+ resource: Resource;
+
+ /**
+ * the data store
+ */
+ store: Store;
+
+ /**
+ * promise that resolves when the store is ready.
+ * rejects if shouldLoad is false
+ */
+ ready: () => Promise;
+};
+
+type ReadyPromise = {
+ promise: Promise;
+ resolve: () => void;
+ reject: (err?: unknown) => void;
+};
+
+export function createResourceBackedStore(
+ fetcher: () => Promise,
+ initialValue: T,
+): ResourceBackedStore {
+ const [shouldLoad, setShouldLoad] = createSignal(false);
+
+ const [resource, { refetch, mutate }] = createResource(
+ shouldLoad,
+ async (load) => {
+ if (!load) {
+ throw new Error("Load not requested");
+ }
+ const result = await fetcher();
+
+ //@ts-expect-error TODO
+ mutate(result);
+
+ return result;
+ },
+ );
+
+ const [store, setStore] = createStore(initialValue);
+ let ready = createReadyPromise();
+
+ createEffect(() => {
+ console.log("watch resource", resource.state);
+ if (resource.state === "pending") return;
+
+ if (resource.error !== undefined) {
+ //TODO figure out why this is causes logout
+ //ready.reject(resource.error);
+ //reset for next attempt
+ ready = createReadyPromise();
+ return;
+ }
+
+ const value = resource();
+ if (value !== undefined) {
+ setStore(value);
+ ready.resolve();
+ }
+ });
+
+ return {
+ shouldLoad,
+ load: () => setShouldLoad(true),
+ reload: () => {
+ if (!shouldLoad()) {
+ setShouldLoad(true);
+ }
+ //TODO figure out why this is causes logout
+ //ready.reject(new Error("Reloading"));
+ ready = createReadyPromise();
+ void refetch();
+ },
+ reset: () => {
+ console.log("### reset");
+ setShouldLoad(false);
+
+ // reset resource + store
+ mutate(undefined);
+ setStore(initialValue);
+
+ // reject any waiters
+ //TODO figure out why this is uncaught
+ //ready.reject(new Error("Reset"));
+ ready = createReadyPromise();
+ },
+ resource,
+ store,
+ ready: async () => ready.promise,
+ };
+}
+
+function createReadyPromise(): ReadyPromise {
+ let resolve!: () => void;
+ let reject!: (err?: unknown) => void;
+
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+
+ return { promise, resolve, reject };
+}
From b7e37a01fee101d3c5a4dac5c35eb4f8cb3d088f Mon Sep 17 00:00:00 2001
From: Christian Fehmer
Date: Sat, 17 Jan 2026 11:01:50 +0100
Subject: [PATCH 02/15] fix restore to initial value
---
frontend/src/ts/signals/connections.ts | 17 ++++++++++-------
.../src/ts/signals/util/resourceBackedStore.ts | 6 +++---
2 files changed, 13 insertions(+), 10 deletions(-)
diff --git a/frontend/src/ts/signals/connections.ts b/frontend/src/ts/signals/connections.ts
index e4b12dfed1d9..213b2720c26b 100644
--- a/frontend/src/ts/signals/connections.ts
+++ b/frontend/src/ts/signals/connections.ts
@@ -4,14 +4,17 @@ import Ape from "../ape/";
import { createEffect } from "solid-js";
import { isAuthenticated } from "./user";
-export const connections = createResourceBackedStore(async () => {
- const response = await Ape.connections.get();
+export const connections = createResourceBackedStore(
+ async () => {
+ const response = await Ape.connections.get();
- if (response.status !== 200) {
- throw new Error(response.body.message);
- }
- return response.body.data;
-}, []);
+ if (response.status !== 200) {
+ throw new Error(response.body.message);
+ }
+ return response.body.data;
+ },
+ () => [],
+);
createEffect(() => {
const authenticated = isAuthenticated();
diff --git a/frontend/src/ts/signals/util/resourceBackedStore.ts b/frontend/src/ts/signals/util/resourceBackedStore.ts
index 046883f11d6a..5780a1b0ffd3 100644
--- a/frontend/src/ts/signals/util/resourceBackedStore.ts
+++ b/frontend/src/ts/signals/util/resourceBackedStore.ts
@@ -48,7 +48,7 @@ type ReadyPromise = {
export function createResourceBackedStore(
fetcher: () => Promise,
- initialValue: T,
+ initialValue: () => T,
): ResourceBackedStore {
const [shouldLoad, setShouldLoad] = createSignal(false);
@@ -67,7 +67,7 @@ export function createResourceBackedStore(
},
);
- const [store, setStore] = createStore(initialValue);
+ const [store, setStore] = createStore(initialValue());
let ready = createReadyPromise();
createEffect(() => {
@@ -107,7 +107,7 @@ export function createResourceBackedStore(
// reset resource + store
mutate(undefined);
- setStore(initialValue);
+ setStore(initialValue());
// reject any waiters
//TODO figure out why this is uncaught
From d643c92503ad836b4785de90ef7be06d3e05f301 Mon Sep 17 00:00:00 2001
From: Christian Fehmer
Date: Sat, 17 Jan 2026 11:37:36 +0100
Subject: [PATCH 03/15] wip
---
.../src/ts/components/pages/AboutPage.tsx | 21 +++-----
frontend/src/ts/signals/connections.ts | 7 +++
.../ts/signals/util/resourceBackedStore.ts | 51 ++++++++++---------
3 files changed, 42 insertions(+), 37 deletions(-)
diff --git a/frontend/src/ts/components/pages/AboutPage.tsx b/frontend/src/ts/components/pages/AboutPage.tsx
index 80b5a2c1683c..699d8b3fd216 100644
--- a/frontend/src/ts/components/pages/AboutPage.tsx
+++ b/frontend/src/ts/components/pages/AboutPage.tsx
@@ -44,21 +44,14 @@ export function AboutPage(): JSXElement {
connections.reload()} text="reload" />
connections.reset()} text="reset" />
-
-
- {(data) => (
-
- {(connection) => (
-
- {connection.initiatorName} to {connection.receiverName}
-
- )}
-
+
+
+ {(connection) => (
+
+ {connection.initiatorName} to {connection.receiverName}
+
)}
-
+
diff --git a/frontend/src/ts/signals/connections.ts b/frontend/src/ts/signals/connections.ts
index 213b2720c26b..c33e0a904635 100644
--- a/frontend/src/ts/signals/connections.ts
+++ b/frontend/src/ts/signals/connections.ts
@@ -24,5 +24,12 @@ createEffect(() => {
}
});
+createEffect(() => {
+ const loading = connections.loading();
+ const error = connections.error();
+
+ console.log("#### change in resource: ", { loading, error });
+});
+
//from legacy code
//await connections.ready;
diff --git a/frontend/src/ts/signals/util/resourceBackedStore.ts b/frontend/src/ts/signals/util/resourceBackedStore.ts
index 5780a1b0ffd3..323afce168ec 100644
--- a/frontend/src/ts/signals/util/resourceBackedStore.ts
+++ b/frontend/src/ts/signals/util/resourceBackedStore.ts
@@ -1,6 +1,6 @@
import { createSignal, createResource, createEffect } from "solid-js";
import { createStore, Store } from "solid-js/store";
-import type { Accessor, Resource } from "solid-js";
+import type { Accessor } from "solid-js";
export type ResourceBackedStore
= {
/**
@@ -24,9 +24,14 @@ export type ResourceBackedStore = {
reset: () => void;
/**
- * resource to be used to get loading and error states
+ * store is loading
*/
- resource: Resource;
+ loading: Accessor;
+
+ /**
+ * loading error
+ */
+ error: Accessor;
/**
* the data store
@@ -51,19 +56,13 @@ export function createResourceBackedStore(
initialValue: () => T,
): ResourceBackedStore {
const [shouldLoad, setShouldLoad] = createSignal(false);
+ const [loading, setLoading] = createSignal(false);
+ const [error, setError] = createSignal(undefined);
- const [resource, { refetch, mutate }] = createResource(
- shouldLoad,
- async (load) => {
- if (!load) {
- throw new Error("Load not requested");
- }
- const result = await fetcher();
-
- //@ts-expect-error TODO
- mutate(result);
-
- return result;
+ const [resource, { refetch }] = createResource(
+ () => (shouldLoad() ? true : null),
+ async () => {
+ return fetcher();
},
);
@@ -71,13 +70,17 @@ export function createResourceBackedStore(
let ready = createReadyPromise();
createEffect(() => {
- console.log("watch resource", resource.state);
- if (resource.state === "pending") return;
+ if (!shouldLoad()) {
+ setLoading(false);
+ setError(undefined);
+ return;
+ }
+
+ setLoading(resource.loading);
if (resource.error !== undefined) {
- //TODO figure out why this is causes logout
- //ready.reject(resource.error);
- //reset for next attempt
+ setError(resource.error);
+ //reset store?
ready = createReadyPromise();
return;
}
@@ -85,6 +88,7 @@ export function createResourceBackedStore(
const value = resource();
if (value !== undefined) {
setStore(value);
+ setError(undefined);
ready.resolve();
}
});
@@ -104,9 +108,9 @@ export function createResourceBackedStore(
reset: () => {
console.log("### reset");
setShouldLoad(false);
+ setLoading(false);
+ setError(undefined);
- // reset resource + store
- mutate(undefined);
setStore(initialValue());
// reject any waiters
@@ -114,7 +118,8 @@ export function createResourceBackedStore(
//ready.reject(new Error("Reset"));
ready = createReadyPromise();
},
- resource,
+ loading,
+ error,
store,
ready: async () => ready.promise,
};
From c56596ad1efdbd7439af3996eb2f507f6d9cedda Mon Sep 17 00:00:00 2001
From: Christian Fehmer
Date: Sat, 17 Jan 2026 23:01:22 +0100
Subject: [PATCH 04/15] rename to loadingStore, cleanup. fix some errors
---
.../src/ts/components/pages/AboutPage.tsx | 10 +-
frontend/src/ts/signals/connections.ts | 12 +-
.../ts/signals/util/resourceBackedStore.ts | 138 ------------------
3 files changed, 14 insertions(+), 146 deletions(-)
delete mode 100644 frontend/src/ts/signals/util/resourceBackedStore.ts
diff --git a/frontend/src/ts/components/pages/AboutPage.tsx b/frontend/src/ts/components/pages/AboutPage.tsx
index 699d8b3fd216..d28c202609a0 100644
--- a/frontend/src/ts/components/pages/AboutPage.tsx
+++ b/frontend/src/ts/components/pages/AboutPage.tsx
@@ -41,10 +41,16 @@ export function AboutPage(): JSXElement {
connections.load()} text="load" />
- connections.reload()} text="reload" />
+ connections.refresh()} text="refresh" />
connections.reset()} text="reset" />
-
+
+ ... is loading
+
+ oh oh, {connections.state().error}
+
+ refreshing...
+
{(connection) => (
diff --git a/frontend/src/ts/signals/connections.ts b/frontend/src/ts/signals/connections.ts
index c33e0a904635..7b5934ae4411 100644
--- a/frontend/src/ts/signals/connections.ts
+++ b/frontend/src/ts/signals/connections.ts
@@ -1,10 +1,10 @@
import { Connection } from "@monkeytype/schemas/connections";
-import { createResourceBackedStore } from "./util/resourceBackedStore";
+import { createLoadingStore } from "./util/loadingStore";
import Ape from "../ape/";
import { createEffect } from "solid-js";
import { isAuthenticated } from "./user";
-export const connections = createResourceBackedStore(
+export const connections = createLoadingStore(
async () => {
const response = await Ape.connections.get();
@@ -19,16 +19,16 @@ export const connections = createResourceBackedStore(
createEffect(() => {
const authenticated = isAuthenticated();
console.log("### isAuthenticated: ", authenticated);
- if (!authenticated) {
+ //TODO check logout during refresh
+ if (!authenticated && connections.state().ready) {
connections.reset();
}
});
createEffect(() => {
- const loading = connections.loading();
- const error = connections.error();
+ const state = connections.state();
- console.log("#### change in resource: ", { loading, error });
+ console.log("#### change in resource: ", state);
});
//from legacy code
diff --git a/frontend/src/ts/signals/util/resourceBackedStore.ts b/frontend/src/ts/signals/util/resourceBackedStore.ts
deleted file mode 100644
index 323afce168ec..000000000000
--- a/frontend/src/ts/signals/util/resourceBackedStore.ts
+++ /dev/null
@@ -1,138 +0,0 @@
-import { createSignal, createResource, createEffect } from "solid-js";
-import { createStore, Store } from "solid-js/store";
-import type { Accessor } from "solid-js";
-
-export type ResourceBackedStore = {
- /**
- * signal
- */
- shouldLoad: Accessor;
-
- /**
- * request store to be loaded
- */
- load: () => void;
-
- /**
- * request store to be reloaded
- */
- reload: () => void;
-
- /**
- * reset the resource + store
- */
- reset: () => void;
-
- /**
- * store is loading
- */
- loading: Accessor;
-
- /**
- * loading error
- */
- error: Accessor;
-
- /**
- * the data store
- */
- store: Store;
-
- /**
- * promise that resolves when the store is ready.
- * rejects if shouldLoad is false
- */
- ready: () => Promise;
-};
-
-type ReadyPromise = {
- promise: Promise;
- resolve: () => void;
- reject: (err?: unknown) => void;
-};
-
-export function createResourceBackedStore(
- fetcher: () => Promise,
- initialValue: () => T,
-): ResourceBackedStore {
- const [shouldLoad, setShouldLoad] = createSignal(false);
- const [loading, setLoading] = createSignal(false);
- const [error, setError] = createSignal(undefined);
-
- const [resource, { refetch }] = createResource(
- () => (shouldLoad() ? true : null),
- async () => {
- return fetcher();
- },
- );
-
- const [store, setStore] = createStore(initialValue());
- let ready = createReadyPromise();
-
- createEffect(() => {
- if (!shouldLoad()) {
- setLoading(false);
- setError(undefined);
- return;
- }
-
- setLoading(resource.loading);
-
- if (resource.error !== undefined) {
- setError(resource.error);
- //reset store?
- ready = createReadyPromise();
- return;
- }
-
- const value = resource();
- if (value !== undefined) {
- setStore(value);
- setError(undefined);
- ready.resolve();
- }
- });
-
- return {
- shouldLoad,
- load: () => setShouldLoad(true),
- reload: () => {
- if (!shouldLoad()) {
- setShouldLoad(true);
- }
- //TODO figure out why this is causes logout
- //ready.reject(new Error("Reloading"));
- ready = createReadyPromise();
- void refetch();
- },
- reset: () => {
- console.log("### reset");
- setShouldLoad(false);
- setLoading(false);
- setError(undefined);
-
- setStore(initialValue());
-
- // reject any waiters
- //TODO figure out why this is uncaught
- //ready.reject(new Error("Reset"));
- ready = createReadyPromise();
- },
- loading,
- error,
- store,
- ready: async () => ready.promise,
- };
-}
-
-function createReadyPromise(): ReadyPromise {
- let resolve!: () => void;
- let reject!: (err?: unknown) => void;
-
- const promise = new Promise((res, rej) => {
- resolve = res;
- reject = rej;
- });
-
- return { promise, resolve, reject };
-}
From 9d28eae9864fde413fc497075a8bdca9065923fa Mon Sep 17 00:00:00 2001
From: Christian Fehmer
Date: Sun, 18 Jan 2026 00:08:06 +0100
Subject: [PATCH 05/15] fixes, added tests
---
.../signals/util/loadingStore.spec.ts | 127 +++++++++++++++++
frontend/src/ts/signals/util/loadingStore.ts | 132 ++++++++++++++++++
2 files changed, 259 insertions(+)
create mode 100644 frontend/__tests__/signals/util/loadingStore.spec.ts
create mode 100644 frontend/src/ts/signals/util/loadingStore.ts
diff --git a/frontend/__tests__/signals/util/loadingStore.spec.ts b/frontend/__tests__/signals/util/loadingStore.spec.ts
new file mode 100644
index 000000000000..68be8ca34b46
--- /dev/null
+++ b/frontend/__tests__/signals/util/loadingStore.spec.ts
@@ -0,0 +1,127 @@
+import { createLoadingStore } from "../../../src/ts/signals/util/loadingStore";
+import { vi, describe, it, expect, beforeEach } from "vitest";
+
+const mockFetcher = vi.fn();
+const initialValue = vi.fn(() => ({ data: null }));
+
+describe("createLoadingStore", () => {
+ beforeEach(() => {
+ mockFetcher.mockClear();
+ initialValue.mockClear();
+ });
+
+ it("should initialize with the correct state", () => {
+ const store = createLoadingStore(mockFetcher, initialValue);
+
+ expect(store.state().state).toBe("unresolved");
+ expect(store.state().loading).toBe(false);
+ expect(store.state().ready).toBe(false);
+ expect(store.state().refreshing).toBe(false);
+ expect(store.state().error).toBeUndefined();
+ expect(store.store).toEqual({ data: null });
+ });
+
+ it("should transition to loading when load is called", async () => {
+ const store = createLoadingStore(mockFetcher, initialValue);
+ store.load();
+
+ expect(store.state().state).toBe("pending");
+ expect(store.state().loading).toBe(true);
+ });
+
+ it("should call the fetcher when load is called", async () => {
+ const store = createLoadingStore(mockFetcher, initialValue);
+ mockFetcher.mockResolvedValueOnce({ data: "test" });
+ store.load();
+
+ await store.ready();
+
+ expect(mockFetcher).toHaveBeenCalledTimes(1);
+ expect(store.state().state).toBe("ready");
+ expect(store.store).toEqual({ data: "test" });
+ });
+
+ it("should handle error when fetcher fails", async () => {
+ mockFetcher.mockRejectedValueOnce(new Error("Failed to load"));
+ const store = createLoadingStore(mockFetcher, initialValue);
+
+ store.load();
+
+ await expect(store.ready()).rejects.toThrow("Failed to load");
+
+ expect(store.state().state).toBe("errored");
+ expect(store.state().error).toEqual(new Error("Failed to load"));
+ });
+
+ it("should transition to refreshing state on refresh", async () => {
+ const store = createLoadingStore(mockFetcher, initialValue);
+ mockFetcher.mockResolvedValueOnce({ data: "test" });
+ store.load();
+
+ store.refresh(); // trigger refresh
+ expect(store.state().state).toBe("refreshing");
+ expect(store.state().refreshing).toBe(true);
+ });
+
+ it("should trigger load when refresh is called and shouldLoad is false", async () => {
+ const store = createLoadingStore(mockFetcher, initialValue);
+ mockFetcher.mockResolvedValueOnce({ data: "test" });
+ expect(store.state().state).toBe("unresolved");
+
+ store.refresh();
+ expect(store.state().state).toBe("refreshing");
+ expect(store.state().refreshing).toBe(true);
+
+ // Wait for the store to be ready after fetching
+ await store.ready();
+
+ // Ensure the store's state is 'ready' after the refresh
+ expect(store.state().state).toBe("ready");
+ expect(store.store).toEqual({ data: "test" });
+ });
+
+ it("should reset the store to its initial value on reset", async () => {
+ const store = createLoadingStore(mockFetcher, initialValue);
+ mockFetcher.mockResolvedValueOnce({ data: "test" });
+ store.load();
+
+ await store.ready();
+
+ expect(store.store).toEqual({ data: "test" });
+
+ store.reset();
+ expect(store.state().state).toBe("unresolved");
+ expect(store.state().loading).toBe(false);
+ expect(store.store).toEqual({ data: null });
+ });
+
+ it("should handle a promise rejection during reset", async () => {
+ const store = createLoadingStore(mockFetcher, initialValue);
+
+ // Mock the fetcher to resolve with data
+ mockFetcher.mockResolvedValueOnce({ data: "test" });
+
+ // Trigger loading the store
+ store.load();
+
+ // Wait for the store to be ready
+ await store.ready();
+
+ // Ensure the store state after loading
+ expect(store.state().state).toBe("ready");
+ expect(store.store).toEqual({ data: "test" });
+
+ // Now call reset, which should reject the ready promise
+ const readyPromise = store.ready(); // Grab the current ready promise
+
+ store.reset(); // Call reset, which should reject the promise
+
+ // Ensure the promise rejects as expected
+ await expect(readyPromise).rejects.toThrow("Reset");
+
+ // Ensure the state is reset
+ expect(store.state().state).toBe("unresolved");
+ expect(store.state().loading).toBe(false);
+ expect(store.store).toEqual({ data: null });
+ });
+});
diff --git a/frontend/src/ts/signals/util/loadingStore.ts b/frontend/src/ts/signals/util/loadingStore.ts
new file mode 100644
index 000000000000..58dcb7a7fcfc
--- /dev/null
+++ b/frontend/src/ts/signals/util/loadingStore.ts
@@ -0,0 +1,132 @@
+import { createSignal, createResource, createEffect } from "solid-js";
+import { createStore, Store } from "solid-js/store";
+import type { Accessor, Resource } from "solid-js";
+import { promiseWithResolvers } from "../../utils/misc";
+
+type State = Pick, "loading" | "error" | "state"> & {
+ ready: boolean;
+ refreshing: boolean;
+};
+
+export type LoadingStore = {
+ /**
+ * request store to be loaded
+ */
+ load: () => void;
+
+ /**
+ * request store to be refreshed
+ */
+ refresh: () => void;
+
+ /**
+ * reset the resource + store
+ */
+ reset: () => void;
+
+ /**
+ * store state
+ */
+ state: Accessor;
+
+ /**
+ * the data store
+ */
+ store: Store;
+
+ /**
+ * promise that resolves when the store is ready.
+ * rejects if shouldLoad is false
+ */
+ ready: () => Promise;
+};
+
+export function createLoadingStore(
+ fetcher: () => Promise,
+ initialValue: () => T,
+): LoadingStore {
+ const [shouldLoad, setShouldLoad] = createSignal(false);
+ const [getState, setState] = createSignal({
+ state: "unresolved",
+ loading: false,
+ ready: false,
+ refreshing: false,
+ error: undefined,
+ });
+
+ const [resource, { refetch }] = createResource(
+ () => (shouldLoad() ? true : null),
+ async () => {
+ return fetcher();
+ },
+ );
+
+ const [store, setStore] = createStore(initialValue());
+ let ready = promiseWithResolvers();
+
+ const updateState = (
+ state: Resource["state"],
+ // oxlint-disable-next-line typescript/no-explicit-any
+ error?: any,
+ ): void => {
+ setState({
+ state,
+ loading: state === "pending",
+ ready: state === "ready",
+ refreshing: state === "refreshing",
+ // oxlint-disable-next-line typescript/no-explicit-any typescript/no-unsafe-assignment
+ error: error,
+ });
+ };
+
+ createEffect(() => {
+ if (!shouldLoad()) {
+ updateState("unresolved");
+ return;
+ }
+ updateState("pending");
+
+ if (resource.error !== undefined) {
+ updateState("errored", resource.error);
+ ready.reject(resource.error);
+ ready = promiseWithResolvers();
+ return;
+ }
+
+ const value = resource();
+ if (value !== undefined) {
+ setStore(value);
+ updateState("ready");
+ ready.resolve();
+ ready = promiseWithResolvers();
+ }
+ });
+
+ return {
+ load: () => {
+ if (!shouldLoad()) setShouldLoad(true);
+ },
+ refresh: () => {
+ if (!shouldLoad()) {
+ setShouldLoad(true);
+ }
+ ready = promiseWithResolvers();
+ updateState("refreshing");
+ void refetch();
+ },
+ reset: () => {
+ setShouldLoad(false);
+
+ setStore(initialValue());
+
+ // reject any waiters
+ //if (ready.state === "pending") {
+ ready.reject(new Error("Reset"));
+ //}
+ ready = promiseWithResolvers();
+ },
+ state: getState,
+ store,
+ ready: async () => ready.promise,
+ };
+}
From d810e048106b5d1360b4afecc9684743936584f9 Mon Sep 17 00:00:00 2001
From: Christian Fehmer
Date: Sun, 18 Jan 2026 00:40:53 +0100
Subject: [PATCH 06/15] ready trigggers load
---
.../signals/util/loadingStore.spec.ts | 7 +++
frontend/src/ts/signals/util/loadingStore.ts | 51 +++++++++++--------
2 files changed, 36 insertions(+), 22 deletions(-)
diff --git a/frontend/__tests__/signals/util/loadingStore.spec.ts b/frontend/__tests__/signals/util/loadingStore.spec.ts
index 68be8ca34b46..994b6d9af3e8 100644
--- a/frontend/__tests__/signals/util/loadingStore.spec.ts
+++ b/frontend/__tests__/signals/util/loadingStore.spec.ts
@@ -29,6 +29,13 @@ describe("createLoadingStore", () => {
expect(store.state().loading).toBe(true);
});
+ it("should enable loading if ready is called", async () => {
+ const store = createLoadingStore(mockFetcher, initialValue);
+ mockFetcher.mockResolvedValueOnce({ data: "test" });
+
+ await store.ready();
+ });
+
it("should call the fetcher when load is called", async () => {
const store = createLoadingStore(mockFetcher, initialValue);
mockFetcher.mockResolvedValueOnce({ data: "test" });
diff --git a/frontend/src/ts/signals/util/loadingStore.ts b/frontend/src/ts/signals/util/loadingStore.ts
index 58dcb7a7fcfc..1493b82d104e 100644
--- a/frontend/src/ts/signals/util/loadingStore.ts
+++ b/frontend/src/ts/signals/util/loadingStore.ts
@@ -102,31 +102,38 @@ export function createLoadingStore(
}
});
- return {
- load: () => {
- if (!shouldLoad()) setShouldLoad(true);
- },
- refresh: () => {
- if (!shouldLoad()) {
- setShouldLoad(true);
- }
- ready = promiseWithResolvers();
- updateState("refreshing");
- void refetch();
- },
- reset: () => {
- setShouldLoad(false);
+ const load = (): void => {
+ if (!shouldLoad()) setShouldLoad(true);
+ };
+ const refresh = (): void => {
+ if (!shouldLoad()) {
+ setShouldLoad(true);
+ }
+ ready = promiseWithResolvers();
+ updateState("refreshing");
+ void refetch();
+ };
- setStore(initialValue());
+ const reset = (): void => {
+ setShouldLoad(false);
- // reject any waiters
- //if (ready.state === "pending") {
- ready.reject(new Error("Reset"));
- //}
- ready = promiseWithResolvers();
- },
+ setStore(initialValue());
+
+ // reject any waiters
+ ready.reject(new Error("Reset"));
+ ready = promiseWithResolvers();
+ };
+
+ return {
+ load,
+ refresh,
+ reset,
state: getState,
store,
- ready: async () => ready.promise,
+ ready: async () => {
+ load();
+ await ready.promise;
+ return;
+ },
};
}
From 32b7203f90222fd884eb0eb4439d7f296fe627fb Mon Sep 17 00:00:00 2001
From: Christian Fehmer
Date: Sun, 18 Jan 2026 13:29:45 +0100
Subject: [PATCH 07/15] expand async content to handle loadingStore
---
.../src/ts/components/common/AsyncContent.tsx | 43 ++++++++++++++-----
.../src/ts/components/pages/AboutPage.tsx | 29 ++++++-------
2 files changed, 46 insertions(+), 26 deletions(-)
diff --git a/frontend/src/ts/components/common/AsyncContent.tsx b/frontend/src/ts/components/common/AsyncContent.tsx
index cf2d57bb1500..790daf07464e 100644
--- a/frontend/src/ts/components/common/AsyncContent.tsx
+++ b/frontend/src/ts/components/common/AsyncContent.tsx
@@ -2,26 +2,47 @@ import { ErrorBoundary, JSXElement, Resource, Show, Suspense } from "solid-js";
import { createErrorMessage } from "../../utils/misc";
import * as Notifications from "../../elements/notifications";
import { Conditional } from "./Conditional";
+import { LoadingStore } from "../../signals/util/loadingStore";
export default function AsyncContent(
props: {
- resource: Resource;
errorMessage?: string;
} & (
| {
- alwaysShowContent?: never;
- children: (data: T) => JSXElement;
+ resource: Resource;
+ loadingStore?: never;
}
| {
- alwaysShowContent: true;
- showLoader?: true;
- children: (data: T | undefined) => JSXElement;
+ loadingStore: LoadingStore;
+ resource?: never;
}
- ),
+ ) &
+ (
+ | {
+ alwaysShowContent?: never;
+ children: (data: T) => JSXElement;
+ }
+ | {
+ alwaysShowContent: true;
+ showLoader?: true;
+ children: (data: T | undefined) => JSXElement;
+ }
+ ),
): JSXElement {
+ const source =
+ props.resource !== undefined
+ ? {
+ value: () => props.resource(),
+ loading: () => props.resource.loading,
+ }
+ : {
+ value: () => props.loadingStore.store,
+ loading: () => props.loadingStore.state().loading,
+ };
+
const value = () => {
try {
- return props.resource();
+ return source.value();
} catch (err) {
const message = createErrorMessage(
err,
@@ -47,7 +68,7 @@ export default function AsyncContent(
};
return (
<>
-
+
@@ -68,9 +89,9 @@ export default function AsyncContent(
}
>
- {props.children(props.resource() as T)}
+ {props.children(source.value() as T)}
diff --git a/frontend/src/ts/components/pages/AboutPage.tsx b/frontend/src/ts/components/pages/AboutPage.tsx
index d28c202609a0..bf82b2340a93 100644
--- a/frontend/src/ts/components/pages/AboutPage.tsx
+++ b/frontend/src/ts/components/pages/AboutPage.tsx
@@ -45,21 +45,20 @@ export function AboutPage(): JSXElement {
connections.reset()} text="reset" />
- ... is loading
-
- oh oh, {connections.state().error}
-
- refreshing...
-
-
- {(connection) => (
-
- {connection.initiatorName} to {connection.receiverName}
-
- )}
-
-
-
+
+ {(data) => (
+
+ {(con) => (
+
+ {con.initiatorName} to {con.receiverName}
+
+ )}
+
+ )}
+
Created with love by Miodec.
From bad7be6d03377d07559804385e2bff6faf428bc3 Mon Sep 17 00:00:00 2001
From: Christian Fehmer
Date: Sun, 18 Jan 2026 14:18:00 +0100
Subject: [PATCH 08/15] fix loading and error handling on loadingStore
---
.../components/AsyncContent.spec.tsx | 108 +++++++++++++++---
.../src/ts/components/common/AsyncContent.tsx | 28 +++--
frontend/src/ts/signals/util/loadingStore.ts | 1 +
3 files changed, 109 insertions(+), 28 deletions(-)
diff --git a/frontend/__tests__/components/AsyncContent.spec.tsx b/frontend/__tests__/components/AsyncContent.spec.tsx
index 613dd48d2851..da0af5c11240 100644
--- a/frontend/__tests__/components/AsyncContent.spec.tsx
+++ b/frontend/__tests__/components/AsyncContent.spec.tsx
@@ -2,25 +2,12 @@ import { describe, it, expect } from "vitest";
import { render, screen, waitFor } from "@solidjs/testing-library";
import { createResource, Resource } from "solid-js";
import AsyncContent from "../../src/ts/components/common/AsyncContent";
+import {
+ createLoadingStore,
+ LoadingStore,
+} from "../../src/ts/signals/util/loadingStore";
describe("AsyncContent", () => {
- function renderWithResource(
- resource: Resource,
- errorMessage?: string,
- ): {
- container: HTMLElement;
- } {
- const { container } = render(() => (
-
- {(data) => {String(data)}
}
-
- ));
-
- return {
- container,
- };
- }
-
it("renders loading state while resource is pending", () => {
const [resource] = createResource(async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
@@ -75,4 +62,91 @@ describe("AsyncContent", () => {
expect(screen.getByText(/An error occurred/)).toBeInTheDocument();
});
});
+
+ it("renders loading state while loadingStore is pending", () => {
+ const loadingStore = createLoadingStore(
+ async () => {
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ return { data: "data" };
+ },
+ () => ({}),
+ );
+
+ const { container } = renderWithLoadingStore(loadingStore);
+
+ const preloader = container.querySelector(".preloader");
+ expect(preloader).toBeInTheDocument();
+ expect(preloader).toHaveClass("preloader");
+ expect(preloader?.querySelector("i")).toHaveClass(
+ "fas",
+ "fa-fw",
+ "fa-spin",
+ "fa-circle-notch",
+ );
+ });
+
+ it("renders data when loadingStore resolves", async () => {
+ const loadingStore = createLoadingStore<{ data?: string }>(
+ async () => {
+ return { data: "Test Data" };
+ },
+ () => ({}),
+ );
+
+ renderWithLoadingStore(loadingStore);
+
+ await waitFor(() => {
+ expect(screen.getByTestId("content")).toHaveTextContent("Test Data");
+ });
+ });
+
+ it("renders error message when loadingStore fails", async () => {
+ const loadingStore = createLoadingStore(
+ async () => {
+ throw new Error("Test error");
+ },
+ () => ({}),
+ );
+
+ renderWithLoadingStore(loadingStore, "Custom error message");
+
+ await waitFor(() => {
+ expect(screen.getByText(/Custom error message/)).toBeInTheDocument();
+ });
+ });
+
+ function renderWithResource(
+ resource: Resource,
+ errorMessage?: string,
+ ): {
+ container: HTMLElement;
+ } {
+ const { container } = render(() => (
+
+ {(data) => {String(data)}
}
+
+ ));
+
+ return {
+ container,
+ };
+ }
+
+ function renderWithLoadingStore(
+ loadingStore: LoadingStore<{ data?: string }>,
+ errorMessage?: string,
+ ): {
+ container: HTMLElement;
+ } {
+ loadingStore.load();
+ const { container } = render(() => (
+
+ {(data) => {data.data}
}
+
+ ));
+
+ return {
+ container,
+ };
+ }
});
diff --git a/frontend/src/ts/components/common/AsyncContent.tsx b/frontend/src/ts/components/common/AsyncContent.tsx
index 790daf07464e..ed12acb02622 100644
--- a/frontend/src/ts/components/common/AsyncContent.tsx
+++ b/frontend/src/ts/components/common/AsyncContent.tsx
@@ -1,4 +1,4 @@
-import { ErrorBoundary, JSXElement, Resource, Show, Suspense } from "solid-js";
+import { ErrorBoundary, JSXElement, Resource, Show } from "solid-js";
import { createErrorMessage } from "../../utils/misc";
import * as Notifications from "../../elements/notifications";
import { Conditional } from "./Conditional";
@@ -54,7 +54,7 @@ export default function AsyncContent(
}
};
const handleError = (err: unknown): string => {
- console.error(err);
+ console.error("AsyncContext failed", err);
return createErrorMessage(err, props.errorMessage ?? "An error occurred");
};
@@ -81,19 +81,25 @@ export default function AsyncContent(
{handleError(err)}
}
>
-
+ {(err) => {handleError(err)}
}
+
+
+
}
- >
-
- {props.children(source.value() as T)}
-
-
+ else={
+
+ {props.children(source.value() as T)}
+
+ }
+ />
}
/>
diff --git a/frontend/src/ts/signals/util/loadingStore.ts b/frontend/src/ts/signals/util/loadingStore.ts
index 1493b82d104e..d827aa97fe79 100644
--- a/frontend/src/ts/signals/util/loadingStore.ts
+++ b/frontend/src/ts/signals/util/loadingStore.ts
@@ -85,6 +85,7 @@ export function createLoadingStore(
return;
}
updateState("pending");
+ console.log("res:", resource.state);
if (resource.error !== undefined) {
updateState("errored", resource.error);
From c6451481edcaaa4ec142fd0b4894cee05a2c9070 Mon Sep 17 00:00:00 2001
From: Christian Fehmer
Date: Sun, 18 Jan 2026 14:35:42 +0100
Subject: [PATCH 09/15] cleanup
---
.../src/ts/components/common/AsyncContent.tsx | 28 +++++++++----------
1 file changed, 14 insertions(+), 14 deletions(-)
diff --git a/frontend/src/ts/components/common/AsyncContent.tsx b/frontend/src/ts/components/common/AsyncContent.tsx
index ed12acb02622..2ed23dad79e4 100644
--- a/frontend/src/ts/components/common/AsyncContent.tsx
+++ b/frontend/src/ts/components/common/AsyncContent.tsx
@@ -58,6 +58,16 @@ export default function AsyncContent(
return createErrorMessage(err, props.errorMessage ?? "An error occurred");
};
+ const loader: JSXElement = (
+
+
+
+ );
+
+ const errorText = (err: unknown): JSXElement => (
+ {handleError(err)}
+ );
+
return (
(
};
return (
<>
-
-
-
-
-
+ {loader}
{p.children(value())}
>
);
})()}
else={
- {handleError(err)}
}
- >
+
- {(err) => {handleError(err)}
}
+ {errorText}
-
-
- }
+ then={loader}
else={
Date: Sun, 18 Jan 2026 18:51:53 +0100
Subject: [PATCH 10/15] loader wip
---
frontend/src/ts/components/common/Loader.tsx | 25 ++++++++++++++++
.../src/ts/components/pages/AboutPage.tsx | 29 ++++++++++++++++++
frontend/src/ts/pages/page.ts | 30 ++++++++++---------
3 files changed, 70 insertions(+), 14 deletions(-)
create mode 100644 frontend/src/ts/components/common/Loader.tsx
diff --git a/frontend/src/ts/components/common/Loader.tsx b/frontend/src/ts/components/common/Loader.tsx
new file mode 100644
index 000000000000..105d606cbcfd
--- /dev/null
+++ b/frontend/src/ts/components/common/Loader.tsx
@@ -0,0 +1,25 @@
+import { JSXElement } from "solid-js";
+import { LoadingStore } from "../../signals/util/loadingStore";
+import { Keyframe } from "../../pages/page";
+import { Store } from "solid-js/store";
+
+export default function Loader(props: {
+ load: Record; keyframe?: Keyframe }>;
+ showLoader?: boolean;
+ errorMessage?: string;
+ children: (data: Record>) => JSXElement;
+}): JSXElement {
+ return (
+ <>
+ Loading stores ...
+ {props.children(
+ Object.fromEntries(
+ Object.entries(props.load).map(([key, value]) => [
+ key,
+ value.store.store,
+ ]),
+ ),
+ )}
+ >
+ );
+}
diff --git a/frontend/src/ts/components/pages/AboutPage.tsx b/frontend/src/ts/components/pages/AboutPage.tsx
index bf82b2340a93..1c7d61c507fe 100644
--- a/frontend/src/ts/components/pages/AboutPage.tsx
+++ b/frontend/src/ts/components/pages/AboutPage.tsx
@@ -13,6 +13,7 @@ import { ChartJs } from "../common/ChartJs";
import { getThemeColors } from "../../signals/theme";
import { connections } from "../../signals/connections";
import { isAuthenticated } from "../../signals/user";
+import Loader from "../common/Loader";
export function AboutPage(): JSXElement {
const isOpen = (): boolean => getActivePage() === "about";
@@ -37,6 +38,34 @@ export function AboutPage(): JSXElement {
return (
+
+ {({ user, connections }) => (
+ <>
+ User name: {user.name}
+ Number of connections {connections.length}
+ >
+ )}
+
+
Connections {connections.store.length}
diff --git a/frontend/src/ts/pages/page.ts b/frontend/src/ts/pages/page.ts
index aa23f89e43b6..a671b7cf8093 100644
--- a/frontend/src/ts/pages/page.ts
+++ b/frontend/src/ts/pages/page.ts
@@ -4,6 +4,7 @@ import {
serialize as serializeUrlSearchParams,
} from "zod-urlsearchparams";
import { ElementWithUtils } from "../utils/dom";
+import { Key } from "readline";
export type PageName =
| "loading"
@@ -24,6 +25,20 @@ type Options = {
data?: T;
};
+export type Keyframe = {
+ /**
+ * Percentage of the loading bar to fill.
+ */
+ percentage: number;
+ /**
+ * Duration in milliseconds for the keyframe animation.
+ */
+ durationMs: number;
+ /**
+ * Text to display below the loading bar.
+ */
+ text?: string;
+};
export type LoadingOptions = {
/**
* Get the loading mode for this page.
@@ -50,20 +65,7 @@ export type LoadingOptions = {
* Each keyframe will be shown in order, with the specified percentage and duration.
* If not provided, a loading spinner will be shown instead.
*/
- keyframes: {
- /**
- * Percentage of the loading bar to fill.
- */
- percentage: number;
- /**
- * Duration in milliseconds for the keyframe animation.
- */
- durationMs: number;
- /**
- * Text to display below the loading bar.
- */
- text?: string;
- }[];
+ keyframes: Keyframe[];
}
);
From 07b5421bd682f1e7a728c5f161090ec1ee3a0200 Mon Sep 17 00:00:00 2001
From: Christian Fehmer
Date: Mon, 19 Jan 2026 00:24:25 +0100
Subject: [PATCH 11/15] add loading/error to Loader
---
backend/src/api/routes/index.ts | 3 +-
frontend/src/ts/components/common/Loader.tsx | 112 +++++++++++++++---
.../src/ts/components/pages/AboutPage.tsx | 16 +++
frontend/src/ts/pages/page.ts | 1 -
frontend/src/ts/signals/util/loadingStore.ts | 13 +-
5 files changed, 120 insertions(+), 25 deletions(-)
diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts
index 61a3ff2f5b98..4e8078bea8b6 100644
--- a/backend/src/api/routes/index.ts
+++ b/backend/src/api/routes/index.ts
@@ -140,7 +140,8 @@ function applyDevApiRoutes(app: Application): void {
app.use("/configure", expressStatic(join(__dirname, "../../../private")));
app.use(async (req, res, next) => {
- const slowdown = (await getLiveConfiguration()).dev.responseSlowdownMs;
+ let slowdown = (await getLiveConfiguration()).dev.responseSlowdownMs;
+ if (req.path.includes("connection")) slowdown *= 2;
if (slowdown > 0) {
Logger.info(
`Simulating ${slowdown}ms delay for ${req.method} ${req.path}`,
diff --git a/frontend/src/ts/components/common/Loader.tsx b/frontend/src/ts/components/common/Loader.tsx
index 105d606cbcfd..ab1f74b8bdcf 100644
--- a/frontend/src/ts/components/common/Loader.tsx
+++ b/frontend/src/ts/components/common/Loader.tsx
@@ -1,25 +1,103 @@
-import { JSXElement } from "solid-js";
-import { LoadingStore } from "../../signals/util/loadingStore";
+import {
+ Accessor,
+ createEffect,
+ createMemo,
+ JSXElement,
+ Show,
+ on,
+} from "solid-js";
+import { LoadError, LoadingStore } from "../../signals/util/loadingStore";
import { Keyframe } from "../../pages/page";
import { Store } from "solid-js/store";
-export default function Loader(props: {
- load: Record; keyframe?: Keyframe }>;
+type LoadingStoreAndKeyframe = {
+ store: LoadingStore;
+ keyframe?: Keyframe;
+};
+type LoadShape = Record;
+type ChildData = {
+ [K in keyof L]: L[K]["store"] extends LoadingStore
+ ? Store
+ : never;
+};
+
+type LoaderProps = {
+ active: Accessor;
+ load: L;
showLoader?: boolean;
errorMessage?: string;
- children: (data: Record>) => JSXElement;
-}): JSXElement {
+ children: (data: ChildData) => JSXElement;
+};
+
+export default function Loader(
+ props: LoaderProps,
+): JSXElement {
+ const loaders = createMemo(() =>
+ Object.values(props.load),
+ );
+
+ createEffect(
+ on(
+ props.active,
+ (active) => {
+ if (active) {
+ console.debug("Loader: load all stores");
+ loaders().forEach((it) => it.store.load());
+ }
+ },
+ { defer: true },
+ ),
+ );
+
+ const stores = createMemo(
+ () =>
+ Object.fromEntries(
+ Object.entries(props.load).map(([key, value]) => [
+ key,
+ value.store.store,
+ ]),
+ ) as {
+ [K in keyof L]: L[K]["store"] extends LoadingStore
+ ? Store
+ : never;
+ },
+ );
+
+ const firstLoadingKeyframe = createMemo(() => {
+ let min: Keyframe | undefined;
+
+ for (const { store, keyframe } of loaders()) {
+ if (!keyframe || !store.state().loading) continue;
+ if (!min || keyframe.percentage < min.percentage) {
+ min = keyframe;
+ }
+ }
+
+ return min;
+ });
+
+ const hasError = createMemo(
+ () =>
+ loaders()
+ .map((it) => it.store.state())
+ .find((it) => it.error !== undefined)?.error,
+ );
+
return (
- <>
- Loading stores ...
- {props.children(
- Object.fromEntries(
- Object.entries(props.load).map(([key, value]) => [
- key,
- value.store.store,
- ]),
- ),
- )}
- >
+ loading keyframe {firstLoadingKeyframe()?.text}}
+ >
+
+ {props.errorMessage ?? "Loading failed"}: {hasError()?.message}
+
+ }
+ >
+ {props.children(stores())}
+
+
);
}
diff --git a/frontend/src/ts/components/pages/AboutPage.tsx b/frontend/src/ts/components/pages/AboutPage.tsx
index 1c7d61c507fe..79c86b6d3878 100644
--- a/frontend/src/ts/components/pages/AboutPage.tsx
+++ b/frontend/src/ts/components/pages/AboutPage.tsx
@@ -14,6 +14,8 @@ import { getThemeColors } from "../../signals/theme";
import { connections } from "../../signals/connections";
import { isAuthenticated } from "../../signals/user";
import Loader from "../common/Loader";
+import { createLoadingStore } from "../../signals/util/loadingStore";
+import { User } from "@monkeytype/schemas/users";
export function AboutPage(): JSXElement {
const isOpen = (): boolean => getActivePage() === "about";
@@ -32,6 +34,19 @@ export function AboutPage(): JSXElement {
open ? await fetchSpeedHistogram() : undefined,
);
+ const users = createLoadingStore(
+ async () => {
+ const response = await Ape.users.get();
+
+ if (response.status !== 200) {
+ throw new Error(response.body.message);
+ }
+ return response.body.data;
+ },
+
+ () => ({}) as User,
+ );
+
createEffect(() => {
console.log(getThemeColors());
});
@@ -39,6 +54,7 @@ export function AboutPage(): JSXElement {
return (
, "loading" | "error" | "state"> & {
+export type LoadError = Error | { message?: string };
+type State = Pick, "loading" | "state"> & {
+ error?: LoadError;
ready: boolean;
refreshing: boolean;
};
@@ -66,15 +68,13 @@ export function createLoadingStore(
const updateState = (
state: Resource["state"],
- // oxlint-disable-next-line typescript/no-explicit-any
- error?: any,
+ error?: LoadError,
): void => {
setState({
state,
loading: state === "pending",
ready: state === "ready",
refreshing: state === "refreshing",
- // oxlint-disable-next-line typescript/no-explicit-any typescript/no-unsafe-assignment
error: error,
});
};
@@ -88,7 +88,7 @@ export function createLoadingStore(
console.log("res:", resource.state);
if (resource.error !== undefined) {
- updateState("errored", resource.error);
+ updateState("errored", resource.error as LoadError);
ready.reject(resource.error);
ready = promiseWithResolvers();
return;
@@ -121,7 +121,8 @@ export function createLoadingStore(
setStore(initialValue());
// reject any waiters
- ready.reject(new Error("Reset"));
+ //TODO figue out why this in unhandled
+ //ready.reject(new Error("Reset"));
ready = promiseWithResolvers();
};
From 03397b7a7848fb1044d006e6dee9873111e9b932 Mon Sep 17 00:00:00 2001
From: Christian Fehmer
Date: Mon, 19 Jan 2026 10:46:18 +0100
Subject: [PATCH 12/15] derp
---
.../components/AsyncContent.spec.tsx | 3 +
.../signals/util/loadingStore.spec.ts | 18 +--
frontend/src/index.html | 1 +
frontend/src/ts/auth.ts | 3 +
frontend/src/ts/components/common/Loader.tsx | 48 ++++---
.../src/ts/components/common/PreLoader.tsx | 122 ++++++++++++++++++
frontend/src/ts/components/mount.tsx | 2 +
.../src/ts/components/pages/AboutPage.tsx | 30 +----
frontend/src/ts/db.ts | 101 +++++++++------
frontend/src/ts/ready.ts | 8 +-
frontend/src/ts/signals/connections.ts | 3 +
.../src/ts/signals/server-configuration.ts | 16 +++
frontend/src/ts/signals/util/loadingStore.ts | 3 +
13 files changed, 264 insertions(+), 94 deletions(-)
create mode 100644 frontend/src/ts/components/common/PreLoader.tsx
create mode 100644 frontend/src/ts/signals/server-configuration.ts
diff --git a/frontend/__tests__/components/AsyncContent.spec.tsx b/frontend/__tests__/components/AsyncContent.spec.tsx
index da0af5c11240..37ff6d0e72ce 100644
--- a/frontend/__tests__/components/AsyncContent.spec.tsx
+++ b/frontend/__tests__/components/AsyncContent.spec.tsx
@@ -65,6 +65,7 @@ describe("AsyncContent", () => {
it("renders loading state while loadingStore is pending", () => {
const loadingStore = createLoadingStore(
+ "test",
async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
return { data: "data" };
@@ -87,6 +88,7 @@ describe("AsyncContent", () => {
it("renders data when loadingStore resolves", async () => {
const loadingStore = createLoadingStore<{ data?: string }>(
+ "test",
async () => {
return { data: "Test Data" };
},
@@ -102,6 +104,7 @@ describe("AsyncContent", () => {
it("renders error message when loadingStore fails", async () => {
const loadingStore = createLoadingStore(
+ "test",
async () => {
throw new Error("Test error");
},
diff --git a/frontend/__tests__/signals/util/loadingStore.spec.ts b/frontend/__tests__/signals/util/loadingStore.spec.ts
index 994b6d9af3e8..7040ced8850e 100644
--- a/frontend/__tests__/signals/util/loadingStore.spec.ts
+++ b/frontend/__tests__/signals/util/loadingStore.spec.ts
@@ -11,7 +11,7 @@ describe("createLoadingStore", () => {
});
it("should initialize with the correct state", () => {
- const store = createLoadingStore(mockFetcher, initialValue);
+ const store = createLoadingStore("test", mockFetcher, initialValue);
expect(store.state().state).toBe("unresolved");
expect(store.state().loading).toBe(false);
@@ -22,7 +22,7 @@ describe("createLoadingStore", () => {
});
it("should transition to loading when load is called", async () => {
- const store = createLoadingStore(mockFetcher, initialValue);
+ const store = createLoadingStore("test", mockFetcher, initialValue);
store.load();
expect(store.state().state).toBe("pending");
@@ -30,14 +30,14 @@ describe("createLoadingStore", () => {
});
it("should enable loading if ready is called", async () => {
- const store = createLoadingStore(mockFetcher, initialValue);
+ const store = createLoadingStore("test", mockFetcher, initialValue);
mockFetcher.mockResolvedValueOnce({ data: "test" });
await store.ready();
});
it("should call the fetcher when load is called", async () => {
- const store = createLoadingStore(mockFetcher, initialValue);
+ const store = createLoadingStore("test", mockFetcher, initialValue);
mockFetcher.mockResolvedValueOnce({ data: "test" });
store.load();
@@ -50,7 +50,7 @@ describe("createLoadingStore", () => {
it("should handle error when fetcher fails", async () => {
mockFetcher.mockRejectedValueOnce(new Error("Failed to load"));
- const store = createLoadingStore(mockFetcher, initialValue);
+ const store = createLoadingStore("test", mockFetcher, initialValue);
store.load();
@@ -61,7 +61,7 @@ describe("createLoadingStore", () => {
});
it("should transition to refreshing state on refresh", async () => {
- const store = createLoadingStore(mockFetcher, initialValue);
+ const store = createLoadingStore("test", mockFetcher, initialValue);
mockFetcher.mockResolvedValueOnce({ data: "test" });
store.load();
@@ -71,7 +71,7 @@ describe("createLoadingStore", () => {
});
it("should trigger load when refresh is called and shouldLoad is false", async () => {
- const store = createLoadingStore(mockFetcher, initialValue);
+ const store = createLoadingStore("test", mockFetcher, initialValue);
mockFetcher.mockResolvedValueOnce({ data: "test" });
expect(store.state().state).toBe("unresolved");
@@ -88,7 +88,7 @@ describe("createLoadingStore", () => {
});
it("should reset the store to its initial value on reset", async () => {
- const store = createLoadingStore(mockFetcher, initialValue);
+ const store = createLoadingStore("test", mockFetcher, initialValue);
mockFetcher.mockResolvedValueOnce({ data: "test" });
store.load();
@@ -103,7 +103,7 @@ describe("createLoadingStore", () => {
});
it("should handle a promise rejection during reset", async () => {
- const store = createLoadingStore(mockFetcher, initialValue);
+ const store = createLoadingStore("test", mockFetcher, initialValue);
// Mock the fetcher to resolve with data
mockFetcher.mockResolvedValueOnce({ data: "test" });
diff --git a/frontend/src/index.html b/frontend/src/index.html
index 75440c591c7a..4cad91d7af92 100644
--- a/frontend/src/index.html
+++ b/frontend/src/index.html
@@ -21,6 +21,7 @@
+
diff --git a/frontend/src/ts/auth.ts b/frontend/src/ts/auth.ts
index da87b66a29ee..95dbfb6424a4 100644
--- a/frontend/src/ts/auth.ts
+++ b/frontend/src/ts/auth.ts
@@ -31,6 +31,7 @@ import * as Sentry from "./sentry";
import { tryCatch } from "@monkeytype/util/trycatch";
import * as AuthEvent from "./observables/auth-event";
import { qs, qsa } from "./utils/dom";
+import { preloaderDonePromise } from "./components/common/PreLoader";
export const gmailProvider = new GoogleAuthProvider();
export const githubProvider = new GithubAuthProvider();
@@ -59,6 +60,8 @@ async function sendVerificationEmail(): Promise {
async function getDataAndInit(): Promise {
try {
console.log("getting account data");
+ await preloaderDonePromise;
+ console.log("getting account data waiting done");
const snapshot = await DB.initSnapshot();
if (snapshot === false) {
diff --git a/frontend/src/ts/components/common/Loader.tsx b/frontend/src/ts/components/common/Loader.tsx
index ab1f74b8bdcf..ce12505a043a 100644
--- a/frontend/src/ts/components/common/Loader.tsx
+++ b/frontend/src/ts/components/common/Loader.tsx
@@ -22,11 +22,12 @@ type ChildData = {
};
type LoaderProps = {
- active: Accessor;
+ active: true | Accessor;
load: L;
showLoader?: boolean;
errorMessage?: string;
- children: (data: ChildData) => JSXElement;
+ onComplete?: (data: ChildData) => void;
+ children?: (data: ChildData) => JSXElement;
};
export default function Loader(
@@ -36,18 +37,23 @@ export default function Loader(
Object.values(props.load),
);
- createEffect(
- on(
- props.active,
- (active) => {
- if (active) {
- console.debug("Loader: load all stores");
- loaders().forEach((it) => it.store.load());
- }
- },
- { defer: true },
- ),
- );
+ if (props.active === true) {
+ console.debug("Loader: load all stores");
+ loaders().forEach((it) => it.store.load());
+ } else {
+ createEffect(
+ on(
+ props.active,
+ (active) => {
+ if (active) {
+ console.debug("Loader: load all stores");
+ loaders().forEach((it) => it.store.load());
+ }
+ },
+ { defer: true },
+ ),
+ );
+ }
const stores = createMemo(
() =>
@@ -63,6 +69,18 @@ export default function Loader(
},
);
+ let completed = false;
+ const allReady = createMemo(() =>
+ loaders().every((it) => it.store.state().ready),
+ );
+
+ createEffect(() => {
+ if (!completed && allReady()) {
+ completed = true;
+ props.onComplete?.(stores());
+ }
+ });
+
const firstLoadingKeyframe = createMemo(() => {
let min: Keyframe | undefined;
@@ -96,7 +114,7 @@ export default function Loader(
}
>
- {props.children(stores())}
+ {props.children?.(stores())}
);
diff --git a/frontend/src/ts/components/common/PreLoader.tsx b/frontend/src/ts/components/common/PreLoader.tsx
new file mode 100644
index 000000000000..35c42a3fef1e
--- /dev/null
+++ b/frontend/src/ts/components/common/PreLoader.tsx
@@ -0,0 +1,122 @@
+import { createEffect, JSXElement, on } from "solid-js";
+import { createLoadingStore } from "../../signals/util/loadingStore";
+import { PartialConfig } from "@monkeytype/schemas/configs";
+import Ape from "../../ape";
+import { isAuthenticated } from "../../signals/user";
+import { Preset } from "@monkeytype/schemas/presets";
+import Loader from "./Loader";
+import { serverConfiguration } from "../../signals/server-configuration";
+import { connections } from "../../signals/connections";
+import { GetUserResponse } from "@monkeytype/contracts/users";
+import { initSnapshot } from "../../db";
+import { Connection } from "@monkeytype/schemas/connections";
+import { promiseWithResolvers } from "../../utils/misc";
+
+const { promise: preloaderDonePromise, resolve: loadDone } =
+ promiseWithResolvers();
+
+export { preloaderDonePromise };
+
+export function PreLoader(): JSXElement {
+ console.log("#### preloader");
+ const user = createLoadingStore(
+ "user",
+ async () => {
+ const response = await Ape.users.get();
+
+ if (response.status !== 200) {
+ throw new Error(response.body.message);
+ }
+ return response.body.data;
+ },
+
+ () => ({}) as GetUserResponse["data"],
+ );
+ const partialConfig = createLoadingStore(
+ "userConfig",
+ async () => {
+ const response = await Ape.configs.get();
+
+ if (response.status !== 200) {
+ throw new Error(response.body.message);
+ }
+ return response.body.data as PartialConfig;
+ },
+
+ () => ({}) as PartialConfig,
+ );
+ const presets = createLoadingStore(
+ "presets",
+ async () => {
+ const response = await Ape.presets.get();
+
+ if (response.status !== 200) {
+ throw new Error(response.body.message);
+ }
+ return response.body.data;
+ },
+
+ () => [],
+ );
+
+ createEffect(() => {
+ on(isAuthenticated, (authenticated: boolean) => {
+ if (authenticated) return;
+
+ console.debug("PreLoader: cleaning user data.");
+ [partialConfig, user, presets].forEach((it) => it.reset());
+ });
+ });
+
+ return (
+ isAuthenticated() && serverConfiguration.state().ready}
+ onComplete={isLoaded}
+ load={{
+ userData: {
+ store: user,
+ keyframe: {
+ percentage: 80,
+ durationMs: 1,
+ text: "Downloading user data...",
+ },
+ },
+ configData: {
+ store: partialConfig,
+ keyframe: {
+ percentage: 85,
+ durationMs: 1,
+ text: "Downloading user config...",
+ },
+ },
+ presetsData: {
+ store: presets,
+ keyframe: {
+ percentage: 90,
+ durationMs: 1,
+ text: "Downloading user presets...",
+ },
+ },
+ connectionsData: {
+ store: connections,
+ keyframe: {
+ percentage: 95,
+ durationMs: 1,
+ text: "Downloading friends...",
+ },
+ },
+ }}
+ />
+ );
+}
+
+function isLoaded(stores: {
+ userData: GetUserResponse["data"];
+ configData: PartialConfig;
+ presetsData: Preset[];
+ connectionsData: Connection[];
+}): void {
+ console.log("preloader done loading", stores.userData.name);
+ void initSnapshot(stores);
+ loadDone();
+}
diff --git a/frontend/src/ts/components/mount.tsx b/frontend/src/ts/components/mount.tsx
index 83adca3ef98a..6e0009e9d4f2 100644
--- a/frontend/src/ts/components/mount.tsx
+++ b/frontend/src/ts/components/mount.tsx
@@ -5,11 +5,13 @@ import { JSXElement } from "solid-js";
import { Footer } from "./layout/footer/Footer";
import { Modals } from "./modals/Modals";
import { AboutPage } from "./pages/AboutPage";
+import { PreLoader } from "./common/PreLoader";
const components: Record JSXElement> = {
Footer: () => ,
Modals: () => ,
AboutPage: () => ,
+ PreLoader: () => ,
};
function mountToMountpoint(name: string, component: () => JSXElement): void {
diff --git a/frontend/src/ts/components/pages/AboutPage.tsx b/frontend/src/ts/components/pages/AboutPage.tsx
index 79c86b6d3878..670ca3c45f54 100644
--- a/frontend/src/ts/components/pages/AboutPage.tsx
+++ b/frontend/src/ts/components/pages/AboutPage.tsx
@@ -14,8 +14,6 @@ import { getThemeColors } from "../../signals/theme";
import { connections } from "../../signals/connections";
import { isAuthenticated } from "../../signals/user";
import Loader from "../common/Loader";
-import { createLoadingStore } from "../../signals/util/loadingStore";
-import { User } from "@monkeytype/schemas/users";
export function AboutPage(): JSXElement {
const isOpen = (): boolean => getActivePage() === "about";
@@ -34,19 +32,6 @@ export function AboutPage(): JSXElement {
open ? await fetchSpeedHistogram() : undefined,
);
- const users = createLoadingStore(
- async () => {
- const response = await Ape.users.get();
-
- if (response.status !== 200) {
- throw new Error(response.body.message);
- }
- return response.body.data;
- },
-
- () => ({}) as User,
- );
-
createEffect(() => {
console.log(getThemeColors());
});
@@ -56,14 +41,6 @@ export function AboutPage(): JSXElement {
- {({ user, connections }) => (
- <>
- User name: {user.name}
- Number of connections {connections.length}
- >
- )}
+ {({ connections }) => Number of connections {connections.length}
}
Connections {connections.store.length}
diff --git a/frontend/src/ts/db.ts b/frontend/src/ts/db.ts
index f71418abdb90..bce5ca013207 100644
--- a/frontend/src/ts/db.ts
+++ b/frontend/src/ts/db.ts
@@ -12,7 +12,7 @@ import {
import * as Loader from "./elements/loader";
import { Badge, CustomTheme } from "@monkeytype/schemas/users";
-import { Config, Difficulty } from "@monkeytype/schemas/configs";
+import { Config, Difficulty, PartialConfig } from "@monkeytype/schemas/configs";
import {
Mode,
Mode2,
@@ -36,6 +36,8 @@ import {
get as getServerConfiguration,
} from "./ape/server-configuration";
import { Connection } from "@monkeytype/schemas/connections";
+import { Preset } from "@monkeytype/schemas/presets";
+import { GetUserResponse } from "@monkeytype/contracts/users";
let dbSnapshot: Snapshot | undefined;
const firstDayOfTheWeek = getFirstDayOfTheWeek();
@@ -87,7 +89,17 @@ export function setSnapshot(
}
}
-export async function initSnapshot(): Promise {
+export async function initSnapshot(preload?: {
+ userData: GetUserResponse["data"];
+ presetsData: Preset[];
+ configData: PartialConfig | null;
+ connectionsData: Connection[];
+}): Promise {
+ console.log("DB: init snapshot");
+ if (dbSnapshot !== undefined) {
+ console.log("DB: return cached snapshot");
+ return dbSnapshot;
+ }
//send api request with token that returns tags, presets, and data needed for snap
const snap = getDefaultSnapshot();
await configurationPromise;
@@ -95,48 +107,63 @@ export async function initSnapshot(): Promise {
try {
if (!isAuthenticated()) return false;
- const connectionsRequest = getServerConfiguration()?.connections.enabled
- ? Ape.connections.get()
- : { status: 200, body: { message: "", data: [] } };
-
- const [userResponse, configResponse, presetsResponse, connectionsResponse] =
- await Promise.all([
+ let userData: GetUserResponse["data"];
+ let presetsData: Preset[];
+ let configData: PartialConfig | null;
+ let connectionsData: Connection[];
+ if (preload) {
+ console.log("DB: init from preload");
+ userData = preload.userData;
+ presetsData = preload.presetsData;
+ configData = preload.configData;
+ connectionsData = preload.connectionsData;
+ } else {
+ const connectionsRequest = getServerConfiguration()?.connections.enabled
+ ? Ape.connections.get()
+ : { status: 200, body: { message: "", data: [] } };
+
+ const [
+ userResponse,
+ configResponse,
+ presetsResponse,
+ connectionsResponse,
+ ] = await Promise.all([
Ape.users.get(),
Ape.configs.get(),
Ape.presets.get(),
connectionsRequest,
]);
- if (userResponse.status !== 200) {
- throw new SnapshotInitError(
- `${userResponse.body.message} (user)`,
- userResponse.status,
- );
- }
- if (configResponse.status !== 200) {
- throw new SnapshotInitError(
- `${configResponse.body.message} (config)`,
- configResponse.status,
- );
- }
- if (presetsResponse.status !== 200) {
- throw new SnapshotInitError(
- `${presetsResponse.body.message} (presets)`,
- presetsResponse.status,
- );
- }
- if (connectionsResponse.status !== 200) {
- throw new SnapshotInitError(
- `${connectionsResponse.body.message} (connections)`,
- connectionsResponse.status,
- );
- }
-
- const userData = userResponse.body.data;
- const configData = configResponse.body.data;
- const presetsData = presetsResponse.body.data;
- const connectionsData = connectionsResponse.body.data;
+ if (userResponse.status !== 200) {
+ throw new SnapshotInitError(
+ `${userResponse.body.message} (user)`,
+ userResponse.status,
+ );
+ }
+ if (configResponse.status !== 200) {
+ throw new SnapshotInitError(
+ `${configResponse.body.message} (config)`,
+ configResponse.status,
+ );
+ }
+ if (presetsResponse.status !== 200) {
+ throw new SnapshotInitError(
+ `${presetsResponse.body.message} (presets)`,
+ presetsResponse.status,
+ );
+ }
+ if (connectionsResponse.status !== 200) {
+ throw new SnapshotInitError(
+ `${connectionsResponse.body.message} (connections)`,
+ connectionsResponse.status,
+ );
+ }
+ userData = userResponse.body.data;
+ configData = configResponse.body.data;
+ presetsData = presetsResponse.body.data;
+ connectionsData = connectionsResponse.body.data;
+ }
if (userData === null) {
throw new SnapshotInitError(
`Request was successful but user data is null`,
diff --git a/frontend/src/ts/ready.ts b/frontend/src/ts/ready.ts
index 7c5fe24ac154..76492b159c27 100644
--- a/frontend/src/ts/ready.ts
+++ b/frontend/src/ts/ready.ts
@@ -5,7 +5,7 @@ import * as ConnectionState from "./states/connection";
import * as AccountButton from "./elements/account-button";
//@ts-expect-error no types for this package
import Konami from "konami";
-import * as ServerConfiguration from "./ape/server-configuration";
+import { serverConfiguration as ServerConfiguration } from "./signals/server-configuration";
import { getActiveFunboxesWithFunction } from "./test/funbox/list";
import { configLoadPromise } from "./config";
import { authPromise } from "./firebase";
@@ -34,14 +34,14 @@ onDOMReady(async () => {
duration: Misc.applyReducedMotion(250),
});
if (ConnectionState.get()) {
- void ServerConfiguration.sync().then(() => {
- if (!ServerConfiguration.get()?.users.signUp) {
+ void ServerConfiguration.ready().then(() => {
+ if (!ServerConfiguration.store.users.signUp) {
AccountButton.hide();
qs(".register")?.addClass("hidden");
qs(".login")?.addClass("hidden");
qs(".disabledNotification")?.removeClass("hidden");
}
- if (!ServerConfiguration.get()?.connections.enabled) {
+ if (!ServerConfiguration.store.connections.enabled) {
qs(".accountButtonAndMenu .goToFriends")?.addClass("hidden");
}
});
diff --git a/frontend/src/ts/signals/connections.ts b/frontend/src/ts/signals/connections.ts
index 7b5934ae4411..b9b5ee43a0e3 100644
--- a/frontend/src/ts/signals/connections.ts
+++ b/frontend/src/ts/signals/connections.ts
@@ -3,9 +3,12 @@ import { createLoadingStore } from "./util/loadingStore";
import Ape from "../ape/";
import { createEffect } from "solid-js";
import { isAuthenticated } from "./user";
+import { serverConfiguration } from "./server-configuration";
export const connections = createLoadingStore(
+ "connections",
async () => {
+ if (!serverConfiguration.store.connections.enabled) return [];
const response = await Ape.connections.get();
if (response.status !== 200) {
diff --git a/frontend/src/ts/signals/server-configuration.ts b/frontend/src/ts/signals/server-configuration.ts
new file mode 100644
index 000000000000..ca08102b3361
--- /dev/null
+++ b/frontend/src/ts/signals/server-configuration.ts
@@ -0,0 +1,16 @@
+import { Configuration } from "@monkeytype/schemas/configuration";
+import { createLoadingStore } from "./util/loadingStore";
+import Ape from "../ape";
+
+export const serverConfiguration = createLoadingStore(
+ "serverConfig",
+ async () => {
+ const response = await Ape.configuration.get();
+
+ if (response.status !== 200) {
+ throw new Error(response.body.message);
+ }
+ return response.body.data;
+ },
+ () => ({}) as Configuration,
+);
diff --git a/frontend/src/ts/signals/util/loadingStore.ts b/frontend/src/ts/signals/util/loadingStore.ts
index c68b7e2442f5..1decb6538fc1 100644
--- a/frontend/src/ts/signals/util/loadingStore.ts
+++ b/frontend/src/ts/signals/util/loadingStore.ts
@@ -44,9 +44,11 @@ export type LoadingStore = {
};
export function createLoadingStore(
+ name: string,
fetcher: () => Promise,
initialValue: () => T,
): LoadingStore {
+ console.debug(`LoadingStore ${name}: created`);
const [shouldLoad, setShouldLoad] = createSignal(false);
const [getState, setState] = createSignal({
state: "unresolved",
@@ -70,6 +72,7 @@ export function createLoadingStore(
state: Resource["state"],
error?: LoadError,
): void => {
+ console.debug(`LoadingStore ${name}: update state to ${state}`);
setState({
state,
loading: state === "pending",
From 9e9badb6fe53214c9ee83acdfcb490cbc1aa5c72 Mon Sep 17 00:00:00 2001
From: Christian Fehmer
Date: Mon, 19 Jan 2026 11:14:56 +0100
Subject: [PATCH 13/15] fix
---
frontend/src/ts/ape/server-configuration.ts | 30 ++------
frontend/src/ts/auth.ts | 7 +-
.../src/ts/components/common/PreLoader.tsx | 11 ++-
frontend/src/ts/db.ts | 69 ++-----------------
frontend/src/ts/signals/connections.ts | 6 --
frontend/src/ts/signals/util/loadingStore.ts | 1 -
6 files changed, 21 insertions(+), 103 deletions(-)
diff --git a/frontend/src/ts/ape/server-configuration.ts b/frontend/src/ts/ape/server-configuration.ts
index 75a60cfd99cd..8a4e59d4d952 100644
--- a/frontend/src/ts/ape/server-configuration.ts
+++ b/frontend/src/ts/ape/server-configuration.ts
@@ -1,31 +1,15 @@
import { Configuration } from "@monkeytype/schemas/configuration";
-import Ape from ".";
-import { promiseWithResolvers } from "../utils/misc";
+import { serverConfiguration } from "../signals/server-configuration";
-let config: Configuration | undefined = undefined;
-
-const {
- promise: configurationPromise,
- resolve,
- reject,
-} = promiseWithResolvers();
-
-export { configurationPromise };
+export const configurationPromise = serverConfiguration.ready();
export function get(): Configuration | undefined {
- return config;
+ return serverConfiguration.state().ready
+ ? serverConfiguration.store
+ : undefined;
}
export async function sync(): Promise {
- const response = await Ape.configuration.get();
-
- if (response.status !== 200) {
- const message = `Could not fetch configuration: ${response.body.message}`;
- console.error(message);
- reject(message);
- return;
- } else {
- config = response.body.data ?? undefined;
- resolve(true);
- }
+ serverConfiguration.refresh();
+ return serverConfiguration.ready();
}
diff --git a/frontend/src/ts/auth.ts b/frontend/src/ts/auth.ts
index 95dbfb6424a4..79af1ba908dc 100644
--- a/frontend/src/ts/auth.ts
+++ b/frontend/src/ts/auth.ts
@@ -59,12 +59,11 @@ async function sendVerificationEmail(): Promise {
async function getDataAndInit(): Promise {
try {
- console.log("getting account data");
await preloaderDonePromise;
- console.log("getting account data waiting done");
- const snapshot = await DB.initSnapshot();
+ const snapshot = DB.getSnapshot();
+ console.log("got snapshot", snapshot);
- if (snapshot === false) {
+ if (snapshot === undefined) {
throw new Error(
"Snapshot didn't initialize due to lacking authentication even though user is authenticated",
);
diff --git a/frontend/src/ts/components/common/PreLoader.tsx b/frontend/src/ts/components/common/PreLoader.tsx
index 35c42a3fef1e..b964431eb398 100644
--- a/frontend/src/ts/components/common/PreLoader.tsx
+++ b/frontend/src/ts/components/common/PreLoader.tsx
@@ -1,4 +1,4 @@
-import { createEffect, JSXElement, on } from "solid-js";
+import { createEffect, JSXElement } from "solid-js";
import { createLoadingStore } from "../../signals/util/loadingStore";
import { PartialConfig } from "@monkeytype/schemas/configs";
import Ape from "../../ape";
@@ -18,7 +18,6 @@ const { promise: preloaderDonePromise, resolve: loadDone } =
export { preloaderDonePromise };
export function PreLoader(): JSXElement {
- console.log("#### preloader");
const user = createLoadingStore(
"user",
async () => {
@@ -60,12 +59,10 @@ export function PreLoader(): JSXElement {
);
createEffect(() => {
- on(isAuthenticated, (authenticated: boolean) => {
- if (authenticated) return;
+ if (!isAuthenticated()) return;
- console.debug("PreLoader: cleaning user data.");
- [partialConfig, user, presets].forEach((it) => it.reset());
- });
+ console.debug("PreLoader: cleaning user data.");
+ [partialConfig, user, presets].forEach((it) => it.reset());
});
return (
diff --git a/frontend/src/ts/db.ts b/frontend/src/ts/db.ts
index bce5ca013207..9b49ea59e0f0 100644
--- a/frontend/src/ts/db.ts
+++ b/frontend/src/ts/db.ts
@@ -31,13 +31,11 @@ import { FunboxMetadata } from "../../../packages/funbox/src/types";
import { getFirstDayOfTheWeek } from "./utils/date-and-time";
import { Language } from "@monkeytype/schemas/languages";
import * as AuthEvent from "./observables/auth-event";
-import {
- configurationPromise,
- get as getServerConfiguration,
-} from "./ape/server-configuration";
+import { configurationPromise } from "./ape/server-configuration";
import { Connection } from "@monkeytype/schemas/connections";
import { Preset } from "@monkeytype/schemas/presets";
import { GetUserResponse } from "@monkeytype/contracts/users";
+import { unwrap } from "solid-js/store";
let dbSnapshot: Snapshot | undefined;
const firstDayOfTheWeek = getFirstDayOfTheWeek();
@@ -89,13 +87,12 @@ export function setSnapshot(
}
}
-export async function initSnapshot(preload?: {
+export async function initSnapshot(preload: {
userData: GetUserResponse["data"];
presetsData: Preset[];
configData: PartialConfig | null;
connectionsData: Connection[];
}): Promise {
- console.log("DB: init snapshot");
if (dbSnapshot !== undefined) {
console.log("DB: return cached snapshot");
return dbSnapshot;
@@ -107,63 +104,11 @@ export async function initSnapshot(preload?: {
try {
if (!isAuthenticated()) return false;
- let userData: GetUserResponse["data"];
- let presetsData: Preset[];
- let configData: PartialConfig | null;
- let connectionsData: Connection[];
- if (preload) {
- console.log("DB: init from preload");
- userData = preload.userData;
- presetsData = preload.presetsData;
- configData = preload.configData;
- connectionsData = preload.connectionsData;
- } else {
- const connectionsRequest = getServerConfiguration()?.connections.enabled
- ? Ape.connections.get()
- : { status: 200, body: { message: "", data: [] } };
-
- const [
- userResponse,
- configResponse,
- presetsResponse,
- connectionsResponse,
- ] = await Promise.all([
- Ape.users.get(),
- Ape.configs.get(),
- Ape.presets.get(),
- connectionsRequest,
- ]);
-
- if (userResponse.status !== 200) {
- throw new SnapshotInitError(
- `${userResponse.body.message} (user)`,
- userResponse.status,
- );
- }
- if (configResponse.status !== 200) {
- throw new SnapshotInitError(
- `${configResponse.body.message} (config)`,
- configResponse.status,
- );
- }
- if (presetsResponse.status !== 200) {
- throw new SnapshotInitError(
- `${presetsResponse.body.message} (presets)`,
- presetsResponse.status,
- );
- }
- if (connectionsResponse.status !== 200) {
- throw new SnapshotInitError(
- `${connectionsResponse.body.message} (connections)`,
- connectionsResponse.status,
- );
- }
+ const userData = preload.userData;
+ const presetsData = preload.presetsData;
+ const configData = unwrap(preload.configData);
+ const connectionsData = preload.connectionsData;
- userData = userResponse.body.data;
- configData = configResponse.body.data;
- presetsData = presetsResponse.body.data;
- connectionsData = connectionsResponse.body.data;
- }
if (userData === null) {
throw new SnapshotInitError(
`Request was successful but user data is null`,
diff --git a/frontend/src/ts/signals/connections.ts b/frontend/src/ts/signals/connections.ts
index b9b5ee43a0e3..e42c7bd45d93 100644
--- a/frontend/src/ts/signals/connections.ts
+++ b/frontend/src/ts/signals/connections.ts
@@ -28,11 +28,5 @@ createEffect(() => {
}
});
-createEffect(() => {
- const state = connections.state();
-
- console.log("#### change in resource: ", state);
-});
-
//from legacy code
//await connections.ready;
diff --git a/frontend/src/ts/signals/util/loadingStore.ts b/frontend/src/ts/signals/util/loadingStore.ts
index 1decb6538fc1..6c473982d5bc 100644
--- a/frontend/src/ts/signals/util/loadingStore.ts
+++ b/frontend/src/ts/signals/util/loadingStore.ts
@@ -88,7 +88,6 @@ export function createLoadingStore(
return;
}
updateState("pending");
- console.log("res:", resource.state);
if (resource.error !== undefined) {
updateState("errored", resource.error as LoadError);
From 85c2afcbd1ba222cf5093a8e8e6a3a9336bc9b9e Mon Sep 17 00:00:00 2001
From: Christian Fehmer
Date: Mon, 19 Jan 2026 11:57:36 +0100
Subject: [PATCH 14/15] fix
---
backend/src/api/routes/index.ts | 6 +++-
frontend/src/styles/loading.scss | 28 +++++++++++++++++++
frontend/src/ts/auth.ts | 11 +-------
frontend/src/ts/components/common/Loader.tsx | 15 ++++++----
.../src/ts/components/common/PreLoader.tsx | 19 ++++++++++---
.../src/ts/components/pages/AboutPage.tsx | 24 +---------------
6 files changed, 59 insertions(+), 44 deletions(-)
diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts
index 4e8078bea8b6..fe53800a4b9b 100644
--- a/backend/src/api/routes/index.ts
+++ b/backend/src/api/routes/index.ts
@@ -141,7 +141,11 @@ function applyDevApiRoutes(app: Application): void {
app.use(async (req, res, next) => {
let slowdown = (await getLiveConfiguration()).dev.responseSlowdownMs;
- if (req.path.includes("connection")) slowdown *= 2;
+ if (req.path.includes("connection")) slowdown *= 2.2;
+ if (req.path.includes("presets")) slowdown *= 1.6;
+ if (req.path.includes("config")) slowdown *= 1.2;
+ if (req.path.includes("configuration")) slowdown *= 0.5;
+
if (slowdown > 0) {
Logger.info(
`Simulating ${slowdown}ms delay for ${req.method} ${req.path}`,
diff --git a/frontend/src/styles/loading.scss b/frontend/src/styles/loading.scss
index fbad0bed09a5..8a61e8aa851f 100644
--- a/frontend/src/styles/loading.scss
+++ b/frontend/src/styles/loading.scss
@@ -38,3 +38,31 @@
}
}
}
+
+#preloader {
+ text-align: center;
+ place-self: center;
+ align-content: center;
+ display: grid;
+ gap: 1rem;
+ width: 100%;
+
+ .text {
+ height: 1.25em;
+ }
+
+ .bar {
+ max-width: 20rem;
+ width: 100%;
+ height: 0.5rem;
+ background: var(--sub-alt-color);
+ border-radius: var(--roundness);
+ justify-self: center;
+ .fill {
+ height: 100%;
+ width: 50%;
+ background: var(--main-color);
+ border-radius: var(--roundness);
+ }
+ }
+}
diff --git a/frontend/src/ts/auth.ts b/frontend/src/ts/auth.ts
index 79af1ba908dc..7ca3ecf34e69 100644
--- a/frontend/src/ts/auth.ts
+++ b/frontend/src/ts/auth.ts
@@ -158,14 +158,6 @@ export async function onAuthStateChanged(
void Sentry.clearUser();
}
- let keyframes = [
- {
- percentage: 90,
- durationMs: 1000,
- text: "Downloading user data...",
- },
- ];
-
//undefined means navigate to whatever the current window.location.pathname is
await navigate(undefined, {
force: true,
@@ -180,8 +172,7 @@ export async function onAuthStateChanged(
loadingPromise: async () => {
await userPromise;
},
- style: "bar",
- keyframes: keyframes,
+ style: "spinner", //TODO none?!
},
});
diff --git a/frontend/src/ts/components/common/Loader.tsx b/frontend/src/ts/components/common/Loader.tsx
index ce12505a043a..416d65e0e6f0 100644
--- a/frontend/src/ts/components/common/Loader.tsx
+++ b/frontend/src/ts/components/common/Loader.tsx
@@ -24,8 +24,8 @@ type ChildData = {
type LoaderProps = {
active: true | Accessor;
load: L;
- showLoader?: boolean;
- errorMessage?: string;
+ loader?: (keyframe?: Keyframe) => JSXElement;
+ error?: (error: LoadError) => JSXElement;
onComplete?: (data: ChildData) => void;
children?: (data: ChildData) => JSXElement;
};
@@ -104,14 +104,17 @@ export default function Loader(
return (
loading keyframe {firstLoadingKeyframe()?.text}}
+ fallback={props.loader?.(firstLoadingKeyframe()) ?? undefined}
>
- {props.errorMessage ?? "Loading failed"}: {hasError()?.message}
-
+ props.error !== undefined ? (
+ // oxlint-disable-next-line typescript/no-non-null-assertion
+ props.error(hasError()!)
+ ) : (
+ Loading failed: {hasError()?.message}
+ )
}
>
{props.children?.(stores())}
diff --git a/frontend/src/ts/components/common/PreLoader.tsx b/frontend/src/ts/components/common/PreLoader.tsx
index b964431eb398..ba62e76b4aa7 100644
--- a/frontend/src/ts/components/common/PreLoader.tsx
+++ b/frontend/src/ts/components/common/PreLoader.tsx
@@ -11,6 +11,7 @@ import { GetUserResponse } from "@monkeytype/contracts/users";
import { initSnapshot } from "../../db";
import { Connection } from "@monkeytype/schemas/connections";
import { promiseWithResolvers } from "../../utils/misc";
+import { Portal } from "solid-js/web";
const { promise: preloaderDonePromise, resolve: loadDone } =
promiseWithResolvers();
@@ -68,12 +69,22 @@ export function PreLoader(): JSXElement {
return (
isAuthenticated() && serverConfiguration.state().ready}
+ loader={(keyframe) => (
+
+
+
+
{keyframe?.text ?? "Loading..."}
+
+
+ )}
onComplete={isLoaded}
load={{
userData: {
store: user,
keyframe: {
- percentage: 80,
+ percentage: 50,
durationMs: 1,
text: "Downloading user data...",
},
@@ -81,7 +92,7 @@ export function PreLoader(): JSXElement {
configData: {
store: partialConfig,
keyframe: {
- percentage: 85,
+ percentage: 70,
durationMs: 1,
text: "Downloading user config...",
},
@@ -89,7 +100,7 @@ export function PreLoader(): JSXElement {
presetsData: {
store: presets,
keyframe: {
- percentage: 90,
+ percentage: 80,
durationMs: 1,
text: "Downloading user presets...",
},
@@ -97,7 +108,7 @@ export function PreLoader(): JSXElement {
connectionsData: {
store: connections,
keyframe: {
- percentage: 95,
+ percentage: 90,
durationMs: 1,
text: "Downloading friends...",
},
diff --git a/frontend/src/ts/components/pages/AboutPage.tsx b/frontend/src/ts/components/pages/AboutPage.tsx
index 670ca3c45f54..966f9c693e20 100644
--- a/frontend/src/ts/components/pages/AboutPage.tsx
+++ b/frontend/src/ts/components/pages/AboutPage.tsx
@@ -1,4 +1,4 @@
-import { createEffect, createResource, For, JSXElement, Show } from "solid-js";
+import { createResource, For, JSXElement, Show } from "solid-js";
import "./AboutPage.scss";
import { Button } from "../common/Button";
import { showModal } from "../../stores/modals";
@@ -10,10 +10,8 @@ import Ape from "../../ape";
import { intervalToDuration } from "date-fns";
import { getNumberWithMagnitude, numberWithSpaces } from "../../utils/numbers";
import { ChartJs } from "../common/ChartJs";
-import { getThemeColors } from "../../signals/theme";
import { connections } from "../../signals/connections";
import { isAuthenticated } from "../../signals/user";
-import Loader from "../common/Loader";
export function AboutPage(): JSXElement {
const isOpen = (): boolean => getActivePage() === "about";
@@ -32,28 +30,8 @@ export function AboutPage(): JSXElement {
open ? await fetchSpeedHistogram() : undefined,
);
- createEffect(() => {
- console.log(getThemeColors());
- });
-
return (
-
- {({ connections }) => Number of connections {connections.length}
}
-
-
Connections {connections.store.length}
From d3a181a5437b130dab2a91f51ba679bb3ef72dc6 Mon Sep 17 00:00:00 2001
From: Christian Fehmer
Date: Mon, 19 Jan 2026 14:57:03 +0100
Subject: [PATCH 15/15] give up for now
---
frontend/src/html/pages/account.html | 1 +
.../ts/components/common/BlockingLoader.tsx | 20 +++
frontend/src/ts/components/common/Loader.tsx | 61 ++++----
.../src/ts/components/common/PreLoader.tsx | 136 ++++++++++++------
.../ts/components/pages/AccountPageLoader.tsx | 59 ++++++++
.../ts/components/pages/FriendsPageLoader.tsx | 41 ++++++
frontend/src/ts/db.ts | 27 +++-
frontend/src/ts/index.ts | 7 +
frontend/src/ts/pages/account.ts | 27 +---
frontend/src/ts/pages/friends.ts | 5 +-
frontend/src/ts/pages/leaderboards.ts | 8 --
frontend/src/ts/signals/connections.ts | 33 ++++-
frontend/src/ts/signals/util/loadingStore.ts | 7 +-
13 files changed, 314 insertions(+), 118 deletions(-)
create mode 100644 frontend/src/ts/components/common/BlockingLoader.tsx
create mode 100644 frontend/src/ts/components/pages/AccountPageLoader.tsx
create mode 100644 frontend/src/ts/components/pages/FriendsPageLoader.tsx
diff --git a/frontend/src/html/pages/account.html b/frontend/src/html/pages/account.html
index b1d7a856c0f7..7ffd78d2a27a 100644
--- a/frontend/src/html/pages/account.html
+++ b/frontend/src/html/pages/account.html
@@ -1,4 +1,5 @@
+
diff --git a/frontend/src/ts/components/common/BlockingLoader.tsx b/frontend/src/ts/components/common/BlockingLoader.tsx
new file mode 100644
index 000000000000..d3c433cba9d0
--- /dev/null
+++ b/frontend/src/ts/components/common/BlockingLoader.tsx
@@ -0,0 +1,20 @@
+import { JSXElement } from "solid-js";
+
+import { Portal } from "solid-js/web";
+import { Keyframe } from "./Loader";
+
+export function BlockingLoader(props: { keyframe?: Keyframe }): JSXElement {
+ return (
+
+
+
+
{props.keyframe?.text ?? "Loading..."}
+
+
+ );
+}
diff --git a/frontend/src/ts/components/common/Loader.tsx b/frontend/src/ts/components/common/Loader.tsx
index 416d65e0e6f0..0d8542149d4d 100644
--- a/frontend/src/ts/components/common/Loader.tsx
+++ b/frontend/src/ts/components/common/Loader.tsx
@@ -1,15 +1,17 @@
-import {
- Accessor,
- createEffect,
- createMemo,
- JSXElement,
- Show,
- on,
-} from "solid-js";
+import { Accessor, createEffect, createMemo, JSXElement, Show } from "solid-js";
import { LoadError, LoadingStore } from "../../signals/util/loadingStore";
-import { Keyframe } from "../../pages/page";
import { Store } from "solid-js/store";
+export type Keyframe = {
+ /**
+ * Percentage of the loading bar to fill.
+ */
+ percentage: number;
+ /**
+ * Text to display below the loading bar.
+ */
+ text?: string;
+};
type LoadingStoreAndKeyframe = {
store: LoadingStore
;
keyframe?: Keyframe;
@@ -23,7 +25,7 @@ type ChildData = {
type LoaderProps = {
active: true | Accessor;
- load: L;
+ load: Accessor;
loader?: (keyframe?: Keyframe) => JSXElement;
error?: (error: LoadError) => JSXElement;
onComplete?: (data: ChildData) => void;
@@ -34,31 +36,28 @@ export default function Loader(
props: LoaderProps,
): JSXElement {
const loaders = createMemo(() =>
- Object.values(props.load),
+ Object.values(props.load()),
);
- if (props.active === true) {
- console.debug("Loader: load all stores");
- loaders().forEach((it) => it.store.load());
- } else {
- createEffect(
- on(
- props.active,
- (active) => {
- if (active) {
- console.debug("Loader: load all stores");
- loaders().forEach((it) => it.store.load());
- }
- },
- { defer: true },
- ),
- );
- }
+ createEffect(() => {
+ const active = props.active === true ? true : props.active();
+
+ if (!active) return;
+
+ console.debug("Loader: load missing stores");
+
+ for (const { store } of loaders()) {
+ const state = store.state();
+ if (!state.loading && !state.ready && !state.error) {
+ store.load();
+ }
+ }
+ });
const stores = createMemo(
() =>
Object.fromEntries(
- Object.entries(props.load).map(([key, value]) => [
+ Object.entries(props.load()).map(([key, value]) => [
key,
value.store.store,
]),
@@ -73,6 +72,10 @@ export default function Loader(
const allReady = createMemo(() =>
loaders().every((it) => it.store.state().ready),
);
+ createEffect(() => {
+ loaders();
+ completed = false;
+ });
createEffect(() => {
if (!completed && allReady()) {
diff --git a/frontend/src/ts/components/common/PreLoader.tsx b/frontend/src/ts/components/common/PreLoader.tsx
index ba62e76b4aa7..c48ef332b9e0 100644
--- a/frontend/src/ts/components/common/PreLoader.tsx
+++ b/frontend/src/ts/components/common/PreLoader.tsx
@@ -1,4 +1,4 @@
-import { createEffect, JSXElement } from "solid-js";
+import { createEffect, createMemo, JSXElement } from "solid-js";
import { createLoadingStore } from "../../signals/util/loadingStore";
import { PartialConfig } from "@monkeytype/schemas/configs";
import Ape from "../../ape";
@@ -6,12 +6,19 @@ import { isAuthenticated } from "../../signals/user";
import { Preset } from "@monkeytype/schemas/presets";
import Loader from "./Loader";
import { serverConfiguration } from "../../signals/server-configuration";
-import { connections } from "../../signals/connections";
+import {
+ connections,
+ friends,
+ pendingConnections,
+} from "../../signals/connections";
import { GetUserResponse } from "@monkeytype/contracts/users";
import { initSnapshot } from "../../db";
import { Connection } from "@monkeytype/schemas/connections";
import { promiseWithResolvers } from "../../utils/misc";
-import { Portal } from "solid-js/web";
+import { BlockingLoader } from "./BlockingLoader";
+import { unwrap } from "solid-js/store";
+import { getActivePage } from "../../signals/core";
+import { ResultMinified } from "@monkeytype/schemas/results";
const { promise: preloaderDonePromise, resolve: loadDone } =
promiseWithResolvers();
@@ -59,61 +66,97 @@ export function PreLoader(): JSXElement {
() => [],
);
+ const results = createLoadingStore(
+ "results",
+ async () => {
+ const response = await Ape.results.get();
+ if (response.status !== 200) {
+ throw new Error(response.body.message);
+ }
+ return response.body.data;
+ },
+ () => [],
+ );
+
createEffect(() => {
if (!isAuthenticated()) return;
console.debug("PreLoader: cleaning user data.");
- [partialConfig, user, presets].forEach((it) => it.reset());
+ [partialConfig, user, presets, results].forEach((it) => it.reset());
});
- return (
- isAuthenticated() && serverConfiguration.state().ready}
- loader={(keyframe) => (
-
-
-
-
{keyframe?.text ?? "Loading..."}
-
-
- )}
- onComplete={isLoaded}
- load={{
- userData: {
- store: user,
- keyframe: {
- percentage: 50,
- durationMs: 1,
- text: "Downloading user data...",
- },
+ const load = createMemo(() => {
+ const page = getActivePage();
+ const stores = {
+ userData: {
+ store: user,
+ keyframe: {
+ percentage: 0,
+ text: "Downloading user data...",
},
- configData: {
- store: partialConfig,
- keyframe: {
- percentage: 70,
- durationMs: 1,
- text: "Downloading user config...",
- },
+ },
+ configData: {
+ store: partialConfig,
+ keyframe: {
+ percentage: 0,
+ text: "Downloading user config...",
+ },
+ },
+ presetsData: {
+ store: presets,
+ keyframe: {
+ percentage: 0,
+ text: "Downloading user presets...",
+ },
+ },
+ connectionsData: {
+ store: connections,
+ keyframe: {
+ percentage: 0,
+ text: "Downloading connections...",
},
- presetsData: {
- store: presets,
+ },
+ ...(page === "friends" && {
+ friends: {
+ store: friends,
+ keyframe: { percentage: 0, text: "Downloading friends..." },
+ },
+ pendingConnections: {
+ store: pendingConnections,
keyframe: {
- percentage: 80,
- durationMs: 1,
- text: "Downloading user presets...",
+ percentage: 0,
+
+ text: "Downloading friend requests...",
},
},
- connectionsData: {
- store: connections,
+ }),
+ ...(page === "account" && {
+ results: {
+ store: results,
keyframe: {
percentage: 90,
- durationMs: 1,
- text: "Downloading friends...",
+ text: "Downloading results...",
},
},
- }}
+ }),
+ };
+ const inc = Math.ceil(100 / Object.keys(stores).length);
+ let percentage = inc;
+ for (const store of Object.values(stores)) {
+ store.keyframe["percentage"] = percentage;
+ percentage += inc;
+ }
+
+ console.log("##### update load", stores);
+ return stores;
+ });
+
+ return (
+ isAuthenticated() && serverConfiguration.state().ready}
+ loader={(kf) => }
+ onComplete={isLoaded}
+ load={() => load()}
/>
);
}
@@ -125,6 +168,11 @@ function isLoaded(stores: {
connectionsData: Connection[];
}): void {
console.log("preloader done loading", stores.userData.name);
- void initSnapshot(stores);
+ void initSnapshot({
+ userData: unwrap(stores.userData),
+ configData: unwrap(stores.configData),
+ presetsData: unwrap(stores.presetsData),
+ connectionsData: unwrap(stores.connectionsData),
+ });
loadDone();
}
diff --git a/frontend/src/ts/components/pages/AccountPageLoader.tsx b/frontend/src/ts/components/pages/AccountPageLoader.tsx
new file mode 100644
index 000000000000..c687bdcc495d
--- /dev/null
+++ b/frontend/src/ts/components/pages/AccountPageLoader.tsx
@@ -0,0 +1,59 @@
+// temporal component, to be removed when page is converted to solid
+
+import { createEffect, JSXElement } from "solid-js";
+import { promiseWithResolvers } from "../../utils/misc";
+import { createLoadingStore } from "../../signals/util/loadingStore";
+import { ResultMinified } from "@monkeytype/schemas/results";
+import Ape from "../../ape";
+import { isAuthenticated } from "../../signals/user";
+import Loader from "../common/Loader";
+import { BlockingLoader } from "../common/BlockingLoader";
+import { getUserResults } from "../../db";
+import { getActivePage } from "../../signals/core";
+import { unwrap } from "solid-js/store";
+
+const { promise: acountPageDonePromise, resolve: loadDone } =
+ promiseWithResolvers();
+
+export { acountPageDonePromise };
+
+export function AccountPageLoader(): JSXElement {
+ const results = createLoadingStore(
+ "results",
+ async () => {
+ const response = await Ape.results.get();
+ if (response.status !== 200) {
+ throw new Error(response.body.message);
+ }
+ return response.body.data;
+ },
+ () => [],
+ );
+
+ createEffect(() => {
+ if (!isAuthenticated()) return;
+ console.debug("AccountPageLoader: cleaning user data.");
+ results.reset();
+ });
+
+ return (
+ isAuthenticated() && getActivePage() === "account"}
+ load={() => ({
+ results: {
+ store: results,
+ keyframe: {
+ percentage: 90,
+ text: "Downloading results...",
+ },
+ },
+ })}
+ loader={(kf) => }
+ onComplete={isLoaded}
+ />
+ );
+}
+
+function isLoaded(stores: { results: ResultMinified[] }): void {
+ void getUserResults(undefined, unwrap(stores.results)).then(() => loadDone());
+}
diff --git a/frontend/src/ts/components/pages/FriendsPageLoader.tsx b/frontend/src/ts/components/pages/FriendsPageLoader.tsx
new file mode 100644
index 000000000000..ed724ed3c828
--- /dev/null
+++ b/frontend/src/ts/components/pages/FriendsPageLoader.tsx
@@ -0,0 +1,41 @@
+// temporal component, to be removed when page is converted to solid
+
+import { JSXElement } from "solid-js";
+import { promiseWithResolvers } from "../../utils/misc";
+
+import { isAuthenticated } from "../../signals/user";
+import Loader from "../common/Loader";
+import { BlockingLoader } from "../common/BlockingLoader";
+
+import { getActivePage } from "../../signals/core";
+
+import { friends, pendingConnections } from "../../signals/connections";
+
+const { promise: friendPageDonePromise, resolve: loadDone } =
+ promiseWithResolvers();
+
+export { friendPageDonePromise };
+
+export function FriendsPageLoader(): JSXElement {
+ return (
+ isAuthenticated() && getActivePage() === "friends"}
+ load={() => ({
+ friends: {
+ store: friends,
+ keyframe: { percentage: 50, text: "Downloading friends..." },
+ },
+ pendingConnections: {
+ store: pendingConnections,
+ keyframe: {
+ percentage: 90,
+
+ text: "Downloading friend requests...",
+ },
+ },
+ })}
+ loader={(kf) => }
+ onComplete={() => loadDone()}
+ />
+ );
+}
diff --git a/frontend/src/ts/db.ts b/frontend/src/ts/db.ts
index 9b49ea59e0f0..28f89345e4f0 100644
--- a/frontend/src/ts/db.ts
+++ b/frontend/src/ts/db.ts
@@ -35,6 +35,9 @@ import { configurationPromise } from "./ape/server-configuration";
import { Connection } from "@monkeytype/schemas/connections";
import { Preset } from "@monkeytype/schemas/presets";
import { GetUserResponse } from "@monkeytype/contracts/users";
+import { ResultMinified } from "@monkeytype/schemas/results";
+import { createEffect } from "solid-js";
+import { pendingConnections } from "./signals/connections";
import { unwrap } from "solid-js/store";
let dbSnapshot: Snapshot | undefined;
@@ -106,7 +109,7 @@ export async function initSnapshot(preload: {
const userData = preload.userData;
const presetsData = preload.presetsData;
- const configData = unwrap(preload.configData);
+ const configData = preload.configData;
const connectionsData = preload.connectionsData;
if (userData === null) {
@@ -253,7 +256,10 @@ export async function initSnapshot(preload: {
}
}
-export async function getUserResults(offset?: number): Promise {
+export async function getUserResults(
+ offset?: number,
+ resultsData?: ResultMinified[],
+): Promise {
if (!isAuthenticated()) return false;
if (!dbSnapshot) return false;
@@ -268,17 +274,20 @@ export async function getUserResults(offset?: number): Promise {
return false;
}
- const response = await Ape.results.get({ query: { offset } });
+ if (resultsData === undefined) {
+ const response = await Ape.results.get({ query: { offset } });
- if (response.status !== 200) {
- Notifications.add("Error getting results", -1, { response });
- return false;
+ if (response.status !== 200) {
+ Notifications.add("Error getting results", -1, { response });
+ return false;
+ }
+ resultsData = response.body.data;
}
//another check in case user logs out while waiting for response
if (!isAuthenticated()) return false;
- const results: SnapshotResult[] = response.body.data.map((result) => {
+ const results: SnapshotResult[] = resultsData.map((result) => {
result.bailedOut ??= false;
result.blindMode ??= false;
result.lazyMode ??= false;
@@ -1113,6 +1122,7 @@ export async function getTestActivityCalendar(
}
export function mergeConnections(connections: Connection[]): void {
+ console.log("##### merge connections", connections);
const snapshot = getSnapshot();
if (!snapshot) return;
@@ -1154,6 +1164,9 @@ export function isFriend(uid: string | undefined): boolean {
);
}
+createEffect(() => {
+ mergeConnections(unwrap(pendingConnections.store));
+});
// export async function DB.getLocalTagPB(tagId) {
// function cont() {
// let ret = 0;
diff --git a/frontend/src/ts/index.ts b/frontend/src/ts/index.ts
index 8161475bcc3d..88868a745723 100644
--- a/frontend/src/ts/index.ts
+++ b/frontend/src/ts/index.ts
@@ -45,6 +45,13 @@ import { applyEngineSettings } from "./anim";
import { qs, qsa, qsr } from "./utils/dom";
import { mountComponents } from "./components/mount";
import "./ready";
+import * as ConnectionSignals from "./signals/connections";
+console.log(
+ "######",
+ ConnectionSignals.connections,
+ ConnectionSignals.friends,
+ ConnectionSignals.pendingConnections,
+);
// Lock Math.random
Object.defineProperty(Math, "random", {
diff --git a/frontend/src/ts/pages/account.ts b/frontend/src/ts/pages/account.ts
index 9e6c9d9d580a..19310859d883 100644
--- a/frontend/src/ts/pages/account.ts
+++ b/frontend/src/ts/pages/account.ts
@@ -35,6 +35,7 @@ import Ape from "../ape";
import { AccountChart } from "@monkeytype/schemas/configs";
import { SortedTableWithLimit } from "../utils/sorted-table";
import { qs, qsa, qsr, ElementWithUtils, onDOMReady } from "../utils/dom";
+import { acountPageDonePromise } from "../components/pages/AccountPageLoader";
let filterDebug = false;
//toggle filterdebug
@@ -993,6 +994,7 @@ export async function downloadResults(offset?: number): Promise {
}
async function update(): Promise {
+ await acountPageDonePromise;
await downloadResults();
try {
await Misc.sleep(0);
@@ -1213,31 +1215,6 @@ export const page = new Page({
id: "account",
element: qsr(".page.pageAccount"),
path: "/account",
- loadingOptions: {
- loadingMode: () => {
- if (DB.getSnapshot()?.results === undefined) {
- return "sync";
- } else {
- return "none";
- }
- },
- loadingPromise: async () => {
- if (DB.getSnapshot() === null) {
- throw new Error(
- "Looks like your account data didn't download correctly. Please refresh the page.
If this error persists, please contact support.",
- );
- }
- return downloadResults();
- },
- style: "bar",
- keyframes: [
- {
- percentage: 90,
- durationMs: 2000,
- text: "Downloading results...",
- },
- ],
- },
afterHide: async (): Promise => {
reset();
Skeleton.remove("pageAccount");
diff --git a/frontend/src/ts/pages/friends.ts b/frontend/src/ts/pages/friends.ts
index 48a8eee47090..aff00c680762 100644
--- a/frontend/src/ts/pages/friends.ts
+++ b/frontend/src/ts/pages/friends.ts
@@ -141,6 +141,7 @@ const removeFriendModal = new SimpleModal({
});
async function fetchPendingConnections(): Promise {
+ return;
const result = await Ape.connections.get({
query: { status: "pending", type: "incoming" },
});
@@ -198,6 +199,7 @@ function updatePendingConnections(): void {
}
async function fetchFriends(): Promise {
+ return;
const result = await Ape.users.getFriends();
if (result.status !== 200) {
Notifications.add("Error getting friends: " + result.body.message, -1);
@@ -513,7 +515,7 @@ export const page = new Page({
display: "Friends",
element: qsr(".page.pageFriends"),
path: "/friends",
- loadingOptions: {
+ /*loadingOptions: {
loadingMode: () => {
if (!getAuthenticatedUser()) {
return "none";
@@ -554,6 +556,7 @@ export const page = new Page({
},
],
},
+ */
afterHide: async (): Promise => {
Skeleton.remove("pageFriends");
diff --git a/frontend/src/ts/pages/leaderboards.ts b/frontend/src/ts/pages/leaderboards.ts
index 182e819c1ad6..9988e375faee 100644
--- a/frontend/src/ts/pages/leaderboards.ts
+++ b/frontend/src/ts/pages/leaderboards.ts
@@ -1485,14 +1485,6 @@ export const page = new PageWithUrlParams({
element: qsr(".page.pageLeaderboards"),
path: "/leaderboards",
urlParamsSchema: UrlParameterSchema,
- loadingOptions: {
- style: "spinner",
- loadingMode: () => "sync",
- loadingPromise: async () => {
- await ServerConfiguration.configurationPromise;
- },
- },
-
afterHide: async (): Promise => {
Skeleton.remove("pageLeaderboards");
stopTimer();
diff --git a/frontend/src/ts/signals/connections.ts b/frontend/src/ts/signals/connections.ts
index e42c7bd45d93..af650dfbb3ac 100644
--- a/frontend/src/ts/signals/connections.ts
+++ b/frontend/src/ts/signals/connections.ts
@@ -4,6 +4,7 @@ import Ape from "../ape/";
import { createEffect } from "solid-js";
import { isAuthenticated } from "./user";
import { serverConfiguration } from "./server-configuration";
+import { Friend } from "@monkeytype/schemas/users";
export const connections = createLoadingStore(
"connections",
@@ -19,12 +20,38 @@ export const connections = createLoadingStore(
() => [],
);
+export const friends = createLoadingStore(
+ "friends",
+ async () => {
+ const response = await Ape.users.getFriends();
+ if (response.status !== 200) {
+ throw new Error(response.body.message);
+ }
+ return response.body.data;
+ },
+ () => [],
+);
+
+export const pendingConnections = createLoadingStore(
+ "pendingConnections",
+ async () => {
+ const response = await Ape.connections.get({
+ query: { status: "pending", type: "incoming" },
+ });
+ if (response.status !== 200) {
+ throw new Error(response.body.message);
+ }
+ return response.body.data;
+ },
+ () => [],
+);
+
createEffect(() => {
const authenticated = isAuthenticated();
- console.log("### isAuthenticated: ", authenticated);
+ console.debug("Connections: clear user data");
//TODO check logout during refresh
- if (!authenticated && connections.state().ready) {
- connections.reset();
+ if (!authenticated) {
+ [connections, friends, pendingConnections].forEach((it) => it.reset());
}
});
diff --git a/frontend/src/ts/signals/util/loadingStore.ts b/frontend/src/ts/signals/util/loadingStore.ts
index 6c473982d5bc..6447c7e388aa 100644
--- a/frontend/src/ts/signals/util/loadingStore.ts
+++ b/frontend/src/ts/signals/util/loadingStore.ts
@@ -1,7 +1,12 @@
import { createSignal, createResource, createEffect } from "solid-js";
import { createStore, Store } from "solid-js/store";
import type { Accessor, Resource } from "solid-js";
-import { promiseWithResolvers } from "../../utils/misc";
+import {
+ addToGlobal,
+ isDevEnvironment,
+ promiseWithResolvers,
+} from "../../utils/misc";
+import { kMaxLength } from "buffer";
export type LoadError = Error | { message?: string };
type State = Pick, "loading" | "state"> & {