Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 26 additions & 5 deletions packages/metro-file-map/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export type CacheManagerWriteOptions = Readonly<{
export type ChangeEvent = Readonly<{
logger?: null | undefined | RootPerfLogger;
changes: ReadonlyFileSystemChanges<Readonly<ChangedFileMetadata>>;
pluginChanges: ReadonlyMap<string, unknown>;
rootDir: string;
}>;

Expand Down Expand Up @@ -191,6 +192,25 @@ interface FileSystem_2 {
}
export {FileSystem_2 as FileSystem};

export function getPluginChanges<ChangeSummary>(
event: ChangeEvent,
plugin: FileMapPlugin<
/**
* > | plugin: FileMapPlugin<empty, empty, ChangeSummary>,
* | ^^^^^ Unsupported feature: Translating "empty type" is currently not supported.
**/
any,
/**
* > | plugin: FileMapPlugin<empty, empty, ChangeSummary>,
* | ^^^^^ Unsupported feature: Translating "empty type" is currently not supported.
**/
any,
ChangeSummary
>,
): ChangeSummary | void;

export type HasteChanges = Readonly<{changedNames: ReadonlySet<string>}>;

export class HasteConflictsError extends Error {
constructor(conflicts: ReadonlyArray<HasteConflict>);
getDetailedMessage(pathsRelativeToRoot: null | undefined | string): string;
Expand All @@ -209,7 +229,7 @@ export type HasteMapItem = {
[platform: string]: HasteMapItemMetadata;
};

export class HastePlugin implements HasteMap, FileMapPlugin<null, string | null> {
export class HastePlugin implements HasteMap, FileMapPlugin<null, string | null, HasteChanges | void> {
constructor(options: HasteMapOptions);
assertValid(): void;
computeConflicts(): Array<HasteConflict>;
Expand All @@ -221,7 +241,7 @@ export class HastePlugin implements HasteMap, FileMapPlugin<null, string | null>
getWorker(): FileMapPluginWorker;
initialize(opts: FileMapPluginInitOptions<null, string | null>): Promise<void>;
readonly name: 'haste';
onChanged(delta: ReadonlyFileSystemChanges<null | undefined | string>): void;
onChanged(delta: ReadonlyFileSystemChanges<null | undefined | string>): HasteChanges | void;
setModule(id: string, module: HasteMapItemMetadata): void;
}

Expand All @@ -247,15 +267,16 @@ export type HealthCheckResult =

export type InputFileMapPlugin = FileMapPlugin<
/**
* > | export type InputFileMapPlugin = FileMapPlugin<empty, empty>;
* > | export type InputFileMapPlugin = FileMapPlugin<empty, empty, unknown>;
* | ^^^^^ Unsupported feature: Translating "empty type" is currently not supported.
**/
any,
/**
* > | export type InputFileMapPlugin = FileMapPlugin<empty, empty>;
* > | export type InputFileMapPlugin = FileMapPlugin<empty, empty, unknown>;
* | ^^^^^ Unsupported feature: Translating "empty type" is currently not supported.
**/
any
any,
unknown
>;

export type InputOptions = Readonly<{
Expand Down
56 changes: 51 additions & 5 deletions packages/metro-file-map/src/__tests__/index-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import type {
} from '../flow-types';
import type {DependencyPlugin} from '../index';
import type {default as FileMapT} from '../index';
import type {HasteMapOptions} from '../plugins/HastePlugin';
import type HastePluginT, {HasteMapOptions} from '../plugins/HastePlugin';
import type {MockMapOptions} from '../plugins/MockPlugin';
import typeof WorkerModule from '../worker';

Expand Down Expand Up @@ -292,7 +292,7 @@ let buildNewFileMap: (
) => Promise<{
...BuildResult,
fileMap: FileMapT,
hasteMap: HasteMap,
hasteMap: HastePluginT,
mockMap: ?MockMap,
dependencyPlugin: ?DependencyPlugin,
}>;
Expand Down Expand Up @@ -445,6 +445,24 @@ describe('FileMap', () => {
expect(FileMap.H).toBe(require('../constants').default);
});

test('throws on plugins that share a name, with or without a worker', () => {
const MockPlugin = require('../plugins/MockPlugin').default;
const mockConfig = {
console: globalThis.console,
rootDir: defaultConfig.rootDir,
mocksPattern: /__mocks__/,
throwOnModuleCollision: false,
};
expect(
() =>
new FileMap({
...defaultConfig,
// MockPlugin has no worker, so holds no plugin data slot
plugins: [new MockPlugin(mockConfig), new MockPlugin(mockConfig)],
}),
).toThrow('metro-file-map: Duplicate plugin name: mocks');
});

test('ignores files given a pattern', async () => {
mockFs[p('/project/fruits/Kiwi.js')] = `
// Kiwi!
Expand Down Expand Up @@ -1683,6 +1701,28 @@ describe('FileMap', () => {
}
});

fm_it(
'publishes what each plugin reports about a batch on the change event',
async ({fileMap, hasteMap}) => {
const getPluginChanges = require('../lib/getPluginChanges').default;
const fruitsRoot = path.join('/', 'project', 'fruits');
mockFs[path.join(fruitsRoot, 'Tomato.js')] = '// Tomato!';
mockEmitters[fruitsRoot].emitFileEvent({
event: 'touch',
relativePath: 'Tomato.js',
metadata: MOCK_CHANGE_FILE,
});
mockDeleteFile(fruitsRoot, 'Banana.js');
const changeEvent = await waitForItToChange(fileMap);

expect(getPluginChanges(changeEvent, hasteMap)).toEqual({
changedNames: new Set(['Tomato', 'Banana']),
});
// Only plugins with something to say are present
expect([...changeEvent.pluginChanges.keys()]).toEqual([hasteMap.name]);
},
);

function mockDeleteFile(root: string, relativePath: string) {
const e = mockEmitters[root];
e.emitFileEvent({event: 'delete', relativePath});
Expand All @@ -1697,7 +1737,7 @@ describe('FileMap', () => {

function fm_it(
title: string,
fn: (fm: Readonly<{fileMap: FileMap, hasteMap: HasteMap}>) => unknown,
fn: (fm: Readonly<{fileMap: FileMap, hasteMap: HastePluginT}>) => unknown,
options?: FileMapTestOptions = {},
): void {
options = options || {};
Expand All @@ -1720,7 +1760,7 @@ describe('FileMap', () => {

fm_it.only = (
title: string,
fn: (fm: Readonly<{fileMap: FileMap, hasteMap: HasteMap}>) => unknown,
fn: (fm: Readonly<{fileMap: FileMap, hasteMap: HastePluginT}>) => unknown,
options?: FileMapTestOptions,
): void => fm_it(title, fn, {...options, only: true});

Expand Down Expand Up @@ -2510,7 +2550,13 @@ describe('FileMap', () => {
relativePath: 'tropical',
});

await waitForItToChange(fileMap);
const changeEvent = await waitForItToChange(fileMap);

// Plugins are updated inside the recrawl, and what they report
// still reaches the event
expect(
require('../lib/getPluginChanges').default(changeEvent, hasteMap),
).toEqual({changedNames: new Set(['Mango', 'Papaya'])});

// Verify crawl was called with the correct directory
expect(mockNodeCrawler).toHaveBeenNthCalledWith(
Expand Down
20 changes: 18 additions & 2 deletions packages/metro-file-map/src/flow-types.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,12 @@ export type ChangedFileMetadata = Readonly<{
export type ChangeEvent = Readonly<{
logger?: ?RootPerfLogger,
changes: ReadonlyFileSystemChanges<Readonly<ChangedFileMetadata>>,
/**
* What each plugin reported about this batch of changes, by plugin name. A
* plugin whose `onChanged` returned nothing is absent. Read an entry with
* `getPluginChanges`, which knows its type.
*/
pluginChanges: ReadonlyMap<string, unknown>,
rootDir: string,
}>;

Expand Down Expand Up @@ -278,19 +284,29 @@ export type V8Serializable = V8SerializablePrimitive | V8SerializableCollection;
export interface FileMapPlugin<
in SerializableState extends void | V8Serializable = void | V8Serializable,
in PerFileData extends void | V8Serializable = void | V8Serializable,
out ChangeSummary = void,
> {
// Must be unique among the plugins of a file map.
readonly name: string;
initialize(
initOptions: FileMapPluginInitOptions<SerializableState, PerFileData>,
): Promise<void>;
assertValid(): void;
onChanged(changes: ReadonlyFileSystemChanges<?PerFileData>): void;
/**
* Update the plugin's state for a batch of changes. It is called
* synchronously, immediately before the batch is emitted as a `ChangeEvent`.
*
* A plugin may return a summary of what changed in terms only it knows,
* such as the Haste names that were rebound, for the file map to publish as
* `ChangeEvent.pluginChanges`. Return nothing if there is nothing to say.
*/
onChanged(changes: ReadonlyFileSystemChanges<?PerFileData>): ChangeSummary;
getSerializableSnapshot(): void | V8Serializable;
getCacheKey(): string;
getWorker(): ?FileMapPluginWorker;
}

export type InputFileMapPlugin = FileMapPlugin<empty, empty>;
export type InputFileMapPlugin = FileMapPlugin<empty, empty, unknown>;

export interface MetadataWorker {
processFile(
Expand Down
77 changes: 50 additions & 27 deletions packages/metro-file-map/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ type InternalOptions = Readonly<{

type IndexedPlugin = Readonly<{
// $FlowFixMe[unclear-type] Plugin types cannot be known statically
plugin: FileMapPlugin<any, any>,
plugin: FileMapPlugin<any, any, unknown>,
dataIdx: ?number,
}>;
type InternalEnqueuedEvent = Readonly<
Expand All @@ -148,7 +148,9 @@ export type {FileDataPluginOptions} from './plugins/FileDataPlugin';
export type {DependencyPluginOptions} from './plugins/DependencyPlugin';
export {DuplicateHasteCandidatesError} from './plugins/haste/DuplicateHasteCandidatesError';
export {HasteConflictsError} from './plugins/haste/HasteConflictsError';
export {default as getPluginChanges} from './lib/getPluginChanges';
export {default as HastePlugin} from './plugins/HastePlugin';
export type {HasteChanges} from './plugins/HastePlugin';

export type {HasteMap} from './flow-types';
export type {HealthCheckResult} from './Watcher';
Expand Down Expand Up @@ -321,19 +323,21 @@ export default class FileMap extends EventEmitter {
const pluginWorkers: Array<FileMapPluginWorker> = [];
const pluginDataIndices = new Map<string, number>();
const plugins = options.plugins ?? [];
const pluginNames = new Set<string>();
for (const plugin of plugins) {
// Plugin data slots, persisted plugin state and change summaries are all
// addressed by name, so a second plugin of the same name would silently
// take over the first's.
invariant(
!pluginNames.has(plugin.name),
'metro-file-map: Duplicate plugin name: %s',
plugin.name,
);
pluginNames.add(plugin.name);
const maybeWorker = plugin.getWorker();
const dataIdx = maybeWorker != null ? dataSlot++ : null;
indexedPlugins.push({plugin, dataIdx});
if (dataIdx != null) {
// Crawlers address plugin data by name, so names must be unique among
// plugins holding a slot - otherwise a crawler would silently write to
// the shadowed plugin's slot.
invariant(
!pluginDataIndices.has(plugin.name),
'metro-file-map: Duplicate plugin name: %s',
plugin.name,
);
pluginDataIndices.set(plugin.name, dataIdx);
}
if (maybeWorker != null) {
Expand Down Expand Up @@ -515,7 +519,9 @@ export default class FileMap extends EventEmitter {
]);

// Update `fileSystem` and plugins based on the file delta.
const actualChanges = await this.#applyFileDelta(
// Nothing is emitted for the initial build, so what plugins say about
// it goes unused.
const {changeAggregator: actualChanges} = await this.#applyFileDelta(
fileSystem,
plugins,
fileDelta,
Expand Down Expand Up @@ -645,6 +651,27 @@ export default class FileMap extends EventEmitter {
return null;
}

/**
* Bring every plugin up to date with a batch of changes, and collect what
* each has to say about it for `ChangeEvent.pluginChanges`.
*/
#updatePlugins(
changeAggregator: FileSystemChangeAggregator,
): Map<string, unknown> {
const pluginChanges = new Map<string, unknown>();
this.#plugins.forEach(({plugin, dataIdx}) => {
const summary = plugin.onChanged(
changeAggregator.getMappedView(
dataIdx != null ? metadata => metadata[dataIdx] : () => null,
),
);
if (summary != null) {
pluginChanges.set(plugin.name, summary);
}
});
return pluginChanges;
}

async #applyFileDelta(
fileSystem: MutableFileSystem,
plugins: ReadonlyArray<IndexedPlugin>,
Expand All @@ -653,7 +680,10 @@ export default class FileMap extends EventEmitter {
removedFiles: ReadonlySet<CanonicalPath>,
clocks?: WatchmanClocks,
}>,
): Promise<FileSystemChangeAggregator> {
): Promise<{
changeAggregator: FileSystemChangeAggregator,
pluginChanges: ReadonlyMap<string, unknown>,
}> {
this.#startupPerfLogger?.point('applyFileDelta_start');
const {changedFiles, removedFiles} = delta;
this.#startupPerfLogger?.point('applyFileDelta_preprocess_start');
Expand Down Expand Up @@ -742,17 +772,11 @@ export default class FileMap extends EventEmitter {
this.#startupPerfLogger?.point('applyFileDelta_add_end');

this.#startupPerfLogger?.point('applyFileDelta_updatePlugins_start');
this.#plugins.forEach(({plugin, dataIdx}) => {
plugin.onChanged(
changeAggregator.getMappedView(
dataIdx != null ? metadata => metadata[dataIdx] : () => null,
),
);
});
const pluginChanges = this.#updatePlugins(changeAggregator);
this.#startupPerfLogger?.point('applyFileDelta_updatePlugins_end');
this.#startupPerfLogger?.point('applyFileDelta_end');

return changeAggregator;
return {changeAggregator, pluginChanges};
}

/**
Expand Down Expand Up @@ -860,13 +884,7 @@ export default class FileMap extends EventEmitter {
}

const _netChange = changeAggregator.getView();
this.#plugins.forEach(({plugin, dataIdx}) => {
plugin.onChanged(
changeAggregator.getMappedView(
dataIdx != null ? metadata => metadata[dataIdx] : () => null,
),
);
});
const pluginChanges = this.#updatePlugins(changeAggregator);

const toPublicMetadata = (
metadata: Readonly<FileMetadata>,
Expand All @@ -893,6 +911,7 @@ export default class FileMap extends EventEmitter {
const changeEvent: ChangeEvent = {
changes: changesWithMetadata,
logger: hmrPerfLogger,
pluginChanges,
rootDir: this.#options.rootDir,
};
this.emit('change', changeEvent);
Expand Down Expand Up @@ -1039,7 +1058,10 @@ export default class FileMap extends EventEmitter {
}

// Reuse the same batch processing logic as build()
const recrawlChangeAggregator = await this.#applyFileDelta(
const {
changeAggregator: recrawlChangeAggregator,
pluginChanges: recrawlPluginChanges,
} = await this.#applyFileDelta(
fileSystem,
this.#plugins,
crawlResult,
Expand Down Expand Up @@ -1067,6 +1089,7 @@ export default class FileMap extends EventEmitter {
const changeEvent: ChangeEvent = {
changes: changesWithMetadata,
logger: null,
pluginChanges: recrawlPluginChanges,
rootDir: this.#options.rootDir,
};
this.emit('change', changeEvent);
Expand Down
Loading
Loading