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
9 changes: 9 additions & 0 deletions .changeset/message-polish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'onsystem': patch
---

An unknown component that looks like a part of a family the design system has, but is like none of its parts, is no longer taken for the part spelled closest. `DialogContent` in a design system on React Aria, whose `Dialog` is the content, got "Did you mean <DialogFooter>?"; it now gets "Did you mean <Dialog>, or one of its parts: DialogTrigger, DialogHeader, DialogFooter, DialogTitle?", with no rename. So do `DropdownMenuContent` and `ContextMenuContent` there, which were taken for the submenu's `DropdownMenuSubContent` and `ContextMenuSubContent`. A misspelled part (`DialogTitel`, `AvatarImg`) is still taken for that part, while a part unlike the one spelled closest no longer is: `SidebarDivider` was taken for `SidebarProvider`, `CardToggle` for `CardTitle` and `CarouselProvider` for `CarouselPrevious`.

`asChild` on a React Aria trigger (`<DialogTrigger asChild>`), which renders no element of its own, now says that the trigger takes the element that opens it as a child: a button, a menu item for a submenu's trigger, and the area you right-click for a context menu's. On a React Aria component, whose `render` is a function, `asChild`, `as`, `component` and `href` no longer get Base UI's advice (`render={<Link href="…" />}`), which React Aria's `render` does not take: they say to use a link component for a link, or to pass `href` to a component that takes it (`SidebarMenuButton`, `BreadcrumbLink`). A `render` function of the design system's own keeps the old advice.

The ESLint plugin no longer waits two minutes on a file when its loader worker stopped before it answered, as `process.exit()` in an `onsystem.config.mjs`, an uncaught error or an unhandled rejection makes it: every file of the run, a file sent to it while it was busy too, fails at once with "the design system's loader stopped before it answered", and why.
20 changes: 10 additions & 10 deletions docs/rules.md

Large diffs are not rendered by default.

27 changes: 18 additions & 9 deletions src/eslint/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,27 @@ import type { Call, LoadRequest, LoadResponse } from './worker.js';
const TIMEOUT_MS = 120_000;

let worker: Worker | undefined;
/**
* Set to 1 by the worker's exit handler, before it answers what it was asked. Its `exit` event
* reaches this thread only when ESLint's next file is already waiting on the stopped worker.
*/
let stopped = new Int32Array(new SharedArrayBuffer(4));

function start(): Worker {
if (worker) return worker;
worker = new Worker(new URL('./eslint-worker.js', import.meta.url));
worker.unref();
worker.on('error', () => {
worker = undefined;
});
worker.on('exit', () => {
worker = undefined;
if (worker && Atomics.load(stopped, 0) === 0) return worker;
stopped = new Int32Array(new SharedArrayBuffer(4));
const current = new Worker(new URL('./eslint-worker.js', import.meta.url), {
workerData: { stopped },
});
return worker;
current.unref();
// A stopped worker's events come after its successor started: they must not forget that one.
const forget = () => {
if (worker === current) worker = undefined;
};
current.on('error', forget);
current.on('exit', forget);
worker = current;
return current;
}

export function loadSync(request: LoadRequest): LoadResponse {
Expand Down
50 changes: 45 additions & 5 deletions src/eslint/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { parentPort, type MessagePort } from 'node:worker_threads';
import {
parentPort,
receiveMessageOnPort,
workerData,
type MessagePort,
} from 'node:worker_threads';

import { excludedBy, type ResolvedConfig } from '../config.js';
import { buildModel, componentFiles, fingerprint } from '../design-system.js';
Expand Down Expand Up @@ -249,11 +254,46 @@ export async function load(request: LoadRequest): Promise<LoadResponse> {
};
}

parentPort?.on('message', ({ request, signal, port }: Call) => {
/** Calls not answered yet: the answer wakes the ESLint thread that waits for it. */
const pending = new Set<Call>();

function answer({ signal, port }: Call, response: LoadResponse): void {
port.postMessage(response);
Atomics.store(signal, 0, 1);
Atomics.notify(signal, 0);
}

if (parentPort) {
const port = parentPort;
// A worker that stops before it answers (`process.exit()` in a config written in code, an
// uncaught error) left ESLint waiting the bridge's whole timeout on every file, with a message
// that blamed a slow load. Its exit handlers still run, so they answer with why it stopped, and
// tell the bridge to start another worker for the next file.
let crash: unknown;
process.on('uncaughtExceptionMonitor', (error) => {
crash = error;
});
process.on('exit', (code) => {
// First, so that the next file starts another worker instead of waiting on this one.
const { stopped } = (workerData ?? {}) as { stopped?: Int32Array };
if (stopped) Atomics.store(stopped, 0, 1);
const why = crash instanceof Error ? `: ${crash.message}` : ` (exit code ${String(code)})`;
// And the calls it never read, posted while it was busy (a config's timer that blocks, then
// exits): they are still on its port, and would wait the bridge's whole timeout too.
let queued: { message: unknown } | undefined;
while ((queued = receiveMessageOnPort(port))) pending.add(queued.message as Call);
for (const call of pending) {
answer(call, { error: `the design system's loader stopped before it answered${why}` });
}
pending.clear();
});
}

parentPort?.on('message', (call: Call) => {
const { request } = call;
pending.add(call);
const reply = (response: LoadResponse) => {
port.postMessage(response);
Atomics.store(signal, 0, 1);
Atomics.notify(signal, 0);
if (pending.delete(call)) answer(call, response);
};
load({ ...request, file: path.resolve(request.cwd, request.file) }).then(
reply,
Expand Down
136 changes: 123 additions & 13 deletions src/lint/rules/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,52 @@ function enclosingComponent(node: ts.Node): string | undefined {

// ─── no-unknown-component ───────────────────────────────────────────────────

/**
* The component an unknown name most likely means: the closest spelling (`DialogTitel` is
* `DialogTitle`), unless the name is a part of a family that has no such part. `DialogContent`
* in a system whose Dialog is the content (React Aria) was spelled closest to another part,
* `DialogFooter`, which means something else, and `DropdownMenuContent` to
* `DropdownMenuSubContent`, a submenu's: the family's component is named instead, with its
* parts. Undefined when nothing is close.
*/
function guessComponent(
name: string,
target: RuleContext['target'],
): { name: string; parts?: string[] } | undefined {
const guess = closest(name, target.names());
// The longest component name that `name` starts with, before another word: `Dialog`.
const family = target
.names()
.filter(
(n) => n.length < name.length && name.startsWith(n) && /[A-Z]/.test(name[n.length] ?? ''),
)
.sort((a, b) => b.length - a.length)[0];
if (family === undefined) return guess === undefined ? undefined : { name: guess };
// Another part of the family, spelled like this one but for the family's name, is a typo of it:
// within a third of the part's length (`Separatr`), or two edits of a short one (`Titel`, `Img`
// for `Image`). Half its length took unlike parts for typos (`Divider` for `Provider`, `Toggle`
// for `Title`). A part that is this one with a word in front (`SubContent` for `Content`) is
// another part.
const part = name.slice(family.length);
const sibling = guess !== undefined && guess !== family && guess.startsWith(family);
const other = sibling ? guess.slice(family.length) : '';
const typo =
!(other.length > part.length && other.endsWith(part)) &&
editDistance(part.toLowerCase(), other.toLowerCase()) <=
Math.max(2, Math.floor(part.length / 3));
if (sibling && typo) return { name: guess };
const parts = target.components.get(family)?.subcomponents ?? [];
if (parts.length) return { name: family, parts };
// A part unlike any of the family's, of a component without parts (`ButtonGroup` next to
// `Button`), is another component: not a sibling's name.
return guess === undefined || sibling ? undefined : { name: guess };
}

/** `, or one of its parts: DialogTrigger, DialogFooter` for a family's component. */
function partsOf(guess: { parts?: string[] }): string {
return guess.parts ? `, or one of its parts: ${guess.parts.join(', ')}` : '';
}

export const noUnknownComponent: Rule = {
id: 'no-unknown-component',
description:
Expand Down Expand Up @@ -338,36 +384,37 @@ export const noUnknownComponent: Rule = {
}

if (resolution.kind === 'missing-export') {
const guess = closest(resolution.name, target.names());
const guess = guessComponent(resolution.name, target);
context.report({
...range,
message:
`"${resolution.name}" is not a design-system component (imported from "${resolution.source}").` +
(guess
? ` Did you mean <${guess}>?`
? ` Did you mean <${guess.name}>${partsOf(guess)}?`
: ' Use search_components to find an existing one.'),
suggestion: guess ? `<${guess}>` : undefined,
suggestion: guess ? `<${guess.name}>` : undefined,
});
continue;
}

if (resolution.kind === 'unresolved') {
const guess = closest(resolution.name, target.names());
const component = guess ? target.components.get(guess) : undefined;
const guess = guessComponent(resolution.name, target);
const component = guess ? target.components.get(guess.name) : undefined;
if (guess && component) {
// A close name is only a safe rename when it is in scope: `<Cards>` in a
// fragment without imports may be an app component the agent has not
// imported yet, not a typo of `<Card>`.
const binding = context.analysis.imports.get(guess.split('.')[0] ?? guess);
const binding = context.analysis.imports.get(guess.name.split('.')[0] ?? guess.name);
const inScope =
binding !== undefined && target.isDesignSystemImport(binding.source, context.file);
context.report({
...range,
message: inScope
? `Unknown component <${resolution.name}>. Did you mean <${guess}>?`
: `Unknown component <${resolution.name}>. Did you mean <${guess}> (${importLine(component, context)})?`,
suggestion: `<${guess}>`,
fix: inScope ? renameTag(context, element, guess) : undefined,
? `Unknown component <${resolution.name}>. Did you mean <${guess.name}>${partsOf(guess)}?`
: `Unknown component <${resolution.name}>. Did you mean <${guess.name}> (${importLine(component, context)})${partsOf(guess)}?`,
suggestion: `<${guess.name}>`,
// The family's component is another component, not a spelling of this one.
fix: inScope && !guess.parts ? renameTag(context, element, guess.name) : undefined,
});
} else if (context.analysis.imports.size > 0) {
// Only for whole modules: fragments without imports routinely use
Expand Down Expand Up @@ -443,22 +490,85 @@ const PROP_EQUIVALENTS: string[][] = [
/** Props other libraries compose with, which no prop of a similar spelling stands in for. */
const FOREIGN_COMPOSITION = new Set(['href', 'as', 'component']);

/** React Aria's packages: a component whose props come from one is built on React Aria. */
const REACT_ARIA = /^(?:react-aria-components|react-aria|@react-aria\/|@react-types\/)/;

/**
* Whether the component's `render` is React Aria's, a function that returns an element of the
* same kind: `DOMRenderFunction<"button", …>`, or that type spelled out
* (`((props: …, renderProps: …) => …)`) on a component whose props come from React Aria. A
* render function of the design system's own (`(props) => ReactNode`) may return a link.
*/
function reactAriaRender(component: ComponentInfo): boolean {
const type = component.props.find((p) => p.name === 'render')?.type;
if (type === undefined) return false;
if (/^\(*DOMRenderFunction</.test(type)) return true;
return (
/^\(*props\b/.test(type) &&
(REACT_ARIA.test(component.library ?? '') ||
component.inherits.some((inherited) => REACT_ARIA.test(inherited.from)))
);
}

/**
* The child a trigger that composes neither way takes, as React Aria's do: a button opens a
* dialog, menu, popover or tooltip, a menu item a submenu, and a context menu opens on a
* right-click anywhere in the area it wraps. Other triggers name no element.
*/
function triggerHint(tag: string): string {
if (/ContextMenuTrigger$/.test(tag)) {
return `It takes the area that opens the menu on a right-click as a child: <${tag}>…</${tag}>.`;
}
const child = /Sub(?:menu)?Trigger$/.test(tag)
? 'MenuItem'
: /(?:Dialog|Sheet|Drawer|Menu|Popover|Tooltip)Trigger$/.test(tag)
? 'Button'
: undefined;
return `It takes the element that opens it as a child: <${tag}>${child ? `<${child}>…</${child}>` : ''}…</${tag}>.`;
}

/**
* Radix composes with `asChild` and a child element, Base UI with a `render`
* element; shadcn/ui ships both, and agents mix them up, or bring `as`, `component`
* or `href` from other libraries. Renaming the prop is not enough (`asChild` is a
* boolean, and a button with an `href` is still no link), so this explains the
* other way instead of offering a fix.
* other way instead of offering a fix. React Aria has neither: a trigger takes the
* element that opens it as a child, and `render` is a function that returns an
* element of the same kind, so a link is a link component, or the component's own `href`.
*/
function compositionHint(
name: string,
known: Set<string>,
tag: string,
): { prop: string; text: string } | undefined {
component: ComponentInfo,
): { prop?: string; text: string } | undefined {
const radix = `compose with asChild and a single child`;
const radixExample = `<${tag} asChild><Link href="…">…</Link></${tag}>`;
const base = `compose with render`;
const baseExample = `<${tag} render={<Link href="…" />}>…</${tag}>`;
const composing = ['asChild', 'as', 'component', 'render'].includes(name);
// React Aria's triggers render no element of their own; one that renders a button takes none.
if (
composing &&
!known.has('asChild') &&
!known.has('render') &&
component.element === undefined &&
/Trigger$/.test(tag)
) {
return { text: triggerHint(tag) };
}
if (reactAriaRender(component) && (composing || name === 'href')) {
const link = 'For a link, use a link component:';
if (name === 'href') {
return { text: `${link} React Aria’s render only swaps in an element of the same kind.` };
}
const none = `React Aria components take no "${name}" prop.`;
return known.has('href')
? { prop: 'href', text: `${none} For a link, pass href: <${tag} href="…">…</${tag}>.` }
: {
text: `${none} ${link} render={(props) => …} only swaps in an element of the same kind.`,
};
}
if (name === 'href') {
if (known.has('asChild')) {
return { prop: 'asChild', text: `For a link, Radix components ${radix}: ${radixExample}.` };
Expand Down Expand Up @@ -550,7 +660,7 @@ export const noUnknownProp: Rule = {
) {
continue;
}
const composition = compositionHint(name, known, element.tag);
const composition = compositionHint(name, known, element.tag, component);
if (composition) {
context.report({
start: attribute.name.getStart(context.sourceFile),
Expand Down
Loading
Loading