diff --git a/.changeset/metal-ladybugs-wave.md b/.changeset/metal-ladybugs-wave.md new file mode 100644 index 000000000..5ba6d046d --- /dev/null +++ b/.changeset/metal-ladybugs-wave.md @@ -0,0 +1,5 @@ +--- +"@rnx-kit/metro-service": minor +--- + +Refactor saveAssets code to allow out of tree overrides diff --git a/packages/metro-service/package.json b/packages/metro-service/package.json index 3734146e3..9bfb75e59 100644 --- a/packages/metro-service/package.json +++ b/packages/metro-service/package.json @@ -64,6 +64,7 @@ }, "depcheck": { "ignoreMatches": [ + "@office-iss/react-native-win32", "metro-babel-transformer" ] }, diff --git a/packages/metro-service/src/asset/android.ts b/packages/metro-service/src/asset/android.ts index d928d27ca..daea6ba18 100644 --- a/packages/metro-service/src/asset/android.ts +++ b/packages/metro-service/src/asset/android.ts @@ -2,7 +2,7 @@ import * as path from "path"; import { getResourceIdentifier } from "./assetPathUtils"; -import type { PackagerAsset } from "./types"; +import type { PackagerAsset, SaveAssetsPlugin } from "./types"; export function getAndroidAssetSuffix(scale: number): string { const tolerance = 0.01; @@ -52,3 +52,15 @@ export function getAssetDestPathAndroid( const fileName = getResourceIdentifier(asset); return path.join(androidFolder, `${fileName}.${asset.type}`); } + +export const saveAssetsAndroid: SaveAssetsPlugin = ( + assets, + _platform, + _assetsDest, + _assetCatalogDest, + addAssetToCopy +) => { + assets.forEach((asset) => + addAssetToCopy(asset, undefined, getAssetDestPathAndroid) + ); +}; diff --git a/packages/metro-service/src/asset/default.ts b/packages/metro-service/src/asset/default.ts new file mode 100644 index 000000000..d2a317f58 --- /dev/null +++ b/packages/metro-service/src/asset/default.ts @@ -0,0 +1,24 @@ +import path from "path"; +import type { PackagerAsset, SaveAssetsPlugin } from "./types"; + +export function getAssetDestPath(asset: PackagerAsset, scale: number): string { + const suffix = scale === 1 ? "" : `@${scale}x`; + const fileName = `${asset.name + suffix}.${asset.type}`; + return path.join( + // Assets can have relative paths outside of the project root. + // Replace `../` with `_` to make sure they don't end up outside of + // the expected assets directory. + asset.httpServerLocation.substr(1).replace(/\.\.\//g, "_"), + fileName + ); +} + +export const saveAssetsDefault: SaveAssetsPlugin = ( + assets, + _platform, + _assetsDest, + _assetCatalogDest, + addAssetToCopy +) => { + assets.forEach((asset) => addAssetToCopy(asset, undefined, getAssetDestPath)); +}; diff --git a/packages/metro-service/src/asset/filter.ts b/packages/metro-service/src/asset/filter.ts index fa55a7441..029b949f5 100644 --- a/packages/metro-service/src/asset/filter.ts +++ b/packages/metro-service/src/asset/filter.ts @@ -1,14 +1,9 @@ // https://github.com/react-native-community/cli/blob/716555851b442a83a1bf5e0db27b6226318c9a69/packages/cli-plugin-metro/src/commands/bundle/filterPlatformAssetScales.ts -const ALLOWED_SCALES: { [key: string]: number[] } = { - ios: [1, 2, 3], -}; - export function filterPlatformAssetScales( - platform: string, + allowlist: ReadonlyArray | undefined, scales: readonly number[] ): readonly number[] { - const allowlist: number[] = ALLOWED_SCALES[platform]; if (!allowlist) { return scales; } diff --git a/packages/metro-service/src/asset/ios.ts b/packages/metro-service/src/asset/ios.ts index 80594ba06..720cb62bf 100644 --- a/packages/metro-service/src/asset/ios.ts +++ b/packages/metro-service/src/asset/ios.ts @@ -3,9 +3,12 @@ import fs from "fs"; import type { AssetData } from "metro"; +import { error, info } from "@rnx-kit/console"; import path from "path"; import { getResourceIdentifier } from "./assetPathUtils"; -import type { PackagerAsset } from "./types"; +import { filterPlatformAssetScales } from "./filter"; +import { getAssetDestPath } from "./default"; +import type { SaveAssetsPlugin } from "./types"; type ImageSet = { basePath: string; @@ -68,17 +71,44 @@ export function writeImageSet(imageSet: ImageSet): void { ); } -export function getAssetDestPathIOS( - asset: PackagerAsset, - scale: number -): string { - const suffix = scale === 1 ? "" : `@${scale}x`; - const fileName = `${asset.name + suffix}.${asset.type}`; - return path.join( - // Assets can have relative paths outside of the project root. - // Replace `../` with `_` to make sure they don't end up outside of - // the expected assets directory. - asset.httpServerLocation.substring(1).replace(/\.\.\//g, "_"), - fileName - ); -} +const ALLOWED_SCALES = [1, 2, 3]; + +export const saveAssetsIOS: SaveAssetsPlugin = ( + assets, + _platform, + _assetsDest, + assetCatalogDest, + addAssetToCopy +) => { + if (assetCatalogDest != null) { + // Use iOS Asset Catalog for images. This will allow Apple app thinning to + // remove unused scales from the optimized bundle. + const catalogDir = path.join(assetCatalogDest, "RNAssets.xcassets"); + if (!fs.existsSync(catalogDir)) { + error( + `Could not find asset catalog 'RNAssets.xcassets' in ${assetCatalogDest}. Make sure to create it if it does not exist.` + ); + return; + } + + info("Adding images to asset catalog", catalogDir); + cleanAssetCatalog(catalogDir); + for (const asset of assets) { + if (isCatalogAsset(asset)) { + const imageSet = getImageSet( + catalogDir, + asset, + filterPlatformAssetScales(ALLOWED_SCALES, asset.scales) + ); + writeImageSet(imageSet); + } else { + addAssetToCopy(asset, ALLOWED_SCALES, getAssetDestPath); + } + } + info("Done adding images to asset catalog"); + } else { + assets.forEach((asset) => + addAssetToCopy(asset, ALLOWED_SCALES, getAssetDestPath) + ); + } +}; diff --git a/packages/metro-service/src/asset/types.ts b/packages/metro-service/src/asset/types.ts index 043f159ba..57ee89e3f 100644 --- a/packages/metro-service/src/asset/types.ts +++ b/packages/metro-service/src/asset/types.ts @@ -1,5 +1,19 @@ +import type { AssetData } from "metro"; + export type PackagerAsset = { httpServerLocation: string; name: string; type: string; }; + +export type SaveAssetsPlugin = ( + assets: ReadonlyArray, + platform: string, + assetsDest: string | undefined, + assetCatalogDest: string | undefined, + addAssetToCopy: ( + asset: AssetData, + allowedScales: number[] | undefined, + getAssetDestPath: (asset: AssetData, scale: number) => string + ) => void +) => void; diff --git a/packages/metro-service/src/asset/write.ts b/packages/metro-service/src/asset/write.ts index ab61381f3..6d40cf401 100644 --- a/packages/metro-service/src/asset/write.ts +++ b/packages/metro-service/src/asset/write.ts @@ -1,18 +1,11 @@ // https://github.com/react-native-community/cli/blob/716555851b442a83a1bf5e0db27b6226318c9a69/packages/cli-plugin-metro/src/commands/bundle/saveAssets.ts -import { error, info, warn } from "@rnx-kit/console"; +import { info, warn } from "@rnx-kit/console"; import * as fs from "fs"; import type { AssetData } from "metro"; import * as path from "path"; -import { getAssetDestPathAndroid } from "./android"; import { filterPlatformAssetScales } from "./filter"; -import { - cleanAssetCatalog, - getAssetDestPathIOS, - getImageSet, - isCatalogAsset, - writeImageSet, -} from "./ios"; +import type { SaveAssetsPlugin } from "./types"; function copy( src: string, @@ -62,7 +55,8 @@ export function saveAssets( assets: ReadonlyArray, platform: string, assetsDest: string | undefined, - assetCatalogDest: string | undefined + assetCatalogDest: string | undefined, + saveAssetsPlugin: SaveAssetsPlugin ): Promise { if (!assetsDest) { warn("Assets destination folder is not set, skipping..."); @@ -71,15 +65,16 @@ export function saveAssets( const filesToCopy: Record = Object.create(null); // Map src -> dest - const getAssetDestPath = - platform === "android" ? getAssetDestPathAndroid : getAssetDestPathIOS; - - const addAssetToCopy = (asset: AssetData) => { + const addAssetToCopy = ( + asset: AssetData, + allowedScales: number[] | undefined, + getAssetDestPath: (asset: AssetData, scale: number) => string + ) => { const validScales = new Set( - filterPlatformAssetScales(platform, asset.scales) + filterPlatformAssetScales(allowedScales, asset.scales) ); - asset.scales.forEach((scale, idx) => { + asset.scales.forEach((scale: number, idx: number) => { if (!validScales.has(scale)) { return; } @@ -87,37 +82,23 @@ export function saveAssets( const dest = path.join(assetsDest, getAssetDestPath(asset, scale)); filesToCopy[src] = dest; }); - }; - - if (platform === "ios" && assetCatalogDest != null) { - // Use iOS Asset Catalog for images. This will allow Apple app thinning to - // remove unused scales from the optimized bundle. - const catalogDir = path.join(assetCatalogDest, "RNAssets.xcassets"); - if (!fs.existsSync(catalogDir)) { - error( - `Could not find asset catalog 'RNAssets.xcassets' in ${assetCatalogDest}. Make sure to create it if it does not exist.` - ); - return Promise.reject(); - } - info("Adding images to asset catalog", catalogDir); - cleanAssetCatalog(catalogDir); - for (const asset of assets) { - if (isCatalogAsset(asset)) { - const imageSet = getImageSet( - catalogDir, - asset, - filterPlatformAssetScales(platform, asset.scales) - ); - writeImageSet(imageSet); - } else { - addAssetToCopy(asset); + asset.scales.forEach((scale, idx) => { + if (!validScales.has(scale)) { + return; } - } - info("Done adding images to asset catalog"); - } else { - assets.forEach(addAssetToCopy); - } + const src = asset.files[idx]; + const dest = path.join(assetsDest, getAssetDestPath(asset, scale)); + filesToCopy[src] = dest; + }); + }; + saveAssetsPlugin( + assets, + platform, + assetsDest, + assetCatalogDest, + addAssetToCopy + ); return copyAll(filesToCopy); } diff --git a/packages/metro-service/src/bundle.ts b/packages/metro-service/src/bundle.ts index dc814b834..07b13129e 100644 --- a/packages/metro-service/src/bundle.ts +++ b/packages/metro-service/src/bundle.ts @@ -9,6 +9,10 @@ import Server from "metro/src/Server"; import Bundle from "metro/src/shared/output/bundle"; import path from "path"; import { saveAssets } from "./asset"; +import type { SaveAssetsPlugin } from "./asset/types"; +import { saveAssetsAndroid } from "./asset/android"; +import { saveAssetsDefault } from "./asset/default"; +import { saveAssetsIOS } from "./asset/ios"; import { ensureBabelConfig } from "./babel"; export type BundleArgs = { @@ -41,6 +45,34 @@ type RequestOptions = { unstable_transformProfile?: BundleOptions["unstable_transformProfile"]; }; +// Eventually this will be part of the rn config, but we require it on older rn versions for win32 and the cli doesn't allow extra config properties. +// See https://github.com/react-native-community/cli/pull/2002 +function getSaveAssetsPlugin( + platform: string, + projectRoot: string +): SaveAssetsPlugin { + if (platform === "win32") { + try { + const saveAssetsPlugin = require.resolve( + "@office-iss/react-native-win32/saveAssetPlugin", + { paths: [projectRoot] } + ); + return require(saveAssetsPlugin); + } catch (_) { + /* empty */ + } + } + + switch (platform) { + case "ios": + return saveAssetsIOS; + case "android": + return saveAssetsAndroid; + default: + return saveAssetsDefault; + } +} + export async function bundle( args: BundleArgs, config: ConfigT, @@ -49,6 +81,11 @@ export async function bundle( // ensure Metro can find Babel config ensureBabelConfig(config); + const saveAssetsPlugin = getSaveAssetsPlugin( + args.platform, + config.projectRoot + ); + if (config.resolver.platforms.indexOf(args.platform) === -1) { error( `Invalid platform ${ @@ -107,7 +144,8 @@ export async function bundle( outputAssets, args.platform, args.assetsDest, - args.assetCatalogDest + args.assetCatalogDest, + saveAssetsPlugin ); } finally { server.end(); diff --git a/packages/metro-service/test/asset/ios.test.ts b/packages/metro-service/test/asset/default.test.ts similarity index 72% rename from packages/metro-service/test/asset/ios.test.ts rename to packages/metro-service/test/asset/default.test.ts index 79da1a0a9..4b9a58803 100644 --- a/packages/metro-service/test/asset/ios.test.ts +++ b/packages/metro-service/test/asset/default.test.ts @@ -1,7 +1,7 @@ import * as path from "path"; -import { getAssetDestPathIOS } from "../../src/asset/ios"; +import { getAssetDestPath } from "../../src/asset/default"; -describe("getAssetDestPathIOS", () => { +describe("getAssetDestPath", () => { test("should build correct path", () => { const asset = { name: "icon", @@ -9,7 +9,7 @@ describe("getAssetDestPathIOS", () => { httpServerLocation: "/assets/test", }; - expect(getAssetDestPathIOS(asset, 1)).toBe( + expect(getAssetDestPath(asset, 1)).toBe( path.normalize("assets/test/icon.png") ); }); @@ -21,10 +21,10 @@ describe("getAssetDestPathIOS", () => { httpServerLocation: "/assets/test", }; - expect(getAssetDestPathIOS(asset, 2)).toBe( + expect(getAssetDestPath(asset, 2)).toBe( path.normalize("assets/test/icon@2x.png") ); - expect(getAssetDestPathIOS(asset, 3)).toBe( + expect(getAssetDestPath(asset, 3)).toBe( path.normalize("assets/test/icon@3x.png") ); }); @@ -36,7 +36,7 @@ describe("getAssetDestPathIOS", () => { httpServerLocation: "/assets/../../test", }; - expect(getAssetDestPathIOS(asset, 1)).toBe( + expect(getAssetDestPath(asset, 1)).toBe( path.normalize("assets/__test/icon.png") ); }); diff --git a/packages/metro-service/test/asset/filter.test.ts b/packages/metro-service/test/asset/filter.test.ts index 9e182b1d9..88e546208 100644 --- a/packages/metro-service/test/asset/filter.test.ts +++ b/packages/metro-service/test/asset/filter.test.ts @@ -2,21 +2,21 @@ import { filterPlatformAssetScales } from "../../src/asset/filter"; describe("filterPlatformAssetScales", () => { test("removes everything but 2x and 3x for iOS", () => { - expect(filterPlatformAssetScales("ios", [1, 1.5, 2, 3, 4])).toEqual([ + expect(filterPlatformAssetScales([1, 2, 3], [1, 1.5, 2, 3, 4])).toEqual([ 1, 2, 3, ]); - expect(filterPlatformAssetScales("ios", [3, 4])).toEqual([3]); + expect(filterPlatformAssetScales([1, 2, 3], [3, 4])).toEqual([3]); }); test("keeps closest largest one if nothing matches", () => { - expect(filterPlatformAssetScales("ios", [0.5, 4, 100])).toEqual([4]); - expect(filterPlatformAssetScales("ios", [0.5, 100])).toEqual([100]); - expect(filterPlatformAssetScales("ios", [0.5])).toEqual([0.5]); - expect(filterPlatformAssetScales("ios", [])).toEqual([]); + expect(filterPlatformAssetScales([1, 2, 3], [0.5, 4, 100])).toEqual([4]); + expect(filterPlatformAssetScales([1, 2, 3], [0.5, 100])).toEqual([100]); + expect(filterPlatformAssetScales([1, 2, 3], [0.5])).toEqual([0.5]); + expect(filterPlatformAssetScales([1, 2, 3], [])).toEqual([]); }); test("keeps all scales for unknown platform", () => { - expect(filterPlatformAssetScales("freebsd", [1, 1.5, 2, 3.7])).toEqual([ + expect(filterPlatformAssetScales(undefined, [1, 1.5, 2, 3.7])).toEqual([ 1, 1.5, 2, 3.7, ]); });