Skip to content

Commit

Permalink
Fix incremental build bug with parallel edges to the same module
Browse files Browse the repository at this point in the history
Summary:
When two imports from the same origin module resolve to the same target module, Metro can get a little confused.

Consider the following example:
```
              import './Module/index';
  ┌─────────────────────────────────────────┐
  │                                         ▼
┌───────────┐  import './Module';         ┌──────────────────┐
│ /Entry.js │ ──────────────────────────▶ │ /Module/index.js │
└───────────┘                             └──────────────────┘
```

If, while the app is running, we delete *one* of the imports but not the other, Metro will stop correctly propagating updates of `/Module/index.js` to its "parent", `/Entry.js`. Fast Refresh will **appear** to work (the UI indicator will flash briefly) but the app will reflect the *old* contents of `/Module/index.js`.

This happens because DeltaBundler's data structure for keeping track of a module's inverse dependencies is a set keyed on absolute paths, which doesn't account for parallel edges ( = multiple inverse dependencies from the same origin module).

Here we change this data structure to a `CountingSet` - a modified `Set` that only deletes items when the number of `delete(item)` calls matches the number of `add(item)` calls.

Reviewed By: huntie

Differential Revision: D37194640

fbshipit-source-id: 89c190b0de3ec75b533f2d41a7024f17d31c59b0
  • Loading branch information
motiz88 authored and facebook-github-bot committed Jun 16, 2022
1 parent 3877d2d commit fc29a11
Show file tree
Hide file tree
Showing 11 changed files with 462 additions and 24 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

'use strict';

import CountingSet from '../../../../lib/CountingSet';

const createModuleIdFactory = require('../../../../lib/createModuleIdFactory');
const {wrapModule} = require('../bytecode');
const {compile, validateBytecodeModule} = require('metro-hermes-compiler');
Expand Down Expand Up @@ -43,7 +45,7 @@ beforeEach(() => {
],
]),
getSource: () => Buffer.from(''),
inverseDependencies: new Set(),
inverseDependencies: new CountingSet(),
output: [
{
data: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

'use strict';

import CountingSet from '../../../../lib/CountingSet';

const createModuleIdFactory = require('../../../../lib/createModuleIdFactory');
const {wrapModule} = require('../js');

Expand All @@ -36,7 +38,7 @@ beforeEach(() => {
],
]),
getSource: () => Buffer.from(''),
inverseDependencies: new Set(),
inverseDependencies: new CountingSet(),
output: [
{
data: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Object {
},
},
"getSource": [Function],
"inverseDependencies": Set {},
"inverseDependencies": Array [],
"output": Array [
Object {
"data": Object {
Expand Down Expand Up @@ -54,9 +54,9 @@ Object {
},
},
"getSource": [Function],
"inverseDependencies": Set {
"inverseDependencies": Array [
"/bundle",
},
],
"output": Array [
Object {
"data": Object {
Expand All @@ -72,9 +72,9 @@ Object {
"/bar" => Object {
"dependencies": Map {},
"getSource": [Function],
"inverseDependencies": Set {
"inverseDependencies": Array [
"/foo",
},
],
"output": Array [
Object {
"data": Object {
Expand All @@ -90,9 +90,9 @@ Object {
"/baz" => Object {
"dependencies": Map {},
"getSource": [Function],
"inverseDependencies": Set {
"inverseDependencies": Array [
"/foo",
},
],
"output": Array [
Object {
"data": Object {
Expand Down Expand Up @@ -139,7 +139,7 @@ Object {
},
},
"getSource": [Function],
"inverseDependencies": Set {},
"inverseDependencies": Array [],
"output": Array [
Object {
"data": Object {
Expand Down
105 changes: 101 additions & 4 deletions packages/metro/src/DeltaBundler/__tests__/traverseDependencies-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import type {Graph, TransformResultDependency} from '../types.flow';

import CountingSet from '../../lib/CountingSet';
import nullthrows from 'nullthrows';

const {
Expand Down Expand Up @@ -202,7 +203,7 @@ async function traverseDependencies(paths, graph, options) {
);
const actualInverseDependencies = new Map();
for (const [path, module] of graph.dependencies) {
actualInverseDependencies.set(path, module.inverseDependencies);
actualInverseDependencies.set(path, new Set(module.inverseDependencies));
}
expect(actualInverseDependencies).toEqual(expectedInverseDependencies);

Expand Down Expand Up @@ -314,7 +315,7 @@ it('should populate all the inverse dependencies', async () => {

expect(
nullthrows(graph.dependencies.get('/bar')).inverseDependencies,
).toEqual(new Set(['/foo', '/bundle']));
).toEqual(new CountingSet(['/foo', '/bundle']));
});

it('should return an empty result when there are no changes', async () => {
Expand Down Expand Up @@ -501,7 +502,7 @@ describe('edge cases', () => {

expect(
nullthrows(graph.dependencies.get('/foo')).inverseDependencies,
).toEqual(new Set(['/bundle', '/baz']));
).toEqual(new CountingSet(['/baz', '/bundle']));
});

it('should handle renames correctly', async () => {
Expand Down Expand Up @@ -2049,7 +2050,7 @@ describe('reorderGraph', () => {
getSource: () => Buffer.from('// source'),
// NOTE: inverseDependencies is traversal state/output, not input, so we
// don't pre-populate it.
inverseDependencies: new Set(),
inverseDependencies: new CountingSet(),
});

const graph = createGraph({
Expand Down Expand Up @@ -2168,3 +2169,99 @@ describe('optional dependencies', () => {
).rejects.toThrow();
});
});

describe('parallel edges', () => {
it('add twice w/ same key, build and remove once', async () => {
// Create a second edge between /foo and /bar.
Actions.addDependency('/foo', '/bar', undefined);

await initialTraverseDependencies(graph, options);

const fooDeps = nullthrows(graph.dependencies.get('/foo')).dependencies;
const fooDepsResolved = [...fooDeps.values()].map(dep => dep.absolutePath);
// We dedupe the dependencies because they have the same `name`.
expect(fooDepsResolved).toEqual(['/bar', '/baz']);

// Remove one of the edges between /foo and /bar (arbitrarily)
Actions.removeDependency('/foo', '/bar');

expect(
getPaths(await traverseDependencies([...files], graph, options)),
).toEqual({
added: new Set(),
modified: new Set(['/foo']),
deleted: new Set(),
});
});

it('add twice w/ same key, build and remove twice', async () => {
// Create a second edge between /foo and /bar.
Actions.addDependency('/foo', '/bar', undefined);

await initialTraverseDependencies(graph, options);

const fooDeps = nullthrows(graph.dependencies.get('/foo')).dependencies;
const fooDepsResolved = [...fooDeps.values()].map(dep => dep.absolutePath);
// We dedupe the dependencies because they have the same `name`.
expect(fooDepsResolved).toEqual(['/bar', '/baz']);

// Remove both edges between /foo and /bar
Actions.removeDependency('/foo', '/bar');
Actions.removeDependency('/foo', '/bar');

expect(
getPaths(await traverseDependencies([...files], graph, options)),
).toEqual({
added: new Set(),
modified: new Set(['/foo']),
deleted: new Set(['/bar']),
});
});

it('add twice w/ different keys, build and remove once', async () => {
// Create a second edge between /foo and /bar, with a different `name`.
Actions.addDependency('/foo', '/bar', undefined, 'bar-second');

await initialTraverseDependencies(graph, options);

const fooDeps = nullthrows(graph.dependencies.get('/foo')).dependencies;
const fooDepsResolved = [...fooDeps.values()].map(dep => dep.absolutePath);
// We don't dedupe the dependencies because they have different `name`s.
expect(fooDepsResolved).toEqual(['/bar', '/baz', '/bar']);

// Remove one of the edges between /foo and /bar (arbitrarily)
Actions.removeDependency('/foo', '/bar');

expect(
getPaths(await traverseDependencies([...files], graph, options)),
).toEqual({
added: new Set(),
modified: new Set(['/foo']),
deleted: new Set(),
});
});

it('add twice w/ different keys, build and remove twice', async () => {
// Create a second edge between /foo and /bar, with a different `name`.
Actions.addDependency('/foo', '/bar', undefined, 'bar-second');

await initialTraverseDependencies(graph, options);

const fooDeps = nullthrows(graph.dependencies.get('/foo')).dependencies;
const fooDepsResolved = [...fooDeps.values()].map(dep => dep.absolutePath);
// We don't dedupe the dependencies because they have different `name`s.
expect(fooDepsResolved).toEqual(['/bar', '/baz', '/bar']);

// Remove both edges between /foo and /bar
Actions.removeDependency('/foo', '/bar');
Actions.removeDependency('/foo', '/bar');

expect(
getPaths(await traverseDependencies([...files], graph, options)),
).toEqual({
added: new Set(),
modified: new Set(['/foo']),
deleted: new Set(['/bar']),
});
});
});
9 changes: 6 additions & 3 deletions packages/metro/src/DeltaBundler/graphOperations.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ import type {
TransformResultDependency,
} from './types.flow';

import CountingSet from '../lib/CountingSet';

const invariant = require('invariant');
const nullthrows = require('nullthrows');

Expand Down Expand Up @@ -104,7 +106,7 @@ type Delta = $ReadOnly<{

// A place to temporarily track inverse dependencies for a module while it is
// being processed but has not been added to `graph.dependencies` yet.
earlyInverseDependencies: Map<string, Set<string>>,
earlyInverseDependencies: Map<string, CountingSet<string>>,
}>;

type InternalOptions<T> = $ReadOnly<{
Expand Down Expand Up @@ -277,7 +279,8 @@ async function processModule<T>(
);

const previousModule = graph.dependencies.get(path) || {
inverseDependencies: delta.earlyInverseDependencies.get(path) || new Set(),
inverseDependencies:
delta.earlyInverseDependencies.get(path) || new CountingSet(),
path,
};
const previousDependencies = previousModule.dependencies || new Map();
Expand Down Expand Up @@ -397,7 +400,7 @@ async function addDependency<T>(
delta.added.add(path);
delta.modified.delete(path);
}
delta.earlyInverseDependencies.set(path, new Set([parentModule.path]));
delta.earlyInverseDependencies.set(path, new CountingSet());

options.onDependencyAdd();
module = await processModule(path, graph, delta, options);
Expand Down
4 changes: 3 additions & 1 deletion packages/metro/src/DeltaBundler/types.flow.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import type {RequireContextParams} from '../ModuleGraph/worker/collectDependenci
import type {PrivateState} from './graphOperations';
import type {JsTransformOptions} from 'metro-transform-worker';

import CountingSet from '../lib/CountingSet';

export type MixedOutput = {
+data: mixed,
+type: string,
Expand Down Expand Up @@ -62,7 +64,7 @@ export type Dependency = {

export type Module<T = MixedOutput> = {
+dependencies: Map<string, Dependency>,
+inverseDependencies: Set<string>,
+inverseDependencies: CountingSet<string>,
+output: $ReadOnlyArray<T>,
+path: string,
+getSource: () => Buffer,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

'use strict';

import CountingSet from '../../lib/CountingSet';

const Metro = require('../../..');
const path = require('path');

Expand Down Expand Up @@ -50,7 +52,7 @@ it('should build the dependency graph', async () => {
expect(graph.dependencies.get(entryPoint)).toEqual(
expect.objectContaining({
path: entryPoint,
inverseDependencies: new Set(),
inverseDependencies: new CountingSet(),
output: [
expect.objectContaining({
type: 'js/module',
Expand Down
Loading

0 comments on commit fc29a11

Please sign in to comment.