Skip to content
6 changes: 6 additions & 0 deletions packages/metro-file-map/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ interface FileSystem_2 {
recursive?: boolean | undefined;
rootDir?: Path | null | undefined;
}): Iterable<Path>;
readonly openDir?: ((this: this, mixedPath: Path, observations?: null | undefined | Observations) => null | undefined | OpenedDir) | undefined;
}
export {FileSystem_2 as FileSystem};

Expand Down Expand Up @@ -297,6 +298,11 @@ export class NoopCacheManager implements CacheManager {
write(): Promise<void>;
}

export type OpenedDir = {
realPath: string;
lookup(basename: string, observations?: null | undefined | Observations): LookupResult;
};

export type WatcherStatus =
| {
type: 'watchman_slow_command';
Expand Down
33 changes: 33 additions & 0 deletions packages/metro-file-map/src/flow-types.js
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,25 @@ export interface FileSystem {
*/
lookup(mixedPath: Path, observations?: ?Observations): LookupResult;

/**
* Resolve `mixedPath` to a directory. Always follow symlinks, and return an
* `OpenedDir` when it exists, mirroring `lookup`. Returns `null` when the
* path is missing or not a directory.
*
* This is an optional method for optimization. The returned handle checks
* direct children without repeating the directory traversal.
* It must not be reused after filesystem mutations.
*
* Observations passed to `openDir` match `lookup` of the directory.
* Child lookups record sufficient observations to invalidate their results,
* including symlinks followed to reach the directory.
*/
readonly openDir?: (
this: this,
mixedPath: Path,
observations?: ?Observations,
) => ?OpenedDir;

matchFiles(opts: {
/* Filter relative paths against a pattern. */
filter?: RegExp | null,
Expand Down Expand Up @@ -492,6 +511,20 @@ export type LookupResult =
metadata: FileMetadata,
};

/**
* A directory handle resolved by `FileSystem.openDir`. Valid only while the
* filesystem is unchanged during use. `lookup` checks one direct basename and
* returns the same result as `FileSystem.lookup` of that child through the
* original directory path.
*
* Observations include symlinks followed to reach the directory, but aren't
* necessarily identical to those of a full lookup.
*/
export type OpenedDir = {
realPath: string,
lookup(basename: string, observations?: ?Observations): LookupResult,
};

/**
* A mutable record of what a query observed of the file system, sufficient to
* decide later whether its answer may have changed.
Expand Down
1 change: 1 addition & 0 deletions packages/metro-file-map/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ export type {
DependencyExtractor,
FileDataResult,
FileMapRoot,
OpenedDir,
WatcherStatus,
} from './flow-types';

Expand Down
139 changes: 137 additions & 2 deletions packages/metro-file-map/src/lib/TreeFS.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
LookupResult,
MutableFileSystem,
Observations,
OpenedDir,
Path,
ProcessFileFunction,
} from '../flow-types';
Expand All @@ -27,6 +28,10 @@ import {RootPathUtils} from './RootPathUtils';
import invariant from 'invariant';
import path from 'node:path';

interface ContentObservations {
readonly content: Observations['content'];
}

type DirectoryNode = Map<string, MixedNode>;
type FileNode = FileMetadata;
type MixedNode = FileNode | DirectoryNode;
Expand Down Expand Up @@ -387,6 +392,134 @@ export default class TreeFS implements MutableFileSystem {
return {exists: true, canonicalPath, realPath, type: 'f', metadata: node};
}

/**
* See `FileSystem.openDir`. The walk stops at the directory node so later
* basename checks do not repeat it. Symlinks followed to get here are
* replayed onto each child lookup: the child path is canonical, so a fresh
* `lookup` of that path would not traverse those links again.
*/
openDir(
this: TreeFS,
mixedPath: Path,
observations?: ?Observations,
): ?OpenedDir {
const symlinkPaths = new Set<string>();
const normalPath = this.#normalizePath(mixedPath);
const result = this.#lookupByNormalPath(normalPath, {
observations: {content: symlinkPaths},
followLeaf: true,
});
if (observations) {
for (const symlinkPath of symlinkPaths) {
observations.content.add(symlinkPath);
}
}
if (!result.exists) {
if (observations) {
observations.existence.add(result.canonicalMissingPath);
}
return null;
}
const {canonicalPath, node, ancestorOfRootIdx} = result;
if (observations) {
observations.existence.add(canonicalPath);
}
if (!isDirectory(node)) {
return null;
}
const realPath = this.#pathUtils.normalToAbsolute(canonicalPath);
// Ancestor Maps omit the child leading back toward the project root.
// Use normal lookup for that child so normalization finds its node.
const childBasenameTowardRoot =
ancestorOfRootIdx != null && ancestorOfRootIdx > 0
? this.#pathUtils.getBasenameOfNthAncestor(ancestorOfRootIdx - 1)
: null;
return {
realPath,
lookup: (
basename: string,
childObservations?: ?Observations,
): LookupResult => {
if (childObservations != null && symlinkPaths.size > 0) {
for (const symlinkPath of symlinkPaths) {
childObservations.content.add(symlinkPath);
}
}
if (basename === childBasenameTowardRoot) {
return this.lookup(
canonicalPath === ''
? basename
: canonicalPath + path.sep + basename,
childObservations,
);
}
return this.#lookupInOpenedDir(
node,
canonicalPath,
basename,
childObservations,
);
},
};
}

#lookupInOpenedDir(
directoryNode: DirectoryNode,
canonicalDir: string,
basename: string,
observations: ?Observations,
): LookupResult {
// Empty names, '.', '..', and nested paths are not Map keys in a directory.
// Fall back to preserve `lookup`'s normalization and '..' handling.
if (
basename === '' ||
basename === '.' ||
basename === '..' ||
basename.includes(path.sep) ||
basename.includes('/')
) {
return this.lookup(
canonicalDir === '' ? basename : canonicalDir + path.sep + basename,
observations,
);
}

const segmentNode = directoryNode.get(basename);
const canonicalPath =
canonicalDir === '' ? basename : canonicalDir + path.sep + basename;
if (segmentNode == null) {
if (observations) {
observations.existence.add(canonicalPath);
}
return {exists: false};
} else if (isDirectory(segmentNode)) {
if (observations) {
observations.existence.add(canonicalPath);
}
return {
exists: true,
canonicalPath,
realPath: this.#pathUtils.normalToAbsolute(canonicalPath),
type: 'd',
};
} else if (isRegularFile(segmentNode)) {
if (observations) {
observations.existence.add(canonicalPath);
}
return {
exists: true,
canonicalPath,
realPath: this.#pathUtils.normalToAbsolute(canonicalPath),
type: 'f',
metadata: segmentNode,
};
} else {
// The full traversal handles cycles and nested links for symlink leaves.
// Directory symlinks were already recorded by the caller.
return this.lookup(canonicalPath, observations);
}
}

getAllFiles(): Array<Path> {
return Array.from(
this.metadataIterator({
Expand Down Expand Up @@ -627,7 +760,9 @@ export default class TreeFS implements MutableFileSystem {
* fails, we automatically have the real path of the first non-existent node.
*
* Note that this code is extremely hot during resolution, being the most
* expensive part of a file existence check. Benchmark any modifications!
* expensive part of a file existence check. Callers that probe many
* basenames in one directory should use `openDir` so the walk happens
* once. Benchmark any modifications!
*/
#lookupByNormalPath(
requestedNormalPath: string,
Expand All @@ -640,7 +775,7 @@ export default class TreeFS implements MutableFileSystem {
}>,
// Record into which the canonical paths of traversed symlinks will be
// added, as `content`. Omit for performance if not needed.
observations?: ?Observations,
observations?: ?ContentObservations,

// Low-level callbacks called on mutations of TreeFS data.
// Omit for performance if not needed.
Expand Down
Loading
Loading