Skip to content

Commit

Permalink
[DevTools] Access metadata in source maps correctly accounting for di…
Browse files Browse the repository at this point in the history
…fferent formats (#22096)

## Summary

Follow up from #22010.

The initial implementation of named hooks and for looking up hook name metadata in an extended source map both assumed that the source maps would always have a `sources` field available, and didn't account for the source maps in the [Index Map](https://sourcemaps.info/spec.html#h.535es3xeprgt) format, which contain a list of `sections` and don't have the `source` field available directly. 

In order to properly access metadata in extended source maps, this commit:

-  Adds a new `SourceMapMetadataConsumer` api, which is a fork / very similar in structure to the corresponding [consumer in Metro](https://github.com/facebook/metro/blob/2b44ec39b4bca93e3e1cf1f268b4be66f894924a/packages/metro-symbolicate/src/SourceMetadataMapConsumer.js#L56) (as specified by @motiz88 in #21782.
- Updates `parseHookNames` to use this new api

## Test Plan

- yarn flow
- yarn test
- yarn test-build-devtools
- added new regression tests covering the index map format
- named hooks still work on manual test of browser extension on a few different apps (code sandbox, create-react-app, internally).
  • Loading branch information
Juan authored Aug 18, 2021
1 parent f51579f commit 6abda7f
Show file tree
Hide file tree
Showing 105 changed files with 3,505 additions and 160 deletions.
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

0 comments on commit 6abda7f

Please sign in to comment.