unplugin-react-fine-grained-signals 0.1.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/LICENSE +21 -0
- package/README.ja.md +250 -0
- package/README.md +261 -0
- package/dist/esbuild.d.ts +10 -0
- package/dist/esbuild.js +5 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/loader.d.ts +15 -0
- package/dist/loader.js +160 -0
- package/dist/rollup.d.ts +11 -0
- package/dist/rollup.js +5 -0
- package/dist/rspack.d.ts +9 -0
- package/dist/rspack.js +5 -0
- package/dist/unplugin-B2_q8KNi.js +1341 -0
- package/dist/unplugin-CG9_mdcA.d.ts +77 -0
- package/dist/vite.d.ts +11 -0
- package/dist/vite.js +5 -0
- package/dist/webpack.d.ts +9 -0
- package/dist/webpack.js +5 -0
- package/package.json +88 -0
|
@@ -0,0 +1,1341 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { createUnplugin } from "unplugin";
|
|
3
|
+
import { declare } from "@babel/helper-plugin-utils";
|
|
4
|
+
import { transformSync } from "@babel/core";
|
|
5
|
+
import * as t from "@babel/types";
|
|
6
|
+
//#region src/internal/transform.ts
|
|
7
|
+
const useSignalsComment = /(^|\s)@useSignals(\s|$)/;
|
|
8
|
+
const noUseSignalsComment = /(^|\s)@noUseSignals(\s|$)/;
|
|
9
|
+
const transformedMetadataKey = "reactFineGrainedSignalsTransformed";
|
|
10
|
+
const noMemoDirective = "use no memo";
|
|
11
|
+
const memoizationDirectives = /* @__PURE__ */ new Set([
|
|
12
|
+
"use memo",
|
|
13
|
+
"use forget",
|
|
14
|
+
"use no memo",
|
|
15
|
+
"use no forget"
|
|
16
|
+
]);
|
|
17
|
+
/** Adds the opt-out unless the author already stated a memoization choice. */
|
|
18
|
+
function addNoMemoDirective(body) {
|
|
19
|
+
if (body.directives.some(({ value }) => memoizationDirectives.has(value.value))) return false;
|
|
20
|
+
body.directives.unshift(t.directive(t.directiveLiteral(noMemoDirective)));
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
function hasLeadingComment(path, pattern) {
|
|
24
|
+
return path.node.leadingComments?.some((comment) => pattern.test(comment.value)) ?? false;
|
|
25
|
+
}
|
|
26
|
+
function hasOwnedLeadingComment(path, pattern) {
|
|
27
|
+
let current = path;
|
|
28
|
+
while (current !== null && !current.isProgram()) {
|
|
29
|
+
if (hasLeadingComment(current, pattern)) return true;
|
|
30
|
+
if (current.isStatement()) {
|
|
31
|
+
const parent = current.parentPath;
|
|
32
|
+
return parent !== null && (parent.isExportNamedDeclaration() || parent.isExportDefaultDeclaration()) && hasLeadingComment(parent, pattern);
|
|
33
|
+
}
|
|
34
|
+
current = current.parentPath;
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
const reactPackageSource = "react";
|
|
39
|
+
function isReactSource(source, reactImportSource) {
|
|
40
|
+
return source === reactPackageSource || source === reactImportSource;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Resolves `name` back to the import specifier and source module it is bound
|
|
44
|
+
* to, or `undefined` if `name` isn't bound by a value-level import at all
|
|
45
|
+
* (not imported, a type-only specifier, or a type-only declaration).
|
|
46
|
+
*
|
|
47
|
+
* Every import-recognition predicate in this file -- React's `memo`/
|
|
48
|
+
* `forwardRef` wrappers, a default-or-namespace React import, a named or
|
|
49
|
+
* namespace `useSignals` import -- reduces to "does this resolved binding
|
|
50
|
+
* have the right specifier kind and source", so they all build on this one
|
|
51
|
+
* binding walk instead of each repeating
|
|
52
|
+
* `getBinding` -> specifier-kind -> `importKind` -> parent-declaration.
|
|
53
|
+
*/
|
|
54
|
+
function resolveImportedBinding(path, name) {
|
|
55
|
+
const binding = path.scope.getBinding(name);
|
|
56
|
+
if (binding === void 0) return void 0;
|
|
57
|
+
const specifier = binding.path;
|
|
58
|
+
if (!specifier.isImportSpecifier() && !specifier.isImportDefaultSpecifier() && !specifier.isImportNamespaceSpecifier()) return;
|
|
59
|
+
if (specifier.isImportSpecifier() && specifier.node.importKind === "type") return void 0;
|
|
60
|
+
const declaration = specifier.parentPath;
|
|
61
|
+
if (!declaration.isImportDeclaration() || declaration.node.importKind === "type") return;
|
|
62
|
+
return {
|
|
63
|
+
specifier,
|
|
64
|
+
source: declaration.node.source.value
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** Is `name` bound by `import { memo } from "react"` (or `forwardRef`, possibly aliased)? */
|
|
68
|
+
function isReactNamedImport(path, name, importedName, reactImportSource) {
|
|
69
|
+
const resolved = resolveImportedBinding(path, name);
|
|
70
|
+
if (resolved === void 0 || !resolved.specifier.isImportSpecifier()) return false;
|
|
71
|
+
return t.isIdentifier(resolved.specifier.node.imported, { name: importedName }) && isReactSource(resolved.source, reactImportSource);
|
|
72
|
+
}
|
|
73
|
+
/** Is `name` bound by `import * as React from "react"` or `import React from "react"`? */
|
|
74
|
+
function isReactDefaultOrNamespaceImport(path, name, reactImportSource) {
|
|
75
|
+
const resolved = resolveImportedBinding(path, name);
|
|
76
|
+
return resolved !== void 0 && (resolved.specifier.isImportNamespaceSpecifier() || resolved.specifier.isImportDefaultSpecifier()) && isReactSource(resolved.source, reactImportSource);
|
|
77
|
+
}
|
|
78
|
+
function isKnownComponentWrapper(path, reactImportSource) {
|
|
79
|
+
const callee = path.get("callee");
|
|
80
|
+
if (callee.isIdentifier()) {
|
|
81
|
+
const name = callee.node.name;
|
|
82
|
+
return isReactNamedImport(path, name, "memo", reactImportSource) || isReactNamedImport(path, name, "forwardRef", reactImportSource);
|
|
83
|
+
}
|
|
84
|
+
if (!callee.isMemberExpression()) return false;
|
|
85
|
+
const propertyName = getReadPropertyName(callee.node);
|
|
86
|
+
if (propertyName !== "memo" && propertyName !== "forwardRef") return false;
|
|
87
|
+
const object = callee.get("object");
|
|
88
|
+
return object.isIdentifier() && isReactDefaultOrNamespaceImport(path, object.node.name, reactImportSource);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The static name of an object property's or class field's key. A computed key
|
|
92
|
+
* is only readable when it is a string literal (`{ ["Home"]: ... }`), exactly as
|
|
93
|
+
* `getReadPropertyName` reads `items["map"]`; anything else is decided at
|
|
94
|
+
* runtime and stays unnamed, which leaves the function untransformed.
|
|
95
|
+
*/
|
|
96
|
+
function getPropertyKeyName(node) {
|
|
97
|
+
const key = unwrapTransparent(node.key);
|
|
98
|
+
if (node.computed) return t.isStringLiteral(key) ? key.value : void 0;
|
|
99
|
+
if (t.isIdentifier(key)) return key.name;
|
|
100
|
+
return t.isStringLiteral(key) ? key.value : void 0;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The component/hook name a function held in a *keyed slot* takes from its key:
|
|
104
|
+
*
|
|
105
|
+
* ```js
|
|
106
|
+
* Card.Header = () => <h1>{count.value}</h1>; // a compound component
|
|
107
|
+
* export const ns = { Home: () => <p>{count.value}</p> }; // an object namespace
|
|
108
|
+
* class Holder { Row = () => <li>{count.value}</li>; } // a class field
|
|
109
|
+
* ```
|
|
110
|
+
*
|
|
111
|
+
* All three are ordinary ways to hold a component that no *binding* names, so
|
|
112
|
+
* without this they resolved to no identity at all and `auto` mode passed over
|
|
113
|
+
* them in silence. The key is the name the rest of the module reaches them by
|
|
114
|
+
* (`<ns.Home />`, `<Card.Header />`), which is precisely what the PascalCase /
|
|
115
|
+
* `useX` conventions are asked to classify -- so a lowercase key (`{ render:
|
|
116
|
+
* ... }`, `obj.onClick = ...`) resolves to a lowercase identity and is filtered
|
|
117
|
+
* out by those same conventions, exactly as a lowercase binding is.
|
|
118
|
+
*
|
|
119
|
+
* Reading the key *before* the function's own `id` is the same precedence the
|
|
120
|
+
* enclosing binding already takes: `Card.onSelect = function Row() {}` is a
|
|
121
|
+
* handler named for debugging, not a component, and the slot it was put in says
|
|
122
|
+
* so more reliably than the label it carries.
|
|
123
|
+
*
|
|
124
|
+
* An identifier-target assignment (`Row = (props) => ...` after a separate
|
|
125
|
+
* `let Row`) is deliberately not named here: the declaration is the binding, and
|
|
126
|
+
* routing it through this would give a re-assignment the same authority as a
|
|
127
|
+
* declaration. It keeps resolving through `getBindingIdentityName`'s own
|
|
128
|
+
* branches exactly as before.
|
|
129
|
+
*
|
|
130
|
+
* An assignment target is required to root in a plain identifier
|
|
131
|
+
* (`getStaticMemberPath`), which is what excludes `this.Row = ...` in a class
|
|
132
|
+
* component's constructor. That shape is genuinely ambiguous here: it is just as
|
|
133
|
+
* often a per-item row renderer invoked through `this.props.items.map(this.Row)`
|
|
134
|
+
* as it is a component, and `this` is not a binding, so the reference walk that
|
|
135
|
+
* disqualifies every other keyed slot (`isKeyedRenderCallback`) cannot see those
|
|
136
|
+
* uses at all -- there would be no way to take the boundary back once given. So
|
|
137
|
+
* the ambiguous case resolves the way this file always resolves one: name
|
|
138
|
+
* nothing, transform nothing. Class components stay a manual `useSignals()` /
|
|
139
|
+
* `@useSignals` opt-in.
|
|
140
|
+
*/
|
|
141
|
+
function getKeyedIdentityName(parent) {
|
|
142
|
+
if (parent.isAssignmentExpression()) {
|
|
143
|
+
const left = parent.node.left;
|
|
144
|
+
if (!t.isMemberExpression(left)) return void 0;
|
|
145
|
+
return getStaticMemberPath(left)?.keys.at(-1);
|
|
146
|
+
}
|
|
147
|
+
if (parent.isObjectProperty() || parent.isClassProperty()) return getPropertyKeyName(parent.node);
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* A member expression read as a root binding plus the chain of static keys
|
|
151
|
+
* taken from it: `a.b.Row` gives `a` / `["b", "Row"]`. Every link has to be a
|
|
152
|
+
* statically readable key and the innermost object a plain identifier, so a
|
|
153
|
+
* computed runtime key (`a[k].Row`) and a `this`-rooted path (`this.Row`) both
|
|
154
|
+
* yield nothing rather than a partial answer.
|
|
155
|
+
*
|
|
156
|
+
* The chain, rather than just the last key, is what lets `isKeyedRenderCallback`
|
|
157
|
+
* match a use site against the definition site: a one-level walk saw
|
|
158
|
+
* `const a = { b: { Row } }` handed to `items.map(a.b.Row)` as an unrelated
|
|
159
|
+
* reference and let the per-item callback keep a hook boundary of its own.
|
|
160
|
+
*/
|
|
161
|
+
function getStaticMemberPath(node) {
|
|
162
|
+
const keys = [];
|
|
163
|
+
let current = node;
|
|
164
|
+
while (t.isMemberExpression(current)) {
|
|
165
|
+
const key = getReadPropertyName(current);
|
|
166
|
+
if (key === void 0) return void 0;
|
|
167
|
+
keys.unshift(key);
|
|
168
|
+
current = current.object;
|
|
169
|
+
}
|
|
170
|
+
return t.isIdentifier(current) ? {
|
|
171
|
+
root: current.name,
|
|
172
|
+
keys
|
|
173
|
+
} : void 0;
|
|
174
|
+
}
|
|
175
|
+
function getBindingIdentityName(path, reactImportSource, climbedParent = climbComponentWrappers(path, reactImportSource).parentPath) {
|
|
176
|
+
const parent = climbedParent;
|
|
177
|
+
if (parent !== null) {
|
|
178
|
+
if (parent.isVariableDeclarator() && t.isIdentifier(parent.node.id)) return parent.node.id.name;
|
|
179
|
+
const keyed = getKeyedIdentityName(parent);
|
|
180
|
+
if (keyed !== void 0) return keyed;
|
|
181
|
+
}
|
|
182
|
+
if ((path.isFunctionDeclaration() || path.isFunctionExpression()) && path.node.id !== null && path.node.id !== void 0) return path.node.id.name;
|
|
183
|
+
}
|
|
184
|
+
/** Does a name read as a React component -- PascalCase -- by convention? */
|
|
185
|
+
function isComponentName(name) {
|
|
186
|
+
return /^[A-Z]/.test(name);
|
|
187
|
+
}
|
|
188
|
+
/** Does a name read as a custom hook -- `useX` -- by convention? */
|
|
189
|
+
function isHookName(name) {
|
|
190
|
+
return /^use[A-Z]/.test(name);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Does `path` render JSX *itself*, rather than merely containing some?
|
|
194
|
+
*
|
|
195
|
+
* Nested functions are skipped, because their JSX is their own output and not
|
|
196
|
+
* this function's: `(Base) => (props) => <Base />` renders nothing at all, it
|
|
197
|
+
* returns a function that does. That distinction is the whole difference
|
|
198
|
+
* between a factory and a component here, so without it a factory of factories
|
|
199
|
+
* would be mistaken for the component at the bottom of the chain and have a
|
|
200
|
+
* hook injected into a function React never renders.
|
|
201
|
+
*
|
|
202
|
+
* The cost of the stricter reading is a component whose only JSX lives inside a
|
|
203
|
+
* callback (`(props) => items.map((i) => <li />)`) -- it renders JSX, but not
|
|
204
|
+
* itself. That direction is the safe one: it declines to derive an identity,
|
|
205
|
+
* leaving behavior exactly as it was, rather than injecting an invalid hook.
|
|
206
|
+
*/
|
|
207
|
+
function rendersJsx(path) {
|
|
208
|
+
let found = false;
|
|
209
|
+
path.traverse({
|
|
210
|
+
Function(nested) {
|
|
211
|
+
nested.skip();
|
|
212
|
+
},
|
|
213
|
+
JSXElement(jsx) {
|
|
214
|
+
found = true;
|
|
215
|
+
jsx.stop();
|
|
216
|
+
},
|
|
217
|
+
JSXFragment(jsx) {
|
|
218
|
+
found = true;
|
|
219
|
+
jsx.stop();
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
return found;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Is `path` a higher-order component factory -- a function that *produces* a
|
|
226
|
+
* component rather than being one, and that React itself therefore never
|
|
227
|
+
* renders?
|
|
228
|
+
*
|
|
229
|
+
* The evidence is the parameter list. React calls a component with exactly one
|
|
230
|
+
* props object, and a `forwardRef` render function with `(props, ref)`; neither
|
|
231
|
+
* of those parameters is ever PascalCase, and a destructured one is a pattern
|
|
232
|
+
* rather than a plain identifier. A PascalCase *identifier* parameter is
|
|
233
|
+
* therefore a component being handed in, which only a factory receives. That is
|
|
234
|
+
* the same naming convention this file already reads to classify `Counter` as a
|
|
235
|
+
* component and `useCount` as a hook, applied one position further in -- not a
|
|
236
|
+
* new kind of guess.
|
|
237
|
+
*
|
|
238
|
+
* The second half -- rendering no JSX of its own (`rendersJsx`, which looks past
|
|
239
|
+
* nested functions) -- keeps a genuine component out no matter how its
|
|
240
|
+
* parameters are spelled: a function that renders is a component.
|
|
241
|
+
*
|
|
242
|
+
* Two consequences follow, and together they are the whole policy for a
|
|
243
|
+
* PascalCase factory such as
|
|
244
|
+
* `export const WithCount = (Base) => (props) => <Base count={count.value} />`:
|
|
245
|
+
*
|
|
246
|
+
* - the factory is never transformed itself (`decideTransform` withholds both
|
|
247
|
+
* the automatic and the annotated route from it), and
|
|
248
|
+
* - the function it returns inherits the factory's name (`getIdentityFactory`)
|
|
249
|
+
* exactly as a camelCase `withCount` factory's returned component already did.
|
|
250
|
+
*
|
|
251
|
+
* The first half is the load-bearing one. Without it the factory looked like an
|
|
252
|
+
* ordinary component from every angle -- a PascalCase binding, and (because the
|
|
253
|
+
* component it returns owned no identity, so `inspectFunction` never skipped it)
|
|
254
|
+
* the returned component's JSX and `.value` reads credited to the factory's own
|
|
255
|
+
* body. `auto` mode injected the boundary into the factory, `all` mode did so
|
|
256
|
+
* with no `.value` read needed at all, and since `WithCount(Foo)` is normally
|
|
257
|
+
* called at module scope the injected `useSignals()` ran with no React
|
|
258
|
+
* dispatcher and threw at import time -- the unrecoverable direction this file
|
|
259
|
+
* steers away from everywhere else.
|
|
260
|
+
*/
|
|
261
|
+
function isHigherOrderComponentFactory(path) {
|
|
262
|
+
return path.node.params.some((param) => {
|
|
263
|
+
const name = getSimpleParameterName(param);
|
|
264
|
+
return name !== void 0 && isComponentName(name);
|
|
265
|
+
}) && !rendersJsx(path);
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* The name a parameter binds, seen through a default value. `(Base)` and
|
|
269
|
+
* `(Base = Fallback)` bind the very same `Base`, but a default makes the node an
|
|
270
|
+
* `AssignmentPattern` wrapping the identifier rather than an identifier itself
|
|
271
|
+
* -- so matching only `Identifier` let a defaulted parameter fall through and
|
|
272
|
+
* put the factory straight back in front of the import-time crash
|
|
273
|
+
* `isHigherOrderComponentFactory` exists to prevent.
|
|
274
|
+
*
|
|
275
|
+
* A destructured or rest parameter binds no single name and yields nothing,
|
|
276
|
+
* which is what keeps a component's `({ Icon })` prop out.
|
|
277
|
+
*/
|
|
278
|
+
function getSimpleParameterName(param) {
|
|
279
|
+
if (t.isIdentifier(param)) return param.name;
|
|
280
|
+
if (t.isAssignmentPattern(param) && t.isIdentifier(param.left)) return param.left.name;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* The higher-order-component factory whose identity `path` inherits, when
|
|
284
|
+
* `path` is the component that factory returns:
|
|
285
|
+
*
|
|
286
|
+
* ```js
|
|
287
|
+
* export const withCount = (Base) => (props) => <Base count={count.value} />;
|
|
288
|
+
* export function withCount(Base) { return (props) => <Base ... />; }
|
|
289
|
+
* ```
|
|
290
|
+
*
|
|
291
|
+
* The inner function is the real component in both, but it has no name of its
|
|
292
|
+
* own: its only identity comes from being what `withCount` returns. Left
|
|
293
|
+
* unresolved it is not a component to `auto` mode and not an annotatable
|
|
294
|
+
* function to `@useSignals`, so a signal write silently stops re-rendering it
|
|
295
|
+
* -- no error, just a stale UI.
|
|
296
|
+
*
|
|
297
|
+
* Three things have to hold, tested cheapest-first:
|
|
298
|
+
*
|
|
299
|
+
* - `path` sits in a return position of the enclosing function: an explicit
|
|
300
|
+
* `return`, or an arrow's concise body, which returns just as literally.
|
|
301
|
+
* Missing the second is what made the concise form fail even when annotated.
|
|
302
|
+
* - The enclosing function has a resolvable name that is not a hook name, and
|
|
303
|
+
* -- if it is a *component* name -- is a function `isHigherOrderComponentFactory`
|
|
304
|
+
* independently identifies as a factory. A function returned by a component or
|
|
305
|
+
* a hook is otherwise a render prop or a callback that runs inside that owner's
|
|
306
|
+
* render rather than an independently mounted component, and a hook boundary of
|
|
307
|
+
* its own would be an invalid hook call. Factories are camelCase by convention
|
|
308
|
+
* (`withCount`, `connect`, `observer`), so the plain name test reads as
|
|
309
|
+
* "returned from a plain factory"; the PascalCase escape hatch exists because
|
|
310
|
+
* `WithCount` is an equally common spelling of the very same thing, and there
|
|
311
|
+
* the convention alone was not merely unhelpful but actively harmful (see
|
|
312
|
+
* `isHigherOrderComponentFactory`).
|
|
313
|
+
*
|
|
314
|
+
* A `useX` name gets no such escape hatch, deliberately. React calls a hook
|
|
315
|
+
* from inside a render that is already in progress, so anything the hook hands
|
|
316
|
+
* back is a closure the caller may invoke during that same render -- no
|
|
317
|
+
* structural evidence in this file can rule that out, and the safe reading of
|
|
318
|
+
* an ambiguous case is to leave the returned function unresolved rather than
|
|
319
|
+
* give it a boundary that would be an invalid hook call.
|
|
320
|
+
* - `path` renders JSX itself (`rendersJsx`). A factory handing back a
|
|
321
|
+
* comparator, a reducer or any other plain closure returns no component and
|
|
322
|
+
* must not be swept up -- and neither must the middle link of a factory
|
|
323
|
+
* chain, whose JSX is all inside the function *it* returns.
|
|
324
|
+
*
|
|
325
|
+
* Only the immediately enclosing function is consulted, so the inherited name
|
|
326
|
+
* is always a real module binding. A factory that returns a factory
|
|
327
|
+
* (`(a) => (b) => (props) => <p />`) deliberately leaves the innermost
|
|
328
|
+
* function unresolved rather than inventing a name for a middle function that
|
|
329
|
+
* has none -- an `@useSignals` annotation there still warns
|
|
330
|
+
* (`warnUnnamedUseSignalsAnnotation`) instead of silently doing nothing.
|
|
331
|
+
*/
|
|
332
|
+
function getIdentityFactory(path, reactImportSource, climbedParent) {
|
|
333
|
+
if (climbedParent === null) return void 0;
|
|
334
|
+
let enclosing;
|
|
335
|
+
if (climbedParent.isArrowFunctionExpression()) enclosing = climbedParent;
|
|
336
|
+
else if (climbedParent.isReturnStatement()) enclosing = climbedParent.getFunctionParent();
|
|
337
|
+
else return;
|
|
338
|
+
if (enclosing === null) return void 0;
|
|
339
|
+
const name = getBindingIdentityName(enclosing, reactImportSource);
|
|
340
|
+
if (name === void 0 || isHookName(name)) return void 0;
|
|
341
|
+
if (!rendersJsx(path)) return void 0;
|
|
342
|
+
if (isComponentName(name) && !isHigherOrderComponentFactory(enclosing)) return void 0;
|
|
343
|
+
return {
|
|
344
|
+
path: enclosing,
|
|
345
|
+
name
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
function resolveComponentIdentity(path, reactImportSource, climbedParent = climbComponentWrappers(path, reactImportSource).parentPath) {
|
|
349
|
+
const own = getBindingIdentityName(path, reactImportSource, climbedParent);
|
|
350
|
+
if (own !== void 0) return {
|
|
351
|
+
kind: "binding",
|
|
352
|
+
name: own
|
|
353
|
+
};
|
|
354
|
+
const factory = getIdentityFactory(path, reactImportSource, climbedParent);
|
|
355
|
+
if (factory === void 0) return void 0;
|
|
356
|
+
return {
|
|
357
|
+
kind: "hoc-return",
|
|
358
|
+
name: `${factory.name}()`,
|
|
359
|
+
factory: factory.path
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
function isComponentIdentity(identity) {
|
|
363
|
+
if (identity === void 0) return false;
|
|
364
|
+
return identity.kind === "hoc-return" || isComponentName(identity.name);
|
|
365
|
+
}
|
|
366
|
+
function isHookIdentity(identity) {
|
|
367
|
+
if (identity === void 0) return false;
|
|
368
|
+
return identity.kind === "binding" && isHookName(identity.name);
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* A PascalCase identity derived from the module's own file name.
|
|
372
|
+
*
|
|
373
|
+
* `export default (props) => <p>{count.value}</p>` is a perfectly ordinary way
|
|
374
|
+
* to write a component and the only component position in a module that carries
|
|
375
|
+
* no name at all: there is no binding, no key, and no function `id` for
|
|
376
|
+
* `getBindingIdentityName` to read, so the export resolved to no identity and
|
|
377
|
+
* `auto` and `all` alike passed straight over it in silence -- while the named
|
|
378
|
+
* `export default function App()` one line away was transformed.
|
|
379
|
+
*
|
|
380
|
+
* The file name is the name such a module actually has: `Counter.tsx` is
|
|
381
|
+
* imported as `Counter`, which is exactly the identity the rest of the codebase
|
|
382
|
+
* refers to it by. Only its *shape* is ever used -- the derived string
|
|
383
|
+
* classifies the export through `isComponentName`/`isHookName` and is never
|
|
384
|
+
* emitted into the output -- so sanitizing punctuation away (`my-widget.tsx` ->
|
|
385
|
+
* `MyWidget`) costs nothing and cannot collide with anything.
|
|
386
|
+
*
|
|
387
|
+
* A name that does not come out PascalCase (`123.tsx`, a module with no usable
|
|
388
|
+
* stem) yields nothing rather than something invented, and the caller reports
|
|
389
|
+
* that rather than silently skipping the export.
|
|
390
|
+
*/
|
|
391
|
+
function deriveModuleIdentityName(filename) {
|
|
392
|
+
if (filename === void 0) return void 0;
|
|
393
|
+
const base = filename.replace(/[?#].*$/, "").replace(/\\/g, "/");
|
|
394
|
+
const name = base.slice(base.lastIndexOf("/") + 1).replace(/\.[^.]*$/, "").split(/[^A-Za-z0-9]+/).filter((part) => part.length > 0).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
|
|
395
|
+
return isComponentName(name) ? name : void 0;
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* The derived identity for an anonymous `export default`, and nothing else.
|
|
399
|
+
* Reached only after `resolveComponentIdentity` has found no real name, so a
|
|
400
|
+
* named default export (`export default function App()`, `export default memo(
|
|
401
|
+
* function App() {})`) keeps the name it wrote for itself.
|
|
402
|
+
*/
|
|
403
|
+
function getDefaultExportIdentity(climbedParent, filename) {
|
|
404
|
+
if (climbedParent === null || !climbedParent.isExportDefaultDeclaration()) return void 0;
|
|
405
|
+
const name = deriveModuleIdentityName(filename);
|
|
406
|
+
return name === void 0 ? void 0 : {
|
|
407
|
+
kind: "binding",
|
|
408
|
+
name
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
function isVerifiedUseSignalsSource(source, importSource) {
|
|
412
|
+
return source === importSource || source === `${importSource}/runtime`;
|
|
413
|
+
}
|
|
414
|
+
function isNamedUseSignalsImport(functionPath, name, importSource) {
|
|
415
|
+
const resolved = resolveImportedBinding(functionPath, name);
|
|
416
|
+
if (resolved === void 0 || !resolved.specifier.isImportSpecifier()) return false;
|
|
417
|
+
return t.isIdentifier(resolved.specifier.node.imported, { name: "useSignals" }) && (importSource === void 0 || isVerifiedUseSignalsSource(resolved.source, importSource));
|
|
418
|
+
}
|
|
419
|
+
function isNamespaceUseSignalsImport(functionPath, name, importSource) {
|
|
420
|
+
const resolved = resolveImportedBinding(functionPath, name);
|
|
421
|
+
return resolved !== void 0 && resolved.specifier.isImportNamespaceSpecifier() && (importSource === void 0 || isVerifiedUseSignalsSource(resolved.source, importSource));
|
|
422
|
+
}
|
|
423
|
+
function isUseSignalsCallee(functionPath, callee, importSource, allowBarrel = true) {
|
|
424
|
+
if (callee.isIdentifier()) return isNamedUseSignalsImport(functionPath, callee.node.name, allowBarrel ? void 0 : importSource);
|
|
425
|
+
if (!callee.isMemberExpression()) return false;
|
|
426
|
+
const object = callee.get("object");
|
|
427
|
+
if (!object.isIdentifier()) return false;
|
|
428
|
+
return getReadPropertyName(callee.node) === "useSignals" && isNamespaceUseSignalsImport(functionPath, object.node.name, allowBarrel ? void 0 : importSource);
|
|
429
|
+
}
|
|
430
|
+
const renderCallbackMethods = /* @__PURE__ */ new Set([
|
|
431
|
+
"map",
|
|
432
|
+
"flatMap",
|
|
433
|
+
"forEach"
|
|
434
|
+
]);
|
|
435
|
+
function isTransparentWrapper(node) {
|
|
436
|
+
return t.isTSNonNullExpression(node) || t.isTSAsExpression(node) || t.isTSSatisfiesExpression(node) || t.isTSTypeAssertion(node) || t.isTSInstantiationExpression(node);
|
|
437
|
+
}
|
|
438
|
+
/** The value-level node behind any chain of transparent TypeScript wrappers. */
|
|
439
|
+
function unwrapTransparent(node) {
|
|
440
|
+
let current = node;
|
|
441
|
+
while (isTransparentWrapper(current)) current = current.expression;
|
|
442
|
+
return current;
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* The path of the syntactic slot `path` really occupies. A reference wrapped in
|
|
446
|
+
* `Row!` or `Row as Fn` sits one or more nodes below the argument list it is
|
|
447
|
+
* actually part of, so climbing past those wrappers is what makes the enclosing
|
|
448
|
+
* call -- and the node that genuinely sits in its argument list -- visible.
|
|
449
|
+
*/
|
|
450
|
+
function climbTransparentWrappers(path) {
|
|
451
|
+
let current = path;
|
|
452
|
+
for (let parent = current.parentPath; parent !== null && isTransparentWrapper(parent.node) && parent.node.expression === current.node; parent = current.parentPath) current = parent;
|
|
453
|
+
return current;
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* The mirror image of `climbTransparentWrappers`: the path of the value a
|
|
457
|
+
* chain of transparent wrappers is wrapped *around*. A declarator's initializer
|
|
458
|
+
* is such a chain in `const Row = ((item) => <li />) as Fn`, so a check that
|
|
459
|
+
* asks whether a binding was initialized with a function has to descend past
|
|
460
|
+
* the wrappers to find it.
|
|
461
|
+
*/
|
|
462
|
+
function unwrapTransparentPath(path) {
|
|
463
|
+
let current = path;
|
|
464
|
+
while (isTransparentWrapper(current.node)) current = current.get("expression");
|
|
465
|
+
return current;
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* The syntactic slot a function really occupies, past every wrapper that leaves
|
|
469
|
+
* both its value and its identity alone: transparent TypeScript wrappers and
|
|
470
|
+
* React's own `memo()` / `forwardRef()` calls. The two interleave freely --
|
|
471
|
+
* `memo(((props) => <p />) as Fn)` puts the wrapper inside the call and
|
|
472
|
+
* `(memo((props) => <p />)) as Fn` puts it outside -- so one loop alternates
|
|
473
|
+
* between them rather than two separate passes, which would each stop at the
|
|
474
|
+
* first wrapper of the other kind.
|
|
475
|
+
*
|
|
476
|
+
* Only argument 0 -- the component itself -- may climb. `memo`'s second
|
|
477
|
+
* argument is an `areEqual` comparator that React calls during reconciliation,
|
|
478
|
+
* outside any component's render: letting it inherit the wrapper's binding name
|
|
479
|
+
* would classify `memo(Row, (a, b) => a.id === b.id)`'s comparator as the
|
|
480
|
+
* component `MemoRow`, inject a full hook boundary into it, and throw
|
|
481
|
+
* "Invalid hook call" on the first re-render. `forwardRef` takes only one
|
|
482
|
+
* argument, so the same rule costs it nothing.
|
|
483
|
+
*/
|
|
484
|
+
function climbComponentWrappers(path, reactImportSource) {
|
|
485
|
+
let current = climbTransparentWrappers(path);
|
|
486
|
+
for (let parent = current.parentPath; parent !== null && parent.isCallExpression() && parent.node.arguments[0] === current.node && isKnownComponentWrapper(parent, reactImportSource); parent = current.parentPath) current = climbTransparentWrappers(parent);
|
|
487
|
+
return current;
|
|
488
|
+
}
|
|
489
|
+
/** The callee and arguments of a plain or optional-chained call. */
|
|
490
|
+
function getCallParts(node) {
|
|
491
|
+
if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) return {
|
|
492
|
+
callee: node.callee,
|
|
493
|
+
arguments: node.arguments
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Is `callee` a member access naming one of the known iteration methods
|
|
498
|
+
* (`items.map`, `items?.flatMap`, `items["forEach"]`)? The object's runtime
|
|
499
|
+
* type cannot be known statically, so this stays a heuristic on the method
|
|
500
|
+
* name -- but a targeted one. `memo`/`forwardRef` can never match it: a bare
|
|
501
|
+
* `memo(Row)` has an identifier callee, and `React.memo(Row)` names a property
|
|
502
|
+
* that is not in the set, so React's wrappers keep their wrapped component
|
|
503
|
+
* eligible for the transform without needing a special case here.
|
|
504
|
+
*/
|
|
505
|
+
function isRenderCallbackCallee(callee) {
|
|
506
|
+
const node = unwrapTransparent(callee);
|
|
507
|
+
if (!t.isMemberExpression(node) && !t.isOptionalMemberExpression(node)) return false;
|
|
508
|
+
const name = getReadPropertyName(node);
|
|
509
|
+
return name !== void 0 && renderCallbackMethods.has(name);
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* The single notion of "this reference means the function runs synchronously,
|
|
513
|
+
* repeatedly, as part of the caller's render": `callback` is *the* callback
|
|
514
|
+
* argument of a call to a known array iteration method. Both the render-callback
|
|
515
|
+
* exclusion and the read propagation in `inspectFunction` ask this same
|
|
516
|
+
* question, so they cannot drift apart.
|
|
517
|
+
*
|
|
518
|
+
* `map`, `flatMap` and `forEach` all have the signature `(callbackFn, thisArg?)`,
|
|
519
|
+
* so only argument 0 is ever invoked. Accepting any position would read the
|
|
520
|
+
* `thisArg` of `items.map(String, Row)` as a render callback and strip `Row` --
|
|
521
|
+
* a component this call never invokes at all -- of its own subscription, leaving
|
|
522
|
+
* it stale on every later signal write.
|
|
523
|
+
*
|
|
524
|
+
* `callback` is matched against the node that literally sits in the argument
|
|
525
|
+
* list, wrappers included, so a caller holding `Row!` compares equal while a
|
|
526
|
+
* caller holding the inner `Row` does not; use `climbTransparentWrappers` to
|
|
527
|
+
* reach the former from the latter.
|
|
528
|
+
*/
|
|
529
|
+
function isRenderCallbackInvocation(call, callback) {
|
|
530
|
+
const parts = getCallParts(call);
|
|
531
|
+
if (parts === void 0 || !isRenderCallbackCallee(parts.callee)) return false;
|
|
532
|
+
return parts.arguments[0] === callback;
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* The name other statements can reach this function's own binding by. The
|
|
536
|
+
* declarator is reached from the climbed position rather than the immediate
|
|
537
|
+
* parent, because a wrapper on the initializer -- `const Row = ((item) =>
|
|
538
|
+
* <li />) as Fn` -- stands between the function and the binding it names.
|
|
539
|
+
*
|
|
540
|
+
* This is `getBindingIdentityName`'s twin, deliberately not shared with it:
|
|
541
|
+
* that function asks what a function *is* (its public component/hook
|
|
542
|
+
* identity, where the enclosing binding always outranks the function's own
|
|
543
|
+
* name and a `memo()`/`forwardRef()` wrapper is climbed through to reach it);
|
|
544
|
+
* this one asks what other code in the module can *call it by* (a function
|
|
545
|
+
* declaration's own name is authoritative the instant it exists, and only a
|
|
546
|
+
* transparent TypeScript wrapper -- never a component wrapper -- sits between
|
|
547
|
+
* a reference and the binding, since `items.map(memo(Row))` does not hand
|
|
548
|
+
* `map` the plain `Row` reference). Each direction was its own past
|
|
549
|
+
* regression, so keep them separate rather than reconciling the priority or
|
|
550
|
+
* the climb depth.
|
|
551
|
+
*/
|
|
552
|
+
function getOwnBindingName(path) {
|
|
553
|
+
if (path.isFunctionDeclaration() && path.node.id !== null && path.node.id !== void 0) return path.node.id.name;
|
|
554
|
+
const parent = climbTransparentWrappers(path).parentPath;
|
|
555
|
+
if (parent !== null && parent.isVariableDeclarator() && t.isIdentifier(parent.node.id)) return parent.node.id.name;
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* The full path a function in a keyed slot is reached by, from a root binding
|
|
559
|
+
* down: `const ns = { Row: ... }` gives `ns` / `["Row"]`, `Card.Row = ...` gives
|
|
560
|
+
* `Card` / `["Row"]`, and `const a = { b: { Row: ... } }` gives `a` /
|
|
561
|
+
* `["b", "Row"]`.
|
|
562
|
+
*
|
|
563
|
+
* Nesting has to be followed all the way out rather than one level, because the
|
|
564
|
+
* path is matched against use sites: stopping early left `items.map(a.b.Row)`
|
|
565
|
+
* unrecognized, so the per-item callback kept a boundary that React would run
|
|
566
|
+
* once per item inside a single render.
|
|
567
|
+
*
|
|
568
|
+
* A class field is deliberately absent -- it is reached through an instance
|
|
569
|
+
* (`new Holder().Row`), which no binding walk can follow.
|
|
570
|
+
*/
|
|
571
|
+
function getKeyedAccessPath(parent) {
|
|
572
|
+
if (parent.isAssignmentExpression()) {
|
|
573
|
+
const left = parent.node.left;
|
|
574
|
+
if (!t.isMemberExpression(left)) return void 0;
|
|
575
|
+
const path = getStaticMemberPath(left);
|
|
576
|
+
return path === void 0 ? void 0 : {
|
|
577
|
+
origin: parent,
|
|
578
|
+
...path
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
if (!parent.isObjectProperty()) return void 0;
|
|
582
|
+
const key = getPropertyKeyName(parent.node);
|
|
583
|
+
if (key === void 0) return void 0;
|
|
584
|
+
const keys = [key];
|
|
585
|
+
for (let container = parent.parentPath; container.isObjectExpression();) {
|
|
586
|
+
const holder = container.parentPath;
|
|
587
|
+
if (holder === null) return void 0;
|
|
588
|
+
if (holder.isVariableDeclarator() && t.isIdentifier(holder.node.id)) return {
|
|
589
|
+
origin: parent,
|
|
590
|
+
root: holder.node.id.name,
|
|
591
|
+
keys
|
|
592
|
+
};
|
|
593
|
+
if (holder.isAssignmentExpression() && t.isMemberExpression(holder.node.left)) {
|
|
594
|
+
const path = getStaticMemberPath(holder.node.left);
|
|
595
|
+
return path === void 0 ? void 0 : {
|
|
596
|
+
origin: parent,
|
|
597
|
+
root: path.root,
|
|
598
|
+
keys: [...path.keys, ...keys]
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
if (!holder.isObjectProperty() || holder.node.value !== container.node) return void 0;
|
|
602
|
+
const outerKey = getPropertyKeyName(holder.node);
|
|
603
|
+
if (outerKey === void 0) return void 0;
|
|
604
|
+
keys.unshift(outerKey);
|
|
605
|
+
container = holder.parentPath;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* The keyed-slot twin of the by-reference check below: is a function held at
|
|
610
|
+
* `ns.Row` / `Card.Row` handed to an iteration method anywhere in the module
|
|
611
|
+
* (`items.map(ns.Row)`)?
|
|
612
|
+
*
|
|
613
|
+
* A keyed component has no binding of its own, so `getOwnBindingName` can say
|
|
614
|
+
* nothing about it and the ordinary reference walk never runs. Naming such a
|
|
615
|
+
* slot (`getKeyedIdentityName`) is what made it eligible for a boundary in the
|
|
616
|
+
* first place, so this is the exclusion that has to come with it: without it,
|
|
617
|
+
* `const handlers = { Row: (item) => <li>{item.value}</li> }` passed to `map`
|
|
618
|
+
* would be the one keyed shape that gets a hook injected into a per-item
|
|
619
|
+
* callback.
|
|
620
|
+
*
|
|
621
|
+
* The walk goes through the *root* binding rather than the function's, then
|
|
622
|
+
* follows the same chain of keys back down from each reference, so only a use
|
|
623
|
+
* that reads this exact slot counts. A JSX use (`<ns.Row />`) is a
|
|
624
|
+
* `JSXMemberExpression`, not a member expression, so it never matches and a
|
|
625
|
+
* genuine compound component keeps its boundary.
|
|
626
|
+
*/
|
|
627
|
+
function isKeyedRenderCallback(parent) {
|
|
628
|
+
const access = getKeyedAccessPath(parent);
|
|
629
|
+
if (access === void 0) return false;
|
|
630
|
+
const binding = access.origin.scope.getBinding(access.root);
|
|
631
|
+
if (binding === void 0) return false;
|
|
632
|
+
return binding.referencePaths.some((rootPath) => {
|
|
633
|
+
let current = rootPath;
|
|
634
|
+
for (const key of access.keys) {
|
|
635
|
+
const member = current.parentPath;
|
|
636
|
+
if (member === null || !member.isMemberExpression() || member.node.object !== current.node || getReadPropertyName(member.node) !== key) return false;
|
|
637
|
+
current = member;
|
|
638
|
+
}
|
|
639
|
+
const slot = climbTransparentWrappers(current);
|
|
640
|
+
return slot.parentPath !== null && isRenderCallbackInvocation(slot.parentPath.node, slot.node);
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
function isRenderCallback(path, visited = /* @__PURE__ */ new Set()) {
|
|
644
|
+
if (visited.has(path.node)) return false;
|
|
645
|
+
visited.add(path.node);
|
|
646
|
+
const argument = climbTransparentWrappers(path);
|
|
647
|
+
const enclosing = argument.parentPath;
|
|
648
|
+
if (enclosing !== null && isRenderCallbackInvocation(enclosing.node, argument.node)) return true;
|
|
649
|
+
if (enclosing !== null && isKeyedRenderCallback(enclosing)) return true;
|
|
650
|
+
const name = getOwnBindingName(path);
|
|
651
|
+
if (name === void 0 || enclosing === null) return false;
|
|
652
|
+
const binding = enclosing.scope.getBinding(name);
|
|
653
|
+
if (binding === void 0) return false;
|
|
654
|
+
if (binding.path.node !== path.node && binding.path.node !== enclosing.node) return false;
|
|
655
|
+
return binding.referencePaths.some((reference) => {
|
|
656
|
+
const slot = climbTransparentWrappers(reference);
|
|
657
|
+
if (slot.parentPath !== null && isRenderCallbackInvocation(slot.parentPath.node, slot.node)) return true;
|
|
658
|
+
return isCalledFromRenderCallback(slot, visited);
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Is `slot` the callee of a call that happens inside a render callback --
|
|
663
|
+
* `items.map((item) => Row(item))` rather than `items.map(Row)`?
|
|
664
|
+
*
|
|
665
|
+
* Both forms run `Row` once per item inside the owner's single render pass, so
|
|
666
|
+
* both must keep `Row` off a boundary of its own: `Row` called as a plain
|
|
667
|
+
* function is never mounted as a fiber, so three hooks injected into it would
|
|
668
|
+
* run in a loop and crash with "Rendered more hooks than during the previous
|
|
669
|
+
* render" the moment the array length changes. The by-reference form is caught
|
|
670
|
+
* by `isRenderCallbackInvocation`; this is the direct-call twin of it.
|
|
671
|
+
*/
|
|
672
|
+
function isCalledFromRenderCallback(slot, visited) {
|
|
673
|
+
const call = slot.parentPath;
|
|
674
|
+
if (call === null) return false;
|
|
675
|
+
const parts = getCallParts(call.node);
|
|
676
|
+
if (parts === void 0 || parts.callee !== slot.node) return false;
|
|
677
|
+
const owner = call.getFunctionParent();
|
|
678
|
+
return owner !== null && isRenderCallback(owner, visited);
|
|
679
|
+
}
|
|
680
|
+
/** The function `name`, referenced from `origin`'s scope, is bound to. */
|
|
681
|
+
function resolveReferencedFunction(origin, name) {
|
|
682
|
+
const binding = origin.scope.getBinding(name);
|
|
683
|
+
if (binding === void 0) return void 0;
|
|
684
|
+
const bindingPath = binding.path;
|
|
685
|
+
if (bindingPath.isFunctionDeclaration()) return bindingPath;
|
|
686
|
+
if (bindingPath.isVariableDeclarator()) {
|
|
687
|
+
const init = bindingPath.get("init");
|
|
688
|
+
if (init.isExpression()) {
|
|
689
|
+
const value = unwrapTransparentPath(init);
|
|
690
|
+
if (value.isArrowFunctionExpression() || value.isFunctionExpression()) return value;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Is `path` a component defined inline as a call argument --
|
|
696
|
+
* `observer(function App() { return <p>{count.value}</p>; })`?
|
|
697
|
+
*
|
|
698
|
+
* The by-reference form is already eligible and documented as such: `const App
|
|
699
|
+
* = ...; observer(App)` is named by its binding, and a reference passed to any
|
|
700
|
+
* call that is not a known iteration method is "ordinary component
|
|
701
|
+
* registration", where React instantiates the result as its own fiber with its
|
|
702
|
+
* own hooks. The inline form is the same registration written in one
|
|
703
|
+
* expression, and left out it was the one shape a third-party HOC could take
|
|
704
|
+
* that went untransformed *and* unreported.
|
|
705
|
+
*
|
|
706
|
+
* Admitting it needs a discriminator with no ambiguity left in it, so all four
|
|
707
|
+
* of these hold:
|
|
708
|
+
*
|
|
709
|
+
* - a function *expression carrying its own PascalCase name*. An arrow or an
|
|
710
|
+
* anonymous function expression has no identity for a boundary to attach to
|
|
711
|
+
* anyway, and the written-out name is the author's own statement that this is
|
|
712
|
+
* a component -- the same statement `const App = ...` makes.
|
|
713
|
+
* - it renders JSX itself. A named callback that renders nothing is a
|
|
714
|
+
* comparator, a reducer, or a test body.
|
|
715
|
+
* - it sits in an *argument* slot, never the callee, so the IIFE
|
|
716
|
+
* `(function App() { ... })()` -- which runs once at module scope, outside any
|
|
717
|
+
* render, exactly where a hook call throws -- is excluded.
|
|
718
|
+
* - it is not the callback of a deferred hook (`useMemo(function Rows() {...},
|
|
719
|
+
* [])`), which runs inside the caller's render and must carry no hook of its
|
|
720
|
+
* own. The render-callback form (`items.map(function Row() {...})`) is
|
|
721
|
+
* excluded before this is ever reached, by `isRenderCallback`.
|
|
722
|
+
*/
|
|
723
|
+
function isInlineNamedComponentArgument(path, call, reactImportSource) {
|
|
724
|
+
if (!path.isFunctionExpression()) return false;
|
|
725
|
+
const id = path.node.id;
|
|
726
|
+
if (id === null || id === void 0 || !isComponentName(id.name)) return false;
|
|
727
|
+
const slot = climbComponentWrappers(path, reactImportSource);
|
|
728
|
+
if (!call.node.arguments.some((argument) => argument === slot.node)) return false;
|
|
729
|
+
if (isDeferredCallbackArgument(path)) return false;
|
|
730
|
+
return rendersJsx(path);
|
|
731
|
+
}
|
|
732
|
+
function isAutomaticTransformCandidate(path, reactImportSource, climbedParent) {
|
|
733
|
+
if (isRenderCallback(path)) return false;
|
|
734
|
+
if (path.isFunctionDeclaration()) return true;
|
|
735
|
+
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.isClassProperty() || parent.isAssignmentExpression() && t.isMemberExpression(parent.node.left) && getStaticMemberPath(parent.node.left) !== void 0 || parent.isCallExpression() && isInlineNamedComponentArgument(path, parent, reactImportSource));
|
|
737
|
+
}
|
|
738
|
+
function isNestedTrackingBoundary(path, reactImportSource) {
|
|
739
|
+
const climbedParent = climbComponentWrappers(path, reactImportSource).parentPath;
|
|
740
|
+
if (!isAutomaticTransformCandidate(path, reactImportSource, climbedParent)) return false;
|
|
741
|
+
const identity = resolveComponentIdentity(path, reactImportSource, climbedParent);
|
|
742
|
+
return isComponentIdentity(identity) || isHookIdentity(identity);
|
|
743
|
+
}
|
|
744
|
+
const deferredCallbackHooks = /* @__PURE__ */ new Set([
|
|
745
|
+
"useEffect",
|
|
746
|
+
"useLayoutEffect",
|
|
747
|
+
"useInsertionEffect",
|
|
748
|
+
"useCallback",
|
|
749
|
+
"useMemo"
|
|
750
|
+
]);
|
|
751
|
+
/**
|
|
752
|
+
* The property name a member expression reads, for the forms this file
|
|
753
|
+
* tracks. A wrapper can sit around the key alone -- `items["map" as
|
|
754
|
+
* const](Row)` is a plain member expression whose property is the wrapped
|
|
755
|
+
* node -- so a computed property still needs unwrapping to reach the name.
|
|
756
|
+
*/
|
|
757
|
+
function getReadPropertyName(node) {
|
|
758
|
+
if (!node.computed) return t.isIdentifier(node.property) ? node.property.name : void 0;
|
|
759
|
+
const property = unwrapTransparent(node.property);
|
|
760
|
+
return t.isStringLiteral(property) ? property.value : void 0;
|
|
761
|
+
}
|
|
762
|
+
const nonSignalValueReceivers = /* @__PURE__ */ new Set([
|
|
763
|
+
"target",
|
|
764
|
+
"currentTarget",
|
|
765
|
+
"current"
|
|
766
|
+
]);
|
|
767
|
+
function isNonSignalValueReceiver(object) {
|
|
768
|
+
const receiver = unwrapTransparent(object);
|
|
769
|
+
if (!t.isMemberExpression(receiver) && !t.isOptionalMemberExpression(receiver)) return false;
|
|
770
|
+
const name = getReadPropertyName(receiver);
|
|
771
|
+
return name !== void 0 && nonSignalValueReceivers.has(name);
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Is `path` the value of a JSX event-handler attribute -- `onClick={() => ...}`,
|
|
775
|
+
* `onChange={(e) => ...}`?
|
|
776
|
+
*
|
|
777
|
+
* The `on[A-Z]` naming convention is what separates the two kinds of function
|
|
778
|
+
* a JSX attribute can carry. A handler runs from the event loop, long after the
|
|
779
|
+
* render that created it, so `e.target.value` inside one is not a render read.
|
|
780
|
+
* A render prop (`renderItem={(item) => ...}`) is invoked by the receiver
|
|
781
|
+
* during the very same render and stays deliberately included -- it is the
|
|
782
|
+
* documented workaround for the render-prop limitation, and excluding it would
|
|
783
|
+
* leave a component that only reads through one with no subscription at all.
|
|
784
|
+
*/
|
|
785
|
+
function isJsxEventHandlerValue(path) {
|
|
786
|
+
const container = climbTransparentWrappers(path).parentPath;
|
|
787
|
+
if (container === null || !container.isJSXExpressionContainer()) return false;
|
|
788
|
+
const attribute = container.parentPath;
|
|
789
|
+
if (attribute === null || !attribute.isJSXAttribute()) return false;
|
|
790
|
+
const name = attribute.node.name;
|
|
791
|
+
return t.isJSXIdentifier(name) && /^on[A-Z]/.test(name.name);
|
|
792
|
+
}
|
|
793
|
+
/** Is `path` the callback argument of `useEffect`/`useMemo`/one of their kin? */
|
|
794
|
+
function isDeferredCallbackArgument(path) {
|
|
795
|
+
const slot = climbTransparentWrappers(path);
|
|
796
|
+
const call = slot.parentPath;
|
|
797
|
+
if (call === null) return false;
|
|
798
|
+
const parts = getCallParts(call.node);
|
|
799
|
+
if (parts === void 0 || parts.arguments[0] !== slot.node) return false;
|
|
800
|
+
const callee = unwrapTransparent(parts.callee);
|
|
801
|
+
if (t.isIdentifier(callee)) return deferredCallbackHooks.has(callee.name);
|
|
802
|
+
if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) return false;
|
|
803
|
+
const property = getReadPropertyName(callee);
|
|
804
|
+
return property !== void 0 && deferredCallbackHooks.has(property);
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Does a read at `path` sit inside a nested function that `functionPath`'s
|
|
808
|
+
* render never runs -- an event handler, a deferred hook callback, or an async
|
|
809
|
+
* body? Only the functions between the read and `functionPath` are examined:
|
|
810
|
+
* the enclosing component is already known to be synchronous by the time any of
|
|
811
|
+
* this matters, and anything further out owns its own inspection.
|
|
812
|
+
*/
|
|
813
|
+
function isDeferredReadContext(path, functionPath) {
|
|
814
|
+
for (let current = path.getFunctionParent(); current !== null && current.node !== functionPath.node; current = current.getFunctionParent()) {
|
|
815
|
+
if (current.node.async) return true;
|
|
816
|
+
if (isJsxEventHandlerValue(current)) return true;
|
|
817
|
+
if (isDeferredCallbackArgument(current)) return true;
|
|
818
|
+
}
|
|
819
|
+
return false;
|
|
820
|
+
}
|
|
821
|
+
function inspectFunction(functionPath, importSource, reactImportSource, visited = /* @__PURE__ */ new Set([functionPath.node])) {
|
|
822
|
+
const inspection = {
|
|
823
|
+
containsJSX: false,
|
|
824
|
+
readsValue: false,
|
|
825
|
+
hasUseSignalsCall: false
|
|
826
|
+
};
|
|
827
|
+
const foldReferencedRenderCallbacks = (call) => {
|
|
828
|
+
const argument = call.node.arguments[0];
|
|
829
|
+
if (argument === void 0) return;
|
|
830
|
+
const reference = unwrapTransparent(argument);
|
|
831
|
+
if (!t.isIdentifier(reference)) return;
|
|
832
|
+
if (!isRenderCallbackInvocation(call.node, argument)) return;
|
|
833
|
+
const target = resolveReferencedFunction(call, reference.name);
|
|
834
|
+
if (target === void 0 || target.isDescendant(functionPath)) return;
|
|
835
|
+
if (visited.has(target.node)) return;
|
|
836
|
+
visited.add(target.node);
|
|
837
|
+
const nested = inspectFunction(target, importSource, reactImportSource, visited);
|
|
838
|
+
if (nested.containsJSX) inspection.containsJSX = true;
|
|
839
|
+
if (nested.readsValue) inspection.readsValue = true;
|
|
840
|
+
};
|
|
841
|
+
const foldDirectlyCalledRenderCallbacks = (call) => {
|
|
842
|
+
const callee = unwrapTransparent(call.node.callee);
|
|
843
|
+
if (!t.isIdentifier(callee) || !isComponentName(callee.name) && !isHookName(callee.name)) return;
|
|
844
|
+
const owner = call.getFunctionParent();
|
|
845
|
+
if (owner === null || owner.node === functionPath.node || !isRenderCallback(owner)) return;
|
|
846
|
+
const target = resolveReferencedFunction(call, callee.name);
|
|
847
|
+
if (target === void 0 || target.isDescendant(functionPath)) return;
|
|
848
|
+
if (visited.has(target.node)) return;
|
|
849
|
+
visited.add(target.node);
|
|
850
|
+
const nested = inspectFunction(target, importSource, reactImportSource, visited);
|
|
851
|
+
if (nested.containsJSX) inspection.containsJSX = true;
|
|
852
|
+
if (nested.readsValue) inspection.readsValue = true;
|
|
853
|
+
};
|
|
854
|
+
const recordValueRead = (node, path) => {
|
|
855
|
+
if (getReadPropertyName(node) !== "value") return;
|
|
856
|
+
if (isNonSignalValueReceiver(node.object)) return;
|
|
857
|
+
if (isDeferredReadContext(path, functionPath)) return;
|
|
858
|
+
inspection.readsValue = true;
|
|
859
|
+
};
|
|
860
|
+
functionPath.traverse({
|
|
861
|
+
Function(path) {
|
|
862
|
+
if (isNestedTrackingBoundary(path, reactImportSource)) path.skip();
|
|
863
|
+
},
|
|
864
|
+
JSXElement(_path) {
|
|
865
|
+
inspection.containsJSX = true;
|
|
866
|
+
},
|
|
867
|
+
JSXFragment(_path) {
|
|
868
|
+
inspection.containsJSX = true;
|
|
869
|
+
},
|
|
870
|
+
MemberExpression(path) {
|
|
871
|
+
recordValueRead(path.node, path);
|
|
872
|
+
},
|
|
873
|
+
OptionalMemberExpression(path) {
|
|
874
|
+
recordValueRead(path.node, path);
|
|
875
|
+
},
|
|
876
|
+
CallExpression(path) {
|
|
877
|
+
foldReferencedRenderCallbacks(path);
|
|
878
|
+
foldDirectlyCalledRenderCallbacks(path);
|
|
879
|
+
if (path.getFunctionParent()?.node !== functionPath.node) return;
|
|
880
|
+
if (isUseSignalsCallee(functionPath, path.get("callee"), importSource)) inspection.hasUseSignalsCall = true;
|
|
881
|
+
},
|
|
882
|
+
OptionalCallExpression(path) {
|
|
883
|
+
foldReferencedRenderCallbacks(path);
|
|
884
|
+
foldDirectlyCalledRenderCallbacks(path);
|
|
885
|
+
}
|
|
886
|
+
});
|
|
887
|
+
return inspection;
|
|
888
|
+
}
|
|
889
|
+
function findRuntimeImports(programPath, runtimeSource) {
|
|
890
|
+
const imports = [];
|
|
891
|
+
for (const statement of programPath.get("body")) {
|
|
892
|
+
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: "useSignals" })) imports.push({
|
|
894
|
+
identifier: t.cloneNode(specifier.node.local),
|
|
895
|
+
bindingPath: specifier
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
return imports;
|
|
899
|
+
}
|
|
900
|
+
function addRuntimeImport(programPath, runtimeSource, functionPath) {
|
|
901
|
+
const local = functionPath.scope.generateUidIdentifier("useSignals");
|
|
902
|
+
const declaration = t.importDeclaration([t.importSpecifier(t.cloneNode(local), t.identifier("useSignals"))], t.stringLiteral(runtimeSource));
|
|
903
|
+
const imports = programPath.get("body").filter((path) => path.isImportDeclaration());
|
|
904
|
+
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("Failed to insert the useSignals import");
|
|
906
|
+
programPath.scope.registerDeclaration(inserted);
|
|
907
|
+
const specifier = inserted.get("specifiers")[0];
|
|
908
|
+
if (specifier === void 0 || !specifier.isImportSpecifier()) throw new Error("Failed to register the useSignals import");
|
|
909
|
+
return {
|
|
910
|
+
identifier: local,
|
|
911
|
+
bindingPath: specifier
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* Is `statements`' first entry a bare zero-argument call whose callee resolves
|
|
916
|
+
* to `useSignals`, with `allowBarrel` controlling whether a barrel/re-export
|
|
917
|
+
* chain counts (see `isUseSignalsCallee`)? Shared by `isExplicitUseSignals`
|
|
918
|
+
* (`allowBarrel: false`, the verified boundary) and
|
|
919
|
+
* `isUnverifiableBarrelUseSignals` (`allowBarrel: true`, to detect the exact
|
|
920
|
+
* call the former rejects).
|
|
921
|
+
*/
|
|
922
|
+
function isFirstStatementUseSignalsCall(functionPath, statements, importSource, allowBarrel) {
|
|
923
|
+
const first = statements[0];
|
|
924
|
+
if (first === void 0 || !first.isExpressionStatement()) return false;
|
|
925
|
+
const expression = first.get("expression");
|
|
926
|
+
if (!expression.isCallExpression() || expression.node.arguments.length !== 0) return false;
|
|
927
|
+
return isUseSignalsCallee(functionPath, expression.get("callee"), importSource, allowBarrel);
|
|
928
|
+
}
|
|
929
|
+
function isExplicitUseSignals(functionPath, statements, importSource) {
|
|
930
|
+
return isFirstStatementUseSignalsCall(functionPath, statements, importSource, false);
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* The named import specifier the absorbed explicit `useSignals()` call resolves
|
|
934
|
+
* to, when the call is a bare identifier bound by one. `applyManaged` replaces
|
|
935
|
+
* that call with its own store declaration, which can leave the import behind
|
|
936
|
+
* with nothing referencing it -- so `Program.exit` needs the specifier to check
|
|
937
|
+
* and drop. A namespace import is deliberately not returned: the namespace
|
|
938
|
+
* object may be used for other exports of the same module.
|
|
939
|
+
*/
|
|
940
|
+
function getAbsorbedUseSignalsImport(functionPath, statements) {
|
|
941
|
+
const first = statements[0];
|
|
942
|
+
if (first === void 0 || !first.isExpressionStatement()) return void 0;
|
|
943
|
+
const expression = first.get("expression");
|
|
944
|
+
if (!expression.isCallExpression()) return void 0;
|
|
945
|
+
const callee = expression.get("callee");
|
|
946
|
+
if (!callee.isIdentifier()) return void 0;
|
|
947
|
+
const resolved = resolveImportedBinding(functionPath, callee.node.name);
|
|
948
|
+
if (resolved === void 0 || !resolved.specifier.isImportSpecifier()) return void 0;
|
|
949
|
+
return resolved.specifier;
|
|
950
|
+
}
|
|
951
|
+
function isUnverifiableBarrelUseSignals(functionPath, statements, importSource, explicit) {
|
|
952
|
+
if (explicit) return false;
|
|
953
|
+
return isFirstStatementUseSignalsCall(functionPath, statements, importSource, true);
|
|
954
|
+
}
|
|
955
|
+
function warnUnverifiableBarrelUseSignals(path, importSource) {
|
|
956
|
+
const warning = path.buildCodeFrameError(`This useSignals() 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 useSignals() boundary. Import useSignals directly from "${importSource}" or "${importSource}/runtime" instead to get the verified boundary.`);
|
|
957
|
+
console.warn(warning.message);
|
|
958
|
+
}
|
|
959
|
+
function warnUnnamedUseSignalsAnnotation(path) {
|
|
960
|
+
const warning = path.buildCodeFrameError("This @useSignals 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/class property key -- to opt it in.");
|
|
961
|
+
console.warn(warning.message);
|
|
962
|
+
}
|
|
963
|
+
function warnUnnamedDefaultExport(path) {
|
|
964
|
+
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
|
+
console.warn(warning.message);
|
|
966
|
+
}
|
|
967
|
+
function shouldAutomaticallyTransform(mode, inspection, identity) {
|
|
968
|
+
if (isHookIdentity(identity)) return mode !== "manual" && inspection.readsValue;
|
|
969
|
+
if (!isComponentIdentity(identity) || !inspection.containsJSX) return false;
|
|
970
|
+
return mode === "all" || mode === "auto" && inspection.readsValue;
|
|
971
|
+
}
|
|
972
|
+
/** Marks the current file as having produced at least one real change. */
|
|
973
|
+
function markTransformed(state) {
|
|
974
|
+
state.file.metadata[transformedMetadataKey] = true;
|
|
975
|
+
}
|
|
976
|
+
/**
|
|
977
|
+
* Resolves what, if anything, `path` needs done to it: the opt-out check,
|
|
978
|
+
* explicit/annotated/automatic eligibility (plus the barrel-useSignals
|
|
979
|
+
* warning that eligibility check surfaces along the way), the
|
|
980
|
+
* async/generator guard, and -- once a function is confirmed eligible for
|
|
981
|
+
* real codegen -- acquiring the runtime `useSignals` import it will call.
|
|
982
|
+
* Pure decision-making: no AST mutation happens here, so every early return is
|
|
983
|
+
* just a `return`, not a `return` guarding mutations already made.
|
|
984
|
+
*/
|
|
985
|
+
function decideTransform(path, state, options, reactImportSource) {
|
|
986
|
+
const climbedParent = climbComponentWrappers(path, reactImportSource).parentPath;
|
|
987
|
+
const identity = resolveComponentIdentity(path, reactImportSource, climbedParent) ?? getDefaultExportIdentity(climbedParent, state.filename);
|
|
988
|
+
const factory = identity?.kind === "hoc-return" ? identity.factory : void 0;
|
|
989
|
+
const ownsLeadingComment = (pattern) => hasOwnedLeadingComment(path, pattern) || factory !== void 0 && hasOwnedLeadingComment(factory, pattern);
|
|
990
|
+
if (ownsLeadingComment(noUseSignalsComment)) return { kind: "skip" };
|
|
991
|
+
const body = path.get("body");
|
|
992
|
+
const statements = body.isBlockStatement() ? body.get("body") : [];
|
|
993
|
+
const explicit = isExplicitUseSignals(path, statements, options.importSource);
|
|
994
|
+
if (isUnverifiableBarrelUseSignals(path, statements, options.importSource, explicit)) warnUnverifiableBarrelUseSignals(path, options.importSource);
|
|
995
|
+
const inspection = inspectFunction(path, options.importSource, reactImportSource);
|
|
996
|
+
const annotation = ownsLeadingComment(useSignalsComment);
|
|
997
|
+
const isFactory = isHigherOrderComponentFactory(path) && !isHookIdentity(identity);
|
|
998
|
+
const annotated = annotation && !isFactory && (isComponentIdentity(identity) || isHookIdentity(identity));
|
|
999
|
+
const candidate = isAutomaticTransformCandidate(path, reactImportSource, climbedParent);
|
|
1000
|
+
if (annotation && !annotated && rendersJsx(path)) {
|
|
1001
|
+
const enclosing = path.getFunctionParent();
|
|
1002
|
+
if (enclosing === null || !hasOwnedLeadingComment(enclosing, useSignalsComment)) warnUnnamedUseSignalsAnnotation(path);
|
|
1003
|
+
}
|
|
1004
|
+
if (options.mode !== "manual" && identity === void 0 && climbedParent !== null && climbedParent.isExportDefaultDeclaration() && rendersJsx(path)) warnUnnamedDefaultExport(path);
|
|
1005
|
+
const automatic = !isFactory && candidate && shouldAutomaticallyTransform(options.mode, inspection, identity);
|
|
1006
|
+
if (!explicit && !annotated && !automatic) return { kind: "skip" };
|
|
1007
|
+
if (!explicit && inspection.hasUseSignalsCall) return { kind: "skip" };
|
|
1008
|
+
if (options.transform === "inject" && explicit) return {
|
|
1009
|
+
kind: "directive-only",
|
|
1010
|
+
body
|
|
1011
|
+
};
|
|
1012
|
+
if (path.node.async || path.node.generator) {
|
|
1013
|
+
if (!explicit && !annotated) return { kind: "skip" };
|
|
1014
|
+
throw path.buildCodeFrameError("useSignals transform only supports synchronous, non-generator functions");
|
|
1015
|
+
}
|
|
1016
|
+
const managedRuntimeSource = `${options.importSource}/runtime`;
|
|
1017
|
+
const importSource = options.transform === "managed" ? managedRuntimeSource : options.importSource;
|
|
1018
|
+
const imports = options.transform === "managed" ? state.managedRuntimeImports : state.directImports;
|
|
1019
|
+
let runtimeImport = imports.find(({ identifier, bindingPath }) => path.scope.getBinding(identifier.name)?.path === bindingPath);
|
|
1020
|
+
if (runtimeImport === void 0) {
|
|
1021
|
+
runtimeImport = addRuntimeImport(state.programPath, importSource, path);
|
|
1022
|
+
imports.push(runtimeImport);
|
|
1023
|
+
}
|
|
1024
|
+
return {
|
|
1025
|
+
kind: options.transform,
|
|
1026
|
+
body,
|
|
1027
|
+
statements,
|
|
1028
|
+
explicit,
|
|
1029
|
+
runtimeImport,
|
|
1030
|
+
absorbedImport: explicit ? getAbsorbedUseSignalsImport(path, statements) : void 0
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
/** Codegen: inject a bare `useSignals()` call at the top of the function body. */
|
|
1034
|
+
function applyInject(path, { body, runtimeImport }, options, state) {
|
|
1035
|
+
const call = t.expressionStatement(t.callExpression(t.cloneNode(runtimeImport.identifier), []));
|
|
1036
|
+
if (body.isBlockStatement()) body.unshiftContainer("body", call);
|
|
1037
|
+
else body.replaceWith(t.blockStatement([call, t.returnStatement(body.node)]));
|
|
1038
|
+
const injectedBody = path.node.body;
|
|
1039
|
+
if (options.reactCompiler === "auto" && t.isBlockStatement(injectedBody)) addNoMemoDirective(injectedBody);
|
|
1040
|
+
path.scope.crawl();
|
|
1041
|
+
markTransformed(state);
|
|
1042
|
+
}
|
|
1043
|
+
/** Codegen: wrap the function body in the managed try/finally render-tracking scope. */
|
|
1044
|
+
function applyManaged(path, { body, statements, explicit, runtimeImport, absorbedImport }, options, state) {
|
|
1045
|
+
const store = path.scope.generateUidIdentifier("signals");
|
|
1046
|
+
const declaration = t.variableDeclaration("const", [t.variableDeclarator(t.cloneNode(store), t.callExpression(t.cloneNode(runtimeImport.identifier), []))]);
|
|
1047
|
+
const first = statements[0];
|
|
1048
|
+
if (explicit && first !== void 0) t.inheritsComments(declaration, first.node);
|
|
1049
|
+
const originalStatements = body.isBlockStatement() ? statements.slice(explicit ? 1 : 0).map((statement) => statement.node) : [t.returnStatement(body.node)];
|
|
1050
|
+
const transformedBody = t.blockStatement([declaration, t.tryStatement(t.blockStatement(originalStatements), null, t.blockStatement([t.expressionStatement(t.callExpression(t.memberExpression(t.cloneNode(store), t.identifier("f")), []))]))]);
|
|
1051
|
+
if (body.isBlockStatement()) transformedBody.directives = body.node.directives;
|
|
1052
|
+
if (options.reactCompiler === "auto") addNoMemoDirective(transformedBody);
|
|
1053
|
+
body.replaceWith(transformedBody);
|
|
1054
|
+
path.scope.crawl();
|
|
1055
|
+
if (absorbedImport !== void 0) state.absorbedImports.push(absorbedImport);
|
|
1056
|
+
markTransformed(state);
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Drops an `import { useSignals } from "<importSource>"` the managed transform
|
|
1060
|
+
* absorbed and nothing else refers to any more, along with the whole
|
|
1061
|
+
* declaration once its last specifier goes. A reused `/runtime` import survives
|
|
1062
|
+
* this untouched: the emitted store declaration still calls it, so the binding
|
|
1063
|
+
* is still referenced.
|
|
1064
|
+
*/
|
|
1065
|
+
function removeAbsorbedImports(programPath, state) {
|
|
1066
|
+
if (state.absorbedImports.length === 0) return;
|
|
1067
|
+
programPath.scope.crawl();
|
|
1068
|
+
for (const specifier of state.absorbedImports) {
|
|
1069
|
+
if (specifier.removed) continue;
|
|
1070
|
+
const binding = programPath.scope.getBinding(specifier.node.local.name);
|
|
1071
|
+
if (binding === void 0 || binding.path.node !== specifier.node || binding.referenced) continue;
|
|
1072
|
+
const declaration = specifier.parentPath;
|
|
1073
|
+
specifier.remove();
|
|
1074
|
+
if (declaration.isImportDeclaration() && declaration.node.specifiers.length === 0) declaration.remove();
|
|
1075
|
+
}
|
|
1076
|
+
state.absorbedImports = [];
|
|
1077
|
+
}
|
|
1078
|
+
const babelTransform = declare((api, options) => {
|
|
1079
|
+
api.assertVersion(8);
|
|
1080
|
+
const managedRuntimeSource = `${options.importSource}/runtime`;
|
|
1081
|
+
const reactImportSource = options.reactImportSource;
|
|
1082
|
+
return {
|
|
1083
|
+
name: "unplugin-react-fine-grained-signals",
|
|
1084
|
+
visitor: {
|
|
1085
|
+
Program: {
|
|
1086
|
+
enter(path, state) {
|
|
1087
|
+
state.programPath = path;
|
|
1088
|
+
state.managedRuntimeImports = findRuntimeImports(path, managedRuntimeSource);
|
|
1089
|
+
state.directImports = findRuntimeImports(path, options.importSource);
|
|
1090
|
+
state.absorbedImports = [];
|
|
1091
|
+
state.file.metadata[transformedMetadataKey] = false;
|
|
1092
|
+
},
|
|
1093
|
+
exit(path, state) {
|
|
1094
|
+
removeAbsorbedImports(path, state);
|
|
1095
|
+
}
|
|
1096
|
+
},
|
|
1097
|
+
Function(path, state) {
|
|
1098
|
+
const decision = decideTransform(path, state, options, reactImportSource);
|
|
1099
|
+
switch (decision.kind) {
|
|
1100
|
+
case "skip": return;
|
|
1101
|
+
case "directive-only":
|
|
1102
|
+
if (options.reactCompiler === "auto" && decision.body.isBlockStatement() && addNoMemoDirective(decision.body.node)) markTransformed(state);
|
|
1103
|
+
return;
|
|
1104
|
+
case "inject":
|
|
1105
|
+
applyInject(path, decision, options, state);
|
|
1106
|
+
return;
|
|
1107
|
+
case "managed":
|
|
1108
|
+
applyManaged(path, decision, options, state);
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
};
|
|
1114
|
+
});
|
|
1115
|
+
/**
|
|
1116
|
+
* A raw-text screen run before the file is parsed at all. Parsing, walking,
|
|
1117
|
+
* printing and generating a source map is the whole cost of this transform, and
|
|
1118
|
+
* it is discarded outright for every file none of the three opt-in routes can
|
|
1119
|
+
* possibly apply to:
|
|
1120
|
+
*
|
|
1121
|
+
* - explicit and annotated opt-in both need the text `useSignals` (an aliased
|
|
1122
|
+
* import, a namespace call and the `@useSignals` comment all still contain
|
|
1123
|
+
* it);
|
|
1124
|
+
* - `auto` needs a `.value` read, so it needs the text `value`;
|
|
1125
|
+
* - `all` additionally wraps JSX components, so it needs a `<` -- but its
|
|
1126
|
+
* custom-hook rule still needs a `.value` read.
|
|
1127
|
+
*
|
|
1128
|
+
* Each test is a plain substring scan and each one is deliberately wider than
|
|
1129
|
+
* what the AST check accepts, so this can only ever bail out on files the walk
|
|
1130
|
+
* would have rejected anyway.
|
|
1131
|
+
*/
|
|
1132
|
+
function mightTransform(code, mode) {
|
|
1133
|
+
if (code.includes("useSignals")) return true;
|
|
1134
|
+
if (mode === "manual") return false;
|
|
1135
|
+
if (code.includes("value")) return true;
|
|
1136
|
+
return mode === "all" && code.includes("<");
|
|
1137
|
+
}
|
|
1138
|
+
/** Runs the private Babel transform for the universal bundler adapter. */
|
|
1139
|
+
function transformReactFineGrainedSignals(code, id, options) {
|
|
1140
|
+
if (!mightTransform(code, options.mode)) return null;
|
|
1141
|
+
const cleanId = id.replace(/[?#].*$/, "");
|
|
1142
|
+
const isTypeScript = /\.[cm]?tsx?$/i.test(cleanId);
|
|
1143
|
+
const supportsJsx = /\.[cm]?(?:jsx?|tsx)$/i.test(cleanId);
|
|
1144
|
+
const parserPlugins = [];
|
|
1145
|
+
if (supportsJsx) parserPlugins.push("jsx");
|
|
1146
|
+
if (isTypeScript) parserPlugins.push("typescript");
|
|
1147
|
+
parserPlugins.push("decorators-legacy", "decoratorAutoAccessors");
|
|
1148
|
+
let result;
|
|
1149
|
+
try {
|
|
1150
|
+
result = transformSync(code, {
|
|
1151
|
+
babelrc: false,
|
|
1152
|
+
configFile: false,
|
|
1153
|
+
filename: id,
|
|
1154
|
+
sourceFileName: cleanId,
|
|
1155
|
+
parserOpts: { plugins: parserPlugins },
|
|
1156
|
+
plugins: [[babelTransform, options]],
|
|
1157
|
+
sourceMaps: true
|
|
1158
|
+
});
|
|
1159
|
+
} catch (error) {
|
|
1160
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1161
|
+
throw new Error(`unplugin-react-fine-grained-signals could not parse ${cleanId} (Babel parser plugins: ${parserPlugins.join(", ") || "none"}): ${detail}`, { cause: error });
|
|
1162
|
+
}
|
|
1163
|
+
if (result === null || typeof result.code !== "string" || result.metadata?.[transformedMetadataKey] !== true) return null;
|
|
1164
|
+
return {
|
|
1165
|
+
code: result.code,
|
|
1166
|
+
map: result.map
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
//#endregion
|
|
1170
|
+
//#region src/unplugin.ts
|
|
1171
|
+
const SCRIPT_MODULE = /\.m?[jt]sx?$/;
|
|
1172
|
+
const pluginName = "unplugin-react-fine-grained-signals";
|
|
1173
|
+
/**
|
|
1174
|
+
* `id` with the `?query`/`#fragment` a bundler appends taken off, which is
|
|
1175
|
+
* exactly what the transform strips before it picks its parser plugins.
|
|
1176
|
+
*
|
|
1177
|
+
* Deciding on the raw id instead diverges from that decision in both
|
|
1178
|
+
* directions. Vite's SFC sub-request `App.vue?vue&type=script&lang.tsx` ends in
|
|
1179
|
+
* `.tsx` only because its *query* does, so it would be claimed and then reach
|
|
1180
|
+
* the parser as `App.vue` -- with neither the `jsx` nor the `typescript` plugin
|
|
1181
|
+
* enabled -- and fail as though the author had written broken syntax.
|
|
1182
|
+
* `App.tsx#frag` ends in neither, so it would be skipped although the transform
|
|
1183
|
+
* handles it perfectly well. Cleaning here keeps the claim and the parse
|
|
1184
|
+
* looking at the same name.
|
|
1185
|
+
*/
|
|
1186
|
+
function cleanModuleId(id) {
|
|
1187
|
+
return id.replace(/[?#].*$/, "");
|
|
1188
|
+
}
|
|
1189
|
+
function canTransform(id, options) {
|
|
1190
|
+
if (id.startsWith("\0")) return false;
|
|
1191
|
+
if (id.includes("/node_modules/") || id.includes("\\node_modules\\")) return false;
|
|
1192
|
+
if (!SCRIPT_MODULE.test(cleanModuleId(id))) return false;
|
|
1193
|
+
if (options.exclude?.(id)) return false;
|
|
1194
|
+
return options.include?.(id) ?? true;
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* `.js`/`.mjs` deliberately resolve to `"jsx"`. JavaScript commonly carries JSX
|
|
1198
|
+
* without a `.jsx` suffix -- the transform's own parser configuration says so,
|
|
1199
|
+
* and its output for such a file still contains that JSX -- but unplugin's
|
|
1200
|
+
* esbuild adapter labels every file it hands back with `guessLoader`, which
|
|
1201
|
+
* maps `.js` to `"js"` and so overrides a project's own
|
|
1202
|
+
* `loader: { ".js": "jsx" }` setting. Because that adapter returns
|
|
1203
|
+
* `{ contents, loader }` for every file passing `transformInclude`, even the
|
|
1204
|
+
* ones this transform declines to touch, a single wrong answer here breaks the
|
|
1205
|
+
* whole build rather than just one module.
|
|
1206
|
+
*/
|
|
1207
|
+
const ESBUILD_LOADERS = {
|
|
1208
|
+
".js": "jsx",
|
|
1209
|
+
".mjs": "jsx",
|
|
1210
|
+
".jsx": "jsx",
|
|
1211
|
+
".ts": "ts",
|
|
1212
|
+
".mts": "ts",
|
|
1213
|
+
".tsx": "tsx"
|
|
1214
|
+
};
|
|
1215
|
+
/**
|
|
1216
|
+
* The loader to label `id`'s contents with, deferring to the build's own
|
|
1217
|
+
* `loader` map first so a project that configured an extension keeps its
|
|
1218
|
+
* choice. Exported for the adapter tests, which exercise the decision without
|
|
1219
|
+
* needing esbuild itself.
|
|
1220
|
+
*/
|
|
1221
|
+
function resolveEsbuildLoader(id, configuredLoaders) {
|
|
1222
|
+
const extension = /\.[^./\\]*$/.exec(cleanModuleId(id))?.[0].toLowerCase() ?? "";
|
|
1223
|
+
return configuredLoaders?.[extension] ?? ESBUILD_LOADERS[extension] ?? "js";
|
|
1224
|
+
}
|
|
1225
|
+
let transformLoaderPath;
|
|
1226
|
+
/** The absolute path of this package's own webpack/rspack loader, if reachable. */
|
|
1227
|
+
function resolveTransformLoaderPath() {
|
|
1228
|
+
if (transformLoaderPath === void 0) try {
|
|
1229
|
+
transformLoaderPath = fileURLToPath(new URL("./loader.js", import.meta.url));
|
|
1230
|
+
} catch {
|
|
1231
|
+
transformLoaderPath = null;
|
|
1232
|
+
}
|
|
1233
|
+
return transformLoaderPath;
|
|
1234
|
+
}
|
|
1235
|
+
const patchedMarker = "__reactFineGrainedSignalsLoaderPatched";
|
|
1236
|
+
let loaderPatchReported = false;
|
|
1237
|
+
/**
|
|
1238
|
+
* Says, once per process, that the rewrite below found nothing to rewrite.
|
|
1239
|
+
*
|
|
1240
|
+
* That rewrite is deliberately shape-matched, so an unplugin release which
|
|
1241
|
+
* restructures its transform rule keeps its own behaviour rather than breaking
|
|
1242
|
+
* the build -- but its own behaviour is the dropped source map this package's
|
|
1243
|
+
* loader exists to avoid, and falling back to it in silence is precisely the
|
|
1244
|
+
* regression nobody would notice. One line is enough to find it; repeating it
|
|
1245
|
+
* per module or per rebuild would only teach people to ignore it.
|
|
1246
|
+
*/
|
|
1247
|
+
function reportLoaderPatchSkipped(reason) {
|
|
1248
|
+
if (loaderPatchReported) return;
|
|
1249
|
+
loaderPatchReported = true;
|
|
1250
|
+
console.warn(`[${pluginName}] Source map fix not applied: ${reason}. Falling back to unplugin's own transform loader, whose output maps point at pre-transform line numbers.`);
|
|
1251
|
+
}
|
|
1252
|
+
/** Is this the bare `{ enforce, use(data) }` rule unplugin adds for `transform`? */
|
|
1253
|
+
function isUnpluginTransformRule(rule) {
|
|
1254
|
+
if (rule === null || typeof rule !== "object") return false;
|
|
1255
|
+
if (typeof rule.use !== "function") return false;
|
|
1256
|
+
return Object.keys(rule).every((key) => key === "use" || key === "enforce");
|
|
1257
|
+
}
|
|
1258
|
+
/**
|
|
1259
|
+
* Repoints unplugin's webpack/rspack transform rule at this package's own
|
|
1260
|
+
* loader, which is the same loader with the source map passed through
|
|
1261
|
+
* correctly -- see `src/loader.ts` for why unplugin's copy drops it.
|
|
1262
|
+
*
|
|
1263
|
+
* The rule is rewritten rather than replaced: unplugin's `use(data)` still
|
|
1264
|
+
* decides *whether* this file is claimed (it applies `transformInclude` and
|
|
1265
|
+
* carries the plugin object along in `options`), and only the loader entries it
|
|
1266
|
+
* stamped with this plugin's `ident` have their module path swapped. Anything
|
|
1267
|
+
* that does not match that shape is left exactly as it was, so a future
|
|
1268
|
+
* unplugin release that restructures this simply gets its own behaviour back
|
|
1269
|
+
* instead of a broken build.
|
|
1270
|
+
*/
|
|
1271
|
+
function useOwnTransformLoader(compiler) {
|
|
1272
|
+
const loaderPath = resolveTransformLoaderPath();
|
|
1273
|
+
if (loaderPath === null) {
|
|
1274
|
+
reportLoaderPatchSkipped("this package's own loader could not be located on disk");
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1277
|
+
const rules = compiler?.options?.module?.rules;
|
|
1278
|
+
if (!Array.isArray(rules)) {
|
|
1279
|
+
reportLoaderPatchSkipped("the compiler exposes no `module.rules` array");
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
let matched = false;
|
|
1283
|
+
for (const rule of rules) {
|
|
1284
|
+
if (!isUnpluginTransformRule(rule)) continue;
|
|
1285
|
+
matched = true;
|
|
1286
|
+
const original = rule.use;
|
|
1287
|
+
if (original[patchedMarker] === true) continue;
|
|
1288
|
+
const patched = (data) => {
|
|
1289
|
+
const entries = original(data);
|
|
1290
|
+
if (!Array.isArray(entries)) return entries;
|
|
1291
|
+
return entries.map((entry) => entry !== null && typeof entry === "object" && entry.ident === "unplugin-react-fine-grained-signals" ? {
|
|
1292
|
+
...entry,
|
|
1293
|
+
loader: loaderPath
|
|
1294
|
+
} : entry);
|
|
1295
|
+
};
|
|
1296
|
+
patched[patchedMarker] = true;
|
|
1297
|
+
rule.use = patched;
|
|
1298
|
+
}
|
|
1299
|
+
if (!matched) reportLoaderPatchSkipped("unplugin's transform rule was not found in `module.rules`");
|
|
1300
|
+
}
|
|
1301
|
+
const reactFineGrainedSignals = createUnplugin((options = {}) => {
|
|
1302
|
+
let configuredLoaders;
|
|
1303
|
+
return {
|
|
1304
|
+
name: pluginName,
|
|
1305
|
+
enforce: "pre",
|
|
1306
|
+
transformInclude(id) {
|
|
1307
|
+
return canTransform(id, options);
|
|
1308
|
+
},
|
|
1309
|
+
transform(code, id) {
|
|
1310
|
+
return transformReactFineGrainedSignals(code, id, {
|
|
1311
|
+
importSource: options.importSource ?? "react-fine-grained-signals",
|
|
1312
|
+
reactImportSource: options.reactImportSource ?? "react",
|
|
1313
|
+
mode: options.mode ?? "auto",
|
|
1314
|
+
transform: options.transform ?? "managed",
|
|
1315
|
+
reactCompiler: options.reactCompiler ?? "auto"
|
|
1316
|
+
});
|
|
1317
|
+
},
|
|
1318
|
+
esbuild: {
|
|
1319
|
+
config(buildOptions) {
|
|
1320
|
+
configuredLoaders = buildOptions.loader;
|
|
1321
|
+
},
|
|
1322
|
+
loader(_code, id) {
|
|
1323
|
+
return resolveEsbuildLoader(id, configuredLoaders);
|
|
1324
|
+
}
|
|
1325
|
+
},
|
|
1326
|
+
webpack: useOwnTransformLoader,
|
|
1327
|
+
rspack: useOwnTransformLoader
|
|
1328
|
+
};
|
|
1329
|
+
});
|
|
1330
|
+
/**
|
|
1331
|
+
* The one piece every per-bundler entry file (`vite.ts`, `webpack.ts`,
|
|
1332
|
+
* `rollup.ts`, `esbuild.ts`, `rspack.ts`) repeated: casting `reactFineGrainedSignals`'s
|
|
1333
|
+
* `bundler` property to a callable factory of its own bundler-specific plugin
|
|
1334
|
+
* type. Each entry file supplies only the two things that actually differ
|
|
1335
|
+
* between bundlers -- which property to read and what shape it returns.
|
|
1336
|
+
*/
|
|
1337
|
+
function createBundlerPlugin(bundler) {
|
|
1338
|
+
return reactFineGrainedSignals[bundler];
|
|
1339
|
+
}
|
|
1340
|
+
//#endregion
|
|
1341
|
+
export { resolveEsbuildLoader as a, reactFineGrainedSignals as i, createBundlerPlugin as n, pluginName as r, canTransform as t };
|