Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
2 changes: 1 addition & 1 deletion packages/react/src/ActionList/ActionList.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@
}

/* if a list has a mix of items with and without descriptions, reset the label font-weight to normal */
&:has([data-has-description='true']):has([data-has-description='false']) {
&[data-mixed-descriptions='true'] {
& .ItemLabel {
font-weight: var(--base-text-weight-normal);
}
Expand Down
40 changes: 40 additions & 0 deletions packages/react/src/ActionList/ActionList.stress.dev.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ const projects = Array.from({length: totalIterations}, (_, i) => ({
scope: `Scope ${i + 1}`,
}))

const mixedProjects = Array.from({length: totalIterations}, (_, i) => ({
name: `Project ${i + 1}`,
scope: i % 2 === 0 ? `Scope ${i + 1}` : undefined,
}))

export const SingleSelect = () => {
return (
<StressTest
Expand Down Expand Up @@ -48,3 +53,38 @@ export const SingleSelect = () => {
/>
)
}

export const MixedDescriptions = () => {
return (
<StressTest
componentName="ActionList"
title="Mixed Descriptions"
description="Stress test with a mix of items with and without descriptions to test :has() selector perf."
totalIterations={totalIterations}
renderIteration={count => {
return (
<>
<ActionList selectionVariant="single" showDividers role="menu" aria-label="Project">
{mixedProjects.map((project, index) => (
<ActionList.Item
key={index}
role="menuitemradio"
selected={index === count}
aria-checked={index === count}
>
<ActionList.LeadingVisual>
<TableIcon />
</ActionList.LeadingVisual>
{project.name}
{project.scope ? (
<ActionList.Description variant="block">{project.scope}</ActionList.Description>
) : null}
</ActionList.Item>
))}
</ActionList>
</>
)
}}
/>
)
}
22 changes: 22 additions & 0 deletions packages/react/src/ActionList/List.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,28 @@ const UnwrappedList = <As extends React.ElementType = 'ul'>(
[variant, selectionVariant, containerSelectionVariant, showDividers, listRole, headingId],
)

// Replaces a CSS `:has([data-has-description])` selector that caused full-subtree
// style recalculation on every DOM mutation (~674ms on 100 items, 10-20s freezes on Safari).
//
// Ideally we'd derive this from children during render, but each Item's description is
// detected via `useSlots` at render time, so the List can't know which Items have
// descriptions without duplicating slot detection or deeply inspecting children trees
// (fragile with Groups, conditional rendering, wrapper components, etc.).
//
// A context-based approach (Items registering their description state with the List) would
// work but adds registration/unregistration callbacks, a new provider, and re-renders when
// the count changes. Not worth the complexity for a derived boolean.
//
// Two querySelector calls after render is trivially cheap compared to what the browser
// was doing on every DOM mutation with `:has()`.
Comment thread
hectahertz marked this conversation as resolved.
React.useLayoutEffect(() => {
const list = listRef.current
if (!list) return
const hasWithDescription = list.querySelector('[data-has-description="true"]') !== null
const hasWithoutDescription = list.querySelector('[data-has-description="false"]') !== null
list.setAttribute('data-mixed-descriptions', String(hasWithDescription && hasWithoutDescription))
Comment thread
hectahertz marked this conversation as resolved.
Outdated
})
Comment thread
hectahertz marked this conversation as resolved.
Outdated
Comment thread
hectahertz marked this conversation as resolved.

return (
<ListContext.Provider value={listContextValue}>
{slots.heading}
Expand Down
Loading