Files
gui/apps/kitchen-sink/tests/ClickDuringEnter.animated.test.tsx
zeekayandHanzo Dev 69c6e860d7 transplant onto tamagui v2.6.0, brand as a re-runnable transform
This repo could never sync. There was NO merge-base with tamagui — 372 commits
here against 15,791 there, unrelated histories — because it began as a
copy-paste rebrand with a flattened layout. Every "sync" since was a hand
re-application that drifted, which is why the tree sat on 7.3.1 while upstream
shipped fixes we then rediscovered by hand.

This commit's PARENT IS TAMAGUI. A future sync is `git merge tama/main` then
`node scripts/rebrand.mjs` — a real merge, because the history is now shared.

174 packages: 166 upstream, our 8 restored (chrome, shell, telemetry, recipes,
get, font-geist-{sans,mono}, @hanzo/booker), bento-get gained. 170/170 build.

Our patches to files UPSTREAM ALSO OWNS are carried, which the package-level
delta missed and which was where the value actually was:
  - useMedia's server snapshot (`initState ?? getMedia()`). Upstream still
    returns undefined there, so every Tamagui Dialog kills a prerender — the
    exact crash hanzo.app spent a day on.
  - data-slot on web host nodes, absent upstream entirely.
Both applied ONTO upstream's newer files (it rewrote useMedia +150/-63 and
createComponent +160/-55), marked `PATCH (ours, carried across the transplant)`.

Seven traps are encoded in scripts/rebrand.mjs, each found by a gate:
skip itself and lockfiles; special-case the double-branded names in BOTH scoped
and bare form; map path strings, not just paths; drop a level for the flattened
group; check disk rather than a stale index.

Rust build artifacts are NOT committed. An earlier draft of this commit swept
apps/desktop/src-tauri/target/debug in via `git add -A` — 3,859 files including
a 272MB .a, over GitHub's 100MB cap, so the push could never have landed. They
are gitignored now.

Co-Authored-By: Hanzo Dev <[email protected]>
2026-07-30 18:25:06 -07:00

175 lines
6.2 KiB
TypeScript

import { expect, test } from '@playwright/test'
import { setupPage } from './test-utils'
/**
* Tests that clicking to close while an enter animation is still playing
* doesn't cause a visual snap-back (the element shouldn't jump back to
* its enterStyle values before starting the exit animation).
*
* Bug: motion driver calls controls.stop() before the mid-flight capture,
* causing the WAAPI animation to cancel and the element to snap back to
* its pre-animation inline styles for one frame.
*/
test.beforeEach(async ({ page }, testInfo) => {
test.skip(
testInfo.project.name === 'animated-css',
'CSS transitions handle mid-flight interruption differently — this tests WAAPI behavior'
)
await setupPage(page, { name: 'ClickDuringEnterCase', type: 'useCase' })
await page.waitForTimeout(500)
})
test('no opacity snap-back when exit triggered during enter animation', async ({
page,
}) => {
// start per-frame opacity sampling
await page.evaluate(() => {
;(window as any).__opacityLog = [] as number[]
;(window as any).__rafRunning = true
const track = () => {
if (!(window as any).__rafRunning) return
const el = document.querySelector('[data-testid="click-enter-target"]')
if (el) {
;(window as any).__opacityLog.push(parseFloat(getComputedStyle(el).opacity))
}
requestAnimationFrame(track)
}
requestAnimationFrame(track)
})
// show element (starts enter animation: opacity 0 -> 1)
await page.getByTestId('click-enter-show').click()
// wait just enough for animation to start (50-100ms into a ~300ms animation)
await page.waitForTimeout(80)
// immediately hide (triggers exit while enter is mid-flight)
await page.getByTestId('click-enter-hide').click()
// wait for exit animation to complete
await page.waitForTimeout(1500)
// stop sampling
await page.evaluate(() => {
;(window as any).__rafRunning = false
})
const frames: number[] = await page.evaluate(() => (window as any).__opacityLog)
// find the peak opacity (the highest opacity reached during enter)
const peakOpacity = Math.max(...frames)
const peakIndex = frames.indexOf(peakOpacity)
// after the peak, opacity should monotonically decrease (exit animation)
// but the bug causes a snap-back: opacity jumps DOWN then back UP then down
// detect this by looking for any frame after peak where opacity increases
// significantly compared to a previous lower value
// simpler check: look for any frame that drops significantly below its
// neighbors (a "dip" indicates snap-back)
let maxDip = 0
for (let i = 1; i < frames.length - 1; i++) {
const prev = frames[i - 1]
const curr = frames[i]
const next = frames[i + 1]
// a dip is when current is significantly lower than both neighbors
if (curr < prev && curr < next) {
const dip = Math.min(prev - curr, next - curr)
if (dip > maxDip) maxDip = dip
}
}
// also check for any opacity increase after exit begins
// find the first frame where opacity starts decreasing after the peak
let exitStartIndex = peakIndex
for (let i = peakIndex + 1; i < frames.length; i++) {
if (frames[i] < frames[i - 1] - 0.01) {
exitStartIndex = i
break
}
}
// after exit starts, look for any significant increase (snap-back indicator)
let maxIncrease = 0
for (let i = exitStartIndex + 1; i < frames.length; i++) {
const increase = frames[i] - frames[i - 1]
if (increase > maxIncrease) maxIncrease = increase
}
console.log('opacity frames:', JSON.stringify(frames.map((f) => +f.toFixed(3))))
console.log(`peak: ${peakOpacity.toFixed(3)} at frame ${peakIndex}`)
console.log(`max dip: ${maxDip.toFixed(3)}`)
console.log(`max increase after exit: ${maxIncrease.toFixed(3)}`)
// there should be no significant dip (snap-back)
// tolerance: 0.15 allows for minor animation interpolation variance
expect(
maxDip,
`Opacity had a snap-back dip of ${maxDip.toFixed(3)} - element jumped to enterStyle then back. Frames: ${JSON.stringify(frames.slice(0, 30).map((f) => +f.toFixed(2)))}`
).toBeLessThan(0.15)
// there should be no significant increase after exit starts
expect(
maxIncrease,
`Opacity increased by ${maxIncrease.toFixed(3)} after exit started - indicates snap-back. Frames: ${JSON.stringify(frames.slice(0, 30).map((f) => +f.toFixed(2)))}`
).toBeLessThan(0.15)
})
test('no transform snap-back when exit triggered during enter animation', async ({
page,
}) => {
// start per-frame transform sampling
await page.evaluate(() => {
;(window as any).__transformLog = [] as { y: number; scale: number }[]
;(window as any).__rafRunning = true
const track = () => {
if (!(window as any).__rafRunning) return
const el = document.querySelector('[data-testid="click-enter-target"]')
if (el) {
const transform = getComputedStyle(el).transform
let y = 0
let scale = 1
if (transform && transform !== 'none') {
const matrix = new DOMMatrix(transform)
y = matrix.m42 // translateY
scale = matrix.a // scaleX (assuming uniform)
}
;(window as any).__transformLog.push({ y, scale })
}
requestAnimationFrame(track)
}
requestAnimationFrame(track)
})
await page.getByTestId('click-enter-show').click()
await page.waitForTimeout(80)
await page.getByTestId('click-enter-hide').click()
await page.waitForTimeout(1500)
await page.evaluate(() => {
;(window as any).__rafRunning = false
})
const frames: { y: number; scale: number }[] = await page.evaluate(
() => (window as any).__transformLog
)
// check for Y-position jumps (snap-back to enterStyle y: -10)
let maxYJump = 0
for (let i = 1; i < frames.length; i++) {
const jump = Math.abs(frames[i].y - frames[i - 1].y)
if (jump > maxYJump) maxYJump = jump
}
console.log(
'Y positions:',
JSON.stringify(frames.slice(0, 30).map((f) => +f.y.toFixed(2)))
)
console.log(`max Y jump: ${maxYJump.toFixed(2)}`)
// with smooth animation, max per-frame jump should be small
// a snap-back would show a jump of 5+ pixels in one frame
expect(
maxYJump,
`Y position jumped ${maxYJump.toFixed(1)}px in one frame - indicates snap-back to enterStyle`
).toBeLessThan(5)
})