From 35ce710c8629671809b2f62253ad3ce3323b744b Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Sat, 11 Apr 2026 01:30:09 +0100 Subject: [PATCH 01/13] Prenormalize symlink target to normal and remove weakmap cache The WeakMap is likely more expensive and this can leave unnormalized absolute paths in the file map cache. NOTE: This bumps the `CACHE_BREAKER` value! --- .../src/crawlers/watchman/index.js | 6 ++++ packages/metro-file-map/src/index.js | 7 ++-- .../metro-file-map/src/lib/RootPathUtils.js | 24 +++++++++++++ packages/metro-file-map/src/lib/TreeFS.js | 34 +++---------------- 4 files changed, 40 insertions(+), 31 deletions(-) diff --git a/packages/metro-file-map/src/crawlers/watchman/index.js b/packages/metro-file-map/src/crawlers/watchman/index.js index 61b07d2bb3..0b3afba2a4 100644 --- a/packages/metro-file-map/src/crawlers/watchman/index.js +++ b/packages/metro-file-map/src/crawlers/watchman/index.js @@ -327,6 +327,12 @@ export default async function watchmanCrawl({ if (fileData.type === 'l') { symlinkInfo = fileData['symlink_target'] ?? 1; } + if (typeof symlinkInfo === 'string') { + symlinkInfo = pathUtils.resolveSymlinkToNormal( + relativeFilePath, + symlinkInfo, + ); + } const nextData: FileMetadata = [ mtime, diff --git a/packages/metro-file-map/src/index.js b/packages/metro-file-map/src/index.js index 7fdfbd8523..6d5508e5b7 100644 --- a/packages/metro-file-map/src/index.js +++ b/packages/metro-file-map/src/index.js @@ -167,7 +167,7 @@ export type { // This should be bumped whenever a code change to `metro-file-map` itself // would cause a change to the cache data structure and/or content (for a given // filesystem state and build parameters). -const CACHE_BREAKER = '11'; +const CACHE_BREAKER = '12'; const CHANGE_INTERVAL = 30; @@ -592,7 +592,10 @@ export default class FileMap extends EventEmitter { .readlink(this.#pathUtils.normalToAbsolute(normalPath)) .then(symlinkTarget => { fileMetadata[H.VISITED] = 1; - fileMetadata[H.SYMLINK] = symlinkTarget; + fileMetadata[H.SYMLINK] = this.#pathUtils.resolveSymlinkToNormal( + normalPath, + symlinkTarget, + ); }); } return null; diff --git a/packages/metro-file-map/src/lib/RootPathUtils.js b/packages/metro-file-map/src/lib/RootPathUtils.js index 6326d0ab58..92c5f70de9 100644 --- a/packages/metro-file-map/src/lib/RootPathUtils.js +++ b/packages/metro-file-map/src/lib/RootPathUtils.js @@ -8,6 +8,7 @@ * @format */ +import normalizePathSeparatorsToSystem from './normalizePathSeparatorsToSystem'; import invariant from 'invariant'; import * as path from 'node:path'; @@ -174,6 +175,29 @@ export class RootPathUtils { ); } + resolveSymlinkToNormal( + symlinkNormalPath: string, + readlinkResult: string, + ): string { + let target = normalizePathSeparatorsToSystem(readlinkResult); + // WARN: This only applies to Windows + Node 20 case, where the value is completely + // unnormalized and a trailing slash may be returned + if (target[target.length - 1] === path.sep) { + target = target.slice(0, -1); + } + if (path.isAbsolute(target)) { + return this.absoluteToNormal(target); + } + // Resolve relative to the symlink's containing directory, expressed as + // a root-relative (possibly non-normal) path, then normalize + const sepIdx = symlinkNormalPath.lastIndexOf(path.sep); + const rootRelativeTarget = + sepIdx === -1 + ? target + : symlinkNormalPath.slice(0, sepIdx) + path.sep + target; + return this.relativeToNormal(rootRelativeTarget); + } + // If a path is a direct ancestor of the project root (or the root itself), // return a number with the degrees of separation, e.g. root=0, parent=1,.. // or null otherwise. diff --git a/packages/metro-file-map/src/lib/TreeFS.js b/packages/metro-file-map/src/lib/TreeFS.js index 3dc71cb7ff..690785ca82 100644 --- a/packages/metro-file-map/src/lib/TreeFS.js +++ b/packages/metro-file-map/src/lib/TreeFS.js @@ -124,10 +124,6 @@ type MetadataIteratorOptions = Readonly<{ * a trailing slash */ export default class TreeFS implements MutableFileSystem { - readonly #cachedNormalSymlinkTargets: WeakMap< - FileNode, - NormalizedSymlinkTarget, - > = new WeakMap(); readonly #pathUtils: RootPathUtils; readonly #processFile: ProcessFileFunction; readonly #rootDir: Path; @@ -1226,36 +1222,16 @@ export default class TreeFS implements MutableFileSystem { symlinkNode: FileMetadata, canonicalPathOfSymlink: Path, ): NormalizedSymlinkTarget { - const cachedResult = this.#cachedNormalSymlinkTargets.get(symlinkNode); - if (cachedResult != null) { - return cachedResult; - } - - const literalSymlinkTarget = symlinkNode[H.SYMLINK]; + const symlinkTarget = symlinkNode[H.SYMLINK]; invariant( - typeof literalSymlinkTarget === 'string', + typeof symlinkTarget === 'string', 'Expected symlink target to be populated.', ); - let absoluteSymlinkTarget = path.resolve( - this.#rootDir, - canonicalPathOfSymlink, - '..', // Symlink target is relative to its containing directory. - literalSymlinkTarget, // May be absolute, in which case the above are ignored - ); - if (absoluteSymlinkTarget.endsWith(path.sep)) { - absoluteSymlinkTarget = absoluteSymlinkTarget.slice(0, -1); - } - const normalSymlinkTarget = this.#pathUtils.absoluteToNormal( - absoluteSymlinkTarget, - ); - const result = { - ancestorOfRootIdx: - this.#pathUtils.getAncestorOfRootIdx(normalSymlinkTarget), - normalPath: normalSymlinkTarget, - startOfBasenameIdx: normalSymlinkTarget.lastIndexOf(path.sep) + 1, + ancestorOfRootIdx: this.#pathUtils.getAncestorOfRootIdx(symlinkTarget), + normalPath: symlinkTarget, + startOfBasenameIdx: symlinkTarget.lastIndexOf(path.sep) + 1, }; - this.#cachedNormalSymlinkTargets.set(symlinkNode, result); return result; } From 00691f14eebe5c9a88c8b8f3ac7dc6f0750ba1ec Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Sat, 11 Apr 2026 01:34:36 +0100 Subject: [PATCH 02/13] Normalize stored value to posix This keeps the metro-file-map value system-stable. Before they could store absolute paths, and system paths, however, this is now normalized to posix normal paths. --- packages/metro-file-map/src/crawlers/watchman/index.js | 5 ++--- packages/metro-file-map/src/index.js | 5 ++--- packages/metro-file-map/src/lib/TreeFS.js | 4 +++- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/metro-file-map/src/crawlers/watchman/index.js b/packages/metro-file-map/src/crawlers/watchman/index.js index 0b3afba2a4..31b2503c6a 100644 --- a/packages/metro-file-map/src/crawlers/watchman/index.js +++ b/packages/metro-file-map/src/crawlers/watchman/index.js @@ -328,9 +328,8 @@ export default async function watchmanCrawl({ symlinkInfo = fileData['symlink_target'] ?? 1; } if (typeof symlinkInfo === 'string') { - symlinkInfo = pathUtils.resolveSymlinkToNormal( - relativeFilePath, - symlinkInfo, + symlinkInfo = normalizePathSeparatorsToPosix( + pathUtils.resolveSymlinkToNormal(relativeFilePath, symlinkInfo), ); } diff --git a/packages/metro-file-map/src/index.js b/packages/metro-file-map/src/index.js index 6d5508e5b7..62d88c7d71 100644 --- a/packages/metro-file-map/src/index.js +++ b/packages/metro-file-map/src/index.js @@ -592,9 +592,8 @@ export default class FileMap extends EventEmitter { .readlink(this.#pathUtils.normalToAbsolute(normalPath)) .then(symlinkTarget => { fileMetadata[H.VISITED] = 1; - fileMetadata[H.SYMLINK] = this.#pathUtils.resolveSymlinkToNormal( - normalPath, - symlinkTarget, + fileMetadata[H.SYMLINK] = normalizePathSeparatorsToPosix( + this.#pathUtils.resolveSymlinkToNormal(normalPath, symlinkTarget), ); }); } diff --git a/packages/metro-file-map/src/lib/TreeFS.js b/packages/metro-file-map/src/lib/TreeFS.js index 690785ca82..e72418c1f1 100644 --- a/packages/metro-file-map/src/lib/TreeFS.js +++ b/packages/metro-file-map/src/lib/TreeFS.js @@ -21,6 +21,7 @@ import type { } from '../flow-types'; import H from '../constants'; +import normalizePathSeparatorsToSystem from './normalizePathSeparatorsToSystem'; import {RootPathUtils} from './RootPathUtils'; import invariant from 'invariant'; import path from 'node:path'; @@ -1222,11 +1223,12 @@ export default class TreeFS implements MutableFileSystem { symlinkNode: FileMetadata, canonicalPathOfSymlink: Path, ): NormalizedSymlinkTarget { - const symlinkTarget = symlinkNode[H.SYMLINK]; + let symlinkTarget = symlinkNode[H.SYMLINK]; invariant( typeof symlinkTarget === 'string', 'Expected symlink target to be populated.', ); + symlinkTarget = normalizePathSeparatorsToSystem(symlinkTarget); const result = { ancestorOfRootIdx: this.#pathUtils.getAncestorOfRootIdx(symlinkTarget), normalPath: symlinkTarget, From e29454cd7defbad33592fcf03fd56ad0b7fd252d Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Sat, 11 Apr 2026 01:43:01 +0100 Subject: [PATCH 03/13] Add/update unit tests --- .../src/lib/__tests__/RootPathUtils-test.js | 41 +++++++++++ .../src/lib/__tests__/TreeFS-test.js | 68 ++++++++----------- 2 files changed, 70 insertions(+), 39 deletions(-) diff --git a/packages/metro-file-map/src/lib/__tests__/RootPathUtils-test.js b/packages/metro-file-map/src/lib/__tests__/RootPathUtils-test.js index a362a10492..b438364e70 100644 --- a/packages/metro-file-map/src/lib/__tests__/RootPathUtils-test.js +++ b/packages/metro-file-map/src/lib/__tests__/RootPathUtils-test.js @@ -246,4 +246,45 @@ describe.each([['win32'], ['posix']])('RootPathUtils on %s', platform => { ); }); } + + describe('resolveSymlinkToNormal', () => { + beforeEach(() => { + pathUtils = new RootPathUtils(p('/project/root')); + }); + + test.each([ + ['foo/link', './target.js', p('foo/target.js')], + ['foo/link', '../bar.js', 'bar.js'], + ['link', 'target.js', 'target.js'], + [p('a/b/link'), p('../../c.js'), 'c.js'], + [p('a/b/link'), p('../../../outside/f.js'), p('../outside/f.js')], + ])( + 'resolves relative target (%s -> %s) to %s', + (symlinkPath, readlinkResult, expected) => { + expect( + pathUtils.resolveSymlinkToNormal(p(symlinkPath), readlinkResult), + ).toEqual(expected); + }, + ); + + test.each([ + ['link', p('/project/root/target.js'), 'target.js'], + ['link', p('/project/root/a/b.js'), p('a/b.js')], + ['link', p('/outside/foo.js'), p('../../outside/foo.js')], + [p('a/link'), p('/project/root'), ''], + ])( + 'resolves absolute target (%s -> %s) to %s', + (symlinkPath, readlinkResult, expected) => { + expect( + pathUtils.resolveSymlinkToNormal(p(symlinkPath), readlinkResult), + ).toEqual(expected); + }, + ); + + test('strips trailing separator from target', () => { + expect( + pathUtils.resolveSymlinkToNormal('link', p('/project/root/dir/')), + ).toEqual('dir'); + }); + }); }); diff --git a/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js b/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js index a94efce125..308c91a740 100644 --- a/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js +++ b/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js @@ -41,18 +41,18 @@ describe.each([['win32'], ['posix']])('TreeFS on %s', platform => { rootDir: p('/project'), files: new Map([ [p('foo/another.js'), [123, 2, 0, null, 0, 'another']], - [p('foo/owndir'), [0, 0, 0, null, '.', null]], - [p('foo/link-to-bar.js'), [0, 0, 0, null, p('../bar.js'), null]], - [p('foo/link-to-another.js'), [0, 0, 0, null, p('another.js'), null]], + [p('foo/owndir'), [0, 0, 0, null, 'foo', null]], + [p('foo/link-to-bar.js'), [0, 0, 0, null, 'bar.js', null]], + [p('foo/link-to-another.js'), [0, 0, 0, null, 'foo/another.js', null]], [p('../outside/external.js'), [0, 0, 0, null, 0, null]], [p('bar.js'), [234, 3, 0, null, 0, 'bar']], - [p('link-to-foo'), [456, 0, 0, null, p('./../project/foo'), null]], - [p('abs-link-out'), [456, 0, 0, null, p('/outside/./baz/..'), null]], + [p('link-to-foo'), [456, 0, 0, null, 'foo', null]], + [p('abs-link-out'), [456, 0, 0, null, '../outside', null]], [p('root'), [0, 0, 0, null, '..', null]], - [p('link-to-nowhere'), [123, 0, 0, null, p('./nowhere'), null]], - [p('link-to-self'), [123, 0, 0, null, p('./link-to-self'), null]], - [p('link-cycle-1'), [123, 0, 0, null, p('./link-cycle-2'), null]], - [p('link-cycle-2'), [123, 0, 0, null, p('./link-cycle-1'), null]], + [p('link-to-nowhere'), [123, 0, 0, null, 'nowhere', null]], + [p('link-to-self'), [123, 0, 0, null, 'link-to-self', null]], + [p('link-cycle-1'), [123, 0, 0, null, 'link-cycle-2', null]], + [p('link-cycle-2'), [123, 0, 0, null, 'link-cycle-1', null]], [p('node_modules/pkg/a.js'), [123, 0, 0, null, 0, 'a']], [p('node_modules/pkg/package.json'), [123, 0, 0, null, 0, 'pkg']], ]), @@ -194,7 +194,7 @@ describe.each([['win32'], ['posix']])('TreeFS on %s', platform => { rootDir: p('/deep/project/root'), files: new Map([ [p('foo/index.js'), [123, 0, 0, null, 0, null]], - [p('link-up'), [123, 0, 0, null, p('..'), null]], + [p('link-up'), [123, 0, 0, null, '..', null]], ]), processFile: () => { throw new Error('Not implemented'); @@ -221,7 +221,7 @@ describe.each([['win32'], ['posix']])('TreeFS on %s', platform => { describe('symlinks to an ancestor of the project root', () => { beforeEach(() => { - tfs.addOrModify(p('foo/link-up-2'), [0, 0, 0, null, p('../..'), null]); + tfs.addOrModify(p('foo/link-up-2'), [0, 0, 0, null, '..', null]); }); test.each([ @@ -277,14 +277,14 @@ describe.each([['win32'], ['posix']])('TreeFS on %s', platform => { test('returns changed (inc. new) and removed files in given FileData', () => { const newFiles: FileData = new Map([ [p('new-file'), [789, 0, 0, null, 0, null]], - [p('link-to-foo'), [456, 0, 0, null, p('./foo'), null]], + [p('link-to-foo'), [456, 0, 0, null, 'foo', null]], // Different modified time, expect new mtime in changedFiles [p('foo/another.js'), [124, 0, 0, null, 0, null]], - [p('link-cycle-1'), [123, 0, 0, null, p('./link-cycle-2'), null]], - [p('link-cycle-2'), [123, 0, 0, null, p('./link-cycle-1'), null]], + [p('link-cycle-1'), [123, 0, 0, null, 'link-cycle-2', null]], + [p('link-cycle-2'), [123, 0, 0, null, 'link-cycle-1', null]], // Was a symlink, now a regular file [p('link-to-self'), [123, 0, 0, null, 0, null]], - [p('link-to-nowhere'), [123, 0, 0, null, p('./nowhere'), null]], + [p('link-to-nowhere'), [123, 0, 0, null, 'nowhere', null]], [p('node_modules/pkg/a.js'), [123, 0, 0, null, 0, 'a']], [p('node_modules/pkg/package.json'), [123, 0, 0, null, 0, 'pkg']], ]); @@ -393,24 +393,18 @@ describe.each([['win32'], ['posix']])('TreeFS on %s', platform => { [ [ p('a/1/package.json'), - [0, 0, 0, null, './real-package.json', null], + [0, 0, 0, null, 'a/1/real-package.json', null], ], [ p('a/2/package.json'), - [0, 0, 0, null, './notexist-package.json', null], - ], - [p('a/b/c/d/link-to-C'), [0, 0, 0, null, p('../../../..'), null]], - [ - p('a/b/c/d/link-to-B'), - [0, 0, 0, null, p('../../../../..'), null], - ], - [ - p('a/b/c/d/link-to-A'), - [0, 0, 0, null, p('../../../../../..'), null], + [0, 0, 0, null, 'a/2/notexist-package.json', null], ], + [p('a/b/c/d/link-to-C'), [0, 0, 0, null, '', null]], + [p('a/b/c/d/link-to-B'), [0, 0, 0, null, '..', null]], + [p('a/b/c/d/link-to-A'), [0, 0, 0, null, '../..', null]], [ p('n_m/workspace/link-to-pkg'), - [0, 0, 0, null, p('../../../workspace-pkg'), null], + [0, 0, 0, null, '../workspace-pkg', null], ], ] as Array<[CanonicalPath, FileMetadata]> ).concat( @@ -817,7 +811,7 @@ describe.each([['win32'], ['posix']])('TreeFS on %s', platform => { new Map([ [ p('newdir/link-to-link-to-bar.js'), - [0, 0, 0, null, p('../foo/link-to-bar.js'), null], + [0, 0, 0, null, 'foo/link-to-bar.js', null], ], [p('foo/baz.js'), [0, 0, 0, null, 0, null]], [p('bar.js'), [999, 1, 0, null, 0, null]], @@ -967,7 +961,7 @@ describe.each([['win32'], ['posix']])('TreeFS on %s', platform => { { baseName: 'link-to-bar.js', canonicalPath: p('foo/link-to-bar.js'), - metadata: [0, 0, 0, null, p('../bar.js'), null], + metadata: [0, 0, 0, null, 'bar.js', null], }, ]), ); @@ -983,7 +977,7 @@ describe.each([['win32'], ['posix']])('TreeFS on %s', platform => { files: new Map([ [p('foo.js'), [123, 0, 0, 'def456', 0, null]], [p('bar.js'), [123, 0, 0, null, 0, null]], - [p('link-to-bar'), [456, 0, 0, null, p('./bar.js'), null]], + [p('link-to-bar'), [456, 0, 0, null, 'bar.js', null]], ]), processFile: mockProcessFile, }); @@ -1084,7 +1078,7 @@ describe.each([['win32'], ['posix']])('TreeFS on %s', platform => { files: new Map([ [p('existing.js'), [123, 0, 0, '', 0]], [p('dir/nested.js'), [456, 0, 0, '', 0]], - [p('mylink'), [0, 0, 0, '', p('./dir')]], + [p('mylink'), [0, 0, 0, '', 'dir']], ]), processFile: () => { throw new Error('Not implemented'); @@ -1214,23 +1208,19 @@ describe.each([['win32'], ['posix']])('TreeFS on %s', platform => { test('tracks added files when adding a symlink', () => { simpleTfs.addOrModify( p('link-to-existing'), - [0, 0, 0, '', p('./existing.js')], + [0, 0, 0, '', 'existing.js'], listener, ); expect(logChange.mock.calls).toEqual([ - [ - 'fileAdded', - p('link-to-existing'), - [0, 0, 0, '', p('./existing.js')], - ], + ['fileAdded', p('link-to-existing'), [0, 0, 0, '', 'existing.js']], ]); }); test('tracks removed symlinks with their metadata', () => { simpleTfs.remove(p('mylink'), listener); expect(logChange.mock.calls).toEqual([ - ['fileRemoved', p('mylink'), [0, 0, 0, '', p('./dir')]], + ['fileRemoved', p('mylink'), [0, 0, 0, '', 'dir']], ]); }); }); @@ -1302,7 +1292,7 @@ describe.each([['win32'], ['posix']])('TreeFS on %s', platform => { rootDir: 'C:\\project', files: new Map([ ['..\\..\\D:\\external\\file.js', externalMeta], - ['link', [0, 0, 0, null, 'D:\\external\\file.js', null]], + ['link', [0, 0, 0, null, '../../D:/external/file.js', null]], ]), processFile: () => { throw new Error('Not implemented'); From 5c75cd07f405607657643a0c8746fe4bea0b08e4 Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Wed, 22 Apr 2026 09:01:54 +0100 Subject: [PATCH 04/13] Drop intermediate data structure in resolveSymlinkTargetToNormalPath --- packages/metro-file-map/src/lib/TreeFS.js | 29 +++++++---------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/packages/metro-file-map/src/lib/TreeFS.js b/packages/metro-file-map/src/lib/TreeFS.js index e72418c1f1..f23d4c2963 100644 --- a/packages/metro-file-map/src/lib/TreeFS.js +++ b/packages/metro-file-map/src/lib/TreeFS.js @@ -38,12 +38,6 @@ function isRegularFile(node: FileNode): boolean { return node[H.SYMLINK] === 0; } -type NormalizedSymlinkTarget = { - ancestorOfRootIdx: ?number, - normalPath: string, - startOfBasenameIdx: number, -}; - type DeserializedSnapshotInput = { rootDir: string, fileSystemData: DirectoryNode, @@ -738,7 +732,7 @@ export default class TreeFS implements MutableFileSystem { // Append any subsequent path segments to the symlink target, and reset // with our new target. const joinedResult = this.#pathUtils.joinNormalToRelative( - normalSymlinkTarget.normalPath, + normalSymlinkTarget, remainingTargetPath, ); @@ -754,12 +748,13 @@ export default class TreeFS implements MutableFileSystem { // with the remaining path results in collapsing segments, e.g: // '../..' + 'parentofroot/root/foo.js' = 'foo.js', then we must add // parentofroot and root as ancestors. + ancestorOfRootIdx = + this.#pathUtils.getAncestorOfRootIdx(normalSymlinkTarget); if ( collectAncestors && !isLastSegment && // No-op optimisation to bail out the common case of nothing to do. - (normalSymlinkTarget.ancestorOfRootIdx === 0 || - joinedResult.collapsedSegments > 0) + (ancestorOfRootIdx === 0 || joinedResult.collapsedSegments > 0) ) { let node: MixedNode = this.#rootNode; let collapsedPath = ''; @@ -774,7 +769,7 @@ export default class TreeFS implements MutableFileSystem { // Add the root only if the target is the root or we have // collapsed segments. i > 0 || - normalSymlinkTarget.ancestorOfRootIdx === 0 || + ancestorOfRootIdx === 0 || joinedResult.collapsedSegments > 0 ) { reverseAncestors.push({ @@ -797,7 +792,7 @@ export default class TreeFS implements MutableFileSystem { // the symlink target, and start collecting ancestors only // from the target itself (ie, the basename of the normal target path) // onwards. - unseenPathFromIdx = normalSymlinkTarget.startOfBasenameIdx; + unseenPathFromIdx = normalSymlinkTarget.lastIndexOf(path.sep) + 1; if (seen == null) { // Optimisation: set this lazily only when we've encountered a symlink @@ -1222,19 +1217,13 @@ export default class TreeFS implements MutableFileSystem { #resolveSymlinkTargetToNormalPath( symlinkNode: FileMetadata, canonicalPathOfSymlink: Path, - ): NormalizedSymlinkTarget { - let symlinkTarget = symlinkNode[H.SYMLINK]; + ): Path { + const symlinkTarget = symlinkNode[H.SYMLINK]; invariant( typeof symlinkTarget === 'string', 'Expected symlink target to be populated.', ); - symlinkTarget = normalizePathSeparatorsToSystem(symlinkTarget); - const result = { - ancestorOfRootIdx: this.#pathUtils.getAncestorOfRootIdx(symlinkTarget), - normalPath: symlinkTarget, - startOfBasenameIdx: symlinkTarget.lastIndexOf(path.sep) + 1, - }; - return result; + return normalizePathSeparatorsToSystem(symlinkTarget); } #getFileData( From 4bd59bab8e6019d0fefdee4bac33a532af9e2f15 Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Wed, 22 Apr 2026 09:07:11 +0100 Subject: [PATCH 05/13] Lazily compute ancestor of root idx --- packages/metro-file-map/src/lib/TreeFS.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/metro-file-map/src/lib/TreeFS.js b/packages/metro-file-map/src/lib/TreeFS.js index f23d4c2963..09dc574e97 100644 --- a/packages/metro-file-map/src/lib/TreeFS.js +++ b/packages/metro-file-map/src/lib/TreeFS.js @@ -748,13 +748,13 @@ export default class TreeFS implements MutableFileSystem { // with the remaining path results in collapsing segments, e.g: // '../..' + 'parentofroot/root/foo.js' = 'foo.js', then we must add // parentofroot and root as ancestors. - ancestorOfRootIdx = - this.#pathUtils.getAncestorOfRootIdx(normalSymlinkTarget); if ( collectAncestors && !isLastSegment && // No-op optimisation to bail out the common case of nothing to do. - (ancestorOfRootIdx === 0 || joinedResult.collapsedSegments > 0) + ((ancestorOfRootIdx = + this.#pathUtils.getAncestorOfRootIdx(normalSymlinkTarget)) === 0 || + joinedResult.collapsedSegments > 0) ) { let node: MixedNode = this.#rootNode; let collapsedPath = ''; From 134d005fc489b66d35a51d7a3c9e2e1c433656c8 Mon Sep 17 00:00:00 2001 From: Rob Hogan <2590098+robhogan@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:20:22 +0100 Subject: [PATCH 06/13] Resolve symlink targets pointing at a filesystem root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolveSymlinkToNormal` trimmed a trailing separator from the readlink result before testing whether it was absolute. For a filesystem root that separator is the only thing making the path absolute, so `/` (or `D:\\`) fell through to the relative branch and resolved to the project root instead of the ancestor chain — silently, and on both platforms. Normalize first and trim the separator off the result instead, which covers both the filesystem-root case and the trailing separator that readlink can return for a Windows junction. --- .../metro-file-map/src/lib/RootPathUtils.js | 31 ++++++++++--------- .../src/lib/__tests__/RootPathUtils-test.js | 28 +++++++++++++++++ .../src/lib/__tests__/TreeFS-test.js | 18 +++++++++++ 3 files changed, 62 insertions(+), 15 deletions(-) diff --git a/packages/metro-file-map/src/lib/RootPathUtils.js b/packages/metro-file-map/src/lib/RootPathUtils.js index 92c5f70de9..cd149b3edb 100644 --- a/packages/metro-file-map/src/lib/RootPathUtils.js +++ b/packages/metro-file-map/src/lib/RootPathUtils.js @@ -179,23 +179,24 @@ export class RootPathUtils { symlinkNormalPath: string, readlinkResult: string, ): string { - let target = normalizePathSeparatorsToSystem(readlinkResult); - // WARN: This only applies to Windows + Node 20 case, where the value is completely - // unnormalized and a trailing slash may be returned - if (target[target.length - 1] === path.sep) { - target = target.slice(0, -1); - } + const target = normalizePathSeparatorsToSystem(readlinkResult); + let normal; if (path.isAbsolute(target)) { - return this.absoluteToNormal(target); + normal = this.absoluteToNormal(target); + } else { + // Resolve relative to the symlink's containing directory, expressed as + // a root-relative (possibly non-normal) path, then normalize + const sepIdx = symlinkNormalPath.lastIndexOf(path.sep); + const rootRelativeTarget = + sepIdx === -1 + ? target + : symlinkNormalPath.slice(0, sepIdx) + path.sep + target; + normal = this.relativeToNormal(rootRelativeTarget); } - // Resolve relative to the symlink's containing directory, expressed as - // a root-relative (possibly non-normal) path, then normalize - const sepIdx = symlinkNormalPath.lastIndexOf(path.sep); - const rootRelativeTarget = - sepIdx === -1 - ? target - : symlinkNormalPath.slice(0, sepIdx) + path.sep + target; - return this.relativeToNormal(rootRelativeTarget); + // Normalization keeps a trailing separator when the result is the root or + // an ancestor of it (e.g. a link to '/'), and readlink itself may return + // one. A stored symlink target never has one. + return normal.endsWith(path.sep) ? normal.slice(0, -1) : normal; } // If a path is a direct ancestor of the project root (or the root itself), diff --git a/packages/metro-file-map/src/lib/__tests__/RootPathUtils-test.js b/packages/metro-file-map/src/lib/__tests__/RootPathUtils-test.js index b438364e70..62be8f81f1 100644 --- a/packages/metro-file-map/src/lib/__tests__/RootPathUtils-test.js +++ b/packages/metro-file-map/src/lib/__tests__/RootPathUtils-test.js @@ -286,5 +286,33 @@ describe.each([['win32'], ['posix']])('RootPathUtils on %s', platform => { pathUtils.resolveSymlinkToNormal('link', p('/project/root/dir/')), ).toEqual('dir'); }); + + // A filesystem root is the one absolute target that consists only of a + // separator, so it must not be trimmed before we test for absoluteness. + test.each([ + ['link', p('/'), p('../..')], + [p('a/link'), p('/'), p('../..')], + ])( + 'resolves filesystem root target (%s -> %s) to %s', + (symlinkPath, readlinkResult, expected) => { + expect( + pathUtils.resolveSymlinkToNormal(symlinkPath, readlinkResult), + ).toEqual(expected); + }, + ); + + if (platform === 'win32') { + test.each([ + ['D:\\', '..\\..\\..\\D:'], + ['D:\\ext\\', '..\\..\\..\\D:\\ext'], + ])( + 'resolves cross-drive root target (%s) to %s', + (readlinkResult, expected) => { + expect( + pathUtils.resolveSymlinkToNormal('link', readlinkResult), + ).toEqual(expected); + }, + ); + } }); }); diff --git a/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js b/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js index 308c91a740..2084c87f71 100644 --- a/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js +++ b/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js @@ -253,6 +253,24 @@ describe.each([['win32'], ['posix']])('TreeFS on %s', platform => { }, ); + test('a link to the filesystem root behaves like a link to ..', () => { + const {RootPathUtils} = require('../RootPathUtils'); + // The project root is one level below the filesystem root here, so a + // symlink to the filesystem root must resolve to '..', not to ''. + const stored = new RootPathUtils(p('/project')).resolveSymlinkToNormal( + p('foo/link-to-fs-root'), + p('/'), + ); + expect(stored).toEqual('..'); + tfs.addOrModify(p('foo/link-to-fs-root'), [0, 0, 0, null, stored, null]); + expect(tfs.lookup(p('foo/link-to-fs-root/project/bar.js'))).toMatchObject( + { + exists: true, + realPath: p('/project/bar.js'), + }, + ); + }); + test('matchFiles follows links up', () => { const matches = [ ...tfs.matchFiles({ From bab1a2d361fa68ddd9abef75b4dcbae0d6a6da74 Mon Sep 17 00:00:00 2001 From: Rob Hogan <2590098+robhogan@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:20:48 +0100 Subject: [PATCH 07/13] Store symlink targets in system separators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The canonical paths keyed in the same `FileData` map are already in system separators, so storing `H.SYMLINK` in POSIX made one field of the tuple disagree with its own key, and cost a `replace()` allocation on every traversal of every symlink on Windows — where the old `WeakMap` had done the conversion once per node. The cache is machine-local, so cross-platform identity of the stored value buys nothing outside tests. With the conversion gone, `#resolveSymlinkTargetToNormalPath` is a field read plus an assertion, so inline it into its only caller and drop the unused `canonicalPathOfSymlink` parameter. Also updates the `TreeFS` and `FileMetadata` docblocks, which still described the stored value as the literal output of readlink. --- .../src/crawlers/watchman/index.js | 5 +-- packages/metro-file-map/src/flow-types.js | 3 +- packages/metro-file-map/src/index.js | 5 +-- packages/metro-file-map/src/lib/TreeFS.js | 35 +++++++------------ .../src/lib/__tests__/TreeFS-test.js | 19 +++++----- 5 files changed, 31 insertions(+), 36 deletions(-) diff --git a/packages/metro-file-map/src/crawlers/watchman/index.js b/packages/metro-file-map/src/crawlers/watchman/index.js index 31b2503c6a..0b3afba2a4 100644 --- a/packages/metro-file-map/src/crawlers/watchman/index.js +++ b/packages/metro-file-map/src/crawlers/watchman/index.js @@ -328,8 +328,9 @@ export default async function watchmanCrawl({ symlinkInfo = fileData['symlink_target'] ?? 1; } if (typeof symlinkInfo === 'string') { - symlinkInfo = normalizePathSeparatorsToPosix( - pathUtils.resolveSymlinkToNormal(relativeFilePath, symlinkInfo), + symlinkInfo = pathUtils.resolveSymlinkToNormal( + relativeFilePath, + symlinkInfo, ); } diff --git a/packages/metro-file-map/src/flow-types.js b/packages/metro-file-map/src/flow-types.js index 73bb8a6249..71cef3aa2b 100644 --- a/packages/metro-file-map/src/flow-types.js +++ b/packages/metro-file-map/src/flow-types.js @@ -292,7 +292,8 @@ export type FileMetadata = [ /* size */ number, /* visited */ 0 | 1, /* sha1 */ ?string, - /* symlink */ 0 | 1 | string, // string specifies target, if known + // A string is the symlink's target, resolved to a normal path, if known. + /* symlink */ 0 | 1 | string, /* plugindata */ ... ]; diff --git a/packages/metro-file-map/src/index.js b/packages/metro-file-map/src/index.js index 62d88c7d71..6d5508e5b7 100644 --- a/packages/metro-file-map/src/index.js +++ b/packages/metro-file-map/src/index.js @@ -592,8 +592,9 @@ export default class FileMap extends EventEmitter { .readlink(this.#pathUtils.normalToAbsolute(normalPath)) .then(symlinkTarget => { fileMetadata[H.VISITED] = 1; - fileMetadata[H.SYMLINK] = normalizePathSeparatorsToPosix( - this.#pathUtils.resolveSymlinkToNormal(normalPath, symlinkTarget), + fileMetadata[H.SYMLINK] = this.#pathUtils.resolveSymlinkToNormal( + normalPath, + symlinkTarget, ); }); } diff --git a/packages/metro-file-map/src/lib/TreeFS.js b/packages/metro-file-map/src/lib/TreeFS.js index 09dc574e97..8cafe39bf3 100644 --- a/packages/metro-file-map/src/lib/TreeFS.js +++ b/packages/metro-file-map/src/lib/TreeFS.js @@ -21,7 +21,6 @@ import type { } from '../flow-types'; import H from '../constants'; -import normalizePathSeparatorsToSystem from './normalizePathSeparatorsToSystem'; import {RootPathUtils} from './RootPathUtils'; import invariant from 'invariant'; import path from 'node:path'; @@ -90,10 +89,10 @@ type MetadataIteratorOptions = Readonly<{ * * SYMLINKS: * - * Symlinks are represented as nodes whose metadata contains their literal - * target. Literal targets are resolved to normal paths at runtime, and cached. - * If a symlink is encountered during traversal, we restart traversal at the - * root node targeting join(normal symlink target, remaining path suffix). + * Symlinks are represented as nodes whose metadata contains their target, + * already resolved to a normal path when the node was populated. If a symlink + * is encountered during traversal, we restart traversal at the root node + * targeting join(normal symlink target, remaining path suffix). * * NODE TYPES: * @@ -102,8 +101,8 @@ type MetadataIteratorOptions = Readonly<{ * - A file is represented by an `Array` (tuple) of metadata, of which: * - A regular file has node[H.SYMLINK] === 0 * - A symlink has node[H.SYMLINK] === 1 or - * typeof node[H.SYMLINK] === 'string', where a string is the literal - * content of the symlink (i.e. from readlink), if known. + * typeof node[H.SYMLINK] === 'string', where a string is the target + * resolved to a normal path, if known. * * TERMINOLOGY: * @@ -714,10 +713,12 @@ export default class TreeFS implements MutableFileSystem { }; } - // Symlink in a directory path - const normalSymlinkTarget = this.#resolveSymlinkTargetToNormalPath( - segmentNode, - currentPath, + // Symlink in a directory path. Targets are stored already resolved + // to a normal path, so this is a read rather than a computation. + const normalSymlinkTarget = segmentNode[H.SYMLINK]; + invariant( + typeof normalSymlinkTarget === 'string', + 'Expected symlink target to be populated.', ); if (opts.collectLinkPaths) { opts.collectLinkPaths.add( @@ -1214,18 +1215,6 @@ export default class TreeFS implements MutableFileSystem { } } - #resolveSymlinkTargetToNormalPath( - symlinkNode: FileMetadata, - canonicalPathOfSymlink: Path, - ): Path { - const symlinkTarget = symlinkNode[H.SYMLINK]; - invariant( - typeof symlinkTarget === 'string', - 'Expected symlink target to be populated.', - ); - return normalizePathSeparatorsToSystem(symlinkTarget); - } - #getFileData( filePath: Path, opts: {followLeaf: boolean} = {followLeaf: true}, diff --git a/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js b/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js index 2084c87f71..fc2bb94583 100644 --- a/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js +++ b/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js @@ -43,11 +43,14 @@ describe.each([['win32'], ['posix']])('TreeFS on %s', platform => { [p('foo/another.js'), [123, 2, 0, null, 0, 'another']], [p('foo/owndir'), [0, 0, 0, null, 'foo', null]], [p('foo/link-to-bar.js'), [0, 0, 0, null, 'bar.js', null]], - [p('foo/link-to-another.js'), [0, 0, 0, null, 'foo/another.js', null]], + [ + p('foo/link-to-another.js'), + [0, 0, 0, null, p('foo/another.js'), null], + ], [p('../outside/external.js'), [0, 0, 0, null, 0, null]], [p('bar.js'), [234, 3, 0, null, 0, 'bar']], [p('link-to-foo'), [456, 0, 0, null, 'foo', null]], - [p('abs-link-out'), [456, 0, 0, null, '../outside', null]], + [p('abs-link-out'), [456, 0, 0, null, p('../outside'), null]], [p('root'), [0, 0, 0, null, '..', null]], [p('link-to-nowhere'), [123, 0, 0, null, 'nowhere', null]], [p('link-to-self'), [123, 0, 0, null, 'link-to-self', null]], @@ -411,18 +414,18 @@ describe.each([['win32'], ['posix']])('TreeFS on %s', platform => { [ [ p('a/1/package.json'), - [0, 0, 0, null, 'a/1/real-package.json', null], + [0, 0, 0, null, p('a/1/real-package.json'), null], ], [ p('a/2/package.json'), - [0, 0, 0, null, 'a/2/notexist-package.json', null], + [0, 0, 0, null, p('a/2/notexist-package.json'), null], ], [p('a/b/c/d/link-to-C'), [0, 0, 0, null, '', null]], [p('a/b/c/d/link-to-B'), [0, 0, 0, null, '..', null]], - [p('a/b/c/d/link-to-A'), [0, 0, 0, null, '../..', null]], + [p('a/b/c/d/link-to-A'), [0, 0, 0, null, p('../..'), null]], [ p('n_m/workspace/link-to-pkg'), - [0, 0, 0, null, '../workspace-pkg', null], + [0, 0, 0, null, p('../workspace-pkg'), null], ], ] as Array<[CanonicalPath, FileMetadata]> ).concat( @@ -829,7 +832,7 @@ describe.each([['win32'], ['posix']])('TreeFS on %s', platform => { new Map([ [ p('newdir/link-to-link-to-bar.js'), - [0, 0, 0, null, 'foo/link-to-bar.js', null], + [0, 0, 0, null, p('foo/link-to-bar.js'), null], ], [p('foo/baz.js'), [0, 0, 0, null, 0, null]], [p('bar.js'), [999, 1, 0, null, 0, null]], @@ -1310,7 +1313,7 @@ describe.each([['win32'], ['posix']])('TreeFS on %s', platform => { rootDir: 'C:\\project', files: new Map([ ['..\\..\\D:\\external\\file.js', externalMeta], - ['link', [0, 0, 0, null, '../../D:/external/file.js', null]], + ['link', [0, 0, 0, null, '..\\..\\D:\\external\\file.js', null]], ]), processFile: () => { throw new Error('Not implemented'); From ba3b95feef0dde2e6f7740f1abbbcf220a330df2 Mon Sep 17 00:00:00 2001 From: Rob Hogan <2590098+robhogan@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:20:52 +0100 Subject: [PATCH 08/13] Note that ancestorOfRootIdx is assigned inside the guard --- packages/metro-file-map/src/lib/TreeFS.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/metro-file-map/src/lib/TreeFS.js b/packages/metro-file-map/src/lib/TreeFS.js index 8cafe39bf3..339eb68028 100644 --- a/packages/metro-file-map/src/lib/TreeFS.js +++ b/packages/metro-file-map/src/lib/TreeFS.js @@ -753,6 +753,8 @@ export default class TreeFS implements MutableFileSystem { collectAncestors && !isLastSegment && // No-op optimisation to bail out the common case of nothing to do. + // Note that ancestorOfRootIdx is assigned here, so it is only up to + // date within this branch - the block below is its sole reader. ((ancestorOfRootIdx = this.#pathUtils.getAncestorOfRootIdx(normalSymlinkTarget)) === 0 || joinedResult.collapsedSegments > 0) From 965472bbfa5926d07c943a2cf01f8c8397b497f5 Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Thu, 24 Sep 2026 08:49:36 +0100 Subject: [PATCH 09/13] Store symlink targets with POSIX separators `TreeFS` snapshots are meant to be portable between root directories and operating systems (see the SERIALISATION note in `TreeFS.js`), and a symlink's target is the only path stored inside the tree. Storing it in system separators broke that in both directions: a snapshot built on POSIX no longer followed symlinks on Windows, which works today, and one built on Windows still failed on POSIX. Store the normal target with POSIX separators at both write sites, and convert it to system separators where traversal reads it. This reverses the storage change in bab1a2d and keeps its inlining and docblock updates. The conversion is the identity function on POSIX. On Windows it allocates on each traversal of a symlink, which can be memoized per node on Windows only if it shows up in measurements. Adds a test that builds a snapshot under one platform's `path` module, loads it under the other, and looks up a file through a symlink. --- .../src/crawlers/watchman/index.js | 5 +- packages/metro-file-map/src/flow-types.js | 3 +- packages/metro-file-map/src/index.js | 5 +- packages/metro-file-map/src/lib/TreeFS.js | 21 ++-- .../src/lib/__tests__/TreeFS-test.js | 100 ++++++++++++++++-- 5 files changed, 109 insertions(+), 25 deletions(-) diff --git a/packages/metro-file-map/src/crawlers/watchman/index.js b/packages/metro-file-map/src/crawlers/watchman/index.js index 0b3afba2a4..31b2503c6a 100644 --- a/packages/metro-file-map/src/crawlers/watchman/index.js +++ b/packages/metro-file-map/src/crawlers/watchman/index.js @@ -328,9 +328,8 @@ export default async function watchmanCrawl({ symlinkInfo = fileData['symlink_target'] ?? 1; } if (typeof symlinkInfo === 'string') { - symlinkInfo = pathUtils.resolveSymlinkToNormal( - relativeFilePath, - symlinkInfo, + symlinkInfo = normalizePathSeparatorsToPosix( + pathUtils.resolveSymlinkToNormal(relativeFilePath, symlinkInfo), ); } diff --git a/packages/metro-file-map/src/flow-types.js b/packages/metro-file-map/src/flow-types.js index 71cef3aa2b..cf9251631f 100644 --- a/packages/metro-file-map/src/flow-types.js +++ b/packages/metro-file-map/src/flow-types.js @@ -292,7 +292,8 @@ export type FileMetadata = [ /* size */ number, /* visited */ 0 | 1, /* sha1 */ ?string, - // A string is the symlink's target, resolved to a normal path, if known. + // A string is the symlink's target, resolved to a normal path with POSIX + // separators, if known. /* symlink */ 0 | 1 | string, /* plugindata */ ... diff --git a/packages/metro-file-map/src/index.js b/packages/metro-file-map/src/index.js index 6d5508e5b7..62d88c7d71 100644 --- a/packages/metro-file-map/src/index.js +++ b/packages/metro-file-map/src/index.js @@ -592,9 +592,8 @@ export default class FileMap extends EventEmitter { .readlink(this.#pathUtils.normalToAbsolute(normalPath)) .then(symlinkTarget => { fileMetadata[H.VISITED] = 1; - fileMetadata[H.SYMLINK] = this.#pathUtils.resolveSymlinkToNormal( - normalPath, - symlinkTarget, + fileMetadata[H.SYMLINK] = normalizePathSeparatorsToPosix( + this.#pathUtils.resolveSymlinkToNormal(normalPath, symlinkTarget), ); }); } diff --git a/packages/metro-file-map/src/lib/TreeFS.js b/packages/metro-file-map/src/lib/TreeFS.js index 339eb68028..07c229e89f 100644 --- a/packages/metro-file-map/src/lib/TreeFS.js +++ b/packages/metro-file-map/src/lib/TreeFS.js @@ -21,6 +21,7 @@ import type { } from '../flow-types'; import H from '../constants'; +import normalizePathSeparatorsToSystem from './normalizePathSeparatorsToSystem'; import {RootPathUtils} from './RootPathUtils'; import invariant from 'invariant'; import path from 'node:path'; @@ -90,9 +91,12 @@ type MetadataIteratorOptions = Readonly<{ * SYMLINKS: * * Symlinks are represented as nodes whose metadata contains their target, - * already resolved to a normal path when the node was populated. If a symlink - * is encountered during traversal, we restart traversal at the root node - * targeting join(normal symlink target, remaining path suffix). + * already resolved to a normal path with POSIX separators when the node was + * populated. The target is the only path stored inside the tree, so it must + * not use system separators, or the snapshot would not be portable between + * operating systems. If a symlink is encountered during traversal, we restart + * traversal at the root node targeting join(normal symlink target, remaining + * path suffix). * * NODE TYPES: * @@ -102,7 +106,7 @@ type MetadataIteratorOptions = Readonly<{ * - A regular file has node[H.SYMLINK] === 0 * - A symlink has node[H.SYMLINK] === 1 or * typeof node[H.SYMLINK] === 'string', where a string is the target - * resolved to a normal path, if known. + * resolved to a normal path with POSIX separators, if known. * * TERMINOLOGY: * @@ -714,12 +718,15 @@ export default class TreeFS implements MutableFileSystem { } // Symlink in a directory path. Targets are stored already resolved - // to a normal path, so this is a read rather than a computation. - const normalSymlinkTarget = segmentNode[H.SYMLINK]; + // to a normal path, with POSIX separators so that the snapshot is + // portable between operating systems. + const storedSymlinkTarget = segmentNode[H.SYMLINK]; invariant( - typeof normalSymlinkTarget === 'string', + typeof storedSymlinkTarget === 'string', 'Expected symlink target to be populated.', ); + const normalSymlinkTarget = + normalizePathSeparatorsToSystem(storedSymlinkTarget); if (opts.collectLinkPaths) { opts.collectLinkPaths.add( this.#pathUtils.normalToAbsolute(currentPath), diff --git a/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js b/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js index fc2bb94583..186e9aa112 100644 --- a/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js +++ b/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js @@ -16,6 +16,7 @@ import type { FileSystemListener, } from '../../flow-types'; import type TreeFSType from '../TreeFS'; +import typeof * as PathModule from 'node:path'; import H from '../../constants'; @@ -43,14 +44,11 @@ describe.each([['win32'], ['posix']])('TreeFS on %s', platform => { [p('foo/another.js'), [123, 2, 0, null, 0, 'another']], [p('foo/owndir'), [0, 0, 0, null, 'foo', null]], [p('foo/link-to-bar.js'), [0, 0, 0, null, 'bar.js', null]], - [ - p('foo/link-to-another.js'), - [0, 0, 0, null, p('foo/another.js'), null], - ], + [p('foo/link-to-another.js'), [0, 0, 0, null, 'foo/another.js', null]], [p('../outside/external.js'), [0, 0, 0, null, 0, null]], [p('bar.js'), [234, 3, 0, null, 0, 'bar']], [p('link-to-foo'), [456, 0, 0, null, 'foo', null]], - [p('abs-link-out'), [456, 0, 0, null, p('../outside'), null]], + [p('abs-link-out'), [456, 0, 0, null, '../outside', null]], [p('root'), [0, 0, 0, null, '..', null]], [p('link-to-nowhere'), [123, 0, 0, null, 'nowhere', null]], [p('link-to-self'), [123, 0, 0, null, 'link-to-self', null]], @@ -414,18 +412,18 @@ describe.each([['win32'], ['posix']])('TreeFS on %s', platform => { [ [ p('a/1/package.json'), - [0, 0, 0, null, p('a/1/real-package.json'), null], + [0, 0, 0, null, 'a/1/real-package.json', null], ], [ p('a/2/package.json'), - [0, 0, 0, null, p('a/2/notexist-package.json'), null], + [0, 0, 0, null, 'a/2/notexist-package.json', null], ], [p('a/b/c/d/link-to-C'), [0, 0, 0, null, '', null]], [p('a/b/c/d/link-to-B'), [0, 0, 0, null, '..', null]], - [p('a/b/c/d/link-to-A'), [0, 0, 0, null, p('../..'), null]], + [p('a/b/c/d/link-to-A'), [0, 0, 0, null, '../..', null]], [ p('n_m/workspace/link-to-pkg'), - [0, 0, 0, null, p('../workspace-pkg'), null], + [0, 0, 0, null, '../workspace-pkg', null], ], ] as Array<[CanonicalPath, FileMetadata]> ).concat( @@ -832,7 +830,7 @@ describe.each([['win32'], ['posix']])('TreeFS on %s', platform => { new Map([ [ p('newdir/link-to-link-to-bar.js'), - [0, 0, 0, null, p('foo/link-to-bar.js'), null], + [0, 0, 0, null, 'foo/link-to-bar.js', null], ], [p('foo/baz.js'), [0, 0, 0, null, 0, null]], [p('bar.js'), [999, 1, 0, null, 0, null]], @@ -1313,7 +1311,7 @@ describe.each([['win32'], ['posix']])('TreeFS on %s', platform => { rootDir: 'C:\\project', files: new Map([ ['..\\..\\D:\\external\\file.js', externalMeta], - ['link', [0, 0, 0, null, '..\\..\\D:\\external\\file.js', null]], + ['link', [0, 0, 0, null, '../../D:/external/file.js', null]], ]), processFile: () => { throw new Error('Not implemented'); @@ -1328,3 +1326,83 @@ describe.each([['win32'], ['posix']])('TreeFS on %s', platform => { }); } }); + +describe('snapshot portability between operating systems', () => { + const rootFor = (platform: string) => + platform === 'win32' ? 'C:\\project' : '/project'; + + function loadTreeFS(platform: string): Class { + jest.resetModules(); + const nodePath = jest.requireActual<{ + posix: PathModule, + win32: PathModule, + }>('path'); + mockPathModule = platform === 'win32' ? nodePath.win32 : nodePath.posix; + return require('../TreeFS').default; + } + + test.each([ + ['posix', 'win32'], + ['win32', 'posix'], + ])('a snapshot built on %s follows symlinks on %s', (from, to) => { + const TreeFSFrom = loadTreeFS(from); + const sep = mockPathModule.sep; + const {RootPathUtils} = require('../RootPathUtils'); + const normalizePathSeparatorsToPosix = + require('../normalizePathSeparatorsToPosix').default; + const linkPath = ['node_modules', 'pkg'].join(sep); + // Stored as FileMap stores a readlink result. + const storedTarget = normalizePathSeparatorsToPosix( + new RootPathUtils(rootFor(from)).resolveSymlinkToNormal( + linkPath, + ['.pnpm', 'pkg@1', 'node_modules', 'pkg'].join(sep), + ), + ); + const snapshot = new TreeFSFrom({ + rootDir: rootFor(from), + files: new Map([ + [ + [ + 'node_modules', + '.pnpm', + 'pkg@1', + 'node_modules', + 'pkg', + 'a.js', + ].join(sep), + [0, 0, 0, null, 0, null], + ], + [linkPath, [0, 0, 0, null, storedTarget, null]], + ]), + processFile: () => { + throw new Error('Not implemented'); + }, + }).getSerializableSnapshot(); + + const TreeFSTo = loadTreeFS(to); + const loaded = TreeFSTo.fromDeserializedSnapshot({ + rootDir: rootFor(to), + // Snapshots are typed opaquely for the cache; this one came from + // getSerializableSnapshot, so it is a DirectoryNode. + // $FlowFixMe[incompatible-type] + fileSystemData: snapshot, + processFile: () => { + throw new Error('Not implemented'); + }, + }); + const toPath = (...segments: Array) => + segments.join(mockPathModule.sep); + expect(loaded.lookup(toPath('node_modules', 'pkg', 'a.js'))).toMatchObject({ + exists: true, + realPath: toPath( + rootFor(to), + 'node_modules', + '.pnpm', + 'pkg@1', + 'node_modules', + 'pkg', + 'a.js', + ), + }); + }); +}); From 2e3f7009f503b7a5f891ecfe4f1c86144e6dcc10 Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Thu, 24 Sep 2026 08:49:51 +0100 Subject: [PATCH 10/13] Resolve symlink targets with path.resolve `readlink` returns a target exactly as the link was created, which need not be well-formed. `RootPathUtils`' fast paths require well-formed input, so a symlink to `..` or `b/..` made any lookup through it throw `Failed to collapse`, a symlink to `.` stored a non-normal path, and a target containing `//` was never found. Main handles all of these, because it resolves targets with `path.resolve`. Resolve the target with `path.resolve` relative to the symlink's directory, then normalize it with `absoluteToNormal`. This runs once per symlink, when its node is populated, and made no measurable difference to crawl time on a pnpm install with 3,030 symlinks. The separator conversion before it is dropped, since win32 `path.resolve` accepts `/`. --- .../metro-file-map/src/lib/RootPathUtils.js | 22 ++++++------------- .../src/lib/__tests__/RootPathUtils-test.js | 21 ++++++++++++++++++ 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/packages/metro-file-map/src/lib/RootPathUtils.js b/packages/metro-file-map/src/lib/RootPathUtils.js index cd149b3edb..d0a82e3b05 100644 --- a/packages/metro-file-map/src/lib/RootPathUtils.js +++ b/packages/metro-file-map/src/lib/RootPathUtils.js @@ -8,7 +8,6 @@ * @format */ -import normalizePathSeparatorsToSystem from './normalizePathSeparatorsToSystem'; import invariant from 'invariant'; import * as path from 'node:path'; @@ -179,20 +178,13 @@ export class RootPathUtils { symlinkNormalPath: string, readlinkResult: string, ): string { - const target = normalizePathSeparatorsToSystem(readlinkResult); - let normal; - if (path.isAbsolute(target)) { - normal = this.absoluteToNormal(target); - } else { - // Resolve relative to the symlink's containing directory, expressed as - // a root-relative (possibly non-normal) path, then normalize - const sepIdx = symlinkNormalPath.lastIndexOf(path.sep); - const rootRelativeTarget = - sepIdx === -1 - ? target - : symlinkNormalPath.slice(0, sepIdx) + path.sep + target; - normal = this.relativeToNormal(rootRelativeTarget); - } + // readlink returns whatever the link was created with, which need not be + // well-formed (e.g. '..', 'a/./b', 'a//b', or '/' separators on Windows), + // so resolve with node:path. This runs once per symlink, when its node is + // populated, not on traversal. + const normal = this.absoluteToNormal( + path.resolve(this.#rootDir, symlinkNormalPath, '..', readlinkResult), + ); // Normalization keeps a trailing separator when the result is the root or // an ancestor of it (e.g. a link to '/'), and readlink itself may return // one. A stored symlink target never has one. diff --git a/packages/metro-file-map/src/lib/__tests__/RootPathUtils-test.js b/packages/metro-file-map/src/lib/__tests__/RootPathUtils-test.js index 62be8f81f1..a7b960ffaa 100644 --- a/packages/metro-file-map/src/lib/__tests__/RootPathUtils-test.js +++ b/packages/metro-file-map/src/lib/__tests__/RootPathUtils-test.js @@ -267,6 +267,27 @@ describe.each([['win32'], ['posix']])('RootPathUtils on %s', platform => { }, ); + // readlink returns the target as the link was created, which need not be + // well-formed. On Windows, these also use '/' separators. + test.each([ + ['a/link', '..', ''], + ['a/link', '.', 'a'], + ['a/link', './', 'a'], + ['a/link', 'b/..', 'a'], + ['a/link', 'b/../c', p('a/c')], + ['a/link', 'b/./c', p('a/b/c')], + ['a/link', 'b//c', p('a/b/c')], + ['a/b/link', '../..', ''], + ['a/b/link', '../../..', '..'], + ])( + 'resolves non-well-formed target (%s -> %s) to %s', + (symlinkPath, readlinkResult, expected) => { + expect( + pathUtils.resolveSymlinkToNormal(p(symlinkPath), readlinkResult), + ).toEqual(expected); + }, + ); + test.each([ ['link', p('/project/root/target.js'), 'target.js'], ['link', p('/project/root/a/b.js'), p('a/b.js')], From b34a4aefa9f0d54159ad47bcbff5884cf43603b0 Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Thu, 24 Sep 2026 09:20:42 +0100 Subject: [PATCH 11/13] Describe stored symlink targets as lexically resolved "Resolved" can be read as a real path. The stored target is only the literal target interpreted against the symlink's directory: symlinks inside it are not followed and it need not exist, so in `TreeFS` terms it is a `normalPath`, not a `canonicalPath`. Say so wherever the `H.SYMLINK` slot is described, including the cache format comment in `index.js`, which still described the stored value as the raw target. --- packages/metro-file-map/src/flow-types.js | 4 ++-- packages/metro-file-map/src/index.js | 3 ++- packages/metro-file-map/src/lib/RootPathUtils.js | 4 ++++ packages/metro-file-map/src/lib/TreeFS.js | 15 +++++++++------ 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/metro-file-map/src/flow-types.js b/packages/metro-file-map/src/flow-types.js index cf9251631f..8f070e6d2d 100644 --- a/packages/metro-file-map/src/flow-types.js +++ b/packages/metro-file-map/src/flow-types.js @@ -292,8 +292,8 @@ export type FileMetadata = [ /* size */ number, /* visited */ 0 | 1, /* sha1 */ ?string, - // A string is the symlink's target, resolved to a normal path with POSIX - // separators, if known. + // A string is the symlink's target, lexically resolved to a normal path with + // POSIX separators, if known. The target need not exist. /* symlink */ 0 | 1 | string, /* plugindata */ ... diff --git a/packages/metro-file-map/src/index.js b/packages/metro-file-map/src/index.js index 62d88c7d71..5b6b129007 100644 --- a/packages/metro-file-map/src/index.js +++ b/packages/metro-file-map/src/index.js @@ -217,7 +217,8 @@ const WATCHMAN_REQUIRED_CAPABILITIES = [ * visited: boolean, // whether the file has been parsed or not. * dependencies: Array, // all relative dependencies of this file. * sha1: ?string, // SHA-1 of the file, if requested via options. - * symlink: ?(1 | 0 | string), // Truthy if symlink, string is target + * symlink: ?(1 | 0 | string), // Truthy if symlink, string is the target, + * // lexically resolved to a normal POSIX path * }; * * // Modules can be targeted to a specific platform based on the file name. diff --git a/packages/metro-file-map/src/lib/RootPathUtils.js b/packages/metro-file-map/src/lib/RootPathUtils.js index d0a82e3b05..53f01a0390 100644 --- a/packages/metro-file-map/src/lib/RootPathUtils.js +++ b/packages/metro-file-map/src/lib/RootPathUtils.js @@ -178,6 +178,10 @@ export class RootPathUtils { symlinkNormalPath: string, readlinkResult: string, ): string { + // Lexically resolves the target against the symlink's directory. This is + // string manipulation only: symlinks within the target are not followed, + // and the target need not exist, so the result is not a real path. + // // readlink returns whatever the link was created with, which need not be // well-formed (e.g. '..', 'a/./b', 'a//b', or '/' separators on Windows), // so resolve with node:path. This runs once per symlink, when its node is diff --git a/packages/metro-file-map/src/lib/TreeFS.js b/packages/metro-file-map/src/lib/TreeFS.js index 07c229e89f..f4db2b89f7 100644 --- a/packages/metro-file-map/src/lib/TreeFS.js +++ b/packages/metro-file-map/src/lib/TreeFS.js @@ -91,8 +91,11 @@ type MetadataIteratorOptions = Readonly<{ * SYMLINKS: * * Symlinks are represented as nodes whose metadata contains their target, - * already resolved to a normal path with POSIX separators when the node was - * populated. The target is the only path stored inside the tree, so it must + * lexically resolved to a normal path with POSIX separators when the node was + * populated. Lexical resolution only interprets the literal target relative to + * the symlink's directory: it does not follow symlinks in the target, and the + * target need not exist, so it is a normalPath, not a canonicalPath (see + * TERMINOLOGY). The target is the only path stored inside the tree, so it must * not use system separators, or the snapshot would not be portable between * operating systems. If a symlink is encountered during traversal, we restart * traversal at the root node targeting join(normal symlink target, remaining @@ -106,7 +109,7 @@ type MetadataIteratorOptions = Readonly<{ * - A regular file has node[H.SYMLINK] === 0 * - A symlink has node[H.SYMLINK] === 1 or * typeof node[H.SYMLINK] === 'string', where a string is the target - * resolved to a normal path with POSIX separators, if known. + * lexically resolved to a normal path with POSIX separators, if known. * * TERMINOLOGY: * @@ -717,9 +720,9 @@ export default class TreeFS implements MutableFileSystem { }; } - // Symlink in a directory path. Targets are stored already resolved - // to a normal path, with POSIX separators so that the snapshot is - // portable between operating systems. + // Symlink in a directory path. Targets are stored already lexically + // resolved to a normal path, with POSIX separators so that the + // snapshot is portable between operating systems. const storedSymlinkTarget = segmentNode[H.SYMLINK]; invariant( typeof storedSymlinkTarget === 'string', From 0d70193a9b419d95bb79b77dacb1cab0c00160b3 Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Thu, 24 Sep 2026 09:20:58 +0100 Subject: [PATCH 12/13] Simplify the root-target check when collecting ancestors through a symlink The guard for adding the root and its collapsed ancestors assigned `getAncestorOfRootIdx(target)` to the traversal's own `ancestorOfRootIdx` state, which was correct only because that variable is reset before the next segment is read. The stored value was never needed: `getAncestorOfRootIdx(x) === 0` holds only for `x === ''`, and the inner `i > 0 || ancestorOfRootIdx === 0 || collapsedSegments > 0` check repeats the guard, so it is always true inside the block. Test the target against `''` in the guard, drop the inner check, and remove `RootPathUtils.getAncestorOfRootIdx`, which has no other callers. Both cases of the guard are covered by existing `TreeFS` tests, and a lookup benchmark on a pnpm install with 3,030 symlinks is unchanged. --- .../metro-file-map/src/lib/RootPathUtils.js | 19 -------------- packages/metro-file-map/src/lib/TreeFS.js | 26 +++++-------------- .../src/lib/__tests__/RootPathUtils-test.js | 12 --------- 3 files changed, 7 insertions(+), 50 deletions(-) diff --git a/packages/metro-file-map/src/lib/RootPathUtils.js b/packages/metro-file-map/src/lib/RootPathUtils.js index 53f01a0390..3ba85d18f2 100644 --- a/packages/metro-file-map/src/lib/RootPathUtils.js +++ b/packages/metro-file-map/src/lib/RootPathUtils.js @@ -195,25 +195,6 @@ export class RootPathUtils { return normal.endsWith(path.sep) ? normal.slice(0, -1) : normal; } - // If a path is a direct ancestor of the project root (or the root itself), - // return a number with the degrees of separation, e.g. root=0, parent=1,.. - // or null otherwise. - getAncestorOfRootIdx(normalPath: string): ?number { - if (normalPath === '') { - return 0; - } - if (normalPath === '..') { - return 1; - } - // Otherwise a *normal* path is only a root ancestor if it is a sequence of - // '../' segments followed by '..', so the length tells us the number of - // up fragments. - if (normalPath.endsWith(SEP_UP_FRAGMENT)) { - return (normalPath.length + 1) / 3; - } - return null; - } - // Takes a normal and relative path, and joins them efficiently into a normal // path, including collapsing trailing '..' in the first part with leading // project root segments in the relative part. diff --git a/packages/metro-file-map/src/lib/TreeFS.js b/packages/metro-file-map/src/lib/TreeFS.js index f4db2b89f7..7901ea183b 100644 --- a/packages/metro-file-map/src/lib/TreeFS.js +++ b/packages/metro-file-map/src/lib/TreeFS.js @@ -763,11 +763,7 @@ export default class TreeFS implements MutableFileSystem { collectAncestors && !isLastSegment && // No-op optimisation to bail out the common case of nothing to do. - // Note that ancestorOfRootIdx is assigned here, so it is only up to - // date within this branch - the block below is its sole reader. - ((ancestorOfRootIdx = - this.#pathUtils.getAncestorOfRootIdx(normalSymlinkTarget)) === 0 || - joinedResult.collapsedSegments > 0) + (normalSymlinkTarget === '' || joinedResult.collapsedSegments > 0) ) { let node: MixedNode = this.#rootNode; let collapsedPath = ''; @@ -778,20 +774,12 @@ export default class TreeFS implements MutableFileSystem { /* for Flow, always true: */ isDirectory(node); i++ ) { - if ( - // Add the root only if the target is the root or we have - // collapsed segments. - i > 0 || - ancestorOfRootIdx === 0 || - joinedResult.collapsedSegments > 0 - ) { - reverseAncestors.push({ - ancestorOfRootIdx: i, - node, - normalPath: collapsedPath, - segmentName: this.#pathUtils.getBasenameOfNthAncestor(i), - }); - } + reverseAncestors.push({ + ancestorOfRootIdx: i, + node, + normalPath: collapsedPath, + segmentName: this.#pathUtils.getBasenameOfNthAncestor(i), + }); node = node.get('..') ?? new Map(); collapsedPath = collapsedPath === '' ? '..' : collapsedPath + path.sep + '..'; diff --git a/packages/metro-file-map/src/lib/__tests__/RootPathUtils-test.js b/packages/metro-file-map/src/lib/__tests__/RootPathUtils-test.js index a7b960ffaa..8cf92c5655 100644 --- a/packages/metro-file-map/src/lib/__tests__/RootPathUtils-test.js +++ b/packages/metro-file-map/src/lib/__tests__/RootPathUtils-test.js @@ -164,18 +164,6 @@ describe.each([['win32'], ['posix']])('RootPathUtils on %s', platform => { ); }); - test.each([ - ['foo', null], - ['', 0], - ['..', 1], - [p('../..'), 2], - [p('../../..'), 3], - [p('../../../foo'), null], - [p('../../../..foo'), null], - ])('getAncestorOfRootIdx (%s => %s)', (input, expected) => { - expect(pathUtils.getAncestorOfRootIdx(input)).toEqual(expected); - }); - if (platform === 'win32') { describe('cross-drive absolute paths (Windows)', () => { test.each([['C:\\project\\root'], ['C:\\']])( From e6e45d30eb3a0d86467091467b0e59988823575a Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Thu, 24 Sep 2026 09:21:05 +0100 Subject: [PATCH 13/13] Test symlink targets that reach or pass the filesystem root A relative target can contain more `..` segments than there are directories above the symlink. `..` at the filesystem root is the root itself, so such a target means the filesystem root, and must be stored as the path from the project root to it rather than going further up. Cover relative targets that reach and pass the filesystem root alongside the existing absolute `/` case, both in `resolveSymlinkToNormal` and in lookups through the stored target. --- .../src/lib/__tests__/RootPathUtils-test.js | 5 +++ .../src/lib/__tests__/TreeFS-test.js | 43 ++++++++++++------- 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/packages/metro-file-map/src/lib/__tests__/RootPathUtils-test.js b/packages/metro-file-map/src/lib/__tests__/RootPathUtils-test.js index 8cf92c5655..b4e4499197 100644 --- a/packages/metro-file-map/src/lib/__tests__/RootPathUtils-test.js +++ b/packages/metro-file-map/src/lib/__tests__/RootPathUtils-test.js @@ -298,9 +298,14 @@ describe.each([['win32'], ['posix']])('RootPathUtils on %s', platform => { // A filesystem root is the one absolute target that consists only of a // separator, so it must not be trimmed before we test for absoluteness. + // Relative targets may also reach the filesystem root, or pass it: '..' at + // the filesystem root is the filesystem root itself. test.each([ ['link', p('/'), p('../..')], [p('a/link'), p('/'), p('../..')], + ['link', '../..', p('../..')], + ['link', '../../../../..', p('../..')], + [p('a/link'), '../../../../../..', p('../..')], ])( 'resolves filesystem root target (%s -> %s) to %s', (symlinkPath, readlinkResult, expected) => { diff --git a/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js b/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js index 186e9aa112..e1b11afdbb 100644 --- a/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js +++ b/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js @@ -254,23 +254,36 @@ describe.each([['win32'], ['posix']])('TreeFS on %s', platform => { }, ); - test('a link to the filesystem root behaves like a link to ..', () => { - const {RootPathUtils} = require('../RootPathUtils'); - // The project root is one level below the filesystem root here, so a - // symlink to the filesystem root must resolve to '..', not to ''. - const stored = new RootPathUtils(p('/project')).resolveSymlinkToNormal( - p('foo/link-to-fs-root'), - p('/'), - ); - expect(stored).toEqual('..'); - tfs.addOrModify(p('foo/link-to-fs-root'), [0, 0, 0, null, stored, null]); - expect(tfs.lookup(p('foo/link-to-fs-root/project/bar.js'))).toMatchObject( - { + // The project root is one level below the filesystem root here, so a + // symlink to the filesystem root must resolve to '..', not to ''. That + // includes relative targets with more '..' segments than there are + // directories above the symlink: '..' at the filesystem root is the + // filesystem root itself. + test.each([[p('/')], ['../..'], ['../../../../..']])( + 'a link to the filesystem root (%s) behaves like a link to ..', + readlinkResult => { + const {RootPathUtils} = require('../RootPathUtils'); + const stored = new RootPathUtils(p('/project')).resolveSymlinkToNormal( + p('foo/link-to-fs-root'), + readlinkResult, + ); + expect(stored).toEqual('..'); + tfs.addOrModify(p('foo/link-to-fs-root'), [ + 0, + 0, + 0, + null, + stored, + null, + ]); + expect( + tfs.lookup(p('foo/link-to-fs-root/project/bar.js')), + ).toMatchObject({ exists: true, realPath: p('/project/bar.js'), - }, - ); - }); + }); + }, + ); test('matchFiles follows links up', () => { const matches = [