Skip to content

Commit c3d1c06

Browse files
authored
feat: eagerly load ExtendedClientDetails during UI initialization (#22719)
* feat: eagerly load ExtendedClientDetails during UI initialization Browser details (ExtendedClientDetails) are now automatically fetched during UI initialization and available immediately in the UI constructor, eliminating the need for asynchronous callbacks in most cases. API changes: - Page.getExtendedClientDetails(): Now always returns a non-null instance (creates placeholder with default values if not yet fetched). Browser details are automatically populated during normal UI initialization. - ExtendedClientDetails.refresh(Consumer): New method to refresh cached browser details with fresh values from the browser. Callback is invoked when refresh completes. - Page.retrieveExtendedClientDetails(ExtendedClientDetailsReceiver): Deprecated. Use getExtendedClientDetails() to access cached details, or ExtendedClientDetails.refresh() to update them. Migration guide: Before: page.retrieveExtendedClientDetails(details -> { int width = details.getScreenWidth(); }); After: ExtendedClientDetails details = page.getExtendedClientDetails(); int width = details.getScreenWidth(); // Available immediately // Or refresh if needed: details.refresh(updated -> { int width = updated.getScreenWidth(); }); Benefits: - No null checks needed when accessing browser details - Browser details available immediately in UI constructor/onAttach - Simpler synchronous API for common use cases - Full backward compatibility maintained * Make browser details collection available before init request completes The browser details collection function getBrowserDetailsParameters was previously defined in FlowBootstrap.js, which was loaded after the init request. This caused the function to be undefined when Flow.ts tried to collect browser details to send with the ?v-r=init request. This change moves the browser details collection logic from FlowBootstrap.js into Flow.ts as a private method collectBrowserDetails(), and registers it as window.Vaadin.Flow.getBrowserDetailsParameters in the Flow constructor. This ensures the function is available when needed during the init request, while maintaining backward compatibility for code that calls the function via the global window object. The TypeScript implementation uses ($wnd as any) casts to access window properties like screen, document, navigator, etc., since the $wnd type doesn't include all browser DOM APIs. Values are stringified before returning to match the original JavaScript implementation. Also adds integration test to verify browser details are available immediately on page load without user interaction. * Make getExtendedClientDetails() always return non-null Browser details are now sent eagerly with the init request, but getExtendedClientDetails() could still return null before the data arrives. This changes the API to always return a non-null instance, using a placeholder with default values (screenWidth = -1, windowName = null) until actual browser details are available. For PreserveOnRefresh functionality, the code now checks if windowName is null rather than checking if the entire ExtendedClientDetails object is null. This is necessary because: - Screen dimensions are always sent with the init request - But window.name may be empty/undefined in new browser windows - This causes windowName = null even when other details are present - Different windows with windowName = null would incorrectly share cached components Changes: - Made ExtendedClientDetails constructor public (marked as internal-only) - UIInternals.getExtendedClientDetails() creates placeholder if null - Page.getExtendedClientDetails() simplified to delegate to UIInternals - Page.retrieveExtendedClientDetails() checks screenWidth == -1 - AbstractNavigationStateRenderer: Changed 3 locations to check windowName == null instead of details == null - WebComponentUI: Changed to check windowName == null This fixes PreserveOnRefreshIT.refreshInDifferentWindow_componentIsRecreated test failure where components were incorrectly preserved across different browser windows due to cache key collisions. * Tweak details
1 parent 26b8df0 commit c3d1c06

File tree

14 files changed

+404
-281
lines changed

14 files changed

+404
-281
lines changed

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

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ export class Flow {
120120
isActive: () => this.isActive
121121
}
122122
};
123+
// Set browser details collection function as global for use by refresh()
124+
($wnd.Vaadin.Flow as any).getBrowserDetailsParameters = this.collectBrowserDetails.bind(this);
123125

124126
// Regular expression used to remove the app-context
125127
const elm = document.head.querySelector('base');
@@ -425,9 +427,16 @@ export class Flow {
425427
return new Promise((resolve, reject) => {
426428
const xhr = new XMLHttpRequest();
427429
const httpRequest = xhr as any;
430+
431+
// Collect browser details to send with init request as JSON
432+
const browserDetails = this.collectBrowserDetails();
433+
const browserDetailsParam = browserDetails
434+
? `&v-browserDetails=${encodeURIComponent(JSON.stringify(browserDetails))}`
435+
: '';
436+
428437
const requestPath = `?v-r=init&location=${encodeURIComponent(
429438
this.getFlowRoutePath(location)
430-
)}&query=${encodeURIComponent(this.getFlowRouteQuery(location))}`;
439+
)}&query=${encodeURIComponent(this.getFlowRouteQuery(location))}${browserDetailsParam}`;
431440

432441
httpRequest.open('GET', requestPath);
433442

@@ -452,6 +461,95 @@ export class Flow {
452461
});
453462
}
454463

464+
// Collects browser details parameters
465+
private collectBrowserDetails(): Record<string, string> {
466+
const params: Record<string, any> = {};
467+
468+
/* Screen height and width */
469+
params['v-sh'] = ($wnd as any).screen.height;
470+
params['v-sw'] = ($wnd as any).screen.width;
471+
/* Browser window dimensions */
472+
params['v-wh'] = ($wnd as any).innerHeight;
473+
params['v-ww'] = ($wnd as any).innerWidth;
474+
/* Body element dimensions */
475+
params['v-bh'] = ($wnd as any).document.body.clientHeight;
476+
params['v-bw'] = ($wnd as any).document.body.clientWidth;
477+
478+
/* Current time */
479+
const date = new Date();
480+
params['v-curdate'] = date.getTime();
481+
482+
/* Current timezone offset (including DST shift) */
483+
const tzo1 = date.getTimezoneOffset();
484+
485+
/* Compare the current tz offset with the first offset from the end
486+
of the year that differs --- if less that, we are in DST, otherwise
487+
we are in normal time */
488+
let dstDiff = 0;
489+
let rawTzo = tzo1;
490+
for (let m = 12; m > 0; m -= 1) {
491+
date.setUTCMonth(m);
492+
const tzo2 = date.getTimezoneOffset();
493+
if (tzo1 !== tzo2) {
494+
dstDiff = tzo1 > tzo2 ? tzo1 - tzo2 : tzo2 - tzo1;
495+
rawTzo = tzo1 > tzo2 ? tzo1 : tzo2;
496+
break;
497+
}
498+
}
499+
500+
/* Time zone offset */
501+
params['v-tzo'] = tzo1;
502+
503+
/* DST difference */
504+
params['v-dstd'] = dstDiff;
505+
506+
/* Time zone offset without DST */
507+
params['v-rtzo'] = rawTzo;
508+
509+
/* DST in effect? */
510+
params['v-dston'] = tzo1 !== rawTzo;
511+
512+
/* Time zone id (if available) */
513+
try {
514+
params['v-tzid'] = Intl.DateTimeFormat().resolvedOptions().timeZone;
515+
} catch (err) {
516+
params['v-tzid'] = '';
517+
}
518+
519+
/* Window name */
520+
if (($wnd as any).name) {
521+
params['v-wn'] = ($wnd as any).name;
522+
}
523+
524+
/* Detect touch device support */
525+
let supportsTouch = false;
526+
try {
527+
($wnd as any).document.createEvent('TouchEvent');
528+
supportsTouch = true;
529+
} catch (e) {
530+
/* Chrome and IE10 touch detection */
531+
supportsTouch = 'ontouchstart' in $wnd || typeof ($wnd as any).navigator.msMaxTouchPoints !== 'undefined';
532+
}
533+
params['v-td'] = supportsTouch;
534+
535+
/* Device Pixel Ratio */
536+
params['v-pr'] = ($wnd as any).devicePixelRatio;
537+
538+
if (($wnd as any).navigator.platform) {
539+
params['v-np'] = ($wnd as any).navigator.platform;
540+
}
541+
542+
/* Stringify each value (they are parsed on the server side) */
543+
const stringParams: Record<string, string> = {};
544+
Object.keys(params).forEach((key) => {
545+
const value = params[key];
546+
if (typeof value !== 'undefined') {
547+
stringParams[key] = value.toString();
548+
}
549+
});
550+
return stringParams;
551+
}
552+
455553
// Create shared connection state store and connection indicator
456554
private addConnectionIndicator() {
457555
// add connection indicator to DOM

flow-client/src/main/frontend/FlowBootstrap.js

Lines changed: 0 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -183,92 +183,6 @@ Please submit an issue to https://github.com/vaadin/flow-components/issues/new/c
183183
ws.pendingApps = null;
184184
}
185185
};
186-
window.Vaadin.Flow.getBrowserDetailsParameters = function () {
187-
var params = {};
188-
189-
/* Screen height and width */
190-
params['v-sh'] = window.screen.height;
191-
params['v-sw'] = window.screen.width;
192-
/* Browser window dimensions */
193-
params['v-wh'] = window.innerHeight;
194-
params['v-ww'] = window.innerWidth;
195-
/* Body element dimensions */
196-
params['v-bh'] = document.body.clientHeight;
197-
params['v-bw'] = document.body.clientWidth;
198-
199-
/* Current time */
200-
var date = new Date();
201-
params['v-curdate'] = date.getTime();
202-
203-
/* Current timezone offset (including DST shift) */
204-
var tzo1 = date.getTimezoneOffset();
205-
206-
/* Compare the current tz offset with the first offset from the end
207-
of the year that differs --- if less that, we are in DST, otherwise
208-
we are in normal time */
209-
var dstDiff = 0;
210-
var rawTzo = tzo1;
211-
for (var m = 12; m > 0; m--) {
212-
date.setUTCMonth(m);
213-
var tzo2 = date.getTimezoneOffset();
214-
if (tzo1 != tzo2) {
215-
dstDiff = tzo1 > tzo2 ? tzo1 - tzo2 : tzo2 - tzo1;
216-
rawTzo = tzo1 > tzo2 ? tzo1 : tzo2;
217-
break;
218-
}
219-
}
220-
221-
/* Time zone offset */
222-
params['v-tzo'] = tzo1;
223-
224-
/* DST difference */
225-
params['v-dstd'] = dstDiff;
226-
227-
/* Time zone offset without DST */
228-
params['v-rtzo'] = rawTzo;
229-
230-
/* DST in effect? */
231-
params['v-dston'] = tzo1 != rawTzo;
232-
233-
/* Time zone id (if available) */
234-
try {
235-
params['v-tzid'] = Intl.DateTimeFormat().resolvedOptions().timeZone;
236-
} catch (err) {
237-
params['v-tzid'] = '';
238-
}
239-
240-
/* Window name */
241-
if (window.name) {
242-
params['v-wn'] = window.name;
243-
}
244-
245-
/* Detect touch device support */
246-
var supportsTouch = false;
247-
try {
248-
document.createEvent('TouchEvent');
249-
supportsTouch = true;
250-
} catch (e) {
251-
/* Chrome and IE10 touch detection */
252-
supportsTouch = 'ontouchstart' in window || typeof navigator.msMaxTouchPoints !== 'undefined';
253-
}
254-
params['v-td'] = supportsTouch;
255-
256-
/* Device Pixel Ratio */
257-
params['v-pr'] = window.devicePixelRatio;
258-
259-
if (navigator.platform) {
260-
params['v-np'] = navigator.platform;
261-
}
262-
263-
/* Stringify each value (they are parsed on the server side) */
264-
Object.keys(params).forEach(function (key) {
265-
var value = params[key];
266-
if (typeof value !== 'undefined') {
267-
params[key] = value.toString();
268-
}
269-
});
270-
return params;
271-
};
272186
}
273187

274188
log('Flow bootstrap loaded');

flow-server/src/main/java/com/vaadin/flow/component/internal/UIInternals.java

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1347,12 +1347,20 @@ public UI getUI() {
13471347
}
13481348

13491349
/**
1350-
* The extended client details, if obtained, are cached in this field.
1350+
* Returns the extended client details. If browser details have not been
1351+
* received yet, returns a placeholder instance with default values (all
1352+
* dimensions set to -1). The placeholder will be updated with actual values
1353+
* when the browser details are received.
13511354
*
1352-
* @return the extended client details, or {@literal null} if not yet
1353-
* received.
1355+
* @return the extended client details (never {@code null})
13541356
*/
13551357
public ExtendedClientDetails getExtendedClientDetails() {
1358+
if (extendedClientDetails == null) {
1359+
// Create placeholder with default values
1360+
extendedClientDetails = new ExtendedClientDetails(ui, null, null,
1361+
null, null, null, null, null, null, null, null, null, null,
1362+
null, null, null, null);
1363+
}
13561364
return extendedClientDetails;
13571365
}
13581366

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

Lines changed: 96 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,21 +18,32 @@
1818
import java.io.Serializable;
1919
import java.util.Date;
2020
import java.util.TimeZone;
21+
import java.util.function.Consumer;
22+
import java.util.function.Function;
2123

24+
import tools.jackson.databind.JsonNode;
25+
import tools.jackson.databind.node.JsonNodeType;
26+
import tools.jackson.databind.node.ObjectNode;
27+
28+
import com.vaadin.flow.component.UI;
29+
import com.vaadin.flow.function.SerializableConsumer;
2230
import com.vaadin.flow.server.VaadinSession;
2331

2432
/**
2533
* Provides extended information about the web browser, such as screen
2634
* resolution and time zone.
2735
* <p>
28-
* Please note that all information is fetched only once, and <em>not updated
29-
* automatically</em>. To retrieve updated values, you can execute JS with
30-
* {@link Page#executeJs(String, Object...)} and get the current value back.
36+
* Browser details are automatically fetched on the first call to
37+
* {@link Page#getExtendedClientDetails()} and cached for the lifetime of the
38+
* UI. The fetch happens asynchronously, so the first call may return
39+
* {@code null} while the data is being retrieved. To update the cached values
40+
* with fresh data from the browser, use {@link #refresh(Consumer)}.
3141
*
3242
* @author Vaadin Ltd
3343
* @since 2.0
3444
*/
3545
public class ExtendedClientDetails implements Serializable {
46+
private final UI ui;
3647
private int screenWidth = -1;
3748
private int screenHeight = -1;
3849
private int windowInnerWidth = -1;
@@ -54,6 +65,8 @@ public class ExtendedClientDetails implements Serializable {
5465
* For internal use only. Updates all properties in the class according to
5566
* the given information.
5667
*
68+
* @param ui
69+
* the UI instance that owns this ExtendedClientDetails
5770
* @param screenWidth
5871
* Screen width
5972
* @param screenHeight
@@ -88,13 +101,14 @@ public class ExtendedClientDetails implements Serializable {
88101
* @param navigatorPlatform
89102
* navigation platform received from the browser
90103
*/
91-
ExtendedClientDetails(String screenWidth, String screenHeight,
104+
public ExtendedClientDetails(UI ui, String screenWidth, String screenHeight,
92105
String windowInnerWidth, String windowInnerHeight,
93106
String bodyClientWidth, String bodyClientHeight, String tzOffset,
94107
String rawTzOffset, String dstShift, String dstInEffect,
95108
String tzId, String curDate, String touchDevice,
96109
String devicePixelRatio, String windowName,
97110
String navigatorPlatform) {
111+
this.ui = ui;
98112
if (screenWidth != null) {
99113
try {
100114
this.screenWidth = Integer.parseInt(screenWidth);
@@ -382,4 +396,82 @@ public boolean isIOS() {
382396
|| (navigatorPlatform != null
383397
&& navigatorPlatform.startsWith("iPod"));
384398
}
399+
400+
/**
401+
* Creates an ExtendedClientDetails instance from browser details JSON
402+
* object. This is intended for internal use when browser details are
403+
* provided as JSON (e.g., during UI initialization or refresh).
404+
* <p>
405+
* For internal use only.
406+
*
407+
* @param ui
408+
* the UI instance that owns this ExtendedClientDetails
409+
* @param json
410+
* the JSON object containing browser details parameters
411+
* @return a new ExtendedClientDetails instance
412+
* @throws RuntimeException
413+
* if the JSON is not a valid object
414+
*/
415+
public static ExtendedClientDetails fromJson(UI ui, JsonNode json) {
416+
if (!(json instanceof ObjectNode)) {
417+
throw new RuntimeException("Expected a JSON object");
418+
}
419+
final ObjectNode jsonObj = (ObjectNode) json;
420+
421+
// Note that JSON returned is a plain string -> string map, the actual
422+
// parsing of the fields happens in ExtendedClient's constructor. If a
423+
// field is missing or the wrong type, pass on null for default.
424+
final Function<String, String> getStringElseNull = key -> {
425+
final JsonNode jsValue = jsonObj.get(key);
426+
if (jsValue != null
427+
&& JsonNodeType.STRING.equals(jsValue.getNodeType())) {
428+
return jsValue.asString();
429+
} else {
430+
return null;
431+
}
432+
};
433+
434+
return new ExtendedClientDetails(ui, getStringElseNull.apply("v-sw"),
435+
getStringElseNull.apply("v-sh"),
436+
getStringElseNull.apply("v-ww"),
437+
getStringElseNull.apply("v-wh"),
438+
getStringElseNull.apply("v-bw"),
439+
getStringElseNull.apply("v-bh"),
440+
getStringElseNull.apply("v-tzo"),
441+
getStringElseNull.apply("v-rtzo"),
442+
getStringElseNull.apply("v-dstd"),
443+
getStringElseNull.apply("v-dston"),
444+
getStringElseNull.apply("v-tzid"),
445+
getStringElseNull.apply("v-curdate"),
446+
getStringElseNull.apply("v-td"),
447+
getStringElseNull.apply("v-pr"),
448+
getStringElseNull.apply("v-wn"),
449+
getStringElseNull.apply("v-np"));
450+
}
451+
452+
/**
453+
* Refreshes the browser details by fetching updated values from the
454+
* browser. The refresh happens asynchronously. The cached values in this
455+
* instance will be updated when the browser responds, and then the provided
456+
* callback will be invoked with the updated details.
457+
*
458+
* @param callback
459+
* a callback that will be invoked with the updated
460+
* ExtendedClientDetails when the refresh is complete
461+
*/
462+
public void refresh(Consumer<ExtendedClientDetails> callback) {
463+
final String js = "return Vaadin.Flow.getBrowserDetailsParameters();";
464+
final SerializableConsumer<JsonNode> resultHandler = json -> {
465+
ExtendedClientDetails details = fromJson(ui, json);
466+
ui.getInternals().setExtendedClientDetails(details);
467+
if (callback != null) {
468+
callback.accept(details);
469+
}
470+
};
471+
final SerializableConsumer<String> errorHandler = err -> {
472+
throw new RuntimeException("Unable to retrieve extended "
473+
+ "client details. JS error is '" + err + "'");
474+
};
475+
ui.getPage().executeJs(js).then(resultHandler, errorHandler);
476+
}
385477
}

0 commit comments

Comments
 (0)