Skip to content

Commit 2e269df

Browse files
committed
fix(FR-2869): lazy-load model card drawer details to speed up ModelStoreListPageV2 (#7363)
Resolves #7362 ([FR-2869](https://lablup.atlassian.net/browse/FR-2869)) ## Summary - Drop `...ModelCardDrawerFragment` from `ModelStoreListPageV2Query` so the list no longer loads `readme`, `vfolder`, or `availablePresets` (with per-preset `DeploymentPresetDetailContentFragment`) for every card edge — these were paid per card but consumed only when a single drawer opens. - Convert `ModelCardDrawer` to self-fetch via two `modelCardV2(id: $id)` root queries (`RoleDetailDrawer` pattern): - `ModelCardDrawerHeaderQuery` — tiny query for `name` + `metadata.title`, drives the Drawer chrome's `title` slot so the header appears quickly with a `Skeleton.Input` fallback. - `ModelCardDrawerContentQuery` — heavy fields (`readme`, `vfolder`, `availablePresets` + fragment) inside the body's Suspense. - `Deploy` button lives in the Drawer chrome's `extra` slot and toggles state lifted to the outer component so it stays clickable while content streams in. - Deep-linking to `?modelCard=<id>` now opens the drawer regardless of which list page the card belongs to — the drawer no longer depends on finding a matching list fragment. - Remove declared-but-unused fields/types previously carried by the drawer fragment: `availablePresets.count`, preset `rank` (+ `AvailablePreset.rank` interface member), `runtimeVariant`, `execution`, `cluster`, `deploymentDefaults` — all redundant with `DeploymentPresetDetailContentFragment` or had no reader. ## Test plan - [ ] Open `/model-store`; verify list loads noticeably faster on projects with many model cards. - [ ] Click a card → drawer opens with the brand icon + title visible immediately (skeleton briefly) and body fills in after the heavy query resolves. - [ ] Click `Deploy` while content is still loading → modal opens once data arrives; verify presets list populates. - [ ] Refresh the page with `?modelCard=<id>` in the URL → drawer opens directly even if the card is on a later page. - [ ] Close + reopen the drawer for a different card → header/body update with the new model's data. - [ ] `bash scripts/verify.sh` passes (Relay/Lint/Format). Pre-existing TS errors in unrelated files unchanged. [FR-2869]: https://lablup.atlassian.net/browse/FR-2869?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
1 parent ccb8d84 commit 2e269df

4 files changed

Lines changed: 77 additions & 71 deletions

File tree

react/src/components/ModelCardDeployModal.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@ interface AvailablePreset {
4747
readonly id: string;
4848
readonly name: string;
4949
readonly description: string | null;
50-
readonly rank: number;
5150
readonly runtimeVariantId: string;
5251
readonly ' $fragmentSpreads': FragmentRefs<'DeploymentPresetDetailContentFragment'>;
5352
}
@@ -392,7 +391,8 @@ const ModelCardDeployModal: React.FC<ModelCardDeployModalProps> = ({
392391
// Deploy. The content component suspends on its data query, then decides
393392
// whether to render the selection modal or auto-deploy silently — for the
394393
// auto-deploy path no modal is ever rendered, so the user goes directly
395-
// from the Deploy button to the serving detail page without a flash.
394+
// from the Deploy button to the new deployment detail page
395+
// (`/deployments/${deploymentId}`) without a flash.
396396
if (!open) {
397397
return null;
398398
}

react/src/components/ModelCardDrawer.tsx

Lines changed: 52 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
Copyright (c) 2015-2026 Lablup Inc. All rights reserved.
44
*/
55
import { ModelCardDrawerFragment$key } from '../__generated__/ModelCardDrawerFragment.graphql';
6+
import { ModelCardDrawerQuery } from '../__generated__/ModelCardDrawerQuery.graphql';
67
import { useBackendAIImageMetaData } from '../hooks';
78
import DeploymentSettingModal from './DeploymentSettingModal';
89
import ErrorBoundaryWithNullFallback from './ErrorBoundaryWithNullFallback';
@@ -24,18 +25,18 @@ import {
2425
import dayjs from 'dayjs';
2526
import * as _ from 'lodash-es';
2627
import Markdown from 'markdown-to-jsx';
27-
import React, { Suspense, useState } from 'react';
28+
import React, { Suspense, useDeferredValue, useState } from 'react';
2829
import { useTranslation } from 'react-i18next';
29-
import { graphql, useFragment } from 'react-relay';
30+
import { graphql, useFragment, useLazyLoadQuery } from 'react-relay';
3031

3132
interface ModelCardDrawerProps {
32-
modelCardDrawerFrgmt: ModelCardDrawerFragment$key | null;
33+
modelCardId: string | undefined;
3334
open: boolean;
3435
onClose: () => void;
3536
}
3637

3738
const ModelCardDrawer: React.FC<ModelCardDrawerProps> = ({
38-
modelCardDrawerFrgmt,
39+
modelCardId,
3940
open,
4041
onClose,
4142
}) => {
@@ -44,6 +45,41 @@ const ModelCardDrawer: React.FC<ModelCardDrawerProps> = ({
4445
const { t } = useTranslation();
4546
const [imageMetaData] = useBackendAIImageMetaData();
4647
const { generateFolderPath } = useFolderExplorerOpener();
48+
const [deployModalOpen, setDeployModalOpen] = useState(false);
49+
const [
50+
isCreateDeploymentOpen,
51+
{ toggle: toggleCreateDeployment, setLeft: closeCreateDeployment },
52+
] = useToggle(false);
53+
54+
// Defer `open` so the lazy query only fires once the drawer has actually
55+
// committed to opening. `loading={deferredOpen !== open}` then lets the
56+
// drawer show its built-in skeleton during the transition instead of an
57+
// inner Suspense fallback (FR-2869 review).
58+
const deferredOpen = useDeferredValue(open);
59+
60+
const drawerData = useLazyLoadQuery<ModelCardDrawerQuery>(
61+
graphql`
62+
query ModelCardDrawerQuery($id: UUID!) {
63+
modelCardV2(id: $id) {
64+
...ModelCardDrawerFragment
65+
}
66+
}
67+
`,
68+
{ id: modelCardId ?? '' },
69+
{
70+
// Skip the network round-trip until the drawer has actually committed
71+
// to opening and a model-card UUID is known. The empty-string fallback
72+
// for `id` is never sent in that case because `store-only` short-
73+
// circuits the fetch.
74+
fetchPolicy:
75+
deferredOpen && open && modelCardId
76+
? 'store-and-network'
77+
: 'store-only',
78+
},
79+
);
80+
81+
const modelCardDrawerFrgmt: ModelCardDrawerFragment$key | null =
82+
drawerData.modelCardV2 ?? null;
4783

4884
const modelCard = useFragment(
4985
graphql`
@@ -77,29 +113,12 @@ const ModelCardDrawer: React.FC<ModelCardDrawerProps> = ({
77113
...VFolderNodeIdenticonV2Fragment
78114
}
79115
availablePresets(orderBy: [{ field: RANK, direction: "ASC" }]) {
80-
count
81116
edges {
82117
node {
83118
id
84119
name
85120
description
86-
rank
87121
runtimeVariantId
88-
runtimeVariant {
89-
name
90-
}
91-
execution {
92-
imageId
93-
startupCommand
94-
}
95-
cluster {
96-
clusterMode
97-
clusterSize
98-
}
99-
deploymentDefaults {
100-
openToPublic
101-
replicaCount
102-
}
103122
...DeploymentPresetDetailContentFragment
104123
}
105124
}
@@ -109,13 +128,6 @@ const ModelCardDrawer: React.FC<ModelCardDrawerProps> = ({
109128
modelCardDrawerFrgmt,
110129
);
111130

112-
const [deployModalOpen, setDeployModalOpen] = useState(false);
113-
// FR-2862 — when the user hits the empty-preset state in
114-
// ModelCardDeployModal, escalate to the deployment shell creation modal
115-
// (`DeploymentSettingModal`), same as the `/deployments` page entry.
116-
const [isCreateDeploymentOpen, { toggle: toggleCreateDeployment }] =
117-
useToggle(false);
118-
119131
const presets =
120132
modelCard?.availablePresets?.edges
121133
?.map((e) => e?.node)
@@ -125,12 +137,22 @@ const ModelCardDrawer: React.FC<ModelCardDrawerProps> = ({
125137
<>
126138
<Drawer
127139
open={open}
128-
onClose={onClose}
140+
loading={deferredOpen !== open}
141+
onClose={() => {
142+
setDeployModalOpen(false);
143+
closeCreateDeployment();
144+
onClose();
145+
}}
129146
destroyOnHidden
130147
placement="right"
131148
size={800}
132149
title={
133-
<BAIFlex direction="row" align="center" gap="xs">
150+
<BAIFlex
151+
direction="row"
152+
align="center"
153+
gap="xs"
154+
style={{ flex: 1, minWidth: 0 }}
155+
>
134156
<ModelBrandIcon modelName={modelCard?.name ?? ''} />
135157
<Typography.Text strong ellipsis>
136158
{modelCard?.metadata?.title || modelCard?.name}

react/src/components/VFolderDeployModal.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,8 @@ const VFolderDeployModalContent: React.FC<VFolderDeployModalContentProps> = ({
195195

196196
// Auto-deploy on mount when there's exactly one preset and one resource
197197
// group — same shortcut as ModelCardDeployModal. No modal is rendered;
198-
// the user goes straight from the trigger to the serving detail page.
198+
// the user goes straight from the trigger to the new deployment detail
199+
// page (`/deployments/${deploymentId}`).
199200
const isAutoDeployScenario =
200201
availablePresets.length === 1 && resourceGroups.length === 1;
201202

@@ -453,8 +454,8 @@ const VFolderDeployModal: React.FC<VFolderDeployModalProps> = ({
453454
// opened it. The content component suspends on its data query, then
454455
// decides whether to render the selection modal or auto-deploy silently —
455456
// for the auto-deploy path no modal is ever rendered, so the user goes
456-
// directly from clicking Start Service to the serving detail page without
457-
// a flash.
457+
// directly from clicking Start Service to the new deployment detail page
458+
// (`/deployments/${deploymentId}`) without a flash.
458459
if (!open) {
459460
return null;
460461
}

react/src/pages/ModelStoreListPageV2.tsx

Lines changed: 19 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
@license
33
Copyright (c) 2015-2026 Lablup Inc. All rights reserved.
44
*/
5-
import { ModelCardDrawerFragment$key } from '../__generated__/ModelCardDrawerFragment.graphql';
65
import {
76
ModelCardV2Filter,
87
ModelStoreListPageV2Query,
@@ -34,6 +33,7 @@ import {
3433
BAIGraphQLPropertyFilter,
3534
BAISelect,
3635
type GraphQLFilter,
36+
safeDecodeUuid,
3737
useUpdatableState,
3838
} from 'backend.ai-ui';
3939
import dayjs from 'dayjs';
@@ -43,7 +43,7 @@ import {
4343
parseAsStringLiteral,
4444
useQueryStates,
4545
} from 'nuqs';
46-
import React, { useDeferredValue, useEffectEvent, useState } from 'react';
46+
import React, { useDeferredValue, useEffectEvent } from 'react';
4747
import { useTranslation } from 'react-i18next';
4848
import { graphql, useFragment, useLazyLoadQuery } from 'react-relay';
4949

@@ -204,9 +204,7 @@ const ModelCardV2Grid: React.FC<{
204204
pageSize: number;
205205
offset: number;
206206
onTotalChange: (total: number) => void;
207-
onCardClick?: (id: string, frgmt: ModelCardDrawerFragment$key) => void;
208-
selectedModelCardId?: string | null;
209-
onSelectedModelCardFound?: (frgmt: ModelCardDrawerFragment$key) => void;
207+
onCardClick?: (id: string) => void;
210208
}> = ({
211209
projectId,
212210
filter,
@@ -218,8 +216,6 @@ const ModelCardV2Grid: React.FC<{
218216
offset,
219217
onTotalChange,
220218
onCardClick,
221-
selectedModelCardId,
222-
onSelectedModelCardFound,
223219
}) => {
224220
'use memo';
225221

@@ -246,7 +242,6 @@ const ModelCardV2Grid: React.FC<{
246242
node {
247243
id
248244
...ModelStoreListPageV2_ModelCardV2Fragment
249-
...ModelCardDrawerFragment
250245
}
251246
}
252247
}
@@ -276,23 +271,6 @@ const ModelCardV2Grid: React.FC<{
276271
onTotalChanged();
277272
}, [total]);
278273

279-
// When items load and a selectedModelCardId is set (e.g. after refresh),
280-
// find the matching fragment and report it to the parent.
281-
const onResolveSelectedModelCard = useEffectEvent(() => {
282-
if (selectedModelCardId) {
283-
const match = items.find(
284-
(edge) => edge?.node?.id === selectedModelCardId,
285-
);
286-
if (match?.node) {
287-
onSelectedModelCardFound?.(match.node);
288-
}
289-
}
290-
});
291-
292-
React.useEffect(() => {
293-
onResolveSelectedModelCard();
294-
}, [selectedModelCardId, result]);
295-
296274
if (items.length === 0) {
297275
return (
298276
<Empty
@@ -312,7 +290,7 @@ const ModelCardV2Grid: React.FC<{
312290
<ModelCardV2Card
313291
modelCardV2Frgmt={item}
314292
searchKeyword={searchKeyword}
315-
onClick={() => onCardClick?.(item.id, item)}
293+
onClick={() => onCardClick?.(item.id)}
316294
/>
317295
</Col>
318296
);
@@ -343,9 +321,6 @@ const ModelStoreListPageV2: React.FC = () => {
343321
queryParams.sort,
344322
);
345323

346-
const [selectedModelCard, setSelectedModelCard] =
347-
useState<ModelCardDrawerFragment$key | null>(null);
348-
349324
const filter: GraphQLFilter | undefined = queryParams.filter ?? undefined;
350325
const deferredFilter = useDeferredValue(filter);
351326
const deferredSortField = useDeferredValue(sortField);
@@ -364,6 +339,18 @@ const ModelStoreListPageV2: React.FC = () => {
364339
const deferredLimit = useDeferredValue(baiPaginationOption.limit);
365340
const deferredOffset = useDeferredValue(baiPaginationOption.offset);
366341

342+
// Drawer data is intentionally NOT fetched as part of the main list query
343+
// — the per-card drawer fragment (readme, all presets, vfolder metadata)
344+
// is heavy, and including it via `...ModelCardDrawerFragment` on every
345+
// edge multiplies the list-page payload. `ModelCardDrawer` owns its own
346+
// query and gates the fetch on `useDeferredValue(open)` so the network
347+
// only kicks in once the drawer actually commits to opening.
348+
const selectedModelCardId = queryParams.modelCard;
349+
const drawerOpen = !!selectedModelCardId;
350+
const localSelectedModelCardId = selectedModelCardId
351+
? safeDecodeUuid(selectedModelCardId)
352+
: undefined;
353+
367354
const isPendingPage =
368355
deferredLimit !== baiPaginationOption.limit ||
369356
deferredOffset !== baiPaginationOption.offset;
@@ -481,10 +468,7 @@ const ModelStoreListPageV2: React.FC = () => {
481468
pageSize={deferredLimit}
482469
offset={deferredOffset}
483470
onTotalChange={setTotal}
484-
selectedModelCardId={queryParams.modelCard}
485-
onSelectedModelCardFound={(frgmt) => setSelectedModelCard(frgmt)}
486-
onCardClick={(id, frgmt) => {
487-
setSelectedModelCard(frgmt);
471+
onCardClick={(id) => {
488472
setQueryParams({ modelCard: id });
489473
}}
490474
/>
@@ -525,10 +509,9 @@ const ModelStoreListPageV2: React.FC = () => {
525509
)}
526510

527511
<ModelCardDrawer
528-
modelCardDrawerFrgmt={selectedModelCard}
529-
open={!!queryParams.modelCard && !!selectedModelCard}
512+
modelCardId={localSelectedModelCardId}
513+
open={drawerOpen}
530514
onClose={() => {
531-
setSelectedModelCard(null);
532515
setQueryParams({ modelCard: null });
533516
}}
534517
/>

0 commit comments

Comments
 (0)