diff --git a/flow-server/src/main/java/com/vaadin/flow/component/UI.java b/flow-server/src/main/java/com/vaadin/flow/component/UI.java index 3943a2a352a..afe8e93e4fb 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/UI.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/UI.java @@ -83,6 +83,8 @@ import com.vaadin.flow.server.auth.AnonymousAllowed; import com.vaadin.flow.server.communication.PushConnection; import com.vaadin.flow.shared.Registration; +import com.vaadin.signals.WritableSignal; +import com.vaadin.signals.local.ValueSignal; /** * The topmost component in any component hierarchy. There is one UI for every @@ -125,7 +127,8 @@ public class UI extends Component private PushConfiguration pushConfiguration; - private Locale locale = Locale.getDefault(); + private final ValueSignal localeSignal = new ValueSignal<>( + Locale.getDefault()); private final UIInternals internals; @@ -806,7 +809,26 @@ Logger getLogger() { */ @Override public Locale getLocale() { - return locale; + return localeSignal.value(); + } + + /** + * Gets a signal that holds the current locale of this UI. + *

+ * The signal is the source of truth for the locale. Reading the signal is + * equivalent to calling {@link #getLocale()}. + *

+ * Note that writing directly to the signal will not notify + * {@link com.vaadin.flow.i18n.LocaleChangeObserver LocaleChangeObserver} + * instances. Use {@link #setLocale(Locale)} if you need observers to be + * notified. + * + * @return a writable signal holding the current locale, never null + * @see #setLocale(Locale) + * @see #getLocale() + */ + public WritableSignal localeSignal() { + return localeSignal; } /** @@ -821,8 +843,8 @@ public Locale getLocale() { */ public void setLocale(Locale locale) { assert locale != null : "Null locale is not supported!"; - if (!this.locale.equals(locale)) { - this.locale = locale; + if (!getLocale().equals(locale)) { + localeSignal.value(locale); EventUtil.informLocaleChangeObservers(this); } } diff --git a/flow-server/src/main/java/com/vaadin/flow/server/VaadinSession.java b/flow-server/src/main/java/com/vaadin/flow/server/VaadinSession.java index d60931e328a..fa5d1153602 100644 --- a/flow-server/src/main/java/com/vaadin/flow/server/VaadinSession.java +++ b/flow-server/src/main/java/com/vaadin/flow/server/VaadinSession.java @@ -54,6 +54,8 @@ import com.vaadin.flow.server.startup.ApplicationConfiguration; import com.vaadin.flow.shared.Registration; import com.vaadin.flow.shared.communication.PushMode; +import com.vaadin.signals.WritableSignal; +import com.vaadin.signals.shared.SharedValueSignal; /** * Contains everything that Vaadin needs to store for a specific user. This is @@ -83,10 +85,14 @@ public class VaadinSession implements HttpSessionBindingListener, Serializable { final List destroyListeners = new CopyOnWriteArrayList<>(); /** - * Default locale of the session. + * Locale value used for serialization. The signal is the runtime source of + * truth; this field is only used to persist the value across serialization. */ private Locale locale = Locale.getDefault(); + private transient SharedValueSignal localeSignal = new SharedValueSignal<>( + locale); + /** * Session wide error handler which is used by default if an error is left * unhandled. @@ -383,7 +389,26 @@ public DeploymentConfiguration getConfiguration() { */ public Locale getLocale() { checkHasLock(); - return locale; + return localeSignal.value(); + } + + /** + * Gets a signal that holds the current locale of this session. + *

+ * The signal is the source of truth for the locale. Reading the signal is + * equivalent to calling {@link #getLocale()}. + *

+ * Note that writing directly to the signal will not propagate the locale + * change to UIs in this session. Use {@link #setLocale(Locale)} if you need + * the locale to be set on all UIs. + * + * @return a writable signal holding the current locale, never null + * @see #setLocale(Locale) + * @see #getLocale() + */ + public WritableSignal localeSignal() { + checkHasLock(); + return localeSignal; } /** @@ -399,7 +424,7 @@ public void setLocale(Locale locale) { assert locale != null : "Null locale is not supported!"; checkHasLock(); - this.locale = locale; + localeSignal.value(locale); getUIs().forEach(ui -> { Map, CurrentInstance> oldInstances = CurrentInstance @@ -1093,6 +1118,7 @@ private void readObject(ObjectInputStream stream) uIs = (Map) stream.readObject(); resourceRegistry = (StreamResourceRegistry) stream.readObject(); pendingAccessQueue = new ConcurrentLinkedQueue<>(); + localeSignal = new SharedValueSignal<>(locale); } finally { CurrentInstance.clearAll(); CurrentInstance.restoreInstances(old); @@ -1118,6 +1144,8 @@ private void writeObject(java.io.ObjectOutputStream stream) } } + // Sync locale field from signal for serialization + locale = localeSignal.value(); stream.defaultWriteObject(); if (serializeUIs) { stream.writeObject(uIs); diff --git a/flow-server/src/test/java/com/vaadin/flow/component/UILocaleSignalTest.java b/flow-server/src/test/java/com/vaadin/flow/component/UILocaleSignalTest.java new file mode 100644 index 00000000000..03ec2209f90 --- /dev/null +++ b/flow-server/src/test/java/com/vaadin/flow/component/UILocaleSignalTest.java @@ -0,0 +1,126 @@ +/* + * 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; + +import java.util.Locale; + +import org.junit.Test; + +import com.vaadin.flow.dom.SignalsUnitTest; +import com.vaadin.signals.WritableSignal; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; + +/** + * Unit tests for {@link UI#localeSignal()}. + */ +public class UILocaleSignalTest extends SignalsUnitTest { + + @Test + public void localeSignal_initialValue_matchesGetLocale() { + UI ui = UI.getCurrent(); + WritableSignal signal = ui.localeSignal(); + + assertNotNull("localeSignal() should never return null", signal); + assertEquals("Signal value should match getLocale()", ui.getLocale(), + signal.value()); + } + + @Test + public void localeSignal_setLocale_signalUpdated() { + UI ui = UI.getCurrent(); + WritableSignal signal = ui.localeSignal(); + + Locale initialLocale = ui.getLocale(); + Locale newLocale = Locale.FRENCH; + + // Ensure we're actually changing the locale + if (initialLocale.equals(newLocale)) { + newLocale = Locale.GERMAN; + } + + ui.setLocale(newLocale); + + assertEquals("Signal should reflect the new locale after setLocale()", + newLocale, signal.value()); + assertEquals("getLocale() should also return the new locale", newLocale, + ui.getLocale()); + } + + @Test + public void localeSignal_writeToSignal_updatesGetLocale() { + UI ui = UI.getCurrent(); + WritableSignal signal = ui.localeSignal(); + + Locale initialLocale = ui.getLocale(); + Locale newLocale = Locale.FRENCH; + + // Ensure we're actually changing the locale + if (initialLocale.equals(newLocale)) { + newLocale = Locale.GERMAN; + } + + signal.value(newLocale); + + assertEquals("getLocale() should reflect the new locale after " + + "writing to signal", newLocale, ui.getLocale()); + assertEquals("Signal should have the new value", newLocale, + signal.value()); + } + + @Test + public void localeSignal_sameInstance_returnedOnMultipleCalls() { + UI ui = UI.getCurrent(); + + WritableSignal signal1 = ui.localeSignal(); + WritableSignal signal2 = ui.localeSignal(); + + assertSame("localeSignal() should return the same instance on " + + "multiple calls", signal1, signal2); + } + + @Test + public void localeSignal_multipleLocaleChanges_signalFollows() { + UI ui = UI.getCurrent(); + WritableSignal signal = ui.localeSignal(); + + ui.setLocale(Locale.FRENCH); + assertEquals(Locale.FRENCH, signal.value()); + + ui.setLocale(Locale.GERMAN); + assertEquals(Locale.GERMAN, signal.value()); + + ui.setLocale(Locale.JAPANESE); + assertEquals(Locale.JAPANESE, signal.value()); + } + + @Test + public void localeSignal_multipleSignalWrites_getLocaleFollows() { + UI ui = UI.getCurrent(); + WritableSignal signal = ui.localeSignal(); + + signal.value(Locale.FRENCH); + assertEquals(Locale.FRENCH, ui.getLocale()); + + signal.value(Locale.GERMAN); + assertEquals(Locale.GERMAN, ui.getLocale()); + + signal.value(Locale.JAPANESE); + assertEquals(Locale.JAPANESE, ui.getLocale()); + } +} diff --git a/flow-server/src/test/java/com/vaadin/flow/server/VaadinSessionLocaleSignalTest.java b/flow-server/src/test/java/com/vaadin/flow/server/VaadinSessionLocaleSignalTest.java new file mode 100644 index 00000000000..06f357dcd34 --- /dev/null +++ b/flow-server/src/test/java/com/vaadin/flow/server/VaadinSessionLocaleSignalTest.java @@ -0,0 +1,131 @@ +/* + * 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.server; + +import java.util.Locale; + +import org.junit.Test; + +import com.vaadin.flow.component.UI; +import com.vaadin.flow.dom.SignalsUnitTest; +import com.vaadin.signals.WritableSignal; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; + +/** + * Unit tests for {@link VaadinSession#localeSignal()}. + */ +public class VaadinSessionLocaleSignalTest extends SignalsUnitTest { + + private VaadinSession getSession() { + return UI.getCurrent().getSession(); + } + + @Test + public void localeSignal_initialValue_matchesGetLocale() { + VaadinSession session = getSession(); + WritableSignal signal = session.localeSignal(); + + assertNotNull("localeSignal() should never return null", signal); + assertEquals("Signal value should match getLocale()", + session.getLocale(), signal.value()); + } + + @Test + public void localeSignal_setLocale_signalUpdated() { + VaadinSession session = getSession(); + WritableSignal signal = session.localeSignal(); + + Locale initialLocale = session.getLocale(); + Locale newLocale = Locale.FRENCH; + + // Ensure we're actually changing the locale + if (initialLocale.equals(newLocale)) { + newLocale = Locale.GERMAN; + } + + session.setLocale(newLocale); + + assertEquals("Signal should reflect the new locale after setLocale()", + newLocale, signal.value()); + assertEquals("getLocale() should also return the new locale", newLocale, + session.getLocale()); + } + + @Test + public void localeSignal_writeToSignal_updatesGetLocale() { + VaadinSession session = getSession(); + WritableSignal signal = session.localeSignal(); + + Locale initialLocale = session.getLocale(); + Locale newLocale = Locale.FRENCH; + + // Ensure we're actually changing the locale + if (initialLocale.equals(newLocale)) { + newLocale = Locale.GERMAN; + } + + signal.value(newLocale); + + assertEquals("getLocale() should reflect the new locale after " + + "writing to signal", newLocale, session.getLocale()); + assertEquals("Signal should have the new value", newLocale, + signal.value()); + } + + @Test + public void localeSignal_sameInstance_returnedOnMultipleCalls() { + VaadinSession session = getSession(); + + WritableSignal signal1 = session.localeSignal(); + WritableSignal signal2 = session.localeSignal(); + + assertSame("localeSignal() should return the same instance on " + + "multiple calls", signal1, signal2); + } + + @Test + public void localeSignal_multipleLocaleChanges_signalFollows() { + VaadinSession session = getSession(); + WritableSignal signal = session.localeSignal(); + + session.setLocale(Locale.FRENCH); + assertEquals(Locale.FRENCH, signal.value()); + + session.setLocale(Locale.GERMAN); + assertEquals(Locale.GERMAN, signal.value()); + + session.setLocale(Locale.JAPANESE); + assertEquals(Locale.JAPANESE, signal.value()); + } + + @Test + public void localeSignal_multipleSignalWrites_getLocaleFollows() { + VaadinSession session = getSession(); + WritableSignal signal = session.localeSignal(); + + signal.value(Locale.FRENCH); + assertEquals(Locale.FRENCH, session.getLocale()); + + signal.value(Locale.GERMAN); + assertEquals(Locale.GERMAN, session.getLocale()); + + signal.value(Locale.JAPANESE); + assertEquals(Locale.JAPANESE, session.getLocale()); + } +}