A Vite/Rolldown plugin that removes JSX attributes (e.g. data-testid) from production builds. Parses with oxc and emits accurate sourcemaps via magic-string.
Covers the @swc/plugin-react-remove-properties use case after the move from @vitejs/plugin-react-swc to the oxc-based @vitejs/plugin-react in Vite 8, where no oxc equivalent of that SWC plugin exists. It is not a drop-in replacement: see Migrating from @swc/plugin-react-remove-properties.
yarn add -D oxc-remove-attributesor
npm i --save-dev oxc-remove-attributesIn vite.config.ts or vite.config.js:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { removeAttributes } from "oxc-remove-attributes";
export default defineConfig({
plugins: [removeAttributes(), react()],
});By default the plugin only runs during vite build, so data-testid attributes remain in dev and in vite build --mode test builds used by e2e suites.
List removeAttributes() before react(). Both are enforce: 'pre' plugins, and Vite keeps array order within an enforce bucket, so this guarantees the plugin sees raw JSX. It matters when React Compiler is enabled (see below) and is harmless otherwise.
@vitejs/plugin-react 6.1+ can run the React Compiler via react({ compiler: true }). That path hands the file to oxc-transform-react, which lowers JSX to jsx() calls as part of the same enforce: 'pre' transform. If react() comes first in the array, this plugin then receives code with no JSXAttribute nodes and removes nothing, leaving your test IDs in the production bundle.
Putting removeAttributes() first fixes it:
export default defineConfig({
plugins: [removeAttributes(), react({ compiler: true })],
});The plugin warns if it sees a file that was already lowered, so the failure is not silent. This combination is covered by the integration tests.
removeAttributes({
// Attribute names to remove (exact match, no regex).
attributes: ["data-testid"],
// File extensions to scan.
extensions: [".tsx", ".jsx"],
// When this plugin runs in the Vite pipeline: 'pre', 'normal' or 'post'.
// Vite-only, ignored by Rolldown.
enforce: "pre",
// Active in 'build', 'serve', or 'both', or a (config, env) => boolean.
// Vite-only, ignored by Rolldown.
apply: "build",
});enforce and apply are Vite plugin concepts. Rolldown has neither, so when you use this plugin with Rolldown directly it always runs.
Prefer the default enforce: 'pre'. By the time 'post' runs, JSX has usually been lowered to jsx(...) calls and there are no JSXAttribute nodes left to remove, so the plugin does nothing — it logs a warning if you select it.
removeAttributes({
attributes: ["data-testid", "data-cy", "data-test"],
});removeAttributes({ apply: "both" });The SWC plugin matches regex patterns against property names. This plugin matches exact names, so patterns have to be expanded into the list of names you actually use.
// Before: @vitejs/plugin-react-swc
react({
plugins: [["@swc/plugin-react-remove-properties", { properties: ["^data-test.*$"] }]],
});
// After: @vitejs/plugin-react (oxc)
defineConfig({
plugins: [
removeAttributes({
attributes: ["data-testid", "data-test", "data-test-id"],
}),
react(),
],
});Other differences worth knowing:
@swc/plugin-react-remove-properties |
oxc-remove-attributes |
|
|---|---|---|
| Matching | Regex patterns | Exact names |
| Default target | ^data-test$ (its own default) |
data-testid |
| Where it runs | Inside the SWC transform | A separate Vite/Rolldown plugin |
| Activation | Tied to the React plugin | apply / enforce, defaults to build only |
If you relied on a broad pattern such as ^data-test.*$ and can't enumerate the names, this plugin won't reproduce that behaviour today. Open an issue if you need pattern matching.
The plugin is a plain { name, transform } plugin, so it works with Rolldown directly:
import { rolldown } from "rolldown";
import { removeAttributes } from "oxc-remove-attributes";
const build = await rolldown({
input: "src/index.jsx",
plugins: [removeAttributes()],
});This path is covered by tests. Remember that apply and enforce do nothing here.
The plugin operates on JSX attributes statically. It will not strip targets that arrive at runtime via a spread, e.g.:
const props = { "data-testid": "x" };
<div {...props} />;If you rely on the bundle being completely free of test IDs, either avoid spreading them or pair this plugin with an oxlint / eslint rule that forbids data-testid in the value side of a spread source.
Namespaced attributes (xlink:href, xml:lang, etc.) and component-prop attributes whose names don't match exactly are also untouched by design.
The plugin matches attribute names exactly. A component that accepts a camelCase prop and forwards it to a DOM data-testid looks like this:
// Consumer
<Modal dataTestId="checkout-modal" />;
// Inside Modal.tsx
<div data-testid={dataTestId}>{children}</div>;With the default attributes: ['data-testid'], the DOM data-testid={dataTestId} is stripped (production DOM is clean), but the dataTestId prop is not — "checkout-modal" survives in the bundle as a JSX prop value even though it's never written to the DOM.
If you want to strip prop pass-through too, include both names:
removeAttributes({ attributes: ["data-testid", "dataTestId"] });ISC