diff --git a/README.md b/README.md
index 7e21369..43efb92 100644
--- a/README.md
+++ b/README.md
@@ -56,21 +56,19 @@ Available in Vaadin 25+, this feature allows you to verify add-on behavior acros
-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 {
- @Override
- public void configurePage(AppShellSettings settings) {
- if (DynamicTheme.isFeatureSupported()) {
- DynamicTheme.LUMO.initialize(settings);
- }
- }
-
}
```
+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).
+
+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:
```properties
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 6319247..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 @@ -90,18 +90,51 @@ 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() { VaadinContext context = VaadinService.getCurrent().getContext(); - Class extends AppShellConfigurator> appShellClass = - AppShellRegistry.getInstance(context).getShell(); + assertNotLegacyTheme(AppShellRegistry.getInstance(context).getShell()); + } + + static void assertNotLegacyTheme(Class extends AppShellConfigurator> appShellClass) { if (appShellClass != null && appShellClass.getAnnotation(Theme.class) != null) { throw new IllegalStateException("App shell is configured with legacy @Theme annotation"); } @@ -115,7 +148,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,17 +160,27 @@ 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 * @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(); - DynamicTheme theme = getCurrent(); + setDefaultIfAbsent(VaadinService.getCurrent().getContext()); + DynamicTheme theme = getSessionTheme(); if (theme == null) { theme = this; VaadinSession.getCurrent().setAttribute(DynamicTheme.class, theme); @@ -162,6 +206,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 +220,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); @@ -191,10 +240,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); + } } } 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..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}) 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,15 +54,38 @@ public class DynamicThemeInitializer implements VaadinServiceInitListener { @Override public void serviceInit(ServiceInitEvent event) { if (DynamicTheme.isFeatureSupported()) { + Class extends AppShellConfigurator> 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