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

rolldown-plugin-react-forward-ref

v0.2.1

Published

Transform React ref props into forwardRef components for React 18 with Rolldown and Vite.

Readme

rolldown-plugin-react-forward-ref

Run selected React 19-style function components on React 18. The plugin adds forwardRef wrappers at module initialization and passes the received ref to the original function as a prop. It supports indirect forwarding:

export function Button(props: ButtonProps) {
  const [variantProps, rest] = splitProps(props, button);
  return <BaseButton {...button.jsx(variantProps)} {...rest} />;
}

The ref reaches splitProps before the function reads props. There is no extra React element around the original function, and wrappers keep their identity across renders. This package adapts ref transport only. It does not polyfill other React 19 features.

Install it as a development dependency:

pnpm add -D rolldown-plugin-react-forward-ref

API and file selection

import reactForwardRef from "rolldown-plugin-react-forward-ref";

reactForwardRef({
  include: ["src/components/**/*.tsx", "src/examples/**/*.tsx"],
  exclude: ["**/*.test.tsx", "**/legacy/**"],
});

include is required and must be a non-empty glob, regular expression, or array of those patterns. exclude accepts the same forms and takes priority. Globs use @rollup/pluginutils and resolve relative to process.cwd(). Paths use forward slashes on all platforms. There are no project-specific path defaults.

Only .js, .jsx, .ts, .tsx, .mjs, .mts, .cjs, .cts, are eligible. Declaration files, virtual modules, node_modules, and Vite ?raw/?url requests are always excluded. JSX in JavaScript files is parsed; the host must also be configured to lower JSX for the selected extension. Other query suffixes are removed before matching. Input must be an ES module; the plugin does not convert CommonJS module exports.

There is one default export for Rolldown, Vite, and Vitest. The plugin uses a transform hook, with order: "pre" and Vite's enforce: "pre". It runs before TypeScript/JSX lowering, in both development and builds. Put it before other plugins with pre-transform hooks, including @vitejs/plugin-react. Already lowered JSX cannot be detected.

Version selection belongs to the consumer configuration. The plugin does not read a React version or enable itself automatically.

Rolldown

import { defineConfig } from "rolldown";
import reactForwardRef from "rolldown-plugin-react-forward-ref";

export default defineConfig({
  input: "src/index.tsx",
  plugins: [reactForwardRef({ include: "src/components/**/*.tsx" })],
  external: /^react(?:\/|$)/,
  output: { dir: "dist", format: "esm", sourcemap: true },
});

Vite

import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import reactForwardRef from "rolldown-plugin-react-forward-ref";

export default defineConfig({
  plugins: [reactForwardRef({ include: "src/components/**/*.tsx" }), react()],
});

React 18-only Vitest configuration

Read the version from the test workspace's dependency graph. An isolated React 18 workspace must install matching React and React DOM versions.

import react from "@vitejs/plugin-react";
import { version } from "react";
import { defineConfig } from "vitest/config";
import reactForwardRef from "rolldown-plugin-react-forward-ref";

export default defineConfig({
  plugins: [
    version.startsWith("18.") &&
      reactForwardRef({
        include: ["src/ui/**/*.tsx", "src/examples/**/*.tsx"],
      }),
    react(),
  ],
  test: { environment: "jsdom" },
});

For projects that alias React, use an explicit target flag instead if the configuration's react import does not resolve to the test runtime. Disable the plugin for React 19 to retain its native behavior. The test suite also checks adapted output on React 19, with the no-ref semantics below.

Component selection

Within each selected file, the plugin selects top-level function declarations and const function initializers with an ASCII uppercase first letter and JSX in their own function body. A default-exported function can have any name or be anonymous, except names with the hook prefix use followed by an uppercase letter or digit. JSX in nested functions or classes does not count. A visible ref read is not required. Hooks and ordinary lowercase utilities stay unchanged.

This is a syntax rule, not proof that a function is a React component. Limit the file filters to component source. A PascalCase utility with JSX that otherwise meets the rule is indistinguishable from a component. Functions used as direct calls, constructors, .call/.apply/.bind targets, or arguments to opaque functions are conservatively left unchanged. This also protects render functions passed to existing forwardRef calls or custom HOCs. Local identifier aliases are followed; this conservative check can also skip a same-named binding in another scope.

| Source form | Behavior | | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | | function Button(props) { return <input {...props} /> } | Wrap the hoisted binding before module statements run. | | Named exports, export { Button as Input }, and export default Button | Keep the export names and shared component identity. | | export default function Button(props) { ... } | Keep its local binding and live default export. | | Anonymous default function or arrow | Wrap the expression once, with display name default. | | const Button = props => <input {...props} /> | Wrap the initializer once. | | Anonymous or named function expression | Wrap it unless its private function name is referenced in its body. | | Destructured, defaulted, optional, or generic props | Call the original function with the merged props, before parameter initialization. | | const Alias = Button | Share the wrapper. Aliases do not select lowercase or imported functions. | | memo(Button) | Keep memo outside the selected component wrapper. | | const Button = memo(props => <input {...props} />) | Wrap the inner function, preserving the comparator argument. | | forwardRef(...) | Leave it and its render input unchanged. | | Opaque HOCs and component factories | Leave inline/returned functions and their identifier inputs unchanged. | | Class, nested, async, generator, let/var functions | Leave unchanged. | | Functions with multiple parameters, rest parameters, or array destructuring | Leave unchanged. | | Components without JSX, including createElement-only bodies | Leave unchanged. |

memo, forwardRef, and createElement are recognized through React named, default, or namespace imports and module-level method aliases such as const cache = React.memo. Destructured aliases and namespace aliases are not resolved. Literal computed access, such as React["memo"], is supported. Dynamic API lookups, re-exports from other modules, and component registries are not resolved. Imported components must be adapted in their own source module. React.createElement references to a selected local component are supported.

Trusted element factories

Use elementFactories when a helper creates, clones, returns, or omits React elements without calling the supplied component directly. The option permits ref adaptation for that helper's component argument. It does not select files or components that would otherwise be ineligible.

reactForwardRef({
  include: "packages/ariakit-ui/src/components/**/*.react.tsx",
  elementFactories: [
    {
      source: "../react-utils/create-render.react.ts",
      imported: "createRender",
    },
    {
      source: "../react-utils/create-render.react.ts",
      imported: "createOptionalRender",
    },
  ],
});

Each import descriptor has these fields:

| Field | Meaning | | --------------- | --------------------------------------------------------------------------------------------- | | source | Exact import source string in the selected file. No path or package resolution is performed. | | imported | Named export, or "default" for a default import. Local import renames are supported. | | argumentIndex | Zero-based component argument position. Defaults to 0; must be a non-negative safe integer. |

For example, { source: "./render", imported: "render", argumentIndex: 1 } matches import { render as make } from "./render"; make(props, Button). Type-only imports, namespace members, and further local aliases are not matched. If another declaration or parameter uses the import's local name anywhere in the file, import-based trust for that name is disabled throughout the file. This conservative rule prevents a shadowed helper from receiving a wrapper.

For a helper defined in the same file, or an intentional match by local name, use a string:

reactForwardRef({
  include: "src/**/*.tsx",
  elementFactories: ["createRender", "createOptionalRender"],
});

Strings trust argument 0 of every identifier call with that name in selected files, including imports from other sources and shadowed bindings. Use import descriptors when these names can refer to unrelated helpers. This string form is compatible with the temporary pnpm patch in Ariakit PR #7498.

Only the configured argument of an ordinary or optional call is trusted. Constructors, .call/.apply/.bind, other arguments, and arguments after a spread remain subject to the opaque-call check. A direct call or an unknown helper elsewhere still prevents wrapping the component. The plugin does not inspect a configured helper's implementation; only opt in helpers that accept a React component type without calling it as a function.

Runtime, types, and source preservation

The implementation uses Oxc's TypeScript/JSX AST and MagicString source edits. The component body, parameter syntax, directives, and comments remain in the source map. Generated names avoid all identifiers in the module, including nested bindings and references. A fresh named import from react avoids a shadowed React value and works without an existing React value import.

A small generated helper calls the original render function directly. It sets displayName to the component binding name. Later writes such as Button.displayName = "Custom" and Button.variant = "primary" target the wrapper. Existing aliases, early module references, and recursive JSX in function declarations refer to that same wrapper. No wrapper is created during a render.

Function declarations keep their hoisting. Their binding is reassigned in a module prelude. This can make bundlers retain unused function declarations. const wrappers have pure annotations and can be removed when unused. TypeScript output uses a typed assignment and a generic identity signature to retain the original component's props and generic call signature. This is a runtime adapter, not a declaration migration to React 18 types. Keep source typechecking separate from the React 18 compatibility test run.

The runtime value is a React forwardRef object. Do not rely on its JavaScript .name, .length, prototype, or callability. Runtime-generated calls that the syntax checks cannot see remain unsupported. Cyclic imports that observe a component before its module evaluates can still see the original hoisted function. The transform does not guarantee identity during such partial module initialization. Fast Refresh state retention across source edits is not a contract; ordinary rerenders retain identity.

The generated marker comment makes a second transform pass a no-op. Keep the marker on transformed source. After JSX lowering, the component detection rule also prevents another pass from adding wrappers.

No-ref semantics

A non-null object or callback ref is added as an own enumerable ref prop using { ...props, ref }. Other props are not mutated. Ref replacement and unmount cleanup remain React's responsibility.

React 18 supplies null to forwardRef for an absent ref, ref={undefined}, and ref={null}. The transform passes props through unchanged when this callback argument is nullish. It does not insert ref: null in this case. Thus, on React 18 all three cases become an absent prop. Destructuring defaults such as { ref = fallback } run in all three cases. The original distinction between explicit null and an absent/undefined ref cannot be recovered on React 18. Adapted React 19 output uses the same normalization; disabled React 19 output keeps native ref-prop semantics.

Callback refs that return cleanup functions are a separate React 19 feature and are not polyfilled. Use callbacks that return nothing for React 18 compatibility.

Dependencies and supported versions

  • Node: ^24.18.0 || >=26.0.0; development and CI use Node 24.20.0.
  • Rolldown: 1.2.7 or later 1.x.
  • Vite: 8.2.2 or later 8.x, including Vitest 5's transform pipeline.
  • React/React DOM: 18.3.1 for the compatibility target. Tests also cover 19.3.0.

The plugin's runtime dependencies are external: oxc-parser, magic-string, and @rollup/pluginutils. React is only a development dependency of this repository. The published plugin neither installs nor bundles React. Generated code imports react from the consumer's dependency graph. Configure React as external when building a reusable component library.

Development

pnpm install --frozen-lockfile
pnpm run check

The checks include syntax fixtures, separate React runtime graphs, real Rolldown and Vite transforms, source maps, TypeScript output, and a tarball installed in a separate consumer. See Contributing.md.

License

MIT.