Skip to content

Commit 18cf984

Browse files
authored
Performance improvement: only re-render top-level component (#3722)
This PR fixes a performance issue where all components using the `useIsTopLayer` hook will re-render when the hook changes. For context, the internal hook is used to know which component is the top most component. This is important in a situation like this: ``` <Dialog> <Menu /> </Dialog> ``` If the Menu inside the Dialog is open, it is considered the top most component. Clicking outside of the Menu or pressing escape should only close the Menu and not the Dialog. This behavior is similar to the native `#top-layer` you see when using native dialogs for example. The issue however is that the `useIsTopLayer` subscribes to an external store which is shared across all components. This means that when the store changes, all components using the hook will re-render. To make things worse, since we can't use these hooks unconditionally, they will all be subscribed to the store even if the Menu component(s) are not open. To solve this, we will use a new state machine and use the `useMachine` hook. This internally uses a `useSyncExternalStoreWithSelector` to subscribe to the store. This means that the component will only re-render if the state computed by the selector changes. This now means that at most 2 components will re-render when the store changes: 1. The component that _was_ in the top most position 2. The component that is going to be in the top most position Fixes: #3630 Closes: #3662 # Test plan Behavior before: notice how all Menu components re-render: https://github.com/user-attachments/assets/3172b632-0fa4-42db-970c-39efc827dd84 After this change, only the Menu that was opened / closed will re-render: https://github.com/user-attachments/assets/5d254bfc-5233-47a7-94d3-eb7a8593e14f
1 parent 662663d commit 18cf984

File tree

5 files changed

+120
-48
lines changed

5 files changed

+120
-48
lines changed

packages/@headlessui-react/CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1717
- Ensure clicking on interactive elements inside `Label` component works ([#3709](https://github.com/tailwindlabs/headlessui/pull/3709))
1818
- Fix focus not returned to SVG Element ([#3704](https://github.com/tailwindlabs/headlessui/pull/3704))
1919
- Fix `Listbox` not focusing first or last option on ArrowUp / ArrowDown ([#3721](https://github.com/tailwindlabs/headlessui/pull/3721))
20+
- Performance improvement: only re-render top-level component when nesting components e.g.: `Menu` inside a `Dialog` ([#3722](https://github.com/tailwindlabs/headlessui/pull/3722))
2021

2122
## [2.2.2] - 2025-04-17
2223

Original file line numberDiff line numberDiff line change
@@ -1,27 +1,7 @@
1-
import { useId } from 'react'
2-
import { DefaultMap } from '../utils/default-map'
3-
import { createStore } from '../utils/store'
1+
import { useCallback, useId } from 'react'
2+
import { stackMachines } from '../machines/stack-machine'
3+
import { useSlice } from '../react-glue'
44
import { useIsoMorphicEffect } from './use-iso-morphic-effect'
5-
import { useStore } from './use-store'
6-
7-
/**
8-
* Map of stable hierarchy stores based on a given scope.
9-
*/
10-
let hierarchyStores = new DefaultMap(() =>
11-
createStore(() => [] as string[], {
12-
ADD(id: string) {
13-
if (this.includes(id)) return this
14-
return [...this, id]
15-
},
16-
REMOVE(id: string) {
17-
let idx = this.indexOf(id)
18-
if (idx === -1) return this
19-
let copy = this.slice()
20-
copy.splice(idx, 1)
21-
return copy
22-
},
23-
})
24-
)
255

266
/**
277
* A hook that returns whether the current node is on the top of the hierarchy,
@@ -46,32 +26,41 @@ let hierarchyStores = new DefaultMap(() =>
4626
* </Dialog>
4727
* ```
4828
*/
49-
export function useIsTopLayer(enabled: boolean, scope: string) {
50-
let hierarchyStore = hierarchyStores.get(scope)
29+
export function useIsTopLayer(enabled: boolean, scope: string | null) {
5130
let id = useId()
52-
let hierarchy = useStore(hierarchyStore)
31+
let stackMachine = stackMachines.get(scope)
32+
33+
let [isTop, onStack] = useSlice(
34+
stackMachine,
35+
useCallback(
36+
(state) => [
37+
stackMachine.selectors.isTop(state, id),
38+
stackMachine.selectors.inStack(state, id),
39+
],
40+
[stackMachine, id, enabled]
41+
)
42+
)
5343

44+
// Depending on the enable state, push/pop the current `id` to/from the
45+
// hierarchy.
5446
useIsoMorphicEffect(() => {
5547
if (!enabled) return
48+
stackMachine.actions.push(id)
49+
return () => stackMachine.actions.pop(id)
50+
}, [stackMachine, enabled, id])
5651

57-
hierarchyStore.dispatch('ADD', id)
58-
return () => hierarchyStore.dispatch('REMOVE', id)
59-
}, [hierarchyStore, enabled])
60-
52+
// If the hook is not enabled, we know for sure it is not going to tbe the
53+
// top-most item.
6154
if (!enabled) return false
6255

63-
let idx = hierarchy.indexOf(id)
64-
let hierarchyLength = hierarchy.length
65-
66-
// Not in the hierarchy yet
67-
if (idx === -1) {
68-
// Assume that it will be inserted at the end, then it means that the `idx`
69-
// will be the length of the current hierarchy.
70-
idx = hierarchyLength
71-
72-
// Increase the hierarchy length as-if the node is already in the hierarchy.
73-
hierarchyLength += 1
74-
}
56+
// If the hook is enabled, and it's on the stack, we can rely on the `isTop`
57+
// derived state to determine if it's the top-most item.
58+
if (onStack) return isTop
7559

76-
return idx === hierarchyLength - 1
60+
// In this scenario, the hook is enabled, but we are not on the stack yet. In
61+
// this case we assume that we will be the top-most item, so we return
62+
// `true`. However, if that's not the case, and once we are on the stack (or
63+
// other items are pushed) this hook will be re-evaluated and the `isTop`
64+
// derived state will be used instead.
65+
return true
7766
}

packages/@headlessui-react/src/machine.ts

+14-5
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,16 @@ export abstract class Machine<State, Event extends { type: number | string }> {
88
)
99
#subscribers: Set<Subscriber<State, any>> = new Set()
1010

11+
disposables = disposables()
12+
1113
constructor(initialState: State) {
1214
this.#state = initialState
1315
}
1416

17+
dispose() {
18+
this.disposables.dispose()
19+
}
20+
1521
get state(): Readonly<State> {
1622
return this.#state
1723
}
@@ -29,20 +35,23 @@ export abstract class Machine<State, Event extends { type: number | string }> {
2935
}
3036
this.#subscribers.add(subscriber)
3137

32-
return () => {
38+
return this.disposables.add(() => {
3339
this.#subscribers.delete(subscriber)
34-
}
40+
})
3541
}
3642

3743
on(type: Event['type'], callback: (state: State, event: Event) => void) {
3844
this.#eventSubscribers.get(type).add(callback)
39-
return () => {
45+
return this.disposables.add(() => {
4046
this.#eventSubscribers.get(type).delete(callback)
41-
}
47+
})
4248
}
4349

4450
send(event: Event) {
45-
this.#state = this.reduce(this.#state, event)
51+
let newState = this.reduce(this.#state, event)
52+
if (newState === this.#state) return // No change
53+
54+
this.#state = newState
4655

4756
for (let subscriber of this.#subscribers) {
4857
let slice = subscriber.selector(this.#state)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { Machine } from '../machine'
2+
import { DefaultMap } from '../utils/default-map'
3+
import { match } from '../utils/match'
4+
5+
type Scope = string | null
6+
type Id = string
7+
8+
interface State {
9+
stack: Id[]
10+
}
11+
12+
export enum ActionTypes {
13+
Push,
14+
Pop,
15+
}
16+
17+
export type Actions = { type: ActionTypes.Push; id: Id } | { type: ActionTypes.Pop; id: Id }
18+
19+
let reducers: {
20+
[P in ActionTypes]: (state: State, action: Extract<Actions, { type: P }>) => State
21+
} = {
22+
[ActionTypes.Push](state, action) {
23+
let id = action.id
24+
let stack = state.stack
25+
let idx = state.stack.indexOf(id)
26+
27+
// Already in the stack, move it to the top
28+
if (idx !== -1) {
29+
let copy = state.stack.slice()
30+
copy.splice(idx, 1)
31+
copy.push(id)
32+
33+
stack = copy
34+
return { ...state, stack }
35+
}
36+
37+
// Not in the stack, add it to the top
38+
return { ...state, stack: [...state.stack, id] }
39+
},
40+
[ActionTypes.Pop](state, action) {
41+
let id = action.id
42+
let idx = state.stack.indexOf(id)
43+
if (idx === -1) return state // Not in the stack
44+
45+
let copy = state.stack.slice()
46+
copy.splice(idx, 1)
47+
48+
return { ...state, stack: copy }
49+
},
50+
}
51+
52+
class StackMachine extends Machine<State, Actions> {
53+
static new() {
54+
return new StackMachine({ stack: [] })
55+
}
56+
57+
reduce(state: Readonly<State>, action: Actions): State {
58+
return match(action.type, reducers, state, action)
59+
}
60+
61+
actions = {
62+
push: (id: Id) => this.send({ type: ActionTypes.Push, id }),
63+
pop: (id: Id) => this.send({ type: ActionTypes.Pop, id }),
64+
}
65+
66+
selectors = {
67+
isTop: (state: State, id: Id) => state.stack[state.stack.length - 1] === id,
68+
inStack: (state: State, id: Id) => state.stack.includes(id),
69+
}
70+
}
71+
72+
export const stackMachines = new DefaultMap<Scope, StackMachine>(() => StackMachine.new())

playgrounds/react/pages/dialog/dialog.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ export default function Home() {
9595

9696
<Transition.Child
9797
as="div"
98+
className="relative"
9899
enter="ease-out transform duration-300"
99100
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
100101
enterTo="opacity-100 translate-y-0 sm:scale-100"

0 commit comments

Comments
 (0)