Lint rules for Lit Web Components, for deno lint.
Good for letting an AI write your components: the rules catch the Lit mistakes a model makes. Battle-tested on real code.
Five plugins. Each says something different about the code it rejects:
| Plugin | Says | Rules |
|---|---|---|
/core |
This does not do what it looks like it does. | 51 |
/strict |
Demanding, but we think you should. | 13 |
/dom-ref |
Reach the DOM through a named ref callback. |
3 |
/reactive-controller |
Anything with a lifetime belongs in a controller. | 7 |
/naming |
What things are called. | 7 |
Add the ones you want:
{
"lint": {
"plugins": [
"jsr:@cunarist/deno-lint-plugin-lit/core",
"jsr:@cunarist/deno-lint-plugin-lit/strict"
]
}
}Adding a plugin turns on every rule in it. Rule ids are the plugin name plus the rule, so turn one off like this:
{ "lint": { "rules": { "exclude": ["lit-reactive-controller/no-timers"] } } }All rules assume standard decorators — the TC39 proposal, which is the
default in Deno and in TypeScript 5. experimentalDecorators is the older,
separate proposal; if you have it switched on, several rules here will be wrong
for you.
// lint.ts
import { coreRules } from "jsr:@cunarist/deno-lint-plugin-lit/core";
import { noTimers } from "jsr:@cunarist/deno-lint-plugin-lit/reactive-controller";
const plugin: Deno.lint.Plugin = {
name: "my-lit",
rules: { ...coreRules, "no-timers": noTimers },
};
export default plugin;{ "lint": { "plugins": ["./lint.ts"] } }Each entry point exports its rules individually plus the whole group as one
record: coreRules, strictRules, domRefRules, reactiveControllerRules,
namingRules.
The rule to read first is simple-template-expressions: a binding is a name,
not an expression. Compute before the return and interpolate by name.
Directives included — repeat(...) is a call like any other.
// BAD - all the same violation
html`<div>${repeat(this.items, k, v)}</div>`;
html`<div @click=${() => this.go()}></div>`;
html`<ul>${this.items.map((i) => html`<li>${i}</li>`)}</ul>`;
html`<div>${this.ready ? a : b}</div>`;
html`<div>${this.items[0]}</div>`;
// GOOD
const renderedItems = repeat(this.items, (i) => i.id, this.#renderItem);
return html`<div @click=${this.#onClick}>${renderedItems}</div>`;This is stricter than Lit's own documentation, which puts conditionals and
.map() straight into templates. The trade is that render() becomes a
description rather than a program.
| Rule | Catches |
|---|---|
attribute-value-entities |
an unescaped &, <, > or " inside a static attribute value in an html template |
binding-positions |
a ${…} binding used as a tag name, in a closing tag, or as an attribute name |
composed-requires-bubbles |
an event constructed with composed: true but without bubbles: true |
lifecycle-super |
an override of a Lit lifecycle callback that never calls its own super implementation |
no-array-mutation-without-reassign |
calling a mutating array method on a reactive property |
no-async-lifecycle |
async on a Lit lifecycle hook |
no-async-render |
an async render() on a Lit component |
no-attribute-property-binding-conflict |
the same name bound as both an attribute and a property on one element |
no-classfield-shadowing |
a plain class field whose name matches a reactive property |
no-context-mutation-by-consumer |
assignment to a field declared with @consume |
no-dispatch-in-render |
calling dispatchEvent(...) from render() |
no-duplicate-context-provider |
two @provide declarations for the same context object on one class |
no-duplicate-property-declaration |
a property declared by both a decorator and a static properties entry |
no-duplicate-slot-names |
two <slot> elements with the same name in one template |
no-duplicate-tag-registration |
registering the same custom element tag twice in one file |
no-duplicate-template-bindings |
the same attribute, property, boolean or event binding appearing twice on one element |
no-index-as-repeat-key |
a repeat key function that returns its own index parameter |
no-inline-event-attribute |
HTML inline event handler attributes such as onclick inside an html template |
no-inner-html-assignment |
assignment to innerHTML or outerHTML on any receiver |
no-invalid-escape-sequences |
a malformed \x or \u escape inside an html template |
no-invalid-html |
markup inside an html template that does not parse as valid HTML |
no-jsx-attribute-names |
the JSX attribute names className and htmlFor in a template |
no-legacy-imports |
imports from the Lit 1 module paths lit-html and lit-element, and imports of names that Lit 2 removed |
no-legacy-template-syntax |
Polymer-style [[oneWay]] and {{twoWay}} bindings inside an html template |
no-multiple-default-slots |
two or more unnamed <slot> elements in one template |
no-native-attributes |
a reactive property named after a global HTML attribute |
no-partial-property-binding |
a property, event or boolean binding that is not the entire attribute value |
no-private-properties |
@property on a field declared with a # private name |
no-property-change-in-updated |
assigning to a reactive property inside updated() or firstUpdated() |
no-property-change-update |
assigning to a reactive property inside update() |
no-property-named-like-lifecycle |
a class field or reactive property named after a Lit lifecycle member |
no-request-update-in-updated |
calling this.requestUpdate() inside updated() or firstUpdated() |
no-script-in-template |
a <script> element inside a Lit template |
no-self-closing-non-void |
self-closing syntax on a non-void element in a template |
no-this-assign-in-render |
assigning to anything reached through this inside render() |
no-this-in-static-styles |
this inside a static styles initialiser |
no-unsafe-css |
unsafeCSS imported from Lit — both the import specifier and every call site |
no-unsafe-html |
the unsafeHTML and unsafeSVG directives — both the import specifier and every call site |
no-useless-template-literals |
an html template whose entire content is one binding and no markup |
no-value-attribute |
a bound value=${…} attribute on a form control; use the property binding .value=${…} |
prefer-static-styles |
a <style> element inside an html template |
require-accessor-with-decorators |
a @property or @state decorator on a plain class field instead of an accessor field |
require-context-type |
a createContext() call from @lit/context with no explicit type argument |
require-dashed-tag |
a custom element name the registry will not accept |
require-dispatch-on-this |
a component event dispatched on something other than the component itself |
require-property-type |
a @property that has an attribute but declares no {type: …} |
require-repeat-key |
repeat(items, template) — the two-argument form, with no key function |
require-scalar-reflect |
reflect: true unless the options prove the value is a scalar |
simple-template-expressions |
Every ${…} binding in an html template must be an identifier, this, or a non-computed member chain — nothing else |
svg-template-for-svg-content |
SVG-only elements written directly in an html template with no enclosing <svg> |
value-after-constraints |
a value binding that appears before a validity constraint attribute on the same form control |
Correct, and worth adopting — but they ask more of you than /core does, and a
few will fire on code that works. createContext("key") is valid Lit. A class
registered from a barrel file looks unregistered here. Light DOM is a real Lit
feature that this ruleset declines to use.
| Rule | Catches |
|---|---|
directive-allowlist |
every import from a Lit directives/ module except lit/directives/ref.js and lit/directives/repeat.js |
no-boolean-property-default-true |
a boolean reactive property whose default is not literal false |
no-event-target-subclass |
a class that extends EventTarget |
no-light-dom |
createRenderRoot() returning this, which renders the component into the light DOM |
no-manual-update |
a Lit component scheduling its own update with requestUpdate, performUpdate, or scheduleUpdate |
no-property-assignment-in-constructor |
assigning a reactive property inside constructor() |
no-string-context-key |
a string literal as the key passed to createContext() |
no-update-complete |
any access to updateComplete |
prefer-context-decorators |
constructing a ContextProvider or ContextConsumer by hand inside a Lit component |
prefer-decorators |
a static properties declaration on a Lit component |
require-custom-element-registration |
a LitElement subclass that is never registered, with neither @customElement nor customElements.define |
require-event-in-event-map |
an event constructed inside a Lit component whose name has no matching HTMLElementEventMap entry in the same file |
require-tag-name-map |
a component registered with @customElement that has no matching HTMLElementTagNameMap entry in the same file |
One idea: the only way to reach an element is a ref bound to a createRef.
@query answers two questions badly: which element, and when.
- Which — a selector matches by shape.
@query("input")means "whichever input comes first". Add one above it and you silently get a different element. - When — it is a lazy
querySelector. It answers any time you ask, includingnullbefore the first render.
A ref settles both: it is bound to one position, and Lit fills its createRef
as the element attaches and clears it as it goes away.
// BAD
class Bad extends LitElement {
@query("#input")
accessor input;
override render() {
return html`<input id="input">`;
}
}
// GOOD
class Good extends LitElement {
#input = createRef<HTMLInputElement>();
override render() {
const inputRef = ref(this.#input);
return html`<input ${inputRef}>`;
}
}| Rule | Catches |
|---|---|
no-dom-query |
querySelector and querySelectorAll inside a Lit component |
no-query-decorators |
@query, @queryAll, @queryAsync, @queryAssignedElements, and @queryAssignedNodes, and their imports from the Lit decorator modules |
prefer-create-ref |
a ref callback that only stashes the element, in favour of createRef |
A ReactiveController is a unit of behaviour with no UI. It keeps components
small, pairs acquisition with release so a resource cannot leak, and turns
"watch this element's size" into something named and reusable.
class ItemsController implements ReactiveController {
#host: ReactiveControllerHost;
#abort: AbortController | undefined;
constructor(host: ReactiveControllerHost) {
this.#host = host;
host.addController(this);
}
hostConnected(): void {
this.#abort = new AbortController();
}
hostDisconnected(): void {
this.#abort?.abort();
this.#abort = undefined;
}
}Components keep only styles and render. Everything else — listeners, timers,
sockets, fetches — moves here, where hostConnected and hostDisconnected sit
next to each other.
| Rule | Catches |
|---|---|
host-constructor |
a controller constructor that is not exactly constructor(host: ReactiveControllerHost) |
lifecycle-allowlist |
any lifecycle override on a Lit component other than styles and render |
no-component-disposables |
constructing AbortController, EventSource, IntersectionObserver, MutationObserver, ResizeObserver, WebSocket, or Worker inside a Lit component, and rejects calling addEventListener or removeEventListener there |
no-fetch-in-component |
a fetch(...) call inside a Lit component class |
no-timers |
setTimeout, setInterval, requestAnimationFrame, and queueMicrotask inside a Lit component |
paired-lifecycle |
a controller that defines hostConnected or hostDisconnected but not both |
self-registration |
a controller that does not call host.addController(this) in its constructor |
What things are called. Some of these are pure convention — a reader can tell an element from a controller from a plain class at a glance. Others catch a real trap: an uppercase attribute or event name is silently lowercased or never matched, so the binding quietly does nothing. Either way the fix is a rename, so they live together.
| Rule | Catches |
|---|---|
attribute-names |
a camelCase reactive property that does not declare an explicit attribute option |
event-name-case |
an event name that is not kebab-case — uppercase or _ — in new CustomEvent(...)/new Event(...) |
no-camelcase-attribute |
an attribute name that is not kebab-case — uppercase or _ |
require-controller-suffix |
a reactive controller class whose name does not end in Controller |
require-element-suffix |
a registered component class whose name does not end in Element |
require-tag-prefix |
a custom element name that carries no namespace segment |
tag-matches-class-name |
a @customElement tag whose segments, after any leading prefix, do not PascalCase to the class name |
- No rule options. Deno's plugin API has no way to configure a rule, so none of these take settings.
- One file at a time. Rules that need a superclass chain or a registration site only see the current module.
- Unclosed tags are under-reported.
<li>,<td>, and<p>may legally omit their closing tag, sono-invalid-htmlleaves them alone.
MIT. See LICENSE for third-party attribution.