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 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 { - @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 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"); } @@ -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 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; 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); 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); - } - } - }