@servicenow/eslint-plugin-aiux-ssr
v1.3.0
Published
ESLint rules for SSR-compatible Lit components in AIUX
Downloads
225
Readme
@servicenow/eslint-plugin-aiux-ssr
ESLint rules for SSR-compatible Lit components in the AIUX framework.
These rules prevent common SSR issues: hydration mismatches, server-side crashes from browser API access, and non-deterministic rendering.
Rules
| Rule | Severity | Fixable | Description | Suggested Fix |
| -------------------------------- | -------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| no-browser-globals-in-render | error | no | Detects window, document, navigator, localStorage, sessionStorage, location, history, screen, alert, confirm, prompt access in render() and constructor. Differentiates crash globals from mismatch globals (navigator). | Move to connectedCallback()/firstUpdated() + reactive property for client-only data. Use static loader() + createContext() for server-needed data. |
| no-module-level-browser-access | error | yes | Catches browser globals at module level without a guard. Supports typeof, globalThis.<global>, !isServer, and isServer else-branch guards. | Auto-fix: wraps with globalThis.<global>?.. Or move into a function/class method. |
| no-dom-reads-in-render | error | no | Catches getBoundingClientRect(), querySelector(), getComputedStyle(), offsetWidth/offsetHeight etc. in render and constructor. | Move DOM query to firstUpdated() or updated() and store result in a reactive property. |
| no-non-deterministic-apis | error | no | Flags Math.random(), new Date(), Date.now(), crypto.randomUUID(), performance.now() in render/constructor. | Compute in connectedCallback() and store as a reactive property, or pass a deterministic value via a context (createContext() + context.set() in loader). |
| no-side-effects-in-render | error | no | Prevents this.prop = val mutations, console.*, fetch(), dispatchEvent(), requestUpdate() in render. | Move mutations to updated(), data fetching to static loader(), event dispatch to connectedCallback(). |
| no-side-effects-in-constructor | error | no | Blocks addEventListener, setTimeout/setInterval, requestAnimationFrame/cancelAnimationFrame, requestIdleCallback/cancelIdleCallback, new IntersectionObserver/ResizeObserver/MutationObserver in constructors. Differentiates crash APIs (don't exist in Node.js) from leak APIs (exist but leak resources). | Move to connectedCallback() for setup and disconnectedCallback() for teardown. |
| require-loader-for-data | warn | no | Warns if fetch() is found in render/constructor of a component without a static loader() method. | Add static async loader(ctx) { ... }, create a context with createContext(), set data via context.set() in the loader, and read via context.get() in render. |
| prefer-isserver-guard | warn | no | Suggests using isServer from lit or globalThis.window instead of typeof window !== 'undefined' checks. | Replace with import { isServer } from 'lit' for Lit rendering context, or globalThis.window for runtime detection. |
| prefer-nothing | warn | yes | Flags empty string '' alternates in ternaries inside html tagged templates in render methods. Skips property (.prop=), attribute, and event (@event=) bindings where '' is semantically correct. | Auto-fix: replaces '' with nothing and adds the import if missing. |
| no-loader-data-in-render | warn | no | Detects this.loaderData and this.getLayoutData(...) usage in render methods of Lit components. Skips getter/method definitions in base class, non-render methods, and non-Lit classes. | Replace with createContext() + context.set() in the loader, declare static contexts = [...], and read via context.get() in render. |
| no-return-from-loader | warn | no | Detects return <expression> inside static loader() methods. Skips bare return;, return {} (no-op base class pattern), returns inside nested functions, and non-Lit classes. | Use createContext() + context.set() to share data instead of returning from loader. |
| require-context-declaration | warn | no | Detects identifier.get() (zero-argument .get() on a module-level identifier) in render methods of Lit classes that lack static contexts = [...]. Skips classes with contexts, .get(arg), this.get(), non-render methods, and non-Lit classes. | Add static contexts = [contextName] to the class so the framework can hydrate context values during SSR. |
| no-context-in-class | warn | no | Detects createContext() calls inside Lit component class bodies (static fields, methods, loader). Contexts are shared across components via import — creating inside a class couples the context to that class and prevents sharing. | Move createContext() to module level and export it. |
Auto-fix Details
Two rules support --fix: no-module-level-browser-access and prefer-nothing.
no-module-level-browser-access
Replaces direct browser global access with globalThis.<global>?. optional chaining, which safely returns undefined in Node.js instead of crashing.
Expression context (variable init, assignment RHS, template expression):
// before
const width = window.innerWidth;
this.theme = localStorage.getItem('theme');
const href = window.location.href;
// after --fix
const width = globalThis.window?.innerWidth;
this.theme = globalThis.localStorage?.getItem('theme');
const href = globalThis.window?.location.href;Statement context (standalone call, assignment to browser global property):
// before
document.title = 'Hello';
// after --fix
if (globalThis.document) {
document.title = 'Hello';
}The fixer walks the full member/call expression chain so window.navigator.userAgent and localStorage.getItem('theme') are replaced as complete units.
prefer-nothing
Replaces empty string alternates in html template ternaries with Lit's nothing sentinel, and adds import {nothing} from 'lit' if not already present.
// before
render() {
return html`${this.show ? html`<span>Badge</span>` : ''}`;
}
// after --fix
import {nothing} from 'lit';
render() {
return html`${this.show ? html`<span>Badge</span>` : nothing}`;
}The fixer skips property/attribute/event bindings (.value=, class=, @click=) where '' is the correct value to clear or unset.
Why the other 11 rules are not fixable
| Rule | Reason |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| no-browser-globals-in-render | Mechanical fixes (globalThis/typeof guard) still produce hydration mismatches — requires architectural change to context pattern or connectedCallback + reactive property |
| no-dom-reads-in-render | Needs code movement to firstUpdated/updated |
| no-non-deterministic-apis | No substitute for Math.random() or new Date() |
| no-side-effects-in-render | Mutations/fetches need architectural redesign |
| no-side-effects-in-constructor | setTimeout/addEventListener exist in Node — typeof guard is semantically wrong; needs move to connectedCallback |
| require-loader-for-data | Creating a static loader() method is not mechanical |
| prefer-isserver-guard | isServer from lit and typeof window have different semantics (Lit rendering context vs JS runtime) |
| no-loader-data-in-render | Requires creating a context with createContext(), moving data population to context.set() in the loader, adding static contexts declaration, and updating render to use context.get() — multi-step architectural change |
| no-return-from-loader | Requires replacing return with context.set() — not a mechanical transformation |
| require-context-declaration | Adding static contexts = [...] requires knowing which contexts are used — not fully mechanical |
| no-context-in-class | Moving createContext() out of a class requires restructuring exports and imports across consumers |
Key Design Decisions
- Render context includes
render()and any method starting withrender/_render(e.g.,renderLayout(),_renderPrimaryNav()) - Constructor context is also checked by
no-browser-globals-in-render,no-dom-reads-in-render, andno-side-effects-in-constructor - Loader methods (
static loader()) are excluded from all checks — they run server-side only - Safe contexts (not flagged):
connectedCallback,disconnectedCallback,firstUpdated,updated, event handlers - Guard patterns suppress violations. Supported patterns:
typeof <global> !== 'undefined'in if/ternary/logical-ANDglobalThis.<global>truthiness in if/ternary/logical-AND!isServerfrom lit in if/ternary/logical-ANDisServerin else/alternate branch (e.g.,if (isServer) { ... } else { /* guarded */ })
- Lit classes detected by:
@customElement()decorator OR extendingLitElement/AIUXElement/AIUXAppLayoutElement - Browser globals expanded to include:
window,document,navigator,localStorage,sessionStorage,location,history,screen,alert,confirm,prompt - Crash vs mismatch:
navigatorexists in Node.js 21+ but returns different values (hydration mismatch), while all other globals are undefined in Node.js (crash). Error messages differentiate these two categories. - Crash vs leak (constructor rule): APIs like
requestAnimationFrame,IntersectionObserverdon't exist in Node.js (crash), whilesetTimeout,addEventListenerexist but leak resources during SSR (leak). Error messages differentiate these two categories.
Usage
The plugin is configured in the root eslint.config.mjs via @servicenow/eslint-config-aiux/ssr. Rules only apply to:
components/*/src/**/*.jsapplications/*/pages/**/*.jsapplications/*/components/**/*.jsexamples/*/pages/**/*.js
Running
# Lint all workspace packages
pnpm lint
# Lint from an application directory
cd applications/aict && npx aiux lint
# Lint with auto-fix (fixes module-level browser globals + built-in rules like semi/quotes)
cd applications/aict && npx aiux lint --fixIDE Setup
VSCode
Install the recommended ESLint extension (dbaeumer.vscode-eslint). The workspace .vscode/settings.json enables auto-fix on save.
WebStorm
ESLint is built in. Go to Settings > Languages & Frameworks > JavaScript > Code Quality Tools > ESLint and select "Automatic ESLint configuration".
Testing
cd tools/eslint-plugin-aiux-ssr
npm testExamples
Violation: Browser global in render
@customElement('my-page')
class MyPage extends AIUXElement {
render() {
const w = window.innerWidth; // Error: browser global in render
return html`<div style="width: ${w}px"></div>`;
}
}Fix: Move to safe lifecycle
@customElement('my-page')
class MyPage extends AIUXElement {
connectedCallback() {
super.connectedCallback();
this.width = window.innerWidth; // OK: connectedCallback is client-only
}
render() {
return html`<div style="width: ${this.width || 0}px"></div>`;
}
}Why not globalThis in render?
globalThis.window?.innerWidth won't crash during SSR, but it returns undefined on the server while the client gets the real value. This causes a hydration mismatch (server HTML differs from client HTML), defeating the purpose of SSR. Use the context pattern (createContext() + context.set() in loader) for server-needed data, or connectedCallback + reactive property for client-only data.
