mirror of
https://github.com/hanzoai/gui.git
synced 2026-10-25 02:31:41 +00:00
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]>
302 lines
12 KiB
TypeScript
302 lines
12 KiB
TypeScript
import { expect, test } from '@playwright/test'
|
|
|
|
/**
|
|
* Regression test for the Sheet mobile-web keyboard AUTOFOCUS-ON-OPEN bug.
|
|
*
|
|
* Runs under the `webkit-sheet` project (Safari engine + touch) like
|
|
* SheetWebKeyboard.test.tsx — iOS Safari's touch/scroll/viewport behavior
|
|
* differs from chromium and this bug is about that viewport timing.
|
|
*
|
|
* Bug: when the sheet autofocuses its first input as it opens, the soft keyboard
|
|
* rises SIMULTANEOUSLY with the open animation, so it is already up at the
|
|
* sheet's first layout pass. layouts measured while the keyboard is up are
|
|
* dropped (they carry the shrunk viewport), so the frame must keep its natural
|
|
* pre-keyboard fit height and translate up by the keyboard height, capped at the
|
|
* top safe area. Before the fix the frame instead COLLAPSED to the shrunk
|
|
* useWindowDimensions*0.7 cap with the bottom "Post Thread" button occluded under
|
|
* the keyboard, and it could not be scrolled to reveal the footer — exactly the
|
|
* consumer's report.
|
|
*
|
|
* Reproduction: the keyboard is emulated by shrinking visualViewport.height and
|
|
* dispatching one 'resize' (what a real iOS keyboard fires). To deterministically
|
|
* hit the autofocus race — the keyboard already up when the sheet first lays out
|
|
* — the page starts with the sheet open (?open=1) and we shrink the viewport +
|
|
* keep an editable focused BEFORE the sheet's first layout lands. That is exactly
|
|
* the state autofocus produces on a real device, where the keyboard animates up
|
|
* alongside the sheet open and the first measured layout is keyboard-shrunk.
|
|
*/
|
|
|
|
const KB_HEIGHT = 320
|
|
const VH = 844
|
|
const KEYBOARD_TOP = VH - KB_HEIGHT // 524
|
|
|
|
test.use({ viewport: { width: 390, height: VH }, hasTouch: true, isMobile: true })
|
|
|
|
async function rect(page: import('@playwright/test').Page, testID: string) {
|
|
return page.evaluate((t) => {
|
|
const e = document.querySelector(`[data-testid="${t}"]`)
|
|
if (!e) return null
|
|
const r = e.getBoundingClientRect()
|
|
return {
|
|
top: Math.round(r.top),
|
|
bottom: Math.round(r.bottom),
|
|
height: Math.round(r.height),
|
|
}
|
|
}, testID)
|
|
}
|
|
|
|
// open the sheet with the keyboard already up at first layout (autofocus race).
|
|
//
|
|
// the keyboard is shrunk via an init script that runs BEFORE the app bundle, so
|
|
// the visual viewport is already collapsed (and an editable focused) when React
|
|
// mounts and the sheet does its very first layout. that's the deterministic
|
|
// encoding of the autofocus race: the keyboard is up at first layout, so the
|
|
// keyboard-free baseline is never captured. (?open=1 mounts the sheet open.)
|
|
async function openWithAutofocusKeyboard(page: import('@playwright/test').Page) {
|
|
await page.addInitScript((kb) => {
|
|
const apply = () => {
|
|
const vv = window.visualViewport
|
|
if (!vv) return
|
|
const base = window.innerHeight || 844
|
|
Object.defineProperty(vv, 'height', { configurable: true, get: () => base - kb })
|
|
Object.defineProperty(vv, 'offsetTop', { configurable: true, get: () => 0 })
|
|
}
|
|
apply()
|
|
// keep an editable focused so the sheet's keyboard-visible check holds.
|
|
window.addEventListener('DOMContentLoaded', () => {
|
|
apply()
|
|
const i = document.createElement('input')
|
|
i.style.cssText = 'position:fixed;top:0;left:0;opacity:0'
|
|
document.body.appendChild(i)
|
|
i.focus()
|
|
window.visualViewport?.dispatchEvent(new Event('resize'))
|
|
})
|
|
}, KB_HEIGHT)
|
|
|
|
await page.goto('/?test=SheetWebKeyboardAutoFocusCase&animationDriver=css&open=1', {
|
|
waitUntil: 'domcontentloaded',
|
|
})
|
|
await page.waitForFunction(
|
|
() => {
|
|
const root = document.getElementById('root')
|
|
return root && root.children.length > 0
|
|
},
|
|
{ timeout: 8000, polling: 50 }
|
|
)
|
|
await expect(page.getByTestId('sheet-web-kb-af-frame')).toBeVisible({ timeout: 5000 })
|
|
// let the open animation settle (anchor seed).
|
|
await page.waitForTimeout(1200)
|
|
// a real soft keyboard fires resize events as it animates up over ~250ms — the
|
|
// keyboard hook's viewport listener (attached on mount) latches the height from
|
|
// those. fire one now that the sheet has settled and the autofocused input
|
|
// holds focus, mirroring that, so the keyboardOccludedHeight tail padding that
|
|
// makes the footer reachable engages.
|
|
await page.evaluate(() => window.visualViewport?.dispatchEvent(new Event('resize')))
|
|
await page.waitForTimeout(500)
|
|
}
|
|
|
|
// touch drag that works in BOTH engines (see SheetWebKeyboard.test.tsx for the
|
|
// WebKit vs chromium TouchList rationale). samples the frame bottom each step.
|
|
async function touchDragSampling(
|
|
page: import('@playwright/test').Page,
|
|
testID: string,
|
|
frameTestID: string,
|
|
deltaY: number,
|
|
steps = 14
|
|
): Promise<number[]> {
|
|
return page.evaluate(
|
|
({ t, frameT, dy, steps }) => {
|
|
return new Promise<number[]>((resolve) => {
|
|
const wrap = document.querySelector(`[data-testid="${t}"]`) as HTMLElement
|
|
const frame = document.querySelector(`[data-testid="${frameT}"]`) as HTMLElement
|
|
const r = wrap.getBoundingClientRect()
|
|
const x = Math.round(r.left + r.width / 2)
|
|
const startY = Math.round(r.top + r.height / 2)
|
|
const samples: number[] = []
|
|
const hasLegacy = typeof (document as any).createTouchList === 'function'
|
|
const makeTouch = (tgt: EventTarget, y: number): Touch =>
|
|
typeof (document as any).createTouch === 'function'
|
|
? (document as any).createTouch(window, tgt, 0, x, y, x, y)
|
|
: new Touch({
|
|
identifier: 0,
|
|
target: tgt as any,
|
|
clientX: x,
|
|
clientY: y,
|
|
pageX: x,
|
|
pageY: y,
|
|
})
|
|
const list = (touches: Touch[]): any =>
|
|
hasLegacy ? (document as any).createTouchList(...touches) : touches
|
|
const fire = (type: string, y: number) => {
|
|
const tgt = (document.elementFromPoint(x, y) as HTMLElement) || wrap
|
|
const touch = makeTouch(tgt, y)
|
|
const empty = type === 'touchend'
|
|
tgt.dispatchEvent(
|
|
new TouchEvent(type, {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
touches: empty ? list([]) : list([touch]),
|
|
targetTouches: empty ? list([]) : list([touch]),
|
|
changedTouches: list([touch]),
|
|
})
|
|
)
|
|
}
|
|
const sample = () =>
|
|
samples.push(Math.round(frame.getBoundingClientRect().bottom))
|
|
fire('touchstart', startY)
|
|
sample()
|
|
let step = 0
|
|
const id = setInterval(() => {
|
|
step++
|
|
fire('touchmove', startY + (dy * step) / steps)
|
|
sample()
|
|
if (step >= steps) {
|
|
clearInterval(id)
|
|
fire('touchend', startY + dy)
|
|
resolve(samples)
|
|
}
|
|
}, 16)
|
|
})
|
|
},
|
|
{ t: testID, frameT: frameTestID, dy: deltaY, steps }
|
|
)
|
|
}
|
|
|
|
// open with autofocus on a field that STARTS BEHIND the keyboard (?focus=body).
|
|
// this is the real-world case — the consumer's autofocused field isn't at the very
|
|
// top of the sheet — and it exercises the auto-scroll-into-view (an autofocused
|
|
// field below the fold must be lifted/scrolled above the keyboard on open, not
|
|
// stay cut off).
|
|
async function openWithBehindKeyboardAutofocus(page: import('@playwright/test').Page) {
|
|
await page.addInitScript((kb) => {
|
|
const apply = () => {
|
|
const vv = window.visualViewport
|
|
if (!vv) return
|
|
const base = window.innerHeight || 844
|
|
Object.defineProperty(vv, 'height', { configurable: true, get: () => base - kb })
|
|
Object.defineProperty(vv, 'offsetTop', { configurable: true, get: () => 0 })
|
|
}
|
|
apply()
|
|
window.addEventListener('DOMContentLoaded', apply)
|
|
}, KB_HEIGHT)
|
|
|
|
await page.goto(
|
|
'/?test=SheetWebKeyboardAutoFocusCase&animationDriver=css&open=1&focus=body',
|
|
{ waitUntil: 'domcontentloaded' }
|
|
)
|
|
await page.waitForFunction(
|
|
() => {
|
|
const root = document.getElementById('root')
|
|
return root && root.children.length > 0
|
|
},
|
|
{ timeout: 8000, polling: 50 }
|
|
)
|
|
await expect(page.getByTestId('sheet-web-kb-af-frame')).toBeVisible({ timeout: 5000 })
|
|
await page.waitForTimeout(1200)
|
|
// the fixture autofocused the Body; fire a resize so the keyboard hook latches the
|
|
// height, then give the keyboard translation + auto-scroll-into-view a beat to
|
|
// clear the field.
|
|
await page.evaluate(() => window.visualViewport?.dispatchEvent(new Event('resize')))
|
|
await page.waitForTimeout(800)
|
|
}
|
|
|
|
test.describe('Sheet web keyboard avoidance — autofocus on open', () => {
|
|
test('keyboard rising with the open animation lifts the sheet and keeps its fit height', async ({
|
|
page,
|
|
}) => {
|
|
await openWithAutofocusKeyboard(page)
|
|
|
|
const frame = await rect(page, 'sheet-web-kb-af-frame')
|
|
expect(frame).toBeTruthy()
|
|
|
|
// the sheet keeps a real (un-collapsed) height. before the fix the frame
|
|
// collapsed to the shrunk useWindowDimensions*0.7 cap.
|
|
expect(frame!.height).toBeGreaterThan(KB_HEIGHT)
|
|
// it lifts so the bottom reaches the keyboard top when the natural fit height
|
|
// can fit in the visible band. It is not expanded to full height.
|
|
expect(frame!.bottom).toBeLessThanOrEqual(KEYBOARD_TOP + 8)
|
|
expect(frame!.bottom).toBeLessThan(VH - 100)
|
|
expect(frame!.top).toBeGreaterThanOrEqual(-4)
|
|
})
|
|
|
|
test('content becomes scrollable when the keyboard opens with autofocus', async ({
|
|
page,
|
|
}) => {
|
|
await openWithAutofocusKeyboard(page)
|
|
|
|
const canScroll = await page.evaluate(() => {
|
|
const n = document.querySelector(
|
|
'[data-testid="sheet-web-kb-af-scrollview"]'
|
|
) as HTMLElement
|
|
return n ? n.scrollHeight - n.clientHeight : 0
|
|
})
|
|
// when the safe-area cap leaves lower content behind the keyboard, the
|
|
// keyboardOccludedHeight spacer enables it to scroll above the keyboard.
|
|
expect(canScroll).toBeGreaterThan(40)
|
|
})
|
|
|
|
test('the bottom "Post Thread" button can be scrolled above the keyboard', async ({
|
|
page,
|
|
}) => {
|
|
await openWithAutofocusKeyboard(page)
|
|
|
|
// scroll the content to the bottom — with the height freeze + tail padding
|
|
// the footer scrolls into view above the keyboard. (the consumer report: the
|
|
// footer was stuck under the keyboard and scrolling didn't work.)
|
|
await page.evaluate(() => {
|
|
const n = document.querySelector(
|
|
'[data-testid="sheet-web-kb-af-scrollview"]'
|
|
) as HTMLElement
|
|
if (n) n.scrollTop = n.scrollHeight
|
|
})
|
|
await page.waitForTimeout(300)
|
|
|
|
const post = await rect(page, 'sheet-web-kb-af-post')
|
|
expect(post).toBeTruthy()
|
|
expect(post!.bottom).toBeLessThanOrEqual(KEYBOARD_TOP + 8)
|
|
})
|
|
|
|
test('pull-down follows the finger monotonically (no double-drive jitter) with autofocus keyboard', async ({
|
|
page,
|
|
}) => {
|
|
await openWithAutofocusKeyboard(page)
|
|
|
|
// pull the sheet DOWN; the frame bottom should increase monotonically. before
|
|
// the fix the sheet was un-anchored/collapsed and the two gesture systems
|
|
// fought, so the trajectory reversed between frames.
|
|
const samples = await touchDragSampling(
|
|
page,
|
|
'sheet-web-kb-af-scrollview',
|
|
'sheet-web-kb-af-frame',
|
|
90
|
|
)
|
|
expect(samples.length).toBeGreaterThan(5)
|
|
|
|
let maxBackward = 0
|
|
for (let i = 1; i < samples.length; i++) {
|
|
const back = samples[i - 1] - samples[i] // positive => moved up (wrong way)
|
|
if (back > maxBackward) maxBackward = back
|
|
}
|
|
expect(maxBackward).toBeLessThan(12)
|
|
})
|
|
|
|
test('an autofocused field that starts behind the keyboard is scrolled above it', async ({
|
|
page,
|
|
}) => {
|
|
await openWithBehindKeyboardAutofocus(page)
|
|
|
|
// the sheet lifts with the keyboard, capped at the safe-area top.
|
|
const frame = await rect(page, 'sheet-web-kb-af-frame')
|
|
expect(frame).toBeTruthy()
|
|
expect(frame!.bottom).toBeLessThanOrEqual(KEYBOARD_TOP + 8)
|
|
expect(frame!.top).toBeGreaterThanOrEqual(-4)
|
|
|
|
// the autofocused Body — which lays out BELOW the keyboard line at rest — ends
|
|
// up clear of the keyboard by translation plus inner scrolling, not cut off
|
|
// under it.
|
|
const body = await rect(page, 'sheet-web-kb-af-body')
|
|
expect(body).toBeTruthy()
|
|
expect(body!.bottom).toBeLessThanOrEqual(KEYBOARD_TOP + 8)
|
|
})
|
|
})
|