Skip to content
Merged
35 changes: 20 additions & 15 deletions frontend/__tests__/components/common/AsyncContent.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ describe("AsyncContent", () => {
query: {
result: string | Error;
},
options?: Omit<Props<unknown>, "query" | "queries" | "children">,
options?: Omit<Props<{ result: string }>, "queries" | "children">,
): {
container: HTMLElement;
} {
Expand All @@ -160,12 +160,18 @@ describe("AsyncContent", () => {
}));

return (
<AsyncContent query={myQuery} {...(options as Props<string>)}>
{(data: string | undefined) => (
<AsyncContent
queries={{ result: myQuery }}
{...(options as Props<{ result: string | undefined }>)}
>
{({ resultData }) => (
<>
static content
<Show when={data !== undefined} fallback={<div>no data</div>}>
<div data-testid="content">{data}</div>
<Show
when={resultData() !== undefined}
fallback={<div>no data</div>}
>
<div data-testid="content">{resultData()}</div>
</Show>
</>
)}
Expand Down Expand Up @@ -318,7 +324,10 @@ describe("AsyncContent", () => {
first: string | Error | undefined;
second: string | Error | undefined;
},
options?: Omit<Props<unknown>, "query" | "queries" | "children">,
options?: Omit<
Props<{ first: string; second: string }>,
"queries" | "children"
>,
): {
container: HTMLElement;
} {
Expand Down Expand Up @@ -347,24 +356,20 @@ describe("AsyncContent", () => {
}));

type Q = { first: string | undefined; second: string | undefined };

return (
<AsyncContent
queries={{ first: firstQuery, second: secondQuery }}
{...(options as Props<Q>)}
>
{(results: {
first: string | undefined;
second: string | undefined;
}) => (
{({ firstData, secondData }) => (
<>
<Show
when={
results.first !== undefined && results.second !== undefined
}
when={firstData() !== undefined && secondData() !== undefined}
fallback={<div>no data</div>}
>
<div data-testid="first">{results.first}</div>
<div data-testid="second">{results.second}</div>
<div data-testid="first">{firstData()}</div>
<div data-testid="second">{secondData()}</div>
</Show>
</>
)}
Expand Down
148 changes: 95 additions & 53 deletions frontend/src/ts/components/common/AsyncContent.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { UseQueryResult } from "@tanstack/solid-query";
import {
Accessor,
createEffect,
createMemo,
ErrorBoundary,
JSXElement,
Expand All @@ -26,8 +27,7 @@ type Collection<T> = Accessor<T> & {
isError: boolean;
};

type QueryMapping = Record<string, unknown> | unknown;
type AsyncMap<T extends QueryMapping> = {
type AsyncMap<T extends Record<string, unknown>> = {
[K in keyof T]: AsyncEntry<T[K]>;
};

Expand All @@ -38,69 +38,59 @@ type BaseProps = {
errorClass?: string;
};

type QueryProps<T extends QueryMapping> = {
type QueryProps<T extends Record<string, unknown>> = {
queries: { [K in keyof T]: UseQueryResult<T[K]> };
};

type SingleQueryProps<T> = {
query: UseQueryResult<T>;
};

type CollectionProps<T extends QueryMapping> = {
type CollectionProps<T extends Record<string, unknown>> = {
collections: { [K in keyof T]: Collection<T[K]> };
};

type SingleCollectionProps<T> = {
collection: Collection<T>;
};
type AccessorMap<T> = { [K in keyof T]: Accessor<T[K]> };
type DataKeys<T> = { [K in keyof T as `${K & string}Data`]: T[K] };

type DeferredChildren<T extends QueryMapping> = {
type Source<T extends Record<string, unknown>> =
| QueryProps<T>
| CollectionProps<T>;

type DeferredChildren<T extends Record<string, unknown>> = {
alwaysShowContent?: false;
children: (data: { [K in keyof T]: T[K] }) => JSXElement;
children: (
data: AccessorMap<DataKeys<{ [K in keyof T]: T[K] }>>,
) => JSXElement;
};

type EagerChildren<T extends QueryMapping> = {
type EagerChildren<T extends Record<string, unknown>> = {
alwaysShowContent: true;
showLoader?: true;
children: (data: { [K in keyof T]: T[K] | undefined }) => JSXElement;
children: (
data: AccessorMap<DataKeys<{ [K in keyof T]: T[K] | undefined }>>,
) => JSXElement;
};

export type Props<T extends QueryMapping> = BaseProps &
(
| QueryProps<T>
| SingleQueryProps<T>
| CollectionProps<T>
| SingleCollectionProps<T>
) &
(DeferredChildren<T> | EagerChildren<T>);
type Children<T extends Record<string, unknown>> =
| DeferredChildren<T>
| EagerChildren<T>;

export type Props<T extends Record<string, unknown>> = BaseProps &
Source<T> &
Children<T>;

export default function AsyncContent<T extends QueryMapping>(
function AsyncContent<T extends Record<string, unknown>>(
props: Props<T>,
): JSXElement {
//@ts-expect-error this is fine
const source = createMemo<AsyncMap<T>>(() => {
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(
Expand All @@ -119,12 +109,9 @@ export default function AsyncContent<T extends QueryMapping>(
const allResolved = (
data: ReturnType<typeof value>,
): 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);
};

Expand All @@ -136,6 +123,52 @@ export default function AsyncContent<T extends QueryMapping>(
.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<T | undefined>((prev) => {
const current = value();
return allResolved(current) ? current : prev;
});
Comment thread
Miodec marked this conversation as resolved.

Comment thread
Miodec marked this conversation as resolved.
const hasResolved = createMemo<boolean>(
(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<DataKeys<{ [K in keyof T]: T[K] | undefined }>>;

const deferredAccessorMap = Object.fromEntries(
typedKeys(source()).map((key) => [
`${String(key)}Data`,
() => lastResolvedValue()?.[key],
]),
) as unknown as AccessorMap<DataKeys<{ [K in keyof T]: T[K] }>>;
// oxlint-enable solid/reactivity

const loader = (): JSXElement =>
props.loader ?? <LoadingCircle class="p-4 text-center text-2xl" />;

Expand All @@ -144,24 +177,31 @@ export default function AsyncContent<T extends QueryMapping>(
<div class={props.errorClass}>{handleError(err)}</div>
);

// Show loader on initial load or when the query key changed (no cached data)
const showLoader = (): boolean =>
isLoading() && !props.alwaysShowContent && !allResolved(value());

return (
<ErrorBoundary fallback={props.ignoreError ? undefined : errorText}>
<Switch
fallback={
<>
<Show when={isLoading() && !props.alwaysShowContent}>
{loader()}
</Show>

<Show when={showLoader()}>{loader()}</Show>
<Show
when={props.alwaysShowContent === true}
fallback={
<Show when={allResolved(value())}>
{props.children(value())}
<Show when={hasResolved()}>
{(_) =>
// oxlint-disable-next-line typescript/no-explicit-any
(props.children as (data: any) => JSXElement)(
deferredAccessorMap,
)
}
</Show>
}
>
{props.children(value())}
{/* oxlint-disable-next-line typescript/no-explicit-any */}
{(props.children as (data: any) => JSXElement)(eagerAccessorMap)}
</Show>
</>
}
Expand All @@ -170,7 +210,7 @@ export default function AsyncContent<T extends QueryMapping>(
{errorText(firstError())}
</Match>

<Match when={isLoading() && !props.alwaysShowContent}>{loader()}</Match>
<Match when={showLoader()}>{loader()}</Match>
</Switch>
</ErrorBoundary>
);
Expand Down Expand Up @@ -204,3 +244,5 @@ function fromCollections<T extends Record<string, unknown>>(collections: {
return acc;
}, {} as AsyncMap<T>);
}

export default AsyncContent;
6 changes: 3 additions & 3 deletions frontend/src/ts/components/modals/VersionHistoryModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,13 @@ export function VersionHistoryModal(): JSXElement {
onScroll={fetchMoreVersions}
>
<AsyncContent
query={releases}
queries={{ releases }}
errorMessage="Failed to load version history"
>
{(data) => (
{({ releasesData }) => (
<>
<div class="releases">
<For each={data.pages.flatMap((it) => it.releases)}>
<For each={releasesData().pages.flatMap((it) => it.releases)}>
{(release) => <ReleaseItem {...release} />}
</For>
</div>
Expand Down
Loading
Loading