diff --git a/.changeset/fix-fetch-image-blob-arraybuffer.md b/.changeset/fix-fetch-image-blob-arraybuffer.md new file mode 100644 index 000000000..34bef3dc3 --- /dev/null +++ b/.changeset/fix-fetch-image-blob-arraybuffer.md @@ -0,0 +1,20 @@ +--- +'@reown/appkit-react-native': patch +'@reown/appkit-bitcoin-react-native': patch +'@reown/appkit-coinbase-react-native': patch +'@reown/appkit-common-react-native': patch +'@reown/appkit-core-react-native': patch +'@reown/appkit-ethers-react-native': patch +'@reown/appkit-solana-react-native': patch +'@reown/appkit-ui-react-native': patch +'@reown/appkit-wagmi-react-native': patch +--- + +fix(core): load wallet & network images on React Native + +`FetchUtil.fetchImage` built its data URL with `response.blob()` + `FileReader`, +but on React Native `fetch(...).blob()` throws "Creating blobs from 'ArrayBuffer' +and 'ArrayBufferView' are not supported" for binary responses — so wallet and +network images silently failed to load (placeholders only). Read the bytes via +`response.arrayBuffer()` and base64-encode them into the data URL instead, using +a dependency-free encoder (RN guarantees neither `Buffer` nor `btoa`). diff --git a/packages/core/src/__tests__/utils/FetchUtil.test.ts b/packages/core/src/__tests__/utils/FetchUtil.test.ts index 0e864fb4d..bab96f430 100644 --- a/packages/core/src/__tests__/utils/FetchUtil.test.ts +++ b/packages/core/src/__tests__/utils/FetchUtil.test.ts @@ -104,4 +104,122 @@ describe('FetchUtil', () => { expect(url).toBe('https://another.com/test?foo=bar&bar=baz&clientId=test-client-id'); }); }); + + describe('fetchImage', () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + }); + + it('should build a base64 data URL from the response ArrayBuffer', async () => { + // "foobar" -> base64 "Zm9vYmFy" + const bytes = new Uint8Array([0x66, 0x6f, 0x6f, 0x62, 0x61, 0x72]); + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + arrayBuffer: async () => bytes.buffer, + headers: { get: () => 'image/png' } + }) as unknown as typeof fetch; + + const fetchUtil = new FetchUtil({ baseUrl }); + const result = await fetchUtil.fetchImage('/getWalletImage/1'); + + expect(result).toBe('data:image/png;base64,Zm9vYmFy'); + }); + + it('should return undefined for a non-ok response', async () => { + const arrayBuffer = jest.fn(); + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + arrayBuffer, + headers: { get: () => 'application/json' } + }) as unknown as typeof fetch; + + const fetchUtil = new FetchUtil({ baseUrl }); + const result = await fetchUtil.fetchImage('/getWalletImage/1'); + + expect(result).toBeUndefined(); + expect(arrayBuffer).not.toHaveBeenCalled(); + }); + + it('should produce correct double-char padding (==) for a one-byte remainder', async () => { + // 'f' -> base64 "Zg==" + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + arrayBuffer: async () => new Uint8Array([0x66]).buffer, + headers: { get: () => 'image/png' } + }) as unknown as typeof fetch; + + const fetchUtil = new FetchUtil({ baseUrl }); + const result = await fetchUtil.fetchImage('/getWalletImage/1'); + + expect(result).toBe('data:image/png;base64,Zg=='); + }); + + it('should produce correct single-char padding (=) for a two-byte remainder', async () => { + // 'fo' -> base64 "Zm8=" + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + arrayBuffer: async () => new Uint8Array([0x66, 0x6f]).buffer, + headers: { get: () => 'image/png' } + }) as unknown as typeof fetch; + + const fetchUtil = new FetchUtil({ baseUrl }); + const result = await fetchUtil.fetchImage('/getWalletImage/1'); + + expect(result).toBe('data:image/png;base64,Zm8='); + }); + + it('should strip content-type parameters when building the data URL', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + arrayBuffer: async () => new Uint8Array([0x66, 0x6f, 0x6f, 0x62, 0x61, 0x72]).buffer, + headers: { get: () => 'image/svg+xml; charset=utf-8' } + }) as unknown as typeof fetch; + + const fetchUtil = new FetchUtil({ baseUrl }); + const result = await fetchUtil.fetchImage('/getWalletImage/1'); + + expect(result).toBe('data:image/svg+xml;base64,Zm9vYmFy'); + }); + + it('should default the content type to image/png when the header is missing', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + arrayBuffer: async () => new Uint8Array([0x66, 0x6f, 0x6f, 0x62, 0x61, 0x72]).buffer, + headers: { get: () => null } + }) as unknown as typeof fetch; + + const fetchUtil = new FetchUtil({ baseUrl }); + const result = await fetchUtil.fetchImage('/getWalletImage/1'); + + expect(result).toBe('data:image/png;base64,Zm9vYmFy'); + }); + + it('should not call response.blob() (RN cannot build a Blob from an ArrayBuffer)', async () => { + const blob = jest.fn(); + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer, + headers: { get: () => 'image/webp' }, + blob + }) as unknown as typeof fetch; + + const fetchUtil = new FetchUtil({ baseUrl }); + await fetchUtil.fetchImage('/getWalletImage/1'); + + expect(blob).not.toHaveBeenCalled(); + }); + + it('should return undefined when the request throws', async () => { + global.fetch = jest + .fn() + .mockRejectedValue(new Error('network error')) as unknown as typeof fetch; + + const fetchUtil = new FetchUtil({ baseUrl }); + const result = await fetchUtil.fetchImage('/getWalletImage/1'); + + expect(result).toBeUndefined(); + }); + }); }); diff --git a/packages/core/src/utils/FetchUtil.ts b/packages/core/src/utils/FetchUtil.ts index f0b3451c2..7fd0c3805 100644 --- a/packages/core/src/utils/FetchUtil.ts +++ b/packages/core/src/utils/FetchUtil.ts @@ -80,18 +80,55 @@ export class FetchUtil { try { const url = this.createUrl({ path, params }).toString(); const response = await fetch(url, { headers }); - const blob = await response.blob(); - const reader = new FileReader(); - reader.readAsDataURL(blob); - return new Promise(resolve => { - reader.onloadend = () => resolve(reader.result as string); - }); + // Bail on non-2xx so error bodies aren't base64-encoded into a bogus data + // URL (RN's `Image` would silently fail instead of showing the placeholder). + if (!response.ok) { + return undefined; + } + + // React Native's `fetch(...).blob()` throws "Creating blobs from + // 'ArrayBuffer' and 'ArrayBufferView' are not supported" for binary + // responses, so Blob + FileReader can't be used to build the data URL + // (wallet/network images would silently fail to load). Read the bytes as + // an ArrayBuffer and base64-encode them into a data URL instead. + const arrayBuffer = await response.arrayBuffer(); + // Strip any `; charset=...`/`; quality=...` parameters so the media type + // doesn't leak into the data URL as `data:image/svg+xml; charset=utf-8;base64,...`, + // which strict parsers may reject. + const rawContentType = response.headers.get('content-type') ?? 'image/png'; + const contentType = rawContentType.split(';')[0]?.trim() || 'image/png'; + + return `data:${contentType};base64,${FetchUtil._arrayBufferToBase64(arrayBuffer)}`; } catch { return undefined; } } + // Dependency-free base64 encoder (RN has no global `Buffer`/`btoa` guarantee). + /* eslint-disable no-bitwise */ + private static _arrayBufferToBase64(buffer: ArrayBuffer): string { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + const bytes = new Uint8Array(buffer); + const parts: string[] = []; + + for (let i = 0; i < bytes.length; i += 3) { + const byte0 = bytes[i] as number; + const byte1 = i + 1 < bytes.length ? (bytes[i + 1] as number) : 0; + const byte2 = i + 2 < bytes.length ? (bytes[i + 2] as number) : 0; + + parts.push(chars[byte0 >> 2] as string); + parts.push(chars[((byte0 & 0x03) << 4) | (byte1 >> 4)] as string); + parts.push( + i + 1 < bytes.length ? (chars[((byte1 & 0x0f) << 2) | (byte2 >> 6)] as string) : '=' + ); + parts.push(i + 2 < bytes.length ? (chars[byte2 & 0x3f] as string) : '='); + } + + return parts.join(''); + } + /* eslint-enable no-bitwise */ + public createUrl({ path, params }: RequestArguments) { let fullUrl: string;