npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

unplugin-react-fine-grained-signals

v0.2.1

Published

Universal useSignalTracking transform for react-fine-grained-signals.

Readme

unplugin-react-fine-grained-signals

English | 日本語

Universal bundler integration for automatic useSignalTracking() insertion and the optional managed render-scope transform in react-fine-grained-signals.

This package is deliberately the only build-time integration. It keeps the Babel implementation private, so application configuration is the same across supported bundlers.

Installation

pnpm add -D unplugin-react-fine-grained-signals

react-fine-grained-signals is a peer dependency. Node.js ^22.18.0 || >=24.11.0 is required — the range Babel 8, which the transform runs on, declares.

Use the plugin and the runtime from the same release line (v0.2 with v0.2): the generated code imports runtime entry points that change between lines. When upgrading from v0.1, upgrade both packages together and rename the @useSignals / @noUseSignals comments — see the v0.2 migration guide.

This package ships ESM only; there is no CommonJS build. An ESM build configuration that uses import is the primary setup, but a CommonJS configuration file (a webpack.config.js or next.config.js in a project without "type": "module") can still load it with require(): every Node.js version in the range above loads the ESM entry points through its built-in require(esm) support. The plugin factory is the default export:

const signals = require("unplugin-react-fine-grained-signals/webpack").default;

Vite

import { defineConfig } from "vite";
import signals from "unplugin-react-fine-grained-signals/vite";

export default defineConfig({
  plugins: [signals({ mode: "auto" })],
});

With React Compiler

React Compiler must run after this transform: it would otherwise memoize away the .value reads before this plugin could mark the component "use no memo", and the component would silently stop updating after its first render. List signals() before the plugin that runs the compiler:

import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import signals from "unplugin-react-fine-grained-signals/vite";

export default defineConfig({
  plugins: [signals({ mode: "auto" }), react({ compiler: true })],
});

In the setup this was tested with (Vite 8, @vitejs/plugin-react 6.1), react({ compiler: true }) also needs plugin-react's optional oxc-transform-react package. Install it next to babel-plugin-react-compiler; without it the build stops with plugin-react's own "requires the optional oxc-transform-react package" error.

Under Vite the order is also defended at the hook level. The plugin is enforce: "pre", and its transform hook additionally declares order: "pre", which Vite sorts on within the pre-enforced group. That makes it run ahead of @vitejs/plugin-react 6's compiler step (react({ compiler: true }), itself enforce: "pre" with no hook order) even when react() is listed first — before this, that order compiled the component first. This is Vite's ordering, not an absolute guarantee: a plugin whose transform also declares order: "pre" and is listed earlier still runs first. Keeping signals() first is therefore still the recommended configuration, and it is required wherever hook ordering does not apply: Rollup orders by array position alone, and the Babel route (@rolldown/plugin-babel with reactCompilerPreset()) has not been verified against this plugin.

Two known limitations apply with React Compiler. Both were present in v0.1.1.

  • Inline render helpers can be memoized by the compiler. A function this plugin treats as part of its caller's render gets no boundary and no "use no memo" of its own. Examples are a PascalCase helper called as a plain function (Title()) and a render callback such as items.map(Row). React Compiler can still compile that helper by itself. If the helper's JSX has no dependency the compiler can see (a module-level signal read is not one), the compiler caches the JSX, and the helper's output stays stale even though the calling component re-renders. Put "use no memo" in the helper's body, or render it as a component (<Title />) instead.

  • An author-written "use memo" is kept. The plugin does not add "use no memo" to a function that already states a memoization directive. Tested with babel-plugin-react-compiler 1.0:

    • With the managed transform, the compiler does not compile the generated try / finally. With the default panicThreshold it skips the function. With panicThreshold: "all_errors" the build fails ("Handle TryStatement without a catch clause").
    • With transform: "inject", the compiler compiles the function and can memoize away its signal reads, so the component can stop updating.

    Do not use "use memo" in functions that read signals.

Other bundlers

Use the matching entry point in the same way:

| Bundler | Entry point | | --- | --- | | Rollup | unplugin-react-fine-grained-signals/rollup | | Webpack | unplugin-react-fine-grained-signals/webpack | | Rspack | unplugin-react-fine-grained-signals/rspack | | esbuild | unplugin-react-fine-grained-signals/esbuild |

import signals from "unplugin-react-fine-grained-signals/webpack";

export default {
  plugins: [signals({ mode: "auto" })],
};

Options

  • mode:
    • "manual": only an explicit first-statement imported useSignalTracking() call or a @signalTracking comment on a named component/custom hook opts it in.
    • "auto" (default): additionally transforms named JSX components and named useX custom hooks that read .value.
    • "all": additionally transforms every named JSX component. Nested callbacks are collected by their owning component but are never transform targets themselves.
  • transform:
    • "managed" (default and recommended): adds an exact try / finally boundary, importing from the package's /runtime entry and closing the render-tracking window synchronously at the point the component function returns.
    • "inject" (supported advanced/compatibility mode): adds bare useSignalTracking() for best-effort opt-in. It inserts a normal useSignalTracking() call without rewriting control flow, so it has the same best-effort tracking boundary as a handwritten call — see the boundary design investigation for the known sibling-misattribution limitation this mode can expose.
  • reactCompiler:
    • "auto" (default): marks every transformed function with "use no memo", so React Compiler does not memoize away the signal reads render tracking depends on. Without it, a compiled component caches its JSX and silently stops updating after the first signal write.
    • "off": omits the directive. Choose it only when React Compiler is not in the build, or when the affected components were verified against the compatibility note.
  • importSource: overrides react-fine-grained-signals for a compatible wrapper. The wrapper must re-export the runtime's root useSignalTracking and its /runtime entry's useManagedSignals, which the generated code imports.
  • reactImportSource: an additional module specifier whose memo and forwardRef exports count as React's own when the plugin decides whether a wrapped function is a component. Recognition is additive, not a replacement: a direct import from "react" is always recognized, so setting this only widens detection and never turns off an existing direct import.
  • include / exclude: functions that filter source module IDs.

Render callback detection

Automatic detection never transforms a render callback — a function handed to one of the array iteration methods map, flatMap, or forEach — because it runs a variable number of times inside one render of its owner, which the Rules of Hooks forbid. Recognition covers the inline definition site (items.map((item) => …)), a callback factored out and referenced by its own binding, whether that binding is a const (const Row = …; items.map(Row)) or a function declaration (function Row() {…} … items.map(Row)), and the optional-chained form of either (items?.map(Row)). The mapFn of Array.from(arrayLike, mapFn) (Array.from({ length: 5 }, Star)) counts the same way, and so does a component held in an object that is destructured before being passed (const { Row } = parts; items.map(Row), or { Row: Item }). Such a callback is left alone, and its JSX and .value reads are collected by the component that invokes it — including when the callback is defined elsewhere in the same module, so that component is transformed even when its own body reads no signal.

The method name is the whole signal here, because a build-time transform cannot know the object's runtime type. The recognized set is deliberately minimal: map and flatMap build an element per item and forEach pushes elements into an accumulator, which are the calls whose callbacks are routinely factored out under a component-shaped name. Predicate and accumulator methods (filter, reduce, find, some, every) are excluded on purpose — their callbacks are lowercase helpers that are never transform targets anyway, so including them would only widen the chance of matching an unrelated method of the same name, and that direction of error is the expensive one: it silently denies a real component its subscription. A reference passed to any other call is therefore ordinary component registration and stays eligible — memo(Row) and forwardRef(Row) as before, and equally third-party wrappers such as observer(Row) or connect(…)(Row), where React instantiates the returned component as its own fiber with its own hooks. The same registration works written inline too, as long as the function keeps its own PascalCase name: observer(function Row() { … }) is recognized exactly as observer(Row) is. An anonymous or arrow function passed the same way — observer((props) => …) — carries no name for a boundary to attach to, and is left untransformed.

This detection has five known limitations:

  • A re-assigned alias is not followed, so a PascalCase helper reached through const RowAlias = Row; items.map(RowAlias) is still treated as a component.

  • A render callback passed as a JSX prop value is not recognized. <Grid renderItem={Row} /> is syntactically identical whether Grid calls renderItem(item) a variable number of times per render (a render prop, where hook injection is unsafe) or instantiates Row as its own component (where hook injection is what makes it update), and nothing in the file says which — so Row keeps its own hook whenever it is independently eligible. State the intent explicitly on the referenced function with a @signalTracking / @noSignalTracking comment.

    Writing the callback inline (<Grid renderItem={(item) => <li>{item.value}</li>} />) is not a general fix. Its reads are attributed to the owning component, so the owner is transformed, but Grid calls renderItem during its own render, after the owner's function has returned. With the default managed transform the owner's boundary is already closed by then, so the reads are tracked only if Grid has a boundary of its own; otherwise the rendered items stay stale. If Grid is your own component, opt it in with @signalTracking (or have it read the signal itself).

  • A callback imported from another module is not followed, because the transform sees one file at a time.

  • A callback reached through an object key (items.map(parts.Row), or const { Row } = parts first) is kept off a boundary, but in auto mode its .value reads are not credited to the caller. A caller that reads no signal itself is then left untransformed and does not update; read the signal in the caller, use mode: "all", or add @signalTracking to the caller.

  • A function used in both roles keeps the exclusion. If Row is passed to map / flatMap / forEach anywhere in the module and is also rendered independently as a JSX tag (<Row item={x} />), the render-callback usage wins: Row gets no hook of its own. That is the crash-safe direction — a hook there would break hook order in the callback usage — but the independently rendered instance then has no subscription and goes stale on later signal writes. Split the two roles into two differently named functions, one per role, or opt the independently rendered function in explicitly with a @signalTracking comment.

When in doubt, keep such helpers explicit: name them lowercase and without a use prefix, or opt them in manually only when they are genuinely rendered as components.

Components called as plain functions

A PascalCase function that the module calls as a plain function — Title(), Title?.(), Title!(), or, for one held in an object, ns.Home(), ns?.group?.Home(), or this.Home() from a sibling member — is never mounted by React as a component of its own: its body simply runs inside the caller's render. It therefore never gets a boundary of its own, in any mode including all, and its JSX and .value reads are collected by the component or custom hook that makes the call:

function Title() { return <h1>{count.value}</h1>; }
// Outer is transformed and subscribes to `count`; Title is left as it is.
function Outer({ show }) { return <div>{show && Title()}</div>; }

A boundary inside Title would add hooks to Outer that run only while show is true — a conditional hook call that React rejects ("Rendered more hooks than during the previous render"). A call made from an event handler, from the callback of useEffect / useLayoutEffect / useInsertionEffect / useCallback / useMemo, or from an async function runs outside the render, so its reads are not collected. Custom hooks (useX) are called as functions by design and keep their own boundary exactly as before.

Known limitations:

  • The decision is module-wide: a single plain call anywhere in the module takes away the function's own boundary. If it is also rendered as <Title />, that instance has no subscription of its own and only refreshes when its parent re-renders; the plugin prints a warning for this mix. Use one form consistently.
  • Only calls through the function's own binding, or through the exact object key path it is defined at, are recognized, and only within the module. Any link of that path may be optional (ns?.group.Home()). Inside a member of the same object literal, this counts as that path: this.Home() in a method, an accessor, a function property value, or an arrow nested in one of those. A nested function or class has a this of its own and is not followed. An alias (const T = Title; T()), Title.call(…), and calls from other modules are not recognized.
  • A function held in an object can also be reached through a computed key: renderers[node.type](node), renderers[key]?.(arg), items.map(renderers[key]), or this[key](arg) from a sibling member. Every slot that such an access can reach (the same depth, under the same static prefix) gets no boundary of its own, because a boundary there would run a variable number of times inside the caller. Its reads are not collected by the caller, because the key is only known at runtime. So a caller that reads no signal itself is not transformed and does not update on the slot's signals. Read the signal in the caller, or add @signalTracking to the caller. This also applies when the module renders the same slot as <renderers.User />.
  • The reads are collected only when the caller is itself a component or a useX hook. A call routed through a lowercase helper (function renderTitle() { return Title(); }) is not followed, so nothing subscribes to those reads; call the function directly from the component, or render it as <Title />.
  • A keyed function (ns.Home()) is collected only when this module defines it in an object literal bound to the root, or with exactly one member assignment (Card.Header = …). A sibling's this.Home() is collected from the object literal that member belongs to.
  • An object member the transform cannot see being called keeps its own boundary. That covers a member reached through a parameter (function render(set, key) { return set[key](); }), through an alias (const R = renderers[key]; R(arg)), through computed destructuring (const { [key]: R } = renderers), from an object inside an array (columns.map((c) => c.Cell(row))), or from another module. Calling such a member inline is then a hook call inside the caller, which crashes as soon as the number of calls changes. Put @noSignalTracking on the member: it then has no boundary of its own, and a transformed caller tracks its reads because they run during the caller's render. Rendering that member as an element (<icons.Check />) then gives the element no subscription of its own.

Higher-order components

A component a higher-order component returns is recognized even though it has no name of its own:

export const withCount = (Base) => (props) => <Base {...props} count={count.value} />;

The inner function is the component here, and it inherits its identity from the factory's own binding — withCount — so auto mode subscribes it and a @signalTracking (or @noSignalTracking) comment on either the returned function or the factory's declaration applies to it. Three conditions keep ordinary closures out: the function must be returned directly by the enclosing function (an explicit return, or an arrow's concise body); the returned function must render JSX itself; and the enclosing function's name must not be a hook name (useX) — or, if it is a component name (PascalCase), the enclosing function must itself qualify as a higher-order component factory (described next). A function returned by a component that fails that test, or by a hook, is instead a render prop that runs inside that owner's render, not a component of its own.

A PascalCase-named factory qualifies when it takes what looks like a component as its own parameter — a plain (not destructured) identifier parameter with a PascalCase name — and renders no JSX itself:

export const WithCount = (Base) => (props) => <Base {...props} count={count.value} />;

WithCount is never transformed itself; the component it returns inherits WithCount's name exactly as the camelCase form above does. A useX-named factory gets no equivalent exception for the closure it returns, deliberately: React calls a hook from inside a render already in progress, so anything the hook hands back may be invoked during that very render, and nothing here can rule that out. The hook itself is unaffected by this and is still transformed normally whenever it qualifies — reading a signal, rendering JSX — exactly like any other hook; only the returned closure inherits no name from it.

The name is inherited from exactly one level out, so a factory returning a factory ((a) => (b) => (props) => …) resolves to nothing, and a @signalTracking comment there reports that rather than silently doing nothing. The one shape this cannot tell apart is a factory whose result is handed straight to an iteration method (items.map(makeRow(prefix))): that is syntactically a HOC returning a component, so it gets a boundary of its own. Pass such a callback by reference (items.map(Row)) or write it inline, so the owning component collects it.

A component can also carry no binding of its own and still be named: one held as an object property — Card.Header = () => <p>{count.value}</p> — or written as an object-literal method (export const ns = { Home() { return <p>{count.value}</p>; } }) is named by its key, the same way <Card.Header /> or <ns.Row /> reaches it (a lowercase key stays excluded, exactly as a lowercase binding does; getters, setters, async and generator methods are excluded, and so are class methods, which only an instance reaches), and a nameless default export (export default (props) => <p>{count.value}</p>) is named after the module's own file — the identity an import App from "./App" already gives it. Functions held by a class are deliberately excluded, though: a class field (class Holder { Row = () => … }, instance or static) and a this.Row = … assignment are never transformed, and a @signalTracking comment on one is reported and ignored. They are reached through this.Row or Holder.Row, which the transform cannot follow to tell a component from a function that render() calls directly (this.Header()) or passes per item (items.map(this.Row)) — and a hook boundary in either of those runs inside the class component's render, which React rejects. If such a function is only ever rendered as an element (<this.Row />), it may call useSignalTracking() itself; otherwise read signals in a function component instead.

memo / forwardRef recognition

Automatic memo / forwardRef recognition matches only a direct import from "react" or from reactImportSource. Importing them through a local barrel or re-export module (import { memo } from "./some-local-module") is not resolved automatically, even when that module ultimately re-exports from "react": the transform sees one file at a time and does not follow re-export chains. An unrecognized wrapper is not an error — the component is silently skipped, so signal writes stop re-rendering it. Three workarounds:

  • set reactImportSource to that module's specifier, if the codebase imports through one stable module path (a bare specifier such as "@app/react" works everywhere; a relative path only matches files that spell it the same way);
  • import memo / forwardRef directly from "react" at the affected call sites;
  • opt the component in explicitly with a @signalTracking comment or a manual useSignalTracking() call.

useSignalTracking() opt-in and rewriting

@signalTracking and @noSignalTracking apply only to their owning function; they do not affect nested functions. The plugin never adds a second useSignalTracking() call: a direct, namespace, or barrel-imported call it finds is treated as the function's existing opt-in. A call that is not the function's first statement, and any barrel-imported call, is left alone in both transform modes. A first-statement call imported directly or as a namespace from importSource is left in place under "inject", but under "managed" (the default) it is absorbed into the generated boundary: the statement is removed and replaced by the managed store declaration plus the try / finally scope, so the function body is rewritten rather than left byte-for-byte untouched.

A first-statement useSignalTracking() call inside an async or generator function fails the build under the default "managed" transform, with useSignalTracking transform only supports synchronous, non-generator functions. That combination is already invalid React — hooks require a synchronous function component — so prefer fixing the function; transform: "inject" accepts it without rewriting the function, if the file must keep building unchanged.

Parameter defaults are evaluated before the function body runs, so a signal read in one — function Label({ v = count.value }) — would happen before the boundary opens and never be tracked. When a transformed function has such a parameter, that parameter and every parameter after it (to keep their evaluation order) are replaced by generated ones and bound again as the first statements inside the boundary, with the same undefined-only default rule. The parameter count is unchanged, so forwardRef's (props, ref) shape is kept. If the body declares a name a moved default refers to, or re-declares a moved parameter, the move is skipped and that read stays untracked; read the signal in the body instead.

The move keeps the default rule and the evaluation order. In TypeScript the generated parameter keeps the original's type and optionality, so the output stays valid TypeScript. The function's length can change, though. A parameter with a default does not count toward length, but the generated parameter that replaces it does. For example, function useLabel(prefix?: string, value = count.value) has a length of 1 in the source and 2 after the transform. Do not rely on the length of a transformed function.

Build integration

Reapplying either transform mode is a no-op. The plugin is registered with enforce: "pre" on bundlers that support it (Vite, webpack, Rspack), and under Vite its transform hook also declares order: "pre" (see With React Compiler). Rollup has neither concept, so list this plugin first there. Wherever the order of plugins or loaders is in your hands, put this plugin before anything that runs React Compiler or compiles JSX away. The transform skips dependencies and non-JavaScript/TypeScript modules, and never touches .cjs / .cts modules. The plugin matches extensions case-insensitively, so it transforms App.TSX exactly like App.tsx. Whether the bundler then compiles such a file the same way is up to the bundler, and is not guaranteed: Vite 8 does not compile .JSX / .TSX as JSX / TSX and fails to parse them, with or without this plugin. Plain .ts files are parsed as TypeScript without JSX, while .tsx, .jsx, and JavaScript files may use JSX.

Fast Refresh

This limitation predates v0.2.1. The managed transform places the component body, including authored Hook calls, inside a generated try/finally so tracking cleanup runs on return, throw, and Suspense. React's Rules of Hooks prohibit calling Hooks inside try, catch, or finally, so this generated structure is outside that documented rule.

In the audited Vite 8 / Oxc Fast Refresh setup, the Refresh transform registers the component but omits its Hook signature for this structure. Adding, removing, or reordering Hooks can therefore preserve stale state and fail with a Hook-order error until a full reload. A plain React control with Hooks in try/finally reproduces the Oxc signature behavior, but that control also violates the Hook rule. This is an interaction between Oxc's signature analysis and a structure that managed mode itself generates; it is not a v0.2.1 regression.

transform: "inject" avoids the generated boundary and passed the audited fixture. It inserts a bare useSignalTracking() Hook, providing the same best-effort boundary as a hand-written Hook; it does not provide managed mode's exact synchronous try/finally tracking contract. It is an alternative, not a contract-equivalent replacement for managed mode.

React Server Components

The transform does not tell server modules from client modules: it reads neither "use client" / "use server" directives nor the bundler's environment or layer. A synchronous server component that reads .value would therefore receive the client-only render-tracking hook and fail when it renders on the server (async server components are never transformed, because async functions never are). In an RSC setup, keep server components out of the transform with exclude (for example by directory), opt individual ones out with a @noSignalTracking comment, or use mode: "manual".

License

MIT © akazawa. See LICENSE.