Skip to content

Commit 160da65

Browse files
gibsondanDagster Devtools
authored andcommitted
[ui] Fetch a bounded preview of run asset/check selections in the runs feed (#25674)
RunsFeedRootQuery fetched every run's full assetSelection and assetCheckSelection, 30 rows at a time. For runs that target many thousands of assets and checks this dominated the response — a single page could be ~10MB — even though the Target column only ever renders ~10 tags plus a count. Fetch only a bounded preview (limit: 25) plus the new count fields, and load the full lists lazily, on demand, when the user opens the "View list" dialog or the lineage link (prefetched when the overflow popover opens). The run actions menu now uses assetCheckSelectionCount instead of the full list to decide its job link. Depends on the GraphQL changes in the parent PR. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> ## Test Plan Load runs feed for an org with runs with 25+ assets - click through, verify links work as expected ## Changelog > The changelog is generated by an agent that examines merged PRs and > summarizes/categorizes user-facing changes. You can optionally replace > this text with a terse description of any user-facing changes in your PR, > which the agent will prioritize. Otherwise, delete this section. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Internal-RevId: 0f4f942f6f2ad4b0370709df103f07d03166333f
1 parent e06b48b commit 160da65

22 files changed

Lines changed: 351 additions & 148 deletions

js_modules/ui-core/client.json

Lines changed: 4 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

js_modules/ui-core/src/assets/AssetListUtils.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {AssetKey} from './types';
66
import {COMMON_COLLATOR} from '../app/Util';
77
import {displayNameForAssetKey} from '../asset-graph/Utils';
88

9-
type Check = {name: string; assetKey: AssetKey};
9+
export type Check = {name: string; assetKey: AssetKey};
1010

1111
export const sortItemAssetKey = (a: AssetKey, b: AssetKey) => {
1212
return COMMON_COLLATOR.compare(displayNameForAssetKey(a), displayNameForAssetKey(b));

js_modules/ui-core/src/runs/AssetTagCollections.tsx

Lines changed: 130 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
DialogFooter,
88
Icon,
99
MiddleTruncate,
10+
SpinnerWithText,
1011
Tag,
1112
} from '@dagster-io/ui-components';
1213
import useResizeObserver from '@react-hook/resize-observer';
@@ -15,6 +16,7 @@ import {Link} from 'react-router-dom';
1516

1617
import {displayNameForAssetKey, tokenForAssetKey} from '../asset-graph/Utils';
1718
import {
19+
Check,
1820
labelForAssetCheck,
1921
renderItemAssetCheck,
2022
renderItemAssetKey,
@@ -31,41 +33,72 @@ import {TagActionsPopover} from '../ui/TagActions';
3133
import {VirtualizedItemListForDialog} from '../ui/VirtualizedItemListForDialog';
3234
import {numberFormatter} from '../ui/formatters';
3335

36+
/**
37+
* Describes a selection whose inline tags are a bounded preview, with the full list fetched lazily
38+
* (e.g. the runs feed, where a run can target tens of thousands of assets/checks). When provided,
39+
* the total count drives the labels and the full list is loaded on demand for the "View list"
40+
* dialog and lineage link.
41+
*/
42+
export interface TagCollectionLazyLoad<T> {
43+
/** Total number of items; the inline preview list may be truncated. */
44+
totalCount: number;
45+
/** The full list, once lazily loaded (null until then). */
46+
allItems: T[] | null;
47+
/** Whether the full list is currently being fetched. */
48+
loading: boolean;
49+
/** Request the full list. Called when the overflow popover opens and when an action is clicked. */
50+
onRequest: () => void;
51+
}
52+
3453
function useShowMoreDialog<T>(
3554
dialogTitle: string,
3655
items: T[] | null,
3756
renderItem: (item: T) => React.ReactNode,
57+
loading?: boolean,
3858
) {
3959
const [showMore, setShowMore] = React.useState(false);
4060

41-
const dialog =
42-
!!items && items.length > 1 ? (
43-
<Dialog
44-
title={dialogTitle}
45-
onClose={() => setShowMore(false)}
46-
style={{minWidth: '400px', width: '50vw', maxWidth: '800px', maxHeight: '70vh'}}
47-
isOpen={showMore}
48-
>
49-
<div style={{height: '500px', overflow: 'hidden'}}>
61+
// Always render the dialog and toggle it with `isOpen` rather than mounting/unmounting it on
62+
// `showMore` — mount/unmount breaks the open/close fade animation.
63+
const dialog = (
64+
<Dialog
65+
title={dialogTitle}
66+
onClose={() => setShowMore(false)}
67+
style={{minWidth: '400px', width: '50vw', maxWidth: '800px', maxHeight: '70vh'}}
68+
isOpen={showMore}
69+
>
70+
<div style={{height: '500px', overflow: 'hidden'}}>
71+
{items && items.length ? (
5072
<VirtualizedItemListForDialog items={items} renderItem={renderItem} />
51-
</div>
52-
<DialogFooter topBorder>
53-
<Button intent="primary" autoFocus onClick={() => setShowMore(false)}>
54-
Done
55-
</Button>
56-
</DialogFooter>
57-
</Dialog>
58-
) : undefined;
73+
) : (
74+
<Box flex={{alignItems: 'center', justifyContent: 'center'}} style={{height: '100%'}}>
75+
{loading ? <SpinnerWithText label="Loading…" /> : null}
76+
</Box>
77+
)}
78+
</div>
79+
<DialogFooter topBorder>
80+
<Button intent="primary" autoFocus onClick={() => setShowMore(false)}>
81+
Done
82+
</Button>
83+
</DialogFooter>
84+
</Dialog>
85+
);
5986

6087
return {dialog, showMore, setShowMore};
6188
}
6289

6390
interface AssetKeyTagCollectionProps {
91+
/**
92+
* The asset keys to render as inline tags. When `lazy` is provided this is only a bounded
93+
* preview — `lazy.totalCount` is the true count and `lazy.allItems` the full list (loaded on
94+
* demand). Without `lazy` it is the complete list.
95+
*/
6496
assetKeys: AssetKey[] | null;
6597
dialogTitle?: string;
6698
useTags?: boolean;
6799
extraTags?: React.ReactNode[];
68100
maxRows?: number;
101+
lazy?: TagCollectionLazyLoad<AssetKey>;
69102
}
70103

71104
/** This hook returns a containerRef and a moreLabelRef. It expects you to populate
@@ -141,20 +174,45 @@ export function useAdjustChildVisibilityToFill(moreLabelFn: (count: number) => s
141174
}
142175

143176
export const AssetKeyTagCollection = React.memo((props: AssetKeyTagCollectionProps) => {
144-
const {assetKeys, useTags, extraTags, maxRows, dialogTitle = 'Assets in run'} = props;
177+
const {assetKeys, useTags, extraTags, maxRows, dialogTitle = 'Assets in run', lazy} = props;
145178

146-
const count = assetKeys?.length ?? 0;
179+
const count = lazy ? lazy.totalCount : (assetKeys?.length ?? 0);
147180
const rendered = maxRows ? 10 : count === 1 ? 1 : 0;
148181

149-
const {sortedAssetKeys, slicedSortedAssetKeys} = React.useMemo(() => {
150-
const sortedAssetKeys = assetKeys?.slice().sort(sortItemAssetKey) ?? [];
182+
// When the preview already holds the entire selection there is nothing more to fetch, so the
183+
// preview itself is the full list and actions needing it are ready immediately.
184+
const previewComplete = !lazy || (assetKeys?.length ?? 0) >= lazy.totalCount;
185+
186+
const {slicedSortedAssetKeys, fullAssetKeys} = React.useMemo(() => {
187+
const sortedPreview = assetKeys?.slice().sort(sortItemAssetKey) ?? [];
188+
const sortedAll = lazy?.allItems ? lazy.allItems.slice().sort(sortItemAssetKey) : null;
151189
return {
152-
sortedAssetKeys,
153-
slicedSortedAssetKeys: sortedAssetKeys?.slice(0, rendered) ?? [],
190+
slicedSortedAssetKeys: sortedPreview.slice(0, rendered),
191+
// The complete, sorted list: the preview when it already holds everything, otherwise the
192+
// lazily-loaded full list (null until it arrives).
193+
fullAssetKeys: previewComplete ? sortedPreview : sortedAll,
154194
};
155-
}, [assetKeys, rendered]);
195+
}, [assetKeys, rendered, lazy, previewComplete]);
156196

157-
const {setShowMore, dialog} = useShowMoreDialog(dialogTitle, sortedAssetKeys, renderItemAssetKey);
197+
const {setShowMore, dialog} = useShowMoreDialog(
198+
dialogTitle,
199+
fullAssetKeys,
200+
renderItemAssetKey,
201+
lazy?.loading,
202+
);
203+
204+
// Kick off the lazy fetch (a no-op when the preview is already complete) when the overflow menu
205+
// opens or "View list" is clicked.
206+
const requestFull = React.useCallback(() => {
207+
if (!previewComplete) {
208+
lazy?.onRequest();
209+
}
210+
}, [previewComplete, lazy]);
211+
212+
const showMore = React.useCallback(() => {
213+
requestFull();
214+
setShowMore(true);
215+
}, [requestFull, setShowMore]);
158216

159217
const moreLabelFn = React.useCallback(
160218
(displayed: number) =>
@@ -223,23 +281,32 @@ export const AssetKeyTagCollection = React.memo((props: AssetKeyTagCollectionPro
223281
<span style={useTags ? {} : {marginBottom: -4}}>
224282
<TagActionsPopover
225283
data={{key: '', value: ''}}
284+
onOpening={requestFull}
226285
actions={[
227286
{
228287
label: 'View list',
229-
onClick: () => setShowMore(true),
230-
},
231-
{
232-
label: 'View lineage',
233-
to: globalAssetGraphPathForAssets(sortedAssetKeys),
288+
onClick: showMore,
234289
},
290+
// Lineage needs the complete set; disable it until the full list is available so we
291+
// never navigate with only the preview.
292+
fullAssetKeys
293+
? {
294+
label: 'View lineage',
295+
to: globalAssetGraphPathForAssets(fullAssetKeys),
296+
}
297+
: {
298+
label: 'View lineage',
299+
to: '',
300+
disabled: true,
301+
},
235302
]}
236303
>
237304
{useTags ? (
238305
<Tag intent="none" icon="asset">
239306
<span ref={moreLabelRef}>{moreLabelFn(0)}</span>
240307
</Tag>
241308
) : (
242-
<ButtonLink onClick={() => setShowMore(true)} underline="hover">
309+
<ButtonLink onClick={showMore} underline="hover">
243310
<Box
244311
flex={{direction: 'row', gap: 8, alignItems: 'center', display: 'inline-flex'}}
245312
>
@@ -256,34 +323,54 @@ export const AssetKeyTagCollection = React.memo((props: AssetKeyTagCollectionPro
256323
);
257324
});
258325

259-
type Check = {name: string; assetKey: AssetKey};
260-
261326
interface AssetCheckTagCollectionProps {
327+
/**
328+
* The asset checks to render as inline tags. When `lazy` is provided this is only a bounded
329+
* preview — `lazy.totalCount` is the true count and `lazy.allItems` the full list (loaded on
330+
* demand). Without `lazy` it is the complete list.
331+
*/
262332
assetChecks: Check[] | null;
263333
dialogTitle?: string;
264334
maxRows?: number;
335+
lazy?: TagCollectionLazyLoad<Check>;
265336
}
266337

267338
export const AssetCheckTagCollection = React.memo((props: AssetCheckTagCollectionProps) => {
268-
const {assetChecks, maxRows, dialogTitle = 'Asset checks in run'} = props;
339+
const {assetChecks, maxRows, dialogTitle = 'Asset checks in run', lazy} = props;
269340

270-
const count = assetChecks?.length ?? 0;
341+
const count = lazy ? lazy.totalCount : (assetChecks?.length ?? 0);
271342
const rendered = maxRows ? 10 : count === 1 ? 1 : 0;
272343

273-
const {sortedAssetChecks, slicedSortedAssetChecks} = React.useMemo(() => {
274-
const sortedAssetChecks = assetChecks?.slice().sort(sortItemAssetCheck) ?? [];
344+
// When the preview already holds every check there is nothing more to fetch.
345+
const previewComplete = !lazy || (assetChecks?.length ?? 0) >= lazy.totalCount;
346+
347+
const {slicedSortedAssetChecks, fullAssetChecks} = React.useMemo(() => {
348+
const sortedPreview = assetChecks?.slice().sort(sortItemAssetCheck) ?? [];
349+
const sortedAll = lazy?.allItems ? lazy.allItems.slice().sort(sortItemAssetCheck) : null;
275350
return {
276-
sortedAssetChecks,
277-
slicedSortedAssetChecks: sortedAssetChecks?.slice(0, rendered) ?? [],
351+
slicedSortedAssetChecks: sortedPreview.slice(0, rendered),
352+
fullAssetChecks: previewComplete ? sortedPreview : sortedAll,
278353
};
279-
}, [assetChecks, rendered]);
354+
}, [assetChecks, rendered, lazy, previewComplete]);
280355

281356
const {setShowMore, dialog} = useShowMoreDialog(
282357
dialogTitle,
283-
sortedAssetChecks,
358+
fullAssetChecks,
284359
renderItemAssetCheck,
360+
lazy?.loading,
285361
);
286362

363+
const requestFull = React.useCallback(() => {
364+
if (!previewComplete) {
365+
lazy?.onRequest();
366+
}
367+
}, [previewComplete, lazy]);
368+
369+
const showMore = React.useCallback(() => {
370+
requestFull();
371+
setShowMore(true);
372+
}, [requestFull, setShowMore]);
373+
287374
const moreLabelFn = React.useCallback(
288375
(displayed: number) =>
289376
displayed === 0
@@ -327,10 +414,11 @@ export const AssetCheckTagCollection = React.memo((props: AssetCheckTagCollectio
327414
<span>
328415
<TagActionsPopover
329416
data={{key: '', value: ''}}
417+
onOpening={requestFull}
330418
actions={[
331419
{
332420
label: 'View list',
333-
onClick: () => setShowMore(true),
421+
onClick: showMore,
334422
},
335423
]}
336424
>

js_modules/ui-core/src/runs/RunActionsMenuRunFragment.tsx

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,7 @@ import {gql} from '../apollo-client';
33
export const RUN_ACTIONS_MENU_RUN_FRAGMENT = gql`
44
fragment RunActionsMenuRunFragment on Run {
55
id
6-
assetSelection {
7-
... on AssetKey {
8-
path
9-
}
10-
}
11-
assetCheckSelection {
12-
name
13-
assetKey {
14-
path
15-
}
16-
}
6+
assetCheckSelectionCount
177
tags {
188
key
199
value

js_modules/ui-core/src/runs/RunFragments.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export const RUN_FRAGMENT = gql`
4040
path
4141
}
4242
}
43+
assetCheckSelectionCount
4344
pipelineSnapshotId
4445
executionPlan {
4546
artifactsPersisted

js_modules/ui-core/src/runs/RunRowTags.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export const RunRowTags = ({
2525
isHovered,
2626
hideTags,
2727
}: {
28-
run: Pick<RunTableRunFragment, 'tags' | 'assetSelection' | 'mode'>;
28+
run: Pick<RunTableRunFragment, 'tags' | 'mode'>;
2929
onAddTag?: (token: RunFilterToken) => void;
3030
isHovered: boolean;
3131
hideTags?: string[];

0 commit comments

Comments
 (0)