Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format

## [Unreleased]

### Added

- **ESLint Plugin:** add `prefer-destructured-lookups` rule — warns when `this.$refs`, `this.$options` or `this.$children` members are accessed more than once in the same method without being destructured ([#729](https://github.com/studiometa/js-toolkit/pull/729))
- **ESLint Plugin:** add `no-dollar-prefix` rule — disallows user-defined instance methods and properties prefixed with `$` in `Base` subclasses, as `$` is reserved for framework members ([#729](https://github.com/studiometa/js-toolkit/pull/729))
- **ESLint Plugin:** add `require-destroyed-cleanup` rule — requires a `destroyed()` method in `Base` subclasses that use `setTimeout`, `setInterval` or `requestAnimationFrame` to prevent memory leaks ([#729](https://github.com/studiometa/js-toolkit/pull/729))

## [v3.6.0-beta.2](https://github.com/studiometa/js-toolkit/compare/3.6.0-beta.1..3.6.0-beta.2) (2026-05-12)

### Added
Expand Down
72 changes: 71 additions & 1 deletion packages/docs/guide/going-further/linting.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ Add the plugin to your `.oxlintrc.json`:
"js-toolkit/no-redundant-with-mount-when-in-view": "warn",
"js-toolkit/no-manual-intersection-observer": "warn",
"js-toolkit/no-manual-mutation-observer": "warn",
"js-toolkit/prefer-ref-over-query-selector": "warn"
"js-toolkit/prefer-ref-over-query-selector": "warn",
"js-toolkit/prefer-destructured-lookups": "warn",
"js-toolkit/no-dollar-prefix": "error",
"js-toolkit/require-destroyed-cleanup": "warn"
}
}
```
Expand Down Expand Up @@ -254,3 +257,70 @@ Disallows `new IntersectionObserver()` inside `Base` subclasses. Use `withInters
**Recommended:** warn

Disallows `new MutationObserver()` inside `Base` subclasses. Use the `withMutation` decorator instead.

### Best practices

#### `js-toolkit/prefer-destructured-lookups`

**Recommended:** warn

Warns when `this.$refs`, `this.$options` or `this.$children` members are accessed more than once in the same method body. Destructure them into local variables at the top of the method to avoid repeated lookups.

```js
// ❌ Bad
async mounted() {
this.$refs.input.focus();
this.$refs.input.value = '';
}

// ✅ Good
async mounted() {
const { input } = this.$refs;
input.focus();
input.value = '';
}
```

#### `js-toolkit/no-dollar-prefix`

**Recommended:** error

Disallows user-defined instance methods and properties prefixed with `$` in `Base` subclasses. The `$` prefix is reserved for framework-provided members (`$el`, `$refs`, `$emit`, `$options`, etc.).

```js
// ❌ Bad
class Foo extends Base {
$helper() {}
}

// ✅ Good
class Foo extends Base {
helper() {}
}
```

#### `js-toolkit/require-destroyed-cleanup`

**Recommended:** warn

Requires a `destroyed()` lifecycle method in `Base` subclasses that call `setTimeout`, `setInterval` or `requestAnimationFrame`. Failing to clean up timers when a component is destroyed can cause memory leaks and hard-to-debug bugs.

```js
// ❌ Bad
class Foo extends Base {
async mounted() {
this._timer = setTimeout(() => {}, 1000);
}
}

// ✅ Good
class Foo extends Base {
async mounted() {
this._timer = setTimeout(() => {}, 1000);
}

async destroyed() {
clearTimeout(this._timer);
}
}
```
13 changes: 12 additions & 1 deletion packages/eslint-plugin-js-toolkit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,10 @@ Add the plugin to your `.oxlintrc.json`:
"js-toolkit/components-pascal-case": "error",
"js-toolkit/require-emit-declared-in-config": "error",
"js-toolkit/require-children-declared-in-config": "error",
"js-toolkit/require-options-declared-in-config": "error"
"js-toolkit/require-options-declared-in-config": "error",
"js-toolkit/prefer-destructured-lookups": "warn",
"js-toolkit/no-dollar-prefix": "error",
"js-toolkit/require-destroyed-cleanup": "warn"
}
}
```
Expand Down Expand Up @@ -144,3 +147,11 @@ export default [
| `js-toolkit/refs-no-bracket-access` | Disallows bracket access with a `[]` suffix on `this.$refs` (e.g. `this.$refs['items[]']`). Rewrites to dot notation camelCase. | error | 🔧 |
| `js-toolkit/prefer-ref-over-query-selector` | Warns when `this.$el.querySelector()` or `this.$el.querySelectorAll()` is used inside a `Base` subclass. Declare a ref in `static config` and use `this.$refs` instead. | warn | |
| `js-toolkit/require-refs-declared-in-config` | Requires all `this.$refs.<name>` accesses to be declared in `static config.refs`. | error | |

### Best practices

| Rule | Description | Recommended | Fixable |
| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ------- |
| `js-toolkit/prefer-destructured-lookups` | Warns when `this.$refs`, `this.$options` or `this.$children` members are accessed more than once in the same method. Destructure into a local variable instead. | warn | |
| `js-toolkit/no-dollar-prefix` | Disallows user-defined instance methods and properties prefixed with `$` in `Base` subclasses. The `$` prefix is reserved for framework-provided members. | error | |
| `js-toolkit/require-destroyed-cleanup` | Requires a `destroyed()` method in `Base` subclasses that call `setTimeout`, `setInterval` or `requestAnimationFrame`, to clear timers and prevent memory leaks. | warn | |
9 changes: 9 additions & 0 deletions packages/eslint-plugin-js-toolkit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ import {
requireEmitDeclaredInConfig,
requireChildrenDeclaredInConfig,
requireOptionsDeclaredInConfig,
preferDestructuredLookups,
noDollarPrefix,
requireDestroyedCleanup,
} from './rules/index.ts';

const PLUGIN_NAME = 'js-toolkit';
Expand Down Expand Up @@ -57,6 +60,9 @@ const rules = {
'require-emit-declared-in-config': requireEmitDeclaredInConfig,
'require-children-declared-in-config': requireChildrenDeclaredInConfig,
'require-options-declared-in-config': requireOptionsDeclaredInConfig,
'prefer-destructured-lookups': preferDestructuredLookups,
'no-dollar-prefix': noDollarPrefix,
'require-destroyed-cleanup': requireDestroyedCleanup,
};

const recommendedRules: Record<string, string> = {
Expand Down Expand Up @@ -86,6 +92,9 @@ const recommendedRules: Record<string, string> = {
[`${PLUGIN_NAME}/require-emit-declared-in-config`]: 'error',
[`${PLUGIN_NAME}/require-children-declared-in-config`]: 'error',
[`${PLUGIN_NAME}/require-options-declared-in-config`]: 'error',
[`${PLUGIN_NAME}/prefer-destructured-lookups`]: 'warn',
[`${PLUGIN_NAME}/no-dollar-prefix`]: 'error',
[`${PLUGIN_NAME}/require-destroyed-cleanup`]: 'warn',
};

const base = eslintCompatPlugin({
Expand Down
3 changes: 3 additions & 0 deletions packages/eslint-plugin-js-toolkit/src/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,6 @@ export { componentsPascalCase } from './components-pascal-case.ts';
export { requireEmitDeclaredInConfig } from './require-emit-declared-in-config.ts';
export { requireChildrenDeclaredInConfig } from './require-children-declared-in-config.ts';
export { requireOptionsDeclaredInConfig } from './require-options-declared-in-config.ts';
export { preferDestructuredLookups } from './prefer-destructured-lookups.ts';
export { noDollarPrefix } from './no-dollar-prefix.ts';
export { requireDestroyedCleanup } from './require-destroyed-cleanup.ts';
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, it } from 'vitest';
import { tester } from '../utils/rule-tester.ts';
import { noDollarPrefix } from './no-dollar-prefix.ts';

describe('no-dollar-prefix', () => {
it('passes and fails correctly', () => {
tester.run('no-dollar-prefix', noDollarPrefix as any, {
valid: [
// Not a Base subclass
`class Foo { $method() {} }`,
// Normal method names
`import { Base } from '@studiometa/js-toolkit';
class Foo extends Base {
mounted() {}
myMethod() {}
myProperty = 'foo';
}`,
// Static members are allowed (e.g. static config)
`import { Base } from '@studiometa/js-toolkit';
class Foo extends Base {
static $config = {};
}`,
// ClassExpression variant
`import { Base } from '@studiometa/js-toolkit';
const Foo = class extends Base {
mounted() {}
};`,
],
invalid: [
{
code: `import { Base } from '@studiometa/js-toolkit';
class Foo extends Base {
$myMethod() {}
}`,
errors: [{ messageId: 'noDollarPrefix' }],
},
{
code: `import { Base } from '@studiometa/js-toolkit';
class Foo extends Base {
$myProperty = 'foo';
}`,
errors: [{ messageId: 'noDollarPrefix' }],
},
{
code: `import { Base } from '@studiometa/js-toolkit';
class Foo extends Base {
$onScroll() {}
$helper() {}
}`,
errors: [{ messageId: 'noDollarPrefix' }, { messageId: 'noDollarPrefix' }],
},
// ClassExpression variant
{
code: `import { Base } from '@studiometa/js-toolkit';
const Foo = class extends Base {
$myMethod() {}
};`,
errors: [{ messageId: 'noDollarPrefix' }],
},
],
});
});
});
42 changes: 42 additions & 0 deletions packages/eslint-plugin-js-toolkit/src/rules/no-dollar-prefix.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { isBaseSubclass, type Node, type RuleContext, createRule } from '../utils/ast.ts';

export const noDollarPrefix = createRule({
meta: {
type: 'problem',
docs: {
description:
'Disallow user-defined methods and properties prefixed with "$" in Base subclasses — "$" is reserved for js-toolkit framework members',
},
messages: {
noDollarPrefix:
'"${{name}}" uses the "$" prefix, which is reserved for js-toolkit framework members. Rename it without the "$" prefix.',
},
},
createOnce(context: RuleContext) {
return {
ClassDeclaration(node: Node) {
check(node, context);
},
ClassExpression(node: Node) {
check(node, context);
},
};
},
});

function check(node: Node, context: RuleContext) {
if (!isBaseSubclass(node, context)) return;

for (const member of node.body?.body ?? []) {
if (member.static) continue;
if (member.type !== 'MethodDefinition' && member.type !== 'PropertyDefinition') continue;
const name: string = member.key?.name ?? '';
if (name.startsWith('$')) {
context.report({
node: member.key,
messageId: 'noDollarPrefix',
data: { name: name.slice(1) },
});
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, it } from 'vitest';
import { tester } from '../utils/rule-tester.ts';
import { preferDestructuredLookups } from './prefer-destructured-lookups.ts';

describe('prefer-destructured-lookups', () => {
it('passes and fails correctly', () => {
tester.run('prefer-destructured-lookups', preferDestructuredLookups as any, {
valid: [
// Not a Base subclass
`class Foo { method() { this.$refs.btn; this.$refs.btn; } }`,
// Single access — no need to destructure
`import { Base } from '@studiometa/js-toolkit';
class Foo extends Base {
mounted() { this.$refs.btn.focus(); }
}`,
// Already destructured
`import { Base } from '@studiometa/js-toolkit';
class Foo extends Base {
mounted() { const { btn } = this.$refs; btn.focus(); btn.blur(); }
}`,
// Two different refs — each accessed once
`import { Base } from '@studiometa/js-toolkit';
class Foo extends Base {
mounted() { this.$refs.btn.focus(); this.$refs.input.value = ''; }
}`,
// Lookup outside a method (e.g. in a property initializer) — no enclosing method, should not warn
`import { Base } from '@studiometa/js-toolkit';
class Foo extends Base {
x = this.$refs.btn;
y = this.$refs.btn;
}`,
],
invalid: [
{
code: `import { Base } from '@studiometa/js-toolkit';
class Foo extends Base {
mounted() {
this.$refs.input.focus();
this.$refs.input.value = '';
}
}`,
errors: [{ messageId: 'preferDestructured' }],
},
{
code: `import { Base } from '@studiometa/js-toolkit';
class Foo extends Base {
mounted() {
this.$options.foo;
this.$options.foo;
}
}`,
errors: [{ messageId: 'preferDestructured' }],
},
{
code: `import { Base } from '@studiometa/js-toolkit';
class Foo extends Base {
mounted() {
this.$children.modal.open();
this.$children.modal.close();
}
}`,
errors: [{ messageId: 'preferDestructured' }],
},
],
});
});
});
Loading
Loading