From 0718d6e4cbbba8f0bc096b3e6ca495cb1b2b40f5 Mon Sep 17 00:00:00 2001 From: Javier Godoy <11554739+javier-godoy@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:25:32 -0300 Subject: [PATCH 1/4] fix: fall back to an application-wide default dynamic theme Close #172 --- .../vaadin/addons/demo/DynamicTheme.java | 50 +++++++++++++++++-- .../addons/demo/DynamicThemeInitializer.java | 20 +++++--- 2 files changed, 59 insertions(+), 11 deletions(-) diff --git a/base/src/main/java/com/flowingcode/vaadin/addons/demo/DynamicTheme.java b/base/src/main/java/com/flowingcode/vaadin/addons/demo/DynamicTheme.java index 6319247..c66ac6e 100644 --- a/base/src/main/java/com/flowingcode/vaadin/addons/demo/DynamicTheme.java +++ b/base/src/main/java/com/flowingcode/vaadin/addons/demo/DynamicTheme.java @@ -90,12 +90,43 @@ private static void assertFeatureInitialized() { /** * Checks if the dynamic theme feature has been initialized for the current session. + *

+ * The feature is initialized if a theme has been set for the current session, or if a default + * theme has been registered for the application. + *

* * @return {@code true} if the feature is supported and initialized; {@code false} otherwise. */ public static boolean isFeatureInitialized() { return isFeatureSupported() - && VaadinSession.getCurrent().getAttribute(DynamicTheme.class) != null; + && (getSessionTheme() != null + || getDefault(VaadinService.getCurrent().getContext()) != null); + } + + private static DynamicTheme getSessionTheme() { + return VaadinSession.getCurrent().getAttribute(DynamicTheme.class); + } + + private static DynamicTheme getDefault(VaadinContext context) { + DefaultTheme defaultTheme = context.getAttribute(DefaultTheme.class); + return defaultTheme != null ? defaultTheme.theme : null; + } + + // Registers this theme as the application-wide default, which is used for sessions that did not + // go through the initialization of index.html (e.g. after a server restart, or when index.html + // was served from a cache). + void setDefault(VaadinContext context) { + context.setAttribute(DefaultTheme.class, new DefaultTheme(this)); + } + + private void setDefaultIfAbsent(VaadinContext context) { + // getAttribute(type, supplier) stores the supplied value when the attribute is absent + context.getAttribute(DefaultTheme.class, () -> new DefaultTheme(this)); + } + + @RequiredArgsConstructor + private static final class DefaultTheme { + private final DynamicTheme theme; } private static void assertNotLegacyTheme() { @@ -115,7 +146,8 @@ private static void assertNotLegacyTheme() { */ public static DynamicTheme getCurrent() { assertFeatureSupported(); - return VaadinSession.getCurrent().getAttribute(DynamicTheme.class); + DynamicTheme theme = getSessionTheme(); + return theme != null ? theme : getDefault(VaadinService.getCurrent().getContext()); } /** @@ -126,6 +158,10 @@ public static DynamicTheme getCurrent() { * as the session default. Subsequently, it injects the corresponding CSS stylesheet * link into the document head. *

+ *

+ * If no application-wide default has been registered, this instance is also registered + * as the default for sessions that did not go through this initialization. + *

* * @param settings the application shell settings to be modified * @throws UnsupportedOperationException if the runtime Vaadin version is older than 25 @@ -136,7 +172,8 @@ public void initialize(AppShellSettings settings) { assertFeatureSupported(); assertNotLegacyTheme(); - DynamicTheme theme = getCurrent(); + setDefaultIfAbsent(VaadinService.getCurrent().getContext()); + DynamicTheme theme = getSessionTheme(); if (theme == null) { theme = this; VaadinSession.getCurrent().setAttribute(DynamicTheme.class, theme); @@ -162,6 +199,10 @@ public void initialize(AppShellSettings settings) { * as the session default. Subsequently, it injects the corresponding CSS stylesheet * link into the document head. *

+ *

+ * If no application-wide default has been registered, this instance is also registered + * as the default for sessions that did not go through this initialization. + *

* * @param response the index HTML response to be modified * @throws UnsupportedOperationException if the runtime Vaadin version is older than 25 @@ -172,7 +213,8 @@ public void initialize(IndexHtmlResponse response) { assertFeatureSupported(); assertNotLegacyTheme(); - DynamicTheme theme = getCurrent(); + setDefaultIfAbsent(VaadinService.getCurrent().getContext()); + DynamicTheme theme = getSessionTheme(); if (theme == null) { theme = this; VaadinSession.getCurrent().setAttribute(DynamicTheme.class, theme); diff --git a/base/src/main/java/com/flowingcode/vaadin/addons/demo/DynamicThemeInitializer.java b/base/src/main/java/com/flowingcode/vaadin/addons/demo/DynamicThemeInitializer.java index fad5616..b556ef3 100644 --- a/base/src/main/java/com/flowingcode/vaadin/addons/demo/DynamicThemeInitializer.java +++ b/base/src/main/java/com/flowingcode/vaadin/addons/demo/DynamicThemeInitializer.java @@ -36,8 +36,8 @@ *

* If the dynamic theme feature is supported, this listener checks for the presence of a * {@code /META-INF/dynamic-theme.properties} file. If found, it reads the {@code theme} property - * (e.g., {@code theme=LUMO}) and registers an {@link IndexHtmlRequestListener} to initialize the - * theme for all requests. + * (e.g., {@code theme=LUMO}), registers it as the default theme of the application, and registers + * an {@link IndexHtmlRequestListener} to initialize the theme for all requests. *

*/ @SuppressWarnings("serial") @@ -52,13 +52,19 @@ public void serviceInit(ServiceInitEvent event) { if (DynamicTheme.isFeatureSupported()) { try { Enumeration resources = getClass().getClassLoader().getResources(PROPERTIES_PATH); + boolean hasDefault = false; while (resources.hasMoreElements()) { URL url = resources.nextElement(); - String source = getSourceName(url); - readTheme(url).ifPresent(theme -> { - logger.info("Applying dynamic theme '{}' from {}", theme, source); - event.addIndexHtmlRequestListener(theme::initialize); - }); + Optional theme = readTheme(url); + if (theme.isPresent()) { + logger.info("Applying dynamic theme '{}' from {}", theme.get(), getSourceName(url)); + // the first listener initializes the session, so its theme is the default + if (!hasDefault) { + theme.get().setDefault(event.getSource().getContext()); + hasDefault = true; + } + event.addIndexHtmlRequestListener(theme.get()::initialize); + } } } catch (IOException e) { throw new RuntimeException("Error reading dynamic-theme.properties", e); From ce66e5eeed1c5c8554a1099f2c00e838d97f34d6 Mon Sep 17 00:00:00 2001 From: Javier Godoy <11554739+javier-godoy@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:25:32 -0300 Subject: [PATCH 2/4] fix: do not add the dynamic theme stylesheet twice --- .../flowingcode/vaadin/addons/demo/DynamicTheme.java | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/base/src/main/java/com/flowingcode/vaadin/addons/demo/DynamicTheme.java b/base/src/main/java/com/flowingcode/vaadin/addons/demo/DynamicTheme.java index c66ac6e..8785b9c 100644 --- a/base/src/main/java/com/flowingcode/vaadin/addons/demo/DynamicTheme.java +++ b/base/src/main/java/com/flowingcode/vaadin/addons/demo/DynamicTheme.java @@ -233,10 +233,14 @@ public void initialize(IndexHtmlResponse response) { } if (href != null) { - Element link = response.getDocument().createElement("link"); - link.attr("rel", "stylesheet"); - link.attr("href", href); - response.getDocument().head().appendChild(link); + // skip the link if it was already added through AppShellSettings + String selector = "link[rel=stylesheet][href=\"" + href + "\"]"; + if (response.getDocument().selectFirst(selector) == null) { + Element link = response.getDocument().createElement("link"); + link.attr("rel", "stylesheet"); + link.attr("href", href); + response.getDocument().head().appendChild(link); + } } } From 5ffe2a0d83b92e3fa3b77bdba78cf153ca528bfe Mon Sep 17 00:00:00 2001 From: Javier Godoy <11554739+javier-godoy@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:25:32 -0300 Subject: [PATCH 3/4] feat: add DefaultDynamicTheme annotation --- README.md | 13 +++- .../addons/demo/DefaultDynamicTheme.java | 64 +++++++++++++++++++ .../vaadin/addons/demo/DynamicTheme.java | 6 +- .../addons/demo/DynamicThemeInitializer.java | 29 +++++++-- 4 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 base/src/main/java/com/flowingcode/vaadin/addons/demo/DefaultDynamicTheme.java diff --git a/README.md b/README.md index 7e21369..9523f65 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,18 @@ Available in Vaadin 25+, this feature allows you to verify add-on behavior acros image -To enable this feature, the `DynamicTheme` must be initialized in the `AppShellConfigurator` of the application. Ensure that the legacy `@Theme` annotation and any Aura or Lumo `@StyleSheet` references are removed. +To enable this feature, annotate the `AppShellConfigurator` of the application with `@DefaultDynamicTheme`. Ensure that the legacy `@Theme` annotation and any Aura or Lumo `@StyleSheet` references are removed. + +```java +@DefaultDynamicTheme(DynamicTheme.LUMO) +public class AppShellConfiguratorImpl implements AppShellConfigurator { + +} +``` + +The annotation is read when the application starts, so the default theme also applies to sessions that did not load `index.html` (for example, after the server restarts while a page is open, or when `index.html` is served from a cache). + +Alternatively, `DynamicTheme` can be initialized in the `configurePage` method of the `AppShellConfigurator`. With this approach, the default theme is only known after `index.html` has been served at least once. ```java public class AppShellConfiguratorImpl implements AppShellConfigurator { diff --git a/base/src/main/java/com/flowingcode/vaadin/addons/demo/DefaultDynamicTheme.java b/base/src/main/java/com/flowingcode/vaadin/addons/demo/DefaultDynamicTheme.java new file mode 100644 index 0000000..ecacc70 --- /dev/null +++ b/base/src/main/java/com/flowingcode/vaadin/addons/demo/DefaultDynamicTheme.java @@ -0,0 +1,64 @@ +/*- + * #%L + * Commons Demo + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * 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. + * #L% + */ +package com.flowingcode.vaadin.addons.demo; + +import com.vaadin.flow.component.page.AppShellConfigurator; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Enables dynamic theme switching with the given theme as the default. + *

+ * This annotation must be placed on the {@link AppShellConfigurator} of the application. It + * replaces calling {@link DynamicTheme#initialize(com.vaadin.flow.server.AppShellSettings) + * DynamicTheme.initialize} from {@link AppShellConfigurator#configurePage configurePage}. Since + * the annotation is read when the application starts, the default theme also applies to sessions + * that did not load {@code index.html} (for instance, after a server restart, or when + * {@code index.html} was served from a cache). + *

+ *

+ * Dynamic theme switching is only available with Vaadin 25+. With older versions this annotation + * has no effect, so an {@link AppShellConfigurator} that runs on several Vaadin versions can be + * annotated as well. The {@link AppShellConfigurator} must not be annotated with the legacy + * {@link com.vaadin.flow.theme.Theme @Theme} annotation. + *

+ *

+ * The annotation is ignored if it is placed on a class that is not the {@link AppShellConfigurator} + * of the application. It is inherited by subclasses of the annotated class. + *

+ */ +@Documented +@Inherited +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface DefaultDynamicTheme { + + /** + * The default theme. + * + * @return the default theme + */ + DynamicTheme value(); + +} diff --git a/base/src/main/java/com/flowingcode/vaadin/addons/demo/DynamicTheme.java b/base/src/main/java/com/flowingcode/vaadin/addons/demo/DynamicTheme.java index 8785b9c..549f08c 100644 --- a/base/src/main/java/com/flowingcode/vaadin/addons/demo/DynamicTheme.java +++ b/base/src/main/java/com/flowingcode/vaadin/addons/demo/DynamicTheme.java @@ -131,8 +131,10 @@ private static final class DefaultTheme { private static void assertNotLegacyTheme() { VaadinContext context = VaadinService.getCurrent().getContext(); - Class appShellClass = - AppShellRegistry.getInstance(context).getShell(); + assertNotLegacyTheme(AppShellRegistry.getInstance(context).getShell()); + } + + static void assertNotLegacyTheme(Class appShellClass) { if (appShellClass != null && appShellClass.getAnnotation(Theme.class) != null) { throw new IllegalStateException("App shell is configured with legacy @Theme annotation"); } diff --git a/base/src/main/java/com/flowingcode/vaadin/addons/demo/DynamicThemeInitializer.java b/base/src/main/java/com/flowingcode/vaadin/addons/demo/DynamicThemeInitializer.java index b556ef3..4263de9 100644 --- a/base/src/main/java/com/flowingcode/vaadin/addons/demo/DynamicThemeInitializer.java +++ b/base/src/main/java/com/flowingcode/vaadin/addons/demo/DynamicThemeInitializer.java @@ -19,6 +19,8 @@ */ package com.flowingcode.vaadin.addons.demo; +import com.vaadin.flow.component.page.AppShellConfigurator; +import com.vaadin.flow.server.AppShellRegistry; import com.vaadin.flow.server.ServiceInitEvent; import com.vaadin.flow.server.VaadinServiceInitListener; import com.vaadin.flow.server.communication.IndexHtmlRequestListener; @@ -34,10 +36,12 @@ /** * Service initialization listener that automatically applies a dynamic theme. *

- * If the dynamic theme feature is supported, this listener checks for the presence of a - * {@code /META-INF/dynamic-theme.properties} file. If found, it reads the {@code theme} property - * (e.g., {@code theme=LUMO}), registers it as the default theme of the application, and registers - * an {@link IndexHtmlRequestListener} to initialize the theme for all requests. + * If the dynamic theme feature is supported, this listener checks whether the + * {@link AppShellConfigurator} is annotated with {@link DefaultDynamicTheme}. Otherwise, it checks + * for the presence of a {@code /META-INF/dynamic-theme.properties} file, and if found, it reads the + * {@code theme} property (e.g., {@code theme=LUMO}). The theme is registered as the default theme + * of the application, and an {@link IndexHtmlRequestListener} is registered to initialize the theme + * for all requests. *

*/ @SuppressWarnings("serial") @@ -50,6 +54,23 @@ public class DynamicThemeInitializer implements VaadinServiceInitListener { @Override public void serviceInit(ServiceInitEvent event) { if (DynamicTheme.isFeatureSupported()) { + Class appShellClass = + AppShellRegistry.getInstance(event.getSource().getContext()).getShell(); + if (appShellClass == null) { + logger.debug("No AppShellConfigurator is registered, @DefaultDynamicTheme is not applied"); + } + DefaultDynamicTheme annotation = appShellClass != null + ? appShellClass.getAnnotation(DefaultDynamicTheme.class) + : null; + if (annotation != null) { + DynamicTheme.assertNotLegacyTheme(appShellClass); + DynamicTheme theme = annotation.value(); + logger.info("Applying dynamic theme '{}' from {}", theme, appShellClass.getName()); + theme.setDefault(event.getSource().getContext()); + event.addIndexHtmlRequestListener(theme::initialize); + return; + } + try { Enumeration resources = getClass().getClassLoader().getResources(PROPERTIES_PATH); boolean hasDefault = false; From 509650b383946f70c656e6c5f04015d186088e90 Mon Sep 17 00:00:00 2001 From: Javier Godoy <11554739+javier-godoy@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:25:32 -0300 Subject: [PATCH 4/4] deprecate: deprecate DynamicTheme.initialize(AppShellSettings) --- README.md | 15 +-------------- .../vaadin/addons/demo/DynamicTheme.java | 5 +++++ .../addons/demo/AppShellConfiguratorImpl.java | 9 +-------- 3 files changed, 7 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 9523f65..43efb92 100644 --- a/README.md +++ b/README.md @@ -67,20 +67,7 @@ public class AppShellConfiguratorImpl implements AppShellConfigurator { The annotation is read when the application starts, so the default theme also applies to sessions that did not load `index.html` (for example, after the server restarts while a page is open, or when `index.html` is served from a cache). -Alternatively, `DynamicTheme` can be initialized in the `configurePage` method of the `AppShellConfigurator`. With this approach, the default theme is only known after `index.html` has been served at least once. - -```java -public class AppShellConfiguratorImpl implements AppShellConfigurator { - - @Override - public void configurePage(AppShellSettings settings) { - if (DynamicTheme.isFeatureSupported()) { - DynamicTheme.LUMO.initialize(settings); - } - } - -} -``` +Initializing `DynamicTheme` from the `configurePage` method of the `AppShellConfigurator` (i.e. calling `DynamicTheme.LUMO.initialize(settings)`) is deprecated, because the default theme is only known after `index.html` has been served at least once. Replace that call by annotating the `AppShellConfigurator` with `@DefaultDynamicTheme`. When targeting Vaadin 14-25 or 23-25, the `AppShellConfigurator` approach cannot be used due to framework and library limitations. To resolve this, you must create a configuration file `src/test/resources/META-INF/dynamic-theme.properties` with the following content: diff --git a/base/src/main/java/com/flowingcode/vaadin/addons/demo/DynamicTheme.java b/base/src/main/java/com/flowingcode/vaadin/addons/demo/DynamicTheme.java index 549f08c..ed6b50c 100644 --- a/base/src/main/java/com/flowingcode/vaadin/addons/demo/DynamicTheme.java +++ b/base/src/main/java/com/flowingcode/vaadin/addons/demo/DynamicTheme.java @@ -169,7 +169,12 @@ public static DynamicTheme getCurrent() { * @throws UnsupportedOperationException if the runtime Vaadin version is older than 25 * @throws IllegalStateException if the {@link AppShellConfigurator} is configured with the legacy * {@link Theme} annotation + * @deprecated Annotate the {@link AppShellConfigurator} with {@link DefaultDynamicTheme} instead. + * This method is only invoked when {@code index.html} is generated, therefore the + * default theme is unknown to sessions that did not load {@code index.html} until it + * has been served at least once. */ + @Deprecated(since = "5.5.0", forRemoval = true) public void initialize(AppShellSettings settings) { assertFeatureSupported(); assertNotLegacyTheme(); diff --git a/base/src/test/java/com/flowingcode/vaadin/addons/demo/AppShellConfiguratorImpl.java b/base/src/test/java/com/flowingcode/vaadin/addons/demo/AppShellConfiguratorImpl.java index a4a9994..71b57cc 100644 --- a/base/src/test/java/com/flowingcode/vaadin/addons/demo/AppShellConfiguratorImpl.java +++ b/base/src/test/java/com/flowingcode/vaadin/addons/demo/AppShellConfiguratorImpl.java @@ -20,15 +20,8 @@ package com.flowingcode.vaadin.addons.demo; import com.vaadin.flow.component.page.AppShellConfigurator; -import com.vaadin.flow.server.AppShellSettings; +@DefaultDynamicTheme(DynamicTheme.LUMO) public class AppShellConfiguratorImpl implements AppShellConfigurator { - @Override - public void configurePage(AppShellSettings settings) { - if (DynamicTheme.isFeatureSupported()) { - DynamicTheme.LUMO.initialize(settings); - } - } - }