Skip to content

Performance improvement: only re-render top-level component #3722

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
May 10, 2025
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
1 change: 1 addition & 0 deletions packages/@headlessui-react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Ensure clicking on interactive elements inside `Label` component works ([#3709](https://github.com/tailwindlabs/headlessui/pull/3709))
- Fix focus not returned to SVG Element ([#3704](https://github.com/tailwindlabs/headlessui/pull/3704))
- Fix `Listbox` not focusing first or last option on ArrowUp / ArrowDown ([#3721](https://github.com/tailwindlabs/headlessui/pull/3721))
- 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))

## [2.2.2] - 2025-04-17

Expand Down
75 changes: 32 additions & 43 deletions packages/@headlessui-react/src/hooks/use-is-top-layer.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,7 @@
import { useId } from 'react'
import { DefaultMap } from '../utils/default-map'
import { createStore } from '../utils/store'
import { useCallback, useId } from 'react'
import { stackMachines } from '../machines/stack-machine'
import { useSlice } from '../react-glue'
import { useIsoMorphicEffect } from './use-iso-morphic-effect'
import { useStore } from './use-store'

/**
* Map of stable hierarchy stores based on a given scope.
*/
let hierarchyStores = new DefaultMap(() =>
createStore(() => [] as string[], {
ADD(id: string) {
if (this.includes(id)) return this
return [...this, id]
},
REMOVE(id: string) {
let idx = this.indexOf(id)
if (idx === -1) return this
let copy = this.slice()
copy.splice(idx, 1)
return copy
},
})
)

/**
* A hook that returns whether the current node is on the top of the hierarchy,
Expand All @@ -46,32 +26,41 @@ let hierarchyStores = new DefaultMap(() =>
* </Dialog>
* ```
*/
export function useIsTopLayer(enabled: boolean, scope: string) {
let hierarchyStore = hierarchyStores.get(scope)
export function useIsTopLayer(enabled: boolean, scope: string | null) {
let id = useId()
let hierarchy = useStore(hierarchyStore)
let stackMachine = stackMachines.get(scope)

let [isTop, onStack] = useSlice(
stackMachine,
useCallback(
(state) => [
stackMachine.selectors.isTop(state, id),
stackMachine.selectors.inStack(state, id),
],
[stackMachine, id, enabled]
)
)

// Depending on the enable state, push/pop the current `id` to/from the
// hierarchy.
useIsoMorphicEffect(() => {
if (!enabled) return
stackMachine.actions.push(id)
return () => stackMachine.actions.pop(id)
}, [stackMachine, enabled, id])

hierarchyStore.dispatch('ADD', id)
return () => hierarchyStore.dispatch('REMOVE', id)
}, [hierarchyStore, enabled])

// If the hook is not enabled, we know for sure it is not going to tbe the
// top-most item.
if (!enabled) return false

let idx = hierarchy.indexOf(id)
let hierarchyLength = hierarchy.length

// Not in the hierarchy yet
if (idx === -1) {
// Assume that it will be inserted at the end, then it means that the `idx`
// will be the length of the current hierarchy.
idx = hierarchyLength

// Increase the hierarchy length as-if the node is already in the hierarchy.
hierarchyLength += 1
}
// If the hook is enabled, and it's on the stack, we can rely on the `isTop`
// derived state to determine if it's the top-most item.
if (onStack) return isTop

return idx === hierarchyLength - 1
// In this scenario, the hook is enabled, but we are not on the stack yet. In
// this case we assume that we will be the top-most item, so we return
// `true`. However, if that's not the case, and once we are on the stack (or
// other items are pushed) this hook will be re-evaluated and the `isTop`
// derived state will be used instead.
return true
}
19 changes: 14 additions & 5 deletions packages/@headlessui-react/src/machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,16 @@ export abstract class Machine<State, Event extends { type: number | string }> {
)
#subscribers: Set<Subscriber<State, any>> = new Set()

disposables = disposables()

constructor(initialState: State) {
this.#state = initialState
}

dispose() {
this.disposables.dispose()
}

get state(): Readonly<State> {
return this.#state
}
Expand All @@ -29,20 +35,23 @@ export abstract class Machine<State, Event extends { type: number | string }> {
}
this.#subscribers.add(subscriber)

return () => {
return this.disposables.add(() => {
this.#subscribers.delete(subscriber)
}
})
}

on(type: Event['type'], callback: (state: State, event: Event) => void) {
this.#eventSubscribers.get(type).add(callback)
return () => {
return this.disposables.add(() => {
this.#eventSubscribers.get(type).delete(callback)
}
})
}

send(event: Event) {
this.#state = this.reduce(this.#state, event)
let newState = this.reduce(this.#state, event)
if (newState === this.#state) return // No change

this.#state = newState

for (let subscriber of this.#subscribers) {
let slice = subscriber.selector(this.#state)
Expand Down
72 changes: 72 additions & 0 deletions packages/@headlessui-react/src/machines/stack-machine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { Machine } from '../machine'
import { DefaultMap } from '../utils/default-map'
import { match } from '../utils/match'

type Scope = string | null
type Id = string

interface State {
stack: Id[]
}

export enum ActionTypes {
Push,
Pop,
}

export type Actions = { type: ActionTypes.Push; id: Id } | { type: ActionTypes.Pop; id: Id }

let reducers: {
[P in ActionTypes]: (state: State, action: Extract<Actions, { type: P }>) => State
} = {
[ActionTypes.Push](state, action) {
let id = action.id
let stack = state.stack
let idx = state.stack.indexOf(id)

// Already in the stack, move it to the top
if (idx !== -1) {
let copy = state.stack.slice()
copy.splice(idx, 1)
copy.push(id)

stack = copy
return { ...state, stack }
}

// Not in the stack, add it to the top
return { ...state, stack: [...state.stack, id] }
},
[ActionTypes.Pop](state, action) {
let id = action.id
let idx = state.stack.indexOf(id)
if (idx === -1) return state // Not in the stack

let copy = state.stack.slice()
copy.splice(idx, 1)

return { ...state, stack: copy }
},
}

class StackMachine extends Machine<State, Actions> {
static new() {
return new StackMachine({ stack: [] })
}

reduce(state: Readonly<State>, action: Actions): State {
return match(action.type, reducers, state, action)
}

actions = {
push: (id: Id) => this.send({ type: ActionTypes.Push, id }),
pop: (id: Id) => this.send({ type: ActionTypes.Pop, id }),
}

selectors = {
isTop: (state: State, id: Id) => state.stack[state.stack.length - 1] === id,
inStack: (state: State, id: Id) => state.stack.includes(id),
}
}

export const stackMachines = new DefaultMap<Scope, StackMachine>(() => StackMachine.new())
1 change: 1 addition & 0 deletions playgrounds/react/pages/dialog/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export default function Home() {

<Transition.Child
as="div"
className="relative"
enter="ease-out transform duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
Expand Down