Skip to content

Commit 76931d3

Browse files
RobinMalfaitadamwathan
authored andcommitted
wip react transition component
1 parent 681db68 commit 76931d3

File tree

2 files changed

+221
-0
lines changed

2 files changed

+221
-0
lines changed
36.8 KB
Loading
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import { robinmalfait } from '@/authors'
2+
import image from './card.jpg'
3+
4+
export const meta = {
5+
title: 'Tailwind UI - React Transition',
6+
description: `Tailwind CSS v1.7.0 is now available, with gradients and more!`,
7+
date: '2020-08-25T10:00:00.000Z',
8+
authors: [robinmalfait],
9+
image,
10+
discussion: 'https://github.com/tailwindlabs/tailwindcss/discussions/2183',
11+
}
12+
13+
Introducing the first component of the `@tailwindui/react` collection, a set of Tailwind-ready component primitives.
14+
15+
<!--more-->
16+
17+
## Transition
18+
19+
Today I am happy to announce that we are releasing our first React component, a
20+
Transition component under the `@tailwindui/react` umbrella.
21+
22+
https://github.com/tailwindlabs/tailwindui-react
23+
24+
Here is a codesandbox example for you:
25+
26+
<iframe
27+
src="https://codesandbox.io/embed/dreamy-villani-1lz49?fontsize=14&hidenavigation=1&module=%2Fsrc%2FApp.js&view=preview"
28+
style={{ height: 500 }}
29+
className="w-full rounded overflow-hidden"
30+
title="dreamy-villani-1lz49"
31+
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
32+
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
33+
/>
34+
35+
---
36+
37+
## Lab Notes
38+
39+
**TL;DR Overview:**
40+
41+
- The basic _transition_ functionality is the easy part
42+
- `useLayoutEffect` vs `useEffect`
43+
- Cancelling transitions
44+
- Testing a transition component is not that easy because there are a lot of timing "issues"
45+
- Coordination between child components has some gotchas
46+
- Performance escape hatch
47+
- Scope creep: setup
48+
49+
### The basic _transition_ functionality is the easy part
50+
51+
When developing this component, the basic functionality was there from day #1. Meaning that we could
52+
apply `enter enterFrom` then `enter enterTo` then wait and then `<empty>` no classes left.
53+
54+
However there are some gotchas I've run into:
55+
56+
1. For starters a `transitionend` event is not fired when there is no actual `transition` happening.
57+
So I ran into an issue where everything just seems broken because we were waiting for that
58+
`transitionend` event. Instead we calculate the `duration` and use a `setTimeout`.
59+
60+
2. When you add / remove classes, you need to give the browser a bit of time to actually apply them.
61+
If you `addClass(from); addClass(to); removeClass(from);` everything will happen at the same time,
62+
we have to explicitly wait (using requestAnimationFrame for example).
63+
64+
3. requestAnimationFrame has some funky issues, so I looked at other libraries and the idea for a
65+
`nextFrame()` function is basically calling `requestAnimationFrame` twice:
66+
67+
```js
68+
function nextFrame(cb) {
69+
requestAnimationFrame(() => {
70+
requestAnimationFrame(cb)
71+
})
72+
}
73+
```
74+
75+
- [Vue implementation](https://github.com/vuejs/vue/blob/59d4351ad8fc042bc263a16ed45a56e9ff5b013e/src/platforms/web/runtime/transition-util.js#L67-L71) reference
76+
77+
4. When resolving the durations from the actual DOM node, some browsers (I am looking at you Safari)
78+
return a list of transition values. Thanks to the Alpine implementation I could resolve this
79+
correctly.
80+
81+
1. I had to make some small changes though: sometimes it is returned as `.3s` sometimes as `300ms`
82+
2. Once parsed, I sorted the list so that we waited for the longest duration
83+
84+
- [Alpine implementation](https://github.com/alpinejs/alpine/blob/05e13e42a1466d3c0c0bfa575b18dcdf58d2bf3d/src/utils.js#L466-L467) reference
85+
86+
### `useLayoutEffect` vs `useEffect` <a name="use-layout-effect-vs-use-effect"></a>
87+
88+
This one is a subtle one, but let's see if I can explain this one. If you look at the `useLayoutEffect` docs you will find this:
89+
90+
> The signature is identical to useEffect, but it fires synchronously after all DOM mutations. Use
91+
> this to read layout from the DOM and synchronously re-render. Updates scheduled inside
92+
> useLayoutEffect will be flushed synchronously, before the browser has a chance to paint.
93+
94+
-- <cite>http://reactjs.org/docs/hooks-reference.html#uselayouteffect</cite>
95+
96+
The key part here is that `useLayoutEffect` is synchronous, this allows us to make better `enter` transitions. Let me show you:
97+
98+
**useEffect**
99+
100+
1. Render `<div />`
101+
2. (Actually touch the DOM)
102+
3. After render, inside the effect, add `enter enterFrom` classes (because async)
103+
4. Once that is applied, remove `enterFrom` add `enterTo`
104+
105+
**useLayoutEffect**
106+
107+
1. Render `<div />`
108+
2. Inside the `useLayoutEffect`, add `enter enterFrom` classes (because sync)
109+
3. (Actually touch the DOM)
110+
4. Once that is applied, remove `enterFrom` add `enterTo`
111+
112+
The big difference now is that in the case of a `useEffect` you see `<div />` at first, when you use
113+
`useLayoutEffect` you see `<div class="enter enterFrom" />`.
114+
115+
### Cancelling transitions
116+
117+
At any stage in the process we should be able to cancel the transitions. I wrote a little
118+
disposables utility for this:
119+
[disposables.ts](https://github.com/tailwindlabs/tailwindui-react/blob/develop/src/utils/disposables.ts) the idea is
120+
that you do _something_ and add the "cleanup" phase as a callback to the disposables collection.
121+
122+
I've made some useful shorthands for the `requestAnimationFrame`, `nextFrame` and `setTimeout`
123+
functions. Any time you schedule one of these async functions they could be cancelled by calling the
124+
dispose() method on the disposables collection. This is nice because we can just return the
125+
`dispose` function in a React useEffect hook and it will be called for us!
126+
127+
### Testing a transition component is not that easy because there are a lot of timing "issues"
128+
129+
You can write some very imperative code where you do an action, wait long enough, verify that something happened. While this works ([Vue transition spec](https://github.com/vuejs/vue/blob/dev/test/unit/features/transition/transition.spec.js#L17-L40)) it is a bit scary to read. So I thought of a different way to test it:
130+
131+
What if we just start "recording" changes to a specific DOM node and its children. Every time that we see a change we will record a snapshot at that point in time. Later on we can visualise the output. Our [Transition.test.tsx](https://github.com/tailwindlabs/tailwindui-react/blob/3a2f2e17b36f31d19e023cd795df9785fb9758fe/src/components/transitions/transition.test.tsx#L422-L439) test implementation.
132+
133+
This has its own issues again because I wanted to use the MutationObserver, but this doesn't exist in JSDom yet (which we use to test our components). So I tried a shim, but long story short... it polled every 30ms so we missed a bunch of updates.
134+
135+
I then moved to a requestAnimationFrame loop instead to record the changes which works pretty well.
136+
137+
### Coordination between child components has some gotchas
138+
139+
The general idea is, when you have a root Transition element it should only unmount once all the child transitions are "done". This is not too bad, however when you have 2 Transitions with 2 different durations one will be done before the other. Initially we just marked them as hidden, but that has unwanted behaviour. Currently we will unmount the child transition once it is done.
140+
141+
```tsx
142+
function Example() {
143+
const [show, setShow] = React.useState(true)
144+
145+
return (
146+
<>
147+
<style>{`
148+
.leave-fast { transition-duration: 75ms; }
149+
.leave-slow { transition-duration: 300ms; }
150+
151+
.leave-from { opacity: 100%; }
152+
.leave-to { opacity: 0%; }
153+
`}</style>
154+
155+
{/* This will unmount once all Transition.Child components are done */}
156+
<Transition show={show}>
157+
{/* This will unmount once the `leave-fast` transition is done */}
158+
<Transition.Child leave="leave-fast" leaveFrom="leave-from" leaveTo="leave-to">
159+
I am fast
160+
</Transition.Child>
161+
162+
{/* This will unmount once the `leave-slow` transition is done */}
163+
<Transition.Child leave="leave-slow" leaveFrom="leave-from" leaveTo="leave-to">
164+
I am slow
165+
</Transition.Child>
166+
</Transition>
167+
168+
<button onClick={() => setShow((v) => !v)}>Toggle</button>
169+
</>
170+
)
171+
}
172+
```
173+
174+
---
175+
176+
Another fun "bug" I found was that if one of the child transitions is rendered conditionally then there was no chance to "finish" the transition. Therefore it would not tell its parent that it was "done" and the whole tree was still mounted. This results in countless of issues we've seen before where the full page is unclickable in case of the Tailwind UI sidebar layouts. (Because that fixed overlay was still present)
177+
178+
```tsx
179+
function Example() {
180+
const [show, setShow] = React.useState(true)
181+
182+
return (
183+
<>
184+
<style>{`
185+
.leave-fast { transition-duration: 75ms; }
186+
.leave-slow { transition-duration: 300ms; }
187+
188+
.leave-from { opacity: 100%; }
189+
.leave-to { opacity: 0%; }
190+
`}</style>
191+
192+
<Transition show={show}>
193+
<Transition.Child leave="leave-fast" leaveFrom="leave-from" leaveTo="leave-to">
194+
I am fast
195+
</Transition.Child>
196+
197+
{true && (
198+
<Transition.Child leave="leave-slow" leaveFrom="leave-from" leaveTo="leave-to">
199+
I am rendered conditionally
200+
</Transition.Child>
201+
)}
202+
</Transition>
203+
204+
<button onClick={() => setShow((v) => !v)}>Toggle</button>
205+
</>
206+
)
207+
}
208+
```
209+
210+
### Performance escape hatch
211+
212+
To apply the correct classNames we could provide a `className` prop and update every time we change the classes during a transition. However this will result in multiple re-renders. In application code you don't always have to worry about performance, however we as the library don't know where this will be used.
213+
214+
Therefore we apply changes to the actual DOM node directly. We do this by getting a `ref` to the actual DOM node and use the `ref.current.classList.add()` and `ref.current.classList.remove()` API's to update the classes without re-rendering the React component.
215+
216+
### Scope creep: setup
217+
218+
1. I've added a structure so that it is easier to create new components later on.
219+
2. I've setup TSDX to do the actual development (testing / linting / building / ...)
220+
3. I've created a playground `yarn playground` using Next.js so that you can play with the components directly in the browser.
221+
4. I've setup commitizen so that we can generate changelogs and follow semver based on these commit messages.

0 commit comments

Comments
 (0)