Skip to content

Commit 27eb46f

Browse files
robhoganmeta-codesync[bot]
authored andcommitted
Add internal 'recrawl' watcher backend event and implement re-crawling a subdirectory.
Summary: In order to interpret low-level OS file events and emit an accurate set of changes to all affected paths, we sometimes need to fall back to recursively scanning a directory and comparing it with previous state. In Metro we have three watchers - Watchman, which has its own internal recrawl mechanism and always (/should!) give us a complete, high-level set of changes. - `FallbackWatcher`, currently used if Watchman is disabled on Windows and Linux, which implements flat inotify watches and *must* already internally crawl any "new" directories in order to watch them(*). - `NativeWatcher`, currently used if Watchman is disabled on macOS - uses `fsevents` to watch a whole tree *without* keeping track of contents. For `NativeWatcher`, if we receive a 'rename' event and we find that the path is a directory, it *may* be new and it *may* have contents. One approach would be for `NativeWatcher` to keep track of all files, internally scan any "renamed" directories, and emit any changes to files. However, that's redundant when we already have all of the mechanics for crawling, comparing with a previous state, and emitting the difference, downstream (because we do exactly that on startup, for current state vs cache). Here, we implement a way for a watcher backend to let downstream know there may be changes. Note that we don't need to worry about duplicate events (eg, a directory is created and immediately some contents are created - we might emit a new file event from the watcher as well as recrawling - but that's fine, because we aggregate changes at emit time). Changelog: Internal Reviewed By: vzaidman Differential Revision: D94241640 fbshipit-source-id: 1ae41f07f17fbec864953ee2111cb3872065e261
1 parent 38fb7c8 commit 27eb46f

12 files changed

Lines changed: 535 additions & 25 deletions

File tree

packages/metro-file-map/src/Watcher.js

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ const debug = require('debug')('Metro:Watcher');
3636

3737
const MAX_WAIT_TIME = 240000;
3838

39+
type InternalCrawlOptions = Readonly<{
40+
previousState: CrawlerOptions['previousState'],
41+
roots: ReadonlyArray<string>,
42+
subpath?: string,
43+
useWatchman: boolean,
44+
}>;
45+
3946
type WatcherOptions = {
4047
abortSignal: AbortSignal,
4148
computeSha1: boolean,
@@ -79,12 +86,41 @@ export class Watcher extends EventEmitter {
7986

8087
async crawl(): Promise<CrawlResult> {
8188
this.#options.perfLogger?.point('crawl_start');
89+
const options = this.#options;
90+
91+
const result = await this.#crawl({
92+
previousState: options.previousState,
93+
roots: options.roots,
94+
useWatchman: options.useWatchman,
95+
});
96+
97+
this.#options.perfLogger?.point('crawl_end');
98+
return result;
99+
}
100+
101+
async recrawl(
102+
subpath: string,
103+
currentFileSystem: CrawlerOptions['previousState']['fileSystem'],
104+
): Promise<CrawlResult> {
105+
return this.#crawl({
106+
previousState: {
107+
clocks: new Map(),
108+
fileSystem: currentFileSystem,
109+
},
110+
roots: [path.join(this.#options.rootDir, subpath)],
111+
subpath,
112+
useWatchman: false,
113+
});
114+
}
82115

116+
async #crawl(crawlOptions: InternalCrawlOptions): Promise<CrawlResult> {
83117
const options = this.#options;
118+
const {useWatchman, subpath} = crawlOptions;
119+
84120
const ignoreForCrawl = (filePath: string) =>
85121
options.ignoreForCrawl(filePath) ||
86122
path.basename(filePath).startsWith(this.#options.healthCheckFilePrefix);
87-
const crawl = options.useWatchman ? watchmanCrawl : nodeCrawl;
123+
const crawl = useWatchman ? watchmanCrawl : nodeCrawl;
88124
let crawler = crawl === watchmanCrawl ? 'watchman' : 'node';
89125

90126
options.abortSignal.throwIfAborted();
@@ -101,12 +137,13 @@ export class Watcher extends EventEmitter {
101137
this.emit('status', status);
102138
},
103139
perfLogger: options.perfLogger,
104-
previousState: options.previousState,
140+
previousState: crawlOptions.previousState,
105141
rootDir: options.rootDir,
106-
roots: options.roots,
142+
roots: crawlOptions.roots,
143+
subpath,
107144
};
108145

109-
debug('Beginning crawl with "%s".', crawler);
146+
debug('Crawling roots: %s with %s crawler.', crawlOptions.roots, crawler);
110147

111148
let delta: CrawlResult;
112149
try {
@@ -143,7 +180,6 @@ export class Watcher extends EventEmitter {
143180
delta.removedFiles.size,
144181
delta.clocks?.size ?? 0,
145182
);
146-
this.#options.perfLogger?.point('crawl_end');
147183
return delta;
148184
}
149185

@@ -202,6 +238,15 @@ export class Watcher extends EventEmitter {
202238
}
203239
return;
204240
}
241+
// Watchman handles recrawls internally - receiving a recrawl event
242+
// when using Watchman would indicate a bug. Log an error and ignore.
243+
if (change.event === 'recrawl' && useWatchman) {
244+
this.#options.console.error(
245+
'metro-file-map: Received unexpected recrawl event while using ' +
246+
'Watchman. Watchman recrawls are not implemented.',
247+
);
248+
return;
249+
}
205250
onChange(change);
206251
});
207252
await watcher.startWatching();

packages/metro-file-map/src/__tests__/index-test.js

Lines changed: 249 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,11 @@ jest.mock('jest-worker', () => ({
6464
}),
6565
}));
6666

67-
jest.mock('../crawlers/node', () => ({__esModule: true, default: jest.fn()}));
67+
const mockNodeCrawler = jest.fn();
68+
jest.mock('../crawlers/node', () => ({
69+
__esModule: true,
70+
default: mockNodeCrawler,
71+
}));
6872
jest.mock('../crawlers/watchman', () => ({
6973
__esModule: true,
7074
default: jest.fn(options => {
@@ -127,9 +131,14 @@ class MockWatcher extends AbstractWatcher {
127131
super(root, opts);
128132
mockEmitters[root] = this;
129133
}
134+
135+
static isSupported(): boolean {
136+
return true;
137+
}
130138
}
131139

132140
jest.mock('../watchers/FallbackWatcher', () => MockWatcher);
141+
jest.mock('../watchers/NativeWatcher', () => MockWatcher);
133142
jest.mock('../watchers/WatchmanWatcher', () => MockWatcher);
134143

135144
type MockFS = {[path: string]: ?string | {link: string}, __proto__: null};
@@ -277,6 +286,7 @@ let cacheContent = null;
277286
describe('FileMap', () => {
278287
beforeEach(() => {
279288
jest.resetModules();
289+
mockNodeCrawler.mockClear();
280290

281291
mockEmitters = Object.create(null);
282292
mockFs = object({
@@ -2482,5 +2492,243 @@ describe('FileMap', () => {
24822492
},
24832493
);
24842494
});
2495+
2496+
describe('recrawl events', () => {
2497+
// Recrawl events only come from non-Watchman watchers (NativeWatcher,
2498+
// FallbackWatcher), because Watchman handles its own recrawls internally.
2499+
// These tests use useWatchman: false to simulate a non-Watchman watcher,
2500+
// so we need to mock nodeCrawl for the initial build.
2501+
beforeEach(() => {
2502+
mockNodeCrawler.mockImplementationOnce(async () => ({
2503+
changedFiles: new Map([
2504+
[path.join('fruits', 'Banana.js'), [32, 42, 0, null, 0, 'Banana']],
2505+
[path.join('fruits', 'Pear.js'), [32, 42, 0, null, 0, 'Pear']],
2506+
[
2507+
path.join('fruits', 'Strawberry.js'),
2508+
[32, 42, 0, null, 0, 'Strawberry'],
2509+
],
2510+
[
2511+
path.join('fruits', '__mocks__', 'Pear.js'),
2512+
[32, 42, 0, null, 0, null],
2513+
],
2514+
[
2515+
path.join('vegetables', 'Melon.js'),
2516+
[32, 42, 0, null, 0, 'Melon'],
2517+
],
2518+
]),
2519+
removedFiles: new Set<string>(),
2520+
}));
2521+
});
2522+
2523+
fm_it(
2524+
'recrawl event triggers subdirectory crawl and detects added files',
2525+
async ({fileMap, hasteMap}) => {
2526+
const {fileSystem: _fileSystem} = await fileMap.build();
2527+
const fruitsRoot = path.join('/', 'project', 'fruits');
2528+
const e = mockEmitters[fruitsRoot];
2529+
2530+
// Simulate a directory move-in: a new subdirectory appears with files
2531+
const newDir = path.join(fruitsRoot, 'tropical');
2532+
const newFile1 = path.join(newDir, 'Mango.js');
2533+
const newFile2 = path.join(newDir, 'Papaya.js');
2534+
2535+
mockFs[newFile1] = `// Mango!`;
2536+
mockFs[newFile2] = `// Papaya!`;
2537+
2538+
// Set up node crawler mock to return the new files
2539+
mockNodeCrawler.mockImplementationOnce(
2540+
async (options: $FlowFixMe) => {
2541+
const {rootDir} = options;
2542+
const changedFiles: Map<string, FileMetadata> = new Map();
2543+
2544+
// Return files found in the crawled subdirectory
2545+
changedFiles.set(path.relative(rootDir, newFile1), [
2546+
100,
2547+
50,
2548+
0,
2549+
null,
2550+
0,
2551+
null,
2552+
]);
2553+
changedFiles.set(path.relative(rootDir, newFile2), [
2554+
101,
2555+
60,
2556+
0,
2557+
null,
2558+
0,
2559+
null,
2560+
]);
2561+
2562+
return {
2563+
changedFiles,
2564+
removedFiles: new Set<string>(),
2565+
};
2566+
},
2567+
);
2568+
2569+
// Emit a recrawl event for the new directory
2570+
e.emitFileEvent({
2571+
event: 'recrawl',
2572+
relativePath: 'tropical',
2573+
});
2574+
2575+
await waitForItToChange(fileMap);
2576+
2577+
// Verify crawl was called with the correct directory
2578+
expect(mockNodeCrawler).toHaveBeenNthCalledWith(
2579+
2, // Second call is the recrawl (first call is initial build)
2580+
expect.objectContaining({
2581+
roots: [newDir],
2582+
}),
2583+
);
2584+
},
2585+
{config: {useWatchman: false}},
2586+
);
2587+
2588+
fm_it(
2589+
'recrawl event detects removed files from a moved-out directory',
2590+
async ({fileMap, hasteMap}) => {
2591+
const {fileSystem} = await fileMap.build();
2592+
const fruitsRoot = path.join('/', 'project', 'fruits');
2593+
const e = mockEmitters[fruitsRoot];
2594+
2595+
// Verify the file exists initially
2596+
const existingFile = path.join(fruitsRoot, 'Banana.js');
2597+
expect(fileSystem.exists(existingFile)).toBe(true);
2598+
expect(hasteMap.getModule('Banana')).toBe(existingFile);
2599+
2600+
// Set up node crawler mock to return the file as removed
2601+
mockNodeCrawler.mockImplementationOnce(
2602+
async (options: $FlowFixMe) => {
2603+
const {rootDir} = options;
2604+
const removedFiles: Set<string> = new Set();
2605+
removedFiles.add(path.relative(rootDir, existingFile));
2606+
2607+
return {
2608+
changedFiles: new Map<string, FileMetadata>(),
2609+
removedFiles,
2610+
};
2611+
},
2612+
);
2613+
2614+
// Emit a recrawl event (simulating directory being moved out)
2615+
e.emitFileEvent({
2616+
event: 'recrawl',
2617+
relativePath: '',
2618+
});
2619+
2620+
const {changes} = await waitForItToChange(fileMap);
2621+
2622+
// Verify deletion was emitted
2623+
expect(countFileChanges(changes)).toBe(1);
2624+
expect([...changes.removedFiles]).toHaveLength(1);
2625+
2626+
// Verify file is no longer in the file system
2627+
expect(fileSystem.exists(existingFile)).toBe(false);
2628+
2629+
// Verify haste map was updated
2630+
expect(hasteMap.getModule('Banana')).toBeNull();
2631+
},
2632+
{config: {useWatchman: false}},
2633+
);
2634+
2635+
fm_it(
2636+
'recrawl event detects both added and removed files',
2637+
async ({fileMap, hasteMap}) => {
2638+
const {fileSystem} = await fileMap.build();
2639+
const fruitsRoot = path.join('/', 'project', 'fruits');
2640+
const e = mockEmitters[fruitsRoot];
2641+
2642+
// Initial state
2643+
const existingFile = path.join(fruitsRoot, 'Pear.js');
2644+
expect(fileSystem.exists(existingFile)).toBe(true);
2645+
2646+
// New file to be added
2647+
const newFile = path.join(fruitsRoot, 'Kiwi.js');
2648+
mockFs[newFile] = `// Kiwi!`;
2649+
2650+
// Set up node crawler mock
2651+
mockNodeCrawler.mockImplementationOnce(
2652+
async (options: $FlowFixMe) => {
2653+
const {rootDir} = options;
2654+
const changedFiles: Map<string, FileMetadata> = new Map();
2655+
const removedFiles: Set<string> = new Set();
2656+
2657+
// Add new file
2658+
changedFiles.set(path.relative(rootDir, newFile), [
2659+
200,
2660+
70,
2661+
0,
2662+
null,
2663+
0,
2664+
null,
2665+
]);
2666+
2667+
// Remove existing file
2668+
removedFiles.add(path.relative(rootDir, existingFile));
2669+
2670+
return {
2671+
changedFiles,
2672+
removedFiles,
2673+
};
2674+
},
2675+
);
2676+
2677+
e.emitFileEvent({
2678+
event: 'recrawl',
2679+
relativePath: '',
2680+
});
2681+
2682+
const {changes} = await waitForItToChange(fileMap);
2683+
2684+
// Verify both changes were emitted
2685+
expect(countFileChanges(changes)).toBe(2);
2686+
expect([...changes.addedFiles]).toHaveLength(1);
2687+
expect([...changes.removedFiles]).toHaveLength(1);
2688+
2689+
// Verify file system state
2690+
expect(fileSystem.exists(newFile)).toBe(true);
2691+
expect(fileSystem.exists(existingFile)).toBe(false);
2692+
2693+
// Verify haste map state
2694+
expect(hasteMap.getModule('Kiwi')).toBe(newFile);
2695+
expect(mockNodeCrawler).toHaveBeenCalled();
2696+
},
2697+
{config: {useWatchman: false}},
2698+
);
2699+
2700+
fm_it(
2701+
'recrawl event with no changes does not emit',
2702+
async ({fileMap}) => {
2703+
await fileMap.build();
2704+
const fruitsRoot = path.join('/', 'project', 'fruits');
2705+
const e = mockEmitters[fruitsRoot];
2706+
2707+
// Set up node crawler mock to return no changes
2708+
mockNodeCrawler.mockImplementationOnce(async () => ({
2709+
changedFiles: new Map<string, FileMetadata>(),
2710+
removedFiles: new Set<string>(),
2711+
}));
2712+
2713+
const changeListener = jest.fn();
2714+
fileMap.on('change', changeListener);
2715+
2716+
e.emitFileEvent({
2717+
event: 'recrawl',
2718+
relativePath: 'nonexistent',
2719+
});
2720+
2721+
// Wait for processing
2722+
await new Promise(resolve => setTimeout(resolve, 100));
2723+
2724+
// Verify crawl was called
2725+
expect(mockNodeCrawler).toHaveBeenCalled();
2726+
2727+
// Verify no change event was emitted (since no changes)
2728+
expect(changeListener).not.toHaveBeenCalled();
2729+
},
2730+
{config: {useWatchman: false}},
2731+
);
2732+
});
24852733
});
24862734
});

packages/metro-file-map/src/crawlers/node/index.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ export default async function nodeCrawl(
184184
perfLogger,
185185
roots,
186186
abortSignal,
187+
subpath,
187188
} = options;
188189

189190
abortSignal?.throwIfAborted();
@@ -198,7 +199,9 @@ export default async function nodeCrawl(
198199

199200
return new Promise((resolve, reject) => {
200201
const callback: Callback = fileData => {
201-
const difference = previousState.fileSystem.getDifference(fileData);
202+
const difference = previousState.fileSystem.getDifference(fileData, {
203+
subpath,
204+
});
202205

203206
perfLogger?.point('nodeCrawl_end');
204207

0 commit comments

Comments
 (0)