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
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 };
|
package/dist/esbuild.js
ADDED
|
@@ -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 };
|
package/dist/index.d.ts
ADDED
|
@@ -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 };
|
package/dist/loader.d.ts
ADDED
|
@@ -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 };
|