From 1dc39a459b72feddfbdfee8cd029102b0237b028 Mon Sep 17 00:00:00 2001 From: Andrew Coates <30809111+acoates-ms@users.noreply.github.com> Date: Tue, 16 Apr 2024 13:55:20 -0700 Subject: [PATCH] Reduce usage of long paths in assets which can cause long path issues (#12942) * Handling for long asset paths * Change files * fix lint * fix --------- Co-authored-by: Marlene Cota --- ...-cb387bc4-0e9f-418b-bbf5-dc96833f9921.json | 7 + vnext/metroShortPathAssetDataPlugin.js | 15 ++ vnext/overrides.json | 10 + vnext/saveAssetPlugin.js | 45 +++++ .../Image/AssetSourceResolver.windows.js | 186 ++++++++++++++++++ vnext/src-win/Libraries/Image/assetPaths.js | 36 ++++ 6 files changed, 299 insertions(+) create mode 100644 change/react-native-windows-cb387bc4-0e9f-418b-bbf5-dc96833f9921.json create mode 100644 vnext/metroShortPathAssetDataPlugin.js create mode 100644 vnext/saveAssetPlugin.js create mode 100644 vnext/src-win/Libraries/Image/AssetSourceResolver.windows.js create mode 100644 vnext/src-win/Libraries/Image/assetPaths.js diff --git a/change/react-native-windows-cb387bc4-0e9f-418b-bbf5-dc96833f9921.json b/change/react-native-windows-cb387bc4-0e9f-418b-bbf5-dc96833f9921.json new file mode 100644 index 00000000000..ab932a0cc8b --- /dev/null +++ b/change/react-native-windows-cb387bc4-0e9f-418b-bbf5-dc96833f9921.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "Handling for long asset paths", + "packageName": "react-native-windows", + "email": "30809111+acoates-ms@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/vnext/metroShortPathAssetDataPlugin.js b/vnext/metroShortPathAssetDataPlugin.js new file mode 100644 index 00000000000..8e0f9fe19f5 --- /dev/null +++ b/vnext/metroShortPathAssetDataPlugin.js @@ -0,0 +1,15 @@ +// @ts-check +/** + * @typedef {import("metro").AssetData} AssetData; + **/ + +/** + * @param {AssetData & {__useShortPath: boolean}} asset + * @returns {Promise} + */ +async function metroShortPathAssetDataPlugin(asset) { + asset.__useShortPath = true; + return Promise.resolve(asset); + } + +module.exports = metroShortPathAssetDataPlugin; \ No newline at end of file diff --git a/vnext/overrides.json b/vnext/overrides.json index 6d4a8752751..6d77d7dbfa4 100644 --- a/vnext/overrides.json +++ b/vnext/overrides.json @@ -377,6 +377,16 @@ "baseFile": "packages/react-native/Libraries/DevToolsSettings/DevToolsSettingsManager.android.js", "baseHash": "1c9eb481e8ed077fa650e3ea34837e2a31310366" }, + { + "type": "platform", + "file": "src-win/Libraries/Image/assetPaths.js" + }, + { + "type": "derived", + "file": "src-win/Libraries/Image/AssetSourceResolver.windows.js", + "baseFile": "packages/react-native/Libraries/Image/AssetSourceResolver.js", + "baseHash": "d9d768a3b1d5cd394e63e8bf68456e1e6ca7f5d2" + }, { "type": "patch", "file": "src-win/Libraries/Image/Image.windows.js", diff --git a/vnext/saveAssetPlugin.js b/vnext/saveAssetPlugin.js new file mode 100644 index 00000000000..c559cdd21a2 --- /dev/null +++ b/vnext/saveAssetPlugin.js @@ -0,0 +1,45 @@ +// @ts-check +const path = require('path'); +const ensureShortPath = require('./Libraries/Image/assetPaths'); + +/** + * @typedef {import("metro").AssetData} AssetData; + **/ + +/** + * @param {AssetData} asset + * @param {number} scale + * @returns {string} + */ +function getAssetDestPath(asset, scale) { + 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. + ensureShortPath(asset.httpServerLocation.substr(1).replace(/\.\.\//g, '_')), + fileName, + ); +} + +/** + * @param {ReadonlyArray} assets + * @param {string} _platform + * @param {string | undefined} _assetsDest + * @param {string | undefined} _assetCatalogDest + * @param {(asset: AssetData, allowedScales: number[] | undefined, getAssetDestPath: (asset: AssetData, scale: number) => string) => void} addAssetToCopy + */ +function saveAssetsWin32( + assets, + _platform, + _assetsDest, + _assetCatalogDest, + addAssetToCopy, +) { + assets.forEach((asset) => + addAssetToCopy(asset, undefined, getAssetDestPath), + ); +} + +module.exports = saveAssetsWin32; \ No newline at end of file diff --git a/vnext/src-win/Libraries/Image/AssetSourceResolver.windows.js b/vnext/src-win/Libraries/Image/AssetSourceResolver.windows.js new file mode 100644 index 00000000000..9a539a6de65 --- /dev/null +++ b/vnext/src-win/Libraries/Image/AssetSourceResolver.windows.js @@ -0,0 +1,186 @@ +/** + * 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-local + * @format + */ + +'use strict'; + +export type ResolvedAssetSource = {| + +__packager_asset: boolean, + +width: ?number, + +height: ?number, + +uri: string, + +scale: number, +|}; + +import type {PackagerAsset} from '@react-native/assets-registry/registry'; + +const PixelRatio = require('../Utilities/PixelRatio').default; +const Platform = require('../Utilities/Platform'); +const {pickScale} = require('./AssetUtils'); +const { + getAndroidResourceFolderName, + getAndroidResourceIdentifier, +} = require('@react-native/assets-registry/path-support'); +const invariant = require('invariant'); +// $FlowFixMe[untyped-import] +const ensureShortPath = require('./assetPaths.js'); // [Windows] + +// [Windows - instead of using basePath from @react-native/assets-registry/path-support] +function getBasePath(asset: PackagerAsset, local: boolean) { + let basePath = asset.httpServerLocation; + if (basePath[0] === '/') { + basePath = basePath.substr(1); + } + + if (local) { + const safePath = basePath.replace(/\.\.\//g, '_'); + // If this asset was created with saveAssetPlugin, then we should shorten the path + // This conditional allow compat of bundles which might have been created without the saveAssetPlugin + // $FlowFixMe: __useShortPath not part of public type + if (asset.__useShortPath) { + return ensureShortPath(safePath); + } + return safePath; + } + + return basePath; +} + +/** + * Returns a path like 'assets/AwesomeModule/icon@2x.png' + */ +function getScaledAssetPath(asset: PackagerAsset, local: boolean): string { + const scale = pickScale(asset.scales, PixelRatio.get()); + const scaleSuffix = scale === 1 ? '' : '@' + scale + 'x'; + const assetDir = getBasePath(asset, local); + return assetDir + '/' + asset.name + scaleSuffix + '.' + asset.type; +} + +/** + * Returns a path like 'drawable-mdpi/icon.png' + */ +function getAssetPathInDrawableFolder(asset: PackagerAsset): string { + const scale = pickScale(asset.scales, PixelRatio.get()); + const drawableFolder = getAndroidResourceFolderName(asset, scale); + const fileName = getAndroidResourceIdentifier(asset); + return drawableFolder + '/' + fileName + '.' + asset.type; +} + +class AssetSourceResolver { + serverUrl: ?string; + // where the jsbundle is being run from + jsbundleUrl: ?string; + // the asset to resolve + asset: PackagerAsset; + + constructor(serverUrl: ?string, jsbundleUrl: ?string, asset: PackagerAsset) { + this.serverUrl = serverUrl; + this.jsbundleUrl = jsbundleUrl; + this.asset = asset; + } + + isLoadedFromServer(): boolean { + return !!this.serverUrl; + } + + isLoadedFromFileSystem(): boolean { + return this.jsbundleUrl != null && this.jsbundleUrl?.startsWith('file://'); + } + + defaultAsset(): ResolvedAssetSource { + if (this.isLoadedFromServer()) { + return this.assetServerURL(); + } + + if (Platform.OS === 'android') { + return this.isLoadedFromFileSystem() + ? this.drawableFolderInBundle() + : this.resourceIdentifierWithoutScale(); + } else { + return this.scaledAssetURLNearBundle(); + } + } + + /** + * Returns an absolute URL which can be used to fetch the asset + * from the devserver + */ + assetServerURL(): ResolvedAssetSource { + invariant(this.serverUrl != null, 'need server to load from'); + return this.fromSource( + this.serverUrl + + getScaledAssetPath(this.asset, false) + + '?platform=' + + Platform.OS + + '&hash=' + + this.asset.hash, + ); + } + + /** + * Resolves to just the scaled asset filename + * E.g. 'assets/AwesomeModule/icon@2x.png' + */ + scaledAssetPath(local: boolean): ResolvedAssetSource { + return this.fromSource(getScaledAssetPath(this.asset, local)); + } + + /** + * Resolves to where the bundle is running from, with a scaled asset filename + * E.g. 'file:///sdcard/bundle/assets/AwesomeModule/icon@2x.png' + */ + scaledAssetURLNearBundle(): ResolvedAssetSource { + const path = this.jsbundleUrl ?? 'file://'; + return this.fromSource( + // Assets can have relative paths outside of the project root. + // When bundling them we replace `../` with `_` to make sure they + // don't end up outside of the expected assets directory. + path + getScaledAssetPath(this.asset, true).replace(/\.\.\//g, '_'), + ); + } + + /** + * The default location of assets bundled with the app, located by + * resource identifier + * The Android resource system picks the correct scale. + * E.g. 'assets_awesomemodule_icon' + */ + resourceIdentifierWithoutScale(): ResolvedAssetSource { + invariant( + Platform.OS === 'android', + 'resource identifiers work on Android', + ); + return this.fromSource(getAndroidResourceIdentifier(this.asset)); + } + + /** + * If the jsbundle is running from a sideload location, this resolves assets + * relative to its location + * E.g. 'file:///sdcard/AwesomeModule/drawable-mdpi/icon.png' + */ + drawableFolderInBundle(): ResolvedAssetSource { + const path = this.jsbundleUrl ?? 'file://'; + return this.fromSource(path + getAssetPathInDrawableFolder(this.asset)); + } + + fromSource(source: string): ResolvedAssetSource { + return { + __packager_asset: true, + width: this.asset.width, + height: this.asset.height, + uri: source, + scale: pickScale(this.asset.scales, PixelRatio.get()), + }; + } + + static pickScale: (scales: Array, deviceScale?: number) => number = + pickScale; +} + +module.exports = AssetSourceResolver; diff --git a/vnext/src-win/Libraries/Image/assetPaths.js b/vnext/src-win/Libraries/Image/assetPaths.js new file mode 100644 index 00000000000..af18f196712 --- /dev/null +++ b/vnext/src-win/Libraries/Image/assetPaths.js @@ -0,0 +1,36 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * Dont use flow here, since this file is used by saveAssetPlugin.js which will run without flow transform + * @format + */ + +'use strict'; + +// Some windows machines may not have long paths enabled +// https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation +// Assets in nested node_modules (common when using pnpm) - end up creating very long paths +// Using this function we shorten longer paths to prevent paths from hitting the path limit +function ensureShortPath(str) { + if (str.length < 40) return str; + + const assetsPrefix = 'assets/'; + + if (!str.startsWith(assetsPrefix)) { + console.warn(`Unexpected asset uri - ${str} may not load correctly.`); + } + + const postStr = str.slice(assetsPrefix.length); + var hash = 0, + i, + chr; + for (i = 0; i < postStr.length; i++) { + chr = postStr.charCodeAt(i); + hash = (hash << 5) - hash + chr; + hash |= 0; // Convert to 32bit integer + } + return assetsPrefix + hash.toString(); +} + +module.exports = ensureShortPath;