From 13d99a61e81aaa9cefd3bfd5c70f480803263bab Mon Sep 17 00:00:00 2001 From: bartekkrok Date: Tue, 7 Apr 2026 11:46:36 +0200 Subject: [PATCH 1/2] add maxInlineSize option for size-based asset inlining Assets whose largest scale variant is within the threshold are inlined as base64 URIs; larger assets are extracted as separate files. The option is supported in both the assets-loader directly and via getAssetTransformRules. --- .../src/loaders/assetsLoader/assetsLoader.ts | 10 +++- .../src/loaders/assetsLoader/options.ts | 2 + .../src/utils/getAssetTransformRules.ts | 13 +++++- .../src/latest/docs/guides/inline-assets.md | 46 ++++++++++++++++++- 4 files changed, 66 insertions(+), 5 deletions(-) diff --git a/packages/repack/src/loaders/assetsLoader/assetsLoader.ts b/packages/repack/src/loaders/assetsLoader/assetsLoader.ts index 919ddf87e..6bf584a5e 100644 --- a/packages/repack/src/loaders/assetsLoader/assetsLoader.ts +++ b/packages/repack/src/loaders/assetsLoader/assetsLoader.ts @@ -222,8 +222,16 @@ export default async function repackAssetsLoader( }) ); + const largestVariantSize = Math.max( + ...assets.map((asset) => asset.data.length) + ); + const shouldInlineAsset = + options.inline === true || + (options.maxInlineSize !== undefined && + largestVariantSize <= options.maxInlineSize); + let result: string; - if (options.inline) { + if (shouldInlineAsset) { logger.debug(`Inlining assets for request ${resourcePath}`); result = inlineAssets({ assets, resourcePath }); } else { diff --git a/packages/repack/src/loaders/assetsLoader/options.ts b/packages/repack/src/loaders/assetsLoader/options.ts index 6f7632771..3b9f4b66a 100644 --- a/packages/repack/src/loaders/assetsLoader/options.ts +++ b/packages/repack/src/loaders/assetsLoader/options.ts @@ -18,6 +18,7 @@ export interface AssetLoaderOptions { scalableAssetExtensions?: string[]; scalableAssetResolutions?: string[]; inline?: boolean; + maxInlineSize?: number; publicPath?: string; remote?: AssetLoaderRemoteOptions; } @@ -39,6 +40,7 @@ export const optionsSchema: Schema = { type: 'array', }, inline: { type: 'boolean' }, + maxInlineSize: { type: 'number' }, publicPath: { type: 'string' }, remote: { type: 'object', diff --git a/packages/repack/src/utils/getAssetTransformRules.ts b/packages/repack/src/utils/getAssetTransformRules.ts index efed8ca03..8a05e5b5a 100644 --- a/packages/repack/src/utils/getAssetTransformRules.ts +++ b/packages/repack/src/utils/getAssetTransformRules.ts @@ -39,6 +39,13 @@ interface GetAssetTransformRulesOptions { */ inline?: boolean; + /** + * Maximum asset file size in bytes to inline as base64 URIs. + * Assets larger than this threshold will be extracted as separate files. + * Mutually exclusive with `inline`. + */ + maxInlineSize?: number; + /** * Configuration for remote asset loading. */ @@ -57,7 +64,8 @@ interface GetAssetTransformRulesOptions { * Creates `module.rules` configuration for handling assets in React Native applications. * * @param options Configuration options - * @param options.inline Whether to inline assets as base64 URIs (defaults to false) + * @param options.inline Whether to inline all assets as base64 URIs (defaults to false) + * @param options.maxInlineSize Maximum asset file size in bytes to inline as base64 URIs; larger assets are extracted as separate files * @param options.remote Configuration for remote asset loading with publicPath and optional assetPath function * @param options.svg Determines how SVG files should be processed ('svgr', 'xml', or 'uri') * @@ -65,6 +73,7 @@ interface GetAssetTransformRulesOptions { */ export function getAssetTransformRules({ inline, + maxInlineSize, remote, svg, }: GetAssetTransformRulesOptions = {}) { @@ -85,7 +94,7 @@ export function getAssetTransformRules({ test: getAssetExtensionsRegExp(extensions), use: { loader: '@callstack/repack/assets-loader', - options: { inline, remote: remoteOptions }, + options: { inline, maxInlineSize, remote: remoteOptions }, }, }); diff --git a/website/src/latest/docs/guides/inline-assets.md b/website/src/latest/docs/guides/inline-assets.md index 87f9b6286..fe9a96482 100644 --- a/website/src/latest/docs/guides/inline-assets.md +++ b/website/src/latest/docs/guides/inline-assets.md @@ -61,13 +61,55 @@ import image from './image.png'; The value of `image` in this example would be either an object with `uri`, `width`, `height` and `scale` or an array of such objects, in case there are multiple scales. -## Selective inlining +## Size-based inlining + +Instead of inlining all assets unconditionally, you can use `maxInlineSize` to set a file size threshold in bytes. Assets whose **largest scale variant** is smaller than or equal to the threshold will be inlined; larger assets will be extracted as separate files. + +```js title="rspack.config.cjs" +const Repack = require("@callstack/repack"); + +module.exports = { + module: { + rules: [ + { + test: Repack.getAssetExtensionsRegExp(), + use: { + loader: "@callstack/repack/assets-loader", + options: { maxInlineSize: 20 * 1024 }, // inline assets up to 20 KB + }, + }, + ], + }, +}; +``` + +Or via the helper: + +```js title="rspack.config.cjs" +const Repack = require("@callstack/repack"); + +module.exports = { + module: { + rules: [...Repack.getAssetTransformRules({ maxInlineSize: 20 * 1024 })], + }, +}; +``` + +:::info Scale variants and the size threshold + +The threshold is compared against the **largest scale variant** of the asset (e.g. `@3x`), not the `@1x` file. This is intentional — when an asset is inlined, all scale variants are embedded into the bundle, so the largest one is what determines the worst-case size impact. + +For example, a `@1x` PNG that is 10 KB may have a `@3x` variant of 80 KB. With `maxInlineSize: 20 * 1024` the asset would be extracted, not inlined, because the `@3x` variant exceeds the threshold. + +::: + +## Selective inlining by path You can provide multiple rules with Re.Pack's [Assets loader](/api/loaders/assets-loader) - one rule would extract the assets and another would inline them. There's no limit how many of these rules you could have. Make sure you configure those rules not to overlap, so that any single asset is only processed by one rule (by one [Assets loader](/api/loaders/assets-loader)). Use combination of `include`, `exclude` and `test` (for extensions matching) to configure each rule. -```js title="rspack.config.mjs" +```js title="rspack.config.cjs" const Repack = require("@callstack/repack"); module.exports = { From 4521cff2bdc5904b0c5afefaeedaf01f14a488b5 Mon Sep 17 00:00:00 2001 From: bartekkrok Date: Fri, 17 Apr 2026 15:02:05 +0200 Subject: [PATCH 2/2] Add changeset, refer to code review and add tests for maxInlineSize option --- .changeset/add-max-inline-size-option.md | 5 ++ .../src/loaders/assetsLoader/assetsLoader.ts | 4 +- .../getAssetTransformRules.test.ts.snap | 23 ++++++ .../__tests__/getAssetTransformRules.test.ts | 8 ++ .../rspack/assets-loader.test.ts.snap | 75 +++++++++++++++++++ .../webpack/assets-loader.test.ts.snap | 75 +++++++++++++++++++ .../src/loaders/assets-loader.test.ts | 69 ++++++++++++++++- 7 files changed, 256 insertions(+), 3 deletions(-) create mode 100644 .changeset/add-max-inline-size-option.md diff --git a/.changeset/add-max-inline-size-option.md b/.changeset/add-max-inline-size-option.md new file mode 100644 index 000000000..41a097b67 --- /dev/null +++ b/.changeset/add-max-inline-size-option.md @@ -0,0 +1,5 @@ +--- +"@callstack/repack": minor +--- + +Add `maxInlineSize` option to assets loader for size-based asset inlining. Assets whose largest variant is within the threshold are inlined as base64 URIs; larger assets are extracted as separate files. diff --git a/packages/repack/src/loaders/assetsLoader/assetsLoader.ts b/packages/repack/src/loaders/assetsLoader/assetsLoader.ts index 6bf584a5e..b9f53cdb0 100644 --- a/packages/repack/src/loaders/assetsLoader/assetsLoader.ts +++ b/packages/repack/src/loaders/assetsLoader/assetsLoader.ts @@ -226,8 +226,8 @@ export default async function repackAssetsLoader( ...assets.map((asset) => asset.data.length) ); const shouldInlineAsset = - options.inline === true || - (options.maxInlineSize !== undefined && + !!options.inline || + (!!options.maxInlineSize && largestVariantSize <= options.maxInlineSize); let result: string; diff --git a/packages/repack/src/utils/__tests__/__snapshots__/getAssetTransformRules.test.ts.snap b/packages/repack/src/utils/__tests__/__snapshots__/getAssetTransformRules.test.ts.snap index 831420881..c99a6420e 100644 --- a/packages/repack/src/utils/__tests__/__snapshots__/getAssetTransformRules.test.ts.snap +++ b/packages/repack/src/utils/__tests__/__snapshots__/getAssetTransformRules.test.ts.snap @@ -8,6 +8,7 @@ exports[`getAssetTransformRules should add SVGR rule when svg="svgr" 1`] = ` "loader": "@callstack/repack/assets-loader", "options": { "inline": undefined, + "maxInlineSize": undefined, "remote": undefined, }, }, @@ -32,6 +33,7 @@ exports[`getAssetTransformRules should add URI rule when svg="uri" 1`] = ` "loader": "@callstack/repack/assets-loader", "options": { "inline": undefined, + "maxInlineSize": undefined, "remote": undefined, }, }, @@ -51,6 +53,7 @@ exports[`getAssetTransformRules should add XML rule when svg="xml" 1`] = ` "loader": "@callstack/repack/assets-loader", "options": { "inline": undefined, + "maxInlineSize": undefined, "remote": undefined, }, }, @@ -70,6 +73,7 @@ exports[`getAssetTransformRules should include additional options for SVGR 1`] = "loader": "@callstack/repack/assets-loader", "options": { "inline": undefined, + "maxInlineSize": undefined, "remote": undefined, }, }, @@ -95,6 +99,7 @@ exports[`getAssetTransformRules should return default asset transform rules when "loader": "@callstack/repack/assets-loader", "options": { "inline": undefined, + "maxInlineSize": undefined, "remote": undefined, }, }, @@ -110,6 +115,23 @@ exports[`getAssetTransformRules should return rules with inline option when prov "loader": "@callstack/repack/assets-loader", "options": { "inline": true, + "maxInlineSize": undefined, + "remote": undefined, + }, + }, + }, +] +`; + +exports[`getAssetTransformRules should return rules with maxInlineSize option when provided 1`] = ` +[ + { + "test": /\\\\\\.\\(bmp\\|gif\\|jpg\\|jpeg\\|png\\|psd\\|svg\\|webp\\|tiff\\|m4v\\|mov\\|mp4\\|mpeg\\|mpg\\|webm\\|aac\\|aiff\\|caf\\|m4a\\|mp3\\|wav\\|html\\|pdf\\|yaml\\|yml\\|otf\\|ttf\\|zip\\|obj\\)\\$/, + "use": { + "loader": "@callstack/repack/assets-loader", + "options": { + "inline": undefined, + "maxInlineSize": 1024, "remote": undefined, }, }, @@ -125,6 +147,7 @@ exports[`getAssetTransformRules should return rules with remote options when pro "loader": "@callstack/repack/assets-loader", "options": { "inline": undefined, + "maxInlineSize": undefined, "remote": { "enabled": true, "publicPath": "https://example.com/assets", diff --git a/packages/repack/src/utils/__tests__/getAssetTransformRules.test.ts b/packages/repack/src/utils/__tests__/getAssetTransformRules.test.ts index 0f4c53abb..63d20826c 100644 --- a/packages/repack/src/utils/__tests__/getAssetTransformRules.test.ts +++ b/packages/repack/src/utils/__tests__/getAssetTransformRules.test.ts @@ -14,6 +14,14 @@ describe('getAssetTransformRules', () => { expect(rules).toMatchSnapshot(); }); + it('should return rules with maxInlineSize option when provided', () => { + const rules = getAssetTransformRules({ maxInlineSize: 1024 }); + + // @ts-ignore + expect(rules[0]?.use?.options?.maxInlineSize).toEqual(1024); + expect(rules).toMatchSnapshot(); + }); + it('should return rules with remote options when provided', () => { const remoteOptions = { publicPath: 'https://example.com/assets' }; const rules = getAssetTransformRules({ remote: remoteOptions }); diff --git a/tests/integration/src/loaders/__snapshots__/rspack/assets-loader.test.ts.snap b/tests/integration/src/loaders/__snapshots__/rspack/assets-loader.test.ts.snap index 7a4a7b268..06912bdbf 100644 --- a/tests/integration/src/loaders/__snapshots__/rspack/assets-loader.test.ts.snap +++ b/tests/integration/src/loaders/__snapshots__/rspack/assets-loader.test.ts.snap @@ -418,3 +418,78 @@ exports[`assetLoader > should inline asset > without scales 2`] = ` └─ out/ └─ main.js" `; + +exports[`assetLoader > should inline asset based on maxInlineSize > extracts asset when size exceeds threshold 1`] = ` +{ + "__packager_asset": true, + "hash": "373411381c08b2c5034c814c24c26b19", + "height": 51, + "httpServerLocation": "assets/__fixtures__/assets", + "name": "logo", + "scales": [ + 1, + ], + "type": "png", + "width": 297, +} +`; + +exports[`assetLoader > should inline asset based on maxInlineSize > extracts asset when size exceeds threshold 2`] = ` +"/ +└─ out/ + ├─ drawable-mdpi/ + │ └─ __fixtures___assets_logo.png + └─ main.js" +`; + +exports[`assetLoader > should inline asset based on maxInlineSize > inlines asset when size is within threshold 1`] = ` +{ + "__packager_asset": true, + "hash": "373411381c08b2c5034c814c24c26b19", + "height": 51, + "httpServerLocation": "assets/__fixtures__/assets", + "name": "logo", + "scales": [ + 1, + ], + "type": "png", + "width": 297, +} +`; + +exports[`assetLoader > should inline asset based on maxInlineSize > inlines asset when size is within threshold 2`] = ` +"/ +└─ out/ + ├─ drawable-mdpi/ + │ └─ __fixtures___assets_logo.png + └─ main.js" +`; + +exports[`assetLoader > should inline asset based on maxInlineSize > uses largest variant to determine threshold (multi-scale asset) 1`] = ` +{ + "__packager_asset": true, + "hash": "86abef08a42e972a96c958d6fa9d43da,02ad731a8881911e488b51c48a4cc6c1,d9f7c31eebb3a41cc180431867b04f38", + "height": 272, + "httpServerLocation": "assets/__fixtures__/assets", + "name": "star", + "scales": [ + 1, + 2, + 3, + ], + "type": "png", + "width": 286, +} +`; + +exports[`assetLoader > should inline asset based on maxInlineSize > uses largest variant to determine threshold (multi-scale asset) 2`] = ` +"/ +└─ out/ + ├─ drawable-mdpi/ + │ └─ __fixtures___assets_star.png + ├─ drawable-xhdpi/ + │ └─ __fixtures___assets_star.png + ├─ drawable-xxhdpi/ + │ └─ __fixtures___assets_star.png + └─ main.js" +`; diff --git a/tests/integration/src/loaders/__snapshots__/webpack/assets-loader.test.ts.snap b/tests/integration/src/loaders/__snapshots__/webpack/assets-loader.test.ts.snap index 7a4a7b268..06912bdbf 100644 --- a/tests/integration/src/loaders/__snapshots__/webpack/assets-loader.test.ts.snap +++ b/tests/integration/src/loaders/__snapshots__/webpack/assets-loader.test.ts.snap @@ -418,3 +418,78 @@ exports[`assetLoader > should inline asset > without scales 2`] = ` └─ out/ └─ main.js" `; + +exports[`assetLoader > should inline asset based on maxInlineSize > extracts asset when size exceeds threshold 1`] = ` +{ + "__packager_asset": true, + "hash": "373411381c08b2c5034c814c24c26b19", + "height": 51, + "httpServerLocation": "assets/__fixtures__/assets", + "name": "logo", + "scales": [ + 1, + ], + "type": "png", + "width": 297, +} +`; + +exports[`assetLoader > should inline asset based on maxInlineSize > extracts asset when size exceeds threshold 2`] = ` +"/ +└─ out/ + ├─ drawable-mdpi/ + │ └─ __fixtures___assets_logo.png + └─ main.js" +`; + +exports[`assetLoader > should inline asset based on maxInlineSize > inlines asset when size is within threshold 1`] = ` +{ + "__packager_asset": true, + "hash": "373411381c08b2c5034c814c24c26b19", + "height": 51, + "httpServerLocation": "assets/__fixtures__/assets", + "name": "logo", + "scales": [ + 1, + ], + "type": "png", + "width": 297, +} +`; + +exports[`assetLoader > should inline asset based on maxInlineSize > inlines asset when size is within threshold 2`] = ` +"/ +└─ out/ + ├─ drawable-mdpi/ + │ └─ __fixtures___assets_logo.png + └─ main.js" +`; + +exports[`assetLoader > should inline asset based on maxInlineSize > uses largest variant to determine threshold (multi-scale asset) 1`] = ` +{ + "__packager_asset": true, + "hash": "86abef08a42e972a96c958d6fa9d43da,02ad731a8881911e488b51c48a4cc6c1,d9f7c31eebb3a41cc180431867b04f38", + "height": 272, + "httpServerLocation": "assets/__fixtures__/assets", + "name": "star", + "scales": [ + 1, + 2, + 3, + ], + "type": "png", + "width": 286, +} +`; + +exports[`assetLoader > should inline asset based on maxInlineSize > uses largest variant to determine threshold (multi-scale asset) 2`] = ` +"/ +└─ out/ + ├─ drawable-mdpi/ + │ └─ __fixtures___assets_star.png + ├─ drawable-xhdpi/ + │ └─ __fixtures___assets_star.png + ├─ drawable-xxhdpi/ + │ └─ __fixtures___assets_star.png + └─ main.js" +`; diff --git a/tests/integration/src/loaders/assets-loader.test.ts b/tests/integration/src/loaders/assets-loader.test.ts index e6995732a..4cc781d76 100644 --- a/tests/integration/src/loaders/assets-loader.test.ts +++ b/tests/integration/src/loaders/assets-loader.test.ts @@ -25,7 +25,8 @@ async function compileBundle( resourceExtensionType: string; }) => string; publicPath: string; - } + }, + maxInlineSize?: number ) { const virtualPlugin = await createVirtualModulePlugin(virtualModules); @@ -49,6 +50,7 @@ async function compileBundle( platform, inline, remote, + maxInlineSize, }, }, }, @@ -147,6 +149,71 @@ describe('assetLoader', () => { }); }); + describe('should inline asset based on maxInlineSize', () => { + it('inlines asset when size is within threshold', async () => { + // logo.android.png is 1948 bytes — threshold above it triggers inline + const { code, volume } = await compileBundle( + 'android', + { + ...getReactNativeVirtualModules(), + './index.js': + "export { default } from './__fixtures__/assets/logo.png';", + }, + undefined, + undefined, + 2000 + ); + + const context: { Export?: { default: Record } } = {}; + vm.runInNewContext(code, context); + + expect(context.Export?.default).toMatchSnapshot(); + expect(volume.toTree()).toMatchSnapshot(); + }); + + it('extracts asset when size exceeds threshold', async () => { + // logo.android.png is 1948 bytes — threshold below it prevents inline + const { code, volume } = await compileBundle( + 'android', + { + ...getReactNativeVirtualModules(), + './index.js': + "export { default } from './__fixtures__/assets/logo.png';", + }, + undefined, + undefined, + 1000 + ); + + const context: { Export?: { default: Record } } = {}; + vm.runInNewContext(code, context); + + expect(context.Export?.default).toMatchSnapshot(); + expect(volume.toTree()).toMatchSnapshot(); + }); + + it('uses largest variant to determine threshold (multi-scale asset)', async () => { + // star@3x.png is 21176 bytes — the largest variant determines whether to inline + const { code, volume } = await compileBundle( + 'android', + { + ...getReactNativeVirtualModules(), + './index.js': + "export { default } from './__fixtures__/assets/star.png';", + }, + undefined, + undefined, + 10000 // below star@3x (21176 bytes), so should extract + ); + + const context: { Export?: { default: Record } } = {}; + vm.runInNewContext(code, context); + + expect(context.Export?.default).toMatchSnapshot(); + expect(volume.toTree()).toMatchSnapshot(); + }); + }); + describe('should convert to remote-asset', () => { it('without scales', async () => { const { code, volume } = await compileBundle(