Skip to content

Commit cb806d1

Browse files
motiz88facebook-github-bot
authored andcommitted
experimentalImportBundleSupport: Retraverse parents of deleted async dependencies
Summary: Changelog: [Experimental] Correctly invalidates resolved async dependencies when their target files are deleted, by tracking inverse dependencies of entries in `importBundleNames` ( = async dependencies not necessarily tracked in the `dependencies` set). ## Problem Suppose we have the following two files ``` // /src/a.js import('b'); ``` ``` // /src/b.js export default 42; ``` described by the following dependency graph: ``` ┌───────────┐ import('b') ┌───────────┐ │ /src/a.js │ ─────────────▶ │ /src/b.js │ └───────────┘ └───────────┘ ``` Now suppose we delete `/src/b.js`: * If we delete it and immediately perform a build, we'd expect the resolver to throw an error. * If we recreate `b.js` in a different directory ( = move it) we'd expect the resolution to be updated. (For the sake of this example, assume we're using Haste resolution.) In short, this needs to work the way sync dependencies work: the inverse dependencies of a deleted node must be invalidated so that their dependencies are resolved anew in the next build. But under `experimentalImportBundleSupport: true`, `b.js` is not tracked as a node in the `dependencies` set, so the code in `DeltaCalculator` that is responsible for this does not apply. ## Solution Here, we create a `getModifiedModulesForDeletedPath` method on `Graph`, which: 1. Encapsulates what `DeltaCalculator` previously did by directly reading `dependencies`. 2. Also returns the inverse dependencies of async modules that may not exist in the `dependencies` set. (2) is achieved by tracking resolved async modules as nodes in a separate set (`importBundleNodes`). These nodes cannot have their own dependencies, so they can't introduce cycles and therefore don't need any new handling in terms of GC. We repurpose the existing logic that maintained `importBundleNames` and expose an `importBundleNames` getter for compatibility. NOTE: Ultimately, I'm planning to remove the `importBundleNames` kludge entirely and instead serialise the resolved paths of async dependencies inside each parent module's dependency array. The current diff directly enables that by preventing improper caching of the paths. (But as explained above, this improper caching is *already* a bug regardless.) Reviewed By: jacdebug Differential Revision: D40463613 fbshipit-source-id: 35c73b0ae8c50d742bd08386d0247fa1e337d6b0
1 parent fb0020c commit cb806d1

File tree

4 files changed

+134
-55
lines changed

4 files changed

+134
-55
lines changed

packages/metro/src/DeltaBundler/DeltaCalculator.js

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -246,16 +246,14 @@ class DeltaCalculator<T> extends EventEmitter {
246246
// If a file has been deleted, we want to invalidate any other file that
247247
// depends on it, so we can process it and correctly return an error.
248248
deletedFiles.forEach((filePath: string) => {
249-
const module = this._graph.dependencies.get(filePath);
250-
251-
if (module) {
252-
module.inverseDependencies.forEach((path: string) => {
253-
// Only mark the inverse dependency as modified if it's not already
254-
// marked as deleted (in that case we can just ignore it).
255-
if (!deletedFiles.has(path)) {
256-
modifiedFiles.add(path);
257-
}
258-
});
249+
for (const path of this._graph.getModifiedModulesForDeletedPath(
250+
filePath,
251+
)) {
252+
// Only mark the inverse dependency as modified if it's not already
253+
// marked as deleted (in that case we can just ignore it).
254+
if (!deletedFiles.has(path)) {
255+
modifiedFiles.add(path);
256+
}
259257
}
260258
});
261259

packages/metro/src/DeltaBundler/Graph.js

Lines changed: 49 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
* 2. We keep the "root buffer" (possibleCycleRoots) free of duplicates by
2727
* making it a Set, instead of storing a "buffered" flag on each node.
2828
* 3. On top of tracking edges between nodes, we also count references between
29-
* nodes and entries in the importBundleNames set.
29+
* nodes and entries in the importBundleNodes set.
3030
*/
3131

3232
import type {RequireContextParams} from '../ModuleGraph/worker/collectDependencies';
@@ -123,19 +123,25 @@ export class Graph<T = MixedOutput> {
123123
+entryPoints: $ReadOnlySet<string>;
124124
+transformOptions: TransformInputOptions;
125125
+dependencies: Dependencies<T> = new Map();
126-
+importBundleNames: Set<string> = new Set();
126+
+#importBundleNodes: Map<
127+
string,
128+
$ReadOnly<{
129+
inverseDependencies: CountingSet<string>,
130+
}>,
131+
> = new Map();
132+
133+
// $FlowIgnore[unsafe-getters-setters]
134+
get importBundleNames(): $ReadOnlySet<string> {
135+
return new Set(this.#importBundleNodes.keys());
136+
}
127137

128138
/// GC state for nodes in the graph (this.dependencies)
129-
#gc: {
139+
+#gc: {
130140
+color: Map<string, NodeColor>,
131141
+possibleCycleRoots: Set<string>,
132-
133-
// Reference counts for entries in importBundleNames
134-
+importBundleRefs: Map<string, number>,
135142
} = {
136143
color: new Map(),
137144
possibleCycleRoots: new Set(),
138-
importBundleRefs: new Map(),
139145
};
140146

141147
/** Resolved context parameters from `require.context`. */
@@ -219,14 +225,10 @@ export class Graph<T = MixedOutput> {
219225
this.dependencies.size === 0,
220226
'initialTraverseDependencies called on nonempty graph',
221227
);
222-
invariant(
223-
this.importBundleNames.size === 0,
224-
'initialTraverseDependencies called on nonempty graph',
225-
);
226228

227229
this.#gc.color.clear();
228230
this.#gc.possibleCycleRoots.clear();
229-
this.#gc.importBundleRefs.clear();
231+
this.#importBundleNodes.clear();
230232

231233
for (const path of this.entryPoints) {
232234
// Each entry point implicitly has a refcount of 1, so mark them all black.
@@ -361,8 +363,8 @@ export class Graph<T = MixedOutput> {
361363
) {
362364
// Don't add a node for the module if we are traversing async dependencies
363365
// lazily (and this is an async dependency). Instead, record it in
364-
// importBundleNames.
365-
this._incrementImportBundleReference(dependency);
366+
// importBundleNodes.
367+
this._incrementImportBundleReference(dependency, parentModule);
366368
} else {
367369
if (!module) {
368370
// Add a new node to the graph.
@@ -419,7 +421,7 @@ export class Graph<T = MixedOutput> {
419421
options.experimentalImportBundleSupport &&
420422
dependency.data.data.asyncType != null
421423
) {
422-
this._decrementImportBundleReference(dependency);
424+
this._decrementImportBundleReference(dependency, parentModule);
423425
}
424426

425427
const module = this.dependencies.get(absolutePath);
@@ -455,6 +457,16 @@ export class Graph<T = MixedOutput> {
455457
}
456458
}
457459

460+
/**
461+
* Gets the list of modules affected by the deletion of a given file. The
462+
* caller is expected to mark these modules as modified in the next call to
463+
* traverseDependencies. Note that the list may contain duplicates.
464+
*/
465+
*getModifiedModulesForDeletedPath(filePath: string): Iterable<string> {
466+
yield* this.dependencies.get(filePath)?.inverseDependencies ?? [];
467+
yield* this.#importBundleNodes.get(filePath)?.inverseDependencies ?? [];
468+
}
469+
458470
_resolveDependencies(
459471
parentPath: string,
460472
dependencies: $ReadOnlyArray<TransformResultDependency>,
@@ -588,32 +600,36 @@ export class Graph<T = MixedOutput> {
588600

589601
/** Garbage collection functions */
590602

591-
// Add an entry to importBundleNames (or increase the reference count of an existing one)
592-
_incrementImportBundleReference(dependency: Dependency) {
603+
// Add an entry to importBundleNodes (or record an inverse dependency of an existing one)
604+
_incrementImportBundleReference(
605+
dependency: Dependency,
606+
parentModule: Module<T>,
607+
) {
593608
const {absolutePath} = dependency;
594-
595-
this.#gc.importBundleRefs.set(
596-
absolutePath,
597-
(this.#gc.importBundleRefs.get(absolutePath) ?? 0) + 1,
598-
);
599-
this.importBundleNames.add(absolutePath);
609+
const importBundleNode = this.#importBundleNodes.get(absolutePath) ?? {
610+
inverseDependencies: new CountingSet(),
611+
};
612+
importBundleNode.inverseDependencies.add(parentModule.path);
613+
this.#importBundleNodes.set(absolutePath, importBundleNode);
600614
}
601615

602-
// Decrease the reference count of an entry in importBundleNames (and delete it if necessary)
603-
_decrementImportBundleReference(dependency: Dependency) {
616+
// Decrease the reference count of an entry in importBundleNodes (and delete it if necessary)
617+
_decrementImportBundleReference(
618+
dependency: Dependency,
619+
parentModule: Module<T>,
620+
) {
604621
const {absolutePath} = dependency;
605622

606-
const prevRefCount = nullthrows(
607-
this.#gc.importBundleRefs.get(absolutePath),
623+
const importBundleNode = nullthrows(
624+
this.#importBundleNodes.get(absolutePath),
608625
);
609626
invariant(
610-
prevRefCount > 0,
611-
'experimentalImportBundleSupport: import bundle refcount not valid',
627+
importBundleNode.inverseDependencies.has(parentModule.path),
628+
'experimentalImportBundleSupport: import bundle inverse references',
612629
);
613-
this.#gc.importBundleRefs.set(absolutePath, prevRefCount - 1);
614-
if (prevRefCount === 1) {
615-
this.#gc.importBundleRefs.delete(absolutePath);
616-
this.importBundleNames.delete(absolutePath);
630+
importBundleNode.inverseDependencies.delete(parentModule.path);
631+
if (importBundleNode.inverseDependencies.size === 0) {
632+
this.#importBundleNodes.delete(absolutePath);
617633
}
618634
}
619635

packages/metro/src/DeltaBundler/__tests__/Graph-test.js

Lines changed: 77 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,16 @@ const Actions = {
6969
}
7070
},
7171

72-
moveFile(from: string, to: string) {
72+
moveFile(from: string, to: string, graph: Graph<>) {
7373
Actions.createFile(to);
74-
Actions.deleteFile(from);
74+
Actions.deleteFile(from, graph);
7575
},
7676

77-
deleteFile(path: string) {
77+
deleteFile(path: string, graph: Graph<>) {
7878
mockedDependencies.delete(path);
79+
for (const modifiedPath of graph.getModifiedModulesForDeletedPath(path)) {
80+
Actions.modifyFile(modifiedPath);
81+
}
7982
},
8083

8184
createFile(path: string) {
@@ -327,7 +330,7 @@ beforeEach(async () => {
327330
const {path} = deps.filter(dep => dep.name === to)[0];
328331

329332
if (!mockedDependencies.has(path)) {
330-
throw new Error(`Dependency not found: ${path}->${to}`);
333+
throw new Error(`Dependency not found: ${from} -> ${path}`);
331334
}
332335
return path;
333336
},
@@ -442,7 +445,7 @@ it('should return added/removed dependencies', async () => {
442445
it('should retry to traverse the dependencies as it was after getting an error', async () => {
443446
await graph.initialTraverseDependencies(options);
444447

445-
Actions.deleteFile(moduleBar);
448+
Actions.deleteFile(moduleBar, graph);
446449

447450
await expect(
448451
graph.traverseDependencies(['/foo'], options),
@@ -587,7 +590,7 @@ describe('edge cases', () => {
587590
await graph.initialTraverseDependencies(options);
588591

589592
Actions.removeDependency('/foo', '/baz');
590-
Actions.moveFile('/baz', '/qux');
593+
Actions.moveFile('/baz', '/qux', graph);
591594
Actions.addDependency('/foo', '/qux');
592595

593596
expect(
@@ -606,7 +609,7 @@ describe('edge cases', () => {
606609
Actions.addDependency('/bundle', '/foo-renamed');
607610
Actions.removeDependency('/bundle', '/foo');
608611

609-
Actions.moveFile('/foo', '/foo-renamed');
612+
Actions.moveFile('/foo', '/foo-renamed', graph);
610613
Actions.addDependency('/foo-renamed', '/bar');
611614
Actions.addDependency('/foo-renamed', '/baz');
612615

@@ -1975,6 +1978,70 @@ describe('edge cases', () => {
19751978
});
19761979
expect(graph.importBundleNames).toEqual(new Set());
19771980
});
1981+
1982+
it('deleting the target of an async dependency retraverses its parent', async () => {
1983+
Actions.removeDependency('/bundle', '/foo');
1984+
Actions.addDependency('/bundle', '/foo', {
1985+
data: {
1986+
asyncType: 'async',
1987+
},
1988+
});
1989+
1990+
/*
1991+
┌─────────┐ async ┌──────┐ ┌──────┐
1992+
│ /bundle │ ·······▶ │ /foo │ ──▶ │ /bar │
1993+
└─────────┘ └──────┘ └──────┘
1994+
1995+
1996+
1997+
┌──────┐
1998+
│ /baz │
1999+
└──────┘
2000+
*/
2001+
await graph.initialTraverseDependencies(localOptions);
2002+
files.clear();
2003+
2004+
Actions.deleteFile('/foo', graph);
2005+
2006+
/*
2007+
┌─────────┐ async ┌┄┄╲┄╱┄┐ ┌──────┐
2008+
│ /bundle │ ·······▶ ┆ /foo ┆ ──▶ │ /bar │
2009+
└─────────┘ └┄┄╱┄╲┄┘ └──────┘
2010+
2011+
2012+
2013+
┌──────┐
2014+
│ /baz │
2015+
└──────┘
2016+
*/
2017+
await expect(
2018+
graph.traverseDependencies([...files], localOptions),
2019+
).rejects.toThrowError('Dependency not found: /bundle -> /foo');
2020+
2021+
// NOTE: not clearing `files`, to mimic DeltaCalculator's error behaviour.
2022+
2023+
Actions.createFile('/foo');
2024+
2025+
/*
2026+
┌─────────┐ async ┏━━━━━━┓ ┌──────┐
2027+
│ /bundle │ ·······▶ ┃ /foo ┃ ──▶ │ /bar │
2028+
└─────────┘ ┗━━━━━━┛ └──────┘
2029+
2030+
2031+
2032+
┌──────┐
2033+
│ /baz │
2034+
└──────┘
2035+
*/
2036+
expect(
2037+
getPaths(await graph.traverseDependencies([...files], localOptions)),
2038+
).toEqual({
2039+
added: new Set([]),
2040+
modified: new Set(['/bundle']),
2041+
deleted: new Set([]),
2042+
});
2043+
expect(graph.importBundleNames).toEqual(new Set(['/foo']));
2044+
});
19782045
});
19792046

19802047
it('should try to transform every file only once', async () => {
@@ -2362,7 +2429,7 @@ describe('require.context', () => {
23622429
]).toEqual([ctxPath]);
23632430

23642431
// Delete the matched file
2365-
Actions.deleteFile('/ctx/matched-file');
2432+
Actions.deleteFile('/ctx/matched-file', graph);
23662433

23672434
// Propagate the deletion to the context module (normally DeltaCalculator's responsibility)
23682435
Actions.removeInferredDependency(ctxPath, '/ctx/matched-file');
@@ -2876,12 +2943,12 @@ describe('optional dependencies', () => {
28762943
Actions.addDependency('/bundle-o', '/regular-a');
28772944
Actions.addDependency('/bundle-o', '/optional-b');
28782945

2879-
Actions.deleteFile('/optional-b');
2880-
28812946
localGraph = new TestGraph({
28822947
entryPoints: new Set(['/bundle-o']),
28832948
transformOptions: options.transformOptions,
28842949
});
2950+
2951+
Actions.deleteFile('/optional-b', localGraph);
28852952
});
28862953

28872954
it('missing optional dependency will be skipped', async () => {

packages/metro/src/DeltaBundler/__tests__/__snapshots__/Graph-test.js.snap

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,6 @@ TestGraph {
112112
"entryPoints": Set {
113113
"/bundle",
114114
},
115-
"importBundleNames": Set {},
116115
"transformOptions": Object {
117116
"dev": false,
118117
"hot": false,
@@ -160,7 +159,6 @@ TestGraph {
160159
"entryPoints": Set {
161160
"/bundle",
162161
},
163-
"importBundleNames": Set {},
164162
"transformOptions": Object {
165163
"dev": false,
166164
"hot": false,

0 commit comments

Comments
 (0)