Skip to content

Commit a1efc6d

Browse files
Miodecfehmer
andauthored
refactor: improve async content (@Miodec) (#7899)
Co-authored-by: Christian Fehmer <cfe@sexy-developer.com>
1 parent e698a6f commit a1efc6d

8 files changed

Lines changed: 222 additions & 141 deletions

File tree

frontend/__tests__/components/common/AsyncContent.spec.tsx

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ describe("AsyncContent", () => {
142142
query: {
143143
result: string | Error;
144144
},
145-
options?: Omit<Props<unknown>, "query" | "queries" | "children">,
145+
options?: Omit<Props<{ result: string }>, "queries" | "children">,
146146
): {
147147
container: HTMLElement;
148148
} {
@@ -160,12 +160,18 @@ describe("AsyncContent", () => {
160160
}));
161161

162162
return (
163-
<AsyncContent query={myQuery} {...(options as Props<string>)}>
164-
{(data: string | undefined) => (
163+
<AsyncContent
164+
queries={{ result: myQuery }}
165+
{...(options as Props<{ result: string | undefined }>)}
166+
>
167+
{({ resultData }) => (
165168
<>
166169
static content
167-
<Show when={data !== undefined} fallback={<div>no data</div>}>
168-
<div data-testid="content">{data}</div>
170+
<Show
171+
when={resultData() !== undefined}
172+
fallback={<div>no data</div>}
173+
>
174+
<div data-testid="content">{resultData()}</div>
169175
</Show>
170176
</>
171177
)}
@@ -318,7 +324,10 @@ describe("AsyncContent", () => {
318324
first: string | Error | undefined;
319325
second: string | Error | undefined;
320326
},
321-
options?: Omit<Props<unknown>, "query" | "queries" | "children">,
327+
options?: Omit<
328+
Props<{ first: string; second: string }>,
329+
"queries" | "children"
330+
>,
322331
): {
323332
container: HTMLElement;
324333
} {
@@ -347,24 +356,20 @@ describe("AsyncContent", () => {
347356
}));
348357

349358
type Q = { first: string | undefined; second: string | undefined };
359+
350360
return (
351361
<AsyncContent
352362
queries={{ first: firstQuery, second: secondQuery }}
353363
{...(options as Props<Q>)}
354364
>
355-
{(results: {
356-
first: string | undefined;
357-
second: string | undefined;
358-
}) => (
365+
{({ firstData, secondData }) => (
359366
<>
360367
<Show
361-
when={
362-
results.first !== undefined && results.second !== undefined
363-
}
368+
when={firstData() !== undefined && secondData() !== undefined}
364369
fallback={<div>no data</div>}
365370
>
366-
<div data-testid="first">{results.first}</div>
367-
<div data-testid="second">{results.second}</div>
371+
<div data-testid="first">{firstData()}</div>
372+
<div data-testid="second">{secondData()}</div>
368373
</Show>
369374
</>
370375
)}

frontend/src/ts/components/common/AsyncContent.tsx

Lines changed: 95 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { UseQueryResult } from "@tanstack/solid-query";
22
import {
33
Accessor,
4+
createEffect,
45
createMemo,
56
ErrorBoundary,
67
JSXElement,
@@ -26,8 +27,7 @@ type Collection<T> = Accessor<T> & {
2627
isError: boolean;
2728
};
2829

29-
type QueryMapping = Record<string, unknown> | unknown;
30-
type AsyncMap<T extends QueryMapping> = {
30+
type AsyncMap<T extends Record<string, unknown>> = {
3131
[K in keyof T]: AsyncEntry<T[K]>;
3232
};
3333

@@ -38,69 +38,59 @@ type BaseProps = {
3838
errorClass?: string;
3939
};
4040

41-
type QueryProps<T extends QueryMapping> = {
41+
type QueryProps<T extends Record<string, unknown>> = {
4242
queries: { [K in keyof T]: UseQueryResult<T[K]> };
4343
};
4444

45-
type SingleQueryProps<T> = {
46-
query: UseQueryResult<T>;
47-
};
48-
49-
type CollectionProps<T extends QueryMapping> = {
45+
type CollectionProps<T extends Record<string, unknown>> = {
5046
collections: { [K in keyof T]: Collection<T[K]> };
5147
};
5248

53-
type SingleCollectionProps<T> = {
54-
collection: Collection<T>;
55-
};
49+
type AccessorMap<T> = { [K in keyof T]: Accessor<T[K]> };
50+
type DataKeys<T> = { [K in keyof T as `${K & string}Data`]: T[K] };
5651

57-
type DeferredChildren<T extends QueryMapping> = {
52+
type Source<T extends Record<string, unknown>> =
53+
| QueryProps<T>
54+
| CollectionProps<T>;
55+
56+
type DeferredChildren<T extends Record<string, unknown>> = {
5857
alwaysShowContent?: false;
59-
children: (data: { [K in keyof T]: T[K] }) => JSXElement;
58+
children: (
59+
data: AccessorMap<DataKeys<{ [K in keyof T]: T[K] }>>,
60+
) => JSXElement;
6061
};
6162

62-
type EagerChildren<T extends QueryMapping> = {
63+
type EagerChildren<T extends Record<string, unknown>> = {
6364
alwaysShowContent: true;
6465
showLoader?: true;
65-
children: (data: { [K in keyof T]: T[K] | undefined }) => JSXElement;
66+
children: (
67+
data: AccessorMap<DataKeys<{ [K in keyof T]: T[K] | undefined }>>,
68+
) => JSXElement;
6669
};
6770

68-
export type Props<T extends QueryMapping> = BaseProps &
69-
(
70-
| QueryProps<T>
71-
| SingleQueryProps<T>
72-
| CollectionProps<T>
73-
| SingleCollectionProps<T>
74-
) &
75-
(DeferredChildren<T> | EagerChildren<T>);
71+
type Children<T extends Record<string, unknown>> =
72+
| DeferredChildren<T>
73+
| EagerChildren<T>;
74+
75+
export type Props<T extends Record<string, unknown>> = BaseProps &
76+
Source<T> &
77+
Children<T>;
7678

77-
export default function AsyncContent<T extends QueryMapping>(
79+
function AsyncContent<T extends Record<string, unknown>>(
7880
props: Props<T>,
7981
): JSXElement {
80-
//@ts-expect-error this is fine
8182
const source = createMemo<AsyncMap<T>>(() => {
82-
if ("query" in props) {
83-
return fromQueries({ defaultQuery: props.query });
84-
} else if ("queries" in props) {
83+
if ("queries" in props) {
8584
return fromQueries(props.queries);
86-
} else if ("collection" in props) {
87-
return fromCollections({ defaultQuery: props.collection });
88-
} else if ("collections" in props) {
85+
} else {
8986
return fromCollections(props.collections);
9087
}
9188
});
9289

93-
const value = (): T => {
94-
if ("defaultQuery" in source()) {
95-
//@ts-expect-error we know the property is present
96-
// oxlint-disable-next-line typescript/no-unsafe-call typescript/no-unsafe-member-access
97-
return source().defaultQuery.value() as T;
98-
} else {
99-
return Object.fromEntries(
100-
typedKeys(source()).map((key) => [key, source()[key].value()]),
101-
) as T; // For multiple queries
102-
}
103-
};
90+
const value = (): T =>
91+
Object.fromEntries(
92+
typedKeys(source()).map((key) => [key, source()[key].value()]),
93+
) as T;
10494

10595
const handleError = (err: unknown): string => {
10696
const message = createErrorMessage(
@@ -119,12 +109,9 @@ export default function AsyncContent<T extends QueryMapping>(
119109
const allResolved = (
120110
data: ReturnType<typeof value>,
121111
): data is { [K in keyof T]: T[K] } => {
122-
//single query
123112
if (data === undefined || data === null) {
124113
return false;
125114
}
126-
if ("defaultQuery" in source()) return true;
127-
128115
return Object.values(data).every((v) => v !== undefined && v !== null);
129116
};
130117

@@ -136,6 +123,52 @@ export default function AsyncContent<T extends QueryMapping>(
136123
.find((s) => s.isError())
137124
?.error?.();
138125

126+
// Keep the last resolved value so deferred children stay mounted during
127+
// transient loading states (e.g. navigating away and back).
128+
const lastResolvedValue = createMemo<T | undefined>((prev) => {
129+
const current = value();
130+
return allResolved(current) ? current : prev;
131+
});
132+
133+
const hasResolved = createMemo<boolean>(
134+
(prev) => prev || lastResolvedValue() !== undefined,
135+
false,
136+
);
137+
138+
// Keys are stable for the component lifetime; per-key closures track
139+
// reactivity internally via value()/lastResolvedValue().
140+
// oxlint-disable-next-line solid/reactivity -- intentional snapshot of initial keys
141+
const keys = typedKeys(source());
142+
if (import.meta.env.DEV) {
143+
createEffect(() => {
144+
const currentKeys = typedKeys(source());
145+
if (
146+
currentKeys.length !== keys.length ||
147+
currentKeys.some((k, i) => k !== keys[i])
148+
) {
149+
console.warn(
150+
"AsyncContent: query keys changed between renders. This is not supported.",
151+
);
152+
}
153+
});
154+
}
155+
156+
// oxlint-disable solid/reactivity
157+
const eagerAccessorMap = Object.fromEntries(
158+
typedKeys(source()).map((key) => [
159+
`${String(key)}Data`,
160+
() => value()?.[key],
161+
]),
162+
) as unknown as AccessorMap<DataKeys<{ [K in keyof T]: T[K] | undefined }>>;
163+
164+
const deferredAccessorMap = Object.fromEntries(
165+
typedKeys(source()).map((key) => [
166+
`${String(key)}Data`,
167+
() => lastResolvedValue()?.[key],
168+
]),
169+
) as unknown as AccessorMap<DataKeys<{ [K in keyof T]: T[K] }>>;
170+
// oxlint-enable solid/reactivity
171+
139172
const loader = (): JSXElement =>
140173
props.loader ?? <LoadingCircle class="p-4 text-center text-2xl" />;
141174

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

180+
// Show loader on initial load or when the query key changed (no cached data)
181+
const showLoader = (): boolean =>
182+
isLoading() && !props.alwaysShowContent && !allResolved(value());
183+
147184
return (
148185
<ErrorBoundary fallback={props.ignoreError ? undefined : errorText}>
149186
<Switch
150187
fallback={
151188
<>
152-
<Show when={isLoading() && !props.alwaysShowContent}>
153-
{loader()}
154-
</Show>
155-
189+
<Show when={showLoader()}>{loader()}</Show>
156190
<Show
157191
when={props.alwaysShowContent === true}
158192
fallback={
159-
<Show when={allResolved(value())}>
160-
{props.children(value())}
193+
<Show when={hasResolved()}>
194+
{(_) =>
195+
// oxlint-disable-next-line typescript/no-explicit-any
196+
(props.children as (data: any) => JSXElement)(
197+
deferredAccessorMap,
198+
)
199+
}
161200
</Show>
162201
}
163202
>
164-
{props.children(value())}
203+
{/* oxlint-disable-next-line typescript/no-explicit-any */}
204+
{(props.children as (data: any) => JSXElement)(eagerAccessorMap)}
165205
</Show>
166206
</>
167207
}
@@ -170,7 +210,7 @@ export default function AsyncContent<T extends QueryMapping>(
170210
{errorText(firstError())}
171211
</Match>
172212

173-
<Match when={isLoading() && !props.alwaysShowContent}>{loader()}</Match>
213+
<Match when={showLoader()}>{loader()}</Match>
174214
</Switch>
175215
</ErrorBoundary>
176216
);
@@ -204,3 +244,5 @@ function fromCollections<T extends Record<string, unknown>>(collections: {
204244
return acc;
205245
}, {} as AsyncMap<T>);
206246
}
247+
248+
export default AsyncContent;

frontend/src/ts/components/modals/VersionHistoryModal.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,13 @@ export function VersionHistoryModal(): JSXElement {
3434
onScroll={fetchMoreVersions}
3535
>
3636
<AsyncContent
37-
query={releases}
37+
queries={{ releases }}
3838
errorMessage="Failed to load version history"
3939
>
40-
{(data) => (
40+
{({ releasesData }) => (
4141
<>
4242
<div class="releases">
43-
<For each={data.pages.flatMap((it) => it.releases)}>
43+
<For each={releasesData().pages.flatMap((it) => it.releases)}>
4444
{(release) => <ReleaseItem {...release} />}
4545
</For>
4646
</div>

0 commit comments

Comments
 (0)