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):
- A durable local cache (survives page reload/browser restart) for full studies, in addition to today's transient in-memory session state.
- 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.
- 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.sessionStorageorwindow.localStorageusage anywhere for study/series/pixel data.localStorageis used only for small UI-preference state (selected table columns, worklist filters, hotkey overrides, metadata-overlay display toggles) via Zustand'spersistmiddleware — seeplatform/viewer/src/store/{useStudiesTableFiltersAndColumnsStore.js,useWorkListStore.js,useMetadataSettingsStore.js,useViewerMetadataSettingsStore.js,useStudiesTableFilters.js}andplatform/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-querycache (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.
- Search results live briefly in an in-memory
- 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 viablob:object URLs created by@cornerstonejs/dicom-image-loader'swadouri.fileManager.add(file)(confirmed inplatform/core/src/store/fileLoaderService/{filesToStudies.js,fileLoaderService.js,dicomFileLoader.js}, and duplicated for the legacy v2 stack underplatform/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.1is already a pinned dependency (rootpackage.json/yarn.lock), used today only byplatform/core/src/store/useViewerStudyErrors.jsto persist a small per-study error log to IndexedDB via a Zustandpersiststorage 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): voidwrites into animageLoadersmap keyed by bare scheme string (no colon).ImageLoaderFnreturns{ promise: Promise<IImage>, cancelFn?: () => void, decache?: () => void }(promisemandatory, the rest optional). Scheme routing (loadImageFromImageLoader) doesimageId.split(':')[0]and looks up an exact match in that map, falling back toregisterUnknownImageLoader'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 ininitDataIntegrations.js) writes to a separatevolumeLoadersmap — confirmed independent ofimageLoaders, so the existing volume-loader registration does not cover stack/2D viewport image loading and a dedicatedregisterImageLoadercall is required, matching the maintainer's explicit ask for an "Image Loader." - Neither
extensions/cornerstone/src/**norplatform/core/src/**was found to call@cornerstonejs/dicom-image-loader's own bootstrap (dicomImageLoader.init()/.configure()), though the package'swadourinamespace 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.
2.5 Related and prior-art issues
- 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 viaregisterImageLoaderin both@cornerstonejs/core(v3) andcornerstone-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), matchingDicomLocalDataSource.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
DicomMetadataStoreis 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
aclDownloadsignal, 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 inplatform/core/src/store/useViewerStudyErrors.js) or the raw IndexedDB API ifidb-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/coreandcornerstone-core(both confirmed to expose a compatibleregisterImageLoader(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) beforeinitDataServiceIntegration(...); register the v2 loader beforesetConfiguration(...)runs inplatform/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.13andcornerstone-core@2.6.1both 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 thesonadorlocal:prefix overwadouri:/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 owninit()/.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.jsis reference shape only). ItsIWebApiDataSourcequery/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 durablesonadorlocal: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 itemid: '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 noonClick. 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
aclDownloadsignal already computed inSelectAndSettingsAndExpandCell.js(activeServer?.perms?.viewdefaulted, refined by per-studystudyMeta?.perms?.Viewfetched lazily viafetchStudyAclPermissionsand cached onDicomMetadataStore) — do not introduce a parallel permission mechanism. As-built gating scope (2026-07-21): the per-row Action-menu item uses the full per-studyaclDownloadsignal; the bulk study-list action gates on server-levelactiveServer?.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 sameStudyListNGcomponent; implement FR-5/FR-6/FR-7 once insideStudyListNG/SelectAndSettingsAndExpandCell.js/Filters.js/StudiesTable.js, not per page. Must correctly resolveStudyInstanceUIDon both code paths used by_getStudyInstanceUIDinSelectAndSettingsAndExpandCell.js—row.iddirectly for Studies/All and Shared,DicomMetadataStore.findStudylookup 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 forThumbnail.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 RadixHoverCard, 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 byStudiesTableShareModal.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
Iconregistry (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-importedcloud-download.svg/trash-bin.svgfromplatform/ui/src/elements/Svg/svgs/directly, matching the direct-SVG-import convention already used inStudiesTableActions.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.js — CustomComponent 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.js — ModalNG-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-existingdownload/Downloadids 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
StudyInstanceUIDviaDicomMetadataStore.findStudy, notrow.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, nosw.jscurrently 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.13packages/core/src/loaders/imageLoader.ts(github.com/cornerstonejs/cornerstone3D, tag v4.22.13);cornerstone-core@2.6.1src/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/}