Skip to content

Commit c5f95b0

Browse files
authored
Fix Transition component from incorrectly exposing the Closing state (#3696)
This PR fixes an issue where the scroll locking logic was incorrectly re-enabled in Dialogs if you were using a `Transition` component or a `transition` prop _and_ you had nested components with the `transition` prop (or a nested `TransitionChild` component) _and_ the parent transition finishes before any of its children. To visualize this, it would happen in this situation: ```tsx <Dialog transition> /* No transition classes */ <DialogBackdrop transition className="duration-500" /> <DialogPanel transition className="duration-200" /> </DialogPanel> </Dialog> ``` With the `transition` prop, internally these components would render a wrapper `Transition` component. The `Dialog` will look at the open/closed state provided by the `Transition` component to know whether to unmount its children or not. The `Dialog` component also has some internal hooks to make it behave as a dialog. One of those hooks is the `useScrollLock` hook. This hook will be enabled if the `Dialog` is open and disabled when it's closed. If you are using the `Transition` component or the `transition` prop, then we have to make sure that the `useScrollLock` gets disabled immediate, and not wait until the transition completes. This is done by looking at the `Closing` state. The reason for this is that disabling the `useScrollLock` also means that we restore the scroll position. But if you in the meantime navigate to a different page which also changes the scroll position, then we would restore the scroll position on a totally different page. We already had this logic setup, but the problem is that the `Closing` state was incorrectly derived from the transition state. That state was only looking at the current component (in the example above, the `Dialog` component) but not at any of the child components. Since the `Dialog` didn't have any transitions itself, the `Closing` state was only briefly there. If there is no `Closing` state, then the `useScrollLock` is looking at the `open` state of the `Dialog`. Because other child components were still transitioning, the `Dialog` was still in an open state. This actually **re-enabled** the `useScrollLock` hook. Because from the `Dialog`s perspective no transitions were happening anymore. Eventually the transitions of all the children completed causing the `Transition` and thus the `Dialog` to unmount. This in turn caused the `useScrollLock` hook to also clean up and restore the scroll position. But as you might have guessed, now this second time, it's restoring _after_ the transition is done. Luckily, the fix is simple. Make sure that the `Closing` state also keeps the full hierarchy into account and not only the state of the current element.
1 parent e10f54b commit c5f95b0

File tree

2 files changed

+12
-11
lines changed

2 files changed

+12
-11
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- Improve `Menu` component performance ([#3685](https://github.com/tailwindlabs/headlessui/pull/3685))
1313
- Improve `Listbox` component performance ([#3688](https://github.com/tailwindlabs/headlessui/pull/3688))
1414
- Open `Menu` and `Listbox` on `mousedown` ([#3689](https://github.com/tailwindlabs/headlessui/pull/3689))
15+
- Fix `Transition` component from incorrectly exposing the `Closing` state ([#3696](https://github.com/tailwindlabs/headlessui/pull/3696))
1516

1617
## [2.2.1] - 2025-04-04
1718

packages/@headlessui-react/src/components/transition/transition.tsx

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,7 @@ function TransitionChildFn<TTag extends ElementType = typeof DEFAULT_TRANSITION_
330330

331331
let { show, appear, initial } = useTransitionContext()
332332

333-
let [state, setState] = useState(show ? TreeStates.Visible : TreeStates.Hidden)
333+
let [treeState, setState] = useState(show ? TreeStates.Visible : TreeStates.Hidden)
334334

335335
let parentNesting = useParentNesting()
336336
let { register, unregister } = parentNesting
@@ -343,26 +343,26 @@ function TransitionChildFn<TTag extends ElementType = typeof DEFAULT_TRANSITION_
343343
if (!container.current) return
344344

345345
// Make sure that we are visible
346-
if (show && state !== TreeStates.Visible) {
346+
if (show && treeState !== TreeStates.Visible) {
347347
setState(TreeStates.Visible)
348348
return
349349
}
350350

351-
return match(state, {
351+
return match(treeState, {
352352
[TreeStates.Hidden]: () => unregister(container),
353353
[TreeStates.Visible]: () => register(container),
354354
})
355-
}, [state, container, register, unregister, show, strategy])
355+
}, [treeState, container, register, unregister, show, strategy])
356356

357357
let ready = useServerHandoffComplete()
358358

359359
useIsoMorphicEffect(() => {
360360
if (!requiresRef) return
361361

362-
if (ready && state === TreeStates.Visible && container.current === null) {
362+
if (ready && treeState === TreeStates.Visible && container.current === null) {
363363
throw new Error('Did you forget to passthrough the `ref` to the actual DOM node?')
364364
}
365-
}, [container, state, ready, requiresRef])
365+
}, [container, treeState, ready, requiresRef])
366366

367367
// Skipping initial transition
368368
let skip = initial && !appear
@@ -470,10 +470,10 @@ function TransitionChildFn<TTag extends ElementType = typeof DEFAULT_TRANSITION_
470470
})
471471

472472
let openClosedState = 0
473-
if (state === TreeStates.Visible) openClosedState |= State.Open
474-
if (state === TreeStates.Hidden) openClosedState |= State.Closed
475-
if (transitionData.enter) openClosedState |= State.Opening
476-
if (transitionData.leave) openClosedState |= State.Closing
473+
if (treeState === TreeStates.Visible) openClosedState |= State.Open
474+
if (treeState === TreeStates.Hidden) openClosedState |= State.Closed
475+
if (show && treeState === TreeStates.Hidden) openClosedState |= State.Opening
476+
if (!show && treeState === TreeStates.Visible) openClosedState |= State.Closing
477477

478478
let render = useRender()
479479

@@ -485,7 +485,7 @@ function TransitionChildFn<TTag extends ElementType = typeof DEFAULT_TRANSITION_
485485
theirProps,
486486
defaultTag: DEFAULT_TRANSITION_CHILD_TAG,
487487
features: TransitionChildRenderFeatures,
488-
visible: state === TreeStates.Visible,
488+
visible: treeState === TreeStates.Visible,
489489
name: 'Transition.Child',
490490
})}
491491
</OpenClosedProvider>

0 commit comments

Comments
 (0)