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

161 lines
6.1 KiB
TypeScript

import { expect, test, type Page } from '@playwright/test'
import { setupPage } from './test-utils'
/**
* Regression tests for the reanimated avoidReRenders "stuck hover / stuck style" bug.
*
* The emitter fast path latches its last-emitted snapshot into the worklet
* (`animatedTargetsRef !== null`). The latch must be reconciled correctly on
* hover-out and on re-renders, or the hovered style (or a stale base style) stays
* stuck. The fix threads a `pseudoActive` flag from createComponent into the
* reanimated emitter so the worklet only keeps the latch while a self pseudo is
* genuinely active, and clears it otherwise.
*
* This bug surfaced in a downstream consumer when `@hanzogui/web` and
* `@hanzogui/animations-reanimated` dists were rebuilt out of sync (web carried the
* pseudoActive arg, reanimated did not consume it) — the latch never cleared. These
* tests pin the in-sync behavior for both the event-sourced (web mouse) and
* driver-sourced (platform-driver / desktop) hover lanes.
*/
const RED = 'rgb(255, 0, 0)'
const GREEN = 'rgb(0, 255, 0)'
const bg = (page: Page, id: string) =>
page.getByTestId(id).evaluate((el) => getComputedStyle(el).backgroundColor)
async function hoverCenter(page: Page, id: string) {
const box = await page.getByTestId(id).boundingBox()
if (!box) throw new Error(`no box for ${id}`)
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
}
test.describe('reanimated stuck hover (event-sourced)', () => {
test.beforeEach(async ({ page }) => {
await setupPage(page, {
name: 'ReanimatedStuckHoverCase',
type: 'useCase',
searchParams: { animationDriver: 'reanimated' },
})
await page.waitForTimeout(300)
})
test('instant hover-out returns to base', async ({ page }) => {
expect(await bg(page, 'stuck-instant'), 'starts red').toBe(RED)
await hoverCenter(page, 'stuck-instant')
await page.waitForTimeout(100)
expect(await bg(page, 'stuck-instant'), 'hover green').toBe(GREEN)
await page.mouse.move(2, 2)
await page.waitForTimeout(100)
expect(await bg(page, 'stuck-instant'), 'hover-out must return to base red').toBe(RED)
})
test('spring hover-out returns to base', async ({ page }) => {
await hoverCenter(page, 'stuck-spring')
await page.waitForTimeout(250)
expect(await bg(page, 'stuck-spring'), 'hover green').toBe(GREEN)
await page.mouse.move(2, 2)
await expect
.poll(() => bg(page, 'stuck-spring'), {
message: 'hover-out must settle on base red',
timeout: 2000,
})
.toBe(RED)
})
test('hover-in then re-render then hover-out is not stuck', async ({ page }) => {
await hoverCenter(page, 'stuck-rerender')
await page.waitForTimeout(80)
expect(await bg(page, 'stuck-rerender'), 'hover green').toBe(GREEN)
// re-render while hovered — fire the button's click programmatically so the
// pointer stays over the square (a real .click() would move the mouse off it)
await page.getByTestId('bump-tick').evaluate((el) => (el as HTMLElement).click())
await page.waitForTimeout(80)
expect(
await bg(page, 'stuck-rerender'),
'still green while hovered after re-render'
).toBe(GREEN)
// now leave — must return to base
await page.mouse.move(2, 2)
await page.waitForTimeout(100)
expect(
await bg(page, 'stuck-rerender'),
'hover-out after re-render must return to base'
).toBe(RED)
})
})
test.describe('reanimated stuck hover (driver-sourced / platform driver)', () => {
test.beforeEach(async ({ page }) => {
await setupPage(page, {
name: 'ReanimatedPlatformDriverHoverCase',
type: 'useCase',
searchParams: { animationDriver: 'reanimated' },
})
await page.waitForFunction(() => typeof (window as any).driveHover === 'function', {
timeout: 5000,
})
await page.waitForTimeout(200)
})
const BLUE = 'rgb(0, 0, 255)'
const driveHover = (page: Page, hovered: boolean) =>
page.evaluate((h) => (window as any).driveHover(h), hovered)
const bump = (page: Page) => page.evaluate(() => (window as any).bumpTick?.())
const toggleActive = (page: Page) =>
page.evaluate(() => (window as any).toggleActive?.())
test('driver hover-in/out returns to base (no transition prop, instant)', async ({
page,
}) => {
expect(await bg(page, 'driver-square'), 'starts red').toBe(RED)
await driveHover(page, true)
await page.waitForTimeout(80)
expect(await bg(page, 'driver-square'), 'driver hover green').toBe(GREEN)
await driveHover(page, false)
await page.waitForTimeout(80)
expect(await bg(page, 'driver-square'), 'driver hover-out must return to base').toBe(
RED
)
})
test('driver hover-in, re-render, hover-out is not stuck', async ({ page }) => {
await driveHover(page, true)
await page.waitForTimeout(60)
await bump(page)
await page.waitForTimeout(60)
expect(
await bg(page, 'driver-square'),
'green persists across re-render while hovered'
).toBe(GREEN)
await driveHover(page, false)
await page.waitForTimeout(80)
expect(
await bg(page, 'driver-square'),
'must return to base after re-render+leave'
).toBe(RED)
})
test('driver hover-out then base color change applies (latch clears)', async ({
page,
}) => {
// the canonical latch bug, on the DRIVER lane: hover once (fires the emitter
// latch), leave, then flip the base color via a plain re-render. before the fix
// the worklet stayed latched on the pre-hover snapshot and the base color never
// updated. it must reach blue.
expect(await bg(page, 'driver-square'), 'starts red').toBe(RED)
await driveHover(page, true)
await page.waitForTimeout(60)
expect(await bg(page, 'driver-square'), 'hover green').toBe(GREEN)
await driveHover(page, false)
await page.waitForTimeout(60)
expect(await bg(page, 'driver-square'), 'unhover red').toBe(RED)
await toggleActive(page)
await page.waitForTimeout(100)
expect(
await bg(page, 'driver-square'),
'base color change after hover must apply (latch must have cleared)'
).toBe(BLUE)
})
})