From b71461fde16a171d95c74e0091fdab7d9dfe0c72 Mon Sep 17 00:00:00 2001 From: Artur Signell Date: Sat, 21 Feb 2026 16:41:44 +0000 Subject: [PATCH 1/2] feat: Add Web Share API support via Page.share() and Page.isShareSupported() Detect navigator.share support during browser details collection and expose it through ExtendedClientDetails.isWebShareSupported(). Add Page.isShareSupported() for checking support and Page.share() for invoking the native share dialog. The share() method throws UnsupportedOperationException if the browser lacks support. --- flow-client/src/main/frontend/Flow.ts | 3 ++ .../flow/component/internal/UIInternals.java | 2 +- .../component/page/ExtendedClientDetails.java | 22 ++++++++- .../com/vaadin/flow/component/page/Page.java | 39 ++++++++++++++++ .../vaadin/flow/server/BootstrapHandler.js | 5 ++- .../page/ExtendedClientDetailsTest.java | 5 ++- .../vaadin/flow/component/page/PageTest.java | 45 ++++++++++++++++++- 7 files changed, 114 insertions(+), 7 deletions(-) diff --git a/flow-client/src/main/frontend/Flow.ts b/flow-client/src/main/frontend/Flow.ts index 61cb614b28a..806d2e91531 100644 --- a/flow-client/src/main/frontend/Flow.ts +++ b/flow-client/src/main/frontend/Flow.ts @@ -563,6 +563,9 @@ export class Flow { params['v-ga'] = await geolocation.queryAvailability(); } + /* Web Share API support */ + params['v-ns'] = !!navigator.share; + /* Stringify each value (they are parsed on the server side) */ const stringParams: Record = {}; Object.keys(params).forEach((key) => { diff --git a/flow-server/src/main/java/com/vaadin/flow/component/internal/UIInternals.java b/flow-server/src/main/java/com/vaadin/flow/component/internal/UIInternals.java index b3265c3df7f..1c47c99d7ed 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/internal/UIInternals.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/internal/UIInternals.java @@ -1441,7 +1441,7 @@ public ExtendedClientDetails getExtendedClientDetails() { // Create placeholder with default values extendedClientDetails = new ExtendedClientDetails(ui, null, null, null, null, null, null, null, null, null, null, null, null, - null, null, null, null, null, null); + null, null, null, null, null, null, null); } return extendedClientDetails; } diff --git a/flow-server/src/main/java/com/vaadin/flow/component/page/ExtendedClientDetails.java b/flow-server/src/main/java/com/vaadin/flow/component/page/ExtendedClientDetails.java index 81a5ca253b0..e23e2f8c710 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/page/ExtendedClientDetails.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/page/ExtendedClientDetails.java @@ -62,6 +62,7 @@ public class ExtendedClientDetails implements Serializable { private String navigatorPlatform; private ColorScheme.Value colorScheme = ColorScheme.Value.NORMAL; private String themeName; + private boolean webShareSupported; /** * For internal use only. Updates all properties in the class according to @@ -106,6 +107,8 @@ public class ExtendedClientDetails implements Serializable { * the current color scheme * @param themeName * the theme name (e.g., "lumo", "aura") + * @param webShareSupported + * whether the browser supports the Web Share API */ public ExtendedClientDetails(UI ui, String screenWidth, String screenHeight, String windowInnerWidth, String windowInnerHeight, @@ -113,7 +116,8 @@ public ExtendedClientDetails(UI ui, String screenWidth, String screenHeight, String rawTzOffset, String dstShift, String dstInEffect, String tzId, String curDate, String touchDevice, String devicePixelRatio, String windowName, - String navigatorPlatform, String colorScheme, String themeName) { + String navigatorPlatform, String colorScheme, String themeName, + String webShareSupported) { this.ui = ui; if (screenWidth != null) { try { @@ -192,6 +196,9 @@ public ExtendedClientDetails(UI ui, String screenWidth, String screenHeight, this.navigatorPlatform = navigatorPlatform; setColorScheme(ColorScheme.Value.fromString(colorScheme)); this.themeName = themeName; + if (webShareSupported != null) { + this.webShareSupported = Boolean.parseBoolean(webShareSupported); + } } /** @@ -431,6 +438,16 @@ public String getThemeName() { return themeName; } + /** + * Returns whether the browser supports the Web Share API + * ({@code navigator.share}). + * + * @return {@code true} if the browser supports the Web Share API + */ + public boolean isWebShareSupported() { + return webShareSupported; + } + /** * Updates the color scheme. For internal use only. * @@ -496,7 +513,8 @@ public static ExtendedClientDetails updateFromJson(UI ui, JsonNode json) { getStringElseNull.apply("v-wn"), getStringElseNull.apply("v-np"), getStringElseNull.apply("v-cs"), - getStringElseNull.apply("v-tn")); + getStringElseNull.apply("v-tn"), + getStringElseNull.apply("v-ns")); ui.getInternals().setExtendedClientDetails(details); ui.getPage().setPageVisibility(getStringElseNull.apply("v-pv")); String ga = getStringElseNull.apply("v-ga"); diff --git a/flow-server/src/main/java/com/vaadin/flow/component/page/Page.java b/flow-server/src/main/java/com/vaadin/flow/component/page/Page.java index c9b6ac5c482..9a2887b16ad 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/page/Page.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/page/Page.java @@ -748,6 +748,45 @@ public void fetchPageDirection(SerializableConsumer callback) { }); } + /** + * Returns whether the browser supports the + * + * Web Share API ({@code navigator.share}). + * + * @return {@code true} if the browser supports the Web Share API + */ + public boolean isShareSupported() { + return getExtendedClientDetails().isWebShareSupported(); + } + + /** + * Invokes the browser's native share dialog using the Web + * Share API. + * + * @param title + * the title to share + * @param text + * the text to share + * @param url + * the URL to share + * @return a pending result that resolves when the share completes or + * rejects if the user cancels or sharing fails + * @throws UnsupportedOperationException + * if the browser does not support the Web Share API + */ + public PendingJavaScriptResult share(String title, String text, + String url) { + if (!isShareSupported()) { + throw new UnsupportedOperationException( + "The browser does not support the Web Share API. " + + "Check isShareSupported() before calling share()."); + } + return executeJs( + "return navigator.share({title: $0, text: $1, url: $2})", title, + text, url); + } + private Direction getDirectionByClientName(String directionClientName) { return Arrays.stream(Direction.values()) .filter(direction -> direction.getClientName() diff --git a/flow-server/src/main/resources/com/vaadin/flow/server/BootstrapHandler.js b/flow-server/src/main/resources/com/vaadin/flow/server/BootstrapHandler.js index e45a36b9250..c3370380378 100644 --- a/flow-server/src/main/resources/com/vaadin/flow/server/BootstrapHandler.js +++ b/flow-server/src/main/resources/com/vaadin/flow/server/BootstrapHandler.js @@ -268,7 +268,10 @@ Please submit an issue to https://github.com/vaadin/flow-components/issues/new/c if(navigator.platform) { params['v-np'] = navigator.platform; } - + + /* Web Share API support */ + params['v-ns'] = !!navigator.share; + /* Stringify each value (they are parsed on the server side) */ Object.keys(params).forEach(function(key) { var value = params[key]; diff --git a/flow-server/src/test/java/com/vaadin/flow/component/page/ExtendedClientDetailsTest.java b/flow-server/src/test/java/com/vaadin/flow/component/page/ExtendedClientDetailsTest.java index 11e6d5d9782..9a2274c7e49 100644 --- a/flow-server/src/test/java/com/vaadin/flow/component/page/ExtendedClientDetailsTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/component/page/ExtendedClientDetailsTest.java @@ -56,6 +56,7 @@ void initializeWithClientValues_gettersReturnExpectedValues() { assertFalse(details.isIPad()); assertEquals(ColorScheme.Value.LIGHT, details.getColorScheme()); assertEquals("aura", details.getThemeName()); + assertTrue(details.isWebShareSupported()); // Don't test getCurrentDate() and time delta due to the dependency on // server-side time @@ -167,6 +168,7 @@ private class ExtendBuilder { private String navigatorPlatform = "Linux i686"; private String colorScheme = "light"; private String themeName = "aura"; + private String webShareSupported = "true"; public ExtendedClientDetails buildDetails() { return new ExtendedClientDetails(null, screenWidth, screenHeight, @@ -174,7 +176,8 @@ public ExtendedClientDetails buildDetails() { bodyClientHeight, timezoneOffset, rawTimezoneOffset, dstSavings, dstInEffect, timeZoneId, clientServerTimeDelta, touchDevice, devicePixelRatio, windowName, - navigatorPlatform, colorScheme, themeName); + navigatorPlatform, colorScheme, themeName, + webShareSupported); } public ExtendBuilder setScreenWidth(String screenWidth) { diff --git a/flow-server/src/test/java/com/vaadin/flow/component/page/PageTest.java b/flow-server/src/test/java/com/vaadin/flow/component/page/PageTest.java index 747a146e97c..ad6cacb5240 100644 --- a/flow-server/src/test/java/com/vaadin/flow/component/page/PageTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/component/page/PageTest.java @@ -464,20 +464,61 @@ void getColorScheme_returnsCachedValue() { // Set up ExtendedClientDetails with color scheme ExtendedClientDetails details = new ExtendedClientDetails(mockUI, null, null, null, null, null, null, null, null, null, null, null, - null, null, null, null, null, "dark", null); + null, null, null, null, null, "dark", null, null); mockUI.getInternals().setExtendedClientDetails(details); Page page = new Page(mockUI); assertEquals(ColorScheme.Value.DARK, page.getColorScheme()); } + @Test + void share_passesCorrectJsAndParameters() { + MockUI mockUI = new MockUI(); + ExtendedClientDetails details = new ExtendedClientDetails(mockUI, null, + null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, "true"); + mockUI.getInternals().setExtendedClientDetails(details); + + TestPage sharePage = new TestPage(mockUI); + sharePage.share("My Title", "Some text", "https://example.com"); + + assertEquals("return navigator.share({title: $0, text: $1, url: $2})", + sharePage.expression); + assertEquals("My Title", sharePage.firstParam); + } + + @Test + void share_throwsWhenNotSupported() { + MockUI mockUI = new MockUI(); + ExtendedClientDetails details = new ExtendedClientDetails(mockUI, null, + null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, "false"); + mockUI.getInternals().setExtendedClientDetails(details); + + Page sharePage = new Page(mockUI); + assertThrows(UnsupportedOperationException.class, + () -> sharePage.share("title", "text", "url")); + } + + @Test + void isShareSupported_delegatesToExtendedClientDetails() { + MockUI mockUI = new MockUI(); + ExtendedClientDetails details = new ExtendedClientDetails(mockUI, null, + null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, "true"); + mockUI.getInternals().setExtendedClientDetails(details); + + Page page = new Page(mockUI); + assertTrue(page.isShareSupported()); + } + @Test void setColorScheme_updatesGetColorScheme() { MockUI mockUI = new MockUI(); // Set up ExtendedClientDetails ExtendedClientDetails details = new ExtendedClientDetails(mockUI, null, null, null, null, null, null, null, null, null, null, null, - null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null); mockUI.getInternals().setExtendedClientDetails(details); Page page = new Page(mockUI) { From cc9dc4ac5269b1ecc51a9cc139df484a1694fbf6 Mon Sep 17 00:00:00 2001 From: Artur Signell Date: Wed, 13 May 2026 06:29:12 +0000 Subject: [PATCH 2/2] refactor: Reshape Web Share API to match Flow signal/facade patterns Move the support state to a UIInternals ValueSignal seeded from the bootstrap handshake, expose Page.shareSupportSignal() in place of isShareSupported(), and route Page.share() through a new WebShare.ts module installed under window.Vaadin.Flow.share. Drops the duplicate v-ns capture from BootstrapHandler.js so the parameter is collected only in Flow.ts, matching v-pv and v-ga. --- flow-client/src/main/frontend/Flow.ts | 3 +- flow-client/src/main/frontend/WebShare.ts | 45 +++++++++++++ .../flow/component/internal/UIInternals.java | 28 +++++++- .../component/page/ExtendedClientDetails.java | 32 +++------- .../com/vaadin/flow/component/page/Page.java | 63 ++++++++++++++---- .../flow/component/page/WebShareSupport.java | 64 +++++++++++++++++++ .../vaadin/flow/server/BootstrapHandler.js | 3 - .../page/ExtendedClientDetailsTest.java | 5 +- .../vaadin/flow/component/page/PageTest.java | 35 ++++------ 9 files changed, 213 insertions(+), 65 deletions(-) create mode 100644 flow-client/src/main/frontend/WebShare.ts create mode 100644 flow-server/src/main/java/com/vaadin/flow/component/page/WebShareSupport.java diff --git a/flow-client/src/main/frontend/Flow.ts b/flow-client/src/main/frontend/Flow.ts index 806d2e91531..a47f114d710 100644 --- a/flow-client/src/main/frontend/Flow.ts +++ b/flow-client/src/main/frontend/Flow.ts @@ -6,6 +6,7 @@ import { } from '@vaadin/common-frontend'; import './Geolocation'; import { currentVisibility } from './PageVisibility'; +import { isShareSupported } from './WebShare'; export interface FlowConfig { imports?: () => Promise; @@ -564,7 +565,7 @@ export class Flow { } /* Web Share API support */ - params['v-ns'] = !!navigator.share; + params['v-ws'] = isShareSupported(); /* Stringify each value (they are parsed on the server side) */ const stringParams: Record = {}; diff --git a/flow-client/src/main/frontend/WebShare.ts b/flow-client/src/main/frontend/WebShare.ts new file mode 100644 index 00000000000..04bd62883f4 --- /dev/null +++ b/flow-client/src/main/frontend/WebShare.ts @@ -0,0 +1,45 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ + +/** + * Returns whether the current browser exposes the Web Share API + * (`navigator.share`). Used by the bootstrap path to seed the server-side + * support signal without waiting for a DOM event. + */ +export function isShareSupported(): boolean { + return typeof navigator.share === 'function'; +} + +const $wnd = window as any; +$wnd.Vaadin ??= {}; +$wnd.Vaadin.Flow ??= {}; +$wnd.Vaadin.Flow.share = { + isSupported: isShareSupported, + + share(title: string | null, text: string | null, url: string | null): Promise { + const data: ShareData = {}; + if (title !== null) { + data.title = title; + } + if (text !== null) { + data.text = text; + } + if (url !== null) { + data.url = url; + } + return navigator.share(data); + } +}; diff --git a/flow-server/src/main/java/com/vaadin/flow/component/internal/UIInternals.java b/flow-server/src/main/java/com/vaadin/flow/component/internal/UIInternals.java index 1c47c99d7ed..42b677290a0 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/internal/UIInternals.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/internal/UIInternals.java @@ -52,6 +52,7 @@ import com.vaadin.flow.component.internal.ComponentMetaData.DependencyInfo; import com.vaadin.flow.component.page.ExtendedClientDetails; import com.vaadin.flow.component.page.Page; +import com.vaadin.flow.component.page.WebShareSupport; import com.vaadin.flow.di.Instantiator; import com.vaadin.flow.dom.Element; import com.vaadin.flow.dom.ElementUtil; @@ -247,6 +248,9 @@ public List getParameters() { private final ValueSignal geolocationAvailabilitySignal = new ValueSignal<>( GeolocationAvailability.UNKNOWN); + private final ValueSignal webShareSupportSignal = new ValueSignal<>( + WebShareSupport.UNKNOWN); + private GeolocationClient geolocationClient; private Registration geolocationClientAvailabilityRegistration; @@ -1441,7 +1445,7 @@ public ExtendedClientDetails getExtendedClientDetails() { // Create placeholder with default values extendedClientDetails = new ExtendedClientDetails(ui, null, null, null, null, null, null, null, null, null, null, null, null, - null, null, null, null, null, null, null); + null, null, null, null, null, null); } return extendedClientDetails; } @@ -1480,6 +1484,28 @@ public void setGeolocationAvailability( this.geolocationAvailabilitySignal.set(availability); } + /** + * Returns the reactive signal holding the Web Share API support state for + * this UI. Starts as {@link WebShareSupport#UNKNOWN} before the first + * client bootstrap report, then transitions to the value the browser + * reports. Application code reads it via {@link Page#shareSupportSignal()}. + * + * @return the support signal + */ + public ValueSignal getWebShareSupportSignal() { + return webShareSupportSignal; + } + + /** + * Updates the Web Share support signal. For framework use only. + * + * @param support + * the new support state + */ + public void setWebShareSupport(WebShareSupport support) { + this.webShareSupportSignal.set(support); + } + /** * Returns the geolocation client currently bound to this UI, or * {@code null} if none has been installed yet. Framework-internal: diff --git a/flow-server/src/main/java/com/vaadin/flow/component/page/ExtendedClientDetails.java b/flow-server/src/main/java/com/vaadin/flow/component/page/ExtendedClientDetails.java index e23e2f8c710..736b32c1962 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/page/ExtendedClientDetails.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/page/ExtendedClientDetails.java @@ -62,7 +62,6 @@ public class ExtendedClientDetails implements Serializable { private String navigatorPlatform; private ColorScheme.Value colorScheme = ColorScheme.Value.NORMAL; private String themeName; - private boolean webShareSupported; /** * For internal use only. Updates all properties in the class according to @@ -107,8 +106,6 @@ public class ExtendedClientDetails implements Serializable { * the current color scheme * @param themeName * the theme name (e.g., "lumo", "aura") - * @param webShareSupported - * whether the browser supports the Web Share API */ public ExtendedClientDetails(UI ui, String screenWidth, String screenHeight, String windowInnerWidth, String windowInnerHeight, @@ -116,8 +113,7 @@ public ExtendedClientDetails(UI ui, String screenWidth, String screenHeight, String rawTzOffset, String dstShift, String dstInEffect, String tzId, String curDate, String touchDevice, String devicePixelRatio, String windowName, - String navigatorPlatform, String colorScheme, String themeName, - String webShareSupported) { + String navigatorPlatform, String colorScheme, String themeName) { this.ui = ui; if (screenWidth != null) { try { @@ -196,9 +192,6 @@ public ExtendedClientDetails(UI ui, String screenWidth, String screenHeight, this.navigatorPlatform = navigatorPlatform; setColorScheme(ColorScheme.Value.fromString(colorScheme)); this.themeName = themeName; - if (webShareSupported != null) { - this.webShareSupported = Boolean.parseBoolean(webShareSupported); - } } /** @@ -438,16 +431,6 @@ public String getThemeName() { return themeName; } - /** - * Returns whether the browser supports the Web Share API - * ({@code navigator.share}). - * - * @return {@code true} if the browser supports the Web Share API - */ - public boolean isWebShareSupported() { - return webShareSupported; - } - /** * Updates the color scheme. For internal use only. * @@ -462,8 +445,8 @@ void setColorScheme(ColorScheme.Value colorScheme) { /** * Parses browser details from the given JSON and updates the UI from them: * stores the resulting {@link ExtendedClientDetails} on the UI's internals - * and seeds the page-visibility and geolocation-availability signals from - * the same payload. + * and seeds the page-visibility, geolocation-availability and + * web-share-support signals from the same payload. *

* For internal use only. * @@ -513,8 +496,7 @@ public static ExtendedClientDetails updateFromJson(UI ui, JsonNode json) { getStringElseNull.apply("v-wn"), getStringElseNull.apply("v-np"), getStringElseNull.apply("v-cs"), - getStringElseNull.apply("v-tn"), - getStringElseNull.apply("v-ns")); + getStringElseNull.apply("v-tn")); ui.getInternals().setExtendedClientDetails(details); ui.getPage().setPageVisibility(getStringElseNull.apply("v-pv")); String ga = getStringElseNull.apply("v-ga"); @@ -526,6 +508,12 @@ public static ExtendedClientDetails updateFromJson(UI ui, JsonNode json) { // unknown value; leave the current availability alone } } + String ws = getStringElseNull.apply("v-ws"); + if (ws != null) { + ui.getInternals().setWebShareSupport( + Boolean.parseBoolean(ws) ? WebShareSupport.SUPPORTED + : WebShareSupport.UNSUPPORTED); + } return details; } diff --git a/flow-server/src/main/java/com/vaadin/flow/component/page/Page.java b/flow-server/src/main/java/com/vaadin/flow/component/page/Page.java index 9a2887b16ad..7e3e7212d1e 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/page/Page.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/page/Page.java @@ -66,6 +66,8 @@ public class Page implements Serializable { PageVisibility.UNKNOWN); private final Signal pageVisibilityReadOnly = pageVisibilitySignal .asReadonly(); + private final ValueSignal webShareSupportSignal; + private final Signal webShareSupportReadOnly; /** * Creates a page instance for the given UI. @@ -80,6 +82,8 @@ public Page(UI ui) { .addEventListener("vaadin-page-visibility-change", e -> setPageVisibility(e.getEventDetail(String.class))) .addEventDetail().debounce(100).allowInert(); + webShareSupportSignal = ui.getInternals().getWebShareSupportSignal(); + webShareSupportReadOnly = webShareSupportSignal.asReadonly(); } /** @@ -749,27 +753,57 @@ public void fetchPageDirection(SerializableConsumer callback) { } /** - * Returns whether the browser supports the + * Returns a read-only signal that tracks whether the browser exposes the * * Web Share API ({@code navigator.share}). + *

+ * The signal value is {@link WebShareSupport#SUPPORTED} when the browser + * exposes {@code navigator.share} (mobile Chromium, mobile Safari, modern + * Edge, recent desktop Safari) and {@link WebShareSupport#UNSUPPORTED} + * otherwise (most desktop Firefox builds, older browsers). The initial + * value is {@link WebShareSupport#UNKNOWN}; it is replaced with a real + * value before any user code observes the signal. + *

+ * The signal value is seeded from the initial client bootstrap, so user + * code always sees a real value. Subscribe with + * {@code Signal.effect(owner, ...)} to react to changes; call + * {@code shareSupportSignal().peek()} for a snapshot outside a reactive + * context, and {@code .get()} inside one. + *

+ * Web Share support is established at page load and does not change during + * the session, so the signal effectively transitions {@code UNKNOWN} → + * {@code SUPPORTED}/{@code UNSUPPORTED} once and then remains stable. * - * @return {@code true} if the browser supports the Web Share API + * @return the read-only Web Share support signal */ - public boolean isShareSupported() { - return getExtendedClientDetails().isWebShareSupported(); + public Signal shareSupportSignal() { + return webShareSupportReadOnly; } /** * Invokes the browser's native share dialog using the Web - * Share API. + * "https://developer.mozilla.org/en-US/docs/Web/API/Navigator/share">Web + * Share API. The dialog lets the user pick a target application + * (messaging, mail, social, AirDrop, …) to receive the supplied content. + *

+ * The call is asynchronous: this method returns immediately and the browser + * shows the share sheet on the next event-loop turn. The returned + * {@link PendingJavaScriptResult} resolves when the share completes and + * rejects when the user cancels the sheet or the browser fails to deliver + * the content; rejections are logged at debug level by Flow itself, so + * callers do not need to attach an error handler unless they want to react + * in the UI. + *

+ * Check {@link #shareSupportSignal()} before exposing a "share" control — + * calling this method when the value is {@link WebShareSupport#UNSUPPORTED} + * throws {@link UnsupportedOperationException}. * * @param title - * the title to share + * the title to share, may be {@code null} * @param text - * the text to share + * the text to share, may be {@code null} * @param url - * the URL to share + * the URL to share, may be {@code null} * @return a pending result that resolves when the share completes or * rejects if the user cancels or sharing fails * @throws UnsupportedOperationException @@ -777,14 +811,17 @@ public boolean isShareSupported() { */ public PendingJavaScriptResult share(String title, String text, String url) { - if (!isShareSupported()) { + if (webShareSupportSignal.peek() == WebShareSupport.UNSUPPORTED) { throw new UnsupportedOperationException( "The browser does not support the Web Share API. " - + "Check isShareSupported() before calling share()."); + + "Check shareSupportSignal() before calling share()."); } - return executeJs( - "return navigator.share({title: $0, text: $1, url: $2})", title, + PendingJavaScriptResult result = executeJs( + "return window.Vaadin.Flow.share.share($0, $1, $2)", title, text, url); + result.then(ok -> { + }, err -> LOGGER.debug("Web share failed: {}", err)); + return result; } private Direction getDirectionByClientName(String directionClientName) { diff --git a/flow-server/src/main/java/com/vaadin/flow/component/page/WebShareSupport.java b/flow-server/src/main/java/com/vaadin/flow/component/page/WebShareSupport.java new file mode 100644 index 00000000000..d0b0d1d3d39 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/page/WebShareSupport.java @@ -0,0 +1,64 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.vaadin.flow.component.page; + +/** + * Whether the browser exposes the Web Share API ({@code navigator.share}). + *

+ * Held by {@link Page#shareSupportSignal()}. Reading the value does not show + * any browser dialog — it reports whether a subsequent + * {@link Page#share(String, String, String)} call would succeed in invoking the + * native share sheet, or whether it would fail because the API is missing in + * the current browser context. + *

+ * Typical usage: + *

    + *
  • {@link #SUPPORTED} — show a "Share" button that triggers + * {@link Page#share(String, String, String)}.
  • + *
  • {@link #UNSUPPORTED} — fall back to a copy-link or social-network + * affordance; the native sheet is unavailable in this browser.
  • + *
  • {@link #UNKNOWN} — only seen in the brief window before the bootstrap + * handshake completes; treat the same as {@link #UNSUPPORTED} until a real + * value arrives.
  • + *
+ */ +public enum WebShareSupport { + + /** + * No value has been reported by the browser yet. Used only as the initial + * value of the signal before the first client handshake delivers the real + * one. In normal request handling the signal is seeded before any user code + * (UI initialization, {@code UIInitListener}, component attach) runs, so + * this value is essentially never observed in practice; once a real value + * has arrived, the signal never returns to {@code UNKNOWN}. + */ + UNKNOWN, + + /** + * The browser exposes {@code navigator.share}; calls to + * {@link Page#share(String, String, String)} will invoke the native share + * sheet. + */ + SUPPORTED, + + /** + * The browser does not expose {@code navigator.share}; calls to + * {@link Page#share(String, String, String)} will throw + * {@link UnsupportedOperationException}. Some desktop browsers, in + * particular older Firefox versions, fall in this bucket. + */ + UNSUPPORTED +} diff --git a/flow-server/src/main/resources/com/vaadin/flow/server/BootstrapHandler.js b/flow-server/src/main/resources/com/vaadin/flow/server/BootstrapHandler.js index c3370380378..7e6d45954de 100644 --- a/flow-server/src/main/resources/com/vaadin/flow/server/BootstrapHandler.js +++ b/flow-server/src/main/resources/com/vaadin/flow/server/BootstrapHandler.js @@ -269,9 +269,6 @@ Please submit an issue to https://github.com/vaadin/flow-components/issues/new/c params['v-np'] = navigator.platform; } - /* Web Share API support */ - params['v-ns'] = !!navigator.share; - /* Stringify each value (they are parsed on the server side) */ Object.keys(params).forEach(function(key) { var value = params[key]; diff --git a/flow-server/src/test/java/com/vaadin/flow/component/page/ExtendedClientDetailsTest.java b/flow-server/src/test/java/com/vaadin/flow/component/page/ExtendedClientDetailsTest.java index 9a2274c7e49..11e6d5d9782 100644 --- a/flow-server/src/test/java/com/vaadin/flow/component/page/ExtendedClientDetailsTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/component/page/ExtendedClientDetailsTest.java @@ -56,7 +56,6 @@ void initializeWithClientValues_gettersReturnExpectedValues() { assertFalse(details.isIPad()); assertEquals(ColorScheme.Value.LIGHT, details.getColorScheme()); assertEquals("aura", details.getThemeName()); - assertTrue(details.isWebShareSupported()); // Don't test getCurrentDate() and time delta due to the dependency on // server-side time @@ -168,7 +167,6 @@ private class ExtendBuilder { private String navigatorPlatform = "Linux i686"; private String colorScheme = "light"; private String themeName = "aura"; - private String webShareSupported = "true"; public ExtendedClientDetails buildDetails() { return new ExtendedClientDetails(null, screenWidth, screenHeight, @@ -176,8 +174,7 @@ public ExtendedClientDetails buildDetails() { bodyClientHeight, timezoneOffset, rawTimezoneOffset, dstSavings, dstInEffect, timeZoneId, clientServerTimeDelta, touchDevice, devicePixelRatio, windowName, - navigatorPlatform, colorScheme, themeName, - webShareSupported); + navigatorPlatform, colorScheme, themeName); } public ExtendBuilder setScreenWidth(String screenWidth) { diff --git a/flow-server/src/test/java/com/vaadin/flow/component/page/PageTest.java b/flow-server/src/test/java/com/vaadin/flow/component/page/PageTest.java index ad6cacb5240..4776ae0a083 100644 --- a/flow-server/src/test/java/com/vaadin/flow/component/page/PageTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/component/page/PageTest.java @@ -71,7 +71,7 @@ public PendingJavaScriptResult executeJs(String expression, this.expression = expression; firstParam = parameters[0]; count++; - return null; + return Mockito.mock(PendingJavaScriptResult.class); } } @@ -464,7 +464,7 @@ void getColorScheme_returnsCachedValue() { // Set up ExtendedClientDetails with color scheme ExtendedClientDetails details = new ExtendedClientDetails(mockUI, null, null, null, null, null, null, null, null, null, null, null, - null, null, null, null, null, "dark", null, null); + null, null, null, null, null, "dark", null); mockUI.getInternals().setExtendedClientDetails(details); Page page = new Page(mockUI); @@ -472,28 +472,22 @@ void getColorScheme_returnsCachedValue() { } @Test - void share_passesCorrectJsAndParameters() { + void share_routesThroughClientShareModule() { MockUI mockUI = new MockUI(); - ExtendedClientDetails details = new ExtendedClientDetails(mockUI, null, - null, null, null, null, null, null, null, null, null, null, - null, null, null, null, null, null, null, "true"); - mockUI.getInternals().setExtendedClientDetails(details); + mockUI.getInternals().setWebShareSupport(WebShareSupport.SUPPORTED); TestPage sharePage = new TestPage(mockUI); sharePage.share("My Title", "Some text", "https://example.com"); - assertEquals("return navigator.share({title: $0, text: $1, url: $2})", + assertEquals("return window.Vaadin.Flow.share.share($0, $1, $2)", sharePage.expression); assertEquals("My Title", sharePage.firstParam); } @Test - void share_throwsWhenNotSupported() { + void share_throwsWhenSignalReportsUnsupported() { MockUI mockUI = new MockUI(); - ExtendedClientDetails details = new ExtendedClientDetails(mockUI, null, - null, null, null, null, null, null, null, null, null, null, - null, null, null, null, null, null, null, "false"); - mockUI.getInternals().setExtendedClientDetails(details); + mockUI.getInternals().setWebShareSupport(WebShareSupport.UNSUPPORTED); Page sharePage = new Page(mockUI); assertThrows(UnsupportedOperationException.class, @@ -501,15 +495,14 @@ void share_throwsWhenNotSupported() { } @Test - void isShareSupported_delegatesToExtendedClientDetails() { + void shareSupportSignal_reflectsBootstrapValue() { MockUI mockUI = new MockUI(); - ExtendedClientDetails details = new ExtendedClientDetails(mockUI, null, - null, null, null, null, null, null, null, null, null, null, - null, null, null, null, null, null, null, "true"); - mockUI.getInternals().setExtendedClientDetails(details); - Page page = new Page(mockUI); - assertTrue(page.isShareSupported()); + assertEquals(WebShareSupport.UNKNOWN, page.shareSupportSignal().peek()); + + mockUI.getInternals().setWebShareSupport(WebShareSupport.SUPPORTED); + assertEquals(WebShareSupport.SUPPORTED, + page.shareSupportSignal().peek()); } @Test @@ -518,7 +511,7 @@ void setColorScheme_updatesGetColorScheme() { // Set up ExtendedClientDetails ExtendedClientDetails details = new ExtendedClientDetails(mockUI, null, null, null, null, null, null, null, null, null, null, null, - null, null, null, null, null, null, null, null); + null, null, null, null, null, null, null); mockUI.getInternals().setExtendedClientDetails(details); Page page = new Page(mockUI) {