Skip to content

Commit b262002

Browse files
Artur-mcollovati
andauthored
feat: Add pageVisibilitySignal() to Page for tracking browser tab visibility (#23614)
Add a read-only signal-based API on Page that tracks whether the browser tab is visible and focused, visible but not focused, or hidden. Uses the browser Page Visibility API combined with focus/blur events, with a Firefox workaround for deferred visibilitychange. Client-side logic lives in page-visibility.js loaded via @jsmodule on UI. --------- Co-authored-by: Marco Collovati <marco@vaadin.com>
1 parent ae31992 commit b262002

8 files changed

Lines changed: 456 additions & 0 deletions

File tree

flow-client/src/main/frontend/Flow.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
type ConnectionStateStore
66
} from '@vaadin/common-frontend';
77
import './Geolocation';
8+
import { currentVisibility } from './PageVisibility';
89

910
export interface FlowConfig {
1011
imports?: () => Promise<any>;
@@ -541,6 +542,9 @@ export class Flow {
541542
const colorScheme = getComputedStyle(document.documentElement).colorScheme.trim();
542543
// "normal" is the default value and means no color scheme is set
543544
params['v-cs'] = colorScheme && colorScheme !== 'normal' ? colorScheme : '';
545+
/* Page visibility — initial state of document.hidden / document.hasFocus() */
546+
params['v-pv'] = currentVisibility();
547+
544548
/* Theme name - detect which theme is in use */
545549
const computedStyle = getComputedStyle(document.documentElement);
546550
let themeName = '';
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*
2+
* Copyright 2000-2026 Vaadin Ltd.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
5+
* use this file except in compliance with the License. You may obtain a copy of
6+
* the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations under
14+
* the License.
15+
*/
16+
17+
type VaadinPageVisibility = 'VISIBLE' | 'VISIBLE_NOT_FOCUSED' | 'HIDDEN';
18+
19+
// Firefox defers the visibilitychange event while the window is blurred, so
20+
// a blur handler needs to wait long enough for that delivery to land before
21+
// concluding the state is really "visible but not focused".
22+
const FIREFOX_BLUR_SETTLE_MS = 500;
23+
const DEFAULT_BLUR_SETTLE_MS = 10;
24+
25+
/**
26+
* Returns the current visibility state synchronously. Used by the bootstrap
27+
* path to seed the server-side signal without waiting for a DOM event.
28+
*/
29+
export function currentVisibility(): VaadinPageVisibility {
30+
if (document.hidden) {
31+
return 'HIDDEN';
32+
}
33+
return document.hasFocus() ? 'VISIBLE' : 'VISIBLE_NOT_FOCUSED';
34+
}
35+
36+
function isFirefox(): boolean {
37+
// Firefox is the only supported browser that reorders visibilitychange
38+
// relative to blur; UA sniffing is acceptable here because the alternative
39+
// is waiting the longer interval on every browser.
40+
return navigator.userAgent.indexOf('Firefox') > -1;
41+
}
42+
43+
let blurTimer: ReturnType<typeof setTimeout> | undefined;
44+
45+
// Dispatch on document.body so the server-side Page facade (listening on
46+
// the UI element, which is body) can update its signal.
47+
function dispatch(state: VaadinPageVisibility): void {
48+
document.body.dispatchEvent(new CustomEvent('vaadin-page-visibility-change', { detail: state }));
49+
}
50+
51+
function clearBlurTimer(): void {
52+
if (blurTimer !== undefined) {
53+
clearTimeout(blurTimer);
54+
blurTimer = undefined;
55+
}
56+
}
57+
58+
document.addEventListener('visibilitychange', () => {
59+
clearBlurTimer();
60+
dispatch(document.hidden ? 'HIDDEN' : 'VISIBLE');
61+
});
62+
63+
window.addEventListener('blur', () => {
64+
clearBlurTimer();
65+
const delay = isFirefox() ? FIREFOX_BLUR_SETTLE_MS : DEFAULT_BLUR_SETTLE_MS;
66+
blurTimer = setTimeout(() => {
67+
blurTimer = undefined;
68+
if (!document.hidden) {
69+
dispatch('VISIBLE_NOT_FOCUSED');
70+
}
71+
}, delay);
72+
});
73+
74+
window.addEventListener('focus', () => {
75+
clearBlurTimer();
76+
if (!document.hidden) {
77+
dispatch('VISIBLE');
78+
}
79+
});

flow-server/src/main/java/com/vaadin/flow/component/page/ExtendedClientDetails.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,7 @@ public static ExtendedClientDetails updateFromJson(UI ui, JsonNode json) {
498498
getStringElseNull.apply("v-cs"),
499499
getStringElseNull.apply("v-tn"));
500500
ui.getInternals().setExtendedClientDetails(details);
501+
ui.getPage().setPageVisibility(getStringElseNull.apply("v-pv"));
501502
String ga = getStringElseNull.apply("v-ga");
502503
if (ga != null) {
503504
try {

flow-server/src/main/java/com/vaadin/flow/component/page/Page.java

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@
2424
import java.util.Objects;
2525
import java.util.UUID;
2626

27+
import org.slf4j.Logger;
28+
import org.slf4j.LoggerFactory;
29+
2730
import com.vaadin.flow.component.Direction;
2831
import com.vaadin.flow.component.UI;
2932
import com.vaadin.flow.component.dependency.JavaScript;
@@ -52,11 +55,17 @@
5255
*/
5356
public class Page implements Serializable {
5457

58+
private static final Logger LOGGER = LoggerFactory.getLogger(Page.class);
59+
5560
private final UI ui;
5661
private final History history;
5762
private DomListenerRegistration resizeReceiver;
5863
private ArrayList<BrowserWindowResizeListener> resizeListeners;
5964
private ValueSignal<WindowSize> windowSizeSignal;
65+
private final ValueSignal<PageVisibility> pageVisibilitySignal = new ValueSignal<>(
66+
PageVisibility.UNKNOWN);
67+
private final Signal<PageVisibility> pageVisibilityReadOnly = pageVisibilitySignal
68+
.asReadonly();
6069

6170
/**
6271
* Creates a page instance for the given UI.
@@ -67,6 +76,10 @@ public class Page implements Serializable {
6776
public Page(UI ui) {
6877
this.ui = ui;
6978
history = new History(ui);
79+
ui.getElement()
80+
.addEventListener("vaadin-page-visibility-change",
81+
e -> setPageVisibility(e.getEventDetail(String.class)))
82+
.addEventDetail().debounce(100).allowInert();
7083
}
7184

7285
/**
@@ -477,6 +490,67 @@ private void ensureResizeListener() {
477490
}
478491
}
479492

493+
/**
494+
* Returns a read-only signal that tracks the browser tab's visibility and
495+
* focus state.
496+
* <p>
497+
* The signal distinguishes between {@link PageVisibility#VISIBLE VISIBLE}
498+
* (tab shown and focused), {@link PageVisibility#VISIBLE_NOT_FOCUSED
499+
* VISIBLE_NOT_FOCUSED} (tab shown but behind another window or another
500+
* application has focus), {@link PageVisibility#HIDDEN HIDDEN} (tab in
501+
* background, minimized, or on a different virtual desktop), and
502+
* {@link PageVisibility#UNKNOWN UNKNOWN} (the initial value, replaced with
503+
* a real one before any user code observes the signal).
504+
* <p>
505+
* The signal value is seeded from the initial client bootstrap, so user
506+
* code always sees a real value. Subscribe with
507+
* {@code Signal.effect(owner, ...)} to react to changes; call
508+
* {@code pageVisibilitySignal().peek()} for a snapshot outside a reactive
509+
* context, and {@code .get()} inside one.
510+
* <p>
511+
* <b>Reliability caveats.</b> The value is best-effort:
512+
* <ul>
513+
* <li>Firefox defers the {@code visibilitychange} event while the window is
514+
* blurred, so transitions from {@link PageVisibility#VISIBLE VISIBLE} to
515+
* {@link PageVisibility#HIDDEN HIDDEN} may take up to half a second longer
516+
* than on Chromium or Safari.</li>
517+
* <li>The {@link PageVisibility#VISIBLE_NOT_FOCUSED VISIBLE_NOT_FOCUSED}
518+
* distinction relies on {@code document.hasFocus()}, which is accurate in
519+
* all supported browsers but depends on the OS reporting focus changes
520+
* promptly — some window-manager configurations can delay it briefly.</li>
521+
* <li>Rapid focus/blur bursts are intentionally coalesced
522+
* ({@code debounce(100)}) so the signal settles once the sequence ends
523+
* instead of firing on each intermediate state.</li>
524+
* </ul>
525+
*
526+
* @return the read-only visibility signal
527+
*/
528+
public Signal<PageVisibility> pageVisibilitySignal() {
529+
return pageVisibilityReadOnly;
530+
}
531+
532+
/**
533+
* Sets the page visibility from a raw client-side value (e.g. from the
534+
* bootstrap parameters or from a {@code vaadin-page-visibility-change} DOM
535+
* event). {@code null} and unknown values are ignored — the latter is
536+
* logged at debug level so a forward-compatible client value does not
537+
* silently disappear.
538+
*
539+
* @param value
540+
* the raw value, or {@code null}
541+
*/
542+
void setPageVisibility(String value) {
543+
if (value == null) {
544+
return;
545+
}
546+
try {
547+
pageVisibilitySignal.set(PageVisibility.valueOf(value));
548+
} catch (IllegalArgumentException e) {
549+
LOGGER.debug("Unknown page visibility value from client: {}",
550+
value);
551+
}
552+
}
553+
480554
/**
481555
* Opens the given url in a new tab.
482556
*
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/*
2+
* Copyright 2000-2026 Vaadin Ltd.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
5+
* use this file except in compliance with the License. You may obtain a copy of
6+
* the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations under
14+
* the License.
15+
*/
16+
package com.vaadin.flow.component.page;
17+
18+
/**
19+
* Represents the visibility state of a browser page.
20+
* <p>
21+
* Uses the browser's Page Visibility API ({@code document.hidden}) combined
22+
* with {@code document.hasFocus()} to distinguish between three observable
23+
* states — fully visible and focused, visible but not focused, and hidden —
24+
* plus an {@link #UNKNOWN} sentinel used before the first value has arrived
25+
* from the client.
26+
*
27+
* @see Page#pageVisibilitySignal()
28+
*/
29+
public enum PageVisibility {
30+
31+
/**
32+
* No value has been reported by the browser yet. Used only as the initial
33+
* value of the signal before the first client handshake delivers the real
34+
* one. In normal request handling the signal is seeded before any user code
35+
* (UI initialization, {@code UIInitListener}, component attach) runs, so
36+
* this value is essentially never observed in practice; once a real value
37+
* has arrived, the signal never returns to {@code UNKNOWN}.
38+
*/
39+
UNKNOWN,
40+
41+
/**
42+
* The page is visible and focused.
43+
* <p>
44+
* In the browser, this means {@code !document.hidden} and
45+
* {@code document.hasFocus()} is {@code true}.
46+
*/
47+
VISIBLE,
48+
49+
/**
50+
* The page is visible but not focused, e.g. behind another window.
51+
* <p>
52+
* In the browser, this means {@code !document.hidden} and
53+
* {@code document.hasFocus()} is {@code false}.
54+
*/
55+
VISIBLE_NOT_FOCUSED,
56+
57+
/**
58+
* The page is not visible, e.g. the browser tab is in the background or the
59+
* window is minimized.
60+
* <p>
61+
* In the browser, this is indicated by {@code document.hidden} being
62+
* {@code true}.
63+
*/
64+
HIDDEN
65+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/*
2+
* Copyright 2000-2026 Vaadin Ltd.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
5+
* use this file except in compliance with the License. You may obtain a copy of
6+
* the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations under
14+
* the License.
15+
*/
16+
package com.vaadin.flow.component.page;
17+
18+
import org.junit.jupiter.api.Test;
19+
import tools.jackson.databind.node.ObjectNode;
20+
21+
import com.vaadin.flow.dom.DomEvent;
22+
import com.vaadin.flow.internal.JacksonUtils;
23+
import com.vaadin.flow.internal.nodefeature.ElementListenerMap;
24+
import com.vaadin.flow.shared.JsonConstants;
25+
import com.vaadin.flow.signals.Signal;
26+
import com.vaadin.flow.signals.local.ValueSignal;
27+
import com.vaadin.tests.util.MockUI;
28+
29+
import static org.junit.jupiter.api.Assertions.assertEquals;
30+
import static org.junit.jupiter.api.Assertions.assertFalse;
31+
import static org.junit.jupiter.api.Assertions.assertSame;
32+
33+
class PageVisibilitySignalTest {
34+
35+
@Test
36+
void pageVisibilitySignal_isReadOnly() {
37+
MockUI ui = new MockUI();
38+
Signal<PageVisibility> signal = ui.getPage().pageVisibilitySignal();
39+
assertFalse(signal instanceof ValueSignal,
40+
"pageVisibilitySignal() should return a read-only signal");
41+
}
42+
43+
@Test
44+
void pageVisibilitySignal_defaultsToUnknownBeforeBootstrap() {
45+
MockUI ui = new MockUI();
46+
Signal<PageVisibility> signal = ui.getPage().pageVisibilitySignal();
47+
assertEquals(PageVisibility.UNKNOWN, signal.peek(),
48+
"Before bootstrap the value should be UNKNOWN so callers can "
49+
+ "distinguish 'no data yet' from a real VISIBLE");
50+
}
51+
52+
@Test
53+
void pageVisibilitySignal_readonlyWrapperIsCached() {
54+
Page page = new MockUI().getPage();
55+
assertSame(page.pageVisibilitySignal(), page.pageVisibilitySignal(),
56+
"Repeated calls must return the same read-only wrapper so "
57+
+ "subscriber identity stays stable");
58+
}
59+
60+
@Test
61+
void pageVisibilitySignal_tracksVisibilityChanges() {
62+
MockUI ui = new MockUI();
63+
Signal<PageVisibility> signal = ui.getPage().pageVisibilitySignal();
64+
65+
fireVisibilityEvent(ui, "VISIBLE");
66+
assertEquals(PageVisibility.VISIBLE, signal.peek());
67+
68+
fireVisibilityEvent(ui, "HIDDEN");
69+
assertEquals(PageVisibility.HIDDEN, signal.peek());
70+
71+
fireVisibilityEvent(ui, "VISIBLE_NOT_FOCUSED");
72+
assertEquals(PageVisibility.VISIBLE_NOT_FOCUSED, signal.peek());
73+
}
74+
75+
@Test
76+
void pageVisibilitySignal_unknownDetailKeepsPreviousValue() {
77+
MockUI ui = new MockUI();
78+
Signal<PageVisibility> signal = ui.getPage().pageVisibilitySignal();
79+
80+
fireVisibilityEvent(ui, "VISIBLE");
81+
fireVisibilityEvent(ui, "SOMETHING_NEW");
82+
83+
assertEquals(PageVisibility.VISIBLE, signal.peek(),
84+
"Unknown detail values from a newer client should not reset "
85+
+ "the signal");
86+
}
87+
88+
private void fireVisibilityEvent(MockUI ui, String visibility) {
89+
ObjectNode eventData = JacksonUtils.createObjectNode();
90+
eventData.put("event.detail", visibility);
91+
eventData.put(JsonConstants.EVENT_DATA_PHASE,
92+
JsonConstants.EVENT_PHASE_TRAILING);
93+
ui.getElement().getNode().getFeature(ElementListenerMap.class)
94+
.fireEvent(new DomEvent(ui.getElement(),
95+
"vaadin-page-visibility-change", eventData));
96+
}
97+
}

0 commit comments

Comments
 (0)