Skip to content

Commit 58f2922

Browse files
authored
skip source maps for replaced scripts (#286)
1 parent 563e86a commit 58f2922

2 files changed

Lines changed: 167 additions & 8 deletions

File tree

‎front_end/models/bindings/CompilerScriptMapping.test.ts‎

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,28 @@ describeWithMockConnection('CompilerScriptMapping', () => {
4545
});
4646
});
4747

48+
/**
49+
* Holds back loading the source map at `url` until the returned function is called,
50+
* so that source maps can finish loading out of parse order. Calling the returned
51+
* function also restores `loadResource`.
52+
*/
53+
const delaySourceMapLoad = (url: string): () => void => {
54+
const {promise: sourceMapLoadReleased, resolve} = Promise.withResolvers<void>();
55+
const pageResourceLoader = SDK.PageResourceLoader.PageResourceLoader.instance();
56+
const loadResource = pageResourceLoader.loadResource.bind(pageResourceLoader);
57+
const loadResourceStub =
58+
sinon.stub(pageResourceLoader, 'loadResource').callsFake(async (resourceUrl, initiator) => {
59+
if (resourceUrl === url) {
60+
await sourceMapLoadReleased;
61+
}
62+
return await loadResource(resourceUrl, initiator);
63+
});
64+
return () => {
65+
loadResourceStub.restore();
66+
resolve();
67+
};
68+
};
69+
4870
it('creates UISourceCodes with the correct content type', async () => {
4971
const target = createTarget();
5072

@@ -518,6 +540,111 @@ describeWithMockConnection('CompilerScriptMapping', () => {
518540
originalScript.debuggerModel.createRawLocation(originalScript, 0, 0)),
519541
originalLibUISourceCode.uiLocation(0, 0));
520542
});
543+
544+
it('supports script replacement when source maps finish loading out of order', async () => {
545+
// When DevTools attaches to a page where a script replaced code of an earlier script
546+
// (e.g. on hot reload), the scripts are reported in parse order, but the small source
547+
// map of the replacement script sometimes finishes loading before the large source map
548+
// of the original bundle. The replacement script must keep mapping to `app.js` regardless.
549+
const target = createTarget();
550+
const sourceRoot = 'webpack:///src';
551+
552+
const bundleScriptInfo = {
553+
url: 'http://example.com/bundle.js',
554+
content: 'const f = console.log;\nf("Hello from the original bundle");',
555+
};
556+
const bundleSourceMapInfo = {
557+
url: `${bundleScriptInfo.url}.map`,
558+
content: encodeSourceMap(
559+
[
560+
'0:0 => lib.js:0:0',
561+
'lib.js: const f = console.log;',
562+
'1:0 => app.js:0:0',
563+
'app.js: f("Hello from the original bundle")',
564+
],
565+
sourceRoot),
566+
};
567+
const replacementScriptInfo = {
568+
url: 'http://example.com/replacement.js',
569+
content: 'f("Hello from the replacement");',
570+
};
571+
const replacementSourceMapInfo = {
572+
url: `${replacementScriptInfo.url}.map`,
573+
content: encodeSourceMap(['0:0 => app.js:0:0', 'app.js: f("Hello from the replacement")'], sourceRoot),
574+
};
575+
576+
const loadBundleSourceMap = delaySourceMapLoad(bundleSourceMapInfo.url);
577+
578+
const bundleScriptPromise = backend.addScript(target, bundleScriptInfo, bundleSourceMapInfo);
579+
const [replacementAppUISourceCode, replacementScript] = await Promise.all([
580+
waitForUISourceCodeAdded(`${sourceRoot}/app.js`, target),
581+
backend.addScript(target, replacementScriptInfo, replacementSourceMapInfo),
582+
]);
583+
584+
const [libUISourceCode, bundleScript] = await Promise.all([
585+
waitForUISourceCodeAdded(`${sourceRoot}/lib.js`, target),
586+
bundleScriptPromise,
587+
loadBundleSourceMap(),
588+
]);
589+
590+
// The replacement script still maps to `app.js`...
591+
assert.strictEqual(workspace.uiSourceCodeForURL(urlString`${sourceRoot}/app.js`), replacementAppUISourceCode);
592+
assert.deepEqual(await debuggerWorkspaceBinding.rawLocationToUILocation(
593+
replacementScript.debuggerModel.createRawLocation(replacementScript, 0, 0)),
594+
replacementAppUISourceCode.uiLocation(0, 0));
595+
assert.deepEqual(await debuggerWorkspaceBinding.uiLocationToRawLocations(replacementAppUISourceCode, 0, 0), [
596+
replacementScript.debuggerModel.createRawLocation(replacementScript, 0, 0),
597+
]);
598+
599+
// ...the stale `app.js` mapping of `bundle.js` is not used...
600+
const {uiSourceCode} = (await debuggerWorkspaceBinding.rawLocationToUILocation(
601+
bundleScript.debuggerModel.createRawLocation(bundleScript, 1, 0)))!;
602+
assert.notStrictEqual(uiSourceCode, replacementAppUISourceCode);
603+
604+
// ...while `bundle.js` still maps to `lib.js`.
605+
assert.deepEqual(await debuggerWorkspaceBinding.rawLocationToUILocation(
606+
bundleScript.debuggerModel.createRawLocation(bundleScript, 0, 0)),
607+
libUISourceCode.uiLocation(0, 0));
608+
});
609+
610+
it('shares UISourceCodes with compatible source maps that finish loading out of order', async () => {
611+
const target = createTarget();
612+
const sourceRoot = 'webpack:///src';
613+
614+
const route1ScriptInfo = {
615+
url: 'http://example.com/route1.js',
616+
content: 'function f(x){}\nf(1)',
617+
};
618+
const route1SourceMapInfo = {
619+
url: `${route1ScriptInfo.url}.map`,
620+
content: encodeSourceMap(['0:0 => shared.js:0:0', '1:0 => route1.js:0:0'], sourceRoot),
621+
};
622+
const route2ScriptInfo = {
623+
url: 'http://example.com/route2.js',
624+
content: 'function f(x){}\nf(2)',
625+
};
626+
const route2SourceMapInfo = {
627+
url: `${route2ScriptInfo.url}.map`,
628+
content: encodeSourceMap(['0:0 => shared.js:0:0', '1:0 => route2.js:0:0'], sourceRoot),
629+
};
630+
631+
const loadRoute1SourceMap = delaySourceMapLoad(route1SourceMapInfo.url);
632+
633+
const route1ScriptPromise = backend.addScript(target, route1ScriptInfo, route1SourceMapInfo);
634+
const [, route2Script] = await Promise.all([
635+
waitForUISourceCodeAdded(`${sourceRoot}/shared.js`, target),
636+
backend.addScript(target, route2ScriptInfo, route2SourceMapInfo),
637+
]);
638+
639+
const [sharedUISourceCode, route1Script] = await Promise.all([
640+
waitForUISourceCodeAdded(`${sourceRoot}/shared.js`, target),
641+
route1ScriptPromise,
642+
loadRoute1SourceMap(),
643+
]);
644+
645+
const sharedLocations = await debuggerWorkspaceBinding.uiLocationToRawLocations(sharedUISourceCode, 0, 0);
646+
assert.sameMembers(sharedLocations.map(location => location.script()), [route1Script, route2Script]);
647+
});
521648
});
522649

523650
it('assumes UTF-8 encoding for source files embedded in source maps', async () => {

‎front_end/models/bindings/CompilerScriptMapping.ts‎

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,8 @@ export class CompilerScriptMapping implements DebuggerSourceMapping {
359359
* and copies over all the mappings from the other source maps that were registered for the
360360
* same URL and which are compatible (agree on the content and ignore-list hint for the given
361361
* URL). If they are considered incompatible, the original `UISourceCode` will simply be
362-
* removed and a new mapping will be established.
362+
* removed and a new mapping will be established, unless the original `UISourceCode` is
363+
* mapped by a newer script, which happens when source maps finish loading out of parse order.
363364
*
364365
* @param event holds the {@link SDK.Script.Script} and its {@link SDK.SourceMap.SourceMap}.
365366
*/
@@ -384,7 +385,16 @@ export class CompilerScriptMapping implements DebuggerSourceMapping {
384385
}
385386
this.#sourceMapToProject.set(sourceMap, project);
386387

388+
const scriptParseOrder = new Map(script.debuggerModel.scripts().map((script, index) => [script, index]));
387389
for (const url of sourceMap.sourceURLs()) {
390+
const existingUISourceCode = project.uiSourceCodeForURL(url);
391+
// Source maps can finish loading out of parse order (e.g. when DevTools attaches after a
392+
// script was replaced on hot reload), so a stale source map must not override the mapping
393+
// of a newer script.
394+
if (!this.#shouldUseSourceMapForURL(existingUISourceCode, url, script, sourceMap, scriptParseOrder)) {
395+
continue;
396+
}
397+
388398
const contentType = Common.ResourceType.resourceTypes.SourceMapScript;
389399
const uiSourceCode = project.createUISourceCode(url, contentType);
390400
if (sourceMap.hasIgnoreListHint(url)) {
@@ -417,18 +427,17 @@ export class CompilerScriptMapping implements DebuggerSourceMapping {
417427
// added scripts in case of code-splitting, since it won't find out about these new mappings.
418428
// By removing and (re)adding a `UISourceCode` for the `url` we effectively force restoration
419429
// of breakpoints.
420-
const otherUISourceCode = project.uiSourceCodeForURL(url);
421-
if (otherUISourceCode !== null) {
422-
// Copy the existing source mappings from the `otherUISourceCode` over as long as
430+
if (existingUISourceCode !== null) {
431+
// Copy the existing source mappings from the `existingUISourceCode` over as long as
423432
// they are compatible with the `sourceMap` wrt. `url`. While doing so, also clean
424-
// up the `otherUISourceCode` (in particular its frame attributions).
425-
for (const otherSourceMap of this.#uiSourceCodeToSourceMaps.get(otherUISourceCode)) {
426-
this.#uiSourceCodeToSourceMaps.delete(otherUISourceCode, otherSourceMap);
433+
// up the `existingUISourceCode` (in particular its frame attributions).
434+
for (const otherSourceMap of this.#uiSourceCodeToSourceMaps.get(existingUISourceCode)) {
435+
this.#uiSourceCodeToSourceMaps.delete(existingUISourceCode, otherSourceMap);
427436
const otherScript = this.#sourceMapManager.clientForSourceMap(otherSourceMap);
428437
if (!otherScript) {
429438
continue;
430439
}
431-
NetworkProject.removeFrameAttribution(otherUISourceCode, otherScript.frameId);
440+
NetworkProject.removeFrameAttribution(existingUISourceCode, otherScript.frameId);
432441
if (sourceMap.compatibleForURL(url, otherSourceMap)) {
433442
this.#uiSourceCodeToSourceMaps.set(uiSourceCode, otherSourceMap);
434443
NetworkProject.addFrameAttribution(uiSourceCode, otherScript.frameId);
@@ -444,6 +453,29 @@ export class CompilerScriptMapping implements DebuggerSourceMapping {
444453
.then(() => this.sourceMapAttachedForTest(sourceMap));
445454
}
446455

456+
#shouldUseSourceMapForURL(existingUISourceCode: Workspace.UISourceCode.UISourceCode|null,
457+
url: Platform.DevToolsPath.UrlString, script: SDK.Script.Script,
458+
sourceMap: SDK.SourceMap.SourceMap,
459+
scriptParseOrder: Map<SDK.Script.Script, number>): boolean {
460+
if (existingUISourceCode === null) {
461+
return true;
462+
}
463+
for (const existingSourceMap of this.#uiSourceCodeToSourceMaps.get(existingUISourceCode)) {
464+
const existingScript = this.#sourceMapManager.clientForSourceMap(existingSourceMap);
465+
// Compatible source maps agree on the source code of `url` (e.g. code splitting) and
466+
// complement each other. Incompatible ones mean a different script replaced the source code
467+
// of `url` (e.g. on hot reload), so only the source map of the newest script should be used.
468+
if (!existingScript || sourceMap.compatibleForURL(url, existingSourceMap)) {
469+
continue;
470+
}
471+
const isExistingScriptNewer = (scriptParseOrder.get(existingScript) ?? -1) > (scriptParseOrder.get(script) ?? -1);
472+
if (isExistingScriptNewer) {
473+
return false;
474+
}
475+
}
476+
return true;
477+
}
478+
447479
/**
448480
* Invoked by the {@link SDK.SourceMapManager.SourceMapManager} when the source map for a given
449481
* {@link SDK.Script.Script} is removed, which could be either because the target is execution

0 commit comments

Comments
 (0)