All files / platform/app/src/hooks useSeriesFetch.ts

0% Statements 0/75
0% Branches 0/27
0% Functions 0/16
0% Lines 0/75

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205                                                                                                                                                                                                                                                                                                                                                                                                                         
import { useCallback, useEffect, useRef, useState } from 'react';
 
import { useAppConfig } from '@state';
import { utils } from '@ohif/core';
import { thumbnailNoImageModalities } from '@ohif/core/src/utils/thumbnailNoImageModalities';
import {
  PreviewThumbnailStatusState,
  type PreviewThumbnailStatus,
  type StudyRow,
} from '@ohif/ui-next';
 
// A series row carries arbitrary DICOM fields plus the thumbnail status this
// panel tracks. Only the latter is typed; the rest stays open.
type PreviewSeries = Record<string, any> & { thumbnailStatus: PreviewThumbnailStatus };
 
// Series rows may carry the UID under either casing depending on the data
// source; read it through one place so callers can't forget a variant.
function getSeriesUID(row: Record<string, any>): string | undefined {
  return row.seriesInstanceUid || row.SeriesInstanceUID;
}
 
/**
 * Runs `worker` over `items` with at most `maxParallel` in flight at once,
 * stopping early if `signal` aborts. A shared cursor hands each worker the
 * next item, so a slow fetch doesn't hold up the rest.
 */
async function runThumbnailPool<T>(
  items: T[],
  maxParallel: number,
  signal: AbortSignal,
  worker: (item: T) => Promise<void>
): Promise<void> {
  let nextIndex = 0;
  const runWorker = async () => {
    while (!signal.aborted) {
      const idx = nextIndex++;
      Iif (idx >= items.length) {
        return;
      }
      await worker(items[idx]);
    }
  };
  await Promise.all(Array.from({ length: Math.min(maxParallel, items.length) }, runWorker));
}
 
/**
 * Fetches the series for the selected study and, where applicable, their
 * thumbnails, exposing the resulting list and an image-error handler.
 *
 * Owns the blob-URL lifecycle for thumbnails produced by the `fetch` strategy:
 * every created `blob:` URL is tracked and revoked on study change / unmount,
 * and `onThumbnailImageError` revokes a single failed thumbnail's URL.
 */
export function useSeriesFetch({
  dataSource,
  selected,
}: {
  dataSource: any;
  selected: StudyRow | null;
}): {
  series: PreviewSeries[];
  onThumbnailImageError: (seriesUID: string) => void;
} {
  const [series, setSeries] = useState<PreviewSeries[]>([]);
  // Blob URLs created by this panel (via the `fetch` thumbnail strategy).
  // Tracked so we can URL.revokeObjectURL them on study change / unmount —
  // otherwise every fetched series leaks one blob worth of memory.
  const ownedBlobUrlsRef = useRef<string[]>([]);
  const [appConfig] = useAppConfig();
  const { sortBySeriesDate } = utils as any;
 
  useEffect(() => {
    // Drives cancellation when the selection changes or the panel unmounts: stops the
    // worker pool from scheduling new fetches and aborts in-flight requests that honor
    // AbortSignal (the `fetch` thumbnail strategy; the bulkDataURI XHR path cannot abort).
    const abortController = new AbortController();
    const { signal } = abortController;
 
    const run = async () => {
      const studyInstanceUID = (selected as any)?.studyInstanceUid;
      Iif (!studyInstanceUID) {
        setSeries([]);
        return;
      }
 
      try {
        const seriesList = await dataSource.query.series.search(studyInstanceUID);
        Iif (signal.aborted) {
          return;
        }
 
        const sortedSeriesList = sortBySeriesDate?.(seriesList) ?? [];
        const normalizedSeriesList = sortedSeriesList.map(row => {
          const modality = String(row.modality || row.Modality || '').toUpperCase();
          const thumbnailStatus: PreviewThumbnailStatus = thumbnailNoImageModalities.includes(
            modality
          )
            ? { status: PreviewThumbnailStatusState.NotApplicable }
            : { status: PreviewThumbnailStatusState.Loading };
          return {
            ...row,
            thumbnailStatus,
          };
        });
 
        setSeries(normalizedSeriesList);
 
        const fetchTargets = normalizedSeriesList.filter((row: PreviewSeries) => {
          Iif (!getSeriesUID(row)) {
            return false;
          }
          return row.thumbnailStatus?.status !== PreviewThumbnailStatusState.NotApplicable;
        });
 
        // Bound parallel thumbnail fetches so studies with many series don't
        // saturate the connection and stall later viewer navigation. Mirrors
        // CS3D's imageLoadPoolManager.maxNumRequests.thumbnail.
        const maxParallelRequests = Math.max(1, appConfig?.maxNumRequests?.thumbnail ?? 5);
        const fetchThumbnail = async (row: (typeof fetchTargets)[number]) => {
          const seriesUID = getSeriesUID(row);
          let src: string | null = null;
          try {
            const getThumbnailSrc = dataSource?.retrieve?.getGetThumbnailSrc?.(
              { StudyInstanceUID: studyInstanceUID, SeriesInstanceUID: seriesUID },
              undefined
            );
            src = (await getThumbnailSrc?.({ signal })) ?? null;
          } catch {
            src = null;
          }
          // Track ownership of blob URLs before the abort check so URLs that
          // arrive just after abort are still revoked on cleanup.
          Iif (src?.startsWith('blob:')) {
            ownedBlobUrlsRef.current.push(src);
          }
          Iif (signal.aborted) {
            return;
          }
          setSeries(prev =>
            prev.map(seriesItem => {
              Iif (getSeriesUID(seriesItem) !== seriesUID) {
                return seriesItem;
              }
              return {
                ...seriesItem,
                thumbnailStatus: src
                  ? { status: PreviewThumbnailStatusState.Ready, src }
                  : { status: PreviewThumbnailStatusState.NotAvailable },
              };
            })
          );
        };
 
        await runThumbnailPool(fetchTargets, maxParallelRequests, signal, fetchThumbnail);
      } catch (e) {
        Iif (!signal.aborted) {
          console.warn('Failed to load preview series/thumbnails for selected study.', e);
          setSeries([]);
        }
      }
    };
 
    void run();
 
    return () => {
      abortController.abort();
      // Revoke blob URLs this run created. Safe even though the old series
      // may still be in the DOM briefly: revokeObjectURL only invalidates
      // future loads, the already-rendered <img> keeps its pixels.
      const urls = ownedBlobUrlsRef.current;
      ownedBlobUrlsRef.current = [];
      urls.forEach(url => {
        try {
          URL.revokeObjectURL(url);
        } catch {}
      });
    };
  }, [dataSource, selected, appConfig?.maxNumRequests?.thumbnail]);
 
  const onThumbnailImageError = useCallback((seriesUID: string) => {
    setSeries(prevSeriesList =>
      prevSeriesList.map(seriesItem => {
        Iif (getSeriesUID(seriesItem) !== seriesUID) {
          return seriesItem;
        }
        const thumbnailStatus = seriesItem.thumbnailStatus as PreviewThumbnailStatus | undefined;
        Iif (
          thumbnailStatus?.status === PreviewThumbnailStatusState.Ready &&
          thumbnailStatus.src?.startsWith('blob:')
        ) {
          try {
            URL.revokeObjectURL(thumbnailStatus.src);
          } catch {}
        }
        return {
          ...seriesItem,
          thumbnailStatus: { status: PreviewThumbnailStatusState.NotAvailable },
        };
      })
    );
  }, []);
 
  return { series, onThumbnailImageError };
}