unplugin-react-fine-grained-signals 0.1.0 → 0.2.0
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.ja.md +266 -42
- package/README.md +267 -42
- package/dist/esbuild.d.ts +3 -3
- package/dist/esbuild.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/loader.d.ts +2 -3
- package/dist/rollup.d.ts +3 -3
- package/dist/rollup.js +1 -1
- package/dist/rspack.d.ts +3 -3
- package/dist/rspack.js +1 -1
- package/dist/{unplugin-CG9_mdcA.d.ts → unplugin-B9gaexDa.d.ts} +3 -3
- package/dist/{unplugin-B2_q8KNi.js → unplugin-CPDTNSYq.js} +775 -112
- package/dist/vite.d.ts +14 -5
- package/dist/vite.js +1 -1
- package/dist/webpack.d.ts +3 -3
- package/dist/webpack.js +1 -1
- package/package.json +25 -16
|
@@ -4,8 +4,8 @@ import { declare } from "@babel/helper-plugin-utils";
|
|
|
4
4
|
import { transformSync } from "@babel/core";
|
|
5
5
|
import * as t from "@babel/types";
|
|
6
6
|
//#region src/internal/transform.ts
|
|
7
|
-
const
|
|
8
|
-
const
|
|
7
|
+
const useSignalTrackingComment = /(^|\s)@signalTracking(\s|$)/;
|
|
8
|
+
const noSignalTrackingComment = /(^|\s)@noSignalTracking(\s|$)/;
|
|
9
9
|
const transformedMetadataKey = "reactFineGrainedSignalsTransformed";
|
|
10
10
|
const noMemoDirective = "use no memo";
|
|
11
11
|
const memoizationDirectives = /* @__PURE__ */ new Set([
|
|
@@ -46,7 +46,7 @@ function isReactSource(source, reactImportSource) {
|
|
|
46
46
|
*
|
|
47
47
|
* Every import-recognition predicate in this file -- React's `memo`/
|
|
48
48
|
* `forwardRef` wrappers, a default-or-namespace React import, a named or
|
|
49
|
-
* namespace `
|
|
49
|
+
* namespace `useSignalTracking` import -- reduces to "does this resolved binding
|
|
50
50
|
* have the right specifier kind and source", so they all build on this one
|
|
51
51
|
* binding walk instead of each repeating
|
|
52
52
|
* `getBinding` -> specifier-kind -> `importKind` -> parent-declaration.
|
|
@@ -88,7 +88,7 @@ function isKnownComponentWrapper(path, reactImportSource) {
|
|
|
88
88
|
return object.isIdentifier() && isReactDefaultOrNamespaceImport(path, object.node.name, reactImportSource);
|
|
89
89
|
}
|
|
90
90
|
/**
|
|
91
|
-
* The static name of an object property's
|
|
91
|
+
* The static name of an object property's key. A computed key
|
|
92
92
|
* is only readable when it is a string literal (`{ ["Home"]: ... }`), exactly as
|
|
93
93
|
* `getReadPropertyName` reads `items["map"]`; anything else is decided at
|
|
94
94
|
* runtime and stays unnamed, which leaves the function untransformed.
|
|
@@ -100,15 +100,39 @@ function getPropertyKeyName(node) {
|
|
|
100
100
|
return t.isStringLiteral(key) ? key.value : void 0;
|
|
101
101
|
}
|
|
102
102
|
/**
|
|
103
|
+
* The identity an object-literal method takes from its key -- the shorthand
|
|
104
|
+
* twin of a keyed `ObjectProperty`:
|
|
105
|
+
*
|
|
106
|
+
* ```js
|
|
107
|
+
* export const ns = { Home() { return <p>{count.value}</p>; } }; // this
|
|
108
|
+
* export const ns = { Home: () => <p>{count.value}</p> }; // == this
|
|
109
|
+
* ```
|
|
110
|
+
*
|
|
111
|
+
* The two spellings put the same function at the same `ns.Home` slot, and the
|
|
112
|
+
* rest of the module reaches both through `<ns.Home />`, so they have to resolve
|
|
113
|
+
* to the same identity -- while only the property form did, the method form was
|
|
114
|
+
* passed over in silence. Unlike a property, the method *is* the function node,
|
|
115
|
+
* so there is no enclosing slot to climb to: its own key is read directly.
|
|
116
|
+
*
|
|
117
|
+
* Only a plain `kind: "method"` qualifies. A getter or setter is an accessor
|
|
118
|
+
* React never calls as a component, and an async or generator method can never
|
|
119
|
+
* carry a boundary (`decideTransform` refuses them anyway), so all four resolve
|
|
120
|
+
* to no identity at all rather than to a name that could only ever be rejected
|
|
121
|
+
* later or, under an annotation, turned into a build error.
|
|
122
|
+
*/
|
|
123
|
+
function getObjectMethodIdentityName(node) {
|
|
124
|
+
if (node.kind !== "method" || node.async || node.generator) return void 0;
|
|
125
|
+
return getPropertyKeyName(node);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
103
128
|
* The component/hook name a function held in a *keyed slot* takes from its key:
|
|
104
129
|
*
|
|
105
130
|
* ```js
|
|
106
131
|
* Card.Header = () => <h1>{count.value}</h1>; // a compound component
|
|
107
132
|
* export const ns = { Home: () => <p>{count.value}</p> }; // an object namespace
|
|
108
|
-
* class Holder { Row = () => <li>{count.value}</li>; } // a class field
|
|
109
133
|
* ```
|
|
110
134
|
*
|
|
111
|
-
*
|
|
135
|
+
* Both are ordinary ways to hold a component that no *binding* names, so
|
|
112
136
|
* without this they resolved to no identity at all and `auto` mode passed over
|
|
113
137
|
* them in silence. The key is the name the rest of the module reaches them by
|
|
114
138
|
* (`<ns.Home />`, `<Card.Header />`), which is precisely what the PascalCase /
|
|
@@ -135,8 +159,10 @@ function getPropertyKeyName(node) {
|
|
|
135
159
|
* disqualifies every other keyed slot (`isKeyedRenderCallback`) cannot see those
|
|
136
160
|
* uses at all -- there would be no way to take the boundary back once given. So
|
|
137
161
|
* the ambiguous case resolves the way this file always resolves one: name
|
|
138
|
-
* nothing, transform nothing. Class
|
|
139
|
-
*
|
|
162
|
+
* nothing, transform nothing. Class fields (`Row = () => ...`) are excluded the
|
|
163
|
+
* same way. Such a renderer is left to its author: one rendered as an element
|
|
164
|
+
* (`<this.Row />`) can call `useSignalTracking()` itself; one called as a
|
|
165
|
+
* function or passed per item cannot hold a hook at all.
|
|
140
166
|
*/
|
|
141
167
|
function getKeyedIdentityName(parent) {
|
|
142
168
|
if (parent.isAssignmentExpression()) {
|
|
@@ -144,7 +170,7 @@ function getKeyedIdentityName(parent) {
|
|
|
144
170
|
if (!t.isMemberExpression(left)) return void 0;
|
|
145
171
|
return getStaticMemberPath(left)?.keys.at(-1);
|
|
146
172
|
}
|
|
147
|
-
if (parent.isObjectProperty()
|
|
173
|
+
if (parent.isObjectProperty()) return getPropertyKeyName(parent.node);
|
|
148
174
|
}
|
|
149
175
|
/**
|
|
150
176
|
* A member expression read as a root binding plus the chain of static keys
|
|
@@ -157,22 +183,34 @@ function getKeyedIdentityName(parent) {
|
|
|
157
183
|
* match a use site against the definition site: a one-level walk saw
|
|
158
184
|
* `const a = { b: { Row } }` handed to `items.map(a.b.Row)` as an unrelated
|
|
159
185
|
* reference and let the per-item callback keep a hook boundary of its own.
|
|
186
|
+
*
|
|
187
|
+
* An optional link reads the same key: `ns?.group.Home`, `ns.group?.Home` and
|
|
188
|
+
* `ns.group.Home` all name one slot, so each link may be either member form.
|
|
160
189
|
*/
|
|
161
190
|
function getStaticMemberPath(node) {
|
|
191
|
+
const chain = getStaticMemberChain(node);
|
|
192
|
+
return chain !== void 0 && t.isIdentifier(chain.root) ? {
|
|
193
|
+
root: chain.root.name,
|
|
194
|
+
keys: chain.keys
|
|
195
|
+
} : void 0;
|
|
196
|
+
}
|
|
197
|
+
/** `getStaticMemberPath` without the root restriction: the innermost object node and the static keys. */
|
|
198
|
+
function getStaticMemberChain(node) {
|
|
162
199
|
const keys = [];
|
|
163
200
|
let current = node;
|
|
164
|
-
while (t.isMemberExpression(current)) {
|
|
201
|
+
while (t.isMemberExpression(current) || t.isOptionalMemberExpression(current)) {
|
|
165
202
|
const key = getReadPropertyName(current);
|
|
166
203
|
if (key === void 0) return void 0;
|
|
167
204
|
keys.unshift(key);
|
|
168
205
|
current = current.object;
|
|
169
206
|
}
|
|
170
|
-
return
|
|
171
|
-
root: current
|
|
207
|
+
return {
|
|
208
|
+
root: current,
|
|
172
209
|
keys
|
|
173
|
-
}
|
|
210
|
+
};
|
|
174
211
|
}
|
|
175
212
|
function getBindingIdentityName(path, reactImportSource, climbedParent = climbComponentWrappers(path, reactImportSource).parentPath) {
|
|
213
|
+
if (path.isObjectMethod()) return getObjectMethodIdentityName(path.node);
|
|
176
214
|
const parent = climbedParent;
|
|
177
215
|
if (parent !== null) {
|
|
178
216
|
if (parent.isVariableDeclarator() && t.isIdentifier(parent.node.id)) return parent.node.id.name;
|
|
@@ -254,7 +292,7 @@ function rendersJsx(path) {
|
|
|
254
292
|
* the returned component's JSX and `.value` reads credited to the factory's own
|
|
255
293
|
* body. `auto` mode injected the boundary into the factory, `all` mode did so
|
|
256
294
|
* with no `.value` read needed at all, and since `WithCount(Foo)` is normally
|
|
257
|
-
* called at module scope the injected `
|
|
295
|
+
* called at module scope the injected `useSignalTracking()` ran with no React
|
|
258
296
|
* dispatcher and threw at import time -- the unrecoverable direction this file
|
|
259
297
|
* steers away from everywhere else.
|
|
260
298
|
*/
|
|
@@ -291,7 +329,7 @@ function getSimpleParameterName(param) {
|
|
|
291
329
|
* The inner function is the real component in both, but it has no name of its
|
|
292
330
|
* own: its only identity comes from being what `withCount` returns. Left
|
|
293
331
|
* unresolved it is not a component to `auto` mode and not an annotatable
|
|
294
|
-
* function to `@
|
|
332
|
+
* function to `@signalTracking`, so a signal write silently stops re-rendering it
|
|
295
333
|
* -- no error, just a stale UI.
|
|
296
334
|
*
|
|
297
335
|
* Three things have to hold, tested cheapest-first:
|
|
@@ -326,8 +364,8 @@ function getSimpleParameterName(param) {
|
|
|
326
364
|
* is always a real module binding. A factory that returns a factory
|
|
327
365
|
* (`(a) => (b) => (props) => <p />`) deliberately leaves the innermost
|
|
328
366
|
* function unresolved rather than inventing a name for a middle function that
|
|
329
|
-
* has none -- an `@
|
|
330
|
-
* (`
|
|
367
|
+
* has none -- an `@signalTracking` annotation there still warns
|
|
368
|
+
* (`warnUnnamedSignalTrackingAnnotation`) instead of silently doing nothing.
|
|
331
369
|
*/
|
|
332
370
|
function getIdentityFactory(path, reactImportSource, climbedParent) {
|
|
333
371
|
if (climbedParent === null) return void 0;
|
|
@@ -408,24 +446,32 @@ function getDefaultExportIdentity(climbedParent, filename) {
|
|
|
408
446
|
name
|
|
409
447
|
};
|
|
410
448
|
}
|
|
411
|
-
function
|
|
412
|
-
return source === importSource
|
|
449
|
+
function isVerifiedSignalTrackingSource(source, importSource) {
|
|
450
|
+
return source === importSource;
|
|
451
|
+
}
|
|
452
|
+
function isNamedUseManagedSignalsImport(functionPath, name, runtimeSource) {
|
|
453
|
+
const resolved = resolveImportedBinding(functionPath, name);
|
|
454
|
+
return resolved !== void 0 && resolved.specifier.isImportSpecifier() && resolved.source === runtimeSource && t.isIdentifier(resolved.specifier.node.imported, { name: "useManagedSignals" });
|
|
455
|
+
}
|
|
456
|
+
function isNamespaceUseManagedSignalsImport(functionPath, name, runtimeSource) {
|
|
457
|
+
const resolved = resolveImportedBinding(functionPath, name);
|
|
458
|
+
return resolved !== void 0 && resolved.specifier.isImportNamespaceSpecifier() && resolved.source === runtimeSource;
|
|
413
459
|
}
|
|
414
|
-
function
|
|
460
|
+
function isNamedSignalTrackingImport(functionPath, name, importSource) {
|
|
415
461
|
const resolved = resolveImportedBinding(functionPath, name);
|
|
416
462
|
if (resolved === void 0 || !resolved.specifier.isImportSpecifier()) return false;
|
|
417
|
-
return t.isIdentifier(resolved.specifier.node.imported, { name: "
|
|
463
|
+
return t.isIdentifier(resolved.specifier.node.imported, { name: "useSignalTracking" }) && (importSource === void 0 || isVerifiedSignalTrackingSource(resolved.source, importSource));
|
|
418
464
|
}
|
|
419
|
-
function
|
|
465
|
+
function isNamespaceSignalTrackingImport(functionPath, name, importSource) {
|
|
420
466
|
const resolved = resolveImportedBinding(functionPath, name);
|
|
421
|
-
return resolved !== void 0 && resolved.specifier.isImportNamespaceSpecifier() && (importSource === void 0 ||
|
|
467
|
+
return resolved !== void 0 && resolved.specifier.isImportNamespaceSpecifier() && (importSource === void 0 || isVerifiedSignalTrackingSource(resolved.source, importSource));
|
|
422
468
|
}
|
|
423
|
-
function
|
|
424
|
-
if (callee.isIdentifier()) return
|
|
469
|
+
function isSignalTrackingCallee(functionPath, callee, importSource, allowBarrel = true) {
|
|
470
|
+
if (callee.isIdentifier()) return isNamedSignalTrackingImport(functionPath, callee.node.name, allowBarrel ? void 0 : importSource);
|
|
425
471
|
if (!callee.isMemberExpression()) return false;
|
|
426
472
|
const object = callee.get("object");
|
|
427
473
|
if (!object.isIdentifier()) return false;
|
|
428
|
-
return getReadPropertyName(callee.node) === "
|
|
474
|
+
return getReadPropertyName(callee.node) === "useSignalTracking" && isNamespaceSignalTrackingImport(functionPath, object.node.name, allowBarrel ? void 0 : importSource);
|
|
429
475
|
}
|
|
430
476
|
const renderCallbackMethods = /* @__PURE__ */ new Set([
|
|
431
477
|
"map",
|
|
@@ -527,9 +573,24 @@ function isRenderCallbackCallee(callee) {
|
|
|
527
573
|
* reach the former from the latter.
|
|
528
574
|
*/
|
|
529
575
|
function isRenderCallbackInvocation(call, callback) {
|
|
576
|
+
const argument = getRenderCallbackArgument(call);
|
|
577
|
+
return argument !== void 0 && argument === callback;
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* The argument slot of `call` that a known iteration method invokes once per
|
|
581
|
+
* item: argument 0 of `items.map` / `flatMap` / `forEach`, and argument 1 --
|
|
582
|
+
* the `mapFn` -- of `Array.from(arrayLike, mapFn)`, which builds an element per
|
|
583
|
+
* item exactly as `map` does (`Array.from({ length: 5 }, Star)`). Only a
|
|
584
|
+
* plain `Array` identifier counts as the callee's object; a shadowing local
|
|
585
|
+
* `Array` with its own `from` is not worth a scope lookup here, and matching it
|
|
586
|
+
* errs toward the crash-safe side (no hook in the callback).
|
|
587
|
+
*/
|
|
588
|
+
function getRenderCallbackArgument(call) {
|
|
530
589
|
const parts = getCallParts(call);
|
|
531
|
-
if (parts === void 0
|
|
532
|
-
return parts.arguments[0]
|
|
590
|
+
if (parts === void 0) return void 0;
|
|
591
|
+
if (isRenderCallbackCallee(parts.callee)) return parts.arguments[0];
|
|
592
|
+
const callee = unwrapTransparent(parts.callee);
|
|
593
|
+
if (t.isMemberExpression(callee) && t.isIdentifier(callee.object, { name: "Array" }) && getReadPropertyName(callee) === "from") return parts.arguments[1];
|
|
533
594
|
}
|
|
534
595
|
/**
|
|
535
596
|
* The name other statements can reach this function's own binding by. The
|
|
@@ -567,6 +628,10 @@ function getOwnBindingName(path) {
|
|
|
567
628
|
*
|
|
568
629
|
* A class field is deliberately absent -- it is reached through an instance
|
|
569
630
|
* (`new Holder().Row`), which no binding walk can follow.
|
|
631
|
+
*
|
|
632
|
+
* An object *method* (`{ Row() {...} }`) is passed in as its own `parent`: it is
|
|
633
|
+
* the function and the slot at once, and it sits in its object literal exactly
|
|
634
|
+
* where the equivalent `{ Row: ... }` property would, so the same climb applies.
|
|
570
635
|
*/
|
|
571
636
|
function getKeyedAccessPath(parent) {
|
|
572
637
|
if (parent.isAssignmentExpression()) {
|
|
@@ -578,7 +643,7 @@ function getKeyedAccessPath(parent) {
|
|
|
578
643
|
...path
|
|
579
644
|
};
|
|
580
645
|
}
|
|
581
|
-
if (!parent.isObjectProperty()) return void 0;
|
|
646
|
+
if (!parent.isObjectProperty() && !parent.isObjectMethod()) return void 0;
|
|
582
647
|
const key = getPropertyKeyName(parent.node);
|
|
583
648
|
if (key === void 0) return void 0;
|
|
584
649
|
const keys = [key];
|
|
@@ -625,20 +690,293 @@ function getKeyedAccessPath(parent) {
|
|
|
625
690
|
* genuine compound component keeps its boundary.
|
|
626
691
|
*/
|
|
627
692
|
function isKeyedRenderCallback(parent) {
|
|
628
|
-
|
|
629
|
-
|
|
693
|
+
return getKeyedSafetyUses(parent).some((slot) => slot.parentPath !== null && isRenderCallbackInvocation(slot.parentPath.node, slot.node));
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Every place the module reads the exact keyed slot `access` describes, as the
|
|
697
|
+
* path of the syntactic slot that read occupies (past transparent TypeScript
|
|
698
|
+
* wrappers, so `ns.Row!` hands back the wrapper that actually sits in an
|
|
699
|
+
* argument list or callee position).
|
|
700
|
+
*
|
|
701
|
+
* The walk goes through the *root* binding, then follows the same chain of keys
|
|
702
|
+
* back down from each reference; a reference that turns off the chain anywhere
|
|
703
|
+
* (`ns.Other`, `ns` passed whole -- or `ns[k]` unless `includeDynamic` is set)
|
|
704
|
+
* is not a use of this slot. Both
|
|
705
|
+
* by-key exclusions -- the render-callback one above and the plain-call one in
|
|
706
|
+
* `isPlainCalledComponent` -- ask their question of this list (joined with the
|
|
707
|
+
* `this` uses in `getKeyedSafetyUses`), so they cannot disagree about which
|
|
708
|
+
* reads count as reaching the slot.
|
|
709
|
+
*/
|
|
710
|
+
function getKeyedSlotUses(access, includeDynamic = false) {
|
|
630
711
|
const binding = access.origin.scope.getBinding(access.root);
|
|
631
|
-
if (binding === void 0) return
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
712
|
+
if (binding === void 0) return [];
|
|
713
|
+
const uses = [];
|
|
714
|
+
for (const rootPath of binding.referencePaths) followKeyedChain(rootPath, access.keys, includeDynamic, uses);
|
|
715
|
+
return uses;
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Follows `keys` down from `start` -- a reference to the root binding, or a
|
|
719
|
+
* `this` that stands for the object holding the slot -- and records the use it
|
|
720
|
+
* reaches in `uses`. A chain that turns off the path records nothing.
|
|
721
|
+
*/
|
|
722
|
+
function followKeyedChain(start, keys, includeDynamic, uses) {
|
|
723
|
+
let current = start;
|
|
724
|
+
for (const [index, key] of keys.entries()) {
|
|
725
|
+
const member = current.parentPath;
|
|
726
|
+
if (member === null || !matchesKeyedLink(member, current, key, includeDynamic)) {
|
|
727
|
+
if (index === keys.length - 1) uses.push(...getDestructuredSlotUses(current, key));
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
current = member;
|
|
731
|
+
}
|
|
732
|
+
uses.push(climbTransparentWrappers(current));
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Every place a sibling reaches the object-literal slot `holder` through
|
|
736
|
+
* `this`:
|
|
737
|
+
*
|
|
738
|
+
* ```jsx
|
|
739
|
+
* const renderers = {
|
|
740
|
+
* Leaf(i) { return <li>{suffix.value}</li>; },
|
|
741
|
+
* Tree(count) { return <ul>{Array.from({ length: count }, (_, i) => this.Leaf(i))}</ul>; },
|
|
742
|
+
* };
|
|
743
|
+
* ```
|
|
744
|
+
*
|
|
745
|
+
* `this.Leaf(i)` runs `Leaf` once per item inside whatever render called
|
|
746
|
+
* `Tree`, exactly as `renderers.Leaf(i)` would, but `this` is not a binding,
|
|
747
|
+
* so the root-reference walk in `getKeyedSlotUses` never sees it -- and a
|
|
748
|
+
* boundary in `Leaf` was then a variable number of hooks in the caller.
|
|
749
|
+
*
|
|
750
|
+
* `this` is only followed where its identity is syntactic: inside a function
|
|
751
|
+
* that is itself a non-arrow member of the object literal (a method, accessor,
|
|
752
|
+
* or `function` property value), and inside arrows nested in it, which inherit
|
|
753
|
+
* that `this`. A nested `function` or class rebinds `this` and is skipped. Each
|
|
754
|
+
* enclosing object literal is its own level, so `this.Leaf` in a method of the
|
|
755
|
+
* object holding `Leaf` reaches it, `this.group.Leaf` in a method of the outer
|
|
756
|
+
* object reaches it through `group`, and a same-named slot in another object
|
|
757
|
+
* literal is never touched. A computed `this[key]` reaches every slot at its
|
|
758
|
+
* depth, like `renderers[key]` does.
|
|
759
|
+
*
|
|
760
|
+
* Only the object literals around this one slot are walked, never the
|
|
761
|
+
* program, so the cost stays with the object that holds the slot.
|
|
762
|
+
*/
|
|
763
|
+
function getThisSlotUses(holder) {
|
|
764
|
+
if (!holder.isObjectProperty() && !holder.isObjectMethod()) return [];
|
|
765
|
+
const key = getPropertyKeyName(holder.node);
|
|
766
|
+
if (key === void 0) return [];
|
|
767
|
+
const keys = [key];
|
|
768
|
+
const uses = [];
|
|
769
|
+
for (let container = holder.parentPath; container?.isObjectExpression();) {
|
|
770
|
+
for (const reference of getObjectThisReferences(container)) followKeyedChain(reference, keys, true, uses);
|
|
771
|
+
const outer = container.parentPath;
|
|
772
|
+
if (outer === null || !outer.isObjectProperty() || outer.node.value !== container.node) break;
|
|
773
|
+
const outerKey = getPropertyKeyName(outer.node);
|
|
774
|
+
if (outerKey === void 0) break;
|
|
775
|
+
keys.unshift(outerKey);
|
|
776
|
+
container = outer.parentPath;
|
|
777
|
+
}
|
|
778
|
+
return uses;
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* `getObjectThisReferences` results, per object literal. One function's
|
|
782
|
+
* decision asks for the same object's references many times over -- every
|
|
783
|
+
* candidacy check of every nested function lands here -- so they are collected
|
|
784
|
+
* once. Keyed by node, so nothing outlives the file being transformed.
|
|
785
|
+
*
|
|
786
|
+
* A rewrite moves the rewritten function's statements under new parents, and a
|
|
787
|
+
* cached path inside it would then describe a tree that no longer exists, so
|
|
788
|
+
* `invalidateThisReferences` drops every entry whose references could lie
|
|
789
|
+
* inside that function: the object literals within it, and -- only when it
|
|
790
|
+
* contains a `this` at all -- the object literals around it. Every other entry
|
|
791
|
+
* stays valid, which matters in a module where most object members are
|
|
792
|
+
* transformed one after another and only a few of them use `this`.
|
|
793
|
+
*/
|
|
794
|
+
const thisReferenceCache = /* @__PURE__ */ new WeakMap();
|
|
795
|
+
function invalidateThisReferences(rewritten) {
|
|
796
|
+
let containsThis = false;
|
|
797
|
+
rewritten.traverse({
|
|
798
|
+
ObjectExpression(object) {
|
|
799
|
+
thisReferenceCache.delete(object.node);
|
|
800
|
+
},
|
|
801
|
+
ThisExpression() {
|
|
802
|
+
containsThis = true;
|
|
638
803
|
}
|
|
639
|
-
const slot = climbTransparentWrappers(current);
|
|
640
|
-
return slot.parentPath !== null && isRenderCallbackInvocation(slot.parentPath.node, slot.node);
|
|
641
804
|
});
|
|
805
|
+
if (!containsThis) return;
|
|
806
|
+
for (let parent = rewritten.parentPath; parent !== null; parent = parent.parentPath) if (parent.isObjectExpression()) thisReferenceCache.delete(parent.node);
|
|
807
|
+
}
|
|
808
|
+
/** The `this` expressions that stand for `object` itself, inside its own non-arrow members. */
|
|
809
|
+
function getObjectThisReferences(object) {
|
|
810
|
+
const cached = thisReferenceCache.get(object.node);
|
|
811
|
+
if (cached !== void 0) return cached;
|
|
812
|
+
const references = [];
|
|
813
|
+
thisReferenceCache.set(object.node, references);
|
|
814
|
+
for (const property of object.get("properties")) {
|
|
815
|
+
let member;
|
|
816
|
+
if (property.isObjectMethod()) member = property;
|
|
817
|
+
else if (property.isObjectProperty()) {
|
|
818
|
+
const value = unwrapTransparentPath(property.get("value"));
|
|
819
|
+
if (value.isFunctionExpression()) member = value;
|
|
820
|
+
}
|
|
821
|
+
member?.traverse({
|
|
822
|
+
Function(nested) {
|
|
823
|
+
if (!nested.isArrowFunctionExpression()) nested.skip();
|
|
824
|
+
},
|
|
825
|
+
Class(nested) {
|
|
826
|
+
nested.skip();
|
|
827
|
+
},
|
|
828
|
+
ThisExpression(reference) {
|
|
829
|
+
references.push(reference);
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
return references;
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Every use that can run the keyed slot `holder` inline: reads through its root
|
|
837
|
+
* binding, computed keys included (`getKeyedSlotUses`), and reads through
|
|
838
|
+
* `this` from a sibling member of the same object literal (`getThisSlotUses`).
|
|
839
|
+
* Both safety exclusions -- per-item callback and plain call -- ask this list.
|
|
840
|
+
*/
|
|
841
|
+
function getKeyedSafetyUses(holder, access = getKeyedAccessPath(holder)) {
|
|
842
|
+
const uses = access === void 0 ? [] : getKeyedSlotUses(access, true);
|
|
843
|
+
uses.push(...getThisSlotUses(holder));
|
|
844
|
+
return uses;
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Does `member` read `key` off `object` -- one link of a keyed-slot path?
|
|
848
|
+
*
|
|
849
|
+
* A static link has to name the key exactly. With `includeDynamic`, a computed
|
|
850
|
+
* link whose key is decided at runtime (`renderers[node.type]`,
|
|
851
|
+
* `renderers?.[key]`) matches too, because nothing rules out that it names this
|
|
852
|
+
* slot. That is what keeps an object-literal component off a boundary of its
|
|
853
|
+
* own when the module dispatches to it dynamically:
|
|
854
|
+
*
|
|
855
|
+
* ```jsx
|
|
856
|
+
* const renderers = { User(node) { return <li>{suffix.value}</li>; } };
|
|
857
|
+
* nodes.map((node) => renderers[node.__typename](node));
|
|
858
|
+
* ```
|
|
859
|
+
*
|
|
860
|
+
* `User` is called once per item inside the caller's render, exactly like
|
|
861
|
+
* `renderers.User(node)` would be, so a boundary in it is a variable number of
|
|
862
|
+
* hooks in the caller -- "Rendered more hooks than during the previous render"
|
|
863
|
+
* as soon as the list grows. The static spelling of that call was already
|
|
864
|
+
* excluded; the dynamic one could not be named, so the slot kept its boundary.
|
|
865
|
+
*
|
|
866
|
+
* Only a link at the right depth counts, so `renderers[key](node)` reaches
|
|
867
|
+
* `renderers.User` but not `renderers.group.User`, and `registry.users[key]`
|
|
868
|
+
* reaches only the slots under `registry.users`.
|
|
869
|
+
*
|
|
870
|
+
* An optional link (`ns?.Home`, `renderers?.[key]`) is matched exactly like
|
|
871
|
+
* the plain one, static or dynamic: it reaches the very same slot, and missing
|
|
872
|
+
* it costs a crash -- `{show && ns?.Home()}` is as much a conditional call
|
|
873
|
+
* inside the caller as `{show && ns.Home()}` is.
|
|
874
|
+
*/
|
|
875
|
+
function matchesKeyedLink(member, object, key, includeDynamic) {
|
|
876
|
+
if (!member.isMemberExpression() && !member.isOptionalMemberExpression()) return false;
|
|
877
|
+
if (member.node.object !== object.node) return false;
|
|
878
|
+
const name = getReadPropertyName(member.node);
|
|
879
|
+
return name === key || includeDynamic && name === void 0;
|
|
880
|
+
}
|
|
881
|
+
/**
|
|
882
|
+
* The references of the binding that `const { key } = object` or
|
|
883
|
+
* `const { key: Alias } = object` creates, when `object` is the node that
|
|
884
|
+
* initializes that declarator; nothing otherwise.
|
|
885
|
+
*/
|
|
886
|
+
function getDestructuredSlotUses(object, key) {
|
|
887
|
+
const declarator = object.parentPath;
|
|
888
|
+
if (declarator === null || !declarator.isVariableDeclarator() || declarator.node.init !== object.node || !t.isObjectPattern(declarator.node.id)) return [];
|
|
889
|
+
for (const property of declarator.node.id.properties) {
|
|
890
|
+
if (!t.isObjectProperty(property) || !t.isIdentifier(property.value)) continue;
|
|
891
|
+
if (getPropertyKeyName(property) !== key) continue;
|
|
892
|
+
const alias = declarator.scope.getBinding(property.value.name);
|
|
893
|
+
return alias === void 0 ? [] : alias.referencePaths.map((reference) => climbTransparentWrappers(reference));
|
|
894
|
+
}
|
|
895
|
+
return [];
|
|
896
|
+
}
|
|
897
|
+
/** Is `slot` the callee of a plain or optional-chained call -- `Row()`, `Row?.()`, `ns.Row!()`? */
|
|
898
|
+
function isPlainCallee(slot) {
|
|
899
|
+
const call = slot.parentPath;
|
|
900
|
+
if (call === null) return false;
|
|
901
|
+
const parts = getCallParts(call.node);
|
|
902
|
+
return parts !== void 0 && parts.callee === slot.node;
|
|
903
|
+
}
|
|
904
|
+
/**
|
|
905
|
+
* Is `path` a component-named function that the module invokes as a plain
|
|
906
|
+
* function -- `Title()`, `Title?.()`, `Title!()`, `ns.Home()` -- rather than
|
|
907
|
+
* (only) mounting it through JSX?
|
|
908
|
+
*
|
|
909
|
+
* ```jsx
|
|
910
|
+
* function Title() { return <h1>{count.value}</h1>; }
|
|
911
|
+
* function Outer({ show }) { return <div>{show && Title()}</div>; }
|
|
912
|
+
* ```
|
|
913
|
+
*
|
|
914
|
+
* `Title` reads exactly like a component, but a plain call never mounts a
|
|
915
|
+
* fiber: React sees only `Outer`, and `Title`'s body simply runs inside
|
|
916
|
+
* `Outer`'s render. A boundary injected into it is therefore three more hooks
|
|
917
|
+
* *in `Outer`*, and here they run only when `show` is true -- the conditional
|
|
918
|
+
* hook call the Rules of Hooks forbid, which React reports as "Rendered more
|
|
919
|
+
* hooks than during the previous render" (or "Expected static flag was
|
|
920
|
+
* missing") the first time `show` flips. The render-callback exclusion
|
|
921
|
+
* (`isRenderCallback`) already handled the looped variant of this -- `Row`
|
|
922
|
+
* called once per item from inside `items.map(...)` -- and this is the same
|
|
923
|
+
* fact stated for every call site, not just the ones inside a loop: a function
|
|
924
|
+
* that is called is not a component, whatever it is named.
|
|
925
|
+
*
|
|
926
|
+
* The exclusion is decided from the module as a whole -- *any* plain call of the
|
|
927
|
+
* function's own binding (or of its exact keyed slot) disqualifies it -- because
|
|
928
|
+
* a boundary is a property of the function, not of one call site: once one
|
|
929
|
+
* caller runs the body inline, a boundary inside it is wrong for that caller no
|
|
930
|
+
* matter what the others do. The reads are not lost; `inspectFunction` folds
|
|
931
|
+
* them into the function that makes the call (`foldDirectlyCalledRenderCallbacks`),
|
|
932
|
+
* so `Outer` is the one that gets transformed and subscribes -- which is right,
|
|
933
|
+
* since `Outer` is the fiber that re-renders and re-runs `Title()`.
|
|
934
|
+
*
|
|
935
|
+
* Deliberately narrow, in both directions:
|
|
936
|
+
*
|
|
937
|
+
* - Only a *component* name is affected. A `useX` hook is called as a function
|
|
938
|
+
* by definition, and its boundary is valid precisely because React runs it
|
|
939
|
+
* inside a render already in progress; hooks keep their handling unchanged.
|
|
940
|
+
* - Only the function's *own* binding counts, as in `isRenderCallback`: a
|
|
941
|
+
* same-named binding in another scope cannot disqualify it, and an alias
|
|
942
|
+
* (`const T = Title; T()`) is not followed. Neither is `Title.call(...)` or
|
|
943
|
+
* `new Title()`, which are not plain calls of the binding.
|
|
944
|
+
*/
|
|
945
|
+
function isPlainCalledComponent(path) {
|
|
946
|
+
const slot = climbTransparentWrappers(path);
|
|
947
|
+
const holder = path.isObjectMethod() ? path : slot.parentPath;
|
|
948
|
+
if (holder === null) return false;
|
|
949
|
+
const access = getKeyedAccessPath(holder);
|
|
950
|
+
const key = access?.keys.at(-1) ?? (holder.isObjectProperty() || holder.isObjectMethod() ? getPropertyKeyName(holder.node) : void 0);
|
|
951
|
+
if (key !== void 0) return isComponentName(key) && getKeyedSafetyUses(holder, access).some(isPlainCallee);
|
|
952
|
+
return getOwnBindingReferences(path, holder)?.some((reference) => isPlainCallee(climbTransparentWrappers(reference))) ?? false;
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* The reference paths of `path`'s own component-named binding, or `undefined`
|
|
956
|
+
* when it has none -- no own binding, a non-component name, or a same-named
|
|
957
|
+
* binding that belongs to some other function. `holder` is the climbed parent
|
|
958
|
+
* the binding is looked up from, matching `isRenderCallback`'s own lookup.
|
|
959
|
+
*/
|
|
960
|
+
function getOwnBindingReferences(path, holder) {
|
|
961
|
+
const name = getOwnBindingName(path);
|
|
962
|
+
if (name === void 0 || !isComponentName(name)) return void 0;
|
|
963
|
+
const binding = holder.scope.getBinding(name);
|
|
964
|
+
if (binding === void 0) return void 0;
|
|
965
|
+
if (binding.path.node !== path.node && binding.path.node !== holder.node) return void 0;
|
|
966
|
+
return binding.referencePaths;
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* Is `path` -- already known to be plain-called -- *also* mounted through JSX
|
|
970
|
+
* by its own binding (`<Title />`)? That instance loses its own boundary along
|
|
971
|
+
* with the plain-called one, so `decideTransform` reports the mix rather than
|
|
972
|
+
* leaving the `<Title />` instance to go stale in silence. Babel records a JSX
|
|
973
|
+
* tag as an ordinary reference to the binding it names, which is what makes
|
|
974
|
+
* this a plain reference walk.
|
|
975
|
+
*/
|
|
976
|
+
function isAlsoMountedThroughJsx(path) {
|
|
977
|
+
const holder = climbTransparentWrappers(path).parentPath;
|
|
978
|
+
if (holder === null || path.isObjectMethod()) return false;
|
|
979
|
+
return getOwnBindingReferences(path, holder)?.some((reference) => reference.isJSXIdentifier()) ?? false;
|
|
642
980
|
}
|
|
643
981
|
function isRenderCallback(path, visited = /* @__PURE__ */ new Set()) {
|
|
644
982
|
if (visited.has(path.node)) return false;
|
|
@@ -646,7 +984,8 @@ function isRenderCallback(path, visited = /* @__PURE__ */ new Set()) {
|
|
|
646
984
|
const argument = climbTransparentWrappers(path);
|
|
647
985
|
const enclosing = argument.parentPath;
|
|
648
986
|
if (enclosing !== null && isRenderCallbackInvocation(enclosing.node, argument.node)) return true;
|
|
649
|
-
|
|
987
|
+
const keyedHolder = path.isObjectMethod() ? path : enclosing;
|
|
988
|
+
if (keyedHolder !== null && isKeyedRenderCallback(keyedHolder)) return true;
|
|
650
989
|
const name = getOwnBindingName(path);
|
|
651
990
|
if (name === void 0 || enclosing === null) return false;
|
|
652
991
|
const binding = enclosing.scope.getBinding(name);
|
|
@@ -692,6 +1031,87 @@ function resolveReferencedFunction(origin, name) {
|
|
|
692
1031
|
}
|
|
693
1032
|
}
|
|
694
1033
|
/**
|
|
1034
|
+
* The keyed-slot twin of `resolveReferencedFunction`: the function a static
|
|
1035
|
+
* member callee such as `ns.Home` / `deep.a.Home` / `Card.Header` reaches, when
|
|
1036
|
+
* this module defines it in one of the keyed shapes `getKeyedAccessPath`
|
|
1037
|
+
* names -- an object literal bound to the root (`const ns = { Home() {} }`,
|
|
1038
|
+
* `{ Home: () => ... }`, nested to any depth) or a member assignment
|
|
1039
|
+
* (`Card.Header = () => ...`).
|
|
1040
|
+
*
|
|
1041
|
+
* Needed because `isPlainCalledComponent` takes the boundary away from such a
|
|
1042
|
+
* slot the moment it is called (`ns.Home()`), and without resolving it here its
|
|
1043
|
+
* reads would reach nobody: the slot no longer subscribes, and the caller never
|
|
1044
|
+
* learns what it read. Anything this cannot resolve unambiguously -- a spread,
|
|
1045
|
+
* a computed runtime key, an import -- resolves to nothing, which leaves the
|
|
1046
|
+
* caller exactly as it would otherwise have been.
|
|
1047
|
+
*/
|
|
1048
|
+
function resolveKeyedFunction(origin, callee) {
|
|
1049
|
+
const chain = getStaticMemberChain(callee);
|
|
1050
|
+
if (chain !== void 0 && t.isThisExpression(chain.root)) {
|
|
1051
|
+
const object = getThisObject(origin);
|
|
1052
|
+
return object === void 0 ? void 0 : resolveObjectLiteralSlot(object, chain.keys);
|
|
1053
|
+
}
|
|
1054
|
+
const access = getStaticMemberPath(callee);
|
|
1055
|
+
if (access === void 0) return void 0;
|
|
1056
|
+
const binding = origin.scope.getBinding(access.root);
|
|
1057
|
+
if (binding === void 0) return void 0;
|
|
1058
|
+
if (binding.path.isVariableDeclarator()) {
|
|
1059
|
+
const found = resolveObjectLiteralSlot(unwrapTransparentPath(binding.path.get("init")), access.keys);
|
|
1060
|
+
if (found !== void 0) return found;
|
|
1061
|
+
}
|
|
1062
|
+
const assigned = [];
|
|
1063
|
+
for (const slot of getKeyedSlotUses({
|
|
1064
|
+
origin,
|
|
1065
|
+
...access
|
|
1066
|
+
})) {
|
|
1067
|
+
const parent = slot.parentPath;
|
|
1068
|
+
if (parent !== null && parent.isAssignmentExpression({ operator: "=" }) && parent.node.left === slot.node) assigned.push(parent);
|
|
1069
|
+
}
|
|
1070
|
+
const only = assigned.length === 1 ? assigned[0] : void 0;
|
|
1071
|
+
if (only === void 0) return void 0;
|
|
1072
|
+
const value = unwrapTransparentPath(only.get("right"));
|
|
1073
|
+
return value.isArrowFunctionExpression() || value.isFunctionExpression() ? value : void 0;
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* The function at `keys` inside the object literal `object`, walking down
|
|
1077
|
+
* through nested literals. The *last* matching property wins, as it does at
|
|
1078
|
+
* runtime; anything that is not a literal or not a function resolves to nothing.
|
|
1079
|
+
*/
|
|
1080
|
+
function resolveObjectLiteralSlot(object, keys) {
|
|
1081
|
+
let container = object;
|
|
1082
|
+
for (const key of keys) {
|
|
1083
|
+
if (container === void 0 || !container.isObjectExpression()) return void 0;
|
|
1084
|
+
const match = container.get("properties").filter((property) => (property.isObjectProperty() || property.isObjectMethod()) && getPropertyKeyName(property.node) === key).at(-1);
|
|
1085
|
+
container = match?.isObjectProperty() ? unwrapTransparentPath(match.get("value")) : match;
|
|
1086
|
+
}
|
|
1087
|
+
if (container === void 0) return void 0;
|
|
1088
|
+
if (container.isObjectMethod()) return container;
|
|
1089
|
+
if (container.isArrowFunctionExpression() || container.isFunctionExpression()) return container;
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* The object literal a `this` written at `origin` stands for, when that is
|
|
1093
|
+
* syntactic: the nearest non-arrow function is a member of an object literal
|
|
1094
|
+
* (a method, accessor, or `function` property value) and no class sits in
|
|
1095
|
+
* between. The inverse of the walk `getObjectThisReferences` does.
|
|
1096
|
+
*/
|
|
1097
|
+
function getThisObject(origin) {
|
|
1098
|
+
let owner = origin.getFunctionParent();
|
|
1099
|
+
while (owner !== null && owner.isArrowFunctionExpression()) owner = owner.getFunctionParent();
|
|
1100
|
+
if (owner === null) return void 0;
|
|
1101
|
+
const blocker = origin.findParent((parent) => parent.node === owner.node || parent.isClass());
|
|
1102
|
+
if (blocker === null || blocker.node !== owner.node) return void 0;
|
|
1103
|
+
if (owner.isObjectMethod()) {
|
|
1104
|
+
const object = owner.parentPath;
|
|
1105
|
+
return object.isObjectExpression() ? object : void 0;
|
|
1106
|
+
}
|
|
1107
|
+
if (!owner.isFunctionExpression()) return void 0;
|
|
1108
|
+
const property = climbTransparentWrappers(owner);
|
|
1109
|
+
const holder = property.parentPath;
|
|
1110
|
+
if (holder === null || !holder.isObjectProperty() || holder.node.value !== property.node) return void 0;
|
|
1111
|
+
const object = holder.parentPath;
|
|
1112
|
+
return object.isObjectExpression() ? object : void 0;
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
695
1115
|
* Is `path` a component defined inline as a call argument --
|
|
696
1116
|
* `observer(function App() { return <p>{count.value}</p>; })`?
|
|
697
1117
|
*
|
|
@@ -731,9 +1151,10 @@ function isInlineNamedComponentArgument(path, call, reactImportSource) {
|
|
|
731
1151
|
}
|
|
732
1152
|
function isAutomaticTransformCandidate(path, reactImportSource, climbedParent) {
|
|
733
1153
|
if (isRenderCallback(path)) return false;
|
|
734
|
-
if (path
|
|
1154
|
+
if (isPlainCalledComponent(path)) return false;
|
|
1155
|
+
if (path.isFunctionDeclaration() || path.isObjectMethod()) return true;
|
|
735
1156
|
const parent = climbedParent === void 0 ? climbComponentWrappers(path, reactImportSource).parentPath : climbedParent;
|
|
736
|
-
return parent !== null && (parent.isVariableDeclarator() || parent.isReturnStatement() || parent.isArrowFunctionExpression() || parent.isExportDefaultDeclaration() || parent.isObjectProperty() || parent.
|
|
1157
|
+
return parent !== null && (parent.isVariableDeclarator() || parent.isReturnStatement() || parent.isArrowFunctionExpression() || parent.isExportDefaultDeclaration() || parent.isObjectProperty() || parent.isAssignmentExpression() && t.isMemberExpression(parent.node.left) && getStaticMemberPath(parent.node.left) !== void 0 || parent.isCallExpression() && isInlineNamedComponentArgument(path, parent, reactImportSource));
|
|
737
1158
|
}
|
|
738
1159
|
function isNestedTrackingBoundary(path, reactImportSource) {
|
|
739
1160
|
const climbedParent = climbComponentWrappers(path, reactImportSource).parentPath;
|
|
@@ -818,14 +1239,27 @@ function isDeferredReadContext(path, functionPath) {
|
|
|
818
1239
|
}
|
|
819
1240
|
return false;
|
|
820
1241
|
}
|
|
821
|
-
|
|
1242
|
+
/**
|
|
1243
|
+
* Is `node` (at `path`) a `.value` read that counts as part of `functionPath`'s
|
|
1244
|
+
* render -- the one rule both `inspectFunction` (does this function subscribe
|
|
1245
|
+
* at all?) and `relocateTrackedParameters` (does this parameter have to move
|
|
1246
|
+
* behind the boundary?) apply, so the two can never disagree about what a
|
|
1247
|
+
* tracked read is.
|
|
1248
|
+
*/
|
|
1249
|
+
function isTrackedValueRead(node, path, functionPath) {
|
|
1250
|
+
if (getReadPropertyName(node) !== "value") return false;
|
|
1251
|
+
if (isNonSignalValueReceiver(node.object)) return false;
|
|
1252
|
+
return !isDeferredReadContext(path, functionPath);
|
|
1253
|
+
}
|
|
1254
|
+
function inspectFunction(functionPath, importSource, managedRuntimeSource, reactImportSource, visited = /* @__PURE__ */ new Set([functionPath.node])) {
|
|
822
1255
|
const inspection = {
|
|
823
1256
|
containsJSX: false,
|
|
824
1257
|
readsValue: false,
|
|
825
|
-
|
|
1258
|
+
hasSignalTrackingCall: false,
|
|
1259
|
+
hasUseManagedSignalsCall: false
|
|
826
1260
|
};
|
|
827
1261
|
const foldReferencedRenderCallbacks = (call) => {
|
|
828
|
-
const argument = call.node
|
|
1262
|
+
const argument = getRenderCallbackArgument(call.node);
|
|
829
1263
|
if (argument === void 0) return;
|
|
830
1264
|
const reference = unwrapTransparent(argument);
|
|
831
1265
|
if (!t.isIdentifier(reference)) return;
|
|
@@ -834,28 +1268,33 @@ function inspectFunction(functionPath, importSource, reactImportSource, visited
|
|
|
834
1268
|
if (target === void 0 || target.isDescendant(functionPath)) return;
|
|
835
1269
|
if (visited.has(target.node)) return;
|
|
836
1270
|
visited.add(target.node);
|
|
837
|
-
const nested = inspectFunction(target, importSource, reactImportSource, visited);
|
|
1271
|
+
const nested = inspectFunction(target, importSource, managedRuntimeSource, reactImportSource, visited);
|
|
838
1272
|
if (nested.containsJSX) inspection.containsJSX = true;
|
|
839
1273
|
if (nested.readsValue) inspection.readsValue = true;
|
|
840
1274
|
};
|
|
841
1275
|
const foldDirectlyCalledRenderCallbacks = (call) => {
|
|
842
1276
|
const callee = unwrapTransparent(call.node.callee);
|
|
843
|
-
|
|
1277
|
+
const member = t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee) ? callee : void 0;
|
|
1278
|
+
const chain = member === void 0 ? void 0 : getStaticMemberChain(member);
|
|
1279
|
+
const name = t.isIdentifier(callee) ? callee.name : chain !== void 0 && (t.isIdentifier(chain.root) || t.isThisExpression(chain.root)) ? chain.keys.at(-1) : void 0;
|
|
1280
|
+
if (name === void 0 || !isComponentName(name) && !isHookName(name)) return;
|
|
1281
|
+
if (!t.isIdentifier(callee) && !isComponentName(name)) return;
|
|
844
1282
|
const owner = call.getFunctionParent();
|
|
845
|
-
if (owner === null
|
|
846
|
-
|
|
1283
|
+
if (owner === null) return;
|
|
1284
|
+
if (owner.node === functionPath.node || !isRenderCallback(owner)) {
|
|
1285
|
+
if (!isComponentName(name)) return;
|
|
1286
|
+
if (isDeferredReadContext(call, functionPath)) return;
|
|
1287
|
+
}
|
|
1288
|
+
const target = t.isIdentifier(callee) ? resolveReferencedFunction(call, callee.name) : member !== void 0 ? resolveKeyedFunction(call, member) : void 0;
|
|
847
1289
|
if (target === void 0 || target.isDescendant(functionPath)) return;
|
|
848
1290
|
if (visited.has(target.node)) return;
|
|
849
1291
|
visited.add(target.node);
|
|
850
|
-
const nested = inspectFunction(target, importSource, reactImportSource, visited);
|
|
1292
|
+
const nested = inspectFunction(target, importSource, managedRuntimeSource, reactImportSource, visited);
|
|
851
1293
|
if (nested.containsJSX) inspection.containsJSX = true;
|
|
852
1294
|
if (nested.readsValue) inspection.readsValue = true;
|
|
853
1295
|
};
|
|
854
1296
|
const recordValueRead = (node, path) => {
|
|
855
|
-
if (
|
|
856
|
-
if (isNonSignalValueReceiver(node.object)) return;
|
|
857
|
-
if (isDeferredReadContext(path, functionPath)) return;
|
|
858
|
-
inspection.readsValue = true;
|
|
1297
|
+
if (isTrackedValueRead(node, path, functionPath)) inspection.readsValue = true;
|
|
859
1298
|
};
|
|
860
1299
|
functionPath.traverse({
|
|
861
1300
|
Function(path) {
|
|
@@ -877,7 +1316,9 @@ function inspectFunction(functionPath, importSource, reactImportSource, visited
|
|
|
877
1316
|
foldReferencedRenderCallbacks(path);
|
|
878
1317
|
foldDirectlyCalledRenderCallbacks(path);
|
|
879
1318
|
if (path.getFunctionParent()?.node !== functionPath.node) return;
|
|
880
|
-
|
|
1319
|
+
const callee = path.get("callee");
|
|
1320
|
+
if (isSignalTrackingCallee(functionPath, callee, importSource)) inspection.hasSignalTrackingCall = true;
|
|
1321
|
+
if (callee.isIdentifier() && isNamedUseManagedSignalsImport(functionPath, callee.node.name, managedRuntimeSource) || callee.isMemberExpression() && getReadPropertyName(callee.node) === "useManagedSignals" && callee.get("object").isIdentifier() && isNamespaceUseManagedSignalsImport(functionPath, callee.get("object").node.name, managedRuntimeSource)) inspection.hasUseManagedSignalsCall = true;
|
|
881
1322
|
},
|
|
882
1323
|
OptionalCallExpression(path) {
|
|
883
1324
|
foldReferencedRenderCallbacks(path);
|
|
@@ -886,26 +1327,26 @@ function inspectFunction(functionPath, importSource, reactImportSource, visited
|
|
|
886
1327
|
});
|
|
887
1328
|
return inspection;
|
|
888
1329
|
}
|
|
889
|
-
function findRuntimeImports(programPath, runtimeSource) {
|
|
1330
|
+
function findRuntimeImports(programPath, runtimeSource, importedName) {
|
|
890
1331
|
const imports = [];
|
|
891
1332
|
for (const statement of programPath.get("body")) {
|
|
892
1333
|
if (!statement.isImportDeclaration() || statement.node.importKind === "type" || statement.node.source.value !== runtimeSource) continue;
|
|
893
|
-
for (const specifier of statement.get("specifiers")) if (specifier.isImportSpecifier() && specifier.node.importKind !== "type" && t.isIdentifier(specifier.node.imported, { name:
|
|
1334
|
+
for (const specifier of statement.get("specifiers")) if (specifier.isImportSpecifier() && specifier.node.importKind !== "type" && t.isIdentifier(specifier.node.imported, { name: importedName })) imports.push({
|
|
894
1335
|
identifier: t.cloneNode(specifier.node.local),
|
|
895
1336
|
bindingPath: specifier
|
|
896
1337
|
});
|
|
897
1338
|
}
|
|
898
1339
|
return imports;
|
|
899
1340
|
}
|
|
900
|
-
function addRuntimeImport(programPath, runtimeSource, functionPath) {
|
|
901
|
-
const local = functionPath.scope.generateUidIdentifier(
|
|
902
|
-
const declaration = t.importDeclaration([t.importSpecifier(t.cloneNode(local), t.identifier(
|
|
1341
|
+
function addRuntimeImport(programPath, runtimeSource, importedName, functionPath) {
|
|
1342
|
+
const local = functionPath.scope.generateUidIdentifier(importedName);
|
|
1343
|
+
const declaration = t.importDeclaration([t.importSpecifier(t.cloneNode(local), t.identifier(importedName))], t.stringLiteral(runtimeSource));
|
|
903
1344
|
const imports = programPath.get("body").filter((path) => path.isImportDeclaration());
|
|
904
1345
|
const inserted = (imports.length === 0 ? programPath.unshiftContainer("body", declaration) : imports.at(-1).insertAfter(declaration))[0];
|
|
905
|
-
if (inserted === void 0 || !inserted.isImportDeclaration()) throw new Error(
|
|
1346
|
+
if (inserted === void 0 || !inserted.isImportDeclaration()) throw new Error(`Failed to insert the ${importedName} import`);
|
|
906
1347
|
programPath.scope.registerDeclaration(inserted);
|
|
907
1348
|
const specifier = inserted.get("specifiers")[0];
|
|
908
|
-
if (specifier === void 0 || !specifier.isImportSpecifier()) throw new Error(
|
|
1349
|
+
if (specifier === void 0 || !specifier.isImportSpecifier()) throw new Error(`Failed to register the ${importedName} import`);
|
|
909
1350
|
return {
|
|
910
1351
|
identifier: local,
|
|
911
1352
|
bindingPath: specifier
|
|
@@ -913,31 +1354,31 @@ function addRuntimeImport(programPath, runtimeSource, functionPath) {
|
|
|
913
1354
|
}
|
|
914
1355
|
/**
|
|
915
1356
|
* Is `statements`' first entry a bare zero-argument call whose callee resolves
|
|
916
|
-
* to `
|
|
917
|
-
* chain counts (see `
|
|
1357
|
+
* to `useSignalTracking`, with `allowBarrel` controlling whether a barrel/re-export
|
|
1358
|
+
* chain counts (see `isSignalTrackingCallee`)? Shared by `isExplicitSignalTracking`
|
|
918
1359
|
* (`allowBarrel: false`, the verified boundary) and
|
|
919
|
-
* `
|
|
1360
|
+
* `isUnverifiableBarrelSignalTracking` (`allowBarrel: true`, to detect the exact
|
|
920
1361
|
* call the former rejects).
|
|
921
1362
|
*/
|
|
922
|
-
function
|
|
1363
|
+
function isFirstStatementSignalTrackingCall(functionPath, statements, importSource, allowBarrel) {
|
|
923
1364
|
const first = statements[0];
|
|
924
1365
|
if (first === void 0 || !first.isExpressionStatement()) return false;
|
|
925
1366
|
const expression = first.get("expression");
|
|
926
1367
|
if (!expression.isCallExpression() || expression.node.arguments.length !== 0) return false;
|
|
927
|
-
return
|
|
1368
|
+
return isSignalTrackingCallee(functionPath, expression.get("callee"), importSource, allowBarrel);
|
|
928
1369
|
}
|
|
929
|
-
function
|
|
930
|
-
return
|
|
1370
|
+
function isExplicitSignalTracking(functionPath, statements, importSource) {
|
|
1371
|
+
return isFirstStatementSignalTrackingCall(functionPath, statements, importSource, false);
|
|
931
1372
|
}
|
|
932
1373
|
/**
|
|
933
|
-
* The named import specifier the absorbed explicit `
|
|
1374
|
+
* The named import specifier the absorbed explicit `useSignalTracking()` call resolves
|
|
934
1375
|
* to, when the call is a bare identifier bound by one. `applyManaged` replaces
|
|
935
1376
|
* that call with its own store declaration, which can leave the import behind
|
|
936
1377
|
* with nothing referencing it -- so `Program.exit` needs the specifier to check
|
|
937
1378
|
* and drop. A namespace import is deliberately not returned: the namespace
|
|
938
1379
|
* object may be used for other exports of the same module.
|
|
939
1380
|
*/
|
|
940
|
-
function
|
|
1381
|
+
function getAbsorbedSignalTrackingImport(functionPath, statements) {
|
|
941
1382
|
const first = statements[0];
|
|
942
1383
|
if (first === void 0 || !first.isExpressionStatement()) return void 0;
|
|
943
1384
|
const expression = first.get("expression");
|
|
@@ -948,22 +1389,26 @@ function getAbsorbedUseSignalsImport(functionPath, statements) {
|
|
|
948
1389
|
if (resolved === void 0 || !resolved.specifier.isImportSpecifier()) return void 0;
|
|
949
1390
|
return resolved.specifier;
|
|
950
1391
|
}
|
|
951
|
-
function
|
|
1392
|
+
function isUnverifiableBarrelSignalTracking(functionPath, statements, importSource, explicit) {
|
|
952
1393
|
if (explicit) return false;
|
|
953
|
-
return
|
|
1394
|
+
return isFirstStatementSignalTrackingCall(functionPath, statements, importSource, true);
|
|
954
1395
|
}
|
|
955
|
-
function
|
|
956
|
-
const warning = path.buildCodeFrameError(`This
|
|
1396
|
+
function warnUnverifiableBarrelSignalTracking(path, importSource) {
|
|
1397
|
+
const warning = path.buildCodeFrameError(`This useSignalTracking() call cannot be verified as "${importSource}"'s own export: it resolves only through a barrel/re-export module, and a single-file transform cannot follow that chain to confirm the target. The component stays on the bare, best-effort useSignalTracking() boundary. Import useSignalTracking directly from "${importSource}" instead to get the verified boundary.`);
|
|
957
1398
|
console.warn(warning.message);
|
|
958
1399
|
}
|
|
959
|
-
function
|
|
960
|
-
const warning = path.buildCodeFrameError("This @
|
|
1400
|
+
function warnUnnamedSignalTrackingAnnotation(path) {
|
|
1401
|
+
const warning = path.buildCodeFrameError("This @signalTracking annotation is ignored: the transform could not resolve a component or hook identity for the annotated function, and the boundary is attached to that identity. Give it a PascalCase (component) or useX (hook) name -- a binding, a named function declaration, or an object property key -- to opt it in.");
|
|
961
1402
|
console.warn(warning.message);
|
|
962
1403
|
}
|
|
963
1404
|
function warnUnnamedDefaultExport(path) {
|
|
964
1405
|
const warning = path.buildCodeFrameError("This anonymous default export is left untransformed: its component identity is derived from the module's file name, and this module's name does not yield a PascalCase identifier. Name the function (`export default function App() { ... }`), or assign it to a PascalCase binding and export that binding, to opt it in.");
|
|
965
1406
|
console.warn(warning.message);
|
|
966
1407
|
}
|
|
1408
|
+
function warnPlainCalledComponentAlsoMounted(path, name) {
|
|
1409
|
+
const warning = path.buildCodeFrameError(`${name} is called as a plain function (${name}()) and also rendered as <${name} />. A plain call runs its body inside the caller's render, so it gets no tracking boundary of its own (its signal reads are tracked by the calling component instead), and the <${name} /> instance therefore only updates when its parent re-renders. Use one form consistently -- render it only as <${name} />, or only call it -- to remove the ambiguity.`);
|
|
1410
|
+
console.warn(warning.message);
|
|
1411
|
+
}
|
|
967
1412
|
function shouldAutomaticallyTransform(mode, inspection, identity) {
|
|
968
1413
|
if (isHookIdentity(identity)) return mode !== "manual" && inspection.readsValue;
|
|
969
1414
|
if (!isComponentIdentity(identity) || !inspection.containsJSX) return false;
|
|
@@ -975,10 +1420,10 @@ function markTransformed(state) {
|
|
|
975
1420
|
}
|
|
976
1421
|
/**
|
|
977
1422
|
* Resolves what, if anything, `path` needs done to it: the opt-out check,
|
|
978
|
-
* explicit/annotated/automatic eligibility (plus the barrel-
|
|
1423
|
+
* explicit/annotated/automatic eligibility (plus the barrel-useSignalTracking
|
|
979
1424
|
* warning that eligibility check surfaces along the way), the
|
|
980
1425
|
* async/generator guard, and -- once a function is confirmed eligible for
|
|
981
|
-
* real codegen -- acquiring the runtime
|
|
1426
|
+
* real codegen -- acquiring the corresponding runtime hook import it will call.
|
|
982
1427
|
* Pure decision-making: no AST mutation happens here, so every early return is
|
|
983
1428
|
* just a `return`, not a `return` guarding mutations already made.
|
|
984
1429
|
*/
|
|
@@ -987,38 +1432,39 @@ function decideTransform(path, state, options, reactImportSource) {
|
|
|
987
1432
|
const identity = resolveComponentIdentity(path, reactImportSource, climbedParent) ?? getDefaultExportIdentity(climbedParent, state.filename);
|
|
988
1433
|
const factory = identity?.kind === "hoc-return" ? identity.factory : void 0;
|
|
989
1434
|
const ownsLeadingComment = (pattern) => hasOwnedLeadingComment(path, pattern) || factory !== void 0 && hasOwnedLeadingComment(factory, pattern);
|
|
990
|
-
if (ownsLeadingComment(
|
|
1435
|
+
if (ownsLeadingComment(noSignalTrackingComment)) return { kind: "skip" };
|
|
991
1436
|
const body = path.get("body");
|
|
992
1437
|
const statements = body.isBlockStatement() ? body.get("body") : [];
|
|
993
|
-
const explicit =
|
|
994
|
-
if (
|
|
995
|
-
const
|
|
996
|
-
const
|
|
1438
|
+
const explicit = isExplicitSignalTracking(path, statements, options.importSource);
|
|
1439
|
+
if (isUnverifiableBarrelSignalTracking(path, statements, options.importSource, explicit)) warnUnverifiableBarrelSignalTracking(path, options.importSource);
|
|
1440
|
+
const managedRuntimeSource = `${options.importSource}/runtime`;
|
|
1441
|
+
const inspection = inspectFunction(path, options.importSource, managedRuntimeSource, reactImportSource);
|
|
1442
|
+
const annotation = ownsLeadingComment(useSignalTrackingComment);
|
|
997
1443
|
const isFactory = isHigherOrderComponentFactory(path) && !isHookIdentity(identity);
|
|
998
1444
|
const annotated = annotation && !isFactory && (isComponentIdentity(identity) || isHookIdentity(identity));
|
|
999
1445
|
const candidate = isAutomaticTransformCandidate(path, reactImportSource, climbedParent);
|
|
1000
1446
|
if (annotation && !annotated && rendersJsx(path)) {
|
|
1001
1447
|
const enclosing = path.getFunctionParent();
|
|
1002
|
-
if (enclosing === null || !hasOwnedLeadingComment(enclosing,
|
|
1448
|
+
if (enclosing === null || !hasOwnedLeadingComment(enclosing, useSignalTrackingComment)) warnUnnamedSignalTrackingAnnotation(path);
|
|
1003
1449
|
}
|
|
1004
1450
|
if (options.mode !== "manual" && identity === void 0 && climbedParent !== null && climbedParent.isExportDefaultDeclaration() && rendersJsx(path)) warnUnnamedDefaultExport(path);
|
|
1451
|
+
if (!candidate && !explicit && !annotated && !isFactory && shouldAutomaticallyTransform(options.mode, inspection, identity) && isComponentIdentity(identity) && isPlainCalledComponent(path) && isAlsoMountedThroughJsx(path)) warnPlainCalledComponentAlsoMounted(path, identity?.name ?? "component");
|
|
1005
1452
|
const automatic = !isFactory && candidate && shouldAutomaticallyTransform(options.mode, inspection, identity);
|
|
1006
1453
|
if (!explicit && !annotated && !automatic) return { kind: "skip" };
|
|
1007
|
-
if (!explicit && inspection.
|
|
1454
|
+
if (!explicit && (inspection.hasSignalTrackingCall || inspection.hasUseManagedSignalsCall)) return { kind: "skip" };
|
|
1008
1455
|
if (options.transform === "inject" && explicit) return {
|
|
1009
1456
|
kind: "directive-only",
|
|
1010
1457
|
body
|
|
1011
1458
|
};
|
|
1012
1459
|
if (path.node.async || path.node.generator) {
|
|
1013
1460
|
if (!explicit && !annotated) return { kind: "skip" };
|
|
1014
|
-
throw path.buildCodeFrameError("
|
|
1461
|
+
throw path.buildCodeFrameError("useSignalTracking transform only supports synchronous, non-generator functions");
|
|
1015
1462
|
}
|
|
1016
|
-
const managedRuntimeSource = `${options.importSource}/runtime`;
|
|
1017
1463
|
const importSource = options.transform === "managed" ? managedRuntimeSource : options.importSource;
|
|
1018
1464
|
const imports = options.transform === "managed" ? state.managedRuntimeImports : state.directImports;
|
|
1019
1465
|
let runtimeImport = imports.find(({ identifier, bindingPath }) => path.scope.getBinding(identifier.name)?.path === bindingPath);
|
|
1020
1466
|
if (runtimeImport === void 0) {
|
|
1021
|
-
runtimeImport = addRuntimeImport(state.programPath, importSource, path);
|
|
1467
|
+
runtimeImport = addRuntimeImport(state.programPath, importSource, options.transform === "managed" ? "useManagedSignals" : "useSignalTracking", path);
|
|
1022
1468
|
imports.push(runtimeImport);
|
|
1023
1469
|
}
|
|
1024
1470
|
return {
|
|
@@ -1027,17 +1473,186 @@ function decideTransform(path, state, options, reactImportSource) {
|
|
|
1027
1473
|
statements,
|
|
1028
1474
|
explicit,
|
|
1029
1475
|
runtimeImport,
|
|
1030
|
-
absorbedImport: explicit ?
|
|
1476
|
+
absorbedImport: explicit ? getAbsorbedSignalTrackingImport(path, statements) : void 0
|
|
1031
1477
|
};
|
|
1032
1478
|
}
|
|
1033
|
-
/**
|
|
1479
|
+
/**
|
|
1480
|
+
* Does `param` itself evaluate a tracked `.value` read when the function is
|
|
1481
|
+
* called -- i.e. in a default value (`{ v = count.value }`, `n = count.value`)?
|
|
1482
|
+
*
|
|
1483
|
+
* Reads inside a nested function are skipped: a closure created by a default
|
|
1484
|
+
* (`format = () => count.value`) does not run during parameter binding, and if
|
|
1485
|
+
* the body calls it during render, it runs after the boundary is open anyway.
|
|
1486
|
+
*/
|
|
1487
|
+
function parameterReadsTrackedValue(param, functionPath) {
|
|
1488
|
+
if (param.isIdentifier()) return false;
|
|
1489
|
+
let found = false;
|
|
1490
|
+
const visit = (path) => {
|
|
1491
|
+
if (isTrackedValueRead(path.node, path, functionPath)) {
|
|
1492
|
+
found = true;
|
|
1493
|
+
path.stop();
|
|
1494
|
+
}
|
|
1495
|
+
};
|
|
1496
|
+
param.traverse({
|
|
1497
|
+
Function(nested) {
|
|
1498
|
+
nested.skip();
|
|
1499
|
+
},
|
|
1500
|
+
MemberExpression: visit,
|
|
1501
|
+
OptionalMemberExpression: visit
|
|
1502
|
+
});
|
|
1503
|
+
return found;
|
|
1504
|
+
}
|
|
1505
|
+
/**
|
|
1506
|
+
* Would moving `moved` from the parameter list into the body change what any
|
|
1507
|
+
* name in them means? Two shapes do, and both are rare enough that the honest
|
|
1508
|
+
* answer is to leave the parameters exactly where they were:
|
|
1509
|
+
*
|
|
1510
|
+
* - A default refers to a name the body itself declares
|
|
1511
|
+
* (`({ v = count.value }) => { const count = ...; }`). In the parameter list
|
|
1512
|
+
* that name is the *outer* binding, because parameters get a scope of their
|
|
1513
|
+
* own; moved into the body it would hit the body's declaration instead (and
|
|
1514
|
+
* its temporal dead zone). Babel models parameters and body-level
|
|
1515
|
+
* declarations as one function scope, which is precisely what makes this
|
|
1516
|
+
* detectable: such a name resolves to an own binding that is not a parameter.
|
|
1517
|
+
* - A moved parameter is re-declared in the body (`var v;`, `function v() {}`),
|
|
1518
|
+
* which is legal next to a parameter but a redeclaration error next to the
|
|
1519
|
+
* `let` the move would emit. Babel records those as constant violations of
|
|
1520
|
+
* the parameter binding.
|
|
1521
|
+
*/
|
|
1522
|
+
function hasRelocationConflict(path, moved) {
|
|
1523
|
+
for (const param of moved) {
|
|
1524
|
+
for (const name of Object.keys(t.getBindingIdentifiers(param.node))) if (path.scope.getOwnBinding(name)?.constantViolations.some((violation) => violation.isVariableDeclarator() || violation.isFunctionDeclaration() || violation.isClassDeclaration())) return true;
|
|
1525
|
+
let conflict = false;
|
|
1526
|
+
param.traverse({ ReferencedIdentifier(reference) {
|
|
1527
|
+
if (!reference.isIdentifier()) return;
|
|
1528
|
+
const binding = path.scope.getOwnBinding(reference.node.name);
|
|
1529
|
+
if (binding !== void 0 && binding.kind !== "param") {
|
|
1530
|
+
conflict = true;
|
|
1531
|
+
reference.stop();
|
|
1532
|
+
}
|
|
1533
|
+
} });
|
|
1534
|
+
if (conflict) return true;
|
|
1535
|
+
}
|
|
1536
|
+
return false;
|
|
1537
|
+
}
|
|
1538
|
+
/**
|
|
1539
|
+
* Moves the parameters whose defaults read a signal behind the boundary,
|
|
1540
|
+
* returning the statements that bind them again, for the caller to place as
|
|
1541
|
+
* the first statements *after* the hook call.
|
|
1542
|
+
*
|
|
1543
|
+
* Parameter defaults are evaluated before the first statement of the body runs,
|
|
1544
|
+
* so in `function C({ v = count.value })` the read happens before the injected
|
|
1545
|
+
* `useManagedSignals()` / `useSignalTracking()` call has opened the render
|
|
1546
|
+
* collection -- the read is real, `inspectFunction` rightly counts it and
|
|
1547
|
+
* transforms the component, and yet the boundary never sees it, so the
|
|
1548
|
+
* component does not re-render when `count` changes. Moving the pattern into
|
|
1549
|
+
* the body is the only placement that runs it after the hook:
|
|
1550
|
+
*
|
|
1551
|
+
* ```js
|
|
1552
|
+
* function C(_param) {
|
|
1553
|
+
* const _signals = _useManagedSignals();
|
|
1554
|
+
* try {
|
|
1555
|
+
* let { v = count.value } = _param;
|
|
1556
|
+
* ...
|
|
1557
|
+
* ```
|
|
1558
|
+
*
|
|
1559
|
+
* The rewrite is kept as small as it can be while staying faithful:
|
|
1560
|
+
*
|
|
1561
|
+
* - Nothing happens unless a parameter actually reads a signal; every other
|
|
1562
|
+
* function keeps its parameter list untouched.
|
|
1563
|
+
* - From the first such parameter *onward*, every parameter moves, in order. A
|
|
1564
|
+
* later default may refer to an earlier parameter (`(a = count.value, b =
|
|
1565
|
+
* a)`), and it may have side effects whose order matters, so moving one
|
|
1566
|
+
* parameter alone could change what the others see.
|
|
1567
|
+
* - Each moved parameter is replaced one-for-one by a generated identifier
|
|
1568
|
+
* rather than removed, so the parameter *count* is unchanged and a
|
|
1569
|
+
* `forwardRef` render function keeps its two-parameter `(props, ref)` shape
|
|
1570
|
+
* (`length` can change where a default used to end the count, which React
|
|
1571
|
+
* only inspects to warn about a `forwardRef` arity other than 0 or 2).
|
|
1572
|
+
* - A default still applies only on `undefined`: `v = count.value` becomes
|
|
1573
|
+
* `let v = _param === undefined ? count.value : _param`, which is the exact
|
|
1574
|
+
* rule a parameter default follows; a destructuring pattern's own nested
|
|
1575
|
+
* defaults keep their meaning by moving verbatim.
|
|
1576
|
+
* - A type annotation travels to the generated parameter, so the signature
|
|
1577
|
+
* still says what it accepts; `?` optional markers have no meaning on a
|
|
1578
|
+
* `let` and are dropped from the moved pattern. In TypeScript the generated
|
|
1579
|
+
* parameter is marked `?` wherever the original was optional.
|
|
1580
|
+
*
|
|
1581
|
+
* When the move cannot be done without changing meaning
|
|
1582
|
+
* (`hasRelocationConflict`), or a parameter is a TypeScript parameter property
|
|
1583
|
+
* (constructor-only, never a component), nothing moves and the read stays
|
|
1584
|
+
* untracked exactly as before -- an edge this declines rather than miscompiles.
|
|
1585
|
+
*/
|
|
1586
|
+
function relocateTrackedParameters(path, typeScript) {
|
|
1587
|
+
const params = path.get("params");
|
|
1588
|
+
const first = params.findIndex((param) => parameterReadsTrackedValue(param, path));
|
|
1589
|
+
if (first === -1) return [];
|
|
1590
|
+
const moved = params.slice(first);
|
|
1591
|
+
const plan = [];
|
|
1592
|
+
for (const param of moved) {
|
|
1593
|
+
const node = param.node;
|
|
1594
|
+
if (t.isRestElement(node)) {
|
|
1595
|
+
if (!isBindablePattern(node.argument)) return [];
|
|
1596
|
+
plan.push({
|
|
1597
|
+
param,
|
|
1598
|
+
pattern: node.argument,
|
|
1599
|
+
rest: node
|
|
1600
|
+
});
|
|
1601
|
+
} else if (t.isAssignmentPattern(node)) {
|
|
1602
|
+
if (!isBindablePattern(node.left)) return [];
|
|
1603
|
+
plan.push({
|
|
1604
|
+
param,
|
|
1605
|
+
pattern: node.left,
|
|
1606
|
+
fallback: node.right
|
|
1607
|
+
});
|
|
1608
|
+
} else if (isBindablePattern(node)) plan.push({
|
|
1609
|
+
param,
|
|
1610
|
+
pattern: node
|
|
1611
|
+
});
|
|
1612
|
+
else return [];
|
|
1613
|
+
}
|
|
1614
|
+
if (hasRelocationConflict(path, moved)) return [];
|
|
1615
|
+
const optional = [];
|
|
1616
|
+
let laterOptional = true;
|
|
1617
|
+
for (let index = plan.length - 1; index >= 0; index -= 1) {
|
|
1618
|
+
const { pattern, fallback, rest } = plan[index];
|
|
1619
|
+
optional[index] = typeScript && rest === void 0 && (pattern.optional === true || fallback !== void 0 && laterOptional);
|
|
1620
|
+
laterOptional &&= rest !== void 0 || optional[index];
|
|
1621
|
+
}
|
|
1622
|
+
const declarations = [];
|
|
1623
|
+
for (const [index, { param, pattern, fallback, rest }] of plan.entries()) {
|
|
1624
|
+
const local = path.scope.generateUidIdentifier("param");
|
|
1625
|
+
const init = fallback === void 0 ? t.cloneNode(local) : t.conditionalExpression(t.binaryExpression("===", t.cloneNode(local), t.identifier("undefined")), fallback, t.cloneNode(local));
|
|
1626
|
+
let replacement = local;
|
|
1627
|
+
if (rest !== void 0) {
|
|
1628
|
+
replacement = t.restElement(t.cloneNode(local));
|
|
1629
|
+
replacement.typeAnnotation = rest.typeAnnotation ?? null;
|
|
1630
|
+
} else if (pattern.typeAnnotation !== null && pattern.typeAnnotation !== void 0) local.typeAnnotation = pattern.typeAnnotation;
|
|
1631
|
+
if (optional[index]) local.optional = true;
|
|
1632
|
+
pattern.typeAnnotation = null;
|
|
1633
|
+
pattern.optional = null;
|
|
1634
|
+
declarations.push(t.variableDeclaration("let", [t.variableDeclarator(pattern, init)]));
|
|
1635
|
+
param.replaceWith(replacement);
|
|
1636
|
+
}
|
|
1637
|
+
return declarations;
|
|
1638
|
+
}
|
|
1639
|
+
function isBindablePattern(node) {
|
|
1640
|
+
return t.isIdentifier(node) || t.isObjectPattern(node) || t.isArrayPattern(node);
|
|
1641
|
+
}
|
|
1642
|
+
/** Codegen: inject a bare `useSignalTracking()` call at the top of the function body. */
|
|
1034
1643
|
function applyInject(path, { body, runtimeImport }, options, state) {
|
|
1035
1644
|
const call = t.expressionStatement(t.callExpression(t.cloneNode(runtimeImport.identifier), []));
|
|
1036
|
-
|
|
1037
|
-
|
|
1645
|
+
const relocated = relocateTrackedParameters(path, state.typeScript);
|
|
1646
|
+
if (body.isBlockStatement()) body.unshiftContainer("body", [call, ...relocated]);
|
|
1647
|
+
else body.replaceWith(t.blockStatement([
|
|
1648
|
+
call,
|
|
1649
|
+
...relocated,
|
|
1650
|
+
t.returnStatement(body.node)
|
|
1651
|
+
]));
|
|
1038
1652
|
const injectedBody = path.node.body;
|
|
1039
1653
|
if (options.reactCompiler === "auto" && t.isBlockStatement(injectedBody)) addNoMemoDirective(injectedBody);
|
|
1040
1654
|
path.scope.crawl();
|
|
1655
|
+
invalidateThisReferences(path);
|
|
1041
1656
|
markTransformed(state);
|
|
1042
1657
|
}
|
|
1043
1658
|
/** Codegen: wrap the function body in the managed try/finally render-tracking scope. */
|
|
@@ -1047,16 +1662,18 @@ function applyManaged(path, { body, statements, explicit, runtimeImport, absorbe
|
|
|
1047
1662
|
const first = statements[0];
|
|
1048
1663
|
if (explicit && first !== void 0) t.inheritsComments(declaration, first.node);
|
|
1049
1664
|
const originalStatements = body.isBlockStatement() ? statements.slice(explicit ? 1 : 0).map((statement) => statement.node) : [t.returnStatement(body.node)];
|
|
1050
|
-
const
|
|
1665
|
+
const relocated = relocateTrackedParameters(path, state.typeScript);
|
|
1666
|
+
const transformedBody = t.blockStatement([declaration, t.tryStatement(t.blockStatement([...relocated, ...originalStatements]), null, t.blockStatement([t.expressionStatement(t.callExpression(t.memberExpression(t.cloneNode(store), t.identifier("finish")), []))]))]);
|
|
1051
1667
|
if (body.isBlockStatement()) transformedBody.directives = body.node.directives;
|
|
1052
1668
|
if (options.reactCompiler === "auto") addNoMemoDirective(transformedBody);
|
|
1053
1669
|
body.replaceWith(transformedBody);
|
|
1054
1670
|
path.scope.crawl();
|
|
1671
|
+
invalidateThisReferences(path);
|
|
1055
1672
|
if (absorbedImport !== void 0) state.absorbedImports.push(absorbedImport);
|
|
1056
1673
|
markTransformed(state);
|
|
1057
1674
|
}
|
|
1058
1675
|
/**
|
|
1059
|
-
* Drops an `import {
|
|
1676
|
+
* Drops an `import { useSignalTracking } from "<importSource>"` the managed transform
|
|
1060
1677
|
* absorbed and nothing else refers to any more, along with the whole
|
|
1061
1678
|
* declaration once its last specifier goes. A reused `/runtime` import survives
|
|
1062
1679
|
* this untouched: the emitted store declaration still calls it, so the binding
|
|
@@ -1075,6 +1692,45 @@ function removeAbsorbedImports(programPath, state) {
|
|
|
1075
1692
|
}
|
|
1076
1693
|
state.absorbedImports = [];
|
|
1077
1694
|
}
|
|
1695
|
+
const jsxPragmaComment = /@jsx(?:ImportSource|Runtime|Frag)?(?![\w$])/;
|
|
1696
|
+
/**
|
|
1697
|
+
* Puts a per-file JSX pragma back in front of the code once a generated import
|
|
1698
|
+
* has been placed ahead of it.
|
|
1699
|
+
*
|
|
1700
|
+
* In a file with no import of its own, `addRuntimeImport` has to put the
|
|
1701
|
+
* runtime import first, and Babel keeps the author's leading comments attached
|
|
1702
|
+
* to the statement they were written above -- so a first-line
|
|
1703
|
+
* `/** @jsxImportSource react-fine-grained-signals *\/` ends up printed after
|
|
1704
|
+
* that import. Oxc (and so Vite) only honors a JSX pragma that comes before the
|
|
1705
|
+
* first statement, so the pragma was silently ignored: the file compiled against
|
|
1706
|
+
* `react/jsx-runtime`, and a signal child then reached React as an object and
|
|
1707
|
+
* threw on mount.
|
|
1708
|
+
*
|
|
1709
|
+
* Every leading comment up to and including the last pragma moves onto the
|
|
1710
|
+
* first generated import, in order, so a license header above the pragma stays
|
|
1711
|
+
* above it; comments after the last pragma -- typically the first statement's
|
|
1712
|
+
* own doc comment -- stay where they were. A file without a pragma is left
|
|
1713
|
+
* exactly as it was. This runs at `Program.exit`, after every function has been
|
|
1714
|
+
* decided, because `@signalTracking` / `@noSignalTracking` are read from those
|
|
1715
|
+
* same leading comments.
|
|
1716
|
+
*/
|
|
1717
|
+
function keepJsxPragmasFirst(programPath, state) {
|
|
1718
|
+
if (!state.importFree) return;
|
|
1719
|
+
const body = programPath.node.body;
|
|
1720
|
+
const authored = body.findIndex((statement) => !t.isImportDeclaration(statement));
|
|
1721
|
+
if (authored <= 0) return;
|
|
1722
|
+
const comments = body[authored].leadingComments;
|
|
1723
|
+
if (comments === null || comments === void 0) return;
|
|
1724
|
+
let lastPragma = -1;
|
|
1725
|
+
for (const [index, comment] of comments.entries()) if (jsxPragmaComment.test(comment.value)) lastPragma = index;
|
|
1726
|
+
if (lastPragma === -1) return;
|
|
1727
|
+
const generated = body[0];
|
|
1728
|
+
const staying = comments.slice(lastPragma + 1);
|
|
1729
|
+
generated.leadingComments = [...generated.leadingComments ?? [], ...comments.slice(0, lastPragma + 1)];
|
|
1730
|
+
body[authored].leadingComments = staying;
|
|
1731
|
+
const interpreter = programPath.node.interpreter;
|
|
1732
|
+
if (interpreter?.trailingComments) interpreter.trailingComments = interpreter.trailingComments.filter((comment) => !staying.includes(comment));
|
|
1733
|
+
}
|
|
1078
1734
|
const babelTransform = declare((api, options) => {
|
|
1079
1735
|
api.assertVersion(8);
|
|
1080
1736
|
const managedRuntimeSource = `${options.importSource}/runtime`;
|
|
@@ -1085,13 +1741,16 @@ const babelTransform = declare((api, options) => {
|
|
|
1085
1741
|
Program: {
|
|
1086
1742
|
enter(path, state) {
|
|
1087
1743
|
state.programPath = path;
|
|
1088
|
-
state.managedRuntimeImports = findRuntimeImports(path, managedRuntimeSource);
|
|
1089
|
-
state.directImports = findRuntimeImports(path, options.importSource);
|
|
1744
|
+
state.managedRuntimeImports = findRuntimeImports(path, managedRuntimeSource, "useManagedSignals");
|
|
1745
|
+
state.directImports = findRuntimeImports(path, options.importSource, "useSignalTracking");
|
|
1090
1746
|
state.absorbedImports = [];
|
|
1747
|
+
state.typeScript = state.file.opts.parserOpts?.plugins?.includes("typescript") === true;
|
|
1748
|
+
state.importFree = !path.node.body.some((statement) => t.isImportDeclaration(statement));
|
|
1091
1749
|
state.file.metadata[transformedMetadataKey] = false;
|
|
1092
1750
|
},
|
|
1093
1751
|
exit(path, state) {
|
|
1094
1752
|
removeAbsorbedImports(path, state);
|
|
1753
|
+
keepJsxPragmasFirst(path, state);
|
|
1095
1754
|
}
|
|
1096
1755
|
},
|
|
1097
1756
|
Function(path, state) {
|
|
@@ -1118,9 +1777,8 @@ const babelTransform = declare((api, options) => {
|
|
|
1118
1777
|
* it is discarded outright for every file none of the three opt-in routes can
|
|
1119
1778
|
* possibly apply to:
|
|
1120
1779
|
*
|
|
1121
|
-
* - explicit
|
|
1122
|
-
*
|
|
1123
|
-
* it);
|
|
1780
|
+
* - explicit opt-in needs `useSignalTracking`; annotation opt-in and opt-out
|
|
1781
|
+
* need `@signalTracking` or `@noSignalTracking`;
|
|
1124
1782
|
* - `auto` needs a `.value` read, so it needs the text `value`;
|
|
1125
1783
|
* - `all` additionally wraps JSX components, so it needs a `<` -- but its
|
|
1126
1784
|
* custom-hook rule still needs a `.value` read.
|
|
@@ -1130,7 +1788,7 @@ const babelTransform = declare((api, options) => {
|
|
|
1130
1788
|
* would have rejected anyway.
|
|
1131
1789
|
*/
|
|
1132
1790
|
function mightTransform(code, mode) {
|
|
1133
|
-
if (code.includes("
|
|
1791
|
+
if (code.includes("useSignalTracking") || code.includes("@signalTracking") || code.includes("@noSignalTracking")) return true;
|
|
1134
1792
|
if (mode === "manual") return false;
|
|
1135
1793
|
if (code.includes("value")) return true;
|
|
1136
1794
|
return mode === "all" && code.includes("<");
|
|
@@ -1168,7 +1826,7 @@ function transformReactFineGrainedSignals(code, id, options) {
|
|
|
1168
1826
|
}
|
|
1169
1827
|
//#endregion
|
|
1170
1828
|
//#region src/unplugin.ts
|
|
1171
|
-
const SCRIPT_MODULE = /\.m?[jt]sx
|
|
1829
|
+
const SCRIPT_MODULE = /\.m?[jt]sx?$/i;
|
|
1172
1830
|
const pluginName = "unplugin-react-fine-grained-signals";
|
|
1173
1831
|
/**
|
|
1174
1832
|
* `id` with the `?query`/`#fragment` a bundler appends taken off, which is
|
|
@@ -1300,21 +1958,26 @@ function useOwnTransformLoader(compiler) {
|
|
|
1300
1958
|
}
|
|
1301
1959
|
const reactFineGrainedSignals = createUnplugin((options = {}) => {
|
|
1302
1960
|
let configuredLoaders;
|
|
1961
|
+
const transform = (code, id) => transformReactFineGrainedSignals(code, id, {
|
|
1962
|
+
importSource: options.importSource ?? "react-fine-grained-signals",
|
|
1963
|
+
reactImportSource: options.reactImportSource ?? "react",
|
|
1964
|
+
mode: options.mode ?? "auto",
|
|
1965
|
+
transform: options.transform ?? "managed",
|
|
1966
|
+
reactCompiler: options.reactCompiler ?? "auto"
|
|
1967
|
+
});
|
|
1303
1968
|
return {
|
|
1304
1969
|
name: pluginName,
|
|
1305
1970
|
enforce: "pre",
|
|
1306
1971
|
transformInclude(id) {
|
|
1307
1972
|
return canTransform(id, options);
|
|
1308
1973
|
},
|
|
1309
|
-
transform
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
});
|
|
1317
|
-
},
|
|
1974
|
+
transform,
|
|
1975
|
+
vite: { transform: {
|
|
1976
|
+
order: "pre",
|
|
1977
|
+
handler(code, id) {
|
|
1978
|
+
return canTransform(id, options) ? transform(code, id) : null;
|
|
1979
|
+
}
|
|
1980
|
+
} },
|
|
1318
1981
|
esbuild: {
|
|
1319
1982
|
config(buildOptions) {
|
|
1320
1983
|
configuredLoaders = buildOptions.loader;
|