This is an automated email from the ASF dual-hosted git repository.

joerghoh pushed a commit to branch SLING-13393
in repository 
https://gitbox.apache.org/repos/asf/sling-org-apache-sling-i18n.git

commit 298a7aa0e3f251b7c6d01613afbaec883165bc01
Author: Joerg Hoh <[email protected]>
AuthorDate: Wed Oct 14 17:41:13 2026 +0200

    Refresh i18n parents before dependent bundles
    
    Co-authored-by: Copilot <[email protected]>
---
 README.md                                          |  8 ++
 .../sling/i18n/impl/JcrResourceBundleProvider.java |  8 +-
 .../ConcurrentJcrResourceBundleLoadingTest.java    | 96 ++++++++++++++++++++++
 .../i18n/it/EagerResourceBundleProviderIT.java     | 38 +++++++++
 .../sling/i18n/it/ResourceBundleProviderIT.java    | 14 ++++
 5 files changed, 160 insertions(+), 4 deletions(-)

diff --git a/README.md b/README.md
index e6e2d53..64dc559 100644
--- a/README.md
+++ b/README.md
@@ -8,3 +8,11 @@ This module is part of the [Apache 
Sling](https://sling.apache.org) project.
 
 Support for creating Java I18N ResourceBundles from repository
 resources. Further details in 
<https://sling.apache.org/documentation/bundles/internationalization-support-i18n.html>.
+
+## Dictionary updates
+
+When `preload.bundles` is enabled, an individual bundle refresh reloads parents
+before their dependent locales so that refreshed bundles use current fallback
+translations. With preloading disabled, affected bundles are invalidated and
+loaded on demand. Existing bundle objects remain snapshots; callers should
+request a bundle again to obtain refreshed translations.
diff --git 
a/src/main/java/org/apache/sling/i18n/impl/JcrResourceBundleProvider.java 
b/src/main/java/org/apache/sling/i18n/impl/JcrResourceBundleProvider.java
index 15a57e9..97047ab 100644
--- a/src/main/java/org/apache/sling/i18n/impl/JcrResourceBundleProvider.java
+++ b/src/main/java/org/apache/sling/i18n/impl/JcrResourceBundleProvider.java
@@ -407,14 +407,14 @@ public class JcrResourceBundleProvider
                 }
             }
         }
-        for (JcrResourceBundle dependentBundle : dependentBundles) {
-            reloadBundle(new Key(dependentBundle.getBaseName(), 
dependentBundle.getLocale()));
-        }
-
         if (this.preloadBundles && !resourceBundleRegistry.isClosed()) {
             // reload the bundle from the repository (will also fill cache and 
register as a service)
             getResourceBundleInternal(null, key.baseName, key.locale, true);
         }
+
+        for (JcrResourceBundle dependentBundle : dependentBundles) {
+            reloadBundle(new Key(dependentBundle.getBaseName(), 
dependentBundle.getLocale()));
+        }
     }
 
     // ---------- SCR Integration 
----------------------------------------------
diff --git 
a/src/test/java/org/apache/sling/i18n/impl/ConcurrentJcrResourceBundleLoadingTest.java
 
b/src/test/java/org/apache/sling/i18n/impl/ConcurrentJcrResourceBundleLoadingTest.java
index 7f3d506..34bd61d 100644
--- 
a/src/test/java/org/apache/sling/i18n/impl/ConcurrentJcrResourceBundleLoadingTest.java
+++ 
b/src/test/java/org/apache/sling/i18n/impl/ConcurrentJcrResourceBundleLoadingTest.java
@@ -35,6 +35,10 @@ import java.util.concurrent.Executors;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
 
+import org.apache.sling.api.resource.ModifiableValueMap;
+import org.apache.sling.api.resource.Resource;
+import org.apache.sling.api.resource.ResourceResolver;
+import org.apache.sling.api.resource.ResourceResolverFactory;
 import org.apache.sling.commons.scheduler.ScheduleOptions;
 import org.apache.sling.commons.scheduler.Scheduler;
 import org.apache.sling.i18n.impl.JcrResourceBundleProvider.Key;
@@ -46,6 +50,7 @@ import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.Parameterized;
 import org.mockito.Mockito;
+import org.osgi.framework.Constants;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
@@ -54,6 +59,7 @@ import static org.junit.Assert.assertNotSame;
 import static org.junit.Assert.assertSame;
 import static org.junit.Assert.fail;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
 
 /**
  * Test case to verify that each bundle is only loaded once, even
@@ -74,9 +80,18 @@ public class ConcurrentJcrResourceBundleLoadingTest {
     public Boolean preload = Boolean.FALSE;
 
     private JcrResourceBundleProvider provider;
+    private final List<Resource> dictionaryRoots = new ArrayList<>();
 
     @Before
     public void setup() throws Exception {
+        ResourceResolver resolver = Mockito.spy(context.resourceResolver());
+        Mockito.doNothing().when(resolver).close();
+        Mockito.doAnswer(query -> dictionaryRoots.iterator())
+                .when(resolver)
+                .findResources(eq(JcrResourceBundle.QUERY_LANGUAGE_ROOTS), 
eq("xpath"));
+        ResourceResolverFactory resolverFactory = 
Mockito.mock(ResourceResolverFactory.class);
+        
Mockito.when(resolverFactory.getServiceResourceResolver(null)).thenReturn(resolver);
+        context.registerService(ResourceResolverFactory.class, 
resolverFactory, Constants.SERVICE_RANKING, 1000);
         // mock other required services
         Scheduler mockScheduler = context.registerService(Scheduler.class, 
Mockito.mock(Scheduler.class));
         // mock this call to avoid a NPE during activation
@@ -153,12 +168,92 @@ public class ConcurrentJcrResourceBundleLoadingTest {
         assertSame(english, provider.getResourceBundle(Locale.ENGLISH));
         ResourceBundle german2 = provider.getResourceBundle(Locale.GERMAN);
         assertNotSame(german2, german);
+        assertSame(english, ((JcrResourceBundle) german2).getParent());
         assertSame(english, provider.getResourceBundle(Locale.ENGLISH));
         assertSame(german2, provider.getResourceBundle(Locale.GERMAN));
         assertSame(english, provider.getResourceBundle(Locale.ENGLISH));
         assertSame(german2, provider.getResourceBundle(Locale.GERMAN));
     }
 
+    @Test
+    public void reloadedFallbacksUseCurrentParents() {
+        Resource englishDictionary = createDictionary("en", "en");
+        Resource fallback =
+                context.create().resource(englishDictionary.getPath() + 
"/fallback", "sling:message", "old English");
+        Resource germanDictionary = createDictionary("de", "de");
+        context.create().resource(germanDictionary.getPath() + "/override", 
"sling:message", "German override");
+        Resource countryDictionary = createDictionary("de_DE", "de_DE");
+        context.create().resource(countryDictionary.getPath() + "/override", 
"sling:message", "Country override");
+        Resource namedDictionary = createDictionary("named-en", "en", "named");
+        context.create().resource(namedDictionary.getPath() + "/named", 
"sling:message", "Named English");
+
+        ResourceBundle english = provider.getResourceBundle(Locale.ENGLISH);
+        ResourceBundle german = provider.getResourceBundle(Locale.GERMAN);
+        ResourceBundle country = provider.getResourceBundle(Locale.GERMANY);
+        ResourceBundle french = provider.getResourceBundle(Locale.FRENCH);
+        ResourceBundle named = provider.getResourceBundle("named", 
Locale.GERMANY);
+        assertEquals("old English", country.getString("fallback"));
+        assertEquals("old English", french.getString("fallback"));
+
+        fallback.adaptTo(ModifiableValueMap.class).put("sling:message", "new 
English");
+        provider.reloadBundle(new Key(null, Locale.ENGLISH));
+
+        ResourceBundle english2 = provider.getResourceBundle(Locale.ENGLISH);
+        ResourceBundle german2 = provider.getResourceBundle(Locale.GERMAN);
+        ResourceBundle country2 = provider.getResourceBundle(Locale.GERMANY);
+        ResourceBundle french2 = provider.getResourceBundle(Locale.FRENCH);
+        assertNotSame(english, english2);
+        assertNotSame(german, german2);
+        assertNotSame(country, country2);
+        assertNotSame(french, french2);
+        assertSame(english2, ((JcrResourceBundle) german2).getParent());
+        assertSame(german2, ((JcrResourceBundle) country2).getParent());
+        assertSame(english2, ((JcrResourceBundle) french2).getParent());
+        assertEquals("new English", country2.getString("fallback"));
+        assertEquals("new English", french2.getString("fallback"));
+        assertEquals("German override", german2.getString("override"));
+        assertEquals("Country override", country2.getString("override"));
+        assertSame(named, provider.getResourceBundle("named", Locale.GERMANY));
+        assertEquals("Named English", named.getString("named"));
+    }
+
+    @Test
+    public void namedFallbackRefreshDoesNotReplaceOtherBasenames() {
+        Resource dictionary = createDictionary("named-en", "en", "named");
+        Resource message =
+                context.create().resource(dictionary.getPath() + "/fallback", 
"sling:message", "old named English");
+        ResourceBundle english = provider.getResourceBundle(Locale.ENGLISH);
+        ResourceBundle german = provider.getResourceBundle(Locale.GERMAN);
+        ResourceBundle named = provider.getResourceBundle("named", 
Locale.GERMANY);
+        assertEquals("old named English", named.getString("fallback"));
+
+        message.adaptTo(ModifiableValueMap.class).put("sling:message", "new 
named English");
+        provider.reloadBundle(new Key("named", Locale.ENGLISH));
+
+        ResourceBundle named2 = provider.getResourceBundle("named", 
Locale.GERMANY);
+        JcrResourceBundle namedGerman = (JcrResourceBundle) 
provider.getResourceBundle("named", Locale.GERMAN);
+        assertNotSame(named, named2);
+        assertSame(namedGerman, ((JcrResourceBundle) named2).getParent());
+        assertSame(provider.getResourceBundle("named", Locale.ENGLISH), 
namedGerman.getParent());
+        assertEquals("new named English", named2.getString("fallback"));
+        assertSame(english, provider.getResourceBundle(Locale.ENGLISH));
+        assertSame(german, provider.getResourceBundle(Locale.GERMAN));
+    }
+
+    private Resource createDictionary(String name, String language, String... 
baseNames) {
+        Resource dictionary = context.create()
+                .resource(
+                        "/apps/i18n/" + name,
+                        "jcr:mixinTypes",
+                        new String[] {"mix:language"},
+                        "jcr:language",
+                        language,
+                        "sling:basename",
+                        baseNames);
+        dictionaryRoots.add(dictionary);
+        return dictionary;
+    }
+
     @Test
     public void newBundleUsedAsParentAfterReload() throws Exception {
         ResourceBundle english = provider.getResourceBundle(Locale.ENGLISH);
@@ -170,6 +265,7 @@ public class ConcurrentJcrResourceBundleLoadingTest {
         assertNotSame(english2, english);
         ResourceBundle german2 = provider.getResourceBundle(Locale.GERMAN);
         assertNotSame(german2, german);
+        assertSame(english2, ((JcrResourceBundle) german2).getParent());
         assertSame(english2, provider.getResourceBundle(Locale.ENGLISH));
         assertSame(german2, provider.getResourceBundle(Locale.GERMAN));
         assertSame(english2, provider.getResourceBundle(Locale.ENGLISH));
diff --git 
a/src/test/java/org/apache/sling/i18n/it/EagerResourceBundleProviderIT.java 
b/src/test/java/org/apache/sling/i18n/it/EagerResourceBundleProviderIT.java
new file mode 100644
index 0000000..df51deb
--- /dev/null
+++ b/src/test/java/org/apache/sling/i18n/it/EagerResourceBundleProviderIT.java
@@ -0,0 +1,38 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 org.apache.sling.i18n.it;
+
+import org.ops4j.pax.exam.Configuration;
+import org.ops4j.pax.exam.Option;
+import org.ops4j.pax.exam.OptionUtils;
+
+import static org.ops4j.pax.exam.cm.ConfigurationAdminOptions.newConfiguration;
+
+public class EagerResourceBundleProviderIT extends ResourceBundleProviderIT {
+
+    @Override
+    @Configuration
+    public Option[] configuration() {
+        return OptionUtils.combine(
+                super.configuration(),
+                
newConfiguration("org.apache.sling.i18n.impl.JcrResourceBundleProvider")
+                        .put("preload.bundles", true)
+                        .asOption());
+    }
+}
diff --git 
a/src/test/java/org/apache/sling/i18n/it/ResourceBundleProviderIT.java 
b/src/test/java/org/apache/sling/i18n/it/ResourceBundleProviderIT.java
index ed2419b..d5ae71c 100644
--- a/src/test/java/org/apache/sling/i18n/it/ResourceBundleProviderIT.java
+++ b/src/test/java/org/apache/sling/i18n/it/ResourceBundleProviderIT.java
@@ -175,6 +175,20 @@ public class ResourceBundleProviderIT extends 
I18nTestSupport {
         assertMessage(MSG_KEY1, Locale.ENGLISH, BASENAME, "overwritten");
     }
 
+    @Test
+    public void testFallbackChangesForUntypedMessages() throws 
RepositoryException {
+        // Message loading also supports entries without the 
sling:MessageEntry type.
+        Node message = enRoot.addNode("fallback", "nt:unstructured");
+        message.setProperty("sling:key", MSG_KEY2);
+        message.setProperty("sling:message", "old English fallback");
+        session.save();
+        assertMessages(MSG_KEY2, "old English fallback", "old English 
fallback", "old English fallback");
+
+        message.setProperty("sling:message", "new English fallback");
+        session.save();
+        assertMessages(MSG_KEY2, "new English fallback", "new English 
fallback", "new English fallback");
+    }
+
     @Test
     public void testChangesDetection() throws RepositoryException {
         // set a key which is only available in the en dictionary

Reply via email to