Bug 670002 - Use source maps in the web console w/ performance issues. r=jsantell

--HG--
rename : devtools/client/framework/source-location.js => devtools/client/framework/source-map-service.js
This commit is contained in:
Jaideep Bhoosreddy 2016-07-20 00:40:00 -04:00
parent cbe2855b89
commit 8f73e01607
10 changed files with 447 additions and 181 deletions

View File

@ -0,0 +1,103 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const SOURCE_TOKEN = "<:>";
function LocationStore (store) {
this._store = store || new Map();
}
/**
* Method to get a promised location from the Store.
* @param location
* @returns Promise<Object>
*/
LocationStore.prototype.get = function (location) {
this._safeAccessInit(location.url);
return this._store.get(location.url).get(location);
};
/**
* Method to set a promised location to the Store
* @param location
* @param promisedLocation
*/
LocationStore.prototype.set = function (location, promisedLocation = null) {
this._safeAccessInit(location.url);
this._store.get(location.url).set(serialize(location), promisedLocation);
};
/**
* Utility method to verify if key exists in Store before accessing it.
* If not, initializing it.
* @param url
* @private
*/
LocationStore.prototype._safeAccessInit = function (url) {
if (!this._store.has(url)) {
this._store.set(url, new Map());
}
};
/**
* Utility proxy method to Map.clear() method
*/
LocationStore.prototype.clear = function () {
this._store.clear();
};
/**
* Retrieves an object containing all locations to be resolved when `source-updated`
* event is triggered.
* @param url
* @returns {Array<String>}
*/
LocationStore.prototype.getByURL = function (url){
if (this._store.has(url)) {
return [...this._store.get(url).keys()];
}
return [];
};
/**
* Invalidates the stale location promises from the store when `source-updated`
* event is triggered, and when FrameView unsubscribes from a location.
* @param url
*/
LocationStore.prototype.clearByURL = function (url) {
this._safeAccessInit(url);
this._store.set(url, new Map());
};
exports.LocationStore = LocationStore;
exports.serialize = serialize;
exports.deserialize = deserialize;
/**
* Utility method to serialize the source
* @param source
* @returns {string}
*/
function serialize(source) {
let { url, line, column } = source;
line = line || 0;
column = column || 0;
return `${url}${SOURCE_TOKEN}${line}${SOURCE_TOKEN}${column}`;
};
/**
* Utility method to serialize the source
* @param source
* @returns Object
*/
function deserialize(source) {
let [ url, line, column ] = source.split(SOURCE_TOKEN);
line = parseInt(line);
column = parseInt(column);
if (column === 0) {
return { url, line };
}
return { url, line, column };
};

View File

@ -16,11 +16,12 @@ DevToolsModules(
'devtools-browser.js',
'devtools.js',
'gDevTools.jsm',
'location-store.js',
'menu-item.js',
'menu.js',
'selection.js',
'sidebar.js',
'source-location.js',
'source-map-service.js',
'target-from-url.js',
'target.js',
'toolbox-highlighter-utils.js',

View File

@ -1,137 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const { Task } = require("devtools/shared/task");
const { assert } = require("devtools/shared/DevToolsUtils");
/**
* A manager class that wraps a TabTarget and listens to source changes
* from source maps and resolves non-source mapped locations to the source mapped
* versions and back and forth, and creating smart elements with a location that
* auto-update when the source changes (from pretty printing, source maps loading, etc)
*
* @param {TabTarget} target
*/
function SourceLocationController(target) {
this.target = target;
this.locations = new Set();
this._onSourceUpdated = this._onSourceUpdated.bind(this);
this.reset = this.reset.bind(this);
this.destroy = this.destroy.bind(this);
target.on("source-updated", this._onSourceUpdated);
target.on("navigate", this.reset);
target.on("will-navigate", this.reset);
target.on("close", this.destroy);
}
SourceLocationController.prototype.reset = function () {
this.locations.clear();
};
SourceLocationController.prototype.destroy = function () {
this.locations.clear();
this.target.off("source-updated", this._onSourceUpdated);
this.target.off("navigate", this.reset);
this.target.off("will-navigate", this.reset);
this.target.off("close", this.destroy);
this.target = this.locations = null;
};
/**
* Add this `location` to be observed and register a callback
* whenever the underlying source is updated.
*
* @param {Object} location
* An object with a {String} url, {Number} line, and optionally
* a {Number} column.
* @param {Function} callback
*/
SourceLocationController.prototype.bindLocation = function (location, callback) {
assert(location.url, "Location must have a url.");
assert(location.line, "Location must have a line.");
this.locations.add({ location, callback });
};
/**
* Called when a new source occurs (a normal source, source maps) or an updated
* source (pretty print) occurs.
*
* @param {String} eventName
* @param {Object} sourceEvent
*/
SourceLocationController.prototype._onSourceUpdated = function (_, sourceEvent) {
let { type, source } = sourceEvent;
// If we get a new source, and it's not a source map, abort;
// we can ahve no actionable updates as this is just a new normal source.
// Also abort if there's no `url`, which means it's unsourcemappable anyway,
// like an eval script.
if (!source.url || type === "newSource" && !source.isSourceMapped) {
return;
}
for (let locationItem of this.locations) {
if (isSourceRelated(locationItem.location, source)) {
this._updateSource(locationItem);
}
}
};
SourceLocationController.prototype._updateSource = Task.async(function* (locationItem) {
let newLocation = yield resolveLocation(this.target, locationItem.location);
if (newLocation) {
let previousLocation = Object.assign({}, locationItem.location);
Object.assign(locationItem.location, newLocation);
locationItem.callback(previousLocation, newLocation);
}
});
/**
* Take a TabTarget and a location, containing a `url`, `line`, and `column`, resolve
* the location to the latest location (so a source mapped location, or if pretty print
* status has been updated)
*
* @param {TabTarget} target
* @param {Object} location
* @return {Promise<Object>}
*/
function resolveLocation(target, location) {
return Task.spawn(function* () {
let newLocation = yield target.resolveLocation({
url: location.url,
line: location.line,
column: location.column || Infinity
});
// Source or mapping not found, so don't do anything
if (newLocation.error) {
return null;
}
return newLocation;
});
}
/**
* Takes a serialized SourceActor form and returns a boolean indicating
* if this source is related to this location, like if a location is a generated source,
* and the source map is loaded subsequently, the new source mapped SourceActor
* will be considered related to this location. Same with pretty printing new sources.
*
* @param {Object} location
* @param {Object} source
* @return {Boolean}
*/
function isSourceRelated(location, source) {
// Mapping location to subsequently loaded source map
return source.generatedUrl === location.url ||
// Mapping source map loc to source map
source.url === location.url;
}
exports.SourceLocationController = SourceLocationController;
exports.resolveLocation = resolveLocation;
exports.isSourceRelated = isSourceRelated;

View File

@ -0,0 +1,200 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const { Task } = require("devtools/shared/task");
const EventEmitter = require("devtools/shared/event-emitter");
const { LocationStore, serialize, deserialize } = require("./location-store");
/**
* A manager class that wraps a TabTarget and listens to source changes
* from source maps and resolves non-source mapped locations to the source mapped
* versions and back and forth, and creating smart elements with a location that
* auto-update when the source changes (from pretty printing, source maps loading, etc)
*
* @param {TabTarget} target
*/
function SourceMapService(target) {
this._target = target;
this._locationStore = new LocationStore();
this._isInitialResolve = true;
EventEmitter.decorate(this);
this._onSourceUpdated = this._onSourceUpdated.bind(this);
this._resolveLocation = this._resolveLocation.bind(this);
this._resolveAndUpdate = this._resolveAndUpdate.bind(this);
this.subscribe = this.subscribe.bind(this);
this.unsubscribe = this.unsubscribe.bind(this);
this.reset = this.reset.bind(this);
this.destroy = this.destroy.bind(this);
target.on("source-updated", this._onSourceUpdated);
target.on("navigate", this.reset);
target.on("will-navigate", this.reset);
target.on("close", this.destroy);
}
/**
* Clears the store containing the cached resolved locations and promises
*/
SourceMapService.prototype.reset = function () {
this._isInitialResolve = true;
this._locationStore.clear();
};
SourceMapService.prototype.destroy = function () {
this.reset();
this._target.off("source-updated", this._onSourceUpdated);
this._target.off("navigate", this.reset);
this._target.off("will-navigate", this.reset);
this._target.off("close", this.destroy);
this._isInitialResolve = null;
this._target = this._locationStore = null;
};
/**
* Sets up listener for the callback to update the FrameView and tries to resolve location
* @param location
* @param callback
*/
SourceMapService.prototype.subscribe = function (location, callback) {
this.on(serialize(location), callback);
this._locationStore.set(location);
if (this._isInitialResolve) {
this._resolveAndUpdate(location);
this._isInitialResolve = false;
}
};
/**
* Removes the listener for the location and clears cached locations
* @param location
* @param callback
*/
SourceMapService.prototype.unsubscribe = function (location, callback) {
this.off(serialize(location), callback);
this._locationStore.clearByURL(location.url);
};
/**
* Tries to resolve the location and if successful,
* emits the resolved location and caches it
* @param location
* @private
*/
SourceMapService.prototype._resolveAndUpdate = function (location) {
this._resolveLocation(location).then(resolvedLocation => {
// We try to source map the first console log to initiate the source-updated event from
// target. The isSameLocation check is to make sure we don't update the frame, if the
// location is not source-mapped.
if (resolvedLocation) {
if (this._isInitialResolve) {
if (!isSameLocation(location, resolvedLocation)) {
this.emit(serialize(location), location, resolvedLocation);
return;
}
}
this.emit(serialize(location), location, resolvedLocation);
}
});
};
/**
* Validates the location model,
* checks if there is existing promise to resolve location, if so returns cached promise
* if not promised to resolve,
* tries to resolve location and returns a promised location
* @param location
* @return Promise<Object>
* @private
*/
SourceMapService.prototype._resolveLocation = Task.async(function* (location) {
// Location must have a url and a line
if (!location.url || !location.line) {
return null;
}
const cachedLocation = this._locationStore.get(location);
if (cachedLocation) {
return cachedLocation;
} else {
const promisedLocation = resolveLocation(this._target, location);
if (promisedLocation) {
this._locationStore.set(location, promisedLocation);
return promisedLocation;
}
}
});
/**
* Checks if the `source-updated` event is fired from the target.
* Checks to see if location store has the source url in its cache,
* if so, tries to update each stale location in the store.
* @param _
* @param sourceEvent
* @private
*/
SourceMapService.prototype._onSourceUpdated = function (_, sourceEvent) {
let { type, source } = sourceEvent;
// If we get a new source, and it's not a source map, abort;
// we can have no actionable updates as this is just a new normal source.
// Also abort if there's no `url`, which means it's unsourcemappable anyway,
// like an eval script.
if (!source.url || type === "newSource" && !source.isSourceMapped) {
return;
}
let sourceUrl = null;
if (source.generatedUrl && source.isSourceMapped) {
sourceUrl = source.generatedUrl;
} else if (source.url && source.isPrettyPrinted) {
sourceUrl = source.url;
}
const locationsToResolve = this._locationStore.getByURL(sourceUrl);
if (locationsToResolve.length) {
this._locationStore.clearByURL(sourceUrl);
for (let location of locationsToResolve) {
this._resolveAndUpdate(deserialize(location));
}
}
};
exports.SourceMapService = SourceMapService;
/**
* Take a TabTarget and a location, containing a `url`, `line`, and `column`, resolve
* the location to the latest location (so a source mapped location, or if pretty print
* status has been updated)
*
* @param {TabTarget} target
* @param {Object} location
* @return {Promise<Object>}
*/
function resolveLocation(target, location) {
return Task.spawn(function* () {
let newLocation = yield target.resolveLocation({
url: location.url,
line: location.line,
column: location.column || Infinity
});
// Source or mapping not found, so don't do anything
if (newLocation.error) {
return null;
}
return newLocation;
});
}
/**
* Returns if the original location and resolved location are the same
* @param location
* @param resolvedLocation
* @returns {boolean}
*/
function isSameLocation(location, resolvedLocation) {
return location.url === resolvedLocation.url &&
location.line === resolvedLocation.line &&
location.column === resolvedLocation.column;
};

View File

@ -11,6 +11,7 @@ const OS_HISTOGRAM = "DEVTOOLS_OS_ENUMERATED_PER_USER";
const OS_IS_64_BITS = "DEVTOOLS_OS_IS_64_BITS_PER_USER";
const SCREENSIZE_HISTOGRAM = "DEVTOOLS_SCREEN_RESOLUTION_ENUMERATED_PER_USER";
const HTML_NS = "http://www.w3.org/1999/xhtml";
const { SourceMapService } = require("./source-map-service");
var {Cc, Ci, Cu} = require("chrome");
var promise = require("promise");
@ -118,6 +119,9 @@ function Toolbox(target, selectedTool, hostType, hostOptions) {
this._target = target;
this._toolPanels = new Map();
this._telemetry = new Telemetry();
if (Services.prefs.getBoolPref("devtools.sourcemap.locations.enabled")) {
this._sourceMapService = new SourceMapService(this._target);
}
this._initInspector = null;
this._inspector = null;
@ -2032,6 +2036,11 @@ Toolbox.prototype = {
this._lastFocusedElement = null;
if (this._sourceMapService) {
this._sourceMapService.destroy();
this._sourceMapService = null;
}
if (this.webconsolePanel) {
this._saveSplitConsoleHeight();
this.webconsolePanel.removeEventListener("resize",

View File

@ -296,6 +296,9 @@ pref("devtools.webconsole.autoMultiline", true);
// Enable the experimental webconsole frontend (work in progress)
pref("devtools.webconsole.new-frontend-enabled", false);
// Enable the experimental support for source maps in console (work in progress)
pref("devtools.sourcemap.locations.enabled", false);
// The number of lines that are displayed in the web console.
pref("devtools.hud.loglimit", 1000);

View File

@ -34,6 +34,8 @@ module.exports = createClass({
showEmptyPathAsHost: PropTypes.bool,
// Option to display a full source instead of just the filename.
showFullSourceUrl: PropTypes.bool,
// Service to enable the source map feature for console.
sourceMapService: PropTypes.object,
},
getDefaultProps() {
@ -46,10 +48,71 @@ module.exports = createClass({
};
},
componentWillMount() {
const sourceMapService = this.props.sourceMapService;
if (sourceMapService) {
const source = this.getSource();
sourceMapService.subscribe(source, this.onSourceUpdated);
}
},
componentWillUnmount() {
const sourceMapService = this.props.sourceMapService;
if (sourceMapService) {
const source = this.getSource();
sourceMapService.unsubscribe(source, this.onSourceUpdated);
}
},
/**
* Component method to update the FrameView when a resolved location is available
* @param event
* @param location
*/
onSourceUpdated(event, location, resolvedLocation) {
const frame = this.getFrame(resolvedLocation);
this.setState({
frame,
isSourceMapped: true,
});
},
/**
* Utility method to convert the Frame object to the
* Source Object model required by SourceMapService
* @param frame
* @returns {{url: *, line: *, column: *}}
*/
getSource(frame) {
frame = frame || this.props.frame;
const { source, line, column } = frame;
return {
url: source,
line,
column,
};
},
/**
* Utility method to convert the Source object model to the
* Frame object model required by FrameView class.
* @param source
* @returns {{source: *, line: *, column: *, functionDisplayName: *}}
*/
getFrame(source) {
const { url, line, column } = source;
return {
source: url,
line,
column,
functionDisplayName: this.props.frame.functionDisplayName,
};
},
render() {
let frame, isSourceMapped;
let {
onClick,
frame,
showFunctionName,
showAnonymousFunctionName,
showHost,
@ -57,6 +120,13 @@ module.exports = createClass({
showFullSourceUrl
} = this.props;
if (this.state && this.state.isSourceMapped) {
frame = this.state.frame;
isSourceMapped = this.state.isSourceMapped;
} else {
frame = this.props.frame;
}
let source = frame.source ? String(frame.source) : "";
let line = frame.line != void 0 ? Number(frame.line) : null;
let column = frame.column != void 0 ? Number(frame.column) : null;
@ -66,17 +136,23 @@ module.exports = createClass({
// has already cached this indirectly. We don't want to attempt to
// link to "self-hosted" and "(unknown)". However, we do want to link
// to Scratchpad URIs.
const isLinkable = !!(isScratchpadScheme(source) || parseURL(source));
// Source mapped sources might not necessary linkable, but they
// are still valid in the debugger.
const isLinkable = !!(isScratchpadScheme(source) || parseURL(source)) || isSourceMapped;
const elements = [];
const sourceElements = [];
let sourceEl;
let tooltip = long;
// If the source is linkable and line > 0
const shouldDisplayLine = isLinkable && line;
// Exclude all falsy values, including `0`, as even
// a number 0 for line doesn't make sense, and should not be displayed.
// If source isn't linkable, don't attempt to append line and column
// info, as this probably doesn't make sense.
if (isLinkable && line) {
if (shouldDisplayLine) {
tooltip += `:${line}`;
// Intentionally exclude 0
if (column) {
@ -104,8 +180,17 @@ module.exports = createClass({
}
let displaySource = showFullSourceUrl ? long : short;
if (showEmptyPathAsHost && (displaySource === "" || displaySource === "/")) {
// SourceMapped locations might not be parsed properly by parseURL.
// Eg: sourcemapped location could be /folder/file.coffee instead of a url
// and so the url parser would not parse non-url locations properly
// Check for "/" in displaySource. If "/" is in displaySource, take everything after last "/".
if (isSourceMapped) {
displaySource = displaySource.lastIndexOf("/") < 0 ?
displaySource :
displaySource.slice(displaySource.lastIndexOf("/") + 1);
} else if (showEmptyPathAsHost && (displaySource === "" || displaySource === "/")) {
displaySource = host;
}
sourceElements.push(dom.span({
@ -113,7 +198,7 @@ module.exports = createClass({
}, displaySource));
// If source is linkable, and we have a line number > 0
if (isLinkable && line) {
if (shouldDisplayLine) {
let lineInfo = `:${line}`;
// Add `data-line` attribute for testing
attributes["data-line"] = line;
@ -134,7 +219,7 @@ module.exports = createClass({
sourceEl = dom.a({
onClick: e => {
e.preventDefault();
onClick(frame);
onClick(this.getSource(frame));
},
href: source,
className: "frame-link-source",

View File

@ -2531,7 +2531,7 @@ WebConsoleFrame.prototype = {
let fullURL = url.split(" -> ").pop();
// Make the location clickable.
let onClick = () => {
let onClick = ({ url, line }) => {
let category = locationNode.closest(".message").category;
let target = null;
@ -2541,10 +2541,14 @@ WebConsoleFrame.prototype = {
target = "styleeditor";
} else if (category === CATEGORY_JS || category === CATEGORY_WEBDEV) {
target = "jsdebugger";
} else if (/\.js$/.test(fullURL)) {
} else if (/\.js$/.test(url)) {
// If it ends in .js, let's attempt to open in debugger
// anyway, as this falls back to normal view-source.
target = "jsdebugger";
} else {
// Point everything else to debugger, if source not available,
// it will fall back to view-source.
target = "jsdebugger";
}
switch (target) {
@ -2552,16 +2556,17 @@ WebConsoleFrame.prototype = {
this.owner.viewSourceInScratchpad(url, line);
return;
case "jsdebugger":
this.owner.viewSourceInDebugger(fullURL, line);
this.owner.viewSourceInDebugger(url, line);
return;
case "styleeditor":
this.owner.viewSourceInStyleEditor(fullURL, line);
this.owner.viewSourceInStyleEditor(url, line);
return;
}
// No matching tool found; use old school view-source
this.owner.viewSource(fullURL, line);
this.owner.viewSource(url, line);
};
const toolbox = gDevTools.getToolbox(this.owner.target);
this.ReactDOM.render(this.FrameView({
frame: {
source: fullURL,
@ -2570,6 +2575,7 @@ WebConsoleFrame.prototype = {
},
showEmptyPathAsHost: true,
onClick,
sourceMapService: toolbox ? toolbox._sourceMapService : null,
}), locationNode);
return locationNode;

View File

@ -245,7 +245,7 @@ TabSources.prototype = {
}
}
throw new Error("getSourceByURL: could not find source for " + url);
throw new Error("getSourceActorByURL: could not find source for " + url);
return null;
},

View File

@ -2091,41 +2091,37 @@ TabActor.prototype = {
onResolveLocation(request) {
let { url, line } = request;
let column = request.column || 0;
let actor = this.sources.getSourceActorByURL(url);
const scripts = this.threadActor.dbg.findScripts({ url });
if (actor) {
// Get the generated source actor if this is source mapped
let generatedActor = actor.generatedSource ?
this.sources.createNonSourceMappedActor(actor.generatedSource) :
actor;
let generatedLocation = new GeneratedLocation(
generatedActor, line, column);
return this.sources.getOriginalLocation(generatedLocation).then(loc => {
// If no map found, return this packet
if (loc.originalLine == null) {
return {
from: this.actorID,
type: "resolveLocation",
error: "MAP_NOT_FOUND"
};
}
loc = loc.toJSON();
return {
from: this.actorID,
url: loc.source.url,
column: loc.column,
line: loc.line
};
if (!scripts[0] || !scripts[0].source) {
return promise.resolve({
from: this.actorID,
type: "resolveLocation",
error: "SOURCE_NOT_FOUND"
});
}
const source = scripts[0].source;
const generatedActor = this.sources.createNonSourceMappedActor(source);
let generatedLocation = new GeneratedLocation(
generatedActor, line, column);
// Fall back to this packet when source is not found
return promise.resolve({
from: this.actorID,
type: "resolveLocation",
error: "SOURCE_NOT_FOUND"
return this.sources.getOriginalLocation(generatedLocation).then(loc => {
// If no map found, return this packet
if (loc.originalLine == null) {
return {
from: this.actorID,
type: "resolveLocation",
error: "MAP_NOT_FOUND"
};
}
loc = loc.toJSON();
return {
from: this.actorID,
url: loc.source.url,
column: loc.column,
line: loc.line
};
});
},
};