Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
f384a69
Revert "feat: Watch the CSS files in the project's public static reso…
mshabarov Oct 13, 2025
30572e8
fix: Update link tag on stylesheet modification
mshabarov Oct 10, 2025
f830a88
Merge branch 'main' into hot-reload-for-css-modifications
mshabarov Oct 13, 2025
e4025a7
take into account gradle path to build dir
mshabarov Oct 13, 2025
5f82132
add unit tests
mshabarov Oct 13, 2025
3c12c5d
Merge remote-tracking branch 'origin/hot-reload-for-css-modifications…
mshabarov Oct 13, 2025
65ecfd5
Merge branch 'main' into hot-reload-for-css-modifications
mshabarov Oct 13, 2025
e694f6e
add method to PropertyDeploymentConfiguration
mshabarov Oct 13, 2025
7740e3c
Merge remote-tracking branch 'origin/hot-reload-for-css-modifications…
mshabarov Oct 13, 2025
869217f
fix maven/gradle detection, imports
mshabarov Oct 14, 2025
d9f9a4a
Merge branch 'main' into hot-reload-for-css-modifications
mshabarov Oct 14, 2025
a1405cf
Merge remote-tracking branch 'origin/main' into hot-reload-for-css-mo…
mshabarov Oct 14, 2025
e24dac3
Merge remote-tracking branch 'origin/hot-reload-for-css-modifications…
mshabarov Oct 14, 2025
fc55ffc
use all entries and swap the resource folder detection
mshabarov Oct 14, 2025
008a09c
use util method
mshabarov Oct 15, 2025
db1204e
Merge branch 'main' into hot-reload-for-css-modifications
mshabarov Oct 15, 2025
140e7b6
improve cache killer token, use replaceAll, refactorings
mshabarov Oct 15, 2025
1b53372
Merge remote-tracking branch 'origin/hot-reload-for-css-modifications…
mshabarov Oct 15, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions flow-server/src/main/java/com/vaadin/flow/hotswap/Hotswapper.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/
package com.vaadin.flow.hotswap;

import java.io.File;
import java.io.Serializable;
import java.net.URI;
import java.util.ArrayList;
Expand All @@ -32,6 +33,7 @@
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand All @@ -57,6 +59,7 @@
import com.vaadin.flow.server.UIInitListener;
import com.vaadin.flow.server.VaadinService;
import com.vaadin.flow.server.VaadinSession;
import com.vaadin.flow.server.frontend.FrontendUtils;

/**
* Entry point for application classes hot reloads.
Expand Down Expand Up @@ -186,6 +189,47 @@ public void onHotswap(URI[] createdResources, URI[] modifiedResources,
createdResources, modifiedResources, deletedResources);
}

if (anyMatches(".*\\.css", createdResources, modifiedResources,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a note for a future improvement: we could refactor a bit of the code to add a onResourceEvent(ResourceEvent event) on VaadinHotswapper interface and move the logic in this method into two separate classes (for styles and translations).

deletedResources)) {
if (liveReload == null) {
LOGGER.debug(
"A change to one or more CSS resources requires a browser page refresh, but BrowserLiveReload is not available. "
+ "Please reload the browser page manually to make changes effective.");
} else {
LOGGER.debug(
"Triggering browser live reload because of CSS resources changes");

File buildResourcesFolder = vaadinService
.getDeploymentConfiguration().getOutputResourceFolder();

List<File> publicStaticResourcesFolders = Stream
.of("META-INF/resources", "resources", "static",
"public")
.map(path -> new File(buildResourcesFolder, path))
.filter(File::exists).toList();

Stream.of(createdResources, modifiedResources, deletedResources)
.flatMap(Arrays::stream).distinct()
.forEach(resource -> {
String resourcePath = resource.getPath();
for (File staticResourcesFolder : publicStaticResourcesFolders) {
String staticResourcesPath = FrontendUtils
.getUnixPath(
staticResourcesFolder.toPath());
if (resourcePath
.startsWith(staticResourcesPath)) {
String path = resourcePath
.replace(staticResourcesPath, "");
if (path.startsWith("/")) {
path = path.substring(1);
}
liveReload.update(path, null);
}
}
});
}
}

if (anyMatches(".*/vaadin-i18n/.*\\.properties", createdResources,
modifiedResources, deletedResources)) {
// Clear resource bundle cache so that translations (and other
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,35 @@ default String getBuildFolder() {
return getStringProperty(InitParameters.BUILD_FOLDER, Constants.TARGET);
}

/**
* Returns a folder inside build folder, where the built tool places
* project's resources.
* <p>
* Only available in development mode.
* <p>
* For Maven this is typically {@code target/classes/} and for Gradle -
* {@code build/resources/main/}.
*
* @return the folder inside build folder where resources are placed, or
* {@code null} if the project folder is unknown.
*/
default File getOutputResourceFolder() {
File projectFolder = getProjectFolder();
if (projectFolder == null) {
return null;
}
String buildFolderName = getBuildFolder();
File buildFolder = new File(projectFolder, buildFolderName);
File gradleOutputResources = new File(buildFolder, "resources/main/");
if (gradleOutputResources.exists()) {
// Gradle
return gradleOutputResources;
} else {
// Maven
return new File(buildFolder, "classes/");
}
}

/**
* Return the project root folder.
* <p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@
import java.lang.annotation.Annotation;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Set;
import java.util.Map;
import java.util.function.Consumer;

import org.jsoup.nodes.Document;
Expand All @@ -41,6 +41,7 @@
import com.vaadin.flow.component.page.Push;
import com.vaadin.flow.component.page.TargetElement;
import com.vaadin.flow.component.page.Viewport;
import com.vaadin.flow.function.DeploymentConfiguration;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.shared.ApplicationConstants;
import com.vaadin.flow.theme.Theme;
Expand Down Expand Up @@ -223,15 +224,14 @@ private AppShellSettings createSettings(VaadinRequest request) {
}
getAnnotations(Inline.class).forEach(settings::addInline);

Set<String> stylesheets = new LinkedHashSet<>();
Map<String, String> stylesheets = new LinkedHashMap<>();
for (StyleSheet sheet : getAnnotations(StyleSheet.class)) {
String href = resolveStyleSheetHref(sheet.value(), request);
if (href != null && !href.isBlank()) {
stylesheets.add(href);
stylesheets.put(href, sheet.value());
}
}
stylesheets.forEach(href -> settings.addLink("stylesheet", href));

addStyleSheets(request, stylesheets, settings);
return settings;
}

Expand Down Expand Up @@ -369,4 +369,33 @@ private <T extends Annotation> List<T> getAnnotations(Class<T> annotation) {
return appShellClass == null ? Collections.emptyList()
: Arrays.asList(appShellClass.getAnnotationsByType(annotation));
}

private static void addStyleSheets(VaadinRequest request,
Map<String, String> stylesheets, AppShellSettings settings) {
DeploymentConfiguration config = request.getService()
.getDeploymentConfiguration();
if (!config.isProductionMode()) {
stylesheets.forEach((resolved, source) -> {
if (source.startsWith("/")) {
source = source.substring(1);
}
if (source.startsWith("./")) {
source = source.substring(2);
}
if (source.startsWith(
ApplicationConstants.CONTEXT_PROTOCOL_PREFIX)) {
source = source.substring(
ApplicationConstants.CONTEXT_PROTOCOL_PREFIX
.length());
}
stylesheets.put(resolved, source);
});
}

stylesheets.forEach((href, sourcePath) -> {
Map<String, String> attributes = Map.of("rel", "stylesheet",
"data-file-path", sourcePath);
settings.addLink(Position.APPEND, href, attributes);
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,11 @@ public String getBuildFolder() {
return parentConfig.getBuildFolder();
}

@Override
public File getOutputResourceFolder() {
return super.getOutputResourceFolder();
}

@Override
public File getJavaResourceFolder() {
return super.getJavaResourceFolder();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/*
* Copyright 2000-2025 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.hotswap;

import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;

import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.mockito.Mockito;

import com.vaadin.flow.internal.BrowserLiveReload;
import com.vaadin.flow.internal.BrowserLiveReloadAccessor;
import com.vaadin.flow.server.MockVaadinServletService;
import com.vaadin.flow.server.startup.ApplicationConfiguration;
import com.vaadin.flow.server.startup.ApplicationConfigurationFactory;
import com.vaadin.tests.util.MockDeploymentConfiguration;

public class HotswapperResourcesTest {

private MockVaadinServletService service;
private BrowserLiveReload liveReload;
private Hotswapper hotswapper;
private File tempProjectDir;

@Before
public void setUp() throws IOException {
MockDeploymentConfiguration dc = new MockDeploymentConfiguration();
// Create a temporary project directory, required for build resources
// folder
tempProjectDir = Files.createTempDirectory("vaadin-hotswap-test")
.toFile();
dc.setProjectFolder(tempProjectDir);

service = new MockVaadinServletService(dc);

// Wire BrowserLiveReload into Lookup via BrowserLiveReloadAccessor
liveReload = Mockito.mock(BrowserLiveReload.class);
Mockito.when(
service.getLookup().lookup(BrowserLiveReloadAccessor.class))
.thenReturn(context -> liveReload);

ApplicationConfiguration appConfig = Mockito
.mock(ApplicationConfiguration.class);
Mockito.when(appConfig.isProductionMode()).thenAnswer(
i -> service.getDeploymentConfiguration().isProductionMode());
Mockito.when(service.getLookup()
.lookup(ApplicationConfigurationFactory.class))
.thenReturn(context -> appConfig);

hotswapper = new Hotswapper(service);
}

@After
public void tearDown() throws IOException {
if (tempProjectDir != null) {
deleteRecursively(tempProjectDir);
}
}

private static void deleteRecursively(File f) throws IOException {
if (f == null || !f.exists()) {
return;
}
if (f.isDirectory()) {
File[] children = f.listFiles();
if (children != null) {
for (File c : children) {
deleteRecursively(c);
}
}
}
Files.deleteIfExists(f.toPath());
}

@Test
public void cssResourceChange_triggersLiveReloadUpdateWithRelativePath()
throws Exception {
File buildResources = service.getDeploymentConfiguration()
.getOutputResourceFolder();
// Mimic a static resources folder under build resources
File publicDir = new File(buildResources, "public");
File css = new File(publicDir, "styles/app.css");
css.getParentFile().mkdirs();
Files.writeString(css.toPath(), "body{}\n");

URI modified = css.toURI();
hotswapper.onHotswap(new URI[0], new URI[] { modified }, new URI[0]);

// Expect BrowserLiveReload.update to be called with relative URL path
// "styles/app.css"
Mockito.verify(liveReload).update("styles/app.css", null);
Mockito.verifyNoMoreInteractions(liveReload);
}

@Test
public void cssResourceChange_noLiveReloadAvailable_noCrash()
throws Exception {
// Create a new service without BrowserLiveReload in Lookup
MockDeploymentConfiguration dc = new MockDeploymentConfiguration();
dc.setProjectFolder(tempProjectDir);
MockVaadinServletService serviceNoLR = new MockVaadinServletService(dc);
// Provide ApplicationConfiguration via factory to avoid NPE in
// BrowserLiveReloadAccessor
com.vaadin.flow.server.startup.ApplicationConfiguration appConfig = Mockito
.mock(com.vaadin.flow.server.startup.ApplicationConfiguration.class);
Mockito.when(appConfig.isProductionMode()).thenAnswer(i -> serviceNoLR
.getDeploymentConfiguration().isProductionMode());
Mockito.when(serviceNoLR.getLookup().lookup(
com.vaadin.flow.server.startup.ApplicationConfigurationFactory.class))
.thenReturn(context -> appConfig);

Hotswapper noLR = new Hotswapper(serviceNoLR);

File buildResources = serviceNoLR.getDeploymentConfiguration()
.getOutputResourceFolder();
File staticDir = new File(buildResources, "static");
File css = new File(staticDir, "theme.css");
css.getParentFile().mkdirs();
Files.writeString(css.toPath(), "html{}\n");

// Should not throw even though live reload is not available; just logs
noLR.onHotswap(new URI[0], new URI[] { css.toURI() }, new URI[0]);
}
}
Loading
Loading