unplugin-react-fine-grained-signals 0.1.1 → 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.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | [日本語](README.ja.md)
4
4
 
5
- Universal bundler integration for automatic `useSignals()` insertion and the
5
+ Universal bundler integration for automatic `useSignalTracking()` insertion and the
6
6
  optional managed render-scope transform in
7
7
  [`react-fine-grained-signals`](https://www.npmjs.com/package/react-fine-grained-signals).
8
8
 
@@ -16,8 +16,25 @@ supported bundlers.
16
16
  pnpm add -D unplugin-react-fine-grained-signals
17
17
  ```
18
18
 
19
- `react-fine-grained-signals` is a peer dependency. This package is ESM-only:
20
- use an ESM build configuration and `import`, not CommonJS `require()`.
19
+ `react-fine-grained-signals` is a peer dependency. Node.js `^22.18.0 || >=24.11.0`
20
+ is required — the range Babel 8, which the transform runs on, declares.
21
+
22
+ Use the plugin and the runtime from the same release line (v0.2 with v0.2): the
23
+ generated code imports runtime entry points that change between lines. When
24
+ upgrading from v0.1, upgrade both packages together and rename the
25
+ `@useSignals` / `@noUseSignals` comments — see the
26
+ [v0.2 migration guide](https://github.com/sakzw/react-fine-grained-signals/blob/main/docs/migration/v0.2.md#transform-plugin).
27
+
28
+ This package ships ESM only; there is no CommonJS build. An ESM build
29
+ configuration that uses `import` is the primary setup, but a CommonJS
30
+ configuration file (a `webpack.config.js` or `next.config.js` in a project
31
+ without `"type": "module"`) can still load it with `require()`: every Node.js
32
+ version in the range above loads the ESM entry points through its built-in
33
+ `require(esm)` support. The plugin factory is the `default` export:
34
+
35
+ ```js
36
+ const signals = require("unplugin-react-fine-grained-signals/webpack").default;
37
+ ```
21
38
 
22
39
  ## Vite
23
40
 
@@ -30,6 +47,65 @@ export default defineConfig({
30
47
  });
31
48
  ```
32
49
 
50
+ ### With React Compiler
51
+
52
+ React Compiler must run *after* this transform: it would otherwise memoize away
53
+ the `.value` reads before this plugin could mark the component `"use no memo"`,
54
+ and the component would silently stop updating after its first render. List
55
+ `signals()` before the plugin that runs the compiler:
56
+
57
+ ```ts
58
+ import react from "@vitejs/plugin-react";
59
+ import { defineConfig } from "vite";
60
+ import signals from "unplugin-react-fine-grained-signals/vite";
61
+
62
+ export default defineConfig({
63
+ plugins: [signals({ mode: "auto" }), react({ compiler: true })],
64
+ });
65
+ ```
66
+
67
+ In the setup this was tested with (Vite 8, `@vitejs/plugin-react` 6.1),
68
+ `react({ compiler: true })` also needs plugin-react's optional
69
+ `oxc-transform-react` package. Install it next to `babel-plugin-react-compiler`;
70
+ without it the build stops with plugin-react's own "requires the optional
71
+ `oxc-transform-react` package" error.
72
+
73
+ Under Vite the order is also defended at the hook level. The plugin is
74
+ `enforce: "pre"`, and its `transform` hook additionally declares
75
+ `order: "pre"`, which Vite sorts on within the pre-enforced group. That makes it
76
+ run ahead of `@vitejs/plugin-react` 6's compiler step (`react({ compiler: true })`,
77
+ itself `enforce: "pre"` with no hook order) even when `react()` is listed first —
78
+ before this, that order compiled the component first. This is Vite's ordering,
79
+ not an absolute guarantee: a plugin whose transform also declares
80
+ `order: "pre"` and is listed earlier still runs first. Keeping `signals()`
81
+ first is therefore still the recommended configuration, and it is required
82
+ wherever hook ordering does not apply: Rollup orders by array position alone,
83
+ and the Babel route (`@rolldown/plugin-babel` with `reactCompilerPreset()`) has
84
+ not been verified against this plugin.
85
+
86
+ Two known limitations apply with React Compiler. Both were present in v0.1.1.
87
+
88
+ - **Inline render helpers can be memoized by the compiler.** A function this
89
+ plugin treats as part of its caller's render gets no boundary and no
90
+ `"use no memo"` of its own. Examples are a PascalCase helper called as a
91
+ plain function (`Title()`) and a render callback such as `items.map(Row)`.
92
+ React Compiler can still compile that helper by itself. If the helper's JSX
93
+ has no dependency the compiler can see (a module-level signal read is not
94
+ one), the compiler caches the JSX, and the helper's output stays stale even
95
+ though the calling component re-renders. Put `"use no memo"` in the helper's
96
+ body, or render it as a component (`<Title />`) instead.
97
+ - **An author-written `"use memo"` is kept.** The plugin does not add
98
+ `"use no memo"` to a function that already states a memoization directive.
99
+ Tested with `babel-plugin-react-compiler` 1.0:
100
+ - With the managed transform, the compiler does not compile the generated
101
+ `try` / `finally`. With the default `panicThreshold` it skips the function.
102
+ With `panicThreshold: "all_errors"` the build fails ("Handle TryStatement
103
+ without a catch clause").
104
+ - With `transform: "inject"`, the compiler compiles the function and can
105
+ memoize away its signal reads, so the component can stop updating.
106
+
107
+ Do not use `"use memo"` in functions that read signals.
108
+
33
109
  ## Other bundlers
34
110
 
35
111
  Use the matching entry point in the same way:
@@ -52,21 +128,22 @@ export default {
52
128
  ## Options
53
129
 
54
130
  - `mode`:
55
- - `"manual"`: only an explicit first-statement imported `useSignals()` call
56
- or a `@useSignals` comment on a named component/custom hook opts it in.
131
+ - `"manual"`: only an explicit first-statement imported `useSignalTracking()` call
132
+ or a `@signalTracking` comment on a named component/custom hook opts it in.
57
133
  - `"auto"` (default): additionally transforms named JSX components and
58
134
  named `useX` custom hooks that read `.value`.
59
135
  - `"all"`: additionally transforms every named JSX component. Nested
60
136
  callbacks are collected by their owning component but are never transform
61
137
  targets themselves.
62
138
  - `transform`:
63
- - `"managed"` (default): adds an exact `try` / `finally` boundary, importing
139
+ - `"managed"` (default and recommended): adds an exact `try` / `finally` boundary, importing
64
140
  from the package's `/runtime` entry and closing the render-tracking
65
141
  window synchronously at the point the component function returns.
66
- - `"inject"`: adds bare `useSignals()` for best-effort opt-in. It inserts a
67
- normal `useSignals()` call without rewriting control flow, so it has the
68
- same best-effort tracking boundary as a handwritten call — see
69
- [the boundary design investigation](../../docs/design/use-signals-boundary-design.md)
142
+ - `"inject"` (supported advanced/compatibility mode): adds bare
143
+ `useSignalTracking()` for best-effort opt-in. It inserts a normal
144
+ `useSignalTracking()` call without rewriting control flow, so it has the same
145
+ best-effort tracking boundary as a handwritten call — see
146
+ [the boundary design investigation](../../development/design/use-signals-boundary-design.md)
70
147
  for the known sibling-misattribution limitation this mode can expose.
71
148
  - `reactCompiler`:
72
149
  - `"auto"` (default): marks every transformed function with `"use no memo"`,
@@ -75,8 +152,10 @@ export default {
75
152
  stops updating after the first signal write.
76
153
  - `"off"`: omits the directive. Choose it only when React Compiler is not in
77
154
  the build, or when the affected components were verified against
78
- [the compatibility note](../../docs/design/react-compiler-compatibility.md).
155
+ [the compatibility note](../../development/design/react-compiler-compatibility.md).
79
156
  - `importSource`: overrides `react-fine-grained-signals` for a compatible wrapper.
157
+ The wrapper must re-export the runtime's root `useSignalTracking` and its
158
+ `/runtime` entry's `useManagedSignals`, which the generated code imports.
80
159
  - `reactImportSource`: an additional module specifier whose `memo` and
81
160
  `forwardRef` exports count as React's own when the plugin decides whether a
82
161
  wrapped function is a component. Recognition is additive, not a replacement:
@@ -93,7 +172,11 @@ of Hooks forbid. Recognition covers the inline definition site
93
172
  (`items.map((item) => …)`), a callback factored out and referenced by its own
94
173
  binding, whether that binding is a `const` (`const Row = …; items.map(Row)`) or
95
174
  a function declaration (`function Row() {…}` … `items.map(Row)`), and the
96
- optional-chained form of either (`items?.map(Row)`). Such a callback is left
175
+ optional-chained form of either (`items?.map(Row)`). The `mapFn` of
176
+ `Array.from(arrayLike, mapFn)` (`Array.from({ length: 5 }, Star)`) counts the
177
+ same way, and so does a component held in an object that is destructured before
178
+ being passed (`const { Row } = parts; items.map(Row)`, or `{ Row: Item }`).
179
+ Such a callback is left
97
180
  alone, and its JSX and `.value` reads are collected by the component that
98
181
  invokes it — including when the callback is defined elsewhere in the same
99
182
  module, so that component is transformed even when its own body reads no
@@ -119,7 +202,7 @@ An anonymous or arrow function passed the same way —
119
202
  `observer((props) => …)` — carries no name for a boundary to attach to, and is
120
203
  left untransformed.
121
204
 
122
- This detection has four known limitations:
205
+ This detection has five known limitations:
123
206
 
124
207
  - A re-assigned alias is not followed, so a PascalCase helper reached through
125
208
  `const RowAlias = Row; items.map(RowAlias)` is still treated as a component.
@@ -129,13 +212,24 @@ This detection has four known limitations:
129
212
  where hook injection is unsafe) or instantiates `Row` as its own component
130
213
  (where hook injection is what makes it update), and nothing in the file says
131
214
  which — so `Row` keeps its own hook whenever it is independently eligible.
132
- Prefer inlining the callback at the call site
133
- (`<Grid renderItem={(item) => <li>{item.value}</li>} />`), which the inline
134
- detection above already attributes to the owning component, or state the
135
- intent explicitly on the referenced function with a `@useSignals` /
136
- `@noUseSignals` comment.
215
+ State the intent explicitly on the referenced function with a
216
+ `@signalTracking` / `@noSignalTracking` comment.
217
+
218
+ Writing the callback inline (`<Grid renderItem={(item) => <li>{item.value}</li>} />`)
219
+ is not a general fix. Its reads are attributed to the owning component, so
220
+ the owner is transformed, but `Grid` calls `renderItem` during its *own*
221
+ render, after the owner's function has returned. With the default managed
222
+ transform the owner's boundary is already closed by then, so the reads are
223
+ tracked only if `Grid` has a boundary of its own; otherwise the rendered items
224
+ stay stale. If `Grid` is your own component, opt it in with
225
+ `@signalTracking` (or have it read the signal itself).
137
226
  - A callback imported from another module is not followed, because the
138
227
  transform sees one file at a time.
228
+ - A callback reached through an object key (`items.map(parts.Row)`, or
229
+ `const { Row } = parts` first) is kept off a boundary, but in `auto` mode its
230
+ `.value` reads are not credited to the caller. A caller that reads no signal
231
+ itself is then left untransformed and does not update; read the signal in the
232
+ caller, use `mode: "all"`, or add `@signalTracking` to the caller.
139
233
  - A function used in both roles keeps the exclusion. If `Row` is passed to
140
234
  `map` / `flatMap` / `forEach` anywhere in the module and is *also* rendered
141
235
  independently as a JSX tag (`<Row item={x} />`), the render-callback usage
@@ -144,12 +238,84 @@ This detection has four known limitations:
144
238
  independently rendered instance then has no subscription and goes stale on
145
239
  later signal writes. Split the two roles into two differently named
146
240
  functions, one per role, or opt the independently rendered function in
147
- explicitly with a `@useSignals` comment.
241
+ explicitly with a `@signalTracking` comment.
148
242
 
149
243
  When in doubt, keep such helpers explicit: name them lowercase and without a
150
244
  `use` prefix, or opt them in manually only when they are genuinely rendered as
151
245
  components.
152
246
 
247
+ ## Components called as plain functions
248
+
249
+ A PascalCase function that the module calls as a plain function — `Title()`,
250
+ `Title?.()`, `Title!()`, or, for one held in an object, `ns.Home()`,
251
+ `ns?.group?.Home()`, or `this.Home()` from a sibling member — is never
252
+ mounted by React as a component of its own: its body simply runs inside the
253
+ caller's render. It therefore never gets a boundary of its own, in any mode
254
+ including `all`, and its JSX and `.value` reads are collected by the component
255
+ or custom hook that makes the call:
256
+
257
+ ```jsx
258
+ function Title() { return <h1>{count.value}</h1>; }
259
+ // Outer is transformed and subscribes to `count`; Title is left as it is.
260
+ function Outer({ show }) { return <div>{show && Title()}</div>; }
261
+ ```
262
+
263
+ A boundary inside `Title` would add hooks to `Outer` that run only while `show`
264
+ is true — a conditional hook call that React rejects ("Rendered more hooks than
265
+ during the previous render"). A call made from an event handler, from the
266
+ callback of `useEffect` / `useLayoutEffect` / `useInsertionEffect` /
267
+ `useCallback` / `useMemo`, or from an async function runs outside the render,
268
+ so its reads are not collected. Custom hooks (`useX`) are called as functions
269
+ by design and keep their own boundary exactly as before.
270
+
271
+ Known limitations:
272
+
273
+ - The decision is module-wide: a single plain call anywhere in the module takes
274
+ away the function's own boundary. If it is *also* rendered as `<Title />`,
275
+ that instance has no subscription of its own and only refreshes when its
276
+ parent re-renders; the plugin prints a warning for this mix. Use one form
277
+ consistently.
278
+ - Only calls through the function's own binding, or through the exact object
279
+ key path it is defined at, are recognized, and only within the module. Any
280
+ link of that path may be optional (`ns?.group.Home()`). Inside a member of
281
+ the same object literal, `this` counts as that path: `this.Home()` in a
282
+ method, an accessor, a `function` property value, or an arrow nested in one
283
+ of those. A nested `function` or class has a `this` of its own and is not
284
+ followed. An alias (`const T = Title; T()`), `Title.call(…)`, and calls from
285
+ other modules are not recognized.
286
+ - A function held in an object can also be reached through a computed key:
287
+ `renderers[node.type](node)`, `renderers[key]?.(arg)`,
288
+ `items.map(renderers[key])`, or `this[key](arg)` from a sibling member.
289
+ Every slot that such an access can reach (the
290
+ same depth, under the same static prefix) gets no boundary of its own,
291
+ because a boundary there would run a variable number of times inside the
292
+ caller. Its reads are *not* collected by the caller, because the key is only
293
+ known at runtime. So a caller that reads no signal itself is not transformed
294
+ and does not update on the slot's signals. Read the signal in the caller, or
295
+ add `@signalTracking` to the caller. This also applies when the module
296
+ renders the same slot as `<renderers.User />`.
297
+ - The reads are collected only when the caller is itself a component or a
298
+ `useX` hook. A call routed through a lowercase helper
299
+ (`function renderTitle() { return Title(); }`) is not followed, so nothing
300
+ subscribes to those reads; call the function directly from the component, or
301
+ render it as `<Title />`.
302
+ - A keyed function (`ns.Home()`) is collected only when this module defines it
303
+ in an object literal bound to the root, or with exactly one member assignment
304
+ (`Card.Header = …`). A sibling's `this.Home()` is collected from the object
305
+ literal that member belongs to.
306
+ - An object member the transform cannot see being called keeps its own
307
+ boundary. That covers a member reached through a parameter
308
+ (`function render(set, key) { return set[key](); }`), through an alias
309
+ (`const R = renderers[key]; R(arg)`), through computed destructuring
310
+ (`const { [key]: R } = renderers`), from an object inside an array
311
+ (`columns.map((c) => c.Cell(row))`), or from another module. Calling such a
312
+ member inline is then a hook call inside the caller, which crashes as soon
313
+ as the number of calls changes. Put `@noSignalTracking` on the member: it
314
+ then has no boundary of its own, and a transformed caller tracks its reads
315
+ because they run during the caller's render. Rendering that member as an
316
+ element (`<icons.Check />`) then gives the element no subscription of its
317
+ own.
318
+
153
319
  ## Higher-order components
154
320
 
155
321
  A component a higher-order component returns is recognized even though it has no
@@ -161,7 +327,7 @@ export const withCount = (Base) => (props) => <Base {...props} count={count.valu
161
327
 
162
328
  The inner function is the component here, and it inherits its identity from the
163
329
  factory's own binding — `withCount` — so `auto` mode subscribes it and a
164
- `@useSignals` (or `@noUseSignals`) comment on either the returned function or the
330
+ `@signalTracking` (or `@noSignalTracking`) comment on either the returned function or the
165
331
  factory's declaration applies to it. Three conditions keep ordinary closures out:
166
332
  the function must be returned directly by the enclosing function (an explicit
167
333
  `return`, or an arrow's concise body); the returned function must render JSX
@@ -189,7 +355,7 @@ normally whenever it qualifies — reading a signal, rendering JSX — exactly l
189
355
  any other hook; only the returned closure inherits no name from it.
190
356
 
191
357
  The name is inherited from exactly one level out, so a factory returning a
192
- factory (`(a) => (b) => (props) => …`) resolves to nothing, and a `@useSignals`
358
+ factory (`(a) => (b) => (props) => …`) resolves to nothing, and a `@signalTracking`
193
359
  comment there reports that rather than silently doing nothing. The one shape
194
360
  this cannot tell apart is a factory whose result is handed straight to an
195
361
  iteration method (`items.map(makeRow(prefix))`): that is syntactically a HOC
@@ -198,15 +364,24 @@ reference (`items.map(Row)`) or write it inline, so the owning component
198
364
  collects it.
199
365
 
200
366
  A component can also carry no binding of its own and still be named: one held
201
- as an object or class property — `Card.Header = () => <p>{count.value}</p>`, or
202
- a `class Holder { Row = () => <p>{count.value}</p> }` field — is named by its
203
- key, the same way `<Card.Header />` or `<ns.Row />` reaches it (a lowercase key
204
- stays excluded, exactly as a lowercase binding does), and a nameless default
367
+ as an object property — `Card.Header = () => <p>{count.value}</p>` — or written as an
368
+ object-literal method (`export const ns = { Home() { return <p>{count.value}</p>; } }`)
369
+ is named by its key, the same way `<Card.Header />` or `<ns.Row />` reaches it
370
+ (a lowercase key stays excluded, exactly as a lowercase binding does; getters,
371
+ setters, async and generator methods are excluded, and so are class methods,
372
+ which only an instance reaches), and a nameless default
205
373
  export (`export default (props) => <p>{count.value}</p>`) is named after the
206
374
  module's own file — the identity an `import App from "./App"` already gives it.
207
- A `this.Row = …` assignment inside a class is deliberately excluded, though —
208
- such a `this`-bound renderer still needs an explicit `useSignals()` call or
209
- `@useSignals` comment, as before this feature existed.
375
+ Functions held by a class are deliberately excluded, though: a class field
376
+ (`class Holder { Row = () => … }`, instance or `static`) and a `this.Row = …`
377
+ assignment are never transformed, and a `@signalTracking` comment on one is
378
+ reported and ignored. They are reached through `this.Row` or `Holder.Row`, which
379
+ the transform cannot follow to tell a component from a function that
380
+ `render()` calls directly (`this.Header()`) or passes per item
381
+ (`items.map(this.Row)`) — and a hook boundary in either of those runs inside
382
+ the class component's render, which React rejects. If such a function is only
383
+ ever rendered as an element (`<this.Row />`), it may call `useSignalTracking()`
384
+ itself; otherwise read signals in a function component instead.
210
385
 
211
386
  ## `memo` / `forwardRef` recognition
212
387
 
@@ -224,13 +399,13 @@ skipped, so signal writes stop re-rendering it. Three workarounds:
224
399
  way);
225
400
  - import `memo` / `forwardRef` directly from `"react"` at the affected call
226
401
  sites;
227
- - opt the component in explicitly with a `@useSignals` comment or a manual
228
- `useSignals()` call.
402
+ - opt the component in explicitly with a `@signalTracking` comment or a manual
403
+ `useSignalTracking()` call.
229
404
 
230
- ## `useSignals()` opt-in and rewriting
405
+ ## `useSignalTracking()` opt-in and rewriting
231
406
 
232
- `@useSignals` and `@noUseSignals` apply only to their owning function; they do
233
- not affect nested functions. The plugin never adds a second `useSignals()`
407
+ `@signalTracking` and `@noSignalTracking` apply only to their owning function; they do
408
+ not affect nested functions. The plugin never adds a second `useSignalTracking()`
234
409
  call: a direct, namespace, or barrel-imported call it finds is treated as the
235
410
  function's existing opt-in. A call that is not the function's first statement,
236
411
  and any barrel-imported call, is left alone in both transform modes. A
@@ -240,21 +415,71 @@ absorbed into the generated boundary: the statement is removed and replaced by
240
415
  the managed store declaration plus the `try` / `finally` scope, so the function
241
416
  body is rewritten rather than left byte-for-byte untouched.
242
417
 
243
- A first-statement `useSignals()` call inside an `async` or generator function
418
+ A first-statement `useSignalTracking()` call inside an `async` or generator function
244
419
  fails the build under the default `"managed"` transform, with
245
- `useSignals transform only supports synchronous, non-generator functions`. That
420
+ `useSignalTracking transform only supports synchronous, non-generator functions`. That
246
421
  combination is already invalid React — hooks require a synchronous function
247
422
  component — so prefer fixing the function; `transform: "inject"` accepts it
248
423
  without rewriting the function, if the file must keep building unchanged.
249
424
 
425
+ Parameter defaults are evaluated before the function body runs, so a signal
426
+ read in one — `function Label({ v = count.value })` — would happen before the
427
+ boundary opens and never be tracked. When a transformed function has such a
428
+ parameter, that parameter and every parameter after it (to keep their
429
+ evaluation order) are replaced by generated ones and bound again as the first
430
+ statements inside the boundary, with the same `undefined`-only default rule.
431
+ The parameter count is unchanged, so `forwardRef`'s `(props, ref)` shape is
432
+ kept. If the body declares a name a moved default refers to, or re-declares a
433
+ moved parameter, the move is skipped and that read stays untracked; read the
434
+ signal in the body instead.
435
+
436
+ The move keeps the default rule and the evaluation order. In TypeScript the
437
+ generated parameter keeps the original's type and optionality, so the output
438
+ stays valid TypeScript. The function's `length` can change, though. A
439
+ parameter with a default does not count toward `length`, but the generated
440
+ parameter that replaces it does. For example,
441
+ `function useLabel(prefix?: string, value = count.value)` has a `length` of 1
442
+ in the source and 2 after the transform. Do not rely on the `length` of a
443
+ transformed function.
444
+
250
445
  ## Build integration
251
446
 
252
- Reapplying either transform mode is a no-op. The transform runs before other
253
- plugin transforms via `enforce: "pre"` on bundlers that support it (Vite,
254
- webpack, Rspack) — Rollup has no such concept, so list this plugin first there
255
- instead — and skips dependencies and non-JavaScript/TypeScript modules.
256
- Plain `.ts` files are parsed as TypeScript without JSX, while `.tsx`, `.jsx`,
257
- and JavaScript files may use JSX.
447
+ Reapplying either transform mode is a no-op. The plugin is registered with
448
+ `enforce: "pre"` on bundlers that support it (Vite, webpack, Rspack), and under
449
+ Vite its transform hook also declares `order: "pre"` (see
450
+ [With React Compiler](#with-react-compiler)). Rollup has neither concept, so
451
+ list this plugin first there. Wherever the order of plugins or loaders is in
452
+ your hands, put this plugin before anything that runs React Compiler or
453
+ compiles JSX away. The transform skips dependencies and
454
+ non-JavaScript/TypeScript modules, and never touches `.cjs` / `.cts` modules.
455
+ The plugin matches extensions case-insensitively, so it transforms `App.TSX`
456
+ exactly like `App.tsx`. Whether the bundler then compiles such a file the same
457
+ way is up to the bundler, and is not guaranteed: Vite 8 does not compile
458
+ `.JSX` / `.TSX` as JSX / TSX and fails to parse them, with or without this
459
+ plugin. Plain `.ts` files are parsed as TypeScript without JSX, while `.tsx`,
460
+ `.jsx`, and JavaScript files may use JSX.
461
+
462
+ ## Fast Refresh
463
+
464
+ This limitation was present in v0.1.1. With the default managed transform and
465
+ Vite's Oxc-based Fast Refresh, changing a transformed component's hooks
466
+ (adding, removing, or reordering a hook) does not preserve Fast Refresh state.
467
+ The refresh fails with "Rendered more hooks than during the previous render"
468
+ (or "fewer"), and the page needs a full reload. The managed boundary puts the
469
+ component's hooks inside a `try` block, where Fast Refresh does not include
470
+ them in the component's hook signature. In the tested fixture,
471
+ `transform: "inject"` did not have this limitation.
472
+
473
+ ## React Server Components
474
+
475
+ The transform does not tell server modules from client modules: it reads
476
+ neither `"use client"` / `"use server"` directives nor the bundler's
477
+ environment or layer. A synchronous server component that reads `.value` would
478
+ therefore receive the client-only render-tracking hook and fail when it renders
479
+ on the server (async server components are never transformed, because async
480
+ functions never are). In an RSC setup, keep server components out of the
481
+ transform with `exclude` (for example by directory), opt individual ones out
482
+ with a `@noSignalTracking` comment, or use `mode: "manual"`.
258
483
 
259
484
  ## License
260
485
 
package/dist/esbuild.d.ts CHANGED
@@ -1,10 +1,10 @@
1
- import { a as createBundlerPlugin, c as resolveEsbuildLoader, d as ReactFineGrainedSignalsTransform, i as canTransform, l as ReactFineGrainedSignalsMode, n as EsbuildLoaderName, o as pluginName, r as ReactFineGrainedSignalsOptions, s as reactFineGrainedSignals, t as BundlerTransformOutput, u as ReactFineGrainedSignalsReactCompiler } from "./unplugin-CG9_mdcA.js";
1
+ import { a as createBundlerPlugin, c as resolveEsbuildLoader, d as ReactFineGrainedSignalsTransform, i as canTransform, l as ReactFineGrainedSignalsMode, n as EsbuildLoaderName, o as pluginName, r as ReactFineGrainedSignalsOptions, s as reactFineGrainedSignals, t as BundlerTransformOutput, u as ReactFineGrainedSignalsReactCompiler } from "./unplugin-B9gaexDa.js";
2
2
  //#region src/esbuild.d.ts
3
3
  /** The plugin contract consumed by esbuild. */
4
- interface EsbuildPlugin {
4
+ export interface EsbuildPlugin {
5
5
  name: string;
6
6
  setup(build: unknown): void | Promise<void>;
7
7
  }
8
8
  declare const _default: (options?: ReactFineGrainedSignalsOptions) => EsbuildPlugin;
9
9
  //#endregion
10
- export { BundlerTransformOutput, EsbuildLoaderName, EsbuildPlugin, type ReactFineGrainedSignalsMode, ReactFineGrainedSignalsOptions, type ReactFineGrainedSignalsReactCompiler, type ReactFineGrainedSignalsTransform, canTransform, createBundlerPlugin, _default as default, pluginName, reactFineGrainedSignals, resolveEsbuildLoader };
10
+ export { BundlerTransformOutput, EsbuildLoaderName, type ReactFineGrainedSignalsMode, ReactFineGrainedSignalsOptions, type ReactFineGrainedSignalsReactCompiler, type ReactFineGrainedSignalsTransform, canTransform, createBundlerPlugin, _default as default, pluginName, reactFineGrainedSignals, resolveEsbuildLoader };
package/dist/esbuild.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as resolveEsbuildLoader, i as reactFineGrainedSignals, n as createBundlerPlugin, r as pluginName, t as canTransform } from "./unplugin-B2_q8KNi.js";
1
+ import { a as resolveEsbuildLoader, i as reactFineGrainedSignals, n as createBundlerPlugin, r as pluginName, t as canTransform } from "./unplugin-CPDTNSYq.js";
2
2
  //#region src/esbuild.ts
3
3
  var esbuild_default = createBundlerPlugin("esbuild");
4
4
  //#endregion
package/dist/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- import { a as createBundlerPlugin, c as resolveEsbuildLoader, d as ReactFineGrainedSignalsTransform, i as canTransform, l as ReactFineGrainedSignalsMode, n as EsbuildLoaderName, o as pluginName, r as ReactFineGrainedSignalsOptions, s as reactFineGrainedSignals, t as BundlerTransformOutput, u as ReactFineGrainedSignalsReactCompiler } from "./unplugin-CG9_mdcA.js";
1
+ import { a as createBundlerPlugin, c as resolveEsbuildLoader, d as ReactFineGrainedSignalsTransform, i as canTransform, l as ReactFineGrainedSignalsMode, n as EsbuildLoaderName, o as pluginName, r as ReactFineGrainedSignalsOptions, s as reactFineGrainedSignals, t as BundlerTransformOutput, u as ReactFineGrainedSignalsReactCompiler } from "./unplugin-B9gaexDa.js";
2
2
  export { BundlerTransformOutput, EsbuildLoaderName, type ReactFineGrainedSignalsMode, ReactFineGrainedSignalsOptions, type ReactFineGrainedSignalsReactCompiler, type ReactFineGrainedSignalsTransform, canTransform, createBundlerPlugin, reactFineGrainedSignals as default, reactFineGrainedSignals, pluginName, resolveEsbuildLoader };
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- import { a as resolveEsbuildLoader, i as reactFineGrainedSignals, n as createBundlerPlugin, r as pluginName, t as canTransform } from "./unplugin-B2_q8KNi.js";
1
+ import { a as resolveEsbuildLoader, i as reactFineGrainedSignals, n as createBundlerPlugin, r as pluginName, t as canTransform } from "./unplugin-CPDTNSYq.js";
2
2
  export { canTransform, createBundlerPlugin, reactFineGrainedSignals as default, reactFineGrainedSignals, pluginName, resolveEsbuildLoader };
package/dist/loader.d.ts CHANGED
@@ -10,6 +10,5 @@ interface LoaderContext {
10
10
  emitWarning?: ((warning: Error) => void) | undefined;
11
11
  emitError?: ((error: Error) => void) | undefined;
12
12
  }
13
- declare function reactFineGrainedSignalsLoader(this: LoaderContext, source: string, map?: unknown): void;
14
- //#endregion
15
- export { reactFineGrainedSignalsLoader as default };
13
+ export default function reactFineGrainedSignalsLoader(this: LoaderContext, source: string, map?: unknown): void;
14
+ //#endregion
package/dist/rollup.d.ts CHANGED
@@ -1,11 +1,11 @@
1
- import { a as createBundlerPlugin, c as resolveEsbuildLoader, d as ReactFineGrainedSignalsTransform, i as canTransform, l as ReactFineGrainedSignalsMode, n as EsbuildLoaderName, o as pluginName, r as ReactFineGrainedSignalsOptions, s as reactFineGrainedSignals, t as BundlerTransformOutput, u as ReactFineGrainedSignalsReactCompiler } from "./unplugin-CG9_mdcA.js";
1
+ import { a as createBundlerPlugin, c as resolveEsbuildLoader, d as ReactFineGrainedSignalsTransform, i as canTransform, l as ReactFineGrainedSignalsMode, n as EsbuildLoaderName, o as pluginName, r as ReactFineGrainedSignalsOptions, s as reactFineGrainedSignals, t as BundlerTransformOutput, u as ReactFineGrainedSignalsReactCompiler } from "./unplugin-B9gaexDa.js";
2
2
  //#region src/rollup.d.ts
3
3
  /** A minimal Rollup-compatible plugin shape. */
4
- interface RollupPlugin {
4
+ export interface RollupPlugin {
5
5
  name: string;
6
6
  enforce?: "pre" | "post";
7
7
  transform?: (code: string, id: string) => BundlerTransformOutput;
8
8
  }
9
9
  declare const _default: (options?: ReactFineGrainedSignalsOptions) => RollupPlugin;
10
10
  //#endregion
11
- export { BundlerTransformOutput, EsbuildLoaderName, type ReactFineGrainedSignalsMode, ReactFineGrainedSignalsOptions, type ReactFineGrainedSignalsReactCompiler, type ReactFineGrainedSignalsTransform, RollupPlugin, canTransform, createBundlerPlugin, _default as default, pluginName, reactFineGrainedSignals, resolveEsbuildLoader };
11
+ export { BundlerTransformOutput, EsbuildLoaderName, type ReactFineGrainedSignalsMode, ReactFineGrainedSignalsOptions, type ReactFineGrainedSignalsReactCompiler, type ReactFineGrainedSignalsTransform, canTransform, createBundlerPlugin, _default as default, pluginName, reactFineGrainedSignals, resolveEsbuildLoader };
package/dist/rollup.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as resolveEsbuildLoader, i as reactFineGrainedSignals, n as createBundlerPlugin, r as pluginName, t as canTransform } from "./unplugin-B2_q8KNi.js";
1
+ import { a as resolveEsbuildLoader, i as reactFineGrainedSignals, n as createBundlerPlugin, r as pluginName, t as canTransform } from "./unplugin-CPDTNSYq.js";
2
2
  //#region src/rollup.ts
3
3
  var rollup_default = createBundlerPlugin("rollup");
4
4
  //#endregion
package/dist/rspack.d.ts CHANGED
@@ -1,9 +1,9 @@
1
- import { a as createBundlerPlugin, c as resolveEsbuildLoader, d as ReactFineGrainedSignalsTransform, i as canTransform, l as ReactFineGrainedSignalsMode, n as EsbuildLoaderName, o as pluginName, r as ReactFineGrainedSignalsOptions, s as reactFineGrainedSignals, t as BundlerTransformOutput, u as ReactFineGrainedSignalsReactCompiler } from "./unplugin-CG9_mdcA.js";
1
+ import { a as createBundlerPlugin, c as resolveEsbuildLoader, d as ReactFineGrainedSignalsTransform, i as canTransform, l as ReactFineGrainedSignalsMode, n as EsbuildLoaderName, o as pluginName, r as ReactFineGrainedSignalsOptions, s as reactFineGrainedSignals, t as BundlerTransformOutput, u as ReactFineGrainedSignalsReactCompiler } from "./unplugin-B9gaexDa.js";
2
2
  //#region src/rspack.d.ts
3
3
  /** The compiler-plugin contract used by Rspack. */
4
- interface RspackPlugin {
4
+ export interface RspackPlugin {
5
5
  apply(compiler: unknown): void;
6
6
  }
7
7
  declare const _default: (options?: ReactFineGrainedSignalsOptions) => RspackPlugin;
8
8
  //#endregion
9
- export { BundlerTransformOutput, EsbuildLoaderName, type ReactFineGrainedSignalsMode, ReactFineGrainedSignalsOptions, type ReactFineGrainedSignalsReactCompiler, type ReactFineGrainedSignalsTransform, RspackPlugin, canTransform, createBundlerPlugin, _default as default, pluginName, reactFineGrainedSignals, resolveEsbuildLoader };
9
+ export { BundlerTransformOutput, EsbuildLoaderName, type ReactFineGrainedSignalsMode, ReactFineGrainedSignalsOptions, type ReactFineGrainedSignalsReactCompiler, type ReactFineGrainedSignalsTransform, canTransform, createBundlerPlugin, _default as default, pluginName, reactFineGrainedSignals, resolveEsbuildLoader };
package/dist/rspack.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as resolveEsbuildLoader, i as reactFineGrainedSignals, n as createBundlerPlugin, r as pluginName, t as canTransform } from "./unplugin-B2_q8KNi.js";
1
+ import { a as resolveEsbuildLoader, i as reactFineGrainedSignals, n as createBundlerPlugin, r as pluginName, t as canTransform } from "./unplugin-CPDTNSYq.js";
2
2
  //#region src/rspack.ts
3
3
  var rspack_default = createBundlerPlugin("rspack");
4
4
  //#endregion
@@ -7,12 +7,12 @@ type ReactFineGrainedSignalsReactCompiler = "auto" | "off";
7
7
  //#region src/unplugin.d.ts
8
8
  interface ReactFineGrainedSignalsOptions {
9
9
  /**
10
- * `manual` preserves explicit useSignals() opt-in. `auto` detects signal
10
+ * `manual` preserves explicit useSignalTracking() opt-in. `auto` detects signal
11
11
  * reads in components and custom hooks; `all` also wraps JSX components that
12
12
  * do not statically expose a .value read.
13
13
  */
14
14
  mode?: ReactFineGrainedSignalsMode;
15
- /** `managed` (default) adds an exact try/finally boundary; `inject` adds bare useSignals() for best-effort opt-in. */
15
+ /** `managed` (default) adds an exact try/finally boundary; `inject` adds bare useSignalTracking() for best-effort opt-in. */
16
16
  transform?: ReactFineGrainedSignalsTransform;
17
17
  /**
18
18
  * `auto` (default) marks every transformed function with `"use no memo"`, so
@@ -21,7 +21,7 @@ interface ReactFineGrainedSignalsOptions {
21
21
  * when its memoization has been verified against this library's tracking.
22
22
  */
23
23
  reactCompiler?: ReactFineGrainedSignalsReactCompiler;
24
- /** Package that exports `useSignals` and its `/runtime` entry. */
24
+ /** Package whose root exports bare `useSignalTracking` and whose `/runtime` entry exports `useManagedSignals`. */
25
25
  importSource?: string;
26
26
  /**
27
27
  * Extra module specifier whose `memo`/`forwardRef` exports are treated as