Oxlint plugin
Lint Ilha islands for primitive order, SSR APIs, and event props that TypeScript cannot check.
TypeScript types your props and accessors. It cannot see call order, render mode, or React-shaped event names. The Oxlint plugin flags those mistakes in the editor and in CI.
The plugin ships as @ilha/router/oxlint. It is a JavaScript Oxlint plugin — no extra compiler.
Install
Install Oxlint and @ilha/router if the app does not already depend on the router.
npm install -D oxlint @ilha/routerpnpm add -D oxlint @ilha/routeryarn add -D oxlint @ilha/routerbun add -D oxlint @ilha/routerEnable the plugin
Point Oxlint at the export and turn on the rules you want.
{
"jsPlugins": ["@ilha/router/oxlint"],
"rules": {
"oxlint-plugin-ilha/pascal-case": "error",
"oxlint-plugin-ilha/no-conditional-primitive": "error",
"oxlint-plugin-ilha/no-primitive-outside-island": "error",
"oxlint-plugin-ilha/prefer-lowercase-events": "error",
"oxlint-plugin-ilha/no-direct-island-call": "error",
"oxlint-plugin-ilha/require-ssr-api": "error",
"oxlint-plugin-ilha/function-in-state": "error",
"oxlint-plugin-ilha/prefer-plain-handler": "warn"
}
}
Save this as .oxlintrc.json at the repo root. Run oxlint in your lint script.
The plugin only treats state, derived, action, effect, onError, and ilha as primitives when you import them from "ilha". A router action() or a local action binding does not match.
What it checks
| Rule | Catches |
|---|---|
pascal-case |
const counter = ilha(...) — mount({ Counter }) matches the binding name |
no-conditional-primitive |
state / derived / action / effect / onError inside if, loops, or && |
no-primitive-outside-island |
Primitive calls at module scope or inside a non-component function |
prefer-lowercase-events |
onClick on a native tag or in an html template — use onclick |
no-direct-island-call |
Island(props) outside another island render |
require-ssr-api |
await Island(...) — use .toStringAsync() or .hydratable() |
function-in-state |
state(fn) or set(fn) when fn is a function value |
prefer-plain-handler |
action() when you never read .pending, .data, or .error |
prefer-lowercase-events ignores PascalCase components, so onCheckedChange on a design-system host stays valid.
Turn the new rules off on files that assert the bad cases:
{
"overrides": [
{
"files": ["**/*.test.ts", "**/*.test.tsx"],
"rules": {
"oxlint-plugin-ilha/no-conditional-primitive": "off",
"oxlint-plugin-ilha/no-primitive-outside-island": "off"
}
}
]
}
Primitive order
Call primitives at the top of the component, in the same order and kind on every render. Put the branch inside the primitive, not around it.
import { function derived<V>(fn: (ctx: {
signal: AbortSignal;
}) => V | Promise<V> | AsyncIterable<V>): DerivedAccessor<V>
Declare derived state at this call position: synchronous computations,
Promises, or async generators. Reads track their dependencies; async work
races latest-run-wins and aborts stale runs through `ctx.signal`.derived, const ilha: IlhaFactoryilha, function state<T>(init?: T | (() => T)): StateAccessor<T>Declare island-local reactive state at this call position. The initializer
applies only when the instance is created — later renders reuse the same
underlying signal, so prop-driven initializers never reset user state.
A function argument is treated as a lazy initializer:
const count = state(() => expensiveInitialValue());
To store a function VALUE, return it from the updater wrapper on write:
setCallback(() => nextCallback);state } from "ilha";
const const Panel: Island<{
open: boolean;
}>
Panel = ilha<{
open: boolean;
}>(component: IslandComponent<{
open: boolean;
}>): Island<{
open: boolean;
}> (+3 overloads)
ilha<{ open: booleanopen: boolean }>(({ open: booleanopen }) => {
const const label: DerivedAccessor<"Open" | "Closed">label = derived<"Open" | "Closed">(fn: (ctx: {
signal: AbortSignal;
}) => "Open" | "Closed" | Promise<"Open" | "Closed"> | AsyncIterable<"Open" | "Closed">): DerivedAccessor<"Open" | "Closed">
Declare derived state at this call position: synchronous computations,
Promises, or async generators. Reads track their dependencies; async work
races latest-run-wins and aborts stale runs through `ctx.signal`.derived(() => (open: booleanopen ? "Open" : "Closed"));
return <"p": WithRawHtmlAttributeValues<JSX.HTMLAttributes<HTMLParagraphElement>>p>{const label: () => "Open" | "Closed" | undefined (+1 overload)label()}</"p": WithRawHtmlAttributeValues<JSX.HTMLAttributes<HTMLParagraphElement>>p>;
});
// oxlint-plugin-ilha/no-conditional-primitive
const Panel = ilha(({ open }) => {
if (open) {
const label = state("Open");
return <p>{label()}</p>;
}
return <p>Closed</p>;
});
A PascalCase function that is not wrapped in ilha() is still a valid primitive frame. It belongs to the island that renders it.
Render on the server
Direct Island(props) calls are for child composition inside another island. For HTML, pick the API that matches the work:
import { ilha } from "ilha";
const Hello = ilha<{ name: string }>(({ name }) => <p>Hello, {name}</p>);
const syncHtml = Hello.toString({ name: "Ada" });
const asyncHtml = await Hello.toStringAsync({ name: "Ada" });
const hydrated = await Hello.hydratable(
{ name: "Ada" },
{ name: "Hello" },
);
require-ssr-api flags await Hello(...). no-direct-island-call flags Hello(...) at module scope.
Events and actions
Use lowercase DOM names on native elements. Reach for action() only when you need .pending, .data, .error, or cancellation.
import { function action<P, R>(fn: (payload: P, ctx: {
signal: AbortSignal;
}) => R): ActionAccessor<P, R>
Declare a reactive operation at this call position. Use plain functions
for ordinary operations; action() adds pending/data/error tracking,
concurrent-invocation bookkeeping, and lifecycle cancellation.action, const ilha: IlhaFactoryilha, function state<T>(init?: T | (() => T)): StateAccessor<T>Declare island-local reactive state at this call position. The initializer
applies only when the instance is created — later renders reuse the same
underlying signal, so prop-driven initializers never reset user state.
A function argument is treated as a lazy initializer:
const count = state(() => expensiveInitialValue());
To store a function VALUE, return it from the updater wrapper on write:
setCallback(() => nextCallback);state } from "ilha";
const const Save: Island<unknown>Save = ilha<unknown>(component: IslandComponent<unknown>): Island<unknown> (+3 overloads)ilha(() => {
const const count: StateAccessor<number>count = state<number>(init?: number | (() => number) | undefined): StateAccessor<number>Declare island-local reactive state at this call position. The initializer
applies only when the instance is created — later renders reuse the same
underlying signal, so prop-driven initializers never reset user state.
A function argument is treated as a lazy initializer:
const count = state(() => expensiveInitialValue());
To store a function VALUE, return it from the updater wrapper on write:
setCallback(() => nextCallback);state(0);
const const save: ActionAccessor<string, Promise<void>>save = action<string, Promise<void>>(fn: (payload: string, ctx: {
signal: AbortSignal;
}) => Promise<void>): ActionAccessor<string, Promise<void>>
Declare a reactive operation at this call position. Use plain functions
for ordinary operations; action() adds pending/data/error tracking,
concurrent-invocation bookkeeping, and lifecycle cancellation.action(async (value: stringvalue: string) => {
await function fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/fetch)fetch("/api", { RequestInit.method?: string | undefinedA string to set request's method.method: "POST", RequestInit.body?: BodyInit | null | undefinedA BodyInit object or null to set request's body.body: value: stringvalue });
});
const const bump: () => voidbump = () => const count: MarkedSignalAccessor
(value: SignalSetter<number>) => void (+1 overload)
count((value: numbervalue) => value: numbervalue + 1);
return (
<>
<"button": WithRawHtmlAttributeValues<JSX.HTMLAttributes<HTMLButtonElement> & ButtonAttributes & {
popovertarget?: string;
popoverTarget?: string;
popovertargetaction?: "hide" | "show" | "toggle";
popoverTargetAction?: "hide" | "show" | "toggle";
}>
button onclick?: NativeEventHandler<PointerEvent & {
readonly currentTarget: HTMLButtonElement;
}> | undefined
onclick={const bump: () => voidbump}>{const count: MarkedSignalAccessor
() => number (+1 overload)
count()}</"button": WithRawHtmlAttributeValues<JSX.HTMLAttributes<HTMLButtonElement> & ButtonAttributes & {
popovertarget?: string;
popoverTarget?: string;
popovertargetaction?: "hide" | "show" | "toggle";
popoverTargetAction?: "hide" | "show" | "toggle";
}>
button>
<"button": WithRawHtmlAttributeValues<JSX.HTMLAttributes<HTMLButtonElement> & ButtonAttributes & {
popovertarget?: string;
popoverTarget?: string;
popovertargetaction?: "hide" | "show" | "toggle";
popoverTargetAction?: "hide" | "show" | "toggle";
}>
button
disabled?: boolean | undefineddisabled={const save: ActionAccessor<string, Promise<void>>save.pending: booleanpending}
onclick?: NativeEventHandler<PointerEvent & {
readonly currentTarget: HTMLButtonElement;
}> | undefined
onclick={() => const save: (payload: string) => voidsave("ok")}
>
{const save: ActionAccessor<string, Promise<void>>save.pending: booleanpending ? "Saving…" : "Save"}
</"button": WithRawHtmlAttributeValues<JSX.HTMLAttributes<HTMLButtonElement> & ButtonAttributes & {
popovertarget?: string;
popoverTarget?: string;
popovertargetaction?: "hide" | "show" | "toggle";
popoverTargetAction?: "hide" | "show" | "toggle";
}>
button>
</>
);
});
prefer-plain-handler warns if save never reads a status field. Store a function in state with an updater wrapper: callback(() => nextFn).