Skip to content
20 changes: 20 additions & 0 deletions .changeset/fix-fetch-image-blob-arraybuffer.md
Original file line number Diff line number Diff line change
@@ -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`).
118 changes: 118 additions & 0 deletions packages/core/src/__tests__/utils/FetchUtil.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
49 changes: 43 additions & 6 deletions packages/core/src/utils/FetchUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(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;

Expand Down
Loading