@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 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.
@@ -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 };