Skip to content

Commit

Permalink
feat: Write source information for React components defined in the pr…
Browse files Browse the repository at this point in the history
…oject (#18470)

* feat: Write source information for React components defined in the project

When transpiling tsx/jsx to ts/js, Babel writes the source information for where components are used. This adds the information about where components are defined.

* Test

* format

* Fix review comments
  • Loading branch information
Artur- committed Jan 24, 2024
1 parent fd9d8c9 commit 1ee4b41
Show file tree
Hide file tree
Showing 8 changed files with 190 additions and 3 deletions.
3 changes: 2 additions & 1 deletion flow-server/src/main/resources/plugins/plugins.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"application-theme-plugin",
"theme-loader",
"theme-live-reload-plugin",
"rollup-plugin-postcss-lit-custom"
"rollup-plugin-postcss-lit-custom",
"react-function-location-plugin"
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "react-function-location-plugin",
"version": "1.0.0",
"description": "A Vite plugin for gather development information about source location of React functions",
"main": "react-function-location-plugin.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "Vaadin Ltd",
"license": "Apache-2.0",
"files": [
"react-function-location-plugin.js"
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import * as t from '@babel/types';

export function addFunctionComponentSourceLocationBabel() {
function isReactFunctionName(name) {
// A React component function always starts with a Capital letter
return name && name.match(/^[A-Z].*/);
}

/**
* Writes debug info as Name.__debugSourceDefine={...} after the given statement ("path").
* This is used to make the source location of the function (defined by the loc parameter) available in the browser in development mode.
* The name __debugSourceDefine is prefixed by __ to mark this is not a public API.
*/
function addDebugInfo(path, name, filename, loc) {
const lineNumber = loc.start.line;
const columnNumber = loc.start.column + 1;
const debugSourceMember = t.memberExpression(t.identifier(name), t.identifier('__debugSourceDefine'));
const debugSourceDefine = t.objectExpression([
t.objectProperty(t.identifier('fileName'), t.stringLiteral(filename)),
t.objectProperty(t.identifier('lineNumber'), t.numericLiteral(lineNumber)),
t.objectProperty(t.identifier('columnNumber'), t.numericLiteral(columnNumber))
]);
const assignment = t.expressionStatement(t.assignmentExpression('=', debugSourceMember, debugSourceDefine));
const condition = t.binaryExpression(
'===',
t.unaryExpression('typeof', t.identifier(name)),
t.stringLiteral('function')
);
const ifFunction = t.ifStatement(condition, t.blockStatement([assignment]));
path.insertAfter(ifFunction);
}

return {
visitor: {
VariableDeclaration(path, state) {
// Finds declarations such as
// const Foo = () => <div/>
// export const Bar = () => <span/>

// and writes a Foo.__debugSourceDefine= {..} after it, referring to the start of the function body
path.node.declarations.forEach((declaration) => {
if (declaration.id.type !== 'Identifier') {
return;
}
const name = declaration?.id?.name;
if (!isReactFunctionName(name)) {
return;
}

const filename = state.file.opts.filename;
if (declaration?.init?.body?.loc) {
addDebugInfo(path, name, filename, declaration.init.body.loc);
}
});
},

FunctionDeclaration(path, state) {
// Finds declarations such as
// functio Foo() { return <div/>; }
// export function Bar() { return <span>Hello</span>;}

// and writes a Foo.__debugSourceDefine= {..} after it, referring to the start of the function body
const node = path.node;
const name = node?.id?.name;
if (!isReactFunctionName(name)) {
return;
}
const filename = state.file.opts.filename;
addDebugInfo(path, name, filename, node.body.loc);
}
}
};
}
5 changes: 4 additions & 1 deletion flow-server/src/main/resources/vite.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import * as net from 'net';

import { processThemeResources } from '#buildFolder#/plugins/application-theme-plugin/theme-handle.js';
import { rewriteCssUrls } from '#buildFolder#/plugins/theme-loader/theme-loader-utils.js';
import { addFunctionComponentSourceLocationBabel } from '#buildFolder#/plugins/react-function-location-plugin/react-function-location-plugin.js';
import settings from '#settingsImport#';
import {
AssetInfo,
Expand Down Expand Up @@ -777,7 +778,9 @@ export const vaadinConfig: UserConfigFn = (env) => {
// We need to use babel to provide the source information for it to be correct
// (otherwise Babel will slightly rewrite the source file and esbuild generate source info for the modified file)
presets: [['@babel/preset-react', { runtime: 'automatic', development: devMode }]],
},
// React writes the source location for where components are used, this writes for where they are defined
plugins: [addFunctionComponentSourceLocationBabel()]
}
}),
{
name: 'vaadin:force-remove-html-middleware',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ public void init() throws IOException {
public void getPluginsReturnsExpectedList() {
String[] expectedPlugins = new String[] { "application-theme-plugin",
"theme-loader", "theme-live-reload-plugin",
"build-status-plugin", "rollup-plugin-postcss-lit-custom" };
"build-status-plugin", "rollup-plugin-postcss-lit-custom",
"react-function-location-plugin" };
final List<String> plugins = FrontendPluginsUtil.getPlugins();
Assert.assertEquals("Unexpected number of plugins in 'plugins.json'",
expectedPlugins.length, plugins.size());
Expand Down
34 changes: 34 additions & 0 deletions flow-tests/test-frontend/vite-basics/frontend/ReactComponents.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
function MyComp() {
return <div data-expected="1_19">A component defined in a function</div>;
}
export function MyCompExport() {
return (
<>
<div data-expected="4_34">A component defined in an exported function</div>
</>
);
}
const MyCompConst = () => <div data-expected="11_29">A component defined in a const</div>;
export const MyCompConstExport = () => <div data-expected="12_42">A component defined in an exported const</div>;

const NotAComp = undefined;

export default function InProjectComponentView() {
function Inner() {
return <div data-expected="17_22">A component defined using a function inside another component</div>;
}
const InnerConst = () => <div data-expected="20_30">A component defined using a const inside another component</div>;

return (
<div data-expected="16_52">
default
<Inner></Inner>
<InnerConst />
<MyComp />
<MyCompExport></MyCompExport>
<MyCompConstExport></MyCompConstExport>
<MyCompConst />
</div>
);
}

10 changes: 10 additions & 0 deletions flow-tests/test-frontend/vite-basics/frontend/routes.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { createBrowserRouter, RouteObject } from 'react-router-dom';
import { serverSideRoutes } from 'Frontend/generated/flow/Flow';
import ReactComponents from './ReactComponents';

export const routes = [
{ path: 'react-components', element: <ReactComponents/> },
...serverSideRoutes
] as RouteObject[];

export default createBrowserRouter(routes);
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.vaadin.viteapp;

import java.util.List;
import java.util.Map;

import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;

import com.vaadin.flow.testutil.ChromeBrowserTest;
import com.vaadin.testbench.TestBenchElement;

public class ReactComponentsIT extends ChromeBrowserTest {

@Before
public void openView() {
getDriver().get(getTestURL());
waitForDevServer();
}

@Override
protected String getTestPath() {
return "/react-components";
}

@Test
public void functionLocationsAvailable() {
List<TestBenchElement> elements = $("*").hasAttribute("data-expected")
.all();
Assert.assertTrue(elements.size() > 5);
for (TestBenchElement element : elements) {
String expected = element.getAttribute("data-expected");
Long line = Long.parseLong(expected.split("_")[0]);
Long column = Long.parseLong(expected.split("_")[1]);
String filenameEnd = "vite-basics/frontend/ReactComponents.tsx";

Map<String, Object> result = (Map<String, Object>) executeScript(
"""
const key = Object.keys(arguments[0]).filter(a => a.startsWith("__reactFiber"))[0];
const fiber = arguments[0][key];
return fiber.return.type.__debugSourceDefine;
""",
element);

Assert.assertTrue(
result.get("fileName").toString().endsWith(filenameEnd));
Assert.assertSame(line, result.get("lineNumber"));
Assert.assertSame(column, result.get("columnNumber"));
}
}
}

0 comments on commit 1ee4b41

Please sign in to comment.