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

170 lines
6.6 KiB
TypeScript

import { expect, test } from '@playwright/test'
import { setupPage } from './test-utils'
/**
* Repro for: "Sheet body unmounts before exit animation completes"
*
* Bug context:
* When a Gui Dialog adapts to a Sheet (Adapt + Sheet + Adapt.Contents),
* the children mounted into Adapt.Contents are driven by Dialog.open, NOT
* by Sheet.open. On close, Dialog tears its tree down immediately while the
* Sheet is still animating out, so the body of the sheet vanishes mid-slide.
*
* In takeout this is masked by an opaque BlurView + $color5 layer painted on
* Sheet.Frame itself, so the empty contents area still looks "full" until
* the slide finishes. The 3PC Dialog is just bg="$backgroundSurface" with no
* inner cover, so the unmount is visible.
*
* What this test asserts:
* 1. Open dialog at narrow viewport so Adapt platform="touch" + when="maxMd"
* activates and Dialog.Adapt swaps in the Sheet.
* 2. The marker text inside the body must be visible (it lives in the sheet).
* 3. After clicking close, sample the marker for several frames during the
* sheet's slide-out. The marker must remain in the DOM with non-zero
* layout for the duration of the slide-out, NOT vanish on the first frame.
* 4. After the animation finishes, the marker should be gone.
*/
test.describe('Dialog Sheet Adapt - body persists during exit animation', () => {
// narrow viewport so `when="maxMd"` triggers the adapted code path
test.use({ viewport: { width: 600, height: 900 } })
test.beforeEach(async ({ page }) => {
await setupPage(page, {
name: 'DialogSheetAdaptUnmountCase',
type: 'useCase',
})
})
test('marker stays mounted while the sheet slides out', async ({ page }) => {
const marker = page.getByTestId('dialog-content-marker')
const sheetFrame = page.locator('.is_Sheet[data-state]')
// open the dialog (which adapts to a sheet on this viewport)
await page.getByTestId('open-dialog').click()
// wait for the sheet to be open and the marker to exist in the DOM.
// we don't assert visibility (toBeVisible) because the css driver can
// leave the sheet positioned just outside the viewport during/after the
// enter animation in this layout — the question this test is about is
// *DOM lifecycle* (mount/unmount), not pixel position.
await expect
.poll(
async () =>
page.evaluate(() =>
document.querySelector('.is_Sheet[data-state]')?.getAttribute('data-state')
),
{ timeout: 5000 }
)
.toBe('open')
await expect(marker).toBeAttached({ timeout: 5000 })
// start the close. clicking elements isn't viable here:
// - the SheetOverlay covers the entire viewport so every element looks
// obstructed,
// - Gui Buttons use Pressable (pointer events), so synthetic
// .click() from page.evaluate doesn't fire onPress.
// the use case exposes window.__dialogSetOpen so we can drive the dialog
// imperatively, which is what we actually want to test.
// sanity: the close call must actually flip the sheet's data-state, else
// the persistence assertions below are meaningless. start the sampler and
// close in the same in-page task so playwright round-trip latency cannot
// shift the samples past the short exit animation under parallel load.
type Sample = { t: number; exists: boolean; state: string | null }
const samples: Sample[] = await page.evaluate(
() =>
new Promise<Sample[]>((resolve) => {
const startedAt = performance.now()
const samples: Sample[] = []
const record = () => {
samples.push({
t: performance.now() - startedAt,
exists: !!document.querySelector('[data-testid="dialog-content-marker"]'),
state:
document
.querySelector('.is_Sheet[data-state]')
?.getAttribute('data-state') ?? null,
})
}
;(window as any).__dialogSetOpen?.(false)
record()
const sample = () => {
record()
if (performance.now() - startedAt >= 500) {
resolve(samples)
return
}
requestAnimationFrame(sample)
}
requestAnimationFrame(sample)
})
)
const firstClosedAt = samples.find((s) => s.state === 'closed')?.t
expect(
firstClosedAt,
'close call should flip the sheet data-state while sampling'
).toBeDefined()
// `medium` for the css driver is `ease-in 400ms`, so anything <250ms after
// the browser observes the closed state is solidly mid-slide. assert only
// that early window so we don't race the legitimate post-animation cleanup
// that the fix does when SheetController.onAnimationComplete fires (the css
// driver's completion signal is loose and can fire ~80ms before the visual
// transition ends).
const midAnimationSamples = samples.filter(
(s) => firstClosedAt != null && s.t >= firstClosedAt && s.t - firstClosedAt <= 250
)
expect(midAnimationSamples.length).toBeGreaterThan(0)
// every early sample taken during the slide-out should still find the
// marker DOM node. if the bug reproduces, the Adapt.Contents portal slot
// is torn down on the very first frame after `dialog.open` flips false,
// because the teardown is driven by Dialog.open and not by Sheet.open.
for (const s of midAnimationSamples) {
expect
.soft(
s.exists,
`marker should still be in DOM at +${Math.round(
firstClosedAt == null ? s.t : s.t - firstClosedAt
)}ms after closed state (mid-animation, before sheet finishes sliding)`
)
.toBe(true)
}
// sanity: eventually the sheet should be fully closed
await expect
.poll(
async () =>
page.evaluate(
() =>
document
.querySelector('.is_Sheet[data-state]')
?.getAttribute('data-state') ?? 'gone'
),
{ timeout: 3000 }
)
.toBe('closed')
// and after the slide-out completes, the marker SHOULD unmount —
// this is the other half of the fix (no permanent mount / memory leak).
// SheetController.onAnimationComplete flips DialogAdaptHiddenContext to
// true, DialogContent then returns null, the portal slot empties.
await expect
.poll(
async () =>
page.evaluate(
() => !!document.querySelector('[data-testid="dialog-content-marker"]')
),
{ timeout: 3000 }
)
.toBe(false)
})
})