Skip to content

Commit c1e614a

Browse files
robhoganmeta-codesync[bot]
authored andcommitted
Fix detection of added/removed files when a directory is moved or cloned (#1660)
Summary: Pull Request resolved: #1660 Previously, when running on macOS without Watchman, when a directory was moved or cloned from another location (inside or outside a watch), the contents would *not* be detected by Metro at their new locations. That's because the OS only sends us a "rename" event for the directory, and no events for any of its contents (unsurprisingly, because they haven't changed). This stack introduces a new 'recrawl' event which watcher backends may fire when they are unable to determine whether the contents of an extant directory are new, and makes use of it in `NativeWatcher`. This does not apply to Watchman, which has its own (similar) recrawl mechanism, and it doesn't apply on Linux or Windows, which currently use a recursive watcher that crawl all "new" directories and compares contents to a tracked file map. Changelog: ``` - **[Fix]**: Detection of contents of directories moved or cloned into a watched location ``` Reviewed By: vzaidman Differential Revision: D94362228 fbshipit-source-id: d652c60539338efa273e196166b75b2327d4b114
1 parent 27eb46f commit c1e614a

5 files changed

Lines changed: 161 additions & 32 deletions

File tree

packages/metro-file-map/src/watchers/FallbackWatcher.js

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -124,12 +124,27 @@ export default class FallbackWatcher extends AbstractWatcher {
124124
}
125125

126126
/**
127-
* Removes a dir from the registry.
127+
* Removes a dir from the registry, returning all files that were registered
128+
* under it (recursively).
128129
*/
129-
#unregisterDir(dirpath: string): void {
130-
if (this.#dirRegistry[dirpath]) {
131-
delete this.#dirRegistry[dirpath];
130+
#unregisterDir(dirpath: string): Array<string> {
131+
const removedFiles: Array<string> = [];
132+
133+
// Find and remove all entries under this directory
134+
for (const registeredDir of Object.keys(this.#dirRegistry)) {
135+
if (
136+
registeredDir === dirpath ||
137+
registeredDir.startsWith(dirpath + path.sep)
138+
) {
139+
// Collect all files in this directory
140+
for (const filename of Object.keys(this.#dirRegistry[registeredDir])) {
141+
removedFiles.push(path.join(registeredDir, filename));
142+
}
143+
delete this.#dirRegistry[registeredDir];
144+
}
132145
}
146+
147+
return removedFiles;
133148
}
134149

135150
/**
@@ -350,7 +365,15 @@ export default class FallbackWatcher extends AbstractWatcher {
350365
return;
351366
}
352367
this.#unregister(fullPath);
353-
this.#unregisterDir(fullPath);
368+
// When a directory is deleted, emit delete events for all files we
369+
// knew about under that directory
370+
const removedFiles = this.#unregisterDir(fullPath);
371+
for (const removedFile of removedFiles) {
372+
this.#emitEvent({
373+
event: DELETE_EVENT,
374+
relativePath: path.relative(this.root, removedFile),
375+
});
376+
}
354377
if (registered) {
355378
this.#emitEvent({event: DELETE_EVENT, relativePath});
356379
}

packages/metro-file-map/src/watchers/NativeWatcher.js

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const debug = require('debug')('Metro:NativeWatcher');
2121

2222
const TOUCH_EVENT = 'touch';
2323
const DELETE_EVENT = 'delete';
24+
const RECRAWL_EVENT = 'recrawl';
2425

2526
/**
2627
* NativeWatcher uses Node's native fs.watch API with recursive: true.
@@ -74,9 +75,8 @@ export default class NativeWatcher extends AbstractWatcher {
7475
// ~instant on macOS or Windows.
7576
recursive: true,
7677
},
77-
(_event, relativePath) => {
78-
// _event is always 'rename' on macOS, so we don't use it.
79-
this._handleEvent(relativePath).catch(error => {
78+
(event, relativePath) => {
79+
this._handleEvent(event, relativePath).catch(error => {
8080
this.emitError(error);
8181
});
8282
},
@@ -95,13 +95,23 @@ export default class NativeWatcher extends AbstractWatcher {
9595
}
9696
}
9797

98-
async _handleEvent(relativePath: string) {
98+
async _handleEvent(event: string, relativePath: string) {
9999
const absolutePath = path.resolve(this.root, relativePath);
100100
if (this.doIgnore(relativePath)) {
101-
debug('Ignoring event on %s (root: %s)', relativePath, this.root);
101+
debug(
102+
'Ignoring event "%s" on %s (root: %s)',
103+
event,
104+
relativePath,
105+
this.root,
106+
);
102107
return;
103108
}
104-
debug('Handling event on %s (root: %s)', relativePath, this.root);
109+
debug(
110+
'Handling event "%s" on %s (root: %s)',
111+
event,
112+
relativePath,
113+
this.root,
114+
);
105115

106116
try {
107117
const stat = await fsPromises.lstat(absolutePath);
@@ -116,6 +126,23 @@ export default class NativeWatcher extends AbstractWatcher {
116126
return;
117127
}
118128

129+
// For directory "rename" events, notify that we need a recrawl since we
130+
// wont' receive events for unmodified files underneath a moved (or
131+
// cloned) directory. Renames are fired by the OS on moves, clones, and
132+
// creations. We ignore "change" events because they indiciate a change
133+
// to directory metadata, rather than its path or existence.
134+
if (type === 'd' && event === 'rename') {
135+
debug(
136+
'Directory rename detected on %s, requesting recrawl',
137+
relativePath,
138+
);
139+
this.emitFileEvent({
140+
event: RECRAWL_EVENT,
141+
relativePath,
142+
});
143+
return;
144+
}
145+
119146
this.emitFileEvent({
120147
event: TOUCH_EVENT,
121148
relativePath,

packages/metro-file-map/src/watchers/__tests__/helpers.js

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ const isWatchmanOnPath = () => {
4242

4343
// `null` Watchers will be marked as skipped tests.
4444
export const WATCHERS: Readonly<{
45-
[key: string]:
45+
[key: 'Watchman' | 'Native' | 'Fallback']:
4646
| Class<FallbackWatcher>
4747
| Class<NativeWatcher>
4848
| Class<WatchmanWatcher>
@@ -53,6 +53,8 @@ export const WATCHERS: Readonly<{
5353
Fallback: FallbackWatcher,
5454
};
5555

56+
export type WatcherName = keyof typeof WATCHERS;
57+
5658
export type EventHelpers = {
5759
nextEvent: (afterFn: () => Promise<void>) => Promise<{
5860
eventType: string,
@@ -62,17 +64,17 @@ export type EventHelpers = {
6264
untilEvent: (
6365
afterFn: () => Promise<void>,
6466
expectedPath: string,
65-
expectedEvent: 'touch' | 'delete',
67+
expectedEvent: 'touch' | 'delete' | 'recrawl',
6668
) => Promise<void>,
6769
allEvents: (
6870
afterFn: () => Promise<void>,
69-
events: ReadonlyArray<[string, 'touch' | 'delete']>,
71+
events: ReadonlyArray<[string, 'touch' | 'delete' | 'recrawl']>,
7072
opts?: {rejectUnexpected: boolean},
7173
) => Promise<void>,
7274
};
7375

7476
export const createTempWatchRoot = async (
75-
watcherName: string,
77+
watcherName: WatcherName,
7678
watchmanConfig: {[key: string]: unknown} | false = {},
7779
): Promise<string> => {
7880
const tmpDir = await mkdtemp(
@@ -94,7 +96,7 @@ export const createTempWatchRoot = async (
9496
};
9597

9698
export const startWatching = async (
97-
watcherName: string,
99+
watcherName: WatcherName,
98100
watchRoot: string,
99101
opts: WatcherOptions,
100102
): (Promise<{

packages/metro-file-map/src/watchers/__tests__/integration-test.js

Lines changed: 91 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
*/
1111

1212
import type {WatcherOptions} from '../common';
13-
import type {EventHelpers} from './helpers';
13+
import type {EventHelpers, WatcherName} from './helpers';
1414

1515
import NativeWatcher from '../NativeWatcher';
1616
import {WATCHERS, createTempWatchRoot, startWatching} from './helpers';
@@ -28,7 +28,7 @@ test('NativeWatcher is supported if and only if darwin', () => {
2828

2929
describe.each(Object.keys(WATCHERS))(
3030
'Watcher integration tests: %s',
31-
watcherName => {
31+
(watcherName: WatcherName) => {
3232
let appRoot;
3333
let cookieCount = 1;
3434
let watchRoot;
@@ -42,6 +42,9 @@ describe.each(Object.keys(WATCHERS))(
4242
? test
4343
: test.skip;
4444

45+
// NativeWatcher emits 'recrawl' for directories, others emit 'touch'
46+
const expectedDirEventType = watcherName === 'Native' ? 'recrawl' : 'touch';
47+
4548
beforeAll(async () => {
4649
watchRoot = await createTempWatchRoot(watcherName);
4750

@@ -53,9 +56,11 @@ describe.each(Object.keys(WATCHERS))(
5356
// the watcher was started. Tests should touch only distinct subsets of
5457
// these files to ensure that tests remain isolated.
5558
await mkdir(join(watchRoot, 'existing'));
59+
await mkdir(join(watchRoot, 'existing', 'to-move-out'));
5660
await Promise.all([
5761
writeFile(join(watchRoot, 'existing', 'file-to-delete.js'), ''),
5862
writeFile(join(watchRoot, 'existing', 'file-to-modify.js'), ''),
63+
writeFile(join(watchRoot, 'existing', 'to-move-out', 'file.js'), ''),
5964
symlink('target', join(watchRoot, 'existing', 'symlink-to-delete')),
6065
]);
6166

@@ -82,11 +87,16 @@ describe.each(Object.keys(WATCHERS))(
8287
});
8388

8489
beforeEach(async () => {
85-
expect(await eventHelpers.nextEvent(() => mkdir(appRoot))).toStrictEqual({
90+
// NativeWatcher emits 'recrawl' for directories, others emit 'touch'
91+
const event = await eventHelpers.nextEvent(() => mkdir(appRoot));
92+
expect(event).toMatchObject({
8693
path: 'app',
87-
eventType: 'touch',
88-
metadata: expect.any(Object),
94+
eventType: expectedDirEventType,
8995
});
96+
// For non-recrawl events, also check metadata
97+
if (event.eventType === 'touch') {
98+
expect(event.metadata).toEqual(expect.any(Object));
99+
}
90100
});
91101

92102
afterEach(async () => {
@@ -186,6 +196,66 @@ describe.each(Object.keys(WATCHERS))(
186196
});
187197
});
188198

199+
maybeTest(
200+
'detects all files when a preexisting directory is moved in from outside a watched root',
201+
async () => {
202+
// Create a directory with a file in it outside the watch root, then move it in and check that both the directory and the file are reported as new.
203+
const outsideDir = await fsPromises.mkdtemp(
204+
join(os.tmpdir(), 'metro-file-map-unwatched-'),
205+
);
206+
const outsideFile = join(outsideDir, 'file.js');
207+
await writeFile(outsideFile, '');
208+
209+
// NativeWatcher emits 'recrawl' for the directory, which triggers a
210+
// full crawl that finds the file. Other watchers emit individual 'touch'
211+
// events for both directory and file.
212+
if (watcherName === 'Native') {
213+
// NativeWatcher: expect recrawl event for the directory only
214+
await eventHelpers.allEvents(
215+
() => fsPromises.rename(outsideDir, join(appRoot, 'moved-in')),
216+
[[join('app', 'moved-in'), 'recrawl']],
217+
{rejectUnexpected: true},
218+
);
219+
} else {
220+
// Other watchers: expect touch events for both directory and file
221+
await eventHelpers.allEvents(
222+
() => fsPromises.rename(outsideDir, join(appRoot, 'moved-in')),
223+
[
224+
[join('app', 'moved-in'), 'touch'],
225+
[join('app', 'moved-in', 'file.js'), 'touch'],
226+
],
227+
{rejectUnexpected: true},
228+
);
229+
}
230+
},
231+
);
232+
233+
maybeTest(
234+
'reports directory as deleted when it is moved from a watched root to outside',
235+
async () => {
236+
// Create a directory with a file in it inside the watch root, then move it out and check that both the directory and the file are reported as deleted.
237+
const outsideDir = await fsPromises.mkdtemp(
238+
join(os.tmpdir(), 'metro-file-map-unwatched-'),
239+
);
240+
241+
await eventHelpers.allEvents(
242+
() =>
243+
fsPromises.rename(
244+
join(watchRoot, 'existing', 'to-move-out'),
245+
join(outsideDir, 'moved-out'),
246+
),
247+
watcherName === 'Native'
248+
? // NativeWatcher only emits an event for the directory, not contents
249+
[[join('existing', 'to-move-out'), 'delete']]
250+
: [
251+
[join('existing', 'to-move-out'), 'delete'],
252+
[join('existing', 'to-move-out', 'file.js'), 'delete'],
253+
],
254+
{rejectUnexpected: true},
255+
);
256+
},
257+
);
258+
189259
maybeTest('detects deletion of a pre-existing symlink', async () => {
190260
expect(
191261
await eventHelpers.nextEvent(() =>
@@ -214,17 +284,21 @@ describe.each(Object.keys(WATCHERS))(
214284
});
215285

216286
maybeTest('detects changes to files in a new directory', async () => {
217-
expect(
218-
await eventHelpers.nextEvent(() => mkdir(join(watchRoot, 'newdir'))),
219-
).toStrictEqual({
287+
const dirEvent = await eventHelpers.nextEvent(() =>
288+
mkdir(join(watchRoot, 'newdir')),
289+
);
290+
expect(dirEvent).toMatchObject({
220291
path: join('newdir'),
221-
eventType: 'touch',
222-
metadata: {
292+
eventType: expectedDirEventType,
293+
});
294+
// For non-recrawl events, also check metadata
295+
if (dirEvent.eventType === 'touch') {
296+
expect(dirEvent.metadata).toStrictEqual({
223297
modifiedTime: expect.any(Number),
224298
size: expect.any(Number),
225299
type: 'd',
226-
},
227-
});
300+
});
301+
}
228302
expect(
229303
await eventHelpers.nextEvent(() =>
230304
writeFile(join(watchRoot, 'newdir', 'file-in-new-dir.js'), 'code'),
@@ -245,6 +319,9 @@ describe.each(Object.keys(WATCHERS))(
245319
maybeTestOn('darwin')(
246320
'emits deletion for all files when a directory is deleted',
247321
async () => {
322+
// For NativeWatcher, the directory events will be 'recrawl', not 'touch'
323+
const dirEventType = expectedDirEventType;
324+
248325
await eventHelpers.allEvents(
249326
async () => {
250327
await mkdir(join(appRoot, 'subdir', 'subdir2'), {recursive: true});
@@ -255,8 +332,8 @@ describe.each(Object.keys(WATCHERS))(
255332
]);
256333
},
257334
[
258-
[join('app', 'subdir'), 'touch'],
259-
[join('app', 'subdir', 'subdir2'), 'touch'],
335+
[join('app', 'subdir'), dirEventType],
336+
[join('app', 'subdir', 'subdir2'), dirEventType],
260337
[join('app', 'subdir', 'deep.js'), 'touch'],
261338
[join('app', 'subdir', 'subdir2', 'deeper.js'), 'touch'],
262339
],

packages/metro-file-map/types/watchers/NativeWatcher.d.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* LICENSE file in the root directory of this source tree.
66
*
77
* @noformat
8-
* @generated SignedSource<<8239479487abff6df70ee54f9e05f352>>
8+
* @generated SignedSource<<b68c5620efd3f5bec83279059d0d1b4e>>
99
*
1010
* This file was translated from Flow by scripts/generateTypeScriptDefinitions.js
1111
* Original file: packages/metro-file-map/src/watchers/NativeWatcher.js
@@ -50,6 +50,6 @@ declare class NativeWatcher extends AbstractWatcher {
5050
* End watching.
5151
*/
5252
stopWatching(): Promise<void>;
53-
_handleEvent(relativePath: string): void;
53+
_handleEvent(event: string, relativePath: string): void;
5454
}
5555
export default NativeWatcher;

0 commit comments

Comments
 (0)