Repository: dagster-io/dagster
Pull request #29627: [2/2] Fetch assets as part of workspace query
Author: login: salazarm
Created: 2025-04-28T13:29:51Z | Merged: 2025-04-29T09:19:39Z

## Summary & Motivation
Motivation: Perf.

We want to move away from the `assetsOrError` resolver because it fetches the entire asset graph and is fairly slow. Instead we're going to use `assetsStateOrError` resolver which avoids fetching the entire asset graph and only returns the asset keys (much much faster). To get the definitions we rely on the WorkspaceContext which will now include asset definitions for each location. Then we combine the two sources in order to get the full list of SDAs + external assets. Note that we don't paginate the fetching in the WorkspaceContext but this is fine since everything is already loaded in memory (from the workspace snapshot) and in testing with large orgs the impact on perf was negligible.

Perf. benefits:

- Less data being loaded from / saved to indexeddb on every load (and every 60 seconds)
- SDA asset fetching is parallelized by code location and only re-fetched if it changes.

## How I Tested These Changes

Updated existing jest tests.
Manual testing with local host cloud with special attention to performance.

I still need to check the case of when an asset is defined in multiple code locations and re-create the tie breaking strategy we use on the backend.



Diff:
diff --git a/js_modules/dagster-ui/packages/ui-core/client.json b/js_modules/dagster-ui/packages/ui-core/client.json
index 429337445786d..d72c0014bbe9a 100644
--- a/js_modules/dagster-ui/packages/ui-core/client.json
+++ b/js_modules/dagster-ui/packages/ui-core/client.json
@@ -8,16 +8,16 @@
   "AssetGraphSidebarQuery": "724ef0733b9b187ffd012e40c02c3b3a5e32967dbcae46e2024b15dfd5bf0271",
   "AssetLiveRunLogsSubscription": "d3ae8fb8b8500d37715da27d84b0840e19a175f27f6e62cc859473345a20f64d",
   "SidebarAssetQuery": "ec1b1b6a078023ee9abc00981cf72a7b7d89dffb4b1094d87311601c25b4a485",
-  "AssetGraphQuery": "82bc68b0a4eb76123a2615d284328b8ae28674964b19b3c429bb0cd52a631386",
+  "AssetGraphQuery": "6a3d9104d2930664a3fa62d8bb7221d8339e79f3b69952d14c5ceabb049ff483",
   "AssetForNavigationQuery": "eb695ab88044ddd7068ea0dc1e2482eaba1fcb11b83de11050ff52f55e83ed3d",
   "AssetDefinitionCollisionQuery": "84027ea05480797a69eb150ce07ac7dfd40d6007a8107f90a6c558cf3a2662f5",
   "AssetGroupMetadataQuery": "649fd8034ea453acebec574365ec19b678930cdbc976f5da2addfe40f985886f",
   "AssetMaterializationUpstreamQuery": "754bab88738acc8d310c71f577ac3cf06dc57950bb1f98a18844e6e00bae756d",
   "AssetPartitionDetailQuery": "a5a3c782894d7365cbf791c57bdec279cbccd809cd2d0dc5fd31df1e6237b927",
   "AssetPartitionStaleQuery": "4215f4014e9d7592142e1775c4b07377703e913389396f9ca14dc6bb779ce764",
-  "AssetViewDefinitionQuery": "e51fcb5fc812fd33e5785da95568e6aa54866d33cd1b4b1c8d5928ce43017c1f",
-  "AssetCatalogTableQuery": "744a413c6d97571f19266b1157e3ef3cccdb56eb22068030162d47ee926f89d0",
-  "AssetCatalogGroupTableQuery": "d8bc68e648dee12c9e9eccfeae9fbe9e82eb2ffa84232f12d38e1c6bf1fa1729",
+  "AssetViewDefinitionQuery": "8e46a21b763fd574fabad6551f7497d66b9c4f22faa2da4eff198a9e68053860",
+  "AssetCatalogTableQuery": "231aae66f6663ee5a67e3305fd8c8564d0b8f5a48613b8e36683a08094724ee6",
+  "AssetCatalogGroupTableQuery": "3fa824256df35a3dc9f89c09dd785f4688c20f29619c667a71731fb0ebe62896",
   "AssetsOverviewRootQuery": "77ab0417c979b92c9ec01cd76a0f49b59f5b8ce7af775cab7e9b3e57b7871f7d",
   "RunStatusAndPartitionKey": "4642abda7da52fb70cc0a47c19cd5bf2fd8b854bb104b6a73eb8545fcd0146b2",
   "RunStatusOnlyQuery": "e0000c8f2600dbe29f305febb04cca005e08da6a7ce03ec20476c59d607495c0",
@@ -42,6 +42,7 @@
   "AssetChecksQuery": "655a37486ebc6427fe969b59420b9590440a901b3b0c7adc4c8a9873c593a7fe",
   "AssetDaemonTicksQuery": "399ac77e660d40eba32c2ab06db2a2936a71e660d93ec108364eec1fdfc16788",
   "AssetColumnLineage": "bcb70460f77b88bbbfaec90982f3e99f522d9a4e270e63832684cfde169fabc7",
+  "AssetsStateQuery": "e1c06f749930bb076d0761e74bd8312a40ac5622464abfbaa9d591c6bdf5f20d",
   "GetAutoMaterializePausedQuery": "50f74183f54031274136ab855701d01f26642a6d958d7452ae13aa6c40ca349d",
   "SetAutoMaterializePausedMutation": "144afc0d6f43dfa6d437c0e7f621e4f19ffb48c7f75669d2e3d742c115aa7b4b",
   "AssetOverviewMetadataEventsQuery": "114f8107c50306b1a96275b011beacf8b6422c88e387f479da4d0a45c4a08cca",
@@ -178,8 +179,8 @@
   "SingleJobQuery": "5ff8f070e59507f5369f1a19abb9a72cfa12439ab04a08dc340866885f6e4702",
   "SingleScheduleQuery": "508a47e32ce04ba5be52c66cd592b74147bf98ec85b9f5d0e4db45172bd9a897",
   "SingleSensorQuery": "dbda5ba47d4ba10f8c527c9a7cd45fba0811276441a17a8ac6f173ed588f025b",
-  "WorkspaceAssetsQuery": "cecc4dd96b527fb96d9e16e6578a28ac62fe1d317623e3dae45190cb9f7c28f5",
-  "LocationWorkspaceQuery": "72685489c28204d48c39ed5d85231756f5637febc8b19e0a0b4967a2d982191e",
+  "WorkspaceAssetsQuery": "908c14a37f5b6e18787dc75b91df4ea06a7aa452a342809a85f099336bc91705",
+  "LocationWorkspaceQuery": "f92089484123ab96f4f87ccfc3ef84413e45a7c16b2cd6145e6f8c694283ac75",
   "CodeLocationStatusQuery": "f92885e073b8b4b9bd588bf248df7b06025e2a1f6e74c082233ac7863f5eef8e",
   "WorkspaceGraphsQuery": "ccbef870f327b56beb0d781a476c8afbbc22ff2621181c8576861daaf7667ecf"
 }
diff --git a/js_modules/dagster-ui/packages/ui-core/src/asset-data/AssetLiveDataProvider.tsx b/js_modules/dagster-ui/packages/ui-core/src/asset-data/AssetLiveDataProvider.tsx
index a3eb295e0793a..c81fb8dbbd647 100644
--- a/js_modules/dagster-ui/packages/ui-core/src/asset-data/AssetLiveDataProvider.tsx
+++ b/js_modules/dagster-ui/packages/ui-core/src/asset-data/AssetLiveDataProvider.tsx
@@ -10,6 +10,7 @@ import {
 import {observeAssetEventsInRuns} from '../asset-graph/AssetRunLogObserver';
 import {LiveDataForNodeWithStaleData, tokenForAssetKey} from '../asset-graph/Utils';
 import {AssetKeyInput} from '../graphql/types';
+import {useThrottledEffect} from '../hooks/useThrottledEffect';
 import {LiveDataPollRateContext} from '../live-data-provider/LiveDataProvider';
 import {LiveDataThreadID} from '../live-data-provider/LiveDataThread';
 import {SUBSCRIPTION_MAX_POLL_RATE} from '../live-data-provider/util';
@@ -133,33 +134,37 @@ export const AssetLiveDataProvider = ({children}: {children: React.ReactNode}) =
     AssetHealthData.manager.invalidateCache();
   }, SUBSCRIPTION_MAX_POLL_RATE);
 
-  React.useEffect(() => {
-    const assetKeyTokens = new Set(allObservedKeys.map(tokenForAssetKey));
-    const dataForObservedKeys = allObservedKeys
-      .map((key) => AssetBaseData.manager.getCacheEntry(tokenForAssetKey(key))!)
-      .filter((n) => n);
-
-    const assetStepKeys = new Set(dataForObservedKeys.flatMap((n) => n.opNames));
-
-    const runInProgressId = uniq(
-      dataForObservedKeys.flatMap((p) => [...p.unstartedRunIds, ...p.inProgressRunIds]),
-    ).sort();
-
-    const unobserve = observeAssetEventsInRuns(runInProgressId, (events) => {
-      if (
-        events.some(
-          (e) =>
-            (e.assetKey && assetKeyTokens.has(tokenForAssetKey(e.assetKey))) ||
-            (e.stepKey && assetStepKeys.has(e.stepKey)),
-        )
-      ) {
-        AssetBaseData.manager.invalidateCache();
-        AssetStaleStatusData.manager.invalidateCache();
-        AssetHealthData.manager.invalidateCache();
-      }
-    });
-    return unobserve;
-  }, [allObservedKeys]);
+  useThrottledEffect(
+    () => {
+      const assetKeyTokens = new Set(allObservedKeys.map(tokenForAssetKey));
+      const dataForObservedKeys = allObservedKeys
+        .map((key) => AssetBaseData.manager.getCacheEntry(tokenForAssetKey(key))!)
+        .filter((n) => n);
+
+      const assetStepKeys = new Set(dataForObservedKeys.flatMap((n) => n.opNames));
+
+      const runInProgressId = uniq(
+        dataForObservedKeys.flatMap((p) => [...p.unstartedRunIds, ...p.inProgressRunIds]),
+      ).sort();
+
+      const unobserve = observeAssetEventsInRuns(runInProgressId, (events) => {
+        if (
+          events.some(
+            (e) =>
+              (e.assetKey && assetKeyTokens.has(tokenForAssetKey(e.assetKey))) ||
+              (e.stepKey && assetStepKeys.has(e.stepKey)),
+          )
+        ) {
+          AssetBaseData.manager.invalidateCache();
+          AssetStaleStatusData.manager.invalidateCache();
+          AssetHealthData.manager.invalidateCache();
+        }
+      });
+      return unobserve;
+    },
+    [allObservedKeys],
+    2000,
+  );
 
   return (
     <AssetHealthData.LiveDataProvider>
diff --git a/js_modules/dagster-ui/packages/ui-core/src/asset-graph/AssetNode.tsx b/js_modules/dagster-ui/packages/ui-core/src/asset-graph/AssetNode.tsx
index 0e63e3c037b58..5d9683fb6e5f8 100644
--- a/js_modules/dagster-ui/packages/ui-core/src/asset-graph/AssetNode.tsx
+++ b/js_modules/dagster-ui/packages/ui-core/src/asset-graph/AssetNode.tsx
@@ -393,6 +393,7 @@ export const ASSET_NODE_FRAGMENT = gql`
     isPartitioned
     isObservable
     isMaterializable
+    isAutoCreatedStub
     owners {
       ... on TeamAssetOwner {
         team
diff --git a/js_modules/dagster-ui/packages/ui-core/src/asset-graph/AssetNodeFacetSettingsButton.tsx b/js_modules/dagster-ui/packages/ui-core/src/asset-graph/AssetNodeFacetSettingsButton.tsx
index 171785f0f455f..1721e0318ffc3 100644
--- a/js_modules/dagster-ui/packages/ui-core/src/asset-graph/AssetNodeFacetSettingsButton.tsx
+++ b/js_modules/dagster-ui/packages/ui-core/src/asset-graph/AssetNodeFacetSettingsButton.tsx
@@ -59,6 +59,7 @@ const ExampleAssetNode: AssetNodeFragment = {
   description: 'This is a test asset description',
   graphName: null,
   hasMaterializePermission: true,
+  isAutoCreatedStub: false,
   id: '["asset1"]',
   isObservable: false,
   isPartitioned: false,
diff --git a/js_modules/dagster-ui/packages/ui-core/src/asset-graph/types/AssetNode.types.ts b/js_modules/dagster-ui/packages/ui-core/src/asset-graph/types/AssetNode.types.ts
index b8418f57cf1d2..307a8a803e246 100644
--- a/js_modules/dagster-ui/packages/ui-core/src/asset-graph/types/AssetNode.types.ts
+++ b/js_modules/dagster-ui/packages/ui-core/src/asset-graph/types/AssetNode.types.ts
@@ -16,6 +16,7 @@ export type AssetNodeFragment = {
   isPartitioned: boolean;
   isObservable: boolean;
   isMaterializable: boolean;
+  isAutoCreatedStub: boolean;
   kinds: Array<string>;
   owners: Array<
     {__typename: 'TeamAssetOwner'; team: string} | {__typename: 'UserAssetOwner'; email: string}
diff --git a/js_modules/dagster-ui/packages/ui-core/src/asset-graph/types/useAssetGraphData.types.ts b/js_modules/dagster-ui/packages/ui-core/src/asset-graph/types/useAssetGraphData.types.ts
index 4779fe9b27951..1a8dd400cc4e9 100644
--- a/js_modules/dagster-ui/packages/ui-core/src/asset-graph/types/useAssetGraphData.types.ts
+++ b/js_modules/dagster-ui/packages/ui-core/src/asset-graph/types/useAssetGraphData.types.ts
@@ -25,6 +25,7 @@ export type AssetGraphQuery = {
     isPartitioned: boolean;
     isObservable: boolean;
     isMaterializable: boolean;
+    isAutoCreatedStub: boolean;
     kinds: Array<string>;
     tags: Array<{__typename: 'DefinitionTag'; key: string; value: string}>;
     owners: Array<
@@ -58,6 +59,7 @@ export type AssetNodeForGraphQueryFragment = {
   isPartitioned: boolean;
   isObservable: boolean;
   isMaterializable: boolean;
+  isAutoCreatedStub: boolean;
   kinds: Array<string>;
   tags: Array<{__typename: 'DefinitionTag'; key: string; value: string}>;
   owners: Array<
@@ -74,4 +76,4 @@ export type AssetNodeForGraphQueryFragment = {
   assetKey: {__typename: 'AssetKey'; path: Array<string>};
 };
 
-export const AssetGraphQueryVersion = '82bc68b0a4eb76123a2615d284328b8ae28674964b19b3c429bb0cd52a631386';
+export const AssetGraphQueryVersion = '6a3d9104d2930664a3fa62d8bb7221d8339e79f3b69952d14c5ceabb049ff483';
diff --git a/js_modules/dagster-ui/packages/ui-core/src/asset-graph/useAssetGraphData.tsx b/js_modules/dagster-ui/packages/ui-core/src/asset-graph/useAssetGraphData.tsx
index bac5ca81a6541..61a6e8a0cb9ec 100644
--- a/js_modules/dagster-ui/packages/ui-core/src/asset-graph/useAssetGraphData.tsx
+++ b/js_modules/dagster-ui/packages/ui-core/src/asset-graph/useAssetGraphData.tsx
@@ -498,6 +498,7 @@ const buildExternalAssetQueryItem = (asset: {
     hasMaterializePermission: false,
     opVersion: null,
     isMaterializable: false,
+    isAutoCreatedStub: true,
     tags: [],
     owners: [],
     id: asset.id,
diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/AssetTableFragment.tsx b/js_modules/dagster-ui/packages/ui-core/src/assets/AssetTableFragment.tsx
index 0045d9cd80529..f9953d3f46e95 100644
--- a/js_modules/dagster-ui/packages/ui-core/src/assets/AssetTableFragment.tsx
+++ b/js_modules/dagster-ui/packages/ui-core/src/assets/AssetTableFragment.tsx
@@ -10,6 +10,7 @@ export const ASSET_TABLE_DEFINITION_FRAGMENT = gql`
     isObservable
     isExecutable
     isPartitioned
+    isAutoCreatedStub
     computeKind
     hasMaterializePermission
     hasReportRunlessAssetEventPermission
diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/AssetsCatalogTable.tsx b/js_modules/dagster-ui/packages/ui-core/src/assets/AssetsCatalogTable.tsx
index 60ede634ef5d6..f6a1065dd5ea1 100644
--- a/js_modules/dagster-ui/packages/ui-core/src/assets/AssetsCatalogTable.tsx
+++ b/js_modules/dagster-ui/packages/ui-core/src/assets/AssetsCatalogTable.tsx
@@ -1,6 +1,6 @@
 import {Box, ButtonGroup} from '@dagster-io/ui-components';
 import * as React from 'react';
-import {useCallback, useContext, useEffect, useLayoutEffect, useMemo, useState} from 'react';
+import {useEffect, useMemo, useState} from 'react';
 import {useRouteMatch} from 'react-router-dom';
 import {useSetRecoilState} from 'recoil';
 import {CreateCatalogViewButton} from 'shared/assets/CreateCatalogViewButton.oss';
@@ -10,190 +10,25 @@ import {AssetTable} from './AssetTable';
 import {ASSET_TABLE_DEFINITION_FRAGMENT, ASSET_TABLE_FRAGMENT} from './AssetTableFragment';
 import {AssetsEmptyState} from './AssetsEmptyState';
 import {AssetTableFragment} from './types/AssetTableFragment.types';
-import {
-  AssetCatalogGroupTableNodeFragment,
-  AssetCatalogGroupTableQuery,
-  AssetCatalogGroupTableQueryVariables,
-  AssetCatalogTableQuery,
-  AssetCatalogTableQueryVariables,
-  AssetCatalogTableQueryVersion,
-} from './types/AssetsCatalogTable.types';
+import {useAllAssets} from './useAllAssets';
 import {AssetViewType, useAssetView} from './useAssetView';
-import {gql, useApolloClient} from '../apollo-client';
-import {AppContext} from '../app/AppContext';
+import {gql} from '../apollo-client';
 import {PYTHON_ERROR_FRAGMENT} from '../app/PythonErrorFragment';
 import {PythonErrorInfo} from '../app/PythonErrorInfo';
 import {FIFTEEN_SECONDS, useRefreshAtInterval} from '../app/QueryRefresh';
 import {currentPageAtom} from '../app/analytics';
-import {PythonErrorFragment} from '../app/types/PythonErrorFragment.types';
 import {tokenForAssetKey} from '../asset-graph/Utils';
 import {useAssetSelectionInput} from '../asset-selection/input/useAssetSelectionInput';
-import {getAssetsByKey} from '../asset-selection/util';
 import {AssetGroupSelector} from '../graphql/types';
-import {useUpdatingRef} from '../hooks/useUpdatingRef';
 import {useBlockTraceUntilTrue} from '../performance/TraceContext';
-import {fetchPaginatedData} from '../runs/fetchPaginatedBucketData';
-import {getCacheManager} from '../search/useIndexedDBCachedQuery';
 import {SyntaxError} from '../selection/CustomErrorListener';
 import {LoadingSpinner} from '../ui/Loading';
 
-type Asset = AssetTableFragment;
+export {useAllAssets} from './useAllAssets';
 
-const groupTableCache = new Map();
+type Asset = AssetTableFragment;
 const emptyArray: string[] = [];
 
-const DEFAULT_BATCH_LIMIT = 10000;
-
-export function useCachedAssets({
-  onAssetsLoaded,
-}: {
-  onAssetsLoaded: (data: AssetTableFragment[]) => void;
-}) {
-  const {localCacheIdPrefix} = useContext(AppContext);
-  const cacheManager = useMemo(
-    () => getCacheManager<AssetTableFragment[]>(`${localCacheIdPrefix}/allAssetNodes`),
-    [localCacheIdPrefix],
-  );
-
-  useLayoutEffect(() => {
-    cacheManager.get(AssetCatalogTableQueryVersion).then((data) => {
-      if (data) {
-        onAssetsLoaded(data);
-      }
-    });
-  }, [cacheManager, onAssetsLoaded]);
-
-  return {cacheManager};
-}
-
-// Module-level cache variables
-let globalAssetsPromise: Promise<Asset[]> | null = null;
-let cachedAllAssets: Asset[] | null = null;
-let cachedAssetsFetchTime: number = 0;
-
-export function useAllAssets({
-  batchLimit = DEFAULT_BATCH_LIMIT,
-  groupSelector,
-}: {groupSelector?: AssetGroupSelector; batchLimit?: number} = {}) {
-  const client = useApolloClient();
-  const [{error, assets}, setErrorAndAssets] = useState<{
-    error: PythonErrorFragment | undefined;
-    assets: Asset[] | undefined;
-  }>({error: undefined, assets: cachedAllAssets || undefined});
-
-  const assetsRef = useUpdatingRef(assets);
-
-  const {cacheManager} = useCachedAssets({
-    onAssetsLoaded: useCallback(
-      (data) => {
-        if (!assetsRef.current) {
-          setErrorAndAssets({
-            error: undefined,
-            assets: data,
-          });
-        }
-      },
-      [assetsRef],
-    ),
-  });
-
-  // Query function for all assets
-  const fetchAllAssets = useCallback(async () => {
-    if (cachedAllAssets && Date.now() - cachedAssetsFetchTime < 6000) {
-      return cachedAllAssets;
-    }
-    if (!globalAssetsPromise) {
-      globalAssetsPromise = (async () => {
-        const allAssets = await fetchPaginatedData({
-          async fetchData(cursor: string | null | undefined) {
-            const {data} = await client.query<
-              AssetCatalogTableQuery,
-              AssetCatalogTableQueryVariables
-            >({
-              query: ASSET_CATALOG_TABLE_QUERY,
-              fetchPolicy: 'no-cache',
-              variables: {cursor, limit: batchLimit},
-            });
-            if (data.assetsOrError.__typename === 'PythonError') {
-              return {
-                data: [],
-                cursor: undefined,
-                hasMore: false,
-                error: data.assetsOrError,
-              };
-            }
-            const assets = data.assetsOrError.nodes;
-            const hasMoreData = assets.length === batchLimit;
-            const nextCursor = data.assetsOrError.cursor;
-            return {data: assets, cursor: nextCursor, hasMore: hasMoreData, error: undefined};
-          },
-        });
-        cachedAssetsFetchTime = Date.now();
-        cachedAllAssets = allAssets;
-        cacheManager.set(allAssets, AssetCatalogTableQueryVersion);
-        globalAssetsPromise = null;
-        return allAssets;
-      })();
-    }
-    return globalAssetsPromise;
-  }, [batchLimit, cacheManager, client]);
-
-  // Query function for group assets
-  const groupQuery = useCallback(async () => {
-    const cacheKey = JSON.stringify(groupSelector);
-    if (groupTableCache.has(cacheKey)) {
-      const cachedData = groupTableCache.get(cacheKey);
-      setErrorAndAssets({
-        error: undefined,
-        assets: cachedData.assetNodes?.map(definitionToAssetTableFragment),
-      });
-      return;
-    }
-    const {data} = await client.query<
-      AssetCatalogGroupTableQuery,
-      AssetCatalogGroupTableQueryVariables
-    >({
-      query: ASSET_CATALOG_GROUP_TABLE_QUERY,
-      variables: {group: groupSelector},
-      fetchPolicy: 'no-cache',
-    });
-    groupTableCache.set(cacheKey, data);
-    setErrorAndAssets({
-      error: undefined,
-      assets: data.assetNodes?.map(definitionToAssetTableFragment),
-    });
-  }, [client, groupSelector]);
-
-  const query = groupSelector ? groupQuery : fetchAllAssets;
-
-  useEffect(() => {
-    if (groupSelector) {
-      groupQuery();
-    } else {
-      fetchAllAssets()
-        .then((allAssets) => {
-          setErrorAndAssets({error: undefined, assets: allAssets});
-        })
-        .catch((e: any) => {
-          if (e.__typename === 'PythonError') {
-            setErrorAndAssets((prev) => ({error: e, assets: prev.assets}));
-          }
-        });
-    }
-  }, [fetchAllAssets, groupQuery, groupSelector]);
-
-  return useMemo(
-    () => ({
-      assets,
-      assetsByAssetKey: getAssetsByKey(assets ?? []),
-      error,
-      loading: !assets && !error,
-      query,
-    }),
-    [assets, error, query],
-  );
-}
-
 interface AssetCatalogTableProps {
   prefixPath: string[];
   setPrefixPath: (prefixPath: string[]) => void;
@@ -346,13 +181,6 @@ export const ASSET_CATALOG_GROUP_TABLE_QUERY = gql`
   ${ASSET_TABLE_DEFINITION_FRAGMENT}
 `;
 
-// When we load the AssetCatalogTable for a particular asset group, we retrieve `assetNodes`,
-// not `assets`. To narrow the scope of this difference we coerce the nodes to look like
-// AssetCatalogTableQuery results.
-function definitionToAssetTableFragment(definition: AssetCatalogGroupTableNodeFragment): Asset {
-  return {__typename: 'Asset', id: definition.id, key: definition.assetKey, definition};
-}
-
 function buildFlatProps(assets: Asset[], _: string[]) {
   return {
     displayed: assets,
diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/__fixtures__/AssetTables.fixtures.ts b/js_modules/dagster-ui/packages/ui-core/src/assets/__fixtures__/AssetTables.fixtures.ts
index 789300f2767ac..d52ea4b7e243c 100644
--- a/js_modules/dagster-ui/packages/ui-core/src/assets/__fixtures__/AssetTables.fixtures.ts
+++ b/js_modules/dagster-ui/packages/ui-core/src/assets/__fixtures__/AssetTables.fixtures.ts
@@ -10,11 +10,11 @@ import {
   StaleStatus,
   buildAsset,
   buildAssetChecks,
-  buildAssetConnection,
   buildAssetFreshnessInfo,
   buildAssetKey,
   buildAssetLatestInfo,
   buildAssetNode,
+  buildAssetRecordConnection,
   buildFreshnessPolicy,
   buildMaterializationEvent,
   buildObservationEvent,
@@ -30,13 +30,13 @@ import {
   SingleNonSdaAssetQuery,
   SingleNonSdaAssetQueryVariables,
 } from '../../workspace/types/VirtualizedAssetRow.types';
-import {ASSET_CATALOG_GROUP_TABLE_QUERY, ASSET_CATALOG_TABLE_QUERY} from '../AssetsCatalogTable';
+import {ASSET_CATALOG_GROUP_TABLE_QUERY} from '../AssetsCatalogTable';
 import {
   AssetCatalogGroupTableQuery,
   AssetCatalogGroupTableQueryVariables,
-  AssetCatalogTableQuery,
-  AssetCatalogTableQueryVariables,
 } from '../types/AssetsCatalogTable.types';
+import {AssetsStateQuery, AssetsStateQueryVariables} from '../types/useAllAssets.types';
+import {ASSET_RECORDS_QUERY} from '../useAllAssets';
 
 export const AssetCatalogGroupTableMock = buildQueryMock<
   AssetCatalogGroupTableQuery,
@@ -247,6 +247,7 @@ export const AssetCatalogTableMockAssets: Asset[] = [
     key: buildAssetKey({path: ['good_asset']}),
     definition: buildAssetNode({
       id: 'test.py.repo.["good_asset"]',
+      assetKey: buildAssetKey({path: ['good_asset']}),
       groupName: 'GROUP2',
       isExecutable: true,
       partitionDefinition: null,
@@ -262,6 +263,7 @@ export const AssetCatalogTableMockAssets: Asset[] = [
     key: buildAssetKey({path: ['late_asset']}),
     definition: buildAssetNode({
       id: 'test.py.repo.["late_asset"]',
+      assetKey: buildAssetKey({path: ['late_asset']}),
       groupName: 'GROUP2',
       partitionDefinition: null,
       freshnessPolicy: buildFreshnessPolicy({
@@ -281,6 +283,7 @@ export const AssetCatalogTableMockAssets: Asset[] = [
     key: buildAssetKey({path: ['run_failing_asset']}),
     definition: buildAssetNode({
       id: 'test.py.repo.["run_failing_asset"]',
+      assetKey: buildAssetKey({path: ['run_failing_asset']}),
       groupName: 'GROUP4',
       partitionDefinition: buildPartitionDefinition({
         description:
@@ -297,6 +300,9 @@ export const AssetCatalogTableMockAssets: Asset[] = [
     key: buildAssetKey({path: ['asset_with_a_very_long_key_that_will_require_truncation']}),
     definition: buildAssetNode({
       id: 'test.py.repo.["asset_with_a_very_long_key_that_will_require_truncation"]',
+      assetKey: buildAssetKey({
+        path: ['asset_with_a_very_long_key_that_will_require_truncation'],
+      }),
       groupName: 'GROUP4',
       partitionDefinition: null,
       description: 'This one should be in a loading state to demo that view',
@@ -307,15 +313,15 @@ export const AssetCatalogTableMockAssets: Asset[] = [
   }),
 ];
 
-export const AssetCatalogTableMock = buildQueryMock<
-  AssetCatalogTableQuery,
-  AssetCatalogTableQueryVariables
->({
-  query: ASSET_CATALOG_TABLE_QUERY,
+export const AssetCatalogTableMock = buildQueryMock<AssetsStateQuery, AssetsStateQueryVariables>({
+  query: ASSET_RECORDS_QUERY,
   variableMatcher: () => true,
   data: {
-    assetsOrError: buildAssetConnection({
-      nodes: AssetCatalogTableMockAssets,
+    assetRecordsOrError: buildAssetRecordConnection({
+      assets: AssetCatalogTableMockAssets.map((asset) => ({
+        ...asset,
+        __typename: 'AssetRecord',
+      })),
     }),
   },
 });
diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/AssetCatalogTablev2.test.tsx b/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/AssetCatalogTablev2.test.tsx
index d7d5a9388f794..190071fcc791b 100644
--- a/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/AssetCatalogTablev2.test.tsx
+++ b/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/AssetCatalogTablev2.test.tsx
@@ -14,23 +14,21 @@ import {
 } from '../../asset-data/types/AssetHealthDataProvider.types';
 import {useAssetSelectionInput} from '../../asset-selection/input/useAssetSelectionInput';
 import {
-  Asset,
   AssetHealthStatus,
   AssetKey,
-  buildAsset,
-  buildAssetConnection,
   buildAssetHealth,
   buildAssetKey,
   buildAssetNode,
+  buildAssetRecord,
+  buildAssetRecordConnection,
 } from '../../graphql/types';
 import {buildQueryMock, getMockResultFn} from '../../testing/mocking';
+import {WorkspaceProvider} from '../../workspace/WorkspaceContext/WorkspaceContext';
+import {buildWorkspaceMocks} from '../../workspace/WorkspaceContext/__fixtures__/Workspace.fixtures';
 import {AssetCatalogTableV2} from '../AssetCatalogTableV2';
 import {AssetCatalogV2VirtualizedTable} from '../AssetCatalogV2VirtualizedTable';
-import {ASSET_CATALOG_TABLE_QUERY} from '../AssetsCatalogTable';
-import {
-  AssetCatalogTableQuery,
-  AssetCatalogTableQueryVariables,
-} from '../types/AssetsCatalogTable.types';
+import {AssetsStateQuery, AssetsStateQueryVariables} from '../types/useAllAssets.types';
+import {ASSET_RECORDS_QUERY, AssetRecord} from '../useAllAssets';
 
 setFeatureFlags({[FeatureFlag.flagUseNewObserveUIs]: true});
 
@@ -54,13 +52,19 @@ jest.mock('../AssetCatalogV2VirtualizedTable', () => ({
   AssetCatalogV2VirtualizedTable: jest.fn(() => null),
 }));
 
-const createMock = ({nodes, returnedCursor}: {returnedCursor: string | null; nodes: Asset[]}) =>
-  buildQueryMock<AssetCatalogTableQuery, AssetCatalogTableQueryVariables>({
-    query: ASSET_CATALOG_TABLE_QUERY,
+const createMock = ({
+  nodes,
+  returnedCursor,
+}: {
+  returnedCursor: string | null;
+  nodes: AssetRecord[];
+}) =>
+  buildQueryMock<AssetsStateQuery, AssetsStateQueryVariables>({
+    query: ASSET_RECORDS_QUERY,
     variableMatcher: () => true,
     data: {
-      assetsOrError: buildAssetConnection({
-        nodes,
+      assetRecordsOrError: buildAssetRecordConnection({
+        assets: nodes,
         cursor: returnedCursor,
       }),
     },
@@ -70,11 +74,11 @@ const createMock = ({nodes, returnedCursor}: {returnedCursor: string | null; nod
 
 const assetsMock = createMock({
   nodes: [
-    buildAsset({key: buildAssetKey({path: ['asset1']})}),
-    buildAsset({key: buildAssetKey({path: ['asset2']})}),
-    buildAsset({key: buildAssetKey({path: ['asset3']})}),
-    buildAsset({key: buildAssetKey({path: ['asset4']})}),
-    buildAsset({key: buildAssetKey({path: ['asset5']})}),
+    buildAssetRecord({id: 'asset1', key: buildAssetKey({path: ['asset1']})}),
+    buildAssetRecord({id: 'asset2', key: buildAssetKey({path: ['asset2']})}),
+    buildAssetRecord({id: 'asset3', key: buildAssetKey({path: ['asset3']})}),
+    buildAssetRecord({id: 'asset4', key: buildAssetKey({path: ['asset4']})}),
+    buildAssetRecord({id: 'asset5', key: buildAssetKey({path: ['asset5']})}),
   ],
   returnedCursor: '-1',
 });
@@ -137,6 +141,8 @@ const getHealthQueryMock = (assetKeys: AssetKey[]) =>
     },
   });
 
+const workspaceMocks = buildWorkspaceMocks([]);
+
 describe('AssetCatalogTableV2', () => {
   it('renders', async () => {
     const assetKeys = [
@@ -157,11 +163,14 @@ describe('AssetCatalogTableV2', () => {
                 assetsMock,
                 healthQueryMock,
                 ...buildMockedAssetGraphLiveQuery(assetKeys, undefined),
+                ...workspaceMocks,
               ]}
             >
-              <AssetLiveDataProvider>
-                <AssetCatalogTableV2 isFullScreen={false} toggleFullScreen={() => {}} />
-              </AssetLiveDataProvider>
+              <WorkspaceProvider>
+                <AssetLiveDataProvider>
+                  <AssetCatalogTableV2 isFullScreen={false} toggleFullScreen={() => {}} />
+                </AssetLiveDataProvider>
+              </WorkspaceProvider>
             </MockedProvider>
           </MemoryRouter>
         </RecoilRoot>,
@@ -203,11 +212,14 @@ describe('AssetCatalogTableV2', () => {
                 assetsMock,
                 healthQueryMock,
                 ...buildMockedAssetGraphLiveQuery(assetKeys, undefined),
+                ...workspaceMocks,
               ]}
             >
-              <AssetLiveDataProvider>
-                <AssetCatalogTableV2 isFullScreen={false} toggleFullScreen={() => {}} />
-              </AssetLiveDataProvider>
+              <WorkspaceProvider>
+                <AssetLiveDataProvider>
+                  <AssetCatalogTableV2 isFullScreen={false} toggleFullScreen={() => {}} />
+                </AssetLiveDataProvider>
+              </WorkspaceProvider>
             </MockedProvider>
           </MemoryRouter>
         </RecoilRoot>,
diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/AssetTables.test.tsx b/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/AssetTables.test.tsx
index 5e33f9441a04d..d3792c1d2a1ca 100644
--- a/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/AssetTables.test.tsx
+++ b/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/AssetTables.test.tsx
@@ -1,28 +1,44 @@
 import {MockedProvider} from '@apollo/client/testing';
-import {render, screen} from '@testing-library/react';
+import {act, render, screen} from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import {MemoryRouter} from 'react-router';
 import {RecoilRoot} from 'recoil';
 
 import {useAssetSelectionInput} from '../../asset-selection/input/useAssetSelectionInput';
+import {
+  buildRepository,
+  buildRepositoryLocation,
+  buildWorkspaceLocationEntry,
+} from '../../graphql/types';
 import {mockViewportClientRect, restoreViewportClientRect} from '../../testing/mocking';
 import {WorkspaceProvider} from '../../workspace/WorkspaceContext/WorkspaceContext';
 import {buildWorkspaceMocks} from '../../workspace/WorkspaceContext/__fixtures__/Workspace.fixtures';
 import {AssetsCatalogTable} from '../AssetsCatalogTable';
 import {
-  AssetCatalogGroupTableMock,
   AssetCatalogTableMock,
+  AssetCatalogTableMockAssets,
   SingleAssetQueryLastRunFailed,
   SingleAssetQueryMaterializedStaleAndLate,
   SingleAssetQueryMaterializedWithLatestRun,
   SingleAssetQueryTrafficDashboard,
 } from '../__fixtures__/AssetTables.fixtures';
 
-const workspaceMocks = buildWorkspaceMocks([]);
+const workspaceMocks = buildWorkspaceMocks([
+  buildWorkspaceLocationEntry({
+    locationOrLoadError: buildRepositoryLocation({
+      repositories: [
+        buildRepository({
+          assetNodes: AssetCatalogTableMockAssets.filter((asset) => asset.definition).map(
+            (asset) => asset.definition!,
+          ),
+        }),
+      ],
+    }),
+  }),
+]);
 
 const MOCKS = [
   AssetCatalogTableMock,
-  AssetCatalogGroupTableMock,
   SingleAssetQueryTrafficDashboard,
   SingleAssetQueryMaterializedWithLatestRun,
   SingleAssetQueryMaterializedStaleAndLate,
@@ -79,7 +95,9 @@ describe('AssetTable', () => {
           </RecoilRoot>
         );
       };
-      render(<Test />);
+      await act(async () => {
+        render(<Test />);
+      });
 
       expect(await screen.findByTestId('materialize-button')).toBeDisabled();
       expect(await screen.findByTestId('materialize-button')).toHaveTextContent(
diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/AssetView.test.tsx b/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/AssetView.test.tsx
index cf5a8e1dd35c1..aa8c6eefa7577 100644
--- a/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/AssetView.test.tsx
+++ b/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/AssetView.test.tsx
@@ -19,6 +19,7 @@ import {
   buildAssetKey,
   buildAssetLatestInfo,
   buildAssetNode,
+  buildAssetRecordConnection,
 } from '../../graphql/types';
 import {buildQueryMock} from '../../testing/mocking';
 import {WorkspaceProvider} from '../../workspace/WorkspaceContext/WorkspaceContext';
@@ -30,7 +31,8 @@ import {
   LatestMaterializationTimestamp,
   RootWorkspaceWithOneLocation,
 } from '../__fixtures__/AssetViewDefinition.fixtures';
-
+import {AssetsStateQuery, AssetsStateQueryVariables} from '../types/useAllAssets.types';
+import {ASSET_RECORDS_QUERY} from '../useAllAssets';
 import '../../../jest/mocks/ComputeGraphData.worker';
 
 // This file must be mocked because Jest can't handle `import.meta.url`.
@@ -75,6 +77,13 @@ describe('AssetView', () => {
                 assetNodes: [buildAssetNode()],
               },
             }),
+            buildQueryMock<AssetsStateQuery, AssetsStateQueryVariables>({
+              query: ASSET_RECORDS_QUERY,
+              variableMatcher: () => true,
+              data: {
+                assetRecordsOrError: buildAssetRecordConnection({assets: []}),
+              },
+            }),
           ]}
         >
           <WorkspaceProvider>
diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/useAllAssets.test.tsx b/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/useAllAssets.test.tsx
index 1386420fe6d1e..731807c2e6fb3 100644
--- a/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/useAllAssets.test.tsx
+++ b/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/useAllAssets.test.tsx
@@ -1,15 +1,13 @@
 import {MockedProvider} from '@apollo/client/testing';
 import {renderHook, waitFor} from '@testing-library/react';
 
-import {Asset, buildAsset, buildAssetConnection} from '../../graphql/types';
+import {buildAssetRecord, buildAssetRecordConnection} from '../../graphql/types';
 import {buildQueryMock} from '../../testing/mocking';
 import {cache as mockedCache} from '../../util/idb-lru-cache';
-import {ASSET_CATALOG_TABLE_QUERY, useAllAssets} from '../AssetsCatalogTable';
-import {
-  AssetCatalogTableQuery,
-  AssetCatalogTableQueryVariables,
-  AssetCatalogTableQueryVersion,
-} from '../types/AssetsCatalogTable.types';
+import {useAllAssets} from '../AssetsCatalogTable';
+import {AssetCatalogTableQueryVersion} from '../types/AssetsCatalogTable.types';
+import {AssetsStateQuery, AssetsStateQueryVariables} from '../types/useAllAssets.types';
+import {ASSET_RECORDS_QUERY, AssetRecord} from '../useAllAssets';
 
 jest.mock('../../util/idb-lru-cache', () => {
   const mockedCache = {
@@ -36,17 +34,17 @@ const createMock = ({
   limit?: number;
   returnedCursor: string | null;
   cursor?: string;
-  nodes: Asset[];
+  nodes: AssetRecord[];
 }) =>
-  buildQueryMock<AssetCatalogTableQuery, AssetCatalogTableQueryVariables>({
-    query: ASSET_CATALOG_TABLE_QUERY,
+  buildQueryMock<AssetsStateQuery, AssetsStateQueryVariables>({
+    query: ASSET_RECORDS_QUERY,
     variables: {
       limit,
       cursor,
     },
     data: {
-      assetsOrError: buildAssetConnection({
-        nodes,
+      assetRecordsOrError: buildAssetRecordConnection({
+        assets: nodes,
         cursor: returnedCursor,
       }),
     },
@@ -56,17 +54,17 @@ const createMock = ({
 describe('useAllAssets', () => {
   it('Paginates correctly', async () => {
     const mock = createMock({
-      nodes: [buildAsset({id: 'asset-id-1'}), buildAsset({id: 'asset-id-2'})],
+      nodes: [buildAssetRecord({id: 'asset-id-1'}), buildAssetRecord({id: 'asset-id-2'})],
       returnedCursor: 'asset-key-2',
     });
     const mock2 = createMock({
       cursor: 'asset-key-2',
-      nodes: [buildAsset({id: 'asset-id-3'}), buildAsset({id: 'asset-id-4'})],
+      nodes: [buildAssetRecord({id: 'asset-id-3'}), buildAssetRecord({id: 'asset-id-4'})],
       returnedCursor: 'asset-key-4',
     });
     const mock3 = createMock({
       cursor: 'asset-key-4',
-      nodes: [buildAsset({id: 'asset-id-5'})],
+      nodes: [buildAssetRecord({id: 'asset-id-5'})],
       returnedCursor: null,
     });
 
@@ -87,22 +85,22 @@ describe('useAllAssets', () => {
     (mockedCache as any)().has.mockResolvedValue(true);
     (mockedCache as any)().get.mockResolvedValueOnce({
       value: {
-        data: [buildAsset()],
+        data: [buildAssetRecord()],
         version: AssetCatalogTableQueryVersion,
       },
     });
     const mock = createMock({
-      nodes: [buildAsset({id: 'asset-id-1'}), buildAsset({id: 'asset-id-2'})],
+      nodes: [buildAssetRecord({id: 'asset-id-1'}), buildAssetRecord({id: 'asset-id-2'})],
       returnedCursor: 'asset-key-2',
     });
     const mock2 = createMock({
       cursor: 'asset-key-2',
-      nodes: [buildAsset({id: 'asset-id-3'}), buildAsset({id: 'asset-id-4'})],
+      nodes: [buildAssetRecord({id: 'asset-id-3'}), buildAssetRecord({id: 'asset-id-4'})],
       returnedCursor: 'asset-key-4',
     });
     const mock3 = createMock({
       cursor: 'asset-key-4',
-      nodes: [buildAsset({id: 'asset-id-5'})],
+      nodes: [buildAssetRecord({id: 'asset-id-5'})],
       returnedCursor: null,
     });
 
diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/types/AssetTableFragment.types.ts b/js_modules/dagster-ui/packages/ui-core/src/assets/types/AssetTableFragment.types.ts
index 9f1f66d49f7cd..fef84294f743b 100644
--- a/js_modules/dagster-ui/packages/ui-core/src/assets/types/AssetTableFragment.types.ts
+++ b/js_modules/dagster-ui/packages/ui-core/src/assets/types/AssetTableFragment.types.ts
@@ -12,6 +12,7 @@ export type AssetTableDefinitionFragment = {
   isObservable: boolean;
   isExecutable: boolean;
   isPartitioned: boolean;
+  isAutoCreatedStub: boolean;
   computeKind: string | null;
   hasMaterializePermission: boolean;
   hasReportRunlessAssetEventPermission: boolean;
@@ -65,6 +66,7 @@ export type AssetTableFragment = {
     isObservable: boolean;
     isExecutable: boolean;
     isPartitioned: boolean;
+    isAutoCreatedStub: boolean;
     computeKind: string | null;
     hasMaterializePermission: boolean;
     hasReportRunlessAssetEventPermission: boolean;
diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/types/AssetView.types.ts b/js_modules/dagster-ui/packages/ui-core/src/assets/types/AssetView.types.ts
index d708cc1848697..c3313f2018379 100644
--- a/js_modules/dagster-ui/packages/ui-core/src/assets/types/AssetView.types.ts
+++ b/js_modules/dagster-ui/packages/ui-core/src/assets/types/AssetView.types.ts
@@ -36,6 +36,7 @@ export type AssetViewDefinitionQuery = {
           isPartitioned: boolean;
           isObservable: boolean;
           isMaterializable: boolean;
+          isAutoCreatedStub: boolean;
           kinds: Array<string>;
           automationCondition: {
             __typename: 'AutomationCondition';
@@ -16504,6 +16505,7 @@ export type AssetViewDefinitionNodeFragment = {
   isPartitioned: boolean;
   isObservable: boolean;
   isMaterializable: boolean;
+  isAutoCreatedStub: boolean;
   kinds: Array<string>;
   automationCondition: {
     __typename: 'AutomationCondition';
@@ -32735,4 +32737,4 @@ export type AssetViewDefinitionNodeFragment = {
     | null;
 };
 
-export const AssetViewDefinitionQueryVersion = 'e51fcb5fc812fd33e5785da95568e6aa54866d33cd1b4b1c8d5928ce43017c1f';
+export const AssetViewDefinitionQueryVersion = '8e46a21b763fd574fabad6551f7497d66b9c4f22faa2da4eff198a9e68053860';
diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/types/AssetsCatalogTable.types.ts b/js_modules/dagster-ui/packages/ui-core/src/assets/types/AssetsCatalogTable.types.ts
index 3bfa1bbfc59f5..1a34ac6b15f1f 100644
--- a/js_modules/dagster-ui/packages/ui-core/src/assets/types/AssetsCatalogTable.types.ts
+++ b/js_modules/dagster-ui/packages/ui-core/src/assets/types/AssetsCatalogTable.types.ts
@@ -27,6 +27,7 @@ export type AssetCatalogTableQuery = {
             isObservable: boolean;
             isExecutable: boolean;
             isPartitioned: boolean;
+            isAutoCreatedStub: boolean;
             computeKind: string | null;
             hasMaterializePermission: boolean;
             hasReportRunlessAssetEventPermission: boolean;
@@ -96,6 +97,7 @@ export type AssetCatalogGroupTableQuery = {
     isObservable: boolean;
     isExecutable: boolean;
     isPartitioned: boolean;
+    isAutoCreatedStub: boolean;
     computeKind: string | null;
     hasMaterializePermission: boolean;
     hasReportRunlessAssetEventPermission: boolean;
@@ -146,6 +148,7 @@ export type AssetCatalogGroupTableNodeFragment = {
   isObservable: boolean;
   isExecutable: boolean;
   isPartitioned: boolean;
+  isAutoCreatedStub: boolean;
   computeKind: string | null;
   hasMaterializePermission: boolean;
   hasReportRunlessAssetEventPermission: boolean;
@@ -185,6 +188,6 @@ export type AssetCatalogGroupTableNodeFragment = {
   };
 };
 
-export const AssetCatalogTableQueryVersion = '744a413c6d97571f19266b1157e3ef3cccdb56eb22068030162d47ee926f89d0';
+export const AssetCatalogTableQueryVersion = '231aae66f6663ee5a67e3305fd8c8564d0b8f5a48613b8e36683a08094724ee6';
 
-export const AssetCatalogGroupTableQueryVersion = 'd8bc68e648dee12c9e9eccfeae9fbe9e82eb2ffa84232f12d38e1c6bf1fa1729';
+export const AssetCatalogGroupTableQueryVersion = '3fa824256df35a3dc9f89c09dd785f4688c20f29619c667a71731fb0ebe62896';
diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/types/useAllAssets.types.ts b/js_modules/dagster-ui/packages/ui-core/src/assets/types/useAllAssets.types.ts
new file mode 100644
index 0000000000000..5e27448460d07
--- /dev/null
+++ b/js_modules/dagster-ui/packages/ui-core/src/assets/types/useAllAssets.types.ts
@@ -0,0 +1,34 @@
+// Generated GraphQL types, do not edit manually.
+
+import * as Types from '../../graphql/types';
+
+export type AssetsStateQueryVariables = Types.Exact<{
+  cursor?: Types.InputMaybe<Types.Scalars['String']['input']>;
+  limit?: Types.InputMaybe<Types.Scalars['Int']['input']>;
+}>;
+
+export type AssetsStateQuery = {
+  __typename: 'Query';
+  assetRecordsOrError:
+    | {
+        __typename: 'AssetRecordConnection';
+        cursor: string | null;
+        assets: Array<{
+          __typename: 'AssetRecord';
+          id: string;
+          key: {__typename: 'AssetKey'; path: Array<string>};
+        }>;
+      }
+    | {
+        __typename: 'PythonError';
+        message: string;
+        stack: Array<string>;
+        errorChain: Array<{
+          __typename: 'ErrorChainLink';
+          isExplicitLink: boolean;
+          error: {__typename: 'PythonError'; message: string; stack: Array<string>};
+        }>;
+      };
+};
+
+export const AssetsStateQueryVersion = 'e1c06f749930bb076d0761e74bd8312a40ac5622464abfbaa9d591c6bdf5f20d';
diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/useAllAssets.tsx b/js_modules/dagster-ui/packages/ui-core/src/assets/useAllAssets.tsx
new file mode 100644
index 0000000000000..4c043d5840546
--- /dev/null
+++ b/js_modules/dagster-ui/packages/ui-core/src/assets/useAllAssets.tsx
@@ -0,0 +1,336 @@
+import {useContext, useLayoutEffect, useMemo, useState} from 'react';
+
+import {ApolloClient, ApolloQueryResult, gql, useApolloClient} from '../apollo-client';
+import {PYTHON_ERROR_FRAGMENT} from '../app/PythonErrorFragment';
+import {PythonErrorFragment} from '../app/types/PythonErrorFragment.types';
+import {tokenForAssetKey} from '../asset-graph/Utils';
+import {AssetGroupSelector} from '../graphql/types';
+import {CacheData} from '../search/useIndexedDBCachedQuery';
+import {cache} from '../util/idb-lru-cache';
+import {weakMapMemoize} from '../util/weakMapMemoize';
+import {AssetTableDefinitionFragment} from './types/AssetTableFragment.types';
+import {
+  AssetsStateQuery,
+  AssetsStateQueryVariables,
+  AssetsStateQueryVersion,
+} from './types/useAllAssets.types';
+import {WorkspaceContext} from '../workspace/WorkspaceContext/WorkspaceContext';
+import {DagsterRepoOption} from '../workspace/WorkspaceContext/util';
+
+export type AssetRecord = Extract<
+  AssetsStateQuery['assetRecordsOrError'],
+  {__typename: 'AssetRecordConnection'}
+>['assets'][0];
+
+const POLL_INTERVAL = 60000; // 1 minute
+const RETRY_INTERVAL = 1000; // 1 second
+
+const DEFAULT_BATCH_LIMIT = 10000;
+
+export function useAllAssets({
+  groupSelector,
+  batchLimit = DEFAULT_BATCH_LIMIT,
+}: {
+  groupSelector?: AssetGroupSelector;
+  batchLimit?: number;
+} = {}) {
+  const client = useApolloClient();
+  const [materializedAssets, setMaterializedAssets] = useState<AssetRecord[]>([]);
+  const manager = getFetchManager(client);
+
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState<PythonErrorFragment | null>(null);
+  useLayoutEffect(() => {
+    let didCleanup = false;
+    manager.setBatchLimit(batchLimit);
+    const unsubscribe = manager.subscribe((assetsOrError) => {
+      if (didCleanup) {
+        return;
+      }
+      if (assetsOrError instanceof Array) {
+        setMaterializedAssets(assetsOrError);
+      } else {
+        setError(assetsOrError);
+      }
+      setLoading(false);
+    });
+    return () => {
+      didCleanup = true;
+      unsubscribe();
+    };
+  }, [manager, batchLimit]);
+
+  const {allRepos, loading: workspaceLoading} = useContext(WorkspaceContext);
+  const allAssetNodes = useMemo(() => getAllAssetNodes(allRepos), [allRepos]);
+
+  const allAssetNodesByKey = useMemo(() => getAllAssetNodesByKey(allAssetNodes), [allAssetNodes]);
+
+  const assets = useMemo(
+    () => getAssets(materializedAssets, allAssetNodesByKey, allAssetNodes, groupSelector),
+    [materializedAssets, allAssetNodesByKey, allAssetNodes, groupSelector],
+  );
+
+  const assetsByAssetKey = useMemo(() => getAssetsByAssetKey(assets), [assets]);
+
+  return {
+    assets,
+    loading: loading || workspaceLoading,
+    query: manager.fetchAssets,
+    assetsByAssetKey,
+    error,
+  };
+}
+
+const getFetchManager = weakMapMemoize((client: ApolloClient<any>) => new FetchManager(client));
+
+class FetchManager {
+  private _assetsOrError: AssetRecord[] | PythonErrorFragment | null = null;
+  private _subscribers = new Set<(assetsOrError: AssetRecord[] | PythonErrorFragment) => void>();
+  private _started = false;
+  private _fetchTimeout: NodeJS.Timeout | null = null;
+  private _fetchPromise: Promise<AssetRecord[] | PythonErrorFragment> | null = null;
+  private _batchLimit = DEFAULT_BATCH_LIMIT;
+  private _cache: ReturnType<typeof cache<CacheData<AssetRecord[]>>>;
+
+  constructor(private readonly client: ApolloClient<any>) {
+    this._cache = cache<CacheData<AssetRecord[]>>({dbName: 'MaterializedAssets', maxCount: 1});
+    this.loadFromIndexedDB(); // Load from IndexedDB on construction, intentionally not awaited.
+  }
+
+  subscribe(callback: (assetsOrError: AssetRecord[] | PythonErrorFragment) => void) {
+    this._subscribers.add(callback);
+    this.startFetchLoop();
+
+    if (this._assetsOrError) {
+      callback(this._assetsOrError);
+    }
+
+    return () => {
+      this._subscribers.delete(callback);
+      if (!this._subscribers.size) {
+        this.stopFetchLoop();
+      }
+    };
+  }
+
+  private async loadFromIndexedDB() {
+    const result = await this._cache.get('data');
+    if (!result) {
+      return;
+    }
+    const {data, version} = result.value;
+    if (data && !this._assetsOrError && version === AssetsStateQueryVersion) {
+      this._assetsOrError = data;
+      this._subscribers.forEach((callback) => callback(data));
+    }
+  }
+
+  private saveToIndexedDB(data: AssetRecord[]) {
+    this._cache.set('data', {data, version: AssetsStateQueryVersion});
+  }
+
+  private startFetchLoop() {
+    if (this._started) {
+      return;
+    }
+    this._started = true;
+    this.fetchAssets();
+  }
+
+  private stopFetchLoop() {
+    this._started = false;
+    if (this._fetchTimeout) {
+      clearTimeout(this._fetchTimeout);
+      this._fetchTimeout = null;
+    }
+  }
+
+  public fetchAssets = async (pollInterval = POLL_INTERVAL) => {
+    if (this._fetchPromise) {
+      return this._fetchPromise;
+    }
+    let nextAssetsOrError: AssetRecord[] | PythonErrorFragment | null = null;
+    try {
+      this._fetchPromise = fetchAssets(this.client, this._batchLimit);
+      nextAssetsOrError = await this._fetchPromise;
+      this._assetsOrError = nextAssetsOrError;
+    } finally {
+      this._fetchPromise = null;
+    }
+
+    let nextPollInterval = POLL_INTERVAL;
+    if (nextAssetsOrError instanceof Array) {
+      setTimeout(() => {
+        this.saveToIndexedDB(nextAssetsOrError);
+      }, 32);
+    } else {
+      if (pollInterval === RETRY_INTERVAL) {
+        // if we're already polling at 1s then set the poll interval back to 1m
+        // to avoid spamming the server with a failing request...
+        nextPollInterval = POLL_INTERVAL;
+      } else {
+        nextPollInterval = RETRY_INTERVAL; // try again in 1 second if there was an error
+      }
+    }
+
+    this._subscribers.forEach((callback) => callback(this._assetsOrError!));
+    if (this._subscribers.size) {
+      this._fetchTimeout = setTimeout(() => {
+        this.fetchAssets();
+      }, nextPollInterval);
+    }
+    return this._assetsOrError;
+  };
+
+  setBatchLimit(batchLimit: number) {
+    this._batchLimit = batchLimit;
+  }
+}
+
+async function fetchAssets(client: ApolloClient<any>, batchLimit: number) {
+  let cursor = undefined;
+  let hasMore = true;
+  const assets: AssetRecord[] = [];
+  while (hasMore) {
+    const result: ApolloQueryResult<AssetsStateQuery> = await client.query<
+      AssetsStateQuery,
+      AssetsStateQueryVariables
+    >({
+      query: ASSET_RECORDS_QUERY,
+      variables: {cursor, limit: batchLimit},
+    });
+    if (!result || result.error) {
+      throw new Error(result.error?.message ?? 'Unknown error');
+    }
+    if (result.data.assetRecordsOrError.__typename === 'AssetRecordConnection') {
+      hasMore = result.data.assetRecordsOrError.assets.length === batchLimit;
+      cursor = result.data.assetRecordsOrError.cursor;
+      assets.push(...result.data.assetRecordsOrError.assets);
+    }
+    if (result.data.assetRecordsOrError.__typename === 'PythonError') {
+      return result.data.assetRecordsOrError;
+    }
+  }
+  return assets;
+}
+
+const getAllAssetNodes = weakMapMemoize((allRepos: DagsterRepoOption[]) => {
+  return allRepos.flatMap((repo) => repo.repository.assetNodes);
+});
+
+const getAllAssetNodesByKey = weakMapMemoize((allAssetNodes: AssetTableDefinitionFragment[]) => {
+  return allAssetNodes.reduce(
+    (acc, assetNode) => {
+      acc[tokenForAssetKey(assetNode.assetKey)] = true;
+      return acc;
+    },
+    {} as Record<string, boolean>,
+  );
+});
+
+const getAssets = weakMapMemoize(
+  (
+    materializedAssets: AssetRecord[],
+    allAssetNodesByKey: Record<string, boolean>,
+    allAssetNodes: AssetTableDefinitionFragment[],
+    groupSelector?: AssetGroupSelector,
+  ) => {
+    const softwareDefinedAssetsWithDuplicates = allAssetNodes.map((assetNode) => ({
+      __typename: 'Asset' as const,
+      id: assetNode.id,
+      key: assetNode.assetKey,
+      definition: assetNode,
+    }));
+
+    const softwareDefinedAssetsByAssetKey: Record<
+      string,
+      (typeof softwareDefinedAssetsWithDuplicates)[number][]
+    > = {};
+    const keysWithMultipleDefinitions: Set<string> = new Set();
+    for (const asset of softwareDefinedAssetsWithDuplicates) {
+      const key = tokenForAssetKey(asset.key);
+      softwareDefinedAssetsByAssetKey[key] = softwareDefinedAssetsByAssetKey[key] || [];
+      softwareDefinedAssetsByAssetKey[key].push(asset);
+      if (softwareDefinedAssetsByAssetKey[key].length > 1) {
+        keysWithMultipleDefinitions.add(key);
+      }
+    }
+
+    const softwareDefinedAssets = softwareDefinedAssetsWithDuplicates.filter((asset) => {
+      /**
+       * Return a materialization node if it exists, otherwise return an observable node if it
+       * exists, otherwise return any non-stub node, otherwise return any node.
+       * This exists to preserve implicit behavior, where the
+       * materialization node was previously preferred over the observable node. This is a
+       * temporary measure until we can appropriately scope the accessors that could apply to
+       * either a materialization or observation node.
+       * This property supports existing behavior but it should be phased out, because it relies on
+       * materialization nodes shadowing observation nodes that would otherwise be exposed.
+       */
+      const key = tokenForAssetKey(asset.key);
+      if (!keysWithMultipleDefinitions.has(key)) {
+        return true;
+      }
+      const materializableAsset = softwareDefinedAssetsByAssetKey[key]?.find(
+        (a) => a.definition?.isMaterializable,
+      );
+      const observableAsset = softwareDefinedAssetsByAssetKey[key]?.find(
+        (a) => a.definition?.isObservable,
+      );
+      const nonGeneratedAsset = softwareDefinedAssetsByAssetKey[key]?.find(
+        (a) => !a.definition?.isAutoCreatedStub && a.definition,
+      );
+      const assetWithRepo = softwareDefinedAssetsByAssetKey[key]?.find((a) => !!a.definition);
+      const anyAsset = softwareDefinedAssetsByAssetKey[key]?.[0];
+
+      const assetToReturn =
+        materializableAsset || observableAsset || nonGeneratedAsset || assetWithRepo || anyAsset;
+
+      return assetToReturn === asset;
+    });
+
+    if (groupSelector) {
+      return softwareDefinedAssets.filter(
+        (asset) =>
+          asset.definition.groupName === groupSelector.groupName &&
+          asset.definition.repository.name === groupSelector.repositoryName &&
+          asset.definition.repository.location.name === groupSelector.repositoryLocationName,
+      );
+    }
+
+    // Assets returned by the assetRecordsOrError resolver but not returned by the WorkspaceContext
+    // don't have a definition and are "external assets"
+    const externalAssets = materializedAssets
+      .filter((asset) => !allAssetNodesByKey[tokenForAssetKey(asset.key)])
+      .map((externalAsset) => ({
+        __typename: 'Asset' as const,
+        id: externalAsset.id,
+        key: externalAsset.key,
+        definition: null,
+      }));
+
+    return [...externalAssets, ...softwareDefinedAssets];
+  },
+);
+
+const getAssetsByAssetKey = weakMapMemoize((assets: ReturnType<typeof getAssets>) => {
+  return new Map(assets.map((asset) => [tokenForAssetKey(asset.key), asset]));
+});
+
+export const ASSET_RECORDS_QUERY = gql`
+  query AssetsStateQuery($cursor: String, $limit: Int) {
+    assetRecordsOrError(cursor: $cursor, limit: $limit) {
+      ...PythonErrorFragment
+      ... on AssetRecordConnection {
+        assets {
+          id
+          key {
+            path
+          }
+        }
+        cursor
+      }
+    }
+  }
+  ${PYTHON_ERROR_FRAGMENT}
+`;
diff --git a/js_modules/dagster-ui/packages/ui-core/src/graphql/schema.graphql b/js_modules/dagster-ui/packages/ui-core/src/graphql/schema.graphql
index f6ffd0b459aa3..386c754e82388 100644
--- a/js_modules/dagster-ui/packages/ui-core/src/graphql/schema.graphql
+++ b/js_modules/dagster-ui/packages/ui-core/src/graphql/schema.graphql
@@ -713,6 +713,7 @@ type AssetNode {
   isObservable: Boolean!
   isMaterializable: Boolean!
   isPartitioned: Boolean!
+  isAutoCreatedStub: Boolean!
   jobNames: [String!]!
   jobs: [Pipeline!]!
   latestMaterializationByPartition(partitions: [String!]): [MaterializationEvent]!
diff --git a/js_modules/dagster-ui/packages/ui-core/src/graphql/types.ts b/js_modules/dagster-ui/packages/ui-core/src/graphql/types.ts
index 2ddd0e5e4e7e0..912159fd4e32a 100644
--- a/js_modules/dagster-ui/packages/ui-core/src/graphql/types.ts
+++ b/js_modules/dagster-ui/packages/ui-core/src/graphql/types.ts
@@ -544,6 +544,7 @@ export type AssetNode = {
   hasReportRunlessAssetEventPermission: Scalars['Boolean']['output'];
   id: Scalars['ID']['output'];
   internalFreshnessPolicy: Maybe<InternalFreshnessPolicy>;
+  isAutoCreatedStub: Scalars['Boolean']['output'];
   isExecutable: Scalars['Boolean']['output'];
   isMaterializable: Scalars['Boolean']['output'];
   isObservable: Scalars['Boolean']['output'];
@@ -7144,6 +7145,10 @@ export const buildAssetNode = (
         : relationshipsToOmit.has('TimeWindowFreshnessPolicy')
           ? ({} as TimeWindowFreshnessPolicy)
           : buildTimeWindowFreshnessPolicy({}, relationshipsToOmit),
+    isAutoCreatedStub:
+      overrides && overrides.hasOwnProperty('isAutoCreatedStub')
+        ? overrides.isAutoCreatedStub!
+        : false,
     isExecutable:
       overrides && overrides.hasOwnProperty('isExecutable') ? overrides.isExecutable! : false,
     isMaterializable:
diff --git a/js_modules/dagster-ui/packages/ui-core/src/hooks/__tests__/useThrottledEffect.test.tsx b/js_modules/dagster-ui/packages/ui-core/src/hooks/__tests__/useThrottledEffect.test.tsx
new file mode 100644
index 0000000000000..1028631bdea74
--- /dev/null
+++ b/js_modules/dagster-ui/packages/ui-core/src/hooks/__tests__/useThrottledEffect.test.tsx
@@ -0,0 +1,277 @@
+import {act, renderHook} from '@testing-library/react-hooks';
+import {useState} from 'react';
+
+import {useThrottledEffect} from '../useThrottledEffect';
+
+// Mock timer functions
+jest.useFakeTimers();
+
+describe('useThrottledEffect', () => {
+  beforeEach(() => {
+    jest.clearAllTimers();
+    jest.clearAllMocks();
+  });
+
+  test('should run effect immediately on first render', () => {
+    const callback = jest.fn();
+    renderHook(() => useThrottledEffect(callback, [1], 1000));
+
+    expect(callback).toHaveBeenCalledTimes(1);
+  });
+
+  test('should not run effect again if dependencies change within delay time', () => {
+    const callback = jest.fn();
+    const {rerender} = renderHook(({dep}) => useThrottledEffect(callback, [dep], 1000), {
+      initialProps: {dep: 1},
+    });
+
+    expect(callback).toHaveBeenCalledTimes(1);
+
+    // Change dependency but don't advance timer
+    rerender({dep: 2});
+
+    // Effect should not have been called again yet
+    expect(callback).toHaveBeenCalledTimes(1);
+  });
+
+  test('should run effect after delay time has passed', () => {
+    const callback = jest.fn();
+    const {rerender} = renderHook(({dep}) => useThrottledEffect(callback, [dep], 1000), {
+      initialProps: {dep: 1},
+    });
+
+    expect(callback).toHaveBeenCalledTimes(1);
+
+    // Change dependency
+    rerender({dep: 2});
+
+    // Advance timer past the delay
+    act(() => {
+      jest.advanceTimersByTime(1000);
+    });
+
+    // Effect should now have been called again
+    expect(callback).toHaveBeenCalledTimes(2);
+  });
+
+  test('should handle multiple rapid dependency changes', () => {
+    const callback = jest.fn();
+    const {rerender} = renderHook(({dep}) => useThrottledEffect(callback, [dep], 1000), {
+      initialProps: {dep: 1},
+    });
+
+    expect(callback).toHaveBeenCalledTimes(1);
+
+    // Multiple rapid changes
+    rerender({dep: 2});
+    rerender({dep: 3});
+    rerender({dep: 4});
+
+    // Effect should still have only been called once
+    expect(callback).toHaveBeenCalledTimes(1);
+
+    // Advance timer past the delay
+    act(() => {
+      jest.advanceTimersByTime(1000);
+    });
+
+    // Effect should now have been called again (with the latest dependency value)
+    expect(callback).toHaveBeenCalledTimes(2);
+  });
+
+  test('should run immediately if dependency changes after delay has passed', () => {
+    const callback = jest.fn();
+    const {rerender} = renderHook(({dep}) => useThrottledEffect(callback, [dep], 1000), {
+      initialProps: {dep: 1},
+    });
+
+    expect(callback).toHaveBeenCalledTimes(1);
+
+    // Advance timer past the delay
+    act(() => {
+      jest.advanceTimersByTime(1500);
+    });
+
+    // Change dependency after delay has passed
+    rerender({dep: 2});
+
+    // Effect should run immediately
+    expect(callback).toHaveBeenCalledTimes(2);
+  });
+
+  test('should clean up timeout on unmount', () => {
+    const callback = jest.fn();
+    const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout');
+
+    const {rerender, unmount} = renderHook(({dep}) => useThrottledEffect(callback, [dep], 1000), {
+      initialProps: {dep: 1},
+    });
+
+    // Trigger a throttled update
+    rerender({dep: 2});
+
+    // Unmount the component
+    unmount();
+
+    // Should have cleared the timeout
+    expect(clearTimeoutSpy).toHaveBeenCalled();
+  });
+
+  test('should work with multiple dependencies', () => {
+    const callback = jest.fn();
+    const {rerender} = renderHook(({deps}) => useThrottledEffect(callback, deps, 1000), {
+      initialProps: {deps: [1, 'a', true]},
+    });
+
+    expect(callback).toHaveBeenCalledTimes(1);
+
+    // Change only one dependency
+    rerender({deps: [1, 'b', true]});
+
+    // Advance timer past the delay
+    act(() => {
+      jest.advanceTimersByTime(1000);
+    });
+
+    // Effect should have been called again
+    expect(callback).toHaveBeenCalledTimes(2);
+  });
+
+  test('should work correctly in a component with state updates', () => {
+    const callback = jest.fn();
+
+    // Create a test component with state
+    const TestHook = ({delay}: {delay: number}) => {
+      const [count, setCount] = useState(0);
+
+      useThrottledEffect(
+        () => {
+          callback(count);
+        },
+        [count],
+        delay,
+      );
+
+      return {setCount};
+    };
+
+    const {result} = renderHook(() => TestHook({delay: 1000}));
+
+    expect(callback).toHaveBeenCalledTimes(1);
+    expect(callback).toHaveBeenLastCalledWith(0);
+
+    // Update state multiple times rapidly
+    act(() => {
+      result.current.setCount(1);
+      result.current.setCount(2);
+      result.current.setCount(3);
+    });
+
+    // Effect should not have been called again yet
+    expect(callback).toHaveBeenCalledTimes(1);
+
+    // Advance timer past the delay
+    act(() => {
+      jest.advanceTimersByTime(1000);
+    });
+
+    // Effect should have been called with the latest state
+    expect(callback).toHaveBeenCalledTimes(2);
+    expect(callback).toHaveBeenLastCalledWith(3);
+  });
+
+  test('should call cleanup function when dependencies change', () => {
+    const cleanup = jest.fn();
+    const callback = jest.fn().mockReturnValue(cleanup);
+
+    const {rerender} = renderHook(({dep}) => useThrottledEffect(callback, [dep], 1000), {
+      initialProps: {dep: 1},
+    });
+
+    expect(callback).toHaveBeenCalledTimes(1);
+    expect(cleanup).not.toHaveBeenCalled();
+
+    // Advance timer and change dependency
+    act(() => {
+      jest.advanceTimersByTime(1500);
+    });
+    rerender({dep: 2});
+
+    // Cleanup should have been called before the next effect
+    expect(cleanup).toHaveBeenCalledTimes(1);
+    expect(callback).toHaveBeenCalledTimes(2);
+  });
+
+  test('should call cleanup function on unmount', () => {
+    const cleanup = jest.fn();
+    const callback = jest.fn().mockReturnValue(cleanup);
+
+    const {unmount} = renderHook(() => useThrottledEffect(callback, [1], 1000));
+
+    expect(callback).toHaveBeenCalledTimes(1);
+    expect(cleanup).not.toHaveBeenCalled();
+
+    // Unmount the component
+    unmount();
+
+    // Cleanup should have been called
+    expect(cleanup).toHaveBeenCalledTimes(1);
+  });
+
+  test('should call previous cleanup before running new effect', () => {
+    const cleanupA = jest.fn();
+    const cleanupB = jest.fn();
+    const callback = jest
+      .fn()
+      .mockImplementationOnce(() => cleanupA)
+      .mockImplementationOnce(() => cleanupB);
+
+    const {rerender, unmount} = renderHook(({dep}) => useThrottledEffect(callback, [dep], 1000), {
+      initialProps: {dep: 1},
+    });
+
+    expect(callback).toHaveBeenCalledTimes(1);
+    expect(cleanupA).not.toHaveBeenCalled();
+
+    // Advance timer and change dependency
+    act(() => {
+      jest.advanceTimersByTime(1500);
+    });
+    rerender({dep: 2});
+
+    // First cleanup should be called before second effect runs
+    expect(cleanupA).toHaveBeenCalledTimes(1);
+    expect(callback).toHaveBeenCalledTimes(2);
+    expect(cleanupB).not.toHaveBeenCalled();
+
+    // Unmount to test second cleanup
+    unmount();
+    expect(cleanupB).toHaveBeenCalledTimes(1);
+  });
+
+  test('should handle case when effect does not return cleanup function', () => {
+    // First call returns cleanup, second doesn't
+    const cleanup = jest.fn();
+    const callback = jest
+      .fn()
+      .mockImplementationOnce(() => cleanup)
+      .mockImplementationOnce(() => undefined);
+
+    const {rerender, unmount} = renderHook(({dep}) => useThrottledEffect(callback, [dep], 1000), {
+      initialProps: {dep: 1},
+    });
+
+    // Advance timer and change dependency
+    act(() => {
+      jest.advanceTimersByTime(1500);
+    });
+    rerender({dep: 2});
+
+    // Should have called cleanup from first effect
+    expect(cleanup).toHaveBeenCalledTimes(1);
+    expect(callback).toHaveBeenCalledTimes(2);
+
+    // Unmount should not error even though second effect had no cleanup
+    expect(() => unmount()).not.toThrow();
+  });
+});
diff --git a/js_modules/dagster-ui/packages/ui-core/src/hooks/useThrottledEffect.tsx b/js_modules/dagster-ui/packages/ui-core/src/hooks/useThrottledEffect.tsx
new file mode 100644
index 0000000000000..27eb3c67b9239
--- /dev/null
+++ b/js_modules/dagster-ui/packages/ui-core/src/hooks/useThrottledEffect.tsx
@@ -0,0 +1,75 @@
+import {DependencyList, useEffect, useRef, useState} from 'react';
+
+/**
+ * A custom hook that throttles the execution of an effect.
+ *
+ * @param {Function} callback - The effect callback to be throttled (can return a cleanup function)
+ * @param {Array} dependencies - Array of dependencies for the effect
+ * @param {number} delay - The minimum time (in ms) between effect executions
+ */
+export const useThrottledEffect = (
+  callback: () => void | (() => void),
+  dependencies: DependencyList | undefined,
+  delay: number,
+) => {
+  const lastRun = useRef(0);
+  const [throttledDeps, setThrottledDeps] = useState(dependencies);
+  const timeoutRef = useRef<NodeJS.Timeout | null>(null);
+  const cleanupFnRef = useRef<undefined | (() => void)>(undefined);
+
+  // Clear any pending timeouts on unmount
+  useEffect(() => {
+    return () => {
+      if (timeoutRef.current) {
+        clearTimeout(timeoutRef.current);
+      }
+    };
+  }, []);
+
+  // Check if dependencies changed
+  useEffect(() => {
+    const now = Date.now();
+    const elapsed = now - lastRun.current;
+
+    // If enough time has passed since last execution, run immediately
+    if (elapsed >= delay) {
+      lastRun.current = now;
+      setThrottledDeps(dependencies);
+    } else {
+      // Otherwise, schedule to run after the delay has passed
+      if (timeoutRef.current) {
+        clearTimeout(timeoutRef.current);
+      }
+
+      timeoutRef.current = setTimeout(() => {
+        lastRun.current = Date.now();
+        setThrottledDeps(dependencies);
+      }, delay - elapsed);
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, dependencies);
+
+  // The actual effect that only runs when throttledDeps change
+  useEffect(() => {
+    // Run cleanup from previous effect if it exists
+    if (typeof cleanupFnRef.current === 'function') {
+      cleanupFnRef.current();
+      cleanupFnRef.current = undefined;
+    }
+
+    // Run the effect and store any returned cleanup function
+    const cleanup = callback();
+    if (typeof cleanup === 'function') {
+      cleanupFnRef.current = cleanup;
+    }
+
+    // Cleanup function for the useEffect
+    return () => {
+      if (typeof cleanupFnRef.current === 'function') {
+        cleanupFnRef.current();
+        cleanupFnRef.current = undefined;
+      }
+    };
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, throttledDeps);
+};
diff --git a/js_modules/dagster-ui/packages/ui-core/src/search/useIndexedDBCachedQuery.tsx b/js_modules/dagster-ui/packages/ui-core/src/search/useIndexedDBCachedQuery.tsx
index 3609443a555db..4469bedb917b9 100644
--- a/js_modules/dagster-ui/packages/ui-core/src/search/useIndexedDBCachedQuery.tsx
+++ b/js_modules/dagster-ui/packages/ui-core/src/search/useIndexedDBCachedQuery.tsx
@@ -13,7 +13,7 @@ import {CompletionType, useBlockTraceUntilTrue} from '../performance/TraceContex
 import {cache} from '../util/idb-lru-cache';
 import {weakMapMemoize} from '../util/weakMapMemoize';
 
-type CacheData<TQuery> = {
+export type CacheData<TQuery> = {
   data: TQuery;
   version: number | string;
 };
diff --git a/js_modules/dagster-ui/packages/ui-core/src/workspace/WorkspaceContext/WorkspaceQueries.tsx b/js_modules/dagster-ui/packages/ui-core/src/workspace/WorkspaceContext/WorkspaceQueries.tsx
index d62faca893398..1e86046c17dea 100644
--- a/js_modules/dagster-ui/packages/ui-core/src/workspace/WorkspaceContext/WorkspaceQueries.tsx
+++ b/js_modules/dagster-ui/packages/ui-core/src/workspace/WorkspaceContext/WorkspaceQueries.tsx
@@ -1,10 +1,10 @@
 import {gql} from '../../apollo-client';
 import {PYTHON_ERROR_FRAGMENT} from '../../app/PythonErrorFragment';
+import {ASSET_TABLE_DEFINITION_FRAGMENT} from '../../assets/AssetTableFragment';
 import {BASIC_INSTIGATION_STATE_FRAGMENT} from '../../overview/BasicInstigationStateFragment';
 import {RESOURCE_ENTRY_FRAGMENT} from '../../resources/WorkspaceResourcesQuery';
 import {SENSOR_SWITCH_FRAGMENT} from '../../sensors/SensorSwitch';
 import {REPOSITORY_INFO_FRAGMENT} from '../RepositoryInformation';
-
 export const LOCATION_WORKSPACE_QUERY = gql`
   query LocationWorkspaceQuery($name: String!) {
     workspaceLocationEntryOrError(name: $name) {
@@ -79,6 +79,10 @@ export const LOCATION_WORKSPACE_QUERY = gql`
       id
       groupName
     }
+    assetNodes {
+      id
+      ...AssetTableDefinitionFragment
+    }
     allTopLevelResourceDetails {
       id
       ...ResourceEntryFragment
@@ -137,6 +141,7 @@ export const LOCATION_WORKSPACE_QUERY = gql`
   ${REPOSITORY_INFO_FRAGMENT}
   ${RESOURCE_ENTRY_FRAGMENT}
   ${PYTHON_ERROR_FRAGMENT}
+  ${ASSET_TABLE_DEFINITION_FRAGMENT}
 `;
 
 export const CODE_LOCATION_STATUS_QUERY = gql`
diff --git a/js_modules/dagster-ui/packages/ui-core/src/workspace/WorkspaceContext/types/WorkspaceQueries.types.ts b/js_modules/dagster-ui/packages/ui-core/src/workspace/WorkspaceContext/types/WorkspaceQueries.types.ts
index 985c61084724b..b19f7c6642063 100644
--- a/js_modules/dagster-ui/packages/ui-core/src/workspace/WorkspaceContext/types/WorkspaceQueries.types.ts
+++ b/js_modules/dagster-ui/packages/ui-core/src/workspace/WorkspaceContext/types/WorkspaceQueries.types.ts
@@ -107,6 +107,56 @@ export type LocationWorkspaceQuery = {
                   pipelineName: string;
                 }>;
                 assetGroups: Array<{__typename: 'AssetGroup'; id: string; groupName: string}>;
+                assetNodes: Array<{
+                  __typename: 'AssetNode';
+                  id: string;
+                  changedReasons: Array<Types.ChangeReason>;
+                  groupName: string;
+                  opNames: Array<string>;
+                  isMaterializable: boolean;
+                  isObservable: boolean;
+                  isExecutable: boolean;
+                  isPartitioned: boolean;
+                  isAutoCreatedStub: boolean;
+                  computeKind: string | null;
+                  hasMaterializePermission: boolean;
+                  hasReportRunlessAssetEventPermission: boolean;
+                  description: string | null;
+                  pools: Array<string>;
+                  jobNames: Array<string>;
+                  kinds: Array<string>;
+                  assetKey: {__typename: 'AssetKey'; path: Array<string>};
+                  internalFreshnessPolicy: {
+                    __typename: 'TimeWindowFreshnessPolicy';
+                    failWindowSeconds: number;
+                    warnWindowSeconds: number | null;
+                  } | null;
+                  partitionDefinition: {
+                    __typename: 'PartitionDefinition';
+                    description: string;
+                    dimensionTypes: Array<{
+                      __typename: 'DimensionDefinitionType';
+                      type: Types.PartitionDefinitionType;
+                      dynamicPartitionsDefinitionName: string | null;
+                    }>;
+                  } | null;
+                  automationCondition: {
+                    __typename: 'AutomationCondition';
+                    label: string | null;
+                    expandedLabel: Array<string>;
+                  } | null;
+                  owners: Array<
+                    | {__typename: 'TeamAssetOwner'; team: string}
+                    | {__typename: 'UserAssetOwner'; email: string}
+                  >;
+                  tags: Array<{__typename: 'DefinitionTag'; key: string; value: string}>;
+                  repository: {
+                    __typename: 'Repository';
+                    id: string;
+                    name: string;
+                    location: {__typename: 'RepositoryLocation'; id: string; name: string};
+                  };
+                }>;
                 allTopLevelResourceDetails: Array<{
                   __typename: 'ResourceDetails';
                   id: string;
@@ -220,6 +270,56 @@ export type WorkspaceLocationNodeFragment = {
             pipelineName: string;
           }>;
           assetGroups: Array<{__typename: 'AssetGroup'; id: string; groupName: string}>;
+          assetNodes: Array<{
+            __typename: 'AssetNode';
+            id: string;
+            changedReasons: Array<Types.ChangeReason>;
+            groupName: string;
+            opNames: Array<string>;
+            isMaterializable: boolean;
+            isObservable: boolean;
+            isExecutable: boolean;
+            isPartitioned: boolean;
+            isAutoCreatedStub: boolean;
+            computeKind: string | null;
+            hasMaterializePermission: boolean;
+            hasReportRunlessAssetEventPermission: boolean;
+            description: string | null;
+            pools: Array<string>;
+            jobNames: Array<string>;
+            kinds: Array<string>;
+            assetKey: {__typename: 'AssetKey'; path: Array<string>};
+            internalFreshnessPolicy: {
+              __typename: 'TimeWindowFreshnessPolicy';
+              failWindowSeconds: number;
+              warnWindowSeconds: number | null;
+            } | null;
+            partitionDefinition: {
+              __typename: 'PartitionDefinition';
+              description: string;
+              dimensionTypes: Array<{
+                __typename: 'DimensionDefinitionType';
+                type: Types.PartitionDefinitionType;
+                dynamicPartitionsDefinitionName: string | null;
+              }>;
+            } | null;
+            automationCondition: {
+              __typename: 'AutomationCondition';
+              label: string | null;
+              expandedLabel: Array<string>;
+            } | null;
+            owners: Array<
+              | {__typename: 'TeamAssetOwner'; team: string}
+              | {__typename: 'UserAssetOwner'; email: string}
+            >;
+            tags: Array<{__typename: 'DefinitionTag'; key: string; value: string}>;
+            repository: {
+              __typename: 'Repository';
+              id: string;
+              name: string;
+              location: {__typename: 'RepositoryLocation'; id: string; name: string};
+            };
+          }>;
           allTopLevelResourceDetails: Array<{
             __typename: 'ResourceDetails';
             id: string;
@@ -313,6 +413,55 @@ export type WorkspaceLocationFragment = {
       pipelineName: string;
     }>;
     assetGroups: Array<{__typename: 'AssetGroup'; id: string; groupName: string}>;
+    assetNodes: Array<{
+      __typename: 'AssetNode';
+      id: string;
+      changedReasons: Array<Types.ChangeReason>;
+      groupName: string;
+      opNames: Array<string>;
+      isMaterializable: boolean;
+      isObservable: boolean;
+      isExecutable: boolean;
+      isPartitioned: boolean;
+      isAutoCreatedStub: boolean;
+      computeKind: string | null;
+      hasMaterializePermission: boolean;
+      hasReportRunlessAssetEventPermission: boolean;
+      description: string | null;
+      pools: Array<string>;
+      jobNames: Array<string>;
+      kinds: Array<string>;
+      assetKey: {__typename: 'AssetKey'; path: Array<string>};
+      internalFreshnessPolicy: {
+        __typename: 'TimeWindowFreshnessPolicy';
+        failWindowSeconds: number;
+        warnWindowSeconds: number | null;
+      } | null;
+      partitionDefinition: {
+        __typename: 'PartitionDefinition';
+        description: string;
+        dimensionTypes: Array<{
+          __typename: 'DimensionDefinitionType';
+          type: Types.PartitionDefinitionType;
+          dynamicPartitionsDefinitionName: string | null;
+        }>;
+      } | null;
+      automationCondition: {
+        __typename: 'AutomationCondition';
+        label: string | null;
+        expandedLabel: Array<string>;
+      } | null;
+      owners: Array<
+        {__typename: 'TeamAssetOwner'; team: string} | {__typename: 'UserAssetOwner'; email: string}
+      >;
+      tags: Array<{__typename: 'DefinitionTag'; key: string; value: string}>;
+      repository: {
+        __typename: 'Repository';
+        id: string;
+        name: string;
+        location: {__typename: 'RepositoryLocation'; id: string; name: string};
+      };
+    }>;
     allTopLevelResourceDetails: Array<{
       __typename: 'ResourceDetails';
       id: string;
@@ -387,6 +536,55 @@ export type WorkspaceRepositoryFragment = {
     pipelineName: string;
   }>;
   assetGroups: Array<{__typename: 'AssetGroup'; id: string; groupName: string}>;
+  assetNodes: Array<{
+    __typename: 'AssetNode';
+    id: string;
+    changedReasons: Array<Types.ChangeReason>;
+    groupName: string;
+    opNames: Array<string>;
+    isMaterializable: boolean;
+    isObservable: boolean;
+    isExecutable: boolean;
+    isPartitioned: boolean;
+    isAutoCreatedStub: boolean;
+    computeKind: string | null;
+    hasMaterializePermission: boolean;
+    hasReportRunlessAssetEventPermission: boolean;
+    description: string | null;
+    pools: Array<string>;
+    jobNames: Array<string>;
+    kinds: Array<string>;
+    assetKey: {__typename: 'AssetKey'; path: Array<string>};
+    internalFreshnessPolicy: {
+      __typename: 'TimeWindowFreshnessPolicy';
+      failWindowSeconds: number;
+      warnWindowSeconds: number | null;
+    } | null;
+    partitionDefinition: {
+      __typename: 'PartitionDefinition';
+      description: string;
+      dimensionTypes: Array<{
+        __typename: 'DimensionDefinitionType';
+        type: Types.PartitionDefinitionType;
+        dynamicPartitionsDefinitionName: string | null;
+      }>;
+    } | null;
+    automationCondition: {
+      __typename: 'AutomationCondition';
+      label: string | null;
+      expandedLabel: Array<string>;
+    } | null;
+    owners: Array<
+      {__typename: 'TeamAssetOwner'; team: string} | {__typename: 'UserAssetOwner'; email: string}
+    >;
+    tags: Array<{__typename: 'DefinitionTag'; key: string; value: string}>;
+    repository: {
+      __typename: 'Repository';
+      id: string;
+      name: string;
+      location: {__typename: 'RepositoryLocation'; id: string; name: string};
+    };
+  }>;
   allTopLevelResourceDetails: Array<{
     __typename: 'ResourceDetails';
     id: string;
@@ -480,6 +678,6 @@ export type LocationStatusEntryFragment = {
   versionKey: string;
 };
 
-export const LocationWorkspaceQueryVersion = '72685489c28204d48c39ed5d85231756f5637febc8b19e0a0b4967a2d982191e';
+export const LocationWorkspaceQueryVersion = 'f92089484123ab96f4f87ccfc3ef84413e45a7c16b2cd6145e6f8c694283ac75';
 
 export const CodeLocationStatusQueryVersion = 'f92885e073b8b4b9bd588bf248df7b06025e2a1f6e74c082233ac7863f5eef8e';
diff --git a/js_modules/dagster-ui/packages/ui-core/src/workspace/types/WorkspaceAssetsQuery.types.ts b/js_modules/dagster-ui/packages/ui-core/src/workspace/types/WorkspaceAssetsQuery.types.ts
index 0f8d33e2a6641..2f4d52c0f5e4f 100644
--- a/js_modules/dagster-ui/packages/ui-core/src/workspace/types/WorkspaceAssetsQuery.types.ts
+++ b/js_modules/dagster-ui/packages/ui-core/src/workspace/types/WorkspaceAssetsQuery.types.ts
@@ -12,6 +12,7 @@ export type RepoAssetTableFragment = {
   isObservable: boolean;
   isExecutable: boolean;
   isPartitioned: boolean;
+  isAutoCreatedStub: boolean;
   computeKind: string | null;
   hasMaterializePermission: boolean;
   hasReportRunlessAssetEventPermission: boolean;
@@ -82,6 +83,7 @@ export type WorkspaceAssetsQuery = {
           isObservable: boolean;
           isExecutable: boolean;
           isPartitioned: boolean;
+          isAutoCreatedStub: boolean;
           computeKind: string | null;
           hasMaterializePermission: boolean;
           hasReportRunlessAssetEventPermission: boolean;
@@ -125,4 +127,4 @@ export type WorkspaceAssetsQuery = {
     | {__typename: 'RepositoryNotFoundError'};
 };
 
-export const WorkspaceAssetsQueryVersion = 'cecc4dd96b527fb96d9e16e6578a28ac62fe1d317623e3dae45190cb9f7c28f5';
+export const WorkspaceAssetsQueryVersion = '908c14a37f5b6e18787dc75b91df4ea06a7aa452a342809a85f099336bc91705';
diff --git a/python_modules/dagster-graphql/dagster_graphql/schema/asset_graph.py b/python_modules/dagster-graphql/dagster_graphql/schema/asset_graph.py
index c18021090847c..62ad1a9988aeb 100644
--- a/python_modules/dagster-graphql/dagster_graphql/schema/asset_graph.py
+++ b/python_modules/dagster-graphql/dagster_graphql/schema/asset_graph.py
@@ -7,6 +7,7 @@
     _check as check,
 )
 from dagster._core.definitions.asset_graph_differ import AssetDefinitionChangeType, AssetGraphDiffer
+from dagster._core.definitions.asset_spec import SYSTEM_METADATA_KEY_AUTO_CREATED_STUB_ASSET
 from dagster._core.definitions.data_version import (
     NULL_DATA_VERSION,
     StaleCauseCategory,
@@ -276,6 +277,7 @@ class GrapheneAssetNode(graphene.ObjectType):
     isObservable = graphene.NonNull(graphene.Boolean)
     isMaterializable = graphene.NonNull(graphene.Boolean)
     isPartitioned = graphene.NonNull(graphene.Boolean)
+    isAutoCreatedStub = graphene.NonNull(graphene.Boolean)
     jobNames = non_null_list(graphene.String)
     jobs = non_null_list(GraphenePipeline)
     latestMaterializationByPartition = graphene.Field(
@@ -1125,6 +1127,12 @@ def resolve_metadata_entries(
     ) -> Sequence[GrapheneMetadataEntry]:
         return list(iterate_metadata_entries(self._asset_node_snap.metadata))
 
+    def resolve_isAutoCreatedStub(self, _graphene_info: ResolveInfo) -> bool:
+        return (
+            self._asset_node_snap.metadata.get(SYSTEM_METADATA_KEY_AUTO_CREATED_STUB_ASSET)
+            is not None
+        )
+
     def resolve_tags(self, _graphene_info: ResolveInfo) -> Sequence[GrapheneDefinitionTag]:
         return [
             GrapheneDefinitionTag(key, value)


Reviews:
user: login: graphite-app[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T13:58:45Z
user: login: graphite-app[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T14:38:09Z
user: login: graphite-app[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T14:55:22Z
user: login: bengotow
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-28T22:00:12Z
user: login: salazarm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T22:01:15Z
user: login: bengotow
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-28T22:02:04Z
user: login: salazarm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T22:02:07Z
user: login: salazarm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T22:02:28Z
user: login: salazarm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T22:03:04Z
user: login: graphite-app[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T08:27:22Z
user: login: graphite-app[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T08:31:27Z

Review comments:
diff_hunk: @@ -0,0 +1,248 @@
+import {useContext, useLayoutEffect, useMemo, useState} from 'react';
+
+import {ApolloClient, ApolloQueryResult, gql, useApolloClient} from '../apollo-client';
+import {PYTHON_ERROR_FRAGMENT} from '../app/PythonErrorFragment';
+import {PythonErrorFragment} from '../app/types/PythonErrorFragment.types';
+import {tokenForAssetKey} from '../asset-graph/Utils';
+import {AssetGroupSelector} from '../graphql/types';
+import {CacheData} from '../search/useIndexedDBCachedQuery';
+import {cache} from '../util/idb-lru-cache';
+import {weakMapMemoize} from '../util/weakMapMemoize';
+import {AssetsStateQuery, AssetsStateQueryVariables} from './types/useAllAssets.types';
+import {WorkspaceContext} from '../workspace/WorkspaceContext/WorkspaceContext';
+
+export type AssetState = Extract<
+  AssetsStateQuery['assetRecordsOrError'],
+  {__typename: 'AssetStateConnection'}
+>['assets'][0];
+
+const POLL_INTERVAL = 60000; // 1 minute
+const DEFAULT_BATCH_LIMIT = 10000;
+export function useAllAssets({
+  groupSelector,
+  batchLimit = DEFAULT_BATCH_LIMIT,
+}: {
+  groupSelector?: AssetGroupSelector;
+  batchLimit?: number;
+} = {}) {
+  const client = useApolloClient();
+  const [materializedAssets, setMaterializedAssets] = useState<AssetState[]>([]);
+  const manager = getFetchManager(client);
+
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState<PythonErrorFragment | null>(null);
+  useLayoutEffect(() => {
+    manager.setBatchLimit(batchLimit);
+    return manager.subscribe((assetsOrError) => {
+      if (assetsOrError instanceof Array) {
+        setMaterializedAssets(assetsOrError);
+      } else {
+        setError(assetsOrError);
+      }
+      setLoading(false);
+    });
+  }, [manager, batchLimit]);
+
+  const {allRepos, loading: workspaceLoading} = useContext(WorkspaceContext);
+  const allAssetNodes = useMemo(() => {
+    return allRepos.flatMap((repo) => repo.repository.assetNodes);
+  }, [allRepos]);
+
+  const allAssetNodesById = useMemo(() => {
+    return allAssetNodes.reduce(
+      (acc, assetNode) => {
+        acc[assetNode.id] = true;
+        return acc;
+      },
+      {} as Record<string, boolean>,
+    );
+  }, [allAssetNodes]);
+
+  const assets = useMemo(() => {
+    const softwareDefinedAssets = allAssetNodes.map((assetNode) => ({
+      __typename: 'Asset' as const,
+      id: assetNode.id,
+      key: assetNode.assetKey,
+      definition: assetNode,
+    }));
+
+    if (groupSelector) {
+      return softwareDefinedAssets.filter(
+        (asset) =>
+          asset.definition.groupName === groupSelector.groupName &&
+          asset.definition.repository.name === groupSelector.repositoryName &&
+          asset.definition.repository.location.name === groupSelector.repositoryLocationName,
+      );
+    }
+
+    // Assets returned by the assetRecordsOrError resolver but not returned by the WorkspaceContext
+    // don't have a definition and are "external assets"
+    const externalAssets = materializedAssets
+      .filter((asset) => !allAssetNodesById[asset.id])
path: js_modules/dagster-ui/packages/ui-core/src/assets/useAllAssets.tsx
user: login: graphite-app[bot]
body: There appears to be an ID format mismatch between asset records and asset nodes that could lead to incorrect identification of external assets. 

Asset records from the backend use `asset_key.to_string()` for IDs (e.g., `'["path", "to", "asset"]'`), while asset nodes use a different format that includes repository information (e.g., `'test.py.repo.["good_asset"]'`).

The current filtering logic `!allAssetNodesById[asset.id]` may not work correctly since these ID formats don't match. This could result in assets being incorrectly classified as external or actual external assets being missed.

Consider using the asset key path for comparison instead of the ID, or transforming the IDs to ensure consistent formatting before comparison.
```suggestion
    const externalAssets = materializedAssets
      .filter((asset) => {
        // Extract the asset key from the materialized asset ID
        const assetKeyString = asset.id;
        // Check if any asset node has a matching key
        return !Object.values(allAssetNodesById).some(
          (node) => node.assetKey.path.toString() === assetKeyString || 
                    node.id.endsWith(assetKeyString)
        );
      })
```
  

*Spotted by [Diamond](https://app.graphite.dev/diamond/?org=dagster-io&ref=ai-review-comment)*<i class='graphite__hidden'><br /><br />Is this helpful? React 👍 or 👎 to let us know.</i>
created_at: 2025-04-28T13:58:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,251 @@
+import {useContext, useLayoutEffect, useMemo, useState} from 'react';
+
+import {ApolloClient, ApolloQueryResult, gql, useApolloClient} from '../apollo-client';
+import {PYTHON_ERROR_FRAGMENT} from '../app/PythonErrorFragment';
+import {PythonErrorFragment} from '../app/types/PythonErrorFragment.types';
+import {tokenForAssetKey} from '../asset-graph/Utils';
+import {AssetGroupSelector} from '../graphql/types';
+import {CacheData} from '../search/useIndexedDBCachedQuery';
+import {cache} from '../util/idb-lru-cache';
+import {weakMapMemoize} from '../util/weakMapMemoize';
+import {AssetsStateQuery, AssetsStateQueryVariables} from './types/useAllAssets.types';
+import {WorkspaceContext} from '../workspace/WorkspaceContext/WorkspaceContext';
+
+export type AssetState = Extract<
+  AssetsStateQuery['assetRecordsOrError'],
+  {__typename: 'AssetRecordConnection'}
+>['assets'][0];
+
+const POLL_INTERVAL = 60000; // 1 minute
+const RETRY_INTERVAL = 1000; // 1 second
+
+const DEFAULT_BATCH_LIMIT = 10000;
+
+export function useAllAssets({
+  groupSelector,
+  batchLimit = DEFAULT_BATCH_LIMIT,
+}: {
+  groupSelector?: AssetGroupSelector;
+  batchLimit?: number;
+} = {}) {
+  const client = useApolloClient();
+  const [materializedAssets, setMaterializedAssets] = useState<AssetState[]>([]);
+  const manager = getFetchManager(client);
+
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState<PythonErrorFragment | null>(null);
+  useLayoutEffect(() => {
+    manager.setBatchLimit(batchLimit);
+    return manager.subscribe((assetsOrError) => {
+      if (assetsOrError instanceof Array) {
+        setMaterializedAssets(assetsOrError);
+      } else {
+        setError(assetsOrError);
+      }
+      setLoading(false);
+    });
+  }, [manager, batchLimit]);
+
+  const {allRepos, loading: workspaceLoading} = useContext(WorkspaceContext);
+  const allAssetNodes = useMemo(() => {
+    return allRepos.flatMap((repo) => repo.repository.assetNodes);
+  }, [allRepos]);
+
+  const allAssetNodesById = useMemo(() => {
+    return allAssetNodes.reduce(
+      (acc, assetNode) => {
+        acc[assetNode.id] = true;
+        return acc;
+      },
+      {} as Record<string, boolean>,
+    );
+  }, [allAssetNodes]);
+
+  const assets = useMemo(() => {
+    const softwareDefinedAssets = allAssetNodes.map((assetNode) => ({
+      __typename: 'Asset' as const,
+      id: assetNode.id,
+      key: assetNode.assetKey,
+      definition: assetNode,
+    }));
+
+    if (groupSelector) {
+      return softwareDefinedAssets.filter(
+        (asset) =>
+          asset.definition.groupName === groupSelector.groupName &&
+          asset.definition.repository.name === groupSelector.repositoryName &&
+          asset.definition.repository.location.name === groupSelector.repositoryLocationName,
+      );
+    }
+
+    // Assets returned by the assetRecordsOrError resolver but not returned by the WorkspaceContext
+    // don't have a definition and are "external assets"
+    const externalAssets = materializedAssets
+      .filter((asset) => !allAssetNodesById[asset.id])
+      .map((externalAsset) => ({
+        __typename: 'Asset' as const,
+        id: externalAsset.id,
+        key: externalAsset.key,
+        definition: null,
+      }));
+
+    return [...externalAssets, ...softwareDefinedAssets];
+  }, [materializedAssets, allAssetNodesById, allAssetNodes, groupSelector]);
+
+  const assetsByAssetKey = useMemo(() => {
+    return new Map(assets.map((asset) => [tokenForAssetKey(asset.key), asset]));
+  }, [assets]);
+
+  return {
+    assets,
+    loading: loading || workspaceLoading,
+    query: manager.fetchAssets,
+    assetsByAssetKey,
+    error,
+  };
+}
+
+const getFetchManager = weakMapMemoize((client: ApolloClient<any>) => new FetchManager(client));
+
+const VERSION = 1;
+class FetchManager {
+  private _assetsOrError: AssetState[] | PythonErrorFragment | null = null;
+  private _subscribers = new Set<(assetsOrError: AssetState[] | PythonErrorFragment) => void>();
+  private _started = false;
+  private _fetchTimeout: NodeJS.Timeout | null = null;
+  private _fetchPromise: Promise<AssetState[] | PythonErrorFragment> | null = null;
+  private _batchLimit = DEFAULT_BATCH_LIMIT;
+  private _cache: ReturnType<typeof cache<CacheData<AssetState[]>>>;
+
+  constructor(private readonly client: ApolloClient<any>) {
+    this._cache = cache<CacheData<AssetState[]>>({dbName: 'MaterializedAssets', maxCount: 1});
+    this.loadFromIndexedDB();
+  }
+
+  subscribe(callback: (assetsOrError: AssetState[] | PythonErrorFragment) => void) {
+    this._subscribers.add(callback);
+    this.startFetchLoop();
+
+    if (this._assetsOrError) {
+      callback(this._assetsOrError);
+    }
+
+    return () => {
+      this._subscribers.delete(callback);
+      if (!this._subscribers.size) {
+        this.stopFetchLoop();
+      }
+    };
+  }
+
+  private async loadFromIndexedDB() {
+    const result = await this._cache.get('data');
+    if (!result) {
+      return;
+    }
+    const {data, version} = result.value;
+    if (data && !this._assetsOrError && version === VERSION) {
+      this._assetsOrError = data;
+      this._subscribers.forEach((callback) => callback(data));
+    }
+  }
+
+  private saveToIndexedDB(data: AssetState[]) {
+    this._cache.set('data', {data, version: VERSION});
+  }
+
+  private startFetchLoop() {
+    if (this._started) {
+      return;
+    }
+    this._started = true;
+    this.fetchAssets();
+  }
+
+  private stopFetchLoop() {
+    this._started = false;
+    if (this._fetchTimeout) {
+      clearTimeout(this._fetchTimeout);
+      this._fetchTimeout = null;
+    }
+  }
+
+  public fetchAssets = async (pollInterval = POLL_INTERVAL) => {
+    if (this._fetchPromise) {
+      return this._fetchPromise;
+    }
+    this._fetchPromise = fetchAssets(this.client, this._batchLimit);
+    this._assetsOrError = await this._fetchPromise;
+    this._fetchPromise = null;
+
+    let nextPollInterval = POLL_INTERVAL;
+    if (this._assetsOrError instanceof Array) {
+      this.saveToIndexedDB(this._assetsOrError);
+    } else {
+      if (pollInterval === RETRY_INTERVAL) {
+        // if we're already polling at 1s then set the poll interval back to 1m
+        // to avoid spamming the server with a failing request...
+        nextPollInterval = POLL_INTERVAL;
+      } else {
+        nextPollInterval = RETRY_INTERVAL; // try again in 1 second if there was an error
+      }
+    }
+
+    this._subscribers.forEach((callback) => callback(this._assetsOrError!));
+    if (this._subscribers.size) {
+      this._fetchTimeout = setTimeout(() => {
+        this.fetchAssets();
+      }, nextPollInterval);
path: js_modules/dagster-ui/packages/ui-core/src/assets/useAllAssets.tsx
user: login: graphite-app[bot]
body: There appears to be a potential race condition in the `fetchAssets` method. If `fetchAssets(this.client, this._batchLimit)` throws an exception, `this._fetchPromise` will never be reset to `null`, which would block all future fetch attempts. Consider adding a try/catch/finally block to ensure `this._fetchPromise` is always reset, even when errors occur:

```typescript
public fetchAssets = async (pollInterval = POLL_INTERVAL) => {
  if (this._fetchPromise) {
    return this._fetchPromise;
  }
  this._fetchPromise = fetchAssets(this.client, this._batchLimit);
  try {
    this._assetsOrError = await this._fetchPromise;
  } finally {
    this._fetchPromise = null;
  }
  
  // rest of the method...
};
```
```suggestion
  public fetchAssets = async (pollInterval = POLL_INTERVAL) => {
    if (this._fetchPromise) {
      return this._fetchPromise;
    }
    this._fetchPromise = fetchAssets(this.client, this._batchLimit);
    try {
      this._assetsOrError = await this._fetchPromise;
    } finally {
      this._fetchPromise = null;
    }

    let nextPollInterval = POLL_INTERVAL;
    if (this._assetsOrError instanceof Array) {
      this.saveToIndexedDB(this._assetsOrError);
    } else {
      if (pollInterval === 5000) {
        // if we're already polling at 1s then set the poll interval back to 1m
        // to avoid spamming the server with a failing request...
        nextPollInterval = POLL_INTERVAL;
      } else {
        nextPollInterval = 1000; // try again in 1 second if there was an error
      }
    }

    this._subscribers.forEach((callback) => callback(this._assetsOrError!));
    if (this._subscribers.size) {
      this._fetchTimeout = setTimeout(() => {
        this.fetchAssets();
      }, nextPollInterval);
    }
    return this._assetsOrError;
  };
```
  

*Spotted by [Diamond](https://app.graphite.dev/diamond/?org=dagster-io&ref=ai-review-comment)*<i class='graphite__hidden'><br /><br />Is this helpful? React 👍 or 👎 to let us know.</i>
created_at: 2025-04-28T14:38:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,258 @@
+import {useContext, useLayoutEffect, useMemo, useState} from 'react';
+
+import {ApolloClient, ApolloQueryResult, gql, useApolloClient} from '../apollo-client';
+import {PYTHON_ERROR_FRAGMENT} from '../app/PythonErrorFragment';
+import {PythonErrorFragment} from '../app/types/PythonErrorFragment.types';
+import {tokenForAssetKey} from '../asset-graph/Utils';
+import {AssetGroupSelector} from '../graphql/types';
+import {CacheData} from '../search/useIndexedDBCachedQuery';
+import {cache} from '../util/idb-lru-cache';
+import {weakMapMemoize} from '../util/weakMapMemoize';
+import {AssetsStateQuery, AssetsStateQueryVariables} from './types/useAllAssets.types';
+import {WorkspaceContext} from '../workspace/WorkspaceContext/WorkspaceContext';
+
+export type AssetRecord = Extract<
+  AssetsStateQuery['assetRecordsOrError'],
+  {__typename: 'AssetRecordConnection'}
+>['assets'][0];
+
+const POLL_INTERVAL = 60000; // 1 minute
+const RETRY_INTERVAL = 1000; // 1 second
+
+const DEFAULT_BATCH_LIMIT = 10000;
+
+export function useAllAssets({
+  groupSelector,
+  batchLimit = DEFAULT_BATCH_LIMIT,
+}: {
+  groupSelector?: AssetGroupSelector;
+  batchLimit?: number;
+} = {}) {
+  const client = useApolloClient();
+  const [materializedAssets, setMaterializedAssets] = useState<AssetRecord[]>([]);
+  const manager = getFetchManager(client);
+
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState<PythonErrorFragment | null>(null);
+  useLayoutEffect(() => {
+    manager.setBatchLimit(batchLimit);
+    return manager.subscribe((assetsOrError) => {
+      if (assetsOrError instanceof Array) {
+        setMaterializedAssets(assetsOrError);
+      } else {
+        setError(assetsOrError);
+      }
+      setLoading(false);
+    });
+  }, [manager, batchLimit]);
+
+  const {allRepos, loading: workspaceLoading} = useContext(WorkspaceContext);
+  const allAssetNodes = useMemo(() => {
+    return allRepos.flatMap((repo) => repo.repository.assetNodes);
+  }, [allRepos]);
+
+  const allAssetNodesByKey = useMemo(() => {
+    return allAssetNodes.reduce(
+      (acc, assetNode) => {
+        acc[tokenForAssetKey(assetNode.assetKey)] = true;
+        return acc;
+      },
+      {} as Record<string, boolean>,
+    );
+  }, [allAssetNodes]);
+
+  const assets = useMemo(() => {
+    const softwareDefinedAssets = allAssetNodes.map((assetNode) => ({
+      __typename: 'Asset' as const,
+      id: assetNode.id,
+      key: assetNode.assetKey,
+      definition: assetNode,
+    }));
+
+    if (groupSelector) {
+      return softwareDefinedAssets.filter(
+        (asset) =>
+          asset.definition.groupName === groupSelector.groupName &&
+          asset.definition.repository.name === groupSelector.repositoryName &&
+          asset.definition.repository.location.name === groupSelector.repositoryLocationName,
+      );
+    }
+
+    // Assets returned by the assetRecordsOrError resolver but not returned by the WorkspaceContext
+    // don't have a definition and are "external assets"
+    const externalAssets = materializedAssets
+      .filter((asset) => !allAssetNodesByKey[tokenForAssetKey(asset.key)])
+      .map((externalAsset) => ({
+        __typename: 'Asset' as const,
+        id: externalAsset.id,
+        key: externalAsset.key,
+        definition: null,
+      }));
+
+    return [...externalAssets, ...softwareDefinedAssets];
+  }, [materializedAssets, allAssetNodesByKey, allAssetNodes, groupSelector]);
+
+  const assetsByAssetKey = useMemo(() => {
+    return new Map(assets.map((asset) => [tokenForAssetKey(asset.key), asset]));
+  }, [assets]);
+
+  return {
+    assets,
+    loading: loading || workspaceLoading,
+    query: manager.fetchAssets,
+    assetsByAssetKey,
+    error,
+  };
+}
+
+const getFetchManager = weakMapMemoize((client: ApolloClient<any>) => new FetchManager(client));
+
+const VERSION = 1;
+class FetchManager {
+  private _assetsOrError: AssetRecord[] | PythonErrorFragment | null = null;
+  private _subscribers = new Set<(assetsOrError: AssetRecord[] | PythonErrorFragment) => void>();
+  private _started = false;
+  private _fetchTimeout: NodeJS.Timeout | null = null;
+  private _fetchPromise: Promise<AssetRecord[] | PythonErrorFragment> | null = null;
+  private _batchLimit = DEFAULT_BATCH_LIMIT;
+  private _cache: ReturnType<typeof cache<CacheData<AssetRecord[]>>>;
+
+  constructor(private readonly client: ApolloClient<any>) {
+    this._cache = cache<CacheData<AssetRecord[]>>({dbName: 'MaterializedAssets', maxCount: 1});
+    this.loadFromIndexedDB();
path: js_modules/dagster-ui/packages/ui-core/src/assets/useAllAssets.tsx
user: login: graphite-app[bot]
body: The `loadFromIndexedDB()` method is asynchronous but called without awaiting in the constructor. This means the constructor will complete before the data is loaded from IndexedDB. Consider either:

1. Moving this to a separate initialization method that can be properly awaited by callers
2. Handling the promise appropriately to ensure data is available when needed
3. Adding a comment explaining this design choice if it's intentional

This approach might lead to race conditions where subscribers receive updates before cached data is loaded.

*Spotted by [Diamond](https://app.graphite.dev/diamond/?org=dagster-io&ref=ai-review-comment)*<i class='graphite__hidden'><br /><br />Is this helpful? React 👍 or 👎 to let us know.</i>
created_at: 2025-04-28T14:55:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10,190 +10,25 @@ import {AssetTable} from './AssetTable';
 import {ASSET_TABLE_DEFINITION_FRAGMENT, ASSET_TABLE_FRAGMENT} from './AssetTableFragment';
 import {AssetsEmptyState} from './AssetsEmptyState';
 import {AssetTableFragment} from './types/AssetTableFragment.types';
-import {
-  AssetCatalogGroupTableNodeFragment,
-  AssetCatalogGroupTableQuery,
-  AssetCatalogGroupTableQueryVariables,
-  AssetCatalogTableQuery,
-  AssetCatalogTableQueryVariables,
-  AssetCatalogTableQueryVersion,
-} from './types/AssetsCatalogTable.types';
+import {useAllAssets} from './useAllAssets';
 import {AssetViewType, useAssetView} from './useAssetView';
-import {gql, useApolloClient} from '../apollo-client';
-import {AppContext} from '../app/AppContext';
+import {gql} from '../apollo-client';
 import {PYTHON_ERROR_FRAGMENT} from '../app/PythonErrorFragment';
 import {PythonErrorInfo} from '../app/PythonErrorInfo';
 import {FIFTEEN_SECONDS, useRefreshAtInterval} from '../app/QueryRefresh';
 import {currentPageAtom} from '../app/analytics';
-import {PythonErrorFragment} from '../app/types/PythonErrorFragment.types';
 import {tokenForAssetKey} from '../asset-graph/Utils';
 import {useAssetSelectionInput} from '../asset-selection/input/useAssetSelectionInput';
-import {getAssetsByKey} from '../asset-selection/util';
 import {AssetGroupSelector} from '../graphql/types';
-import {useUpdatingRef} from '../hooks/useUpdatingRef';
 import {useBlockTraceUntilTrue} from '../performance/TraceContext';
-import {fetchPaginatedData} from '../runs/fetchPaginatedBucketData';
-import {getCacheManager} from '../search/useIndexedDBCachedQuery';
 import {SyntaxError} from '../selection/CustomErrorListener';
 import {LoadingSpinner} from '../ui/Loading';
 
-type Asset = AssetTableFragment;
+export {useAllAssets} from './useAllAssets';
path: js_modules/dagster-ui/packages/ui-core/src/assets/AssetsCatalogTable.tsx
user: login: bengotow
body: This is a little odd, why re-export here?
created_at: 2025-04-28T21:48:33Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,278 @@
+import {useContext, useLayoutEffect, useMemo, useState} from 'react';
+
+import {ApolloClient, ApolloQueryResult, gql, useApolloClient} from '../apollo-client';
+import {PYTHON_ERROR_FRAGMENT} from '../app/PythonErrorFragment';
+import {PythonErrorFragment} from '../app/types/PythonErrorFragment.types';
+import {tokenForAssetKey} from '../asset-graph/Utils';
+import {AssetGroupSelector} from '../graphql/types';
+import {CacheData} from '../search/useIndexedDBCachedQuery';
+import {cache} from '../util/idb-lru-cache';
+import {weakMapMemoize} from '../util/weakMapMemoize';
+import {AssetTableDefinitionFragment} from './types/AssetTableFragment.types';
+import {AssetsStateQuery, AssetsStateQueryVariables} from './types/useAllAssets.types';
+import {WorkspaceContext} from '../workspace/WorkspaceContext/WorkspaceContext';
+import {DagsterRepoOption} from '../workspace/WorkspaceContext/util';
+
+export type AssetRecord = Extract<
+  AssetsStateQuery['assetRecordsOrError'],
+  {__typename: 'AssetRecordConnection'}
+>['assets'][0];
+
+const POLL_INTERVAL = 60000; // 1 minute
+const RETRY_INTERVAL = 1000; // 1 second
+
+const DEFAULT_BATCH_LIMIT = 10000;
+
+export function useAllAssets({
+  groupSelector,
+  batchLimit = DEFAULT_BATCH_LIMIT,
+}: {
+  groupSelector?: AssetGroupSelector;
+  batchLimit?: number;
+} = {}) {
+  const client = useApolloClient();
+  const [materializedAssets, setMaterializedAssets] = useState<AssetRecord[]>([]);
+  const manager = getFetchManager(client);
+
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState<PythonErrorFragment | null>(null);
+  useLayoutEffect(() => {
+    manager.setBatchLimit(batchLimit);
+    return manager.subscribe((assetsOrError) => {
+      if (assetsOrError instanceof Array) {
+        setMaterializedAssets(assetsOrError);
+      } else {
+        setError(assetsOrError);
+      }
+      setLoading(false);
+    });
+  }, [manager, batchLimit]);
+
+  const {allRepos, loading: workspaceLoading} = useContext(WorkspaceContext);
+  const allAssetNodes = useMemo(() => getAllAssetNodes(allRepos), [allRepos]);
+
+  const allAssetNodesByKey = useMemo(() => getAllAssetNodesByKey(allAssetNodes), [allAssetNodes]);
+
+  const assets = useMemo(
+    () => getAssets(materializedAssets, allAssetNodesByKey, allAssetNodes, groupSelector),
+    [materializedAssets, allAssetNodesByKey, allAssetNodes, groupSelector],
+  );
+
+  const assetsByAssetKey = useMemo(() => getAssetsByAssetKey(assets), [assets]);
+
+  return {
+    assets,
+    loading: loading || workspaceLoading,
+    query: manager.fetchAssets,
+    assetsByAssetKey,
+    error,
+  };
+}
+
+const getFetchManager = weakMapMemoize((client: ApolloClient<any>) => new FetchManager(client));
+
+const VERSION = 1;
+class FetchManager {
+  private _assetsOrError: AssetRecord[] | PythonErrorFragment | null = null;
+  private _subscribers = new Set<(assetsOrError: AssetRecord[] | PythonErrorFragment) => void>();
+  private _started = false;
+  private _fetchTimeout: NodeJS.Timeout | null = null;
+  private _fetchPromise: Promise<AssetRecord[] | PythonErrorFragment> | null = null;
+  private _batchLimit = DEFAULT_BATCH_LIMIT;
+  private _cache: ReturnType<typeof cache<CacheData<AssetRecord[]>>>;
+
+  constructor(private readonly client: ApolloClient<any>) {
+    this._cache = cache<CacheData<AssetRecord[]>>({dbName: 'MaterializedAssets', maxCount: 1});
+    this.loadFromIndexedDB(); // Load from IndexedDB on construction, intentionally not awaited.
+  }
+
+  subscribe(callback: (assetsOrError: AssetRecord[] | PythonErrorFragment) => void) {
+    this._subscribers.add(callback);
+    this.startFetchLoop();
+
+    if (this._assetsOrError) {
+      callback(this._assetsOrError);
+    }
+
+    return () => {
+      this._subscribers.delete(callback);
+      if (!this._subscribers.size) {
+        this.stopFetchLoop();
+      }
+    };
+  }
+
+  private async loadFromIndexedDB() {
+    const result = await this._cache.get('data');
+    if (!result) {
+      return;
+    }
+    const {data, version} = result.value;
+    if (data && !this._assetsOrError && version === VERSION) {
+      this._assetsOrError = data;
+      this._subscribers.forEach((callback) => callback(data));
+    }
+  }
+
+  private saveToIndexedDB(data: AssetRecord[]) {
+    this._cache.set('data', {data, version: VERSION});
+  }
+
+  private startFetchLoop() {
+    if (this._started) {
+      return;
+    }
+    this._started = true;
+    this.fetchAssets();
+  }
+
+  private stopFetchLoop() {
+    this._started = false;
+    if (this._fetchTimeout) {
+      clearTimeout(this._fetchTimeout);
+      this._fetchTimeout = null;
+    }
+  }
+
+  public fetchAssets = async (pollInterval = POLL_INTERVAL) => {
+    if (this._fetchPromise) {
+      return this._fetchPromise;
+    }
+    let nextAssetsOrError: AssetRecord[] | PythonErrorFragment | null = null;
+    try {
+      this._fetchPromise = fetchAssets(this.client, this._batchLimit);
path: js_modules/dagster-ui/packages/ui-core/src/assets/useAllAssets.tsx
user: login: bengotow
body: It's a little confusing to have `this.fetchAssets` and `fetchAssets` be two separate functions,  wonder if rename this one to `fetchAssetsUntilDone` or something like that. It also seems like `fetchAssets` sorta duplicates `AccumulatingDataFetcher` but I don't feel strongly about reusing it.
created_at: 2025-04-28T21:54:51Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,278 @@
+import {useContext, useLayoutEffect, useMemo, useState} from 'react';
+
+import {ApolloClient, ApolloQueryResult, gql, useApolloClient} from '../apollo-client';
+import {PYTHON_ERROR_FRAGMENT} from '../app/PythonErrorFragment';
+import {PythonErrorFragment} from '../app/types/PythonErrorFragment.types';
+import {tokenForAssetKey} from '../asset-graph/Utils';
+import {AssetGroupSelector} from '../graphql/types';
+import {CacheData} from '../search/useIndexedDBCachedQuery';
+import {cache} from '../util/idb-lru-cache';
+import {weakMapMemoize} from '../util/weakMapMemoize';
+import {AssetTableDefinitionFragment} from './types/AssetTableFragment.types';
+import {AssetsStateQuery, AssetsStateQueryVariables} from './types/useAllAssets.types';
+import {WorkspaceContext} from '../workspace/WorkspaceContext/WorkspaceContext';
+import {DagsterRepoOption} from '../workspace/WorkspaceContext/util';
+
+export type AssetRecord = Extract<
+  AssetsStateQuery['assetRecordsOrError'],
+  {__typename: 'AssetRecordConnection'}
+>['assets'][0];
+
+const POLL_INTERVAL = 60000; // 1 minute
+const RETRY_INTERVAL = 1000; // 1 second
+
+const DEFAULT_BATCH_LIMIT = 10000;
+
+export function useAllAssets({
+  groupSelector,
+  batchLimit = DEFAULT_BATCH_LIMIT,
+}: {
+  groupSelector?: AssetGroupSelector;
+  batchLimit?: number;
+} = {}) {
+  const client = useApolloClient();
+  const [materializedAssets, setMaterializedAssets] = useState<AssetRecord[]>([]);
+  const manager = getFetchManager(client);
+
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState<PythonErrorFragment | null>(null);
+  useLayoutEffect(() => {
+    manager.setBatchLimit(batchLimit);
+    return manager.subscribe((assetsOrError) => {
+      if (assetsOrError instanceof Array) {
+        setMaterializedAssets(assetsOrError);
+      } else {
+        setError(assetsOrError);
+      }
+      setLoading(false);
+    });
+  }, [manager, batchLimit]);
+
+  const {allRepos, loading: workspaceLoading} = useContext(WorkspaceContext);
+  const allAssetNodes = useMemo(() => getAllAssetNodes(allRepos), [allRepos]);
+
+  const allAssetNodesByKey = useMemo(() => getAllAssetNodesByKey(allAssetNodes), [allAssetNodes]);
+
+  const assets = useMemo(
+    () => getAssets(materializedAssets, allAssetNodesByKey, allAssetNodes, groupSelector),
+    [materializedAssets, allAssetNodesByKey, allAssetNodes, groupSelector],
+  );
+
+  const assetsByAssetKey = useMemo(() => getAssetsByAssetKey(assets), [assets]);
+
+  return {
+    assets,
+    loading: loading || workspaceLoading,
+    query: manager.fetchAssets,
+    assetsByAssetKey,
+    error,
+  };
+}
+
+const getFetchManager = weakMapMemoize((client: ApolloClient<any>) => new FetchManager(client));
+
+const VERSION = 1;
+class FetchManager {
+  private _assetsOrError: AssetRecord[] | PythonErrorFragment | null = null;
+  private _subscribers = new Set<(assetsOrError: AssetRecord[] | PythonErrorFragment) => void>();
+  private _started = false;
+  private _fetchTimeout: NodeJS.Timeout | null = null;
+  private _fetchPromise: Promise<AssetRecord[] | PythonErrorFragment> | null = null;
+  private _batchLimit = DEFAULT_BATCH_LIMIT;
+  private _cache: ReturnType<typeof cache<CacheData<AssetRecord[]>>>;
+
+  constructor(private readonly client: ApolloClient<any>) {
+    this._cache = cache<CacheData<AssetRecord[]>>({dbName: 'MaterializedAssets', maxCount: 1});
+    this.loadFromIndexedDB(); // Load from IndexedDB on construction, intentionally not awaited.
+  }
+
+  subscribe(callback: (assetsOrError: AssetRecord[] | PythonErrorFragment) => void) {
+    this._subscribers.add(callback);
+    this.startFetchLoop();
+
+    if (this._assetsOrError) {
+      callback(this._assetsOrError);
+    }
+
+    return () => {
+      this._subscribers.delete(callback);
+      if (!this._subscribers.size) {
+        this.stopFetchLoop();
+      }
+    };
+  }
+
+  private async loadFromIndexedDB() {
+    const result = await this._cache.get('data');
+    if (!result) {
+      return;
+    }
+    const {data, version} = result.value;
+    if (data && !this._assetsOrError && version === VERSION) {
+      this._assetsOrError = data;
+      this._subscribers.forEach((callback) => callback(data));
+    }
+  }
+
+  private saveToIndexedDB(data: AssetRecord[]) {
+    this._cache.set('data', {data, version: VERSION});
+  }
+
+  private startFetchLoop() {
+    if (this._started) {
+      return;
+    }
+    this._started = true;
+    this.fetchAssets();
+  }
+
+  private stopFetchLoop() {
+    this._started = false;
+    if (this._fetchTimeout) {
+      clearTimeout(this._fetchTimeout);
+      this._fetchTimeout = null;
+    }
+  }
+
+  public fetchAssets = async (pollInterval = POLL_INTERVAL) => {
+    if (this._fetchPromise) {
+      return this._fetchPromise;
+    }
+    let nextAssetsOrError: AssetRecord[] | PythonErrorFragment | null = null;
+    try {
+      this._fetchPromise = fetchAssets(this.client, this._batchLimit);
+      nextAssetsOrError = await this._fetchPromise;
+      this._assetsOrError = nextAssetsOrError;
+    } finally {
+      this._fetchPromise = null;
+    }
+
+    let nextPollInterval = POLL_INTERVAL;
+    if (nextAssetsOrError instanceof Array) {
+      setTimeout(() => {
+        this.saveToIndexedDB(nextAssetsOrError);
+      }, 32);
path: js_modules/dagster-ui/packages/ui-core/src/assets/useAllAssets.tsx
user: login: bengotow
body: is there significance to this number? It feels like there is given the specificity
created_at: 2025-04-28T21:55:09Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,278 @@
+import {useContext, useLayoutEffect, useMemo, useState} from 'react';
+
+import {ApolloClient, ApolloQueryResult, gql, useApolloClient} from '../apollo-client';
+import {PYTHON_ERROR_FRAGMENT} from '../app/PythonErrorFragment';
+import {PythonErrorFragment} from '../app/types/PythonErrorFragment.types';
+import {tokenForAssetKey} from '../asset-graph/Utils';
+import {AssetGroupSelector} from '../graphql/types';
+import {CacheData} from '../search/useIndexedDBCachedQuery';
+import {cache} from '../util/idb-lru-cache';
+import {weakMapMemoize} from '../util/weakMapMemoize';
+import {AssetTableDefinitionFragment} from './types/AssetTableFragment.types';
+import {AssetsStateQuery, AssetsStateQueryVariables} from './types/useAllAssets.types';
+import {WorkspaceContext} from '../workspace/WorkspaceContext/WorkspaceContext';
+import {DagsterRepoOption} from '../workspace/WorkspaceContext/util';
+
+export type AssetRecord = Extract<
+  AssetsStateQuery['assetRecordsOrError'],
+  {__typename: 'AssetRecordConnection'}
+>['assets'][0];
+
+const POLL_INTERVAL = 60000; // 1 minute
+const RETRY_INTERVAL = 1000; // 1 second
+
+const DEFAULT_BATCH_LIMIT = 10000;
+
+export function useAllAssets({
+  groupSelector,
+  batchLimit = DEFAULT_BATCH_LIMIT,
+}: {
+  groupSelector?: AssetGroupSelector;
+  batchLimit?: number;
+} = {}) {
+  const client = useApolloClient();
+  const [materializedAssets, setMaterializedAssets] = useState<AssetRecord[]>([]);
+  const manager = getFetchManager(client);
+
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState<PythonErrorFragment | null>(null);
+  useLayoutEffect(() => {
+    manager.setBatchLimit(batchLimit);
+    return manager.subscribe((assetsOrError) => {
+      if (assetsOrError instanceof Array) {
+        setMaterializedAssets(assetsOrError);
+      } else {
+        setError(assetsOrError);
+      }
+      setLoading(false);
+    });
+  }, [manager, batchLimit]);
+
+  const {allRepos, loading: workspaceLoading} = useContext(WorkspaceContext);
+  const allAssetNodes = useMemo(() => getAllAssetNodes(allRepos), [allRepos]);
+
+  const allAssetNodesByKey = useMemo(() => getAllAssetNodesByKey(allAssetNodes), [allAssetNodes]);
+
+  const assets = useMemo(
+    () => getAssets(materializedAssets, allAssetNodesByKey, allAssetNodes, groupSelector),
+    [materializedAssets, allAssetNodesByKey, allAssetNodes, groupSelector],
+  );
+
+  const assetsByAssetKey = useMemo(() => getAssetsByAssetKey(assets), [assets]);
+
+  return {
+    assets,
+    loading: loading || workspaceLoading,
+    query: manager.fetchAssets,
+    assetsByAssetKey,
+    error,
+  };
+}
+
+const getFetchManager = weakMapMemoize((client: ApolloClient<any>) => new FetchManager(client));
+
+const VERSION = 1;
+class FetchManager {
+  private _assetsOrError: AssetRecord[] | PythonErrorFragment | null = null;
+  private _subscribers = new Set<(assetsOrError: AssetRecord[] | PythonErrorFragment) => void>();
+  private _started = false;
+  private _fetchTimeout: NodeJS.Timeout | null = null;
+  private _fetchPromise: Promise<AssetRecord[] | PythonErrorFragment> | null = null;
+  private _batchLimit = DEFAULT_BATCH_LIMIT;
+  private _cache: ReturnType<typeof cache<CacheData<AssetRecord[]>>>;
+
+  constructor(private readonly client: ApolloClient<any>) {
+    this._cache = cache<CacheData<AssetRecord[]>>({dbName: 'MaterializedAssets', maxCount: 1});
+    this.loadFromIndexedDB(); // Load from IndexedDB on construction, intentionally not awaited.
+  }
+
+  subscribe(callback: (assetsOrError: AssetRecord[] | PythonErrorFragment) => void) {
+    this._subscribers.add(callback);
+    this.startFetchLoop();
+
+    if (this._assetsOrError) {
+      callback(this._assetsOrError);
+    }
+
+    return () => {
+      this._subscribers.delete(callback);
+      if (!this._subscribers.size) {
+        this.stopFetchLoop();
+      }
+    };
+  }
+
+  private async loadFromIndexedDB() {
+    const result = await this._cache.get('data');
+    if (!result) {
+      return;
+    }
+    const {data, version} = result.value;
+    if (data && !this._assetsOrError && version === VERSION) {
+      this._assetsOrError = data;
+      this._subscribers.forEach((callback) => callback(data));
+    }
+  }
+
+  private saveToIndexedDB(data: AssetRecord[]) {
+    this._cache.set('data', {data, version: VERSION});
+  }
+
+  private startFetchLoop() {
+    if (this._started) {
+      return;
+    }
+    this._started = true;
+    this.fetchAssets();
+  }
+
+  private stopFetchLoop() {
+    this._started = false;
+    if (this._fetchTimeout) {
+      clearTimeout(this._fetchTimeout);
+      this._fetchTimeout = null;
+    }
+  }
+
+  public fetchAssets = async (pollInterval = POLL_INTERVAL) => {
+    if (this._fetchPromise) {
+      return this._fetchPromise;
+    }
+    let nextAssetsOrError: AssetRecord[] | PythonErrorFragment | null = null;
+    try {
+      this._fetchPromise = fetchAssets(this.client, this._batchLimit);
path: js_modules/dagster-ui/packages/ui-core/src/assets/useAllAssets.tsx
user: login: bengotow
body: Worth calling out that `stopFetchLoop()` does not actually stop this fetch loop, which paginates within fetchAssets until it runs out of pages?

The fetch loop that is actually stopped is the refresh loop?
created_at: 2025-04-28T21:56:00Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,56 @@
+import {DependencyList, useEffect, useRef, useState} from 'react';
+
+/**
+ * A custom hook that throttles the execution of an effect.
+ *
+ * @param {Function} callback - The effect callback to be throttled
+ * @param {Array} dependencies - Array of dependencies for the effect
+ * @param {number} delay - The minimum time (in ms) between effect executions
+ */
+export const useThrottledEffect = (
+  callback: () => void,
path: js_modules/dagster-ui/packages/ui-core/src/hooks/useThrottledEffect.tsx
user: login: bengotow
body: This hook implementation doesn't seem to support the teardown returned by callback(), which it seems like we're expecting to work like useEffect? 
created_at: 2025-04-28T22:00:06Z
author_association: COLLABORATOR
diff_hunk: @@ -10,190 +10,25 @@ import {AssetTable} from './AssetTable';
 import {ASSET_TABLE_DEFINITION_FRAGMENT, ASSET_TABLE_FRAGMENT} from './AssetTableFragment';
 import {AssetsEmptyState} from './AssetsEmptyState';
 import {AssetTableFragment} from './types/AssetTableFragment.types';
-import {
-  AssetCatalogGroupTableNodeFragment,
-  AssetCatalogGroupTableQuery,
-  AssetCatalogGroupTableQueryVariables,
-  AssetCatalogTableQuery,
-  AssetCatalogTableQueryVariables,
-  AssetCatalogTableQueryVersion,
-} from './types/AssetsCatalogTable.types';
+import {useAllAssets} from './useAllAssets';
 import {AssetViewType, useAssetView} from './useAssetView';
-import {gql, useApolloClient} from '../apollo-client';
-import {AppContext} from '../app/AppContext';
+import {gql} from '../apollo-client';
 import {PYTHON_ERROR_FRAGMENT} from '../app/PythonErrorFragment';
 import {PythonErrorInfo} from '../app/PythonErrorInfo';
 import {FIFTEEN_SECONDS, useRefreshAtInterval} from '../app/QueryRefresh';
 import {currentPageAtom} from '../app/analytics';
-import {PythonErrorFragment} from '../app/types/PythonErrorFragment.types';
 import {tokenForAssetKey} from '../asset-graph/Utils';
 import {useAssetSelectionInput} from '../asset-selection/input/useAssetSelectionInput';
-import {getAssetsByKey} from '../asset-selection/util';
 import {AssetGroupSelector} from '../graphql/types';
-import {useUpdatingRef} from '../hooks/useUpdatingRef';
 import {useBlockTraceUntilTrue} from '../performance/TraceContext';
-import {fetchPaginatedData} from '../runs/fetchPaginatedBucketData';
-import {getCacheManager} from '../search/useIndexedDBCachedQuery';
 import {SyntaxError} from '../selection/CustomErrorListener';
 import {LoadingSpinner} from '../ui/Loading';
 
-type Asset = AssetTableFragment;
+export {useAllAssets} from './useAllAssets';
path: js_modules/dagster-ui/packages/ui-core/src/assets/AssetsCatalogTable.tsx
user: login: salazarm
body: Didn't want to update a lot of callsites in this PR (and internal) but I can do that in a follow up.
created_at: 2025-04-28T22:01:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10,190 +10,25 @@ import {AssetTable} from './AssetTable';
 import {ASSET_TABLE_DEFINITION_FRAGMENT, ASSET_TABLE_FRAGMENT} from './AssetTableFragment';
 import {AssetsEmptyState} from './AssetsEmptyState';
 import {AssetTableFragment} from './types/AssetTableFragment.types';
-import {
-  AssetCatalogGroupTableNodeFragment,
-  AssetCatalogGroupTableQuery,
-  AssetCatalogGroupTableQueryVariables,
-  AssetCatalogTableQuery,
-  AssetCatalogTableQueryVariables,
-  AssetCatalogTableQueryVersion,
-} from './types/AssetsCatalogTable.types';
+import {useAllAssets} from './useAllAssets';
 import {AssetViewType, useAssetView} from './useAssetView';
-import {gql, useApolloClient} from '../apollo-client';
-import {AppContext} from '../app/AppContext';
+import {gql} from '../apollo-client';
 import {PYTHON_ERROR_FRAGMENT} from '../app/PythonErrorFragment';
 import {PythonErrorInfo} from '../app/PythonErrorInfo';
 import {FIFTEEN_SECONDS, useRefreshAtInterval} from '../app/QueryRefresh';
 import {currentPageAtom} from '../app/analytics';
-import {PythonErrorFragment} from '../app/types/PythonErrorFragment.types';
 import {tokenForAssetKey} from '../asset-graph/Utils';
 import {useAssetSelectionInput} from '../asset-selection/input/useAssetSelectionInput';
-import {getAssetsByKey} from '../asset-selection/util';
 import {AssetGroupSelector} from '../graphql/types';
-import {useUpdatingRef} from '../hooks/useUpdatingRef';
 import {useBlockTraceUntilTrue} from '../performance/TraceContext';
-import {fetchPaginatedData} from '../runs/fetchPaginatedBucketData';
-import {getCacheManager} from '../search/useIndexedDBCachedQuery';
 import {SyntaxError} from '../selection/CustomErrorListener';
 import {LoadingSpinner} from '../ui/Loading';
 
-type Asset = AssetTableFragment;
+export {useAllAssets} from './useAllAssets';
path: js_modules/dagster-ui/packages/ui-core/src/assets/AssetsCatalogTable.tsx
user: login: bengotow
body: Ahh nice!
created_at: 2025-04-28T22:02:04Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,278 @@
+import {useContext, useLayoutEffect, useMemo, useState} from 'react';
+
+import {ApolloClient, ApolloQueryResult, gql, useApolloClient} from '../apollo-client';
+import {PYTHON_ERROR_FRAGMENT} from '../app/PythonErrorFragment';
+import {PythonErrorFragment} from '../app/types/PythonErrorFragment.types';
+import {tokenForAssetKey} from '../asset-graph/Utils';
+import {AssetGroupSelector} from '../graphql/types';
+import {CacheData} from '../search/useIndexedDBCachedQuery';
+import {cache} from '../util/idb-lru-cache';
+import {weakMapMemoize} from '../util/weakMapMemoize';
+import {AssetTableDefinitionFragment} from './types/AssetTableFragment.types';
+import {AssetsStateQuery, AssetsStateQueryVariables} from './types/useAllAssets.types';
+import {WorkspaceContext} from '../workspace/WorkspaceContext/WorkspaceContext';
+import {DagsterRepoOption} from '../workspace/WorkspaceContext/util';
+
+export type AssetRecord = Extract<
+  AssetsStateQuery['assetRecordsOrError'],
+  {__typename: 'AssetRecordConnection'}
+>['assets'][0];
+
+const POLL_INTERVAL = 60000; // 1 minute
+const RETRY_INTERVAL = 1000; // 1 second
+
+const DEFAULT_BATCH_LIMIT = 10000;
+
+export function useAllAssets({
+  groupSelector,
+  batchLimit = DEFAULT_BATCH_LIMIT,
+}: {
+  groupSelector?: AssetGroupSelector;
+  batchLimit?: number;
+} = {}) {
+  const client = useApolloClient();
+  const [materializedAssets, setMaterializedAssets] = useState<AssetRecord[]>([]);
+  const manager = getFetchManager(client);
+
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState<PythonErrorFragment | null>(null);
+  useLayoutEffect(() => {
+    manager.setBatchLimit(batchLimit);
+    return manager.subscribe((assetsOrError) => {
+      if (assetsOrError instanceof Array) {
+        setMaterializedAssets(assetsOrError);
+      } else {
+        setError(assetsOrError);
+      }
+      setLoading(false);
+    });
+  }, [manager, batchLimit]);
+
+  const {allRepos, loading: workspaceLoading} = useContext(WorkspaceContext);
+  const allAssetNodes = useMemo(() => getAllAssetNodes(allRepos), [allRepos]);
+
+  const allAssetNodesByKey = useMemo(() => getAllAssetNodesByKey(allAssetNodes), [allAssetNodes]);
+
+  const assets = useMemo(
+    () => getAssets(materializedAssets, allAssetNodesByKey, allAssetNodes, groupSelector),
+    [materializedAssets, allAssetNodesByKey, allAssetNodes, groupSelector],
+  );
+
+  const assetsByAssetKey = useMemo(() => getAssetsByAssetKey(assets), [assets]);
+
+  return {
+    assets,
+    loading: loading || workspaceLoading,
+    query: manager.fetchAssets,
+    assetsByAssetKey,
+    error,
+  };
+}
+
+const getFetchManager = weakMapMemoize((client: ApolloClient<any>) => new FetchManager(client));
+
+const VERSION = 1;
+class FetchManager {
+  private _assetsOrError: AssetRecord[] | PythonErrorFragment | null = null;
+  private _subscribers = new Set<(assetsOrError: AssetRecord[] | PythonErrorFragment) => void>();
+  private _started = false;
+  private _fetchTimeout: NodeJS.Timeout | null = null;
+  private _fetchPromise: Promise<AssetRecord[] | PythonErrorFragment> | null = null;
+  private _batchLimit = DEFAULT_BATCH_LIMIT;
+  private _cache: ReturnType<typeof cache<CacheData<AssetRecord[]>>>;
+
+  constructor(private readonly client: ApolloClient<any>) {
+    this._cache = cache<CacheData<AssetRecord[]>>({dbName: 'MaterializedAssets', maxCount: 1});
+    this.loadFromIndexedDB(); // Load from IndexedDB on construction, intentionally not awaited.
+  }
+
+  subscribe(callback: (assetsOrError: AssetRecord[] | PythonErrorFragment) => void) {
+    this._subscribers.add(callback);
+    this.startFetchLoop();
+
+    if (this._assetsOrError) {
+      callback(this._assetsOrError);
+    }
+
+    return () => {
+      this._subscribers.delete(callback);
+      if (!this._subscribers.size) {
+        this.stopFetchLoop();
+      }
+    };
+  }
+
+  private async loadFromIndexedDB() {
+    const result = await this._cache.get('data');
+    if (!result) {
+      return;
+    }
+    const {data, version} = result.value;
+    if (data && !this._assetsOrError && version === VERSION) {
+      this._assetsOrError = data;
+      this._subscribers.forEach((callback) => callback(data));
+    }
+  }
+
+  private saveToIndexedDB(data: AssetRecord[]) {
+    this._cache.set('data', {data, version: VERSION});
+  }
+
+  private startFetchLoop() {
+    if (this._started) {
+      return;
+    }
+    this._started = true;
+    this.fetchAssets();
+  }
+
+  private stopFetchLoop() {
+    this._started = false;
+    if (this._fetchTimeout) {
+      clearTimeout(this._fetchTimeout);
+      this._fetchTimeout = null;
+    }
+  }
+
+  public fetchAssets = async (pollInterval = POLL_INTERVAL) => {
+    if (this._fetchPromise) {
+      return this._fetchPromise;
+    }
+    let nextAssetsOrError: AssetRecord[] | PythonErrorFragment | null = null;
+    try {
+      this._fetchPromise = fetchAssets(this.client, this._batchLimit);
path: js_modules/dagster-ui/packages/ui-core/src/assets/useAllAssets.tsx
user: login: salazarm
body: Yeah stops the refresh loop, not the fetch loop. I figured we can just let it run (for most orgs there won't be any looping since most are under 10k assets).
created_at: 2025-04-28T22:02:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,278 @@
+import {useContext, useLayoutEffect, useMemo, useState} from 'react';
+
+import {ApolloClient, ApolloQueryResult, gql, useApolloClient} from '../apollo-client';
+import {PYTHON_ERROR_FRAGMENT} from '../app/PythonErrorFragment';
+import {PythonErrorFragment} from '../app/types/PythonErrorFragment.types';
+import {tokenForAssetKey} from '../asset-graph/Utils';
+import {AssetGroupSelector} from '../graphql/types';
+import {CacheData} from '../search/useIndexedDBCachedQuery';
+import {cache} from '../util/idb-lru-cache';
+import {weakMapMemoize} from '../util/weakMapMemoize';
+import {AssetTableDefinitionFragment} from './types/AssetTableFragment.types';
+import {AssetsStateQuery, AssetsStateQueryVariables} from './types/useAllAssets.types';
+import {WorkspaceContext} from '../workspace/WorkspaceContext/WorkspaceContext';
+import {DagsterRepoOption} from '../workspace/WorkspaceContext/util';
+
+export type AssetRecord = Extract<
+  AssetsStateQuery['assetRecordsOrError'],
+  {__typename: 'AssetRecordConnection'}
+>['assets'][0];
+
+const POLL_INTERVAL = 60000; // 1 minute
+const RETRY_INTERVAL = 1000; // 1 second
+
+const DEFAULT_BATCH_LIMIT = 10000;
+
+export function useAllAssets({
+  groupSelector,
+  batchLimit = DEFAULT_BATCH_LIMIT,
+}: {
+  groupSelector?: AssetGroupSelector;
+  batchLimit?: number;
+} = {}) {
+  const client = useApolloClient();
+  const [materializedAssets, setMaterializedAssets] = useState<AssetRecord[]>([]);
+  const manager = getFetchManager(client);
+
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState<PythonErrorFragment | null>(null);
+  useLayoutEffect(() => {
+    manager.setBatchLimit(batchLimit);
+    return manager.subscribe((assetsOrError) => {
+      if (assetsOrError instanceof Array) {
+        setMaterializedAssets(assetsOrError);
+      } else {
+        setError(assetsOrError);
+      }
+      setLoading(false);
+    });
+  }, [manager, batchLimit]);
+
+  const {allRepos, loading: workspaceLoading} = useContext(WorkspaceContext);
+  const allAssetNodes = useMemo(() => getAllAssetNodes(allRepos), [allRepos]);
+
+  const allAssetNodesByKey = useMemo(() => getAllAssetNodesByKey(allAssetNodes), [allAssetNodes]);
+
+  const assets = useMemo(
+    () => getAssets(materializedAssets, allAssetNodesByKey, allAssetNodes, groupSelector),
+    [materializedAssets, allAssetNodesByKey, allAssetNodes, groupSelector],
+  );
+
+  const assetsByAssetKey = useMemo(() => getAssetsByAssetKey(assets), [assets]);
+
+  return {
+    assets,
+    loading: loading || workspaceLoading,
+    query: manager.fetchAssets,
+    assetsByAssetKey,
+    error,
+  };
+}
+
+const getFetchManager = weakMapMemoize((client: ApolloClient<any>) => new FetchManager(client));
+
+const VERSION = 1;
+class FetchManager {
+  private _assetsOrError: AssetRecord[] | PythonErrorFragment | null = null;
+  private _subscribers = new Set<(assetsOrError: AssetRecord[] | PythonErrorFragment) => void>();
+  private _started = false;
+  private _fetchTimeout: NodeJS.Timeout | null = null;
+  private _fetchPromise: Promise<AssetRecord[] | PythonErrorFragment> | null = null;
+  private _batchLimit = DEFAULT_BATCH_LIMIT;
+  private _cache: ReturnType<typeof cache<CacheData<AssetRecord[]>>>;
+
+  constructor(private readonly client: ApolloClient<any>) {
+    this._cache = cache<CacheData<AssetRecord[]>>({dbName: 'MaterializedAssets', maxCount: 1});
+    this.loadFromIndexedDB(); // Load from IndexedDB on construction, intentionally not awaited.
+  }
+
+  subscribe(callback: (assetsOrError: AssetRecord[] | PythonErrorFragment) => void) {
+    this._subscribers.add(callback);
+    this.startFetchLoop();
+
+    if (this._assetsOrError) {
+      callback(this._assetsOrError);
+    }
+
+    return () => {
+      this._subscribers.delete(callback);
+      if (!this._subscribers.size) {
+        this.stopFetchLoop();
+      }
+    };
+  }
+
+  private async loadFromIndexedDB() {
+    const result = await this._cache.get('data');
+    if (!result) {
+      return;
+    }
+    const {data, version} = result.value;
+    if (data && !this._assetsOrError && version === VERSION) {
+      this._assetsOrError = data;
+      this._subscribers.forEach((callback) => callback(data));
+    }
+  }
+
+  private saveToIndexedDB(data: AssetRecord[]) {
+    this._cache.set('data', {data, version: VERSION});
+  }
+
+  private startFetchLoop() {
+    if (this._started) {
+      return;
+    }
+    this._started = true;
+    this.fetchAssets();
+  }
+
+  private stopFetchLoop() {
+    this._started = false;
+    if (this._fetchTimeout) {
+      clearTimeout(this._fetchTimeout);
+      this._fetchTimeout = null;
+    }
+  }
+
+  public fetchAssets = async (pollInterval = POLL_INTERVAL) => {
+    if (this._fetchPromise) {
+      return this._fetchPromise;
+    }
+    let nextAssetsOrError: AssetRecord[] | PythonErrorFragment | null = null;
+    try {
+      this._fetchPromise = fetchAssets(this.client, this._batchLimit);
+      nextAssetsOrError = await this._fetchPromise;
+      this._assetsOrError = nextAssetsOrError;
+    } finally {
+      this._fetchPromise = null;
+    }
+
+    let nextPollInterval = POLL_INTERVAL;
+    if (nextAssetsOrError instanceof Array) {
+      setTimeout(() => {
+        this.saveToIndexedDB(nextAssetsOrError);
+      }, 32);
path: js_modules/dagster-ui/packages/ui-core/src/assets/useAllAssets.tsx
user: login: salazarm
body: Oh it's just 1 30fps frame away
created_at: 2025-04-28T22:02:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,56 @@
+import {DependencyList, useEffect, useRef, useState} from 'react';
+
+/**
+ * A custom hook that throttles the execution of an effect.
+ *
+ * @param {Function} callback - The effect callback to be throttled
+ * @param {Array} dependencies - Array of dependencies for the effect
+ * @param {number} delay - The minimum time (in ms) between effect executions
+ */
+export const useThrottledEffect = (
+  callback: () => void,
path: js_modules/dagster-ui/packages/ui-core/src/hooks/useThrottledEffect.tsx
user: login: salazarm
body: Good catch 😅 
created_at: 2025-04-28T22:03:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,325 @@
+import {useContext, useLayoutEffect, useMemo, useState} from 'react';
+
+import {ApolloClient, ApolloQueryResult, gql, useApolloClient} from '../apollo-client';
+import {PYTHON_ERROR_FRAGMENT} from '../app/PythonErrorFragment';
+import {PythonErrorFragment} from '../app/types/PythonErrorFragment.types';
+import {tokenForAssetKey} from '../asset-graph/Utils';
+import {AssetGroupSelector} from '../graphql/types';
+import {CacheData} from '../search/useIndexedDBCachedQuery';
+import {cache} from '../util/idb-lru-cache';
+import {weakMapMemoize} from '../util/weakMapMemoize';
+import {AssetTableDefinitionFragment} from './types/AssetTableFragment.types';
+import {AssetsStateQuery, AssetsStateQueryVariables} from './types/useAllAssets.types';
+import {WorkspaceContext} from '../workspace/WorkspaceContext/WorkspaceContext';
+import {DagsterRepoOption} from '../workspace/WorkspaceContext/util';
+
+export type AssetRecord = Extract<
+  AssetsStateQuery['assetRecordsOrError'],
+  {__typename: 'AssetRecordConnection'}
+>['assets'][0];
+
+const POLL_INTERVAL = 60000; // 1 minute
+const RETRY_INTERVAL = 1000; // 1 second
+
+const DEFAULT_BATCH_LIMIT = 10000;
+
+export function useAllAssets({
+  groupSelector,
+  batchLimit = DEFAULT_BATCH_LIMIT,
+}: {
+  groupSelector?: AssetGroupSelector;
+  batchLimit?: number;
+} = {}) {
+  const client = useApolloClient();
+  const [materializedAssets, setMaterializedAssets] = useState<AssetRecord[]>([]);
+  const manager = getFetchManager(client);
+
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState<PythonErrorFragment | null>(null);
+  useLayoutEffect(() => {
+    manager.setBatchLimit(batchLimit);
+    return manager.subscribe((assetsOrError) => {
+      if (assetsOrError instanceof Array) {
+        setMaterializedAssets(assetsOrError);
+      } else {
+        setError(assetsOrError);
+      }
+      setLoading(false);
+    });
+  }, [manager, batchLimit]);
path: js_modules/dagster-ui/packages/ui-core/src/assets/useAllAssets.tsx
user: login: graphite-app[bot]
body: The `useLayoutEffect` cleanup function correctly returns the unsubscribe function from the manager, but there's a potential race condition if the component unmounts during an in-flight fetch operation. Consider adding a mounted ref to prevent state updates after unmount:

```typescript
const isMountedRef = useRef(true);
useLayoutEffect(() => {
  manager.setBatchLimit(batchLimit);
  return () => {
    isMountedRef.current = false;
    return manager.subscribe((assetsOrError) => {
      if (!isMountedRef.current) return;
      // Rest of the callback...
    });
  };
}, [manager, batchLimit]);
```

This pattern helps avoid React warnings about state updates on unmounted components and prevents potential memory leaks.
```suggestion
useLayoutEffect(() => {
  const isMountedRef = { current: true };
  manager.setBatchLimit(batchLimit);
  const unsubscribe = manager.subscribe((assetsOrError) => {
    if (!isMountedRef.current) return;
    if (assetsOrError instanceof Array) {
      setMaterializedAssets(assetsOrError);
    } else {
      setError(assetsOrError);
    }
    setLoading(false);
  });
  
  return () => {
    isMountedRef.current = false;
    unsubscribe();
  };
}, [manager, batchLimit]);
```
  

*Spotted by [Diamond](https://app.graphite.dev/diamond/?org=dagster-io&ref=ai-review-comment)*<i class='graphite__hidden'><br /><br />Is this helpful? React 👍 or 👎 to let us know.</i>
created_at: 2025-04-29T08:27:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,75 @@
+import {DependencyList, useEffect, useRef, useState} from 'react';
+
+/**
+ * A custom hook that throttles the execution of an effect.
+ *
+ * @param {Function} callback - The effect callback to be throttled (can return a cleanup function)
+ * @param {Array} dependencies - Array of dependencies for the effect
+ * @param {number} delay - The minimum time (in ms) between effect executions
+ */
+export const useThrottledEffect = (
+  callback: () => void | (() => void),
+  dependencies: DependencyList | undefined,
+  delay: number,
+) => {
+  const lastRun = useRef(0);
+  const [throttledDeps, setThrottledDeps] = useState(dependencies);
+  const timeoutRef = useRef<NodeJS.Timeout | null>(null);
+  const cleanupFnRef = useRef<undefined | (() => void)>(undefined);
+
+  // Clear any pending timeouts on unmount
+  useEffect(() => {
+    return () => {
+      if (timeoutRef.current) {
+        clearTimeout(timeoutRef.current);
+      }
+    };
+  }, []);
+
+  // Check if dependencies changed
+  useEffect(() => {
+    const now = Date.now();
+    const elapsed = now - lastRun.current;
+
+    // If enough time has passed since last execution, run immediately
+    if (elapsed >= delay) {
+      lastRun.current = now;
+      setThrottledDeps(dependencies);
+    } else {
+      // Otherwise, schedule to run after the delay has passed
+      if (timeoutRef.current) {
+        clearTimeout(timeoutRef.current);
+      }
+
+      timeoutRef.current = setTimeout(() => {
+        lastRun.current = Date.now();
+        setThrottledDeps(dependencies);
+      }, delay - elapsed);
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, dependencies);
path: js_modules/dagster-ui/packages/ui-core/src/hooks/useThrottledEffect.tsx
user: login: graphite-app[bot]
body: There's a potential issue with the dependencies effect where a memory leak could occur. If the component unmounts while a timeout is pending, the timeout callback might still execute and call `setThrottledDeps` on an unmounted component, which can trigger React warnings about state updates on unmounted components.

While the cleanup function properly clears the timeout, it doesn't address the case where the timeout has already fired but the callback hasn't yet executed. Consider using a mounted ref to track component lifecycle:

```js
const isMountedRef = useRef(true);

useEffect(() => {
  return () => {
    isMountedRef.current = false;
  };
}, []);
```

Then in the timeout callback:

```js
timeoutRef.current = setTimeout(() => {
  if (isMountedRef.current) {
    lastRun.current = Date.now();
    setThrottledDeps(dependencies);
  }
}, delay - elapsed);
```

This ensures state updates only occur when the component is still mounted.
```suggestion
  // Check if dependencies changed
  const isMountedRef = useRef(true);

  useEffect(() => {
    return () => {
      isMountedRef.current = false;
    };
  }, []);

  useEffect(() => {
    const now = Date.now();
    const elapsed = now - lastRun.current;

    // If enough time has passed since last execution, run immediately
    if (elapsed >= delay) {
      lastRun.current = now;
      setThrottledDeps(dependencies);
    } else {
      // Otherwise, schedule to run after the delay has passed
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }

      timeoutRef.current = setTimeout(() => {
        if (isMountedRef.current) {
          lastRun.current = Date.now();
          setThrottledDeps(dependencies);
        }
      }, delay - elapsed);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, dependencies);
```
  

*Spotted by [Diamond](https://app.graphite.dev/diamond/?org=dagster-io&ref=ai-review-comment)*<i class='graphite__hidden'><br /><br />Is this helpful? React 👍 or 👎 to let us know.</i>
created_at: 2025-04-29T08:31:26Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-28T13:33:00Z
body: Deploy preview for _dagit-core-storybook_ ready!

✅ Preview
https://dagit-core-storybook-gpvupq2tp-elementl.vercel.app
https://salazarm-asset-fetching.core-storybook.dagster-docs.io

Built with commit 621597e4c6e84d793ce1707cfc5728d244ad3f06.
This pull request is being automatically deployed with [vercel-action](https://github.com/marketplace/actions/vercel-action)
author_association: CONTRIBUTOR
user: login: alangenfeld
created_at: 2025-04-28T21:49:58Z
body: > I still need to check the case of when an asset is defined in multiple code locations and re-create the tie breaking strategy we use on the backend.

We should definitely lock this down before landing this. 


author_association: CONTRIBUTOR
user: login: salazarm
created_at: 2025-04-29T08:43:42Z
body: @alangenfeld added that logic on the frontend. For now I just hide all of the other assets to recreate what the backend did but in the future we can definitely expose them somehow
author_association: CONTRIBUTOR