Files
headlessui/packages/@headlessui-react/src/test-utils/execute-timeline.ts
T
Robin Malfait 0a39cf6b22 Transition component (#326)
* add redent function when verifying snapshots

This allows us not to care about the correct amount of spaces and always
produces a clean output.

* make the container the parent of the wrapper element

* drop the visible prop on the Portal component

* drop visible prop on Portal component

+ Also cleanup a little bit

* expose the RenderStrategy

* implement Transition component in Vue

* expose Transition component

* add Transitions to the Dialog example
2021-04-12 23:40:42 +02:00

183 lines
5.3 KiB
TypeScript

import snapshotDiff from 'snapshot-diff'
import { render } from '@testing-library/react'
import { disposables } from '../utils/disposables'
import { reportChanges } from './report-dom-node-changes'
function redentSnapshot(input: string) {
let minSpaces = Infinity
let lines = input.split('\n')
for (let line of lines) {
if (line.trim() === '---') continue
let spacesInLine = (line.match(/^[+-](\s+)/g) || []).pop()!.length - 1
minSpaces = Math.min(minSpaces, spacesInLine)
}
let replacer = new RegExp(`^([+-])\\s{${minSpaces}}(.*)`, 'g')
return input
.split('\n')
.map(line =>
line.trim() === '---' ? line : line.replace(replacer, (_, sign, rest) => `${sign} ${rest}`)
)
.join('\n')
}
export async function executeTimeline(
element: JSX.Element,
steps: ((tools: ReturnType<typeof render>) => (null | number)[])[]
) {
let d = disposables()
let snapshots: { content: DocumentFragment; recordedAt: bigint }[] = []
//
let tools = render(element)
// Start listening for changes
d.add(
reportChanges(
() => document.body.innerHTML,
() => {
// This will ensure that any DOM change to the body has been recorded.
snapshots.push({
content: tools.asFragment(),
recordedAt: process.hrtime.bigint(),
})
}
)
)
// We start with a `null` value because we will start with a snapshot even _before_ things start
// happening.
let timestamps: (null | number)[] = [null]
//
await steps.reduce(async (chain, step) => {
await chain
let durations = step(tools)
// Note: The following calls are just in place to ensure that **we** waited long enough for the
// transitions to take place. This has no impact on the actual transitions. Above where the
// `reportDOMNodeChanges` is used we will actually record all the changes, no matter what
// happens here.
timestamps.push(...durations)
let totalDuration = durations
.filter((duration): duration is number => duration !== null)
.reduce((total, current) => total + current, 0)
// Changes happen in the next frame
await new Promise(resolve => d.nextFrame(resolve))
// We wait for the amount of the duration
await new Promise(resolve => d.setTimeout(resolve, totalDuration))
// We wait an additional next frame so that we know that we are done
await new Promise(resolve => d.nextFrame(resolve))
}, Promise.resolve())
if (snapshots.length <= 0) {
throw new Error('We could not record any changes')
}
let uniqueSnapshots = snapshots
// Only keep the snapshots that are unique. Multiple snapshots of the same
// content are a bit useless for us.
.filter((snapshot, i) => {
if (i === 0) return true
return snapshot.content !== snapshots[i - 1].content
})
// Add a relative time compaired to the previous snapshot. We recorded everything in
// process.hrtime.bigint() which is in nanoseconds, we want it in milliseconds.
.map((snapshot, i, all) => ({
...snapshot,
relativeToPreviousSnapshot:
i === 0 ? 0 : Number((snapshot.recordedAt - all[i - 1].recordedAt) / BigInt(1e6)),
}))
let diffed = uniqueSnapshots
.map((call, i) => {
// Skip initial render, because there is nothing to compare with
if (i === 0) return false
// The next bit of code is a bit ugly, but mos of the code is just cleaning up some "noise"
// that we don't need in our test output.
return `Render ${i}:${
// `This took: ${call.relativeTime}ms`
timestamps[i] === null
? ''
: ` Transition took at least ${timestamps[i]}ms (${
isWithinFrame(call.relativeToPreviousSnapshot, timestamps[i]!)
? 'yes'
: `no, it took ${call.relativeToPreviousSnapshot}ms`
})`
}\n${redentSnapshot(
snapshotDiff(uniqueSnapshots[i - 1].content, call.content, {
aAnnotation: '__REMOVE_ME__',
bAnnotation: '__REMOVE_ME__',
contextLines: 0,
})
// Just to do some cleanup
.replace(/\n\n@@([^@@]*)@@/g, '') // Top level @@ signs
.replace(/@@([^@@]*)@@/g, '---') // In between @@ signs
.replace(/[-+] __REMOVE_ME__\n/g, '')
.replace(/Snapshot Diff:\n/g, '')
)
.split('\n')
.map(line => ` ${line}`)
.join('\n')}`
})
.filter(Boolean)
.join('\n\n')
d.dispose()
return diffed
}
executeTimeline.fullTransition = (duration: number) => {
return [
/** Stage 1: Immediately add `base` and `from` classes */
null,
/** Stage 2: Immediately remove `from` classes and add `to` classes */
null,
/** Stage 3: After duration remove `to` and `base` classes */
duration,
]
}
let state: {
before: number
fps: number
handle: ReturnType<typeof requestAnimationFrame> | null
} = {
before: Date.now(),
fps: 0,
handle: null,
}
state.handle = requestAnimationFrame(function loop() {
let now = Date.now()
state.fps = Math.round(1000 / (now - state.before))
state.before = now
state.handle = requestAnimationFrame(loop)
})
afterAll(() => {
if (state.handle) cancelAnimationFrame(state.handle)
})
function isWithinFrame(actual: number, expected: number) {
let buffer = state.fps
let min = expected - buffer
let max = expected + buffer
return actual >= min && actual <= max
}