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

lukaszlenart pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/struts.git


The following commit(s) were added to refs/heads/main by this push:
     new 4c94c4f89 WW-5549 Fix I18nInterceptor supportedLocale breaking 
request_locale (#1594)
4c94c4f89 is described below

commit 4c94c4f89a15b3102c3822dfc64dca15ee42a731
Author: Lukasz Lenart <[email protected]>
AuthorDate: Fri Mar 6 07:50:06 2026 +0100

    WW-5549 Fix I18nInterceptor supportedLocale breaking request_locale (#1594)
    
    * fix(i18n): ensure request_locale takes precedence over Accept-Language 
when supportedLocale is configured
    
    When supportedLocale was configured on the I18nInterceptor, the 
Accept-Language
    header match in AcceptLanguageLocaleHandler.find() returned early before
    SessionLocaleHandler/CookieLocaleHandler ever checked their explicit locale
    parameters (request_locale, request_cookie_locale). This made it impossible
    to switch locale via request parameters when supportedLocale was set.
    
    Changes:
    - Reorder AcceptLanguageLocaleHandler.find() to check request_only_locale
      before Accept-Language matching
    - Reorder SessionLocaleHandler.find() to check request_locale before super
    - Reorder CookieLocaleHandler.find() to check request_cookie_locale before 
super
    - Add isLocaleSupported() helper to validate locales against supportedLocale
    - Filter all locale sources (params, session, cookies) through 
supportedLocale
    - Add 4 tests covering the bug scenario and supportedLocale filtering
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    Co-Authored-By: Claude <[email protected]>
    
    * test(i18n): cover missing supportedLocale locale-selection paths
    
    Add regression tests for unsupported request_cookie_locale fallback, stored 
cookie revalidation, and request_only_locale precedence to lock in WW-5549 
behavior across remaining branches.
    
    Co-authored-by: Cursor <[email protected]>
    
    * refactor(i18n): extract locale handlers with deprecated inner wrappers
    
    Move locale handler implementations into a dedicated interceptor.i18n 
package with reusable abstract bases, keep thin deprecated inner wrappers in 
I18nInterceptor for one release-cycle compatibility, and document the 
LocaleHandler contract.
    
    Co-authored-by: Cursor <[email protected]>
    
    * fix(i18n): validate request_only_locale against supportedLocale and fix 
Accept-Language fallback
    
    RequestLocaleHandler.find() now checks isLocaleSupported() before
    returning, preventing unsupported locales from slipping through via
    the request_only_locale parameter. AcceptLanguageLocaleHandler.find()
    now returns the first Accept-Language locale when supportedLocale is
    empty, fixing ACCEPT_LANGUAGE storage mode with no filter configured.
    
    Also includes refactoring: deprecated inner classes collapsed with
    LocaleHandlerAdapter, shouldStore field encapsulated via disableStore(),
    logger pattern standardized to private static final, and class-level
    JavaDoc added to handler classes.
    
    Made-with: Cursor
    
    ---------
    
    Co-authored-by: Claude <[email protected]>
    Co-authored-by: Cursor <[email protected]>
---
 .../struts2/interceptor/I18nInterceptor.java       | 274 +++++++++------------
 .../interceptor/i18n/AbstractLocaleHandler.java    |  49 ++++
 .../i18n/AbstractStoredLocaleHandler.java          |  81 ++++++
 .../i18n/AcceptLanguageLocaleHandler.java          |  78 ++++++
 .../interceptor/i18n/CookieLocaleHandler.java      |  78 ++++++
 .../struts2/interceptor/i18n/LocaleHandler.java    |  63 +++++
 .../interceptor/i18n/RequestLocaleHandler.java     |  88 +++++++
 .../interceptor/i18n/SessionLocaleHandler.java     |  90 +++++++
 .../struts2/interceptor/I18nInterceptorTest.java   | 168 +++++++++++--
 ...9-i18n-supportedlocale-breaks-request-locale.md | 152 ++++++++++++
 10 files changed, 947 insertions(+), 174 deletions(-)

diff --git 
a/core/src/main/java/org/apache/struts2/interceptor/I18nInterceptor.java 
b/core/src/main/java/org/apache/struts2/interceptor/I18nInterceptor.java
index d0837a95b..204780ca9 100644
--- a/core/src/main/java/org/apache/struts2/interceptor/I18nInterceptor.java
+++ b/core/src/main/java/org/apache/struts2/interceptor/I18nInterceptor.java
@@ -26,18 +26,11 @@ import org.apache.struts2.util.TextParseUtil;
 import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
 import org.apache.logging.log4j.message.ParameterizedMessage;
-import org.apache.struts2.ServletActionContext;
 import org.apache.struts2.dispatcher.HttpParameters;
 import org.apache.struts2.dispatcher.Parameter;
 
-import jakarta.servlet.http.Cookie;
-import jakarta.servlet.http.HttpServletResponse;
-import jakarta.servlet.http.HttpSession;
-
 import java.util.Collections;
-import java.util.Enumeration;
 import java.util.Locale;
-import java.util.Map;
 import java.util.Set;
 import java.util.stream.Collectors;
 
@@ -108,6 +101,10 @@ public class I18nInterceptor extends AbstractInterceptor {
             .collect(Collectors.toSet());
     }
 
+    protected boolean isLocaleSupported(Locale locale) {
+        return supportedLocale.isEmpty() || supportedLocale.contains(locale);
+    }
+
     @Inject
     public void setLocaleProviderFactory(LocaleProviderFactory 
localeProviderFactory) {
         this.localeProviderFactory = localeProviderFactory;
@@ -219,202 +216,167 @@ public class I18nInterceptor extends 
AbstractInterceptor {
     /**
      * Uses to handle reading/storing Locale from/in different locations
      */
-    protected interface LocaleHandler {
-        Locale find();
-        Locale read(ActionInvocation invocation);
-        Locale store(ActionInvocation invocation, Locale locale);
-        boolean shouldStore();
+    @Deprecated(forRemoval = true, since = "7.2.0")
+    protected interface LocaleHandler extends 
org.apache.struts2.interceptor.i18n.LocaleHandler {
     }
 
-    protected class RequestLocaleHandler implements LocaleHandler {
+    /**
+     * @deprecated Since 7.2.0, use the top-level handler classes in {@code 
org.apache.struts2.interceptor.i18n}.
+     * Scheduled for removal in the next release cycle.
+     */
+    @Deprecated(forRemoval = true, since = "7.2.0")
+    protected abstract class LocaleHandlerAdapter implements LocaleHandler {
 
-        protected ActionInvocation actionInvocation;
-        protected boolean shouldStore = true;
+        private final org.apache.struts2.interceptor.i18n.LocaleHandler 
delegate;
 
-        protected RequestLocaleHandler(ActionInvocation invocation) {
-            actionInvocation = invocation;
+        protected 
LocaleHandlerAdapter(org.apache.struts2.interceptor.i18n.LocaleHandler 
delegate) {
+            this.delegate = delegate;
         }
 
+        @Override
         public Locale find() {
-            LOG.debug("Searching locale in request under parameter {}", 
requestOnlyParameterName);
-
-            Parameter requestedLocale = findLocaleParameter(actionInvocation, 
requestOnlyParameterName);
-            if (requestedLocale.isDefined()) {
-                return getLocaleFromParam(requestedLocale.getValue());
-            }
-
-            return null;
+            return delegate.find();
         }
 
         @Override
-        public Locale store(ActionInvocation invocation, Locale locale) {
-            return locale;
+        public Locale read(ActionInvocation invocation) {
+            return delegate.read(invocation);
         }
 
         @Override
-        public Locale read(ActionInvocation invocation) {
-            LOG.debug("Searching current Invocation context");
-            // no overriding locale definition found, stay with current 
invocation (=browser) locale
-            Locale locale = invocation.getInvocationContext().getLocale();
-            if (locale != null) {
-                LOG.debug("Applied invocation context locale: {}", locale);
-            }
-            return locale;
+        public Locale store(ActionInvocation invocation, Locale locale) {
+            return delegate.store(invocation, locale);
         }
 
         @Override
         public boolean shouldStore() {
-            return shouldStore;
+            return delegate.shouldStore();
         }
     }
 
-    protected class AcceptLanguageLocaleHandler extends RequestLocaleHandler {
-
-        protected AcceptLanguageLocaleHandler(ActionInvocation invocation) {
-            super(invocation);
-        }
+    private org.apache.struts2.interceptor.i18n.RequestLocaleHandler 
createRequestDelegate(ActionInvocation invocation, String requestOnlyParam) {
+        return new 
org.apache.struts2.interceptor.i18n.RequestLocaleHandler(invocation, 
requestOnlyParam) {
+            @Override
+            protected Locale getLocaleFromParam(String requestedLocale) {
+                return 
I18nInterceptor.this.getLocaleFromParam(requestedLocale);
+            }
 
-        @Override
-        @SuppressWarnings("rawtypes")
-        public Locale find() {
-            if (!supportedLocale.isEmpty()) {
-                Enumeration locales = 
actionInvocation.getInvocationContext().getServletRequest().getLocales();
-                while (locales.hasMoreElements()) {
-                    Locale locale = (Locale) locales.nextElement();
-                    if (supportedLocale.contains(locale)) {
-                        return locale;
-                    }
-                }
+            @Override
+            protected Parameter findLocaleParameter(ActionInvocation inv, 
String paramName) {
+                return I18nInterceptor.this.findLocaleParameter(inv, 
paramName);
             }
-            return super.find();
-        }
 
+            @Override
+            protected boolean isLocaleSupported(Locale locale) {
+                return I18nInterceptor.this.isLocaleSupported(locale);
+            }
+        };
     }
 
-    protected class SessionLocaleHandler extends AcceptLanguageLocaleHandler {
-
-        protected SessionLocaleHandler(ActionInvocation invocation) {
-            super(invocation);
-        }
-
-        @Override
-        public Locale find() {
-            Locale requestOnlyLocale = super.find();
-
-            if (requestOnlyLocale != null) {
-                LOG.debug("Found locale under request only param, it won't be 
stored in session!");
-                shouldStore = false;
-                return requestOnlyLocale;
+    private org.apache.struts2.interceptor.i18n.AcceptLanguageLocaleHandler 
createAcceptLanguageDelegate(ActionInvocation invocation) {
+        return new 
org.apache.struts2.interceptor.i18n.AcceptLanguageLocaleHandler(
+            invocation, requestOnlyParameterName, supportedLocale
+        ) {
+            @Override
+            protected Locale getLocaleFromParam(String requestedLocale) {
+                return 
I18nInterceptor.this.getLocaleFromParam(requestedLocale);
             }
 
-            LOG.debug("Searching locale in request under parameter {}", 
parameterName);
-            Parameter requestedLocale = findLocaleParameter(actionInvocation, 
parameterName);
-            if (requestedLocale.isDefined()) {
-                return getLocaleFromParam(requestedLocale.getValue());
+            @Override
+            protected Parameter findLocaleParameter(ActionInvocation inv, 
String paramName) {
+                return I18nInterceptor.this.findLocaleParameter(inv, 
paramName);
             }
 
-            return null;
-        }
-
-        @Override
-        public Locale store(ActionInvocation invocation, Locale locale) {
-            Map<String, Object> session = 
invocation.getInvocationContext().getSession();
-
-            if (session != null) {
-                String sessionId = 
ServletActionContext.getRequest().getSession().getId();
-                synchronized (sessionId.intern()) {
-                    session.put(attributeName, locale);
-                }
+            @Override
+            protected boolean isLocaleSupported(Locale locale) {
+                return I18nInterceptor.this.isLocaleSupported(locale);
             }
+        };
+    }
 
-            return locale;
-        }
-
-        @Override
-        public Locale read(ActionInvocation invocation) {
-            Locale locale = null;
-
-            LOG.debug("Checks session for saved locale");
-            HttpSession session = 
ServletActionContext.getRequest().getSession(false);
-
-            if (session != null) {
-                String sessionId = session.getId();
-                synchronized (sessionId.intern()) {
-                    Object sessionLocale = 
invocation.getInvocationContext().getSession().get(attributeName);
-                    if (sessionLocale instanceof Locale) {
-                        locale = (Locale) sessionLocale;
-                        LOG.debug("Applied session locale: {}", locale);
-                    }
-                }
+    private org.apache.struts2.interceptor.i18n.SessionLocaleHandler 
createSessionDelegate(ActionInvocation invocation) {
+        return new org.apache.struts2.interceptor.i18n.SessionLocaleHandler(
+            invocation, requestOnlyParameterName, supportedLocale, 
parameterName, attributeName
+        ) {
+            @Override
+            protected Locale getLocaleFromParam(String requestedLocale) {
+                return 
I18nInterceptor.this.getLocaleFromParam(requestedLocale);
             }
 
-            if (locale == null) {
-                LOG.debug("No Locale defined in session, fetching from current 
request and it won't be stored in session!");
-                shouldStore = false;
-                locale = super.read(invocation);
-            } else {
-                LOG.debug("Found stored Locale {} in session, using it!", 
locale);
+            @Override
+            protected Parameter findLocaleParameter(ActionInvocation inv, 
String paramName) {
+                return I18nInterceptor.this.findLocaleParameter(inv, 
paramName);
             }
 
-            return locale;
-        }
+            @Override
+            protected boolean isLocaleSupported(Locale locale) {
+                return I18nInterceptor.this.isLocaleSupported(locale);
+            }
+        };
     }
 
-    protected class CookieLocaleHandler extends AcceptLanguageLocaleHandler {
-        protected CookieLocaleHandler(ActionInvocation invocation) {
-            super(invocation);
-        }
-
-        @Override
-        public Locale find() {
-            Locale requestOnlySessionLocale = super.find();
+    private org.apache.struts2.interceptor.i18n.CookieLocaleHandler 
createCookieDelegate(ActionInvocation invocation) {
+        return new org.apache.struts2.interceptor.i18n.CookieLocaleHandler(
+            invocation, requestOnlyParameterName, supportedLocale, 
requestCookieParameterName, attributeName
+        ) {
+            @Override
+            protected Locale getLocaleFromParam(String requestedLocale) {
+                return 
I18nInterceptor.this.getLocaleFromParam(requestedLocale);
+            }
 
-            if (requestOnlySessionLocale != null) {
-                shouldStore = false;
-                return requestOnlySessionLocale;
+            @Override
+            protected Parameter findLocaleParameter(ActionInvocation inv, 
String paramName) {
+                return I18nInterceptor.this.findLocaleParameter(inv, 
paramName);
             }
 
-            LOG.debug("Searching locale in request under parameter {}", 
requestCookieParameterName);
-            Parameter requestedLocale = findLocaleParameter(actionInvocation, 
requestCookieParameterName);
-            if (requestedLocale.isDefined()) {
-                return getLocaleFromParam(requestedLocale.getValue());
+            @Override
+            protected boolean isLocaleSupported(Locale locale) {
+                return I18nInterceptor.this.isLocaleSupported(locale);
             }
+        };
+    }
 
-            return null;
+    /**
+     * @deprecated Since 7.2.0, use {@link 
org.apache.struts2.interceptor.i18n.RequestLocaleHandler}.
+     * Scheduled for removal in the next release cycle.
+     */
+    @Deprecated(forRemoval = true, since = "7.2.0")
+    protected class RequestLocaleHandler extends LocaleHandlerAdapter {
+        protected RequestLocaleHandler(ActionInvocation invocation) {
+            super(createRequestDelegate(invocation, requestOnlyParameterName));
         }
+    }
 
-        @Override
-        public Locale store(ActionInvocation invocation, Locale locale) {
-            HttpServletResponse response = ServletActionContext.getResponse();
-
-            Cookie cookie = new Cookie(attributeName, locale.toString());
-            cookie.setMaxAge(1209600); // two weeks
-            response.addCookie(cookie);
-
-            return locale;
+    /**
+     * @deprecated Since 7.2.0, use {@link 
org.apache.struts2.interceptor.i18n.AcceptLanguageLocaleHandler}.
+     * Scheduled for removal in the next release cycle.
+     */
+    @Deprecated(forRemoval = true, since = "7.2.0")
+    protected class AcceptLanguageLocaleHandler extends LocaleHandlerAdapter {
+        protected AcceptLanguageLocaleHandler(ActionInvocation invocation) {
+            super(createAcceptLanguageDelegate(invocation));
         }
+    }
 
-        @Override
-        public Locale read(ActionInvocation invocation) {
-            Locale locale = null;
-
-            Cookie[] cookies = ServletActionContext.getRequest().getCookies();
-            if (cookies != null) {
-                for (Cookie cookie : cookies) {
-                    if (attributeName.equals(cookie.getName())) {
-                        locale = getLocaleFromParam(cookie.getValue());
-                    }
-                }
-            }
+    /**
+     * @deprecated Since 7.2.0, use {@link 
org.apache.struts2.interceptor.i18n.SessionLocaleHandler}.
+     * Scheduled for removal in the next release cycle.
+     */
+    @Deprecated(forRemoval = true, since = "7.2.0")
+    protected class SessionLocaleHandler extends LocaleHandlerAdapter {
+        protected SessionLocaleHandler(ActionInvocation invocation) {
+            super(createSessionDelegate(invocation));
+        }
+    }
 
-            if (locale == null) {
-                LOG.debug("No Locale defined in cookie, fetching from current 
request and it won't be stored!");
-                shouldStore = false;
-                locale = super.read(invocation);
-            } else {
-                LOG.debug("Found stored Locale {} in cookie, using it!", 
locale);
-            }
-            return locale;
+    /**
+     * @deprecated Since 7.2.0, use {@link 
org.apache.struts2.interceptor.i18n.CookieLocaleHandler}.
+     * Scheduled for removal in the next release cycle.
+     */
+    @Deprecated(forRemoval = true, since = "7.2.0")
+    protected class CookieLocaleHandler extends LocaleHandlerAdapter {
+        protected CookieLocaleHandler(ActionInvocation invocation) {
+            super(createCookieDelegate(invocation));
         }
     }
 
diff --git 
a/core/src/main/java/org/apache/struts2/interceptor/i18n/AbstractLocaleHandler.java
 
b/core/src/main/java/org/apache/struts2/interceptor/i18n/AbstractLocaleHandler.java
new file mode 100644
index 000000000..935081663
--- /dev/null
+++ 
b/core/src/main/java/org/apache/struts2/interceptor/i18n/AbstractLocaleHandler.java
@@ -0,0 +1,49 @@
+/*
+ * 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.struts2.interceptor.i18n;
+
+import org.apache.struts2.ActionInvocation;
+import org.apache.struts2.dispatcher.Parameter;
+
+import java.util.Locale;
+
+public abstract class AbstractLocaleHandler implements LocaleHandler {
+
+    protected final ActionInvocation actionInvocation;
+    private boolean shouldStore = true;
+
+    protected AbstractLocaleHandler(ActionInvocation invocation) {
+        this.actionInvocation = invocation;
+    }
+
+    @Override
+    public boolean shouldStore() {
+        return shouldStore;
+    }
+
+    protected void disableStore() {
+        this.shouldStore = false;
+    }
+
+    protected abstract Locale getLocaleFromParam(String requestedLocale);
+
+    protected abstract Parameter findLocaleParameter(ActionInvocation 
invocation, String parameterName);
+
+    protected abstract boolean isLocaleSupported(Locale locale);
+}
diff --git 
a/core/src/main/java/org/apache/struts2/interceptor/i18n/AbstractStoredLocaleHandler.java
 
b/core/src/main/java/org/apache/struts2/interceptor/i18n/AbstractStoredLocaleHandler.java
new file mode 100644
index 000000000..659a2fb6e
--- /dev/null
+++ 
b/core/src/main/java/org/apache/struts2/interceptor/i18n/AbstractStoredLocaleHandler.java
@@ -0,0 +1,81 @@
+/*
+ * 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.struts2.interceptor.i18n;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.apache.struts2.ActionInvocation;
+import org.apache.struts2.dispatcher.Parameter;
+
+import java.util.Locale;
+import java.util.Set;
+
+public abstract class AbstractStoredLocaleHandler extends 
AcceptLanguageLocaleHandler {
+
+    private static final Logger LOG = 
LogManager.getLogger(AbstractStoredLocaleHandler.class);
+
+    private final String explicitParameterName;
+
+    protected AbstractStoredLocaleHandler(ActionInvocation invocation,
+                                          String requestOnlyParameterName,
+                                          Set<Locale> supportedLocale,
+                                          String explicitParameterName) {
+        super(invocation, requestOnlyParameterName, supportedLocale);
+        this.explicitParameterName = explicitParameterName;
+    }
+
+    protected Locale findExplicitLocale() {
+        LOG.debug("Searching locale in request under parameter {}", 
explicitParameterName);
+        Parameter requestedLocale = findLocaleParameter(actionInvocation, 
explicitParameterName);
+        if (requestedLocale.isDefined()) {
+            Locale locale = getLocaleFromParam(requestedLocale.getValue());
+            if (locale != null && isLocaleSupported(locale)) {
+                return locale;
+            }
+            LOG.debug("Requested locale {} is not supported, ignoring", 
requestedLocale.getValue());
+        }
+        return null;
+    }
+
+    protected Locale findRequestOnlyLocale() {
+        Locale requestOnlyLocale = findRequestOnlyParamLocale();
+        if (requestOnlyLocale != null) {
+            LOG.debug("Found locale under request only param, it won't be 
stored!");
+            disableStore();
+            return requestOnlyLocale;
+        }
+        return null;
+    }
+
+    protected Locale normalizeStoredLocale(Locale locale, ActionInvocation 
invocation) {
+        if (locale != null && !isLocaleSupported(locale)) {
+            LOG.debug("Stored locale {} is not in supportedLocale, ignoring", 
locale);
+            locale = null;
+        }
+
+        if (locale == null) {
+            LOG.debug("No Locale defined in storage, fetching from current 
request and it won't be stored!");
+            disableStore();
+            return super.read(invocation);
+        } else {
+            LOG.debug("Found stored Locale {}, using it!", locale);
+            return locale;
+        }
+    }
+}
diff --git 
a/core/src/main/java/org/apache/struts2/interceptor/i18n/AcceptLanguageLocaleHandler.java
 
b/core/src/main/java/org/apache/struts2/interceptor/i18n/AcceptLanguageLocaleHandler.java
new file mode 100644
index 000000000..6ca229987
--- /dev/null
+++ 
b/core/src/main/java/org/apache/struts2/interceptor/i18n/AcceptLanguageLocaleHandler.java
@@ -0,0 +1,78 @@
+/*
+ * 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.struts2.interceptor.i18n;
+
+import org.apache.struts2.ActionInvocation;
+
+import java.util.Enumeration;
+import java.util.Locale;
+import java.util.Set;
+
+/**
+ * Resolves locale by first checking the request-only parameter and then 
falling back
+ * to the browser's {@code Accept-Language} header.
+ * <p>
+ * When a {@code supportedLocale} set is configured, only Accept-Language 
values present
+ * in that set are accepted. When the set is empty (the default), the first 
locale
+ * advertised by the browser is returned as-is.
+ *
+ * @see RequestLocaleHandler
+ * @see AbstractStoredLocaleHandler
+ */
+public abstract class AcceptLanguageLocaleHandler extends RequestLocaleHandler 
{
+
+    private final Set<Locale> supportedLocale;
+
+    protected AcceptLanguageLocaleHandler(ActionInvocation invocation, String 
requestOnlyParameterName, Set<Locale> supportedLocale) {
+        super(invocation, requestOnlyParameterName);
+        this.supportedLocale = supportedLocale;
+    }
+
+    @Override
+    public Locale find() {
+        Locale locale = findRequestOnlyParamLocale();
+        if (locale != null) {
+            return locale;
+        }
+        return findAcceptLanguageLocale();
+    }
+
+    @Override
+    public Locale read(ActionInvocation invocation) {
+        if (!supportedLocale.isEmpty()) {
+            Locale locale = findAcceptLanguageLocale();
+            if (locale != null) {
+                return locale;
+            }
+        }
+        return super.read(invocation);
+    }
+
+    @SuppressWarnings("rawtypes")
+    protected Locale findAcceptLanguageLocale() {
+        Enumeration locales = 
actionInvocation.getInvocationContext().getServletRequest().getLocales();
+        while (locales.hasMoreElements()) {
+            Locale acceptLocale = (Locale) locales.nextElement();
+            if (supportedLocale.isEmpty() || 
supportedLocale.contains(acceptLocale)) {
+                return acceptLocale;
+            }
+        }
+        return null;
+    }
+}
diff --git 
a/core/src/main/java/org/apache/struts2/interceptor/i18n/CookieLocaleHandler.java
 
b/core/src/main/java/org/apache/struts2/interceptor/i18n/CookieLocaleHandler.java
new file mode 100644
index 000000000..7c0efc0de
--- /dev/null
+++ 
b/core/src/main/java/org/apache/struts2/interceptor/i18n/CookieLocaleHandler.java
@@ -0,0 +1,78 @@
+/*
+ * 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.struts2.interceptor.i18n;
+
+import org.apache.struts2.ActionInvocation;
+import org.apache.struts2.ServletActionContext;
+
+import jakarta.servlet.http.Cookie;
+import jakarta.servlet.http.HttpServletResponse;
+
+import java.util.Locale;
+import java.util.Set;
+
+public abstract class CookieLocaleHandler extends AbstractStoredLocaleHandler {
+
+    private final String attributeName;
+
+    protected CookieLocaleHandler(ActionInvocation invocation,
+                                  String requestOnlyParameterName,
+                                  Set<Locale> supportedLocale,
+                                  String requestCookieParameterName,
+                                  String attributeName) {
+        super(invocation, requestOnlyParameterName, supportedLocale, 
requestCookieParameterName);
+        this.attributeName = attributeName;
+    }
+
+    @Override
+    public Locale find() {
+        Locale locale = findExplicitLocale();
+        if (locale != null) {
+            return locale;
+        }
+        return findRequestOnlyLocale();
+    }
+
+    @Override
+    public Locale store(ActionInvocation invocation, Locale locale) {
+        HttpServletResponse response = ServletActionContext.getResponse();
+
+        Cookie cookie = new Cookie(attributeName, locale.toString());
+        cookie.setMaxAge(1209600); // two weeks
+        response.addCookie(cookie);
+
+        return locale;
+    }
+
+    @Override
+    public Locale read(ActionInvocation invocation) {
+        Locale locale = null;
+
+        Cookie[] cookies = ServletActionContext.getRequest().getCookies();
+        if (cookies != null) {
+            for (Cookie cookie : cookies) {
+                if (attributeName.equals(cookie.getName())) {
+                    locale = getLocaleFromParam(cookie.getValue());
+                }
+            }
+        }
+
+        return normalizeStoredLocale(locale, invocation);
+    }
+}
diff --git 
a/core/src/main/java/org/apache/struts2/interceptor/i18n/LocaleHandler.java 
b/core/src/main/java/org/apache/struts2/interceptor/i18n/LocaleHandler.java
new file mode 100644
index 000000000..78476dc78
--- /dev/null
+++ b/core/src/main/java/org/apache/struts2/interceptor/i18n/LocaleHandler.java
@@ -0,0 +1,63 @@
+/*
+ * 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.struts2.interceptor.i18n;
+
+import org.apache.struts2.ActionInvocation;
+
+import java.util.Locale;
+
+/**
+ * Strategy used by {@code I18nInterceptor} to resolve and optionally persist 
the current request locale.
+ * <p>
+ * Implementations encapsulate locale source-specific behavior (request 
parameters, session, cookies,
+ * or Accept-Language header), while the interceptor orchestrates the overall 
lifecycle.
+ */
+public interface LocaleHandler {
+
+    /**
+     * Looks for an explicit locale override in request-scoped sources.
+     *
+     * @return a locale override or {@code null} when no explicit override is 
present
+     */
+    Locale find();
+
+    /**
+     * Reads locale from persistent/context sources when {@link #find()} did 
not resolve one.
+     *
+     * @param invocation current action invocation
+     * @return resolved locale or {@code null} when no locale could be resolved
+     */
+    Locale read(ActionInvocation invocation);
+
+    /**
+     * Persists the resolved locale when storage is enabled for the current 
handler.
+     *
+     * @param invocation current action invocation
+     * @param locale locale to store
+     * @return the effective locale to apply to the invocation context
+     */
+    Locale store(ActionInvocation invocation, Locale locale);
+
+    /**
+     * Indicates if the locale should be persisted for the current request.
+     *
+     * @return {@code true} when {@link #store(ActionInvocation, Locale)} 
should be invoked
+     */
+    boolean shouldStore();
+}
diff --git 
a/core/src/main/java/org/apache/struts2/interceptor/i18n/RequestLocaleHandler.java
 
b/core/src/main/java/org/apache/struts2/interceptor/i18n/RequestLocaleHandler.java
new file mode 100644
index 000000000..b421c8c26
--- /dev/null
+++ 
b/core/src/main/java/org/apache/struts2/interceptor/i18n/RequestLocaleHandler.java
@@ -0,0 +1,88 @@
+/*
+ * 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.struts2.interceptor.i18n;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.apache.struts2.ActionInvocation;
+import org.apache.struts2.dispatcher.Parameter;
+
+import java.util.Locale;
+
+/**
+ * Resolves locale from a request-only parameter (not persisted to session or 
cookie).
+ * <p>
+ * When a matching request parameter is present and the locale is
+ * {@linkplain #isLocaleSupported(Locale) supported}, it is applied to the 
current
+ * request only; it is never stored for subsequent requests.
+ *
+ * @see AcceptLanguageLocaleHandler
+ * @see AbstractStoredLocaleHandler
+ */
+public abstract class RequestLocaleHandler extends AbstractLocaleHandler {
+
+    private static final Logger LOG = 
LogManager.getLogger(RequestLocaleHandler.class);
+
+    private final String requestOnlyParameterName;
+
+    protected RequestLocaleHandler(ActionInvocation invocation, String 
requestOnlyParameterName) {
+        super(invocation);
+        this.requestOnlyParameterName = requestOnlyParameterName;
+    }
+
+    @Override
+    public Locale find() {
+        return findRequestOnlyParamLocale();
+    }
+
+    /**
+     * Looks up the locale from the request-only parameter without any 
additional fallback.
+     * Subclasses that add fallback logic (e.g. Accept-Language) can override 
{@link #find()}
+     * while stored-locale handlers can call this method directly to skip the 
fallback.
+     */
+    protected Locale findRequestOnlyParamLocale() {
+        LOG.debug("Searching locale in request under parameter {}", 
requestOnlyParameterName);
+
+        Parameter requestedLocale = findLocaleParameter(actionInvocation, 
requestOnlyParameterName);
+        if (requestedLocale.isDefined()) {
+            Locale locale = getLocaleFromParam(requestedLocale.getValue());
+            if (locale != null && isLocaleSupported(locale)) {
+                return locale;
+            }
+            LOG.debug("Requested locale {} is not supported, ignoring", 
requestedLocale.getValue());
+        }
+
+        return null;
+    }
+
+    @Override
+    public Locale store(ActionInvocation invocation, Locale locale) {
+        return locale;
+    }
+
+    @Override
+    public Locale read(ActionInvocation invocation) {
+        LOG.debug("Searching current Invocation context");
+        Locale locale = invocation.getInvocationContext().getLocale();
+        if (locale != null) {
+            LOG.debug("Applied invocation context locale: {}", locale);
+        }
+        return locale;
+    }
+}
diff --git 
a/core/src/main/java/org/apache/struts2/interceptor/i18n/SessionLocaleHandler.java
 
b/core/src/main/java/org/apache/struts2/interceptor/i18n/SessionLocaleHandler.java
new file mode 100644
index 000000000..44acc17f2
--- /dev/null
+++ 
b/core/src/main/java/org/apache/struts2/interceptor/i18n/SessionLocaleHandler.java
@@ -0,0 +1,90 @@
+/*
+ * 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.struts2.interceptor.i18n;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.apache.struts2.ActionInvocation;
+import org.apache.struts2.ServletActionContext;
+
+import jakarta.servlet.http.HttpSession;
+
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+public abstract class SessionLocaleHandler extends AbstractStoredLocaleHandler 
{
+
+    private static final Logger LOG = 
LogManager.getLogger(SessionLocaleHandler.class);
+
+    private final String attributeName;
+
+    protected SessionLocaleHandler(ActionInvocation invocation,
+                                   String requestOnlyParameterName,
+                                   Set<Locale> supportedLocale,
+                                   String parameterName,
+                                   String attributeName) {
+        super(invocation, requestOnlyParameterName, supportedLocale, 
parameterName);
+        this.attributeName = attributeName;
+    }
+
+    @Override
+    public Locale find() {
+        Locale locale = findExplicitLocale();
+        if (locale != null) {
+            return locale;
+        }
+        return findRequestOnlyLocale();
+    }
+
+    @Override
+    public Locale store(ActionInvocation invocation, Locale locale) {
+        Map<String, Object> session = 
invocation.getInvocationContext().getSession();
+
+        if (session != null) {
+            String sessionId = 
ServletActionContext.getRequest().getSession().getId();
+            synchronized (sessionId.intern()) {
+                session.put(attributeName, locale);
+            }
+        }
+
+        return locale;
+    }
+
+    @Override
+    public Locale read(ActionInvocation invocation) {
+        Locale locale = null;
+
+        LOG.debug("Checks session for saved locale");
+        HttpSession session = 
ServletActionContext.getRequest().getSession(false);
+
+        if (session != null) {
+            String sessionId = session.getId();
+            synchronized (sessionId.intern()) {
+                Object sessionLocale = 
invocation.getInvocationContext().getSession().get(attributeName);
+                if (sessionLocale instanceof Locale) {
+                    locale = (Locale) sessionLocale;
+                    LOG.debug("Applied session locale: {}", locale);
+                }
+            }
+        }
+
+        return normalizeStoredLocale(locale, invocation);
+    }
+}
diff --git 
a/core/src/test/java/org/apache/struts2/interceptor/I18nInterceptorTest.java 
b/core/src/test/java/org/apache/struts2/interceptor/I18nInterceptorTest.java
index 2617d9f61..243bf5e51 100644
--- a/core/src/test/java/org/apache/struts2/interceptor/I18nInterceptorTest.java
+++ b/core/src/test/java/org/apache/struts2/interceptor/I18nInterceptorTest.java
@@ -35,6 +35,7 @@ import org.springframework.mock.web.MockHttpSession;
 
 import jakarta.servlet.http.Cookie;
 import jakarta.servlet.http.HttpServletResponse;
+
 import java.io.Serializable;
 import java.util.Arrays;
 import java.util.HashMap;
@@ -100,7 +101,7 @@ public class I18nInterceptorTest extends TestCase {
 
         
assertFalse(mai.getInvocationContext().getParameters().get(I18nInterceptor.DEFAULT_PARAMETER).isDefined());
 // should have been removed
 
-        Locale denmark = new Locale("da", "DK");
+        Locale denmark = new 
Locale.Builder().setLanguage("da").setRegion("DK").build();
         assertNotNull(session.get(I18nInterceptor.DEFAULT_SESSION_ATTRIBUTE)); 
// should be stored here
         assertEquals(denmark, 
session.get(I18nInterceptor.DEFAULT_SESSION_ATTRIBUTE)); // should create a 
locale object
     }
@@ -111,7 +112,7 @@ public class I18nInterceptorTest extends TestCase {
 
         
assertFalse(mai.getInvocationContext().getParameters().get(I18nInterceptor.DEFAULT_PARAMETER).isDefined());
 // should have been removed
 
-        Locale denmark = new Locale("da", "DK");
+        Locale denmark = new 
Locale.Builder().setLanguage("da").setRegion("DK").build();
         assertNull(session.get(I18nInterceptor.DEFAULT_SESSION_ATTRIBUTE)); // 
should be stored here
         assertEquals(denmark, mai.getInvocationContext().getLocale()); // 
should create a locale object
     }
@@ -122,7 +123,7 @@ public class I18nInterceptorTest extends TestCase {
 
         
assertFalse(mai.getInvocationContext().getParameters().get(I18nInterceptor.DEFAULT_PARAMETER).isDefined());
 // should have been removed
 
-        Locale denmark = new Locale("da");
+        Locale denmark = Locale.forLanguageTag("da");
         assertNotNull(session.get(I18nInterceptor.DEFAULT_SESSION_ATTRIBUTE)); 
// should be stored here
         assertEquals(denmark, 
session.get(I18nInterceptor.DEFAULT_SESSION_ATTRIBUTE)); // should create a 
locale object
     }
@@ -173,7 +174,7 @@ public class I18nInterceptorTest extends TestCase {
 
         
assertFalse(mai.getInvocationContext().getParameters().get(I18nInterceptor.DEFAULT_PARAMETER).isDefined());
 // should have been removed
 
-        Locale variant = new Locale("ja", "JP", "JP");
+        Locale variant = Locale.forLanguageTag("ja-JP-x-lvariant-JP");
         Locale locale = (Locale) 
session.get(I18nInterceptor.DEFAULT_SESSION_ATTRIBUTE);
         assertNotNull(locale); // should be stored here
         assertEquals(variant, locale);
@@ -187,7 +188,7 @@ public class I18nInterceptorTest extends TestCase {
         
assertFalse(mai.getInvocationContext().getParameters().get(I18nInterceptor.DEFAULT_PARAMETER).isDefined());
 // should have been removed
         assertNull(session.get(I18nInterceptor.DEFAULT_SESSION_ATTRIBUTE));
 
-        Locale variant = new Locale("ja", "JP", "JP");
+        Locale variant = Locale.forLanguageTag("ja-JP-x-lvariant-JP");
         Locale locale = mai.getInvocationContext().getLocale();
         assertNotNull(locale); // should be stored here
         assertEquals(variant, locale);
@@ -205,7 +206,7 @@ public class I18nInterceptorTest extends TestCase {
     }
 
     public void testRealLocalesInParams() throws Exception {
-        Locale[] locales = new Locale[] { Locale.CANADA_FRENCH };
+        Locale[] locales = new Locale[]{Locale.CANADA_FRENCH};
         assertTrue(locales.getClass().isArray());
         prepare(I18nInterceptor.DEFAULT_PARAMETER, locales);
         interceptor.intercept(mai);
@@ -265,7 +266,7 @@ public class I18nInterceptorTest extends TestCase {
 
     public void testAcceptLanguageBasedLocale() throws Exception {
         // given
-        request.setPreferredLocales(Arrays.asList(new Locale("da_DK"), new 
Locale("pl")));
+        
request.setPreferredLocales(Arrays.asList(Locale.forLanguageTag("da-DK"), 
Locale.forLanguageTag("pl")));
         interceptor.setLocaleStorage(null);
         interceptor.setSupportedLocale("en,pl");
 
@@ -275,12 +276,143 @@ public class I18nInterceptorTest extends TestCase {
         // then
         assertNull(session.get(I18nInterceptor.DEFAULT_SESSION_ATTRIBUTE)); // 
should not be stored here
         assertNull(session.get(I18nInterceptor.DEFAULT_SESSION_ATTRIBUTE)); // 
should not create a locale object
-        assertEquals(new Locale("pl"), mai.getInvocationContext().getLocale());
+        assertEquals(Locale.forLanguageTag("pl"), 
mai.getInvocationContext().getLocale());
+    }
+
+    public void testSupportedLocaleWithRequestLocale() throws Exception {
+        // given - supportedLocale configured + request_locale param with 
SESSION storage
+        request.setPreferredLocales(Arrays.asList(Locale.ENGLISH));
+        interceptor.setSupportedLocale("en,fr");
+        prepare(I18nInterceptor.DEFAULT_PARAMETER, "fr");
+
+        // when
+        interceptor.intercept(mai);
+
+        // then - request_locale wins over Accept-Language
+        assertEquals(Locale.FRENCH, 
session.get(I18nInterceptor.DEFAULT_SESSION_ATTRIBUTE));
+        assertEquals(Locale.FRENCH, mai.getInvocationContext().getLocale());
+    }
+
+    public void testSupportedLocaleRejectsUnsupportedRequestLocale() throws 
Exception {
+        // given - request_locale=es but supportedLocale="en,fr"
+        request.setPreferredLocales(Arrays.asList(Locale.ENGLISH));
+        interceptor.setSupportedLocale("en,fr");
+        prepare(I18nInterceptor.DEFAULT_PARAMETER, "es");
+
+        // when
+        interceptor.intercept(mai);
+
+        // then - es rejected, falls back to Accept-Language match (en)
+        assertNull(session.get(I18nInterceptor.DEFAULT_SESSION_ATTRIBUTE));
+        assertEquals(Locale.ENGLISH, mai.getInvocationContext().getLocale());
+    }
+
+    public void testSupportedLocaleRevalidatesSessionLocale() throws Exception 
{
+        // given - session has stored locale "de" but supportedLocale changed 
to "en,fr"
+        session.put(I18nInterceptor.DEFAULT_SESSION_ATTRIBUTE, Locale.GERMAN);
+        request.setPreferredLocales(Arrays.asList(Locale.FRENCH));
+        interceptor.setSupportedLocale("en,fr");
+
+        // when
+        interceptor.intercept(mai);
+
+        // then - stored "de" rejected, falls back to Accept-Language match 
(fr)
+        assertEquals(Locale.FRENCH, mai.getInvocationContext().getLocale());
+    }
+
+    public void testSupportedLocaleWithCookieStorage() throws Exception {
+        // given - supportedLocale configured + request_cookie_locale param 
with COOKIE storage
+        prepare(I18nInterceptor.DEFAULT_COOKIE_PARAMETER, "fr");
+        request.setPreferredLocales(Arrays.asList(Locale.ENGLISH));
+        interceptor.setSupportedLocale("en,fr");
+
+        final Cookie cookie = new 
Cookie(I18nInterceptor.DEFAULT_COOKIE_ATTRIBUTE, "fr");
+        HttpServletResponse response = 
EasyMock.createMock(HttpServletResponse.class);
+        response.addCookie(CookieMatcher.eqCookie(cookie));
+        EasyMock.replay(response);
+
+        ac.put(StrutsStatics.HTTP_RESPONSE, response);
+        interceptor.setLocaleStorage(I18nInterceptor.Storage.COOKIE.name());
+
+        // when
+        interceptor.intercept(mai);
+
+        // then - request_cookie_locale=fr wins
+        EasyMock.verify(response);
+        assertEquals(Locale.FRENCH, mai.getInvocationContext().getLocale());
+    }
+
+    public void testSupportedLocaleRejectsUnsupportedRequestCookieLocale() 
throws Exception {
+        // given - request_cookie_locale=es but supportedLocale="en,fr"
+        prepare(I18nInterceptor.DEFAULT_COOKIE_PARAMETER, "es");
+        request.setPreferredLocales(Arrays.asList(Locale.ENGLISH));
+        interceptor.setSupportedLocale("en,fr");
+
+        HttpServletResponse response = 
EasyMock.createStrictMock(HttpServletResponse.class);
+        EasyMock.replay(response);
+
+        ac.put(StrutsStatics.HTTP_RESPONSE, response);
+        interceptor.setLocaleStorage(I18nInterceptor.Storage.COOKIE.name());
+
+        // when
+        interceptor.intercept(mai);
+
+        // then - unsupported request_cookie_locale ignored, falls back to 
Accept-Language match
+        EasyMock.verify(response);
+        assertEquals(Locale.ENGLISH, mai.getInvocationContext().getLocale());
+    }
+
+    public void testSupportedLocaleRevalidatesStoredCookieLocale() throws 
Exception {
+        // given - cookie has stored "de" but supportedLocale changed to 
"en,fr"
+        request.setCookies(new 
Cookie(I18nInterceptor.DEFAULT_COOKIE_ATTRIBUTE, "de"));
+        request.setPreferredLocales(Arrays.asList(Locale.ITALIAN));
+        interceptor.setSupportedLocale("en,fr");
+
+        HttpServletResponse response = 
EasyMock.createStrictMock(HttpServletResponse.class);
+        EasyMock.replay(response);
+
+        ac.put(StrutsStatics.HTTP_RESPONSE, response);
+        interceptor.setLocaleStorage(I18nInterceptor.Storage.COOKIE.name());
+
+        // when
+        interceptor.intercept(mai);
+
+        // then - stored "de" rejected and fallback locale from invocation 
context is used
+        EasyMock.verify(response);
+        assertEquals(Locale.US, mai.getInvocationContext().getLocale());
+    }
+
+    public void testRequestOnlyLocalePrecedenceWithSupportedLocale() throws 
Exception {
+        // given - request_only_locale should win over Accept-Language match
+        prepare(I18nInterceptor.DEFAULT_REQUEST_ONLY_PARAMETER, "fr");
+        request.setPreferredLocales(Arrays.asList(Locale.ENGLISH));
+        interceptor.setSupportedLocale("en,fr");
+
+        // when
+        interceptor.intercept(mai);
+
+        // then - request_only_locale applied and not persisted
+        assertNull(session.get(I18nInterceptor.DEFAULT_SESSION_ATTRIBUTE));
+        assertEquals(Locale.FRENCH, mai.getInvocationContext().getLocale());
+    }
+
+    public void testSupportedLocaleRejectsUnsupportedRequestOnlyLocale() 
throws Exception {
+        // given - request_only_locale=es but supportedLocale="en,fr"
+        prepare(I18nInterceptor.DEFAULT_REQUEST_ONLY_PARAMETER, "es");
+        request.setPreferredLocales(Arrays.asList(Locale.ENGLISH));
+        interceptor.setSupportedLocale("en,fr");
+
+        // when
+        interceptor.intercept(mai);
+
+        // then - es rejected, falls back to stored session locale / 
invocation context
+        assertNull(session.get(I18nInterceptor.DEFAULT_SESSION_ATTRIBUTE));
+        assertEquals(Locale.ENGLISH, mai.getInvocationContext().getLocale());
     }
 
     public void testAcceptLanguageBasedLocaleWithFallbackToDefault() throws 
Exception {
         // given
-        request.setPreferredLocales(Arrays.asList(new Locale("da_DK"), new 
Locale("es")));
+        
request.setPreferredLocales(Arrays.asList(Locale.forLanguageTag("da-DK"), 
Locale.forLanguageTag("es")));
 
         interceptor.setLocaleStorage(null);
         interceptor.setSupportedLocale("en,pl");
@@ -308,9 +440,9 @@ public class I18nInterceptorTest extends TestCase {
         session = new HashMap<>();
 
         ac = ActionContext.of()
-            .bind()
-            .withSession(session)
-            .withParameters(HttpParameters.create().build());
+                .bind()
+                .withSession(session)
+                .withParameters(HttpParameters.create().build());
 
         request = new MockHttpServletRequest();
         request.setSession(new MockHttpSession());
@@ -348,8 +480,8 @@ public class I18nInterceptorTest extends TestCase {
         public boolean matches(Object argument) {
             Cookie cookie = ((Cookie) argument);
             return
-                (cookie.getName().equals(expected.getName()) &&
-                 cookie.getValue().equals(expected.getValue()));
+                    (cookie.getName().equals(expected.getName()) &&
+                            cookie.getValue().equals(expected.getValue()));
         }
 
         public static Cookie eqCookie(Cookie ck) {
@@ -359,10 +491,10 @@ public class I18nInterceptorTest extends TestCase {
 
         public void appendTo(StringBuffer buffer) {
             buffer
-                .append("Received")
-                .append(expected.getName())
-                .append("/")
-                .append(expected.getValue());
+                    .append("Received")
+                    .append(expected.getName())
+                    .append("/")
+                    .append(expected.getValue());
         }
     }
 
diff --git 
a/thoughts/shared/research/2026-02-22-WW-5549-i18n-supportedlocale-breaks-request-locale.md
 
b/thoughts/shared/research/2026-02-22-WW-5549-i18n-supportedlocale-breaks-request-locale.md
new file mode 100644
index 000000000..3125ec72b
--- /dev/null
+++ 
b/thoughts/shared/research/2026-02-22-WW-5549-i18n-supportedlocale-breaks-request-locale.md
@@ -0,0 +1,152 @@
+---
+date: 2026-02-22T12:00:00+01:00
+topic: "I18nInterceptor supportedLocale disables request_locale parameter"
+tags: [research, codebase, i18n, interceptor, locale, WW-5549]
+status: complete
+git_commit: a21c763d8a8592f1056086134414123f6d8d168d
+---
+
+# Research: WW-5549 - I18nInterceptor supportedLocale disables request_locale
+
+**Date**: 2026-02-22
+
+## Research Question
+
+When `supportedLocale` is configured on the i18n interceptor, the 
`request_locale` parameter stops working if the browser's Accept-Language 
header matches a supported locale.
+
+## Summary
+
+The bug is in the class hierarchy of `LocaleHandler` implementations. 
`AcceptLanguageLocaleHandler.find()` returns early when it finds a match 
between the browser's Accept-Language header and the `supportedLocale` set. 
Since `SessionLocaleHandler` and `CookieLocaleHandler` both extend 
`AcceptLanguageLocaleHandler` and call `super.find()` first, the explicit 
`request_locale` parameter is never checked when the Accept-Language header 
matches a supported locale.
+
+## Detailed Findings
+
+### Class Hierarchy
+
+```
+LocaleHandler (interface)
+  └── RequestLocaleHandler          (storage=REQUEST, checks 
request_only_locale)
+        └── AcceptLanguageLocaleHandler  (storage=ACCEPT_LANGUAGE, checks 
Accept-Language header)
+              ├── SessionLocaleHandler   (storage=SESSION, checks 
request_locale + session)
+              └── CookieLocaleHandler    (storage=COOKIE, checks 
request_cookie_locale + cookie)
+```
+
+### The Bug: AcceptLanguageLocaleHandler.find() — Line 279
+
+```java
+// I18nInterceptor.java:279-290
+@Override
+public Locale find() {
+    if (!supportedLocale.isEmpty()) {
+        Enumeration locales = 
actionInvocation.getInvocationContext().getServletRequest().getLocales();
+        while (locales.hasMoreElements()) {
+            Locale locale = (Locale) locales.nextElement();
+            if (supportedLocale.contains(locale)) {
+                return locale;  // ← RETURNS HERE, never calls super.find()
+            }
+        }
+    }
+    return super.find();  // ← Only reached if supportedLocale is empty or no 
match
+}
+```
+
+### SessionLocaleHandler.find() — Line 301
+
+```java
+// I18nInterceptor.java:300-317
+@Override
+public Locale find() {
+    Locale requestOnlyLocale = super.find();  // ← calls 
AcceptLanguageLocaleHandler.find()
+
+    if (requestOnlyLocale != null) {
+        LOG.debug("Found locale under request only param, it won't be stored 
in session!");
+        shouldStore = false;          // ← prevents session storage
+        return requestOnlyLocale;     // ← returns WITHOUT checking 
request_locale
+    }
+
+    // request_locale is only checked here, which is never reached when 
super.find() returns non-null
+    Parameter requestedLocale = findLocaleParameter(actionInvocation, 
parameterName);
+    if (requestedLocale.isDefined()) {
+        return getLocaleFromParam(requestedLocale.getValue());
+    }
+    return null;
+}
+```
+
+### Concrete Bug Scenario
+
+Configuration: `supportedLocale="fr,en"`, storage=SESSION (default)
+
+1. User has French browser (`Accept-Language: fr,en`)
+2. App defaults to French — correct
+3. User clicks "English" link with `?request_locale=en`
+4. `SessionLocaleHandler.find()` calls `super.find()` → 
`AcceptLanguageLocaleHandler.find()`
+5. Accept-Language header yields `fr`, which IS in `supportedLocale`
+6. Returns `fr` immediately — `request_locale=en` is **never checked**
+7. `shouldStore = false` — so even if it were checked, it wouldn't persist
+8. Locale stays French despite explicit user request to switch to English
+
+### intercept() Flow
+
+```java
+// I18nInterceptor.java:117-144
+LocaleHandler localeHandler = getLocaleHandler(invocation);  // 
SessionLocaleHandler for default
+Locale locale = localeHandler.find();       // BUG: returns Accept-Language 
match, skips request_locale
+if (locale == null) {
+    locale = localeHandler.read(invocation); // never reached when find() 
returns non-null
+}
+if (localeHandler.shouldStore()) {
+    locale = localeHandler.store(invocation, locale); // shouldStore=false, 
skipped
+}
+useLocale(invocation, locale);  // sets the wrong locale
+```
+
+### Root Cause
+
+`SessionLocaleHandler.find()` was designed to call `super.find()` to check 
`request_only_locale` (a non-persistent locale override). It interprets any 
non-null result from `super.find()` as "a request-only locale was found." But 
`AcceptLanguageLocaleHandler.find()` conflates two different things:
+
+1. A locale from `request_only_locale` parameter (legitimate non-persistent 
override)
+2. A locale from Accept-Language header matching `supportedLocale` (ambient 
browser preference)
+
+Both return non-null from `super.find()`, and `SessionLocaleHandler` cannot 
distinguish between them.
+
+### The Same Bug Affects CookieLocaleHandler
+
+`CookieLocaleHandler.find()` (line 369) has the identical pattern — calls 
`super.find()` and returns early if non-null, skipping `request_cookie_locale`.
+
+## Code References
+
+- 
[`I18nInterceptor.java`](https://github.com/apache/struts/blob/a21c763d8a8592f1056086134414123f6d8d168d/core/src/main/java/org/apache/struts2/interceptor/I18nInterceptor.java)
 — Full interceptor
+  - Line 65: `supportedLocale` field declaration
+  - Line 103-109: `setSupportedLocale()` — parses comma-delimited string to 
`Set<Locale>`
+  - Line 117-144: `intercept()` — main flow
+  - Line 152-167: `getLocaleHandler()` — factory for handler selection
+  - Line 229-269: `RequestLocaleHandler` — base handler, checks 
`request_only_locale`
+  - Line 271-292: `AcceptLanguageLocaleHandler` — **bug location** at line 
279-290
+  - Line 294-361: `SessionLocaleHandler` — **affected** at line 300-317
+  - Line 363-419: `CookieLocaleHandler` — **also affected** at line 369-384
+- 
[`I18nInterceptorTest.java`](https://github.com/apache/struts/blob/a21c763d8a8592f1056086134414123f6d8d168d/core/src/test/java/org/apache/struts2/interceptor/I18nInterceptorTest.java)
 — Test class
+  - Line 266: `testAcceptLanguageBasedLocale` — only tests ACCEPT_LANGUAGE 
storage mode
+  - Line 281: `testAcceptLanguageBasedLocaleWithFallbackToDefault` — fallback 
test
+
+## Test Coverage Gaps
+
+1. **No test** for `supportedLocale` + `request_locale` used simultaneously
+2. **No test** for `supportedLocale` with SESSION storage mode (the default!)
+3. **No test** for `supportedLocale` with COOKIE storage mode
+4. Existing `supportedLocale` tests only use `ACCEPT_LANGUAGE` storage mode 
where the bug doesn't manifest (because `AcceptLanguageLocaleHandler` is used 
directly, not through `SessionLocaleHandler`)
+
+## Fix Direction
+
+The `request_locale` / `request_cookie_locale` parameter (explicit user 
choice) should always take precedence over the Accept-Language header (ambient 
browser preference). Options:
+
+1. **Reorder in SessionLocaleHandler/CookieLocaleHandler**: Check 
`request_locale` **before** calling `super.find()`, so the explicit parameter 
always wins
+2. **Reorder in AcceptLanguageLocaleHandler**: Check `super.find()` 
(request_only_locale) first, then fall back to Accept-Language matching — this 
would fix it for `AcceptLanguageLocaleHandler` itself but not for 
`SessionLocaleHandler`/`CookieLocaleHandler` which have their own `find()` 
override
+3. **Restructure the hierarchy**: Separate Accept-Language matching from the 
`find()` chain so it doesn't interfere with explicit parameter checks
+
+Option 1 is the most targeted fix with minimal risk.
+
+## Open Questions
+
+1. Should `supportedLocale` also validate/filter `request_locale` values? 
(e.g., reject `request_locale=es` if `supportedLocale="en,fr"`)
+2. Should the session-stored locale also be validated against 
`supportedLocale` on subsequent requests?
+3. The `Locale::new` constructor (line 107) is deprecated — should this be 
updated to use `Locale.forLanguageTag()` or `Locale.Builder`?
\ No newline at end of file

Reply via email to