Ensure appear works using the Transition component (even when used with SSR) (#2646)

* ensure `appear` works in combination with SSR

* add appear transition example

* update changelog

* add scale to appear example

* trigger immediate transition once the DOM is ready

* ensure React doesn't change the `className` underneath us

* handle all base classes

We are bypassing React when handling classes in the Transition
component. Let's ensure the base classes from the prop are also added
correctly.

* add missing `base` to tests

* simplify `useTransition` hook

* add react-hot-toast example

* make TS happy

* ensure the `classNames` are unique

* remove classNames if it results in an empty string

This will ensure that we don't end up with `class=""` in the DOM

* ensure `unmount` is defaulting to `true`

* do not read from `prevShow` in render

After fixing the other bugs, this part only caused bugs right now. Even
when re-rendering the Transition component while transitioning. Dropping
this fixes that behaviour.

* extend `appear` demo with appear, show, unmount booleans

+ a `lazily` one to mimic a conditional render on the client instead of
  a fresh page refresh.
This commit is contained in:
Robin Malfait
2023-08-07 17:59:40 +02:00
committed by GitHub
parent 88a0138d92
commit 842890d054
10 changed files with 395 additions and 22 deletions
+1
View File
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Disable smooth scrolling when opening/closing `Dialog` components on iOS ([#2635](https://github.com/tailwindlabs/headlessui/pull/2635))
- Don't assume `<Tab />` components are available when setting the next index ([#2642](https://github.com/tailwindlabs/headlessui/pull/2642))
- Fix incorrectly focused `Combobox.Input` component on page load ([#2654](https://github.com/tailwindlabs/headlessui/pull/2654))
- Ensure `appear` works using the `Transition` component (even when used with SSR) ([#2646](https://github.com/tailwindlabs/headlessui/pull/2646))
## [1.7.16] - 2023-07-27
@@ -302,7 +302,7 @@ function TransitionChildFn<TTag extends ElementType = typeof DEFAULT_TRANSITION_
} = props as typeof props
let container = useRef<HTMLElement | null>(null)
let transitionRef = useSyncRefs(container, ref)
let strategy = rest.unmount ? RenderStrategy.Unmount : RenderStrategy.Hidden
let strategy = rest.unmount ?? true ? RenderStrategy.Unmount : RenderStrategy.Hidden
let { show, appear, initial } = useTransitionContext()
@@ -310,7 +310,6 @@ function TransitionChildFn<TTag extends ElementType = typeof DEFAULT_TRANSITION_
let parentNesting = useParentNesting()
let { register, unregister } = parentNesting
let prevShow = useRef<boolean | null>(null)
useEffect(() => register(container), [register, container])
@@ -332,6 +331,7 @@ function TransitionChildFn<TTag extends ElementType = typeof DEFAULT_TRANSITION_
}, [state, container, register, unregister, show, strategy])
let classes = useLatestValue({
base: splitClasses(rest.className),
enter: splitClasses(enter),
enterFrom: splitClasses(enterFrom),
enterTo: splitClasses(enterTo),
@@ -358,11 +358,11 @@ function TransitionChildFn<TTag extends ElementType = typeof DEFAULT_TRANSITION_
// Skipping initial transition
let skip = initial && !appear
let immediate = appear && show && initial
let transitionDirection = (() => {
if (!ready) return 'idle'
if (skip) return 'idle'
if (prevShow.current === show) return 'idle'
return show ? 'enter' : 'leave'
})() as TransitionDirection
@@ -404,6 +404,7 @@ function TransitionChildFn<TTag extends ElementType = typeof DEFAULT_TRANSITION_
}, parentNesting)
useTransition({
immediate,
container,
classes,
direction: transitionDirection,
@@ -422,25 +423,23 @@ function TransitionChildFn<TTag extends ElementType = typeof DEFAULT_TRANSITION_
}),
})
useEffect(() => {
if (!skip) return
if (strategy === RenderStrategy.Hidden) {
prevShow.current = null
} else {
prevShow.current = show
}
}, [show, skip, state])
let theirProps = rest
let ourProps = { ref: transitionRef }
if (appear && show && initial) {
if (immediate) {
theirProps = {
...theirProps,
// Already apply the `enter` and `enterFrom` on the server if required
className: classNames(rest.className, ...classes.current.enter, ...classes.current.enterFrom),
}
} else {
// When we re-render while we are in the middle of the transition, then we should take the
// incoming className and the current classes that are applied.
//
// This is a bit dirty, but we need to make sure React is not applying changes to the class
// attribute while we are transitioning.
theirProps.className = classNames(rest.className, container.current?.className)
if (theirProps.className === '') delete theirProps.className
}
return (
@@ -476,7 +475,7 @@ function TransitionRootFn<TTag extends ElementType = typeof DEFAULT_TRANSITION_C
ref: Ref<HTMLElement>
) {
// @ts-expect-error
let { show, appear = false, unmount, ...theirProps } = props as typeof props
let { show, appear = false, unmount = true, ...theirProps } = props as typeof props
let internalTransitionRef = useRef<HTMLElement | null>(null)
let transitionRef = useSyncRefs(internalTransitionRef, ref)
@@ -30,6 +30,7 @@ it('should be possible to transition', async () => {
transition(
element,
{
base: [],
enter: ['enter'],
enterFrom: ['enterFrom'],
enterTo: ['enterTo'],
@@ -87,6 +88,7 @@ it('should wait the correct amount of time to finish a transition', async () =>
transition(
element,
{
base: [],
enter: ['enter'],
enterFrom: ['enterFrom'],
enterTo: ['enterTo'],
@@ -156,6 +158,7 @@ it('should keep the delay time into account', async () => {
transition(
element,
{
base: [],
enter: ['enter'],
enterFrom: ['enterFrom'],
enterTo: ['enterTo'],
@@ -77,6 +77,7 @@ function waitForTransition(node: HTMLElement, done: () => void) {
export function transition(
node: HTMLElement,
classes: {
base: string[]
enter: string[]
enterFrom: string[]
enterTo: string[]
@@ -116,6 +117,7 @@ export function transition(
removeClasses(
node,
...classes.base,
...classes.enter,
...classes.enterTo,
...classes.enterFrom,
@@ -124,15 +126,15 @@ export function transition(
...classes.leaveTo,
...classes.entered
)
addClasses(node, ...base, ...from)
addClasses(node, ...classes.base, ...base, ...from)
d.nextFrame(() => {
removeClasses(node, ...from)
addClasses(node, ...to)
removeClasses(node, ...classes.base, ...base, ...from)
addClasses(node, ...classes.base, ...base, ...to)
waitForTransition(node, () => {
removeClasses(node, ...base)
addClasses(node, ...classes.entered)
removeClasses(node, ...classes.base, ...base)
addClasses(node, ...classes.base, ...classes.entered)
return _done()
})
@@ -9,8 +9,11 @@ import { useIsoMorphicEffect } from './use-iso-morphic-effect'
import { useLatestValue } from './use-latest-value'
interface TransitionArgs {
immediate: boolean
container: MutableRefObject<HTMLElement | null>
classes: MutableRefObject<{
base: string[]
enter: string[]
enterFrom: string[]
enterTo: string[]
@@ -26,12 +29,25 @@ interface TransitionArgs {
onStop: MutableRefObject<(direction: TransitionArgs['direction']) => void>
}
export function useTransition({ container, direction, classes, onStart, onStop }: TransitionArgs) {
export function useTransition({
immediate,
container,
direction,
classes,
onStart,
onStop,
}: TransitionArgs) {
let mounted = useIsMounted()
let d = useDisposables()
let latestDirection = useLatestValue(direction)
useIsoMorphicEffect(() => {
if (!immediate) return
latestDirection.current = 'enter'
}, [immediate])
useIsoMorphicEffect(() => {
let dd = disposables()
d.add(dd.dispose)
@@ -1,3 +1,15 @@
export function classNames(...classes: (false | null | undefined | string)[]): string {
return classes.filter(Boolean).join(' ')
return Array.from(
new Set(
classes.flatMap((value) => {
if (typeof value === 'string') {
return value.split(' ')
}
return []
})
)
)
.filter(Boolean)
.join(' ')
}
+1
View File
@@ -27,6 +27,7 @@
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-flatpickr": "^3.10.9",
"react-hot-toast": "2.3.0",
"tailwindcss": "^3.2.7"
},
"devDependencies": {
@@ -0,0 +1,287 @@
import { Transition } from '@headlessui/react'
import { Fragment, useState } from 'react'
import { Button } from '../../components/button'
export default function AppearExample() {
let [show, setShow] = useState(true)
let [lazy, setLazy] = useState(false)
return (
<div className="space-y-4 p-8">
<div className="flex items-center gap-3">
<Button onClick={() => setShow((v) => !v)}>Toggle show</Button>
<Button onClick={() => setLazy((v) => !v)}>Toggle lazy</Button>
</div>
<div className="flex items-center gap-4">
<div className="rounded-md bg-white p-4 shadow ring-1 ring-black/5">
<span className="mb-2">Initial render</span>
<div className="grid max-w-6xl grid-cols-4 gap-4">
<Transition
show={show}
appear={true}
unmount={true}
enter="duration-1000 transition"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="duration-1000 transition"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
className="aspect-square flex-1 rounded-md bg-blue-200 p-4"
>
Appear: true, unmount: true
</Transition>
<Transition
as={Fragment}
show={show}
appear={true}
unmount={true}
enter="duration-1000 transition"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="duration-1000 transition"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<div className="aspect-square flex-1 rounded-md bg-blue-200 p-4">
Appear: true, as={`Fragment`}, unmount: true
</div>
</Transition>
<Transition
show={show}
appear={false}
unmount={true}
enter="duration-1000 transition"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="duration-1000 transition"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
className="aspect-square flex-1 rounded-md bg-blue-200 p-4"
>
Appear: false, unmount: true
</Transition>
<Transition
as={Fragment}
show={show}
appear={false}
unmount={true}
enter="duration-1000 transition"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="duration-1000 transition"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<div className="aspect-square flex-1 rounded-md bg-blue-200 p-4">
Appear: false, as={`Fragment`}, unmount: true
</div>
</Transition>
<Transition
show={show}
appear={true}
unmount={false}
enter="duration-1000 transition"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="duration-1000 transition"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
className="aspect-square flex-1 rounded-md bg-blue-200 p-4"
>
Appear: true, unmount: false
</Transition>
<Transition
as={Fragment}
show={show}
appear={true}
unmount={false}
enter="duration-1000 transition"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="duration-1000 transition"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<div className="aspect-square flex-1 rounded-md bg-blue-200 p-4">
Appear: true, as={`Fragment`}, unmount: false
</div>
</Transition>
<Transition
show={show}
appear={false}
unmount={false}
enter="duration-1000 transition"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="duration-1000 transition"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
className="aspect-square flex-1 rounded-md bg-blue-200 p-4"
>
Appear: false, unmount: false
</Transition>
<Transition
as={Fragment}
show={show}
appear={false}
unmount={false}
enter="duration-1000 transition"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="duration-1000 transition"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<div className="aspect-square flex-1 rounded-md bg-blue-200 p-4">
Appear: false, as={`Fragment`}, unmount: false
</div>
</Transition>
</div>
</div>
{lazy && (
<div className="rounded-md bg-white p-4 shadow ring-1 ring-black/5">
<span className="mb-2">Not on the initial render</span>
<div className="grid max-w-6xl grid-cols-4 gap-4">
<Transition
show={show}
appear={true}
unmount={true}
enter="duration-1000 transition"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="duration-1000 transition"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
className="aspect-square flex-1 rounded-md bg-blue-200 p-4"
>
Appear: true, unmount: true
</Transition>
<Transition
as={Fragment}
show={show}
appear={true}
unmount={true}
enter="duration-1000 transition"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="duration-1000 transition"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<div className="aspect-square flex-1 rounded-md bg-blue-200 p-4">
Appear: true, as={`Fragment`}, unmount: true
</div>
</Transition>
<Transition
show={show}
appear={false}
unmount={true}
enter="duration-1000 transition"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="duration-1000 transition"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
className="aspect-square flex-1 rounded-md bg-blue-200 p-4"
>
Appear: false, unmount: true
</Transition>
<Transition
as={Fragment}
show={show}
appear={false}
unmount={true}
enter="duration-1000 transition"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="duration-1000 transition"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<div className="aspect-square flex-1 rounded-md bg-blue-200 p-4">
Appear: false, as={`Fragment`}, unmount: true
</div>
</Transition>
<Transition
show={show}
appear={true}
unmount={false}
enter="duration-1000 transition"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="duration-1000 transition"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
className="aspect-square flex-1 rounded-md bg-blue-200 p-4"
>
Appear: true, unmount: false
</Transition>
<Transition
as={Fragment}
show={show}
appear={true}
unmount={false}
enter="duration-1000 transition"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="duration-1000 transition"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<div className="aspect-square flex-1 rounded-md bg-blue-200 p-4">
Appear: true, as={`Fragment`}, unmount: false
</div>
</Transition>
<Transition
show={show}
appear={false}
unmount={false}
enter="duration-1000 transition"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="duration-1000 transition"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
className="aspect-square flex-1 rounded-md bg-blue-200 p-4"
>
Appear: false, unmount: false
</Transition>
<Transition
as={Fragment}
show={show}
appear={false}
unmount={false}
enter="duration-1000 transition"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="duration-1000 transition"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<div className="aspect-square flex-1 rounded-md bg-blue-200 p-4">
Appear: false, as={`Fragment`}, unmount: false
</div>
</Transition>
</div>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,40 @@
import React from 'react'
import { Transition } from '@headlessui/react'
import { Toaster, ToastIcon, toast, resolveValue } from 'react-hot-toast'
const TailwindToaster = () => {
return (
<Toaster position="top-right">
{(t) => (
<Transition
appear
show={t.visible}
className="flex transform rounded bg-white p-4 shadow-lg"
enter="transition-all duration-500"
enterFrom="opacity-0 scale-50"
enterTo="opacity-100 scale-100"
leave="transition-all duration-150"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-75"
>
<ToastIcon toast={t} />
<p className="px-2">{resolveValue(t.message, t)}</p>
</Transition>
)}
</Toaster>
)
}
export default function App() {
return (
<div className="m-8">
<button
className="rounded bg-blue-500 p-4 text-white"
onClick={() => toast.success('This is Tailwind CSS')}
>
Create TailwindCSS Toast
</button>
<TailwindToaster />
</div>
)
}
+12
View File
@@ -2746,6 +2746,11 @@ globals@^11.1.0:
resolved "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz"
integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==
goober@^2.1.10:
version "2.1.13"
resolved "https://registry.yarnpkg.com/goober/-/goober-2.1.13.tgz#e3c06d5578486212a76c9eba860cbc3232ff6d7c"
integrity sha512-jFj3BQeleOoy7t93E9rZ2de+ScC4lQICLwiAQmKMg9F6roKGaLSHoCDYKkWlSafg138jejvq/mTdvmnwDQgqoQ==
graceful-fs@^4.1.2, graceful-fs@^4.2.4:
version "4.2.9"
resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.9.tgz"
@@ -4719,6 +4724,13 @@ react-flatpickr@^3.10.9:
flatpickr "^4.6.2"
prop-types "^15.5.10"
react-hot-toast@2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/react-hot-toast/-/react-hot-toast-2.3.0.tgz#70b3d183ac2a4afb6b17cda4a7f4cfe02e730415"
integrity sha512-/RxV+bfjld7tSJR1SCLzMAXgFuNW7fCpK6+vbYqfmbGSWcqTMz2rizrvfWKvtcPH5HK0NqxmBaC5SrAy1F42zA==
dependencies:
goober "^2.1.10"
react-is@^16.13.1:
version "16.13.1"
resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz"