From 842890d054182e1afc4664a731dbd3067da39558 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 7 Aug 2023 17:59:40 +0200 Subject: [PATCH] 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. --- packages/@headlessui-react/CHANGELOG.md | 1 + .../src/components/transitions/transition.tsx | 29 +- .../transitions/utils/transition.test.ts | 3 + .../transitions/utils/transition.ts | 12 +- .../src/hooks/use-transition.ts | 18 +- .../src/utils/class-names.ts | 14 +- packages/playground-react/package.json | 1 + .../pages/transitions/appear.tsx | 287 ++++++++++++++++++ .../pages/transitions/react-hot-toast.tsx | 40 +++ yarn.lock | 12 + 10 files changed, 395 insertions(+), 22 deletions(-) create mode 100644 packages/playground-react/pages/transitions/appear.tsx create mode 100644 packages/playground-react/pages/transitions/react-hot-toast.tsx diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index 41adfc3..5425932 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -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 `` 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 diff --git a/packages/@headlessui-react/src/components/transitions/transition.tsx b/packages/@headlessui-react/src/components/transitions/transition.tsx index 416ff23..0adaf5e 100644 --- a/packages/@headlessui-react/src/components/transitions/transition.tsx +++ b/packages/@headlessui-react/src/components/transitions/transition.tsx @@ -302,7 +302,7 @@ function TransitionChildFn(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(null) useEffect(() => register(container), [register, container]) @@ -332,6 +331,7 @@ function TransitionChildFn { 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 { - 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 ) { // @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(null) let transitionRef = useSyncRefs(internalTransitionRef, ref) diff --git a/packages/@headlessui-react/src/components/transitions/utils/transition.test.ts b/packages/@headlessui-react/src/components/transitions/utils/transition.test.ts index 67fe9c8..8ee66ba 100644 --- a/packages/@headlessui-react/src/components/transitions/utils/transition.test.ts +++ b/packages/@headlessui-react/src/components/transitions/utils/transition.test.ts @@ -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'], diff --git a/packages/@headlessui-react/src/components/transitions/utils/transition.ts b/packages/@headlessui-react/src/components/transitions/utils/transition.ts index d9f5878..1c80fea 100644 --- a/packages/@headlessui-react/src/components/transitions/utils/transition.ts +++ b/packages/@headlessui-react/src/components/transitions/utils/transition.ts @@ -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() }) diff --git a/packages/@headlessui-react/src/hooks/use-transition.ts b/packages/@headlessui-react/src/hooks/use-transition.ts index 23405f8..e5adf5e 100644 --- a/packages/@headlessui-react/src/hooks/use-transition.ts +++ b/packages/@headlessui-react/src/hooks/use-transition.ts @@ -9,8 +9,11 @@ import { useIsoMorphicEffect } from './use-iso-morphic-effect' import { useLatestValue } from './use-latest-value' interface TransitionArgs { + immediate: boolean container: MutableRefObject 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) diff --git a/packages/@headlessui-react/src/utils/class-names.ts b/packages/@headlessui-react/src/utils/class-names.ts index 159b03c..12b3d11 100644 --- a/packages/@headlessui-react/src/utils/class-names.ts +++ b/packages/@headlessui-react/src/utils/class-names.ts @@ -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(' ') } diff --git a/packages/playground-react/package.json b/packages/playground-react/package.json index edd8bf2..09ad435 100644 --- a/packages/playground-react/package.json +++ b/packages/playground-react/package.json @@ -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": { diff --git a/packages/playground-react/pages/transitions/appear.tsx b/packages/playground-react/pages/transitions/appear.tsx new file mode 100644 index 0000000..f0f9067 --- /dev/null +++ b/packages/playground-react/pages/transitions/appear.tsx @@ -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 ( +
+
+ + +
+ +
+
+ Initial render +
+ + Appear: true, unmount: true + + + +
+ Appear: true, as={`Fragment`}, unmount: true +
+
+ + + Appear: false, unmount: true + + + +
+ Appear: false, as={`Fragment`}, unmount: true +
+
+ + + Appear: true, unmount: false + + + +
+ Appear: true, as={`Fragment`}, unmount: false +
+
+ + + Appear: false, unmount: false + + + +
+ Appear: false, as={`Fragment`}, unmount: false +
+
+
+
+ + {lazy && ( +
+ Not on the initial render +
+ + Appear: true, unmount: true + + + +
+ Appear: true, as={`Fragment`}, unmount: true +
+
+ + + Appear: false, unmount: true + + + +
+ Appear: false, as={`Fragment`}, unmount: true +
+
+ + + Appear: true, unmount: false + + + +
+ Appear: true, as={`Fragment`}, unmount: false +
+
+ + + Appear: false, unmount: false + + + +
+ Appear: false, as={`Fragment`}, unmount: false +
+
+
+
+ )} +
+
+ ) +} diff --git a/packages/playground-react/pages/transitions/react-hot-toast.tsx b/packages/playground-react/pages/transitions/react-hot-toast.tsx new file mode 100644 index 0000000..4fc96d1 --- /dev/null +++ b/packages/playground-react/pages/transitions/react-hot-toast.tsx @@ -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 ( + + {(t) => ( + + +

{resolveValue(t.message, t)}

+
+ )} +
+ ) +} + +export default function App() { + return ( +
+ + +
+ ) +} diff --git a/yarn.lock b/yarn.lock index 431af3f..bea35b1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"