diff --git a/.changeset/quiet-fonts-stay.md b/.changeset/quiet-fonts-stay.md new file mode 100644 index 000000000..40e21f2c6 --- /dev/null +++ b/.changeset/quiet-fonts-stay.md @@ -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. diff --git a/apps/tester-app/__tests__/bundle.test.ts b/apps/tester-app/__tests__/bundle.test.ts index c06926daa..61d533f51 100644 --- a/apps/tester-app/__tests__/bundle.test.ts +++ b/apps/tester-app/__tests__/bundle.test.ts @@ -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', diff --git a/packages/repack/src/loaders/assetsLoader/assetsLoader.ts b/packages/repack/src/loaders/assetsLoader/assetsLoader.ts index 2376a6334..3c4724fdd 100644 --- a/packages/repack/src/loaders/assetsLoader/assetsLoader.ts +++ b/packages/repack/src/loaders/assetsLoader/assetsLoader.ts @@ -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) { diff --git a/packages/repack/src/plugins/OutputPlugin/OutputPlugin.ts b/packages/repack/src/plugins/OutputPlugin/OutputPlugin.ts index 49ddb2134..b5c1768cd 100644 --- a/packages/repack/src/plugins/OutputPlugin/OutputPlugin.ts +++ b/packages/repack/src/plugins/OutputPlugin/OutputPlugin.ts @@ -271,6 +271,8 @@ export class OutputPlugin { }); } + localAssetsCopyProcessor?.enqueueAndroidKeepFile(); + for (const chunk of remoteChunks) { const specs = matchChunkToSpecs(chunk, this.remoteSpecs); diff --git a/packages/repack/src/plugins/utils/AssetsCopyProcessor.ts b/packages/repack/src/plugins/utils/AssetsCopyProcessor.ts index 5d5174c98..0261e2697 100644 --- a/packages/repack/src/plugins/utils/AssetsCopyProcessor.ts +++ b/packages/repack/src/plugins/utils/AssetsCopyProcessor.ts @@ -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> = []; + private readonly androidResources = new Set(); constructor( public readonly config: { @@ -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) => () => @@ -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, + `\n` + ); + }); + } + execute() { const queue = this.queue; this.queue = []; diff --git a/packages/repack/src/plugins/utils/__tests__/AssetsCopyProcessor.test.ts b/packages/repack/src/plugins/utils/__tests__/AssetsCopyProcessor.test.ts index 236890928..6a2ba9b7b 100644 --- a/packages/repack/src/plugins/utils/__tests__/AssetsCopyProcessor.test.ts +++ b/packages/repack/src/plugins/utils/__tests__/AssetsCopyProcessor.test.ts @@ -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); @@ -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', @@ -146,6 +165,7 @@ describe('AssetsCopyProcessor', () => { } as unknown as StatsChunk, { isEntry: true, sourceMapFile: 'index.bundle.map' } ); + acp.enqueueAndroidKeepFile(); await Promise.all(acp.execute()); expect( @@ -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( + '\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( + '\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 () => { diff --git a/tests/integration/src/loaders/__fixtures__/assets/TestFont.otf b/tests/integration/src/loaders/__fixtures__/assets/TestFont.otf new file mode 100644 index 000000000..bc15c1b9e --- /dev/null +++ b/tests/integration/src/loaders/__fixtures__/assets/TestFont.otf @@ -0,0 +1 @@ +font fixture otf diff --git a/tests/integration/src/loaders/__fixtures__/assets/TestFont.ttc b/tests/integration/src/loaders/__fixtures__/assets/TestFont.ttc new file mode 100644 index 000000000..a581ae4e0 --- /dev/null +++ b/tests/integration/src/loaders/__fixtures__/assets/TestFont.ttc @@ -0,0 +1 @@ +font fixture ttc diff --git a/tests/integration/src/loaders/__fixtures__/assets/TestFont.ttf b/tests/integration/src/loaders/__fixtures__/assets/TestFont.ttf new file mode 100644 index 000000000..07fc4d6e5 --- /dev/null +++ b/tests/integration/src/loaders/__fixtures__/assets/TestFont.ttf @@ -0,0 +1 @@ +font fixture ttf diff --git a/tests/integration/src/loaders/__fixtures__/assets/TestFontFamily.xml b/tests/integration/src/loaders/__fixtures__/assets/TestFontFamily.xml new file mode 100644 index 000000000..80897ae96 --- /dev/null +++ b/tests/integration/src/loaders/__fixtures__/assets/TestFontFamily.xml @@ -0,0 +1 @@ + diff --git a/tests/integration/src/loaders/__fixtures__/assets/TestShape.xml b/tests/integration/src/loaders/__fixtures__/assets/TestShape.xml new file mode 100644 index 000000000..653b007ae --- /dev/null +++ b/tests/integration/src/loaders/__fixtures__/assets/TestShape.xml @@ -0,0 +1 @@ + diff --git a/tests/integration/src/loaders/assets-loader.test.ts b/tests/integration/src/loaders/assets-loader.test.ts index 63c68c38d..76eca6f71 100644 --- a/tests/integration/src/loaders/assets-loader.test.ts +++ b/tests/integration/src/loaders/assets-loader.test.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import vm from 'node:vm'; @@ -26,7 +27,8 @@ async function compileBundle( }) => string; publicPath: string; }, - maxInlineSize?: number + maxInlineSize?: number, + configuration: { extensions?: string[]; devServer?: boolean } = {} ) { const virtualPlugin = await createVirtualModulePlugin(virtualModules); @@ -34,6 +36,7 @@ async function compileBundle( context: __dirname, mode: 'development', devtool: false, + ...(configuration.devServer ? { devServer: {} } : {}), entry: './index.js', resolve: getResolveOptions(platform), output: { @@ -43,7 +46,7 @@ async function compileBundle( module: { rules: [ { - test: getAssetExtensionsRegExp(), + test: getAssetExtensionsRegExp(configuration.extensions), use: { loader: require.resolve('@callstack/repack/assets-loader'), options: { @@ -63,6 +66,183 @@ async function compileBundle( } describe('assetLoader', () => { + describe('font asset placement', () => { + const extensions = ['ttf', 'otf', 'ttc']; + + it.each(extensions)( + 'should emit .%s fonts to font and raw without changing metadata', + async (extension) => { + const content = `font fixture ${extension}\n`; + const { code, volume } = await compileBundle( + 'android', + { + ...getReactNativeVirtualModules(), + './index.js': `export { default } from './__fixtures__/assets/TestFont.${extension}';`, + }, + false, + { enabled: false, publicPath: 'http://localhost:9999' }, + undefined, + { extensions } + ); + + const context: { Export?: { default: Record } } = {}; + vm.runInNewContext(code, context); + + expect(context.Export?.default).toMatchObject({ + name: 'TestFont', + type: extension, + scales: [1], + hash: createHash('md5').update(content).digest('hex'), + httpServerLocation: 'assets/__fixtures__/assets', + }); + expect( + volume.readFileSync( + `/out/raw/__fixtures___assets_testfont.${extension}`, + 'utf8' + ) + ).toBe(content); + expect( + volume.readFileSync( + `/out/font/__fixtures___assets_testfont.${extension}`, + 'utf8' + ) + ).toBe(content); + } + ); + + it('should inline fonts without emitting files', async () => { + const content = 'font fixture ttf\n'; + const { code, volume } = await compileBundle( + 'android', + { + ...getReactNativeVirtualModules(), + './index.js': + "export { default } from './__fixtures__/assets/TestFont.ttf';", + }, + true + ); + + const context: { Export?: { default: Record } } = {}; + vm.runInNewContext(code, context); + + expect(context.Export?.default).toMatchObject({ + uri: `data:font/ttf;base64,${Buffer.from(content).toString('base64')}`, + scale: 1, + }); + expect(volume.existsSync('/out/raw')).toBe(false); + expect(volume.existsSync('/out/font')).toBe(false); + expect(volume.existsSync('/out/assets')).toBe(false); + }); + + it.each([ + { + filename: 'TestFontFamily', + content: '\n', + destination: 'font', + }, + { + filename: 'TestShape', + content: '\n', + destination: 'drawable-mdpi', + }, + ])( + 'should emit XML resources to $destination', + async ({ filename, content, destination }) => { + const { code, volume } = await compileBundle( + 'android', + { + ...getReactNativeVirtualModules(), + './index.js': `export { default } from './__fixtures__/assets/${filename}.xml';`, + }, + false, + undefined, + undefined, + { extensions: ['xml'] } + ); + + const context: { Export?: { default: Record } } = {}; + vm.runInNewContext(code, context); + + expect(context.Export?.default).toMatchObject({ + name: filename, + type: 'xml', + }); + expect( + volume.readFileSync( + `/out/${destination}/__fixtures___assets_${filename.toLowerCase()}.xml`, + 'utf8' + ) + ).toBe(content); + } + ); + + describe.each([ + { mode: 'iOS', platform: 'ios', devServer: false, remote: false }, + { + mode: 'Android dev server', + platform: 'android', + devServer: true, + remote: false, + }, + { + mode: 'Android remote', + platform: 'android', + devServer: false, + remote: true, + }, + ])('$mode fonts', ({ platform, devServer, remote }) => { + it.each(extensions)( + 'should preserve existing .%s asset paths', + async (extension) => { + const content = `font fixture ${extension}\n`; + const { code, volume } = await compileBundle( + platform, + { + ...getReactNativeVirtualModules(), + './index.js': `export { default } from './__fixtures__/assets/TestFont.${extension}';`, + }, + false, + remote + ? { enabled: true, publicPath: 'http://localhost:9999' } + : undefined, + undefined, + { extensions, devServer } + ); + + const context: { Export?: { default: Record } } = {}; + vm.runInNewContext(code, context); + + expect( + volume.readFileSync( + `/out/${remote ? 'remote-assets/' : ''}assets/__fixtures__/assets/TestFont.${extension}`, + 'utf8' + ) + ).toBe(content); + expect(volume.existsSync('/out/raw')).toBe(false); + expect(volume.existsSync('/out/font')).toBe(false); + if (remote) { + expect(context.Export?.default).toMatchObject({ + uri: `http://localhost:9999/assets/__fixtures__/assets/TestFont.${extension}`, + scale: 1, + }); + } else { + expect(context.Export?.default).toMatchObject({ + name: 'TestFont', + type: extension, + httpServerLocation: 'assets/__fixtures__/assets', + }); + if (devServer) { + expect(context.Export?.default).toHaveProperty( + 'fileSystemLocation', + '__fixtures__/assets' + ); + } + } + } + ); + }); + }); + describe.each(['ios', 'android'])('on %s', (platform) => { it('should load and extract asset without scales', async () => { const { code, volume } = await compileBundle(platform, {