From b0f877cf1a99585bec9e3824e702d448473b597d Mon Sep 17 00:00:00 2001 From: Bartosz Hanc Date: Thu, 29 Oct 2026 18:18:39 +0100 Subject: [PATCH 1/2] feat: expose resource cache path utility --- .../api/__snapshots__/apiSurface.test.ts.snap | 1 + .../fetcher/resourceCachePath.test.ts | 55 +++++++++++++++++++ .../src/fetcher/fetcher.ts | 16 +++++- packages/react-native-executorch/src/utils.ts | 2 + 4 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 packages/react-native-executorch/__tests__/fetcher/resourceCachePath.test.ts diff --git a/packages/react-native-executorch/__tests__/api/__snapshots__/apiSurface.test.ts.snap b/packages/react-native-executorch/__tests__/api/__snapshots__/apiSurface.test.ts.snap index 702bec1a08..a2a7f74425 100644 --- a/packages/react-native-executorch/__tests__/api/__snapshots__/apiSurface.test.ts.snap +++ b/packages/react-native-executorch/__tests__/api/__snapshots__/apiSurface.test.ts.snap @@ -164,6 +164,7 @@ exports[`public API surface matches the recorded export list 1`] = ` "defaultWorkletRuntime", "download", "getRegisteredBackends", + "getResourceCachePath", "inspectModel", "isRnExecuTorchError", "llm", diff --git a/packages/react-native-executorch/__tests__/fetcher/resourceCachePath.test.ts b/packages/react-native-executorch/__tests__/fetcher/resourceCachePath.test.ts new file mode 100644 index 0000000000..3dcd9e9a27 --- /dev/null +++ b/packages/react-native-executorch/__tests__/fetcher/resourceCachePath.test.ts @@ -0,0 +1,55 @@ +import { Platform } from 'react-native'; +import RNBlobUtil from 'react-native-blob-util'; +import { getResourceCachePath } from '../../src/utils'; +import { download } from '../../src/fetcher/fetcher'; +import { setTelemetryEnabled } from '../../src/fetcher/telemetry'; +import { RENAMED_HF_REPOS } from '../../src/fetcher/renamedRepos'; +import { fakeFs, fakeNet } from '../support/blobUtilMock'; + +const org = 'https://huggingface.co/software-mansion/'; + +describe('getResourceCachePath', () => { + it.each(Object.entries(RENAMED_HF_REPOS))('preserves cache identity for %s', (name, alias) => { + const path = '/resolve/v1.0.0/model.pte'; + expect(getResourceCachePath(org + name + path)).toBe(getResourceCachePath(org + alias + path)); + }); + + it('ignores query parameters and keeps different resource URLs distinct', () => { + const source = 'https://example.com/models/one/model.pte'; + expect(getResourceCachePath(source + '?download=true')).toBe(getResourceCachePath(source)); + expect(getResourceCachePath(source)).not.toBe( + getResourceCachePath(source.replace('/one/', '/two/')) + ); + }); + + it('uses the platform persistent directory', () => { + const directory = + Platform.OS === 'android' + ? RNBlobUtil.fs.dirs.SDCardDir || RNBlobUtil.fs.dirs.DocumentDir + : RNBlobUtil.fs.dirs.DocumentDir; + expect(getResourceCachePath('https://example.com/model.pte')).toMatch( + directory + '/react-native-executorch/' + ); + }); + + it('passes through local paths without filesystem or network work', () => { + expect(getResourceCachePath('file:///local/model.pte')).toBe('/local/model.pte'); + expect(getResourceCachePath('/local/model.pte')).toBe('/local/model.pte'); + getResourceCachePath('https://example.com/model.pte'); + expect(fakeFs.paths()).toHaveLength(0); + expect(fakeNet.requests()).toHaveLength(0); + }); + + it('returns the same path as the downloader', async () => { + const source = 'https://example.com/model.pte'; + fakeNet.serve(source, { body: 'model-bytes' }); + setTelemetryEnabled(false); + try { + const path = await download(source); + expect(path).toBe(getResourceCachePath(source)); + expect(fakeFs.readText(path)).toBe('model-bytes'); + } finally { + setTelemetryEnabled(true); + } + }); +}); diff --git a/packages/react-native-executorch/src/fetcher/fetcher.ts b/packages/react-native-executorch/src/fetcher/fetcher.ts index f9900f154b..9d37ea7880 100644 --- a/packages/react-native-executorch/src/fetcher/fetcher.ts +++ b/packages/react-native-executorch/src/fetcher/fetcher.ts @@ -57,7 +57,17 @@ const djb2 = (s: string): number => { const isRemote = (source: string): boolean => /^https?:\/\//.test(source); -function cachePathFor(url: string): string { +/** + * Returns the persistent resource path used by {@link download} without downloading + * or checking whether it exists. Repository aliases and query parameters use the + * same cache identity as downloads. Local paths lose any `file://` prefix. + * @category Utils / Functions + * @param source The remote resource URL or local file path. + * @returns The persistent cache path or the supplied local path. + */ +export function getResourceCachePath(source: string): string { + if (!isRemote(source)) return source.replace(/^file:\/\//, ''); + const url = source; const urlWithoutQuery = url.split('?')[0]!; const basename = urlWithoutQuery.split('/').pop() || 'model'; // Hash guards against basename collisions across different repos/versions. @@ -224,7 +234,7 @@ const inFlight = new Map(); async function downloadUrl(url: string, cb: DownloadUrlCallbacks): Promise { if (cb.signal?.aborted) throw abortError(); - const dest = cachePathFor(url); + const dest = getResourceCachePath(url); if (cb.forceDownload) { // Drop the cached copy so the checks below fall through to a real fetch. @@ -800,7 +810,7 @@ export async function download(source: T, options: DownloadOptions = {}): Pro const measured = await Promise.all( urls.map(async (url) => { if (!options.forceDownload) { - const cached = await fileSize(cachePathFor(url)); + const cached = await fileSize(getResourceCachePath(url)); if (cached > 0) return { size: cached, pending: undefined, cached: true }; } return { size: 0, pending: remoteSize(url, options.signal), cached: false }; diff --git a/packages/react-native-executorch/src/utils.ts b/packages/react-native-executorch/src/utils.ts index 765eb7ff64..5779372969 100644 --- a/packages/react-native-executorch/src/utils.ts +++ b/packages/react-native-executorch/src/utils.ts @@ -8,6 +8,8 @@ import { loadModel } from './core/model'; import type { ModelSpec, ConcreteDim } from './core/schema'; import RNBlobUtil from 'react-native-blob-util'; +export { getResourceCachePath } from './fetcher/fetcher'; + /** * Options accepted by {@link useResourceDownload} and by every `use` hook * built on top of it. From f67d03bc35e5f826ab1aaa932d331598f92f8b21 Mon Sep 17 00:00:00 2001 From: Bartosz Hanc Date: Mon, 2 Nov 2026 12:21:02 +0100 Subject: [PATCH 2/2] fix: preserve local paths in getResourceCachePath --- .../__tests__/fetcher/resourceCachePath.test.ts | 16 +++++++++------- .../src/fetcher/fetcher.ts | 5 +++-- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/react-native-executorch/__tests__/fetcher/resourceCachePath.test.ts b/packages/react-native-executorch/__tests__/fetcher/resourceCachePath.test.ts index 3dcd9e9a27..499d55a612 100644 --- a/packages/react-native-executorch/__tests__/fetcher/resourceCachePath.test.ts +++ b/packages/react-native-executorch/__tests__/fetcher/resourceCachePath.test.ts @@ -32,13 +32,15 @@ describe('getResourceCachePath', () => { ); }); - it('passes through local paths without filesystem or network work', () => { - expect(getResourceCachePath('file:///local/model.pte')).toBe('/local/model.pte'); - expect(getResourceCachePath('/local/model.pte')).toBe('/local/model.pte'); - getResourceCachePath('https://example.com/model.pte'); - expect(fakeFs.paths()).toHaveLength(0); - expect(fakeNet.requests()).toHaveLength(0); - }); + it.each(['file:///local/model.pte', '/local/model.pte'])( + 'passes through %s unchanged, matching the downloader without filesystem or network work', + async (source) => { + expect(getResourceCachePath(source)).toBe(source); + expect(await download(source)).toBe(getResourceCachePath(source)); + expect(fakeFs.paths()).toHaveLength(0); + expect(fakeNet.requests()).toHaveLength(0); + } + ); it('returns the same path as the downloader', async () => { const source = 'https://example.com/model.pte'; diff --git a/packages/react-native-executorch/src/fetcher/fetcher.ts b/packages/react-native-executorch/src/fetcher/fetcher.ts index 9d37ea7880..91ccf2ea77 100644 --- a/packages/react-native-executorch/src/fetcher/fetcher.ts +++ b/packages/react-native-executorch/src/fetcher/fetcher.ts @@ -60,13 +60,14 @@ const isRemote = (source: string): boolean => /^https?:\/\//.test(source); /** * Returns the persistent resource path used by {@link download} without downloading * or checking whether it exists. Repository aliases and query parameters use the - * same cache identity as downloads. Local paths lose any `file://` prefix. + * same cache identity as downloads. Local paths are returned unchanged, including + * any `file://` prefix, matching {@link download}. * @category Utils / Functions * @param source The remote resource URL or local file path. * @returns The persistent cache path or the supplied local path. */ export function getResourceCachePath(source: string): string { - if (!isRemote(source)) return source.replace(/^file:\/\//, ''); + if (!isRemote(source)) return source; const url = source; const urlWithoutQuery = url.split('?')[0]!; const basename = urlWithoutQuery.split('/').pop() || 'model';