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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 akazawa
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.ja.md ADDED
@@ -0,0 +1,250 @@
1
+ # unplugin-react-fine-grained-signals
2
+
3
+ [English](README.md) | [日本語](README.ja.md)
4
+
5
+ [`react-fine-grained-signals`](https://www.npmjs.com/package/react-fine-grained-signals)(npmへまだ公開されていません)
6
+ 向けの、`useSignals()` 自動挿入と任意のmanaged render scope変換を提供する汎用
7
+ bundler integrationです。
8
+
9
+ このpackageは意図的に唯一のbuild-time integrationです。Babel実装は内部に
10
+ 閉じ込め、利用側はbundlerごとのentry pointだけを設定します。
11
+
12
+ ## インストール
13
+
14
+ ```sh
15
+ pnpm add -D unplugin-react-fine-grained-signals
16
+ ```
17
+
18
+ `react-fine-grained-signals` はpeer dependencyです。このpackageはESM-onlyです。
19
+ CommonJSの`require()`ではなく、ESM設定から`import`してください。
20
+
21
+ ## Vite
22
+
23
+ ```ts
24
+ import { defineConfig } from "vite";
25
+ import signals from "unplugin-react-fine-grained-signals/vite";
26
+
27
+ export default defineConfig({
28
+ plugins: [signals({ mode: "auto" })],
29
+ });
30
+ ```
31
+
32
+ ## その他のbundler
33
+
34
+ 対応するentry pointを同様の要領で使用します。
35
+
36
+ | Bundler | Entry point |
37
+ | --- | --- |
38
+ | Rollup | `unplugin-react-fine-grained-signals/rollup` |
39
+ | Webpack | `unplugin-react-fine-grained-signals/webpack` |
40
+ | Rspack | `unplugin-react-fine-grained-signals/rspack` |
41
+ | esbuild | `unplugin-react-fine-grained-signals/esbuild` |
42
+
43
+ ```ts
44
+ import signals from "unplugin-react-fine-grained-signals/webpack";
45
+
46
+ export default {
47
+ plugins: [signals({ mode: "auto" })],
48
+ };
49
+ ```
50
+
51
+ ## オプション
52
+
53
+ - `mode`
54
+ - `"manual"`: 先頭文にあるimport済みの `useSignals()`、または
55
+ `@useSignals` を付けた名前付きcomponent/custom hookだけを変換します。
56
+ - `"auto"`(既定): さらに `.value` を読む名前付きJSX componentと
57
+ 名前付き `useX` custom hookを変換します。
58
+ - `"all"`: さらにすべての名前付きJSX componentを変換します。ネストした
59
+ callbackの読み取りは親componentが収集しますが、callback自体は変換しません。
60
+ - `transform`
61
+ - `"managed"`(既定): 厳密な `try` / `finally` 境界を追加します。
62
+ `/runtime` からimportし、componentの関数がreturnする時点でrender
63
+ tracking windowを同期的に閉じます。
64
+ - `"inject"`: best-effortなopt-in向けに変換なしの `useSignals()` を
65
+ 追加します。制御フローを書き換えず、通常の `useSignals()` を先頭hook
66
+ として挿入するため、手書きと同じbest-effort境界になります。この
67
+ modeが露呈し得るsibling誤帰属の既知の制約については、
68
+ [境界設計の検討docs](../../docs/design/use-signals-boundary-design.ja.md)
69
+ を参照してください。
70
+ - `reactCompiler`
71
+ - `"auto"`(既定): 変換したすべての関数に `"use no memo"` を付け、render
72
+ trackingが必要とするsignal読み取りをReact Compilerがmemoizationで
73
+ 消さないようにします。付けない場合、compile済みcomponentはJSXをcacheし、
74
+ 最初のsignal書き込み以降、無言で更新が止まります。
75
+ - `"off"`: directiveを付けません。React Compilerをbuildで使っていない場合か、
76
+ 対象componentを[互換性の検討docs](../../docs/design/react-compiler-compatibility.ja.md)
77
+ に照らして確認済みの場合だけ選んでください。
78
+ - `importSource`: `react-fine-grained-signals` 互換wrapperへの置き換えです。
79
+ - `reactImportSource`: wrapされた関数をcomponentと判定する際に、`memo` /
80
+ `forwardRef` をReact由来とみなすmodule specifierを追加します。置き換えでは
81
+ なく追加であり、`"react"` からのdirect importは常に認識されます。つまり
82
+ 設定しても既存のdirect importの認識が外れることはありません。
83
+ - `include` / `exclude`: source module IDを絞る関数です。
84
+
85
+ ## Render callbackの検出
86
+
87
+ 自動検出はrender callback(配列のiteration method `map`、`flatMap`、`forEach`
88
+ へ渡す関数)を変換しません。呼び出し元の1回のrenderの中で実行回数が変わるため、
89
+ Rules of Hooksに反するからです。認識するのは、定義箇所にinlineで書いた場合
90
+ (`items.map((item) => …)`)、変数に切り出してその束縛名で参照して渡す場合
91
+ (`const` の `const Row = …; items.map(Row)` でも、function宣言の
92
+ `function Row() {…}` … `items.map(Row)` でも)、およびそれぞれのoptional
93
+ chaining形(`items?.map(Row)`)です。こうしたcallback自体は変換せず、そのJSXと
94
+ `.value` 読み取りは呼び出し元のcomponentが収集します。callbackが同じmodule内の
95
+ 別の場所で定義されている場合も同様で、呼び出し元componentの本体自体がsignalを
96
+ 読んでいなくても、そのcomponentが変換対象になります。
97
+
98
+ 対象objectの実行時の型はbuild時にはわからないため、判定材料はmethod名だけです。
99
+ そのため認識する集合は意図的に最小限にしています。`map` と `flatMap` は要素ごとに
100
+ elementを組み立て、`forEach` は蓄積用の配列へelementを積む呼び出しで、callbackを
101
+ component風の名前で切り出す典型がこの3つです。述語・畳み込み系(`filter`、
102
+ `reduce`、`find`、`some`、`every`)は意図的に除外しています。これらのcallbackは
103
+ そもそも変換対象にならない小文字のhelperであり、含めても得るものがない一方、
104
+ 同名の無関係なmethodに誤って一致する余地だけが広がるからです。しかもその向きの
105
+ 誤りのほうが高くつきます。実在のcomponentからsubscriptionを無言で奪うためです。
106
+ したがって、それ以外の呼び出しへ名前で渡す参照は通常のcomponent登録として扱い、
107
+ 変換対象のままにします。従来どおりの `memo(Row)` / `forwardRef(Row)` に加えて、
108
+ `observer(Row)` や `connect(…)(Row)` のようなサードパーティのwrapperも同じです。
109
+ これらが返すcomponentは、Reactが独自のfiberとして、独自のhook contextで
110
+ instance化します。同じ登録はinlineで書いても機能します。関数自身が
111
+ PascalCaseの名前を持っていればよく、`observer(function Row() { … })` は
112
+ `observer(Row)` とまったく同様に認識されます。同じ形でもanonymousな関数や
113
+ arrow関数(`observer((props) => …)`)は、boundaryを結び付けるための名前を
114
+ 持たないため、変換されないままです。
115
+
116
+ この検出には既知の制約が4つあります。
117
+
118
+ - 再代入したaliasはたどりません。`const RowAlias = Row; items.map(RowAlias)`
119
+ 経由で渡したPascalCaseのhelperは、componentとして扱われたままになります。
120
+ - JSXのprop値として渡すrender callbackは認識しません。`<Grid renderItem={Row} />`
121
+ は、`Grid` が1回のrenderで `renderItem(item)` を可変回数呼ぶ場合(render prop。
122
+ hook挿入は安全ではありません)でも、`Row` を独立したcomponentとしてinstance化
123
+ する場合(hook挿入こそが更新を成立させます)でも構文が同一で、そのファイルだけ
124
+ では区別できません。そのため `Row` は、単独で変換条件を満たすなら自分のhookを
125
+ 持ったままになります。呼び出し箇所にcallbackをinlineで書くか
126
+ (`<Grid renderItem={(item) => <li>{item.value}</li>} />`。上記のinline検出が
127
+ 呼び出し元componentに正しく帰属させます)、参照先の関数に `@useSignals` /
128
+ `@noUseSignals` コメントで意図を明示してください。
129
+ - 別moduleからimportしたcallbackはたどりません。変換は1ファイルずつ処理する
130
+ ためです。
131
+ - 2つの役割を兼ねる関数は、除外されたままになります。`Row` をmodule内のどこかで
132
+ `map` / `flatMap` / `forEach` に渡していて、なおかつ別の場所でJSXタグとして
133
+ 単独でrenderしている(`<Row item={x} />`)場合、render callbackとしての用法が
134
+ 優先され、`Row` は自分のhookを持ちません。これはクラッシュしない側の選択です
135
+ (hookを持たせると、callbackとしての用法でhookの順序が壊れます)。ただし単独で
136
+ renderされるほうはsubscriptionを持たないため、以降のsignalの書き換えで
137
+ 内容が古いままになります。役割ごとに別名の2つの関数へ分けるか、単独で
138
+ renderするほうの関数を `@useSignals` コメントで明示的にopt-inしてください。
139
+
140
+ 判断に迷う場合は、そうしたhelperを明示的に保ってください。小文字で `use`
141
+ 始まりでない名前にするか、実際にcomponentとしてrenderされるときにだけ手動で
142
+ opt-inします。
143
+
144
+ ## Higher-order component (HOC)
145
+
146
+ higher-order component(HOC)が返すcomponentは、自分自身の名前を持たなくても
147
+ 認識します。
148
+
149
+ ```jsx
150
+ export const withCount = (Base) => (props) => <Base {...props} count={count.value} />;
151
+ ```
152
+
153
+ ここでcomponentは内側の関数であり、その識別子はfactory自身のbinding
154
+ (`withCount`)から継承します。したがって `auto` modeはこれをsubscribeし、
155
+ `@useSignals`(`@noUseSignals` も同様)コメントは、返される関数側とfactoryの
156
+ 宣言側のどちらに書いても適用されます。通常のclosureを巻き込まないための条件は
157
+ 3つです。囲む関数から直接returnされていること(明示的な `return`、または
158
+ arrowの簡潔なbody)、返される関数自身がJSXをrenderしていること、そして囲む
159
+ 関数の名前がhookの名前(`useX`)でないこと — あるいはcomponentの名前
160
+ (PascalCase)である場合は、その囲む関数自身が後述のhigher-order component
161
+ factoryの条件を満たしていることです。その条件を満たさないcomponentが返す
162
+ 関数、またはhookが返す関数は、そのownerのrender内で動くrender propであって、
163
+ 独立したcomponentではありません。
164
+
165
+ PascalCaseの名前を持つfactoryは、componentらしきものを自分の引数として
166
+ 受け取り — destructureされていない、PascalCaseの名前を持つ単なる識別子の
167
+ 引数 — 、かつ自分自身はJSXをrenderしない場合に該当します。
168
+
169
+ ```jsx
170
+ export const WithCount = (Base) => (props) => <Base {...props} count={count.value} />;
171
+ ```
172
+
173
+ `WithCount` 自体は変換されません。返されるcomponentは、上記のcamelCase形と
174
+ まったく同様に `WithCount` の名前を継承します。`useX` という名前のfactoryが
175
+ 返すclosureには、これに相当する例外は意図的にありません。Reactはすでに進行中の
176
+ renderの中からhookを呼び出すため、そのhookが返すものはまさにそのrender中に
177
+ 呼び出される可能性があり、それを否定する材料がここには何もないからです。
178
+ このことはhook自体には影響しません。hookそのものは、signalを読む・JSXを
179
+ renderするといった条件を満たす限り、他のhookとまったく同様に変換対象のまま
180
+ です。名前を継承しないのは、返されるclosureだけです。
181
+
182
+ 名前の継承はちょうど1段だけたどるため、factoryを返すfactory
183
+ (`(a) => (b) => (props) => …`)は解決せず、そこに書いた `@useSignals` コメントは
184
+ 無言で消えるのではなく警告として報告されます。唯一区別できない形は、factoryの
185
+ 戻り値をそのままiteration methodへ渡す場合(`items.map(makeRow(prefix))`)です。
186
+ これは構文上componentを返すHOCそのものなので、独自のboundaryを持ちます。その
187
+ ようなcallbackは参照で渡すか(`items.map(Row)`)、inlineで書いて呼び出し元の
188
+ componentに収集させてください。
189
+
190
+ componentは、自分自身のbindingを持たなくても名前を得られる場合があります。
191
+ objectやclassのproperty — `Card.Header = () => <p>{count.value}</p>` や、
192
+ `class Holder { Row = () => <p>{count.value}</p> }` のようなfield — として
193
+ 保持されているcomponentは、`<Card.Header />` や `<ns.Row />` がそこへ到達する
194
+ のと同じ、そのkeyから名前を得ます(小文字のkeyは、小文字のbindingと同様に
195
+ 除外されたままです)。また、名前を持たないdefault export
196
+ (`export default (props) => <p>{count.value}</p>`)は、moduleのファイル名から
197
+ 名前を得ます。これは `import App from "./App"` が実際にそのcomponentへ与えて
198
+ いる識別子そのものです。ただし、class内での `this.Row = …` という代入
199
+ (たとえばconstructor内)は意図的に対象外です。そうした `this` を束縛する
200
+ rendererには、この機能が存在する以前と同様、明示的な `useSignals()` 呼び出し
201
+ か `@useSignals` コメントが必要です。
202
+
203
+ ## `memo` / `forwardRef` の認識
204
+
205
+ `memo` / `forwardRef` の自動認識は、`"react"` または `reactImportSource` から
206
+ のdirect importだけに一致します。ローカルのbarrelやre-export module経由の
207
+ import(`import { memo } from "./some-local-module"`)は、その moduleが最終的に
208
+ `"react"` をre-exportしていても解決しません。変換は1ファイルずつ処理し、
209
+ re-exportの連鎖をたどらないためです。認識できなかった場合もエラーにはならず、
210
+ そのcomponentは無言でスキップされるため、signalを書き換えても再renderされなく
211
+ なります。回避策は次の3つです。
212
+
213
+ - 単一の安定したmodule pathでimportしているなら、`reactImportSource` にその
214
+ specifierを設定する(`"@app/react"` のようなbare specifierはどのファイルでも
215
+ 一致します。相対pathは同じ綴りのファイルにしか一致しません)。
216
+ - 該当箇所で `memo` / `forwardRef` を `"react"` から直接importする。
217
+ - `@useSignals` コメントか手書きの `useSignals()` 呼び出しで明示的にopt-inする。
218
+
219
+ ## `useSignals()` のopt-inと書き換え
220
+
221
+ `@useSignals` と `@noUseSignals` は、その関数だけに適用されます。pluginが
222
+ 二重の `useSignals()` 呼び出しを挿入することはありません。既存のdirect
223
+ import、namespace import、barrel import経由の呼び出しは、その関数のopt-inと
224
+ みなします。先頭文でない呼び出しと、barrel import経由の呼び出しは、いずれの
225
+ 変換モードでもそのまま残ります。`importSource` からdirect importまたは
226
+ namespace importした先頭文の呼び出しは、`"inject"` ではそのまま残りますが、
227
+ `"managed"`(既定)では生成する境界に吸収されます。つまりその文は削除され、
228
+ managed storeの宣言と `try` / `finally` scopeに置き換わるため、関数本体は
229
+ そのまま残るのではなく書き換えられます。
230
+
231
+ 先頭文での `useSignals()` 呼び出しが `async` 関数やgenerator関数の中にあると、
232
+ 既定の `"managed"` 変換ではbuildが失敗し、
233
+ `useSignals transform only supports synchronous, non-generator functions`
234
+ というエラーになります。この組み合わせはそもそもReactとして不正です
235
+ (hookは同期のfunction componentを必要とします)。関数側を直すのが望ましく、
236
+ どうしても現状のままbuildを通す必要がある場合は `transform: "inject"` を
237
+ 使うと、関数を書き換えずにこの呼び出しを受け入れます。
238
+
239
+ ## Buildへの組み込み
240
+
241
+ いずれの変換モードも再適用するとno-opです。この変換は、対応するbundler
242
+ (Vite、webpack、Rspack)では `enforce: "pre"` によって他のplugin変換より
243
+ 先に実行されます。Rollupにはこの概念がないため、このpluginを最初にlistして
244
+ ください。また、依存関係やJavaScript/TypeScript以外のmoduleはskipします。
245
+ `.ts` はJSXなしのTypeScriptとして、`.tsx`、`.jsx`、JavaScriptはJSXを含めて
246
+ 解析します。
247
+
248
+ ## License
249
+
250
+ MIT © akazawa。[LICENSE](LICENSE) を参照してください。
package/README.md ADDED
@@ -0,0 +1,261 @@
1
+ # unplugin-react-fine-grained-signals
2
+
3
+ [English](README.md) | [日本語](README.ja.md)
4
+
5
+ Universal bundler integration for automatic `useSignals()` insertion and the
6
+ optional managed render-scope transform in
7
+ [`react-fine-grained-signals`](https://www.npmjs.com/package/react-fine-grained-signals) (not yet published to npm).
8
+
9
+ This package is deliberately the only build-time integration. It keeps the
10
+ Babel implementation private, so application configuration is the same across
11
+ supported bundlers.
12
+
13
+ ## Installation
14
+
15
+ ```sh
16
+ pnpm add -D unplugin-react-fine-grained-signals
17
+ ```
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()`.
21
+
22
+ ## Vite
23
+
24
+ ```ts
25
+ import { defineConfig } from "vite";
26
+ import signals from "unplugin-react-fine-grained-signals/vite";
27
+
28
+ export default defineConfig({
29
+ plugins: [signals({ mode: "auto" })],
30
+ });
31
+ ```
32
+
33
+ ## Other bundlers
34
+
35
+ Use the matching entry point in the same way:
36
+
37
+ | Bundler | Entry point |
38
+ | --- | --- |
39
+ | Rollup | `unplugin-react-fine-grained-signals/rollup` |
40
+ | Webpack | `unplugin-react-fine-grained-signals/webpack` |
41
+ | Rspack | `unplugin-react-fine-grained-signals/rspack` |
42
+ | esbuild | `unplugin-react-fine-grained-signals/esbuild` |
43
+
44
+ ```ts
45
+ import signals from "unplugin-react-fine-grained-signals/webpack";
46
+
47
+ export default {
48
+ plugins: [signals({ mode: "auto" })],
49
+ };
50
+ ```
51
+
52
+ ## Options
53
+
54
+ - `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.
57
+ - `"auto"` (default): additionally transforms named JSX components and
58
+ named `useX` custom hooks that read `.value`.
59
+ - `"all"`: additionally transforms every named JSX component. Nested
60
+ callbacks are collected by their owning component but are never transform
61
+ targets themselves.
62
+ - `transform`:
63
+ - `"managed"` (default): adds an exact `try` / `finally` boundary, importing
64
+ from the package's `/runtime` entry and closing the render-tracking
65
+ 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)
70
+ for the known sibling-misattribution limitation this mode can expose.
71
+ - `reactCompiler`:
72
+ - `"auto"` (default): marks every transformed function with `"use no memo"`,
73
+ so React Compiler does not memoize away the signal reads render tracking
74
+ depends on. Without it, a compiled component caches its JSX and silently
75
+ stops updating after the first signal write.
76
+ - `"off"`: omits the directive. Choose it only when React Compiler is not in
77
+ the build, or when the affected components were verified against
78
+ [the compatibility note](../../docs/design/react-compiler-compatibility.md).
79
+ - `importSource`: overrides `react-fine-grained-signals` for a compatible wrapper.
80
+ - `reactImportSource`: an additional module specifier whose `memo` and
81
+ `forwardRef` exports count as React's own when the plugin decides whether a
82
+ wrapped function is a component. Recognition is additive, not a replacement:
83
+ a direct import from `"react"` is always recognized, so setting this only
84
+ widens detection and never turns off an existing direct import.
85
+ - `include` / `exclude`: functions that filter source module IDs.
86
+
87
+ ## Render callback detection
88
+
89
+ Automatic detection never transforms a render callback — a function handed to
90
+ one of the array iteration methods `map`, `flatMap`, or `forEach` — because it
91
+ runs a variable number of times inside one render of its owner, which the Rules
92
+ of Hooks forbid. Recognition covers the inline definition site
93
+ (`items.map((item) => …)`), a callback factored out and referenced by its own
94
+ binding, whether that binding is a `const` (`const Row = …; items.map(Row)`) or
95
+ a function declaration (`function Row() {…}` … `items.map(Row)`), and the
96
+ optional-chained form of either (`items?.map(Row)`). Such a callback is left
97
+ alone, and its JSX and `.value` reads are collected by the component that
98
+ invokes it — including when the callback is defined elsewhere in the same
99
+ module, so that component is transformed even when its own body reads no
100
+ signal.
101
+
102
+ The method name is the whole signal here, because a build-time transform cannot
103
+ know the object's runtime type. The recognized set is deliberately minimal:
104
+ `map` and `flatMap` build an element per item and `forEach` pushes elements into
105
+ an accumulator, which are the calls whose callbacks are routinely factored out
106
+ under a component-shaped name. Predicate and accumulator methods (`filter`,
107
+ `reduce`, `find`, `some`, `every`) are excluded on purpose — their callbacks are
108
+ lowercase helpers that are never transform targets anyway, so including them
109
+ would only widen the chance of matching an unrelated method of the same name,
110
+ and that direction of error is the expensive one: it silently denies a real
111
+ component its subscription. A reference passed to any other call is therefore
112
+ ordinary component registration and stays eligible — `memo(Row)` and
113
+ `forwardRef(Row)` as before, and equally third-party wrappers such as
114
+ `observer(Row)` or `connect(…)(Row)`, where React instantiates the returned
115
+ component as its own fiber with its own hooks. The same registration works
116
+ written inline too, as long as the function keeps its own PascalCase name:
117
+ `observer(function Row() { … })` is recognized exactly as `observer(Row)` is.
118
+ An anonymous or arrow function passed the same way —
119
+ `observer((props) => …)` — carries no name for a boundary to attach to, and is
120
+ left untransformed.
121
+
122
+ This detection has four known limitations:
123
+
124
+ - A re-assigned alias is not followed, so a PascalCase helper reached through
125
+ `const RowAlias = Row; items.map(RowAlias)` is still treated as a component.
126
+ - A render callback passed as a JSX prop value is not recognized.
127
+ `<Grid renderItem={Row} />` is syntactically identical whether `Grid` calls
128
+ `renderItem(item)` a variable number of times per render (a render prop,
129
+ where hook injection is unsafe) or instantiates `Row` as its own component
130
+ (where hook injection is what makes it update), and nothing in the file says
131
+ 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.
137
+ - A callback imported from another module is not followed, because the
138
+ transform sees one file at a time.
139
+ - A function used in both roles keeps the exclusion. If `Row` is passed to
140
+ `map` / `flatMap` / `forEach` anywhere in the module and is *also* rendered
141
+ independently as a JSX tag (`<Row item={x} />`), the render-callback usage
142
+ wins: `Row` gets no hook of its own. That is the crash-safe direction — a
143
+ hook there would break hook order in the callback usage — but the
144
+ independently rendered instance then has no subscription and goes stale on
145
+ later signal writes. Split the two roles into two differently named
146
+ functions, one per role, or opt the independently rendered function in
147
+ explicitly with a `@useSignals` comment.
148
+
149
+ When in doubt, keep such helpers explicit: name them lowercase and without a
150
+ `use` prefix, or opt them in manually only when they are genuinely rendered as
151
+ components.
152
+
153
+ ## Higher-order components
154
+
155
+ A component a higher-order component returns is recognized even though it has no
156
+ name of its own:
157
+
158
+ ```jsx
159
+ export const withCount = (Base) => (props) => <Base {...props} count={count.value} />;
160
+ ```
161
+
162
+ The inner function is the component here, and it inherits its identity from the
163
+ factory's own binding — `withCount` — so `auto` mode subscribes it and a
164
+ `@useSignals` (or `@noUseSignals`) comment on either the returned function or the
165
+ factory's declaration applies to it. Three conditions keep ordinary closures out:
166
+ the function must be returned directly by the enclosing function (an explicit
167
+ `return`, or an arrow's concise body); the returned function must render JSX
168
+ itself; and the enclosing function's name must not be a hook name (`useX`) — or,
169
+ if it is a component name (PascalCase), the enclosing function must itself
170
+ qualify as a higher-order component factory (described next). A function
171
+ returned by a component that fails that test, or by a hook, is instead a render
172
+ prop that runs inside that owner's render, not a component of its own.
173
+
174
+ A PascalCase-named factory qualifies when it takes what looks like a component
175
+ as its own parameter — a plain (not destructured) identifier parameter with a
176
+ PascalCase name — and renders no JSX itself:
177
+
178
+ ```jsx
179
+ export const WithCount = (Base) => (props) => <Base {...props} count={count.value} />;
180
+ ```
181
+
182
+ `WithCount` is never transformed itself; the component it returns inherits
183
+ `WithCount`'s name exactly as the camelCase form above does. A `useX`-named
184
+ factory gets no equivalent exception for the closure it returns, deliberately:
185
+ React calls a hook from inside a render already in progress, so anything the
186
+ hook hands back may be invoked during that very render, and nothing here can
187
+ rule that out. The hook itself is unaffected by this and is still transformed
188
+ normally whenever it qualifies — reading a signal, rendering JSX — exactly like
189
+ any other hook; only the returned closure inherits no name from it.
190
+
191
+ 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`
193
+ comment there reports that rather than silently doing nothing. The one shape
194
+ this cannot tell apart is a factory whose result is handed straight to an
195
+ iteration method (`items.map(makeRow(prefix))`): that is syntactically a HOC
196
+ returning a component, so it gets a boundary of its own. Pass such a callback by
197
+ reference (`items.map(Row)`) or write it inline, so the owning component
198
+ collects it.
199
+
200
+ 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
205
+ export (`export default (props) => <p>{count.value}</p>`) is named after the
206
+ 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.
210
+
211
+ ## `memo` / `forwardRef` recognition
212
+
213
+ Automatic `memo` / `forwardRef` recognition matches only a direct import from
214
+ `"react"` or from `reactImportSource`. Importing them through a local
215
+ barrel or re-export module (`import { memo } from "./some-local-module"`) is
216
+ not resolved automatically, even when that module ultimately re-exports from
217
+ `"react"`: the transform sees one file at a time and does not follow re-export
218
+ chains. An unrecognized wrapper is not an error — the component is silently
219
+ skipped, so signal writes stop re-rendering it. Three workarounds:
220
+
221
+ - set `reactImportSource` to that module's specifier, if the codebase imports
222
+ through one stable module path (a bare specifier such as `"@app/react"`
223
+ works everywhere; a relative path only matches files that spell it the same
224
+ way);
225
+ - import `memo` / `forwardRef` directly from `"react"` at the affected call
226
+ sites;
227
+ - opt the component in explicitly with a `@useSignals` comment or a manual
228
+ `useSignals()` call.
229
+
230
+ ## `useSignals()` opt-in and rewriting
231
+
232
+ `@useSignals` and `@noUseSignals` apply only to their owning function; they do
233
+ not affect nested functions. The plugin never adds a second `useSignals()`
234
+ call: a direct, namespace, or barrel-imported call it finds is treated as the
235
+ function's existing opt-in. A call that is not the function's first statement,
236
+ and any barrel-imported call, is left alone in both transform modes. A
237
+ first-statement call imported directly or as a namespace from `importSource` is
238
+ left in place under `"inject"`, but under `"managed"` (the default) it is
239
+ absorbed into the generated boundary: the statement is removed and replaced by
240
+ the managed store declaration plus the `try` / `finally` scope, so the function
241
+ body is rewritten rather than left byte-for-byte untouched.
242
+
243
+ A first-statement `useSignals()` call inside an `async` or generator function
244
+ fails the build under the default `"managed"` transform, with
245
+ `useSignals transform only supports synchronous, non-generator functions`. That
246
+ combination is already invalid React — hooks require a synchronous function
247
+ component — so prefer fixing the function; `transform: "inject"` accepts it
248
+ without rewriting the function, if the file must keep building unchanged.
249
+
250
+ ## Build integration
251
+
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.
258
+
259
+ ## License
260
+
261
+ MIT © akazawa. See [LICENSE](LICENSE).
@@ -0,0 +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";
2
+ //#region src/esbuild.d.ts
3
+ /** The plugin contract consumed by esbuild. */
4
+ interface EsbuildPlugin {
5
+ name: string;
6
+ setup(build: unknown): void | Promise<void>;
7
+ }
8
+ declare const _default: (options?: ReactFineGrainedSignalsOptions) => EsbuildPlugin;
9
+ //#endregion
10
+ export { BundlerTransformOutput, EsbuildLoaderName, EsbuildPlugin, type ReactFineGrainedSignalsMode, ReactFineGrainedSignalsOptions, type ReactFineGrainedSignalsReactCompiler, type ReactFineGrainedSignalsTransform, canTransform, createBundlerPlugin, _default as default, pluginName, reactFineGrainedSignals, resolveEsbuildLoader };
@@ -0,0 +1,5 @@
1
+ import { a as resolveEsbuildLoader, i as reactFineGrainedSignals, n as createBundlerPlugin, r as pluginName, t as canTransform } from "./unplugin-B2_q8KNi.js";
2
+ //#region src/esbuild.ts
3
+ var esbuild_default = createBundlerPlugin("esbuild");
4
+ //#endregion
5
+ export { canTransform, createBundlerPlugin, esbuild_default as default, pluginName, reactFineGrainedSignals, resolveEsbuildLoader };
@@ -0,0 +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";
2
+ export { BundlerTransformOutput, EsbuildLoaderName, type ReactFineGrainedSignalsMode, ReactFineGrainedSignalsOptions, type ReactFineGrainedSignalsReactCompiler, type ReactFineGrainedSignalsTransform, canTransform, createBundlerPlugin, reactFineGrainedSignals as default, reactFineGrainedSignals, pluginName, resolveEsbuildLoader };
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ import { a as resolveEsbuildLoader, i as reactFineGrainedSignals, n as createBundlerPlugin, r as pluginName, t as canTransform } from "./unplugin-B2_q8KNi.js";
2
+ export { canTransform, createBundlerPlugin, reactFineGrainedSignals as default, reactFineGrainedSignals, pluginName, resolveEsbuildLoader };
@@ -0,0 +1,15 @@
1
+ //#region src/loader.d.ts
2
+ type LoaderCallback = (error: Error | null, content?: string, sourceMap?: unknown) => void;
3
+ /** The slice of webpack's/rspack's loader context this loader uses. */
4
+ interface LoaderContext {
5
+ async(): LoaderCallback;
6
+ query: unknown;
7
+ resource: string;
8
+ addDependency?: ((file: string) => void) | undefined;
9
+ getDependencies?: (() => string[]) | undefined;
10
+ emitWarning?: ((warning: Error) => void) | undefined;
11
+ emitError?: ((error: Error) => void) | undefined;
12
+ }
13
+ declare function reactFineGrainedSignalsLoader(this: LoaderContext, source: string, map?: unknown): void;
14
+ //#endregion
15
+ export { reactFineGrainedSignalsLoader as default };