Skip to content

html5: fix ACTIVATE/DEACTIVATE getting stuck when a tab is hidden without a focus/blur pair (common on mobile Safari) - #2104

Open
mallyskies wants to merge 2 commits into
openfl:developfrom
masquepublishing:fix/visibilitychange-and-press-activation
Open

mallyskies wants to merge 2 commits into
openfl:developfrom
masquepublishing:fix/visibilitychange-and-press-activation

Conversation

@mallyskies

Copy link
Copy Markdown

HTML5Application's ACTIVATE/DEACTIVATE tracking can get stuck DEACTIVATE forever, with nothing left to fire ACTIVATE:

  1. handleWindowEvent() has had a "visibilitychange" case since Visibility-change API implementation added #1401 (closed, unmerged) / d918341, but nothing ever calls addEventListener for it (i.e. dead code since 2020). Anything hiding the tab without a blur/focus pair on the window (e.g. a tab-overview UI vs. a click into another window) never DEACTIVATEs, or DEACTIVATEs and never gets ACTIVATE back.
  2. On mobile Safari, returning from the tab overview ("All Tabs") fires a single blur going out and nothing coming back: no focus, no visibilitychange. The page is frontmost and receiving touches, but hasFocus() returns false. Further, HTML5Window's preventDefault() on touchstart blocks the synthetic click some engines use internally to notice a backgrounded page regained focus, so a touch-only canvas can never leave the hidden state again after the first backgrounding.

Real-world impact
An app that suspends on DEACTIVATE (pause, mute, stop a timer) and expects ACTIVATE to resume it can become suspended for the rest of the session. We ran into this with one of our game apps that hides the game progress when not active.

Fix

  1. Register the visibilitychange listener as per Page Visibility API.
  2. Treat a mousedown/touchstart reaching the window's element as proof it's active, regardless of what hidden says, since these events can't happen unless the window is frontmost. Refactors the two existing "became visible" branches into one activate() so this doesn't duplicate them a third time.

Testing
project.xml:

<project>
	<app main="Main" file="ActivateTest" path="bin"/>
	<haxelib name="lime"/>
	<source path="src"/>
</project>

src/Main.hx:

import lime.app.Application;

class Main extends Application {
	public override function onWindowActivate():Void log("ACTIVATE");
	public override function onWindowDeactivate():Void log("DEACTIVATE");

	function log(s:String):Void {
		var w:Dynamic = js.Browser.window;
		if (w.__log == null) w.__log = [];
		w.__log.push(s);
	}
}

lime build html5 -debug, open html5/bin/index.html, then in the browser console, paste:

window.dispatchEvent(new Event('blur'));                // -> window.__log = ["DEACTIVATE"]
document.dispatchEvent(new Event('visibilitychange'));  // no focus event at all
window.__log;

Outputs ["DEACTIVATE"] on unpatched develop, and with the fix in place displays: ["DEACTIVATE", "ACTIVATE"].

Same process for the touch path. Reload, then:

window.dispatchEvent(new Event('blur'));
const c = document.querySelector('canvas');
const t = new Touch({identifier: 1, target: c, clientX: 10, clientY: 10});
c.dispatchEvent(new TouchEvent('touchstart', {touches: [t], targetTouches: [t], changedTouches: [t], cancelable: true, bubbles: true}));
window.__log;

Outputs ["DEACTIVATE"] on unpatched develop, and with the fix in place displays: ["DEACTIVATE", "ACTIVATE"].

…E already handle

handleWindowEvent() has had a "visibilitychange" case since openfl#1401 (closed,
never merged), reimplemented in d918341. That commit kept the switch arm and
the `hidden` latch but dropped the addEventListener call, so the case has
been dead code since 2020: ACTIVATE/DEACTIVATE could only ever follow a real
window focus/blur pair.

That misses any tab-hidden transition that doesn't fire focus/blur on this
window, e.g. opening a browser tab overview or switching tabs by a gesture
rather than a direct click on another window. An app embedded in such a page
can DEACTIVATE-suspend on the way out and then never see anything to
ACTIVATE-resume it on the way back, since no focus event follows either.

visibilitychange is a document event (Page Visibility API), not a window
event, so it is registered on Browser.document alongside the window-level
focus/blur/resize/beforeunload listeners already above it.
WebKit can leave a page's focus state stale after the user returns to it -
observed on iOS Safari returning from the tab overview ('All Tabs'), where
the page never receives a 'focus' DOM event even though it is frontmost and
receiving input again. lime's own ACTIVATE/DEACTIVATE tracking has no way to
notice: it only clears the internal `hidden` latch on a real 'focus' event
(or the 'visibilitychange' case, previous commit), so an app that suspended
itself on DEACTIVATE stays suspended indefinitely with no further event ever
arriving to resume it.

A mousedown/touchstart reaching the window's own element cannot happen
unless the window is frontmost, so treat it as an activation signal in its
own right rather than waiting on a 'focus' event that may never come. This
matters most for touchstart: HTML5Window already calls preventDefault() on
it, which on WebKit also suppresses the synthetic click some engines use
internally to notice a backgrounded page regained focus - so a canvas that
only ever receives touches (true of most mobile play) could otherwise never
leave the hidden state again after the first time it was backgrounded.

Refactors the two existing 'became visible' branches in handleWindowEvent()
('focus', and the visible half of 'visibilitychange') to share the same
activate() this adds, rather than duplicating the same three lines a third
time.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant