Skip to content

Commit c8037fc

Browse files
Fix enter transitions for the Transition component (#3074)
* improve `transition` logic I spend a lot of time trying to debug this, and I'm not 100% sure that I'm correct yet. However, what we used to do is apply the "before" set of classes, wait a frame and then apply the "after" set of classes which should trigger a transition. However, for some reason, applying the "before" classes already start a transition. My current thinking is that our component is doing a lot of work and therfore prematurely applying the classes and actually triggering the transition. For example, if we want to go from `opacity-0` to `opacity-100`, it looks like setting `opacity-0` is already transitioning (from 100% because that's the default value). Then, we set `opacity-100`, but our component was just going from 100 -> 0 and we were only at let's say 98%. It looks like we cancelled the transition and only went from 98% -> 100%. I can't fully explain it purely because I don't 100% get what's going on. However, this commit fixes it in a way where we first prepare the transition by explicitly cancelling all in-flight transitions. Once that is done, we can apply the "before" set of classes, then we can apply the "after" set of classes. This seems to work consistently (even though we have failing tests, might be a JSDOM issue). This tells me that at least parts of my initial thoughts are correct where some transition is already happening (for some reason). I'm not sure what the reason is exactly. Are we doing too much work and blocking the main thread? That would be my guess... * simplify check * updating playgrounds to improve transitions * update changelog * track in-flight transitions * document `disposables()` and `useDisposables()` functions * ensure we alway return a cleanup function * move comment * apply `enterTo` or `leaveTo` classes once we are done transitioning * cleanup & simplify logic * update comment * fix another bug + update tests * add failing test as playground * simplify event callbacks Instead of always ensuring that there is an event, let's use the `?.` operator and conditionally call the callbacks instead. * use existing `useOnDisappear` hook * remove repro * only unmount if we are not transitioning ourselves * `show` is already guaranteed to be a boolean In a previous commit we checked for `undefined` and threw an error. Which means that this part is unreachable if `show` is undefined. * cleanup temporary test changes * Update packages/@headlessui-react/src/components/transition/utils/transition.ts Co-authored-by: Jonathan Reinink <[email protected]> * Update packages/@headlessui-react/src/components/transition/utils/transition.ts Co-authored-by: Jonathan Reinink <[email protected]> * Update packages/@headlessui-react/src/components/transition/utils/transition.ts Co-authored-by: Jonathan Reinink <[email protected]> * Update packages/@headlessui-react/src/components/transition/utils/transition.ts Co-authored-by: Jonathan Reinink <[email protected]> * Update packages/@headlessui-react/src/utils/disposables.ts Co-authored-by: Jonathan Reinink <[email protected]> * Update packages/@headlessui-react/src/components/transition/transition.tsx Co-authored-by: Jonathan Reinink <[email protected]> * Update packages/@headlessui-react/src/components/transition/transition.tsx Co-authored-by: Jonathan Reinink <[email protected]> * run prettier --------- Co-authored-by: Jonathan Reinink <[email protected]>
1 parent c1d3b7e commit c8037fc

File tree

11 files changed

+234
-166
lines changed

11 files changed

+234
-166
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2323
- Expose missing `data-disabled` and `data-focus` attributes on the `TabsPanel`, `MenuButton`, `PopoverButton` and `DisclosureButton` components ([#3061](https://github.com/tailwindlabs/headlessui/pull/3061))
2424
- Fix cursor position when re-focusing the `ComboboxInput` component ([#3065](https://github.com/tailwindlabs/headlessui/pull/3065))
2525
- Keep focus inside of the `<ComboboxInput />` component ([#3073](https://github.com/tailwindlabs/headlessui/pull/3073))
26+
- Fix enter transitions for the `Transition` component ([#3074](https://github.com/tailwindlabs/headlessui/pull/3074))
2627

2728
### Changed
2829

packages/@headlessui-react/src/components/transition/__snapshots__/transition.test.tsx.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ exports[`Setup API transition classes should be possible to passthrough the tran
136136
exports[`Setup API transition classes should be possible to passthrough the transition classes and immediately apply the enter transitions when appear is set to true 1`] = `
137137
<div
138138
class="enter enter-from"
139+
style=""
139140
>
140141
Children
141142
</div>

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,7 @@ describe('Setup API', () => {
343343
<div
344344
class="foo1
345345
foo2 foo1 foo2 leave"
346+
style=""
346347
>
347348
Children
348349
</div>

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

Lines changed: 37 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import React, {
44
Fragment,
55
createContext,
66
useContext,
7-
useEffect,
87
useMemo,
98
useRef,
109
useState,
@@ -18,6 +17,7 @@ import { useFlags } from '../../hooks/use-flags'
1817
import { useIsMounted } from '../../hooks/use-is-mounted'
1918
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
2019
import { useLatestValue } from '../../hooks/use-latest-value'
20+
import { useOnDisappear } from '../../hooks/use-on-disappear'
2121
import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete'
2222
import { useSyncRefs } from '../../hooks/use-sync-refs'
2323
import { useTransition } from '../../hooks/use-transition'
@@ -259,26 +259,6 @@ function useNesting(done?: () => void, parent?: NestingContextValues) {
259259
)
260260
}
261261

262-
function noop() {}
263-
let eventNames = ['beforeEnter', 'afterEnter', 'beforeLeave', 'afterLeave'] as const
264-
function ensureEventHooksExist(events: TransitionEvents) {
265-
let result = {} as Record<keyof typeof events, () => void>
266-
for (let name of eventNames) {
267-
result[name] = events[name] ?? noop
268-
}
269-
return result
270-
}
271-
272-
function useEvents(events: TransitionEvents) {
273-
let eventsRef = useRef(ensureEventHooksExist(events))
274-
275-
useEffect(() => {
276-
eventsRef.current = ensureEventHooksExist(events)
277-
}, [events])
278-
279-
return eventsRef
280-
}
281-
282262
// ---
283263

284264
let DEFAULT_TRANSITION_CHILD_TAG = 'div' as const
@@ -349,7 +329,7 @@ function TransitionChildFn<TTag extends ElementType = typeof DEFAULT_TRANSITION_
349329
leaveTo: splitClasses(leaveTo),
350330
})
351331

352-
let events = useEvents({
332+
let events = useLatestValue({
353333
beforeEnter,
354334
afterEnter,
355335
beforeLeave,
@@ -369,6 +349,7 @@ function TransitionChildFn<TTag extends ElementType = typeof DEFAULT_TRANSITION_
369349
let immediate = appear && show && initial
370350

371351
let transitionDirection = (() => {
352+
if (immediate) return 'enter'
372353
if (!ready) return 'idle'
373354
if (skip) return 'idle'
374355
return show ? 'enter' : 'leave'
@@ -380,11 +361,11 @@ function TransitionChildFn<TTag extends ElementType = typeof DEFAULT_TRANSITION_
380361
return match(direction, {
381362
enter: () => {
382363
transitionStateFlags.addFlag(State.Opening)
383-
events.current.beforeEnter()
364+
events.current.beforeEnter?.()
384365
},
385366
leave: () => {
386367
transitionStateFlags.addFlag(State.Closing)
387-
events.current.beforeLeave()
368+
events.current.beforeLeave?.()
388369
},
389370
idle: () => {},
390371
})
@@ -394,26 +375,29 @@ function TransitionChildFn<TTag extends ElementType = typeof DEFAULT_TRANSITION_
394375
return match(direction, {
395376
enter: () => {
396377
transitionStateFlags.removeFlag(State.Opening)
397-
events.current.afterEnter()
378+
events.current.afterEnter?.()
398379
},
399380
leave: () => {
400381
transitionStateFlags.removeFlag(State.Closing)
401-
events.current.afterLeave()
382+
events.current.afterLeave?.()
402383
},
403384
idle: () => {},
404385
})
405386
})
406387

388+
let isTransitioning = useRef(false)
389+
407390
let nesting = useNesting(() => {
408-
// When all children have been unmounted we can only hide ourselves if and only if we are not
409-
// transitioning ourselves. Otherwise we would unmount before the transitions are finished.
391+
// When all children have been unmounted we can only hide ourselves if and
392+
// only if we are not transitioning ourselves. Otherwise we would unmount
393+
// before the transitions are finished.
394+
if (isTransitioning.current) return
395+
410396
setState(TreeStates.Hidden)
411397
unregister(container)
412398
}, parentNesting)
413399

414-
let isTransitioning = useRef(false)
415400
useTransition({
416-
immediate,
417401
container,
418402
classes,
419403
direction: transitionDirection,
@@ -426,8 +410,8 @@ function TransitionChildFn<TTag extends ElementType = typeof DEFAULT_TRANSITION_
426410
nesting.onStop(container, direction, afterEvent)
427411

428412
if (direction === 'leave' && !hasChildren(nesting)) {
429-
// When we don't have children anymore we can safely unregister from the parent and hide
430-
// ourselves.
413+
// When we don't have children anymore we can safely unregister from the
414+
// parent and hide ourselves.
431415
setState(TreeStates.Hidden)
432416
unregister(container)
433417
}
@@ -437,10 +421,10 @@ function TransitionChildFn<TTag extends ElementType = typeof DEFAULT_TRANSITION_
437421
let theirProps = rest
438422
let ourProps = { ref: transitionRef }
439423

424+
// Already apply the `enter` and `enterFrom` on the server if required
440425
if (immediate) {
441426
theirProps = {
442427
...theirProps,
443-
// Already apply the `enter` and `enterFrom` on the server if required
444428
className: classNames(rest.className, ...classes.current.enter, ...classes.current.enterFrom),
445429
}
446430
}
@@ -457,6 +441,21 @@ function TransitionChildFn<TTag extends ElementType = typeof DEFAULT_TRANSITION_
457441
if (theirProps.className === '') delete theirProps.className
458442
}
459443

444+
// If we were never transitioning, or we're not transitioning anymore, then
445+
// apply the `enterTo` and `leaveTo` classes as the final state.
446+
else {
447+
theirProps.className = classNames(
448+
rest.className,
449+
container.current?.className,
450+
...match(transitionDirection, {
451+
enter: [...classes.current.enterTo, ...classes.current.entered],
452+
leave: classes.current.leaveTo,
453+
idle: [],
454+
})
455+
)
456+
if (theirProps.className === '') delete theirProps.className
457+
}
458+
460459
return (
461460
<NestingContext.Provider value={nesting}>
462461
<OpenClosedProvider
@@ -504,7 +503,7 @@ function TransitionRootFn<TTag extends ElementType = typeof DEFAULT_TRANSITION_C
504503
show = (usesOpenClosedState & State.Open) === State.Open
505504
}
506505

507-
if (![true, false].includes(show as unknown as boolean)) {
506+
if (show === undefined) {
508507
throw new Error('A <Transition /> is used but it is missing a `show={true | false}` prop.')
509508
}
510509

@@ -532,27 +531,18 @@ function TransitionRootFn<TTag extends ElementType = typeof DEFAULT_TRANSITION_C
532531
}, [changes, show])
533532

534533
let transitionBag = useMemo<TransitionContextValues>(
535-
() => ({ show: show as boolean, appear, initial }),
534+
() => ({ show, appear, initial }),
536535
[show, appear, initial]
537536
)
538537

538+
// Ensure we change the tree state to hidden once the transition becomes hidden
539+
useOnDisappear(internalTransitionRef, () => setState(TreeStates.Hidden))
540+
539541
useIsoMorphicEffect(() => {
540542
if (show) {
541543
setState(TreeStates.Visible)
542544
} else if (!hasChildren(nestingBag)) {
543545
setState(TreeStates.Hidden)
544-
} else if (
545-
process.env.NODE_ENV !==
546-
'test' /* TODO: Remove this once we have real tests! JSDOM doesn't "render", therefore getBoundingClientRect() will always result in `0`. */
547-
) {
548-
let node = internalTransitionRef.current
549-
if (!node) return
550-
let rect = node.getBoundingClientRect()
551-
552-
if (rect.x === 0 && rect.y === 0 && rect.width === 0 && rect.height === 0) {
553-
// The node is completely hidden, let's hide it
554-
setState(TreeStates.Hidden)
555-
}
556546
}
557547
}, [show, nestingBag])
558548

packages/@headlessui-react/src/components/transition/utils/transition.test.ts

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@ it('should be possible to transition', async () => {
2626
)
2727

2828
await new Promise<void>((resolve) => {
29-
transition(
30-
element,
31-
{
29+
transition(element, {
30+
direction: 'enter', // Show
31+
classes: {
3232
base: [],
3333
enter: ['enter'],
3434
enterFrom: ['enterFrom'],
@@ -38,9 +38,8 @@ it('should be possible to transition', async () => {
3838
leaveTo: [],
3939
entered: ['entered'],
4040
},
41-
true, // Show
42-
resolve
43-
)
41+
done: resolve,
42+
})
4443
})
4544

4645
await new Promise((resolve) => d.nextFrame(resolve))
@@ -49,13 +48,13 @@ it('should be possible to transition', async () => {
4948
expect(snapshots[0].content).toEqual('<div></div>')
5049

5150
// Start of transition
52-
expect(snapshots[1].content).toEqual('<div class="enter enterFrom"></div>')
51+
expect(snapshots[1].content).toEqual('<div style="" class="enter enterFrom"></div>')
5352

5453
// NOTE: There is no `enter enterTo`, because we didn't define a duration. Therefore it is not
5554
// necessary to put the classes on the element and immediately remove them.
5655

5756
// Cleanup phase
58-
expect(snapshots[2].content).toEqual('<div class="enterTo entered"></div>')
57+
expect(snapshots[2].content).toEqual('<div style="" class="entered enterTo enter"></div>')
5958

6059
d.dispose()
6160
})
@@ -84,9 +83,9 @@ it('should wait the correct amount of time to finish a transition', async () =>
8483
)
8584

8685
await new Promise<void>((resolve) => {
87-
transition(
88-
element,
89-
{
86+
transition(element, {
87+
direction: 'enter', // Show
88+
classes: {
9089
base: [],
9190
enter: ['enter'],
9291
enterFrom: ['enterFrom'],
@@ -96,9 +95,8 @@ it('should wait the correct amount of time to finish a transition', async () =>
9695
leaveTo: [],
9796
entered: ['entered'],
9897
},
99-
true, // Show
100-
resolve
101-
)
98+
done: resolve,
99+
})
102100
})
103101

104102
await new Promise((resolve) => d.nextFrame(resolve))
@@ -154,9 +152,9 @@ it('should keep the delay time into account', async () => {
154152
)
155153

156154
await new Promise<void>((resolve) => {
157-
transition(
158-
element,
159-
{
155+
transition(element, {
156+
direction: 'enter', // Show
157+
classes: {
160158
base: [],
161159
enter: ['enter'],
162160
enterFrom: ['enterFrom'],
@@ -166,9 +164,8 @@ it('should keep the delay time into account', async () => {
166164
leaveTo: [],
167165
entered: ['entered'],
168166
},
169-
true, // Show
170-
resolve
171-
)
167+
done: resolve,
168+
})
172169
})
173170

174171
await new Promise((resolve) => d.nextFrame(resolve))

0 commit comments

Comments
 (0)