From 6956ca07684b3d1de29f567897c395e3bc3a9a54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ahiome=20Israel=20=28OdiB=C3=A0=29=20Christopher?= Date: Sun, 31 May 2026 03:18:04 +0100 Subject: [PATCH] feat(build): inline small image assets under 1KB as data URLs --- App.tsx | 2 + assets/images/test-tiny-icon.png | Bin 0 -> 68 bytes jest.config.js | 2 +- metro.config.js | 8 ++++ src/utils/assetInlinePolyfill.ts | 15 +++++++ tests/utils/assetInlining.test.ts | 51 +++++++++++++++++++++++ tools/metro-plugins/imageInlinePlugin.js | 30 +++++++++++++ 7 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 assets/images/test-tiny-icon.png create mode 100644 src/utils/assetInlinePolyfill.ts create mode 100644 tests/utils/assetInlining.test.ts create mode 100644 tools/metro-plugins/imageInlinePlugin.js diff --git a/App.tsx b/App.tsx index 19b1f43..dd3a73e 100644 --- a/App.tsx +++ b/App.tsx @@ -1,7 +1,9 @@ +import './src/utils/assetInlinePolyfill'; import { StatusBar } from 'expo-status-bar'; import React, { useEffect, useRef } from 'react'; import { Alert, AppState, AppStateStatus, LogBox } from 'react-native'; + import StorybookUI from './.rnstorybook'; import './global.css'; diff --git a/assets/images/test-tiny-icon.png b/assets/images/test-tiny-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..909c66db1740b7c1b41eb4db6c414a7ab5bb6a23 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcwN$DG5Lh8v~O;;{|;n Oi^0>?&t;ucLK6U5DhwL{ literal 0 HcmV?d00001 diff --git a/jest.config.js b/jest.config.js index da5cab8..9233422 100644 --- a/jest.config.js +++ b/jest.config.js @@ -15,7 +15,7 @@ module.exports = { }, transformIgnorePatterns: [ // Transform all expo-* packages and other native modules - 'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(-.*)?|@expo(-.*)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg)', + 'node_modules/(?!(.pnpm/.*?/node_modules/)?((jest-)?react-native|@react-native(-community)?|expo(-.*)?|@expo(-.*)?|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg))', ], collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts', '!src/**/index.ts'], testPathIgnorePatterns: ['/node_modules/'], diff --git a/metro.config.js b/metro.config.js index b52f24d..8738230 100644 --- a/metro.config.js +++ b/metro.config.js @@ -221,4 +221,12 @@ nativewindConfig.serializer.customSerializer = wrapWithRouteSizeAnalyzer( nativewindConfig.serializer.customSerializer, ); +// Register Metro asset inlining plugin for Issue #369 +nativewindConfig.transformer ??= {}; +nativewindConfig.transformer.assetPlugins = [ + ...(nativewindConfig.transformer.assetPlugins || []), + require.resolve('./tools/metro-plugins/imageInlinePlugin.js'), +]; + module.exports = nativewindConfig; + diff --git a/src/utils/assetInlinePolyfill.ts b/src/utils/assetInlinePolyfill.ts new file mode 100644 index 0000000..a4ddc08 --- /dev/null +++ b/src/utils/assetInlinePolyfill.ts @@ -0,0 +1,15 @@ +import AssetSourceResolver from 'react-native/Libraries/Image/AssetSourceResolver'; + +const originalDefaultAsset = AssetSourceResolver.prototype.defaultAsset; + +AssetSourceResolver.prototype.defaultAsset = function () { + try { + if (this.asset && typeof this.asset.base64 === 'string') { + return this.fromSource(this.asset.base64); + } + } catch (error) { + // Non-blocking warning: fall back to normal asset resolution + console.warn('[asset-inline-polyfill] Error resolving inline asset:', error); + } + return originalDefaultAsset.call(this); +}; diff --git a/tests/utils/assetInlining.test.ts b/tests/utils/assetInlining.test.ts new file mode 100644 index 0000000..426cba9 --- /dev/null +++ b/tests/utils/assetInlining.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from '@jest/globals'; +import AssetSourceResolver from 'react-native/Libraries/Image/AssetSourceResolver'; +import '../../src/utils/assetInlinePolyfill'; // Import to apply the polyfill + +describe('Asset Inlining Polyfill', () => { + it('should resolve an inlined asset to its base64 data URL', () => { + const mockInlinedAsset = { + __packager_asset: true, + fileSystemLocation: '/path/to/assets', + httpServerLocation: '/assets', + width: 48, + height: 48, + scales: [1], + hash: '4f1cb2cac2370cd5050681232e8575a8', + name: 'test-tiny-icon', + type: 'png', + base64: 'data:image/png;base64,iVBORw0KGgoAAAANS...', // custom inlined data URL + } as any; + + const resolver = new AssetSourceResolver('http://localhost:8081', null, mockInlinedAsset); + const resolved = resolver.defaultAsset(); + + expect(resolved).toBeDefined(); + expect(resolved.uri).toBe(mockInlinedAsset.base64); + expect(resolved.width).toBe(48); + expect(resolved.height).toBe(48); + }); + + it('should fall back to standard resolution for non-inlined assets', () => { + const mockStandardAsset = { + __packager_asset: true, + fileSystemLocation: '/path/to/assets', + httpServerLocation: '/assets', + width: 48, + height: 48, + scales: [1], + hash: '4f1cb2cac2370cd5050681232e8575a8', + name: 'react-logo', + type: 'png', + // no base64 key + } as any; + + const resolver = new AssetSourceResolver('http://localhost:8081', null, mockStandardAsset); + const resolved = resolver.defaultAsset(); + + expect(resolved).toBeDefined(); + // Default URL-based asset resolution + expect(resolved.uri).toContain('react-logo.png'); + expect(resolved.uri.startsWith('data:')).toBe(false); + }); +}); diff --git a/tools/metro-plugins/imageInlinePlugin.js b/tools/metro-plugins/imageInlinePlugin.js new file mode 100644 index 0000000..ecdb883 --- /dev/null +++ b/tools/metro-plugins/imageInlinePlugin.js @@ -0,0 +1,30 @@ +const fs = require('fs'); + +const INLINE_LIMIT = 1024; // 1 KB +const SUPPORTED_TYPES = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp']); + +module.exports = function imageInlinePlugin(assetData) { + const type = assetData.type.toLowerCase(); + + if (SUPPORTED_TYPES.has(type)) { + const primaryFile = assetData.files[0]; + if (primaryFile && fs.existsSync(primaryFile)) { + try { + const stat = fs.statSync(primaryFile); + if (stat.size < INLINE_LIMIT) { + const mime = type === 'jpg' ? 'jpeg' : type; + const base64 = fs.readFileSync(primaryFile, 'base64'); + assetData.base64 = `data:image/${mime};base64,${base64}`; + } + } catch (error) { + // Non-fatal build warning: must not break compilation + console.warn( + `[image-inline-plugin] Failed to evaluate or inline asset ${primaryFile}:`, + error.message + ); + } + } + } + + return assetData; +};