Skip to content

Commit cc30d6b

Browse files
committed
perf: avoid babel in dev by using magic string for hook names injection
1 parent f5f067a commit cc30d6b

4 files changed

Lines changed: 180 additions & 17 deletions

File tree

package-lock.json

Lines changed: 21 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,10 @@
4343
"@rollup/pluginutils": "^5.0.0",
4444
"babel-plugin-transform-hook-names": "^1.0.2",
4545
"debug": "^4.4.3",
46+
"magic-string": "^0.30.21",
4647
"picocolors": "^1.1.1",
47-
"vite-prerender-plugin": "^0.5.8"
48+
"vite-prerender-plugin": "^0.5.8",
49+
"zimmerframe": "^1.1.4"
4850
},
4951
"peerDependencies": {
5052
"@babel/core": "7.x",

src/index.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { TransformOptions } from "@babel/core";
55

66
import prefresh from "@prefresh/vite";
77
import { preactDevtoolsPlugin } from "./devtools.js";
8+
import { transformHookNamesPlugin } from "./transform-hook-names.js";
89
import { createFilter, parseId } from "./utils.js";
910
import { vitePrerenderPlugin } from "vite-prerender-plugin";
1011
import { transformAsync } from "@babel/core";
@@ -140,7 +141,7 @@ function preactPlugin({
140141
babelOptions.parserOpts ||= {} as any;
141142
babelOptions.parserOpts.plugins ||= [];
142143

143-
let useBabel = typeof babel !== "undefined";
144+
const useBabel = typeof babel !== "undefined";
144145
const shouldTransform = createFilter(
145146
include || [/\.[cm]?[tj]sx?$/],
146147
exclude || [/node_modules/],
@@ -205,7 +206,6 @@ function preactPlugin({
205206
config = resolvedConfig;
206207
devToolsEnabled =
207208
devToolsEnabled ?? (!config.isProduction || devtoolsInProd);
208-
useBabel ||= !config.isProduction || !!devToolsEnabled;
209209
},
210210
async transform(code, url) {
211211
// Ignore query parameters, as in Vue SFC virtual modules.
@@ -272,7 +272,7 @@ function preactPlugin({
272272
alias: {
273273
"react-dom/test-utils": "preact/test-utils",
274274
"react-dom": "preact/compat",
275-
"react/jsx-runtime": "preact/jsx-runtime",
275+
"react/jsx-runtime": "preact/jsx-runtime",
276276
react: "preact/compat",
277277
},
278278
},
@@ -282,6 +282,15 @@ function preactPlugin({
282282
]
283283
: []),
284284
jsxPlugin,
285+
...(!useBabel
286+
? [
287+
transformHookNamesPlugin({
288+
devtoolsInProd,
289+
devToolsEnabled,
290+
shouldTransform,
291+
}),
292+
]
293+
: []),
285294
preactDevtoolsPlugin({
286295
devtoolsInProd,
287296
devToolsEnabled,

src/transform-hook-names.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { extractAssignedNames } from "@rollup/pluginutils";
2+
import type { Node, Program } from "estree";
3+
import MagicString from "magic-string";
4+
import type { Plugin } from "vite";
5+
import { walk } from "zimmerframe";
6+
7+
import type { RollupFilter } from "./utils.js";
8+
import { parseId } from "./utils.js";
9+
10+
const HOOK_IMPORTS = new Set(["preact/hooks", "preact/compat", "react"]);
11+
const HOOK_NAME_RE = /^(useState|useReducer|useRef|useMemo)$/;
12+
const FILTER_CODE_RE = /\buse(?:State|Reducer|Ref|Memo)\b/;
13+
14+
type NodeWithRange<N extends Node = Node> = N & {
15+
start: number;
16+
end: number;
17+
};
18+
19+
export interface TransformHookNamesPluginOptions {
20+
devtoolsInProd?: boolean;
21+
devToolsEnabled?: boolean;
22+
shouldTransform: RollupFilter;
23+
}
24+
25+
export function transformHookNamesPlugin({
26+
devtoolsInProd,
27+
devToolsEnabled,
28+
shouldTransform,
29+
}: TransformHookNamesPluginOptions): Plugin {
30+
return {
31+
name: "preact:transform-hook-names",
32+
configResolved(config) {
33+
devToolsEnabled =
34+
devToolsEnabled ?? (!config.isProduction || devtoolsInProd);
35+
},
36+
transform(code, url) {
37+
if (!devToolsEnabled) return;
38+
39+
const { id } = parseId(url);
40+
if (!shouldTransform(id)) return;
41+
if (!FILTER_CODE_RE.test(code)) return;
42+
43+
let ast: NodeWithRange<Program>;
44+
try {
45+
ast = this.parse(code) as NodeWithRange<Program>;
46+
} catch {
47+
return;
48+
}
49+
50+
const importedHooks = getImportedHooks(ast);
51+
if (importedHooks.size === 0) return;
52+
53+
const s = new MagicString(code);
54+
let hasHelper = false;
55+
walk<NodeWithRange, null>(ast, null, {
56+
CallExpression(node, { path, next }) {
57+
const callee = node.callee;
58+
if (callee.type !== "Identifier") {
59+
next();
60+
return;
61+
}
62+
63+
const hookName = callee.name;
64+
// the hook name might be shadowed by a local variable,
65+
// but that false-positive is fine
66+
// since `addHookName` is transparent
67+
if (!HOOK_NAME_RE.test(hookName) || !importedHooks.has(hookName)) {
68+
next();
69+
return;
70+
}
71+
72+
const parent = path[path.length - 1];
73+
const bindingNames = getOuterBindingNames(parent);
74+
if (bindingNames.length === 0) {
75+
next();
76+
return;
77+
}
78+
79+
let name = bindingNames[0];
80+
hasHelper = true;
81+
s.prependLeft(node.start, "addHookName(");
82+
s.appendRight(node.end, `, ${JSON.stringify(name)})`);
83+
next();
84+
},
85+
});
86+
if (!hasHelper) return;
87+
88+
const firstNode = ast.body[0] as NodeWithRange;
89+
s.prependLeft(
90+
firstNode.start,
91+
'import { addHookName } from "preact/devtools";\n',
92+
);
93+
94+
return {
95+
code: s.toString(),
96+
map: s.generateMap({ hires: "boundary" }),
97+
};
98+
},
99+
};
100+
}
101+
102+
function getImportedHooks(ast: NodeWithRange<Program>): Set<string> {
103+
const hooks = new Set<string>();
104+
for (const node of ast.body) {
105+
if (node.type !== "ImportDeclaration") continue;
106+
107+
const source =
108+
typeof node.source.value === "string" ? node.source.value : null;
109+
if (!source || !HOOK_IMPORTS.has(source)) continue;
110+
111+
for (const specifier of node.specifiers) {
112+
if (specifier.type !== "ImportSpecifier") continue;
113+
114+
const importedName = specifier.imported.name;
115+
if (
116+
!HOOK_NAME_RE.test(importedName) ||
117+
specifier.local.name !== importedName
118+
)
119+
continue;
120+
121+
hooks.add(importedName);
122+
}
123+
}
124+
return hooks;
125+
}
126+
127+
function getOuterBindingNames(node: Node | undefined): string[] {
128+
if (!node) return [];
129+
130+
switch (node.type) {
131+
case "VariableDeclarator":
132+
return extractAssignedNames(node.id);
133+
case "AssignmentExpression":
134+
return extractAssignedNames(node.left);
135+
case "Identifier":
136+
case "ArrayPattern":
137+
case "ObjectPattern":
138+
case "RestElement":
139+
case "AssignmentPattern":
140+
return extractAssignedNames(node);
141+
default:
142+
return [];
143+
}
144+
}

0 commit comments

Comments
 (0)