Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/shiny-sheets-unghost.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@vyui/core": patch
---

Fix the sheet ghost-panel-eats-taps bug: usePresence now hard-caps total time in Leaving (un-cancellable MAX_LEAVING_FRAMES net that forces Left even when an animation start never resolves) — so a vue-lynx replace-all style patch that kills a close animation without firing its end/cancel can no longer leave an invisible tap-eating panel mounted.
58 changes: 58 additions & 0 deletions packages/core/src/components/Presence/Presence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,64 @@ describe('Presence — state machine via inject', () => {
expect(probe.ctx.controllers.mount.value).toBe(false)
})

it('hard-caps Leaving: forces Left when an animation start never resolves', async () => {
const onClose = vi.fn()
const probe = mountWithProbe({ initialShow: true, onClose })
await waitForUpdate()
await frames(40)
await waitForUpdate()
expect(probe.ctx.controllers.state.value).toBe(PresenceState.Entered)

probe.setOpen(false)
await waitForUpdate()
expect(probe.ctx.controllers.state.value).toBe(PresenceState.Leaving)
// A start with no matching end/cancel — what a style wipe that destroys
// a running animation produces. The 24-frame fallback is dead (the start
// bumped its loop id), so only the MAX_LEAVING_FRAMES cap can save us.
// (No mid-cap "still Leaving" probe here: rAF cadence in jsdom is too
// loose to assert against wall-clock waits; the in-flight pinning is
// covered by the Entering test above.)
probe.ctx.animationHandlers.handleKFStart()
await frames(80)
await waitForUpdate()
expect(probe.ctx.controllers.state.value).toBe(PresenceState.Left)
expect(probe.ctx.controllers.mount.value).toBe(false)
expect(onClose).toHaveBeenCalledTimes(1)

// The stuck isKFAnimating flag was cleared on the forced Left — the next
// open cycle must reach Entered through the normal fallback again.
probe.setOpen(true)
await waitForUpdate()
await frames(40)
await waitForUpdate()
expect(probe.ctx.controllers.state.value).toBe(PresenceState.Entered)
})

it('reopening during Leaving stands the hard cap down', async () => {
const onClose = vi.fn()
const probe = mountWithProbe({ initialShow: true, onClose })
await waitForUpdate()
await frames(40)
await waitForUpdate()
expect(probe.ctx.controllers.state.value).toBe(PresenceState.Entered)

probe.setOpen(false)
await waitForUpdate()
probe.ctx.animationHandlers.handleKFStart()
// Cancel the close while the leaving animation is still "in flight",
// let the rescheduled Entering land, then resolve the animation.
probe.setOpen(true)
await frames(15)
await waitForUpdate()
probe.ctx.animationHandlers.handleKFEnd()
// Wait past the cap: the stood-down hard loop must NOT force Left.
await frames(70)
await waitForUpdate()
expect(probe.ctx.controllers.state.value).toBe(PresenceState.Entered)
expect(probe.ctx.controllers.mount.value).toBe(true)
expect(onClose).not.toHaveBeenCalled()
})

it('enableDelay lands the state machine in DelayedEntering before Entered', async () => {
// The DelayedEntering state is scheduled 16 frames after show. We pin
// animation in flight (handleKFStart) before any waiting so the entering
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/components/Presence/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export {
} from './Presence'

export {
MAX_LEAVING_FRAMES,
MAX_WAIT_FRAMES,
usePresence,
type UsePresenceRefOptions,
Expand Down
53 changes: 52 additions & 1 deletion packages/core/src/components/Presence/usePresence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,22 @@ export interface UsePresenceRefOptions {
*/
export const MAX_WAIT_FRAMES = 24

/**
* Hard ceiling on the TOTAL number of frames an element may sit in `Leaving`,
* regardless of animation events. 60 frames ≈ 1s at 60fps — comfortably past
* any exit animation (280ms default) plus the {@link MAX_WAIT_FRAMES} grace.
*
* The per-frame fallback in `handleStateLeaving` is cancelled by ANY
* `bindanimationstart` / `bindtransitionstart`, after which the machine
* trusts a matching end/cancel to arrive. On Lynx that trust can be broken:
* a BG re-render that patches styles replaces main-thread-written inline
* styles wholesale (vue-lynx SET_STYLE), which can kill a running animation
* without ever firing its end/cancel — the state hangs in `Leaving` and the
* invisible child keeps eating taps. This cap is the un-cancellable safety
* net: once it expires, `Left` is forced no matter what.
*/
export const MAX_LEAVING_FRAMES = 60

/**
* The core animation state machine for `<Presence>`.
*
Expand All @@ -64,7 +80,9 @@ export const MAX_WAIT_FRAMES = 24
*
* If no animation fires for {@link MAX_WAIT_FRAMES} frames after entering one
* of those states, the state advances anyway so unanimated content doesn't
* hang on screen.
* hang on screen. `Leaving` additionally carries the un-cancellable
* {@link MAX_LEAVING_FRAMES} hard cap, which forces `Left` even when a start
* event arrived but its end/cancel never will.
*
* Race protection — `enteringLoopIdRef` / `leavingLoopIdRef` /
* `showScheduleIdRef` are incremented on every relevant trigger; in-flight
Expand Down Expand Up @@ -117,6 +135,10 @@ export function usePresence(opts: UsePresenceRefOptions): UsePresenceReturnType
const showScheduleIdRef = { current: 0 }
const enteringWaitFramesRef = { current: 0 }
const leavingWaitFramesRef = { current: 0 }
// Separate id for the hard-cap loop: bumped ONLY on (re)entry into Leaving,
// never by animation start events — that's what makes it un-cancellable.
const leavingHardLoopIdRef = { current: 0 }
const leavingHardWaitFramesRef = { current: 0 }

// ----- helpers ------------------------------------------------------------

Expand Down Expand Up @@ -252,6 +274,35 @@ export function usePresence(opts: UsePresenceRefOptions): UsePresenceReturnType
delayFrames(1, tryLeft)
}
delayFrames(1, tryLeft)

// Un-cancellable hard cap — see MAX_LEAVING_FRAMES. Unlike `tryLeft`,
// this loop ignores `leavingLoopIdRef` bumps and the animating flags; it
// only stands down when the state itself moved on (an animation end
// advanced to Left) or the consumer flipped `show` back to true (the
// re-entry path owns the element again).
leavingHardWaitFramesRef.current = 0
leavingHardLoopIdRef.current += 1
const hardLoopId = leavingHardLoopIdRef.current
const forceLeft = () => {
if (hardLoopId !== leavingHardLoopIdRef.current) return
if (state.value !== PresenceState.Leaving) return
if (showRef.current) return
if (leavingHardWaitFramesRef.current >= MAX_LEAVING_FRAMES) {
log(
debugLog,
`[vyui-presence][usePresence] leaving hard cap reached, force Left, loopId: ${hardLoopId}`,
)
// An animation start whose end/cancel never arrived leaves these
// stuck true — clear them so the next open/close cycle isn't poisoned.
isKFAnimating.current = false
isTransitionAnimating.current = false
setPresenceState(PresenceState.Left)
return
}
leavingHardWaitFramesRef.current += 1
delayFrames(1, forceLeft)
}
delayFrames(1, forceLeft)
}

const handleStateEnteringWithDelay = () => {
Expand Down