Files
gui/apps/kitchen-sink/tests/PopoverClickDuringEnter.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

198 lines
5.6 KiB
TypeScript

import { expect, test } from '@playwright/test'
import { setupPage } from './test-utils'
/**
* Tests that clicking a hoverable popover trigger while the enter animation
* is still playing produces a smooth exit - no snap-back, no re-enter cycle.
*
* Bug: when clicking during enter, the popover:
* 1. Snaps to closed (no animation)
* 2. Starts animating back in
* 3. Then finally animates out
*/
test.beforeEach(async ({ page }, testInfo) => {
test.skip(
testInfo.project.name === 'animated-native',
'Native driver does not support hover animations on web'
)
await setupPage(page, { name: 'PopoverHoverableDelayCase', type: 'useCase' })
await page.waitForLoadState('networkidle')
await page.waitForTimeout(500)
})
test('click during enter animation produces smooth exit (no snap-back)', async ({
page,
}) => {
const trigger = page.locator('#delay-trigger')
const content = page.locator('#delay-content')
await expect(content).not.toBeVisible()
// start per-frame opacity sampling
await page.evaluate(() => {
;(window as any).__opLog = [] as number[]
;(window as any).__rafOn = true
const track = () => {
if (!(window as any).__rafOn) return
const el = document.getElementById('delay-content')
if (el) {
;(window as any).__opLog.push(parseFloat(getComputedStyle(el).opacity))
}
requestAnimationFrame(track)
}
requestAnimationFrame(track)
})
// hover to trigger open (delay is 400ms)
await trigger.hover()
// wait for delay + a bit of animation time
await page.waitForTimeout(500)
// popover should be visible and animating
const isVisible = await content.isVisible().catch(() => false)
if (!isVisible) {
// if not visible yet, wait more
await page.waitForTimeout(300)
}
// now click the trigger (should close the popover)
await trigger.click()
// wait for exit animation
await page.waitForTimeout(1500)
// stop sampling
await page.evaluate(() => {
;(window as any).__rafOn = false
})
const frames: number[] = await page.evaluate(() => (window as any).__opLog || [])
console.log(
'opacity timeline:',
JSON.stringify(frames.slice(0, 40).map((f) => +f.toFixed(3)))
)
if (frames.length < 3) {
// not enough data, skip analysis
console.log('Not enough frames captured')
return
}
// find the peak opacity
const peak = Math.max(...frames)
const peakIdx = frames.indexOf(peak)
// look for dips (snap-back indicators)
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]
if (curr < prev && curr < next) {
const dip = Math.min(prev - curr, next - curr)
if (dip > maxDip) maxDip = dip
}
}
// look for re-enter pattern: after opacity decreases, it increases again significantly
let foundDecrease = false
let maxReIncrease = 0
for (let i = peakIdx + 1; i < frames.length; i++) {
if (frames[i] < frames[i - 1] - 0.01) {
foundDecrease = true
}
if (foundDecrease && frames[i] > frames[i - 1] + 0.05) {
const increase = frames[i] - frames[i - 1]
if (increase > maxReIncrease) maxReIncrease = increase
}
}
console.log(`peak: ${peak.toFixed(3)} at frame ${peakIdx}`)
console.log(`max dip: ${maxDip.toFixed(3)}`)
console.log(`max re-increase after exit starts: ${maxReIncrease.toFixed(3)}`)
// no significant dip (snap-back)
expect(
maxDip,
`Opacity dip of ${maxDip.toFixed(3)} detected - element snapped back during exit`
).toBeLessThan(0.15)
// no significant re-increase after exit starts (re-enter)
expect(
maxReIncrease,
`Opacity increased by ${maxReIncrease.toFixed(3)} after exit started - re-enter detected`
).toBeLessThan(0.15)
})
test('click during mid-enter: transform should not jump', async ({ page }) => {
const trigger = page.locator('#delay-trigger')
const content = page.locator('#delay-content')
await expect(content).not.toBeVisible()
// start per-frame Y position sampling
await page.evaluate(() => {
;(window as any).__yLog = [] as number[]
;(window as any).__rafOn = true
const track = () => {
if (!(window as any).__rafOn) return
const el = document.getElementById('delay-content')
if (el) {
const transform = getComputedStyle(el).transform
let y = 0
if (transform && transform !== 'none') {
const matrix = new DOMMatrix(transform)
y = matrix.m42
}
;(window as any).__yLog.push(y)
}
requestAnimationFrame(track)
}
requestAnimationFrame(track)
})
// hover to trigger open
await trigger.hover()
await page.waitForTimeout(500)
const isVisible = await content.isVisible().catch(() => false)
if (!isVisible) {
await page.waitForTimeout(300)
}
// click to close during enter animation
await trigger.click()
await page.waitForTimeout(1500)
await page.evaluate(() => {
;(window as any).__rafOn = false
})
const frames: number[] = await page.evaluate(() => (window as any).__yLog || [])
console.log(
'Y positions:',
JSON.stringify(frames.slice(0, 30).map((f) => +f.toFixed(2)))
)
if (frames.length < 3) {
console.log('Not enough frames captured')
return
}
// check for single-frame jumps
let maxJump = 0
for (let i = 1; i < frames.length; i++) {
const jump = Math.abs(frames[i] - frames[i - 1])
if (jump > maxJump) maxJump = jump
}
console.log(`max Y jump: ${maxJump.toFixed(2)}`)
expect(maxJump, `Y position jumped ${maxJump.toFixed(1)}px in one frame`).toBeLessThan(
5
)
})