Skip to main content

Linting

Trackunit publishes the ESLint rules it uses for its own apps as @trackunit/eslint-plugin-trackunit. The plugin ships ready-to-use presets that check TypeScript, imports, module boundaries, React and tests, plus Trackunit-specific rules for components, class names and translations.

New workspaces created with npx @trackunit/create-iris-app-workspace already have Trackunit linting set up:

  • A root eslint.config.cjs that applies the base and react presets.
  • Apps and extensions whose eslint.config.cjs only extends the root config, tagged type:app or type:extension.
  • A lint target for every project, from the @nx/eslint/plugin in nx.json.

Your workspace already has it if the root package.json lists @trackunit/eslint-plugin-trackunit and there is a root eslint.config.cjs. In that case, skip to Run lint. Otherwise, follow the steps below to add it yourself.

Requirements​

Your workspace needs:

  • ESLint 9 (9.33.0 or later) with a flat config file (eslint.config.*). ESLint 10 is not supported yet. To install or upgrade ESLint, run npm install -D eslint@9; without the @9, npm installs ESLint 10.
  • Node.js 24 or later.
  • TypeScript 5.9 or 6.0. TypeScript 7 is not supported yet.
  • Nx at the version @trackunit/iris-app depends on. See Updating your Nx workspace.

Check your versions from the workspace root:

npx eslint --version
node --version
npx tsc --version
npx nx --version
npm view @trackunit/iris-app dependencies.nx
warning
Still on ESLint 8 or .eslintrc files?

The plugin only supports flat config. Migrate your workspace first with the ESLint configuration migration guide and the Nx guide to switching to flat config. Nx converts every project in one go with npx nx g @nx/eslint:convert-to-flat-config --eslintConfigFormat=cjs. Then come back to this page.

Set up linting in an existing workspace​

The steps apply workspace-wide: do them once, for every project, including projects you created before adopting the plugin.

1. Install the plugin​

npm install -D @trackunit/eslint-plugin-trackunit @typescript-eslint/parser@8 @nx/eslint@[your-nx-version]

Replace [your-nx-version] with the nx version in your root package.json (for example 23.3.0). All Nx packages in a workspace must share one version.

@typescript-eslint/parser and graphql are peer dependencies of the plugin. IrisX workspaces already have graphql, and npm installs missing peers automatically. If your package manager doesn't, install graphql@16 yourself.

2. Replace the root ESLint config​

Delete the existing root ESLint config: eslint.config.mjs (Nx creates it when you generate your first extension), and eslint.config.js or eslint.config.ts if you have them. Then create eslint.config.cjs:

eslint.config.cjs
const { defineConfig } = require('eslint/config');
const { configs } = require('@trackunit/eslint-plugin-trackunit');

module.exports = defineConfig([
{
ignores: ['**/dist', '**/out-tsc', '**/vitest.config.*.timestamp*'],
},
...configs.base,
...configs.react,
{
// Your own workspace-wide rules go here, after the presets.
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
rules: {},
},
]);

Use .cjs for the root config: the IrisX generators only give new apps and extensions a config that extends the root (and tag them) when the root config is eslint.config.cjs. ESLint picks eslint.config.js and eslint.config.mjs over eslint.config.cjs in the same folder, so the old files have to go.

Replace Nx's default presets, don't add to them

The root config Nx generates spreads nx.configs['flat/base'], nx.configs['flat/typescript'] and nx.configs['flat/javascript']. Do not keep them next to the Trackunit presets: ESLint stops with Cannot redefine plugin "@typescript-eslint". configs.base replaces them.

Also leave out the @nx/enforce-module-boundaries block Nx generates (the one that allows everything with sourceTag: '*'). configs.base configures that rule with Trackunit's constraints, and a later block would override them.

Keep your own rules​

Copy any rules you added to the old root config into the last block, after ...configs.react. ESLint applies config blocks in order, so your rules take precedence over the presets. If a rule comes from a plugin the presets don't include, copy the block or config spread that registers that plugin too.

Prefer an ES module config?​

An eslint.config.mjs with the same content also works:

eslint.config.mjs
import { defineConfig } from 'eslint/config';
import { configs } from '@trackunit/eslint-plugin-trackunit';

export default defineConfig([
{
ignores: ['**/dist', '**/out-tsc', '**/vitest.config.*.timestamp*'],
},
...configs.base,
...configs.react,
{
// Your own workspace-wide rules go here, after the presets.
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
rules: {},
},
]);

With an .mjs root, do step 3 the other way round: delete each project's eslint.config.cjs and give it an eslint.config.mjs with import baseConfig from '../../eslint.config.mjs'; and export default [...baseConfig];. The IrisX generators don't recognise this setup, though: every app or extension you create afterwards needs steps 3 to 5 by hand. We recommend .cjs.

3. Make every project extend the root config​

For every project with an ESLint config (apps, extensions and shared libraries), delete any eslint.config.mjs or eslint.config.js in the project folder. Then replace the content of its eslint.config.cjs (create it if it doesn't exist) with a single line that extends the root config:

apps/[name-of-your-app]/eslint.config.cjs
module.exports = [...require('../../eslint.config.cjs')];
libs/[feature-name]/[name-of-your-extension]/eslint.config.cjs
module.exports = [...require('../../../eslint.config.cjs')];

Use one ../ per folder between the project and the workspace root. An extension created without a subdir lives in libs/[name-of-your-extension] and needs ../../eslint.config.cjs.

If a project config had its own rules, see Override or turn off a rule for a project.

4. Give every project a lint target​

Register the Nx ESLint plugin in nx.json. If nx.json already has a plugins array, add the entry to it:

nx.json
{
"plugins": [
{
"plugin": "@nx/eslint/plugin",
"options": {
"targetName": "lint"
}
}
]
}

If @nx/eslint/plugin is already registered (for example with "targetName": "eslint:lint"), change its targetName to lint instead of adding a second entry.

The plugin adds a lint target to every project that has an ESLint config, including apps, which don't have one in older workspaces.

If nx.json has namedInputs.production, add "!{projectRoot}/eslint.config.cjs" to it, so editing a lint config doesn't invalidate cached builds:

nx.json
{
"namedInputs": {
"production": ["default", "!{projectRoot}/eslint.config.cjs"]
}
}

Keep the entries already in the array; only add the new one.

Extensions created by older SDK versions declare their own lint target. Delete the whole lint entry, including any options or outputs, from each extension's project.json so the plugin's target is used instead. The @nx/eslint:lint executor it uses is deprecated:

libs/[feature-name]/[name-of-your-extension]/project.json
"lint": {
"executor": "@nx/eslint:lint"
},

5. Tag every project​

configs.base enforces Trackunit's module boundaries with @nx/enforce-module-boundaries. A project without tags can't import other projects in the workspace, so an untagged app fails as soon as it imports its extensions: A project without tags matching at least one constraint cannot depend on any libraries. Set tags in each app's and extension's project.json:

apps/[name-of-your-app]/project.json
"tags": ["type:app"],
libs/[feature-name]/[name-of-your-extension]/project.json
"tags": ["type:extension"],

Tag shared libraries type:react (React code) or type:vanilla (no React). The tags decide what may import what:

  • type:app may depend on type:extension, type:react and type:vanilla projects.
  • type:extension and type:react may depend on type:react and type:vanilla projects.
  • type:vanilla may only depend on type:vanilla projects, and may not import React.
  • No type:* project may import i18next or react-i18next directly.

6. Turn on strict type checking​

Several type-aware rules, such as @typescript-eslint/no-unnecessary-condition and @typescript-eslint/strict-boolean-expressions, need TypeScript's strictNullChecks. Set strict in the root tsconfig.base.json:

tsconfig.base.json
{
"compilerOptions": {
"strict": true
}
}

Only change the strict value; keep the rest of the file. Turning on strict can surface type errors in existing code. Fix them so your apps and extensions keep building.

7. Fix code generated by older SDK versions​

UI extensions generated by older versions of @trackunit/iris-app have two lint errors in src/index.tsx.

The file imports @trackunit/iris-app-runtime-core, which isn't declared in package.json. Install it:

npm install @trackunit/iris-app-runtime-core

The errorBoundary callback declares parameters it never uses. Remove them:

libs/[feature-name]/[name-of-your-extension]/src/index.tsx
errorBoundary() {

8. Lint Playwright tests​

Skip this step unless your workspace uses @trackunit/iris-app-playwright. Its playwright.config.ts and playwright/ folder belong to playwright/tsconfig.json, which the presets don't know about. Add this block to the end of the array in the root eslint.config.cjs, otherwise ESLint reports Parsing error: "parserOptions.project" has been provided for @typescript-eslint/parser for your Playwright files:

eslint.config.cjs
{
files: ['**/playwright.config.ts', '**/playwright/**/*.ts'],
languageOptions: {
parserOptions: {
project: ['./playwright/tsconfig*.json', './tsconfig.*?.json'],
},
},
},

Your workspace is now linted with Trackunit's rules. Apps and extensions you generate from now on with an up-to-date @trackunit/iris-app already extend the root config and carry the right tag.

Run lint​

After setting up or renaming ESLint config files, run npx nx reset once so Nx picks up the changes.

Lint a single project:

npx nx lint [name-of-your-app]
npx nx lint [feature-name]-[name-of-your-extension]

An extension created without a subdir is named just [name-of-your-extension].

Lint the whole workspace:

npx nx run-many -t lint

Code you wrote before adopting the plugin will probably have findings. Add --fix to either command to apply the fixes the rules can make automatically, then fix or override what's left. Warnings are reported but don't fail the command.

What the presets check​

The presets are arrays of flat-config blocks. Spread configs.base first, then configs.react:

  • configs.base is the foundation for every project: TypeScript, import and JSDoc rules, Trackunit's module boundaries, restricted imports and the Trackunit rules that aren't React-specific, such as @trackunit/no-typescript-assertion. In *.spec.ts(x) files it turns off rules that get in the way of tests, such as no-console.
  • configs.react adds React and React Hooks rules, Trackunit's component rules (for example one exported component per file, named after the file), Testing Library rules for spec files and the Vitest globals (describe, it, expect, vi, …). It does not enable any Storybook rules; see Opt in to the Storybook rules.

Trackunit's own rules are prefixed @trackunit/. To find out what a rule checks and why, look it up in the rule list in the plugin README. If a rule gets in your way, you can override it for a project.

Override or turn off a rule for a project​

Add a block after the root config in the project's eslint.config.cjs. Blocks later in the array take precedence:

libs/[feature-name]/[name-of-your-extension]/eslint.config.cjs
module.exports = [
...require('../../../eslint.config.cjs'),
{
files: ['**/*.ts', '**/*.tsx'],
rules: {
// Turn a rule off for this project.
'@trackunit/one-component-per-file': 'off',
// Or change its severity.
'@trackunit/component-name-matches-filename': 'warn',
},
},
];

To change a rule for the whole workspace, put the same block after the presets in the root eslint.config.cjs instead.

Opt in to the Storybook rules​

The react preset registers Trackunit's Storybook rules but doesn't enable them. If your workspace has Storybook stories, turn them on by adding this block after ...configs.react in the root eslint.config.cjs:

eslint.config.cjs
{
rules: {
'@trackunit/storybook/require-folder-landing-page': 'error',
'@trackunit/storybook/require-csf-factory': 'error',
'@trackunit/storybook/no-local-meta-component': 'error',
},
},

@trackunit/storybook/require-csf-factory reports every story that isn't written in Storybook's CSF factory format (<meta>.story({...})), including stories with an export default {...} meta. Leave it out until your stories use that format.

The Storybook section of the plugin README describes what each rule enforces.

Keep the plugin up to date​

@trackunit/eslint-plugin-trackunit is upgraded together with your other @trackunit/* packages by the @trackunit/migrations upgrade flow. From the workspace root:

npx nx g @trackunit/migrations:migrate
npx nx g @trackunit/migrations:run-migrations

New plugin versions can add or tighten rules, so run npx nx run-many -t lint after upgrading.

Nice to know

The upgrade doesn't set up Trackunit linting for you. A workspace that doesn't use the plugin keeps its current lint setup until you follow the steps on this page.