Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/quiet-fonts-stay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@callstack/repack': patch
---

Emit an additional `res/raw` copy of bundled Android font binaries so Metro-compatible consumers such as Skia can load them by name. Preserve the existing `res/font` output for native `@font/...`, `R.font`, and XML font-family references. Generate `res/raw/keep.xml` for bundled Android resources so resource shrinking preserves both font copies and other assets loaded by name at runtime.

XML font-family resources continue to use `res/font`. Asset metadata and iOS, development-server, inline, and remote-loader output paths are unchanged.
1 change: 1 addition & 0 deletions apps/tester-app/__tests__/bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ describe('bundle command', () => {
'react-native-bundle-output/drawable-mdpi/src_assetstest_localassets_webpack.png',
'react-native-bundle-output/drawable-xxhdpi/src_assetstest_localassets_webpack.png',
'react-native-bundle-output/drawable-xhdpi/src_assetstest_localassets_webpack.png',
'react-native-bundle-output/raw/keep.xml',
'remote-assets/assets/src/assetsTest/remoteAssets/webpack.png',
'remote-assets/assets/src/assetsTest/remoteAssets/webpack@2x.png',
'remote-assets/assets/src/assetsTest/remoteAssets/webpack@3x.png',
Expand Down
14 changes: 14 additions & 0 deletions packages/repack/src/loaders/assetsLoader/assetsLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,20 @@ export default async function repackAssetsLoader(

// Assets are emitted relatively to `output.path`.
this.emitFile(filename, data ?? '');

if (
platform === 'android' &&
path.dirname(filename) === 'font' &&
testFonts.test(filename)
) {
const rawFilename = path.join('raw', path.basename(filename));

logger.debug(
`Emitting asset ${rawFilename} for request ${resourcePath}`
);

this.emitFile(rawFilename, data ?? '');
}
}

if (options.remote?.enabled) {
Expand Down
2 changes: 2 additions & 0 deletions packages/repack/src/plugins/OutputPlugin/OutputPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,8 @@ export class OutputPlugin {
});
}

localAssetsCopyProcessor?.enqueueAndroidKeepFile();

for (const chunk of remoteChunks) {
const specs = matchChunkToSpecs(chunk, this.remoteSpecs);

Expand Down
36 changes: 36 additions & 0 deletions packages/repack/src/plugins/utils/AssetsCopyProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@ import fs from 'node:fs';
import path from 'node:path';
import type { StatsChunk } from '@rspack/core';

const androidResourceRegExp =
/^(drawable|raw|font)(?:-[^/]+)?\/([^/]+)\.[^/.]+$/;

export class AssetsCopyProcessor {
queue: Array<() => Promise<void>> = [];
private readonly androidResources = new Set<string>();

constructor(
public readonly config: {
Expand Down Expand Up @@ -140,6 +144,16 @@ export class AssetsCopyProcessor {
.filter((file) => !/\.(map|bundle\.json)$/.test(file))
.filter((file) => !/^remote-assets/.test(file));

if (platform === 'android') {
for (const asset of mediaAssets) {
const resource = androidResourceRegExp.exec(asset.replace(/\\/g, '/'));

if (resource) {
this.androidResources.add(`@${resource[1]}/${resource[2]}`);
}
}
}

this.queue.push(
...mediaAssets.map(
(asset) => () =>
Expand Down Expand Up @@ -193,6 +207,28 @@ export class AssetsCopyProcessor {
}
}

// Resources loaded by name from JavaScript are invisible to the resource shrinker.
// This mirrors Metro's createKeepFileAsync.
enqueueAndroidKeepFile() {
if (!this.androidResources.size) {
return;
}

const keepPath = path.join(this.config.assetsDest, 'raw', 'keep.xml');
const resources = [...this.androidResources].sort().join(',');

this.queue.push(async () => {
await this.filesystem.promises.mkdir(path.dirname(keepPath), {
recursive: true,
});

await this.filesystem.promises.writeFile(
keepPath,
`<resources xmlns:tools="http://schemas.android.com/tools" tools:keep="${resources}" />\n`
);
});
}

execute() {
const queue = this.queue;
this.queue = [];
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import fs from 'node:fs';
import path from 'node:path';
import type { StatsChunk } from '@rspack/core';
import { vol } from 'memfs';
import { AssetsCopyProcessor } from '../AssetsCopyProcessor.js';

jest.mock('node:fs', () => jest.requireActual('memfs').fs);
Expand All @@ -13,8 +15,25 @@ const write = (path: string, content: string) => {
const read = (path: string) => {
return fs.readFileSync(path, 'utf-8');
};
const makeChunk = (
file: string,
auxiliaryFiles: string[],
isEntry = true
): StatsChunk => ({
type: 'chunk',
rendered: true,
initial: isEntry,
entry: isEntry,
size: 0,
files: [file],
auxiliaryFiles,
});

describe('AssetsCopyProcessor', () => {
beforeEach(() => {
vol.reset();
});

describe('for ios', () => {
const acpConfigStub = {
platform: 'ios',
Expand Down Expand Up @@ -146,6 +165,7 @@ describe('AssetsCopyProcessor', () => {
} as unknown as StatsChunk,
{ isEntry: true, sourceMapFile: 'index.bundle.map' }
);
acp.enqueueAndroidKeepFile();
await Promise.all(acp.execute());

expect(
Expand All @@ -161,6 +181,112 @@ describe('AssetsCopyProcessor', () => {
'/target/generated/res/react/release/drawable-mdpi/node_modules_reactnative_libraries_newappscreen_components_logo.png'
)
).toEqual('image');
expect(read('/target/generated/res/react/release/raw/keep.xml')).toEqual(
'<resources xmlns:tools="http://schemas.android.com/tools" tools:keep="@drawable/node_modules_reactnative_libraries_newappscreen_components_logo" />\n'
);
});

it('should keep resources from all local chunks, deduplicating density variants', async () => {
const entryAssets = [
'drawable-mdpi/assets_logo.png',
'raw/assets_inter.otf',
'font/assets_inter.otf',
'index.bundle.map',
'index.bundle.json',
'remote-assets/assets/remote.png',
];
const chunkAssets = [
'drawable-xhdpi/assets_logo.png',
'raw/assets_clip.mp4',
'font/assets_family.xml',
];
for (const file of [
'index.bundle',
'async.chunk.bundle',
...entryAssets,
...chunkAssets,
]) {
mkdirp(path.dirname(`/dist/${file}`));
write(`/dist/${file}`, 'content');
}

const acp = new AssetsCopyProcessor(acpConfigStub, fs as any);
acp.enqueueChunk(makeChunk('index.bundle', entryAssets), {
isEntry: true,
sourceMapFile: 'index.bundle.map',
});
acp.enqueueChunk(makeChunk('async.chunk.bundle', chunkAssets, false), {
isEntry: false,
});
acp.enqueueAndroidKeepFile();
await Promise.all(acp.execute());

expect(read(`${acpConfigStub.assetsDest}/raw/keep.xml`)).toEqual(
'<resources xmlns:tools="http://schemas.android.com/tools" tools:keep="@drawable/assets_logo,@font/assets_family,@font/assets_inter,@raw/assets_clip,@raw/assets_inter" />\n'
);
expect(read(`${acpConfigStub.assetsDest}/raw/assets_inter.otf`)).toBe(
'content'
);
expect(read(`${acpConfigStub.assetsDest}/font/assets_inter.otf`)).toBe(
'content'
);
expect(
fs.existsSync(
`${acpConfigStub.assetsDest}/remote-assets/assets/remote.png`
)
).toBe(false);
});

it.each([{ assets: [] }, { assets: ['assets/Inter.otf'] }])(
'should not create a keep file when no Android resources are copied ($assets)',
async ({ assets }) => {
mkdirp('/dist/assets');
write('/dist/index.bundle', 'bundle');
write('/dist/assets/Inter.otf', 'font');
const acp = new AssetsCopyProcessor(acpConfigStub, fs as any);
acp.enqueueChunk(makeChunk('index.bundle', assets), { isEntry: true });
acp.enqueueAndroidKeepFile();
await Promise.all(acp.execute());

expect(fs.existsSync(`${acpConfigStub.assetsDest}/raw/keep.xml`)).toBe(
false
);
}
);

it('should not create a keep file for iOS', async () => {
mkdirp('/dist/raw');
write('/dist/index.bundle', 'bundle');
write('/dist/raw/inter.otf', 'font');
const acp = new AssetsCopyProcessor(
{ ...acpConfigStub, platform: 'ios' },
fs as any
);
acp.enqueueChunk(makeChunk('index.bundle', ['raw/inter.otf']), {
isEntry: true,
});
acp.enqueueAndroidKeepFile();
await Promise.all(acp.execute());

expect(fs.existsSync(`${acpConfigStub.assetsDest}/raw/keep.xml`)).toBe(
false
);
});

it('should not create a keep file unless it is enqueued', async () => {
mkdirp('/dist/raw');
write('/dist/index.bundle', 'bundle');
write('/dist/raw/inter.otf', 'font');
const acp = new AssetsCopyProcessor(acpConfigStub, fs as any);
acp.enqueueChunk(makeChunk('index.bundle', ['raw/inter.otf']), {
isEntry: true,
});
await Promise.all(acp.execute());

expect(read(`${acpConfigStub.assetsDest}/raw/inter.otf`)).toBe('font');
expect(fs.existsSync(`${acpConfigStub.assetsDest}/raw/keep.xml`)).toBe(
false
);
});

it("should copy regular chunk's files into correct directories", async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
font fixture otf
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
font fixture ttc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
font fixture ttf
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<font-family></font-family>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<shape></shape>
Loading
Loading