diff --git a/frontend/__tests__/components/common/AsyncContent.spec.tsx b/frontend/__tests__/components/common/AsyncContent.spec.tsx index 6902b670d586..39328af98d2a 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; } { @@ -160,12 +160,18 @@ describe("AsyncContent", () => { })); return ( - )}> - {(data: string | undefined) => ( + )} + > + {({ resultData }) => ( <> static content - no data}> -
{data}
+ no data} + > +
{resultData()}
)} @@ -318,7 +324,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; } { @@ -347,24 +356,20 @@ describe("AsyncContent", () => { })); type Q = { first: string | undefined; second: string | undefined }; + return ( )} > - {(results: { - first: string | undefined; - second: string | undefined; - }) => ( + {({ firstData, secondData }) => ( <> no data} > -
{results.first}
-
{results.second}
+
{firstData()}
+
{secondData()}
)} diff --git a/frontend/src/ts/components/common/AsyncContent.tsx b/frontend/src/ts/components/common/AsyncContent.tsx index 4890f7635bd2..1309377dc4ca 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, @@ -26,8 +27,7 @@ type Collection = Accessor & { isError: boolean; }; -type QueryMapping = Record | unknown; -type AsyncMap = { +type AsyncMap> = { [K in keyof T]: AsyncEntry; }; @@ -38,69 +38,59 @@ 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 = { +type Source> = + | QueryProps + | CollectionProps; + +type DeferredChildren> = { alwaysShowContent?: false; - children: (data: { [K in keyof T]: T[K] }) => JSXElement; + children: ( + data: AccessorMap>, + ) => JSXElement; }; -type EagerChildren = { +type EagerChildren> = { alwaysShowContent: true; showLoader?: true; - children: (data: { [K in keyof T]: T[K] | undefined }) => JSXElement; + children: ( + data: AccessorMap>, + ) => JSXElement; }; -export type Props = BaseProps & - ( - | QueryProps - | SingleQueryProps - | CollectionProps - | SingleCollectionProps - ) & - (DeferredChildren | EagerChildren); +type Children> = + | DeferredChildren + | EagerChildren; + +export type Props> = BaseProps & + Source & + Children; -export default function AsyncContent( +function AsyncContent>( props: Props, ): JSXElement { - //@ts-expect-error this is fine 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( @@ -119,12 +109,9 @@ export default function AsyncContent( 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); }; @@ -136,6 +123,52 @@ 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, + ); + + // 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) => [ + `${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 => props.loader ?? ; @@ -144,24 +177,31 @@ export default function AsyncContent(
{handleError(err)}
); + // Show loader on initial load or when the query key changed (no cached data) + const showLoader = (): boolean => + isLoading() && !props.alwaysShowContent && !allResolved(value()); + return ( - - {loader()} - - + {loader()} - {props.children(value())} + + {(_) => + // oxlint-disable-next-line typescript/no-explicit-any + (props.children as (data: any) => JSXElement)( + deferredAccessorMap, + ) + } } > - {props.children(value())} + {/* oxlint-disable-next-line typescript/no-explicit-any */} + {(props.children as (data: any) => JSXElement)(eagerAccessorMap)} } @@ -170,7 +210,7 @@ export default function AsyncContent( {errorText(firstError())} - {loader()} + {loader()} ); @@ -204,3 +244,5 @@ function fromCollections>(collections: { return acc; }, {} as AsyncMap); } + +export default AsyncContent; diff --git a/frontend/src/ts/components/modals/VersionHistoryModal.tsx b/frontend/src/ts/components/modals/VersionHistoryModal.tsx index 44764bd14717..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/AboutPage.tsx b/frontend/src/ts/components/pages/AboutPage.tsx index b365c71dc9f3..6dd0cf73855e 100644 --- a/frontend/src/ts/components/pages/AboutPage.tsx +++ b/frontend/src/ts/components/pages/AboutPage.tsx @@ -64,25 +64,31 @@ export function AboutPage(): JSXElement {
- {(data) => ( + {({ typingStatsData }) => (
typingStatsData()?.testsStarted, + ], + ["total typing time", () => typingStatsData()?.timeTyping], + [ + "total tests completed", + () => typingStatsData()?.testsCompleted, + ], ] as const } > - {([title, data]) => ( + {([title, stat]) => (
{title}
-
{data?.text ?? "-"}
-
{data?.subText ?? "-"}
+
{stat()?.text ?? "-"}
+
{stat()?.subText ?? "-"}
)}
@@ -93,20 +99,20 @@ export function AboutPage(): JSXElement {
- {(data) => ( + {({ speedHistogramData }) => ( <>
distribution of time 60 leaderboard results (wpm)
- {numberOfHistogramRecords(data?.data)} total results + {numberOfHistogramRecords(speedHistogramData()?.data)} total + results
)} @@ -403,17 +410,17 @@ export function AboutPage(): JSXElement { text="top supporters" /> - {(data) => ( + {({ supportersData }) => (
- {(name) =>
{name}
}
+ {(name) =>
{name}
}
)}
@@ -426,17 +433,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 15edb775c214..ffb05ddda3ec 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, @@ -170,13 +170,18 @@ export function LeaderboardPage(): JSXElement {
- - {(config) => ( + + {({ serverConfigurationQueryData }) => ( )} @@ -191,51 +196,61 @@ export function LeaderboardPage(): JSXElement { /> } > - {({ data, rank, config }) => ( - - )} + {({ + entriesQueryData, + rankQueryData, + serverConfigurationQueryData, + }) => { + const minWpm = () => { + const d = entriesQueryData(); + return d && "minWpm" in d ? (d.minWpm as number) : undefined; + }; + + return ( + + ); + }}
} > - {(data) => ( + {({ entriesQueryData }) => (
setScrollToUser(false)} @@ -270,7 +287,9 @@ export function LeaderboardPage(): JSXElement {
- - {(profile) => } + + {({ profileQueryData }) => ( + + )}
diff --git a/frontend/src/ts/components/popups/alerts/Inbox.tsx b/frontend/src/ts/components/popups/alerts/Inbox.tsx index 1783575f238b..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={ } > - {(inbox) => ( + {({ inboxQueryData }) => ( <> - it.status === "unclaimed")}> + it.status === "unclaimed")} + >
} > {(entry) => } diff --git a/frontend/storybook/stories/AsyncContent.stories.tsx b/frontend/storybook/stories/AsyncContent.stories.tsx index e4b53b9fdbd5..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
}
);