From dad7bd6d12ae9fa76de583131bbc0dfd181503c9 Mon Sep 17 00:00:00 2001 From: Jonathan LoBue Date: Fri, 21 Aug 2026 09:37:57 -0700 Subject: [PATCH 1/6] Expose the cache's persisted state per URL to the renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CacheManager records the outcome of every download in a `-info` sidecar next to the cached file, but the renderer could only reach that state by asking for the content itself, which starts a download for anything not yet cached. A new read-only `getCacheInfos(urls)` IPC returns the persisted CacheInfo for a batch of URLs — CACHED with its checksum, the persisted ERROR, or NOT_CACHED for a URL never requested — without ever fetching. A URL the cache cannot key at all is reported as an ERROR entry for that URL instead of failing the whole batch. This lets the renderer classify NFTs it has not rendered from what earlier visits and sessions already learned about their files. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01YKEGodEgVdEuvUM8dza52q --- packages/gui/src/@types/CacheService.ts | 4 ++ .../gui/src/electron/CacheManager.test.ts | 54 +++++++++++++++++++ packages/gui/src/electron/CacheManager.ts | 24 +++++++++ .../gui/src/electron/constants/CacheAPI.ts | 1 + packages/gui/src/electron/preload.ts | 1 + 5 files changed, 84 insertions(+) diff --git a/packages/gui/src/@types/CacheService.ts b/packages/gui/src/@types/CacheService.ts index ad03ce30a9..3ccdc4883a 100644 --- a/packages/gui/src/@types/CacheService.ts +++ b/packages/gui/src/@types/CacheService.ts @@ -1,3 +1,5 @@ +import type CacheInfo from './CacheInfo'; + type CacheRequestOptions = { maxSize?: number; timeout?: number; @@ -22,6 +24,8 @@ type CacheService = { getChecksum: (url: string, options?: CacheRequestOptions) => Promise; getURI: (url: string, options?: CacheRequestOptions) => Promise; invalidate: (url: string) => Promise; + // Read-only lookup of the persisted cache state of each url — never downloads + getCacheInfos: (urls: string[]) => Promise; // Event subscriptions subscribeToDirectoryChange: (callback: (newDirectory: string) => void) => () => void; diff --git a/packages/gui/src/electron/CacheManager.test.ts b/packages/gui/src/electron/CacheManager.test.ts index 00a5371b25..48046d141a 100644 --- a/packages/gui/src/electron/CacheManager.test.ts +++ b/packages/gui/src/electron/CacheManager.test.ts @@ -219,3 +219,57 @@ describe('CacheManager eviction', () => { expect(mockDownloadFile).toHaveBeenCalledTimes(1); }); }); + +describe('CacheManager getCacheInfos', () => { + let cacheDirectory: string; + + beforeEach(async () => { + mockDownloadFile.mockReset(); + cacheDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'chia-cache-manager-')); + }); + + afterEach(async () => { + await fs.rm(cacheDirectory, { recursive: true, force: true }); + }); + + it('reports persisted outcomes per url without downloading anything', async () => { + const payload = Buffer.from('cached payload'); + mockDownloadFile.mockImplementation(async (url, localPath) => { + if (url === 'https://example.com/broken.png') { + throw new Error('getaddrinfo ENOTFOUND example.com'); + } + await fs.writeFile(localPath, payload); + return { + 'content-type': 'image/png', + }; + }); + + const cacheManager = new CacheManager({ + cacheDirectory, + maxCacheSize: 1024, + }); + await cacheManager.init(); + + await expect(cacheManager.getContent('https://example.com/ok.png')).resolves.toEqual(payload); + await expect(cacheManager.getContent('https://example.com/broken.png')).rejects.toThrow('ENOTFOUND'); + mockDownloadFile.mockClear(); + + const infos = await cacheManager.getCacheInfos([ + 'https://example.com/ok.png', + 'https://example.com/broken.png', + 'https://example.com/never-requested.png', + 'not a url', + ]); + + expect(infos.map((info) => [info.url, info.state])).toEqual([ + ['https://example.com/ok.png', 'CACHED'], + ['https://example.com/broken.png', 'ERROR'], + ['https://example.com/never-requested.png', 'NOT_CACHED'], + ['not a url', 'ERROR'], + ]); + expect(infos[0]).toMatchObject({ checksum: expect.any(String) }); + expect(infos[1]).toMatchObject({ error: 'getaddrinfo ENOTFOUND example.com' }); + expect(infos[3]).toMatchObject({ error: 'Invalid URL: not a url' }); + expect(mockDownloadFile).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/gui/src/electron/CacheManager.ts b/packages/gui/src/electron/CacheManager.ts index 745cd4899d..abe302d413 100644 --- a/packages/gui/src/electron/CacheManager.ts +++ b/packages/gui/src/electron/CacheManager.ts @@ -235,6 +235,7 @@ export default class CacheManager extends EventEmitter { this.getURI(url, options), ); ipcMainHandle(CacheAPI.INVALIDATE, (url: string) => this.invalidate(url)); + ipcMainHandle(CacheAPI.GET_CACHE_INFOS, (urls: string[]) => this.getCacheInfos(urls)); ipcMainHandle(CacheAPI.GET_CACHE_DIRECTORY, () => this.cacheDirectory); ipcMainHandle(CacheAPI.GET_MAX_CACHE_SIZE, () => this.maxCacheSize); @@ -642,6 +643,29 @@ export default class CacheManager extends EventEmitter { throw new Error('Unknown cache state'); } + // Reports what the cache already knows about each url without fetching + // anything: a download that never happened stays NOT_CACHED, and a url the + // cache cannot key at all is reported as an error instead of failing the + // whole batch. This lets the renderer classify NFTs that are not on screen + // (and so never verify their files) from outcomes persisted by earlier + // visits and sessions. + async getCacheInfos(urls: string[]): Promise { + return Promise.all( + urls.map(async (url) => { + try { + return await this.getCacheInfoByURL(url); + } catch (error) { + return { + url, + state: CacheState.ERROR, + error: (error as Error).message, + timestamp: Date.now(), + }; + } + }), + ); + } + async clearCache() { // cancel all ongoing requests for (const ongoingRequest of this.ongoingRequests.values()) { diff --git a/packages/gui/src/electron/constants/CacheAPI.ts b/packages/gui/src/electron/constants/CacheAPI.ts index 7d81a804e1..9a25425745 100644 --- a/packages/gui/src/electron/constants/CacheAPI.ts +++ b/packages/gui/src/electron/constants/CacheAPI.ts @@ -18,6 +18,7 @@ enum CacheAPI { GET_HEADERS = `${API.CACHE}:getHeaders`, GET_CHECKSUM = `${API.CACHE}:getChecksum`, GET_URI = `${API.CACHE}:getUri`, + GET_CACHE_INFOS = `${API.CACHE}:getCacheInfos`, INVALIDATE = `${API.CACHE}:invalidate`, // Event subscriptions diff --git a/packages/gui/src/electron/preload.ts b/packages/gui/src/electron/preload.ts index fbff3191ba..b7a61fb4ff 100644 --- a/packages/gui/src/electron/preload.ts +++ b/packages/gui/src/electron/preload.ts @@ -132,6 +132,7 @@ contextBridge.exposeInMainWorld(API.CACHE, { getURI: (url: string, options?: { maxSize?: number; timeout?: number }) => invokeWithCustomErrors(CacheAPI.GET_URI, url, options), invalidate: (url: string) => invokeWithCustomErrors(CacheAPI.INVALIDATE, url), + getCacheInfos: (urls: string[]) => invokeWithCustomErrors(CacheAPI.GET_CACHE_INFOS, urls), subscribeToDirectoryChange: (callback: (...args: unknown[]) => void) => onIpcEvent(CacheAPI.ON_CACHE_DIRECTORY_CHANGED, callback), subscribeToMaxSizeChange: (callback: (...args: unknown[]) => void) => From ef639963004426740b9715d5b76814dda50bd79a Mon Sep 17 00:00:00 2001 From: Jonathan LoBue Date: Fri, 21 Aug 2026 09:38:02 -0700 Subject: [PATCH 2/6] Add a preview-availability filter to the NFT gallery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NFTs whose media cannot be shown — a dead host, a file that no longer matches its on-chain hash, or no file at all — render a placeholder tile but could not be singled out, so finding the broken ones in a large collection meant scrolling past every healthy one. The gallery's filter bar gains a third pill next to the Types and Visible/Hidden ones, with "Preview available" / "Preview not available" checkboxes and counts that follow the same four-state model as the visibility pill. The status behind the filter comes from two sources, both kept in a per-NFT store inside NFTProvider: - Tiles report what they actually settled on showing. The decision is derived from the same booleans NFTPreview renders from, so the filter classifies an NFT exactly as its tile does. Only preview-mode tiles report; the detail view verifies the full data file rather than the thumbnail and can legitimately disagree. - The gallery is virtualized, so most NFTs never mount. Those are classified from the cache's persisted outcomes via the new `getCacheInfos` IPC, mirroring the URI walk `useNFTVerifyHash` performs: a cached file matching the hash makes the preview available, and it is unavailable only once every URI has a settled failure. A URI the cache has never seen (or failed only transiently) leaves the NFT undecided, and undecided NFTs count as available — a tile would still attempt the download. Lookups run in batches of 200 URLs, once per NFT per session; a live report always wins over a lookup. Invalidating an NFT clears its verdict so the refreshed tile reports anew. Filtering and the statistics counts re-run as verdicts arrive, so with "Preview not available" selected the gallery converges on the broken NFTs as tiles settle. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01YKEGodEgVdEuvUM8dza52q --- .../gui/src/@types/NFTPreviewAvailability.ts | 8 + packages/gui/src/@types/NFTPreviewStatus.ts | 9 + packages/gui/src/@types/NFTsDataStatistics.ts | 5 +- .../src/components/nfts/NFTFilterProvider.tsx | 19 +- .../gui/src/components/nfts/NFTPreview.tsx | 37 ++++ .../components/nfts/gallery/NFTGallery.tsx | 114 +++++++++++ .../components/nfts/provider/NFTProvider.tsx | 28 ++- .../nfts/provider/NFTProviderContext.ts | 5 + .../provider/hooks/useNFTPreviewStatuses.ts | 178 ++++++++++++++++++ packages/gui/src/hooks/useFilteredNFTs.ts | 3 +- packages/gui/src/hooks/useNFTs.ts | 49 ++++- .../util/getNFTPreviewStatusFromCache.test.ts | 75 ++++++++ .../src/util/getNFTPreviewStatusFromCache.ts | 57 ++++++ .../gui/src/util/getNFTsDataStatistics.ts | 5 + 14 files changed, 586 insertions(+), 6 deletions(-) create mode 100644 packages/gui/src/@types/NFTPreviewAvailability.ts create mode 100644 packages/gui/src/@types/NFTPreviewStatus.ts create mode 100644 packages/gui/src/components/nfts/provider/hooks/useNFTPreviewStatuses.ts create mode 100644 packages/gui/src/util/getNFTPreviewStatusFromCache.test.ts create mode 100644 packages/gui/src/util/getNFTPreviewStatusFromCache.ts diff --git a/packages/gui/src/@types/NFTPreviewAvailability.ts b/packages/gui/src/@types/NFTPreviewAvailability.ts new file mode 100644 index 0000000000..9ad7f8b3a9 --- /dev/null +++ b/packages/gui/src/@types/NFTPreviewAvailability.ts @@ -0,0 +1,8 @@ +enum NFTPreviewAvailability { + AVAILABLE = 'available', + UNAVAILABLE = 'unavailable', + ALL = 'all', + NONE = 'none', +} + +export default NFTPreviewAvailability; diff --git a/packages/gui/src/@types/NFTPreviewStatus.ts b/packages/gui/src/@types/NFTPreviewStatus.ts new file mode 100644 index 0000000000..5e17ec4cae --- /dev/null +++ b/packages/gui/src/@types/NFTPreviewStatus.ts @@ -0,0 +1,9 @@ +// Whether an NFT's gallery tile can show its media. UNAVAILABLE covers every +// placeholder a tile renders instead of content: no file to verify against, a +// file that failed to download, and a file that does not match its hash. +enum NFTPreviewStatus { + AVAILABLE = 'available', + UNAVAILABLE = 'unavailable', +} + +export default NFTPreviewStatus; diff --git a/packages/gui/src/@types/NFTsDataStatistics.ts b/packages/gui/src/@types/NFTsDataStatistics.ts index be03dd1483..66d392ed48 100644 --- a/packages/gui/src/@types/NFTsDataStatistics.ts +++ b/packages/gui/src/@types/NFTsDataStatistics.ts @@ -1,5 +1,8 @@ import type FileType from '../constants/FileType'; -type NFTsDataStatistics = Record; +type NFTsDataStatistics = Record< + FileType | 'visible' | 'hidden' | 'total' | 'sensitive' | 'previewAvailable' | 'previewUnavailable', + number +>; export default NFTsDataStatistics; diff --git a/packages/gui/src/components/nfts/NFTFilterProvider.tsx b/packages/gui/src/components/nfts/NFTFilterProvider.tsx index 1c59d33706..18859182b3 100644 --- a/packages/gui/src/components/nfts/NFTFilterProvider.tsx +++ b/packages/gui/src/components/nfts/NFTFilterProvider.tsx @@ -1,5 +1,6 @@ import React, { createContext, useMemo, useState, type ReactNode } from 'react'; +import NFTPreviewAvailability from '../../@types/NFTPreviewAvailability'; import NFTVisibility from '../../@types/NFTVisibility'; import FileType from '../../constants/FileType'; @@ -7,11 +8,13 @@ export interface NFTFilterContextData { walletIds: number[]; types: FileType[]; visibility: NFTVisibility; + previewAvailability: NFTPreviewAvailability; search: string | undefined; setWalletIds: (value: number[]) => void; setTypes: (value: FileType[]) => void; setVisibility: (value: NFTVisibility) => void; + setPreviewAvailability: (value: NFTPreviewAvailability) => void; setSearch: (value: string | undefined) => void; } @@ -34,6 +37,7 @@ export default function NFTFilterProvider(props: NFTFilterProviderProps) { FileType.UNKNOWN, ]); const [visibility, setVisibility] = useState(NFTVisibility.ALL); + const [previewAvailability, setPreviewAvailability] = useState(NFTPreviewAvailability.ALL); const [search, setSearch] = useState(''); const value = useMemo( @@ -41,14 +45,27 @@ export default function NFTFilterProvider(props: NFTFilterProviderProps) { walletIds, types, visibility, + previewAvailability, search, setWalletIds, setTypes, setVisibility, + setPreviewAvailability, setSearch, }), - [walletIds, types, visibility, search, setWalletIds, setTypes, setVisibility, setSearch], + [ + walletIds, + types, + visibility, + previewAvailability, + search, + setWalletIds, + setTypes, + setVisibility, + setPreviewAvailability, + setSearch, + ], ); return {children}; diff --git a/packages/gui/src/components/nfts/NFTPreview.tsx b/packages/gui/src/components/nfts/NFTPreview.tsx index 96e715cf0c..01b3c5b0e3 100644 --- a/packages/gui/src/components/nfts/NFTPreview.tsx +++ b/packages/gui/src/components/nfts/NFTPreview.tsx @@ -5,6 +5,7 @@ import { alpha, Box, IconButton, Tooltip } from '@mui/material'; import React, { useMemo, useRef, Fragment, useCallback, useEffect, type ReactNode } from 'react'; import styled from 'styled-components'; +import NFTPreviewStatus from '../../@types/NFTPreviewStatus'; import AudioSmallIcon from '../../assets/img/audio-small.svg'; import DocumentBlobIcon from '../../assets/img/document-blob.svg'; import DocumentSmallIcon from '../../assets/img/document-small.svg'; @@ -30,6 +31,7 @@ import useHideObjectionableContent from '../../hooks/useHideObjectionableContent import useNFT from '../../hooks/useNFT'; import useNFTImageFittingMode from '../../hooks/useNFTImageFittingMode'; import useNFTMetadata from '../../hooks/useNFTMetadata'; +import useNFTProvider from '../../hooks/useNFTProvider'; import useNFTVerifyHash from '../../hooks/useNFTVerifyHash'; import { useNFTVideoLoopGlobal, useNFTVideoLoopForNFT } from '../../hooks/useNFTVideoLoop'; import useStateAbort from '../../hooks/useStateAbort'; @@ -139,6 +141,7 @@ export default function NFTPreview(props: NFTPreviewProps) { } = props; const { getURI } = useCache(); + const { setPreviewStatus } = useNFTProvider(); const nftId = useMemo(() => getNFTId(id), [id]); const iframeRef = useRef(null); const { isDarkMode } = useDarkMode(); @@ -513,6 +516,40 @@ export default function NFTPreview(props: NFTPreviewProps) { !icon && ![FileType.MODEL, FileType.DOCUMENT].includes(previewFileType); + // What the tile settled on showing — the same decision the render below + // makes — so the gallery's preview filter classifies NFTs exactly as their + // tiles do. Undefined while the tile is still loading. + const previewStatus = useMemo(() => { + if (isLoading) { + return undefined; + } + + if (!hasFile || isHashMismatch) { + return NFTPreviewStatus.UNAVAILABLE; + } + + if (usesIframe) { + if (prepareError) { + return NFTPreviewStatus.UNAVAILABLE; + } + + if (!previewContent) { + return undefined; + } + } + + return NFTPreviewStatus.AVAILABLE; + }, [isLoading, hasFile, isHashMismatch, usesIframe, prepareError, previewContent]); + + useEffect(() => { + // Only preview-mode tiles report: the detail view verifies the full data + // file rather than the thumbnail and can legitimately disagree with the + // gallery tile for the same NFT. + if (isPreview && previewStatus) { + setPreviewStatus(nftId, previewStatus); + } + }, [isPreview, previewStatus, nftId, setPreviewStatus]); + return ( {isLoading ? ( diff --git a/packages/gui/src/components/nfts/gallery/NFTGallery.tsx b/packages/gui/src/components/nfts/gallery/NFTGallery.tsx index ad30c71ed8..6795f595aa 100644 --- a/packages/gui/src/components/nfts/gallery/NFTGallery.tsx +++ b/packages/gui/src/components/nfts/gallery/NFTGallery.tsx @@ -35,6 +35,7 @@ import { xor, intersection /* , sortBy */ } from 'lodash'; import React, { useMemo, useCallback, useRef, useEffect } from 'react'; import { VirtuosoGrid } from 'react-virtuoso'; +import NFTPreviewAvailability from '../../../@types/NFTPreviewAvailability'; import NFTVisibility from '../../../@types/NFTVisibility'; import FileType from '../../../constants/FileType'; import useFilteredNFTs from '../../../hooks/useFilteredNFTs'; @@ -111,6 +112,9 @@ export default function NFTGallery() { visibility, setVisibility, + previewAvailability, + setPreviewAvailability, + statistics, } = useFilteredNFTs(); @@ -245,6 +249,40 @@ export default function NFTGallery() { } } + function togglePreviewAvailable() { + switch (previewAvailability) { + case NFTPreviewAvailability.ALL: + setPreviewAvailability(NFTPreviewAvailability.UNAVAILABLE); + return; + case NFTPreviewAvailability.AVAILABLE: + setPreviewAvailability(NFTPreviewAvailability.NONE); + return; + case NFTPreviewAvailability.NONE: + setPreviewAvailability(NFTPreviewAvailability.AVAILABLE); + return; + case NFTPreviewAvailability.UNAVAILABLE: + default: + setPreviewAvailability(NFTPreviewAvailability.ALL); + } + } + + function togglePreviewUnavailable() { + switch (previewAvailability) { + case NFTPreviewAvailability.ALL: + setPreviewAvailability(NFTPreviewAvailability.AVAILABLE); + return; + case NFTPreviewAvailability.AVAILABLE: + setPreviewAvailability(NFTPreviewAvailability.ALL); + return; + case NFTPreviewAvailability.NONE: + setPreviewAvailability(NFTPreviewAvailability.UNAVAILABLE); + return; + case NFTPreviewAvailability.UNAVAILABLE: + default: + setPreviewAvailability(NFTPreviewAvailability.NONE); + } + } + function renderNFTCard(index: number, nft: NFTInfo) { return ( + + + + Any preview   + } size="extraSmall" /> + + ) : previewAvailability === NFTPreviewAvailability.AVAILABLE ? ( + + Preview available   + } size="extraSmall" /> + + ) : previewAvailability === NFTPreviewAvailability.UNAVAILABLE ? ( + + Preview not available   + } + size="extraSmall" + /> + + ) : ( + None (0) + ) + } + > + + + + } + label={ + + + Preview available + + } + size="extraSmall" + /> + + } + /> + + } + label={ + + + Preview not available + + } + size="extraSmall" + /> + + } + /> + + + + + diff --git a/packages/gui/src/components/nfts/provider/NFTProvider.tsx b/packages/gui/src/components/nfts/provider/NFTProvider.tsx index 40beb5faef..471fb381bb 100644 --- a/packages/gui/src/components/nfts/provider/NFTProvider.tsx +++ b/packages/gui/src/components/nfts/provider/NFTProvider.tsx @@ -9,6 +9,7 @@ import useMetadataData from './hooks/useMetadataData'; import useNFTData from './hooks/useNFTData'; import useNFTDataNachos from './hooks/useNFTDataNachos'; import useNFTDataOnDemand from './hooks/useNFTDataOnDemand'; +import useNFTPreviewStatuses from './hooks/useNFTPreviewStatuses'; const log = debug('nft:NFTProvider'); @@ -131,6 +132,13 @@ export default function NFTProvider(props: NFTProviderProps) { [subscribeToDataChanges, subscribeToNachosChanges], ); + const { getPreviewStatus, setPreviewStatus, invalidatePreviewStatus, subscribeToPreviewStatusChanges } = + useNFTPreviewStatuses({ + nfts, + nachos, + subscribeToChanges, + }); + const invalidateNFT = useCallback( async (id: string | undefined) => { log(`Invalidating ${id}`); @@ -143,6 +151,9 @@ export default function NFTProvider(props: NFTProviderProps) { return; } + // the files are about to be re-fetched, so the preview verdict is stale + invalidatePreviewStatus(id); + // invalidate nft files const promises = []; const { dataUris, metadataUris } = nft; @@ -177,7 +188,15 @@ export default function NFTProvider(props: NFTProviderProps) { await Promise.all([invalidateNachos(), invalidateMetadata(id), invalidateNFTOnDemand(id)]); }, - [fetchNFT, fetchMetadata, invalidate, invalidateNachos, invalidateMetadata, invalidateNFTOnDemand], + [ + fetchNFT, + fetchMetadata, + invalidate, + invalidateNachos, + invalidateMetadata, + invalidateNFTOnDemand, + invalidatePreviewStatus, + ], ); const context = useMemo( @@ -192,6 +211,10 @@ export default function NFTProvider(props: NFTProviderProps) { getMetadata, subscribeToMetadataChanges, + getPreviewStatus, + setPreviewStatus, + subscribeToPreviewStatusChanges, + subscribeToChanges, invalidate: invalidateNFT, @@ -214,6 +237,9 @@ export default function NFTProvider(props: NFTProviderProps) { subscribeToNFTChanges, getMetadata, subscribeToMetadataChanges, + getPreviewStatus, + setPreviewStatus, + subscribeToPreviewStatusChanges, count, loaded, progress, diff --git a/packages/gui/src/components/nfts/provider/NFTProviderContext.ts b/packages/gui/src/components/nfts/provider/NFTProviderContext.ts index 120e9f767c..68ab12797d 100644 --- a/packages/gui/src/components/nfts/provider/NFTProviderContext.ts +++ b/packages/gui/src/components/nfts/provider/NFTProviderContext.ts @@ -2,6 +2,7 @@ import { type NFTInfo } from '@chia-network/api'; import { createContext } from 'react'; import type MetadataState from '../../../@types/MetadataState'; +import type NFTPreviewStatus from '../../../@types/NFTPreviewStatus'; import type NFTState from '../../../@types/NFTState'; const NFTProviderContext = createContext< @@ -29,6 +30,10 @@ const NFTProviderContext = createContext< id: string | undefined, callback: (metadataState: MetadataState) => void, ) => () => void; + + getPreviewStatus: (id: string | undefined) => NFTPreviewStatus | undefined; + setPreviewStatus: (id: string, status: NFTPreviewStatus) => void; + subscribeToPreviewStatusChanges: (callback: () => void) => () => void; } | undefined >(undefined); diff --git a/packages/gui/src/components/nfts/provider/hooks/useNFTPreviewStatuses.ts b/packages/gui/src/components/nfts/provider/hooks/useNFTPreviewStatuses.ts new file mode 100644 index 0000000000..9b0defb6ed --- /dev/null +++ b/packages/gui/src/components/nfts/provider/hooks/useNFTPreviewStatuses.ts @@ -0,0 +1,178 @@ +import { EventEmitter } from 'events'; + +import { type NFTInfo } from '@chia-network/api'; +import debug from 'debug'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import type NFTPreviewStatus from '../../../../@types/NFTPreviewStatus'; +import useCache from '../../../../hooks/useCache'; +import getNFTPreviewStatusFromCache from '../../../../util/getNFTPreviewStatusFromCache'; + +const log = debug('chia-gui:NFTProvider:useNFTPreviewStatuses'); + +// Cache infos are looked up in batches so a large collection does not hand +// the main process thousands of file reads in one IPC call. +const LOOKUP_BATCH_SIZE = 200; + +type UseNFTPreviewStatusesProps = { + nfts: Map; // should be immutable + nachos: Map; // should be immutable + subscribeToChanges: (callback: () => void) => () => void; // should be immutable +}; + +// warning: only used by NFTProvider +// +// Tracks, per NFT, whether its gallery tile can show a preview. Tiles report +// what they actually render once they settle; NFTs that are not on screen +// (the gallery is virtualized, so most never mount) are classified from the +// outcomes the cache persisted during earlier visits and sessions, without +// downloading anything. A live report always wins over a cache lookup. +export default function useNFTPreviewStatuses(props: UseNFTPreviewStatusesProps) { + const { nfts, nachos, subscribeToChanges } = props; + + const { getCacheInfos } = useCache(); + + const [statuses /* immutable */] = useState(() => new Map()); + // NFTs whose cached outcomes were already looked up. An NFT that never + // reaches the screen never changes its cache state, so one lookup per + // session is enough; a tile that does mount reports live instead. + const [lookedUp /* immutable */] = useState(() => new Set()); + + const events = useMemo(() => { + const eventEmitter = new EventEmitter(); + eventEmitter.setMaxListeners(Infinity); + return eventEmitter; + }, []); + + // immutable function + const getPreviewStatus = useCallback( + (nftId: string | undefined): NFTPreviewStatus | undefined => (nftId ? statuses.get(nftId) : undefined), + [statuses /* immutable */], + ); + + // immutable function + const setPreviewStatus = useCallback( + (nftId: string, status: NFTPreviewStatus) => { + lookedUp.add(nftId); + + if (statuses.get(nftId) === status) { + return; + } + + statuses.set(nftId, status); + events.emit('changed'); + }, + [events /* immutable */, statuses /* immutable */, lookedUp /* immutable */], + ); + + // immutable function + const invalidatePreviewStatus = useCallback( + (nftId: string) => { + lookedUp.delete(nftId); + + if (statuses.delete(nftId)) { + events.emit('changed'); + } + }, + [events /* immutable */, statuses /* immutable */, lookedUp /* immutable */], + ); + + // immutable function + const subscribeToPreviewStatusChanges = useCallback( + (callback: () => void) => { + events.on('changed', callback); + + return () => { + events.off('changed', callback); + }; + }, + [events /* immutable */], + ); + + const isLookingUpRef = useRef(false); + const lookUpAgainRef = useRef(false); + + // Classifies every NFT not yet looked up from the cache's persisted state. + // Runs serialized: NFT pages arrive in bursts, and a run that finds the + // flag set simply sweeps once more when it finishes. + const lookUpFromCache = useCallback(async () => { + if (isLookingUpRef.current) { + lookUpAgainRef.current = true; + return; + } + + isLookingUpRef.current = true; + try { + do { + lookUpAgainRef.current = false; + + const pending: [string, NFTInfo][] = []; + const collect = (nft: NFTInfo, nftId: string) => { + if (!lookedUp.has(nftId)) { + pending.push([nftId, nft]); + } + }; + + nfts.forEach(collect); + nachos.forEach((nft, nftId) => { + if (!nfts.has(nftId)) { + collect(nft, nftId); + } + }); + + for (let start = 0; start < pending.length; start += LOOKUP_BATCH_SIZE) { + const batch = pending.slice(start, start + LOOKUP_BATCH_SIZE); + const urls = Array.from(new Set(batch.flatMap(([, nft]) => nft.dataUris ?? []))); + + // eslint-disable-next-line no-await-in-loop -- batches are sequential on purpose, to pace the main process + const cacheInfos = urls.length ? await getCacheInfos(urls) : []; + const cacheInfoByUrl = new Map(cacheInfos.map((cacheInfo) => [cacheInfo.url, cacheInfo])); + + let changed = false; + batch.forEach(([nftId, nft]) => { + if (lookedUp.has(nftId)) { + // a tile reported live while the lookup was in flight + return; + } + + lookedUp.add(nftId); + + const status = getNFTPreviewStatusFromCache(nft, (url) => cacheInfoByUrl.get(url)); + if (status) { + statuses.set(nftId, status); + changed = true; + } + }); + + if (changed) { + events.emit('changed'); + } + } + } while (lookUpAgainRef.current); + } catch (e) { + log(`Error looking up preview statuses from the cache: ${(e as Error).message}`); + } finally { + isLookingUpRef.current = false; + } + }, [ + nfts /* immutable */, + nachos /* immutable */, + getCacheInfos /* immutable */, + statuses /* immutable */, + lookedUp /* immutable */, + events /* immutable */, + ]); + + useEffect(() => { + lookUpFromCache(); + + return subscribeToChanges(lookUpFromCache); + }, [lookUpFromCache, subscribeToChanges]); + + return { + getPreviewStatus, // immutable + setPreviewStatus, // immutable + invalidatePreviewStatus, // immutable + subscribeToPreviewStatusChanges, // immutable + } as const; +} diff --git a/packages/gui/src/hooks/useFilteredNFTs.ts b/packages/gui/src/hooks/useFilteredNFTs.ts index 730b033ec7..ec0f5d0b01 100644 --- a/packages/gui/src/hooks/useFilteredNFTs.ts +++ b/packages/gui/src/hooks/useFilteredNFTs.ts @@ -6,12 +6,13 @@ export default function useFilteredNFTs() { const filter = useNFTFilter(); const [hideSensitiveContent, setHideSensitiveContent] = useHideObjectionableContent(); - const { search, visibility, types, walletIds } = filter; + const { search, visibility, previewAvailability, types, walletIds } = filter; const nftsResult = useNFTs({ // filter props search, visibility, + previewAvailability, types, walletIds, diff --git a/packages/gui/src/hooks/useNFTs.ts b/packages/gui/src/hooks/useNFTs.ts index 60dfacec8b..dd91565d23 100644 --- a/packages/gui/src/hooks/useNFTs.ts +++ b/packages/gui/src/hooks/useNFTs.ts @@ -4,6 +4,8 @@ import { useMemo, useEffect, useState, useCallback } from 'react'; import type Metadata from '../@types/Metadata'; import MetadataState from '../@types/MetadataState'; +import NFTPreviewAvailability from '../@types/NFTPreviewAvailability'; +import NFTPreviewStatus from '../@types/NFTPreviewStatus'; import NFTVisibility from '../@types/NFTVisibility'; import NFTsDataStatistics from '../@types/NFTsDataStatistics'; import FileType from '../constants/FileType'; @@ -24,9 +26,11 @@ const prepareNFTs = throttle( nfts: Map, nachos: Map, getMetadata: (id: string) => MetadataState, + getPreviewStatus: (nftId: string) => NFTPreviewStatus | undefined, walletIds: number[], isHidden: (nftId: string) => boolean, visibility: NFTVisibility, + previewAvailability: NFTPreviewAvailability, types: FileType[], search: string, onReponse: (filtered: NFTInfo[], statistics: NFTsDataStatistics) => void, @@ -42,6 +46,8 @@ const prepareNFTs = throttle( hidden: 0, total: 0, sensitive: 0, + previewAvailable: 0, + previewUnavailable: 0, }; const filtered: NFTInfo[] = []; @@ -80,6 +86,15 @@ const prepareNFTs = throttle( stats.sensitive += 1; } + // Only a settled verdict places an NFT among the unavailable previews; + // one that has not been classified yet counts as available. + const isPreviewUnavailable = getPreviewStatus(nftId) === NFTPreviewStatus.UNAVAILABLE; + if (isPreviewUnavailable) { + stats.previewUnavailable += 1; + } else { + stats.previewAvailable += 1; + } + stats.total += 1; // process filtering @@ -95,6 +110,14 @@ const prepareNFTs = throttle( return; } + const previewMatches = + previewAvailability === NFTPreviewAvailability.ALL || + (previewAvailability === NFTPreviewAvailability.AVAILABLE && !isPreviewUnavailable) || + (previewAvailability === NFTPreviewAvailability.UNAVAILABLE && isPreviewUnavailable); + if (!previewMatches) { + return; + } + if (!type || !types.includes(type)) { return; } @@ -135,6 +158,7 @@ export type UseNFTsProps = { search?: string; types?: FileType[]; visibility?: NFTVisibility; + previewAvailability?: NFTPreviewAvailability; hideSensitiveContent?: boolean | 'false' | 'true'; }; @@ -147,11 +171,23 @@ export default function useNFTs(props: UseNFTsProps = {}) { types = allTypes, search = '', visibility = NFTVisibility.ALL, + previewAvailability = NFTPreviewAvailability.ALL, // hideSensitiveContent = false, } = props; - const { nfts, nachos, getMetadata, isLoading, error, progress, invalidate, count, subscribeToChanges } = - useNFTProvider(); + const { + nfts, + nachos, + getMetadata, + getPreviewStatus, + subscribeToPreviewStatusChanges, + isLoading, + error, + progress, + invalidate, + count, + subscribeToChanges, + } = useNFTProvider(); const [isNFTHidden] = useHiddenNFTs(); const total = useMemo(() => count + nachos.size, [count, nachos.size]); @@ -168,6 +204,8 @@ export default function useNFTs(props: UseNFTsProps = {}) { hidden: 0, total: 0, sensitive: 0, + previewAvailable: 0, + previewUnavailable: 0, }); const updateFiltered = useCallback(() => { @@ -176,9 +214,11 @@ export default function useNFTs(props: UseNFTsProps = {}) { nfts, nachos, getMetadata, + getPreviewStatus, walletIds, isNFTHidden, visibility, + previewAvailability, types, search, (newFiltered, newStatistics) => { @@ -190,9 +230,11 @@ export default function useNFTs(props: UseNFTsProps = {}) { nfts, // immutable nachos, // immutable getMetadata, // immutable + getPreviewStatus, // immutable walletIds, // immutable isNFTHidden, visibility, + previewAvailability, types, search, setFiltered, @@ -212,6 +254,9 @@ export default function useNFTs(props: UseNFTsProps = {}) { [subscribeToChanges, updateFiltered], ); + // preview verdicts arrive as tiles settle and as cache lookups complete + useEffect(() => subscribeToPreviewStatusChanges(updateFiltered), [subscribeToPreviewStatusChanges, updateFiltered]); + return { total, nfts: filtered, diff --git a/packages/gui/src/util/getNFTPreviewStatusFromCache.test.ts b/packages/gui/src/util/getNFTPreviewStatusFromCache.test.ts new file mode 100644 index 0000000000..ff35daa5ca --- /dev/null +++ b/packages/gui/src/util/getNFTPreviewStatusFromCache.test.ts @@ -0,0 +1,75 @@ +import type CacheInfo from '../@types/CacheInfo'; +import NFTPreviewStatus from '../@types/NFTPreviewStatus'; +import CacheState from '../constants/CacheState'; + +import getNFTPreviewStatusFromCache from './getNFTPreviewStatusFromCache'; + +const HASH = '0xabc123'; + +function cached(url: string, checksum: string): CacheInfo { + return { url, state: CacheState.CACHED, checksum, headers: {}, timestamp: 1 }; +} + +function errored(url: string, error: string): CacheInfo { + return { url, state: CacheState.ERROR, error, timestamp: 1 }; +} + +function notCached(url: string): CacheInfo { + return { url, state: CacheState.NOT_CACHED, timestamp: 1 }; +} + +function lookup(infos: CacheInfo[]) { + const byUrl = new Map(infos.map((info) => [info.url, info])); + return (url: string) => byUrl.get(url); +} + +describe('getNFTPreviewStatusFromCache', () => { + it('is unavailable when there is no file to verify against', () => { + expect(getNFTPreviewStatusFromCache({ dataUris: [], dataHash: HASH }, lookup([]))).toBe( + NFTPreviewStatus.UNAVAILABLE, + ); + expect(getNFTPreviewStatusFromCache({ dataUris: ['https://a/x.png'], dataHash: undefined }, lookup([]))).toBe( + NFTPreviewStatus.UNAVAILABLE, + ); + }); + + it('is available once any uri has cached bytes matching the hash, ignoring the 0x prefix', () => { + const status = getNFTPreviewStatusFromCache( + { dataUris: ['https://a/x.png', 'https://b/x.png'], dataHash: HASH }, + lookup([errored('https://a/x.png', 'getaddrinfo ENOTFOUND a'), cached('https://b/x.png', 'abc123')]), + ); + + expect(status).toBe(NFTPreviewStatus.AVAILABLE); + }); + + it('is unavailable only when every uri has a settled failure', () => { + const status = getNFTPreviewStatusFromCache( + { dataUris: ['https://a/x.png', 'https://b/x.png'], dataHash: HASH }, + lookup([ + errored('https://a/x.png', 'Request timed out after 30000ms of inactivity'), + cached('https://b/x.png', 'feed'), + ]), + ); + + expect(status).toBe(NFTPreviewStatus.UNAVAILABLE); + }); + + it('stays undecided while a uri has never been fetched', () => { + const status = getNFTPreviewStatusFromCache( + { dataUris: ['https://a/x.png', 'https://b/x.png'], dataHash: HASH }, + lookup([errored('https://a/x.png', 'getaddrinfo ENOTFOUND a'), notCached('https://b/x.png')]), + ); + + expect(status).toBeUndefined(); + expect(getNFTPreviewStatusFromCache({ dataUris: ['https://a/x.png'], dataHash: HASH }, lookup([]))).toBeUndefined(); + }); + + it('stays undecided after a transient error the cache will retry', () => { + const status = getNFTPreviewStatusFromCache( + { dataUris: ['https://a/x.png'], dataHash: HASH }, + lookup([errored('https://a/x.png', 'Request aborted')]), + ); + + expect(status).toBeUndefined(); + }); +}); diff --git a/packages/gui/src/util/getNFTPreviewStatusFromCache.ts b/packages/gui/src/util/getNFTPreviewStatusFromCache.ts new file mode 100644 index 0000000000..1f87dd08c0 --- /dev/null +++ b/packages/gui/src/util/getNFTPreviewStatusFromCache.ts @@ -0,0 +1,57 @@ +import type CacheInfo from '../@types/CacheInfo'; +import NFTPreviewStatus from '../@types/NFTPreviewStatus'; +import CacheState from '../constants/CacheState'; + +import compareChecksums from './compareChecksums'; + +// Persisted errors the cache retries on the next access, so a tile would try +// the download again — they do not settle the preview as unavailable. +const TRANSIENT_ERRORS = ['Response aborted', 'Request aborted']; + +export type NFTPreviewSource = { + dataUris?: string[]; + dataHash?: string; +}; + +/** + * Classifies an NFT's preview from what the cache already persisted about its + * data URIs, without fetching anything. Mirrors the walk `useNFTVerifyHash` + * performs when the tile is on screen: the first URI whose cached bytes match + * the on-chain hash makes the preview available; the preview is unavailable + * only once every URI has a settled failure (a persisted download error or a + * cached file with the wrong checksum). A URI the cache has not seen yet — or + * failed only transiently — leaves the outcome undecided (`undefined`), since + * the tile would still attempt it. + */ +export default function getNFTPreviewStatusFromCache( + nft: NFTPreviewSource, + getCacheInfo: (url: string) => CacheInfo | undefined, +): NFTPreviewStatus | undefined { + const { dataUris, dataHash } = nft; + + // nothing to verify against — the tile shows "No file available" + if (!dataUris?.length || !dataHash) { + return NFTPreviewStatus.UNAVAILABLE; + } + + let isSettled = true; + + for (const uri of dataUris) { + const cacheInfo = getCacheInfo(uri); + + if (cacheInfo?.state === CacheState.CACHED) { + if (cacheInfo.checksum && compareChecksums(cacheInfo.checksum, dataHash)) { + return NFTPreviewStatus.AVAILABLE; + } + // a cached file with the wrong checksum is a settled failure for this uri + } else if (cacheInfo?.state === CacheState.ERROR) { + if (TRANSIENT_ERRORS.includes(cacheInfo.error)) { + isSettled = false; + } + } else { + isSettled = false; + } + } + + return isSettled ? NFTPreviewStatus.UNAVAILABLE : undefined; +} diff --git a/packages/gui/src/util/getNFTsDataStatistics.ts b/packages/gui/src/util/getNFTsDataStatistics.ts index d3aca1f56f..ad8232da4c 100644 --- a/packages/gui/src/util/getNFTsDataStatistics.ts +++ b/packages/gui/src/util/getNFTsDataStatistics.ts @@ -19,6 +19,8 @@ export default function getNFTsDataStatistics( hidden: 0, total: 0, sensitive: 0, + previewAvailable: 0, + previewUnavailable: 0, }; data.forEach((item) => { @@ -37,6 +39,9 @@ export default function getNFTsDataStatistics( stats.sensitive += 1; } + // no preview verdicts are known here — nothing counts as unavailable + stats.previewAvailable += 1; + stats.total += 1; }); From b332522744f16290a2384c46a385345d8942a355 Mon Sep 17 00:00:00 2001 From: Jonathan LoBue Date: Fri, 21 Aug 2026 09:57:53 -0700 Subject: [PATCH 3/6] Classify unmounted NFTs from their preview sources, not the data file alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cache lookup walked only the data URIs against the data hash, but a preview-mode tile verifies preview video, preview image, and data file in that order and shows the first verified one. An NFT whose thumbnail would verify while every data URI is a settled failure was therefore stored as unavailable, and under "Preview available" it stayed filtered out — never mounting, so no tile could ever correct it. The classifier now mirrors the tile: it takes the NFT's metadata state and walks the same sources in the same order; any verified source makes the preview available, and it is unavailable only once every URI of every source has a settled failure. Metadata that is still loading leaves the verdict undecided — a thumbnail may yet verify — so the store re-sweeps when metadata arrives, via a new global change event on the metadata store. The metadata store is already populated for every NFT by the gallery's search and statistics, so consulting it adds no requests. Sweeps are coalesced to one per 250ms and persisted outcomes are memoized per URL for the session, so the repeated sweeps during initial load only hit the main process for URLs not seen before. Invalidating an NFT forgets its URLs along with its verdict. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01YKEGodEgVdEuvUM8dza52q --- .../components/nfts/provider/NFTProvider.tsx | 12 +- .../nfts/provider/hooks/useMetadataData.ts | 15 +++ .../provider/hooks/useNFTPreviewStatuses.ts | 118 +++++++++++++----- .../util/getNFTPreviewStatusFromCache.test.ts | 84 +++++++++++-- .../src/util/getNFTPreviewStatusFromCache.ts | 105 +++++++++++----- 5 files changed, 260 insertions(+), 74 deletions(-) diff --git a/packages/gui/src/components/nfts/provider/NFTProvider.tsx b/packages/gui/src/components/nfts/provider/NFTProvider.tsx index 471fb381bb..9c37d9a3fb 100644 --- a/packages/gui/src/components/nfts/provider/NFTProvider.tsx +++ b/packages/gui/src/components/nfts/provider/NFTProvider.tsx @@ -99,6 +99,7 @@ export default function NFTProvider(props: NFTProviderProps) { getMetadata, fetchMetadata, subscribeToMetadataChanges, + subscribeToChanges: subscribeToMetadataDataChanges, invalidate: invalidateMetadata, } = useMetadataData({ fetchNFT, @@ -136,7 +137,9 @@ export default function NFTProvider(props: NFTProviderProps) { useNFTPreviewStatuses({ nfts, nachos, + getMetadata, subscribeToChanges, + subscribeToMetadataChanges: subscribeToMetadataDataChanges, }); const invalidateNFT = useCallback( @@ -151,14 +154,13 @@ export default function NFTProvider(props: NFTProviderProps) { return; } - // the files are about to be re-fetched, so the preview verdict is stale - invalidatePreviewStatus(id); - // invalidate nft files const promises = []; + const invalidatedUris: string[] = []; const { dataUris, metadataUris } = nft; dataUris.forEach((uri) => promises.push(invalidate(uri))); + invalidatedUris.push(...dataUris); const firstMetadataUri = metadataUris && metadataUris[0]; if (firstMetadataUri) { @@ -174,15 +176,19 @@ export default function NFTProvider(props: NFTProviderProps) { if (previewVideoUris) { previewVideoUris.forEach((uri: string) => promises.push(invalidate(uri))); + invalidatedUris.push(...previewVideoUris); } if (previewImageUris) { previewImageUris.forEach((uri: string) => promises.push(invalidate(uri))); + invalidatedUris.push(...previewImageUris); } } } catch (e) { log(`Error loading metadata for ${id}: ${(e as Error).message}`); } finally { + // the files are being re-fetched, so the preview verdict is stale + invalidatePreviewStatus(id, invalidatedUris); await Promise.all(promises); } diff --git a/packages/gui/src/components/nfts/provider/hooks/useMetadataData.ts b/packages/gui/src/components/nfts/provider/hooks/useMetadataData.ts index c25ade0370..3dc20b1816 100644 --- a/packages/gui/src/components/nfts/provider/hooks/useMetadataData.ts +++ b/packages/gui/src/components/nfts/provider/hooks/useMetadataData.ts @@ -45,6 +45,8 @@ export default function useMetadataData(props: UseMetadataDataProps) { error: metadataOnDemand.error, isLoading: !!metadataOnDemand.promise, }); + + events.emit('changed'); }, [events /* immutable */, metadatasOnDemand /* immutable */], ); @@ -177,10 +179,23 @@ export default function useMetadataData(props: UseMetadataDataProps) { [events /* immutable */], ); + // immutable function + const subscribeToChanges = useCallback( + (callback: () => void) => { + events.on('changed', callback); + + return () => { + events.off('changed', callback); + }; + }, + [events /* immutable */], + ); + return { getMetadata, fetchMetadata, subscribeToMetadataChanges, + subscribeToChanges, invalidate, } as const; } diff --git a/packages/gui/src/components/nfts/provider/hooks/useNFTPreviewStatuses.ts b/packages/gui/src/components/nfts/provider/hooks/useNFTPreviewStatuses.ts index 9b0defb6ed..d1d29f2f4e 100644 --- a/packages/gui/src/components/nfts/provider/hooks/useNFTPreviewStatuses.ts +++ b/packages/gui/src/components/nfts/provider/hooks/useNFTPreviewStatuses.ts @@ -4,39 +4,49 @@ import { type NFTInfo } from '@chia-network/api'; import debug from 'debug'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type CacheInfo from '../../../../@types/CacheInfo'; +import type MetadataState from '../../../../@types/MetadataState'; import type NFTPreviewStatus from '../../../../@types/NFTPreviewStatus'; import useCache from '../../../../hooks/useCache'; -import getNFTPreviewStatusFromCache from '../../../../util/getNFTPreviewStatusFromCache'; +import getNFTPreviewStatusFromCache, { getNFTPreviewUrls } from '../../../../util/getNFTPreviewStatusFromCache'; const log = debug('chia-gui:NFTProvider:useNFTPreviewStatuses'); // Cache infos are looked up in batches so a large collection does not hand // the main process thousands of file reads in one IPC call. const LOOKUP_BATCH_SIZE = 200; +// NFT pages and metadata results arrive in bursts; one sweep per window. +const LOOKUP_DELAY = 250; type UseNFTPreviewStatusesProps = { nfts: Map; // should be immutable nachos: Map; // should be immutable + getMetadata: (id: string) => MetadataState; // should be immutable subscribeToChanges: (callback: () => void) => () => void; // should be immutable + subscribeToMetadataChanges: (callback: () => void) => () => void; // should be immutable }; // warning: only used by NFTProvider // // Tracks, per NFT, whether its gallery tile can show a preview. Tiles report -// what they actually render once they settle; NFTs that are not on screen -// (the gallery is virtualized, so most never mount) are classified from the -// outcomes the cache persisted during earlier visits and sessions, without -// downloading anything. A live report always wins over a cache lookup. +// the verdict they settle on; NFTs that are not on screen (the gallery is +// virtualized, so most never mount) are classified from the outcomes the +// cache persisted during earlier visits and sessions, without downloading +// anything. A live report always wins over a cache lookup. export default function useNFTPreviewStatuses(props: UseNFTPreviewStatusesProps) { - const { nfts, nachos, subscribeToChanges } = props; + const { nfts, nachos, getMetadata, subscribeToChanges, subscribeToMetadataChanges } = props; const { getCacheInfos } = useCache(); const [statuses /* immutable */] = useState(() => new Map()); - // NFTs whose cached outcomes were already looked up. An NFT that never - // reaches the screen never changes its cache state, so one lookup per - // session is enough; a tile that does mount reports live instead. - const [lookedUp /* immutable */] = useState(() => new Set()); + // NFTs that need no further lookup: a tile reported them, the cache settled + // them, or every input is known and only a download (which a tile would + // then report) can decide them. + const [settled /* immutable */] = useState(() => new Set()); + // Persisted outcomes already fetched. A url's outcome only changes through + // a download — which the tile then reports live — or an invalidation, + // which forgets it here. + const [cacheInfos /* immutable */] = useState(() => new Map()); const events = useMemo(() => { const eventEmitter = new EventEmitter(); @@ -53,7 +63,7 @@ export default function useNFTPreviewStatuses(props: UseNFTPreviewStatusesProps) // immutable function const setPreviewStatus = useCallback( (nftId: string, status: NFTPreviewStatus) => { - lookedUp.add(nftId); + settled.add(nftId); if (statuses.get(nftId) === status) { return; @@ -62,19 +72,20 @@ export default function useNFTPreviewStatuses(props: UseNFTPreviewStatusesProps) statuses.set(nftId, status); events.emit('changed'); }, - [events /* immutable */, statuses /* immutable */, lookedUp /* immutable */], + [events /* immutable */, statuses /* immutable */, settled /* immutable */], ); // immutable function const invalidatePreviewStatus = useCallback( - (nftId: string) => { - lookedUp.delete(nftId); + (nftId: string, urls: string[]) => { + settled.delete(nftId); + urls.forEach((url) => cacheInfos.delete(url)); if (statuses.delete(nftId)) { events.emit('changed'); } }, - [events /* immutable */, statuses /* immutable */, lookedUp /* immutable */], + [events /* immutable */, statuses /* immutable */, settled /* immutable */, cacheInfos /* immutable */], ); // immutable function @@ -92,9 +103,9 @@ export default function useNFTPreviewStatuses(props: UseNFTPreviewStatusesProps) const isLookingUpRef = useRef(false); const lookUpAgainRef = useRef(false); - // Classifies every NFT not yet looked up from the cache's persisted state. - // Runs serialized: NFT pages arrive in bursts, and a run that finds the - // flag set simply sweeps once more when it finishes. + // Classifies every NFT not yet settled from the cache's persisted state. + // Runs serialized: a sweep that finds the flag set simply sweeps once more + // when it finishes. const lookUpFromCache = useCallback(async () => { if (isLookingUpRef.current) { lookUpAgainRef.current = true; @@ -108,7 +119,7 @@ export default function useNFTPreviewStatuses(props: UseNFTPreviewStatusesProps) const pending: [string, NFTInfo][] = []; const collect = (nft: NFTInfo, nftId: string) => { - if (!lookedUp.has(nftId)) { + if (!settled.has(nftId)) { pending.push([nftId, nft]); } }; @@ -121,27 +132,40 @@ export default function useNFTPreviewStatuses(props: UseNFTPreviewStatusesProps) }); for (let start = 0; start < pending.length; start += LOOKUP_BATCH_SIZE) { - const batch = pending.slice(start, start + LOOKUP_BATCH_SIZE); - const urls = Array.from(new Set(batch.flatMap(([, nft]) => nft.dataUris ?? []))); - - // eslint-disable-next-line no-await-in-loop -- batches are sequential on purpose, to pace the main process - const cacheInfos = urls.length ? await getCacheInfos(urls) : []; - const cacheInfoByUrl = new Map(cacheInfos.map((cacheInfo) => [cacheInfo.url, cacheInfo])); + // The metadata store already fetches every NFT's metadata for the + // gallery's search and statistics; reading it here adds no requests. + const batch = pending + .slice(start, start + LOOKUP_BATCH_SIZE) + .map(([nftId, nft]) => ({ nftId, nft, metadataState: getMetadata(nftId) })); + + const urls = Array.from( + new Set(batch.flatMap(({ nft, metadataState }) => getNFTPreviewUrls(nft, metadataState))), + ).filter((url) => !cacheInfos.has(url)); + + if (urls.length) { + // eslint-disable-next-line no-await-in-loop -- batches are sequential on purpose, to pace the main process + const fetchedInfos = await getCacheInfos(urls); + fetchedInfos.forEach((cacheInfo) => cacheInfos.set(cacheInfo.url, cacheInfo)); + } let changed = false; - batch.forEach(([nftId, nft]) => { - if (lookedUp.has(nftId)) { + batch.forEach(({ nftId, nft, metadataState }) => { + if (settled.has(nftId)) { // a tile reported live while the lookup was in flight return; } - lookedUp.add(nftId); - - const status = getNFTPreviewStatusFromCache(nft, (url) => cacheInfoByUrl.get(url)); + const status = getNFTPreviewStatusFromCache(nft, metadataState, (url) => cacheInfos.get(url)); if (status) { statuses.set(nftId, status); + settled.add(nftId); changed = true; + } else if (!metadataState.isLoading) { + // every input is known and the cache cannot decide — only a + // download can, and the tile that performs it reports it + settled.add(nftId); } + // otherwise the metadata is still loading: swept again once it settles }); if (changed) { @@ -157,17 +181,43 @@ export default function useNFTPreviewStatuses(props: UseNFTPreviewStatusesProps) }, [ nfts /* immutable */, nachos /* immutable */, + getMetadata /* immutable */, getCacheInfos /* immutable */, statuses /* immutable */, - lookedUp /* immutable */, + settled /* immutable */, + cacheInfos /* immutable */, events /* immutable */, ]); + const lookUpTimeoutRef = useRef | undefined>(undefined); + + const scheduleLookUp = useCallback(() => { + if (lookUpTimeoutRef.current) { + return; + } + + lookUpTimeoutRef.current = setTimeout(() => { + lookUpTimeoutRef.current = undefined; + lookUpFromCache(); + }, LOOKUP_DELAY); + }, [lookUpFromCache]); + useEffect(() => { - lookUpFromCache(); + scheduleLookUp(); + + const unsubscribeNFTs = subscribeToChanges(scheduleLookUp); + const unsubscribeMetadata = subscribeToMetadataChanges(scheduleLookUp); - return subscribeToChanges(lookUpFromCache); - }, [lookUpFromCache, subscribeToChanges]); + return () => { + unsubscribeNFTs(); + unsubscribeMetadata(); + + if (lookUpTimeoutRef.current) { + clearTimeout(lookUpTimeoutRef.current); + lookUpTimeoutRef.current = undefined; + } + }; + }, [scheduleLookUp, subscribeToChanges, subscribeToMetadataChanges]); return { getPreviewStatus, // immutable diff --git a/packages/gui/src/util/getNFTPreviewStatusFromCache.test.ts b/packages/gui/src/util/getNFTPreviewStatusFromCache.test.ts index ff35daa5ca..5bfc16aa27 100644 --- a/packages/gui/src/util/getNFTPreviewStatusFromCache.test.ts +++ b/packages/gui/src/util/getNFTPreviewStatusFromCache.test.ts @@ -1,10 +1,12 @@ import type CacheInfo from '../@types/CacheInfo'; +import type MetadataState from '../@types/MetadataState'; import NFTPreviewStatus from '../@types/NFTPreviewStatus'; import CacheState from '../constants/CacheState'; -import getNFTPreviewStatusFromCache from './getNFTPreviewStatusFromCache'; +import getNFTPreviewStatusFromCache, { getNFTPreviewUrls } from './getNFTPreviewStatusFromCache'; const HASH = '0xabc123'; +const PREVIEW_HASH = '0xdef456'; function cached(url: string, checksum: string): CacheInfo { return { url, state: CacheState.CACHED, checksum, headers: {}, timestamp: 1 }; @@ -23,20 +25,30 @@ function lookup(infos: CacheInfo[]) { return (url: string) => byUrl.get(url); } +const noMetadata: MetadataState = { metadata: undefined, isLoading: false, error: new Error('No metadata URI') }; +const loadingMetadata: MetadataState = { metadata: undefined, isLoading: true }; +const metadataWithPreview: MetadataState = { + metadata: { preview_image_uris: ['https://thumbs/x.png'], preview_image_hash: PREVIEW_HASH }, + isLoading: false, +}; + +const dead = errored('https://a/x.png', 'getaddrinfo ENOTFOUND a'); + describe('getNFTPreviewStatusFromCache', () => { it('is unavailable when there is no file to verify against', () => { - expect(getNFTPreviewStatusFromCache({ dataUris: [], dataHash: HASH }, lookup([]))).toBe( - NFTPreviewStatus.UNAVAILABLE, - ); - expect(getNFTPreviewStatusFromCache({ dataUris: ['https://a/x.png'], dataHash: undefined }, lookup([]))).toBe( + expect(getNFTPreviewStatusFromCache({ dataUris: [], dataHash: HASH }, noMetadata, lookup([]))).toBe( NFTPreviewStatus.UNAVAILABLE, ); + expect( + getNFTPreviewStatusFromCache({ dataUris: ['https://a/x.png'], dataHash: undefined }, noMetadata, lookup([])), + ).toBe(NFTPreviewStatus.UNAVAILABLE); }); - it('is available once any uri has cached bytes matching the hash, ignoring the 0x prefix', () => { + it('is available once any data uri has cached bytes matching the hash, ignoring the 0x prefix', () => { const status = getNFTPreviewStatusFromCache( { dataUris: ['https://a/x.png', 'https://b/x.png'], dataHash: HASH }, - lookup([errored('https://a/x.png', 'getaddrinfo ENOTFOUND a'), cached('https://b/x.png', 'abc123')]), + noMetadata, + lookup([dead, cached('https://b/x.png', 'abc123')]), ); expect(status).toBe(NFTPreviewStatus.AVAILABLE); @@ -45,6 +57,7 @@ describe('getNFTPreviewStatusFromCache', () => { it('is unavailable only when every uri has a settled failure', () => { const status = getNFTPreviewStatusFromCache( { dataUris: ['https://a/x.png', 'https://b/x.png'], dataHash: HASH }, + noMetadata, lookup([ errored('https://a/x.png', 'Request timed out after 30000ms of inactivity'), cached('https://b/x.png', 'feed'), @@ -57,19 +70,72 @@ describe('getNFTPreviewStatusFromCache', () => { it('stays undecided while a uri has never been fetched', () => { const status = getNFTPreviewStatusFromCache( { dataUris: ['https://a/x.png', 'https://b/x.png'], dataHash: HASH }, - lookup([errored('https://a/x.png', 'getaddrinfo ENOTFOUND a'), notCached('https://b/x.png')]), + noMetadata, + lookup([dead, notCached('https://b/x.png')]), ); expect(status).toBeUndefined(); - expect(getNFTPreviewStatusFromCache({ dataUris: ['https://a/x.png'], dataHash: HASH }, lookup([]))).toBeUndefined(); + expect( + getNFTPreviewStatusFromCache({ dataUris: ['https://a/x.png'], dataHash: HASH }, noMetadata, lookup([])), + ).toBeUndefined(); }); it('stays undecided after a transient error the cache will retry', () => { const status = getNFTPreviewStatusFromCache( { dataUris: ['https://a/x.png'], dataHash: HASH }, + noMetadata, lookup([errored('https://a/x.png', 'Request aborted')]), ); expect(status).toBeUndefined(); }); + + it('is available through a verified thumbnail even when the data file is unreachable', () => { + const status = getNFTPreviewStatusFromCache( + { dataUris: ['https://a/x.png'], dataHash: HASH }, + metadataWithPreview, + lookup([dead, cached('https://thumbs/x.png', 'def456')]), + ); + + expect(status).toBe(NFTPreviewStatus.AVAILABLE); + }); + + it('is available through a verified data file while the metadata is still loading', () => { + const status = getNFTPreviewStatusFromCache( + { dataUris: ['https://a/x.png'], dataHash: HASH }, + loadingMetadata, + lookup([cached('https://a/x.png', 'abc123')]), + ); + + expect(status).toBe(NFTPreviewStatus.AVAILABLE); + }); + + it('does not settle an unreachable data file as unavailable until the metadata is known', () => { + const nft = { dataUris: ['https://a/x.png'], dataHash: HASH }; + + expect(getNFTPreviewStatusFromCache(nft, loadingMetadata, lookup([dead]))).toBeUndefined(); + expect(getNFTPreviewStatusFromCache(nft, metadataWithPreview, lookup([dead]))).toBeUndefined(); + expect( + getNFTPreviewStatusFromCache( + nft, + metadataWithPreview, + lookup([dead, errored('https://thumbs/x.png', 'getaddrinfo ENOTFOUND thumbs')]), + ), + ).toBe(NFTPreviewStatus.UNAVAILABLE); + expect(getNFTPreviewStatusFromCache(nft, noMetadata, lookup([dead]))).toBe(NFTPreviewStatus.UNAVAILABLE); + }); + + it('lists the urls the classification consults', () => { + const nft = { dataUris: ['https://a/x.png'], dataHash: HASH }; + + expect(getNFTPreviewUrls(nft, loadingMetadata)).toEqual(['https://a/x.png']); + expect(getNFTPreviewUrls(nft, metadataWithPreview)).toEqual(['https://thumbs/x.png', 'https://a/x.png']); + // a preview source without a hash is never verified, so its uris are not consulted + expect( + getNFTPreviewUrls(nft, { + metadata: { preview_image_uris: ['https://thumbs/unhashed.png'] }, + isLoading: false, + }), + ).toEqual(['https://a/x.png']); + }); }); diff --git a/packages/gui/src/util/getNFTPreviewStatusFromCache.ts b/packages/gui/src/util/getNFTPreviewStatusFromCache.ts index 1f87dd08c0..91f5878f7a 100644 --- a/packages/gui/src/util/getNFTPreviewStatusFromCache.ts +++ b/packages/gui/src/util/getNFTPreviewStatusFromCache.ts @@ -1,11 +1,13 @@ import type CacheInfo from '../@types/CacheInfo'; +import type Metadata from '../@types/Metadata'; +import type MetadataState from '../@types/MetadataState'; import NFTPreviewStatus from '../@types/NFTPreviewStatus'; import CacheState from '../constants/CacheState'; import compareChecksums from './compareChecksums'; // Persisted errors the cache retries on the next access, so a tile would try -// the download again — they do not settle the preview as unavailable. +// the download again — they do not settle a uri as failed. const TRANSIENT_ERRORS = ['Response aborted', 'Request aborted']; export type NFTPreviewSource = { @@ -13,45 +15,92 @@ export type NFTPreviewSource = { dataHash?: string; }; +type PreviewCandidate = { + uris?: string[]; + hash?: string; +}; + +// The sources a preview-mode tile verifies, in the priority order +// `selectNFTPreviewState` applies: preview video, preview image, data file. +// The preview candidates exist only once the metadata is known. +function getCandidates(nft: NFTPreviewSource, metadata: Metadata | undefined): PreviewCandidate[] { + const candidates: PreviewCandidate[] = []; + + if (metadata) { + candidates.push( + { uris: metadata.preview_video_uris, hash: metadata.preview_video_hash }, + { uris: metadata.preview_image_uris, hash: metadata.preview_image_hash }, + ); + } + + candidates.push({ uris: nft.dataUris, hash: nft.dataHash }); + + return candidates; +} + +function settledMetadata(metadataState: MetadataState): Metadata | undefined { + return metadataState.isLoading ? undefined : metadataState.metadata; +} + +/** The urls whose cache state `getNFTPreviewStatusFromCache` consults. */ +export function getNFTPreviewUrls(nft: NFTPreviewSource, metadataState: MetadataState): string[] { + return getCandidates(nft, settledMetadata(metadataState)).flatMap((candidate) => + candidate.hash ? (candidate.uris ?? []) : [], + ); +} + +type UriOutcome = 'verified' | 'failed' | 'undecided'; + +function classifyUri(hash: string, cacheInfo: CacheInfo | undefined): UriOutcome { + if (cacheInfo?.state === CacheState.CACHED) { + // a cached file with the wrong checksum is a settled failure for this uri + return cacheInfo.checksum && compareChecksums(cacheInfo.checksum, hash) ? 'verified' : 'failed'; + } + + if (cacheInfo?.state === CacheState.ERROR) { + return TRANSIENT_ERRORS.includes(cacheInfo.error) ? 'undecided' : 'failed'; + } + + return 'undecided'; +} + /** * Classifies an NFT's preview from what the cache already persisted about its - * data URIs, without fetching anything. Mirrors the walk `useNFTVerifyHash` - * performs when the tile is on screen: the first URI whose cached bytes match - * the on-chain hash makes the preview available; the preview is unavailable - * only once every URI has a settled failure (a persisted download error or a - * cached file with the wrong checksum). A URI the cache has not seen yet — or - * failed only transiently — leaves the outcome undecided (`undefined`), since - * the tile would still attempt it. + * files, without fetching anything. Mirrors what a preview-mode tile settles + * on: it walks the same sources `useNFTVerifyHash` verifies — preview video, + * preview image, data file — and the first uri whose cached bytes match its + * hash makes the preview available. The preview is unavailable only once + * every uri of every source has a settled failure (a persisted download error + * or cached bytes with the wrong checksum). Anything the cache has not seen + * yet, or failed only transiently, leaves the outcome undecided + * (`undefined`), as does metadata that is still loading: until it settles the + * preview sources are unknown, and a thumbnail may still make the preview + * available even when the data file itself is unreachable. */ export default function getNFTPreviewStatusFromCache( nft: NFTPreviewSource, + metadataState: MetadataState, getCacheInfo: (url: string) => CacheInfo | undefined, ): NFTPreviewStatus | undefined { - const { dataUris, dataHash } = nft; + let isUndecided = metadataState.isLoading; - // nothing to verify against — the tile shows "No file available" - if (!dataUris?.length || !dataHash) { - return NFTPreviewStatus.UNAVAILABLE; - } + for (const candidate of getCandidates(nft, settledMetadata(metadataState))) { + // a source without a hash or uris has nothing to verify and contributes + // nothing — it can neither make the preview available nor fail it + if (candidate.hash && candidate.uris?.length) { + for (const uri of candidate.uris) { + const outcome = classifyUri(candidate.hash, getCacheInfo(uri)); - let isSettled = true; + if (outcome === 'verified') { + return NFTPreviewStatus.AVAILABLE; + } - for (const uri of dataUris) { - const cacheInfo = getCacheInfo(uri); - - if (cacheInfo?.state === CacheState.CACHED) { - if (cacheInfo.checksum && compareChecksums(cacheInfo.checksum, dataHash)) { - return NFTPreviewStatus.AVAILABLE; - } - // a cached file with the wrong checksum is a settled failure for this uri - } else if (cacheInfo?.state === CacheState.ERROR) { - if (TRANSIENT_ERRORS.includes(cacheInfo.error)) { - isSettled = false; + if (outcome === 'undecided') { + isUndecided = true; + } } - } else { - isSettled = false; } } - return isSettled ? NFTPreviewStatus.UNAVAILABLE : undefined; + return isUndecided ? undefined : NFTPreviewStatus.UNAVAILABLE; } From 95d0f6960ff9f63498c5e5cf02f181703735b5b2 Mon Sep 17 00:00:00 2001 From: Jonathan LoBue Date: Fri, 21 Aug 2026 09:57:58 -0700 Subject: [PATCH 4/6] Report preview availability from the verification state, not the render path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every preview-mode tile reported into the shared store, but the verdict was derived from what the tile happened to draw: a compact tile for a non-image type and a document or model tile show a type icon without opening the iframe, so a file that failed to download still counted as available there, while the gallery card for the same NFT hit prepareError and reported it unavailable. Whichever tile mounted last won, and an autocomplete row could flip the gallery's verdict. The verdict now follows the verification state all of these tiles share: the preview is available when the selected source is verified and could be served from the cache, unavailable when there is no file, a settled mismatch, or a download failure — the same outcome the hash badge reports, whatever the tile draws. It stays undecided while verification is in flight and, for a failed data file, while the metadata has not settled, since a thumbnail may still verify. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01YKEGodEgVdEuvUM8dza52q --- .../gui/src/components/nfts/NFTPreview.tsx | 34 +++++++++++-------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/packages/gui/src/components/nfts/NFTPreview.tsx b/packages/gui/src/components/nfts/NFTPreview.tsx index 01b3c5b0e3..0e58f2b40b 100644 --- a/packages/gui/src/components/nfts/NFTPreview.tsx +++ b/packages/gui/src/components/nfts/NFTPreview.tsx @@ -516,30 +516,36 @@ export default function NFTPreview(props: NFTPreviewProps) { !icon && ![FileType.MODEL, FileType.DOCUMENT].includes(previewFileType); - // What the tile settled on showing — the same decision the render below - // makes — so the gallery's preview filter classifies NFTs exactly as their - // tiles do. Undefined while the tile is still loading. + // The verdict behind the gallery's preview filter. It follows the + // verification state rather than the render path: a document or model + // tile draws its type icon even when the file could not be fetched, and a + // compact tile never opens the iframe, yet the file is just as unavailable + // (the hash badge says so) — so every preview-mode tile of an NFT reaches + // the same verdict, whichever of them happens to mount. Undefined while + // anything that could still change the verdict is in flight. const previewStatus = useMemo(() => { - if (isLoading) { + if (isLoading || isLoadingVerifyHash) { return undefined; } - if (!hasFile || isHashMismatch) { - return NFTPreviewStatus.UNAVAILABLE; + if (!preview?.isVerified) { + // no file, a settled mismatch, or a file that failed to download — but + // a thumbnail in metadata that has not arrived yet may still verify + return isLoadingMetadata ? undefined : NFTPreviewStatus.UNAVAILABLE; } - if (usesIframe) { - if (prepareError) { - return NFTPreviewStatus.UNAVAILABLE; - } + if (prepareError) { + // the verified file could not be served from the cache + return NFTPreviewStatus.UNAVAILABLE; + } - if (!previewContent) { - return undefined; - } + if (!previewContent) { + // preparePreview has not settled on this uri yet + return undefined; } return NFTPreviewStatus.AVAILABLE; - }, [isLoading, hasFile, isHashMismatch, usesIframe, prepareError, previewContent]); + }, [isLoading, isLoadingVerifyHash, isLoadingMetadata, preview, prepareError, previewContent]); useEffect(() => { // Only preview-mode tiles report: the detail view verifies the full data From 862ce0c5cab61100c44d220a32e709900537c925 Mon Sep 17 00:00:00 2001 From: Jonathan LoBue Date: Fri, 21 Aug 2026 10:25:08 -0700 Subject: [PATCH 5/6] Clear a refreshing NFT's preview verdict before the metadata round-trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving the verdict reset into the refresh's finally block left the gallery classifying a refreshing NFT from what its files used to be for as long as the metadata fetch took — with a slow or hung metadata host, the whole timeout. The verdict is now dropped up front, together with the memoized outcomes of the data files, and dropped again once the deletions have completed: the preview uris are only known after the metadata round-trip, and a cache lookup that overlaps the deletions could memoize outcomes the refresh is about to remove. After the second reset the store holds nothing about the NFT, so the next lookup sees the files as not cached and only the refreshed tile's own verification decides. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01YKEGodEgVdEuvUM8dza52q --- .../src/components/nfts/provider/NFTProvider.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/gui/src/components/nfts/provider/NFTProvider.tsx b/packages/gui/src/components/nfts/provider/NFTProvider.tsx index 9c37d9a3fb..d81e01b013 100644 --- a/packages/gui/src/components/nfts/provider/NFTProvider.tsx +++ b/packages/gui/src/components/nfts/provider/NFTProvider.tsx @@ -156,11 +156,18 @@ export default function NFTProvider(props: NFTProviderProps) { // invalidate nft files const promises = []; - const invalidatedUris: string[] = []; const { dataUris, metadataUris } = nft; + const invalidatedUris: string[] = [...dataUris]; + + // Drop the preview verdict right away, together with what is known about + // the data files: the filter must not keep classifying an NFT that is + // being refreshed from what its files used to be while the metadata + // round-trip below is still running. Repeated once the files are gone — + // the preview uris are only known after that round-trip, and a lookup + // that overlaps the deletions could memoize outcomes they remove. + invalidatePreviewStatus(id, invalidatedUris); dataUris.forEach((uri) => promises.push(invalidate(uri))); - invalidatedUris.push(...dataUris); const firstMetadataUri = metadataUris && metadataUris[0]; if (firstMetadataUri) { @@ -187,9 +194,8 @@ export default function NFTProvider(props: NFTProviderProps) { } catch (e) { log(`Error loading metadata for ${id}: ${(e as Error).message}`); } finally { - // the files are being re-fetched, so the preview verdict is stale - invalidatePreviewStatus(id, invalidatedUris); await Promise.all(promises); + invalidatePreviewStatus(id, invalidatedUris); } await Promise.all([invalidateNachos(), invalidateMetadata(id), invalidateNFTOnDemand(id)]); From 921201049f2223da41cf20aa3c6509f710feb0c8 Mon Sep 17 00:00:00 2001 From: Jonathan LoBue Date: Fri, 21 Aug 2026 10:33:41 -0700 Subject: [PATCH 6/6] Keep a refresh from leaving stale preview verdicts behind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps around the refresh reset: - Waiting with Promise.all meant one rejected deletion (a uri the cache cannot key) threw before the late reset ran, and threw while the other deletions were still in flight. The refresh now waits for every deletion with allSettled, resets, and only then re-raises the first failure — so the reset can neither be skipped nor race a deletion. - A cache lookup whose IPC round-trip started before the files were deleted could return after the late reset, memoize CACHED outcomes for files that no longer exist, and settle the NFT on them; later sweeps then skipped it. The store now counts invalidations, and a lookup that spans one discards its result and starts the sweep over, since the NFTs the invalidation reset are unsettled again. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01YKEGodEgVdEuvUM8dza52q --- .../src/components/nfts/provider/NFTProvider.tsx | 14 +++++++++++--- .../nfts/provider/hooks/useNFTPreviewStatuses.ts | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/gui/src/components/nfts/provider/NFTProvider.tsx b/packages/gui/src/components/nfts/provider/NFTProvider.tsx index d81e01b013..7b3e9ff7e5 100644 --- a/packages/gui/src/components/nfts/provider/NFTProvider.tsx +++ b/packages/gui/src/components/nfts/provider/NFTProvider.tsx @@ -193,9 +193,17 @@ export default function NFTProvider(props: NFTProviderProps) { } } catch (e) { log(`Error loading metadata for ${id}: ${(e as Error).message}`); - } finally { - await Promise.all(promises); - invalidatePreviewStatus(id, invalidatedUris); + } + + // Wait for every deletion, even when one of them fails (a uri the cache + // cannot key), so the reset below cannot race a deletion still in + // flight; the first failure still propagates afterwards as before. + const results = await Promise.allSettled(promises); + invalidatePreviewStatus(id, invalidatedUris); + + const failure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected'); + if (failure) { + throw failure.reason; } await Promise.all([invalidateNachos(), invalidateMetadata(id), invalidateNFTOnDemand(id)]); diff --git a/packages/gui/src/components/nfts/provider/hooks/useNFTPreviewStatuses.ts b/packages/gui/src/components/nfts/provider/hooks/useNFTPreviewStatuses.ts index d1d29f2f4e..13674c937d 100644 --- a/packages/gui/src/components/nfts/provider/hooks/useNFTPreviewStatuses.ts +++ b/packages/gui/src/components/nfts/provider/hooks/useNFTPreviewStatuses.ts @@ -75,9 +75,15 @@ export default function useNFTPreviewStatuses(props: UseNFTPreviewStatusesProps) [events /* immutable */, statuses /* immutable */, settled /* immutable */], ); + // Bumped by every invalidation. A lookup whose IPC round-trip spans one may + // have read files the invalidation deleted in the meantime, so it discards + // its result instead of memoizing it. + const invalidationGeneration = useRef(0); + // immutable function const invalidatePreviewStatus = useCallback( (nftId: string, urls: string[]) => { + invalidationGeneration.current += 1; settled.delete(nftId); urls.forEach((url) => cacheInfos.delete(url)); @@ -143,8 +149,18 @@ export default function useNFTPreviewStatuses(props: UseNFTPreviewStatusesProps) ).filter((url) => !cacheInfos.has(url)); if (urls.length) { + const generation = invalidationGeneration.current; // eslint-disable-next-line no-await-in-loop -- batches are sequential on purpose, to pace the main process const fetchedInfos = await getCacheInfos(urls); + + if (generation !== invalidationGeneration.current) { + // an invalidation ran while this lookup was in flight — the + // outcomes may describe files that are gone now, and the NFTs + // it reset are unsettled again, so start the sweep over + lookUpAgainRef.current = true; + break; + } + fetchedInfos.forEach((cacheInfo) => cacheInfos.set(cacheInfo.url, cacheInfo)); }