From c147efe67d9fc2558d53ae5dfcc757b8b741807d Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 2 May 2026 20:32:12 +0200 Subject: [PATCH 01/15] brrr --- .../components/common/AsyncContent.spec.tsx | 73 ++++++++++++++++--- .../src/ts/components/common/AsyncContent.tsx | 41 ++++++++--- .../components/modals/VersionHistoryModal.tsx | 2 +- .../src/ts/components/pages/AboutPage.tsx | 54 +++++++------- .../pages/leaderboard/LeaderboardPage.tsx | 58 ++++++++------- .../components/pages/profile/ProfilePage.tsx | 2 +- .../src/ts/components/popups/alerts/Inbox.tsx | 2 +- .../stories/AsyncContent.stories.tsx | 4 +- 8 files changed, 158 insertions(+), 78 deletions(-) diff --git a/frontend/__tests__/components/common/AsyncContent.spec.tsx b/frontend/__tests__/components/common/AsyncContent.spec.tsx index 6902b670d586..07c5e44a45f2 100644 --- a/frontend/__tests__/components/common/AsyncContent.spec.tsx +++ b/frontend/__tests__/components/common/AsyncContent.spec.tsx @@ -159,13 +159,39 @@ describe("AsyncContent", () => { retry: 0, })); + if (options?.alwaysShowContent) { + return ( + )} + alwaysShowContent + > + {(data) => ( + <> + static content + no data} + > +
{data()}
+
+ + )} +
+ ); + } + return ( - )}> - {(data: string | undefined) => ( + )} + alwaysShowContent={false} + > + {(data) => ( <> static content - no data}> -
{data}
+ no data}> +
{data()}
)} @@ -347,24 +373,49 @@ describe("AsyncContent", () => { })); type Q = { first: string | undefined; second: string | undefined }; + + if (options?.alwaysShowContent) { + return ( + )} + alwaysShowContent + > + {(results) => ( + <> + no data} + > +
{results()?.first}
+
{results()?.second}
+
+ + )} +
+ ); + } + return ( )} + alwaysShowContent={false} > - {(results: { - first: string | undefined; - second: string | undefined; - }) => ( + {(results) => ( <> no data} > -
{results.first}
-
{results.second}
+
{results().first}
+
{results().second}
)} diff --git a/frontend/src/ts/components/common/AsyncContent.tsx b/frontend/src/ts/components/common/AsyncContent.tsx index 4890f7635bd2..4c4b915dc2f6 100644 --- a/frontend/src/ts/components/common/AsyncContent.tsx +++ b/frontend/src/ts/components/common/AsyncContent.tsx @@ -56,13 +56,15 @@ type SingleCollectionProps = { type DeferredChildren = { alwaysShowContent?: false; - children: (data: { [K in keyof T]: T[K] }) => JSXElement; + children: (data: Accessor<{ [K in keyof T]: T[K] }>) => JSXElement; }; type EagerChildren = { alwaysShowContent: true; showLoader?: true; - children: (data: { [K in keyof T]: T[K] | undefined }) => JSXElement; + children: ( + data: Accessor<{ [K in keyof T]: T[K] } | undefined>, + ) => JSXElement; }; export type Props = BaseProps & @@ -136,6 +138,18 @@ export default function AsyncContent( .find((s) => s.isError()) ?.error?.(); + // Keep the last resolved value so deferred children stay mounted during + // transient loading states (e.g. navigating away and back). + const lastResolvedValue = createMemo((prev) => { + const current = value(); + return allResolved(current) ? current : prev; + }); + + const hasResolved = createMemo( + (prev) => prev || lastResolvedValue() !== undefined, + false, + ); + const loader = (): JSXElement => props.loader ?? ; @@ -144,24 +158,31 @@ export default function AsyncContent(
{handleError(err)}
); + // Only show loader on initial load, not on refetches + const showLoader = (): boolean => + isLoading() && + !props.alwaysShowContent && + lastResolvedValue() === undefined; + return ( - - {loader()} - - + {loader()} - {props.children(value())} + + {(_) => + props.children( + lastResolvedValue as Accessor<{ [K in keyof T]: T[K] }>, + ) + } } > - {props.children(value())} + {props.children(value)} } @@ -170,7 +191,7 @@ export default function AsyncContent( {errorText(firstError())} - {loader()} + {loader()} ); diff --git a/frontend/src/ts/components/modals/VersionHistoryModal.tsx b/frontend/src/ts/components/modals/VersionHistoryModal.tsx index 44764bd14717..5f0939fcf35d 100644 --- a/frontend/src/ts/components/modals/VersionHistoryModal.tsx +++ b/frontend/src/ts/components/modals/VersionHistoryModal.tsx @@ -40,7 +40,7 @@ export function VersionHistoryModal(): JSXElement { {(data) => ( <>
- it.releases)}> + it.releases)}> {(release) => }
diff --git a/frontend/src/ts/components/pages/AboutPage.tsx b/frontend/src/ts/components/pages/AboutPage.tsx index b365c71dc9f3..475d3e0f26cc 100644 --- a/frontend/src/ts/components/pages/AboutPage.tsx +++ b/frontend/src/ts/components/pages/AboutPage.tsx @@ -67,27 +67,29 @@ export function AboutPage(): JSXElement { query={typingStats} errorMessage="Failed to get global typing stats" > - {(data) => ( -
- - {([title, data]) => ( -
-
{title}
-
{data?.text ?? "-"}
-
{data?.subText ?? "-"}
-
- )} -
-
- )} + {(data) => { + return ( +
+ data()?.testsStarted], + ["total typing time", () => data()?.timeTyping], + ["total tests completed", () => data()?.testsCompleted], + ] as const + } + > + {([title, stat]) => ( +
+
{title}
+
{stat()?.text ?? "-"}
+
{stat()?.subText ?? "-"}
+
+ )} +
+
+ ); + }}
@@ -101,12 +103,12 @@ export function AboutPage(): JSXElement {
distribution of time 60 leaderboard results (wpm)
- {numberOfHistogramRecords(data?.data)} total results + {numberOfHistogramRecords(data()?.data)} total results
)} @@ -413,7 +415,7 @@ export function AboutPage(): JSXElement { "grid-template-columns": "repeat(auto-fill, minmax(13em, 1fr))", }} > - {(name) =>
{name}
}
+ {(name) =>
{name}
}
)} @@ -436,7 +438,7 @@ export function AboutPage(): JSXElement { "grid-template-columns": "repeat(auto-fill, minmax(13em, 1fr))", }} > - {(name) =>
{name}
}
+ {(name) =>
{name}
}
)} diff --git a/frontend/src/ts/components/pages/leaderboard/LeaderboardPage.tsx b/frontend/src/ts/components/pages/leaderboard/LeaderboardPage.tsx index 15edb775c214..146021f9461d 100644 --- a/frontend/src/ts/components/pages/leaderboard/LeaderboardPage.tsx +++ b/frontend/src/ts/components/pages/leaderboard/LeaderboardPage.tsx @@ -175,8 +175,8 @@ export function LeaderboardPage(): JSXElement { )} @@ -203,27 +203,33 @@ export function LeaderboardPage(): JSXElement { alwaysShowContent errorClass="rounded bg-sub-alt p-4" > - {({ data, rank, config }) => ( - - )} + {(queries) => { + const data = () => queries()?.data; + const rank = () => queries()?.rank; + const config = () => queries()?.config; + return ( + { + const d = data(); + return d && "minWpm" in d + ? (d.minWpm as number) + : undefined; + })()} + memoryDifference={getLbMemoryDifference( + getSelection(), + rank()?.rank, + )} + isLbOptOut={getSnapshot()?.lbOptOut ?? false} + isBanned={getSnapshot()?.banned ?? false} + minTimeTyping={config()?.leaderboards.minTimeTyping ?? 0} + userTimeTyping={getSnapshot()?.typingStats.timeTyping ?? 0} + /> + ); + }} @@ -249,7 +255,7 @@ export function LeaderboardPage(): JSXElement { dataQuery.isFetching || dataQuery.isRefetching } - lastPage={Math.ceil((data?.count ?? 0) / pageSize)} + lastPage={Math.ceil((data()?.count ?? 0) / pageSize)} userPage={userPage()} currentPage={getPage()} onPageChange={setPage} @@ -261,7 +267,7 @@ export function LeaderboardPage(): JSXElement {
setScrollToUser(false)} @@ -270,7 +276,7 @@ export function LeaderboardPage(): JSXElement {
- {(profile) => } + {(profile) => }
diff --git a/frontend/src/ts/components/popups/alerts/Inbox.tsx b/frontend/src/ts/components/popups/alerts/Inbox.tsx index 1783575f238b..8d2829f4f09d 100644 --- a/frontend/src/ts/components/popups/alerts/Inbox.tsx +++ b/frontend/src/ts/components/popups/alerts/Inbox.tsx @@ -149,7 +149,7 @@ export function Inbox(): JSXElement { Nothing to show
} > {(entry) => } diff --git a/frontend/storybook/stories/AsyncContent.stories.tsx b/frontend/storybook/stories/AsyncContent.stories.tsx index e4b53b9fdbd5..aa7418b2c4f0 100644 --- a/frontend/storybook/stories/AsyncContent.stories.tsx +++ b/frontend/storybook/stories/AsyncContent.stories.tsx @@ -35,7 +35,7 @@ function LoadingExample(): ReturnType { })); return ( - {(data) =>
{data}
}
+ {(data) =>
{data()}
}
); } @@ -47,7 +47,7 @@ function SuccessExample(): ReturnType { return ( - {(data) =>
{data}
} + {(data) =>
{data()}
}
); } From b89904c4bd1ae52f79aa508dc1b54e7609ed86ce Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 2 May 2026 20:58:31 +0200 Subject: [PATCH 02/15] simplify props destructuring in AsyncContent and LeaderboardPage components --- .../components/common/AsyncContent.spec.tsx | 22 ++---- .../src/ts/components/common/AsyncContent.tsx | 78 +++++++++++++++---- .../pages/leaderboard/LeaderboardPage.tsx | 49 ++++++------ 3 files changed, 94 insertions(+), 55 deletions(-) diff --git a/frontend/__tests__/components/common/AsyncContent.spec.tsx b/frontend/__tests__/components/common/AsyncContent.spec.tsx index 07c5e44a45f2..1b8e4a21e9d5 100644 --- a/frontend/__tests__/components/common/AsyncContent.spec.tsx +++ b/frontend/__tests__/components/common/AsyncContent.spec.tsx @@ -381,17 +381,14 @@ describe("AsyncContent", () => { {...(options as Props)} alwaysShowContent > - {(results) => ( + {({ first, second }) => ( <> no data
} > -
{results()?.first}
-
{results()?.second}
+
{first()}
+
{second()}
)} @@ -405,17 +402,14 @@ describe("AsyncContent", () => { {...(options as Props)} alwaysShowContent={false} > - {(results) => ( + {({ first, second }) => ( <> no data
} > -
{results().first}
-
{results().second}
+
{first()}
+
{second()}
)} diff --git a/frontend/src/ts/components/common/AsyncContent.tsx b/frontend/src/ts/components/common/AsyncContent.tsx index 4c4b915dc2f6..4b275a92f4b4 100644 --- a/frontend/src/ts/components/common/AsyncContent.tsx +++ b/frontend/src/ts/components/common/AsyncContent.tsx @@ -54,6 +54,8 @@ type SingleCollectionProps = { collection: Collection; }; +type AccessorMap = { [K in keyof T]: Accessor }; + type DeferredChildren = { alwaysShowContent?: false; children: (data: Accessor<{ [K in keyof T]: T[K] }>) => JSXElement; @@ -67,18 +69,39 @@ type EagerChildren = { ) => JSXElement; }; +type MultiDeferredChildren = { + alwaysShowContent?: false; + children: (data: AccessorMap<{ [K in keyof T]: T[K] }>) => JSXElement; +}; + +type MultiEagerChildren = { + alwaysShowContent: true; + showLoader?: true; + children: ( + data: AccessorMap<{ [K in keyof T]: T[K] | undefined }>, + ) => JSXElement; +}; + +type SingleSource = SingleQueryProps | SingleCollectionProps; +type MultiSource = QueryProps | CollectionProps; +type SingleChildren = DeferredChildren | EagerChildren; +type MultiChildren = + | MultiDeferredChildren + | MultiEagerChildren; + export type Props = BaseProps & - ( - | QueryProps - | SingleQueryProps - | CollectionProps - | SingleCollectionProps - ) & - (DeferredChildren | EagerChildren); - -export default function AsyncContent( - props: Props, -): JSXElement { + (SingleSource | MultiSource) & + (SingleChildren | MultiChildren); + +// Single query/collection overloads +function AsyncContent( + props: BaseProps & SingleSource & SingleChildren, +): JSXElement; +// Multi query/collection overloads +function AsyncContent>( + props: BaseProps & MultiSource & MultiChildren, +): JSXElement; +function AsyncContent(props: Props): JSXElement { //@ts-expect-error this is fine const source = createMemo>(() => { if ("query" in props) { @@ -150,6 +173,27 @@ export default function AsyncContent( false, ); + // Keys are stable for the component lifetime; per-key closures track + // reactivity internally via value()/lastResolvedValue(). + // oxlint-disable solid/reactivity + const multi = !("defaultQuery" in source()); + + const eagerAccessorMap = multi + ? (Object.fromEntries( + typedKeys(source()).map((key) => [key, () => value()?.[key]]), + ) as AccessorMap<{ [K in keyof T]: T[K] | undefined }>) + : undefined; + + const deferredAccessorMap = multi + ? (Object.fromEntries( + typedKeys(source()).map((key) => [ + key, + () => lastResolvedValue()?.[key], + ]), + ) as AccessorMap<{ [K in keyof T]: T[K] }>) + : undefined; + // oxlint-enable solid/reactivity + const loader = (): JSXElement => props.loader ?? ; @@ -175,14 +219,18 @@ export default function AsyncContent( fallback={ {(_) => - props.children( - lastResolvedValue as Accessor<{ [K in keyof T]: T[K] }>, + // oxlint-disable-next-line typescript/no-explicit-any + (props.children as (data: any) => JSXElement)( + multi ? deferredAccessorMap : lastResolvedValue, ) } } > - {props.children(value)} + {/* oxlint-disable-next-line typescript/no-explicit-any */} + {(props.children as (data: any) => JSXElement)( + multi ? eagerAccessorMap : value, + )} } @@ -225,3 +273,5 @@ function fromCollections>(collections: { return acc; }, {} as AsyncMap); } + +export default AsyncContent; diff --git a/frontend/src/ts/components/pages/leaderboard/LeaderboardPage.tsx b/frontend/src/ts/components/pages/leaderboard/LeaderboardPage.tsx index 146021f9461d..7c56bcde099d 100644 --- a/frontend/src/ts/components/pages/leaderboard/LeaderboardPage.tsx +++ b/frontend/src/ts/components/pages/leaderboard/LeaderboardPage.tsx @@ -203,33 +203,28 @@ export function LeaderboardPage(): JSXElement { alwaysShowContent errorClass="rounded bg-sub-alt p-4" > - {(queries) => { - const data = () => queries()?.data; - const rank = () => queries()?.rank; - const config = () => queries()?.config; - return ( - { - const d = data(); - return d && "minWpm" in d - ? (d.minWpm as number) - : undefined; - })()} - memoryDifference={getLbMemoryDifference( - getSelection(), - rank()?.rank, - )} - isLbOptOut={getSnapshot()?.lbOptOut ?? false} - isBanned={getSnapshot()?.banned ?? false} - minTimeTyping={config()?.leaderboards.minTimeTyping ?? 0} - userTimeTyping={getSnapshot()?.typingStats.timeTyping ?? 0} - /> - ); - }} + {({ data, rank, config }) => ( + { + const d = data(); + return d && "minWpm" in d + ? (d.minWpm as number) + : undefined; + })()} + memoryDifference={getLbMemoryDifference( + getSelection(), + rank()?.rank, + )} + isLbOptOut={getSnapshot()?.lbOptOut ?? false} + isBanned={getSnapshot()?.banned ?? false} + minTimeTyping={config()?.leaderboards.minTimeTyping ?? 0} + userTimeTyping={getSnapshot()?.typingStats.timeTyping ?? 0} + /> + )} From d7479b33ac31d40c7870f0711f7d23406056db90 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 2 May 2026 21:32:32 +0200 Subject: [PATCH 03/15] rework --- .../components/common/AsyncContent.spec.tsx | 18 ++++++++++-------- .../src/ts/components/common/AsyncContent.tsx | 18 ++++++++++++------ .../components/modals/VersionHistoryModal.tsx | 6 +++--- .../pages/leaderboard/LeaderboardPage.tsx | 12 ++++++------ 4 files changed, 31 insertions(+), 23 deletions(-) diff --git a/frontend/__tests__/components/common/AsyncContent.spec.tsx b/frontend/__tests__/components/common/AsyncContent.spec.tsx index 1b8e4a21e9d5..6de008c642df 100644 --- a/frontend/__tests__/components/common/AsyncContent.spec.tsx +++ b/frontend/__tests__/components/common/AsyncContent.spec.tsx @@ -381,14 +381,16 @@ describe("AsyncContent", () => { {...(options as Props)} alwaysShowContent > - {({ first, second }) => ( + {({ firstData, secondData }) => ( <> no data} > -
{first()}
-
{second()}
+
{firstData()}
+
{secondData()}
)} @@ -402,14 +404,14 @@ describe("AsyncContent", () => { {...(options as Props)} alwaysShowContent={false} > - {({ first, second }) => ( + {({ firstData, secondData }) => ( <> no data} > -
{first()}
-
{second()}
+
{firstData()}
+
{secondData()}
)} diff --git a/frontend/src/ts/components/common/AsyncContent.tsx b/frontend/src/ts/components/common/AsyncContent.tsx index 4b275a92f4b4..a375380186f8 100644 --- a/frontend/src/ts/components/common/AsyncContent.tsx +++ b/frontend/src/ts/components/common/AsyncContent.tsx @@ -55,6 +55,7 @@ type SingleCollectionProps = { }; type AccessorMap = { [K in keyof T]: Accessor }; +type DataKeys = { [K in keyof T as `${K & string}Data`]: T[K] }; type DeferredChildren = { alwaysShowContent?: false; @@ -71,14 +72,16 @@ type EagerChildren = { type MultiDeferredChildren = { alwaysShowContent?: false; - children: (data: AccessorMap<{ [K in keyof T]: T[K] }>) => JSXElement; + children: ( + data: AccessorMap>, + ) => JSXElement; }; type MultiEagerChildren = { alwaysShowContent: true; showLoader?: true; children: ( - data: AccessorMap<{ [K in keyof T]: T[K] | undefined }>, + data: AccessorMap>, ) => JSXElement; }; @@ -180,17 +183,20 @@ function AsyncContent(props: Props): JSXElement { const eagerAccessorMap = multi ? (Object.fromEntries( - typedKeys(source()).map((key) => [key, () => value()?.[key]]), - ) as AccessorMap<{ [K in keyof T]: T[K] | undefined }>) + typedKeys(source()).map((key) => [ + `${String(key)}Data`, + () => value()?.[key], + ]), + ) as AccessorMap>) : undefined; const deferredAccessorMap = multi ? (Object.fromEntries( typedKeys(source()).map((key) => [ - key, + `${String(key)}Data`, () => lastResolvedValue()?.[key], ]), - ) as AccessorMap<{ [K in keyof T]: T[K] }>) + ) as AccessorMap>) : undefined; // oxlint-enable solid/reactivity diff --git a/frontend/src/ts/components/modals/VersionHistoryModal.tsx b/frontend/src/ts/components/modals/VersionHistoryModal.tsx index 5f0939fcf35d..c6da79425ff6 100644 --- a/frontend/src/ts/components/modals/VersionHistoryModal.tsx +++ b/frontend/src/ts/components/modals/VersionHistoryModal.tsx @@ -34,13 +34,13 @@ export function VersionHistoryModal(): JSXElement { onScroll={fetchMoreVersions} > - {(data) => ( + {({ releasesData }) => ( <>
- it.releases)}> + it.releases)}> {(release) => }
diff --git a/frontend/src/ts/components/pages/leaderboard/LeaderboardPage.tsx b/frontend/src/ts/components/pages/leaderboard/LeaderboardPage.tsx index 7c56bcde099d..c7c7f4859a53 100644 --- a/frontend/src/ts/components/pages/leaderboard/LeaderboardPage.tsx +++ b/frontend/src/ts/components/pages/leaderboard/LeaderboardPage.tsx @@ -203,25 +203,25 @@ export function LeaderboardPage(): JSXElement { alwaysShowContent errorClass="rounded bg-sub-alt p-4" > - {({ data, rank, config }) => ( + {({ dataData, rankData, configData }) => ( { - const d = data(); + const d = dataData(); return d && "minWpm" in d ? (d.minWpm as number) : undefined; })()} memoryDifference={getLbMemoryDifference( getSelection(), - rank()?.rank, + rankData()?.rank, )} isLbOptOut={getSnapshot()?.lbOptOut ?? false} isBanned={getSnapshot()?.banned ?? false} - minTimeTyping={config()?.leaderboards.minTimeTyping ?? 0} + minTimeTyping={configData()?.leaderboards.minTimeTyping ?? 0} userTimeTyping={getSnapshot()?.typingStats.timeTyping ?? 0} /> )} From 403c1408b802b6b15312bc36c9930be794c1ae9e Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 2 May 2026 22:28:50 +0200 Subject: [PATCH 04/15] remove single mode --- .../components/common/AsyncContent.spec.tsx | 30 ++-- .../src/ts/components/common/AsyncContent.tsx | 128 ++++++------------ .../src/ts/components/pages/AboutPage.tsx | 42 +++--- .../pages/leaderboard/LeaderboardPage.tsx | 20 +-- .../components/pages/profile/ProfilePage.tsx | 4 +- .../src/ts/components/popups/alerts/Inbox.tsx | 6 +- .../stories/AsyncContent.stories.tsx | 12 +- 7 files changed, 107 insertions(+), 135 deletions(-) diff --git a/frontend/__tests__/components/common/AsyncContent.spec.tsx b/frontend/__tests__/components/common/AsyncContent.spec.tsx index 6de008c642df..a3ef45ce3dd7 100644 --- a/frontend/__tests__/components/common/AsyncContent.spec.tsx +++ b/frontend/__tests__/components/common/AsyncContent.spec.tsx @@ -142,7 +142,7 @@ describe("AsyncContent", () => { query: { result: string | Error; }, - options?: Omit, "query" | "queries" | "children">, + options?: Omit, "queries" | "children">, ): { container: HTMLElement; } { @@ -162,18 +162,18 @@ describe("AsyncContent", () => { if (options?.alwaysShowContent) { return ( )} + queries={{ result: myQuery }} + {...(options as Props<{ result: string }>)} alwaysShowContent > - {(data) => ( + {({ resultData }) => ( <> static content no data} > -
{data()}
+
{resultData()}
)} @@ -183,15 +183,18 @@ describe("AsyncContent", () => { return ( )} + queries={{ result: myQuery }} + {...(options as Props<{ result: string }>)} alwaysShowContent={false} > - {(data) => ( + {({ resultData }) => ( <> static content - no data}> -
{data()}
+ no data} + > +
{resultData()}
)} @@ -344,7 +347,10 @@ describe("AsyncContent", () => { first: string | Error | undefined; second: string | Error | undefined; }, - options?: Omit, "query" | "queries" | "children">, + options?: Omit< + Props<{ first: string; second: string }>, + "queries" | "children" + >, ): { container: HTMLElement; } { diff --git a/frontend/src/ts/components/common/AsyncContent.tsx b/frontend/src/ts/components/common/AsyncContent.tsx index a375380186f8..b64849dcfd65 100644 --- a/frontend/src/ts/components/common/AsyncContent.tsx +++ b/frontend/src/ts/components/common/AsyncContent.tsx @@ -26,8 +26,7 @@ type Collection = Accessor & { isError: boolean; }; -type QueryMapping = Record | unknown; -type AsyncMap = { +type AsyncMap> = { [K in keyof T]: AsyncEntry; }; @@ -38,46 +37,29 @@ type BaseProps = { errorClass?: string; }; -type QueryProps = { +type QueryProps> = { queries: { [K in keyof T]: UseQueryResult }; }; -type SingleQueryProps = { - query: UseQueryResult; -}; - -type CollectionProps = { +type CollectionProps> = { collections: { [K in keyof T]: Collection }; }; -type SingleCollectionProps = { - collection: Collection; -}; - type AccessorMap = { [K in keyof T]: Accessor }; type DataKeys = { [K in keyof T as `${K & string}Data`]: T[K] }; -type DeferredChildren = { - alwaysShowContent?: false; - children: (data: Accessor<{ [K in keyof T]: T[K] }>) => JSXElement; -}; +type Source> = + | QueryProps + | CollectionProps; -type EagerChildren = { - alwaysShowContent: true; - showLoader?: true; - children: ( - data: Accessor<{ [K in keyof T]: T[K] } | undefined>, - ) => JSXElement; -}; - -type MultiDeferredChildren = { +type DeferredChildren> = { alwaysShowContent?: false; children: ( data: AccessorMap>, ) => JSXElement; }; -type MultiEagerChildren = { +type EagerChildren> = { alwaysShowContent: true; showLoader?: true; children: ( @@ -85,50 +67,29 @@ type MultiEagerChildren = { ) => JSXElement; }; -type SingleSource = SingleQueryProps | SingleCollectionProps; -type MultiSource = QueryProps | CollectionProps; -type SingleChildren = DeferredChildren | EagerChildren; -type MultiChildren = - | MultiDeferredChildren - | MultiEagerChildren; - -export type Props = BaseProps & - (SingleSource | MultiSource) & - (SingleChildren | MultiChildren); - -// Single query/collection overloads -function AsyncContent( - props: BaseProps & SingleSource & SingleChildren, -): JSXElement; -// Multi query/collection overloads +type Children> = + | DeferredChildren + | EagerChildren; + +export type Props> = BaseProps & + Source & + Children; + function AsyncContent>( - props: BaseProps & MultiSource & MultiChildren, -): JSXElement; -function AsyncContent(props: Props): JSXElement { - //@ts-expect-error this is fine + props: Props, +): JSXElement { const source = createMemo>(() => { - if ("query" in props) { - return fromQueries({ defaultQuery: props.query }); - } else if ("queries" in props) { + if ("queries" in props) { return fromQueries(props.queries); - } else if ("collection" in props) { - return fromCollections({ defaultQuery: props.collection }); - } else if ("collections" in props) { + } else { return fromCollections(props.collections); } }); - const value = (): T => { - if ("defaultQuery" in source()) { - //@ts-expect-error we know the property is present - // oxlint-disable-next-line typescript/no-unsafe-call typescript/no-unsafe-member-access - return source().defaultQuery.value() as T; - } else { - return Object.fromEntries( - typedKeys(source()).map((key) => [key, source()[key].value()]), - ) as T; // For multiple queries - } - }; + const value = (): T => + Object.fromEntries( + typedKeys(source()).map((key) => [key, source()[key].value()]), + ) as T; const handleError = (err: unknown): string => { const message = createErrorMessage( @@ -147,12 +108,9 @@ function AsyncContent(props: Props): JSXElement { const allResolved = ( data: ReturnType, ): data is { [K in keyof T]: T[K] } => { - //single query if (data === undefined || data === null) { return false; } - if ("defaultQuery" in source()) return true; - return Object.values(data).every((v) => v !== undefined && v !== null); }; @@ -179,25 +137,19 @@ function AsyncContent(props: Props): JSXElement { // Keys are stable for the component lifetime; per-key closures track // reactivity internally via value()/lastResolvedValue(). // oxlint-disable solid/reactivity - const multi = !("defaultQuery" in source()); - - const eagerAccessorMap = multi - ? (Object.fromEntries( - typedKeys(source()).map((key) => [ - `${String(key)}Data`, - () => value()?.[key], - ]), - ) as AccessorMap>) - : undefined; - - const deferredAccessorMap = multi - ? (Object.fromEntries( - typedKeys(source()).map((key) => [ - `${String(key)}Data`, - () => lastResolvedValue()?.[key], - ]), - ) as AccessorMap>) - : undefined; + const eagerAccessorMap = Object.fromEntries( + typedKeys(source()).map((key) => [ + `${String(key)}Data`, + () => value()?.[key], + ]), + ) as unknown as AccessorMap>; + + const deferredAccessorMap = Object.fromEntries( + typedKeys(source()).map((key) => [ + `${String(key)}Data`, + () => lastResolvedValue()?.[key], + ]), + ) as unknown as AccessorMap>; // oxlint-enable solid/reactivity const loader = (): JSXElement => @@ -227,16 +179,14 @@ function AsyncContent(props: Props): JSXElement { {(_) => // oxlint-disable-next-line typescript/no-explicit-any (props.children as (data: any) => JSXElement)( - multi ? deferredAccessorMap : lastResolvedValue, + deferredAccessorMap, ) }
} > {/* oxlint-disable-next-line typescript/no-explicit-any */} - {(props.children as (data: any) => JSXElement)( - multi ? eagerAccessorMap : value, - )} + {(props.children as (data: any) => JSXElement)(eagerAccessorMap)} } diff --git a/frontend/src/ts/components/pages/AboutPage.tsx b/frontend/src/ts/components/pages/AboutPage.tsx index 475d3e0f26cc..4e67e4e8d28e 100644 --- a/frontend/src/ts/components/pages/AboutPage.tsx +++ b/frontend/src/ts/components/pages/AboutPage.tsx @@ -64,18 +64,27 @@ export function AboutPage(): JSXElement {
- {(data) => { + {({ typingStatsData }) => { return (
data()?.testsStarted], - ["total typing time", () => data()?.timeTyping], - ["total tests completed", () => data()?.testsCompleted], + [ + "total tests started", + () => typingStatsData()?.testsStarted, + ], + [ + "total typing time", + () => typingStatsData()?.timeTyping, + ], + [ + "total tests completed", + () => typingStatsData()?.testsCompleted, + ], ] as const } > @@ -95,20 +104,20 @@ export function AboutPage(): JSXElement {
- {(data) => ( + {({ speedHistogramData }) => ( <>
distribution of time 60 leaderboard results (wpm)
- {numberOfHistogramRecords(data()?.data)} total results + {numberOfHistogramRecords(speedHistogramData()?.data)} total + results
)} @@ -405,17 +415,17 @@ export function AboutPage(): JSXElement { text="top supporters" /> - {(data) => ( + {({ supportersData }) => (
- {(name) =>
{name}
}
+ {(name) =>
{name}
}
)}
@@ -428,17 +438,17 @@ export function AboutPage(): JSXElement { text="contributors" /> - {(data) => ( + {({ contributorsData }) => (
- {(name) =>
{name}
}
+ {(name) =>
{name}
}
)}
diff --git a/frontend/src/ts/components/pages/leaderboard/LeaderboardPage.tsx b/frontend/src/ts/components/pages/leaderboard/LeaderboardPage.tsx index c7c7f4859a53..43b36368ab29 100644 --- a/frontend/src/ts/components/pages/leaderboard/LeaderboardPage.tsx +++ b/frontend/src/ts/components/pages/leaderboard/LeaderboardPage.tsx @@ -170,13 +170,15 @@ export function LeaderboardPage(): JSXElement {
- - {(config) => ( + + {({ configData }) => ( )} @@ -229,14 +231,14 @@ export function LeaderboardPage(): JSXElement {
} > - {(data) => ( + {({ dataData }) => (
setScrollToUser(false)} @@ -271,7 +273,7 @@ export function LeaderboardPage(): JSXElement {
- - {(profile) => } + + {({ profileData }) => }
diff --git a/frontend/src/ts/components/popups/alerts/Inbox.tsx b/frontend/src/ts/components/popups/alerts/Inbox.tsx index 8d2829f4f09d..98bd66d08e8a 100644 --- a/frontend/src/ts/components/popups/alerts/Inbox.tsx +++ b/frontend/src/ts/components/popups/alerts/Inbox.tsx @@ -117,10 +117,10 @@ export function Inbox(): JSXElement { } body={ } > - {(inbox) => ( + {({ inboxData }) => ( <> it.status === "unclaimed")}>
} > {(entry) => } diff --git a/frontend/storybook/stories/AsyncContent.stories.tsx b/frontend/storybook/stories/AsyncContent.stories.tsx index aa7418b2c4f0..8a6f71393417 100644 --- a/frontend/storybook/stories/AsyncContent.stories.tsx +++ b/frontend/storybook/stories/AsyncContent.stories.tsx @@ -35,7 +35,9 @@ function LoadingExample(): ReturnType { })); return ( - {(data) =>
{data()}
}
+ + {({ queryData }) =>
{queryData()}
} +
); } @@ -46,8 +48,10 @@ function SuccessExample(): ReturnType { })); return ( - - {(data) =>
{data()}
} + + {({ queryData }) => ( +
{queryData()}
+ )}
); } @@ -61,7 +65,7 @@ function ErrorExample(): ReturnType { })); return ( - + {() =>
This won't render
}
); From a5187e1e21025902303332fe93061a866da86a33 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 2 May 2026 22:56:25 +0200 Subject: [PATCH 05/15] renames --- .../pages/leaderboard/LeaderboardPage.tsx | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/frontend/src/ts/components/pages/leaderboard/LeaderboardPage.tsx b/frontend/src/ts/components/pages/leaderboard/LeaderboardPage.tsx index 43b36368ab29..89e17e7f85a5 100644 --- a/frontend/src/ts/components/pages/leaderboard/LeaderboardPage.tsx +++ b/frontend/src/ts/components/pages/leaderboard/LeaderboardPage.tsx @@ -66,7 +66,7 @@ export function LeaderboardPage(): JSXElement { //update url after the data is loaded createEffect(() => { - if (isOpen() && dataQuery.isSuccess) { + if (isOpen() && entriesQuery.isSuccess) { updateGetParameters(getSelection(), getPage()); } }); @@ -90,7 +90,7 @@ export function LeaderboardPage(): JSXElement { } }); - const dataQuery = useQuery(() => ({ + const entriesQuery = useQuery(() => ({ ...getLeaderboardQueryOptions({ ...getSelection(), page: getPage() ?? 0, @@ -193,26 +193,26 @@ export function LeaderboardPage(): JSXElement { /> } > - {({ dataData, rankData, configData }) => ( + {({ entriesData, rankData, configData }) => ( { - const d = dataData(); + const d = entriesData(); return d && "minWpm" in d ? (d.minWpm as number) : undefined; @@ -231,7 +231,7 @@ export function LeaderboardPage(): JSXElement { @@ -248,9 +248,9 @@ export function LeaderboardPage(): JSXElement { Date: Sat, 2 May 2026 23:03:35 +0200 Subject: [PATCH 06/15] rename --- .../pages/leaderboard/LeaderboardPage.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/frontend/src/ts/components/pages/leaderboard/LeaderboardPage.tsx b/frontend/src/ts/components/pages/leaderboard/LeaderboardPage.tsx index 89e17e7f85a5..73ba5e28a4a3 100644 --- a/frontend/src/ts/components/pages/leaderboard/LeaderboardPage.tsx +++ b/frontend/src/ts/components/pages/leaderboard/LeaderboardPage.tsx @@ -231,14 +231,14 @@ export function LeaderboardPage(): JSXElement {
} > - {({ dataData }) => ( + {({ entriesQueryData }) => (
setScrollToUser(false)} @@ -273,7 +275,9 @@ export function LeaderboardPage(): JSXElement {
Date: Sat, 2 May 2026 23:04:17 +0200 Subject: [PATCH 07/15] rename --- frontend/src/ts/components/pages/profile/ProfilePage.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/ts/components/pages/profile/ProfilePage.tsx b/frontend/src/ts/components/pages/profile/ProfilePage.tsx index af0b231625a3..a7197213f095 100644 --- a/frontend/src/ts/components/pages/profile/ProfilePage.tsx +++ b/frontend/src/ts/components/pages/profile/ProfilePage.tsx @@ -20,8 +20,10 @@ export function ProfilePage(): JSXElement { return (
- - {({ profileData }) => } + + {({ profileQueryData }) => ( + + )}
From 103aa0360425dcb896ac2da1b998c0e0794c2a81 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 2 May 2026 23:06:43 +0200 Subject: [PATCH 08/15] rename --- frontend/src/ts/components/popups/alerts/Inbox.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/frontend/src/ts/components/popups/alerts/Inbox.tsx b/frontend/src/ts/components/popups/alerts/Inbox.tsx index 98bd66d08e8a..ecdf629cdac6 100644 --- a/frontend/src/ts/components/popups/alerts/Inbox.tsx +++ b/frontend/src/ts/components/popups/alerts/Inbox.tsx @@ -117,12 +117,14 @@ export function Inbox(): JSXElement { } body={ } > - {({ inboxData }) => ( + {({ inboxQueryData }) => ( <> - it.status === "unclaimed")}> + it.status === "unclaimed")} + >
} > {(entry) => } From a03a6ee070b090af7face112364b71eaf91288bb Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 2 May 2026 23:08:55 +0200 Subject: [PATCH 09/15] no return --- .../src/ts/components/pages/AboutPage.tsx | 57 +++++++++---------- 1 file changed, 26 insertions(+), 31 deletions(-) diff --git a/frontend/src/ts/components/pages/AboutPage.tsx b/frontend/src/ts/components/pages/AboutPage.tsx index 4e67e4e8d28e..6dd0cf73855e 100644 --- a/frontend/src/ts/components/pages/AboutPage.tsx +++ b/frontend/src/ts/components/pages/AboutPage.tsx @@ -67,38 +67,33 @@ export function AboutPage(): JSXElement { queries={{ typingStats }} errorMessage="Failed to get global typing stats" > - {({ typingStatsData }) => { - return ( -
- ( +
+ typingStatsData()?.testsStarted, - ], - [ - "total typing time", - () => typingStatsData()?.timeTyping, - ], - [ - "total tests completed", - () => typingStatsData()?.testsCompleted, - ], - ] as const - } - > - {([title, stat]) => ( -
-
{title}
-
{stat()?.text ?? "-"}
-
{stat()?.subText ?? "-"}
-
- )} -
-
- ); - }} + "total tests started", + () => typingStatsData()?.testsStarted, + ], + ["total typing time", () => typingStatsData()?.timeTyping], + [ + "total tests completed", + () => typingStatsData()?.testsCompleted, + ], + ] as const + } + > + {([title, stat]) => ( +
+
{title}
+
{stat()?.text ?? "-"}
+
{stat()?.subText ?? "-"}
+
+ )} +
+
+ )}
From 80beefe0122bbb3a36feaaf52acc815722a10285 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 2 May 2026 23:19:01 +0200 Subject: [PATCH 10/15] your wish is my command --- .../components/pages/leaderboard/LeaderboardPage.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/frontend/src/ts/components/pages/leaderboard/LeaderboardPage.tsx b/frontend/src/ts/components/pages/leaderboard/LeaderboardPage.tsx index 73ba5e28a4a3..4da9d77ad272 100644 --- a/frontend/src/ts/components/pages/leaderboard/LeaderboardPage.tsx +++ b/frontend/src/ts/components/pages/leaderboard/LeaderboardPage.tsx @@ -170,15 +170,18 @@ export function LeaderboardPage(): JSXElement {
- - {({ configData }) => ( + + {({ serverConfigurationQueryData }) => ( )} From 6728b626d8dd1945f017109efe9d27a43b1f0a68 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 2 May 2026 23:19:50 +0200 Subject: [PATCH 11/15] rename --- .../pages/leaderboard/LeaderboardPage.tsx | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/frontend/src/ts/components/pages/leaderboard/LeaderboardPage.tsx b/frontend/src/ts/components/pages/leaderboard/LeaderboardPage.tsx index 4da9d77ad272..89443afa0568 100644 --- a/frontend/src/ts/components/pages/leaderboard/LeaderboardPage.tsx +++ b/frontend/src/ts/components/pages/leaderboard/LeaderboardPage.tsx @@ -201,32 +201,39 @@ export function LeaderboardPage(): JSXElement { > - {({ entriesData, rankData, configData }) => ( + {({ + entriesQueryData, + rankQueryData, + serverConfigurationQueryData, + }) => ( { - const d = entriesData(); + const d = entriesQueryData(); return d && "minWpm" in d ? (d.minWpm as number) : undefined; })()} memoryDifference={getLbMemoryDifference( getSelection(), - rankData()?.rank, + rankQueryData()?.rank, )} isLbOptOut={getSnapshot()?.lbOptOut ?? false} isBanned={getSnapshot()?.banned ?? false} - minTimeTyping={configData()?.leaderboards.minTimeTyping ?? 0} + minTimeTyping={ + serverConfigurationQueryData()?.leaderboards + .minTimeTyping ?? 0 + } userTimeTyping={getSnapshot()?.typingStats.timeTyping ?? 0} /> )} From 665ae907a11fc8b394d8108aaa116edff1a4ac12 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sun, 3 May 2026 09:36:28 +0200 Subject: [PATCH 12/15] refactor: simplify minWpm calculation in UserRank component --- .../pages/leaderboard/LeaderboardPage.tsx | 52 ++++++++++--------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/frontend/src/ts/components/pages/leaderboard/LeaderboardPage.tsx b/frontend/src/ts/components/pages/leaderboard/LeaderboardPage.tsx index 89443afa0568..ffb05ddda3ec 100644 --- a/frontend/src/ts/components/pages/leaderboard/LeaderboardPage.tsx +++ b/frontend/src/ts/components/pages/leaderboard/LeaderboardPage.tsx @@ -212,31 +212,33 @@ export function LeaderboardPage(): JSXElement { entriesQueryData, rankQueryData, serverConfigurationQueryData, - }) => ( - { - const d = entriesQueryData(); - return d && "minWpm" in d - ? (d.minWpm as number) - : undefined; - })()} - memoryDifference={getLbMemoryDifference( - getSelection(), - rankQueryData()?.rank, - )} - isLbOptOut={getSnapshot()?.lbOptOut ?? false} - isBanned={getSnapshot()?.banned ?? false} - minTimeTyping={ - serverConfigurationQueryData()?.leaderboards - .minTimeTyping ?? 0 - } - userTimeTyping={getSnapshot()?.typingStats.timeTyping ?? 0} - /> - )} + }) => { + const minWpm = () => { + const d = entriesQueryData(); + return d && "minWpm" in d ? (d.minWpm as number) : undefined; + }; + + return ( + + ); + }} From 3c7ffcaaeaca3e24771178193d8e176f7d6e902d Mon Sep 17 00:00:00 2001 From: Miodec Date: Sun, 3 May 2026 09:43:46 +0200 Subject: [PATCH 13/15] add key change detection in AsyncContent component --- .../src/ts/components/common/AsyncContent.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/frontend/src/ts/components/common/AsyncContent.tsx b/frontend/src/ts/components/common/AsyncContent.tsx index b64849dcfd65..f4a8765a8c5c 100644 --- a/frontend/src/ts/components/common/AsyncContent.tsx +++ b/frontend/src/ts/components/common/AsyncContent.tsx @@ -1,6 +1,7 @@ import { UseQueryResult } from "@tanstack/solid-query"; import { Accessor, + createEffect, createMemo, ErrorBoundary, JSXElement, @@ -136,6 +137,22 @@ function AsyncContent>( // Keys are stable for the component lifetime; per-key closures track // reactivity internally via value()/lastResolvedValue(). + // oxlint-disable-next-line solid/reactivity -- intentional snapshot of initial keys + const keys = typedKeys(source()); + if (import.meta.env.DEV) { + createEffect(() => { + const currentKeys = typedKeys(source()); + if ( + currentKeys.length !== keys.length || + currentKeys.some((k, i) => k !== keys[i]) + ) { + console.warn( + "AsyncContent: query keys changed between renders. This is not supported.", + ); + } + }); + } + // oxlint-disable solid/reactivity const eagerAccessorMap = Object.fromEntries( typedKeys(source()).map((key) => [ From 4697142ec8d3a58f5a5a4dd8f82019fc18a74a60 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sun, 3 May 2026 10:26:56 +0200 Subject: [PATCH 14/15] fix: update loader visibility logic to show on initial load or query key change --- frontend/src/ts/components/common/AsyncContent.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/frontend/src/ts/components/common/AsyncContent.tsx b/frontend/src/ts/components/common/AsyncContent.tsx index f4a8765a8c5c..1309377dc4ca 100644 --- a/frontend/src/ts/components/common/AsyncContent.tsx +++ b/frontend/src/ts/components/common/AsyncContent.tsx @@ -177,11 +177,9 @@ function AsyncContent>(
{handleError(err)}
); - // Only show loader on initial load, not on refetches + // Show loader on initial load or when the query key changed (no cached data) const showLoader = (): boolean => - isLoading() && - !props.alwaysShowContent && - lastResolvedValue() === undefined; + isLoading() && !props.alwaysShowContent && !allResolved(value()); return ( From 15c107aab7adbf9d9e76f167f37ce20cdfc1b881 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Sun, 3 May 2026 10:27:20 +0200 Subject: [PATCH 15/15] cleanup AsyncContent test --- .../components/common/AsyncContent.spec.tsx | 50 +------------------ 1 file changed, 1 insertion(+), 49 deletions(-) diff --git a/frontend/__tests__/components/common/AsyncContent.spec.tsx b/frontend/__tests__/components/common/AsyncContent.spec.tsx index a3ef45ce3dd7..39328af98d2a 100644 --- a/frontend/__tests__/components/common/AsyncContent.spec.tsx +++ b/frontend/__tests__/components/common/AsyncContent.spec.tsx @@ -159,33 +159,10 @@ describe("AsyncContent", () => { retry: 0, })); - if (options?.alwaysShowContent) { - return ( - )} - alwaysShowContent - > - {({ resultData }) => ( - <> - static content - no data
} - > -
{resultData()}
- - - )} - - ); - } - return ( )} - alwaysShowContent={false} + {...(options as Props<{ result: string | undefined }>)} > {({ resultData }) => ( <> @@ -380,35 +357,10 @@ describe("AsyncContent", () => { type Q = { first: string | undefined; second: string | undefined }; - if (options?.alwaysShowContent) { - return ( - )} - alwaysShowContent - > - {({ firstData, secondData }) => ( - <> - no data
} - > -
{firstData()}
-
{secondData()}
-
- - )} - - ); - } - return ( )} - alwaysShowContent={false} > {({ firstData, secondData }) => ( <>