Skip to content

fix(lint): unknown family parts, React Aria composition hints, and a stopped ESLint loader - #265

Merged
dgesteves merged 5 commits into
mainfrom
fix/os2-59-message-polish
Oct 30, 2026
Merged

dgesteves merged 5 commits into
mainfrom
fix/os2-59-message-polish

Conversation

@dgesteves

@dgesteves dgesteves commented Oct 30, 2026 •

Copy link
Copy Markdown
Owner

Part of the OS2-59 polish bundle: the message and ESLint area. The CLI and Action area was #264 and the package and contributor area #266, both merged. None of them share a file.

What was wrong

  • In a shadcn/ui style built on React Aria, import { DialogContent } from "@/components/ui/dialog" (there is none: Dialog is the content) got Did you mean <DialogFooter>?, the part spelled closest, which means something else. DropdownMenuContent and ContextMenuContent got the submenu's DropdownMenuSubContent and ContextMenuSubContent, renamed when in scope.
  • <DialogTrigger asChild> on a React Aria trigger got <DialogTrigger> has no prop "asChild". with no hint, while Base UI and Radix get one.
  • On a React Aria component, whose render prop is a function (DOMRenderFunction, or that type spelled out, ((props: …, renderProps: …) => …), as on shadcn/ui's SidebarMenuButton), asChild, as, component and href got Base UI's advice, compose with render instead: <Button render={<Link href="…" />}>, which React Aria's render does not take: it must return an element of the same kind.
  • When the ESLint plugin's loader worker stopped before it answered (process.exit(1) in an onsystem.config.mjs, an uncaught error or an unhandled rejection), a file waited the bridge's whole 120-second timeout and then said the design system "did not load within 120 seconds". The worker's exit event reaches the bridge only after ESLint has moved on, so later files of the run were posted to the stopped worker too.

What changed

  • src/lint/rules/components.ts
    • guessComponent(): when an unknown name starts with a component's name and another word (Dialog + Content), and the closest spelling is another part of that family that is not a typo of this part, or there is no close spelling, it names the family's component and lists its parts: Did you mean <Dialog>, or one of its parts: DialogTrigger, DialogHeader, DialogFooter, DialogTitle?, with no rename. A typo of the part is within a third of its length or two edits of it (DialogTitel, DialogHeadr, AvatarImg for AvatarImage), and keeps the rename when it is in scope; half its length took unlike parts for typos, as main does (CardToggle for CardTitle, CarouselProvider for CarouselPrevious, SelectOption for SelectSection); a part that is this one with a word in front (SubContent for Content) is another part. A component without parts is not offered for a name that only starts with it (ButtonGroup next to Button).
    • compositionHint(): a component named *Trigger that has neither asChild nor render and renders no element of its own, as React Aria's DialogTrigger, MenuTrigger and SubmenuTrigger do, says, for asChild, as, component or render, that it takes the element that opens it as a child: <DialogTrigger><Button>…</Button>…</DialogTrigger> for dialog, sheet, drawer, menu, popover and tooltip triggers, <SubmenuTrigger><MenuItem>…</MenuItem>…</SubmenuTrigger> for a submenu's, the area that opens the menu on a right-click for ContextMenuTrigger, and no element for others. A trigger that renders its own <button> gets main's message, <DialogTrigger> has no prop "asChild"., since a button inside it would nest one button in another. A component whose render is React Aria's says, for asChild, as and component, React Aria components take no "asChild" prop., then For a link, pass href: <SidebarMenuButton href="…">…</SidebarMenuButton>. when it takes href (suggestion href), or that a link is a link component; for href, that a link is a link component. render counts as React Aria's when its type is DOMRenderFunction<…>, or a function of props on a component whose props come from React Aria (@react-types/*, react-aria-components); a render function of the design system's own ((props) => ReactNode, shadcn/ui's AttachmentTrigger) keeps the advice it had.
  • src/eslint/worker.ts: the worker keeps the calls it has not answered. If it stops, its exit handler (which runs for process.exit(), an uncaught exception and an unhandled rejection in a worker thread) first sets a stopped flag it shares with the bridge, then takes the calls still on its port, posted while it was busy and never read (receiveMessageOnPort), and answers those and the ones it was working on with the design system's loader stopped before it answered, plus the error's message (from uncaughtExceptionMonitor, which does not change how the worker ends) or the exit code. The background refresh from fix(eslint): non-blocking refresh, config edits, rule options and suppression warnings #255 (setImmediate, looking()) is unchanged and its tests pass.
  • src/eslint/bridge.ts: the stopped flag (an Int32Array on a SharedArrayBuffer, passed in workerData) makes the next file start another worker; a stopped worker's error and exit events forget it only while it is still the current one.
  • docs/rules.md: the no-unknown-component and no-unknown-prop rows describe the family guess and the React Aria hints.

How it was tested

  • test/component-guesses.test.ts: the family case as a missing export and as an unresolved tag without a rename; DropdownMenuContent and ContextMenuContent next to their *SubContent parts, imported, without a rename; AvatarImg taken for AvatarImage with the rename; misspelled parts that keep their rename; ButtonGroup next to Button; on test/fixtures/rac-ui (now with menu.tsx and sidebar.tsx), the trigger hints for DialogTrigger, MenuTrigger, SubmenuTrigger and ContextMenuTrigger, the asChild/href hints on React Aria's Button, and href for SidebarMenuButton, whose render type starts with ((props: ; and the old advice for an AttachmentTrigger with a render function of its own. 6 of its 11 tests failed on 3cc1fe0. Added since: a DialogTrigger that renders a button gets no trigger hint, and CardToggle and CarouselProvider get their family, while CardContnt keeps its rename; both tests fail on 09874ea.
  • test/eslint-worker-exit.test.ts: ESLint with the plugin, built as it ships, lints three files of a project whose onsystem.config.mjs calls process.exit(1), throws from setImmediate, or leaves a rejected promise. Each file fails with onsystem could not check this file: the design system's loader stopped before it answered, plus (exit code 1), : boom in config or : unhandled in config. The three tests took 3.15 s together; the process.exit one on 3cc1fe0 took 120,505 ms and failed its "under 30 s" check. A fourth test lints one file, then has the config block for 3 seconds and call process.exit(3), and lints a second file while it is blocked: that file fails with (exit code 3). On 09874ea it took 120,015 ms and failed the same check; here the four tests took 710, 665, 679 and 3,444 ms.
  • By hand, with the built plugin on three files: 708 ms for process.exit(1), 709 ms for the uncaught error and 708 ms for the unhandled rejection, every file with the reason; a worker that exits between lints (setTimeout(() => process.exit(0), 1500), 3 s apart) is replaced, 260 ms for the next file. A config that blocks for 1.5 s from 400 ms after it loads and then calls process.exit(3), with one file linted, then another 800 ms later: the first is checked in about 500 ms, the second fails with (exit code 3) in 870 to 876 ms (three runs, Node 24.18.0).
  • check app on shadcn/ui's aria, base and radix registries and React Aria's starter, against this PR's previous commit: on aria, 76 of 455 findings changed (the quoted prop, href for components that take it, the trigger examples, the *SubContent and AvatarImg guesses, and the old advice back for Badge and AttachmentTrigger); on base and radix, only AvatarImg (3 findings each); on the starter, 29 (the quoted prop, href, SubmenuTrigger). On material-kit-react, no finding changed. Then against 09874ea: the third-of-length typo test changed 6 of 455 findings on aria, 6 of 426 on base and 6 of 429 on radix (CardSubtitle and EmptyStateTitle get Card and Empty with their parts, not CardTitle and EmptyTitle), 2 of 149 on the starter (SelectOption gets Select and its parts, not SelectSection), and none of 107 on material-kit-react. The trigger change changed no finding on any of them.

Gate: pnpm install --frozen-lockfile --prefer-offline, pnpm format:check, pnpm lint, pnpm typecheck, pnpm test (114 files passed, 1 skipped; 1476 tests passed, 3 skipped), pnpm build and pnpm smoke all pass; for the docs, pnpm --filter site generate and the site's test (295 passed), and the generated rules data has the new row (the site's typecheck, lint, build and the built /rules/no-unknown-component and /rules/no-unknown-prop pages were checked on 09874ea). pnpm corpus on all 25 runs: "Counts match corpus/snapshot.json", and all 53,872 findings are the same as on 09874ea.

Left for later

  • A render function of the design system's own on a React Aria system (shadcn/ui aria's Badge, AttachmentTrigger) keeps main's advice, Base UI components compose with render instead: render={<Link href="…" />}, though its render takes a function; naming render={(props) => <Link {...props} href="…" />} there would fit it.
  • src/lint/rules/hardcoded.ts: drop a redundant dark: when the light class maps to the same token (bg-red-500 dark:bg-red-400 → dark:bg-destructive); keep the variant prefix in the color suggestion (hover:bg-gray-100 gives bg-muted, while the fix says hover:bg-muted); say "off the scale" for an inline borderRadius: 4; treat color: "white" in a style object as text-white is treated.
  • Token ranking (src/tokens/): on a tie, prefer the name without a tint or container qualifier (bg-[#855300] matched colors.surface-tint before colors.primary).
  • docs/eslint.md: the parser snippet covers **/*.{ts,tsx} only, so a .jsx file gets a parsing error.
  • Not changed: color.accent.$root in messages follows DTCG 2025.10 and docs/tokens.md, as the finding's verifier noted.

Roadmap item: OS2-59 (os-adopt-clients/message-polish (2, 3), os-bugs-edges/os-eslint-worker-death-hang).

…stopped ESLint loader

- An unknown part of a family (`DialogContent` where Dialog is the
  content, as on React Aria) gets the family's component and its parts,
  not the sibling spelled closest (`DialogFooter`). A misspelled part
  (`DialogTitel`) is still taken for that part, with the rename.
- `asChild` on a trigger without asChild or render says it takes the
  element that opens it as a child. On React Aria's function `render`,
  asChild, as, component and href no longer get Base UI's render
  element advice.
- The ESLint loader worker answers every waiting call from its exit
  handler, so a worker that stops (process.exit in a config, an
  uncaught error) fails the lint at once instead of after 120 s.

Roadmap item OS2-59.
…nu parts, Img, href hints

- The bridge and the loader worker share a "stopped" flag: the worker's exit handler sets it
  before it answers, and the next file starts another worker instead of posting to the stopped
  one, whose exit event arrives too late. A 3-file lint with process.exit(1), an uncaught error
  or an unhandled rejection in onsystem.config.mjs now fails each file at once, with the reason.
- A sibling part that is this part with a word in front (DropdownMenuSubContent for
  DropdownMenuContent) is not taken for it; a short part two edits away is (AvatarImg is
  AvatarImage, with the rename in scope).
- React Aria's render is recognised when spelled out ((props: …) => …) on a component whose
  props come from React Aria; a render function of the design system's own keeps the old advice.
- The trigger hint names a button for dialog, menu, popover and tooltip triggers, a menu item for
  a submenu's, and no element for a context menu's area; "take no "as" prop", and pass href to a
  component that takes it.
- docs/rules.md describes the family guess and the React Aria hints.
…for a trigger that renders a button

- src/eslint/worker.ts: the exit handler also takes the calls still on the worker's port, posted
  while it was busy, and answers them with why it stopped. A config whose timer blocked for 1.5 s
  and then called process.exit(3) left the next file waiting 120 s; it now fails in under a
  second with "(exit code 3)".
- src/lint/rules/components.ts: the trigger hint ("It takes the element that opens it as a
  child") is only for a trigger that renders no element of its own, as React Aria's do. A
  DialogTrigger that renders a button gets main's message again, rather than advice that nests
  one button in another. A part is a typo of a sibling within a third of its length (at least
  two edits), not half: CardToggle is no longer taken for CardTitle, nor CarouselProvider for
  CarouselPrevious.
- Tests for each, docs/rules.md and the changeset updated.
@dgesteves
dgesteves merged commit 80c25e0 into main Oct 30, 2026
19 checks passed
@dgesteves
dgesteves deleted the fix/os2-59-message-polish branch October 30, 2026 20:28
@dgesteves-release dgesteves-release Bot mentioned this pull request Oct 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant