Local/offline DICOM study cache: Cornerstone3D image loader, download manager, and study-list/viewer controls

1. Overview

Build a persistent, browser-side local/offline cache for downloaded DICOM studies in the Sonador Viewer (OHIF v3 fork), so that once a study has been explicitly cached it opens near-instantly and can be reviewed without a network connection, and so that clinical users can proactively queue studies for offline availability instead of waiting on live transfer during review.

This delivers the three user-visible capabilities requested in #20 (closed):

  1. A durable local cache (survives page reload/browser restart) for full studies, in addition to today's transient in-memory session state.
  2. Explicit user controls to queue, monitor, cancel, and remove cached studies/series — a "Download Manager" reachable from every study-list page, plus per-row and per-series controls in the study list and viewer.
  3. Visual indicators (study-list row, series thumbnail) showing what is available offline, with storage/size detail on hover.

Scoping headline: this is a new, additive capability. Research below found no existing local/offline data cache, no IndexedDB usage for DICOM content, and no Cornerstone3D image-loader registration anywhere in this codebase — the feature is built from first principles against the patterns the codebase already uses elsewhere (data sources, toolbar modules, study-list action menus), not by extending or repurposing an existing mechanism.

Note (2026-07-20, label correction): the toggle-button label originally specified here as "Download for offline" / "Remove local copy" has been shortened to "Go offline" / "Remove offline" throughout this spec — three-word labels don't render cleanly in this interface's buttons/menus. See the updated FR-7, FR-9, AR-6, AC-6, AC-11, and §5.3 below.

Note (2026-07-21, as-built decisions — authorized by @roakes): implementation (!60) finalized several points differently from the original text; the affected FR/AR/AC entries below have been revised in place and are marked "as-built (2026-07-21)". Headlines: UI copy is "Save Offline Copy" / "Remove Offline Copy" (with "Cancel Download" / "Downloading…" while a transfer is in flight), superseding the 2026-07-20 two-word label decision; the study-list cached indicator is a heavier row font with no icon or row-level popup; cancelled transfers clean up their own partial downloads (AC-3); the dialog is titled "Offline Storage" with tabs Active Transfers / Offline Studies; search adds Service Episode ID (0038,0060) as an eighth field. Full before/after change log in the issue comments.

2. Background / current state

2.1 Correction to the originating description

The issue text states "OHIF uses session storage to cache for searches and series that have been downloaded to the local machine." This is not literally accurate for this codebase and the spec corrects it here so the implementer doesn't go looking for sessionStorage code that doesn't exist:

  • There is no window.sessionStorage or window.localStorage usage anywhere for study/series/pixel data. localStorage is used only for small UI-preference state (selected table columns, worklist filters, hotkey overrides, metadata-overlay display toggles) via Zustand's persist middleware — see platform/viewer/src/store/{useStudiesTableFiltersAndColumnsStore.js,useWorkListStore.js,useMetadataSettingsStore.js,useViewerMetadataSettingsStore.js,useStudiesTableFilters.js} and platform/viewer/src/App.js (hotkey-definitions).
  • What actually behaves like a "session cache" today is two independent, both transient, mechanisms:
    • Search results live briefly in an in-memory @tanstack/react-query cache (platform/viewer/src/hooks/useStudies.js) with no persister configured — cleared on reload.
    • DicomMetadataStore (platform/core/src/services/DicomMetadataStore/DicomMetadataStore.ts) is a plain in-memory singleton (const _model = { studies: [] }) with no persistence backing at all.
  • The one existing "local data" concept, extensions/cornerstone/src/DicomLocalDataSource.js, is for locally uploaded files (drag-and-drop import), not for caching studies retrieved from the server, and it identifies image data via blob: object URLs created by @cornerstonejs/dicom-image-loader's wadouri.fileManager.add(file) (confirmed in platform/core/src/store/fileLoaderService/{filesToStudies.js,fileLoaderService.js,dicomFileLoader.js}, and duplicated for the legacy v2 stack under platform/viewer/src/lib/localFileLoaders/). Blob URLs do not survive a page reload and are per-tab, per-File-object references — this scheme is structurally unable to back a persistent offline cache regardless of what wraps it, which is why FR-1 below requires a new, durable imageId scheme rather than reusing this one.
  • One durable, unused precedent exists: idb-keyval@6.2.1 is already a pinned dependency (root package.json/yarn.lock), used today only by platform/core/src/store/useViewerStudyErrors.js to persist a small per-study error log to IndexedDB via a Zustand persist storage adapter. This is a reasonable existing library to build the new cache's storage layer on (see AR-1), but it has never been used for study/pixel data.

2.2 Dual v2/v3 rendering stack (already exists, must both be supported)

extensions/cornerstone/src/init.js initializes both the legacy Cornerstone stack (cornerstone-core, cornerstone-tools) and Cornerstone3D (@cornerstonejs/core) side by side, calling OHIF.utils.cornerstone3dUtils.initCornerstone3d() then initDataServiceIntegration({...}) (extensions/cornerstone/src/initDataIntegrations.js), which registers Cornerstone3D volume loaders (registerVolumeLoader('cornerstoneStreamingImageVolume', ...), ...DynamicImageVolume...) and metadata providers, but never registers an image loader. This confirms the maintainer's implementation notes are correct that an image loader is a genuinely separate, currently-missing piece (volume loaders and image loaders are independent registries — verified against @cornerstonejs/core source at the pinned version, §2.4).

Legacy v2 image loading is bootstrapped implicitly in platform/viewer/src/config.js's setConfiguration() (invoked from App.js's constructor): it sets cornerstoneWADOImageLoader.external.cornerstone = cornerstone and calls .configure(...), which causes cornerstone-wado-image-loader to self-register its wadouri:/wadors: handlers into cornerstone-core's loader registry.

2.3 Data-source layer has no chaining/fallback

OHIF v3 "data sources" (IWebApiDataSource, platform/core/src/DataSources/IWebApiDataSource.js) are registered by name into a flat map on ExtensionManager (platform/core/src/extensions/ExtensionManager.js): registerStorageProvider(name, provider)this.dataSources[name] = provider, retrieved by exact name via getDataProvider(name). extensions/cornerstone/src/DicomLocalDataSource.js's createDicomLocalApi is registered this way in platform/viewer/src/App.js (_initExtensions): extensionManager.registerStorageProvider('dcm-local', createDicomLocalApi({ name: 'dcm-local' })). There is no automatic "try provider A, fall back to provider B" mechanism anywhere in this layer. This directly affects the design of "local loader tried preferentially, falls back to remote" (FR-1/AR-4 below): that behavior cannot be implemented as data-source chaining and must live at the imageId-selection and/or image-loader level instead.

2.4 Upstream API verification (pinned versions)

Resolved from root yarn.lock / extensions/cornerstone/package.json / platform/core/package.json:

Package Pinned/resolved version
@cornerstonejs/core 4.22.13
@cornerstonejs/dicom-image-loader 4.22.13
@cornerstonejs/tools 4.22.13
@cornerstonejs/adapters 4.22.13
cornerstone-core (legacy v2) 2.6.1
cornerstone-tools (legacy) 6.0.10
cornerstone-wado-image-loader (legacy) 4.13.2
dicom-parser 1.8.21
dcmjs 0.41.0 (the range satisfying @ohif/core/@ohif/extension-cornerstone; other transitive consumers in the tree resolve different dcmjs versions independently — not relevant to this feature)
idb-keyval 6.2.1

Verified directly against source at these tags (not "latest" docs):

  • @cornerstonejs/core@4.22.13, packages/core/src/loaders/imageLoader.ts: registerImageLoader(scheme: string, imageLoader: ImageLoaderFn): void writes into an imageLoaders map keyed by bare scheme string (no colon). ImageLoaderFn returns { promise: Promise<IImage>, cancelFn?: () => void, decache?: () => void } (promise mandatory, the rest optional). Scheme routing (loadImageFromImageLoader) does imageId.split(':')[0] and looks up an exact match in that map, falling back to registerUnknownImageLoader's handler only when no scheme matches at all — there is no priority-ordered, multi-scheme fallback for a single imageId. This means "register the local loader before the default loaders so it resolves first" (from the maintainer's notes) cannot be implemented as loader-registration order; see AR-3's correction.
  • cornerstone-core@2.6.1 (legacy), src/imageLoader.js: registerImageLoader(scheme, imageLoader) exists with the same name and an equivalent contract — imageId.substring(0, colonIndex) scheme parsing, imageLoadObject.promise.then(...) called directly on the returned object. Confirmed functionally compatible with the v3 API, supporting a shared implementation strategy (AR-2).
  • registerVolumeLoader (already used in initDataIntegrations.js) writes to a separate volumeLoaders map — confirmed independent of imageLoaders, so the existing volume-loader registration does not cover stack/2D viewport image loading and a dedicated registerImageLoader call is required, matching the maintainer's explicit ask for an "Image Loader."
  • Neither extensions/cornerstone/src/** nor platform/core/src/** was found to call @cornerstonejs/dicom-image-loader's own bootstrap (dicomImageLoader.init()/.configure()), though the package's wadouri namespace is imported directly in two file-loading call sites. This was not exhaustively confirmed across every extension directory — flagged as AR-3's open verification item for the implementer.
  • oak-tree/medical-imaging/imaging-development-env#58 (closed December-2023 tracking issue) explicitly carries "Add support for background downloading and cache of cases (studies): queue and store in the browser's local cache" forward into this issue (#20 (closed)) — confirms #20 (closed) is the sole live tracker for this capability, nothing was implemented under #58 itself.
  • #36 (closed) ("Add a button to the viewer which allows for users to download a zip archive of a series (or study)", implemented via ohif-viewers!51) is a different, already-shipped feature: a server-side zip-archive export that triggers a normal browser file download to the user's disk. It is unrelated to this issue's in-browser persistent cache, but it already owns UI real estate and terminology ("Download") that this feature must avoid colliding with — see AR-6.

3. Functional requirements

  • FR-1 (Persistent local cache & image loader). Implement a durable, per-instance local cache (IndexedDB-backed, AR-1) for full DICOM studies, addressable by a new Cornerstone image-loader scheme (e.g. sonadorlocal:) registered via registerImageLoader in both @cornerstonejs/core (v3) and cornerstone-core (v2, AR-2), so cached studies render without any network request and survive page reload. The loader must support all valid encoded DICOM instances (not restricted by modality), matching DicomLocalDataSource.js's existing SR/SEG/DOC handling as a floor, not a ceiling.
  • FR-2 (Preferential local, fallback to remote). When a study/series/instance is available in the local cache, the app must construct sonadorlocal: imageIds for it in preference to the existing remote scheme (wadouri:/wadors:); when not cached (or only partially cached), the existing remote path must be used unchanged, per-instance. This is implemented at the imageId-construction layer, not the data-source layer (§2.3, AR-4).
  • FR-3 (Metadata availability). Both raw DICOM bytes and study/series metadata must be retrievable from the local cache and able to populate component queries by study and series UUID, mirroring how DicomMetadataStore is populated today by the local-upload flow (platform/core/src/store/fileLoaderService/filesToStudies.js).
  • FR-4 (Download Manager service). Add a background job queue supporting multiple simultaneously queued/in-flight downloads, with per-job state (queued, downloading, cancelled, completed, error) and progress, that survives the study-list/viewer components unmounting (a user navigating away must not stop or lose an in-progress download).
  • FR-5 (Download Manager UI) — naming/scope revised as-built (2026-07-21). A dialog titled "Offline Storage", reachable from a launcher (with an active-transfer count badge) visible on the "Studies / All", "Worklist", and "Shared" pages, with two tabs: Active Transfers (per-job cancel plus a bulk Cancel Transfers action) and Offline Studies (per-entry remove plus a bulk Clear Storage action behind a blocking confirmation). Search covers Study UID, Series UID, Patient Name, PatientID, Study Description, Series Description, Accession Number, and Service Episode ID (0038,0060) — see AC-9 as-built for the per-tab scope.
  • FR-6 (Study-list row indicator) — revised as-built (2026-07-21). Each study-list row whose study is available locally renders its metadata text in a heavier font weight. No per-row icon or hover popup is shown (decision: a per-row icon crowded the first column and duplicated the state already conveyed by the row weight; the originally-specified hover detail — series count, consumed storage, related metadata — lives instead in the Offline Storage dialog's study cards and per-series hover details, FR-5).
  • FR-7 (Study-list Action menu) — labels revised as-built (2026-07-21). The per-row "Action" (⋯) menu gains a "Save Offline Copy" item, visible only when the user has view permission for that study (reuse the existing aclDownload signal, AR-7), which queues the study into the Download Manager; while a transfer for that study is in flight the same item reads "Cancel Download", and once cached the menu shows "Remove Offline Copy" instead.
  • FR-8 (Viewer sidebar indicator) — revised as-built (2026-07-21). Each series thumbnail in the left sidebar shows an indicator icon when that series is cached locally; hovering shows a popup with cached-instance count and storage size. The delete-local-copy button originally specified for the popup was dropped (decision: per-series deletion from a transient hover surface was judged too destructive; removal is performed from the viewer More menu, the study-list Action menu, or the Offline Storage dialog, which operate at study granularity).
  • FR-9 (Viewer toolbar control) — labels revised as-built (2026-07-21). The viewer's "More" toolbar menu shows "Save Offline Copy" (offline-cache icon) when the open study is not cached, "Downloading…" (which dispatches cancel) while a transfer is in flight, or "Remove Offline Copy" (trash icon) when cached — label text matching the study-list Action-menu equivalent.
  • FR-10 (Graceful missing-data handling). A local loader miss (partial download, cancelled job, corrupted/evicted entry) must fall back to the remote loader per-instance without failing the viewport or requiring a full page reload.

4. Architectural requirements

  • AR-1 (Storage layer). Use IndexedDB as the persistence layer, via idb-keyval (already pinned at 6.2.1, precedent in platform/core/src/store/useViewerStudyErrors.js) or the raw IndexedDB API if idb-keyval's single-store model proves too limited for the binary+metadata volume this feature needs. Store, per instance: raw DICOM Part10 bytes (or per-frame pixel data, implementer's choice, documented in §5.1), naturalized metadata JSON, and byte size, keyed so lookups by StudyInstanceUID/SeriesInstanceUID/SOPInstanceUID are all supported (FR-3).
  • AR-2 (Shared v2/v3 read path). Register the new scheme in both @cornerstonejs/core and cornerstone-core (both confirmed to expose a compatible registerImageLoader(scheme, loaderFn) API returning/consuming a {promise, ...} shape, §2.4) against a single shared internal module that reads cached bytes — do not duplicate the storage-read logic between the v2 and v3 loader functions, only the thin adapter that satisfies each package's exact return-object shape.
  • AR-3 (Registration ordering — corrected). Register the v3 loader in extensions/cornerstone/src/init.js (or a module it imports) before initDataServiceIntegration(...); register the v2 loader before setConfiguration(...) runs in platform/viewer/src/config.js/App.js. Correction to the maintainer's framing: "register before the default loaders so it resolves first" cannot mean priority-ordered scheme lookup — @cornerstonejs/core@4.22.13 and cornerstone-core@2.6.1 both route by exact scheme-string match (imageId.split(':')[0] / imageId.substring(0, colonIndex)), with no fallback across schemes for one imageId. "Resolves first" instead means: the code that builds the imageId list for a displaySet must itself choose the sonadorlocal: prefix over wadouri:/wadors: when cached data exists (FR-2). Registration order only matters in the narrow sense that the scheme must be registered before any component tries to load an imageId using it. Before implementation, confirm whether @cornerstonejs/dicom-image-loader's own init()/.configure() bootstrap is called anywhere in the app (research did not conclusively locate it, §2.4) — this affects exactly where in the v3 boot sequence the new registration needs to sit relative to the existing WADO loader's own setup.
  • AR-4 (No data-source chaining). Do not attempt to implement local→remote fallback via ExtensionManager.registerStorageProvider/getDataProvider (§2.3) — it is a flat, non-chaining map. Keep fallback logic entirely within imageId selection (FR-2) and the loader's own per-instance behavior (FR-10).
  • AR-5 (DicomLocalDataSource.js is reference shape only). Its IWebApiDataSource query/retrieve/store interface is a useful shape reference, but its blob-URL-based imageId scheme (§2.1) must not be reused or extended — the new cache needs durable sonadorlocal: imageIds resolved against IndexedDB.
  • AR-6 (Naming/ID collision avoidance). Three existing UI elements already use "Download" for unrelated features and must not be reused, relabeled, or collided with: (a) study-list per-row Action-menu item id: 'download'fetchDownloadStudies, part of #36 (closed)'s zip-export feature; (b) viewer toolbar More-menu item id: 'Download'CornerstoneViewportDownloadForm.js, a canvas screenshot/PNG export unrelated to DICOM data; (c) StudiesTableActions.js's bulk-toolbar "Download" button, also #36 (closed)-related and currently a non-functional stub with no onClick. New commands/element IDs must be distinct — e.g. goOffline, removeOffline, cancelStudyDownload — and UI copy reads "Save Offline Copy" / "Remove Offline Copy" (as-built decision 2026-07-21, superseding the two-word "Go offline"/"Remove offline" correction: the two-word labels read as a connectivity-mode toggle rather than a per-study cache action, and the longer copy renders acceptably in the target menus) — still unambiguous next to the existing "Download" controls.
  • AR-7 (Permission reuse). Gate "Save Offline Copy" visibility with the existing aclDownload signal already computed in SelectAndSettingsAndExpandCell.js (activeServer?.perms?.view defaulted, refined by per-study studyMeta?.perms?.View fetched lazily via fetchStudyAclPermissions and cached on DicomMetadataStore) — do not introduce a parallel permission mechanism. As-built gating scope (2026-07-21): the per-row Action-menu item uses the full per-study aclDownload signal; the bulk study-list action gates on server-level activeServer?.perms?.view; the viewer More-menu control carries no separate gate, because opening a study in the viewer already requires view permission (see AC-6 as-built).
  • AR-8 (Single shared surface). platform/viewer/src/pages/{StudyListPageNG,WorkListPageNG,SharedWithMePageNG} all render the same StudyListNG component; implement FR-5/FR-6/FR-7 once inside StudyListNG/SelectAndSettingsAndExpandCell.js/Filters.js/StudiesTable.js, not per page. Must correctly resolve StudyInstanceUID on both code paths used by _getStudyInstanceUID in SelectAndSettingsAndExpandCell.jsrow.id directly for Studies/All and Shared, DicomMetadataStore.findStudy lookup for Worklist rows.
  • AR-9 (Reuse existing popup pattern). Use the existing OverlayTrigger (platform/ui/src/components/overlayTrigger/) + Tooltip (platform/ui/src/components/tooltip/Tooltip.js) combination already used for Thumbnail.js's series-warning badge (getWarningInfo()) for all new hover-info popups (FR-6, FR-8), rather than introducing a new popover mechanism. As-built (2026-07-21): the FR-8 thumbnail badge follows this pattern; the FR-6 row popup was dropped (see FR-6 as-built); the Offline Storage dialog's hover details use the ui-next Radix HoverCard, matching the NG study-list surface it lives on.
  • AR-10 (Reuse existing modal pattern). Build the Download Manager dialog on ModalNG (platform/ui/src/components/ModalNG/ModalNG.js), following the structure already established by StudiesTableShareModal.js (debounced search input, react-query-driven list + mutations, per-row action icon) — this is the closest existing precedent for a searchable, list-based, actionable modal in the study-list feature area.
  • AR-11 (Icon assets). No dedicated "download" icon exists in the string-keyed Icon registry (platform/ui/src/elements/Icon/getIcon.js + platform/ui/src/elements/Icon/icons/) used by toolbar buttons and thumbnail badges; a new SVG must be added there for FR-8/FR-9 (the existing 'create-screen-capture' icon belongs to the unrelated screenshot feature and must not be repurposed; 'trash' already exists and is suitable for "remove"). For NG-styled study-list surfaces (FR-6/FR-7), reuse the already-imported cloud-download.svg/trash-bin.svg from platform/ui/src/elements/Svg/svgs/ directly, matching the direct-SVG-import convention already used in StudiesTableActions.js.

5. Implementation specification

5.1 State model

Entity Storage Read API (indicative) Notes
Cached instance IndexedDB, keyed by SOPInstanceUID (or a compound StudyInstanceUID/SeriesInstanceUID/SOPInstanceUID key) LocalCacheService.getInstanceBytes(uids), .getInstanceMetadata(uids) Raw DICOM bytes + naturalized metadata JSON + byte size, per AR-1
Study cache summary Derived/maintained index over cached instances LocalCacheService.getStudySummary(StudyInstanceUID){ seriesCount, instanceCount, totalBytes, cachedAt } Backs FR-6/FR-8 hover popups; maintain as a running summary rather than recomputing per read if instance counts get large
Download job In-memory (queue) + persisted enough state to reflect status after reload (job list, not necessarily byte-level resume) DownloadManagerService.listJobs(), .cancel(jobId) States: queued, downloading, cancelled, completed, error; supports concurrent jobs (FR-4)

5.2 Event contract

Both new services should follow the existing pub/sub convention already used elsewhere in the app (e.g. displaySetService.subscribe(displaySetService.EVENTS.DISPLAY_SET_DATASYNC, ...) in extensions/cornerstone/src/initDataIntegrations.js, and DicomMetadataStore._broadcastEvent(EVENTS.SERIES_ADDED, ...) in DicomLocalDataSource.js):

  • LocalCacheService.EVENTS = { INSTANCE_CACHED, INSTANCE_REMOVED, STUDY_CACHE_UPDATED }
  • DownloadManagerService.EVENTS = { JOB_QUEUED, JOB_PROGRESS, JOB_STATE_CHANGED }

UI components (study-list row, sidebar thumbnail badge, Download Manager dialog, toolbar button) subscribe to these rather than polling, so FR-6/FR-8/FR-9's cached/not-cached state stays reactive.

5.3 File-by-file plan

1. platform/core — storage and job-queue services 1.1. New platform/core/src/services/LocalCacheService/LocalCacheService.ts — IndexedDB-backed CRUD + summary queries for cached instances (AR-1), EVENTS pub/sub (§5.2). 1.2. New platform/core/src/services/LocalCacheService/DownloadManagerService.ts — job queue (FR-4): enqueue, cancel, progress, state; must survive component unmount (own lifecycle independent of any React tree). 1.3. Register both services in the platform's services registration point (follow the existing pattern used for DicomMetadataStore/UIModalService/other core services — confirm exact registration file at implementation time; not located during research). 1.4. platform/core/src/services/DicomMetadataStore/DicomMetadataStore.ts — add or reuse a rehydration path so opening a cached study populates the store the same way filesToStudies.js does for uploads (FR-3).

2. extensions/cornerstone — v3 image loader and UI wiring 2.1. New extensions/cornerstone/src/loaders/sonadorLocalImageLoader.js — implements the confirmed ImageLoaderFn contract ({ promise, cancelFn?, decache? }, §2.4) against LocalCacheService, decoding via dcmjs/dicom-parser following the parsing approach already used in platform/core/src/store/fileLoaderService/dicomFileLoader.js. 2.2. extensions/cornerstone/src/init.js — import and call registerImageLoader('sonadorlocal', sonadorLocalImageLoader) before initDataServiceIntegration(...) (AR-3). 2.3. Extend imageId construction (site to confirm at implementation time — likely alongside whatever builds imageIds for the primary remote data source, analogous to DicomLocalDataSource.js's getImageIdsForDisplaySet/getImageIdsForInstance) to prefer sonadorlocal: imageIds when LocalCacheService reports the instance cached (FR-2). This integration point was not conclusively located during research (the primary remote data source's imageId-building code was out of scope for the research pass) — flagged for implementation-time investigation. 2.4. extensions/cornerstone/src/toolbarModule.js — add GoOffline/RemoveOffline entries to the "More" submenu's buttons array, using a CustomComponent (not a static config item) so label/icon can branch on cache state (AR-6, FR-9). 2.5. New extensions/cornerstone/src/toolbarComponents/LocalCacheToolbarButton.jsCustomComponent following the existing pattern in SeriesTagToolbarButton.js/DistortionFilterToolbarButton.js, subscribing to LocalCacheService.EVENTS/DownloadManagerService.EVENTS for the active study. 2.6. extensions/cornerstone/src/commandsModule.js — add goOffline, removeOffline, cancelStudyDownload commands (AR-6) dispatched by 2.5 and by the study-list menu (4.1).

3. platform/viewer — v2 legacy bridge 3.1. platform/viewer/src/config.js — register cornerstone.registerImageLoader('sonadorlocal', legacySonadorLocalImageLoader) before cornerstoneWADOImageLoader.configure(...)/external.cornerstone = cornerstone (AR-3), using a thin v2-contract adapter over the same shared read module as 2.1 (AR-2).

4. platform/viewer — study-list UI 4.1. platform/viewer/src/components/studyList/StudyListNG/components/SelectAndSettingsAndExpandCell/SelectAndSettingsAndExpandCell.js — cache-status subscription/lookup; offline-availability icon badge in the existing column-0 slot (FR-6); hover popup (AR-9); extend the options/filteredOptions array with go-offline (gated by aclDownload, AR-7) and remove-offline items (FR-7), using the same lazy-ACL-fetch pattern already present for aclDownload/aclShare. 4.2. platform/viewer/src/components/studyList/StudyListNG/components/StudiesTable/StudiesTable.module.scss + StudiesTable.js — new conditional row class for heavier font weight on cached rows (FR-6). 4.3. platform/viewer/src/components/studyList/StudyListNG/components/Filters/Filters.js — Download Manager launcher button in the serverPickerAndRefresh row (FR-5), shared across all three pages by construction (AR-8). 4.4. New platform/viewer/src/components/studyList/StudyListNG/components/DownloadManagerModal/DownloadManagerModal.jsModalNG-based dialog (AR-10) with Active Transfers / Locally Stored tabs, seven-field debounced search, per-job cancel and per-entry remove (FR-5).

5. platform/ui — viewer sidebar thumbnail 5.1. platform/ui/src/components/studyBrowser/Thumbnail.js — add getCacheInfo(...) alongside the existing getWarningInfo()/getDerivedInfo() in getSeriesInformation(): badge icon (new asset, AR-11) + OverlayTrigger/Tooltip popup (AR-9) with instance count, storage size, and a delete button (FR-8). 5.2. platform/viewer/src/connectedComponents/ConnectedStudyBrowser.js — thread cache-status data and the delete callback down through StudyBrowser.js into Thumbnail.js.

6. platform/ui — icon assets 6.1. platform/ui/src/elements/Icon/icons/ + getIcon.js — add a "cached/downloaded" indicator icon and register it in the ICONS map (AR-11). 6.2. Reuse existing cloud-download.svg/trash-bin.svg from platform/ui/src/elements/Svg/svgs/ for the study-list surfaces (AR-11).

6. Refactor analysis

Not applicable in the disposition-table sense: research confirmed registerImageLoader has zero existing call sites in this codebase (§2.2/§2.4), so this feature does not change or migrate an established pattern used by other components — it introduces one. The one adjacent existing mechanism, DicomLocalDataSource.js's local-file-upload data source, is explicitly left untouched (AR-5) because it serves a different use case (ad hoc local file import) with a different, intentionally non-persistent imageId scheme; nothing about this feature requires changing it, and it should not be refactored as part of this work.

7. Acceptance criteria

  • AC-1: After a full page reload, opening a previously fully-cached study loads pixel data from the local cache with no network request for those instances, in both an OHIF-v3 (Cornerstone3D) viewport and a legacy OHIF-v2 viewport.
  • AC-2: A study cached only partially (e.g., a cancelled or errored download) renders correctly by falling back to the remote loader per-instance for the missing instances, without erroring the viewport.
  • AC-3 (revised as-built, 2026-07-21): Cancelling an in-progress download halts further fetches for that job immediately and removes the instances that job had stored — a cancelled transfer cleans up after itself rather than retaining partial data (decision by @roakes during implementation). Instances cached by earlier, completed downloads of the same study are untouched and remain independently usable.
  • AC-4: Rapidly toggling "Go offline"/"Remove offline" on the same study (double-click, or a click while a job is already in flight) does not corrupt cache state, leave orphaned IndexedDB entries, or spawn duplicate concurrent jobs for the same study.
  • AC-5: Removing a cached series from the viewer sidebar popup while a viewport is actively displaying already-loaded frames from that series does not crash the viewport; any new frame requests for that series correctly fall back to remote.
  • AC-6 (revised as-built, 2026-07-21): A user without view permission for a study never sees "Save Offline Copy" in the study-list Action menu for that study. The viewer More-menu control is intentionally not separately gated — reaching the viewer for a study already requires view permission (AR-7 as-built gating scope).
  • AC-7: Navigating away from the viewer or study list mid-download does not stop the download; reopening the Download Manager later shows the job still progressing (or completed/errored) with working cancel/remove controls.
  • AC-8: Studies that have never been cached render study-list rows, action-menu items, and viewer toolbar/sidebar exactly as before this feature — no regression for existing consumers.
  • AC-9 (revised as-built, 2026-07-21): The Offline Storage dialog's search filters the Offline Studies tab by each of Study UID, Series UID, Patient Name, PatientID, Study Description, Series Description, Accession Number, and Service Episode ID (0038,0060). The Active Transfers tab matches the study-level fields only — series-level fields are unknowable for a job until its instances have arrived.
  • AC-10: Exceeding the browser's IndexedDB storage quota during a download surfaces a visible error state on that job (not a silent failure, unhandled promise rejection, or app crash).
  • AC-11: New command/element IDs (goOffline, removeOffline, cancelStudyDownload, and their UI ids) do not collide with the pre-existing download/Download ids used by the #36 (closed) zip-export feature or the screenshot-export feature (AR-6).
  • AC-12: Instances of at least the modalities already special-cased in DicomLocalDataSource.js (SR, SEG, and a standard image modality such as CT) load correctly from the local cache.
  • AC-13: Worklist-page rows (which resolve StudyInstanceUID via DicomMetadataStore.findStudy, not row.id) show correct cache status and controls, matching Studies/All and Shared pages (AR-8).

8. Out of scope

  • Fixing or wiring up the pre-existing, unrelated non-functional bulk "Download" button in StudiesTableActions.js — that belongs to #36 (closed), not this feature.
  • Any change to the #36 (closed) zip-archive export feature beyond avoiding ID/label collisions with it (AR-6).
  • Backend/Orthanc changes — this is a browser-side cache built on data already retrievable through the existing DICOMweb/WADO endpoints used for normal viewing; no new server API is required for the base feature.
  • Cross-device or cross-browser-profile sync of the local cache (IndexedDB is per-origin, per-profile only).
  • Automatic cache eviction/LRU policy beyond explicit user-triggered removal — flag as a follow-up decision if unbounded local storage growth becomes a concern.
  • Service-worker-based transparent HTTP caching — the existing PWA service-worker scaffold (platform/viewer/public/init-service-worker.js, Workbox, no sw.js currently present) covers app-shell assets only and is a distinct mechanism from this feature's explicit, user-controlled per-study data cache.
  • At-rest encryption of cached data beyond whatever protection the browser's IndexedDB implementation already provides — flag as a follow-up security/compliance question given this project's existing ACL/security work (milestone "Sonador Security Extensions: Access Control").
  • Multi-tab coordination/deduplication of simultaneous downloads of the same study across browser tabs — first cut may allow redundant concurrent downloads across tabs without corruption; true cross-tab dedup is a follow-up.

9. References

  • Origin: #20 (closed)
  • Related (carried scope, closed): oak-tree/medical-imaging/imaging-development-env#58
  • Related (ruled out as unrelated prior art): #36 (closed) (implemented via !51)
  • Milestone: %22 ("Sonador Studylist UI Refinements Round 2")
  • Upstream, version-pinned: @cornerstonejs/core@4.22.13 packages/core/src/loaders/imageLoader.ts (github.com/cornerstonejs/cornerstone3D, tag v4.22.13); cornerstone-core@2.6.1 src/imageLoader.js (github.com/cornerstonejs/cornerstone, tag v2.6.1); Cornerstone3D "Image Loaders" concept doc (cornerstonejs.org/docs/concepts/cornerstone-core/imageLoader)
  • Repo docs: docs/latest/architecture/index.md, docs/latest/extensions/index.md (note: extension-module docs predate the data-source concept and should not be relied on for that layer — see code instead)
  • Reference files: extensions/cornerstone/src/{DicomLocalDataSource.js,initDataIntegrations.js,init.js,toolbarModule.js,commandsModule.js,toolbarComponents/}, platform/viewer/src/{config.js,App.js}, platform/core/src/extensions/ExtensionManager.js, platform/core/src/services/DicomMetadataStore/DicomMetadataStore.ts, platform/core/src/store/fileLoaderService/{filesToStudies.js,fileLoaderService.js,dicomFileLoader.js}, platform/core/src/store/useViewerStudyErrors.js, platform/viewer/src/components/studyList/StudyListNG/**, platform/ui/src/components/studyBrowser/{Thumbnail.js,StudyBrowser.js,ImageThumbnail.js}, platform/ui/src/components/{overlayTrigger,tooltip/Tooltip.js,ModalNG/ModalNG.js}, platform/ui/src/elements/{Icon/getIcon.js,Svg/svgs/}
Edited by Zayd Vanderson