diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index a0acd98..82d2298 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -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 diff --git a/packages/@headlessui-react/src/hooks/use-is-top-layer.ts b/packages/@headlessui-react/src/hooks/use-is-top-layer.ts index 8583753..87e2e6b 100644 --- a/packages/@headlessui-react/src/hooks/use-is-top-layer.ts +++ b/packages/@headlessui-react/src/hooks/use-is-top-layer.ts @@ -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, @@ -46,32 +26,41 @@ let hierarchyStores = new DefaultMap(() => * * ``` */ -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 + // 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 - // 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 - } - - 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 } diff --git a/packages/@headlessui-react/src/machine.ts b/packages/@headlessui-react/src/machine.ts index 7040125..cd9ff94 100644 --- a/packages/@headlessui-react/src/machine.ts +++ b/packages/@headlessui-react/src/machine.ts @@ -8,10 +8,16 @@ export abstract class Machine { ) #subscribers: Set> = new Set() + disposables = disposables() + constructor(initialState: State) { this.#state = initialState } + dispose() { + this.disposables.dispose() + } + get state(): Readonly { return this.#state } @@ -29,20 +35,23 @@ export abstract class Machine { } 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) diff --git a/packages/@headlessui-react/src/machines/stack-machine.ts b/packages/@headlessui-react/src/machines/stack-machine.ts new file mode 100644 index 0000000..84bb8ae --- /dev/null +++ b/packages/@headlessui-react/src/machines/stack-machine.ts @@ -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) => 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 { + static new() { + return new StackMachine({ stack: [] }) + } + + reduce(state: Readonly, 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(() => StackMachine.new()) diff --git a/playgrounds/react/pages/dialog/dialog.tsx b/playgrounds/react/pages/dialog/dialog.tsx index bd7cdca..fa17c08 100644 --- a/playgrounds/react/pages/dialog/dialog.tsx +++ b/playgrounds/react/pages/dialog/dialog.tsx @@ -95,6 +95,7 @@ export default function Home() {