Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[DevTools] Access metadata in source maps correctly accounting for different formats #22096

Merged
merged 8 commits into from
Aug 18, 2021
197 changes: 197 additions & 0 deletions packages/react-devtools-extensions/src/SourceMapMetadataConsumer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
/*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
*/

import type {Position} from './astUtils';
import type {
ReactSourceMetadata,
IndexSourceMap,
BasicSourceMap,
MixedSourceMap,
} from './SourceMapTypes';
import type {HookMap} from './generateHookMap';
import * as util from 'source-map/lib/util';
import {decodeHookMap} from './generateHookMap';
import {getHookNameForLocation} from './getHookNameForLocation';

type MetadataMap = Map<string, ?ReactSourceMetadata>;

const HOOK_MAP_INDEX_IN_REACT_METADATA = 0;
const REACT_METADATA_INDEX_IN_FB_METADATA = 1;
const REACT_SOURCES_EXTENSION_KEY = 'x_react_sources';
const FB_SOURCES_EXTENSION_KEY = 'x_facebook_sources';

/**
* Extracted from the logic in source-map@0.8.0-beta.0's SourceMapConsumer.
* By default, source names are normalized using the same logic that the
* `source-map@0.8.0-beta.0` package uses internally. This is crucial for keeping the
* sources list in sync with a `SourceMapConsumer` instance.
*/
function normalizeSourcePath(
sourceInput: string,
map: {+sourceRoot?: ?string, ...},
): string {
const {sourceRoot} = map;
let source = sourceInput;

// eslint-disable-next-line react-internal/no-primitive-constructors
source = String(source);
return util.computeSourceURL(sourceRoot, source);
}

/**
* Consumes the `x_react_sources` or `x_facebook_sources` metadata field from a
* source map and exposes ways to query the React DevTools specific metadata
* included in those fields.
*/
export class SourceMapMetadataConsumer {
_sourceMap: MixedSourceMap;
_decodedHookMapCache: Map<string, HookMap>;
_metadataBySource: ?MetadataMap;

constructor(sourcemap: MixedSourceMap) {
this._sourceMap = sourcemap;
this._decodedHookMapCache = new Map();
this._metadataBySource = null;
}

/**
* Returns the Hook name assigned to a given location in the source code,
* and a HookMap extracted from an extended source map.
* See `getHookNameForLocation` for more details on implementation.
*
* When used with the `source-map` package, you'll first use
* `SourceMapConsumer#originalPositionFor` to retrieve a source location,
* then pass that location to `hookNameFor`.
*/
hookNameFor({
line,
column,
source,
}: {|
...Position,
+source: ?string,
|}): ?string {
if (source == null) {
return null;
}

const hookMap = this._getHookMapForSource(source);
if (hookMap == null) {
return null;
}

return getHookNameForLocation({line, column}, hookMap);
}

hasHookMap(source: ?string) {
if (source == null) {
return null;
}
return this._getHookMapForSource(source) != null;
}

/**
* Prepares and caches a lookup table of metadata by source name.
*/
_getMetadataBySource(): MetadataMap {
if (this._metadataBySource == null) {
this._metadataBySource = this._getMetadataObjectsBySourceNames(
this._sourceMap,
);
}

return this._metadataBySource;
}

/**
* Collects source metadata from the given map using the current source name
* normalization function. Handles both index maps (with sections) and plain
* maps.
*
* NOTE: If any sources are repeated in the map (which shouldn't usually happen,
* but is technically possible because of index maps) we only keep the
* metadata from the last occurrence of any given source.
*/
_getMetadataObjectsBySourceNames(sourcemap: MixedSourceMap): MetadataMap {
if (sourcemap.mappings === undefined) {
const indexSourceMap: IndexSourceMap = sourcemap;
const metadataMap = new Map();
indexSourceMap.sections.forEach(section => {
const metadataMapForIndexMap = this._getMetadataObjectsBySourceNames(
section.map,
);
metadataMapForIndexMap.forEach((value, key) => {
metadataMap.set(key, value);
});
});
return metadataMap;
}

const metadataMap = new Map();
const basicMap: BasicSourceMap = sourcemap;
const updateMap = (metadata: ReactSourceMetadata, sourceIndex: number) => {
let source = basicMap.sources[sourceIndex];
if (source != null) {
source = normalizeSourcePath(source, basicMap);
metadataMap.set(source, metadata);
}
};

if (
sourcemap.hasOwnProperty(REACT_SOURCES_EXTENSION_KEY) &&
sourcemap[REACT_SOURCES_EXTENSION_KEY] != null
) {
const reactMetadataArray = sourcemap[REACT_SOURCES_EXTENSION_KEY];
reactMetadataArray.filter(Boolean).forEach(updateMap);
} else if (
sourcemap.hasOwnProperty(FB_SOURCES_EXTENSION_KEY) &&
sourcemap[FB_SOURCES_EXTENSION_KEY] != null
) {
const fbMetadataArray = sourcemap[FB_SOURCES_EXTENSION_KEY];
if (fbMetadataArray != null) {
fbMetadataArray.forEach((fbMetadata, sourceIndex) => {
// When extending source maps with React metadata using the
// x_facebook_sources field, the position at index 1 on the
// metadata tuple is reserved for React metadata
const reactMetadata =
fbMetadata != null
? fbMetadata[REACT_METADATA_INDEX_IN_FB_METADATA]
: null;
if (reactMetadata != null) {
updateMap(reactMetadata, sourceIndex);
}
});
}
}

return metadataMap;
}

/**
* Decodes the function name mappings for the given source if needed, and
* retrieves a sorted, searchable array of mappings.
*/
_getHookMapForSource(source: string): ?HookMap {
if (this._decodedHookMapCache.has(source)) {
return this._decodedHookMapCache.get(source);
}
let hookMap = null;
const metadataBySource = this._getMetadataBySource();
const normalized = normalizeSourcePath(source, this._sourceMap);
const metadata = metadataBySource.get(normalized);
if (metadata != null) {
const encodedHookMap = metadata[HOOK_MAP_INDEX_IN_REACT_METADATA];
hookMap = encodedHookMap != null ? decodeHookMap(encodedHookMap) : null;
}
if (hookMap != null) {
this._decodedHookMapCache.set(source, hookMap);
}
return hookMap;
}
}
10 changes: 5 additions & 5 deletions packages/react-devtools-extensions/src/SourceMapTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ export type BasicSourceMap = {|
+x_react_sources?: ReactSourcesArray,
|};

export type IndexMapSection = {
map: IndexMap | BasicSourceMap,
export type IndexSourceMapSection = {
map: IndexSourceMap | BasicSourceMap,
offset: {
line: number,
column: number,
Expand All @@ -37,14 +37,14 @@ export type IndexMapSection = {
...
};

export type IndexMap = {|
export type IndexSourceMap = {|
+file?: string,
+mappings?: void, // avoids SourceMap being a disjoint union
+sourcesContent?: void,
+sections: Array<IndexMapSection>,
+sections: Array<IndexSourceMapSection>,
+version: number,
+x_facebook_sources?: FBSourcesArray,
+x_react_sources?: ReactSourcesArray,
|};

export type MixedSourceMap = IndexMap | BasicSourceMap;
export type MixedSourceMap = IndexSourceMap | BasicSourceMap;
34 changes: 34 additions & 0 deletions packages/react-devtools-extensions/src/SourceMapUtils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
*/

import type {
BasicSourceMap,
MixedSourceMap,
IndexSourceMap,
} from './SourceMapTypes';

export function sourceMapIncludesSource(
sourcemap: MixedSourceMap,
source: ?string,
): boolean {
if (source == null) {
return false;
}
if (sourcemap.mappings === undefined) {
const indexSourceMap: IndexSourceMap = sourcemap;
return indexSourceMap.sections.some(section => {
return sourceMapIncludesSource(section.map, source);
});
}

const basicMap: BasicSourceMap = sourcemap;
return basicMap.sources.some(
s => s === 'Inline Babel script' || source.endsWith(s),
);
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading