Skip to content

Commit 578de2e

Browse files
Artur-claude
andauthored
feat: Modular feature flag system using Service Provider Interface (#22361)
Refactored feature flags to use Service Provider Interface (SPI) pattern, allowing each module to define its own feature flags that are dynamically loaded at runtime. This eliminates the need to hardcode all feature flags in the FeatureFlags class. Changes: - Added FeatureFlagProvider interface for modules to implement - Split feature flags into domain-specific providers: - CoreFeatureFlagProvider: Core Flow framework features - CopilotFeatureFlagProvider: Copilot-related features (placeholder) - HillaFeatureFlagProvider: Hilla-related features - FlowComponentsFeatureFlagProvider: Flow Components features - TestFeatureFlagProvider: Test-only features (in test sources) - Updated FeatureFlags to load features dynamically via ServiceLoader - Removed static feature flag definitions from FeatureFlags - Maintained backward compatibility with public static references for commonly used flags - Updated debug window to no longer filter EXAMPLE feature (now only in tests) - Fixed Maven plugin's CombinedClassLoader to properly combine resources from all classloaders, allowing ServiceLoader to find all service provider files The CombinedClassLoader fix ensures that the build-dev-bundle plugin can properly load feature flag providers by combining META-INF/services resources from all classloaders instead of returning resources from only the first classloader. This design provides better modularity and extensibility, allowing new modules to easily add their own feature flags without modifying core classes. Fixes #13356 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 0aa0f2f commit 578de2e

File tree

13 files changed

+362
-89
lines changed

13 files changed

+362
-89
lines changed

flow-plugins/flow-dev-bundle-plugin/src/main/java/com/vaadin/flow/plugin/maven/Reflector.java

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import java.net.URL;
2424
import java.net.URLClassLoader;
2525
import java.util.ArrayList;
26+
import java.util.Collections;
2627
import java.util.Enumeration;
2728
import java.util.HashMap;
2829
import java.util.List;
@@ -401,15 +402,33 @@ public URL getResource(String name) {
401402

402403
@Override
403404
public Enumeration<URL> getResources(String name) throws IOException {
405+
List<URL> allResources = new ArrayList<>();
406+
407+
// Collect resources from all classloaders
404408
Enumeration<URL> resources = super.getResources(name);
405-
if (!resources.hasMoreElements() && delegate != null) {
409+
while (resources.hasMoreElements()) {
410+
allResources.add(resources.nextElement());
411+
}
412+
413+
if (delegate != null) {
406414
resources = delegate.getResources(name);
415+
while (resources.hasMoreElements()) {
416+
URL url = resources.nextElement();
417+
if (!allResources.contains(url)) {
418+
allResources.add(url);
419+
}
420+
}
407421
}
408-
if (!resources.hasMoreElements()) {
409-
resources = ClassLoader.getPlatformClassLoader()
410-
.getResources(name);
422+
423+
resources = ClassLoader.getPlatformClassLoader().getResources(name);
424+
while (resources.hasMoreElements()) {
425+
URL url = resources.nextElement();
426+
if (!allResources.contains(url)) {
427+
allResources.add(url);
428+
}
411429
}
412-
return resources;
430+
431+
return Collections.enumeration(allResources);
413432
}
414433
}
415434

flow-plugins/flow-maven-plugin/src/main/java/com/vaadin/flow/plugin/maven/Reflector.java

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import java.net.URL;
2929
import java.net.URLClassLoader;
3030
import java.util.ArrayList;
31+
import java.util.Collections;
3132
import java.util.Enumeration;
3233
import java.util.HashMap;
3334
import java.util.List;
@@ -473,15 +474,33 @@ public URL getResource(String name) {
473474

474475
@Override
475476
public Enumeration<URL> getResources(String name) throws IOException {
477+
List<URL> allResources = new ArrayList<>();
478+
479+
// Collect resources from all classloaders
476480
Enumeration<URL> resources = super.getResources(name);
477-
if (!resources.hasMoreElements() && delegate != null) {
481+
while (resources.hasMoreElements()) {
482+
allResources.add(resources.nextElement());
483+
}
484+
485+
if (delegate != null) {
478486
resources = delegate.getResources(name);
487+
while (resources.hasMoreElements()) {
488+
URL url = resources.nextElement();
489+
if (!allResources.contains(url)) {
490+
allResources.add(url);
491+
}
492+
}
479493
}
480-
if (!resources.hasMoreElements()) {
481-
resources = ClassLoader.getPlatformClassLoader()
482-
.getResources(name);
494+
495+
resources = ClassLoader.getPlatformClassLoader().getResources(name);
496+
while (resources.hasMoreElements()) {
497+
URL url = resources.nextElement();
498+
if (!allResources.contains(url)) {
499+
allResources.add(url);
500+
}
483501
}
484-
return resources;
502+
503+
return Collections.enumeration(allResources);
485504
}
486505

487506
/**

flow-plugins/flow-plugin-base/src/test/java/com/vaadin/flow/plugin/base/BuildFrontendUtilTest.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -708,9 +708,9 @@ public void runNodeUpdater_generateFeatureFlagsJsFile() throws Exception {
708708
.readString(generatedFeatureFlagsFile.toPath())
709709
.replace("\r\n", "\n");
710710

711-
Assert.assertTrue("Example feature should not be set at build time",
711+
Assert.assertFalse("Example feature should not be set at build time",
712712
featureFlagsJs.contains(
713-
"window.Vaadin.featureFlags.exampleFeatureFlag = false;\n"));
713+
"window.Vaadin.featureFlags.exampleFeatureFlag"));
714714
}
715715

716716
private void fillAdapter() throws URISyntaxException {
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
* Copyright 2000-2025 Vaadin Ltd.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
5+
* use this file except in compliance with the License. You may obtain a copy of
6+
* the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations under
14+
* the License.
15+
*/
16+
package com.vaadin.experimental;
17+
18+
import java.util.List;
19+
20+
/**
21+
* Provides core Flow framework feature flags.
22+
*
23+
* @since 25.0
24+
*/
25+
public class CoreFeatureFlagProvider implements FeatureFlagProvider {
26+
27+
public static final Feature COLLABORATION_ENGINE_BACKEND = new Feature(
28+
"Collaboration Kit backend for clustering support",
29+
"collaborationEngineBackend",
30+
"https://github.com/vaadin/platform/issues/1988", true, null);
31+
32+
public static final Feature FLOW_FULLSTACK_SIGNALS = new Feature(
33+
"Flow Full-stack Signals", "flowFullstackSignals",
34+
"https://github.com/vaadin/platform/issues/7373", true, null);
35+
36+
public static final Feature ACCESSIBLE_DISABLED_BUTTONS = new Feature(
37+
"Accessible disabled buttons", "accessibleDisabledButtons",
38+
"https://github.com/vaadin/web-components/issues/4585", true, null);
39+
40+
public static final Feature COMPONENT_STYLE_INJECTION = new Feature(
41+
"Enable theme component style injection", "themeComponentStyles",
42+
"https://github.com/vaadin/flow/issues/21608", true, null);
43+
44+
public static final Feature COPILOT_EXPERIMENTAL = new Feature(
45+
"Copilot experimental features", "copilotExperimentalFeatures",
46+
"https://vaadin.com/docs/latest/tools/copilot", false, null);
47+
48+
@Override
49+
public List<Feature> getFeatures() {
50+
return List.of(COLLABORATION_ENGINE_BACKEND, FLOW_FULLSTACK_SIGNALS,
51+
ACCESSIBLE_DISABLED_BUTTONS, COMPONENT_STYLE_INJECTION,
52+
COPILOT_EXPERIMENTAL);
53+
}
54+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/*
2+
* Copyright 2000-2025 Vaadin Ltd.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
5+
* use this file except in compliance with the License. You may obtain a copy of
6+
* the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations under
14+
* the License.
15+
*/
16+
package com.vaadin.experimental;
17+
18+
import java.io.Serializable;
19+
import java.util.List;
20+
21+
/**
22+
* Service provider interface for modules to declare their feature flags.
23+
* <p>
24+
* Implementations should be registered via the Java Service Provider Interface
25+
* mechanism by creating a file named
26+
* {@code META-INF/services/com.vaadin.experimental.FeatureFlagProvider}
27+
* containing the fully qualified class name of the implementation.
28+
* <p>
29+
* This allows each module to define its own feature flags that will only be
30+
* loaded when the module is on the classpath.
31+
*
32+
* @since 25.0
33+
*/
34+
public interface FeatureFlagProvider extends Serializable {
35+
36+
/**
37+
* Returns the list of features provided by this module.
38+
* <p>
39+
* The returned list should be immutable and not change during the lifetime
40+
* of the application.
41+
*
42+
* @return list of features, never null
43+
*/
44+
List<Feature> getFeatures();
45+
}

flow-server/src/main/java/com/vaadin/experimental/FeatureFlags.java

Lines changed: 63 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,12 @@
2424
import java.net.URL;
2525
import java.nio.charset.StandardCharsets;
2626
import java.util.ArrayList;
27+
import java.util.HashMap;
2728
import java.util.List;
29+
import java.util.Map;
2830
import java.util.Optional;
2931
import java.util.Properties;
32+
import java.util.ServiceLoader;
3033
import java.util.function.Function;
3134

3235
import org.apache.commons.io.FileUtils;
@@ -51,51 +54,17 @@ public class FeatureFlags implements Serializable {
5154

5255
public static final String SYSTEM_PROPERTY_PREFIX_EXPERIMENTAL = "vaadin.experimental.";
5356

54-
public static final Feature EXAMPLE = new Feature(
55-
"Example feature. Internally used for testing purposes. Does not have any effect on production applications.",
56-
"exampleFeatureFlag", "https://github.com/vaadin/flow/pull/12004",
57-
false,
58-
"com.vaadin.flow.server.frontend.NodeTestComponents$ExampleExperimentalComponent");
59-
public static final Feature COLLABORATION_ENGINE_BACKEND = new Feature(
60-
"Collaboration Kit backend for clustering support",
61-
"collaborationEngineBackend",
62-
"https://github.com/vaadin/platform/issues/1988", true, null);
63-
64-
public static final Feature COPILOT_EXPERIMENTAL = new Feature(
65-
"Copilot experimental features", "copilotExperimentalFeatures",
66-
"https://vaadin.com/docs/latest/tools/copilot", false, null);
67-
68-
public static final Feature HILLA_FULLSTACK_SIGNALS = new Feature(
69-
"Hilla Full-stack Signals", "fullstackSignals",
70-
"https://github.com/vaadin/hilla/discussions/1902", true, null);
71-
72-
public static final Feature FLOW_FULLSTACK_SIGNALS = new Feature(
73-
"Flow Full-stack Signals", "flowFullstackSignals",
74-
"https://github.com/vaadin/platform/issues/7373", true, null);
75-
76-
public static final Feature MASTER_DETAIL_LAYOUT_COMPONENT = new Feature(
77-
"Master Detail Layout component", "masterDetailLayoutComponent",
78-
"https://github.com/vaadin/platform/issues/7173", true,
79-
"com.vaadin.flow.component.masterdetaillayout.MasterDetailLayout");
80-
81-
public static final Feature ACCESSIBLE_DISABLED_BUTTONS = new Feature(
82-
"Accessible disabled buttons", "accessibleDisabledButtons",
83-
"https://github.com/vaadin/web-components/issues/4585", true, null);
84-
85-
public static final Feature LAYOUT_COMPONENT_IMPROVEMENTS = new Feature(
86-
"HorizontalLayout and VerticalLayout improvements",
87-
"layoutComponentImprovements",
88-
"https://github.com/vaadin/flow-components/issues/6998", true,
89-
null);
90-
91-
public static final Feature DEFAULT_AUTO_RESPONSIVE_FORM_LAYOUT = new Feature(
92-
"Form Layout auto-responsive mode enabled by default",
93-
"defaultAutoResponsiveFormLayout",
94-
"https://github.com/vaadin/platform/issues/7172", true, null);
95-
96-
public static final Feature COMPONENT_STYLE_INJECTION = new Feature(
97-
"Enable theme component style injection", "themeComponentStyles",
98-
"https://github.com/vaadin/flow/issues/21608", true, null);
57+
// Feature constants pointing to provider definitions for backward
58+
// compatibility
59+
public static final Feature COLLABORATION_ENGINE_BACKEND = CoreFeatureFlagProvider.COLLABORATION_ENGINE_BACKEND;
60+
public static final Feature FLOW_FULLSTACK_SIGNALS = CoreFeatureFlagProvider.FLOW_FULLSTACK_SIGNALS;
61+
public static final Feature ACCESSIBLE_DISABLED_BUTTONS = CoreFeatureFlagProvider.ACCESSIBLE_DISABLED_BUTTONS;
62+
public static final Feature COMPONENT_STYLE_INJECTION = CoreFeatureFlagProvider.COMPONENT_STYLE_INJECTION;
63+
public static final Feature COPILOT_EXPERIMENTAL = CoreFeatureFlagProvider.COPILOT_EXPERIMENTAL;
64+
public static final Feature HILLA_FULLSTACK_SIGNALS = HillaFeatureFlagProvider.HILLA_FULLSTACK_SIGNALS;
65+
public static final Feature MASTER_DETAIL_LAYOUT_COMPONENT = FlowComponentsFeatureFlagProvider.MASTER_DETAIL_LAYOUT_COMPONENT;
66+
public static final Feature LAYOUT_COMPONENT_IMPROVEMENTS = FlowComponentsFeatureFlagProvider.LAYOUT_COMPONENT_IMPROVEMENTS;
67+
public static final Feature DEFAULT_AUTO_RESPONSIVE_FORM_LAYOUT = FlowComponentsFeatureFlagProvider.DEFAULT_AUTO_RESPONSIVE_FORM_LAYOUT;
9968

10069
private List<Feature> features = new ArrayList<>();
10170

@@ -117,16 +86,7 @@ public class FeatureFlags implements Serializable {
11786
*/
11887
public FeatureFlags(Lookup lookup) {
11988
this.lookup = lookup;
120-
features.add(new Feature(EXAMPLE));
121-
features.add(new Feature(COLLABORATION_ENGINE_BACKEND));
122-
features.add(new Feature(HILLA_FULLSTACK_SIGNALS));
123-
features.add(new Feature(FLOW_FULLSTACK_SIGNALS));
124-
features.add(new Feature(COPILOT_EXPERIMENTAL));
125-
features.add(new Feature(MASTER_DETAIL_LAYOUT_COMPONENT));
126-
features.add(new Feature(ACCESSIBLE_DISABLED_BUTTONS));
127-
features.add(new Feature(LAYOUT_COMPONENT_IMPROVEMENTS));
128-
features.add(new Feature(DEFAULT_AUTO_RESPONSIVE_FORM_LAYOUT));
129-
features.add(new Feature(COMPONENT_STYLE_INJECTION));
89+
loadFeaturesFromProviders();
13090
loadProperties();
13191
}
13292

@@ -425,4 +385,52 @@ private void checkForUnsupportedFeatureFlags(Properties props,
425385
}
426386
}
427387
}
388+
389+
/**
390+
* Loads feature flags from all available FeatureFlagProvider
391+
* implementations using the ServiceLoader mechanism.
392+
*/
393+
private void loadFeaturesFromProviders() {
394+
try {
395+
ServiceLoader<FeatureFlagProvider> loader = ServiceLoader.load(
396+
FeatureFlagProvider.class,
397+
this.getClass().getClassLoader());
398+
399+
Map<String, String> featureIdToProvider = new HashMap<>();
400+
401+
for (FeatureFlagProvider provider : loader) {
402+
List<Feature> providerFeatures = provider.getFeatures();
403+
if (providerFeatures != null) {
404+
String providerName = provider.getClass().getName();
405+
for (Feature feature : providerFeatures) {
406+
// Check for feature ID conflicts
407+
String existingProvider = featureIdToProvider
408+
.get(feature.getId());
409+
if (existingProvider != null) {
410+
getLogger().warn(
411+
"Feature flag conflict: Feature ID '{}' is defined by both '{}' and '{}'. "
412+
+ "Using the first definition from '{}'. "
413+
+ "Each feature flag should have a unique ID across all providers.",
414+
feature.getId(), existingProvider,
415+
providerName, existingProvider);
416+
// Skip this duplicate feature
417+
continue;
418+
}
419+
420+
featureIdToProvider.put(feature.getId(), providerName);
421+
// Create new Feature instances to ensure proper
422+
// isolation
423+
features.add(new Feature(feature));
424+
}
425+
}
426+
}
427+
428+
if (!features.isEmpty()) {
429+
getLogger().debug("Loaded {} feature flags from providers",
430+
features.size());
431+
}
432+
} catch (Exception e) {
433+
getLogger().warn("Failed to load feature flags from providers", e);
434+
}
435+
}
428436
}

0 commit comments

Comments
 (0)