Repository: microsoft/griffel
Pull request #142 (2022-06-20 07:48:02)

feat(devtools): jump to sourcemap
Add ability to jump to source for griffel devtools:
![20YtOgbDZL](https://user-images.githubusercontent.com/28751745/174550308-bfc2d8a2-5a8f-403a-a7cf-3e22747e2e05.gif)

Source URL came from error stack.

TODO:
parse URL from error stack to get to original source
  - I've tried the parser in react-render-tracker. It has some limitations when used in this case:
     - it parses only base64 sourcemap (which is enough for TMP), we need to add ability to read extermal sourcemap
     - when using webpack, the parsed url from base64 sourcemap does not contain [namespace](https://webpack.js.org/configuration/output/#outputdevtoolmodulefilenametemplate) for some reason :/
     - it uses global `webpackChunk_<xxx>` object to resolve modules map. This object is in the host page's context, so some extra work is required to get this object from host using content-script.


github-actions[bot]: ## 📊 Bundle size report

<details>
<summary>Unchanged fixtures</summary>

| Package & Exports                                                                                                            |        Size (minified/GZIP) |
| ---------------------------------------------------------------------------------------------------------------------------- | --------------------------: |
| <samp>core</samp> <br /> <abbr title='bundle-size/makeStyles.fixture.js'>makeStyles + mergeClasses (build time)</abbr>       |       `1.8 kB`<br />`861 B` |
| <samp>core</samp> <br /> <abbr title='bundle-size/makeStylesRuntime.fixture.js'>makeStyles + mergeClasses (runtime)</abbr>   | `20.269 kB`<br />`7.549 kB` |
| <samp>react</samp> <br /> <abbr title='bundle-size/makeStaticStyles-runtime.fixture.js'>makeStaticStyles (runtime)</abbr>    |  `8.533 kB`<br />`3.706 kB` |
| <samp>react</samp> <br /> <abbr title='bundle-size/makeStyles-runtime.fixture.js'>makeStyles + mergeClasses (runtime)</abbr> | `21.586 kB`<br />`8.082 kB` |
| <samp>react</samp> <br /> <abbr title='bundle-size/makeStyles.fixture.js'>makeStyles + mergeClasses (build time)</abbr>      |  `3.095 kB`<br />`1.394 kB` |

</details>
<sub>🤖 This report was generated against <a href='https://github.com/microsoft/griffel/commit/4e4c7c29deae40ae64bd3b6860356d0adeb05e8e'>4e4c7c29deae40ae64bd3b6860356d0adeb05e8e</a></sub>

<!-- Sticky Pull Request Comment -->

Patch:
diff --git a/change/@griffel-core-1e5e67a5-96c2-4f20-9d02-feb94abc63ae.json b/change/@griffel-core-1e5e67a5-96c2-4f20-9d02-feb94abc63ae.json
new file mode 100644
index 0000000..8e18e22
--- /dev/null
+++ b/change/@griffel-core-1e5e67a5-96c2-4f20-9d02-feb94abc63ae.json
@@ -0,0 +1,7 @@
+{
+  "type": "patch",
+  "comment": "feat: Add support for source maps in devtools",
+  "packageName": "@griffel/core",
+  "email": "yuanboxue@microsoft.com",
+  "dependentChangeType": "none"
+}
diff --git a/change/@griffel-devtools-e92c23ad-de61-4826-86af-fb58c3083ae2.json b/change/@griffel-devtools-e92c23ad-de61-4826-86af-fb58c3083ae2.json
new file mode 100644
index 0000000..e1f2a03
--- /dev/null
+++ b/change/@griffel-devtools-e92c23ad-de61-4826-86af-fb58c3083ae2.json
@@ -0,0 +1,7 @@
+{
+  "type": "minor",
+  "comment": "feat: Add button next to slot name to jump to source",
+  "packageName": "@griffel/devtools",
+  "email": "yuanboxue@microsoft.com",
+  "dependentChangeType": "patch"
+}
diff --git a/packages/core/src/__styles.ts b/packages/core/src/__styles.ts
index 0c3b429..3e2b635 100644
--- a/packages/core/src/__styles.ts
+++ b/packages/core/src/__styles.ts
@@ -1,4 +1,4 @@
-import { debugData, isDevToolsEnabled } from './devtools';
+import { debugData, isDevToolsEnabled, getSourceURLfromError } from './devtools';
 import { reduceToClassNameForSlots } from './runtime/reduceToClassNameForSlots';
 import { MakeStylesOptions, CSSClassesMapBySlot, CSSRulesByBucket } from './types';
 
@@ -16,6 +16,11 @@ export function __styles<Slots extends string>(
   let ltrClassNamesForSlots: Record<Slots, string> | null = null;
   let rtlClassNamesForSlots: Record<Slots, string> | null = null;
 
+  let sourceURL: string | undefined;
+  if (process.env.NODE_ENV !== 'production' && isDevToolsEnabled) {
+    sourceURL = getSourceURLfromError();
+  }
+
   function computeClasses(options: Pick<MakeStylesOptions, 'dir' | 'renderer'>): Record<Slots, string> {
     const { dir, renderer } = options;
 
@@ -43,7 +48,7 @@ export function __styles<Slots extends string>(
       : (rtlClassNamesForSlots as Record<Slots, string>);
 
     if (process.env.NODE_ENV !== 'production' && isDevToolsEnabled) {
-      debugData.addSequenceDetails(classNamesForSlots!);
+      debugData.addSequenceDetails(classNamesForSlots!, sourceURL);
     }
 
     return classNamesForSlots;
diff --git a/packages/core/src/devtools/getDebugTree.ts b/packages/core/src/devtools/getDebugTree.ts
index c83f185..1460c92 100644
--- a/packages/core/src/devtools/getDebugTree.ts
+++ b/packages/core/src/devtools/getDebugTree.ts
@@ -35,6 +35,7 @@ export function getDebugTree(debugSequenceHash: SequenceHash, parentNode?: Debug
       const mapData = debugData.getSequenceDetails(debugSequenceHash);
       if (mapData) {
         node.slot = mapData.slotName;
+        node.sourceURL = mapData.sourceURL;
       }
 
       const cssRule = debugData.getCSSRules().find(cssRule => {
diff --git a/packages/core/src/devtools/getSourceURLfromError.ts b/packages/core/src/devtools/getSourceURLfromError.ts
new file mode 100644
index 0000000..4cfce3d
--- /dev/null
+++ b/packages/core/src/devtools/getSourceURLfromError.ts
@@ -0,0 +1,26 @@
+import { parseStackTraceLine } from './react-render-tracker/stackTrace';
+
+export function getSourceURLfromError() {
+  const stacks = String(new Error().stack).split('\n');
+  const userMakeStyleCallLine = findUserMakeStyleCallInStacks(stacks);
+  if (userMakeStyleCallLine === undefined) {
+    return undefined;
+  }
+
+  const result = parseStackTraceLine(userMakeStyleCallLine);
+  return result?.loc as string | undefined;
+}
+
+function findUserMakeStyleCallInStacks(stacks: string[]) {
+  for (let i = stacks.length - 1; i >= 0; --i) {
+    if (stacks[i].includes('at getSourceURLfromError')) {
+      // The error stacks look like:
+      //   getSourceURLfromError
+      //   makeStyles/__styles in griffel core
+      //   makeStyles/__styles in griffel react
+      //   user makeStyles call
+      return stacks[i + 3];
+    }
+  }
+  return undefined;
+}
diff --git a/packages/core/src/devtools/index.ts b/packages/core/src/devtools/index.ts
index 4a25916..0846c82 100644
--- a/packages/core/src/devtools/index.ts
+++ b/packages/core/src/devtools/index.ts
@@ -1,5 +1,6 @@
 export { injectDevTools } from './injectDevTools';
 export { isDevToolsEnabled } from './isDevToolsEnabled';
 export { debugData } from './store';
+export { getSourceURLfromError } from './getSourceURLfromError';
 
 export type { DebugCSSRules, DebugSequence, DebugResult } from './types';
diff --git a/packages/core/src/devtools/react-render-tracker/stackTrace.ts b/packages/core/src/devtools/react-render-tracker/stackTrace.ts
new file mode 100644
index 0000000..46d58de
--- /dev/null
+++ b/packages/core/src/devtools/react-render-tracker/stackTrace.ts
@@ -0,0 +1,86 @@
+// TODO: duplicated from https://github.com/lahmatiy/react-render-tracker/blob/main/src/publisher/react-integration/utils/stackTrace.ts
+// once it is published as a standalone npm package, remove this file
+
+// Adopted version of StackTrace-Parser
+// https://github.com/errwischt/stacktrace-parser/blob/master/src/stack-trace-parser.js
+
+const UNKNOWN_FUNCTION = '<unknown>';
+
+type LineParseResult = null | {
+  name: string;
+  loc: string | null;
+};
+
+export function parseStackTraceLine(line: string): LineParseResult {
+  return parseChrome(line) || parseGecko(line) || parseJSC(line);
+}
+
+const chromeRe =
+  /^\s*at (.*?) ?\(((?:file|https?|blob|chrome-extension|native|eval|webpack|<anonymous>|\/|[a-z]:\\|\\\\).*?)?\)?\s*$/i;
+const chromeRe2 =
+  /^\s*at ()((?:file|https?|blob|chrome-extension|native|eval|webpack|<anonymous>|\/|[a-z]:\\|\\\\).*?)\s*$/i;
+const chromeEvalRe = /\((\S*)\)/;
+
+function parseChrome(line: string): LineParseResult {
+  const parts = chromeRe.exec(line) || chromeRe2.exec(line);
+
+  if (!parts) {
+    return null;
+  }
+
+  let loc = parts[2];
+  const isNative = loc && loc.indexOf('native') === 0; // start of line
+  const isEval = loc && loc.indexOf('eval') === 0; // start of line
+
+  const submatch = chromeEvalRe.exec(loc);
+  if (isEval && submatch != null) {
+    // throw out eval line/column and use top-most line/column number
+    loc = submatch[1]; // url
+  }
+
+  return {
+    loc: !isNative ? parts[2] : null,
+    name: parts[1] || UNKNOWN_FUNCTION,
+  };
+}
+
+const geckoRe =
+  /^\s*(.*?)(?:\((.*?)\))?(?:^|@)((?:file|https?|blob|chrome|webpack|resource|\[native).*?|[^@]*bundle)\s*$/i;
+const geckoEvalRe = /(\S+) line (\d+)(?: > eval line \d+)* > eval/i;
+
+function parseGecko(line: string): LineParseResult {
+  const parts = geckoRe.exec(line);
+
+  if (!parts) {
+    return null;
+  }
+
+  let loc = parts[3];
+  const isEval = loc && loc.indexOf(' > eval') > -1;
+
+  const submatch = geckoEvalRe.exec(loc);
+  if (isEval && submatch != null) {
+    // throw out eval line/column and use top-most line number
+    loc = submatch[1];
+  }
+
+  return {
+    loc: parts[3],
+    name: parts[1] || UNKNOWN_FUNCTION,
+  };
+}
+
+const javaScriptCoreRe = /^\s*(?:([^@]*)(?:\((.*?)\))?@)?(\S.*?)\s*$/i;
+
+function parseJSC(line: string): LineParseResult {
+  const parts = javaScriptCoreRe.exec(line);
+
+  if (!parts) {
+    return null;
+  }
+
+  return {
+    loc: parts[3],
+    name: parts[1] || UNKNOWN_FUNCTION,
+  };
+}
diff --git a/packages/core/src/devtools/store.ts b/packages/core/src/devtools/store.ts
index 3f629bf..a6fed81 100644
--- a/packages/core/src/devtools/store.ts
+++ b/packages/core/src/devtools/store.ts
@@ -1,8 +1,8 @@
-import { SequenceHash } from '../types';
+import type { SequenceHash } from '../types';
 import { SEQUENCE_PREFIX, SEQUENCE_SIZE } from '../constants';
 import { mergeClassesCachedResults } from '../mergeClasses';
 
-const sequenceDetails: Record<SequenceHash, { slotName: string }> = {};
+const sequenceDetails: Record<SequenceHash, { slotName: string; sourceURL?: string }> = {};
 
 const cssRules: string[] = [];
 
@@ -26,9 +26,12 @@ export const debugData = {
   addCSSRule: (rule: string) => {
     cssRules.push(rule);
   },
-  addSequenceDetails: <Slots extends string | number>(classNamesForSlots: Record<Slots, string>) => {
+  addSequenceDetails: <Slots extends string | number>(
+    classNamesForSlots: Record<Slots, string>,
+    sourceURL?: string,
+  ) => {
     Object.entries<string>(classNamesForSlots).forEach(([slotName, sequenceHash]) => {
-      sequenceDetails[sequenceHash.substring(0, SEQUENCE_SIZE)] = { slotName };
+      sequenceDetails[sequenceHash.substring(0, SEQUENCE_SIZE)] = { slotName, sourceURL };
     });
   },
 
diff --git a/packages/core/src/devtools/types.ts b/packages/core/src/devtools/types.ts
index f9e6a23..738c9de 100644
--- a/packages/core/src/devtools/types.ts
+++ b/packages/core/src/devtools/types.ts
@@ -10,6 +10,7 @@ declare global {
 
 export type DebugAtomicClassName = { className: string; overriddenBy?: string };
 export type DebugCSSRules = Record<string /* className */, string /* cssRule */>;
+
 export type DebugSequence = {
   sequenceHash: SequenceHash;
   direction: 'ltr' | 'rtl';
@@ -18,6 +19,8 @@ export type DebugSequence = {
 
   slot?: string;
   rules?: DebugCSSRules;
+
+  sourceURL?: string;
 };
 
 export type DebugResult = DebugSequence;
diff --git a/packages/core/src/makeStyles.ts b/packages/core/src/makeStyles.ts
index 19ce1b9..d7251c8 100644
--- a/packages/core/src/makeStyles.ts
+++ b/packages/core/src/makeStyles.ts
@@ -1,4 +1,4 @@
-import { debugData, isDevToolsEnabled } from './devtools';
+import { debugData, isDevToolsEnabled, getSourceURLfromError } from './devtools';
 import { resolveStyleRulesForSlots } from './resolveStyleRulesForSlots';
 import { reduceToClassNameForSlots } from './runtime/reduceToClassNameForSlots';
 import { CSSClassesMapBySlot, CSSRulesByBucket, MakeStylesOptions, StylesBySlots } from './types';
@@ -12,6 +12,11 @@ export function makeStyles<Slots extends string | number>(stylesBySlots: StylesB
   let ltrClassNamesForSlots: Record<Slots, string> | null = null;
   let rtlClassNamesForSlots: Record<Slots, string> | null = null;
 
+  let sourceURL: string | undefined;
+  if (process.env.NODE_ENV !== 'production' && isDevToolsEnabled) {
+    sourceURL = getSourceURLfromError();
+  }
+
   function computeClasses(options: MakeStylesOptions): Record<Slots, string> {
     const { dir, renderer } = options;
 
@@ -42,7 +47,7 @@ export function makeStyles<Slots extends string | number>(stylesBySlots: StylesB
       : (rtlClassNamesForSlots as Record<Slots, string>);
 
     if (process.env.NODE_ENV !== 'production' && isDevToolsEnabled) {
-      debugData.addSequenceDetails(classNamesForSlots!);
+      debugData.addSequenceDetails(classNamesForSlots!, sourceURL);
     }
 
     return classNamesForSlots;
diff --git a/packages/devtools/src/FlattenView.tsx b/packages/devtools/src/FlattenView.tsx
index 278abff..6c08c56 100644
--- a/packages/devtools/src/FlattenView.tsx
+++ b/packages/devtools/src/FlattenView.tsx
@@ -98,9 +98,9 @@ export const FlattenView: React.FC<FlattenViewProps> = props => {
       </div>
       <div className={classes.rules}>
         <ViewContext.Provider value={contextValue}>
-          {filteredSlots.map(({ slot, rules }) => {
+          {filteredSlots.map(({ slot, rules, sourceURL }) => {
             const key = slot + rules.map(rule => rule.cssRule).join('');
-            return <SlotCSSRules key={key} slot={slot} atomicRules={rules} />;
+            return <SlotCSSRules key={key} slot={slot} atomicRules={rules} sourceURL={sourceURL} />;
           })}
         </ViewContext.Provider>
       </div>
diff --git a/packages/devtools/src/SlotCSSRules.tsx b/packages/devtools/src/SlotCSSRules.tsx
index 3c23f50..96eb22a 100644
--- a/packages/devtools/src/SlotCSSRules.tsx
+++ b/packages/devtools/src/SlotCSSRules.tsx
@@ -39,12 +39,23 @@ const useStyles = makeStyles({
       ...shorthands.borderRight('3px', 'solid', tokens.foreground),
     },
   },
+  toSourceCode: {
+    cursor: 'help',
+    textDecorationLine: 'underline',
+    textDecorationStyle: 'dotted',
+    paddingLeft: '10px',
+    paddingRight: '10px',
+  },
   rules: {
     ...shorthands.padding(0, '5px'),
   },
 });
 
-export const SlotCSSRules: React.FC<{ slot: string; atomicRules: AtomicRules[] }> = ({ slot, atomicRules }) => {
+export const SlotCSSRules: React.FC<{ slot: string; atomicRules: AtomicRules[]; sourceURL?: string }> = ({
+  slot,
+  atomicRules,
+  sourceURL,
+}) => {
   const rules = React.useMemo(() => getMonolithicCSSRules(atomicRules), [atomicRules]);
 
   const [expanded, setExpanded] = React.useState(true);
@@ -56,10 +67,22 @@ export const SlotCSSRules: React.FC<{ slot: string; atomicRules: AtomicRules[] }
   const { setHighlightedClass } = useViewContext();
   const undoHighlight = () => setHighlightedClass('');
 
+  const jumpToSourceHandler = sourceURL
+    ? (e: React.SyntheticEvent) => {
+        e.stopPropagation();
+        openOriginalCode(sourceURL);
+      }
+    : undefined;
+
   return (
     <div>
       <pre className={slotClassName} onClick={toggleExpanded}>
         {slot}
+        {jumpToSourceHandler && (
+          <div className={classes.toSourceCode} onClick={jumpToSourceHandler} title={`inspect source`}>
+            {`</>`}
+          </div>
+        )}
       </pre>
       {expanded && (
         <div className={classes.rules} onClick={undoHighlight}>
@@ -69,3 +92,13 @@ export const SlotCSSRules: React.FC<{ slot: string; atomicRules: AtomicRules[] }
     </div>
   );
 };
+
+function openOriginalCode(sourceURL: string) {
+  chrome.devtools.inspectedWindow.eval<string>('window.location.origin', {}, async () => {
+    // example sourceURL: https://source.js:<lineNumber>:<columnNumber>
+    const results = sourceURL.split(':');
+    results.pop();
+    const line = Number(results.pop()) ?? 1;
+    chrome.devtools.panels.openResource(results.join(':'), line - 1, () => ({}));
+  });
+}
diff --git a/packages/devtools/src/types.ts b/packages/devtools/src/types.ts
index 80b4804..a281d34 100644
--- a/packages/devtools/src/types.ts
+++ b/packages/devtools/src/types.ts
@@ -13,7 +13,7 @@ export type ColorTokens = {
   cssSelector: string;
 };
 
-export type SlotInfo = { slot: string; rules: AtomicRules[] };
+export type SlotInfo = { slot: string; rules: AtomicRules[]; sourceURL?: string };
 
 export type AtomicRules = { cssRule: string; overriddenBy?: string };
 
diff --git a/packages/devtools/src/utils.ts b/packages/devtools/src/utils.ts
index 59027af..c6b826b 100644
--- a/packages/devtools/src/utils.ts
+++ b/packages/devtools/src/utils.ts
@@ -3,12 +3,13 @@ import { SlotInfo } from './types';
 
 export function getRulesBySlots(node: DebugResult, result: SlotInfo[] = []): SlotInfo[] {
   if (node.children.length === 0 && node.slot) {
-    const { debugClassNames, rules, slot } = node;
+    const { debugClassNames, rules, slot, sourceURL } = node;
 
     return [
       ...result,
       {
         slot,
+        sourceURL,
         rules: debugClassNames.map(({ className, overriddenBy }) => {
           return {
             cssRule: rules![className],
@@ -29,11 +30,11 @@ export function filterSlots(slots: SlotInfo[], searchTerm: string) {
     return slots;
   }
 
-  return slots.reduce<SlotInfo[]>((acc, { slot, rules }) => {
+  return slots.reduce<SlotInfo[]>((acc, { slot, rules, sourceURL }) => {
     const filteredRules = rules.filter(rule => rule.cssRule.includes(searchTerm));
 
     if (filteredRules.length) {
-      return [...acc, { slot, rules: filteredRules }];
+      return [...acc, { slot, rules: filteredRules, sourceURL }];
     }
 
     return acc;


Test patch:
diff --git a/packages/core/src/devtools/getDebugTree.test.ts b/packages/core/src/devtools/getDebugTree.test.ts
index aebd6bf..38df61d 100644
--- a/packages/core/src/devtools/getDebugTree.test.ts
+++ b/packages/core/src/devtools/getDebugTree.test.ts
@@ -17,6 +17,8 @@ const options: MakeStylesOptions = {
 const findSequenceHash = (classNames: string) =>
   classNames.split(' ').find(className => className.startsWith(SEQUENCE_PREFIX));
 
+const souceURLregex = /.*\/.*:[0-9]+:[0-9]+/; // url with line and column number
+
 describe('getDebugTree', () => {
   it.each<'ltr' | 'rtl'>(['ltr', 'rtl'])('returns styles merge tree when dir=%p', dir => {
     const classes = makeStyles({
@@ -51,6 +53,7 @@ describe('getDebugTree', () => {
               },
               sequenceHash: sequenceBlock,
               slot: 'block',
+              sourceURL: expect.stringMatching(souceURLregex),
             },
           ],
           debugClassNames: [
@@ -76,6 +79,7 @@ describe('getDebugTree', () => {
           },
           sequenceHash: sequenceGrid,
           slot: 'grid',
+          sourceURL: expect.stringMatching(souceURLregex),
         },
       ],
       debugClassNames: [
diff --git a/packages/core/src/devtools/store.test.ts b/packages/core/src/devtools/store.test.ts
index 8057fa7..503f23b 100644
--- a/packages/core/src/devtools/store.test.ts
+++ b/packages/core/src/devtools/store.test.ts
@@ -60,7 +60,14 @@ describe('debugData', () => {
     const sequenceBlock = findSequenceHash(classes.block);
     const sequenceGrid = findSequenceHash(classes.grid);
 
-    expect(debugData.getSequenceDetails(sequenceBlock!)).toEqual({ slotName: 'block' });
-    expect(debugData.getSequenceDetails(sequenceGrid!)).toEqual({ slotName: 'grid' });
+    const souceURLregex = /.*\/.*:[0-9]+:[0-9]+/; // url with line and column number
+    expect(debugData.getSequenceDetails(sequenceBlock!)).toEqual({
+      slotName: 'block',
+      sourceURL: expect.stringMatching(souceURLregex),
+    });
+    expect(debugData.getSequenceDetails(sequenceGrid!)).toEqual({
+      slotName: 'grid',
+      sourceURL: expect.stringMatching(souceURLregex),
+    });
   });
 });