@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.
- package/README.md +631 -0
- package/dist/index.d.mts +58 -0
- package/dist/index.d.ts +58 -0
- package/dist/index.js +354 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +331 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +38 -0
- package/src/index.ts +51 -0
- package/src/types.ts +35 -0
- package/src/utils/addAutoImport.ts +40 -0
- package/src/utils/createAutoElement.ts +21 -0
- package/src/utils/getRootObject.ts +25 -0
- package/src/utils/hasAttributeGetCall.ts +59 -0
- package/src/utils/hasGetCall.ts +93 -0
- package/src/utils/index.ts +9 -0
- package/src/utils/isInsideAttribute.ts +12 -0
- package/src/utils/isInsideObserverHOC.ts +24 -0
- package/src/utils/isInsideReactiveContext.ts +23 -0
- package/src/utils/wrapChildrenAsFunction.ts +113 -0
- package/src/visitors/index.ts +3 -0
- package/src/visitors/jsxElement.ts +52 -0
- package/src/visitors/jsxExpressionContainer.ts +42 -0
- package/src/visitors/program.ts +43 -0
- package/tests/attribute.test.ts +136 -0
- package/tests/basic.test.ts +184 -0
- package/tests/complex.test.ts +192 -0
- package/tests/edge-cases.test.ts +231 -0
- package/tests/options.test.ts +149 -0
- package/tests/reactive-children.test.ts +265 -0
- package/tsconfig.json +11 -0
- package/tsup.config.ts +10 -0
- package/vitest.config.ts +9 -0
package/README.md
ADDED
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
# @usels/babel-plugin-legend-memo
|
|
2
|
+
|
|
3
|
+
A Babel plugin that automatically wraps Legend-State observable `.get()` calls in JSX with reactive `<Memo>` boundaries — and also auto-wraps children of `Memo`/`Show`/`Computed` components.
|
|
4
|
+
|
|
5
|
+
```jsx
|
|
6
|
+
// You write this
|
|
7
|
+
<div>{count$.get()}</div>
|
|
8
|
+
|
|
9
|
+
// Plugin transforms to
|
|
10
|
+
import { Memo } from "@legendapp/state/react";
|
|
11
|
+
<div><Memo>{() => count$.get()}</Memo></div>
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
**One plugin replaces two** — no longer need `@legendapp/state/babel` separately.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Table of Contents
|
|
19
|
+
|
|
20
|
+
- [Features](#features)
|
|
21
|
+
- [Installation](#installation)
|
|
22
|
+
- [Setup](#setup)
|
|
23
|
+
- [How It Works](#how-it-works)
|
|
24
|
+
- [Feature 1: Auto-wrap `.get()` in JSX expressions](#feature-1-auto-wrap-get-in-jsx-expressions)
|
|
25
|
+
- [Feature 2: Auto-wrap `.get()` in JSX attributes](#feature-2-auto-wrap-get-in-jsx-attributes)
|
|
26
|
+
- [Feature 3: Auto-wrap children of Memo/Show/Computed](#feature-3-auto-wrap-children-of-memoshowcomputed)
|
|
27
|
+
- [Skip Cases](#skip-cases)
|
|
28
|
+
- [Plugin Options](#plugin-options)
|
|
29
|
+
- [Writing Components](#writing-components)
|
|
30
|
+
- [Migration Guide](#migration-guide)
|
|
31
|
+
- [Common Patterns](#common-patterns)
|
|
32
|
+
- [FAQ](#faq)
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Features
|
|
37
|
+
|
|
38
|
+
1. **Auto-wraps JSX expressions** — `{count$.get()}` → `<Memo>{() => count$.get()}</Memo>`
|
|
39
|
+
2. **Auto-wraps JSX attributes** — element with `.get()` in props → entire element wrapped in `<Memo>`
|
|
40
|
+
3. **Auto-wraps reactive children** — `<Memo>{expr}</Memo>` → `<Memo>{() => expr}</Memo>` (replaces `@legendapp/state/babel`)
|
|
41
|
+
4. **Auto-adds import** — `import { Memo } from "@legendapp/state/react"` added automatically
|
|
42
|
+
5. **No double-wrapping** — skips already-reactive contexts (`Memo`, `Show`, `Computed`, `For`, `observer()`)
|
|
43
|
+
6. **Safe detection** — only wraps `$`-suffixed observables by default, skips `Map.get('key')`
|
|
44
|
+
7. **Supports optional chaining** — `obs$?.get()` and `obs$.items[0].get()` detected correctly
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Installation
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npm install -D @usels/babel-plugin-legend-memo
|
|
52
|
+
# or
|
|
53
|
+
pnpm add -D @usels/babel-plugin-legend-memo
|
|
54
|
+
# or
|
|
55
|
+
yarn add -D @usels/babel-plugin-legend-memo
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**Peer dependency required:**
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
npm install -D @babel/core
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Setup
|
|
67
|
+
|
|
68
|
+
### babel.config.js
|
|
69
|
+
|
|
70
|
+
```js
|
|
71
|
+
module.exports = {
|
|
72
|
+
plugins: ['@usels/babel-plugin-legend-memo'],
|
|
73
|
+
};
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### .babelrc
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
{
|
|
80
|
+
"plugins": ["@usels/babel-plugin-legend-memo"]
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### With options
|
|
85
|
+
|
|
86
|
+
```js
|
|
87
|
+
module.exports = {
|
|
88
|
+
plugins: [
|
|
89
|
+
['@usels/babel-plugin-legend-memo', {
|
|
90
|
+
componentName: 'Memo',
|
|
91
|
+
importSource: '@legendapp/state/react',
|
|
92
|
+
allGet: false,
|
|
93
|
+
wrapReactiveChildren: true,
|
|
94
|
+
}]
|
|
95
|
+
],
|
|
96
|
+
};
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## How It Works
|
|
102
|
+
|
|
103
|
+
### Feature 1: Auto-wrap `.get()` in JSX expressions
|
|
104
|
+
|
|
105
|
+
When a JSX expression contains a `$`-suffixed observable `.get()` call, the plugin wraps it in `<Memo>{() => ...}</Memo>` and automatically adds the import.
|
|
106
|
+
|
|
107
|
+
```jsx
|
|
108
|
+
// Input
|
|
109
|
+
function App() {
|
|
110
|
+
return (
|
|
111
|
+
<div>
|
|
112
|
+
{count$.get()}
|
|
113
|
+
<span>{user$.profile.name.get()}</span>
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Output
|
|
119
|
+
import { Memo } from "@legendapp/state/react";
|
|
120
|
+
function App() {
|
|
121
|
+
return (
|
|
122
|
+
<div>
|
|
123
|
+
<Memo>{() => count$.get()}</Memo>
|
|
124
|
+
<span>
|
|
125
|
+
<Memo>{() => user$.profile.name.get()}</Memo>
|
|
126
|
+
</span>
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Multiple `.get()` calls in a single expression are wrapped together:
|
|
133
|
+
|
|
134
|
+
```jsx
|
|
135
|
+
// Input
|
|
136
|
+
<p>{a$.get() + " " + b$.get()}</p>
|
|
137
|
+
|
|
138
|
+
// Output
|
|
139
|
+
<p><Memo>{() => a$.get() + " " + b$.get()}</Memo></p>
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Ternary and conditional expressions:
|
|
143
|
+
|
|
144
|
+
```jsx
|
|
145
|
+
// Input
|
|
146
|
+
<div>{isActive$.get() ? "ON" : "OFF"}</div>
|
|
147
|
+
<div>{show$.get() && <Modal />}</div>
|
|
148
|
+
<div>{isVisible$.get() ? <A /> : <B />}</div>
|
|
149
|
+
|
|
150
|
+
// Output
|
|
151
|
+
<div><Memo>{() => isActive$.get() ? "ON" : "OFF"}</Memo></div>
|
|
152
|
+
<div><Memo>{() => show$.get() && <Modal />}</Memo></div>
|
|
153
|
+
<div><Memo>{() => isVisible$.get() ? <A /> : <B />}</Memo></div>
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
### Feature 2: Auto-wrap `.get()` in JSX attributes
|
|
159
|
+
|
|
160
|
+
When a JSX element has `.get()` in its props, the **entire element** is wrapped in `<Memo>`:
|
|
161
|
+
|
|
162
|
+
```jsx
|
|
163
|
+
// Input — single attribute
|
|
164
|
+
<Component value={obs$.get()} />
|
|
165
|
+
|
|
166
|
+
// Output
|
|
167
|
+
import { Memo } from "@legendapp/state/react";
|
|
168
|
+
<Memo>{() => <Component value={obs$.get()} />}</Memo>
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Multiple attributes with `.get()` are wrapped together in one `<Memo>`:
|
|
172
|
+
|
|
173
|
+
```jsx
|
|
174
|
+
// Input — multiple attributes
|
|
175
|
+
<Component value={obs$.get()} label={name$.get()} />
|
|
176
|
+
|
|
177
|
+
// Output
|
|
178
|
+
<Memo>{() => <Component value={obs$.get()} label={name$.get()} />}</Memo>
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Attributes + children together — whole element is wrapped:
|
|
182
|
+
|
|
183
|
+
```jsx
|
|
184
|
+
// Input
|
|
185
|
+
<div className={theme$.get()}>
|
|
186
|
+
{count$.get()}
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
// Output
|
|
190
|
+
<Memo>{() =>
|
|
191
|
+
<div className={theme$.get()}>
|
|
192
|
+
{count$.get()}
|
|
193
|
+
</div>
|
|
194
|
+
}</Memo>
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
### Feature 3: Auto-wrap children of Memo/Show/Computed
|
|
200
|
+
|
|
201
|
+
Non-function children of `Memo`, `Show`, and `Computed` are automatically wrapped in `() =>`. This is equivalent to the `@legendapp/state/babel` plugin behavior.
|
|
202
|
+
|
|
203
|
+
```jsx
|
|
204
|
+
// Input
|
|
205
|
+
<Memo>{count$.get()}</Memo>
|
|
206
|
+
<Show if={cond$}>{count$.get()}</Show>
|
|
207
|
+
<Computed>{count$.get()}</Computed>
|
|
208
|
+
|
|
209
|
+
// Output (no new import needed — Memo/Show/Computed are user-imported)
|
|
210
|
+
<Memo>{() => count$.get()}</Memo>
|
|
211
|
+
<Show if={cond$}>{() => count$.get()}</Show>
|
|
212
|
+
<Computed>{() => count$.get()}</Computed>
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Direct JSX element children:
|
|
216
|
+
|
|
217
|
+
```jsx
|
|
218
|
+
// Input
|
|
219
|
+
<Memo><div>hello</div></Memo>
|
|
220
|
+
<Memo><span>{count$.get()}</span></Memo>
|
|
221
|
+
|
|
222
|
+
// Output
|
|
223
|
+
<Memo>{() => <div>hello</div>}</Memo>
|
|
224
|
+
<Memo>{() => <span>{count$.get()}</span>}</Memo>
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Multiple children → wrapped in Fragment:
|
|
228
|
+
|
|
229
|
+
```jsx
|
|
230
|
+
// Input
|
|
231
|
+
<Memo>
|
|
232
|
+
<Header />
|
|
233
|
+
<Body />
|
|
234
|
+
</Memo>
|
|
235
|
+
|
|
236
|
+
// Output
|
|
237
|
+
<Memo>{() => <><Header /><Body /></>}</Memo>
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Combined — `Show` with `.get()` attribute AND children:
|
|
241
|
+
|
|
242
|
+
```jsx
|
|
243
|
+
// Input
|
|
244
|
+
<Show if={obs$.get()}>{count$.get()}</Show>
|
|
245
|
+
|
|
246
|
+
// Output
|
|
247
|
+
import { Memo } from "@legendapp/state/react";
|
|
248
|
+
<Memo>{() => <Show if={obs$.get()}>{() => count$.get()}</Show>}</Memo>
|
|
249
|
+
// ↑ children wrapped first, then whole element wrapped for attribute
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
## Skip Cases
|
|
255
|
+
|
|
256
|
+
The plugin intentionally skips these cases:
|
|
257
|
+
|
|
258
|
+
| Case | Example | Reason |
|
|
259
|
+
|------|---------|--------|
|
|
260
|
+
| `.get()` with arguments | `map.get('key')` | `Map.prototype.get` takes args |
|
|
261
|
+
| No `$` suffix | `store.get()` | Not a Legend-State observable (use `allGet: true` to override) |
|
|
262
|
+
| Already inside reactive context | `<Memo>{() => count$.get()}</Memo>` | Already reactive — no double-wrapping |
|
|
263
|
+
| Inside `observer()` HOC | `observer(() => <div>{obs$.get()}</div>)` | Whole component is reactive |
|
|
264
|
+
| Already a function child | `<Memo>{() => ...}</Memo>` | Already wrapped |
|
|
265
|
+
| Identifier/reference child | `<Memo>{renderFn}</Memo>` | Function reference — already correct |
|
|
266
|
+
| `key` prop | `<li key={item$.id.get()}>` | React reconciliation requires literal key |
|
|
267
|
+
| `ref` prop | `<div ref={domRef$.get()}>` | DOM ref, not a reactive value |
|
|
268
|
+
| Inside event handler | `onClick={() => obs$.set(...)}` | Lazy callback — shouldn't be reactive boundary |
|
|
269
|
+
| Inside `useMemo`/`useCallback` | `useMemo(() => obs$.get(), [])` | Hook internals — not JSX expressions |
|
|
270
|
+
|
|
271
|
+
---
|
|
272
|
+
|
|
273
|
+
## Plugin Options
|
|
274
|
+
|
|
275
|
+
```typescript
|
|
276
|
+
interface PluginOptions {
|
|
277
|
+
/**
|
|
278
|
+
* Wrapper component name
|
|
279
|
+
* @default "Memo"
|
|
280
|
+
*/
|
|
281
|
+
componentName?: string;
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Import source for the wrapper component
|
|
285
|
+
* @default "@legendapp/state/react"
|
|
286
|
+
*/
|
|
287
|
+
importSource?: string;
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Detect all .get() calls regardless of $ suffix
|
|
291
|
+
* @default false
|
|
292
|
+
*/
|
|
293
|
+
allGet?: boolean;
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Additional method names to detect beyond "get"
|
|
297
|
+
* @default ["get"]
|
|
298
|
+
*/
|
|
299
|
+
methodNames?: string[];
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Additional reactive component names to skip
|
|
303
|
+
* Merged with defaults: Auto, For, Show, Memo, Computed, Switch
|
|
304
|
+
*/
|
|
305
|
+
reactiveComponents?: string[];
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Observer HOC function names — skip content inside these
|
|
309
|
+
* @default ["observer"]
|
|
310
|
+
*/
|
|
311
|
+
observerNames?: string[];
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Auto-wrap non-function children of Memo/Show/Computed in () =>
|
|
315
|
+
* Equivalent to @legendapp/state/babel plugin behavior
|
|
316
|
+
* @default true
|
|
317
|
+
*/
|
|
318
|
+
wrapReactiveChildren?: boolean;
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Additional component names whose children should be auto-wrapped
|
|
322
|
+
* Merged with defaults: Memo, Show, Computed
|
|
323
|
+
*/
|
|
324
|
+
wrapReactiveChildrenComponents?: string[];
|
|
325
|
+
}
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
### Examples
|
|
329
|
+
|
|
330
|
+
```js
|
|
331
|
+
// Custom wrapper component (e.g., using @usels/core)
|
|
332
|
+
['@usels/babel-plugin-legend-memo', {
|
|
333
|
+
componentName: 'Auto',
|
|
334
|
+
importSource: '@usels/core',
|
|
335
|
+
}]
|
|
336
|
+
|
|
337
|
+
// Detect all .get() regardless of $ suffix
|
|
338
|
+
['@usels/babel-plugin-legend-memo', {
|
|
339
|
+
allGet: true,
|
|
340
|
+
}]
|
|
341
|
+
|
|
342
|
+
// Disable Memo/Show/Computed children wrapping
|
|
343
|
+
['@usels/babel-plugin-legend-memo', {
|
|
344
|
+
wrapReactiveChildren: false,
|
|
345
|
+
}]
|
|
346
|
+
|
|
347
|
+
// Add custom reactive components to skip list
|
|
348
|
+
['@usels/babel-plugin-legend-memo', {
|
|
349
|
+
reactiveComponents: ['MyObserver', 'ReactiveContainer'],
|
|
350
|
+
}]
|
|
351
|
+
|
|
352
|
+
// Add custom components whose children should be auto-wrapped
|
|
353
|
+
['@usels/babel-plugin-legend-memo', {
|
|
354
|
+
wrapReactiveChildrenComponents: ['MyMemo', 'CustomComputed'],
|
|
355
|
+
}]
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
---
|
|
359
|
+
|
|
360
|
+
## Writing Components
|
|
361
|
+
|
|
362
|
+
### ✅ Do: Write `.get()` naturally in JSX
|
|
363
|
+
|
|
364
|
+
```tsx
|
|
365
|
+
// Just use .get() — plugin handles the reactive boundary
|
|
366
|
+
function Counter() {
|
|
367
|
+
return (
|
|
368
|
+
<div>
|
|
369
|
+
<p>Count: {count$.get()}</p>
|
|
370
|
+
<p>User: {user$.name.get()}</p>
|
|
371
|
+
<p>Status: {isActive$.get() ? "Active" : "Inactive"}</p>
|
|
372
|
+
</div>
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
### ✅ Do: Use `$` suffix for observables
|
|
378
|
+
|
|
379
|
+
```tsx
|
|
380
|
+
// The $ suffix is required for auto-detection (default behavior)
|
|
381
|
+
const count$ = observable(0);
|
|
382
|
+
const user$ = observable({ name: 'Alice' });
|
|
383
|
+
const items$ = observable([]);
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
### ✅ Do: Use Memo/Show/Computed freely — no need to write `() =>`
|
|
387
|
+
|
|
388
|
+
```tsx
|
|
389
|
+
// The plugin auto-wraps children
|
|
390
|
+
function App() {
|
|
391
|
+
return (
|
|
392
|
+
<>
|
|
393
|
+
<Memo>{count$.get()}</Memo>
|
|
394
|
+
|
|
395
|
+
<Show if={isVisible$}>
|
|
396
|
+
{content$.get()}
|
|
397
|
+
</Show>
|
|
398
|
+
|
|
399
|
+
<Computed>
|
|
400
|
+
{price$.get() * qty$.get()}
|
|
401
|
+
</Computed>
|
|
402
|
+
|
|
403
|
+
{/* Direct JSX children work too */}
|
|
404
|
+
<Memo>
|
|
405
|
+
<div className="card">{count$.get()}</div>
|
|
406
|
+
</Memo>
|
|
407
|
+
</>
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
### ✅ Do: Use `observer()` for fully-reactive components
|
|
413
|
+
|
|
414
|
+
```tsx
|
|
415
|
+
// observer() makes the whole component reactive
|
|
416
|
+
// Plugin skips .get() calls inside observer — no double-wrapping
|
|
417
|
+
const MyComponent = observer(() => {
|
|
418
|
+
return (
|
|
419
|
+
<div>
|
|
420
|
+
<h2>{user$.name.get()}</h2>
|
|
421
|
+
<p>{user$.bio.get()}</p>
|
|
422
|
+
</div>
|
|
423
|
+
);
|
|
424
|
+
});
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
### ✅ Do: Use `For` for reactive lists
|
|
428
|
+
|
|
429
|
+
```tsx
|
|
430
|
+
// For handles list reactivity — plugin skips inside For
|
|
431
|
+
<For each={items$}>
|
|
432
|
+
{(item$) => (
|
|
433
|
+
<li key={item$.id.get()}>
|
|
434
|
+
{item$.name.get()}
|
|
435
|
+
</li>
|
|
436
|
+
)}
|
|
437
|
+
</For>
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
### ❌ Don't: Manually add `<Memo>` around expressions (already handled)
|
|
441
|
+
|
|
442
|
+
```tsx
|
|
443
|
+
// ❌ Redundant — plugin already wraps expressions
|
|
444
|
+
<div>
|
|
445
|
+
<Memo>{() => count$.get()}</Memo>
|
|
446
|
+
</div>
|
|
447
|
+
|
|
448
|
+
// ✅ Just write the expression
|
|
449
|
+
<div>
|
|
450
|
+
{count$.get()}
|
|
451
|
+
</div>
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
### ❌ Don't: Manually write `() =>` inside Memo/Show/Computed
|
|
455
|
+
|
|
456
|
+
```tsx
|
|
457
|
+
// ❌ Redundant — plugin auto-wraps children
|
|
458
|
+
<Memo>{() => count$.get()}</Memo>
|
|
459
|
+
|
|
460
|
+
// ✅ Plugin handles this
|
|
461
|
+
<Memo>{count$.get()}</Memo>
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
### ❌ Don't: Use `.get()` in `key` prop
|
|
465
|
+
|
|
466
|
+
```tsx
|
|
467
|
+
// ❌ Plugin can't wrap key prop — key must be on the outermost element
|
|
468
|
+
items.map(item$ => <li key={item$.id.get()}>{item$.name.get()}</li>)
|
|
469
|
+
|
|
470
|
+
// ✅ Use For instead — handles keys automatically
|
|
471
|
+
<For each={items$}>
|
|
472
|
+
{(item$) => <li>{item$.name.get()}</li>}
|
|
473
|
+
</For>
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
---
|
|
477
|
+
|
|
478
|
+
## Migration Guide
|
|
479
|
+
|
|
480
|
+
### From manual `<Memo>` wrapping
|
|
481
|
+
|
|
482
|
+
Before:
|
|
483
|
+
```tsx
|
|
484
|
+
function App() {
|
|
485
|
+
return (
|
|
486
|
+
<div>
|
|
487
|
+
<Memo>{() => count$.get()}</Memo>
|
|
488
|
+
<Memo>{() => user$.name.get()}</Memo>
|
|
489
|
+
</div>
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
After (let the plugin handle it):
|
|
495
|
+
```tsx
|
|
496
|
+
function App() {
|
|
497
|
+
return (
|
|
498
|
+
<div>
|
|
499
|
+
{count$.get()}
|
|
500
|
+
{user$.name.get()}
|
|
501
|
+
</div>
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
### From `@legendapp/state/babel` + another plugin
|
|
507
|
+
|
|
508
|
+
Before (two plugins):
|
|
509
|
+
```js
|
|
510
|
+
// babel.config.js
|
|
511
|
+
module.exports = {
|
|
512
|
+
plugins: [
|
|
513
|
+
"@legendapp/state/babel", // Memo/Show/Computed children wrapping
|
|
514
|
+
"some-other-plugin", // .get() auto-wrapping
|
|
515
|
+
],
|
|
516
|
+
};
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
After (one plugin):
|
|
520
|
+
```js
|
|
521
|
+
module.exports = {
|
|
522
|
+
plugins: [
|
|
523
|
+
"@usels/babel-plugin-legend-memo", // Both features included
|
|
524
|
+
],
|
|
525
|
+
};
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
---
|
|
529
|
+
|
|
530
|
+
## Common Patterns
|
|
531
|
+
|
|
532
|
+
### Counter with increment button
|
|
533
|
+
|
|
534
|
+
```tsx
|
|
535
|
+
const count$ = observable(0);
|
|
536
|
+
|
|
537
|
+
function Counter() {
|
|
538
|
+
return (
|
|
539
|
+
<div>
|
|
540
|
+
<p>Count: {count$.get()}</p>
|
|
541
|
+
<button onClick={() => count$.set(c => c + 1)}>
|
|
542
|
+
Increment
|
|
543
|
+
</button>
|
|
544
|
+
</div>
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
// Plugin wraps {count$.get()} — button handler is NOT wrapped (inside function)
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
### Conditional display with Show
|
|
551
|
+
|
|
552
|
+
```tsx
|
|
553
|
+
const isLoggedIn$ = observable(false);
|
|
554
|
+
const username$ = observable('');
|
|
555
|
+
|
|
556
|
+
function Header() {
|
|
557
|
+
return (
|
|
558
|
+
<header>
|
|
559
|
+
<Show if={isLoggedIn$}>
|
|
560
|
+
Welcome, {username$.get()}!
|
|
561
|
+
</Show>
|
|
562
|
+
<Show if={() => !isLoggedIn$.get()}>
|
|
563
|
+
<a href="/login">Login</a>
|
|
564
|
+
</Show>
|
|
565
|
+
</header>
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
### Reactive form fields
|
|
571
|
+
|
|
572
|
+
```tsx
|
|
573
|
+
const formData$ = observable({ name: '', email: '' });
|
|
574
|
+
|
|
575
|
+
function Form() {
|
|
576
|
+
return (
|
|
577
|
+
<form>
|
|
578
|
+
<input
|
|
579
|
+
value={formData$.name.get()}
|
|
580
|
+
onChange={e => formData$.name.set(e.target.value)}
|
|
581
|
+
/>
|
|
582
|
+
{/* Plugin wraps entire <input> since value attr has .get() */}
|
|
583
|
+
</form>
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
### Reactive styles
|
|
589
|
+
|
|
590
|
+
```tsx
|
|
591
|
+
const theme$ = observable({ primary: '#007bff', isDark: false });
|
|
592
|
+
|
|
593
|
+
function ThemedButton() {
|
|
594
|
+
return (
|
|
595
|
+
<button
|
|
596
|
+
style={{
|
|
597
|
+
backgroundColor: theme$.primary.get(),
|
|
598
|
+
color: theme$.isDark.get() ? 'white' : 'black',
|
|
599
|
+
}}
|
|
600
|
+
>
|
|
601
|
+
Click me
|
|
602
|
+
</button>
|
|
603
|
+
// Plugin wraps entire button since style attribute has .get()
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
---
|
|
609
|
+
|
|
610
|
+
## FAQ
|
|
611
|
+
|
|
612
|
+
**Q: Does this work with TypeScript?**
|
|
613
|
+
A: Yes, the plugin processes TypeScript JSX files. Ensure `@babel/plugin-syntax-jsx` is included (or use the Vite plugin which handles this automatically).
|
|
614
|
+
|
|
615
|
+
**Q: What about `obs$?.get()` optional chaining?**
|
|
616
|
+
A: Supported — detected and wrapped correctly.
|
|
617
|
+
|
|
618
|
+
**Q: What if I don't use the `$` suffix?**
|
|
619
|
+
A: Enable `allGet: true` in options to detect all `.get()` calls regardless of variable name.
|
|
620
|
+
|
|
621
|
+
**Q: Is there a risk of infinite loops from re-visiting wrapped nodes?**
|
|
622
|
+
A: No — after wrapping, the visitor sees `{() => ...}` which is already a function and skips it.
|
|
623
|
+
|
|
624
|
+
**Q: Does this work with `observer()` HOC from `@legendapp/state/react`?**
|
|
625
|
+
A: Yes — the plugin detects `observer()` wrappers and skips content inside them.
|
|
626
|
+
|
|
627
|
+
**Q: What happens with `<For>` components?**
|
|
628
|
+
A: `For` is in the default reactive components list — content inside `For` is skipped (not wrapped).
|
|
629
|
+
|
|
630
|
+
**Q: Can I use a custom component instead of `Memo`?**
|
|
631
|
+
A: Yes — use `componentName` and `importSource` options.
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { types, PluginObj } from '@babel/core';
|
|
2
|
+
|
|
3
|
+
interface PluginOptions {
|
|
4
|
+
/** Wrapper component name (default: "Memo") */
|
|
5
|
+
componentName?: string;
|
|
6
|
+
/** Import source (default: "@legendapp/state/react") */
|
|
7
|
+
importSource?: string;
|
|
8
|
+
/** If true, detect all .get() calls regardless of $ suffix (default: false) */
|
|
9
|
+
allGet?: boolean;
|
|
10
|
+
/** Additional method names to detect (default: ["get"]) */
|
|
11
|
+
methodNames?: string[];
|
|
12
|
+
/** Skip additional reactive component names (merged with defaults: Auto, For, Show, Memo, Computed, Switch) */
|
|
13
|
+
reactiveComponents?: string[];
|
|
14
|
+
/** Observer HOC function names (default: ["observer"]) */
|
|
15
|
+
observerNames?: string[];
|
|
16
|
+
/**
|
|
17
|
+
* Auto-wrap non-function children of Memo/Show/Computed in () => (default: true)
|
|
18
|
+
* Equivalent to @legendapp/state/babel plugin behavior.
|
|
19
|
+
* Set to false to disable.
|
|
20
|
+
*/
|
|
21
|
+
wrapReactiveChildren?: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Additional component names whose children should be auto-wrapped as () =>
|
|
24
|
+
* Merged with defaults: ['Memo', 'Show', 'Computed']
|
|
25
|
+
*/
|
|
26
|
+
wrapReactiveChildrenComponents?: string[];
|
|
27
|
+
}
|
|
28
|
+
interface PluginState {
|
|
29
|
+
autoImportNeeded: boolean;
|
|
30
|
+
autoImportSource: string;
|
|
31
|
+
autoComponentName: string;
|
|
32
|
+
reactiveComponents: Set<string>;
|
|
33
|
+
observerNames: Set<string>;
|
|
34
|
+
autoWrapChildrenComponents: Set<string>;
|
|
35
|
+
opts: PluginOptions;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @usels/babel-plugin-legend-memo
|
|
40
|
+
*
|
|
41
|
+
* Automatically wraps Legend-State observable .get() calls in JSX with <Auto> component
|
|
42
|
+
* for fine-grained reactive rendering without wrapping entire components.
|
|
43
|
+
*
|
|
44
|
+
* Detection rules:
|
|
45
|
+
* - Zero-argument .get() on $-suffixed variables (e.g., count$.get(), user$.name.get())
|
|
46
|
+
* - Also supports optional chaining: obs$?.get()
|
|
47
|
+
* - Skips: .get(key) with args, non-$ vars (unless allGet:true), inside reactive contexts
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* Input: <div>{count$.get()}</div>
|
|
51
|
+
* Output: import { Auto } from "@usels/core";
|
|
52
|
+
* <div><Auto>{() => count$.get()}</Auto></div>
|
|
53
|
+
*/
|
|
54
|
+
declare function autoWrapPlugin({ types: t, }: {
|
|
55
|
+
types: typeof types;
|
|
56
|
+
}): PluginObj<PluginState>;
|
|
57
|
+
|
|
58
|
+
export { type PluginOptions, autoWrapPlugin as default };
|