Skip to content

Commit e24c6ae

Browse files
robhoganfacebook-github-bot
authored andcommitted
Add unstable_transformResultKey and prune unmodified modules from deltas
Summary: `Graph.traverseDependencies`, at the core of delta calculation and therefore Fast Refresh and incremental builds, relies on being given a list of modified file paths, and derives a graph delta from them. Currently, if a path corresponds to a module in a given graph, that module will be returned as a "modified" module, serialised, and sent to the client for execution. This diff introduces modified module pruning, by a) being more selective about when we regard a module as *potentially* modified during traversal, and then b) by actually diffing the modules as a final pass before returning a delta. We do this by storing the transformer's cache key as a "`transformResultKey`", diffing that, and also diffing dependencies and inverse dependencies to account for resolution changes. ## Package exports and symlinks Because we don't *yet* have smart-enough incremental invalidation of resolutions, we have to invalidate the whole of every graph for any symlink or `package.json#exports` change. We implement this by calling `Graph.traverseDependencies` with *every* path in the graph. Currently, without pruning unmodified modules, this results in enormous deltas of hundreds of nodes. With pruning, although we must still re-traverse and re-resolve everything, we can avoid sending anything to the client unless there are any actual changes (and then, only send the minimum), and consequently do much less injection/execution on the client. This results in correct Fast Refresh of symlink and `package.json#exports` changes in subsecond time, and (IMO) unblocks symlinks-by-default. ``` - **[Performance]**: Prune unmodified modules from delta updates before sending them to the client ``` Reviewed By: Andjeliko Differential Revision: D45691844 fbshipit-source-id: df09a3ec9016f691ef0bfaea5a723a9ec57d9d79
1 parent a3c5b26 commit e24c6ae

File tree

6 files changed

+153
-47
lines changed

6 files changed

+153
-47
lines changed

packages/metro/src/DeltaBundler/DeltaCalculator.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,10 +323,17 @@ class DeltaCalculator<T> extends EventEmitter {
323323
};
324324
}
325325

326+
debug('Traversing dependencies for %s paths', modifiedDependencies.length);
326327
const {added, modified, deleted} = await this._graph.traverseDependencies(
327328
modifiedDependencies,
328329
this._options,
329330
);
331+
debug(
332+
'Calculated graph delta {added: %s, modified: %d, deleted: %d}',
333+
added.size,
334+
modified.size,
335+
deleted.size,
336+
);
330337

331338
return {
332339
added,

packages/metro/src/DeltaBundler/Graph.js

Lines changed: 98 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -176,17 +176,24 @@ export class Graph<T = MixedOutput> {
176176

177177
// Record the paths that are part of the dependency graph before we start
178178
// 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));
179+
// that only exist as part of the graph mid-traversal, and to eliminate
180+
// modules that end up in the same state that they started from the delta.
181+
const originalModules = new Map<string, Module<T>>();
182+
for (const path of paths) {
183+
const originalModule = this.dependencies.get(path);
184+
if (originalModule) {
185+
originalModules.set(path, originalModule);
186+
}
187+
}
181188

182-
for (const path of existingPaths) {
189+
for (const [path] of originalModules) {
183190
// Traverse over modules that are part of the dependency graph.
184191
//
185192
// Note: A given path may not be part of the graph *at this time*, in
186193
// particular it may have been removed since we started traversing, but
187194
// in that case the path will be visited if and when we add it back to
188195
// the graph in a subsequent iteration.
189-
if (this.dependencies.get(path)) {
196+
if (this.dependencies.has(path)) {
190197
await this._traverseDependenciesForSingleFile(
191198
path,
192199
delta,
@@ -208,17 +215,35 @@ export class Graph<T = MixedOutput> {
208215
// but may not actually differ, may be new, or may have been deleted after
209216
// processing. The actually-modified modules are the intersection of
210217
// delta.modified with the pre-existing paths, minus modules deleted.
211-
for (const path of existingPaths) {
218+
for (const [path, originalModule] of originalModules) {
212219
invariant(
213220
!delta.added.has(path),
214221
'delta.added has %s, but this path was already in the graph.',
215222
path,
216223
);
217224
if (delta.modified.has(path)) {
218-
// Only report a module as modified if we're not already reporting it
219-
// as added or deleted.
225+
// It's expected that a module may be both modified and subsequently
226+
// deleted - we'll only return it as deleted.
220227
if (!delta.deleted.has(path)) {
221-
modified.set(path, nullthrows(this.dependencies.get(path)));
228+
// If a module existed before and has not been deleted, it must be
229+
// in the dependencies map.
230+
const newModule = nullthrows(this.dependencies.get(path));
231+
if (
232+
// Module.dependencies is mutable, so it's not obviously the case
233+
// that referential equality implies no modification. However, we
234+
// only mutate dependencies in two cases:
235+
// 1. Within _processModule. In that case, we always mutate a new
236+
// module and set a new reference in this.dependencies.
237+
// 2. During _releaseModule, when recursively removing
238+
// dependencies. In that case, we immediately discard the module
239+
// object.
240+
// TODO: Refactor for more explicit immutability
241+
newModule !== originalModule ||
242+
transfromOutputMayDiffer(newModule, originalModule) ||
243+
!allDependenciesEqual(newModule, originalModule)
244+
) {
245+
modified.set(path, newModule);
246+
}
222247
}
223248
}
224249
}
@@ -290,10 +315,6 @@ export class Graph<T = MixedOutput> {
290315
): Promise<Module<T>> {
291316
const resolvedContext = this.#resolvedContexts.get(path);
292317

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-
297318
// Transform the file via the given option.
298319
// TODO: Unbind the transform method from options
299320
const result = await options.transform(path, resolvedContext);
@@ -306,48 +327,67 @@ export class Graph<T = MixedOutput> {
306327
options,
307328
);
308329

309-
const previousModule = this.dependencies.get(path) || {
310-
inverseDependencies:
311-
delta.earlyInverseDependencies.get(path) || new CountingSet(),
312-
path,
313-
};
314-
const previousDependencies = previousModule.dependencies || new Map();
330+
const previousModule = this.dependencies.get(path);
315331

316-
// Update the module information.
317-
const module = {
318-
...previousModule,
332+
const previousDependencies = previousModule?.dependencies ?? new Map();
333+
334+
const nextModule = {
335+
...(previousModule ?? {
336+
inverseDependencies:
337+
delta.earlyInverseDependencies.get(path) ?? new CountingSet(),
338+
path,
339+
}),
319340
dependencies: new Map(previousDependencies),
320341
getSource: result.getSource,
321342
output: result.output,
343+
unstable_transformResultKey: result.unstable_transformResultKey,
322344
};
323-
this.dependencies.set(module.path, module);
345+
346+
// Update the module information.
347+
this.dependencies.set(nextModule.path, nextModule);
324348

325349
// Diff dependencies (1/2): remove dependencies that have changed or been removed.
350+
let dependenciesRemoved = false;
326351
for (const [key, prevDependency] of previousDependencies) {
327352
const curDependency = currentDependencies.get(key);
328353
if (
329354
!curDependency ||
330355
!dependenciesEqual(prevDependency, curDependency, options)
331356
) {
332-
this._removeDependency(module, key, prevDependency, delta, options);
357+
dependenciesRemoved = true;
358+
this._removeDependency(nextModule, key, prevDependency, delta, options);
333359
}
334360
}
335361

336362
// Diff dependencies (2/2): add dependencies that have changed or been added.
337-
const promises = [];
363+
const addDependencyPromises = [];
338364
for (const [key, curDependency] of currentDependencies) {
339365
const prevDependency = previousDependencies.get(key);
340366
if (
341367
!prevDependency ||
342368
!dependenciesEqual(prevDependency, curDependency, options)
343369
) {
344-
promises.push(
345-
this._addDependency(module, key, curDependency, delta, options),
370+
addDependencyPromises.push(
371+
this._addDependency(nextModule, key, curDependency, delta, options),
346372
);
347373
}
348374
}
349375

350-
await Promise.all(promises);
376+
if (
377+
previousModule &&
378+
!transfromOutputMayDiffer(previousModule, nextModule) &&
379+
!dependenciesRemoved &&
380+
addDependencyPromises.length === 0
381+
) {
382+
// We have not operated on nextModule, so restore previousModule
383+
// to aid diffing.
384+
this.dependencies.set(previousModule.path, previousModule);
385+
return previousModule;
386+
}
387+
388+
delta.modified.add(path);
389+
390+
await Promise.all(addDependencyPromises);
351391

352392
// Replace dependencies with the correctly-ordered version. As long as all
353393
// the above promises have resolved, this will be the same map but without
@@ -357,13 +397,13 @@ export class Graph<T = MixedOutput> {
357397

358398
// Catch obvious errors with a cheap assertion.
359399
invariant(
360-
module.dependencies.size === currentDependencies.size,
400+
nextModule.dependencies.size === currentDependencies.size,
361401
'Failed to add the correct dependencies',
362402
);
363403

364-
module.dependencies = currentDependencies;
404+
nextModule.dependencies = currentDependencies;
365405

366-
return module;
406+
return nextModule;
367407
}
368408

369409
async _addDependency(
@@ -470,7 +510,10 @@ export class Graph<T = MixedOutput> {
470510
/**
471511
* Collect a list of context modules which include a given file.
472512
*/
473-
markModifiedContextModules(filePath: string, modifiedPaths: Set<string>) {
513+
markModifiedContextModules(
514+
filePath: string,
515+
modifiedPaths: Set<string> | CountingSet<string>,
516+
) {
474517
for (const [absolutePath, context] of this.#resolvedContexts) {
475518
if (
476519
!modifiedPaths.has(absolutePath) &&
@@ -814,6 +857,23 @@ function dependenciesEqual(
814857
);
815858
}
816859

860+
function allDependenciesEqual<T>(
861+
a: Module<T>,
862+
b: Module<T>,
863+
options: $ReadOnly<{lazy: boolean, ...}>,
864+
): boolean {
865+
if (a.dependencies.size !== b.dependencies.size) {
866+
return false;
867+
}
868+
for (const [key, depA] of a.dependencies) {
869+
const depB = b.dependencies.get(key);
870+
if (!depB || !dependenciesEqual(depA, depB, options)) {
871+
return false;
872+
}
873+
}
874+
return true;
875+
}
876+
817877
function contextParamsEqual(
818878
a: ?RequireContextParams,
819879
b: ?RequireContextParams,
@@ -829,3 +889,10 @@ function contextParamsEqual(
829889
a.mode === b.mode)
830890
);
831891
}
892+
893+
function transfromOutputMayDiffer<T>(a: Module<T>, b: Module<T>): boolean {
894+
return (
895+
a.unstable_transformResultKey == null ||
896+
a.unstable_transformResultKey !== b.unstable_transformResultKey
897+
);
898+
}

packages/metro/src/DeltaBundler/Transformer.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ class Transformer {
155155

156156
return {
157157
...data.result,
158+
unstable_transformResultKey: fullKey.toString(),
158159
getSource(): Buffer {
159160
if (fileBuffer) {
160161
return fileBuffer;

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

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,14 @@ let mockedDependencyTree: Map<
4343
}>,
4444
>,
4545
> = new Map();
46-
const files = new Set<string>();
46+
47+
/* `files` emulates the changed paths typically aggregated by DeltaCalcutor.
48+
* Paths will be added to this set by any addition, deletion or modification,
49+
* respecting getModifiedModulesForDeletedPath. Each such operation will
50+
* increment the count - we'll intepret count as a file revision number, with
51+
* a changed count reflected in a change to the transform output key.
52+
*/
53+
const files = new CountingSet<string>();
4754
let graph: TestGraph;
4855
let options;
4956

@@ -161,11 +168,14 @@ const Actions = {
161168
},
162169
};
163170

164-
function deferred(value: {
165-
+dependencies: $ReadOnlyArray<TransformResultDependency>,
166-
+getSource: () => Buffer,
167-
+output: $ReadOnlyArray<MixedOutput>,
168-
}) {
171+
function deferred(
172+
value: $ReadOnly<{
173+
dependencies: $ReadOnlyArray<TransformResultDependency>,
174+
getSource: () => Buffer,
175+
output: $ReadOnlyArray<MixedOutput>,
176+
unstable_transformResultKey?: ?string,
177+
}>,
178+
) {
169179
let resolve;
170180
const promise = new Promise(res => (resolve = res));
171181

@@ -249,7 +259,7 @@ class TestGraph extends Graph<> {
249259
): Promise<Result<MixedOutput>> {
250260
// Get a snapshot of the graph before the traversal.
251261
const dependenciesBefore = new Set(this.dependencies.keys());
252-
const pathsBefore = new Set(paths);
262+
const modifiedPaths = new Set(files);
253263

254264
// Mutate the graph and calculate a delta.
255265
const delta = await super.traverseDependencies(paths, options);
@@ -258,7 +268,7 @@ class TestGraph extends Graph<> {
258268
const expectedDelta = computeDelta(
259269
dependenciesBefore,
260270
this.dependencies,
261-
pathsBefore,
271+
modifiedPaths,
262272
);
263273
expect(getPaths(delta)).toEqual(expectedDelta);
264274

@@ -294,6 +304,17 @@ beforeEach(async () => {
294304
Promise<TransformResultWithSource<MixedOutput>>,
295305
>()
296306
.mockImplementation(async (path: string, context: ?RequireContext) => {
307+
const unstable_transformResultKey =
308+
path +
309+
(context
310+
? // For context modules, the real transformer will hash the
311+
// generated template, which varies according to its dependencies.
312+
// Approximate that by concatenating dependency paths.
313+
(mockedDependencyTree.get(path) ?? [])
314+
.map(d => d.path)
315+
.sort()
316+
.join('|')
317+
: ` (revision ${files.count(path)})`);
297318
return {
298319
dependencies: (mockedDependencyTree.get(path) || []).map(dep => ({
299320
name: dep.name,
@@ -318,6 +339,7 @@ beforeEach(async () => {
318339
type: 'js/module',
319340
},
320341
],
342+
unstable_transformResultKey,
321343
};
322344
});
323345

@@ -407,7 +429,7 @@ it('should return an empty result when there are no changes', async () => {
407429
getPaths(await graph.traverseDependencies(['/bundle'], options)),
408430
).toEqual({
409431
added: new Set(),
410-
modified: new Set(['/bundle']),
432+
modified: new Set([]),
411433
deleted: new Set(),
412434
});
413435
});
@@ -1959,13 +1981,15 @@ describe('edge cases', () => {
19591981
│ /baz │
19601982
└──────┘
19611983
*/
1984+
mockTransform.mockClear();
19621985
expect(
19631986
getPaths(await graph.traverseDependencies([...files], localOptions)),
19641987
).toEqual({
19651988
added: new Set([]),
19661989
modified: new Set(['/bundle']),
19671990
deleted: new Set([]),
19681991
});
1992+
expect(mockTransform).toHaveBeenCalledWith('/bundle', undefined);
19691993
});
19701994
});
19711995

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ TestGraph {
3030
},
3131
],
3232
"path": "/bundle",
33+
"unstable_transformResultKey": "/bundle (revision 0)",
3334
},
3435
"/foo" => Object {
3536
"dependencies": Map {
@@ -71,6 +72,7 @@ TestGraph {
7172
},
7273
],
7374
"path": "/foo",
75+
"unstable_transformResultKey": "/foo (revision 0)",
7476
},
7577
"/bar" => Object {
7678
"dependencies": Map {},
@@ -89,6 +91,7 @@ TestGraph {
8991
},
9092
],
9193
"path": "/bar",
94+
"unstable_transformResultKey": "/bar (revision 0)",
9295
},
9396
"/baz" => Object {
9497
"dependencies": Map {},
@@ -107,6 +110,7 @@ TestGraph {
107110
},
108111
],
109112
"path": "/baz",
113+
"unstable_transformResultKey": "/baz (revision 0)",
110114
},
111115
},
112116
"entryPoints": Set {
@@ -153,6 +157,7 @@ TestGraph {
153157
},
154158
],
155159
"path": "/bundle",
160+
"unstable_transformResultKey": "/bundle (revision 0)",
156161
},
157162
},
158163
"entryPoints": Set {

0 commit comments

Comments
 (0)