Skip to content

Commit

Permalink
Implement resolveAsset handling
Browse files Browse the repository at this point in the history
Summary:
Add support for resolving assets (determined by `resolver.isAssetFile`) from Package Exports.

- As with source files, assets in `"exports"` do not automatically append `sourceExts` or platform-specific exts. **However**, we allow expansion of *source resolutions* (i.e. `2x.png` etc) from an `"exports"` target. This is Metro/React Native-specific functionality which otherwise has no equivalent in the Package Exports spec.
- Falls back to legacy file resolution logic when the asset is missing or `unstable_enablePackageExports` is `false`.

Changelog: **[Experimental]** Add asset handling for package exports via `context.resolveAsset`

Reviewed By: motiz88

Differential Revision: D43193253

fbshipit-source-id: 41ced8d6a91fc2d32051348635852c5df1f5d213
  • Loading branch information
huntie authored and facebook-github-bot committed Feb 15, 2023
1 parent aa20356 commit 6e6f36f
Show file tree
Hide file tree
Showing 5 changed files with 126 additions and 34 deletions.
9 changes: 7 additions & 2 deletions packages/metro-resolver/src/PackageExportsResolve.js
Expand Up @@ -9,10 +9,11 @@
* @oncall react_native
*/

import type {ExportMap, ResolutionContext, SourceFileResolution} from './types';
import type {ExportMap, FileResolution, ResolutionContext} from './types';

import path from 'path';
import InvalidPackageConfigurationError from './errors/InvalidPackageConfigurationError';
import resolveAsset from './resolveAsset';
import toPosixPath from './utils/toPosixPath';

/**
Expand Down Expand Up @@ -40,7 +41,7 @@ export function resolvePackageTargetFromExports(
modulePath: string,
exportsField: ExportMap | string,
platform: string | null,
): SourceFileResolution | null {
): FileResolution | null {
const raiseConfigError = (reason: string) => {
throw new InvalidPackageConfigurationError({
reason,
Expand All @@ -64,6 +65,10 @@ export function resolvePackageTargetFromExports(
if (match != null) {
const filePath = path.join(packagePath, match);

if (context.isAssetFile(filePath)) {
return resolveAsset(context, filePath);
}

if (context.doesFileExist(filePath)) {
return {type: 'sourceFile', filePath};
}
Expand Down
60 changes: 60 additions & 0 deletions packages/metro-resolver/src/__tests__/package-exports-test.js
Expand Up @@ -9,6 +9,7 @@
* @oncall react_native
*/

import path from 'path';
import Resolver from '../index';
import {createPackageAccessors, createResolutionContext} from './utils';

Expand Down Expand Up @@ -588,4 +589,63 @@ describe('with package exports resolution enabled', () => {
});
});
});

describe('asset resolutions', () => {
const assetResolutions = ['1', '1.5', '2', '3', '4'];
const isAssetFile = (filePath: string) => filePath.endsWith('.png');

const baseContext = {
...createResolutionContext({
'/root/src/main.js': '',
'/root/node_modules/test-pkg/package.json': JSON.stringify({
main: './index.js',
exports: {
'./icons/metro.png': './assets/icons/metro.png',
},
}),
'/root/node_modules/test-pkg/assets/icons/metro.png': '',
'/root/node_modules/test-pkg/assets/icons/metro@2x.png': '',
'/root/node_modules/test-pkg/assets/icons/metro@3x.png': '',
}),
isAssetFile,
originModulePath: '/root/src/main.js',
unstable_enablePackageExports: true,
};

test('should resolve assets using "exports" field and calling `resolveAsset`', () => {
const resolveAsset = jest.fn(
(dirPath: string, basename: string, extension: string) => {
const basePath = dirPath + path.sep + basename;
const assets = [
basePath + extension,
...assetResolutions.map(
resolution => basePath + '@' + resolution + 'x' + extension,
),
].filter(candidate => baseContext.doesFileExist(candidate));

return assets.length ? assets : null;
},
);
const context = {
...baseContext,
resolveAsset,
};

expect(
Resolver.resolve(context, 'test-pkg/icons/metro.png', null),
).toEqual({
type: 'assetFiles',
filePaths: [
'/root/node_modules/test-pkg/assets/icons/metro.png',
'/root/node_modules/test-pkg/assets/icons/metro@2x.png',
'/root/node_modules/test-pkg/assets/icons/metro@3x.png',
],
});
expect(resolveAsset).toHaveBeenLastCalledWith(
'/root/node_modules/test-pkg/assets/icons',
'metro',
'.png',
);
});
});
});
41 changes: 12 additions & 29 deletions packages/metro-resolver/src/resolve.js
Expand Up @@ -28,6 +28,7 @@ import InvalidPackageError from './errors/InvalidPackageError';
import formatFileCandidates from './errors/formatFileCandidates';
import {getPackageEntryPoint} from './PackageResolve';
import {resolvePackageTargetFromExports} from './PackageExportsResolve';
import resolveAsset from './resolveAsset';
import invariant from 'invariant';

function resolve(
Expand Down Expand Up @@ -355,27 +356,19 @@ function resolveFile(
fileName: string,
platform: string | null,
): Result<Resolution, FileCandidates> {
const {isAssetFile, resolveAsset} = context;
if (isAssetFile(fileName)) {
const extension = path.extname(fileName);
const basename = path.basename(fileName, extension);
if (!/@\d+(?:\.\d+)?x$/.test(basename)) {
try {
const assets = resolveAsset(dirPath, basename, extension);
if (assets != null) {
return mapResult(resolvedAs(assets), filePaths => ({
type: 'assetFiles',
filePaths,
}));
}
} catch (err) {
if (err.code === 'ENOENT') {
return failedFor({type: 'asset', name: fileName});
}
}
if (context.isAssetFile(fileName)) {
const assetResolutions = resolveAsset(
context,
path.join(dirPath, fileName),
);

if (assetResolutions == null) {
return failedFor({type: 'asset', name: fileName});
}
return failedFor({type: 'asset', name: fileName});

return resolvedAs(assetResolutions);
}

const candidateExts: Array<string> = [];
const filePathPrefix = path.join(dirPath, fileName);
const sfContext = {...context, candidateExts, filePathPrefix};
Expand Down Expand Up @@ -513,14 +506,4 @@ function failedFor<TResolution, TCandidates>(
return {type: 'failed', candidates};
}

function mapResult<TResolution, TNewResolution, TCandidates>(
result: Result<TResolution, TCandidates>,
mapper: TResolution => TNewResolution,
): Result<TNewResolution, TCandidates> {
if (result.type === 'failed') {
return result;
}
return {type: 'resolved', resolution: mapper(result.resolution)};
}

module.exports = resolve;
42 changes: 42 additions & 0 deletions packages/metro-resolver/src/resolveAsset.js
@@ -0,0 +1,42 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict
* @format
* @oncall react_native
*/

import type {AssetResolution, ResolutionContext} from './types';

import path from 'path';

/**
* Resolve a file path as an asset. Returns the set of files found after
* expanding asset resolutions (e.g. `icon@2x.png`). Users may override this
* behaviour via `context.resolveAsset`.
*/
export default function resolveAsset(
context: ResolutionContext,
filePath: string,
): AssetResolution | null {
const dirPath = path.dirname(filePath);
const extension = path.extname(filePath);
const basename = path.basename(filePath, extension);

try {
if (!/@\d+(?:\.\d+)?x$/.test(basename)) {
const assets = context.resolveAsset(dirPath, basename, extension);
if (assets != null) {
return {
type: 'assetFiles',
filePaths: assets,
};
}
}
} catch (e) {}

return null;
}
8 changes: 5 additions & 3 deletions packages/metro-resolver/src/types.js
Expand Up @@ -22,9 +22,11 @@ export type SourceFileResolution = $ReadOnly<{
filePath: string,
}>;
export type AssetFileResolution = $ReadOnlyArray<string>;
export type FileResolution =
| SourceFileResolution
| {+type: 'assetFiles', +filePaths: AssetFileResolution};
export type AssetResolution = $ReadOnly<{
type: 'assetFiles',
filePaths: AssetFileResolution,
}>;
export type FileResolution = AssetResolution | SourceFileResolution;

export type FileAndDirCandidates = {
+dir: FileCandidates,
Expand Down

0 comments on commit 6e6f36f

Please sign in to comment.