From 9a64fec875250bbcb1f231e4aac8da1a1a09fd95 Mon Sep 17 00:00:00 2001 From: flamboh Date: Sat, 3 Oct 2026 17:42:22 +0000 Subject: [PATCH 1/8] perf(web): DOM changes no longer restyle the whole page Two composer banner selectors used an unqualified `+:has([data-chat-composer-form])`. Chrome tracked that :has() on and , so any element inserted or removed anywhere restyled the entire document. The rest of each selector already requires the composer form, so `+*` matches the same elements. --- apps/web/src/components/chat/ComposerBanner.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ComposerBanner.tsx b/apps/web/src/components/chat/ComposerBanner.tsx index 78753c36097b..cbcb58897157 100644 --- a/apps/web/src/components/chat/ComposerBanner.tsx +++ b/apps/web/src/components/chat/ComposerBanner.tsx @@ -111,7 +111,7 @@ function Attachment({ className, ...props }: ComponentProps<"div">) { "mx-auto -mb-[calc(1rem+1px)] w-[calc(100%-2*var(--chat-composer-drawer-inset))]", // Adjacent attachments share their outline, including notices outside the form. "[&+[data-slot=composer-banner-attachment]_[data-composer-banner-surface=attached]]:before:rounded-none [&+[data-slot=composer-banner-attachment]_[data-composer-banner-surface=attached]]:before:border-t-0", - "[&+:has([data-chat-composer-form])_[data-chat-composer-form]>[data-slot=composer-banner-attachment]:first-child_[data-composer-banner-surface=attached]]:before:rounded-none [&+:has([data-chat-composer-form])_[data-chat-composer-form]>[data-slot=composer-banner-attachment]:first-child_[data-composer-banner-surface=attached]]:before:border-t-0", + "[&+*_[data-chat-composer-form]>[data-slot=composer-banner-attachment]:first-child_[data-composer-banner-surface=attached]]:before:rounded-none [&+*_[data-chat-composer-form]>[data-slot=composer-banner-attachment]:first-child_[data-composer-banner-surface=attached]]:before:border-t-0", className, )} {...props} From c26d21d2f3a2476115bd414b17b4e52a0bef03e3 Mon Sep 17 00:00:00 2001 From: flamboh Date: Sat, 3 Oct 2026 08:41:51 +0000 Subject: [PATCH 2/8] perf(web): virtualize command palette results Broad thread searches mounted a heavy row for every match, so typing a common word into the command palette blocked the main thread for seconds on large histories. The palette now renders its flattened groups through LegendList with Base UI's virtualized mode, so only the visible rows mount. Results, ordering, headings and keyboard behaviour are unchanged: keyboard highlights scroll into view, Enter runs the tracked highlight even when its row is unmounted, and typing still hides the highlight until the first ArrowDown. useThreadSearch now returns the settled query so thread items only rebuild when results land, not on every keystroke. --- .../components/CommandPalette.logic.test.ts | 45 +++++++ .../src/components/CommandPalette.logic.ts | 59 ++++++++++ apps/web/src/components/CommandPalette.tsx | 67 +++++++++-- .../src/components/CommandPaletteResults.tsx | 110 ++++++++++++++++-- apps/web/src/components/ui/autocomplete.tsx | 28 ++++- apps/web/src/components/ui/command.tsx | 27 +++++ .../src/state/queries.threadSearch.test.tsx | 64 ++++++++++ apps/web/src/state/queries.ts | 3 + 8 files changed, 387 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/state/queries.threadSearch.test.tsx diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 916b66a32b34..2bd7c118c69f 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -5,12 +5,14 @@ import { makeThreadFixture } from "../test-fixtures"; import { buildBrowseGroups, buildCommandPaletteProjectMetadata, + buildCommandPaletteRows, buildProjectActionItems, buildThreadActionItems, buildLinkedThreadActionItems, enumerateCommandPaletteItems, filterPinnedBrowseEntries, filterCommandPaletteGroups, + findHighlightedCommandPaletteItem, reduceCommandPaletteUiState, type CommandPaletteActionItem, type CommandPaletteGroup, @@ -849,3 +851,46 @@ describe("filterCommandPaletteGroups", () => { ]); }); }); + +describe("virtualized command palette rows", () => { + const action = (value: string, disabled = false): CommandPaletteActionItem => ({ + kind: "action", + value, + searchTerms: [], + title: value, + icon: null, + ...(disabled ? { disabled } : {}), + run: async () => {}, + }); + const groups: CommandPaletteGroup[] = [ + { value: "actions", label: "Actions", items: [action("new-thread"), action("offline", true)] }, + { value: "threads", label: "Threads", items: [action("thread-a"), action("thread-b")] }, + ]; + + it("keeps group order and headings while indexing only enabled items", () => { + const { rows, itemValues, rowIndexByItemIndex } = buildCommandPaletteRows(groups); + + expect(rows.map((row) => (row.kind === "label" ? `# ${row.label}` : row.key))).toEqual([ + "# Actions", + "actions:new-thread", + "actions:offline", + "# Threads", + "threads:thread-a", + "threads:thread-b", + ]); + expect(itemValues).toEqual(["new-thread", "thread-a", "thread-b"]); + expect(rowIndexByItemIndex).toEqual([1, 4, 5]); + expect(rows.flatMap((row) => (row.kind === "item" ? [row.itemIndex] : []))).toEqual([ + 0, + null, + 1, + 2, + ]); + }); + + it("resolves Enter to the highlighted item without needing its row mounted", () => { + expect(findHighlightedCommandPaletteItem(groups, "thread-b")?.value).toBe("thread-b"); + expect(findHighlightedCommandPaletteItem(groups, "offline")).toBeNull(); + expect(findHighlightedCommandPaletteItem(groups, null)).toBeNull(); + }); +}); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index d031f36ed35c..49e963f68313 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -172,6 +172,65 @@ export interface CommandPaletteView { readonly initialQuery?: string; } +export type CommandPaletteRow = + | { + readonly kind: "label"; + readonly key: string; + readonly label: string; + readonly first: boolean; + } + | { + readonly kind: "item"; + readonly key: string; + readonly item: CommandPaletteActionItem | CommandPaletteSubmenuItem; + /** Position among enabled items, or null for disabled rows the keyboard skips. */ + readonly itemIndex: number | null; + }; + +/** + * Flattens groups into the rows a virtualized list renders. `itemValues` is the + * highlightable item order Base UI navigates; `rowIndexByItemIndex` maps a + * highlight back to its row for scrolling. + */ +export function buildCommandPaletteRows(groups: ReadonlyArray) { + const rows: CommandPaletteRow[] = []; + const itemValues: string[] = []; + const rowIndexByItemIndex: number[] = []; + for (const group of groups) { + if (group.label) { + rows.push({ + kind: "label", + key: `group:${group.value}`, + label: group.label, + first: rows.length === 0, + }); + } + for (const item of group.items) { + const itemIndex = item.disabled ? null : itemValues.length; + if (itemIndex !== null) { + itemValues.push(item.value); + rowIndexByItemIndex.push(rows.length); + } + rows.push({ kind: "item", key: `${group.value}:${item.value}`, item, itemIndex }); + } + } + return { rows, itemValues, rowIndexByItemIndex }; +} + +/** The enabled item Enter should run for a highlight, whether or not its row is mounted. */ +export function findHighlightedCommandPaletteItem( + groups: ReadonlyArray, + highlightedItemValue: string | null, +): CommandPaletteActionItem | CommandPaletteSubmenuItem | null { + if (highlightedItemValue === null) return null; + for (const group of groups) { + for (const item of group.items) { + if (item.value === highlightedItemValue && !item.disabled) return item; + } + } + return null; +} + export function enumerateCommandPaletteItems( items: ReadonlyArray, ): CommandPaletteActionItem[] { diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index aad0e71a06b0..ae777a064277 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -77,6 +77,7 @@ import { type KeyboardEvent, type ReactNode, } from "react"; +import type { LegendListRef } from "@legendapp/list/react"; import { useAtomValue } from "@effect/atom-react"; import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; @@ -157,7 +158,9 @@ import { buildRootGroups, buildThreadActionItems, buildLinkedThreadActionItems, + buildCommandPaletteRows, enumerateCommandPaletteItems, + findHighlightedCommandPaletteItem, type CommandPaletteActionItem, type CommandPaletteOpenIntent, type CommandPaletteSubmenuItem, @@ -174,7 +177,10 @@ import { import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { CommandPaletteContent } from "./CommandPaletteContent"; -import { CommandPaletteResults } from "./CommandPaletteResults"; +import { + CommandPaletteVirtualizedResults, + scrollCommandPaletteRowIntoView, +} from "./CommandPaletteResults"; import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon, ForgejoIcon } from "./Icons"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { Checkbox } from "./ui/checkbox"; @@ -694,6 +700,14 @@ function OpenCommandPaletteDialog(props: { const deferredQuery = useDeferredValue(query); const isActionsOnly = deferredQuery.startsWith(">"); const [highlightedItemValue, setHighlightedItemValue] = useState(null); + const resultListRef = useRef(null); + // Typing clears the highlight. Base UI keeps its own on the first row, but the + // palette shows none until the user navigates, and the first ArrowDown lands on it. + const highlightClearedRef = useRef(false); + function clearTypedHighlight(): void { + highlightClearedRef.current = true; + setHighlightedItemValue(null); + } const clientSettings = useClientSettings(); const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false, @@ -1425,7 +1439,7 @@ function OpenCommandPaletteDialog(props: { ? { source: match.source, snippet: match.snippet, - query: threadSearchQuery, + query: threadSearch.query, } : undefined; }, @@ -1446,7 +1460,7 @@ function OpenCommandPaletteDialog(props: { projectTitleById, providerEntryByEnvironmentAndInstanceId, threadContentMatchByKey, - threadSearchQuery, + threadSearch.query, threads, ], ); @@ -1494,7 +1508,7 @@ function OpenCommandPaletteDialog(props: { function handleQueryChange(nextQuery: string): void { browseNavigation.invalidate(); - setHighlightedItemValue(null); + clearTypedHighlight(); setQuery(nextQuery); if (nextQuery === "" && currentView?.initialQuery) { popView(); @@ -2938,6 +2952,7 @@ function OpenCommandPaletteDialog(props: { } else if (isBrowsing) { displayedGroups = relativePathNeedsActiveProject ? [] : browseGroups; } + const resultRows = buildCommandPaletteRows(displayedGroups); const inputPlaceholder = newProjectFlow !== null @@ -3083,6 +3098,35 @@ function OpenCommandPaletteDialog(props: { if (event.key === "Backspace" && query === "" && isSubmenu) { event.preventDefault(); popView(); + return; + } + + const firstItemValue = resultRows.itemValues[0]; + if (event.key === "ArrowDown" && highlightClearedRef.current && firstItemValue) { + (event as typeof event & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.(); + event.preventDefault(); + highlightClearedRef.current = false; + setHighlightedItemValue(firstItemValue); + scrollCommandPaletteRowIntoView( + resultListRef.current, + resultRows.rowIndexByItemIndex[0] ?? 0, + ); + return; + } + + // Base UI clicks the highlighted row on Enter, which does nothing once the + // virtualized list has unmounted it, so run the tracked highlight directly. + if (event.key === "Enter" && !event.nativeEvent.isComposing && event.keyCode !== 229) { + const highlightedItem = findHighlightedCommandPaletteItem( + displayedGroups, + highlightedItemValue ?? firstItemValue ?? null, + ); + if (highlightedItem) { + (event as typeof event & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.(); + event.preventDefault(); + event.stopPropagation(); + executeItem(highlightedItem); + } } } @@ -3387,8 +3431,16 @@ function OpenCommandPaletteDialog(props: { onKeyDown: handleKeyDown, }} mode="none" - onItemHighlighted={(value) => { + items={resultRows.itemValues} + virtualized + onItemHighlighted={(value, eventDetails) => { + if (eventDetails.reason === "none" && highlightClearedRef.current) return; + highlightClearedRef.current = false; setHighlightedItemValue(typeof value === "string" ? value : null); + const rowIndex = resultRows.rowIndexByItemIndex[eventDetails.index]; + if (eventDetails.reason === "keyboard" && rowIndex !== undefined) { + scrollCommandPaletteRowIntoView(resultListRef.current, rowIndex); + } }} onValueChange={handleQueryChange} showBackHint={isSubmenu} @@ -3426,8 +3478,9 @@ function OpenCommandPaletteDialog(props: { ) : null} - void; } +function CommandPaletteEmptyState(props: { emptyStateMessage?: string; isActionsOnly: boolean }) { + return ( +
+ {props.emptyStateMessage ?? + (props.isActionsOnly + ? "No matching actions." + : "No matching commands, projects, or threads.")} +
+ ); +} + export function CommandPaletteResults(props: CommandPaletteResultsProps) { if (props.groups.length === 0) { - return ( -
- {props.emptyStateMessage ?? - (props.isActionsOnly - ? "No matching actions." - : "No matching commands, projects, or threads.")} -
- ); + return ; } return ( @@ -63,6 +74,87 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) { ); } +/** + * Scrolls a keyboard highlight into view the way the unvirtualized list did: + * nearest edge, clear of the scroll fade. Rows outside the rendered window + * fall back to the list's own scrolling. + */ +export function scrollCommandPaletteRowIntoView(list: LegendListRef | null, rowIndex: number) { + const element = list?.getState?.().elementAtIndex(rowIndex); + if (element instanceof HTMLElement) { + element.scrollIntoView({ block: "nearest" }); + return; + } + void list?.scrollIndexIntoView?.({ index: rowIndex, animated: false }); +} + +interface CommandPaletteVirtualizedResultsProps extends Omit { + rows: ReadonlyArray; + listRef: RefObject; +} + +/** + * Renders only the visible rows. The parent passes the flat item order to the + * Command root as `items` with `virtualized`, scrolls keyboard highlights into + * view through `listRef`, and runs Enter itself since the row may be unmounted. + */ +export function CommandPaletteVirtualizedResults(props: CommandPaletteVirtualizedResultsProps) { + const { listRef } = props; + const [scrollFade, setScrollFade] = useState({ top: false, bottom: false }); + const updateScrollFade = useCallback(() => { + const scrollElement = listRef.current?.getScrollableNode?.(); + if (!(scrollElement instanceof HTMLElement)) return; + const top = scrollElement.scrollTop > 1; + const bottom = + scrollElement.scrollHeight - scrollElement.clientHeight - scrollElement.scrollTop > 1; + setScrollFade((current) => + current.top === top && current.bottom === bottom ? current : { top, bottom }, + ); + }, [listRef]); + + if (props.rows.length === 0) { + return ; + } + + return ( + + + ref={listRef} + data={props.rows} + keyExtractor={(row) => row.key} + getItemType={(row) => row.kind} + extraData={props.highlightedItemValue} + renderItem={({ item: row }) => + row.kind === "label" ? ( +
+ {row.label} +
+ ) : row.itemIndex === null ? ( + + ) : ( + + ) + } + estimatedItemSize={40} + drawDistance={400} + onLayout={updateScrollFade} + onScroll={updateScrollFade} + contentContainerClassName="px-2" + className={cn( + "min-h-0 scroll-py-6 overflow-x-hidden overscroll-y-contain py-2", + getVirtualizedScrollFadeClassName(scrollFade), + )} + /> +
+ ); +} + function DisabledCommandPaletteResultRow(props: { item: CommandPaletteActionItem | CommandPaletteSubmenuItem; }) { @@ -96,6 +188,7 @@ function DisabledCommandPaletteResultRow(props: { } function CommandPaletteResultRow(props: { + index?: number; item: CommandPaletteActionItem | CommandPaletteSubmenuItem; isActive: boolean; keybindings: ResolvedKeybindingsConfig; @@ -107,6 +200,7 @@ function CommandPaletteResultRow(props: { return ( { diff --git a/apps/web/src/components/ui/autocomplete.tsx b/apps/web/src/components/ui/autocomplete.tsx index 8251d8c24b7c..548efd944897 100644 --- a/apps/web/src/components/ui/autocomplete.tsx +++ b/apps/web/src/components/ui/autocomplete.tsx @@ -146,11 +146,35 @@ function AutocompleteGroup({ className, ...props }: AutocompletePrimitive.Group. ); } +const GROUP_LABEL_CLASS_NAME = "px-2 py-1.5 font-medium text-muted-foreground text-xs"; + function AutocompleteGroupLabel({ className, ...props }: AutocompletePrimitive.GroupLabel.Props) { return ( + ); +} + +// A group label for virtualized lists, where rows cannot be wrapped in a group. +function AutocompleteListHeading({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AutocompleteListVirtualized({ className, ...props }: AutocompletePrimitive.List.Props) { + return ( + ); @@ -226,6 +250,8 @@ export { AutocompleteGroupLabel, AutocompleteEmpty, AutocompleteList, + AutocompleteListHeading, + AutocompleteListVirtualized, AutocompleteClear, AutocompleteCollection, }; diff --git a/apps/web/src/components/ui/command.tsx b/apps/web/src/components/ui/command.tsx index d3a83865151d..b3a473ba1d22 100644 --- a/apps/web/src/components/ui/command.tsx +++ b/apps/web/src/components/ui/command.tsx @@ -12,6 +12,8 @@ import { AutocompleteInput, AutocompleteItem, AutocompleteList, + AutocompleteListHeading, + AutocompleteListVirtualized, } from "~/components/ui/autocomplete"; import { DIALOG_BACKDROP_CLASS, DIALOG_POPUP_CLASS } from "~/components/ui/dialog-styles"; import { Button } from "~/components/ui/button"; @@ -123,6 +125,29 @@ function CommandList({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function CommandListHeading({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + function CommandPanel({ className, ...props }: React.ComponentProps<"div">) { return (
({ + matches: [{ threadId: "thread-1", source: "user", snippet: "a link" }], + isLoading: false, +})); +vi.mock("@effect/atom-react", async (importOriginal) => ({ + ...(await importOriginal()), + useAtomValue: (atom: { label?: ReadonlyArray }) => + String(atom.label?.[0]).includes("empty") ? { matches: [], isLoading: false } : searchResults, +})); + +const environmentIds = [EnvironmentId.make("local")]; +type ThreadSearch = ReturnType; + +function Probe(props: { query: string; onResult: (result: ThreadSearch) => void }) { + props.onResult(useThreadSearch(environmentIds, props.query)); + return null; +} + +describe("useThreadSearch", () => { + let renderer: ReactTestRenderer | null = null; + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal("window", globalThis); + }); + afterEach(() => { + act(() => renderer?.unmount()); + renderer = null; + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it("keeps the settled query and matches stable while typing, then reports the settled query", () => { + const results: ThreadSearch[] = []; + const render = (query: string) => { + const element = results.push(result)} />; + act(() => { + if (renderer) renderer.update(element); + else renderer = create(element); + }); + return results.at(-1)!; + }; + + render(""); + const typing = ["li", "lin", "link "].map(render); + expect(typing.map((result) => result.query)).toEqual(["", "", ""]); + expect(new Set(typing.map((result) => result.matches)).size).toBe(1); + expect(typing.every((result) => result.isPending)).toBe(true); + + act(() => { + vi.advanceTimersByTime(200); + }); + const settled = results.at(-1)!; + expect(settled.query).toBe("link"); + expect(settled.matches).toBe(searchResults.matches); + expect(settled.isPending).toBe(false); + }); +}); diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 15211efab4e6..01bfa8864bb7 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -72,6 +72,8 @@ export function useThreadSearch( query: string, ): { readonly matches: ReadonlyArray; + /** The settled query `matches` came from; it only changes when results do. */ + readonly query: string; readonly isPending: boolean; } { const normalizedQuery = query.trim(); @@ -88,6 +90,7 @@ export function useThreadSearch( const isDebouncing = canSearch && normalizedQuery !== debouncedQuery; return { matches: isDebouncing ? EMPTY_THREAD_SEARCH_MATCHES : result.matches, + query: settledQuery ?? "", isPending: canSearch && (isDebouncing || result.isLoading), }; } From c795acbdd008ee3ca0d91ca20927d323dc2ba4b2 Mon Sep 17 00:00:00 2001 From: flamboh Date: Sat, 3 Oct 2026 18:56:47 +0000 Subject: [PATCH 3/8] fix(web): palette fallbacks follow Base UI for modifiers, browse mode and submenus Modified Enter/ArrowDown no longer run the tracked highlight (Base UI ignores them). Browse, clone and new-project modes don't auto-highlight, so ArrowDown and Enter there are left to Base UI. Entering a submenu hides the highlight until the first navigation, as typing does. --- apps/web/src/components/CommandPalette.tsx | 24 +++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index ae777a064277..f7b78b5ae676 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -701,8 +701,9 @@ function OpenCommandPaletteDialog(props: { const isActionsOnly = deferredQuery.startsWith(">"); const [highlightedItemValue, setHighlightedItemValue] = useState(null); const resultListRef = useRef(null); - // Typing clears the highlight. Base UI keeps its own on the first row, but the - // palette shows none until the user navigates, and the first ArrowDown lands on it. + // Typing or entering a submenu clears the highlight. Base UI keeps its own on the + // first row, but the palette shows none until the user navigates, and the first + // ArrowDown lands on it. const highlightClearedRef = useRef(false); function clearTypedHighlight(): void { highlightClearedRef.current = true; @@ -1477,6 +1478,7 @@ function OpenCommandPaletteDialog(props: { ...(view.initialQuery ? { initialQuery: view.initialQuery } : {}), }, ]); + highlightClearedRef.current = true; setHighlightedItemValue(null); setQuery(view.initialQuery ?? ""); }, @@ -2953,6 +2955,8 @@ function OpenCommandPaletteDialog(props: { displayedGroups = relativePathNeedsActiveProject ? [] : browseGroups; } const resultRows = buildCommandPaletteRows(displayedGroups); + const autoHighlightsFirstRow = + !isBrowsing && !isRemoteProjectCloneFlow && newProjectFlow === null; const inputPlaceholder = newProjectFlow !== null @@ -3101,8 +3105,16 @@ function OpenCommandPaletteDialog(props: { return; } - const firstItemValue = resultRows.itemValues[0]; - if (event.key === "ArrowDown" && highlightClearedRef.current && firstItemValue) { + // Base UI ignores navigation keys with modifiers, so these fallbacks do too. + if (event.ctrlKey || event.shiftKey || event.altKey || event.metaKey) return; + // Base UI only keeps a hidden highlight on the first row when it auto-highlights. + const firstItemValue = autoHighlightsFirstRow ? resultRows.itemValues[0] : undefined; + if ( + event.key === "ArrowDown" && + highlightClearedRef.current && + firstItemValue && + !event.nativeEvent.isComposing + ) { (event as typeof event & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.(); event.preventDefault(); highlightClearedRef.current = false; @@ -3393,9 +3405,7 @@ function OpenCommandPaletteDialog(props: { Date: Sat, 3 Oct 2026 18:33:10 +0000 Subject: [PATCH 4/8] chore(lint): flag :has() variants that restyle the whole page Tailwind arbitrary variants like [&+:has(x)_...] attach :has() to nothing in particular, so Chrome evaluates it on every ancestor up to and any DOM change then restyles the entire document (see the ComposerBanner fix). The new t3code/no-unscoped-has rule requires each :has() in an arbitrary variant to be anchored to a class, attribute, id or tag below the document root. --- oxlint-plugin-t3code/index.ts | 2 + .../rules/no-unscoped-has.test.ts | 60 ++++++++++++ oxlint-plugin-t3code/rules/no-unscoped-has.ts | 91 +++++++++++++++++++ vite.config.ts | 1 + 4 files changed, 154 insertions(+) create mode 100644 oxlint-plugin-t3code/rules/no-unscoped-has.test.ts create mode 100644 oxlint-plugin-t3code/rules/no-unscoped-has.ts diff --git a/oxlint-plugin-t3code/index.ts b/oxlint-plugin-t3code/index.ts index f4669f588f67..855ba1e7d098 100644 --- a/oxlint-plugin-t3code/index.ts +++ b/oxlint-plugin-t3code/index.ts @@ -8,6 +8,7 @@ import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-te import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-escape-hatches.ts"; import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts"; import noTestInLoop from "./rules/no-test-in-loop.ts"; +import noUnscopedHas from "./rules/no-unscoped-has.ts"; export default definePlugin({ meta: { @@ -22,5 +23,6 @@ export default definePlugin({ "no-mobile-uniwind-theme-escape-hatches": noMobileUniwindThemeEscapeHatches, "no-native-title-tooltip": noNativeTitleTooltip, "no-test-in-loop": noTestInLoop, + "no-unscoped-has": noUnscopedHas, }, }); diff --git a/oxlint-plugin-t3code/rules/no-unscoped-has.test.ts b/oxlint-plugin-t3code/rules/no-unscoped-has.test.ts new file mode 100644 index 000000000000..d26c0202f133 --- /dev/null +++ b/oxlint-plugin-t3code/rules/no-unscoped-has.test.ts @@ -0,0 +1,60 @@ +/* oxlint-disable t3code/no-unscoped-has -- the fixtures are invalid on purpose */ +import { assert, describe } from "@effect/vitest"; + +import { createOxlintRuleHarness } from "../test/utils.ts"; + +const rule = createOxlintRuleHarness("t3code/no-unscoped-has", { + filename: "fixture.tsx", +}); + +describe("t3code/no-unscoped-has", () => { + rule.valid( + "allows :has() on the element itself", + `const className = "[&:has([data-slot=icon])]:ps-2";`, + ); + + rule.valid( + "allows :has() anchored to an attribute", + `const className = "[&+[data-chat-composer-form]:has(>[data-slot=banner])]:mt-0";`, + ); + + rule.valid( + "allows :has() inside :not() on the element itself", + `const className = "[&:not(:has(+[data-slot=footer]))]:rounded-b-2xl";`, + ); + + rule.valid( + "allows built-in has-* variants", + `const className = "has-[>[data-slot=icon]]:ps-2 group-has-[:checked]:opacity-100";`, + ); + + rule.valid( + "allows a sibling selector without :has()", + `const className = "[&+*_[data-chat-composer-form]>[data-slot=attachment]]:before:rounded-none";`, + ); + + rule.valid("ignores prose mentioning :has()", `const note = "uses :has( for styling";`); + + rule.invalid( + "reports a sibling :has() with nothing anchoring it", + `const className = "[&+:has([data-chat-composer-form])_[data-chat-composer-form]]:before:rounded-none";`, + (output) => { + assert.match(output, /Anchor the :has\(\)/); + }, + ); + + rule.invalid( + "reports a descendant :has() with nothing anchoring it", + `const className = cn("p-2", "[&_:has(>input)]:gap-1");`, + ); + + rule.invalid( + "reports a universal :has() ancestor", + "const className = `flex [*:has([data-open])_&]:hidden`;", + ); + + rule.invalid( + "reports :has() anchored to the document root", + `const className = "[body:has([data-dialog-open])_&]:overflow-hidden";`, + ); +}); diff --git a/oxlint-plugin-t3code/rules/no-unscoped-has.ts b/oxlint-plugin-t3code/rules/no-unscoped-has.ts new file mode 100644 index 000000000000..c408eccc5345 --- /dev/null +++ b/oxlint-plugin-t3code/rules/no-unscoped-has.ts @@ -0,0 +1,91 @@ +import { defineRule } from "@oxlint/plugins"; + +const COMBINATOR_PATTERN = /[\s>+~]/u; +// A class, id, attribute, or tag at the start of the compound narrows it. +const NARROWING_PATTERN = /[.#[]|^[a-z]/iu; +const ROOT_COMPOUND_PATTERN = /^(?:html|body|:root)(?![\w-])/iu; + +/** Top-level `[...]` groups in a class token, i.e. Tailwind arbitrary variants and values. */ +function bracketGroups(token: string): string[] { + const groups: string[] = []; + let depth = 0; + let start = -1; + for (let index = 0; index < token.length; index++) { + const char = token[index]; + if (char === "[") { + if (depth === 0) start = index + 1; + depth++; + } else if (char === "]" && depth > 0) { + depth--; + if (depth === 0) groups.push(token.slice(start, index)); + } + } + return groups; +} + +/** The compound selector each `:has(` in `selector` is attached to. */ +function hasCompounds(selector: string): string[] { + const compounds: string[] = []; + let index = selector.indexOf(":has("); + while (index !== -1) { + let start = index - 1; + let depth = 0; + // An unbalanced "(" means the :has() sits inside :not()/:is()/:where(), + // so the compound outside that wrapper still applies. + for (; start >= 0; start--) { + const char = selector[start]; + if (char === ")") depth++; + else if (char === "(") { + if (depth > 0) depth--; + } else if (depth === 0 && COMBINATOR_PATTERN.test(char ?? "")) break; + } + compounds.push(selector.slice(start + 1, index)); + index = selector.indexOf(":has(", index + 1); + } + return compounds; +} + +/** Arbitrary variants in `text` whose `:has()` is unanchored or anchored to the document root. */ +function findUnscopedHasVariants(text: string): string[] { + if (!text.includes(":has(")) return []; + const offenders: string[] = []; + for (const token of text.split(/\s+/u)) { + for (const group of bracketGroups(token)) { + if (!group.includes(":has(")) continue; + // Tailwind writes spaces as "_", and "&" is the element carrying the class. + const selector = group.replaceAll("_", " ").replaceAll("&", ".self"); + const unscoped = hasCompounds(selector).some( + (compound) => !NARROWING_PATTERN.test(compound) || ROOT_COMPOUND_PATTERN.test(compound), + ); + if (unscoped) offenders.push(`[${group}]`); + } + } + return offenders; +} + +export default defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow Tailwind arbitrary variants with a :has() that is not anchored to a class, attribute, id, or tag.", + }, + }, + create(context) { + const message = (variant: string) => + `Anchor the :has() in ${variant} to a class, attribute, or tag below the document root, e.g. [&+[data-x]_…] or a has-* variant. Chrome evaluates an unanchored :has() on every ancestor, so any DOM change then restyles the whole page.`; + return { + Literal(node) { + if (typeof node.value !== "string") return; + for (const variant of findUnscopedHasVariants(node.value)) { + context.report({ node, message: message(variant) }); + } + }, + TemplateElement(node) { + for (const variant of findUnscopedHasVariants(node.value.cooked ?? node.value.raw)) { + context.report({ node, message: message(variant) }); + } + }, + }; + }, +}); diff --git a/vite.config.ts b/vite.config.ts index 97729f5784b3..0eee88e2ad08 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -168,6 +168,7 @@ export default defineConfig({ "t3code/no-manual-effect-runtime-in-tests": "error", "t3code/no-native-title-tooltip": "error", "t3code/no-test-in-loop": "error", + "t3code/no-unscoped-has": "error", "t3code/namespace-node-imports": "error", }, overrides: [ From db34e10752b03bbcb47ba13d7b87b3a7e65de007 Mon Sep 17 00:00:00 2001 From: flamboh Date: Sat, 3 Oct 2026 18:39:20 +0000 Subject: [PATCH 5/8] fix(lint): no-unscoped-has ignores values, negated anchors and case Only bracket groups used as variants are checked, so text like content-[':has(x)'] no longer reports. A class inside :not() no longer counts as an anchor, and :HAS() is matched case-insensitively. --- .../rules/no-unscoped-has.test.ts | 17 +++++++ oxlint-plugin-t3code/rules/no-unscoped-has.ts | 44 ++++++++++++++----- 2 files changed, 50 insertions(+), 11 deletions(-) diff --git a/oxlint-plugin-t3code/rules/no-unscoped-has.test.ts b/oxlint-plugin-t3code/rules/no-unscoped-has.test.ts index d26c0202f133..d1f9a9d7e7a7 100644 --- a/oxlint-plugin-t3code/rules/no-unscoped-has.test.ts +++ b/oxlint-plugin-t3code/rules/no-unscoped-has.test.ts @@ -35,6 +35,16 @@ describe("t3code/no-unscoped-has", () => { rule.valid("ignores prose mentioning :has()", `const note = "uses :has( for styling";`); + rule.valid( + "ignores :has() inside an arbitrary value", + `const className = "before:content-[':has(foo)']";`, + ); + + rule.valid( + "allows a negated :has() on the element itself", + `const className = "[&:not(.collapsed):not(:has(>[data-slot=icon]))]:ps-2";`, + ); + rule.invalid( "reports a sibling :has() with nothing anchoring it", `const className = "[&+:has([data-chat-composer-form])_[data-chat-composer-form]]:before:rounded-none";`, @@ -53,6 +63,13 @@ describe("t3code/no-unscoped-has", () => { "const className = `flex [*:has([data-open])_&]:hidden`;", ); + rule.invalid( + "reports :has() whose only anchor is negated", + `const className = "[*:not(.safe):has([data-open])_&]:hidden";`, + ); + + rule.invalid("reports uppercase :HAS()", `const className = "[*:HAS([data-open])_&]:hidden";`); + rule.invalid( "reports :has() anchored to the document root", `const className = "[body:has([data-dialog-open])_&]:overflow-hidden";`, diff --git a/oxlint-plugin-t3code/rules/no-unscoped-has.ts b/oxlint-plugin-t3code/rules/no-unscoped-has.ts index c408eccc5345..638a36a32d76 100644 --- a/oxlint-plugin-t3code/rules/no-unscoped-has.ts +++ b/oxlint-plugin-t3code/rules/no-unscoped-has.ts @@ -1,12 +1,13 @@ import { defineRule } from "@oxlint/plugins"; const COMBINATOR_PATTERN = /[\s>+~]/u; -// A class, id, attribute, or tag at the start of the compound narrows it. +// A class, id, attribute, or leading tag narrows a compound. Negated ones don't, +// so `:not(...)` is removed before this check. const NARROWING_PATTERN = /[.#[]|^[a-z]/iu; const ROOT_COMPOUND_PATTERN = /^(?:html|body|:root)(?![\w-])/iu; -/** Top-level `[...]` groups in a class token, i.e. Tailwind arbitrary variants and values. */ -function bracketGroups(token: string): string[] { +/** Tailwind arbitrary variants in a class token: top-level `[...]` groups followed by `:`. */ +function variantGroups(token: string): string[] { const groups: string[] = []; let depth = 0; let start = -1; @@ -17,7 +18,7 @@ function bracketGroups(token: string): string[] { depth++; } else if (char === "]" && depth > 0) { depth--; - if (depth === 0) groups.push(token.slice(start, index)); + if (depth === 0 && token[index + 1] === ":") groups.push(token.slice(start, index)); } } return groups; @@ -45,18 +46,39 @@ function hasCompounds(selector: string): string[] { return compounds; } +/** `compound` without any `:not(...)`, including one left open around the `:has()`. */ +function withoutNegations(compound: string): string { + let result = ""; + let index = 0; + while (index < compound.length) { + if (!compound.startsWith(":not(", index)) { + result += compound[index]; + index++; + continue; + } + let depth = 0; + for (index += ":not".length; index < compound.length; index++) { + if (compound[index] === "(") depth++; + else if (compound[index] === ")" && --depth === 0) break; + } + index++; + } + return result; +} + /** Arbitrary variants in `text` whose `:has()` is unanchored or anchored to the document root. */ function findUnscopedHasVariants(text: string): string[] { - if (!text.includes(":has(")) return []; + // Selectors are ASCII case-insensitive. + if (!text.toLowerCase().includes(":has(")) return []; const offenders: string[] = []; for (const token of text.split(/\s+/u)) { - for (const group of bracketGroups(token)) { - if (!group.includes(":has(")) continue; + for (const group of variantGroups(token)) { // Tailwind writes spaces as "_", and "&" is the element carrying the class. - const selector = group.replaceAll("_", " ").replaceAll("&", ".self"); - const unscoped = hasCompounds(selector).some( - (compound) => !NARROWING_PATTERN.test(compound) || ROOT_COMPOUND_PATTERN.test(compound), - ); + const selector = group.toLowerCase().replaceAll("_", " ").replaceAll("&", ".self"); + const unscoped = hasCompounds(selector).some((compound) => { + const anchor = withoutNegations(compound); + return !NARROWING_PATTERN.test(anchor) || ROOT_COMPOUND_PATTERN.test(anchor); + }); if (unscoped) offenders.push(`[${group}]`); } } From 69b37117ce08f660beb84ffe1637b7f708920a71 Mon Sep 17 00:00:00 2001 From: flamboh Date: Sat, 3 Oct 2026 18:58:19 +0000 Subject: [PATCH 6/8] fix(lint): no-unscoped-has handles selector lists, group/peer and quoted values Each branch of a selector list needs its own anchor. group-[...] and peer-[...] count as anchored to .group/.peer, and quoted attribute values and data-/aria- variants are not read as selectors. --- .../rules/no-unscoped-has.test.ts | 25 ++++++++++ oxlint-plugin-t3code/rules/no-unscoped-has.ts | 46 ++++++++++++++----- 2 files changed, 59 insertions(+), 12 deletions(-) diff --git a/oxlint-plugin-t3code/rules/no-unscoped-has.test.ts b/oxlint-plugin-t3code/rules/no-unscoped-has.test.ts index d1f9a9d7e7a7..dacf277280a7 100644 --- a/oxlint-plugin-t3code/rules/no-unscoped-has.test.ts +++ b/oxlint-plugin-t3code/rules/no-unscoped-has.test.ts @@ -45,6 +45,21 @@ describe("t3code/no-unscoped-has", () => { `const className = "[&:not(.collapsed):not(:has(>[data-slot=icon]))]:ps-2";`, ); + rule.valid( + "allows :has() on a group or peer element", + `const className = "group-[:has(input)]:p-2 peer-[:has(input)]:p-2 group-[&:has(input)]/row:p-2";`, + ); + + rule.valid( + "allows a selector list whose own branch is anchored", + `const className = "[:is(.a,.b):has(x)_&]:p-2 [&:not(.a,:has(x))]:p-2";`, + ); + + rule.valid( + "ignores :has() text in quoted attribute values", + `const className = "data-[foo='_:has(x)']:p-2 [&_[data-query='_:has(foo)']]:p-2";`, + ); + rule.invalid( "reports a sibling :has() with nothing anchoring it", `const className = "[&+:has([data-chat-composer-form])_[data-chat-composer-form]]:before:rounded-none";`, @@ -70,6 +85,16 @@ describe("t3code/no-unscoped-has", () => { rule.invalid("reports uppercase :HAS()", `const className = "[*:HAS([data-open])_&]:hidden";`); + rule.invalid( + "reports a selector-list branch borrowing another branch's anchor", + `const className = "[.safe,:has(input)_&]:p-2";`, + ); + + rule.invalid( + "reports an unanchored branch inside :is()", + `const className = "[&_:is(.safe,:has(input))]:p-2";`, + ); + rule.invalid( "reports :has() anchored to the document root", `const className = "[body:has([data-dialog-open])_&]:overflow-hidden";`, diff --git a/oxlint-plugin-t3code/rules/no-unscoped-has.ts b/oxlint-plugin-t3code/rules/no-unscoped-has.ts index 638a36a32d76..ef4e645f4941 100644 --- a/oxlint-plugin-t3code/rules/no-unscoped-has.ts +++ b/oxlint-plugin-t3code/rules/no-unscoped-has.ts @@ -6,9 +6,15 @@ const COMBINATOR_PATTERN = /[\s>+~]/u; const NARROWING_PATTERN = /[.#[]|^[a-z]/iu; const ROOT_COMPOUND_PATTERN = /^(?:html|body|:root)(?![\w-])/iu; +// group-[...] and peer-[...] match the .group/.peer element; data-[...] and +// aria-[...] hold attribute values, not selectors. +const GROUP_PREFIX_PATTERN = /(?:^|:)(?:group|peer)-$/u; +const ATTRIBUTE_PREFIX_PATTERN = /(?:^|:)(?:data|aria)-$/u; +const QUOTED_PATTERN = /(["'])(?:\\.|(?!\1).)*\1/gu; + /** Tailwind arbitrary variants in a class token: top-level `[...]` groups followed by `:`. */ -function variantGroups(token: string): string[] { - const groups: string[] = []; +function variantGroups(token: string): { prefix: string; group: string }[] { + const groups: { prefix: string; group: string }[] = []; let depth = 0; let start = -1; for (let index = 0; index < token.length; index++) { @@ -18,7 +24,9 @@ function variantGroups(token: string): string[] { depth++; } else if (char === "]" && depth > 0) { depth--; - if (depth === 0 && token[index + 1] === ":") groups.push(token.slice(start, index)); + if (depth === 0 && token[index + 1] === ":") { + groups.push({ prefix: token.slice(0, start - 1), group: token.slice(start, index) }); + } } } return groups; @@ -29,18 +37,25 @@ function hasCompounds(selector: string): string[] { const compounds: string[] = []; let index = selector.indexOf(":has("); while (index !== -1) { - let start = index - 1; + let compound = ""; let depth = 0; - // An unbalanced "(" means the :has() sits inside :not()/:is()/:where(), - // so the compound outside that wrapper still applies. - for (; start >= 0; start--) { - const char = selector[start]; + // Other branches of a selector list are skipped until the wrapper that holds + // them opens. An unbalanced "(" means the :has() sits inside :not()/:is()/ + // :where(), so the compound outside that wrapper still applies. + let skippingBranch = false; + for (let position = index - 1; position >= 0; position--) { + const char = selector[position] ?? ""; if (char === ")") depth++; else if (char === "(") { if (depth > 0) depth--; - } else if (depth === 0 && COMBINATOR_PATTERN.test(char ?? "")) break; + else skippingBranch = false; + } else if (depth === 0 && char === ",") { + skippingBranch = true; + continue; + } else if (depth === 0 && !skippingBranch && COMBINATOR_PATTERN.test(char)) break; + if (!skippingBranch) compound = char + compound; } - compounds.push(selector.slice(start + 1, index)); + compounds.push(compound); index = selector.indexOf(":has(", index + 1); } return compounds; @@ -72,9 +87,16 @@ function findUnscopedHasVariants(text: string): string[] { if (!text.toLowerCase().includes(":has(")) return []; const offenders: string[] = []; for (const token of text.split(/\s+/u)) { - for (const group of variantGroups(token)) { + for (const { prefix, group } of variantGroups(token)) { + if (ATTRIBUTE_PREFIX_PATTERN.test(prefix)) continue; // Tailwind writes spaces as "_", and "&" is the element carrying the class. - const selector = group.toLowerCase().replaceAll("_", " ").replaceAll("&", ".self"); + const relative = group.toLowerCase().replace(QUOTED_PATTERN, '""').replaceAll("_", " "); + const owner = GROUP_PREFIX_PATTERN.test(prefix) ? ".group" : ".self"; + const selector = relative.includes("&") + ? relative.replaceAll("&", owner) + : owner === ".self" + ? relative + : `${owner}:is(${relative})`; const unscoped = hasCompounds(selector).some((compound) => { const anchor = withoutNegations(compound); return !NARROWING_PATTERN.test(anchor) || ROOT_COMPOUND_PATTERN.test(anchor); From ffd8e73c7c0769f0e8952b0d9313d138ca65af86 Mon Sep 17 00:00:00 2001 From: flamboh Date: Sat, 3 Oct 2026 19:11:38 +0000 Subject: [PATCH 7/8] fix(lint): no-unscoped-has checks named group/peer variants --- oxlint-plugin-t3code/rules/no-unscoped-has.test.ts | 5 +++++ oxlint-plugin-t3code/rules/no-unscoped-has.ts | 7 +++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/oxlint-plugin-t3code/rules/no-unscoped-has.test.ts b/oxlint-plugin-t3code/rules/no-unscoped-has.test.ts index dacf277280a7..70e4ee1bb3d4 100644 --- a/oxlint-plugin-t3code/rules/no-unscoped-has.test.ts +++ b/oxlint-plugin-t3code/rules/no-unscoped-has.test.ts @@ -95,6 +95,11 @@ describe("t3code/no-unscoped-has", () => { `const className = "[&_:is(.safe,:has(input))]:p-2";`, ); + rule.invalid( + "reports an unanchored :has() inside a named group variant", + `const className = "group-[&_:has(x)]/row:p-2";`, + ); + rule.invalid( "reports :has() anchored to the document root", `const className = "[body:has([data-dialog-open])_&]:overflow-hidden";`, diff --git a/oxlint-plugin-t3code/rules/no-unscoped-has.ts b/oxlint-plugin-t3code/rules/no-unscoped-has.ts index ef4e645f4941..477a7994916c 100644 --- a/oxlint-plugin-t3code/rules/no-unscoped-has.ts +++ b/oxlint-plugin-t3code/rules/no-unscoped-has.ts @@ -12,7 +12,10 @@ const GROUP_PREFIX_PATTERN = /(?:^|:)(?:group|peer)-$/u; const ATTRIBUTE_PREFIX_PATTERN = /(?:^|:)(?:data|aria)-$/u; const QUOTED_PATTERN = /(["'])(?:\\.|(?!\1).)*\1/gu; -/** Tailwind arbitrary variants in a class token: top-level `[...]` groups followed by `:`. */ +// A variant's closing bracket is followed by ":" or a group/peer name like "/row:". +const VARIANT_END_PATTERN = /^(?:\/[\w-]+)?:/u; + +/** Tailwind arbitrary variants in a class token: top-level `[...]` groups used as variants. */ function variantGroups(token: string): { prefix: string; group: string }[] { const groups: { prefix: string; group: string }[] = []; let depth = 0; @@ -24,7 +27,7 @@ function variantGroups(token: string): { prefix: string; group: string }[] { depth++; } else if (char === "]" && depth > 0) { depth--; - if (depth === 0 && token[index + 1] === ":") { + if (depth === 0 && VARIANT_END_PATTERN.test(token.slice(index + 1))) { groups.push({ prefix: token.slice(0, start - 1), group: token.slice(start, index) }); } } From 0679a9a3a4c37b340434e66b899a99b7aaa15319 Mon Sep 17 00:00:00 2001 From: flamboh Date: Sat, 3 Oct 2026 19:29:28 +0000 Subject: [PATCH 8/8] fix(lint): no-unscoped-has follows Tailwind's & and in-* rules A variant without & applies to the element itself, so [:has(x)] and not-[:has(x)] are anchored. A wrapper only lends its anchor when the :has() is its subject, so [:has(x)_.foo] and group-[:has(x)_.y] still report, as does in-[...], which matches an ancestor. --- .../rules/no-unscoped-has.test.ts | 17 ++++++ oxlint-plugin-t3code/rules/no-unscoped-has.ts | 57 ++++++++++++++++--- 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/oxlint-plugin-t3code/rules/no-unscoped-has.test.ts b/oxlint-plugin-t3code/rules/no-unscoped-has.test.ts index 70e4ee1bb3d4..76f0e7c7159d 100644 --- a/oxlint-plugin-t3code/rules/no-unscoped-has.test.ts +++ b/oxlint-plugin-t3code/rules/no-unscoped-has.test.ts @@ -60,6 +60,11 @@ describe("t3code/no-unscoped-has", () => { `const className = "data-[foo='_:has(x)']:p-2 [&_[data-query='_:has(foo)']]:p-2";`, ); + rule.valid( + "allows a selector without & on the element itself", + `const className = "[:has(>input)]:p-2 not-[:has(>[data-slot=icon])]:ps-2";`, + ); + rule.invalid( "reports a sibling :has() with nothing anchoring it", `const className = "[&+:has([data-chat-composer-form])_[data-chat-composer-form]]:before:rounded-none";`, @@ -100,6 +105,18 @@ describe("t3code/no-unscoped-has", () => { `const className = "group-[&_:has(x)]/row:p-2";`, ); + rule.invalid( + "reports a :has() on an ancestor of a selector without &", + `const className = "[:has(x)_.foo]:p-2";`, + ); + + rule.invalid( + "reports a :has() on an ancestor of a group", + `const className = "group-[:has(x)_.y]:p-2";`, + ); + + rule.invalid("reports an in-* ancestor :has()", `const className = "in-[:has(x)]:p-2";`); + rule.invalid( "reports :has() anchored to the document root", `const className = "[body:has([data-dialog-open])_&]:overflow-hidden";`, diff --git a/oxlint-plugin-t3code/rules/no-unscoped-has.ts b/oxlint-plugin-t3code/rules/no-unscoped-has.ts index 477a7994916c..088505b3e0cf 100644 --- a/oxlint-plugin-t3code/rules/no-unscoped-has.ts +++ b/oxlint-plugin-t3code/rules/no-unscoped-has.ts @@ -6,9 +6,10 @@ const COMBINATOR_PATTERN = /[\s>+~]/u; const NARROWING_PATTERN = /[.#[]|^[a-z]/iu; const ROOT_COMPOUND_PATTERN = /^(?:html|body|:root)(?![\w-])/iu; -// group-[...] and peer-[...] match the .group/.peer element; data-[...] and -// aria-[...] hold attribute values, not selectors. +// group-[...] and peer-[...] match the .group/.peer element, in-[...] matches an +// ancestor, and data-[...] and aria-[...] hold attribute values, not selectors. const GROUP_PREFIX_PATTERN = /(?:^|:)(?:group|peer)-$/u; +const ANCESTOR_PREFIX_PATTERN = /(?:^|:)in-$/u; const ATTRIBUTE_PREFIX_PATTERN = /(?:^|:)(?:data|aria)-$/u; const QUOTED_PATTERN = /(["'])(?:\\.|(?!\1).)*\1/gu; @@ -35,6 +36,39 @@ function variantGroups(token: string): { prefix: string; group: string }[] { return groups; } +/** Index of the ")" closing the "(" at `open`, or the selector's length. */ +function closingParen(selector: string, open: number): number { + let depth = 0; + for (let index = open; index < selector.length; index++) { + if (selector[index] === "(") depth++; + else if (selector[index] === ")" && --depth === 0) return index; + } + return selector.length; +} + +/** + * Whether nothing after `from` in the enclosing branch is joined by a combinator, + * i.e. the element ending at `from` is the subject that its wrapper matches. + */ +function isWrapperSubject(selector: string, from: number): boolean { + let depth = 0; + for (let index = from; index < selector.length; index++) { + const char = selector[index] ?? ""; + if (char === "(") depth++; + else if (char === ")") { + if (depth === 0) return true; + depth--; + } else if (depth > 0) continue; + else if (char === ",") return true; + else if (COMBINATOR_PATTERN.test(char)) { + const next = selector.slice(index).trimStart()[0]; + if (/\s/u.test(char) && (next === undefined || next === ")" || next === ",")) continue; + return false; + } + } + return true; +} + /** The compound selector each `:has(` in `selector` is attached to. */ function hasCompounds(selector: string): string[] { const compounds: string[] = []; @@ -42,16 +76,22 @@ function hasCompounds(selector: string): string[] { while (index !== -1) { let compound = ""; let depth = 0; + let subjectEnd = closingParen(selector, index + ":has".length) + 1; // Other branches of a selector list are skipped until the wrapper that holds // them opens. An unbalanced "(" means the :has() sits inside :not()/:is()/ - // :where(), so the compound outside that wrapper still applies. + // :where(); the compound outside that wrapper applies only when the :has() + // is in the wrapper's subject position. let skippingBranch = false; for (let position = index - 1; position >= 0; position--) { const char = selector[position] ?? ""; if (char === ")") depth++; else if (char === "(") { if (depth > 0) depth--; - else skippingBranch = false; + else { + if (!isWrapperSubject(selector, subjectEnd)) break; + skippingBranch = false; + subjectEnd = closingParen(selector, position) + 1; + } } else if (depth === 0 && char === ",") { skippingBranch = true; continue; @@ -93,12 +133,13 @@ function findUnscopedHasVariants(text: string): string[] { for (const { prefix, group } of variantGroups(token)) { if (ATTRIBUTE_PREFIX_PATTERN.test(prefix)) continue; // Tailwind writes spaces as "_", and "&" is the element carrying the class. + // A selector without "&" applies to that element, as `&:is(...)`. const relative = group.toLowerCase().replace(QUOTED_PATTERN, '""').replaceAll("_", " "); const owner = GROUP_PREFIX_PATTERN.test(prefix) ? ".group" : ".self"; - const selector = relative.includes("&") - ? relative.replaceAll("&", owner) - : owner === ".self" - ? relative + const selector = ANCESTOR_PREFIX_PATTERN.test(prefix) + ? `:is(${relative.replaceAll("&", "*")}) .self` + : relative.includes("&") + ? relative.replaceAll("&", owner) : `${owner}:is(${relative})`; const unscoped = hasCompounds(selector).some((compound) => { const anchor = withoutNegations(compound);