Skip to content

Commit 5d7305e

Browse files
robhoganfacebook-github-bot
authored andcommitted
Fix graph delta bugs when a dependency is added+modified+removed / removed+modified+added within a traversal
Summary: I came across this edge case while working on module diffing. Typically: 1. DeltaCalculator aggregates relevant file changes between requests for a delta (e.g., via HMR). (If no client is connected, these changes may accrue over a period of time) 2. When `getDelta` is called, this list of paths is provided to `Graph.traverseDependencies`, which determines the actual graph delta, performing any necessary transformation and dependency traversal. 3. `traverseDependencies` works through these paths sequentially, marking modules as modified and deeply adding/removing dependencies as it goes. It ignores any path that doesn't correspond to a module in the graph. Previously, a path was added to `delta.modified` if only if it was given in `paths` *and* existed in the graph at the time we came to traverse it as part of the `traverseDependencies` sequence. ## Error cases This led to issues in a couple of cases: ## Module modified while temporarily NOT part of the graph For an existing graph with edges `A->B` and `A->C`. 1. The dependency `A->C` is removed, freeing `C` from the graph and adding it to `delta.deleted`. `A` is marked modified. 2. `C` is modified, but traversal skips it because it's not in the graph. 3. The dependency `B->C` is added. `C` is removed from `delta.deleted` and processed. `B` is marked modified. Result: `{ added: [], modified: [A, B], deleted: [] }` - `C`'s modification is missed. ### Module modified while temporarily part of the graph For an existing graph with edges `A->B`. 1. The dependency `B->C` is added, processing `C` as a new dependency and adding it to `delta.added` 2. `C` is modified, traversal marks it as modified because it's now part of the graph. 3. The dependency `A->B` is removed. `C` is removed from `delta.added` but remains in `delta.modified`. `A` is marked modified, `B` marked deleted. Result: `C`'s path remains in `delta.modified` but not `added` or `deleted`, but it's not present in the graph at the end of the traversal. `traverseDependencies` throws, client sees a redbox from `nullthrows` on: ``` modified.set(pathOfC, nullthrows(this.dependencies.get(pathOfC))) ``` ## This diff - Mark modules as potentially modified when they are **processed**, *either directly or transitively*. - Filter modifications to only those from the given `paths` that were present in the graph *before traversal started*. [Building this list feeds nicely into subsequent work on diffing modules.] Changelog: ``` **[Fix]**: Fix crash on a module added+modified+removed between updates. **[Fix]**: Fix missed modification on module removed+modified+added between updates. ``` Reviewed By: motiz88 Differential Revision: D45691691 fbshipit-source-id: 4f246582311063650f26678006b6089c778014f4
1 parent cc9a346 commit 5d7305e

File tree

2 files changed

+67
-8
lines changed

2 files changed

+67
-8
lines changed

packages/metro/src/DeltaBundler/Graph.js

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -174,11 +174,19 @@ export class Graph<T = MixedOutput> {
174174

175175
const internalOptions = getInternalOptions(options);
176176

177-
for (const path of paths) {
178-
// Start traversing from modules that are already part of the dependency graph.
177+
// Record the paths that are part of the dependency graph before we start
178+
// traversing - we'll use this to ensure we don't report modules modified
179+
// that only exist as part of the graph mid-traversal.
180+
const existingPaths = paths.filter(path => this.dependencies.has(path));
181+
182+
for (const path of existingPaths) {
183+
// Traverse over modules that are part of the dependency graph.
184+
//
185+
// Note: A given path may not be part of the graph *at this time*, in
186+
// particular it may have been removed since we started traversing, but
187+
// in that case the path will be visited if and when we add it back to
188+
// the graph in a subsequent iteration.
179189
if (this.dependencies.get(path)) {
180-
delta.modified.add(path);
181-
182190
await this._traverseDependenciesForSingleFile(
183191
path,
184192
delta,
@@ -195,10 +203,23 @@ export class Graph<T = MixedOutput> {
195203
}
196204

197205
const modified = new Map<string, Module<T>>();
198-
for (const path of delta.modified) {
199-
// Only report a module as modified if we're not already reporting it as added or deleted.
200-
if (!delta.added.has(path) && !delta.deleted.has(path)) {
201-
modified.set(path, nullthrows(this.dependencies.get(path)));
206+
207+
// A path in delta.modified has been processed during this traversal,
208+
// but may not actually differ, may be new, or may have been deleted after
209+
// processing. The actually-modified modules are the intersection of
210+
// delta.modified with the pre-existing paths, minus modules deleted.
211+
for (const path of existingPaths) {
212+
invariant(
213+
!delta.added.has(path),
214+
'delta.added has %s, but this path was already in the graph.',
215+
path,
216+
);
217+
if (delta.modified.has(path)) {
218+
// Only report a module as modified if we're not already reporting it
219+
// as added or deleted.
220+
if (!delta.deleted.has(path)) {
221+
modified.set(path, nullthrows(this.dependencies.get(path)));
222+
}
202223
}
203224
}
204225

@@ -268,6 +289,11 @@ export class Graph<T = MixedOutput> {
268289
options: InternalOptions<T>,
269290
): Promise<Module<T>> {
270291
const resolvedContext = this.#resolvedContexts.get(path);
292+
293+
// Mark any module processed as potentially modified. Once we've finished
294+
// traversing we'll filter this set down.
295+
delta.modified.add(path);
296+
271297
// Transform the file via the given option.
272298
// TODO: Unbind the transform method from options
273299
const result = await options.transform(path, resolvedContext);

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -675,6 +675,39 @@ describe('edge cases', () => {
675675
expect(graph.dependencies.get('/baz')).toBe(undefined);
676676
});
677677

678+
it('remove a dependency, modify it, and re-add it elsewhere', async () => {
679+
await graph.initialTraverseDependencies(options);
680+
681+
Actions.removeDependency('/foo', '/bar');
682+
Actions.modifyFile('/bar');
683+
Actions.addDependency('/baz', '/bar');
684+
685+
expect(
686+
getPaths(await graph.traverseDependencies([...files], options)),
687+
).toEqual({
688+
added: new Set(),
689+
modified: new Set(['/foo', '/bar', '/baz']),
690+
deleted: new Set(),
691+
});
692+
});
693+
694+
it('Add a dependency, modify it, and remove it', async () => {
695+
await graph.initialTraverseDependencies(options);
696+
697+
Actions.createFile('/quux');
698+
Actions.addDependency('/bar', '/quux');
699+
Actions.modifyFile('/quux');
700+
Actions.removeDependency('/foo', '/bar');
701+
702+
expect(
703+
getPaths(await graph.traverseDependencies([...files], options)),
704+
).toEqual({
705+
added: new Set(),
706+
modified: new Set(['/foo']),
707+
deleted: new Set(['/bar']),
708+
});
709+
});
710+
678711
it('removes a cyclic dependency but should not remove any dependency', async () => {
679712
Actions.createFile('/bar1');
680713
Actions.addDependency('/bar', '/bar1');

0 commit comments

Comments
 (0)