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,53 @@
1
+ # react-fine-grained-signals
2
+
3
+ [English](README.md) | [日本語](README.ja.md)
4
+
5
+ [alien-signals](https://www.npmjs.com/package/alien-signals) を基盤とした、React 19向けの実験的なfine-grainedレンダリングレイヤーです。小さなリアクティブプリミティブ、Reactフック、そして対象を意図的に絞ったDOMプロパティへの直接バインディング用JSXランタイムを提供します。このJSXランタイムは明示的に有効化した場合のみ使用されます。
6
+
7
+ **React 19以降が必要です。** JSXランタイムは、React 18では利用できないcallback refのクリーンアップを使用します。
8
+
9
+ ## ドキュメント
10
+
11
+ ライブラリの使い方のガイド、設計検討メモ、ドキュメント索引全体については[`docs/README.ja.md`](docs/README.ja.md)を参照してください。
12
+
13
+ ## インストール
14
+
15
+ ```sh
16
+ # 公開予定のパッケージ名です。まだnpmには公開していません。
17
+ pnpm add react-fine-grained-signals alien-signals
18
+ ```
19
+
20
+ `alien-signals` はpeer dependencyなので、上のように併せてinstallしてください。
21
+
22
+ `useSignals()` を自動挿入するオプションのbuild pluginは別途installします。[描画最適化](docs/rendering-optimization.ja.md)を参照してください。
23
+
24
+ ## 開発
25
+
26
+ ```sh
27
+ pnpm install --frozen-lockfile
28
+ pnpm typecheck
29
+ pnpm test
30
+ pnpm build
31
+ ```
32
+
33
+ workspace aliasを使わないクリーンなVite consumerから、pack済みESM packageを検証するには次を実行します。
34
+
35
+ ```sh
36
+ pnpm test:consumer
37
+ ```
38
+
39
+ ブラウザ向けの概念実証では、サーバーでアプリをレンダーし、React 19でハイドレーションを行い、Chromium上でsignalの直接バインディングを検証します。
40
+
41
+ ```sh
42
+ pnpm exec playwright install --only-shell chromium
43
+ pnpm prepare:e2e
44
+ pnpm test:browser
45
+ ```
46
+
47
+ 同じサンプルを `http://127.0.0.1:4173` で確認するには、`pnpm dev:browser` を実行してください。このコマンドは最初に変換パッケージをビルドします。
48
+
49
+ ## 謝辞
50
+
51
+ - [alien-signals](https://www.npmjs.com/package/alien-signals) — 本パッケージが基盤とするシグナルエンジン。
52
+ - [@preact/signals-core](https://www.npmjs.com/package/@preact/signals-core) — ベンチマークの比較対象。
53
+ - [@preact/signals-react](https://www.npmjs.com/package/@preact/signals-react) — `useSignals()` boundaryのstore protocol設計における先行事例。詳細は[Prior art](docs/design/use-signals-boundary-design.ja.md#先行事例)を参照。
package/README.md ADDED
@@ -0,0 +1,54 @@
1
+ # react-fine-grained-signals
2
+
3
+ [English](README.md) | [日本語](README.ja.md)
4
+
5
+ An experimental fine-grained rendering layer for React 19, built on [alien-signals](https://www.npmjs.com/package/alien-signals). It provides small reactive primitives, React hooks, and an opt-in JSX runtime for a deliberately narrow set of direct DOM bindings.
6
+
7
+ **Requires React 19 or newer.** The JSX runtime uses callback-ref cleanup, which is unavailable in React 18.
8
+
9
+ ## Documentation
10
+
11
+ See [`docs/README.md`](docs/README.md) for guides on using the library, design investigation memos, and the full documentation index.
12
+
13
+ ## Installation
14
+
15
+ ```sh
16
+ # Planned package name — it is not published to npm yet.
17
+ pnpm add react-fine-grained-signals alien-signals
18
+ ```
19
+
20
+ `alien-signals` is a peer dependency, so install it alongside this package.
21
+
22
+ The optional build plugin for automatic `useSignals()` insertion is installed separately — see [Rendering optimization](docs/rendering-optimization.md).
23
+
24
+ ## Development
25
+
26
+ ```sh
27
+ pnpm install --frozen-lockfile
28
+ pnpm typecheck
29
+ pnpm test
30
+ pnpm build
31
+ ```
32
+
33
+ To verify the packed ESM packages from a clean Vite consumer (without workspace
34
+ aliases), run:
35
+
36
+ ```sh
37
+ pnpm test:consumer
38
+ ```
39
+
40
+ The browser proof of concept renders the app on the server, hydrates it with React 19, and exercises direct signal bindings in Chromium:
41
+
42
+ ```sh
43
+ pnpm exec playwright install --only-shell chromium
44
+ pnpm prepare:e2e
45
+ pnpm test:browser
46
+ ```
47
+
48
+ Run `pnpm dev:browser` to build the transform package and inspect the same example at `http://127.0.0.1:4173`.
49
+
50
+ ## Acknowledgments
51
+
52
+ - [alien-signals](https://www.npmjs.com/package/alien-signals) — the signal engine this package builds on.
53
+ - [@preact/signals-core](https://www.npmjs.com/package/@preact/signals-core) — benchmark comparison target.
54
+ - [@preact/signals-react](https://www.npmjs.com/package/@preact/signals-react) — prior art for the `useSignals()` boundary's store protocol; see [Prior art](docs/design/use-signals-boundary-design.md#prior-art).
@@ -0,0 +1,286 @@
1
+ import { a as untrackedRender, n as hasActiveRenderCollector, t as RenderSubscription } from "./render-tracking-DCzw57BW.js";
2
+ import { computed, effect, endBatch, getActiveSub, setActiveSub, signal, startBatch, trigger } from "alien-signals";
3
+ //#region src/core/base.ts
4
+ const signalInstances = /* @__PURE__ */ new WeakSet();
5
+ /**
6
+ * Cross-instance signal brand. `Symbol.for` resolves through the registry that
7
+ * is shared by every realm, so a signal made by a duplicate copy of this
8
+ * package (pnpm hoisting, a monorepo consumer, an ESM/CJS split) or in another
9
+ * realm (iframe, worker) still answers `isSignal`. Shared with the deep-signal
10
+ * proxy only; the package's public API stays `isSignal`.
11
+ */
12
+ const SIGNAL_BRAND = Symbol.for("react-fine-grained-signals.signal");
13
+ const SIGNAL_BRAND_VERSION = 1;
14
+ const SIGNAL_BRAND_MIN_VERSION = 1;
15
+ /** Marks an internal signal implementation for the public identity guard. */
16
+ function registerSignal(value) {
17
+ signalInstances.add(value);
18
+ Object.defineProperty(value, SIGNAL_BRAND, {
19
+ value: SIGNAL_BRAND_VERSION,
20
+ enumerable: false,
21
+ writable: false,
22
+ configurable: false
23
+ });
24
+ return value;
25
+ }
26
+ /** Returns whether a value came from this package's signal APIs, any copy. */
27
+ function isSignal(value) {
28
+ if (typeof value !== "object" || value === null) return false;
29
+ if (signalInstances.has(value)) return true;
30
+ const brand = value[SIGNAL_BRAND];
31
+ if (typeof brand !== "number" || brand < SIGNAL_BRAND_MIN_VERSION) return false;
32
+ return typeof value.peek === "function";
33
+ }
34
+ /** Internal writable signal implementation shared with deep signals. */
35
+ var SignalImpl = class {
36
+ #source;
37
+ #renderSubscription = new RenderSubscription();
38
+ #currentValue;
39
+ #watchedSinceWrite = false;
40
+ constructor(initialValue) {
41
+ this.#source = signal(initialValue);
42
+ this.#currentValue = initialValue;
43
+ }
44
+ /**
45
+ * Records that this signal was read while some subscriber (an alien-signals
46
+ * effect/computed, or a React render collector) was active. Cleared by the
47
+ * next write, so after that write's flush has drained, a still-false flag
48
+ * means every reactive subscriber has re-run without re-reading this signal
49
+ * and has therefore been unlinked from it.
50
+ */
51
+ markWatched() {
52
+ this.#watchedSinceWrite = true;
53
+ }
54
+ /**
55
+ * Conservative "somebody still depends on me" test used to decide whether a
56
+ * `deepSignal` per-key version signal is safe to drop. Never reports `false`
57
+ * for a signal that still has a live dependent: the React side is exact
58
+ * (`hasListeners`), and the alien-signals side is covered by `markWatched`,
59
+ * whose flag can only be `false` once a write has notified every subscriber
60
+ * and none of them read this signal again.
61
+ */
62
+ hasSubscribers() {
63
+ return this.#watchedSinceWrite || this.#renderSubscription.hasListeners();
64
+ }
65
+ get value() {
66
+ if (this.#renderSubscription.track()) return this.#currentValue;
67
+ this.#source();
68
+ return this.#currentValue;
69
+ }
70
+ set value(nextValue) {
71
+ if (Object.is(this.#currentValue, nextValue)) return;
72
+ const requiresManualTrigger = this.#currentValue === nextValue;
73
+ this.#currentValue = nextValue;
74
+ this.#watchedSinceWrite = false;
75
+ try {
76
+ if (requiresManualTrigger) trigger(this.#source);
77
+ else this.#source(nextValue);
78
+ } finally {
79
+ this.#renderSubscription.notify();
80
+ }
81
+ }
82
+ peek() {
83
+ return this.#currentValue;
84
+ }
85
+ };
86
+ /** Creates a writable reactive value. */
87
+ function signal$1(initialValue) {
88
+ return registerSignal(new SignalImpl(initialValue));
89
+ }
90
+ /** Unwraps a computed's box, rethrowing a cached getter error on read. */
91
+ function unbox(box) {
92
+ if ("error" in box) throw box.error;
93
+ return box.value;
94
+ }
95
+ /** Creates a lazily evaluated reactive value. */
96
+ function computed$1(getter) {
97
+ const source = computed((previous) => {
98
+ try {
99
+ const value = getter();
100
+ return previous !== void 0 && "value" in previous && Object.is(previous.value, value) ? previous : { value };
101
+ } catch (error) {
102
+ return { error };
103
+ }
104
+ });
105
+ let disposeRenderBridge;
106
+ let isInitialBridgeRun = true;
107
+ let lastRenderValue;
108
+ const renderSubscription = new RenderSubscription(() => {
109
+ isInitialBridgeRun = true;
110
+ disposeRenderBridge = effect(() => {
111
+ const nextValue = untrackedRender(source);
112
+ const changedSinceRender = lastRenderValue !== void 0 && nextValue !== lastRenderValue;
113
+ lastRenderValue = nextValue;
114
+ if (isInitialBridgeRun) {
115
+ isInitialBridgeRun = false;
116
+ if (changedSinceRender) renderSubscription.bumpVersion();
117
+ } else if (changedSinceRender) renderSubscription.notify();
118
+ });
119
+ }, () => {
120
+ disposeRenderBridge?.();
121
+ disposeRenderBridge = void 0;
122
+ });
123
+ return registerSignal({
124
+ get value() {
125
+ if (hasActiveRenderCollector()) {
126
+ const nextValue = untracked(() => untrackedRender(source));
127
+ if (lastRenderValue !== void 0 && nextValue !== lastRenderValue) renderSubscription.bumpVersion();
128
+ lastRenderValue = nextValue;
129
+ renderSubscription.track();
130
+ return unbox(nextValue);
131
+ }
132
+ return unbox(source());
133
+ },
134
+ peek() {
135
+ return unbox(untracked(() => source()));
136
+ }
137
+ });
138
+ }
139
+ /**
140
+ * Reports an error thrown by an `effect()` callback — its body or its cleanup —
141
+ * without letting it escape into the flush that ran it.
142
+ *
143
+ * alien-signals' `flush()` drains the rest of its effect queue in a `finally`
144
+ * *without running those effects*, so one throwing effect silently cancels
145
+ * every effect still queued behind it in that cycle — an unrelated binding, or
146
+ * a `useSignals()`-tracked component's commit, quietly missing an update — and
147
+ * then propagates out of whatever write triggered the flush (an event handler,
148
+ * anywhere). Catching here keeps the failure local to this one effect.
149
+ *
150
+ * The policy is *report, never re-raise*, matching the two other reporters in
151
+ * this codebase (`readBoundSignal` in src/runtime/jsx.ts and `notifyListener`
152
+ * in src/core/render-tracking.ts):
153
+ *
154
+ * 1. Always `console.error(message, { cause })` — the package-wide reporting
155
+ * shape; assert against `mock.calls[i][1].cause` in tests.
156
+ * 2. Then hand the error to `reportError()` when the host defines it, so
157
+ * `window.onerror` / `addEventListener("error")` / a telemetry SDK still see
158
+ * the failure. `reportError` *dispatches an error event* rather than
159
+ * throwing, so it reports exactly like an uncaught error while remaining
160
+ * non-fatal by construction.
161
+ *
162
+ * Both hooks are host-controlled, so **everything in this function runs inside a
163
+ * `try`/`catch`, property lookups included**. A host can legitimately make
164
+ * `console.error` throw (some React test setups install a throwing one to make
165
+ * warnings fatal) or expose `reportError` as a throwing getter; either would
166
+ * otherwise re-raise out of the reporter itself and cancel the rest of the
167
+ * flush — precisely the failure this function exists to prevent. The two steps
168
+ * get separate guards rather than one shared one, so a broken `console.error`
169
+ * still leaves the `reportError` channel to surface the error, and vice versa.
170
+ *
171
+ * What this deliberately no longer does is `queueMicrotask(() => { throw })`.
172
+ * That reads like a browser-only "let it reach `window.onerror`" trick, but a
173
+ * throw out of a microtask is not a recoverable event outside a browser: in
174
+ * Node it raises `uncaughtException`, which by default **terminates the
175
+ * process** (verified on Node 24.19: exit code 1). Every server-side consumer —
176
+ * SSR data plumbing, a Node script, a test harness — got an unrecoverable crash
177
+ * from an effect body that a plain `try`/`catch` would have handled, and
178
+ * because the throw was deferred out of the write, nothing could catch it
179
+ * anywhere. `reportError` is not a fallback for that on Node: it is the WHATWG
180
+ * reporting API, implemented by browsers, Web Workers, Deno, and Bun, but not
181
+ * defined by Node at any version this package supports (absent on Node 24 LTS,
182
+ * still not listed in the Node 26 globals docs). The feature check below is
183
+ * therefore the whole story — where it fails, step 1 is the report.
184
+ *
185
+ * The failure therefore never propagates to the write that triggered it, and
186
+ * that containment covers *synchronous* throws from the body and the cleanup.
187
+ * An `async` effect body that rejects is a different channel this cannot reach
188
+ * (an unhandled rejection, still fatal by default on Node), so `await`ed work
189
+ * needs its own `try`/`catch`. Either way, code that wants to handle its own
190
+ * failure should do so at the real failure site — inside the effect body or
191
+ * cleanup — which is always possible and is where the `try`/`catch` belongs.
192
+ */
193
+ function reportEffectError(error) {
194
+ try {
195
+ console.error("react-fine-grained-signals: an effect() callback threw; the error is contained and reported here so this flush can finish.", { cause: error });
196
+ } catch {}
197
+ try {
198
+ const report = globalThis.reportError;
199
+ if (typeof report === "function") report.call(globalThis, error);
200
+ } catch {}
201
+ }
202
+ /**
203
+ * Wraps the cleanup an effect body returned so a throw out of it is contained
204
+ * exactly like a throw out of the body.
205
+ *
206
+ * alien-signals stores the returned cleanup on the effect node and calls it
207
+ * through its own private `runCleanup`, from two places this package cannot
208
+ * intercept (verified against alien-signals@3.2.1):
209
+ *
210
+ * - `run()` calls it *before* re-running the body, i.e. inside `flush()`'s
211
+ * `try`, so a throw there hits the very same queue-cancelling failure mode
212
+ * `reportEffectError` exists for — worse, in fact, because it aborts the
213
+ * flush before this effect's own body has even re-run.
214
+ * - `effectOper()` calls it on disposal — both the disposer returned below and
215
+ * the `unwatched` hook alien-signals fires when a nested effect loses its
216
+ * last subscriber, which happens during `unlink`/`purgeDeps` and so can also
217
+ * land in the middle of a flush.
218
+ *
219
+ * Neither call site is reachable from outside the module, so the guard goes on
220
+ * the cleanup itself, on its way back to alien-signals: one wrapper covers
221
+ * every path that can invoke it, now and later. The wrapper never rethrows, so
222
+ * alien-signals always sees the cleanup return normally and its own
223
+ * bookkeeping (`e.cleanup = undefined`, `activeSub` restore, the flags check
224
+ * after `runCleanup`) stays on the success path.
225
+ */
226
+ function guardCleanup(cleanup) {
227
+ return () => {
228
+ try {
229
+ cleanup();
230
+ } catch (error) {
231
+ reportEffectError(error);
232
+ }
233
+ };
234
+ }
235
+ /** Runs a reactive side effect and returns a disposer. */
236
+ function effect$1(fn) {
237
+ let disposed = false;
238
+ let disposeEffect;
239
+ disposeEffect = effect(() => {
240
+ if (disposed) return;
241
+ let cleanup;
242
+ try {
243
+ cleanup = untrackedRender(fn);
244
+ } catch (error) {
245
+ reportEffectError(error);
246
+ return;
247
+ }
248
+ if (cleanup === void 0) return;
249
+ const guardedCleanup = guardCleanup(cleanup);
250
+ if (disposed) {
251
+ untracked(guardedCleanup);
252
+ return;
253
+ }
254
+ return guardedCleanup;
255
+ });
256
+ return () => {
257
+ if (disposed) return;
258
+ disposed = true;
259
+ disposeEffect?.();
260
+ };
261
+ }
262
+ /** Groups writes, deferring effect notifications until the callback completes. */
263
+ function batch(fn) {
264
+ startBatch();
265
+ try {
266
+ return fn();
267
+ } finally {
268
+ endBatch();
269
+ }
270
+ }
271
+ /** Runs a callback without collecting reactive dependencies. */
272
+ function untracked(fn) {
273
+ return untrackedRender(() => {
274
+ const activeSub = getActiveSub();
275
+ setActiveSub();
276
+ try {
277
+ return fn();
278
+ } finally {
279
+ setActiveSub(activeSub);
280
+ }
281
+ });
282
+ }
283
+ //#endregion
284
+ export { effect$1 as a, signal$1 as c, computed$1 as i, untracked as l, SignalImpl as n, isSignal as o, batch as r, registerSignal as s, SIGNAL_BRAND as t };
285
+
286
+ //# sourceMappingURL=base-BZTnpU8U.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"base-BZTnpU8U.js","names":["createSignal","signal","computed","createComputed","createEffect","effect"],"sources":["../src/core/base.ts"],"sourcesContent":["import {\n computed as createComputed,\n effect as createEffect,\n endBatch,\n getActiveSub,\n setActiveSub,\n signal as createSignal,\n startBatch,\n trigger,\n} from \"alien-signals\";\nimport {\n hasActiveRenderCollector,\n RenderSubscription,\n untrackedRender,\n} from \"./render-tracking.js\";\n\n/** A readable reactive value. */\nexport interface ReadonlySignal<T> {\n readonly value: T;\n peek(): T;\n}\n\n/** A readable and writable reactive value. */\nexport interface Signal<T> extends ReadonlySignal<T> {\n value: T;\n}\n\nconst signalInstances = new WeakSet<object>();\n\n/**\n * Cross-instance signal brand. `Symbol.for` resolves through the registry that\n * is shared by every realm, so a signal made by a duplicate copy of this\n * package (pnpm hoisting, a monorepo consumer, an ESM/CJS split) or in another\n * realm (iframe, worker) still answers `isSignal`. Shared with the deep-signal\n * proxy only; the package's public API stays `isSignal`.\n */\nexport const SIGNAL_BRAND: unique symbol = Symbol.for(\"react-fine-grained-signals.signal\");\n// The brand carries a protocol version instead of `true` so a future instance\n// can tell which contract a foreign signal claims. A version only ever widens\n// the `{ value, peek() }` contract; a breaking change must take a new symbol\n// key, which is why anything from the minimum upwards is trusted here.\nconst SIGNAL_BRAND_VERSION = 1;\nconst SIGNAL_BRAND_MIN_VERSION = 1;\n\n/** Marks an internal signal implementation for the public identity guard. */\nexport function registerSignal<T extends object>(value: T): T {\n signalInstances.add(value);\n // Non-enumerable keeps the brand out of JSON, `Object.keys`, spread, and\n // React's prop diffing; non-writable and non-configurable keep it from being\n // retargeted or stripped once the value is public. The hardening is free:\n // measured against a writable/configurable descriptor the cost is identical,\n // because it is the `defineProperty` call itself, and V8 keeps the instance\n // in fast properties either way (checked with `%HasFastProperties`). Stamping\n // the prototype instead would be worse — that pushes the prototype into\n // dictionary mode, which every lookup through it then pays for.\n Object.defineProperty(value, SIGNAL_BRAND, {\n value: SIGNAL_BRAND_VERSION,\n enumerable: false,\n writable: false,\n configurable: false,\n });\n return value;\n}\n\n/** Returns whether a value came from this package's signal APIs, any copy. */\nexport function isSignal(value: unknown): value is ReadonlySignal<unknown> {\n if (typeof value !== \"object\" || value === null) return false;\n // The WeakSet is both the cheaper lookup and the authority for values created\n // by this instance; the brand is the fallback for every other instance.\n if (signalInstances.has(value)) return true;\n const brand = (value as { [SIGNAL_BRAND]?: unknown })[SIGNAL_BRAND];\n if (typeof brand !== \"number\" || brand < SIGNAL_BRAND_MIN_VERSION) return false;\n // A foreign brand is a claim, not a proof. Checking the cheap half of the\n // contract turns a malformed brand into `false` instead of a crash inside a\n // render, and costs nothing on the local-instance path above.\n return typeof (value as ReadonlySignal<unknown>).peek === \"function\";\n}\n\n/** Internal writable signal implementation shared with deep signals. */\nexport class SignalImpl<T> implements Signal<T> {\n readonly #source: ReturnType<typeof createSignal<T>>;\n readonly #renderSubscription = new RenderSubscription();\n #currentValue: T;\n // Liveness bookkeeping for `deepSignal`'s per-key metadata pruning; see\n // `markWatched`/`hasSubscribers` below. Not maintained on the read path\n // itself — `deepSignal`'s `track()` already knows whether a subscriber is\n // active and calls `markWatched()` there, so ordinary reads pay nothing.\n #watchedSinceWrite = false;\n\n constructor(initialValue: T) {\n this.#source = createSignal(initialValue);\n this.#currentValue = initialValue;\n }\n\n /**\n * Records that this signal was read while some subscriber (an alien-signals\n * effect/computed, or a React render collector) was active. Cleared by the\n * next write, so after that write's flush has drained, a still-false flag\n * means every reactive subscriber has re-run without re-reading this signal\n * and has therefore been unlinked from it.\n */\n markWatched(): void {\n this.#watchedSinceWrite = true;\n }\n\n /**\n * Conservative \"somebody still depends on me\" test used to decide whether a\n * `deepSignal` per-key version signal is safe to drop. Never reports `false`\n * for a signal that still has a live dependent: the React side is exact\n * (`hasListeners`), and the alien-signals side is covered by `markWatched`,\n * whose flag can only be `false` once a write has notified every subscriber\n * and none of them read this signal again.\n */\n hasSubscribers(): boolean {\n return this.#watchedSinceWrite || this.#renderSubscription.hasListeners();\n }\n\n get value(): T {\n if (this.#renderSubscription.track()) return this.#currentValue;\n // The call records the dependency; `#currentValue` preserves Object.is\n // semantics where the underlying callable API treats 0 and -0 alike.\n this.#source();\n return this.#currentValue;\n }\n\n set value(nextValue: T) {\n if (Object.is(this.#currentValue, nextValue)) return;\n const requiresManualTrigger = this.#currentValue === nextValue;\n this.#currentValue = nextValue;\n // Every subscriber is about to be notified, so each one either re-reads\n // this signal (setting the flag again through `markWatched`) or drops its\n // link to it. See `hasSubscribers`.\n this.#watchedSinceWrite = false;\n // The alien-signals write below flushes effects synchronously, so it can\n // still throw even though `effect()` now contains both its body and its\n // cleanup: anything else sharing this graph (an alien-signals effect\n // created directly, a computed's render bridge) is outside that guard.\n // `#currentValue` is already committed at that point, so skipping the\n // React-side notification would\n // leave every subscribed `RenderStore` believing the old value is still\n // current — a permanently stale UI that no later write can repair,\n // because a subsequent write compares against the already-updated\n // `#currentValue`. `finally` keeps React in sync no matter how the flush\n // exits, and still lets the original error propagate to the writer.\n try {\n if (requiresManualTrigger) {\n trigger(this.#source);\n } else {\n this.#source(nextValue);\n }\n } finally {\n this.#renderSubscription.notify();\n }\n }\n\n peek(): T {\n return this.#currentValue;\n }\n}\n\n/** Creates a writable reactive value. */\nexport function signal<T>(initialValue: T): Signal<T> {\n return registerSignal(new SignalImpl(initialValue));\n}\n\n/** A computed's cached result, boxed so a thrown error can be cached too. */\ntype ComputedBox<T> = { value: T } | { error: unknown };\n\n/** Unwraps a computed's box, rethrowing a cached getter error on read. */\nfunction unbox<T>(box: ComputedBox<T>): T {\n if (\"error\" in box) throw box.error;\n return box.value;\n}\n\n/** Creates a lazily evaluated reactive value. */\nexport function computed<T>(getter: () => T): ReadonlySignal<T> {\n // alien-signals' `updateComputed` resets its dirty/pending flags in a\n // `finally` no matter how the callback exits, so a thrown getter leaves the\n // node looking clean with its stale cached value, and can permanently\n // unwatch an effect whose run was interrupted mid-flush (verified against\n // alien-signals@3.2.1: neither the computed nor the effect ever recovers on\n // a later write). Catching here keeps the callback always returning\n // normally, so alien-signals' own bookkeeping stays on the success path;\n // the error is boxed and only rethrown when something reads `.value` or\n // `.peek()`, which for React happens during render where an Error Boundary\n // can catch it (mirrors `createDeepSelectorStore` in `src/react/hooks.ts`).\n const source = createComputed<ComputedBox<T>>((previous) => {\n try {\n const value = getter();\n // Guard `\"value\" in previous`: if `previous` is an error box, its\n // `.value` is `undefined`, so without this a legitimate recovery to\n // `value === undefined` would `Object.is` that against `undefined` and\n // wrongly keep returning the stale error box.\n return previous !== undefined && \"value\" in previous && Object.is(previous.value, value)\n ? previous\n : { value };\n } catch (error) {\n return { error };\n }\n });\n let disposeRenderBridge: (() => void) | undefined;\n let isInitialBridgeRun = true;\n let lastRenderValue: ComputedBox<T> | undefined;\n const renderSubscription = new RenderSubscription(\n () => {\n isInitialBridgeRun = true;\n disposeRenderBridge = createEffect(() => {\n const nextValue = untrackedRender(source);\n const changedSinceRender =\n lastRenderValue !== undefined && nextValue !== lastRenderValue;\n lastRenderValue = nextValue;\n if (isInitialBridgeRun) {\n isInitialBridgeRun = false;\n if (changedSinceRender) renderSubscription.bumpVersion();\n } else if (changedSinceRender) {\n renderSubscription.notify();\n }\n });\n },\n () => {\n disposeRenderBridge?.();\n disposeRenderBridge = undefined;\n },\n );\n\n const result: ReadonlySignal<T> = {\n get value(): T {\n if (hasActiveRenderCollector()) {\n const nextValue = untracked(() => untrackedRender(source));\n if (lastRenderValue !== undefined && nextValue !== lastRenderValue) {\n renderSubscription.bumpVersion();\n }\n lastRenderValue = nextValue;\n // Track after evaluating so this render records the post-evaluation\n // version without masking an older concurrent render.\n renderSubscription.track();\n return unbox(nextValue);\n }\n return unbox(source());\n },\n // `peek()` is documented as an untracked read, not an error-suppressing\n // one, so it rethrows the same as `.value` — consistent with\n // `useDeepSignalValue`, whose selector errors always surface on read.\n peek(): T {\n return unbox(untracked(() => source()));\n },\n };\n return registerSignal(result);\n}\n\n/**\n * Reports an error thrown by an `effect()` callback — its body or its cleanup —\n * without letting it escape into the flush that ran it.\n *\n * alien-signals' `flush()` drains the rest of its effect queue in a `finally`\n * *without running those effects*, so one throwing effect silently cancels\n * every effect still queued behind it in that cycle — an unrelated binding, or\n * a `useSignals()`-tracked component's commit, quietly missing an update — and\n * then propagates out of whatever write triggered the flush (an event handler,\n * anywhere). Catching here keeps the failure local to this one effect.\n *\n * The policy is *report, never re-raise*, matching the two other reporters in\n * this codebase (`readBoundSignal` in src/runtime/jsx.ts and `notifyListener`\n * in src/core/render-tracking.ts):\n *\n * 1. Always `console.error(message, { cause })` — the package-wide reporting\n * shape; assert against `mock.calls[i][1].cause` in tests.\n * 2. Then hand the error to `reportError()` when the host defines it, so\n * `window.onerror` / `addEventListener(\"error\")` / a telemetry SDK still see\n * the failure. `reportError` *dispatches an error event* rather than\n * throwing, so it reports exactly like an uncaught error while remaining\n * non-fatal by construction.\n *\n * Both hooks are host-controlled, so **everything in this function runs inside a\n * `try`/`catch`, property lookups included**. A host can legitimately make\n * `console.error` throw (some React test setups install a throwing one to make\n * warnings fatal) or expose `reportError` as a throwing getter; either would\n * otherwise re-raise out of the reporter itself and cancel the rest of the\n * flush — precisely the failure this function exists to prevent. The two steps\n * get separate guards rather than one shared one, so a broken `console.error`\n * still leaves the `reportError` channel to surface the error, and vice versa.\n *\n * What this deliberately no longer does is `queueMicrotask(() => { throw })`.\n * That reads like a browser-only \"let it reach `window.onerror`\" trick, but a\n * throw out of a microtask is not a recoverable event outside a browser: in\n * Node it raises `uncaughtException`, which by default **terminates the\n * process** (verified on Node 24.19: exit code 1). Every server-side consumer —\n * SSR data plumbing, a Node script, a test harness — got an unrecoverable crash\n * from an effect body that a plain `try`/`catch` would have handled, and\n * because the throw was deferred out of the write, nothing could catch it\n * anywhere. `reportError` is not a fallback for that on Node: it is the WHATWG\n * reporting API, implemented by browsers, Web Workers, Deno, and Bun, but not\n * defined by Node at any version this package supports (absent on Node 24 LTS,\n * still not listed in the Node 26 globals docs). The feature check below is\n * therefore the whole story — where it fails, step 1 is the report.\n *\n * The failure therefore never propagates to the write that triggered it, and\n * that containment covers *synchronous* throws from the body and the cleanup.\n * An `async` effect body that rejects is a different channel this cannot reach\n * (an unhandled rejection, still fatal by default on Node), so `await`ed work\n * needs its own `try`/`catch`. Either way, code that wants to handle its own\n * failure should do so at the real failure site — inside the effect body or\n * cleanup — which is always possible and is where the `try`/`catch` belongs.\n */\nfunction reportEffectError(error: unknown): void {\n try {\n console.error(\n \"react-fine-grained-signals: an effect() callback threw; the error is contained and reported here so this flush can finish.\",\n { cause: error },\n );\n } catch {\n // A host that made `console.error` itself throw does not get to turn a\n // contained effect failure back into one that escapes and kills the flush.\n }\n try {\n // Looked up per call, not captured at module load, so a host (or a test)\n // that installs its own `reportError` later is still honored — and read\n // inside the guard, because the property itself can be a throwing getter.\n const report = (globalThis as { reportError?: (error: unknown) => void }).reportError;\n if (typeof report === \"function\") report.call(globalThis, error);\n } catch {\n // Same rule for the second hook: a host whose `reportError` throws must not\n // break containment either. The error already went to the console above.\n }\n}\n\n/**\n * Wraps the cleanup an effect body returned so a throw out of it is contained\n * exactly like a throw out of the body.\n *\n * alien-signals stores the returned cleanup on the effect node and calls it\n * through its own private `runCleanup`, from two places this package cannot\n * intercept (verified against alien-signals@3.2.1):\n *\n * - `run()` calls it *before* re-running the body, i.e. inside `flush()`'s\n * `try`, so a throw there hits the very same queue-cancelling failure mode\n * `reportEffectError` exists for — worse, in fact, because it aborts the\n * flush before this effect's own body has even re-run.\n * - `effectOper()` calls it on disposal — both the disposer returned below and\n * the `unwatched` hook alien-signals fires when a nested effect loses its\n * last subscriber, which happens during `unlink`/`purgeDeps` and so can also\n * land in the middle of a flush.\n *\n * Neither call site is reachable from outside the module, so the guard goes on\n * the cleanup itself, on its way back to alien-signals: one wrapper covers\n * every path that can invoke it, now and later. The wrapper never rethrows, so\n * alien-signals always sees the cleanup return normally and its own\n * bookkeeping (`e.cleanup = undefined`, `activeSub` restore, the flags check\n * after `runCleanup`) stays on the success path.\n */\nfunction guardCleanup(cleanup: () => void): () => void {\n return () => {\n try {\n cleanup();\n } catch (error) {\n reportEffectError(error);\n }\n };\n}\n\n/** Runs a reactive side effect and returns a disposer. */\nexport function effect(fn: () => void | (() => void)): () => void {\n let disposed = false;\n let disposeEffect: (() => void) | undefined;\n disposeEffect = createEffect(() => {\n if (disposed) return;\n let cleanup: void | (() => void);\n try {\n cleanup = untrackedRender(fn);\n } catch (error) {\n reportEffectError(error);\n return;\n }\n if (cleanup === undefined) return;\n const guardedCleanup = guardCleanup(cleanup);\n if (disposed) {\n untracked(guardedCleanup);\n return;\n }\n return guardedCleanup;\n });\n return () => {\n if (disposed) return;\n disposed = true;\n disposeEffect?.();\n };\n}\n\n/** Groups writes, deferring effect notifications until the callback completes. */\nexport function batch<T>(fn: () => T): T {\n startBatch();\n try {\n return fn();\n } finally {\n endBatch();\n }\n}\n\n/** Runs a callback without collecting reactive dependencies. */\nexport function untracked<T>(fn: () => T): T {\n return untrackedRender(() => {\n const activeSub = getActiveSub();\n setActiveSub();\n try {\n return fn();\n } finally {\n setActiveSub(activeSub);\n }\n });\n}\n"],"mappings":"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"}
@@ -0,0 +1,24 @@
1
+ //#region src/core/base.d.ts
2
+ /** A readable reactive value. */
3
+ interface ReadonlySignal<T> {
4
+ readonly value: T;
5
+ peek(): T;
6
+ }
7
+ /** A readable and writable reactive value. */
8
+ interface Signal<T> extends ReadonlySignal<T> {
9
+ value: T;
10
+ }
11
+ /** Returns whether a value came from this package's signal APIs, any copy. */
12
+ declare function isSignal(value: unknown): value is ReadonlySignal<unknown>;
13
+ /** Creates a writable reactive value. */
14
+ declare function signal<T>(initialValue: T): Signal<T>;
15
+ /** Creates a lazily evaluated reactive value. */
16
+ declare function computed<T>(getter: () => T): ReadonlySignal<T>;
17
+ /** Runs a reactive side effect and returns a disposer. */
18
+ declare function effect(fn: () => void | (() => void)): () => void;
19
+ /** Groups writes, deferring effect notifications until the callback completes. */
20
+ declare function batch<T>(fn: () => T): T;
21
+ /** Runs a callback without collecting reactive dependencies. */
22
+ declare function untracked<T>(fn: () => T): T;
23
+ //#endregion
24
+ export { effect as a, untracked as c, computed as i, Signal as n, isSignal as o, batch as r, signal as s, ReadonlySignal as t };