Files
react/packages/react-devtools-extensions/src/generateHookMap.js
Juan 88d121899a [DevTools] Support extended source maps with named hooks information (#22010)
## Summary

Adds support for statically extracting names for hook calls from source code, and extending source maps with that information so that DevTools does not have to directly parse source code at runtime, which will speed up the Named Hooks feature and allow it to be enabled by default.

Specifically, this PR includes the following parts:

- [x] Adding logic to statically extract relevant hook names from the parsed source code (i.e. the babel ast). Note that this logic differs slightly from the existing logic in that the existing logic also uses runtime information from DevTools (such as whether given hooks are a custom hook) to extract names for hooks, whereas this code is meant to run entirely at build time, so it does not rely on that information.
- [x] Generating an encoded "hook map", which encodes the information about a hooks *original* source location, and it's corresponding name. This "hook map" will be used to generate extended source maps, included tentatively under an extra `x_react_hook_map` field. The map itself is formatted and encoded in a very similar way as how the `names` and `mappings` fields of a standard source map are encoded ( = Base64 VLQ delta coding representing offsets into a string array), and how the "function map" in Metro is encoded, as suggested in #21782. Note that this initial version uses a very basic format, and we are not implementing our own custom encoding, but reusing the `encode` function from `sourcemap-codec`.
- [x] Updating the logic in `parseHookNames` to check if the source maps have been extended with the hook map information, and if so use that information to extract the hook names without loading the original source code. In this PR we are manually generating extended source maps in our tests in order to test that this functionality works as expected, even though we are not actually generating the extended source maps in production.

The second stage of this work, which will likely need to occur outside this repo, is to update bundlers such as Metro to use these new primitives to actually generate source maps that DevTools can use.

### Follow-ups

- Enable named hooks by default when extended source maps are present
- Support looking up hook names when column numbers are not present in source map.
- Measure performance improvement of using extended source maps (manual testing suggests ~4 to 5x faster)
- Update relevant bundlers to generate extended source maps.

## Test Plan

- yarn flow
- Tests still pass
  - yarn test
  - yarn test-build-devtools
- Named hooks still work on manual test of browser extension on a few different apps (code sandbox, create-react-app, facebook).
- For new functionality:
  - New tests for statically extracting hook names.
  - New tests for using extended source maps to look up hook names at runtime.
2021-08-11 10:46:19 -04:00

124 lines
3.8 KiB
JavaScript

/*
* 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
*/
import {getHookNamesMappingFromAST} from './astUtils';
import {encode, decode} from 'sourcemap-codec';
import {File} from '@babel/types';
export type HookMap = {|
names: $ReadOnlyArray<string>,
mappings: HookMapMappings,
|};
export type EncodedHookMap = {|
names: $ReadOnlyArray<string>,
mappings: string,
|};
// See generateHookMap below for more details on formatting
export type HookMapEntry = [
number, // 1-indexed line number
number, // 0-indexed column number
number, // 0-indexed index into names array
number, // TODO: filler number to support reusing encoding from `sourcemap-codec` (see TODO below)
];
export type HookMapLine = HookMapEntry[];
export type HookMapMappings = HookMapLine[];
/**
* Given a parsed source code AST, returns a "Hook Map", which is a
* mapping which maps locations in the source code to their to their
* corresponding Hook name, if there is a relevant Hook name for that
* location (see getHookNamesMappingFromAST for details on the
* representation of the mapping).
*
* The format of the Hook Map follows a similar format as the `name`
* and `mappings` fields in the Source Map spec, where `names` is an
* array of strings, and `mappings` contains segments lines, columns,
* and indices into the `names` array.
*
* E.g.:
* {
* names: ["<no-hook>", "state"],
* mappings: [
* [ -> line 1
* [1, 0, 0], -> line, col, name index
* ],
* [ -> line 2
* [2, 5, 1], -> line, col, name index
* [2, 15, 0], -> line, col, name index
* ],
* ],
* }
*/
export function generateHookMap(sourceAST: File): HookMap {
const hookNamesMapping = getHookNamesMappingFromAST(sourceAST);
const namesMap: Map<string, number> = new Map();
const names = [];
const mappings = [];
let currentLine = null;
hookNamesMapping.forEach(({name, start}) => {
let nameIndex = namesMap.get(name);
if (nameIndex == null) {
names.push(name);
nameIndex = names.length - 1;
namesMap.set(name, nameIndex);
}
// TODO: We add a -1 at the end of the entry so we can later
// encode/decode the mappings by reusing the encode/decode functions
// from the `sourcemap-codec` library. This library expects segments
// of specific sizes (i.e. of size 4) in order to encode them correctly.
// In the future, when we implement our own encoding, we will not
// need this restriction and can remove the -1 at the end.
const entry = [start.line, start.column, nameIndex, -1];
if (currentLine !== start.line) {
currentLine = start.line;
mappings.push([entry]);
} else {
const current = mappings[mappings.length - 1];
current.push(entry);
}
});
return {names, mappings};
}
/**
* Returns encoded version of a Hook Map that is returned
* by generateHookMap.
*
* **NOTE:**
* TODO: To encode the `mappings` in the Hook Map, we
* reuse the encode function from the `sourcemap-codec`
* library, which means that we are restricted to only
* encoding segments of specific sizes.
* Inside generateHookMap we make sure to build segments
* of size 4.
* In the future, when we implement our own encoding, we will not
* need this restriction and can remove the -1 at the end.
*/
export function generateEncodedHookMap(sourceAST: File): EncodedHookMap {
const hookMap = generateHookMap(sourceAST);
const encoded = encode(hookMap.mappings);
return {
names: hookMap.names,
mappings: encoded,
};
}
export function decodeHookMap(encodedHookMap: EncodedHookMap): HookMap {
return {
names: encodedHookMap.names,
mappings: decode(encodedHookMap.mappings),
};
}