Skip to content

Commit 960b09d

Browse files
allow jsx alternatives (#691)
1 parent de49757 commit 960b09d

File tree

4 files changed

+474
-0
lines changed

4 files changed

+474
-0
lines changed

.changeset/pretty-chicken-fail.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@preact/signals-react-transform": patch
3+
---
4+
5+
Adds `detectTransformedJSX` option which will trigger transformation when alternative methods (like `React.createElement`) are used to create elements

packages/react-transform/README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,25 @@ module.exports = {
131131
};
132132
```
133133

134+
### `detectTransformedJSX`
135+
136+
When enabled, alternative methods like `React.createElement` and `jsx-runtime` will also trigger transformation.
137+
This can be especially useful when npm depencendies are being transpiled as those generally don't contain JSX since they have alredy been transpiled to JavaScript.
138+
139+
```js
140+
// babel.config.js
141+
module.exports = {
142+
plugins: [
143+
[
144+
"@preact/signals-react-transform",
145+
{
146+
detectTransformedJSX: tue,
147+
},
148+
],
149+
],
150+
};
151+
```
152+
134153
## Logging
135154

136155
This plugin uses the [`debug`](https://www.npmjs.com/package/debug) package to log information about what it's doing. To enable logging, set the `DEBUG` environment variable to `signals:react-transform:*`.

packages/react-transform/src/index.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ const getHookIdentifier = "getHookIdentifier";
2424
const maybeUsesSignal = "maybeUsesSignal";
2525
const containsJSX = "containsJSX";
2626
const alreadyTransformed = "alreadyTransformed";
27+
const jsxIdentifiers = "jsxIdentifiers";
28+
const jsxObjects = "jsxObjects";
2729

2830
const UNMANAGED = "0";
2931
const MANAGED_COMPONENT = "1";
@@ -330,6 +332,35 @@ function isValueMemberExpression(
330332
);
331333
}
332334

335+
function isJSXAlternativeCall(
336+
path: NodePath<BabelTypes.CallExpression>,
337+
state: PluginPass
338+
): boolean {
339+
const jsxIdentifierSet = get(state, jsxIdentifiers) as Set<string>;
340+
const jsxObjectMap = get(state, jsxObjects) as Map<string, string[]>;
341+
const callee = path.get("callee");
342+
343+
// Check direct function calls like _jsx("div", props) or createElement("div", props)
344+
if (callee.isIdentifier()) {
345+
return jsxIdentifierSet?.has(callee.node.name) ?? false;
346+
}
347+
348+
// Check member expression calls like React.createElement("div", props) or jsxRuntime.jsx("div", props)
349+
if (callee.isMemberExpression()) {
350+
const object = callee.get("object");
351+
const property = callee.get("property");
352+
353+
if (object.isIdentifier() && property.isIdentifier()) {
354+
const objectName = object.node.name;
355+
const methodName = property.node.name;
356+
const allowedMethods = jsxObjectMap?.get(objectName);
357+
return allowedMethods?.includes(methodName) ?? false;
358+
}
359+
}
360+
361+
return false;
362+
}
363+
333364
const tryCatchTemplate = template.statements`var STORE_IDENTIFIER = HOOK_IDENTIFIER(HOOK_USAGE);
334365
try {
335366
BODY
@@ -465,6 +496,88 @@ function createImportLazily(
465496
};
466497
}
467498

499+
function detectJSXAlternativeImports(
500+
path: NodePath<BabelTypes.Program>,
501+
state: PluginPass
502+
) {
503+
const jsxIdentifierSet = new Set<string>();
504+
const jsxObjectMap = new Map<string, string[]>();
505+
506+
const jsxPackages = {
507+
"react/jsx-runtime": ["jsx", "jsxs"],
508+
"react/jsx-dev-runtime": ["jsxDEV"],
509+
react: ["createElement"],
510+
};
511+
512+
path.traverse({
513+
ImportDeclaration(importPath) {
514+
const packageName = importPath.node.source.value;
515+
const jsxMethods = jsxPackages[packageName as keyof typeof jsxPackages];
516+
517+
if (!jsxMethods) {
518+
return;
519+
}
520+
521+
for (const specifier of importPath.node.specifiers) {
522+
if (
523+
specifier.type === "ImportSpecifier" &&
524+
specifier.imported.type === "Identifier"
525+
) {
526+
// Check if this is a function we care about
527+
if (jsxMethods.includes(specifier.imported.name)) {
528+
jsxIdentifierSet.add(specifier.local.name);
529+
}
530+
} else if (specifier.type === "ImportDefaultSpecifier") {
531+
// Handle default imports - add to objects map for member access
532+
jsxObjectMap.set(specifier.local.name, jsxMethods);
533+
}
534+
}
535+
},
536+
VariableDeclarator(varPath) {
537+
const init = varPath.get("init");
538+
539+
if (init.isCallExpression()) {
540+
const callee = init.get("callee");
541+
const args = init.get("arguments");
542+
543+
if (
544+
callee.isIdentifier() &&
545+
callee.node.type === "Identifier" &&
546+
callee.node.name === "require" &&
547+
args.length > 0 &&
548+
args[0].isStringLiteral()
549+
) {
550+
const packageName = args[0].node.value;
551+
const jsxMethods =
552+
jsxPackages[packageName as keyof typeof jsxPackages];
553+
554+
if (jsxMethods) {
555+
if (varPath.node.id.type === "Identifier") {
556+
// Handle CJS require like: const React = require("react")
557+
jsxObjectMap.set(varPath.node.id.name, jsxMethods);
558+
} else if (varPath.node.id.type === "ObjectPattern") {
559+
// Handle destructured CJS require like: const { createElement } = require("react")
560+
for (const prop of varPath.node.id.properties) {
561+
if (
562+
prop.type === "ObjectProperty" &&
563+
prop.key.type === "Identifier" &&
564+
prop.value.type === "Identifier" &&
565+
jsxMethods.includes(prop.key.name)
566+
) {
567+
jsxIdentifierSet.add(prop.value.name);
568+
}
569+
}
570+
}
571+
}
572+
}
573+
}
574+
},
575+
});
576+
577+
set(state, jsxIdentifiers, jsxIdentifierSet);
578+
set(state, jsxObjects, jsxObjectMap);
579+
}
580+
468581
export interface PluginOptions {
469582
/**
470583
* Specify the mode to use:
@@ -475,6 +588,12 @@ export interface PluginOptions {
475588
mode?: "auto" | "manual" | "all";
476589
/** Specify a custom package to import the `useSignals` hook from. */
477590
importSource?: string;
591+
/**
592+
* Detect JSX elements created using alternative methods like jsx-runtime or createElement calls.
593+
* When enabled, detects patterns from react/jsx-runtime and react packages.
594+
* @default false
595+
*/
596+
detectTransformedJSX?: boolean;
478597
experimental?: {
479598
/**
480599
* If set to true, the component body will not be wrapped in a try/finally
@@ -569,6 +688,10 @@ export default function signalsTransform(
569688
options.importSource ?? defaultImportSource
570689
)
571690
);
691+
692+
if (options.detectTransformedJSX) {
693+
detectJSXAlternativeImports(path, state);
694+
}
572695
},
573696
},
574697

@@ -577,6 +700,14 @@ export default function signalsTransform(
577700
FunctionDeclaration: visitFunction,
578701
ObjectMethod: visitFunction,
579702

703+
CallExpression(path, state) {
704+
if (options.detectTransformedJSX) {
705+
if (isJSXAlternativeCall(path, state)) {
706+
setOnFunctionScope(path, containsJSX, true, this.filename);
707+
}
708+
}
709+
},
710+
580711
MemberExpression(path) {
581712
if (isValueMemberExpression(path)) {
582713
setOnFunctionScope(path, maybeUsesSignal, true, this.filename);

0 commit comments

Comments
 (0)