@usels/babel-plugin-legend-memo 0.0.1-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,93 @@
1
+ import type { NodePath } from '@babel/core';
2
+ import type { PluginOptions } from '../types';
3
+ import { getRootObject } from './getRootObject';
4
+
5
+ function isGetCallNode(node: any, opts: PluginOptions): boolean {
6
+ if (node.type === 'CallExpression') {
7
+ const { callee, arguments: args } = node;
8
+ if (callee.type !== 'MemberExpression') return false;
9
+ if (callee.property.type !== 'Identifier') return false;
10
+ if (callee.property.name !== 'get') return false;
11
+ if (args.length !== 0) return false;
12
+ if (!opts.allGet) {
13
+ const root = getRootObject(callee.object);
14
+ if (!root || !root.name?.endsWith('$')) return false;
15
+ }
16
+ return true;
17
+ }
18
+ if (node.type === 'OptionalCallExpression') {
19
+ const { callee, arguments: args } = node;
20
+ if (callee.type !== 'OptionalMemberExpression') return false;
21
+ if (callee.property?.type !== 'Identifier') return false;
22
+ if (callee.property?.name !== 'get') return false;
23
+ if (args.length !== 0) return false;
24
+ if (!opts.allGet) {
25
+ const root = getRootObject(callee.object);
26
+ if (!root || !root.name?.endsWith('$')) return false;
27
+ }
28
+ return true;
29
+ }
30
+ return false;
31
+ }
32
+
33
+ /**
34
+ * Checks if a NodePath contains a zero-argument .get() call on a $-suffixed observable.
35
+ *
36
+ * IMPORTANT: path.traverse() visits CHILDREN, not the root node itself.
37
+ * We must check the root node separately.
38
+ *
39
+ * - Stops traversal at function boundaries (ArrowFunctionExpression, FunctionExpression, FunctionDeclaration)
40
+ * - Supports OptionalCallExpression: obs$?.get()
41
+ * - Ignores .get(key) calls with arguments (Map.get(key))
42
+ * - When opts.allGet is false (default), root object must end with '$'
43
+ */
44
+ const FUNCTION_BOUNDARY_TYPES = new Set([
45
+ 'ArrowFunctionExpression',
46
+ 'FunctionExpression',
47
+ 'FunctionDeclaration',
48
+ ]);
49
+
50
+ export function hasGetCall(exprPath: NodePath, opts: PluginOptions): boolean {
51
+ // If the root node itself is a function boundary, do not descend into it.
52
+ // path.traverse() visits CHILDREN only, so the ArrowFunctionExpression visitor
53
+ // inside traverse() would NOT fire for the root — leading to false positives.
54
+ if (FUNCTION_BOUNDARY_TYPES.has(exprPath.node.type)) {
55
+ return false;
56
+ }
57
+
58
+ // Check the root node itself first (traverse does NOT visit it)
59
+ if (isGetCallNode(exprPath.node, opts)) {
60
+ return true;
61
+ }
62
+
63
+ let found = false;
64
+
65
+ exprPath.traverse({
66
+ // Stop at function boundaries — don't wrap event handlers or hook callbacks
67
+ ArrowFunctionExpression(innerPath) {
68
+ innerPath.skip();
69
+ },
70
+ FunctionExpression(innerPath) {
71
+ innerPath.skip();
72
+ },
73
+ FunctionDeclaration(innerPath) {
74
+ innerPath.skip();
75
+ },
76
+
77
+ OptionalCallExpression(innerPath) {
78
+ if (isGetCallNode(innerPath.node, opts)) {
79
+ found = true;
80
+ innerPath.stop();
81
+ }
82
+ },
83
+
84
+ CallExpression(innerPath) {
85
+ if (isGetCallNode(innerPath.node, opts)) {
86
+ found = true;
87
+ innerPath.stop();
88
+ }
89
+ },
90
+ });
91
+
92
+ return found;
93
+ }
@@ -0,0 +1,9 @@
1
+ export { getRootObject } from './getRootObject';
2
+ export { hasGetCall } from './hasGetCall';
3
+ export { hasAttributeGetCall } from './hasAttributeGetCall';
4
+ export { isInsideReactiveContext } from './isInsideReactiveContext';
5
+ export { isInsideObserverHOC } from './isInsideObserverHOC';
6
+ export { isInsideAttribute } from './isInsideAttribute';
7
+ export { createAutoElement } from './createAutoElement';
8
+ export { addAutoImport } from './addAutoImport';
9
+ export { wrapChildrenAsFunction } from './wrapChildrenAsFunction';
@@ -0,0 +1,12 @@
1
+ import type { NodePath } from '@babel/core';
2
+
3
+ /**
4
+ * Returns true if the path is inside a JSXAttribute.
5
+ * Used by JSXExpressionContainer visitor to skip attribute expressions
6
+ * (those are handled by the JSXElement visitor instead).
7
+ */
8
+ export function isInsideAttribute(path: NodePath): boolean {
9
+ return (
10
+ path.findParent((p) => p.isJSXAttribute()) !== null
11
+ );
12
+ }
@@ -0,0 +1,24 @@
1
+ import type { NodePath } from '@babel/core';
2
+
3
+ /**
4
+ * Returns true if the path is inside an observer() HOC call expression.
5
+ * observer() wraps a function component, making it reactive — no Auto wrapping needed.
6
+ *
7
+ * Unlike reactive JSX components (Auto, For, etc.), observer() is a CallExpression
8
+ * wrapper, so it cannot be detected by isInsideReactiveContext.
9
+ *
10
+ * Default observerNames: ["observer"]
11
+ * Configurable via opts.observerNames: ["observer", "reactive", ...]
12
+ */
13
+ export function isInsideObserverHOC(
14
+ path: NodePath,
15
+ observerNames: Set<string>,
16
+ ): boolean {
17
+ return (
18
+ path.findParent((p) => {
19
+ if (!p.isCallExpression()) return false;
20
+ const callee = p.node.callee;
21
+ return callee.type === 'Identifier' && observerNames.has(callee.name);
22
+ }) !== null
23
+ );
24
+ }
@@ -0,0 +1,23 @@
1
+ import type { NodePath } from '@babel/core';
2
+
3
+ /**
4
+ * Returns true if the path has a parent JSXElement whose tag name is in reactiveComponents.
5
+ * Used to skip wrapping inside Auto, For, Show, Memo, Computed, Switch, etc.
6
+ *
7
+ * Uses direct node.type comparison instead of t.isJSXIdentifier() to avoid
8
+ * needing to pass `t` as a parameter.
9
+ */
10
+ export function isInsideReactiveContext(
11
+ path: NodePath,
12
+ reactiveComponents: Set<string>,
13
+ ): boolean {
14
+ return (
15
+ path.findParent((p) => {
16
+ if (!p.isJSXElement()) return false;
17
+ const name = p.node.openingElement.name;
18
+ return (
19
+ name.type === 'JSXIdentifier' && reactiveComponents.has(name.name)
20
+ );
21
+ }) !== null
22
+ );
23
+ }
@@ -0,0 +1,113 @@
1
+ import type { types as BabelTypes } from '@babel/core';
2
+ import type {
3
+ Expression,
4
+ JSXChild,
5
+ JSXElement,
6
+ JSXExpressionContainer,
7
+ } from '@babel/types';
8
+
9
+ /**
10
+ * Expression types that indicate children are already function-like.
11
+ * Wrapping these again would be incorrect.
12
+ *
13
+ * - ArrowFunctionExpression: {() => ...} — already wrapped
14
+ * - FunctionExpression: {function() {...}} — already wrapped
15
+ * - MemberExpression: {obj.render} — already a reference
16
+ * - Identifier: {renderFn} — already a reference
17
+ */
18
+ const ALREADY_FUNCTION_TYPES = new Set([
19
+ 'ArrowFunctionExpression',
20
+ 'FunctionExpression',
21
+ 'MemberExpression',
22
+ 'Identifier',
23
+ ]);
24
+
25
+ /** Strip whitespace-only JSXText nodes (newlines/spaces between tags) */
26
+ function filterEmptyText(children: JSXChild[]): JSXChild[] {
27
+ return children.filter(
28
+ (c) => !(c.type === 'JSXText' && c.value.trim().length === 0),
29
+ );
30
+ }
31
+
32
+ /** Check if the (single) child is already a function/reference — no wrapping needed */
33
+ function areChildrenAlreadyFunction(children: JSXChild[]): boolean {
34
+ if (children.length !== 1) return false;
35
+ const child = children[0];
36
+ if (child.type !== 'JSXExpressionContainer') return false;
37
+ const expr = (child as JSXExpressionContainer).expression;
38
+ return expr.type !== 'JSXEmptyExpression' && ALREADY_FUNCTION_TYPES.has(expr.type);
39
+ }
40
+
41
+ /**
42
+ * Build the arrow function body from filtered children.
43
+ *
44
+ * - Single JSXElement child → use it directly as body: () => <Foo />
45
+ * - Single expression child → unwrap from container: () => someExpr
46
+ * - Multiple children → wrap in Fragment: () => <><A /><B /></>
47
+ */
48
+ function buildArrowBody(
49
+ t: typeof BabelTypes,
50
+ children: JSXChild[],
51
+ ): Expression {
52
+ if (children.length === 1) {
53
+ const child = children[0];
54
+ if (child.type === 'JSXElement') return child as JSXElement;
55
+ if (child.type === 'JSXExpressionContainer') {
56
+ const expr = (child as JSXExpressionContainer).expression;
57
+ if (expr.type !== 'JSXEmptyExpression') return expr as Expression;
58
+ }
59
+ }
60
+ return t.jsxFragment(
61
+ t.jsxOpeningFragment(),
62
+ t.jsxClosingFragment(),
63
+ children as any[],
64
+ );
65
+ }
66
+
67
+ /**
68
+ * Wraps non-function children of a JSXElement in an arrow function.
69
+ *
70
+ * Before: <Memo>{count$.get()}</Memo>
71
+ * After: <Memo>{() => count$.get()}</Memo>
72
+ *
73
+ * Returns the new JSXElement node, or null if no transformation is needed
74
+ * (children are already a function, or there are no meaningful children).
75
+ */
76
+ export function wrapChildrenAsFunction(
77
+ t: typeof BabelTypes,
78
+ node: JSXElement,
79
+ ): JSXElement | null {
80
+ // Only handle simple JSXIdentifier element names (not <Foo.Bar> etc.)
81
+ if (node.openingElement.name.type !== 'JSXIdentifier') return null;
82
+
83
+ const children = filterEmptyText(node.children);
84
+
85
+ if (children.length === 0) return null;
86
+ if (areChildrenAlreadyFunction(children)) return null;
87
+
88
+ // Only wrap if first child is a JSXElement or a non-function expression
89
+ const firstChild = children[0];
90
+ const needsWrapping =
91
+ firstChild.type === 'JSXElement' ||
92
+ (firstChild.type === 'JSXExpressionContainer' &&
93
+ !ALREADY_FUNCTION_TYPES.has(
94
+ (firstChild as JSXExpressionContainer).expression.type,
95
+ ));
96
+
97
+ if (!needsWrapping) return null;
98
+
99
+ const elementName = node.openingElement.name.name;
100
+ const body = buildArrowBody(t, children);
101
+ const arrowFn = t.arrowFunctionExpression([], body);
102
+
103
+ return t.jsxElement(
104
+ t.jsxOpeningElement(
105
+ t.jsxIdentifier(elementName),
106
+ node.openingElement.attributes,
107
+ false,
108
+ ),
109
+ t.jsxClosingElement(t.jsxIdentifier(elementName)),
110
+ [t.jsxExpressionContainer(arrowFn)],
111
+ false,
112
+ );
113
+ }
@@ -0,0 +1,3 @@
1
+ export { createProgramVisitor } from './program';
2
+ export { createJSXElementVisitor } from './jsxElement';
3
+ export { createJSXExpressionContainerVisitor } from './jsxExpressionContainer';
@@ -0,0 +1,52 @@
1
+ import type { NodePath, types as BabelTypes } from '@babel/core';
2
+ import type { JSXElement } from '@babel/types';
3
+ import type { PluginState } from '../types';
4
+ import { hasAttributeGetCall } from '../utils/hasAttributeGetCall';
5
+ import { isInsideReactiveContext } from '../utils/isInsideReactiveContext';
6
+ import { isInsideObserverHOC } from '../utils/isInsideObserverHOC';
7
+ import { createAutoElement } from '../utils/createAutoElement';
8
+ import { wrapChildrenAsFunction } from '../utils/wrapChildrenAsFunction';
9
+
10
+ export function createJSXElementVisitor(t: typeof BabelTypes) {
11
+ return function JSXElement(
12
+ path: NodePath<JSXElement>,
13
+ state: PluginState,
14
+ ): void {
15
+ // STEP 1 (NEW): If this element is Memo/Show/Computed (or configured),
16
+ // auto-wrap non-function children in () => — equivalent to @legendapp/state/babel
17
+ const elementName =
18
+ path.node.openingElement.name.type === 'JSXIdentifier'
19
+ ? path.node.openingElement.name.name
20
+ : null;
21
+
22
+ if (elementName && state.autoWrapChildrenComponents.has(elementName)) {
23
+ const wrapped = wrapChildrenAsFunction(t, path.node);
24
+ if (wrapped !== null) {
25
+ path.replaceWith(wrapped);
26
+ // path.node now points to the new element — continue to check attributes
27
+ }
28
+ }
29
+
30
+ // 1. Skip if already inside a reactive context (Auto, For, Show, Memo, etc.)
31
+ if (isInsideReactiveContext(path, state.reactiveComponents)) return;
32
+
33
+ // 2. Skip if inside an observer() HOC
34
+ if (isInsideObserverHOC(path, state.observerNames)) return;
35
+
36
+ // 3. Check if any non-special attributes contain .get() calls
37
+ if (!hasAttributeGetCall(path, state.opts)) return;
38
+
39
+ // 4. Wrap the entire JSXElement in <Auto>{() => element}</Auto>
40
+ const autoElement = createAutoElement(
41
+ t,
42
+ path.node,
43
+ state.autoComponentName,
44
+ );
45
+ path.replaceWith(autoElement);
46
+
47
+ state.autoImportNeeded = true;
48
+
49
+ // 5. Skip traversal of the new node to prevent double-wrapping
50
+ path.skip();
51
+ };
52
+ }
@@ -0,0 +1,42 @@
1
+ import type { NodePath, types as BabelTypes } from '@babel/core';
2
+ import type { JSXExpressionContainer } from '@babel/types';
3
+ import type { PluginState } from '../types';
4
+ import { hasGetCall } from '../utils/hasGetCall';
5
+ import { isInsideReactiveContext } from '../utils/isInsideReactiveContext';
6
+ import { isInsideObserverHOC } from '../utils/isInsideObserverHOC';
7
+ import { isInsideAttribute } from '../utils/isInsideAttribute';
8
+ import { createAutoElement } from '../utils/createAutoElement';
9
+
10
+ export function createJSXExpressionContainerVisitor(t: typeof BabelTypes) {
11
+ return function JSXExpressionContainer(
12
+ path: NodePath<JSXExpressionContainer>,
13
+ state: PluginState,
14
+ ): void {
15
+ // 1. Skip if inside a JSXAttribute — JSXElement visitor handles those
16
+ if (isInsideAttribute(path)) return;
17
+
18
+ // 2. Skip if already inside a reactive context (Auto, For, Show, Memo, etc.)
19
+ if (isInsideReactiveContext(path, state.reactiveComponents)) return;
20
+
21
+ // 3. Skip if inside an observer() HOC
22
+ if (isInsideObserverHOC(path, state.observerNames)) return;
23
+
24
+ // 4. Skip JSXEmptyExpression {}
25
+ const expression = path.node.expression;
26
+ if (expression.type === 'JSXEmptyExpression') return;
27
+
28
+ // 5. Check if the expression contains a .get() call
29
+ const exprPath = path.get('expression') as NodePath;
30
+ if (!hasGetCall(exprPath, state.opts)) return;
31
+
32
+ // 6. Wrap the expression in <Auto>{() => expression}</Auto>
33
+ const autoElement = createAutoElement(
34
+ t,
35
+ expression,
36
+ state.autoComponentName,
37
+ );
38
+ path.replaceWith(autoElement);
39
+
40
+ state.autoImportNeeded = true;
41
+ };
42
+ }
@@ -0,0 +1,43 @@
1
+ import type { NodePath, types as BabelTypes } from '@babel/core';
2
+ import type { Program } from '@babel/types';
3
+ import type { PluginState } from '../types';
4
+ import { addAutoImport } from '../utils/addAutoImport';
5
+
6
+ export function createProgramVisitor(t: typeof BabelTypes) {
7
+ return {
8
+ enter(path: NodePath<Program>, state: PluginState) {
9
+ const opts = state.opts ?? {};
10
+ state.autoImportNeeded = false;
11
+ state.autoImportSource = opts.importSource ?? '@legendapp/state/react';
12
+ state.autoComponentName = opts.componentName ?? 'Memo';
13
+ state.reactiveComponents = new Set([
14
+ state.autoComponentName,
15
+ 'For',
16
+ 'Show',
17
+ 'Memo',
18
+ 'Computed',
19
+ 'Switch',
20
+ ...(opts.reactiveComponents ?? []),
21
+ ]);
22
+ state.observerNames = new Set([
23
+ 'observer',
24
+ ...(opts.observerNames ?? []),
25
+ ]);
26
+ state.autoWrapChildrenComponents =
27
+ opts.wrapReactiveChildren !== false
28
+ ? new Set([
29
+ 'Memo',
30
+ 'Show',
31
+ 'Computed',
32
+ ...(opts.wrapReactiveChildrenComponents ?? []),
33
+ ])
34
+ : new Set(opts.wrapReactiveChildrenComponents ?? []);
35
+ },
36
+
37
+ exit(path: NodePath<Program>, state: PluginState) {
38
+ if (state.autoImportNeeded) {
39
+ addAutoImport(path, t, state);
40
+ }
41
+ },
42
+ };
43
+ }
@@ -0,0 +1,136 @@
1
+ import pluginTester from 'babel-plugin-tester';
2
+ import plugin from '../src';
3
+
4
+ const babelOptions = {
5
+ plugins: ['@babel/plugin-syntax-jsx'],
6
+ configFile: false,
7
+ babelrc: false,
8
+ };
9
+
10
+ pluginTester({
11
+ plugin,
12
+ pluginOptions: {},
13
+ babelOptions,
14
+ title: 'JSX attribute transforms',
15
+ tests: {
16
+ 'wraps element with single attribute .get()': {
17
+ code: `
18
+ function App() {
19
+ return <Component value={obs$.get()} />;
20
+ }
21
+ `,
22
+ output: `
23
+ import { Memo } from "@legendapp/state/react";
24
+ function App() {
25
+ return <Memo>{() => <Component value={obs$.get()} />}</Memo>;
26
+ }
27
+ `,
28
+ },
29
+
30
+ 'wraps self-closing element with attribute .get()': {
31
+ code: `
32
+ function App() {
33
+ return <Input disabled={isDisabled$.get()} />;
34
+ }
35
+ `,
36
+ output: `
37
+ import { Memo } from "@legendapp/state/react";
38
+ function App() {
39
+ return <Memo>{() => <Input disabled={isDisabled$.get()} />}</Memo>;
40
+ }
41
+ `,
42
+ },
43
+
44
+ 'wraps element with multiple attribute .get() into single Auto': {
45
+ code: `
46
+ function App() {
47
+ return <Component value={obs$.get()} label={name$.get()} />;
48
+ }
49
+ `,
50
+ output: `
51
+ import { Memo } from "@legendapp/state/react";
52
+ function App() {
53
+ return (
54
+ <Memo>{() => <Component value={obs$.get()} label={name$.get()} />}</Memo>
55
+ );
56
+ }
57
+ `,
58
+ },
59
+
60
+ 'wraps entire element when both attribute and children have .get()': {
61
+ code: `
62
+ function App() {
63
+ return <div className={theme$.get()}>{count$.get()}</div>;
64
+ }
65
+ `,
66
+ output: `
67
+ import { Memo } from "@legendapp/state/react";
68
+ function App() {
69
+ return (
70
+ <Memo>{() => <div className={theme$.get()}>{count$.get()}</div>}</Memo>
71
+ );
72
+ }
73
+ `,
74
+ },
75
+
76
+ 'wraps element with spread attribute containing .get()': {
77
+ code: `
78
+ function App() {
79
+ return <Component {...obs$.get()} />;
80
+ }
81
+ `,
82
+ output: `
83
+ import { Memo } from "@legendapp/state/react";
84
+ function App() {
85
+ return <Memo>{() => <Component {...obs$.get()} />}</Memo>;
86
+ }
87
+ `,
88
+ },
89
+
90
+ 'does NOT wrap element with only key={obs$.get()}': {
91
+ code: `
92
+ function App() {
93
+ return <li key={item$.id.get()}>content</li>;
94
+ }
95
+ `,
96
+ },
97
+
98
+ 'does NOT wrap element with only ref={obs$.get()}': {
99
+ code: `
100
+ function App() {
101
+ return <div ref={domRef$.get()}>content</div>;
102
+ }
103
+ `,
104
+ },
105
+
106
+ 'wraps element when key + other attribute both have .get()': {
107
+ code: `
108
+ function App() {
109
+ return <li key={item$.id.get()} className={theme$.get()}>content</li>;
110
+ }
111
+ `,
112
+ output: `
113
+ import { Memo } from "@legendapp/state/react";
114
+ function App() {
115
+ return (
116
+ <Memo>
117
+ {() => (
118
+ <li key={item$.id.get()} className={theme$.get()}>
119
+ content
120
+ </li>
121
+ )}
122
+ </Memo>
123
+ );
124
+ }
125
+ `,
126
+ },
127
+
128
+ 'does NOT wrap element when attribute has .get() with args (Map.get)': {
129
+ code: `
130
+ function App() {
131
+ return <Component value={map.get("key")} />;
132
+ }
133
+ `,
134
+ },
135
+ },
136
+ });