Custom JavaScript plugins

npm install oxc-tsrx@latest puts an oxlint command on your PATH, and that command already lints .tsrx. So this page starts with the linter you have, not with a second one you would have to install.

You write one ordinary Oxlint JavaScript plugin, list it in .oxlintrc.json, and it runs on both halves of your project: on .js, .ts, .jsx, and .tsx directly, and on .tsrx through the TSX projection, with every diagnostic reported at the line and column of the file you wrote. It runs in your editor too, at the same positions. There is no second linter and no separate plugin format.

The .tsrx half costs one extra parse per file, and oxlint says so on stderr every time it does it. Turning the extra parse off covers when you might want that. If your rule needs to see TSRX control syntax as its own node types rather than as the JavaScript it compiles to, read what your rule sees on .tsrx before you write it.

Two ways to read this page. The sections below build the idea up from an empty directory, one command at a time, and they are the shortest route to understanding what runs where. If you already have a Vite+ app from vp create, or you want the complete configured project rather than the explanation, skip to the whole path on a fresh Vite+ project, which is the same route with every scaffold-specific step filled in.

Set up the project#

You need Node.js 20.19 or newer, and one install:

npm install oxc-tsrx@latest

Save this as src/TaskList.tsrx:

type Task = { id: string; label: string; done: boolean };

export function TaskList({ tasks, ready }: { tasks: Task[]; ready: boolean }) @{
  debugger;

  @if (ready) {
    <ul class="tasks">
      @for (const task of tasks) {
        <li>{task.label}</li>;
      }
    </ul>;
  } @else {
    <p>Loading tasks</p>;
  }
}

The @for block has no key, on purpose, and the debugger statement is there on purpose too.

Run the linter you already have#

See it run
# The oxlint that oxc-tsrx installs already reads .tsrx
npx oxlint src/TaskList.tsrx
src/TaskList.tsrx:4:3: warning eslint(no-debugger): `debugger` statement is not allowed
Found 0 error(s) and 1 warning(s).
Real output, captured at build time. The whole sample project is one src/TaskList.tsrx and an install of oxc-tsrx. There is no configuration file yet.

No config file, no second linter, no build step. oxlint read your authored .tsrx and reported a built-in OXC rule against the line you wrote. Those built-in rules are Rust, so you cannot add one yourself. What you can add is a JavaScript plugin, and the rest of this page is about where that plugin runs.

What are the nodes called?#

A lint rule is a set of callbacks named after node types, so the first question is always the same: what are the nodes called? Answer it by parsing the file and printing what comes back.

Save this as explore-tsrx-ast.mjs:

import { readFileSync } from "node:fs";
import { parseSync } from "oxc-tsrx/parser";

const file = "src/TaskList.tsrx";
const result = parseSync(file, readFileSync(file, "utf8"));

function* walk(node) {
  if (Array.isArray(node)) {
    for (const item of node) yield* walk(item);
  } else if (node && typeof node === "object") {
    if (typeof node.type === "string") yield node;
    for (const value of Object.values(node)) yield* walk(value);
  }
}

for (const node of walk(result.program)) {
  if (node.type.startsWith("JSX") && node.type.endsWith("Expression")) {
    console.log(node.type);
  }
}
See it run
# Print the node type of every TSRX control block in the file
node explore-tsrx-ast.mjs
JSXIfExpression
JSXForExpression
Real output, captured at build time, from the explore-tsrx-ast.mjs script above run against src/TaskList.tsrx.

There are your two node names. Every TSRX control block gets its own node type, shaped like the JSX nodes you already know:

You write The node you visit
@if / @else JSXIfExpression
@for / @empty JSXForExpression
@switch / @case / @default JSXSwitchExpression
@try / @pending / @catch JSXTryExpression
@{ } statement containers JSXCodeBlock

Everything else, including elements, attributes, statements, and TypeScript types, uses the same shapes as oxc-parser. The Parsing guide covers the tree in more depth.

Write an oxlint JavaScript plugin#

Your plugin runs on the ordinary half of the project, so give the project an ordinary half: a plain React component. Save this as src/TaskRow.tsx:

type Task = { id: string; label: string; done: boolean };

export function TaskRow({ task }: { task: Task }) {
  return <li className={task.done ? "done" : ""}>{task.label}</li>;
}

export function TaskRows({ tasks }: { tasks: Task[] }) {
  return (
    <ul className="tasks">
      {tasks.map((task) => (
        <TaskRow task={task} />
      ))}
    </ul>
  );
}

Its .map() call has the same missing-key problem as the @for block in the .tsrx file. One rule idea, two file types: that is the whole tour.

An Oxlint plugin is an ES module whose default export is { meta, rules }. Every rule gets a create(context) that returns a visitor object keyed by node type. If you have written an ESLint rule before, this will look familiar.

Save this as oxlint-demo-plugin.mjs:

// An Oxlint JavaScript plugin. The default export is `{ meta, rules }`, and
// each rule's `create(context)` returns a visitor keyed by node type. Oxlint
// runs this on ordinary .js/.ts/.jsx/.tsx files.

function hasKeyProp(element) {
  return element.openingElement.attributes.some(
    (attribute) => attribute.type === "JSXAttribute" && attribute.name.name === "key",
  );
}

const requireKeyedMap = {
  meta: {
    type: "problem",
    docs: { description: "Require a key prop on JSX returned straight from .map()" },
    messages: { missing: "JSX returned from .map() should declare a `key` prop." },
    schema: [],
  },
  create(context) {
    return {
      CallExpression(node) {
        if (node.callee.type !== "MemberExpression") return;
        if (node.callee.property.name !== "map") return;
        const returned = node.arguments[0]?.body;
        if (returned?.type !== "JSXElement" || hasKeyProp(returned)) return;
        context.report({ node: returned, messageId: "missing" });
      },
    };
  },
};

export default {
  meta: { name: "tsrx-demo", version: "0.1.0" },
  rules: { "require-keyed-map": requireKeyedMap },
};

Reading it top to bottom:

  • create(context) returns an object whose keys are node type names. Oxlint calls CallExpression(node) for every call expression it walks past.
  • context.report({ node, messageId }) is how you raise a problem. Passing node is what gives you the line and column of the code you wrote.
  • meta.messages holds the wording, keyed by id, so the text lives in one place.
  • meta.name on the plugin is the prefix your rules are configured under, so this one is tsrx-demo/require-keyed-map.

Oxlint only loads a plugin you list, and only turns on a rule you enable. Save this as .oxlintrc.json:

{
  "jsPlugins": ["./oxlint-demo-plugin.mjs"],
  "rules": {
    "tsrx-demo/require-keyed-map": "error"
  }
}
See it run
# Your own JavaScript rule, running inside the oxlint you installed
npx oxlint src/TaskRow.tsx
src/TaskRow.tsx:11:9: error tsrx-demo(require-keyed-map): JSX returned from .map() should declare a `key` prop.
Real output, captured at build time. The sample project now has the .oxlintrc.json and oxlint-demo-plugin.mjs from above, plus the ordinary src/TaskRow.tsx.

That is your own JavaScript, running inside the oxlint that oxc-tsrx installed, with no other linter involved.

The same plugin on .tsrx#

Leave everything exactly as it is and point the same command at the .tsrx file instead:

See it run
# Same plugin, same config, one .tsrx file
npx oxlint src/TaskList.tsrx
src/TaskList.tsrx:4:3: warning eslint(no-debugger): `debugger` statement is not allowed
Found 0 error(s) and 1 warning(s).
oxlint (oxc-tsrx): running JS plugins on 1 .tsrx file(s) by linting the TSX projection; this parses each of those files once more. Disable with "settings": { "oxcTsrx": { "jsPluginsOnTsrx": false } }.
Real output, captured at build time, from the same project and the same .oxlintrc.json, pointed at the .tsrx file instead.

Two things happened there. The oxlint (oxc-tsrx): line is the disclosure: linting .tsrx with a JavaScript plugin costs one more parse of that file, and the command tells you so every time, naming the setting that turns it off. It goes to stderr, so in a terminal it arrives before the report; the transcript above prints stdout first and stderr after it, which is why it reads last there.

The other thing is that your rule ran and found nothing, because require-keyed-map looks for a .map() call and TaskList.tsrx has an @for block instead. The built-in no-debugger rule still reported, from the native Rust lane, exactly as it did before you added a plugin.

Give the rule something to find. Add this as src/TaskFeed.tsrx:

type Task = { id: string; label: string; done: boolean };

export function TaskFeed({ tasks }: { tasks: Task[] }) @{
  const rows = tasks.map((task) => <li>{task.label}</li>);

  <ul class="feed">{rows}</ul>;
}
See it run
# Your own rule, reporting on the .tsrx file you wrote
npx oxlint src/TaskFeed.tsrx
src/TaskFeed.tsrx:4:36: error tsrx-demo(require-keyed-map): JSX returned from .map() should declare a `key` prop.
Found 1 error(s) and 0 warning(s).
oxlint (oxc-tsrx): running JS plugins on 1 .tsrx file(s) by linting the TSX projection; this parses each of those files once more. Disable with "settings": { "oxcTsrx": { "jsPluginsOnTsrx": false } }.
Real output, captured at build time, after src/TaskFeed.tsrx was added to the same project.

That is your own JavaScript rule, reporting a problem in a .tsrx file, at the column of the <li> you wrote. Line 4, column 36 is where that <li> really is in the file above.

One command over a directory holding both file types does both halves at once:

See it run
# Both file types at once, one plugin, one command
npx oxlint src
src/TaskFeed.tsrx:4:36: error tsrx-demo(require-keyed-map): JSX returned from .map() should declare a `key` prop.
src/TaskList.tsrx:4:3: warning eslint(no-debugger): `debugger` statement is not allowed
src/TaskRow.tsx:11:9: error tsrx-demo(require-keyed-map): JSX returned from .map() should declare a `key` prop.
Found 2 error(s) and 1 warning(s).
oxlint (oxc-tsrx): running JS plugins on 2 .tsrx file(s) by linting the TSX projection; this parses each of those files once more. Disable with "settings": { "oxcTsrx": { "jsPluginsOnTsrx": false } }.
Real output, captured at build time, from the same project with one command run over the whole src directory.

The same plugin in your editor#

Nothing extra to configure: open src/TaskFeed.tsrx with the official OXC extension installed and your rule is a squiggle, at the same line and column oxlint just reported, beside the built-in Rust ones.

The language server does the same thing the command line does. It projects the buffer you are editing, hands that projection to a small Node.js host, and runs the published Oxlint binary over it, then maps the results back onto your bytes. Three practical consequences:

  • It re-lints on open, change, and save, so the extra parse happens per lint rather than once. The Node.js host is started once per workspace, and only if your config declares jsPlugins.
  • It announces itself once, in the server's output log, with the same jsPluginsOnTsrx key the command line names. Nothing appears in your editor UI.
  • A broken plugin does not take your other diagnostics with it. If the lane cannot start or one of your rules throws, the built-in rules still publish and a js-plugins-unavailable warning carries the reason. If one of your reports had no position in the source you wrote, it is dropped and a js-plugins-unmapped warning says how many, so an empty Problems panel is never how you find out.

Editor integration has the rest, including the one activation step the official extension needs.

The whole path, on a fresh Vite+ project#

Everything above assumed a plain project: one install, a file, a config. A real TSRX app is usually a Vite+ project scaffolded by vp create, and that route has more moving parts. Three of them belong to the TSRX toolchain rather than to this package, and setup tells you so instead of doing them.

This section is that whole route, in order, with nothing left out. It was run end to end on a scaffold created for it: Node.js 24.15.0, pnpm 11.17.0, Vite+ 0.2.6, oxc-tsrx 0.1.4, oxlint-tsgolint 0.24.0, @tsrx/typescript-plugin 0.3.112, @tsrx/react 0.2.50, TypeScript 6.0.3, on macOS arm64.

1. Scaffold, and install this package#

You do not have vp yet, and there is nothing to install to get it. Run it from the registry, naming the Vite+ version you want:

npx --yes -p vite-plus@0.2.6 vp create vite -- my-app --template react-ts
cd my-app

From here on, use the pnpm tab, not the npm one. vp create writes a devEngines.packageManager block into package.json naming pnpm, and npm then refuses to run in that project at all, exiting with npm error code EBADDEVENGINES and npm error Invalid name "pnpm" does not match "npm". That is npm honouring your own project's declaration, not a failure of anything here. Every transcript below was captured with the pnpm tab's commands, which is why setup prints (pnpm). Pick whichever manager your scaffold names.

npm install --save-dev oxc-tsrx@latest
npx oxc-tsrx setup

2. Read what setup says it will not do#

See it run
# Four slots this package owns, then four things it will not touch
npx oxc-tsrx setup
setup 4 compatibility slot(s) for oxc-tsrx 0.1.4 (pnpm)
- oxc-parser: active
- oxlint: active
- oxfmt: active
- oxc.path.oxlint: active (editor)
  .vscode/settings.json carries "oxc.path.oxlint": "node_modules/oxc-tsrx/bin/oxlint". This is the one file setup writes outside node_modules; it merges that single key and never edits package.json or tsconfig.json.
TSRX language support in the editor belongs to the TSRX toolchain, not to this package. Nothing below was installed, changed, or configured:
  ! install @tsrx/typescript-plugin yourself: it is what gives an editor TSRX language support, and oxc-tsrx never installs it
  ! install a TSRX framework binding yourself (one of @tsrx/react, @tsrx/vue, @tsrx/solid, @tsrx/preact, @tsrx/ripple, octane); oxc-tsrx does not choose one for you
  ! add "plugins": [{ "name": "@tsrx/typescript-plugin" }] under compilerOptions in tsconfig.app.json yourself. Not tsconfig.json: that one is solution-style ("files": [], "references": [...]) and a plugin declared there is inert. oxc-tsrx never edits tsconfig.json
  ! typescript 6.0.3 is outside @tsrx/typescript-plugin's declared peer range (>=5.9 <6). It may still work; if the editor misbehaves, pinning typescript into that range is the first thing to try. oxc-tsrx never changes your typescript version
Real output, captured at build time, from a project shaped like a fresh vp create React scaffold with oxc-tsrx installed and none of the TSRX toolchain packages yet. One difference from a real scaffold: setup names the two tsconfig files by absolute path, and the capture rewrites the throwaway project directory away, so they read as bare filenames here.

The first four lines are the slots setup owns. Three are packages inside node_modules that Vite+ resolves by name; the fourth is one key merged into your own .vscode/settings.json, which is the only file setup writes outside node_modules. The Vite+ page has the rules it follows there.

The lines marked ! are the boundary. setup never installs a package, never edits package.json, and never edits a tsconfig.json, so TSRX language support is reported and left alone. The next step is you doing those four things.

3. Do the part setup will not do#

Install the TypeScript plugin that teaches an editor what .tsrx is, plus one framework binding. @tsrx/react is the React one; @tsrx/vue, @tsrx/solid, @tsrx/preact, @tsrx/ripple, and octane are the others.

npm install --save-dev @tsrx/typescript-plugin @tsrx/react

Then declare the plugin in the tsconfig that owns your source. In a Vite+ scaffold that is tsconfig.app.json, the project whose include names src. Add one key to the compilerOptions block already there; leave everything else alone:

{
  "compilerOptions": {
    "plugins": [{ "name": "@tsrx/typescript-plugin" }]
  },
  "include": ["src"]
}

Not the root tsconfig.json. A vp create scaffold writes a solution-style root, { "files": [], "references": [...] }, which owns no files at all, so a plugins entry there applies to nothing and fails by doing nothing. That is the single most expensive way to get this wrong, and it is why setup names the referenced project by path rather than saying "your tsconfig".

One expected warning: vp create scaffolds TypeScript 6, and @tsrx/typescript-plugin declares peerDependencies.typescript: ^5.9.3, so a stock scaffold is outside the declared range and your package manager will say so. That is a support statement, not a failure. A stock scaffold on TypeScript 6.0.3 was measured working while this page was written. If the language service does misbehave, pinning TypeScript into the declared range is the first thing to try.

4. Check the boundary is closed#

See it run
# Three of the four unowned prerequisites are done; one is left
npx oxc-tsrx status
oxc-tsrx 0.1.4 compatibility (pnpm)
- oxc-parser: active
- oxlint: active
- oxfmt: active
- oxc.path.oxlint: active (editor)
  .vscode/settings.json carries "oxc.path.oxlint": "node_modules/oxc-tsrx/bin/oxlint". This is the one file setup writes outside node_modules; it merges that single key and never edits package.json or tsconfig.json.
TSRX language support in the editor belongs to the TSRX toolchain, not to this package. Nothing below was installed, changed, or configured:
  ! typescript 6.0.3 is outside @tsrx/typescript-plugin's declared peer range (>=5.9 <6). It may still work; if the editor misbehaves, pinning typescript into that range is the first thing to try. oxc-tsrx never changes your typescript version
Real output, captured at build time, from the same project after the TSRX toolchain packages were installed and tsconfig.app.json declared the plugin. On a real scaffold this prints identically.

Three of the four ! lines are gone, because you did them. The remaining one is the TypeScript range from the previous step, and it stays until you pin TypeScript yourself.

5. Write the rule#

Save this as house-rules.mjs:

// A house rule: an ordinary Oxlint JavaScript plugin. The default export is
// `{ meta, rules }`, and each rule's `create(context)` returns a visitor keyed
// by AST node type. Nothing here is TSRX-specific.

const noInlineStyleObject = {
  meta: {
    type: "suggestion",
    docs: { description: "Prefer a class over an inline style object" },
    messages: { inline: "Inline `style={{ ... }}` object. Use a class instead." },
    schema: [],
  },
  create(context) {
    return {
      JSXAttribute(node) {
        if (node.name?.name !== "style") return;
        if (node.value?.type !== "JSXExpressionContainer") return;
        if (node.value.expression?.type !== "ObjectExpression") return;
        context.report({ node, messageId: "inline" });
      },
    };
  },
};

export default {
  meta: { name: "house-rules", version: "1.0.0" },
  rules: { "no-inline-style-object": noInlineStyleObject },
};

Save this as .oxlintrc.json:

{
  "jsPlugins": ["./house-rules.mjs"],
  "rules": {
    "house-rules/no-inline-style-object": "warn"
  }
}

One top-level jsPlugins, with no overrides block. That is deliberate: a single top-level declaration serves a mixed batch correctly, and the next step is the measurement that says so.

Now give the rule one of each file type to find. Save this as src/Greeting.tsrx:

type Guest = { id: string; name: string };

export function Greeting({ guests, ready }: { guests: Guest[]; ready: boolean }) @{
  @if (ready) {
    <ul style={{ padding: 0 }}>
      @for (const guest of guests; key guest.id) {
        <li>{guest.name}</li>;
      }
    </ul>;
  } @else {
    <p>Loading</p>;
  }
}

And save this as src/Panel.tsx:

export function Panel({ label }: { label: string }) {
  return <section style={{ margin: 0 }}>{label}</section>;
}

6. Run it on both halves at once#

The command is a path rather than npx oxlint, because in a Vite+ project that name belongs to Vite+; step 7 is about that. This is the oxlint this package installed, at the same path setup just wrote into .vscode/settings.json.

See it run
# One command, one top-level jsPlugins, both file types
node_modules/oxc-tsrx/bin/oxlint src
src/Greeting.tsrx:5:9: warning house-rules(no-inline-style-object): Inline `style={{ ... }}` object. Use a class instead.
src/Panel.tsx:2:19: warning house-rules(no-inline-style-object): Inline `style={{ ... }}` object. Use a class instead.
Found 0 error(s) and 2 warning(s).
oxlint (oxc-tsrx): running JS plugins on 1 .tsrx file(s) by linting the TSX projection; this parses each of those files once more. Disable with "settings": { "oxcTsrx": { "jsPluginsOnTsrx": false } }.
Real output, captured at build time, from the walkthrough project with house-rules.mjs, .oxlintrc.json, src/Greeting.tsrx, and src/Panel.tsx in place.

One command, one plugin, one top-level jsPlugins, both file types, and both diagnostics at the positions in the files you wrote. src/Greeting.tsrx:5:9 is the style attribute on the <ul>, and src/Panel.tsx:2:19 is the one on the <section>.

No overrides block was needed for that. An earlier version of this page suggested scoping jsPlugins under overrides in a Vite+ project to keep a mixed batch from being poisoned. Re-measured on the scaffold above, a top-level declaration handles the mixed batch, so the extra scoping is not part of this route. Reach for overrides when you actually want different rules on the two halves, not as a workaround.

7. In a Vite+ project, the oxlint on your PATH is Vite+'s#

This is the one command that does not behave the way it does elsewhere in these docs, which is why step 6 spells out a path instead of typing npx oxlint. In a Vite+ project node_modules/.bin/oxlint belongs to Vite+, and running it prints This oxlint wrapper is for IDE extension use only (--lsp mode). To lint your code, run: vp lint and exits 1 without linting anything.

That is Vite+ being correct about its own project. Your two working commands are node_modules/oxc-tsrx/bin/oxlint, which is this package's own and the same path setup wrote into .vscode/settings.json, and vp lint, which needs one more edit; see step 9. The Vite+ page has the shim in full.

8. The same rule in your editor#

Open src/Greeting.tsrx with the official OXC extension installed and the rule is a squiggle, beside the built-in Rust ones. There is nothing further to configure: setup already pointed the extension at this package's oxlint, and the language server reads the same .oxlintrc.json the command line just used.

The position is the same one step 6 printed. That is not a hope; the language server and oxlint are held to it by pnpm run test:plugins, which drives one real user plugin over one real .tsrx file through both surfaces and fails if they disagree about a position. Editor integration covers the editor half, including the one-line extra-parse disclosure the server writes to its output log and what happens when a plugin fails to load.

9. If you also want vp lint to run it#

vp lint does not read .oxlintrc.json. Vite+ owns lint configuration in the lint block of vite.config.ts, and vp create folds any scaffolded .oxlintrc.json into that block on the way out. Measured on the project above: a plugin declared only in .oxlintrc.json produces nothing from vp lint, and a plugin declared only in vite.config.ts produces nothing in the editor. If you want both surfaces, declare it in both places.

The vite.config.ts half is two additions to what vp create already wrote: a { name, specifier } entry in lint.jsPlugins, and a lint.rules entry. Nothing is deleted, lint.options included:

import { defineConfig, lazyPlugins } from "vite-plus";
import react from "@vitejs/plugin-react";

// https://vite.dev/config/
export default defineConfig({
  staged: {
    "*": "vp check --fix",
  },
  fmt: {},
  lint: {
    plugins: ["react", "typescript", "oxc"],
    rules: {
      "react/rules-of-hooks": "error",
      "react/only-export-components": [
        "warn",
        {
          allowConstantExport: true,
        },
      ],
      "vite-plus/prefer-vite-plus-imports": "error",
      "house-rules/no-inline-style-object": "warn",
    },
    options: {
      typeAware: true,
      typeCheck: true,
    },
    jsPlugins: [
      {
        name: "vite-plus",
        specifier: "vite-plus/oxlint-plugin",
      },
      {
        name: "house-rules",
        specifier: "./house-rules.mjs",
      },
    ],
  },
  plugins: lazyPlugins(() => [react()]),
});

lint.options is the scaffold's type-aware default, and on Vite+ 0.2.6 it needs one dependency that the scaffold does not have. Vite+ 0.2.6 depends on oxlint-tsgolint 7.0.2001; this package runs the type-aware lane only against 0.24.0. Without a 0.24.0 in the project, vp lint on a batch containing .tsrx stops with unsupported tsgolint version 7.0.2001 and exits 2, after reporting the ordinary half. Install the runner and the whole lint block works as written:

npm install --save-dev oxlint-tsgolint@0.24.0

That was measured on this scaffold, with options untouched: both .tsrx and .tsx reported, type-aware included. The type-aware template default has all three runs, the resolution rule behind them, and the alternative if you would rather delete lint.options than add a dependency.

Do not confuse that with the refusal below, which is a different thing with a similar shape. options.typeAware in a plain .oxlintrc.json reaches the direct oxlint command, which never starts a type process from config alone and says so:

See it run
# The .tsrx half refuses; exit 2 is the configuration refusal
node_modules/oxc-tsrx/bin/oxlint src
src/Panel.tsx:2:19: warning house-rules(no-inline-style-object): Inline `style={{ ... }}` object. Use a class instead.
Found 0 error(s) and 1 warning(s).
oxlint (oxc-tsrx): running JS plugins on 1 .tsrx file(s) by linting the TSX projection; this parses each of those files once more. Disable with "settings": { "oxcTsrx": { "jsPluginsOnTsrx": false } }.
oxlint (oxc-tsrx): type-aware tsgolint/type-check mode requires the explicit --type-aware or --type-check opt-in; it is never started or silently disabled by config alone
Real output, captured at build time, from the same project after options.typeAware was added to .oxlintrc.json. That is the option a vp create scaffold sets in vite.config.ts.

A batch with no .tsrx file in it is unaffected by either, which is why a stock scaffold lints fine until you add your first TSRX component.

How it runs, and what it costs#

.tsrx files are linted by a native Rust process, and that process has no Node.js runtime to run your module in. What it does have is a projection: one legal TSX rendering of your .tsrx source, which it already builds to run OXC's built-in rules, plus a byte-for-byte map from positions in that projection back to positions in what you wrote.

So oxlint writes each projection into a throwaway directory, runs the published Oxlint binary over it with your .oxlintrc.json, and sends the diagnostics back through that map. Your severities, rule options, extends, and overrides are resolved by Oxlint itself, from your own config, so a rule behaves the same on .tsrx as it does anywhere else.

The cost is one extra parse per .tsrx file, and it is never silent. Every run that does it writes one line to stderr, ahead of the report, which is the oxlint (oxc-tsrx): line in both runs above. --silent suppresses it along with everything else. A --format=json report carries the same fact as data, under oxcTsrx.jsPluginProjection, as { "files": N, "extraParses": N, "unmapped": N }. unmapped is how many of your plugin diagnostics could not be placed in the source you wrote; the next section explains when that happens.

If the installed Oxlint is outside the range this route was built against (>=1.74.0 <2.0.0), the command refuses and exits 1 rather than running with your rules quietly switched off.

What your rule sees on .tsrx#

Your rule is handed the projection, not your authored TSRX tree. Four consequences, in the order they tend to bite:

TSRX control syntax is already compiled away, and reporting on it does not work. @if, @for, @switch, and @try do not reach your rule as JSXIfExpression and friends; they arrive as the ordinary if, for, and switch statements they project to. A rule keyed on JSXForExpression will never fire on this route.

Read the next sentence carefully, because the two facts together are easy to get wrong. Your rule visits those projected statements, but a report on one of them is dropped, because its span covers text the projection wrote rather than text you did. A rule keyed on IfStatement therefore fires inside Oxlint and produces nothing on a file whose if came from @if:

rule keyed on reported dropped
JSXElement 7 0
IfStatement 0 1
SwitchStatement 0 1
FunctionDeclaration 0 1
Identifier 11 5

Measured on one .tsrx file using @if, @switch, and @try, with one rule per node type.

The drops are counted, not silent: see unmapped below. But if your rule needs to report on TSRX control flow, this route cannot do it. Use the ESLint route, which parses your file directly.

context.filename is the projection's path, not yours. It points inside the throwaway directory and ends in .tsrx.tsx: a src/View.tsrx in your project is <temporary directory>/src/View.tsrx.tsx to your rule. The path relative to your working directory is preserved, so a rule that tests for src/ still works, but one that compares against an absolute project path, or that expects the extension to be .tsrx, does not. The diagnostic itself is still reported against src/View.tsrx, which is what you and your editor see. This holds in the editor as well, with a different throwaway directory per session.

A diagnostic that lands on projected-only text is dropped, and counted. The projection inserts markers and wrappers that correspond to nothing you typed. If a rule reports on one of those, there is no authored position to point at, so the diagnostic is discarded rather than reported at an invented location.

That drop is never silent. A run that discards any of your diagnostics writes a second oxlint (oxc-tsrx): line to stderr saying how many, and the same number is in oxcTsrx.jsPluginProjection.unmapped in a --format=json report. In the editor it arrives as one js-plugins-unmapped warning on the file. So a rule that fires on .tsx and reports nothing on .tsrx is something you are told about rather than something you have to suspect.

A report on the whole Program is not one of these, even though its span covers the projection, markers and all. It is mapped to your authored file from its first token to the end, which is the same place the same rule lands on an ordinary .tsx file. Whatever sits above that first token — a comment, a blank line, // @ts-nocheck — makes no difference. The same holds for any report that runs to the end of the file across text the projection rewrote.

What is still dropped is a report whose span sits partly on a marker and partly on code you wrote and stops short of the end of the file, which happens when a rule reports on a node the projection rewrote rather than on one of your own tokens. If that is the rule you need, the ESLint route parses your file directly.

An overrides glob written for .tsrx is matched for you, in your own config only. The projection is named View.tsrx.tsx, which **/*.tsrx does not match, so oxlint also emits each of your overrides[].files and excludeFiles globs with .tsx appended. A config reached through extends does not get that rewrite yet, so a .tsrx-targeted override in a shared config will not apply on this route. Put those overrides in the config that names jsPlugins.

Turning the extra parse off#

If you would rather not pay the second parse, say so in settings:

{
  "jsPlugins": ["./oxlint-demo-plugin.mjs"],
  "rules": {
    "tsrx-demo/require-keyed-map": "error"
  },
  "settings": {
    "oxcTsrx": {
      "jsPluginsOnTsrx": false
    }
  }
}

Your plugins keep running on ordinary files. On .tsrx the command now refuses out loud rather than dropping your rule and reporting success:

See it run
# With the lane switched off, the .tsrx half refuses out loud
npx oxlint src/TaskFeed.tsrx
oxlint (oxc-tsrx): JavaScript plugins are not hosted by the native TSRX lint target itself: it is a Rust process with no Node runtime. The `oxlint` command OXC for TSRX installs runs them on .tsrx for you, by linting the TSX projection with the published Oxlint binary and mapping every diagnostic back to your authored source. Run `oxlint` instead of this target, or remove the settings.oxcTsrx.jsPluginsOnTsrx false opt-out that turned that lane off
Real output, captured at build time, from the same project after settings.oxcTsrx.jsPluginsOnTsrx was set to false.

The same setting turns the editor's lane off. There, the refusal arrives as one lint-unavailable diagnostic on the file, carrying the same text, so an empty Problems panel is never how you find out.

The configuration guide has the full support matrix for what the native .tsrx lane accepts.

When your rule must see authored TSRX nodes: ESLint#

Everything above hands your rule the projection, where @if and @for have already become if and for. If your rule is about that syntax, you need the authored tree, and for that there is still one route: ESLint's public parser slot.

This is an escape hatch, not the recommended default. ESLint is a second linter to install and configure, it is not part of oxc-tsrx, and it does not reuse any of the native .tsrx work above.

npm install --save-dev eslint

Copy the parser adapter once#

ESLint does not know what a .tsrx file is. You fix that with a parser adapter: a module exporting parseForESLint, which ESLint calls instead of its own parser.

The adapter is not in the oxc-tsrx npm package. It lives in this project's repository and nowhere else, so installing oxc-tsrx does not give it to you. Download tsrx-eslint-parser.mjs (opens in new tab) and save it in your project under that name. It sits at examples/custom-js-plugins/tsrx-eslint-parser.mjs in the repository (opens in new tab), and the link tracks main rather than a release, because the file is not versioned with the package. It is about 120 lines, and most of it is offset bookkeeping. This is the part that matters:

export function parseForESLint(sourceText, options = {}) {
  const filePath = options.filePath ?? "input.tsrx";
  const result = parseSync(filePath, sourceText, {
    astType: "ts",
    lang: "tsrx",
    preserveParens: false,
    range: true,
    sourceType: options.sourceType === "script" ? "script" : "module",
  });
  if (result.errors.length > 0) throw syntaxError(sourceText, result.errors[0]);
  if (!result.program) throw new SyntaxError(`TSRX parser returned no Program for ${filePath}`);

  const visitorKeys = prepareForEslint(result.program, result.comments, sourceText);
  return {
    ast: result.program,
    visitorKeys,
    services: {
      isTsrx: true,
      parser: "oxc-tsrx/parser",
    },
  };
}

The rest of the file does four supporting jobs, each for a reason:

  • Adds range and loc to every node, because that is how ESLint knows where to underline a problem.
  • Builds visitorKeys, a map of node type to child property names. ESLint uses it to walk into node types it has never heard of. Without it, your rules would never be called for TSRX nodes.
  • Converts comments, so directives like // eslint-disable-next-line work.
  • Turns parse errors into SyntaxError, which ESLint reports as an ordinary Parsing error message rather than a crash.

The in-repo copy imports the parser by relative path (../../packages/toolchain/dist/parser.js) so the repository's own tests can load it without an install. In your project, change that one line to oxc-tsrx/parser. Both resolve to the same module.

Write the rules#

The rules themselves are ordinary ESLint rules that happen to name TSRX nodes. Save this as demo-lint-plugin.mjs:

const noTsrxIf = {
  meta: {
    type: "suggestion",
    docs: {
      description: "Demo a JavaScript rule visiting authored TSRX control syntax",
    },
    messages: {
      avoid: "Demo rule: prefer a declarative component over this TSRX @if block.",
    },
    schema: [],
  },
  create(context) {
    return {
      JSXIfExpression(node) {
        context.report({ node, messageId: "avoid" });
      },
    };
  },
};

const requireKeyedFor = {
  meta: {
    type: "problem",
    docs: {
      description: "Require a key expression on TSRX @for blocks",
    },
    messages: {
      missing: "TSRX @for blocks should declare `key <expression>`.",
    },
    schema: [],
  },
  create(context) {
    return {
      JSXForExpression(node) {
        if (node.key == null) context.report({ node, messageId: "missing" });
      },
    };
  },
};

export default {
  meta: {
    name: "eslint-plugin-tsrx-demo",
    version: "0.1.0",
  },
  rules: {
    "no-tsrx-if": noTsrxIf,
    "require-keyed-for": requireKeyedFor,
  },
};

requireKeyedFor reads node.key, a real field on JSXForExpression. That is the whole trick: once a parser hands you the authored tree, checking TSRX syntax is ordinary JavaScript.

Wire it up and run it#

Save this as eslint.config.mjs:

import tsrxDemo from "./demo-lint-plugin.mjs";
import tsrxParser from "./tsrx-eslint-parser.mjs";

export default [
  {
    files: ["**/*.tsrx"],
    languageOptions: { parser: tsrxParser, sourceType: "module" },
    plugins: { "tsrx-demo": tsrxDemo },
    rules: {
      "tsrx-demo/no-tsrx-if": "warn",
      "tsrx-demo/require-keyed-for": "error",
    },
  },
];

The files: ["**/*.tsrx"] line matters more than it looks. ESLint only lints extensions a config block claims. Leave it out and ESLint reports the file as ignored because no matching configuration was supplied, which is not an obvious way of saying "no block matched your extension".

See it run
# Both rules fire, on the @if and the @for you authored
npx eslint src/TaskList.tsrx

src/TaskList.tsrx
  6:3  warning  Demo rule: prefer a declarative component over this TSRX @if block  tsrx-demo/no-tsrx-if
  8:7  error    TSRX @for blocks should declare `key <expression>`                  tsrx-demo/require-keyed-for

✖ 2 problems (1 error, 1 warning)

Real output, captured at build time by running ESLint 10.7.0 against src/TaskList.tsrx with the adapter, plugin, and config above.

Both rules fired, on the @if and the @for you wrote. Now give the @for block the key it was missing:

@for (const task of tasks; key task.id) {
See it run
# The error is gone; the warning you asked for stays
npx eslint src/TaskList.tsrx

src/TaskList.tsrx
  6:3  warning  Demo rule: prefer a declarative component over this TSRX @if block  tsrx-demo/no-tsrx-if

✖ 1 problem (0 errors, 1 warning)

Real output, captured at build time from the same project after the @for block was given its key expression.

The error is gone and the warning you asked for stays. Both rules read node types that only exist in the authored tree, which is the one thing this route still buys you over running the same rule inside oxlint.

What the ESLint route does not do#

It is AST-only. Rules that read the tree work. Two things do not, and it is better to hit them here than halfway through writing a rule.

There are no tokens. The v1 parser API does not expose OXC's token stream, so the adapter sets program.tokens = [] rather than faking it. sourceCode.getText() works, but sourceCode.getFirstToken(node) returns null, and any rule built on token methods cannot be correct here.

Scope is not guaranteed. Ordinary ESTree descendants are traversed normally, but there is no framework scope contract yet, so binding and scope behavior around TSRX control syntax is not something to rely on.

Where a custom check can run today#

There is no single "TSRX plugin" format. Each row below is a different program that could run a check, with the parser that feeds it and how real it is:

Where the check runs Parser it uses Plugin shape How real today
The oxlint oxc-tsrx installs, on ordinary .js/.ts/.tsx OXC's own parser An Oxlint JS plugin Shipping; this is the walkthrough above
The oxlint oxc-tsrx installs, on .tsrx The native Rust TSRX parser, then the TSX projection The same Oxlint JS plugin, plus native Rust rules Shipping; one extra parse per file, disclosed on stderr
Your editor, on .tsrx The same projection, from the in-memory buffer The same Oxlint JS plugin, plus native Rust rules Shipping; one extra parse per lint, disclosed in the server log
Upstream oxlint, on .tsrx none none Released upstream Oxlint cannot parse .tsrx at all
ESLint (its own process) A parseForESLint adapter you copy A normal ESLint plugin Works for AST-only rules that need authored TSRX nodes; proven by an ESLint 10 test
A Vite plugin (dev/build process) The repo's TSRX parser service An ordinary Vite plugin calling this.warn Works, but only as a source-local example in this repo
Native oxc-tsrx-lint, called directly The native Rust TSRX projection Native Rust rules only Shipping; it is Rust with no Node.js runtime and refuses jsPlugins. Run oxlint instead
oxc-tsrx/lint/plugins-dev none Re-exports Oxlint's RuleTester Real, and useful for unit-testing a rule; it is not a host

Two more things trip people up often enough to state plainly:

  • oxc-tsrx/lint/plugins-dev is not a host. It is one export, Oxlint's RuleTester, for testing a rule you wrote. It does not run one against .tsrx.
  • The official OXC VS Code extension is a client, not a rule runtime. When a custom TSRX rule shows a squiggle, the extension is only displaying it.

A Vite plugin can read the authored TSRX AST too, through a pre-transform parser service that parses each .tsrx file once and caches it. That is a source-local example rather than an installable API; see Vite and Vite+ for how it composes. Vite+ surfaces Oxlint's jsPlugins in its lint block, and those plugins reach both halves of the project the same way they do from a plain .oxlintrc.json.

The runnable version of everything above lives in examples/custom-js-plugins, with the fresh-scaffold walkthrough's own files in examples/custom-js-plugins/vite-plus. Its tests use the real parser, ESLint 10.7.0, Vite 8.1.5, and @tsrx/vite-plugin-react 0.0.72. Oxlint in this repository is pinned and tested at 1.74.0; public releases may have moved past that.

Status and what is coming#

Running your rule on .tsrx works today, on the command line and in the editor, using the projection route described above. It does not depend on any unmerged upstream change. It was last proved on a scaffold created by vp create and configured by following the walkthrough on this page and nothing else: the reader's rule reported at the authored positions from oxlint, the language server published the same rule at the same position, the ordinary .tsx half reported from the same command, and the built-in Rust rules kept reporting throughout.

What is still upstream is running a JavaScript rule against the authored TSRX tree inside Oxlint, which is what would make JSXIfExpression and JSXForExpression visible to an Oxlint plugin. OXC PR #24262 (opens in new tab) adds overrides[].languageOptions.parser routing for Oxlint's JS-plugin host. As of 2026-07-26 it is still a Draft, and it is a local source build, not something you can install. The wider language-plugin idea (discussion #21936 (opens in new tab)) is still a discussion. Until one of them lands, a rule about TSRX control syntax itself belongs on the ESLint route.

examples/vscode-lints/README.md has an editor demo of that draft: the official OXC VS Code extension, pointed at a workspace-local launcher, showing tsrx-demo(no-tsrx-if) on an authored @if block. It proves you do not need a second VS Code extension; it does not make the upstream draft released.

Last audited: 2026-07-27.